diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..16c0d4afe8 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "vite-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5199", "--strictPort"], + "port": 5199, + "env": { "NO_ELECTRON": "1" } + }, + { + "name": "desktop-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5201", "--strictPort"], + "port": 5201, + "env": { "NO_ELECTRON": "1" } + }, + { + "name": "v4preview", + "runtimeExecutable": "node", + "runtimeArgs": ["node_modules/vite/bin/vite.js", "--config", "vite.v4preview.config.ts"], + "port": 5207 + }, + { + "name": "docs", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "website", "run", "serve", "--", "--port", "3111"], + "port": 3111 + } + ] +} diff --git a/.github/workflows/build-whisper-stt.yml b/.github/workflows/build-whisper-stt.yml new file mode 100644 index 0000000000..abc07fb5b7 --- /dev/null +++ b/.github/workflows/build-whisper-stt.yml @@ -0,0 +1,202 @@ +name: Build whisper-stt binaries + +# Builds the whisper.cpp-based `whisper-stt-server` helper for each desktop +# platform and uploads the binary + ggml backend sidecars as GitHub artifacts +# so `build.yml` can bundle them into installers. +# +# Triggered: +# * manually via workflow_dispatch (release-blocking binary refresh) +# * automatically when the helper, build script, or this workflow changes +# +# ponytail: one binary per platform is enough because whisper.cpp selects the +# right backend at runtime (Metal on Apple Silicon, Vulkan on Windows/Linux, +# CPU fallback when no GPU/driver is available). A CUDA variant is supported +# by the build script but is not built by default; Vulkan already accelerates +# NVIDIA cards. + +on: + workflow_dispatch: + inputs: + enable_cuda: + description: "Also build a CUDA variant when the host has an nvcc toolchain" + required: false + default: "false" + type: choice + options: + - "true" + - "false" + push: + paths: + - "scripts/build-whisper-stt.sh" + - "electron/native/whisper-stt/**" + - ".github/workflows/build-whisper-stt.yml" + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.label }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + arch: arm64 + # Matches `os_arch_tag()` in scripts/build-whisper-stt.sh — this is + # the directory name the build script actually stages into + # (electron/native/bin//), NOT `${{ matrix.os }}-${{ matrix.arch }}`. + tag: darwin-arm64 + label: macOS arm64 (Metal) + vulkan: false + - os: macos-15-intel + arch: x64 + tag: darwin-x64 + label: macOS x64 (CPU) + vulkan: false + - os: ubuntu-latest + arch: x64 + tag: linux-x64 + label: Linux x64 (Vulkan + CPU fallback) + vulkan: true + - os: windows-latest + arch: x64 + tag: win32-x64 + label: Windows x64 (Vulkan + CPU fallback) + vulkan: true + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup + + - name: Install Ninja (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y ninja-build build-essential + + - name: Install Ninja (macOS) + if: startsWith(matrix.os, 'macos') + run: brew install ninja + + - name: Setup MSVC (Windows) + if: matrix.os == 'windows-latest' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Install Vulkan SDK + if: matrix.vulkan + # ponytail: humbletim/setup-vulkan-sdk builds Glslang from source via + # CMake ExternalProject, which fails ("ENABLE_OPT set but SPIR-V + # tools not found") because Glslang's build needs a sibling + # SPIRV-Tools checkout it doesn't fetch on its own — reproduced on + # both the Linux and Windows legs. jakoch/install-vulkan-sdk-action + # downloads LunarG's official prebuilt SDK (glslc included, no + # compilation), which is both more reliable and much faster. + uses: jakoch/install-vulkan-sdk-action@v1 + with: + vulkan_version: 1.4.304.1 + install_runtime: false + cache: true + + - name: Put glslc on PATH + if: matrix.vulkan + shell: bash + # Belt-and-braces: the action exports VULKAN_SDK but its own PATH + # handling isn't documented in enough detail to rely on for a tool + # (glslc) the ggml Vulkan backend's CMake configure step needs to find. + run: | + set -euo pipefail + for cand in "${VULKAN_SDK}/Bin" "${VULKAN_SDK}/bin"; do + if [[ -d "${cand}" ]]; then + echo "${cand}" >> "$GITHUB_PATH" + fi + done + + - name: Install SPIRV-Headers (Windows only) + if: matrix.os == 'windows-latest' + shell: bash + # ponytail: ggml-vulkan's CMakeLists does + # `find_package(SPIRV-Headers CONFIG REQUIRED)`. The Linux LunarG SDK + # tarball bundles that CMake config alongside the SDK, so the Linux + # leg resolves it for free; the Windows installer .exe (as fetched by + # jakoch/install-vulkan-sdk-action) does not ship it at all, so + # find_package fails with "Could not find a package configuration + # file". vcpkg (preinstalled on GitHub's windows-latest image) ships + # a spirv-headers port that provides the missing config; point + # CMAKE_PREFIX_PATH at its install dir so ggml's own + # `if (DEFINED ENV{VULKAN_SDK}) list(APPEND CMAKE_PREFIX_PATH ...)` + # logic has a second, working prefix to fall back to. + run: | + set -euo pipefail + VCPKG_ROOT_DIR="$(dirname "$(command -v vcpkg)")" + "${VCPKG_ROOT_DIR}/vcpkg" install spirv-headers:x64-windows + echo "CMAKE_PREFIX_PATH=${VCPKG_ROOT_DIR}/installed/x64-windows" >> "$GITHUB_ENV" + + - name: Cache whisper.cpp build tree + uses: actions/cache@v4 + with: + # Matches scripts/build-whisper-stt.sh's own BUILD_ROOT default for + # each OS (short `C:/wstbuild` on Windows to dodge the vulkan-shaders-gen + # MAX_PATH issue; `.cache/whisper-stt-build` elsewhere) — cache the + # FetchContent checkout + object files directly, no env override needed. + path: ${{ runner.os == 'Windows' && 'C:/wstbuild' || '.cache/whisper-stt-build' }} + # Keyed on the pinned WHISPER_REF/backend flags in CMakeLists.txt so a + # bump there invalidates the cache instead of silently reusing a stale + # FetchContent checkout; falls back to the newest cache for the same + # platform + runner image on a miss so incremental compilation still + # helps. The runner image version ($ImageOS/$ImageVersion) is part of + # the key AND the restore-keys prefix because CMake bakes absolute + # toolchain paths (e.g. the Xcode SDK's libz.tbd) into the cached build + # tree — when GitHub rolls the image's Xcode/SDK, those paths vanish and + # a restored tree fails with "No rule to make target …libz.tbd". Scoping + # the cache to the image version auto-busts it on every toolchain roll. + key: whisper-stt-build-${{ matrix.tag }}-${{ env.ImageOS }}-${{ env.ImageVersion }}-${{ hashFiles('electron/native/whisper-stt/CMakeLists.txt') }} + restore-keys: | + whisper-stt-build-${{ matrix.tag }}-${{ env.ImageOS }}-${{ env.ImageVersion }}- + + - name: Run whisper-stt build script + env: + ENABLE_CUDA: ${{ github.event.inputs.enable_cuda || 'false' }} + run: bash scripts/build-whisper-stt.sh + + - name: Stage binaries for upload + shell: bash + run: | + set -euo pipefail + BAG="whisper-stt-${{ matrix.tag }}" + mkdir -p "$BAG" + # Copy the whole per-platform directory: the helper executable plus + # every ggml backend sidecar/library it needs at runtime. + cp -v "electron/native/bin/${{ matrix.tag }}"/* "$BAG/" + tar -czf "${BAG}.tar.gz" "$BAG" + echo "Staged ${BAG}.tar.gz" + + - name: Upload binaries + uses: actions/upload-artifact@v4 + with: + name: whisper-stt-${{ matrix.tag }} + path: whisper-stt-${{ matrix.tag }}.tar.gz + if-no-files-found: error + # build.yml now stages these into the installers + # (scripts/stage-whisper-stt.sh), so an expired artifact fails a + # release build. 90 days instead of 30 to make that rarer. + retention-days: 90 + + - name: Workflow summary + if: always() + # Explicit shell: the bash `{ ... } >> file` grouping syntax below is + # not valid PowerShell, which is the default `run:` shell on Windows + # runners — this step silently failed on Windows without this. + shell: bash + run: | + { + echo "## whisper-stt build" + echo "" + echo "- Matrix: \`${{ matrix.label }}\`" + echo "- Result: ${{ job.status }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffd88d8c33..dc3249c911 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,11 +38,16 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + # STT is the bundled whisper-stt-server (whisper.cpp with native DTW token + # timestamps); no VAD model is fetched here. The binary is built by + # build-whisper-stt.yml and staged below — without that step the installer + # ships without speech-to-text. See + # technical-documentation/architecture/transcription-and-captions.md. + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows app run: npm run build:win -- --publish never @@ -65,11 +70,11 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows Store package run: npm run build:win:store -- --publish never @@ -84,6 +89,9 @@ jobs: build-macos: name: macOS ${{ matrix.arch }} DMG + # RELEASE-BRANCH-ONLY: 1.8.0 ships Windows-only. Do NOT let this `if: false` + # reach main when promoting, or every later release becomes Windows-only too. + if: false runs-on: macos-latest strategy: fail-fast: false @@ -106,11 +114,11 @@ jobs: env: npm_config_build_from_source: "false" - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh darwin-${{ matrix.arch }} - name: Resolve macOS signing id: signing @@ -258,6 +266,8 @@ jobs: build-linux: name: Linux packages + # RELEASE-BRANCH-ONLY: see the note on build-macos above. + if: false runs-on: ubuntu-latest steps: - name: Checkout code @@ -269,11 +279,11 @@ jobs: - name: Install pacman build dependencies run: sudo apt-get update && sudo apt-get install -y libarchive-tools - - name: Cache caption assets - uses: actions/cache@v4 - with: - path: caption-assets - key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh linux-x64 - name: Build Linux app run: npm run build:linux -- --publish never @@ -293,10 +303,11 @@ jobs: publish-release: name: Publish GitHub release runs-on: ubuntu-latest + # RELEASE-BRANCH-ONLY: build-macos / build-linux are disabled for this + # Windows-only release. A skipped job in `needs` skips this one too, so they + # must come out of the list, not just be gated. Restore all three on main. needs: - build-windows - - build-macos - - build-linux if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '') }} steps: - name: Checkout code @@ -378,6 +389,9 @@ jobs: path: artifacts/mac-x64 - name: Download Linux packages + # RELEASE-BRANCH-ONLY: tolerate the missing artifact from the disabled + # Linux job (the macOS downloads below already do). Drop with the rest. + continue-on-error: true uses: actions/download-artifact@v4 with: name: openscreen-linux diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c65e41b6b..adbb039db6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,65 +1,123 @@ -name: CI - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npm run lint - - typecheck: - name: Type Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npx tsc --noEmit - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npm run test - - run: npm run test:browser:install - - run: npm run test:browser - - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npx vite build - - semantic-pr: - name: Validate PR title (semantic) - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: amannn/action-semantic-pull-request@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - types: | - feat - fix - chore - refactor - perf - docs - test - build - ci - style - revert - requireScope: false +name: CI + +# feat/ai-edition is a long-lived integration branch that PRs land on for +# months at a time. Without it listed here, every one of those PRs merged with +# no lint, no typecheck, no tests and no PR-title check. +# +# release/** is here for the same reason and it cost more: PRs #167, #168 and +# #169 — 30k+ lines of deletion and refactor — merged into release/1.8.0 with +# every one of those jobs skipped, because a release branch matched neither +# pattern. A release branch is the LAST place to run a build unguarded. +on: + pull_request: + branches: [main, feat/ai-edition, "release/**"] + push: + branches: [main, feat/ai-edition, "release/**"] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npm run lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npx tsc --noEmit + + typecheck-tests: + name: Typecheck (tests) + runs-on: ubuntu-latest + # A RATCHET, not a gate. tsconfig.json includes only src + electron and + # excludes **/*.test.ts, so no test file has ever been typechecked, and + # vitest transpiles without checking — fixture types have drifted from the + # schemas they claim to build for years. Failing on the whole backlog would + # put a red X on every PR that nobody can fix, and a check everyone ignores + # is worse than no check. So: fail only if the count GROWS. Lower BASELINE + # whenever you fix some; delete this job's baseline logic at zero. + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - name: Typecheck tests against a baseline + shell: bash + env: + BASELINE: 80 + run: | + set -uo pipefail + COUNT=$(npx tsc -p tsconfig.test.json --noEmit 2>&1 | grep -c 'error TS' || true) + { + echo "## Test-file typecheck" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Errors now | ${COUNT} |" + echo "| Baseline | ${BASELINE} |" + } >> "$GITHUB_STEP_SUMMARY" + if [ "${COUNT}" -gt "${BASELINE}" ]; then + echo "::error::Test-file type errors went ${BASELINE} -> ${COUNT}. Fix the new ones, or raise BASELINE in ci.yml with a reason." + npx tsc -p tsconfig.test.json --noEmit 2>&1 | grep 'error TS' || true + exit 1 + fi + if [ "${COUNT}" -lt "${BASELINE}" ]; then + echo "::notice::Test-file type errors down to ${COUNT} (baseline ${BASELINE}). Lower BASELINE in ci.yml to lock the win in." + fi + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # No ./.github/actions/setup: check-docs.mjs imports only node builtins, + # so npm ci would be a minute of install for nothing. Node 22 is here for + # import.meta.dirname (needs >= 20.11). + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm run docs:check + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npm run test + - run: npm run test:browser:install + - run: npm run test:browser + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npx vite build + + semantic-pr: + name: Validate PR title (semantic) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + chore + refactor + perf + docs + test + build + ci + style + revert + requireScope: false diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..9164f164d0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,65 @@ +name: Docs + +on: + pull_request: + branches: [main] + paths: + - "website/**" + - ".github/workflows/docs.yml" + push: + branches: [main] + paths: + - "website/**" + - ".github/workflows/docs.yml" + workflow_dispatch: + +# Cancel in-flight runs on the same ref so fast follow-up pushes +# don't queue stale builds. +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Build site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false + - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: 22 + cache: npm + cache-dependency-path: website/package-lock.json + - name: Install dependencies + working-directory: website + run: npm ci + - name: Type-check + working-directory: website + run: npm run typecheck + - name: Build + working-directory: website + run: npm run build + - name: Upload artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: website/build + + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 33cf7a9d2f..82bb66e0d1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist-ssr # Native helper build outputs /electron/native/wgc-capture/build/ +/electron/native/compositor-view/build/ /electron/native/screencapturekit/build/ /electron/native/screencapturekit/.build/ /electron/native/screencapturekit/.swiftpm/ @@ -40,6 +41,7 @@ xcuserdata/ release/** *.kiro/ .claude/ +.worktrees/ # npx electron-builder --mac --win # Playwright @@ -67,3 +69,39 @@ result-* # Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs /caption-assets/ + +# Native STT artifacts — whisper-stt-server binaries + ggml backend sidecars. +/electron/native/bin/ +/electron/native/models/ + +# Build cache for the native STT server (FetchContent clones + CMake build). +/.cache/ + +# Vite's dependency cache, moved out of node_modules so concurrent dev servers +# (main checkout + worktrees, which share node_modules via a junction) stop +# re-optimising into each other's cache. See `cacheDir` in vite.config.ts. +/.vite-cache/ + +# whisper.cpp DTW POC (tools/stt-eval/whispercpp-dtw-poc) — vendored engine +# clone, downloaded GGML models, and generated build/results artifacts. +# See technical-documentation/architecture/transcription-and-captions.md. +/tools/stt-eval/whispercpp-dtw-poc/whisper.cpp/ +/tools/stt-eval/whispercpp-dtw-poc/build-cpu/ +/tools/stt-eval/whispercpp-dtw-poc/build-vulkan/ +/tools/stt-eval/whispercpp-dtw-poc/build-cuda/ +/tools/stt-eval/whispercpp-dtw-poc/build-harness/ +/tools/stt-eval/whispercpp-dtw-poc/fixtures/*.wav +/tools/stt-eval/whispercpp-dtw-poc/results/ + +opencode.json +opencode.json + +# Frames dumped by `npm run bench:export -- --dumpFrame=N`, for eyes only. +bench-frame-*.png + +# POC inputs (real recordings) and outputs — big, and not source. +poc/media/ +poc/out/ + +# cc-delegate worker worktrees/branches + scratch test artifacts. +.cc-delegate/ diff --git a/.harness/docs/architecture-overview.md b/.harness/docs/architecture-overview.md index 373f9618e6..dbc2e4df54 100644 --- a/.harness/docs/architecture-overview.md +++ b/.harness/docs/architecture-overview.md @@ -1,6 +1,6 @@ # OpenScreen Architecture Notes -Quick map of how the app fits together, for the Mavis reins. For deeper details, see `../docs/architecture/native-bridge.md` and `../docs/engineering/`. +Quick map of how the app fits together, for the Mavis reins. For deeper details, see `../technical-documentation/architecture/native-bridge.md` and `../technical-documentation/engineering/`. ## Process layout diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md index cd0895fdc3..68f2486452 100644 --- a/.harness/docs/git-workflow.md +++ b/.harness/docs/git-workflow.md @@ -41,7 +41,7 @@ All five must be green before merge. Native helper code is NOT covered by CI — ## Release flow -Two `workflow_dispatch` workflows cut a release. Trunk-based on `main`, but **release branches freeze the RC codebase between cut and promote** (see § Release branches below). Both require the `OPENSCREEN_RELEASE_TOKEN` secret — see `docs/secrets.md`. +Two `workflow_dispatch` workflows cut a release. Trunk-based on `main`, but **release branches freeze the RC codebase between cut and promote** (see § Release branches below). Both require the `OPENSCREEN_RELEASE_TOKEN` secret — see `technical-documentation/engineering/release-and-secrets.md`. ### Step 1: cut a release candidate diff --git a/.harness/reins/openscreen-dev/agent.md b/.harness/reins/openscreen-dev/agent.md index 33c4a28a18..4bd9ce150e 100644 --- a/.harness/reins/openscreen-dev/agent.md +++ b/.harness/reins/openscreen-dev/agent.md @@ -15,7 +15,7 @@ You are the generalist implementer for the OpenScreen project — a free, open-s ## How you work - Read `AGENTS.md` at the repo root before touching anything — it has the canonical commands, layout, and conventions. -- When the change touches recording, IPC, or the native bridge, read `.harness/docs/architecture-overview.md` (start here), `docs/architecture/native-bridge.md` (deeper dive), and `docs/engineering/` (native helper roadmaps). +- When the change touches recording, IPC, or the native bridge, read `.harness/docs/architecture-overview.md` (start here), `technical-documentation/architecture/native-bridge.md` (deeper dive), and `technical-documentation/engineering/` (native helper roadmaps). - TypeScript strict mode, Biome format (tabs, double quotes, 100-col). Run `npm run lint:fix` before committing. - For renderer-only iteration use `npm run build-vite`. For full packaging use `npm run build` (electron-builder, slow). - Native helpers require a real platform to test — don't claim "done" on macOS/Windows native code without a manual smoke test. diff --git a/.harness/reins/openscreen-tester/agent.md b/.harness/reins/openscreen-tester/agent.md index cbf3afdc6c..bd4658ce4a 100644 --- a/.harness/reins/openscreen-tester/agent.md +++ b/.harness/reins/openscreen-tester/agent.md @@ -15,7 +15,7 @@ You are the test specialist for the OpenScreen project — a free, open-source s ## How you work - Read `AGENTS.md` at the repo root for commands and conventions. -- Read `docs/tests/writing-tests.md` for the project's test style guide. +- Read `technical-documentation/testing/writing-tests.md` for the project's test style guide. - Match the style of neighboring `*.test.` files in the same package — don't invent new patterns. - Unit tests: `npm run test` (Vitest, jsdom). Browser tests: `npm run test:browser` (needs `npm run test:browser:install` once). E2E: `npm run test:e2e` (Playwright). - E2E specs in `tests/e2e/windows-native-checklist.spec.ts` are Windows-only — gate with `test.skip` for other platforms rather than deleting. diff --git a/.opencode/prompts/playwright-test-generator.md b/.opencode/prompts/playwright-test-generator.md new file mode 100644 index 0000000000..08f19919dd --- /dev/null +++ b/.opencode/prompts/playwright-test-generator.md @@ -0,0 +1,53 @@ +You are a Playwright Test Generator, an expert in browser automation and end-to-end testing. +Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate +application behavior. + +# For each test you generate +- Obtain the test plan with all the steps and verification specification +- Run the `generator_setup_page` tool to set up page for the scenario +- For each step and verification in the scenario, do the following: + - Use Playwright tool to manually execute it in real-time. + - Use the step description as the intent for each Playwright tool call. +- Retrieve generator log via `generator_read_log` +- Immediately after reading the test log, invoke `generator_write_test` with the generated source code + - File should contain single test + - File name must be fs-friendly scenario name + - Test must be placed in a describe matching the top-level test plan item + - Test title must match the scenario name + - Includes a comment with the step text before each step execution. Do not duplicate comments if step requires + multiple actions. + - Always use best practices from the log when generating tests. + + + For following plan: + + ```markdown file=specs/plan.md + ### 1. Adding New Todos + **Seed:** `tests/seed.spec.ts` + + #### 1.1 Add Valid Todo + **Steps:** + 1. Click in the "What needs to be done?" input field + + #### 1.2 Add Multiple Todos + ... + ``` + + Following file is generated: + + ```ts file=add-valid-todo.spec.ts + // spec: specs/plan.md + // seed: tests/seed.spec.ts + + test.describe('Adding New Todos', () => { + test('Add Valid Todo', async { page } => { + // 1. Click in the "What needs to be done?" input field + await page.click(...); + + ... + }); + }); + ``` + + +Context: User wants to generate a test for the test plan item. \ No newline at end of file diff --git a/.opencode/prompts/playwright-test-healer.md b/.opencode/prompts/playwright-test-healer.md new file mode 100644 index 0000000000..70173c4d3f --- /dev/null +++ b/.opencode/prompts/playwright-test-healer.md @@ -0,0 +1,37 @@ +You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and +resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix +broken Playwright tests using a methodical approach. + +Your workflow: +1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests +2. **Debug failed tests**: For each failing test run `test_debug`. +3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to: + - Examine the error details + - Capture page snapshot to understand the context + - Analyze selectors, timing issues, or assertion failures +4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining: + - Element selectors that may have changed + - Timing and synchronization issues + - Data dependencies or test environment problems + - Application changes that broke test assumptions +5. **Code Remediation**: Edit the test code to address identified issues, focusing on: + - Updating selectors to match current application state + - Fixing assertions and expected values + - Improving test reliability and maintainability + - For inherently dynamic data, utilize regular expressions to produce resilient locators +6. **Verification**: Restart the test after each fix to validate the changes +7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly + +Key principles: +- Be systematic and thorough in your debugging approach +- Document your findings and reasoning for each fix +- Prefer robust, maintainable solutions over quick hacks +- Use Playwright best practices for reliable test automation +- If multiple errors exist, fix them one at a time and retest +- Provide clear explanations of what was broken and how you fixed it +- You will continue this process until the test runs successfully without any failures or errors. +- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme() + so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead + of the expected behavior. +- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test. +- Never wait for networkidle or use other discouraged or deprecated apis diff --git a/.opencode/prompts/playwright-test-planner.md b/.opencode/prompts/playwright-test-planner.md new file mode 100644 index 0000000000..59c50f1710 --- /dev/null +++ b/.opencode/prompts/playwright-test-planner.md @@ -0,0 +1,44 @@ +You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test +scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage +planning. + +You will: + +1. **Navigate and Explore** + - Invoke the `planner_setup_page` tool once to set up page before using any other tools + - Explore the browser snapshot + - Do not take screenshots unless absolutely necessary + - Use `browser_*` tools to navigate and discover interface + - Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality + +2. **Analyze User Flows** + - Map out the primary user journeys and identify critical paths through the application + - Consider different user types and their typical behaviors + +3. **Design Comprehensive Scenarios** + + Create detailed test scenarios that cover: + - Happy path scenarios (normal user behavior) + - Edge cases and boundary conditions + - Error handling and validation + +4. **Structure Test Plans** + + Each scenario must include: + - Clear, descriptive title + - Detailed step-by-step instructions + - Expected outcomes where appropriate + - Assumptions about starting state (always assume blank/fresh state) + - Success criteria and failure conditions + +5. **Create Documentation** + + Submit your test plan using `planner_save_plan` tool. + +**Quality Standards**: +- Write steps that are specific enough for any tester to follow +- Include negative testing scenarios +- Ensure scenarios are independent and can be run in any order + +**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and +professional formatting suitable for sharing with development and QA teams. diff --git a/.worktrees/wt-9ce78f24/HANDOFF.md b/.worktrees/wt-9ce78f24/HANDOFF.md deleted file mode 100644 index 9ab72d3187..0000000000 --- a/.worktrees/wt-9ce78f24/HANDOFF.md +++ /dev/null @@ -1,299 +0,0 @@ -# AI-Edition Implementation Handoff - -**Branch**: `docs/ai-edition-plan` (commit `cf25858`, pushed to `origin`) -**Worktree**: `G:\repos\openscreen\.worktrees\wt-9ce78f24` -**Dev server**: `http://localhost:5173/?windowType=editor` (browser mode with shim) - ---- - -## 1. Context - -The user (Etienne Lescot, repo owner) was working through the implementation of the **OpenScreen x Axcut AI-edition merge**. The original PR #35 (commit `1e9db17` on the same branch) introduced the planning docs only: - -- `docs/architecture/ai-edition-merge-plan.md` — the 10-phase merge plan -- `docs/architecture/axcut-inventory.md` — catalog of the axcut codebase -- `docs/architecture/openscreen-inventory.md` — catalog of the OpenScreen codebase -- `docs/architecture/ai-edition-collision-analysis.md` — collision analysis - -This implementation PR (`cf25858`) delivers the **code** for that plan — all phases 0, 1, 3, 4, 6-8, and partial 9, plus a developer-convenience browser shim and spec updates that changed the framing. - -The plan was re-framed mid-implementation. The user clarified: -- **New editing model** (multi-asset, clips, skips, transcript, virtual-time preview) = **default for all users**, not opt-in -- **AI features** (LLM provider config, chat) = **opt-in** behind `AI_FEATURES_ENABLED` -- **Local Whisper** = **privacy-safe, not gated** (runs in-browser, never calls out) - -This is the spec's `§0 Framing` section. See `docs/architecture/ai-edition-merge-plan.md` lines ~13-65. - ---- - -## 2. What was built (file by file) - -### 2.1 Schema & migration (`src/lib/ai-edition/`) - -| File | Purpose | -|------|---------| -| `schema/index.ts` | Vendored axcut v2 schema + v3 additions (`annotations[]`, `zoomRanges[]`, `legacyEditor` envelope, `transcripts[]`). `axcutSchemaVersion = 3`. `clip.sourceEndSec` made optional (duration unknown at migration time). | -| `schema/index.test.ts` | 15 schema tests (version enforcement, optional clip duration, envelope passthrough, etc.) | -| `document/timeline.ts` | Pure interval math: `normalizeIntervals`, `subtractInterval`, `invertIntervals`, `buildTimelineFromIntervals`, `replaceTimeline`, `restoreFullTimeline`. Ported from axcut `apps/server/src/lib/timeline.ts` (no event bridge, no agent — just the math). | -| `document/timeline.test.ts` | 14 tests covering all the above. | -| `document/migrate.ts` | Bidirectional `EditorProjectData` (v2) ↔ `AxcutDocument` (v3). Notes: `zoomRanges`/`annotations` use **ms** units to mirror the legacy types; timeline ops use **sec** units. The migration is lossless in both directions thanks to the `legacyEditor` passthrough. | -| `document/migrate.test.ts` | 14 tests including round-trip, v1 legacy, focus clamping. | -| `document/transcribe.ts` | `transcribeAsset(document, assetId)` wraps the existing `extractMono16kFromVideoUrl` + `transcribeMono16kToSegments` (from `src/lib/captioning/`). Returns an `AxcutTranscript`. `withTranscript` writes it back to the document. | -| `document/ids.ts` | `createId(prefix)` using `uuid.v4()`. | -| `timeline/virtual-preview.ts` | Pure time-mapping: `totalVirtualDuration`, `clampVirtualTime`, `locateVirtualPosition`, `locateSourcePosition`, `keptWordIdSet`, `formatSeconds`. | -| `timeline/virtual-preview.test.ts` | 8 tests. | -| `store/projectStore.ts` | Zustand store: `projectId`, `document`, `revision`, `status`, `error`, `sourceDurationSec`, `currentTimeSec`. Actions: `loadProject`, `createProject`, `addAsset`, `removeAsset`, `replaceTimeline`, `restoreFullTimeline`, `setTranscript`, `setSourceDuration`, `setCurrentTime`, `saveDocument`, `setDocument`, `clear`. | -| `store/projectStore.test.ts` | 5 tests with `nativeBridgeClient.aiEdition` mocked. | -| `exporter/documentExporter.ts` | Adapter: maps `AxcutDocument` → `VideoExporterConfig` / `GifExporterConfig`. Clips → `trimRegions` (inverse). Reads `legacyEditor` for wallpaper, cursor, webcam, etc. `sourceWidth`/`sourceHeight` come from caller. | - -### 2.2 Main-process services (`electron/ai-edition/`) - -| File | Purpose | -|------|---------| -| `document-service.ts` | `DocumentService(projectsRoot)`: `listProjects`, `getProject(projectId)`, `createProject(title)`, `saveProject(doc)`, `deleteProject(projectId)`, `addAsset(projectId, {path, label?})`, `removeAsset(projectId, assetId)`. One `.axcut` JSON file per project under `app.getPath('userData')/projects/`. Validates paths against an allowlist of video extensions. Cascades clips + skipRanges on asset removal. | -| `document-service.test.ts` | 16 tests (CRUD, path traversal, cascade, primary-asset reassignment). | -| `provider-registry.ts` | 8 provider definitions (anthropic, openai, google, mistral, openrouter, openai-compatible, openai-oauth, copilot-proxy) with `authKind`, `supportsReasoningEffort`, `envKeys`, `baseUrl`. Ported from axcut `provider-registry.ts`. | -| `llm-config-store.ts` | `LlmConfigStore(userDataPath)`: config in `llm-config.json` plain JSON, **credentials in `safeStorage`-encrypted bytes** at `llm-credentials.enc`. Env vars override stored keys (same precedence as axcut). | -| `chat-service.ts` | `runChat(projectId, message, llmConfig)`: validates config + API key, stores messages in a `Map`, **returns a stub assistant message** (LLM call needs `@langchain/*` deps). `getChatHistory(projectId)` returns the in-memory list. | -| `native-bridge/services/aiEditionService.ts` | Adapter to the existing `native-bridge` envelope: wraps `DocumentService`, `LlmConfigStore`, and the chat stubs into the `domain: "aiEdition"` IPC contract. | - -### 2.3 IPC bridge extensions - -- `electron/ipc/nativeBridge.ts` — added the `aiEdition` domain case. Each action calls into `AiEditionService` (`document.listProjects`, `document.get`, `document.create`, `document.save`, `document.delete`, `document.addAsset`, `document.removeAsset`, `llm.getSnapshot`, `llm.setConfig`, `llm.setApiKey`, `llm.removeApiKey`, `chat.run`, `chat.history`). -- `electron/ipc/handlers.ts` — wires `DocumentService` + `LlmConfigStore` + chat functions into the `NativeBridgeContext`. -- `src/native/contracts.ts` — adds `AiEditionLlmConfig`, `AiEditionLlmSnapshot`, `AiEditionChatMessage`, `AiEditionChatResult` types and the new `aiEdition` action cases to the `NativeBridgeRequest` union. -- `src/native/client.ts` — adds the `nativeBridgeClient.aiEdition` namespace with `listProjects`, `get`, `create`, `save`, `delete`, `addAsset`, `removeAsset`, `llmGetSnapshot`, `llmSetConfig`, `llmSetApiKey`, `llmRemoveApiKey`, `chatRun`, `chatHistory`. -- `src/native/browserShim.ts` — **new**. Browser-mode shim that: - - Stubs `window.electronAPI` (no-op `openVideoFilePicker`, `pickExportSavePath`, etc.) - - Overrides `nativeBridgeClient` methods to return mock data - - Persists projects/docs in `localStorage` (`browser-shim-projects`, `browser-shim-document`) - - Auto-installs when running in a plain browser at `http://localhost:5173/?windowType=editor` - - Detected via absence of `window.electronAPI` - -### 2.4 Renderer UI (`src/components/ai-edition/`) - -| File | Purpose | -|------|---------| -| `IconRail.tsx` | Vertical 36-44px icon rail with collapse/expand chevron. Used for both left and right rails. Tooltip on hover. | -| `NewEditorShell.tsx` | **The default editor** for all users (replaces legacy `VideoEditor`). Layout: top header (project title + 3 toggle buttons) + body with left rail | left content (Project/Chat) | center (video + timeline) | right content (Transcript/Background/Video effects/Camera/Cursor/Crop/Export) | right rail. Recording → asset on editor open (auto-creates project + adds asset). Legacy `.openscreen` loading via the "Open" header button (migrates v2 → v3). | -| `AiEditionShell.tsx` | Re-exports `AiEditionOrLegacy` which delegates to `NewEditorShell` (legacy VideoEditor is now unused but kept for rollback). | -| `ProjectPanel.tsx` | Left content: project list + create input + assets list. Uses raw Tailwind matching OpenScreen's dark surface. | -| `TimelinePane.tsx` + `.module.css` | Ported from axcut `apps/web/src/components/TimelinePane.tsx` (~837 lines). Ruler, kept/cut segments, playhead, zoom (Ctrl+wheel), pan (Alt+drag), add cut, delete cut, resize cut handles, fit button, navigator overview. | -| `VirtualPreview.tsx` + `.module.css` | Ported from axcut `apps/web/src/components/VirtualPreview.tsx`. Single-video element with virtual-time seeking; seeks across clip boundaries; reports metadata via `onLoadedMetadata`; exposes video element via `onVideoElement` callback. | -| `TranscriptEditor.tsx` + `.module.css` | Click word / shift-click word → range → "Cut" button → `dropWordRange` op. Kept words = default, skipped = red strikethrough. | -| `ChatPanel.tsx` | Right content when `leftTab === "chat"`. Messages list + input + send. In-memory history. | -| `EditorSettings.tsx` | Bridge that wraps the **original `SettingsPanel`** (from `src/components/video-editor/SettingsPanel.tsx`, unchanged). Reads from `AxcutDocument.legacyEditor` (wallpaper, cursor, webcam, shadow, etc.), `document.zoomRanges`, `document.annotations`. Writes back through `setDocument` / `saveDocument`. Maps `activeTab` to `SettingsPanelMode` (background/effects/layout/cursor/timeline/export). Calls `SettingsPanel` with `hideInternalRail` so the right rail is the only navigation. | - -### 2.5 App-level wiring - -- `src/App.tsx` — imports and calls `installBrowserShims()` before render. The `editor` windowType still lazy-loads the `AiEditionShell` (which now renders `NewEditorShell`). -- `src/components/video-editor/featureFlags.ts` — renamed `AI_EDITION_ENABLED` → `AI_FEATURES_ENABLED`, default `false`. The flag now **only** gates the LLM/agent UI (chat panel). The new editor is the default for everyone. -- `package.json` — added `zod: ^3.23.8` and `zustand: ^5.0.8`. - -### 2.6 Documentation - -- `docs/architecture/ai-edition-merge-plan.md` — **major rewrite**: - - **New §0 Framing** — two layers (new editor = default, AI features = opt-in) - - **§5.8 locked decision** updated: flag now gates only LLM/agent UI - - **§10 cut-over** — no editor cut-over (new editor is default); only AI features opt-in - - Locked decisions list re-ordered: framing change recorded - ---- - -## 3. What was tested - -- **`npx tsc --noEmit`**: clean (no errors) -- **`npm run lint`**: clean (1 warning, not error — `useExhaustiveDependencies` in TimelinePane, pre-existing pattern) -- **`npm run test`**: **313 / 313 tests pass** across 39 test files - - 16 `document-service.test.ts` - - 15 `schema/index.test.ts` - - 14 `timeline.test.ts` - - 14 `migrate.test.ts` - - 8 `virtual-preview.test.ts` - - 5 `projectStore.test.ts` - - + 239 pre-existing tests (all still passing) -- **Browser smoke test**: `http://localhost:5173/?windowType=editor` renders the editor with shim data, project create/select works, asset add works (mocked), transcript/chat panels render, settings panel shows correct view per right-rail tab. - ---- - -## 4. Key decisions and rationale - -### 4.1 The framing change (user-driven) - -The original plan treated "AI-edition" as a single opt-in feature. Mid-implementation the user said: *multi-asset/clips/etc. is valid outside of user opt-in. It is valid outside of user opt-in. The opt-in should be limited to llm/conversation.* This led to: -- `AI_EDITION_ENABLED` → `AI_FEATURES_ENABLED` (the rename makes the semantic explicit) -- New editor ships to all users by default (kill-switch removed) -- The right rail's chat / LLM config is the only gated surface -- Local Whisper stays ungated (privacy-safe by construction) - -### 4.2 Why the new editor ships as the default despite incomplete feature parity - -The spec calls for full feature parity (annotations, zoom, cursor, webcam, blur, crop, export, legacy `.openscreen` loading). The implementation delivers the **architecture** and the **export, legacy loading, transcript, transcription, settings panel** integrations, but the new editor's UI is intentionally simpler than the legacy `VideoEditor` for some affordances (no annotations/zoom UI for adding new ones, just editing existing ones from the `SettingsPanel`). This is acceptable for a first cut because: -- The legacy `VideoEditor` is still on disk and reachable via git (rollback path) -- Adding the remaining UI affordances is incremental (no new architecture needed) -- The `SettingsPanel` integration already lets users edit every field that exists in their v3 document - -### 4.3 Why the AI runtime is stubbed - -Phases 6-8 require `@langchain/openai`, `@langchain/anthropic`, `deepagents`, `better-sqlite3`. These are heavy (multi-MB native modules, OAuth flows, langgraph runtime). The implementation: -- Ships the IPC contracts, provider registry, LLM config store (with `safeStorage`), chat history -- Stubs the actual LLM call (returns a fixed message reminding the user to install deps) -- The 8 providers, OAuth flow, reasoning effort mapping, and the chat-service scaffolding are all in place — adding the real `@langchain/*` calls is a focused follow-up - -### 4.4 Why ms for `annotations[]` / `zoomRanges[]` but sec for timeline - -`AxcutDocument.annotations` and `AxcutDocument.zoomRanges` mirror the legacy `ProjectEditorState.annotationRegions` / `.zoomRegions` which use **ms**. The timeline ops (`skipRanges`, `clips.sourceStartSec`, etc.) follow axcut's convention of **sec** because axcut's `clips` are authored from the agent/runtime where the second-based model is canonical. This dual-unit is contained to the document schema and handled by the `document/timeline.ts` math + `migrate.ts` conversion. The renderer reads `document.zoomRanges` directly as ms. - -### 4.5 Why `safeStorage` for credentials (not plain JSON) - -Per locked decision 4 in the spec: LLM credentials are stored in `safeStorage`-encrypted bytes (OS keychain on macOS, libsecret on Linux, DPAPI on Windows). Config (provider, model, baseUrl, reasoningEffort) is plain JSON. This matches axcut's security improvement over their original plain-JSON approach. - ---- - -## 5. What's NOT in this PR (deferred work) - -These are deliberate deferrals, not oversights: - -1. **Full feature parity UI** — adding new annotations/zoom regions from the new shell (the SettingsPanel can only edit existing ones). Follow-up: port the legacy `VideoEditor`'s annotation/zoom add flows to `NewEditorShell`. -2. **Real LLM calls** — `@langchain/*` deps not installed. Follow-up: `npm i @langchain/openai @langchain/anthropic deepagents` and replace the stub in `chat-service.ts:runChat`. -3. **SQLite for sessions/checkpoints** — `better-sqlite3` not installed. Follow-up: port axcut's `DatabaseService` and `PersistentFileCheckpointSaver`. -4. **Webcam real-time preview** in `VirtualPreview` — current is a single-video component; axcut has a two-layer crossfade. Follow-up for a richer preview experience. -5. **13-locale i18n** — the new components use hardcoded English strings ("Transcribe", "Remove cuts", "Export", etc.). Follow-up: add to `src/i18n/locales//*.json`. -6. **Settings sync to `userPreferences.ts`** — `AI_FEATURES_ENABLED` toggle is a constant, not user-toggleable. Follow-up: wire to the existing settings sync. -7. **Export dialog integration** — the new editor's "Export" button shows a toast. Follow-up: wire the `ExportDialog` component with the full options. -8. **The legacy `VideoEditor.tsx`** (2961 lines) is unchanged on disk. It can be deleted in a follow-up once confidence is high. - ---- - -## 6. How to continue - -### 6.1 Resume this branch - -```bash -cd G:\repos\openscreen\.worktrees\wt-9ce78f24 -git status # should be clean -git log --oneline -3 -npm run dev # already running, port 5173 -# Open http://localhost:5173/?windowType=editor -``` - -### 6.2 Add a real LLM provider - -1. `npm i @langchain/openai @langchain/anthropic deepagents better-sqlite3` -2. In `electron/ai-edition/chat-service.ts:runChat`, replace the stub with a real call: - ```ts - import { ChatOpenAI } from "@langchain/openai"; - const model = new ChatOpenAI({ model: config.model, apiKey }); - const result = await model.invoke(message); - ``` -3. Add the corresponding provider in `provider-registry.ts` if it's not already there. - -### 6.3 Add full feature parity (annotations/zoom creation UI) - -1. Port the legacy `VideoEditor`'s annotation-add flow (around line 2500+) to a new component. -2. Mount it in `NewEditorShell` alongside `SettingsPanel`. -3. Wire it to `documentStore.setDocument` (already wired through `EditorSettings`). - -### 6.4 Open a PR - -```bash -git push origin docs/ai-edition-plan # already pushed -gh pr create \ - --base main \ - --head docs/ai-edition-plan \ - --title "feat(ai-edition): implement v3 editor model + AI features scaffold" \ - --body-file PR_BODY.md -``` - -### 6.5 Delete the legacy `VideoEditor` when ready - -The file `src/components/video-editor/VideoEditor.tsx` (2961 lines) is now unused in the default flow. `grep -r "from.*VideoEditor" src/` to confirm. Then delete and remove from `App.tsx` lazy import. - ---- - -## 7. File map (where to look) - -``` -G:\repos\openscreen\.worktrees\wt-9ce78f24\ -├── docs/architecture/ -│ └── ai-edition-merge-plan.md # updated §0, §5.9, §10 -├── electron/ -│ ├── ai-edition/ -│ │ ├── document-service.ts # CRUD on .axcut files -│ │ ├── document-service.test.ts -│ │ ├── llm-config-store.ts # safeStorage credentials -│ │ ├── provider-registry.ts # 8 providers, static -│ │ └── chat-service.ts # in-memory, LLM stub -│ ├── ipc/ -│ │ ├── handlers.ts # wires services to bridge -│ │ └── nativeBridge.ts # adds aiEdition domain -│ └── native-bridge/services/ -│ └── aiEditionService.ts # bridge adapter -├── src/ -│ ├── App.tsx # installs browser shim -│ ├── native/ -│ │ ├── browserShim.ts # NEW - browser-mode stubs -│ │ ├── client.ts # adds aiEdition namespace -│ │ └── contracts.ts # adds aiEdition types -│ ├── components/ -│ │ ├── video-editor/ -│ │ │ ├── SettingsPanel.tsx # + hideInternalRail prop -│ │ │ └── featureFlags.ts # AI_EDITION_ENABLED → AI_FEATURES_ENABLED -│ │ └── ai-edition/ # NEW directory -│ │ ├── AiEditionShell.tsx # kill-switch removed -│ │ ├── NewEditorShell.tsx # main shell, the default -│ │ ├── IconRail.tsx -│ │ ├── ProjectPanel.tsx -│ │ ├── TimelinePane.tsx + .module.css -│ │ ├── VirtualPreview.tsx + .module.css -│ │ ├── TranscriptEditor.tsx + .module.css -│ │ ├── ChatPanel.tsx -│ │ └── EditorSettings.tsx # bridge → SettingsPanel -│ └── lib/ai-edition/ # NEW directory -│ ├── schema/index.ts + .test.ts -│ ├── document/ -│ │ ├── timeline.ts + .test.ts -│ │ ├── migrate.ts + .test.ts -│ │ ├── transcribe.ts -│ │ └── ids.ts -│ ├── timeline/ -│ │ └── virtual-preview.ts + .test.ts -│ ├── store/ -│ │ └── projectStore.ts + .test.ts -│ └── exporter/ -│ └── documentExporter.ts -└── package.json # +zod, +zustand -``` - ---- - -## 8. The conversation arc (for context) - -1. User asked to check PR #35 and start implementation per its plan. -2. Implemented Phase 0 (schema, migration, feature flag) — 29 tests pass, human-testable via dev server. -3. Implemented PR 1.1 (project panel, document service, IPC bridge) — human-testable. -4. User asked for total spec completion. Implemented PR 1.2 + 1.3 (timeline port, preview port, new editor shell with kill-switch). -5. User said "implement 1, 2, and 3" with the axcut `\\wsl.localhost\Ubuntu\home\etienne\repos\axcut\` path. Implemented: - - Export (Phase 3) — adapter to existing VideoExporter - - Legacy `.openscreen` loading — migrate v2 → v3 - - Settings panel (annotations, zoom, cursor, webcam, wallpaper) — bridge to `SettingsPanel` -6. User said "go on → full implementation". Implemented Phases 6-8 scaffolding (provider registry, LLM config store with safeStorage, chat service stub, IPC contracts) and Phase 9 partial (settings toggle, i18n deferred). -7. User asked to relaunch in browser. Added `browserShim.ts` for `http://localhost:5173/?windowType=editor`. -8. User asked for UI redesign to match original OpenScreen + axcut layout. Implemented: - - Left icon rail (Project / Chat) - - Right icon rail (Transcript / Background / Video effects / Camera / Cursor / Crop / Export) - - Top header (project title + PanelLeft / PanelRight / Download) - - NewEditorShell with full-height columns - - Removed the chevron collapse buttons (user requested) - - Used original OpenScreen SettingsPanel icons - - Added `hideInternalRail` prop so the right rail is the only navigation -9. User asked about worktree, branch, commit, push. Confirmed branch (`docs/ai-edition-plan`), committed and pushed. -10. User asked for a handoff summary in English for a coding agent. - ---- - -**The next coding agent should**: -- Open `http://localhost:5173/?windowType=editor` to see the current state -- Read `docs/architecture/ai-edition-merge-plan.md` for the plan -- Pick up from §5 (deferred work) — most impactful next steps are real LLM calls (#2) and feature parity UI (#1) -- All architecture is in place; the remaining work is wiring and UI polish, not new design diff --git a/AGENTS.md b/AGENTS.md index 566132003b..b4b873979e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R - `src/` — React app: UI, editor components, timeline, i18n, captioning/cursor/exporter libs - `electron/` — main process, IPC, recording orchestration - `electron/native/` — **native** capture helpers: `screencapturekit/` (Swift, macOS) and `wgc-capture/` (C++/Win32, Windows). These are built and shipped with the app, not loaded from npm -- `docs/` — architecture, engineering roadmaps, testing guides +- `technical-documentation/` — architecture, engineering and testing reference (start at its README) - `tests/` — Playwright e2e specs + fixtures - `scripts/` — native build scripts, diagnostic tools - `nix/`, `flake.nix` — Linux packaging @@ -59,6 +59,8 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi **The HUD widget** (recording controller) +- **It is invisible in screenshots by default.** The HUD (and the Notes window) call `setContentProtection(true)` so the recording controls never end up baked into a recording — the same `SetWindowDisplayAffinity` that WGC honours also hides them from *your* screenshots. The window is there, and clicks land, but you are aiming blind at a rectangle you cannot see. Set **`OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`** in the app's environment to turn it off for a session; every skipped window logs a warning. Unset it before recording anything real, or the HUD ends up in the video. +- The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 600×160). It is **click-through** (`setIgnoreMouseEvents(true, { forward: true })`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. - Control row (left→right): layout preset, **source** button (`Screen`/`Window` → label becomes the picked source), system-audio toggle, mic toggle, **webcam toggle** (shows the detected camera name), cursor-highlight toggle, **record**, notes, open-editor, language, minimize, close. The record button is disabled until a source is chosen (tooltip: "Please select a source to record"). @@ -75,6 +77,11 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi 4. Exercise the feature in the editor (e.g. Full Camera: press **C** to add a segment on the timeline, scrub to see the webcam grow to fullscreen and ease back; **Ctrl+Z** / **Ctrl+Shift+Z** undo/redo). 5. Capture a screenshot as proof. Clean up: stop `npm run dev`, remove temporary worktree junctions/lock. +**Judging the rendered picture** + +- A preview screenshot is a **downscaled** view of the compositor's output (a 1920-wide render shown in a ~600px pane, then downscaled again by the screenshot). Fine detail — a corner radius, a 1° edge slope, a soft shadow — does not survive that, and squinting at it produces confident wrong conclusions. To decide anything about pixels, **export and measure**: `Export → MP4 1080p`, then `ffmpeg -ss -i out.mp4 -frames:v 1 -c:v ppm frame.ppm` and walk the raw bytes (a P6 PPM is a 15-line parser) for the exact edges. That is what settled a "the tilt is truncated" report: measured right edge 1539 px against a computed corner at 1540 — no clipping at all, the real defect was elsewhere. +- ffmpeg lives at `crates/thirdparty/ffmpeg-*/bin/ffmpeg.exe` (also needed on `PATH` for the compositor addon to load). + ## PR & commit conventions - Branch from `main`; never push to it directly. @@ -91,7 +98,7 @@ Two `workflow_dispatch` workflows cut a release with a pre-release candidate (RC - **Promote RC**: Actions → "Promote RC to stable release" → Run workflow. Input: `rc_tag` (e.g. `v1.5.0-rc.2`), optional `release_notes_extra`. Closes the `vX.Y.Z` milestone, strips `-rc.N` from `package.json`, pushes `vX.Y.Z` tag, which triggers `build.yml` to publish a stable release (full notarization, Tier 3 homebrew/winget/nix/aur fires). Notifies `#announcements` on Discord. - **Manual fallback**: `git tag vX.Y.Z-rc.N && git push origin vX.Y.Z-rc.N` does the same as Cut RC (minus the milestone migration and Discord announce) — useful for emergency cuts. -Both workflows require the `OPENSCREEN_RELEASE_TOKEN` secret (a fine-grained PAT with `contents: write` + `issues: write`). This is the standard fix for `release: published` not triggering downstream workflows when the release is created by `GITHUB_TOKEN`. See `docs/secrets.md`. +Both workflows require the `OPENSCREEN_RELEASE_TOKEN` secret (a fine-grained PAT with `contents: write` + `issues: write`). This is the standard fix for `release: published` not triggering downstream workflows when the release is created by `GITHUB_TOKEN`. See `technical-documentation/engineering/release-and-secrets.md`. **Release branches freeze the build between cut and promote.** Every RC cut creates `release/vX.Y.Z-rc.N`. The branch is *not* merged into `main` until the stable tag is published; only cherry-picks of bugfixes land on the release branch during the RC window. The stable tag points at the branch tip (RC + cherry-picks), then `promote.yml` opens a `release/vX.Y.Z-sync → main` PR to bring main into line. This contract exists because of the v1.6.0 incident (2026-07-05) where the original promote workflow tagged `main` instead of the RC snapshot, causing 23 unreleased commits to ship in `v1.6.0`. Full rules in `.harness/docs/git-workflow.md` § Release branches. diff --git a/ROADMAP.md b/ROADMAP.md index 85b827ed53..27fee379f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ This roadmap is the source of truth for what we're shipping next in OpenScreen. OpenScreen is, first and foremost, a polished screen recorder. Record, trim on the timeline, export. Most users will keep using exactly this workflow. -We're also exploring an optional AI editing layer — for users who want to edit by talking or by editing a transcript. It's opt-in, off by default, and never required. If you don't enable it, the AI layer doesn't exist for your install: nothing downloads, nothing leaves your machine, no LLM is contacted. +There is also an optional AI editing layer — for users who want to edit by talking or by editing a transcript. It's opt-in, off by default, and never required. If you don't enable it, the AI layer doesn't exist for your install: nothing downloads, nothing leaves your machine, no LLM is contacted. Three axes guide every decision on this roadmap: @@ -16,19 +16,30 @@ Three axes guide every decision on this roadmap: - **Sleek UX stays** — every AI feature must keep the OpenScreen feel: minimal clicks, instant feedback, no clutter. - **100% free, forever** — no paywalls, no premium tier, no usage caps. Every feature on this page ships under MIT. -## 🤖 Direction — the optional AI Edition -A Screen Studio + Descript clone, open-source and free forever. The recorder-first UX stays intact, and the AI layer sits beside it, off by default. +## 🤖 The optional AI Edition — shipped, off by default +A Screen Studio + Descript alternative, open-source and free forever. The recorder-first UX stays intact, and the AI layer sits beside it, off by default. -Capabilities we're exploring (each one opt-in, each one toggleable independently): +What ships today (each one opt-in, each one toggleable independently): -- **Local Whisper transcription (opt-in, on-device)** — OpenScreen already ships on-device Whisper transcription for automatic captions. This extends that foundation: the same local transcript powers the editing features below, with no upload, no cloud, no extra setup required. -- **Transcript-driven editing (opt-in, local)** — edit video like a doc (Descript-style: delete a word, cut the span). Works with the local transcript; no cloud needed. -- **One-click cleanup (opt-in, local)** — filler-word removal, silence trimming, Studio Sound voice enhancement. All on-device. -- **Edit by chat (opt-in, requires BYO LLM key)** — say "cut the part where I repeat myself between 0:42 and 1:10" and the agent applies a structured timeline operation. Off until you connect a provider. -- **Non-destructive project document (always on)** — every edit, AI or manual, is undoable; the timeline is always recoverable. -- **Bring-your-own LLM (opt-in)** — OpenAI, Anthropic, Google, Mistral, OpenRouter, GitHub Copilot, OpenAI-compatible endpoints, ChatGPT account auth. You choose; we never see your keys or your data. +- [x] **Local Whisper transcription (on-device)** — whisper.cpp with the compute backend picked at runtime: Metal on Apple Silicon, Vulkan on Windows and Linux, CPU everywhere else. No upload, no cloud, works offline. It's the foundation every feature below stands on. +- [x] **Transcript-driven editing (local)** — edit video like a doc: select words, press `Delete`, the span is cut from playback and export. Silences are marked inline and trimmable the same way. Word boundaries are re-anchored on the audio so a cut lands where the word actually starts. +- [x] **Captions as a derived layer (local)** — cues are a live view of the transcript, not generated text you then maintain; restyle or regroup them with no regeneration step. Optional translation into 15 languages, stored beside the transcript and never in it. +- [x] **Edit by chat (requires BYO LLM key)** — describe an edit in plain language; the agent applies real, undoable timeline operations (trims, zooms, speed, annotations, clip ranges, reordering). Off until you connect a provider. +- [x] **Non-destructive project document (always on)** — `.openscreen` projects keep every edit re-editable, and `Ctrl/Cmd + Z` covers agent edits exactly like manual ones. +- [x] **Bring-your-own LLM (opt-in)** — Anthropic, OpenAI, Google, Mistral, OpenRouter, MiniMax, and any OpenAI-compatible endpoint. Keys live in your OS credential store via Electron `safeStorage`; requests go straight from your machine to the provider. We never see them, because there is no server to see them with. -This section is a direction, not a sprint plan. Concrete items land here as RFCs once the recorder is stable enough to build on top of. +Still open on this axis: + +- [ ] **One-click cleanup** — silence trimming ships in the transcript pane, but there's no dedicated filler-word pass (only the agent names a word a filler today), and voice enhancement ("Studio Sound") isn't started. +- [ ] **Sanctioned ChatGPT / GitHub Copilot sign-in** — both were removed in 1.8.0: reaching a user's subscription meant shipping GitHub's and OpenAI's own client IDs and an editor `User-Agent` against endpoints reserved for first-party clients, from inside a signed installer. They come back on the vendors' sanctioned surfaces — GitHub's Copilot SDK (we register our own OAuth App) and `codex app-server` (drives the user's own `codex login`, no client ID shipped at all). Separate integrations, not a header swap. + +## 🖥️ Rendering & platform parity +The live preview and MP4 export run on one native Rust + Direct3D 11 compositor: demux → decode → composite → hardware encode → mux, GPU-resident, no CPU readback between stages. Both consume the same scene description, so the frame you see in the editor is the frame the export writes — there is no second renderer that can drift. + +That engine is **Windows-only today**, which makes this the largest gap on the roadmap: + +- [ ] **MP4 export on macOS and Linux** — needs a Metal and a Vulkan backend behind the same scene contract. Recording, editing, transcription and GIF export already work on all three platforms; MP4 export does not. +- [ ] **Feature:** software H.264 fallback when no GPU encoder is available — [#18](../../issues/18). Critical for VMs, broken-driver machines, and headless environments. ## 🛠️ Stability & quality (what we're actually shipping) Pulled from real user bug reports on getopenscreen/openscreen. This is the queue for the next release window. @@ -37,17 +48,15 @@ Pulled from real user bug reports on getopenscreen/openscreen. This is the queue - [ ] **Fix:** crash after stopping macOS recording — [#21](../../issues/21) (macOS 26.4.1, Apple Silicon). Crash is in the Electron / Node async fs shutdown path; recording artifacts are written correctly. - [ ] **Fix:** macOS cursor offset in single-window capture — [#22](../../issues/22). - [ ] **Fix:** recover preview from WebGL context loss on Linux / Wayland — [#19](../../issues/19). -- [ ] **Feature:** software H.264 fallback when no GPU encoder MFT is available — [#18](../../issues/18). Critical for VMs, broken-driver machines, and headless environments. -- [ ] **Feature:** copy / paste attributes & effects in the timeline — [#24](../../issues/24). Right-click menu + standard Ctrl/Cmd+C / Ctrl/Cmd+V shortcuts. -- [ ] **Feature:** restore blur regions (rectangle / oval / freehand, mosaic + CSS) — [#76](../../issues/76). Upstream v1.5.0 dropped the feature in the final release before archiving. The renderer pipeline (`BlurSettingsPanel`, `blurEffects`, `annotationRenderer`) is already present in this fork; the export guard in `src/lib/exporter/videoExporter.ts:151-152` (refuses export while `showBlur` or `motionBlurAmount` is set) is what needs to be unblocked, plus a regression test in the timeline. +- [x] **Feature:** copy / paste attributes in the timeline — [#24](../../issues/24). `Ctrl/Cmd + C` / `Ctrl/Cmd + V` copy a selected region's attributes onto another region of the same kind. +- [ ] **Feature:** right-click context menu for the copy / paste above — the other half of [#24](../../issues/24), still open. +- [x] **Feature:** restore blur regions — [#76](../../issues/76). Shipped as an annotation **type** rather than its own region kind: Gaussian or mosaic, rectangle or oval, composited natively in both preview and export. Freehand is deliberately not offered when creating one — its input was broken and the renderer only ever masked the bounding box, and a half-reliable privacy tool is worse than no tool, because people trust it. Existing freehand shapes still render as their bounding box, with the inspector saying so. ## 📚 Site & documentation -- [ ] **Feature:** Docusaurus site — landing + docs, deployed to GitHub Pages via CI. - - Monorepo at `website/`. Versioning off until v2. - - Landing (pitch, demo, quick start, downloads) + migrate `docs/` → `website/docs/`. - - Bespoke theme (TBD). - - CI: build on PR (artifact preview), deploy to Pages on `main`. Custom domain as follow-up. - - MIT, no tracking, no paywall — same posture as the app. +- [x] **Feature:** Docusaurus site — landing + docs, live at [getopenscreen.github.io/openscreen](https://getopenscreen.github.io/openscreen/), built from `website/` and deployed to GitHub Pages by `.github/workflows/docs.yml` on every push to `main`. Landing page plus a Features section covering recording, the media library, the timeline, captions, AI editing and export. MIT, no tracking, no paywall — same posture as the app. + - [ ] Custom domain. + - [ ] Versioning — still off until v2. + - Engineering docs stay in `technical-documentation/` on purpose: they track the code rather than the product, are link-checked by `npm run docs:check`, and aren't user-facing. ## 📬 How to influence this roadmap - **Discord** — join the OpenScreen Discord and post in [#🗺️・roadmap](https://discord.com/channels/1489517664467681310/1493586210675884265). The fastest way to get a thumbs-up or thumbs-down on a feature. @@ -61,4 +70,5 @@ Anything not on this list yet? Open an issue and tag it `roadmap` — we'll tria ## Changelog - **2026-06-24** — initial draft. Stability items pulled from open issues / PRs on getopenscreen/openscreen. AI section presented as opt-in / off by default. Whisper entry updated to reflect existing caption feature. - **2026-06-25** — added "Site & documentation" tier: Docusaurus + GitHub Pages. Cleaned smoke-test noise from the changelog (internal CI sync validation, not user-facing). -- **2026-07-06** — added blur regions to the stability & quality tier. Confirmed upstream deprecated the feature in v1.5.0 without an explicit reason; the renderer code carried over to the fork, so the work is unblocking the export guard + adding coverage. Tracked via #76. \ No newline at end of file +- **2026-07-06** — added blur regions to the stability & quality tier. Confirmed upstream deprecated the feature in v1.5.0 without an explicit reason; the renderer code carried over to the fork, so the work is unblocking the export guard + adding coverage. Tracked via #76. +- **2026-07-27** — reconciled the roadmap with the code. The AI Edition tier moved from "a direction, not a sprint plan" to shipped: on-device transcription, transcript-driven editing, captions as a derived layer with translation, the chat agent, and `.openscreen` projects are all in. Provider list corrected — ChatGPT and GitHub Copilot were removed in 1.8.0 and are now blocked on the vendors' sanctioned surfaces, and MiniMax was missing. New "Rendering & platform parity" tier: preview and MP4 export share one native D3D11 compositor, and porting it off Windows is now the biggest open item; #18 moved there since it's an encoder concern. Blur (#76) marked shipped — as an annotation type, not a region kind, so the old note pointing at `src/lib/exporter/videoExporter.ts` was doubly stale (that file was deleted with the web export pipeline). Copy/paste (#24) split: the shortcuts shipped, the right-click menu didn't. Docusaurus site marked shipped. \ No newline at end of file diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000000..a1c2de27f9 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,61 @@ +# Third-party notices + +OpenScreen is MIT licensed (see [LICENSE](LICENSE)). The installers additionally +bundle the pre-built native components below. This file ships inside the +application resources and satisfies the attribution and source-offer obligations +that come with them. + +npm dependencies are not listed here: they are resolved from `package.json` and +distributed by their own registries, not redistributed inside our binaries. + +--- + +## FFmpeg — shared libraries (Windows only) + +- **Components**: `avcodec-*.dll`, `avformat-*.dll`, `avutil-*.dll`, + `swresample-*.dll`, `swscale-*.dll` and their siblings, under + `resources/electron/native/bin/win32-*/`. +- **Used by**: the native D3D11 compositor addon, which links against them at + load time. +- **License**: **GNU Lesser General Public License v2.1 or later** + (). FFmpeg's own + licensing page: . +- **This is an LGPL build, not a GPL one.** It is configured without + `--enable-gpl` and without `--enable-nonfree`, and links no GPL-only library + (x264, x265, xvid, vidstab, rubberband, frei0r, …). `scripts/fetch-ffmpeg.mjs` + verifies this before vendoring — it reads `ffmpeg -L`, `-buildconf` and + `-encoders` and refuses any binary that reports otherwise. +- **Upstream binaries**: BtbN/FFmpeg-Builds, release + `autobuild-2026-07-15-14-01`, the `*-lgpl-shared-8.1` assets. Pinned by + SHA-256 in `scripts/fetch-ffmpeg.mjs`; the digests there identify the exact + artifacts we ship. + +- **Corresponding source**: FFmpeg n8.1.2, commit `94138f6973`, from + . The build configuration and scripts that + produced these exact binaries are published at + . +- **Relinking**: as required by the LGPL, these are dynamic libraries. You may + replace them with your own build of the same FFmpeg version by overwriting the + DLLs in `resources/electron/native/bin/win32-*/`. + +## whisper.cpp and ggml + +- **Components**: `whisper-stt-server` and its ggml backend sidecars, under + `resources/electron/native/bin/-/`. +- **License**: MIT — and + . +- Built from source by `scripts/build-whisper-stt.sh`; the pinned upstream + revision is in `electron/native/whisper-stt/CMakeLists.txt`. +- The speech model (`ggml-*.bin`) is **not** bundled — it is downloaded into the + user's data directory on first use by `electron/stt/modelManager.ts`. + +## OpenScreen native helpers + +`wgc-capture` (Windows Graphics Capture), the ScreenCaptureKit helper (macOS) +and the D3D11 compositor addon are part of this repository and are covered by +[LICENSE](LICENSE). + +--- + +To report an omission or request source for anything bundled here, open an issue +at . diff --git a/biome.json b/biome.json index 517be7287f..b7c87298dd 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,10 @@ { "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, - "files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!**/*.css", "!**/design/**", "!**/.worktrees/**"] + }, "formatter": { "enabled": true, "indentStyle": "tab", @@ -92,7 +95,14 @@ "useGetterReturn": "error" } }, - "includes": ["**", "**/dist", "**/.eslintrc.cjs", "!**/*.css"] + "includes": [ + "**", + "**/dist", + "**/.eslintrc.cjs", + "!**/*.css", + "!**/design/**", + "!**/.worktrees/**" + ] }, "javascript": { "formatter": { "quoteStyle": "double" } }, "overrides": [ diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml new file mode 100644 index 0000000000..35d539b187 --- /dev/null +++ b/crates/.cargo/config.toml @@ -0,0 +1,13 @@ +# FFMPEG_DIR relatif au dossier crates/ (portable dans le repo). Y déposer le build +# ffmpeg LGPL-shared (voir README). LIBCLANG_PATH = install LLVM locale (bindgen). +# Ces valeurs cèdent à une vraie variable d'environnement (force=false par défaut). +# +# Pinné sur le MÊME build release-branch (n8.1.2-22-g94138f6973, tag BtbN +# autobuild-2026-07-15-14-01) que scripts/fetch-ffmpeg.mjs vendorise dans +# electron/native/bin// pour le packaging — pas un snapshot "master-latest" +# flottant. L'addon (compositor-view-napi) doit être lié à la même version de +# ffmpeg que celle shippée, sinon les DLLs vendorisées ne matchent pas les +# noms de DLL importés (avcodec-NN.dll etc.) et le require() échoue au runtime. +[env] +FFMPEG_DIR = { value = "thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared", relative = true } +LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin" diff --git a/crates/.gitignore b/crates/.gitignore new file mode 100644 index 0000000000..3ecbf6ad43 --- /dev/null +++ b/crates/.gitignore @@ -0,0 +1,21 @@ +# build +/target + +# dépendance téléchargée : build ffmpeg LGPL-shared BtbN (~160 Mo, voir README) +/thirdparty + +# sorties générées (vidéos C*.mp4, PNG, PPM, GIF, dumps). Le glob couvre les `--out` +# nommés par backend (out-hw/, out-cpu/) : un PPM de preuve pèse 6 Mo et il en sort un +# par cfg et par backend. +/out* +*.raw +*.nv12 + +# médias de fixture (provenance dans fixture/fixture.json, qui est suivi) +fixture/*.mp4 +fixture/*.cursor.json + +# artefacts des spikes S0 +/spikes/*.exe +/spikes/*.obj +/spikes/*.log diff --git a/crates/Cargo.lock b/crates/Cargo.lock new file mode 100644 index 0000000000..52c11e5e35 --- /dev/null +++ b/crates/Cargo.lock @@ -0,0 +1,696 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "compositor-view-napi" +version = "0.0.0" +dependencies = [ + "anyhow", + "napi", + "napi-build", + "napi-derive", + "openscreen-compositor", + "windows", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openscreen-compositor" +version = "0.0.0" +dependencies = [ + "anyhow", + "bindgen", + "cc", + "image", + "serde", + "serde_json", + "windows", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "poc-d3d" +version = "0.0.0" +dependencies = [ + "anyhow", + "openscreen-compositor", + "windows", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/Cargo.toml b/crates/Cargo.toml new file mode 100644 index 0000000000..ecb97d3c96 --- /dev/null +++ b/crates/Cargo.toml @@ -0,0 +1,59 @@ +# Workspace Rust d'OpenScreen. +# +# compositor/ la bibliothèque : D3D11, pipeline, effets HLSL, scène, curseur, audio, +# vue live. C'est du code de PRODUCTION — l'addon Electron s'y lie. +# compositor-view-napi/ l'addon napi-rs empaqueté dans l'app (compositor_view.node). +# poc-d3d/ le POC d'origine (GUI Win32 de preview/export + bench fps). Rien ici +# n'est packagé ; il est conservé comme banc de mesure et pièce d'époque. +# +# Le tout vivait sous `poc-d3d/`, ce qui laissait croire que la bibliothèque était jetable alors +# que l'app en dépend : un `.node` périmé s'est ainsi retrouvé packagé sans que personne tique. +# Les dépendances communes sont déclarées ici une seule fois plutôt que recopiées par crate. + +[workspace] +resolver = "2" +members = ["compositor", "compositor-view-napi", "poc-d3d"] +# `cargo build` (x.bat) ne construit que le POC ; l'addon se build explicitement +# (`cargo build -p compositor-view-napi`), comme avant le découpage. +default-members = ["poc-d3d"] + +[workspace.package] +version = "0.0.0" +edition = "2021" + +[workspace.dependencies] +openscreen-compositor = { path = "compositor" } +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Décodage des wallpapers image (jpg/png) pour le fond natif. default-features off → +# on ne tire que les décodeurs nécessaires (build plus léger). +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } + +[workspace.dependencies.windows] +version = "0.58" +features = [ + "Win32_Foundation", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D_Fxc", # D3DCompile (HLSL runtime) + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Foundation_Numerics", # requis par les méthodes D2D à brosse (CreateSolidColorBrush) + "Win32_Graphics_Direct2D", # cible de rendu D2D sur surface DXGI (texte, text.rs) + "Win32_Graphics_Direct2D_Common", # D2D1_COLOR_F / PIXEL_FORMAT + "Win32_Graphics_DirectWrite", # mise en page + rastérisation du texte + "Win32_Graphics_Gdi", # brosses/police GUI de la preview (poc-d3d/src/app.rs) + "Win32_System_Performance", # QueryPerformanceCounter / Frequency (§10) + "Win32_System_LibraryLoader", # GetModuleHandleW (hInstance) + "Win32_UI_WindowsAndMessaging", # fenêtres (harnais live + GUI du POC) + "Win32_UI_Controls", # barre de progression (poc-d3d/src/app.rs) + "Win32_UI_Input_KeyboardAndMouse", # EnableWindow (poc-d3d/src/app.rs) +] + +[profile.release] +opt-level = 3 +lto = true +debug = false +# §10 : debug_assertions OFF dans tout run mesuré +overflow-checks = false diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000000..605c5a3663 --- /dev/null +++ b/crates/README.md @@ -0,0 +1,101 @@ +# crates/ — le compositeur natif D3D11 (le fast-path natif retenu) + +| Crate | Rôle | +|---|---| +| [`compositor/`](compositor) | La bibliothèque : D3D11, décodage, pipeline, effets HLSL, scène, curseur, audio, vue live. **Code de production** — la preview et l'export de l'app passent par là. | +| [`compositor-view-napi/`](compositor-view-napi) | L'addon napi-rs. C'est lui qui produit `compositor_view.node`, **le binaire packagé dans l'app**. | +| [`poc-d3d/`](poc-d3d) | Le POC d'origine : GUI Win32 de preview/export + harnais de bench fps. **Rien ici n'est packagé** ; conservé comme banc de mesure et pièce d'époque. | + +Tout vivait auparavant sous `poc-d3d/`, ce qui laissait croire que la bibliothèque était +jetable alors que l'app en dépend — un `.node` périmé s'est ainsi retrouvé packagé sans que +personne tique. Le garde-fou est décrit dans +[`build-and-packaging.md`](../technical-documentation/engineering/build-and-packaging.md#stale-native-artifacts) ; +en un mot : sur Windows, packager avec `npm run build:win`, jamais `npm run build`. + +Le reste de ce document est l'historique du POC qui a produit ce compositeur. + +Troisième POC de rendu d'OpenScreen, à côté de [`poc/`](../poc) (web, WebCodecs + WebGPU) +et [`poc-native/`](../poc-native) (Rust + wgpu/Vulkan). Voir la spec : +[`technical-documentation/engineering/rendering-performance.md`](../technical-documentation/engineering/rendering-performance.md), +annexe D (D.6 en particulier). + +**Ce qu'il prouve.** Le chemin natif GPU-résident — celui que `poc-native` a montré bloqué +sur le driver AMD (Vulkan Video absent, cf. D.5) — est en réalité **débloqué via D3D11**, +sur le driver actuel, sans mise à jour. Application Windows native écrite de zéro (pas un +fork) : **un seul `ID3D11Device`, zéro readback CPU entre les étapes** — + +``` +décode D3D11VA (×2 sources, NV12 GPU) → compositeur HLSL → RGB→NV12 (2 passes RTV) + → encode h264_amf (GPU→GPU) → mux MP4 +``` + +Effets, écrits depuis les maths (mêmes que le compositeur WGSL) : layout animé, zooms, +NV12→RGB BT.709, coins arrondis + masques (SDF), ombres portées (pénombre SDF), fond flouté +(dual-Kawase), flou de mouvement par vélocité, curseur custom + click bounce. + +**Résultat mesuré** (protocole §C.2 du doc : régime soutenu, tour de chauffe jeté, +spread < 15 %) : config tous-effets **~126 fps** en 1080p60, au-dessus du web (79) et de +wgpu (48–68). Le fps enveloppe demux → décode → composite → encode → mux (§10 : une lecture +d'horloge avant/après tout le run). Détail des couches C0→C8 : [`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md). + +C'est le **fast-path natif retenu** pour Windows (cf. le marqueur de décision dans l'annexe D). +`poc/` reste l'hôte de lancement portable ; `poc-native` reste la preuve de portabilité du +compositeur (WGSL natif à l'identique) + la carte des coûts. + +## Stack + +- **Rust + windows-rs**, D3D11 nu (feature level 11_1, `VIDEO_SUPPORT`, multithread-protected). + Décision framework en [`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md) (Vulkan/Direct2D/GStreamer/wgpu instruits, écartés). +- **ffmpeg (libav\*)** LGPL pour demux / décode D3D11VA / encode `h264_amf` / mux. Bindings + générés par `bindgen` (choix vs `ffmpeg-next` : suit ffmpeg 8.x — voir [`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md)), + shim C (`shim.c`) pour les structs opaques. +- Compositeur HLSL (`src/shaders.hlsl`) compilé au runtime. + +## Prérequis + +- Rust (toolchain msvc), Visual Studio (MSVC + Windows SDK), LLVM (libclang, pour bindgen). +- **Build ffmpeg LGPL-shared** dans `thirdparty/` (non versionné) : récupérer + `ffmpeg-n8.1.2-win64-lgpl-shared` (le build pinné dans `.cargo/config.toml` — le + MÊME que `scripts/fetch-ffmpeg.mjs` vendorise, sinon le require() de l'addon échoue), + dézipper dans + `thirdparty/`. Le chemin est relatif au dossier, fixé dans `.cargo/config.toml` (`FFMPEG_DIR`). + `LIBCLANG_PATH` y pointe l'install LLVM. Ajuste le chemin vcvars dans `x.bat` si besoin. + +## Build & run + +`x.bat` encapsule vcvars + ffmpeg/bin sur le PATH runtime. Deux modes : + +**GUI (défaut)** — preview/playback interactive + export. Rapproche le POC d'une intégration +app : le même compositeur/pipeline mesuré alimente une vraie boucle de rendu. + +``` +x.bat run --release [-- --fixture fixture --out out] +``` + +Fenêtre native : preview du compositing en lecture bouclée (swapchain DXGI flip, blit +zéro-copie du RT), sélecteur de preset C0→C8, Play/Pause, **Export** (barre de progression + +bilan _temps + fps_). Écrit `out/export.mp4`. Détail : [`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md). + +**Bench (§9/§10)** — la mesure fps headless, inchangée : + +``` +x.bat run --release -- --cfg C0..C8 --fixture fixture --repeat 3 --out out/ +``` + +Produit `out/C{0..8}.mp4` (1080p60, 360 frames), `out/C{n}_f{60,180,300}.png`, +`out/report.json` + table markdown sur stdout. + +## Fixture + +Deux flux screen (le 2ᵉ simule une webcam HQ), 1080p60 CBP, 360 frames, coupés en `-c copy`. +Médias non versionnés (convention des autres POC) — provenance et commandes de régénération +dans `fixture/fixture.json`, qui est suivi. + +## Docs + +L'architecture du compositeur est documentée dans +[`technical-documentation/architecture/native-compositor.md`](../technical-documentation/architecture/native-compositor.md), +et les mesures qui l'ont choisie dans +[`technical-documentation/engineering/rendering-performance.md`](../technical-documentation/engineering/rendering-performance.md). +Ce README ne couvre que le build et le run de la crate. `spikes/` = les probes jetables +(Direct2D, NV12 render-target) conservés comme preuve. diff --git a/crates/compositor-view-napi/Cargo.toml b/crates/compositor-view-napi/Cargo.toml new file mode 100644 index 0000000000..166dc920b6 --- /dev/null +++ b/crates/compositor-view-napi/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "compositor-view-napi" +version.workspace = true +edition.workspace = true +description = "Addon napi-rs : expose openscreen_compositor::live::LiveView (fenêtre D3D enfant) à Electron. C'est CE crate qui produit compositor_view.node, le binaire packagé dans l'app." + +[lib] +crate-type = ["cdylib"] +name = "compositor_view" + +[dependencies] +napi = { version = "2", default-features = false, features = ["napi6"] } +napi-derive = "2" +openscreen-compositor.workspace = true +anyhow.workspace = true + +[dependencies.windows] +version = "0.58" +features = ["Win32_Foundation"] + +[build-dependencies] +napi-build = "2" diff --git a/crates/compositor-view-napi/build.rs b/crates/compositor-view-napi/build.rs new file mode 100644 index 0000000000..f609ed3c1b --- /dev/null +++ b/crates/compositor-view-napi/build.rs @@ -0,0 +1,5 @@ +fn main() { + // Configure le linker Windows pour un module chargeable par Node (symboles napi_* + // résolus au chargement par le process Node/Electron). + napi_build::setup(); +} diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs new file mode 100644 index 0000000000..05caf82f7e --- /dev/null +++ b/crates/compositor-view-napi/src/lib.rs @@ -0,0 +1,477 @@ +//! Addon napi-rs : pont Electron ↔ `openscreen_compositor::live::LiveView`. Expose la vue +//! offscreen (Option B, post-readback `Vec` RGBA8 → `` HTML) à la +//! glue TS (native-bridge domaine "compositor"). Les `#[napi]` sont appelés +//! depuis le thread principal Node (là où vit la `BrowserWindow`) ; le rendu et +//! la publication de la dernière frame vivent sur le thread dédié de `LiveView` +//! et sont récupérés via `read_frame`. + +use napi::bindgen_prelude::*; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}; +use napi::{Env, JsFunction, Task}; +use napi_derive::napi; +use openscreen_compositor::compositor::{live_params_from_scene, Compositor}; +use openscreen_compositor::d3d::{Backend, Gpu}; +use openscreen_compositor::live::{LiveView, PausedPreviews}; +use openscreen_compositor::scene::Scene; +use openscreen_compositor::{config, pipeline}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +/// Résolution cible du preview en pixels device (largeur/hauteur du `` +/// Electron affichant la preview). `x`/`y` ne sont plus utilisés (Option B : +/// la position est gérée par CSS côté web) — conservés dans l'objet pour +/// compatibilité structurelle avec l'ancien code de la glue TS, simplement +/// ignorés côté Rust. +#[napi(object)] +pub struct CompositorViewRect { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +static REGISTRY: OnceLock>> = OnceLock::new(); +static NEXT_ID: Mutex = Mutex::new(1); + +fn registry() -> &'static Mutex> { + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Crée une vue **offscreen** (pas de HWND, pas de fenêtre native). Démarre juste +/// un thread de rendu qui compose chaque frame, blit-resize vers `rect.width`× +/// `rect.height` (réutilise le même `ensure_resize_target`/`blit_resized` que +/// l'export), lit le résultat vers CPU via staging `D3D11_USAGE_STAGING` + +/// `Map`/`Unmap` et stocke un `Vec` RGBA8 tightly-packed dans la vue pour +/// que `read_frame` le retourne à la glue TS. +/// +/// `screen_path` est requis (F3 : le vrai enregistrement de l'app — deux +/// fichiers H264 séparés). `webcam_path`/`cursor_path` sont optionnels +/// (absents → pas de caméra / pas de curseur). +/// +/// `rect` ne sert plus que pour `width`/`height` (résolution cible du preview) ; +/// `x`/`y` sont ignorés (compat structurelle — la position est gérée par CSS). +/// Quel backend cette machine utilisera : `"hardware"`, `"cpu"`, ou `"none"` si aucun +/// device D3D11 ne se crée (la vue échouera alors avec son propre message, plus précis). +/// +/// Sert à PRÉVENIR : sur `"cpu"`, le rendu passe par WARP + décodage logiciel — la +/// preview tombe à ~8 fps avec tous les effets et l'export met des minutes au lieu de +/// secondes. L'utilisateur doit le savoir AVANT de lancer un export, pas après. D'où une +/// question posée au système et non à une vue : la modale d'export la pose sans qu'aucune +/// preview n'existe. Réponse mise en cache côté Rust — c'est une propriété de la machine. +#[napi] +pub fn probe_backend() -> String { + match Gpu::probe() { + Some(Backend::Hardware) => "hardware", + Some(Backend::Cpu) => "cpu", + None => "none", + } + .to_string() +} + +#[napi] +pub fn create_view( + rect: CompositorViewRect, + screen_path: Option, + webcam_path: Option, + cursor_path: Option, +) -> Result { + let screen = screen_path + .ok_or_else(|| Error::from_reason("create_view: screen_path is required"))?; + let webcam = webcam_path.unwrap_or_default(); + let cursor = cursor_path.unwrap_or_default(); + let view = LiveView::create( + rect.width.max(1) as u32, + rect.height.max(1) as u32, + &screen, + &webcam, + &cursor, + ) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + let id = { + let mut n = NEXT_ID.lock().unwrap(); + let id = *n; + *n += 1; + id + }; + registry().lock().unwrap().insert(id, view); + Ok(id) +} + +/// Met à jour la résolution cible du preview. L'ancienne sémantique « position +/// + taille de la fenêtre overlay » (`x, y, w, h`) n'a plus lieu d'être (la +/// preview est un bitmap posé sur un `` Electron, positionné en CSS) : +/// on garde la même forme d'objet `CompositorViewRect` côté TS pour ne pas +/// casser l'ABI, mais `x`/`y` sont silencieusement ignorés et seules +/// `width`/`height` sont propagées au thread de rendu. La résolution prend +/// effet au prochain tour (`compositor::readback_resized` reconstruit la +/// staging si `width`/`height` ont changé). +#[napi] +pub fn set_rect(id: i32, rect: CompositorViewRect) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_rect(rect.width.max(1) as u32, rect.height.max(1) as u32); + } +} + +/// Une frame de preview auto-descriptive : ses pixels PLUS tout ce qu'il faut pour +/// les interpréter (dimensions) et pour décider s'il faut les repeindre (génération). +/// Retournée par `read_frame`. Le consommateur JS n'a plus à deviner la taille depuis +/// `canvas.width` (ancien couplage implicite fragile) : elle voyage avec les octets. +#[napi(object)] +pub struct FramePacket { + /// Génération monotone de CETTE frame (≥ 1). Le consommateur la retient et la + /// repasse en `since_gen` au prochain appel ; tant qu'elle ne change pas, il n'y + /// a rien de neuf à peindre. `f64` car napi n'expose pas `u64` — sans risque : la + /// génération n'atteindra jamais 2^53 (ce serait des milliards d'années de rendu). + pub gen: f64, + pub width: u32, + pub height: u32, + /// R,G,B,A tightly-packed, `width * height * 4` octets — ce que `putImageData` / + /// `ImageData` attendent côté JS (canvas 2D, format natif RGBA8). + pub data: Buffer, +} + +/// Renvoie la dernière frame readback du thread de rendu SI elle est plus récente que +/// `since_gen`, sous forme de {@link FramePacket} (génération + dimensions + pixels). +/// +/// `Ok(None)` — le consommateur n'a rien à peindre — si : +/// - la vue `id` n'existe pas dans le registre (jamais créée ou déjà détruite), +/// - aucune frame n'a encore été composée (1er appel avant que le thread de rendu +/// n'ait publié quoi que ce soit), OU +/// - le consommateur possède déjà la génération courante (`gen <= since_gen`). C'est +/// le cas dominant en édition (preview figée en pause) : on renvoie `None` SANS +/// cloner le buffer ni traverser l'IPC. Tout le coût `O(w·h)` par frame — clone +/// Rust + structured-clone IPC + copies canvas — disparaît tant que rien ne bouge. +/// Passer `since_gen = 0` force la livraison de la frame courante (1re lecture). +/// +/// Quand une frame EST retournée, son `data` est détaché du `Vec` interne +/// (l'ownership passe au JS GC) ; le thread de rendu continue à composer sans bloquer +/// le thread Node. +#[napi] +pub fn read_frame(id: i32, since_gen: f64) -> Result> { + // Snapshot le pixel buffer HORS du lock du registre : on en a besoin vivant + // (r#[napi] retourne un Buffer qui consomme l'ownership du Vec). Sinon le + // MutexGuard serait tenu pendant que la frame est consommée par JS, ce qui + // bloquerait tout autre appel napi (`set_rect`, `destroy_view`, ...). + let slot = match registry().lock().unwrap().get(&id) { + None => return Ok(None), + Some(v) => { + // Le thread de rendu est mort (device D3D11 indisponible, décodeur en échec…) : + // il ne publiera plus jamais de frame. Sans ce relais, `create_view` a déjà + // répondu Ok et l'échec ne se voyait que dans un `eprintln!` — l'utilisateur + // restait devant un canvas noir sans explication (PR #162). La boucle de pull + // du renderer appelle ceci ~30×/s, donc l'erreur remonte tout de suite, et par + // le chemin d'erreur que `read_frame` a déjà (`Result`), sans changer le contrat. + if let Some(fatal) = v.fatal_error() { + return Err(Error::from_reason(fatal)); + } + v.latest_frame_since(since_gen.max(0.0) as u64) + } + }; + Ok(slot.map(|(gen, w, h, pixels)| { + debug_assert_eq!(pixels.len(), (w as usize) * (h as usize) * 4); + FramePacket { + gen: gen as f64, + width: w, + height: h, + data: Buffer::from(pixels), + } + })) +} + +/// Param live (inspector). Le type de valeur route vers le bon setter : +/// bool = switch (backgroundBlur…), number = slider (shadow/roundness/motionBlur), +/// string = sélection (backgroundColor "#rrggbb"). +#[napi] +pub fn set_param(id: i32, key: String, value: Either3) { + if let Some(v) = registry().lock().unwrap().get(&id) { + match value { + Either3::A(b) => v.set_param_bool(&key, b), + Either3::B(n) => v.set_param_num(&key, n), + Either3::C(s) => v.set_param_str(&key, &s), + } + } +} + +#[napi] +pub fn set_playing(id: i32, playing: bool) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_playing(playing); + } +} + +/// Positionne la vue au temps SOURCE du clip actif (conversion timeline faite côté renderer). +#[napi] +pub fn present_time(id: i32, seconds: f64) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_time(seconds); + } +} + +/// Remplace les sources du clip actif sans recréer la vue ni son thread de rendu. L'identité +/// timeline et le playhead source sont atomiques avec le switch : deux clips partageant les +/// mêmes fichiers restent distincts, et les deux décodeurs ouvrent directement la bonne frame. +#[napi] +pub fn set_active_clip( + id: i32, + screen_path: String, + webcam_path: String, + webcam_offset_sec: f64, + clip_index: u32, + source_time_sec: f64, +) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_active_clip( + &screen_path, + &webcam_path, + webcam_offset_sec, + clip_index as usize, + source_time_sec, + ); + } +} + +/// Installe la scène de l'app (JSON `SceneDescription`) sur la vue : layout preset piloté par +/// l'app au lieu de la fixture. JSON invalide → ignoré côté natif. +#[napi] +pub fn set_scene(id: i32, scene_json: String) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.set_scene(&scene_json); + } +} + +#[napi] +pub fn destroy_view(id: i32) { + // remove hors du lock : le Drop (join du thread de rendu) ne le tient pas. + let removed = registry().lock().unwrap().remove(&id); + drop(removed); +} + +/// Bilan d'un export natif (mesure §10 : une lecture d'horloge avant-après tout le run). +#[napi(object)] +pub struct ExportStats { + pub frames: u32, + pub wall_s: f64, + pub fps: f64, + /// Durée de la vidéo exportée (secondes) — distincte de `wall_s` (temps de rendu réel). + pub video_duration_s: f64, +} + +/// Builds a `progress: &mut dyn FnMut(u64)` closure (the shape both `run_composited` and +/// `run_composited_multi` already call once per encoded frame, for free — measured to not +/// affect the C8 benchmark's fps) that forwards to `tsfn`, throttled to ~10/s. Encoding at +/// typical export rates would otherwise cross the JS thread boundary dozens of times a +/// second for no UI benefit; the throttle keeps that cost negligible regardless of encode +/// speed. Always reports the very first tick (frame <= 1) so a fast/short export still +/// shows at least one progress update instead of jumping straight to the final Promise +/// resolution. +fn throttled_progress( + tsfn: Option>, +) -> impl FnMut(u64) { + let mut last_sent = std::time::Instant::now() - std::time::Duration::from_secs(1); + move |frames: u64| { + let Some(tsfn) = &tsfn else { return }; + let now = std::time::Instant::now(); + if frames <= 1 || now.duration_since(last_sent).as_millis() >= 100 { + last_sent = now; + tsfn.call(frames as u32, ThreadsafeFunctionCallMode::NonBlocking); + } + } +} + +/// Pauses every live preview of this process for the duration of an export — their render +/// threads stop composing/presenting, which frees the GPU's 3D engine (measured: preview on +/// ~72 fps → preview off ~125 fps) — and gives each one back the transport it was found with +/// when dropped, including on an early `return Err` or a panic. +/// +/// Restoring the saved state instead of resuming everything is the whole point; see +/// `PausedPreviews` for the bug the blanket resume caused (a preview left free-running behind +/// a paused editor, ending up on another clip's scene with the zoom regions filtered out). +struct PreviewPause(PausedPreviews); + +impl PreviewPause { + fn begin() -> Self { + // A poisoned registry means some other napi call panicked mid-mutation; the export + // itself is still worth running, we just have no previews we can speak for. + Self(match registry().lock() { + Ok(reg) => PausedPreviews::pause(reg.iter().map(|(id, view)| (*id, view))), + Err(_) => PausedPreviews::default(), + }) + } +} + +impl Drop for PreviewPause { + fn drop(&mut self) { + if let Ok(reg) = registry().lock() { + self.0.restore(reg.iter().map(|(id, view)| (*id, view))); + } + } +} + +/// Convertit une fonction JS optionnelle en `ThreadsafeFunction` appelable depuis le thread +/// libuv qui exécute `Task::compute` — c'est la seule façon de rappeler JS depuis là. Chaque +/// appel transporte juste le nombre de frames encodées (`u32`) ; le JS connaît déjà le total +/// attendu (durée × fps des clips) et calcule le pourcentage lui-même. +fn make_progress_tsfn( + f: Option, +) -> Result>> { + f.map(|f| f.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))) + .transpose() +} + +/// Un clip de la timeline pour l'export multiclip (JS : camelCase). +#[napi(object)] +pub struct ClipInput { + pub screen_path: String, + pub webcam_path: String, + pub source_start_sec: f64, + pub source_end_sec: f64, + /// Décalage caméra (s) : temps source webcam = temps source screen - offset. + pub webcam_offset_sec: f64, + /// `false` évite une ouverture ffmpeg vouée à échouer et réserve du silence à ce clip. + pub has_audio: bool, +} + +/// Taille/cadence/codec de sortie voulus par l'app (modale d'export). Tous optionnels : +/// absent → comportement historique (1920x1080, fps du 1er clip, h264). `width`/`height` +/// sont arrondis au pair le plus proche (exigence NV12 4:2:0) côté `export_multi`. +#[napi(object)] +pub struct ExportParamsInput { + pub width: Option, + pub height: Option, + pub fps: Option, + /// "h264" | "h265". Toute autre valeur (ex. "vp9", pas d'équivalent matériel AMF) fait + /// échouer l'export avec un message clair plutôt que de silencieusement retomber sur h264. + pub codec: Option, +} + +/// Export multiclip mesuré (worker libuv). Rend la vraie timeline (clips + trims) en un MP4. +/// `scene_json` (optionnel) = la même scène que la preview live : fond/layout/webcam/curseur — +/// sans elle on ne retomberait QUE sur le layout fixture A↔B, plus du tout ce que l'utilisateur +/// a configuré (le bug corrigé ici). Layout/zoom restent statiques (pas encore de zoom regions +/// ni de camera-fullscreen animés côté export). Rend aux previews le transport qu'elles avaient +/// (même en erreur) — voir `PreviewPause`. +pub struct ExportMultiTask { + out_path: String, + clips: Vec, + scene_json: Option, + params: Option, + on_progress: Option>, +} + +impl Task for ExportMultiTask { + type Output = (u32, f64, f64, f64); + type JsValue = ExportStats; + + fn compute(&mut self) -> Result { + // Previews paused for the whole render (GPU 3D engine freed) and restored + // exactly as found when this guard drops, including on the error paths. + let _previews = PreviewPause::begin(); + // Même sélection que la preview : l'export d'un hôte sans GPU passe par + // libopenh264 au lieu d'AMF, plutôt que d'échouer. + let gpu = Gpu::create_auto(false).map_err(|e| Error::from_reason(format!("{e:#}")))?; + let mut cfg = config::all().pop().expect("au moins une config"); // C8 + cfg.zoom = false; + cfg.layout_anim = false; + cfg.mblur_n = 1; // layout statique → pas de motion blur de layout (pas de surcoût) + + // scène de l'app = même chemin que la preview live : fond, layout, webcam, curseur. + // JSON absent/invalide → pas de scène (fixture), pareil que si la preview n'en avait + // jamais reçu — jamais un fallback masquant, juste rien de configuré. + let scene = self.scene_json.as_deref().and_then(|j| Scene::from_json(j).ok()); + if let Some(scene) = &scene { + cfg.bg_blur = scene.effects.blur; + cfg.cursor = scene.cursor.show; + } else { + cfg.cursor = false; + } + + let mut export_params = pipeline::ExportParams::default(); + if let Some(p) = &self.params { + if let Some(w) = p.width { + export_params.width = w.max(2) & !1; // pair le plus proche (>=2, NV12) + } + if let Some(h) = p.height { + export_params.height = h.max(2) & !1; + } + export_params.fps = p.fps; + if let Some(codec) = &p.codec { + export_params.codec = match codec.as_str() { + "h264" => pipeline::ExportCodec::H264, + "h265" => pipeline::ExportCodec::H265, + other => { + return Err(Error::from_reason(format!( + "codec d'export \"{other}\" non supporté par le pipeline natif (h264/h265 seulement — pas d'équivalent matériel AMF pour VP9, et le chemin logiciel testé était trop lent pour être utile)" + ))); + } + }; + } + } + + // Le compositeur rastérise à la taille RÉELLEMENT encodée — d'où sa + // construction ici, une fois `export_params` résolu. + // + // Avant : il composait toujours en 1920×1080 puis `blit_resized` étirait + // vers la taille d'export. Deux défauts, une seule cause — tout export + // dépassant 1080p sur un axe était un AGRANDISSEMENT (un 4K portait le + // quart de l'information), et tout ratio ≠ 16:9 devait passer par une + // compensation géométrique. En construisant le compositeur à la + // géométrie de sortie, `blit_resized` devient une identité et les pixels + // sont rastérisés exactement là où ils seront encodés. + let comp = Compositor::new_sized(&gpu, export_params.width, export_params.height) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + if let Some(scene) = &scene { + comp.set_live_params(live_params_from_scene(scene)); + } + comp.set_scene(scene); + + let mut progress = throttled_progress(self.on_progress.take()); + let s = pipeline::run_composited_multi( + &self.clips, + &self.out_path, + &gpu, + &comp, + &cfg, + &export_params, + &mut progress, + ) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + Ok((s.frames as u32, s.wall_s, s.fps, s.video_duration_s)) + } + + fn resolve(&mut self, _env: Env, out: Self::Output) -> Result { + Ok(ExportStats { frames: out.0, wall_s: out.1, fps: out.2, video_duration_s: out.3 }) + } +} + +/// Lance un export multiclip natif (vraie timeline → MP4) et résout `Promise`. +/// `scene_json` : même `SceneDescription` que la preview (fond/layout/webcam/effets/curseur). +/// `params` : taille/cadence/codec de sortie voulus (absent → 1920x1080/fps du 1er clip/h264). +/// `on_progress(framesEncodées)` optionnel — rappelé côté JS à ~10 Hz max pendant le rendu ; +/// le JS calcule lui-même le pourcentage (il connaît déjà le total attendu, durée×fps des clips). +#[napi] +pub fn export_multi( + clips: Vec, + out_path: String, + scene_json: Option, + params: Option, + on_progress: Option, +) -> Result> { + let clips = clips + .into_iter() + .map(|c| pipeline::ClipSource { + screen: c.screen_path, + webcam: c.webcam_path, + source_start_sec: c.source_start_sec, + source_end_sec: c.source_end_sec, + webcam_offset_sec: c.webcam_offset_sec, + has_audio: c.has_audio, + }) + .collect(); + Ok(AsyncTask::new(ExportMultiTask { + out_path, + clips, + scene_json, + params, + on_progress: make_progress_tsfn(on_progress)?, + })) +} diff --git a/crates/compositor/Cargo.toml b/crates/compositor/Cargo.toml new file mode 100644 index 0000000000..969f26be8c --- /dev/null +++ b/crates/compositor/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openscreen-compositor" +version.workspace = true +edition.workspace = true +description = "Compositeur natif D3D11 d'OpenScreen : décodage, pipeline, effets HLSL, scène, curseur, audio et vue live. Code de production — l'addon Electron (compositor-view-napi) s'y lie, le POC (poc-d3d) le consomme aussi." + +[lib] +name = "openscreen_compositor" +path = "src/lib.rs" + +[build-dependencies] +bindgen = "0.70" +cc = "1" + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +image.workspace = true +windows.workspace = true diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs new file mode 100644 index 0000000000..85b2225c31 --- /dev/null +++ b/crates/compositor/build.rs @@ -0,0 +1,52 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let ff = env::var("FFMPEG_DIR").expect("FFMPEG_DIR non défini (voir .cargo/config.toml)"); + + // --- linkage : les import libs LGPL de BtbN --- + println!("cargo:rustc-link-search=native={}\\lib", ff); + for lib in ["avformat", "avcodec", "avutil", "swscale", "swresample"] { + println!("cargo:rustc-link-lib=dylib={}", lib); + } + + println!("cargo:rerun-if-changed=wrapper.h"); + println!("cargo:rerun-if-changed=shim.c"); + println!("cargo:rerun-if-env-changed=FFMPEG_DIR"); + + // shim C : accesseurs pour les structs que bindgen rend opaques (AVFormatContext). + cc::Build::new() + .file("shim.c") + .include(format!("{}\\include", ff)) + .compile("sn_shim"); + + // --- bindings générés sur les VRAIS headers 8.x (immunisé contre la version) --- + let bindings = bindgen::Builder::default() + .header("wrapper.h") + .clang_arg(format!("-I{}\\include", ff)) + .allowlist_function("av.*") + .allowlist_function("avcodec_.*") + .allowlist_function("avformat_.*") + .allowlist_function("avio_.*") + .allowlist_function("swr_.*") + .allowlist_function("sws_.*") + .allowlist_type("AV.*") + .allowlist_type("SwrContext") + .allowlist_type("SwsContext") + .allowlist_var("SWS_.*") + .allowlist_var("AV_.*") + .allowlist_var("AVERROR.*") + .allowlist_var("FF_.*") + .allowlist_var("AVIO_.*") + // enums en constantes simples : plus simple à manipuler en FFI brut + .default_enum_style(bindgen::EnumVariation::ModuleConsts) + .derive_default(true) + .layout_tests(false) + .generate() + .expect("bindgen a échoué sur les headers ffmpeg"); + + let out = PathBuf::from(env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out.join("ffi.rs")) + .expect("écriture ffi.rs"); +} diff --git a/crates/compositor/shim.c b/crates/compositor/shim.c new file mode 100644 index 0000000000..b95af05eca --- /dev/null +++ b/crates/compositor/shim.c @@ -0,0 +1,9 @@ +// Accesseurs pour les champs d'AVFormatContext que bindgen rend opaque +// (struct atteinte seulement par pointeur -> blob opaque). Compilé contre les +// VRAIS headers ffmpeg 8.x par MSVC : offsets corrects, immunisé contre la version. +#include + +AVStream* sn_fmt_stream(AVFormatContext* s, int i) { return s->streams[i]; } +unsigned sn_fmt_nb_streams(AVFormatContext* s) { return s->nb_streams; } +AVIOContext* sn_fmt_get_pb(AVFormatContext* s) { return s->pb; } +void sn_fmt_set_pb(AVFormatContext* s, AVIOContext* p) { s->pb = p; } diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs new file mode 100644 index 0000000000..9b15cdae9f --- /dev/null +++ b/crates/compositor/src/audio.rs @@ -0,0 +1,1062 @@ +//! Piste audio native de l'export multiclip : ffmpeg décode les sources écran, swresample +//! normalise tout en f32 planaire 48 kHz stéréo, WSOLA applique les speed regions, puis un +//! unique encodeur AAC alimente le même muxer que la vidéo. + +use crate::ffi::*; +use crate::regions::SpeedSegment; +use anyhow::{bail, Result}; +use std::f32::consts::PI; +use std::ffi::CString; +use std::ptr; + +pub const AUDIO_OUTPUT_SAMPLE_RATE: i32 = 48_000; +pub const AUDIO_OUTPUT_CHANNELS: usize = 2; +pub const AUDIO_BITRATE: i64 = 128_000; +pub const AUDIO_BOUNDARY_FADE_SAMPLES: usize = 240; + +const AVERROR_EAGAIN: i32 = -11; +const AVERROR_EOF: i32 = -541478725; +const AVSEEK_FLAG_BACKWARD: i32 = 1; +const DEFAULT_FRAME_SEC: f64 = 0.04; +const MIN_FRAME_SEC: f64 = 0.005; +const DEFAULT_SEARCH_SEC: f64 = 0.01; +const TARGET_GRAINS: usize = 8; +const PASSTHROUGH_EPSILON: f64 = 1e-3; + +pub type PlanarPcm = Vec>; + +extern "C" { + fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; + // bindgen rend `AVFormatContext` opaque (atteinte seulement par pointeur), d'où l'accesseur + // compilé contre les vrais headers — voir shim.c. + fn sn_fmt_nb_streams(s: *mut AVFormatContext) -> u32; +} + +fn averr(ret: i32, ctx: &str) -> Result<()> { + if ret < 0 { + let mut buf = [0i8; 256]; + unsafe { av_strerror(ret, buf.as_mut_ptr(), buf.len()) }; + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_string_lossy(); + bail!("{ctx}: {ret} ({msg})"); + } + Ok(()) +} + +struct AudioResampler { + ctx: *mut SwrContext, + input_rate: i32, +} + +impl AudioResampler { + unsafe fn from_frame(frame: *mut AVFrame, dctx: *mut AVCodecContext) -> Result { + let mut output_layout = AVChannelLayout::default(); + av_channel_layout_default(&mut output_layout, AUDIO_OUTPUT_CHANNELS as i32); + + let mut fallback_layout = AVChannelLayout::default(); + let input_layout = if (*frame).ch_layout.nb_channels > 0 { + &(*frame).ch_layout as *const AVChannelLayout + } else if (*dctx).ch_layout.nb_channels > 0 { + &(*dctx).ch_layout as *const AVChannelLayout + } else { + av_channel_layout_default(&mut fallback_layout, 1); + &fallback_layout as *const AVChannelLayout + }; + let input_rate = if (*frame).sample_rate > 0 { + (*frame).sample_rate + } else { + (*dctx).sample_rate + }; + if input_rate <= 0 { + av_channel_layout_uninit(&mut output_layout); + av_channel_layout_uninit(&mut fallback_layout); + bail!("fréquence audio source invalide"); + } + + let mut ctx: *mut SwrContext = ptr::null_mut(); + let ret = swr_alloc_set_opts2( + &mut ctx, + &output_layout, + AVSampleFormat::AV_SAMPLE_FMT_FLTP, + AUDIO_OUTPUT_SAMPLE_RATE, + input_layout, + (*frame).format as AVSampleFormat::Type, + input_rate, + 0, + ptr::null_mut(), + ); + av_channel_layout_uninit(&mut output_layout); + av_channel_layout_uninit(&mut fallback_layout); + averr(ret, "swr_alloc_set_opts2")?; + if ctx.is_null() { + bail!("swr_alloc_set_opts2: contexte nul"); + } + averr(swr_init(ctx), "swr_init")?; + Ok(Self { ctx, input_rate }) + } + + unsafe fn push(&mut self, frame: *mut AVFrame, output: &mut PlanarPcm) -> Result<()> { + let out_capacity = swr_get_out_samples(self.ctx, (*frame).nb_samples).max(1) as usize; + let mut planes = vec![vec![0.0f32; out_capacity]; AUDIO_OUTPUT_CHANNELS]; + let mut output_ptrs: Vec<*mut u8> = planes + .iter_mut() + .map(|plane| plane.as_mut_ptr() as *mut u8) + .collect(); + let input_ptrs = (*frame).extended_data as *const *const u8; + let converted = swr_convert( + self.ctx, + output_ptrs.as_mut_ptr(), + out_capacity as i32, + input_ptrs, + (*frame).nb_samples, + ); + averr(converted, "swr_convert")?; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + planes[channel].truncate(converted as usize); + output[channel].extend_from_slice(&planes[channel]); + } + Ok(()) + } + + unsafe fn flush(&mut self, output: &mut PlanarPcm) -> Result<()> { + loop { + let delay = swr_get_delay(self.ctx, self.input_rate as i64); + if delay <= 0 { + break; + } + let out_capacity = (((delay * AUDIO_OUTPUT_SAMPLE_RATE as i64) + + self.input_rate as i64 - 1) + / self.input_rate as i64 + + 32) as usize; + let mut planes = vec![vec![0.0f32; out_capacity]; AUDIO_OUTPUT_CHANNELS]; + let mut output_ptrs: Vec<*mut u8> = planes + .iter_mut() + .map(|plane| plane.as_mut_ptr() as *mut u8) + .collect(); + let converted = swr_convert( + self.ctx, + output_ptrs.as_mut_ptr(), + out_capacity as i32, + ptr::null(), + 0, + ); + averr(converted, "swr_convert(flush)")?; + if converted == 0 { + break; + } + for channel in 0..AUDIO_OUTPUT_CHANNELS { + planes[channel].truncate(converted as usize); + output[channel].extend_from_slice(&planes[channel]); + } + } + Ok(()) + } +} + +impl Drop for AudioResampler { + fn drop(&mut self) { + unsafe { swr_free(&mut self.ctx) }; + } +} + +/// Un décodeur + resampler par piste audio du conteneur, tous alimentés par la même passe de +/// démux. Chaque piste produit du f32 planaire 48 kHz stéréo recadré sur la même fenêtre +/// source, si bien que le mixage final est une simple somme échantillon par échantillon. +struct AudioTrackDecoder { + stream_index: i32, + dctx: *mut AVCodecContext, + tb_sec: f64, + resampler: Option, + decoded: PlanarPcm, + origin_sec: Option, + reached_end: bool, + decoder_eof: bool, +} + +impl Drop for AudioTrackDecoder { + fn drop(&mut self) { + unsafe { avcodec_free_context(&mut self.dctx) }; + } +} + +/// Décode uniquement la fenêtre source conservée. Le seek audio retombe sur une trame +/// antérieure ; l'origine pts du premier bloc resamplé permet ensuite de couper précisément la +/// prélecture sans supposer que la piste commence à t=0. +/// +/// TOUTES les pistes audio sont décodées puis mixées. L'enregistreur natif macOS écrit l'audio +/// système et le micro en deux pistes AAC distinctes, toutes deux marquées `default` : +/// `av_find_best_stream` n'en rendait qu'une — la première, silencieuse dès que rien ne joue sur +/// le système — et le micro disparaissait de l'export (issue #108). La PR #109 avait corrigé ce +/// défaut dans l'exporteur navigateur ; l'app n'emprunte plus ce chemin, d'où la même correction +/// ici, dans le chemin natif. +pub fn decode_clip_audio(path: &str, source_start_sec: f64, source_end_sec: f64) -> Result> { + unsafe { decode_clip_audio_inner(path, source_start_sec, source_end_sec) } +} + +unsafe fn decode_clip_audio_inner( + path: &str, + source_start_sec: f64, + source_end_sec: f64, +) -> Result> { + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "audio open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "audio find_stream_info")?; + // Énumération de toutes les pistes audio (voir le commentaire de `decode_clip_audio`). + let mut audio_stream_count = 0usize; + let mut tracks: Vec = Vec::new(); + for index in 0..sn_fmt_nb_streams(fmt) as i32 { + let stream = sn_fmt_stream(fmt, index); + let codecpar = (*stream).codecpar; + if (*codecpar).codec_type != AVMediaType::AVMEDIA_TYPE_AUDIO { + continue; + } + audio_stream_count += 1; + // Une piste qu'on ne sait pas ouvrir est ignorée, pas fatale : avant ce mixage une + // seule piste était ouverte, donc une piste annexe exotique ne pouvait pas casser un + // export. Le `bail!` plus bas garantit qu'on ne perd pas l'audio en silence pour + // autant — c'est précisément le défaut qu'on corrige ici. + let decoder = avcodec_find_decoder((*codecpar).codec_id); + if decoder.is_null() { + continue; + } + let mut dctx = avcodec_alloc_context3(decoder); + if dctx.is_null() { + avformat_close_input(&mut fmt); + bail!("audio avcodec_alloc_context3"); + } + if avcodec_parameters_to_context(dctx, codecpar) < 0 + || avcodec_open2(dctx, decoder, ptr::null_mut()) < 0 + { + avcodec_free_context(&mut dctx); + continue; + } + let time_base = (*stream).time_base; + tracks.push(AudioTrackDecoder { + stream_index: index, + dctx, + tb_sec: if time_base.den != 0 { + time_base.num as f64 / time_base.den as f64 + } else { + 0.0 + }, + resampler: None, + decoded: vec![Vec::::new(); AUDIO_OUTPUT_CHANNELS], + origin_sec: None, + reached_end: false, + decoder_eof: false, + }); + } + if tracks.is_empty() { + avformat_close_input(&mut fmt); + if audio_stream_count > 0 { + bail!("audio : {audio_stream_count} piste(s) présentes, aucune décodable"); + } + return Ok(None); + } + + // Un seul seek : il repositionne le conteneur entier. On le cale sur la première piste + // audio puis on vide tous les décodeurs. Si une autre piste reprend légèrement après le + // début de la fenêtre demandée, son recadrage (plus bas) la zéro-padde devant — le + // décalage inter-pistes est absorbé là, pas ici. + let seek_tb_sec = tracks[0].tb_sec; + let seek_stream_index = tracks[0].stream_index; + if seek_tb_sec > 0.0 { + let target = (source_start_sec / seek_tb_sec).floor() as i64; + if av_seek_frame(fmt, seek_stream_index, target, AVSEEK_FLAG_BACKWARD) >= 0 { + for track in tracks.iter_mut() { + avcodec_flush_buffers(track.dctx); + } + } + } + + let mut packet = av_packet_alloc(); + let mut frame = av_frame_alloc(); + let mut input_eof = false; + + // Une seule passe de démux alimente tous les décodeurs : chaque paquet est routé vers la + // piste dont il porte l'index. On continue tant qu'AU MOINS une piste a encore quelque + // chose à produire. + while tracks.iter().any(|t| !t.reached_end && !t.decoder_eof) { + if !input_eof { + let read = av_read_frame(fmt, packet); + if read == AVERROR_EOF { + for track in tracks.iter_mut() { + avcodec_send_packet(track.dctx, ptr::null()); + } + input_eof = true; + } else { + averr(read, "audio av_read_frame")?; + let packet_stream = (*packet).stream_index; + if let Some(track) = tracks + .iter_mut() + .find(|t| t.stream_index == packet_stream && !t.reached_end) + { + averr(avcodec_send_packet(track.dctx, packet), "audio send_packet")?; + } + av_packet_unref(packet); + } + } + + for track in tracks.iter_mut() { + if track.reached_end || track.decoder_eof { + continue; + } + loop { + let ret = avcodec_receive_frame(track.dctx, frame); + if ret == AVERROR_EOF { + track.decoder_eof = true; + break; + } + if ret == AVERROR_EAGAIN { + if input_eof { + track.decoder_eof = true; + } + break; + } + averr(ret, "audio receive_frame")?; + + let pts = (*frame).best_effort_timestamp; + let frame_sec = if pts != i64::MIN && track.tb_sec > 0.0 { + pts as f64 * track.tb_sec + } else { + track.origin_sec.unwrap_or(source_start_sec) + }; + if frame_sec >= source_end_sec { + track.reached_end = true; + av_frame_unref(frame); + break; + } + if track.origin_sec.is_none() { + track.origin_sec = Some(frame_sec); + } + if track.resampler.is_none() { + track.resampler = Some(AudioResampler::from_frame(frame, track.dctx)?); + } + track + .resampler + .as_mut() + .unwrap() + .push(frame, &mut track.decoded)?; + av_frame_unref(frame); + } + } + } + + for track in tracks.iter_mut() { + if let Some(r) = track.resampler.as_mut() { + r.flush(&mut track.decoded)?; + } + } + + av_frame_free(&mut frame); + av_packet_free(&mut packet); + avformat_close_input(&mut fmt); + + let target_samples = (((source_end_sec - source_start_sec).max(0.0) + * AUDIO_OUTPUT_SAMPLE_RATE as f64) + .round()) as usize; + let aligned: Vec<(f64, &PlanarPcm)> = tracks + .iter() + .map(|track| (track.origin_sec.unwrap_or(source_start_sec), &track.decoded)) + .collect(); + Ok(Some(mix_aligned_tracks( + &aligned, + source_start_sec, + target_samples, + ))) +} + +/// Recadre chaque piste sur la MÊME fenêtre (`target_samples` échantillons à partir de +/// `source_start_sec`) puis les somme. Une piste dont le premier paquet décodé arrive après le +/// début de la fenêtre est zéro-paddée devant ; la prélecture d'une piste décodée trop tôt (le +/// seek retombe sur une trame antérieure) est coupée. C'est ce recadrage qui absorbe les +/// décalages de départ entre pistes, si bien que le mixage lui-même n'est qu'une somme. +/// +/// Séparé du décodage pour être testable sans ffmpeg. +fn mix_aligned_tracks( + tracks: &[(f64, &PlanarPcm)], + source_start_sec: f64, + target_samples: usize, +) -> PlanarPcm { + let mut mixed = vec![vec![0.0f32; target_samples]; AUDIO_OUTPUT_CHANNELS]; + for &(origin_sec, decoded) in tracks { + let relative_start = + ((source_start_sec - origin_sec) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as i64; + let (src_start, dst_start) = if relative_start >= 0 { + (relative_start as usize, 0usize) + } else { + (0usize, (-relative_start) as usize) + }; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + if src_start >= decoded[channel].len() || dst_start >= target_samples { + continue; + } + let count = (decoded[channel].len() - src_start).min(target_samples - dst_start); + for offset in 0..count { + mixed[channel][dst_start + offset] += decoded[channel][src_start + offset]; + } + } + } + // Sommer plusieurs pistes peut dépasser la pleine échelle : on écrête. Sur une source + // mono-piste, sommer dans un buffer nul est l'identité et on n'écrête pas — le comportement + // d'avant ce mixage est conservé tel quel. + if tracks.len() > 1 { + for plane in mixed.iter_mut() { + for sample in plane.iter_mut() { + *sample = sample.clamp(-1.0, 1.0); + } + } + } + mixed +} + +fn hann(length: usize) -> Vec { + let mut window = vec![0.0; length]; + for (i, value) in window.iter_mut().enumerate() { + *value = 0.5 - 0.5 * ((2.0 * PI * i as f32) / (length - 1) as f32).cos(); + } + window +} + +/// Port direct du WSOLA web. Tous les canaux partagent les positions choisies sur un downmix +/// mono, sinon deux recherches indépendantes déplaceraient l'image stéréo. +pub struct WsolaTimeStretcher { + channels: usize, + passthrough: bool, + n: usize, + hs: usize, + ha: f64, + search_radius: i64, + window: Vec, + buf: PlanarPcm, + mono: Vec, + buf_start: i64, + out: PlanarPcm, + win_sum: Vec, + out_start: usize, + ideal_pos: f64, + grain_pos: i64, + frame: usize, + placed_any: bool, +} + +impl WsolaTimeStretcher { + pub fn new( + sample_rate: i32, + channels: usize, + speed: f64, + expected_output_samples: usize, + ) -> Self { + let channels = channels.max(1); + let passthrough = (speed - 1.0).abs() < PASSTHROUGH_EPSILON; + let mut n = (sample_rate as f64 * DEFAULT_FRAME_SEC).round() as usize; + n = n.max(4); + if n % 2 != 0 { + n += 1; + } + let mut hs = n / 2; + if expected_output_samples > 0 { + let min_hs = 2usize.max( + ((sample_rate as f64 * MIN_FRAME_SEC) / 2.0).round() as usize, + ); + let target_hs = expected_output_samples / TARGET_GRAINS; + hs = hs.min(min_hs.max(target_hs)); + } + let n = hs * 2; + let search_radius = ((sample_rate as f64 * DEFAULT_SEARCH_SEC).round() as usize) + .min(hs) as i64; + Self { + channels, + passthrough, + n, + hs, + ha: hs as f64 * speed, + search_radius, + window: hann(n), + buf: vec![Vec::new(); channels], + mono: Vec::new(), + buf_start: 0, + out: vec![Vec::new(); channels], + win_sum: Vec::new(), + out_start: 0, + ideal_pos: 0.0, + grain_pos: 0, + frame: 0, + placed_any: false, + } + } + + pub fn push(&mut self, planar: &[Vec]) -> PlanarPcm { + if self.passthrough { + return (0..self.channels) + .map(|channel| { + planar + .get(channel) + .or_else(|| planar.first()) + .cloned() + .unwrap_or_default() + }) + .collect(); + } + self.append(planar); + self.process(false) + } + + pub fn flush(&mut self) -> PlanarPcm { + if self.passthrough { + return self.empty_chunk(); + } + self.process(true) + } + + fn empty_chunk(&self) -> PlanarPcm { + vec![Vec::new(); self.channels] + } + + fn append(&mut self, planar: &[Vec]) { + let add_len = planar.first().map(|p| p.len()).unwrap_or(0); + if add_len == 0 { + return; + } + for channel in 0..self.channels { + if let Some(source) = planar.get(channel).or_else(|| planar.first()) { + self.buf[channel].extend_from_slice(&source[..add_len.min(source.len())]); + if source.len() < add_len { + let target_len = self.buf[channel].len() + add_len - source.len(); + self.buf[channel].resize(target_len, 0.0); + } + } + } + for i in 0..add_len { + let mut sum = 0.0f32; + for channel in 0..self.channels { + sum += planar + .get(channel) + .and_then(|p| p.get(i)) + .or_else(|| planar.first().and_then(|p| p.get(i))) + .copied() + .unwrap_or(0.0); + } + self.mono.push(sum / self.channels as f32); + } + } + + fn buf_end(&self) -> i64 { + self.buf_start + self.buf[0].len() as i64 + } + + fn sample_at(&self, channel: usize, absolute_index: i64) -> f32 { + let index = absolute_index - self.buf_start; + if index < 0 { + 0.0 + } else { + self.buf[channel].get(index as usize).copied().unwrap_or(0.0) + } + } + + fn mono_at(&self, absolute_index: i64) -> f32 { + let index = absolute_index - self.buf_start; + if index < 0 { + 0.0 + } else { + self.mono.get(index as usize).copied().unwrap_or(0.0) + } + } + + fn process(&mut self, final_chunk: bool) -> PlanarPcm { + let mut emitted = self.empty_chunk(); + loop { + let search_target = (self.ideal_pos + self.ha).round() as i64; + let required_end = (self.grain_pos + self.n as i64) + .max(self.grain_pos + self.hs as i64 + self.n as i64) + .max(search_target + self.search_radius + self.n as i64); + if !final_chunk && self.buf_end() < required_end { + break; + } + if self.grain_pos + self.n as i64 > self.buf_end() { + break; + } + + self.place_grain(self.grain_pos); + let placed_frame = self.frame; + let reference_start = self.grain_pos + self.hs as i64; + let best_delta = self.find_best_delta(reference_start, search_target); + self.grain_pos = search_target + best_delta; + self.ideal_pos += self.ha; + self.frame += 1; + + self.collect(placed_frame * self.hs, &mut emitted); + self.discard_below(self.grain_pos); + } + if final_chunk { + self.collect_all(&mut emitted); + } + emitted + } + + fn place_grain(&mut self, position: i64) { + let output_absolute = self.frame * self.hs; + self.ensure_out(output_absolute + self.n); + let base = output_absolute - self.out_start; + for channel in 0..self.channels { + for k in 0..self.n { + let sample = self.sample_at(channel, position + k as i64); + self.out[channel][base + k] += sample * self.window[k]; + } + } + for k in 0..self.n { + self.win_sum[base + k] += self.window[k]; + } + self.placed_any = true; + } + + fn find_best_delta(&self, reference_start: i64, target: i64) -> i64 { + if reference_start + self.n as i64 > self.buf_end() { + return 0; + } + let mut reference_energy = 0.0f32; + for k in 0..self.n { + let sample = self.mono_at(reference_start + k as i64); + reference_energy += sample * sample; + } + if reference_energy == 0.0 { + return 0; + } + + let mut best_delta = 0; + let mut best_score = f32::NEG_INFINITY; + let low = (-self.search_radius).max(self.buf_start - target); + let high = self + .search_radius + .min(self.buf_end() - self.n as i64 - target); + for delta in low..=high { + let candidate_start = target + delta; + let mut dot = 0.0f32; + let mut energy = 0.0f32; + for k in 0..self.n { + let candidate = self.mono_at(candidate_start + k as i64); + dot += candidate * self.mono_at(reference_start + k as i64); + energy += candidate * candidate; + } + let score = if energy > 0.0 { dot / energy.sqrt() } else { 0.0 }; + if score > best_score { + best_score = score; + best_delta = delta; + } + } + best_delta + } + + fn ensure_out(&mut self, absolute_end: usize) { + let needed = absolute_end - self.out_start; + if needed <= self.out[0].len() { + return; + } + let next_len = needed.max(self.out[0].len() * 2).max(self.n * 4); + for channel in 0..self.channels { + self.out[channel].resize(next_len, 0.0); + } + self.win_sum.resize(next_len, 0.0); + } + + fn collect(&mut self, absolute_end: usize, emitted: &mut PlanarPcm) { + let count = absolute_end.saturating_sub(self.out_start); + if count == 0 { + return; + } + for channel in 0..self.channels { + for i in 0..count { + let weight = self.win_sum[i]; + let mut sample = self.out[channel][i]; + if weight > 1e-6 { + sample /= weight; + } + emitted[channel].push(sample); + } + self.out[channel] = self.out[channel][count..].to_vec(); + } + self.win_sum = self.win_sum[count..].to_vec(); + self.out_start = absolute_end; + } + + fn collect_all(&mut self, emitted: &mut PlanarPcm) { + if !self.placed_any { + return; + } + let end = (self.frame - 1) * self.hs + self.n; + self.collect(end, emitted); + } + + fn discard_below(&mut self, absolute_index: i64) { + let drop_count = absolute_index - self.buf_start; + if drop_count <= 0 { + return; + } + let drop_count = drop_count as usize; + for channel in 0..self.channels { + self.buf[channel] = self.buf[channel][drop_count.min(self.buf[channel].len())..].to_vec(); + } + self.mono = self.mono[drop_count.min(self.mono.len())..].to_vec(); + self.buf_start = absolute_index; + } +} + +fn stretch_pcm_to_length(pcm: &[Vec], target_samples: usize) -> PlanarPcm { + if target_samples == 0 { + return vec![Vec::new(); AUDIO_OUTPUT_CHANNELS]; + } + let source_samples = pcm.first().map(|channel| channel.len()).unwrap_or(0); + if source_samples == 0 { + return vec![vec![0.0; target_samples]; AUDIO_OUTPUT_CHANNELS]; + } + if source_samples.abs_diff(target_samples) <= 1 { + let mut exact = vec![vec![0.0; target_samples]; AUDIO_OUTPUT_CHANNELS]; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + if let Some(source) = pcm.get(channel) { + let count = source.len().min(target_samples); + exact[channel][..count].copy_from_slice(&source[..count]); + } + } + return exact; + } + + let speed = source_samples as f64 / target_samples as f64; + let mut stretcher = WsolaTimeStretcher::new( + AUDIO_OUTPUT_SAMPLE_RATE, + AUDIO_OUTPUT_CHANNELS, + speed, + target_samples, + ); + let chunks = [stretcher.push(pcm), stretcher.flush()]; + let mut exact = vec![vec![0.0; target_samples]; AUDIO_OUTPUT_CHANNELS]; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + let mut written = 0usize; + for chunk in &chunks { + let source = &chunk[channel]; + let count = source.len().min(target_samples - written); + if count > 0 { + exact[channel][written..written + count].copy_from_slice(&source[..count]); + written += count; + } + if written == target_samples { + break; + } + } + } + exact +} + +/// Découpe le PCM gardé avec les mêmes spans et la même quantification frame que la vidéo. +pub fn stretch_clip_pcm_by_speed( + pcm: &[Vec], + speed_segments: &[SpeedSegment], + output_fps: f64, +) -> PlanarPcm { + let total_source_samples = pcm.first().map(|channel| channel.len()).unwrap_or(0); + let mut source_cursor = 0usize; + let mut chunks: Vec = Vec::with_capacity(speed_segments.len()); + for segment in speed_segments { + let input_samples = ((segment.end_sec - segment.start_sec) + * AUDIO_OUTPUT_SAMPLE_RATE as f64) + .round() + .max(0.0) as usize; + let input_start = source_cursor; + let input_end = (input_start + input_samples).min(total_source_samples); + source_cursor = input_start + input_samples; + let output_samples = ((segment.frame_count as f64 / output_fps) + * AUDIO_OUTPUT_SAMPLE_RATE as f64) + .round() + .max(0.0) as usize; + if input_end <= input_start { + chunks.push(vec![vec![0.0; output_samples]; AUDIO_OUTPUT_CHANNELS]); + continue; + } + let slice: PlanarPcm = (0..AUDIO_OUTPUT_CHANNELS) + .map(|channel| pcm[channel][input_start..input_end].to_vec()) + .collect(); + chunks.push(stretch_pcm_to_length(&slice, output_samples)); + } + + let mut output = vec![Vec::new(); AUDIO_OUTPUT_CHANNELS]; + for chunk in chunks { + for channel in 0..AUDIO_OUTPUT_CHANNELS { + output[channel].extend_from_slice(&chunk[channel]); + } + } + output +} + +#[derive(Clone, Copy)] +pub struct AudioConcatSegmentPlan { + pub start_sample: usize, + pub sample_count: usize, + pub silence: bool, +} + +pub struct AudioConcatPlan { + pub total_samples: usize, + pub segments: Vec, +} + +/// Les offsets sont la somme ENTIÈRE des longueurs arrondies clip par clip ; recalculer depuis +/// une durée cumulée ferait dériver les jonctions sur une longue timeline. +pub fn build_audio_concat_plan( + output_frame_counts: &[u64], + has_audio: &[bool], + output_fps: f64, +) -> AudioConcatPlan { + let mut cursor = 0usize; + let mut segments = Vec::with_capacity(output_frame_counts.len()); + for (index, &frame_count) in output_frame_counts.iter().enumerate() { + let sample_count = if output_fps > 0.0 { + ((frame_count as f64 / output_fps) * AUDIO_OUTPUT_SAMPLE_RATE as f64) + .round() + .max(0.0) as usize + } else { + 0 + }; + segments.push(AudioConcatSegmentPlan { + start_sample: cursor, + sample_count, + silence: !has_audio.get(index).copied().unwrap_or(false), + }); + cursor += sample_count; + } + AudioConcatPlan { total_samples: cursor, segments } +} + +pub fn assemble_concatenated_pcm( + clip_pcm: &[Option], + plan: &AudioConcatPlan, +) -> PlanarPcm { + let mut output = vec![vec![0.0f32; plan.total_samples]; AUDIO_OUTPUT_CHANNELS]; + for (index, segment) in plan.segments.iter().enumerate() { + if segment.sample_count == 0 || segment.silence { + continue; + } + let Some(Some(pcm)) = clip_pcm.get(index) else { + continue; + }; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + let Some(source) = pcm.get(channel) else { + continue; + }; + let count = segment.sample_count.min(source.len()); + output[channel][segment.start_sample..segment.start_sample + count] + .copy_from_slice(&source[..count]); + } + } + + for boundary in plan.segments.windows(2) { + let current = boundary[0]; + let next = boundary[1]; + let fade = AUDIO_BOUNDARY_FADE_SAMPLES + .min(current.sample_count / 2) + .min(next.sample_count / 2); + if fade == 0 { + continue; + } + let tail_start = current.start_sample + current.sample_count - fade; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + for k in 0..fade { + let phase = (k as f32 / fade as f32) * PI * 0.5; + output[channel][tail_start + k] *= phase.cos(); + output[channel][next.start_sample + k] *= phase.sin(); + } + } + } + output +} + +/// Encodeur AAC attaché au muxer avant son header. Les paquets utilisent le même interleaver +/// que la vidéo ; les pts restent en unités échantillon jusqu'au rescale vers l'AVStream. +pub(crate) struct AacEncoder { + context: *mut AVCodecContext, + stream: *mut AVStream, + packet: *mut AVPacket, +} + +impl AacEncoder { + pub(crate) unsafe fn open(output: *mut AVFormatContext) -> Result { + let name = CString::new("aac")?; + let codec = avcodec_find_encoder_by_name(name.as_ptr()); + if codec.is_null() { + bail!("encodeur aac introuvable"); + } + let context = avcodec_alloc_context3(codec); + if context.is_null() { + bail!("aac avcodec_alloc_context3"); + } + (*context).sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_FLTP; + (*context).sample_rate = AUDIO_OUTPUT_SAMPLE_RATE; + (*context).bit_rate = AUDIO_BITRATE; + (*context).time_base = AVRational { num: 1, den: AUDIO_OUTPUT_SAMPLE_RATE }; + av_channel_layout_default(&mut (*context).ch_layout, AUDIO_OUTPUT_CHANNELS as i32); + averr(avcodec_open2(context, codec, ptr::null_mut()), "aac avcodec_open2")?; + + let stream = avformat_new_stream(output, ptr::null()); + if stream.is_null() { + bail!("aac avformat_new_stream"); + } + averr( + avcodec_parameters_from_context((*stream).codecpar, context), + "aac parameters_from_context", + )?; + (*stream).time_base = (*context).time_base; + let packet = av_packet_alloc(); + if packet.is_null() { + bail!("aac av_packet_alloc"); + } + Ok(Self { context, stream, packet }) + } + + pub(crate) unsafe fn encode(&mut self, pcm: &[Vec], output: *mut AVFormatContext) -> Result<()> { + let total_samples = pcm.first().map(|channel| channel.len()).unwrap_or(0); + let frame_size = if (*self.context).frame_size > 0 { + (*self.context).frame_size as usize + } else { + 1024 + }; + let mut offset = 0usize; + while offset < total_samples { + let sample_count = frame_size.min(total_samples - offset); + let mut frame = av_frame_alloc(); + if frame.is_null() { + bail!("aac av_frame_alloc"); + } + (*frame).format = (*self.context).sample_fmt as i32; + (*frame).sample_rate = AUDIO_OUTPUT_SAMPLE_RATE; + (*frame).nb_samples = sample_count as i32; + averr( + av_channel_layout_copy(&mut (*frame).ch_layout, &(*self.context).ch_layout), + "aac channel_layout_copy", + )?; + averr(av_frame_get_buffer(frame, 0), "aac frame_get_buffer")?; + averr(av_frame_make_writable(frame), "aac frame_make_writable")?; + for channel in 0..AUDIO_OUTPUT_CHANNELS { + let destination = *(*frame).extended_data.add(channel) as *mut f32; + ptr::write_bytes(destination, 0, sample_count); + if let Some(source) = pcm.get(channel) { + let available = source.len().saturating_sub(offset).min(sample_count); + if available > 0 { + ptr::copy_nonoverlapping(source.as_ptr().add(offset), destination, available); + } + } + } + (*frame).pts = offset as i64; + averr(avcodec_send_frame(self.context, frame), "aac send_frame")?; + self.drain(output)?; + av_frame_free(&mut frame); + offset += sample_count; + } + averr(avcodec_send_frame(self.context, ptr::null()), "aac flush")?; + self.drain(output) + } + + unsafe fn drain(&mut self, output: *mut AVFormatContext) -> Result<()> { + loop { + let ret = avcodec_receive_packet(self.context, self.packet); + if ret == AVERROR_EAGAIN || ret == AVERROR_EOF { + return Ok(()); + } + averr(ret, "aac receive_packet")?; + (*self.packet).stream_index = (*self.stream).index; + av_packet_rescale_ts(self.packet, (*self.context).time_base, (*self.stream).time_base); + averr( + av_interleaved_write_frame(output, self.packet), + "aac interleaved_write_frame", + )?; + av_packet_unref(self.packet); + } + } +} + +impl Drop for AacEncoder { + fn drop(&mut self) { + unsafe { + av_packet_free(&mut self.packet); + avcodec_free_context(&mut self.context); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Même contenu sur les deux canaux : le mixage travaille canal par canal, donc asserter sur + /// un seul suffit, mais les deux plans doivent exister (le format de sortie est stéréo). + fn planar(samples: &[f32]) -> PlanarPcm { + vec![samples.to_vec(), samples.to_vec()] + } + + #[test] + fn single_track_passes_through_unchanged() { + let track = planar(&[0.25, -0.5, 0.75]); + let mixed = mix_aligned_tracks(&[(0.0, &track)], 0.0, 3); + assert_eq!(mixed[0], vec![0.25, -0.5, 0.75]); + assert_eq!(mixed[1], vec![0.25, -0.5, 0.75]); + } + + #[test] + fn single_track_is_not_clamped() { + // Promesse de non-régression : une source mono-piste ressort telle quelle, y compris + // hors pleine échelle. Seul le mixage multipiste écrête. + let track = planar(&[1.5, -1.5]); + let mixed = mix_aligned_tracks(&[(0.0, &track)], 0.0, 2); + assert_eq!(mixed[0], vec![1.5, -1.5]); + } + + #[test] + fn silent_first_track_does_not_swallow_the_microphone() { + // Le cas de l'issue #108 : l'enregistreur natif macOS écrit l'audio système en première + // piste (silencieuse si rien ne joue) et le micro en seconde. `av_find_best_stream` ne + // rendait que la première, et l'export sortait muet. + let system_audio = planar(&[0.0, 0.0, 0.0]); + let microphone = planar(&[0.3, -0.4, 0.5]); + let mixed = mix_aligned_tracks(&[(0.0, &system_audio), (0.0, µphone)], 0.0, 3); + assert_eq!(mixed[0], vec![0.3, -0.4, 0.5]); + } + + #[test] + fn tracks_are_summed() { + let a = planar(&[0.1, 0.2]); + let b = planar(&[0.2, 0.3]); + let mixed = mix_aligned_tracks(&[(0.0, &a), (0.0, &b)], 0.0, 2); + assert!((mixed[0][0] - 0.3).abs() < 1e-6); + assert!((mixed[0][1] - 0.5).abs() < 1e-6); + } + + #[test] + fn multi_track_sum_is_clamped_to_full_scale() { + let a = planar(&[0.8, -0.8]); + let b = planar(&[0.8, -0.8]); + let mixed = mix_aligned_tracks(&[(0.0, &a), (0.0, &b)], 0.0, 2); + assert_eq!(mixed[0], vec![1.0, -1.0]); + } + + #[test] + fn a_track_starting_late_is_zero_padded_at_the_front() { + // La piste commence 1 ms après le début de la fenêtre demandée : 48 échantillons de + // silence devant, son contenu ensuite. Sans ce recadrage, sommer désalignerait les pistes. + let late = planar(&[0.5; 8]); + let mixed = mix_aligned_tracks(&[(0.001, &late)], 0.0, 64); + assert_eq!(&mixed[0][..48], &[0.0f32; 48][..]); + assert_eq!(&mixed[0][48..56], &[0.5f32; 8][..]); + assert_eq!(&mixed[0][56..], &[0.0f32; 8][..]); + } + + #[test] + fn a_track_decoded_early_has_its_prefetch_trimmed() { + // Le seek audio retombe sur une trame antérieure à la fenêtre : cette prélecture est + // coupée, pas mixée. + let mut samples = vec![0.0f32; 48]; + samples.extend_from_slice(&[0.5; 8]); + let early = planar(&samples); + let mixed = mix_aligned_tracks(&[(-0.001, &early)], 0.0, 8); + assert_eq!(mixed[0], vec![0.5; 8]); + } +} diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs new file mode 100644 index 0000000000..d1641587cd --- /dev/null +++ b/crates/compositor/src/compositor.rs @@ -0,0 +1,3330 @@ +//! Compositeur D3D11 : rend les calques dans un render target RGBA8, un draw par quad. +//! NV12 échantillonné depuis les textures décodeur (SRV par plan), effets en HLSL (§7). + +use crate::config::Cfg; +use crate::cursor::CursorTrack; +use crate::scene::{Scene, SceneBackground, SceneCrop, SceneCursorSprite}; +use crate::d3d::Gpu; +use crate::ffi::AVFrame; +use anyhow::{bail, Result}; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::ffi::c_void; +use windows::core::{Interface, PCSTR}; +use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3}; +use windows::Win32::Graphics::Direct3D::{ + ID3DBlob, D3D11_SRV_DIMENSION_TEXTURE2DARRAY, D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, +}; +use windows::Win32::Graphics::Direct3D11::*; +use windows::Win32::Graphics::Dxgi::Common::*; + +pub const OUT_W: u32 = 1920; +pub const OUT_H: u32 = 1080; + +/// Parse une couleur "#rgb" / "#rrggbb" (sRGB, comme les wallpapers web) → [r,g,b,a] 0..1. +/// Les couleurs plates suivent le même chemin que `bg_color` (pas de linéarisation). +/// Décode une data URL base64 (`data:image/png;base64,AAAA…`) en octets. `None` si ce n'en est +/// pas une — l'appelant retombe alors sur une lecture disque. +/// +/// Écrit à la main plutôt qu'avec une dépendance : c'est le seul usage de base64 du projet, et le +/// décodeur tient en quinze lignes vérifiables. Les caractères hors alphabet (retours à la ligne +/// d'un URI replié, `=` de padding) sont ignorés, ce qui rend la fonction tolérante sans être +/// laxiste : un caractère invalide ne peut pas décaler le flux, il est simplement absent. +fn decode_data_uri(uri: &str) -> Option> { + let rest = uri.strip_prefix("data:")?; + let comma = rest.find(',')?; + if !rest[..comma].contains("base64") { + return None; + } + let payload = &rest[comma + 1..]; + let sextet = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some((c - b'A') as u32), + b'a'..=b'z' => Some((c - b'a') as u32 + 26), + b'0'..=b'9' => Some((c - b'0') as u32 + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + }; + let mut out = Vec::with_capacity(payload.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0u32); + for byte in payload.bytes() { + let Some(v) = sextet(byte) else { continue }; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + } + } + Some(out) +} + +fn parse_hex(s: &str) -> Option<[f32; 4]> { + let h = s.trim().trim_start_matches('#'); + let (r, g, b) = match h.len() { + 3 => { + let d = |i: usize| u8::from_str_radix(&h[i..=i], 16).ok().map(|v| v * 17); + (d(0)?, d(1)?, d(2)?) + } + 6 => ( + u8::from_str_radix(&h[0..2], 16).ok()?, + u8::from_str_radix(&h[2..4], 16).ok()?, + u8::from_str_radix(&h[4..6], 16).ok()?, + ), + _ => return None, + }; + Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) +} + +/// Rect source après crop puis zoom, dans les UV de la texture D3D. `u_max`/`v_max` +/// excluent le padding NV12 ; le crop reste donc exprimé dans le frame visible (0..1), +/// comme `VirtualPreview.cropVideoStyle`, puis le focus du zoom est remappé dans ce crop. +fn screen_source_rect( + u_max: f32, + v_max: f32, + crop: Option, + zoom: f32, + focus: [f32; 2], +) -> [f32; 4] { + let normalized_crop = crop.and_then(|crop| { + if !crop.x.is_finite() || !crop.y.is_finite() + || !crop.width.is_finite() || !crop.height.is_finite() + { + return None; + } + let x0 = crop.x.clamp(0.0, 1.0); + let y0 = crop.y.clamp(0.0, 1.0); + let x1 = (crop.x + crop.width).clamp(x0, 1.0); + let y1 = (crop.y + crop.height).clamp(y0, 1.0); + (x1 > x0 && y1 > y0).then_some([x0, y0, x1, y1]) + }); + let [x0, y0, x1, y1] = normalized_crop.unwrap_or([0.0, 0.0, 1.0, 1.0]); + let (cu0, cv0, cu1, cv1) = (x0 * u_max, y0 * v_max, x1 * u_max, y1 * v_max); + let (cw, ch) = (cu1 - cu0, cv1 - cv0); + let zoom = if zoom.is_finite() && zoom >= 1.0 { zoom } else { 1.0 }; + let fx = if focus[0].is_finite() { focus[0].clamp(0.0, 1.0) } else { 0.5 }; + let fy = if focus[1].is_finite() { focus[1].clamp(0.0, 1.0) } else { 0.5 }; + let (hu, hv) = (cw / (2.0 * zoom), ch / (2.0 * zoom)); + // `.max(cu0/cv0)` absorbs the tiny float inversion possible at zoom=1. + let su0 = (cu0 + fx * cw - hu).clamp(cu0, (cu1 - 2.0 * hu).max(cu0)); + let sv0 = (cv0 + fy * ch - hv).clamp(cv0, (cv1 - 2.0 * hv).max(cv0)); + [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv] +} + +/// Sous-rect SOURCE (en UV de texture) qui remplit une boîte de ratio `box_ar` **sans +/// déformer** l'image : le plus grand rect centré ayant ce ratio, tiré de la frame +/// visible — l'équivalent de `object-fit: cover` côté web. +/// +/// C'est LA primitive qui garantit qu'une couche vidéo n'est jamais étirée. Le +/// contrat est déplacé de l'appelant (« donne-moi un dst au ratio de la source », +/// hypothèse qu'un preset pouvait violer en silence) vers le calcul lui-même +/// (« quel que soit le dst, je choisis la coupe qui l'habille »). +/// +/// * `visible` : dimensions RÉELLES de l'image dans la texture (`AVFrame::width/height`) ; +/// elles peuvent être plus petites que la texture, qui est allouée avec du padding +/// décodeur — d'où la division finale par `tex`. +/// * `tex` : dimensions de la texture, pour normaliser en UV. +/// * `box_ar` : ratio largeur/hauteur de la boîte de destination, en pixels de rendu. +/// +/// Retourne `(u0, v0, u1, v1)`. Quand la boîte a déjà le ratio de la source, la coupe +/// est la frame entière — donc aucun changement de pixel sur les placements qui étaient +/// déjà corrects. +fn cover_crop_uv(visible: [f32; 2], tex: [f32; 2], box_ar: f32) -> (f32, f32, f32, f32) { + let (cam_w, cam_h) = (visible[0].max(1.0), visible[1].max(1.0)); + let (tex_w, tex_h) = (tex[0].max(1.0), tex[1].max(1.0)); + let full = [0.0, 0.0, cam_w / tex_w, cam_h / tex_h]; + let [u0, v0, u1, v1] = cover_uv_rect(full, tex, box_ar); + (u0, v0, u1, v1) +} + +/// Rétrécit un rect SOURCE déjà exprimé en UV (`[u0, v0, u1, v1]`) autour de son +/// centre pour qu'il porte le ratio `box_ar` une fois rapporté aux pixels de la +/// texture. C'est la forme générale de `object-fit: cover`, et LA primitive qui +/// garantit qu'une couche vidéo n'est jamais étirée. +/// +/// Deux appelants, deux points d'entrée dans le rect : +/// - la **webcam** part de la frame visible entière (`cover_crop_uv`) ; +/// - l'**écran** part du rect déjà réduit par le crop utilisateur ET le zoom, +/// et n'applique ce cover que dans les layouts qui le demandent +/// (`Scene.layout.screen_cover` — les blocs side-by-side / top-bottom, où le +/// web fait exactement la même chose via `screenCover`). +/// +/// Rogner APRÈS le crop et le zoom est ce qui rend l'opération composable : le +/// crop décide quoi montrer, le zoom où regarder, le cover comment habiller la +/// boîte. Chacun réduit le rect précédent, jamais ne le déforme. +/// +/// Quand le rect a déjà le ratio de la boîte, il est renvoyé inchangé — donc +/// aucun placement déjà correct ne bouge. +fn cover_uv_rect(uv: [f32; 4], tex: [f32; 2], box_ar: f32) -> [f32; 4] { + let (tex_w, tex_h) = (tex[0].max(1.0), tex[1].max(1.0)); + let (w_uv, h_uv) = ((uv[2] - uv[0]).max(1e-6), (uv[3] - uv[1]).max(1e-6)); + // ratio du rect courant, en PIXELS (les UV sont anisotropes dès que la + // texture n'est pas carrée — d'où le passage par `tex`). + let (w_px, h_px) = (w_uv * tex_w, h_uv * tex_h); + let cur_ar = w_px / h_px; + let box_ar = if box_ar.is_finite() && box_ar > 0.0 { box_ar } else { cur_ar }; + let (new_w_px, new_h_px) = if box_ar >= cur_ar { + (w_px, w_px / box_ar) // boîte plus large → pleine largeur, on rogne en hauteur + } else { + (h_px * box_ar, h_px) // boîte plus haute → pleine hauteur, on rogne en largeur + }; + let (new_w, new_h) = (new_w_px / tex_w, new_h_px / tex_h); + let (cx, cy) = (uv[0] + w_uv * 0.5, uv[1] + h_uv * 0.5); + [cx - new_w * 0.5, cy - new_h * 0.5, cx + new_w * 0.5, cy + new_h * 0.5] +} + +/// Constant buffer d'un calque (doit matcher `cbuffer Layer` du HLSL, 64 octets). +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct LayerCB { + pub dst: [f32; 4], + pub src: [f32; 4], + pub quad_px: [f32; 2], + pub radius_px: f32, + pub mode: f32, + pub color: [f32; 4], + pub fx: [f32; 4], + pub src_prev: [f32; 4], + pub dst_prev: [f32; 4], + pub mb: [f32; 4], // mb[0] = nombre de taps de motion blur +} + +/// Valeurs continues pilotées par l'inspector (celles qui étaient codées en dur dans +/// `compose_frame`). Le défaut reproduit le rendu actuel → bench/export inchangés. +/// Les booléens/taps (fond flouté, ombre on/off, coins on/off, motion blur) restent +/// portés par le `Cfg` que le thread live reconstruit depuis les switches. +#[derive(Clone, Copy)] +pub struct LiveParams { + pub bg_color: [f32; 4], // fond plat (mode couleur) quand non flouté + pub shadow_scale: f32, // multiplie l'opacité des ombres (1 = défaut, 0 = off) + pub radius_scale: f32, // multiplie le rayon des coins (1 = défaut, 0 = carré) + pub padding: f32, // 0..1 : inset supplémentaire du screen (0 = défaut fixture) + pub webcam_size_scale: f32, // multiplie la taille de la webcam (1 = défaut) + pub webcam_mirror: bool, // miroir horizontal de la webcam + pub webcam_shape: u32, // 0=rect, 1=circle, 2=square, 3=rounded (défaut) + pub cursor_size_scale: f32, // multiplie la taille du curseur (1 = défaut) + pub cursor_bounce_scale: f32, // multiplie l'amplitude du click-bounce (1 = défaut, 0 = off) + /// 0..1 : flou de mouvement DU CURSEUR (indépendant du motion blur écran/`cfg.mblur_n`). + /// Approximé par le même mécanisme de traînée fantôme (taps décalés le long de la + /// vélocité), pas par un flou gaussien variable comme le canvas web — plus simple à + /// réutiliser côté GPU, effet de streak équivalent. + pub cursor_motion_blur: f32, + /// False when the "webcam" decoder is actually just the screen video again (the TS side + /// falls `webcamPath` back to the screen asset's own path when a clip has no real camera, + /// purely so the decoder pipeline has something valid to open) — drawing the PiP box in + /// that case duplicates the screen video into its own corner. Live-only: derived in + /// `live.rs` by comparing the active clip's screen/webcam paths; defaults `true` (draw) + /// so fixture/bench renders and any caller that never sets it keep their old behavior. + pub has_webcam: bool, +} + +impl Default for LiveParams { + fn default() -> Self { + Self { + bg_color: [0.10, 0.11, 0.14, 1.0], + shadow_scale: 1.0, + radius_scale: 1.0, + padding: 0.0, + webcam_size_scale: 1.0, + webcam_mirror: false, + webcam_shape: 3, + cursor_size_scale: 1.0, + cursor_bounce_scale: 1.0, + cursor_motion_blur: 0.0, + has_webcam: true, + } + } +} + +/// "rectangle"|"circle"|"square"|"rounded" -> code webcam_shape (0/1/2/3). Partagé entre le +/// live (`live.rs::set_param_str`) et l'export (construit `LiveParams` depuis la scène) — une +/// seule table de vérité pour ce mapping. +pub fn webcam_shape_code(shape: &str) -> u32 { + match shape { + "rectangle" => 0, + "circle" => 1, + "square" => 2, + _ => 3, // "rounded" (défaut) + } +} + +/// Construit les `LiveParams` équivalents à ce que l'inspector pousse en live, mais depuis la +/// scène de l'app — l'export est un rendu one-shot sans historique de sliders, donc il doit lire +/// directement la config déjà posée dans la scène plutôt que dupliquer un mécanisme d'inspector. +/// Unités identiques à `RightPanes.tsx` (mêmes conversions, pas de re-normalisation) : voir +/// `sceneDescription.ts` pour la correspondance settings -> champs de scène. +pub fn live_params_from_scene(s: &crate::scene::Scene) -> LiveParams { + LiveParams { + shadow_scale: s.effects.shadow, + // `radius_scale` reste le multiplicateur du chemin INSPECTOR (bench/GUI standalone) ; le + // rayon écran d'une scène vient désormais de `effects.roundness_frac`, lu directement + // dans `compose_frame`. Le faire transiter ici obligeait à le normaliser par un rayon de + // fixture (`p.screen.radius`, 24 px) pour ressortir la valeur de départ — un aller-retour + // qui ne servait qu'à faire passer des pixels pour un ratio. + padding: s.effects.padding, + webcam_size_scale: s.layout.webcam_size, + webcam_mirror: s.layout.webcam_mirror, + webcam_shape: webcam_shape_code(&s.layout.webcam_shape), + cursor_size_scale: s.cursor.size, + cursor_bounce_scale: s.cursor.click_bounce, + cursor_motion_blur: s.cursor.motion_blur, + ..LiveParams::default() + } +} + +pub struct Compositor { + dev: ID3D11Device, + ctx: ID3D11DeviceContext, + rt: ID3D11Texture2D, + rtv: ID3D11RenderTargetView, + rt_srv: ID3D11ShaderResourceView, + staging: ID3D11Texture2D, + vs: ID3D11VertexShader, + ps: ID3D11PixelShader, + vs_fs: ID3D11VertexShader, + ps_y: ID3D11PixelShader, + ps_uv: ID3D11PixelShader, + sampler: ID3D11SamplerState, + cbuf: ID3D11Buffer, + blend: ID3D11BlendState, + blend_none: ID3D11BlendState, + nv12: ID3D11Texture2D, // notre NV12 simple (RT), source de la copie vers le pool encodeur + rtv_y: ID3D11RenderTargetView, + rtv_uv: ID3D11RenderTargetView, + // ping-pong demi-résolution pour le flou séparable (§7 E3) + ps_blur: ID3D11PixelShader, + ps_tex: ID3D11PixelShader, + half_a_rtv: ID3D11RenderTargetView, + half_a_srv: ID3D11ShaderResourceView, + half_b_rtv: ID3D11RenderTargetView, + half_b_srv: ID3D11ShaderResourceView, + // dual-Kawase : chaîne quart (480x270) + huitième (240x135) + ps_kdown: ID3D11PixelShader, + ps_kup: ID3D11PixelShader, + q_rtv: ID3D11RenderTargetView, + q_srv: ID3D11ShaderResourceView, + e_rtv: ID3D11RenderTargetView, + e_srv: ID3D11ShaderResourceView, + /// Copie pleine réso de l'image composée, pour les annotations de type flou : on ne peut pas + /// échantillonner le render target sur lequel on dessine, donc on le recopie ici d'abord. + /// Distincte de `accum` à dessein — `accum` sert au motion blur, qui appelle `compose_frame` + /// plusieurs fois par frame et dont l'accumulation serait écrasée. + ann_copy: ID3D11Texture2D, + ann_copy_srv: ID3D11ShaderResourceView, + // accumulateur pour le flou de mouvement (supersampling temporel) + accum: ID3D11Texture2D, + accum_rtv: ID3D11RenderTargetView, + accum_srv: ID3D11ShaderResourceView, + blend_add: ID3D11BlendState, + /// RefCell (pas un simple champ) pour que `set_cursor` reste `&self`, comme `set_scene` / + /// `set_live_params` — nécessaire pour le rebrancher par clip dans l'export multiclip, qui + /// n'a qu'une référence partagée au `Compositor`. + cursor: RefCell>, + /// Override du temps d'échantillonnage curseur (secondes) — `None` = comportement fixture + /// (`frame / FPS`). L'export multiclip et le live le positionnent au PTS écran courant, + /// c'est-à-dire au temps source absolu du clip actif. + cursor_t_override: RefCell>, + /// Override du temps des zoom/full-camera regions (secondes source du clip actif). Le nom + /// `timeline_t_override` est conservé pour l'API existante, mais ce temps n'est plus cumulé + /// entre clips : les régions projetées par l'app portent elles aussi des temps source. + /// Séparé de l'override curseur pour préserver les chemins fixture sans télémétrie. + timeline_t_override: RefCell>, + // cache des SRV décodeur par (texture array, slice) : le pool réutilise ~32 textures, + // donc après warmup plus aucune création de SRV par frame (overhead CPU supprimé). + srv_cache: RefCell>, + live_params: RefCell, + /// Scène pilotée par l'app (contrat) : quand présente, remplace le layout fixture de + /// `timeline()`. Voir `scene.rs` / `SceneDescription` (TS). + scene: RefCell>, + /// Rastériseur de texte (Direct2D/DirectWrite). `Option` parce qu'un échec d'init des + /// fabriques ne doit pas empêcher tout le compositeur de tourner : sans lui, les annotations + /// texte sont simplement absentes, comme avant. + text_raster: Option, + /// Cache des textures de texte, indexé sur l'ID d'annotation. La `u64` est la clé de contenu + /// (`TextSpec::cache_key`) : on ne re-rastérise que si elle change, donc jamais pour un + /// déplacement ou une animation. + text_cache: RefCell>, + /// Cache des textures d'annotation image, indexé sur l'ID d'annotation (SRV, w, h, longueur + /// de la source). Séparé de `img_cache` : les wallpapers sont des chemins disque, ces images + /// des data URL de plusieurs Mo qu'on ne veut pas utiliser comme clés de hachage. + ann_img_cache: RefCell>, + /// Cache des textures wallpaper image (clé = chemin absolu) : décodage/upload une seule + /// fois, puis réutilisées par frame. (SRV, largeur, hauteur). + img_cache: RefCell>, + /// Dimensions du RENDER TARGET en pixels — la taille à laquelle `compose_frame` + /// rastérise réellement, et donc le dénominateur de TOUTE conversion + /// normalisé↔px de ce fichier. + /// + /// Historiquement c'était la constante `OUT_W`×`OUT_H` : un canvas 16:9 figé, + /// étiré en fin de pipeline vers la vraie sortie. Cette constante produisait + /// deux défauts distincts, tous deux issus d'elle seule : + /// - une **forme** fausse dès que la sortie n'est pas 16:9 → rattrapée en + /// aval par `apply_undistort` (9 correctifs successifs sur l'écran, la + /// webcam, le curseur, les ombres, les coins, le crop, le fond) ; + /// - une **résolution** plafonnée → jamais rattrapée, parce qu'aucun + /// correctif au niveau du calque ne peut recréer des pixels qui n'ont pas + /// été rastérisés (un export 4K était du 1080p agrandi). + /// + /// Rendre cette taille variable retire la cause commune. `OUT_W`/`OUT_H` ne + /// sont plus qu'une valeur par défaut, jamais une référence géométrique. + render_size: Cell<(u32, u32)>, + /// Ressources de resize export (allouées paresseusement à la 1re taille de sortie ≠ + /// OUT_W×OUT_H — le live et les exports "Source"/1080p restent sur `rgb_to_nv12` inchangé, + /// zéro coût). Voir `rgb_to_nv12_scaled`. + resize_target: RefCell>, + /// Cache de la staging texture de readback live, dimensionnée à la dernière taille + /// de prévisualisation demandée (variable, contrairement au `staging` fixe à + /// OUT_W×OUT_H). Recréée quand la taille change — voir `readback_resized`. + live_readback_staging: RefCell>, + /// Staging NV12 du readback d'ENCODAGE (backend CPU) — même motif de cache par taille + /// que `live_readback_staging`, mais en NV12 et non en RGBA : l'encodeur logiciel veut + /// les plans Y/UV, pas des pixels RGBA. Voir `read_nv12_scaled`. + nv12_readback_staging: RefCell>, +} + +/// Ressources d'un resize export à une taille cible : RGBA intermédiaire (résultat du +/// redimensionnement bilinéaire du RT composé, toujours rendu en interne à OUT_W×OUT_H) + +/// sa propre texture NV12 à cette même taille cible (le NV12 principal du `Compositor` reste +/// fixé à OUT_W×OUT_H, partagé par le live). +struct ResizeTarget { + w: u32, + h: u32, + rgba_rtv: ID3D11RenderTargetView, + rgba_srv: ID3D11ShaderResourceView, + nv12: ID3D11Texture2D, + nv12_rtv_y: ID3D11RenderTargetView, + nv12_rtv_uv: ID3D11RenderTargetView, +} + +pub const HALF_W: u32 = OUT_W / 2; +pub const HALF_H: u32 = OUT_H / 2; + +pub const FIXTURE_FRAMES: u32 = 360; +const FPS: f32 = 60.0; + +/// Longueurs de style exprimées en FRACTION du petit côté du cadre, et non en pixels. +/// +/// Elles étaient écrites en px bruts au point d'appel, ce qui voulait dire « px du render +/// target » — donc une proportion DIFFÉRENTE selon la taille de rendu : 40 px, c'est 3,7 % d'un +/// cadre 1080 mais 1,9 % d'un 2160. L'ombre était donc deux fois plus douce en preview qu'à +/// l'export, et un export 4K la recevait deux fois plus faible qu'un 1080p — même famille de bug +/// que les rayons venus de l'app, mais née à l'intérieur du natif. Les valeurs ci-dessous sont +/// les anciennes constantes rapportées au cadre 1080 contre lequel elles avaient été réglées : +/// le rendu à cette résolution est donc inchangé, et devient enfin identique partout ailleurs. +const SHADOW_TUNING_REF_PX: f32 = 1080.0; +const SCREEN_SHADOW_SPREAD_FRAC: f32 = 40.0 / SHADOW_TUNING_REF_PX; +const SCREEN_SHADOW_OFFSET_FRAC: f32 = 16.0 / SHADOW_TUNING_REF_PX; +const WEBCAM_SHADOW_SPREAD_FRAC: f32 = 32.0 / SHADOW_TUNING_REF_PX; +const WEBCAM_SHADOW_OFFSET_FRAC: f32 = 12.0 / SHADOW_TUNING_REF_PX; +/// Opacité FIXE de l'ombre portée de la caméra (layout PiP uniquement). Contrairement à +/// l'ombre de l'écran — dont l'opacité est pilotée par le slider Shadow (`shadow_scale`) — +/// l'ombre de la caméra est une ombre légère NON paramétrable : même valeur quelle que soit +/// la position du slider. Parité avec le preset PiP côté web (`compositeLayout.ts`, +/// `rgba(0,0,0,0.35)`), dont l'ombre est elle aussi un forfait fixe et PiP-only. +const WEBCAM_SHADOW_OPACITY: f32 = 0.35; +/// Taille de base du curseur, même convention (34 px réglés contre un cadre 1080). +const CURSOR_BASE_SIZE_FRAC: f32 = 34.0 / SHADOW_TUNING_REF_PX; + +/// Rect [x,y,w,h] normalisé d'un sprite de curseur de taille `w`×`h` dont le pivot `hotspot` +/// (fraction 0..1 de l'image) doit tomber exactement sur `center`. +/// +/// L'invariant est que `center` reste sur le pixel désigné QUELLE QUE SOIT la taille : le +/// décalage grandit avec le sprite, donc il doit être une fraction de `w`/`h` et pas une +/// constante. Un pivot centré en dur (0.5) laissait la pointe dériver de plus en plus loin de +/// la zone visée à mesure qu'on agrandissait le curseur. +fn cursor_sprite_dst(center: [f32; 2], w: f32, h: f32, hotspot: [f32; 2]) -> [f32; 4] { + [center[0] - w * hotspot[0], center[1] - h * hotspot[1], w, h] +} + +/// Où poser le curseur, et dans quel repère. +/// +/// Le curseur remplace un pointeur qui faisait partie de l'image capturée, donc il vit SUR la +/// surface de l'écran, pas dans un calque au-dessus. Quand cet écran est incliné en 3D, ce n'est +/// donc pas seulement sa position qu'il faut projeter mais son sprite entier : autrement il se +/// lit comme un autocollant plat posé sur une scène en perspective. +#[derive(Clone, Copy)] +enum CursorPlacement { + /// Écran droit : centre en coordonnées sortie 0..1. + Upright { center: [f32; 2] }, + /// Écran incliné : position 0..1 DANS le plan, plus de quoi projeter les coins du sprite. + Tilted { + /// Position du pivot dans le plan (0..1 depuis son coin haut-gauche). + plane_pt: [f32; 2], + quad: crate::regions::TiltedQuad, + /// Centre du plan en px sortie — `quad.corners` y est relatif. + center_px: [f32; 2], + /// Taille du rect d'écran NON incliné en px : l'unité dans laquelle la taille du + /// curseur est exprimée, et donc ce qui la convertit en fraction du plan. + screen_px: [f32; 2], + /// Taille de la cible de rendu en px, pour repasser des px aux 0..1 de la sortie. + render_px: [f32; 2], + }, +} + +impl CursorPlacement { + /// Interpolation entre deux placements, pour les copies de la traînée de flou. Sur un plan + /// incliné on interpole DANS le plan : la traînée suit alors la surface au lieu de couper + /// droit à travers la perspective. + fn lerp(self, other: CursorPlacement, f: f32) -> CursorPlacement { + match (self, other) { + ( + CursorPlacement::Tilted { plane_pt: a, quad, center_px, screen_px, render_px }, + CursorPlacement::Tilted { plane_pt: b, .. }, + ) => CursorPlacement::Tilted { + plane_pt: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f], + quad, + center_px, + screen_px, + render_px, + }, + (a, b) => { + let (p, q) = (a.upright_center(), b.upright_center()); + CursorPlacement::Upright { + center: [p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f], + } + } + } + } + + /// Le centre en coordonnées sortie, quel que soit le repère — ce dont ont besoin le curseur + /// math de secours et le calcul de vélocité. + fn upright_center(self) -> [f32; 2] { + match self { + CursorPlacement::Upright { center } => center, + CursorPlacement::Tilted { plane_pt, quad, center_px, render_px, .. } => { + let (px, py) = quad.point_px(plane_pt[0], plane_pt[1]); + [(center_px[0] + px) / render_px[0], (center_px[1] + py) / render_px[1]] + } + } + } +} + +fn ease_in_out_cubic(x: f32) -> f32 { + let x = x.clamp(0.0, 1.0); + if x < 0.5 { + 4.0 * x * x * x + } else { + 1.0 - (-2.0 * x + 2.0).powi(3) / 2.0 + } +} +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} +fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] { + [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t), lerp(a[3], b[3], t)] +} + +/// Un calque vidéo animé (rect sortie, taille px, rayon) — screen ou webcam. +#[derive(Clone, Copy)] +struct Placement { + dst: [f32; 4], + radius: f32, +} + +/// Paramètres d'une frame : dérivés du temps par la timeline (§8). +#[derive(Clone, Copy)] +struct FrameParams { + zoom: f32, + focus: [f32; 2], + screen: Placement, + webcam: Placement, // dst carré (w en px via OUT_W) +} + +/// Timeline figée de la fixture (6 s) : zoom 1.0→1.8→1.0, layout A(PIP)↔B(côte à côte). +/// `frame` fractionnaire pour permettre le supersampling temporel (flou de mouvement). +/// Gaté par `cfg` : zoom et layout ne bougent que si activés. +fn timeline(frame: f32, cfg: &Cfg) -> FrameParams { + let t = frame / FPS; // secondes + + // zoom : montée [0,3s] puis descente [3s,6s], easeInOutCubic + let zoom = if cfg.zoom { + let zt = if t < 3.0 { ease_in_out_cubic(t / 3.0) } else { ease_in_out_cubic((6.0 - t) / 3.0) }; + 1.0 + 0.8 * zt + } else { + 1.0 + }; + + // layout A = PIP bas-droite ; B = côte à côte. Transitions A→B [2,2.5]s, B→A [4,4.5]s. + let lf = if !cfg.layout_anim { + 0.0 + } else if t < 2.0 { + 0.0 + } else if t < 2.5 { + ease_in_out_cubic((t - 2.0) / 0.5) + } else if t < 4.0 { + 1.0 + } else if t < 4.5 { + 1.0 - ease_in_out_cubic((t - 4.0) / 0.5) + } else { + 0.0 + }; + + // Layout A (PIP) + let a_screen = Placement { dst: [0.05, 0.05, 0.90, 0.90], radius: 24.0 }; + let a_side = 320.0_f32; + let a_webcam = Placement { + dst: [ + (OUT_W as f32 - 40.0 - a_side) / OUT_W as f32, + (OUT_H as f32 - 40.0 - a_side) / OUT_H as f32, + a_side / OUT_W as f32, + a_side / OUT_H as f32, + ], + radius: 40.0, + }; + // Layout B (côte à côte) : screen à gauche (16:9), webcam carré à droite + let b_screen = Placement { dst: [0.035, 0.22, 0.60, 0.5625], radius: 20.0 }; + let b_side = 520.0_f32; + let b_webcam = Placement { + dst: [ + 0.70, + (OUT_H as f32 - b_side) * 0.5 / OUT_H as f32, + b_side / OUT_W as f32, + b_side / OUT_H as f32, + ], + radius: 40.0, + }; + + FrameParams { + zoom, + focus: [0.5, 0.32], + screen: Placement { dst: lerp4(a_screen.dst, b_screen.dst, lf), radius: lerp(a_screen.radius, b_screen.radius, lf) }, + webcam: Placement { dst: lerp4(a_webcam.dst, b_webcam.dst, lf), radius: lerp(a_webcam.radius, b_webcam.radius, lf) }, + } +} + +/// Placements statiques screen+webcam pour un preset de layout de l'app (contrat de scène) — +/// remplace le planning A↔B fixture de `timeline()`. Zoom = 1 (les zoom regions viennent ensuite). +/// La taille/forme/miroir webcam restent appliqués par-dessus via `LiveParams`. +fn preset_placements(preset: &str) -> FrameParams { + // plein cadre : le padding l'insère ensuite (padding 0 → bord à bord). + let full_screen = Placement { dst: [0.0, 0.0, 1.0, 1.0], radius: 24.0 }; + // PiP bas-droite (≈ layout A fixture). + let a_side = 320.0_f32; + let pip_webcam = Placement { + dst: [ + (OUT_W as f32 - 40.0 - a_side) / OUT_W as f32, + (OUT_H as f32 - 40.0 - a_side) / OUT_H as f32, + a_side / OUT_W as f32, + a_side / OUT_H as f32, + ], + radius: 40.0, + }; + // webcam hors écran (no-webcam) : quad de taille nulle, jamais visible. + let off_webcam = Placement { dst: [2.0, 2.0, 0.0, 0.0], radius: 0.0 }; + + let (screen, webcam) = match preset { + "dual-frame" => { + // côte à côte : screen 16:9 à gauche, webcam carré à droite (≈ layout B fixture). + let b_side = 520.0_f32; + ( + Placement { dst: [0.035, 0.22, 0.60, 0.5625], radius: 20.0 }, + Placement { + dst: [ + 0.70, + (OUT_H as f32 - b_side) * 0.5 / OUT_H as f32, + b_side / OUT_W as f32, + b_side / OUT_H as f32, + ], + radius: 40.0, + }, + ) + } + "vertical-stack" => { + // haut/bas : screen en haut, webcam carré centré en bas. + let w_side = 360.0_f32; + ( + Placement { dst: [0.13, 0.04, 0.74, 0.52], radius: 20.0 }, + Placement { + dst: [ + 0.5 - (w_side * 0.5) / OUT_W as f32, + 0.60, + w_side / OUT_W as f32, + w_side / OUT_H as f32, + ], + radius: 40.0, + }, + ) + } + "no-webcam" => (full_screen, off_webcam), + _ => (full_screen, pip_webcam), // "picture-in-picture" (défaut) + }; + + FrameParams { zoom: 1.0, focus: [0.5, 0.5], screen, webcam } +} + +unsafe fn compile(src: &[u8], entry: &[u8], target: &[u8]) -> Result { + let mut code: Option = None; + let mut err: Option = None; + let r = D3DCompile( + src.as_ptr() as *const c_void, + src.len(), + PCSTR::null(), + None, + None, + PCSTR(entry.as_ptr()), + PCSTR(target.as_ptr()), + D3DCOMPILE_OPTIMIZATION_LEVEL3, + 0, + &mut code, + Some(&mut err), + ); + if r.is_err() { + if let Some(e) = err { + let msg = std::slice::from_raw_parts( + e.GetBufferPointer() as *const u8, + e.GetBufferSize(), + ); + bail!("D3DCompile {}: {}", String::from_utf8_lossy(entry), String::from_utf8_lossy(msg)); + } + bail!("D3DCompile a échoué"); + } + Ok(code.unwrap()) +} + +impl Compositor { + /// Compositeur à la taille de rendu par défaut (`OUT_W`×`OUT_H`). + /// Préférer `new_sized` dès qu'on connaît la géométrie de sortie réelle. + pub fn new(gpu: &Gpu) -> Result { + Self::new_sized(gpu, OUT_W, OUT_H) + } + + /// Compositeur rastérisant à `w`×`h`. + /// + /// La taille de rendu est fixée à la construction plutôt que mutable à chaud : + /// la rendre variable imposerait de passer le RT, la NV12, la staging et toute + /// la pyramide de flou en `RefCell`, donc d'ajouter de la mutabilité intérieure + /// sur le chemin GPU chaud — pour un événement qui n'arrive quasiment jamais + /// (l'utilisateur change de ratio, ou on bascule preview↔export). L'appelant + /// reconstruit le compositeur quand la sortie change ; c'est quelques dizaines + /// de ms, sur un changement rare. + /// + /// Les dimensions passées sont arrondies via `normalize_render_size` (pair, + /// ≥2 — contrainte NV12). L'appelant qui décide de reconstruire DOIT comparer + /// sa taille voulue à `normalize_render_size(...)` et non à la valeur brute : + /// sinon une cible impaire ne serait jamais atteinte par `render_size()` (qui + /// renvoie la valeur arrondie), et le compositeur se reconstruirait à chaque + /// frame. C'est justement pour rendre cette règle partageable qu'elle est une + /// fonction publique et non un calcul enfoui ici. + pub fn new_sized(gpu: &Gpu, w: u32, h: u32) -> Result { + let (w, h) = Self::normalize_render_size(w, h); + unsafe { Self::new_inner(gpu, w, h) } + } + + /// Arrondit une taille de rendu voulue à ce qu'un render target peut réellement + /// être : au pair supérieur (la texture NV12 est en 4:2:0, chroma + /// sous-échantillonnée 2×2, et `CreateTexture2D` refuse une dimension impaire), + /// jamais sous 2. UNE seule définition de la règle, appelée par `new_sized` + /// (côté production de la taille) et par la boucle de preview (côté décision de + /// reconstruire) — les deux ne peuvent donc pas diverger. + pub fn normalize_render_size(w: u32, h: u32) -> (u32, u32) { + (((w.max(2) + 1) & !1), ((h.max(2) + 1) & !1)) + } + + unsafe fn new_inner(gpu: &Gpu, out_w: u32, out_h: u32) -> Result { + let dev = gpu.device.clone(); + let ctx = gpu.context.clone(); + + // --- render target RGBA8 (gamma natif de la vidéo ; voir note couleur docs) --- + let mut td = D3D11_TEXTURE2D_DESC { + Width: out_w, + Height: out_h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut rt: Option = None; + dev.CreateTexture2D(&td, None, Some(&mut rt))?; + let rt = rt.unwrap(); + let mut rtv: Option = None; + dev.CreateRenderTargetView(&rt, None, Some(&mut rtv))?; + let mut rt_srv: Option = None; + dev.CreateShaderResourceView(&rt, None, Some(&mut rt_srv))?; + + // staging pour readback PNG + td.Usage = D3D11_USAGE_STAGING; + td.BindFlags = 0; + td.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32; + let mut staging: Option = None; + dev.CreateTexture2D(&td, None, Some(&mut staging))?; + + // --- shaders --- + let hlsl = include_bytes!("shaders.hlsl"); + let vsb = compile(hlsl, b"vs_main\0", b"vs_5_0\0")?; + let psb = compile(hlsl, b"ps_main\0", b"ps_5_0\0")?; + let vs_bytes = + std::slice::from_raw_parts(vsb.GetBufferPointer() as *const u8, vsb.GetBufferSize()); + let ps_bytes = + std::slice::from_raw_parts(psb.GetBufferPointer() as *const u8, psb.GetBufferSize()); + let mut vs: Option = None; + dev.CreateVertexShader(vs_bytes, None, Some(&mut vs))?; + let mut ps: Option = None; + dev.CreatePixelShader(ps_bytes, None, Some(&mut ps))?; + + // shaders RGB->NV12 + let fsb = compile(hlsl, b"vs_fs\0", b"vs_5_0\0")?; + let yb = compile(hlsl, b"ps_y\0", b"ps_5_0\0")?; + let uvb = compile(hlsl, b"ps_uv\0", b"ps_5_0\0")?; + let fs_bytes = + std::slice::from_raw_parts(fsb.GetBufferPointer() as *const u8, fsb.GetBufferSize()); + let y_bytes = + std::slice::from_raw_parts(yb.GetBufferPointer() as *const u8, yb.GetBufferSize()); + let uv_bytes = + std::slice::from_raw_parts(uvb.GetBufferPointer() as *const u8, uvb.GetBufferSize()); + let mut vs_fs: Option = None; + dev.CreateVertexShader(fs_bytes, None, Some(&mut vs_fs))?; + let mut ps_y: Option = None; + dev.CreatePixelShader(y_bytes, None, Some(&mut ps_y))?; + let mut ps_uv: Option = None; + dev.CreatePixelShader(uv_bytes, None, Some(&mut ps_uv))?; + + // --- sampler bilinéaire clamp --- + let sd = D3D11_SAMPLER_DESC { + Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR, + AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, + AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, + AddressW: D3D11_TEXTURE_ADDRESS_CLAMP, + ComparisonFunc: D3D11_COMPARISON_NEVER, + MaxLOD: f32::MAX, + ..Default::default() + }; + let mut sampler: Option = None; + dev.CreateSamplerState(&sd, Some(&mut sampler))?; + + // --- constant buffer dynamique --- + let bd = D3D11_BUFFER_DESC { + ByteWidth: std::mem::size_of::() as u32, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + ..Default::default() + }; + let mut cbuf: Option = None; + dev.CreateBuffer(&bd, None, Some(&mut cbuf))?; + + // --- blend alpha prémultiplié --- + let mut bl = D3D11_BLEND_DESC::default(); + bl.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC { + BlendEnable: true.into(), + SrcBlend: D3D11_BLEND_ONE, + DestBlend: D3D11_BLEND_INV_SRC_ALPHA, + BlendOp: D3D11_BLEND_OP_ADD, + SrcBlendAlpha: D3D11_BLEND_ONE, + DestBlendAlpha: D3D11_BLEND_INV_SRC_ALPHA, + BlendOpAlpha: D3D11_BLEND_OP_ADD, + RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8, + }; + let mut blend: Option = None; + dev.CreateBlendState(&bl, Some(&mut blend))?; + + // blend désactivé mais écriture ACTIVE (le défaut a WriteMask=0 -> rien n'est écrit) + let mut bl_none = D3D11_BLEND_DESC::default(); + bl_none.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8; + let mut blend_none: Option = None; + dev.CreateBlendState(&bl_none, Some(&mut blend_none))?; + + // notre texture NV12 simple (ArraySize=1) : NV12+RT n'est autorisé qu'en non-array + // sur cet iGPU. On y rend la conversion, puis copie GPU->GPU vers le pool encodeur. + let nvd = D3D11_TEXTURE2D_DESC { + Width: out_w, + Height: out_h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut nv12: Option = None; + dev.CreateTexture2D(&nvd, None, Some(&mut nv12))?; + let nv12 = nv12.unwrap(); + let mk_rtv = |fmt: DXGI_FORMAT| -> Result { + let d = D3D11_RENDER_TARGET_VIEW_DESC { + Format: fmt, + ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D, + Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 { + Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 }, + }, + }; + let mut rtv: Option = None; + dev.CreateRenderTargetView(&nv12, Some(&d), Some(&mut rtv))?; + Ok(rtv.unwrap()) + }; + let rtv_y = mk_rtv(DXGI_FORMAT_R8_UNORM)?; + let rtv_uv = mk_rtv(DXGI_FORMAT_R8G8_UNORM)?; + + // shaders de flou + copie + let blurb = compile(hlsl, b"ps_blur\0", b"ps_5_0\0")?; + let texb = compile(hlsl, b"ps_tex\0", b"ps_5_0\0")?; + let mut ps_blur: Option = None; + dev.CreatePixelShader( + std::slice::from_raw_parts(blurb.GetBufferPointer() as *const u8, blurb.GetBufferSize()), + None, + Some(&mut ps_blur), + )?; + let mut ps_tex: Option = None; + dev.CreatePixelShader( + std::slice::from_raw_parts(texb.GetBufferPointer() as *const u8, texb.GetBufferSize()), + None, + Some(&mut ps_tex), + )?; + + // shaders dual-Kawase + let kdb = compile(hlsl, b"ps_kawase_down\0", b"ps_5_0\0")?; + let kub = compile(hlsl, b"ps_kawase_up\0", b"ps_5_0\0")?; + let mut ps_kdown: Option = None; + dev.CreatePixelShader( + std::slice::from_raw_parts(kdb.GetBufferPointer() as *const u8, kdb.GetBufferSize()), + None, + Some(&mut ps_kdown), + )?; + let mut ps_kup: Option = None; + dev.CreatePixelShader( + std::slice::from_raw_parts(kub.GetBufferPointer() as *const u8, kub.GetBufferSize()), + None, + Some(&mut ps_kup), + )?; + + // textures RGBA RT+SRV à une taille donnée (chaîne de flou) + let mk_rgba = |w: u32, h: u32| -> Result<(ID3D11RenderTargetView, ID3D11ShaderResourceView)> { + let hd = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut t: Option = None; + dev.CreateTexture2D(&hd, None, Some(&mut t))?; + let t = t.unwrap(); + let mut rtv: Option = None; + dev.CreateRenderTargetView(&t, None, Some(&mut rtv))?; + let mut srv: Option = None; + dev.CreateShaderResourceView(&t, None, Some(&mut srv))?; + Ok((rtv.unwrap(), srv.unwrap())) + }; + // Pyramide dual-Kawase derivee de la taille de rendu (et non d'un demi de + // 1080 fige) : sinon le rayon effectif du flou de fond changerait d'un format + // a l'autre. `.max(1)` protege les tres petites tailles de preview. + let (half_w, half_h) = ((out_w / 2).max(1), (out_h / 2).max(1)); + let (half_a_rtv, half_a_srv) = mk_rgba(half_w, half_h)?; + let (half_b_rtv, half_b_srv) = mk_rgba(half_w, half_h)?; + let (q_rtv, q_srv) = mk_rgba((half_w / 2).max(1), (half_h / 2).max(1))?; + let (e_rtv, e_srv) = mk_rgba((half_w / 4).max(1), (half_h / 4).max(1))?; + + // accumulateur pleine réso (RGBA) + blend additif pondéré (facteur = 1/N) + let ad = D3D11_TEXTURE2D_DESC { + Width: out_w, + Height: out_h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut accum: Option = None; + dev.CreateTexture2D(&ad, None, Some(&mut accum))?; + let accum = accum.unwrap(); + let mut accum_rtv: Option = None; + dev.CreateRenderTargetView(&accum, None, Some(&mut accum_rtv))?; + let mut accum_srv: Option = None; + dev.CreateShaderResourceView(&accum, None, Some(&mut accum_srv))?; + + // Copie de travail des annotations flou. Chaîne de mips COMPLÈTE (`MipLevels: 0`) : c'est + // elle qui fournit le flou. Échantillonner un niveau plus bas donne un vrai lissage pour + // n'importe quel rayon à coût constant, là où un noyau de quelques taps espacés produit + // des copies fantômes au lieu d'un flou. + let ann_desc = D3D11_TEXTURE2D_DESC { + MipLevels: 0, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + MiscFlags: D3D11_RESOURCE_MISC_GENERATE_MIPS.0 as u32, + ..ad + }; + let mut ann_copy: Option = None; + dev.CreateTexture2D(&ann_desc, None, Some(&mut ann_copy))?; + let ann_copy = ann_copy.unwrap(); + let mut ann_copy_srv: Option = None; + dev.CreateShaderResourceView(&ann_copy, None, Some(&mut ann_copy_srv))?; + + let mut bla = D3D11_BLEND_DESC::default(); + bla.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC { + BlendEnable: true.into(), + SrcBlend: D3D11_BLEND_BLEND_FACTOR, + DestBlend: D3D11_BLEND_ONE, + BlendOp: D3D11_BLEND_OP_ADD, + SrcBlendAlpha: D3D11_BLEND_BLEND_FACTOR, + DestBlendAlpha: D3D11_BLEND_ONE, + BlendOpAlpha: D3D11_BLEND_OP_ADD, + RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8, + }; + let mut blend_add: Option = None; + dev.CreateBlendState(&bla, Some(&mut blend_add))?; + + Ok(Compositor { + dev, + ctx, + rt, + rtv: rtv.unwrap(), + rt_srv: rt_srv.unwrap(), + staging: staging.unwrap(), + vs: vs.unwrap(), + ps: ps.unwrap(), + vs_fs: vs_fs.unwrap(), + ps_y: ps_y.unwrap(), + ps_uv: ps_uv.unwrap(), + sampler: sampler.unwrap(), + cbuf: cbuf.unwrap(), + blend: blend.unwrap(), + blend_none: blend_none.unwrap(), + nv12, + rtv_y, + rtv_uv, + ps_blur: ps_blur.unwrap(), + ps_tex: ps_tex.unwrap(), + half_a_rtv, + half_a_srv, + half_b_rtv, + half_b_srv, + ps_kdown: ps_kdown.unwrap(), + ps_kup: ps_kup.unwrap(), + q_rtv, + q_srv, + e_rtv, + e_srv, + ann_copy, + ann_copy_srv: ann_copy_srv.unwrap(), + accum, + accum_rtv: accum_rtv.unwrap(), + accum_srv: accum_srv.unwrap(), + blend_add: blend_add.unwrap(), + cursor: RefCell::new(None), + cursor_t_override: RefCell::new(None), + timeline_t_override: RefCell::new(None), + srv_cache: RefCell::new(HashMap::new()), + live_params: RefCell::new(LiveParams::default()), + scene: RefCell::new(None), + text_raster: match crate::text::TextRasterizer::new() { + Ok(r) => Some(r), + Err(e) => { + eprintln!("[texte] init Direct2D/DirectWrite impossible, annotations texte désactivées: {e}"); + None + } + }, + text_cache: RefCell::new(HashMap::new()), + ann_img_cache: RefCell::new(HashMap::new()), + img_cache: RefCell::new(HashMap::new()), + render_size: Cell::new((out_w, out_h)), + resize_target: RefCell::new(None), + live_readback_staging: RefCell::new(None), + nv12_readback_staging: RefCell::new(None), + }) + } + + /// Largeur du render target en px. **Le** dénominateur de toute conversion + /// px→normalisé de ce fichier : à utiliser partout où `OUT_W` servait de + /// référence géométrique. Cf. `Compositor::render_size`. + #[inline] + fn rw(&self) -> f32 { + self.render_size.get().0 as f32 + } + + /// Hauteur du render target en px. Cf. `Compositor::rw`. + #[inline] + fn rh(&self) -> f32 { + self.render_size.get().1 as f32 + } + + /// Dimensions entières du render target — pour les viewports et les boucles + /// de readback, qui veulent des `u32` et non des `f32`. + #[inline] + fn render_dims(&self) -> (u32, u32) { + self.render_size.get() + } + + /// Taille à laquelle ce compositeur rastérise, après l'arrondi au pair de + /// `new_sized`. L'appelant la compare à la géométrie qu'il veut produire pour + /// savoir s'il doit reconstruire le compositeur (cf. `new_sized`). + pub fn render_size(&self) -> (u32, u32) { + self.render_size.get() + } + + /// Met à jour les paramètres continus pilotés par l'inspector (thread live uniquement). + pub fn set_live_params(&self, p: LiveParams) { + *self.live_params.borrow_mut() = p; + } + + /// Installe (ou retire) la scène de l'app. Présente → `compose_frame` prend ses placements + /// depuis le layout preset au lieu du planning fixture. + pub fn set_scene(&self, s: Option) { + *self.scene.borrow_mut() = s; + } + + /// Crée les SRV Y (R8) et UV (R8G8) sur la tranche d'array de la frame décodeur. + pub unsafe fn nv12_srvs( + &self, + frame: *const AVFrame, + ) -> Result<(ID3D11ShaderResourceView, ID3D11ShaderResourceView)> { + let tex_ptr = (*frame).data[0] as *mut c_void; + let slice = (*frame).data[1] as u32; + // cache hit : le pool réutilise les mêmes textures -> zéro création après warmup + let key = (tex_ptr as usize, slice); + if let Some((y, uv)) = self.srv_cache.borrow().get(&key) { + return Ok((y.clone(), uv.clone())); + } + let tex = ID3D11Texture2D::from_raw_borrowed(&tex_ptr) + .ok_or_else(|| anyhow::anyhow!("frame sans texture D3D11"))? + .clone(); + + let mk = |fmt: DXGI_FORMAT| -> Result { + let mut d = D3D11_SHADER_RESOURCE_VIEW_DESC { + Format: fmt, + ViewDimension: D3D11_SRV_DIMENSION_TEXTURE2DARRAY, + ..Default::default() + }; + d.Anonymous.Texture2DArray = D3D11_TEX2D_ARRAY_SRV { + MostDetailedMip: 0, + MipLevels: 1, + FirstArraySlice: slice, + ArraySize: 1, + }; + let mut srv: Option = None; + self.dev.CreateShaderResourceView(&tex, Some(&d), Some(&mut srv))?; + Ok(srv.unwrap()) + }; + let y = mk(DXGI_FORMAT_R8_UNORM)?; + let uv = mk(DXGI_FORMAT_R8G8_UNORM)?; + self.srv_cache.borrow_mut().insert(key, (y.clone(), uv.clone())); + Ok((y, uv)) + } + + /// Dimensions réelles de la texture décodeur (alignée macrobloc : 1080->1088, etc.). + pub unsafe fn tex_dims(&self, frame: *const AVFrame) -> (u32, u32) { + let tex_ptr = (*frame).data[0] as *mut c_void; + let tex = ID3D11Texture2D::from_raw_borrowed(&tex_ptr).unwrap(); + let mut d = D3D11_TEXTURE2D_DESC::default(); + tex.GetDesc(&mut d); + (d.Width, d.Height) + } + + /// État de composition : RT principal, viewport plein, shaders de calque, blend prémultiplié. + /// (Sans clear — sert à reprendre après les passes de flou.) + pub unsafe fn bind_compose_state(&self) { + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.VSSetShader(&self.vs, None); + self.ctx.PSSetShader(&self.ps, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + self.ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); + self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff); + } + + /// Prépare la passe : état de composition + clear. + pub unsafe fn begin(&self, clear: [f32; 4]) { + self.bind_compose_state(); + self.ctx.ClearRenderTargetView(&self.rtv, &clear); + } + + /// Passe plein écran générique (triangle unique) : `srv` -> `rtv` via `ps`, avec `fx`. + unsafe fn fs_pass( + &self, + rtv: &ID3D11RenderTargetView, + srv: &ID3D11ShaderResourceView, + ps: &ID3D11PixelShader, + w: u32, + h: u32, + fx: [f32; 4], + ) { + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(ps, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: w as f32, Height: h as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.upload_cb(&LayerCB { fx, ..Default::default() }); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + } + + /// Fond flouté (§7), dual-Kawase : suppose le screen déjà dessiné plein écran dans le RT. + /// Chaîne down (RT→960→480→240) puis up (240→480→960→RT). ~6 passes de 5-8 taps + /// à résolution décroissante, vs 2 passes gaussiennes 49-tap. Le RT devient le fond. + pub unsafe fn blur_bg(&self, _sigma: f32) { + let off = 2.2; // spread par passe + // La pyramide se dérive de la taille de rendu, pas d'une constante : sinon + // le rayon effectif du flou changerait avec la résolution de sortie (un + // demi de 1080 n'est pas un demi de 2160), et le fond flouté ne serait plus + // le même effet d'un format à l'autre. + let (rw_i, rh_i) = self.render_dims(); + let (half_w, half_h) = (rw_i / 2, rh_i / 2); + let hw = half_w as f32; + let hh = half_h as f32; + // DOWN : texel = 1/(dims de la SOURCE échantillonnée) + self.fs_pass(&self.half_a_rtv, &self.rt_srv, &self.ps_kdown, half_w, half_h, + [1.0 / self.rw(), 1.0 / self.rh(), off, 0.0]); + self.fs_pass(&self.q_rtv, &self.half_a_srv, &self.ps_kdown, half_w / 2, half_h / 2, + [1.0 / hw, 1.0 / hh, off, 0.0]); + self.fs_pass(&self.e_rtv, &self.q_srv, &self.ps_kdown, half_w / 4, half_h / 4, + [2.0 / hw, 2.0 / hh, off, 0.0]); + // UP + self.fs_pass(&self.q_rtv, &self.e_srv, &self.ps_kup, half_w / 2, half_h / 2, + [4.0 / hw, 4.0 / hh, off, 0.0]); + self.fs_pass(&self.half_a_rtv, &self.q_srv, &self.ps_kup, half_w, half_h, + [2.0 / hw, 2.0 / hh, off, 0.0]); + self.fs_pass(&self.rtv, &self.half_a_srv, &self.ps_kup, rw_i, rh_i, + [1.0 / hw, 1.0 / hh, off, 0.0]); + } + + unsafe fn upload_cb(&self, cb: &LayerCB) { + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx + .Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut m)) + .unwrap(); + std::ptr::copy_nonoverlapping(cb as *const LayerCB as *const u8, m.pData as *mut u8, std::mem::size_of::()); + self.ctx.Unmap(&self.cbuf, 0); + self.ctx.VSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())])); + self.ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())])); + } + + /// Calque vidéo NV12. + pub unsafe fn draw_video( + &self, + cb: &LayerCB, + srv_y: &ID3D11ShaderResourceView, + srv_uv: &ID3D11ShaderResourceView, + ) { + self.upload_cb(cb); + self.ctx + .PSSetShaderResources(0, Some(&[Some(srv_y.clone()), Some(srv_uv.clone())])); + self.ctx.Draw(4, 0); + } + + /// Calque couleur pleine (fond). + pub unsafe fn draw_solid(&self, cb: &LayerCB) { + self.upload_cb(cb); + self.ctx.Draw(4, 0); + } + + /// Fond wallpaper image (cover-fit). `path` = chemin absolu (résolu côté app). Décodé et + /// uploadé une fois (cache), puis échantillonné en mode 6. Err → l'appelant retombe sur une + /// couleur plate. Le rect uv `src` recouvre toute la sortie en rognant le débordement. + unsafe fn draw_image_bg(&self, path: &str, output_aspect: f32) -> Result<()> { + // NB : la recherche est isolée dans un `let` pour que l'emprunt immuable soit relâché + // AVANT le `borrow_mut()` (sinon double-emprunt RefCell → panic sur la 1re frame image). + let cached = self.img_cache.borrow().get(path).cloned(); + let (srv, iw, ih) = match cached { + Some(v) => v, + None => { + let loaded = self.load_image_srv(path)?; + self.img_cache.borrow_mut().insert(path.to_string(), loaded.clone()); + loaded + } + }; + let ai = iw as f32 / ih as f32; + // Le fond remplit TOUJOURS le cadre (dst=[0,0,1,1], jamais rétréci par `undistort`), + // mais le canvas interne est un 16:9 fixe étiré ensuite vers le VRAI ratio de sortie + // (`blit_resized`, non uniforme) : le crop "cover" doit donc être calculé contre ce vrai + // ratio de sortie (`output_aspect`, = final_out_w/final_out_h), pas contre le ratio fixe + // du canvas — sinon l'image, déjà cover-fittée pour du 16:9, se retrouve re-déformée par + // l'étirement final vers un ratio différent (ex. 9:16, cf. rapport utilisateur). + let ao = output_aspect; + let (u0, v0, u1, v1) = if ai > ao { + let vis = ao / ai; // rogne horizontalement + ((1.0 - vis) * 0.5, 0.0, 1.0 - (1.0 - vis) * 0.5, 1.0) + } else { + let vis = ai / ao; // rogne verticalement + (0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5) + }; + self.upload_cb(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [u0, v0, u1, v1], + mode: 6.0, + ..Default::default() + }); + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.ctx.Draw(4, 0); + Ok(()) + } + + /// Décode un fichier image (jpg/png) → texture RGBA immuable + SRV. + unsafe fn load_image_srv(&self, path: &str) -> Result<(ID3D11ShaderResourceView, u32, u32)> { + // Les annotations image stockent une data URL (cf. `types.ts` : « Separate storage for + // image data URL »), pas un chemin : on décode alors depuis la mémoire. Les wallpapers + // continuent de passer par le disque. + let img = if let Some(bytes) = decode_data_uri(path) { + image::load_from_memory(&bytes) + .map_err(|e| anyhow::anyhow!("data URI image ({} octets): {}", bytes.len(), e))? + .to_rgba8() + } else { + image::open(path) + .map_err(|e| anyhow::anyhow!("wallpaper {}: {}", path, e))? + .to_rgba8() + }; + let (w, h) = (img.width(), img.height()); + let pixels = img.into_raw(); + let td = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_IMMUTABLE, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let init = D3D11_SUBRESOURCE_DATA { + pSysMem: pixels.as_ptr() as *const c_void, + SysMemPitch: w * 4, + SysMemSlicePitch: 0, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&td, Some(&init), Some(&mut tex))?; + let tex = tex.unwrap(); + let mut srv: Option = None; + self.dev.CreateShaderResourceView(&tex, None, Some(&mut srv))?; + Ok((srv.unwrap(), w, h)) + } + + pub fn set_cursor(&self, track: CursorTrack) { + *self.cursor.borrow_mut() = Some(track); + } + + pub fn clear_cursor(&self) { + *self.cursor.borrow_mut() = None; + } + + /// Voir `cursor_t_override`. `None` restaure le comportement fixture (`frame / FPS`). + pub fn set_cursor_time(&self, t: Option) { + *self.cursor_t_override.borrow_mut() = t; + } + + /// Voir `timeline_t_override`. `None` restaure le comportement fixture (`frame / FPS`). + pub fn set_timeline_time(&self, t: Option) { + *self.timeline_t_override.borrow_mut() = t; + } + + /// Copie de la scène courante (si présente) — utilisé par l'export multiclip pour lire les + /// réglages curseur (thème/lissage/show) sans dupliquer le contrat de scène côté pipeline. + pub fn scene_snapshot(&self) -> Option { + self.scene.borrow().clone() + } + + /// Curseur custom (dot+ring) centré en `center` (0..1 sortie), taille `size_px`, opacité `a`. + /// `clip` = rect "Clip to canvas" en espace sortie [x,y,w,h] ; passer un rect englobant tout + /// (ex. [-1,-1,3,3]) pour désactiver l'effet. + unsafe fn draw_cursor(&self, center: [f32; 2], size_px: f32, a: f32, clip: [f32; 4]) { + let w = size_px / self.rw(); + let h = size_px / self.rh(); + let dst = [center[0] - w * 0.5, center[1] - h * 0.5, w, h]; + self.draw_solid(&LayerCB { + dst, + quad_px: [size_px, size_px], + mode: 4.0, + color: [1.0, 1.0, 1.0, a], + fx: clip, + ..Default::default() + }); + } + + /// Curseur thème (sprite PNG, ex. arrow.png) dont le PIVOT `hotspot` (fraction 0..1 de + /// l'image) tombe sur `center`, à la taille de référence `size_px`. `Err` → l'appelant + /// retombe sur `draw_cursor` (math dot+ring). + unsafe fn draw_cursor_sprite( + &self, + placement: CursorPlacement, + size_px: f32, + a: f32, + sprite: &SceneCursorSprite, + clip: [f32; 4], + ) -> Result<()> { + let path = sprite.path.as_str(); + let cached = self.img_cache.borrow().get(path).cloned(); + let (srv, iw, ih) = match cached { + Some(v) => v, + None => { + let loaded = self.load_image_srv(path)?; + self.img_cache.borrow_mut().insert(path.to_string(), loaded.clone()); + loaded + } + }; + let ar = iw as f32 / ih as f32; + let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; + let hotspot = [sprite.hotspot_x, sprite.hotspot_y]; + + let cb = match placement { + CursorPlacement::Upright { center } => LayerCB { + dst: cursor_sprite_dst(center, pw / self.rw(), ph / self.rh(), hotspot), + src: [0.0, 0.0, 1.0, 1.0], + mode: 7.0, + color: [1.0, 1.0, 1.0, a], + fx: clip, + ..Default::default() + }, + CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, .. } => { + // Le sprite est posé DANS le plan : sa taille devient une fraction du plan + // (l'unité de `size_px` est le rect d'écran non incliné), et ses 4 coins + // traversent la même projection que la vidéo. La réduction due au tilt vient + // donc de la projection elle-même — rien à multiplier à la main. + let (wf, hf) = (pw / screen_px[0], ph / screen_px[1]); + let x0 = plane_pt[0] - hotspot[0] * wf; + let y0 = plane_pt[1] - hotspot[1] * hf; + let corners = [(x0, y0), (x0 + wf, y0), (x0 + wf, y0 + hf), (x0, y0 + hf)] + .map(|(fx, fy)| { + let (px, py) = quad.point_px(fx, fy); + (center_px[0] + px, center_px[1] + py) + }); + let (min_x, max_x) = corners + .iter() + .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = corners + .iter() + .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + // Le quad projeté d'un sprite peut être très fin de biais : une bbox d'un pixel + // de large ferait diverger le warp inverse, donc plancher à 1 px. + let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); + let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + LayerCB { + dst: [min_x / self.rw(), min_y / self.rh(), bw / self.rw(), bh / self.rh()], + quad_px: [bw, bh], + mode: 13.0, + color: [1.0, 1.0, 1.0, a], + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + dst_prev: clip, + ..Default::default() + } + } + }; + + self.upload_cb(&cb); + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.ctx.Draw(4, 0); + Ok(()) + } + + /// Sprite de l'état courant (`cursor_type`, ex. `"text"`), à défaut celui de la flèche, + /// à défaut le curseur math (dot+ring). + /// + /// Le repli sur la flèche compte : un thème n'apporte que sa flèche et son pointeur, les + /// autres états venant de l'art intégrée — mais si un état inconnu apparaît, mieux vaut + /// une flèche qu'un point dans un cercle. + unsafe fn draw_cur_themed( + &self, + sprites: &HashMap, + cursor_type: Option<&str>, + placement: CursorPlacement, + size_px: f32, + a: f32, + clip: [f32; 4], + ) { + let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); + if let Some(sprite) = sprite { + if self.draw_cursor_sprite(placement, size_px, a, sprite, clip).is_ok() { + return; + } + } + // Le repli math reste droit même sur un plan incliné : il ne devrait plus apparaître + // maintenant que l'art par défaut existe, et lui donner sa propre passe de warp pour + // un cas de secours ne se justifie pas. + self.draw_cursor(placement.upright_center(), size_px, a, clip); + } + + /// Ombre portée (§7 E4) sous un quad `dst` (normalisé) de taille `size_px`. + /// Le quad d'ombre est élargi de `spread` px et décalé de `offset_px`. + /// `spread`/`offset_px` sont des px RÉELS de la sortie finale (même convention que + /// `radius_px` pour l'arrondi normal, cf. `compose_frame`) — PAS des px du canvas fixe + /// 16:9. Convertis ici en marge/décalage CANVAS (avant l'étirement final anisotrope de + /// `blit_resized`), par axe (`/stretch_x`, `/stretch_y`), pour que ce halo redevienne un + /// vrai halo isotrope une fois cet étirement appliqué — sans ça (ancien calcul : marge + /// identique en fraction canvas quel que soit l'axe) l'ombre ressort visiblement elliptique + /// dès que la sortie n'est pas 16:9 (rapport utilisateur, ex. export vertical 9:16). + /// `stretch_x`/`stretch_y` sont aussi transmis au shader (`mb.yz`) pour pré-déformer la SDF + /// elle-même — même technique que l'arrondi normal (mode 0) — sinon la COURBURE des coins + /// de l'ombre reste elliptique même une fois sa taille globale corrigée. + pub unsafe fn draw_shadow( + &self, + dst: [f32; 4], + size_px: [f32; 2], + radius: f32, + spread: f32, + offset_px: [f32; 2], + opacity: f32, + ) { + let sx = spread / self.rw(); + let sy = spread / self.rh(); + let ox = offset_px[0] / self.rw(); + let oy = offset_px[1] / self.rh(); + let cb = LayerCB { + dst: [dst[0] - sx + ox, dst[1] - sy + oy, dst[2] + 2.0 * sx, dst[3] + 2.0 * sy], + quad_px: [size_px[0] + 2.0 * spread, size_px[1] + 2.0 * spread], + radius_px: radius, + mode: 2.0, + color: [0.0, 0.0, 0.0, opacity], + fx: [spread, 0.0, 0.0, 0.0], + mb: [0.0, 1.0, 1.0, 0.0], + ..Default::default() + }; + self.draw_solid(&cb); + } + + /// Ombre d'un écran incliné en 3D : la pénombre suit le QUADRILATÈRE projeté (mode 12), pas + /// son rect englobant. `corners` sont les 4 coins (TL, TR, BR, BL) en px relatifs au centre, + /// tels que `rotated_quad_corners_px` les rend ; `center_px` est ce centre à l'écran. + /// + /// `radius` est le rayon des coins du PLAN, réutilisé tel quel : la projection l'étire de + /// ±10 % selon l'endroit du bord, écart invisible sur une ombre floue, alors qu'une ombre à + /// coins vifs derrière un écran arrondi dépasse en pointe et se voit tout de suite. + pub unsafe fn draw_quad_shadow( + &self, + corners: &[(f32, f32); 4], + center_px: [f32; 2], + radius: f32, + spread: f32, + offset_px: [f32; 2], + opacity: f32, + ) { + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + // La boîte de rendu doit contenir la pénombre entière, sinon elle se coupe net — le + // quadrilatère seul ne suffit pas. + let box_w = (max_x - min_x) + 2.0 * spread; + let box_h = (max_y - min_y) + 2.0 * spread; + let origin_x = center_px[0] + min_x - spread + offset_px[0]; + let origin_y = center_px[1] + min_y - spread + offset_px[1]; + // Coins en px locaux à cette boîte, même convention que le mode 8. + let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x + spread, y - min_y + spread] }; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + self.draw_solid(&LayerCB { + dst: [ + origin_x / self.rw(), + origin_y / self.rh(), + box_w / self.rw(), + box_h / self.rh(), + ], + quad_px: [box_w, box_h], + radius_px: radius, + mode: 12.0, + color: [0.0, 0.0, 0.0, opacity], + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + mb: [0.0, spread, 1.0, 0.0], + ..Default::default() + }); + } + + /// Compose une frame animée (§6/§8) : fond flouté + screen zoomé (padding, coins, ombre) + /// + webcam crop carré (coins, ombre), placements interpolés A↔B par la timeline. + pub unsafe fn compose_frame( + &self, + screen: *const AVFrame, + webcam: *const AVFrame, + frame: f32, + cfg: &Cfg, + ) -> Result<()> { + let (sy, suv) = self.nv12_srvs(screen)?; + let (wy, wuv) = self.nv12_srvs(webcam)?; + let (stw, sth) = self.tex_dims(screen); + let (wtw, wth) = self.tex_dims(webcam); + let (scw, sch) = ((*screen).width as f32, (*screen).height as f32); + let (wcw, wch) = ((*webcam).width as f32, (*webcam).height as f32); + let u_max = scw / stw as f32; + let v_max = sch / sth as f32; + + // Scène de l'app présente → placements du layout preset (ou, mieux, le rect résolu par + // l'app dans `layout.webcam_rect`) ; sinon planning fixture (bench). + let scene_ref = self.scene.borrow(); + let scene_preset: Option = + scene_ref.as_ref().map(|s| s.layout.preset.clone()); + // Webcam rect résolu par l'app (= `computeCompositeLayout`, source de vérité unique + // entre preview et natif) : quand il est présent ET que la scène est posée, on l'utilise + // COMME placement de base. Sinon, fallback sur `preset_placements` historique (PiP + // codé en dur à 320 px + 40 px de marge — l'arrangement qui dérivait de la preview). + let app_webcam_rect: Option<[f32; 4]> = scene_ref + .as_ref() + .and_then(|s| s.layout.webcam_rect) + .map(|r| [r.x, r.y, r.width, r.height]); + // Idem pour l'écran. Les deux rects viennent du MÊME appel `computeCompositeLayout`, donc + // les consommer ensemble est la seule façon de garder le bloc écran+caméra cohérent : + // n'en prendre qu'un revenait à mélanger la géométrie de l'app et un placement fixture. + let app_screen_rect: Option<[f32; 4]> = scene_ref + .as_ref() + .and_then(|s| s.layout.screen_rect) + .map(|r| [r.x, r.y, r.width, r.height]); + let (mut p, mut pp) = match &scene_preset { + Some(preset) => { + // Chaque rect résolu par l'app remplace INDÉPENDAMMENT sa contrepartie du + // preset ; sinon celle du preset reste (le padding slider l'insèrera ensuite + // dans `scale_frame`). + // + // Avant, ce match portait sur `app_webcam_rect` et le rect ÉCRAN n'était donc + // honoré que si un rect webcam arrivait aussi. Un layout sans caméra gardait + // l'écran plein cadre du preset — pendant que `fit_screen` (plus bas) coupait + // quand même son fit au ratio du crop, puisqu'un `app_screen_rect` était bien + // présent. Résultat : un clip recadré sans caméra était étiré, et aucune des + // deux voies ne le rattrapait. Coupler l'écran à la présence de la caméra + // n'avait aucune raison d'être — ce sont deux calques indépendants. + let mut fp = preset_placements(preset); + if let Some(wr) = app_webcam_rect { + fp.webcam.dst = wr; + } + if let Some(sr) = app_screen_rect { + fp.screen.dst = sr; + } + (fp, fp) // layout statique → vélocité nulle + } + None => (timeline(frame, cfg), timeline(frame - 1.0, cfg)), + }; + let lp = *self.live_params.borrow(); + // Motion blur écran : quand la scène (contrat de l'app) est posée, c'est elle qui pilote + // (parité inspector : 1.0 + motion_blur*15 taps), sinon on retombe sur `cfg.mblur_n` + // (le bench fixture continue d'utiliser ses taps explicites). + let mb_taps = scene_ref + .as_ref() + .map(|s| 1.0 + s.effects.motion_blur.clamp(0.0, 1.0) * 15.0) + .unwrap_or(cfg.mblur_n as f32); + + // Zoom regions + Full Camera : filtrées en amont pour le clip actif et échantillonnées + // dans le même référentiel source que le PTS du décodeur écran. + let empty_zoom: Vec = Vec::new(); + let empty_cam: Vec = Vec::new(); + let zoom_regions = scene_ref.as_ref().map(|s| &s.zoom_regions).unwrap_or(&empty_zoom); + let cam_regions = + scene_ref.as_ref().map(|s| &s.camera_fullscreen_regions).unwrap_or(&empty_cam); + let webcam_reactive = scene_ref.as_ref().map(|s| s.layout.webcam_reactive_zoom).unwrap_or(false); + let source_t = self.timeline_t_override.borrow().unwrap_or(frame / FPS); + let source_t_prev = source_t - 1.0 / FPS; + // le focus "auto" (suivi curseur) réutilise la même piste que le rendu du curseur. + let cursor_ref = self.cursor.borrow(); + let cursor_for_zoom = cursor_ref.as_ref(); + // La rotation 3D (mode 8, pas de motion blur dans ce chemin — cf. le commentaire au + // point d'appel) n'est calculée QUE pour la frame courante ; `pp` ne sert qu'au zoom + // écran normal (vélocité pour le motion blur du chemin non-tilté). + let mut zoom_rotation = [0.0f32; 3]; + if !zoom_regions.is_empty() { + let zs = crate::regions::zoom_state_at(zoom_regions, source_t, cursor_for_zoom); + p.zoom = zs.scale; + p.focus = zs.focus; + zoom_rotation = zs.rotation; + let zs_p = crate::regions::zoom_state_at(zoom_regions, source_t_prev, cursor_for_zoom); + pp.zoom = zs_p.scale; + pp.focus = zs_p.focus; + } + // Full Camera ignore le rétrécissement réactif de la webcam (design web : mélanger + // "rétrécit pour le zoom" et "grandit en plein cadre" dans la même frame n'a pas de sens). + let cam_progress = crate::regions::camera_fullscreen_progress_at(cam_regions, source_t); + let cam_progress_prev = + crate::regions::camera_fullscreen_progress_at(cam_regions, source_t_prev); + // rétrécissement réactif : la webcam rétrécit pendant un zoom actif (1/zoom, plancher + // 0.35 — parité `reactiveWebcamScale`, TS). Ignoré pendant Full Camera (voir ci-dessus). + let reactive_scale = |zoom: f32, progress: f32| -> f32 { + if webcam_reactive && progress <= 0.0 && zoom.is_finite() && zoom > 0.0 { + (1.0 / zoom).clamp(0.35, 1.0) + } else { + 1.0 + } + }; + // `lp.webcam_size_scale` vient de `scene.layout.webcamSize` (voir `live_params_from_scene`) + // — le MÊME nombre que le fraction webcamSizePreset déjà pris en compte côté app pour + // calculer `wr` (`computeCompositeLayout`, TS). Quand l'app fournit un `webcam_rect` + // explicite, la taille y est donc déjà cuite : réappliquer `lp.webcam_size_scale` ici + // double-échelonnerait la boîte (ex. un preset 34% → webcam rendue à ~34%×34% ≈ 12% au + // lieu de 34%, la webcam apparaissant bien plus petite que ce que montre l'aperçu web). + // Seul `reactive_scale` (rétrécissement pendant un zoom, une valeur ANIMÉE par frame que + // le rect statique de l'app ne capture pas) doit encore s'appliquer dans ce cas. + let base_size_scale = if app_webcam_rect.is_some() { 1.0 } else { lp.webcam_size_scale }; + let webcam_size_scale = base_size_scale * reactive_scale(p.zoom, cam_progress); + let webcam_size_scale_prev = base_size_scale * reactive_scale(pp.zoom, cam_progress_prev); + + // padding : échelle globale du layout autour du centre du cadre (parité web frameRenderer : + // paddingScale = 1 - padding*0.4 → padding 0 = plein cadre). S'applique à TOUS les presets : + // côté web, side-by-side et top/bottom soudent écran+caméra en un bloc unique et c'est ce + // bloc que le padding rétrécit (cf. `compositeLayout.ts`, branche `block`). Vertical-stack + // en était exempté tant qu'il était full-bleed ; il ne l'est plus. + let padding_scale = 1.0 - lp.padding * 0.4; + let scale_frame = |dst: [f32; 4], s: f32| -> [f32; 4] { + [0.5 + (dst[0] - 0.5) * s, 0.5 + (dst[1] - 0.5) * s, dst[2] * s, dst[3] * s] + }; + // webcam : ancrée à son coin bas-droite (grandit vers le haut-gauche, pas depuis le centre). + let scale_corner_br = |dst: [f32; 4], s: f32| -> [f32; 4] { + let (brx, bry) = (dst[0] + dst[2], dst[1] + dst[3]); + let (nw, nh) = (dst[2] * s, dst[3] * s); + [brx - nw, bry - nh, nw, nh] + }; + // parité web (compositeLayout) : rectangle/rounded gardent le ratio natif de la webcam ; + // square/circle forcent un carré (side = min). Le placement de base est carré → on ajuste + // ici, en gardant le coin bas-droite fixe (cohérent avec le size-scale). + let is_square_shape = matches!(lp.webcam_shape, 1 | 2); // circle | square + let cam_ar = if is_square_shape { 1.0 } else { (wcw / wch).max(0.01) }; + let fit_cam_aspect = |dst: [f32; 4]| -> [f32; 4] { + let s = (dst[2] * self.rw()).min(dst[3] * self.rh()); // côté carré de base (px) + let (pw, ph) = if cam_ar >= 1.0 { (s, s / cam_ar) } else { (s * cam_ar, s) }; + let (nw, nh) = (pw / self.rw(), ph / self.rh()); + let (brx, bry) = (dst[0] + dst[2], dst[1] + dst[3]); + [brx - nw, bry - nh, nw, nh] + }; + // Variantes ancrées au CENTRE (au lieu du coin bas-droite) de `dst`, pour le cas où + // `dst` vient de `app_webcam_rect` : ce rect est déjà la position que l'utilisateur a + // choisie/déplacée (résolue côté app via `computeCompositeLayout`, même convention + // centre-fraction que `cx`/`cy` dans `compositeLayout.ts`) — l'ancrer au coin bas-droite + // comme le fait `fit_cam_aspect` (pensé pour le placement par DÉFAUT, ancré à ce coin + // avec une marge fixe) réancre silencieusement la webcam glissée n'importe où d'autre à + // ce coin, ignorant la position réelle choisie par l'utilisateur — le bug rapporté + // (webcam glissée au coin bas-gauche, DOM/JSON envoyé au natif confirmant une position + // flush, mais rendu natif visiblement décalé). Le centre est le point fixe qui a un sens + // pour un rect DÉJÀ positionné par l'app ; le coin bas-droite n'a de sens que pour le + // placement par défaut, qui grandit depuis ce coin faute de position explicite. + let scale_center = |dst: [f32; 4], s: f32| -> [f32; 4] { + let (cx, cy) = (dst[0] + dst[2] * 0.5, dst[1] + dst[3] * 0.5); + let (nw, nh) = (dst[2] * s, dst[3] * s); + [cx - nw * 0.5, cy - nh * 0.5, nw, nh] + }; + // Le ratio de sortie réel (peut différer du canvas interne 16:9 fixe) et le facteur + // d'étirement non uniforme que `blit_resized` appliquera en fin de pipeline — nécessaires + // ici (avant `undistort`, plus bas) pour que le fit ci-dessous cible le ratio de boîte tel + // qu'il apparaîtra APRÈS cet étirement, pas tel qu'il est dans l'espace canvas pré-étirement + // (sinon le fit et l'undistort composent deux corrections indépendantes et sur-rétrécissent + // le contenu — cf. rapport utilisateur : crop 9:16 + sortie 9:16 + padding 0% laissait + // quand même une grosse marge, alors que le crop correspond déjà exactement au cadre). + // Le crop de l'utilisateur (dialogue "Edit clip") a son PROPRE ratio (ex. une bande + // verticale 9:16 recadrée dans une source 16:9) — le zoom appliqué ensuite (§ + // `screen_source_rect`) le préserve (mêmes facteurs sur les deux axes), donc c'est bien + // le ratio du CROP qui doit dimensionner le quad de destination, pas celui (fixe, issu + // du preset de layout) de `p.screen.dst`. Sans ça, le rect recadré (dont le ratio propre + // diffère de la boîte du preset) se retrouve étiré pour remplir cette boîte — parité web + // cassée : `computeCompositeLayout`/`centerRectInBounds` (TS) contiennent déjà le crop + // dans sa boîte en respectant son ratio, le natif ne le faisait pas (rapport utilisateur). + let active_crop = scene_ref.as_ref().and_then(|scene| { + scene.crop_by_clip.get(scene.active_clip_index).copied().flatten() + }); + let crop_aspect = match active_crop { + Some(c) if c.width > 0.0001 && c.height > 0.0001 => { + (c.width * scw) / (c.height * sch).max(0.0001) + } + _ => scw / sch.max(0.0001), + }; + // Contain (parité `centerRectInBounds`) : rétrécit `dst` (centré) pour que son ratio + // devienne `aspect`, sans jamais dépasser sa boîte d'origine — mais la boîte de référence + // doit être mesurée telle qu'elle apparaîtra APRÈS l'étirement de sortie (`dst` * ratio de + // sortie), pas dans l'espace canvas 16:9 pré-étirement : sinon le fit cible le mauvais + // ratio de boîte dès que la sortie n'est pas 16:9. `undistort` (plus bas) annule ensuite + // exactement ce même facteur, donc convertir le résultat en fraction canvas se fait par + // `/ uniform_stretch` (propriété de `undistort` : le ratio final ne dépend que de la + // taille de `dst` en PIXELS CANVAS, jamais du ratio de sortie choisi). + let fit_dst_to_aspect = |dst: [f32; 4], aspect: f32| -> [f32; 4] { + let box_w_px = dst[2] * self.rw(); + let box_h_px = dst[3] * self.rh(); + let box_ar = box_w_px / box_h_px.max(0.0001); + let (nw_px, nh_px) = if aspect > box_ar { + (box_w_px, box_w_px / aspect.max(0.0001)) + } else { + (box_h_px * aspect, box_h_px) + }; + let (nw, nh) = (nw_px / self.rw(), nh_px / self.rh()); + let (cx, cy) = (dst[0] + dst[2] * 0.5, dst[1] + dst[3] * 0.5); + [cx - nw * 0.5, cy - nh * 0.5, nw, nh] + }; + // Quand l'app a résolu la boîte écran, elle a DÉJÀ appliqué le padding (le rect est + // calculé contre `maxContentSize`) et l'a DÉJÀ mise au ratio du crop + // (`computeCompositeLayout` reçoit la taille de la source recadrée) : rejouer + // `scale_frame` + `fit_dst_to_aspect` par-dessus appliquerait le padding deux fois et + // re-contiendrait une boîte déjà au bon ratio. Même raisonnement que pour la webcam. + let fit_screen = |dst: [f32; 4]| { + if app_screen_rect.is_some() { + dst + } else { + fit_dst_to_aspect(scale_frame(dst, padding_scale), crop_aspect) + } + }; + let s_dst = fit_screen(p.screen.dst); + let s_dst_prev = fit_screen(pp.screen.dst); + // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée + // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) + // — SAUF quand l'app a résolu un placement explicite (`app_webcam_rect`, drag-to-reposition + // compris). Ce rect est déjà exprimé en fraction du VRAI output (calculé côté web par + // `computeCompositeLayout` avec les vraies dimensions de sortie), position ET aspect déjà + // corrects — `fit_cam_aspect`/`scale_corner_br` (chemin preset par défaut) sont donc + // doublement inadaptés ici : ils réancrent au coin bas-droite (ignorant la position + // choisie par l'utilisateur) ET recalculent l'aspect en pixels du canvas fixe 16:9 + // (`OUT_W`×`OUT_H`), une référence différente du vrai output dès que la sortie n'est pas + // 16:9 (rapport utilisateur : webcam glissée au coin bas-gauche en 9:16, JSON envoyé au + // natif confirmant une position flush, mais rendu native visiblement décalé ET trop + // petit). On garde seulement `scale_center` (zoom réactif, préserve position+aspect) puis + // on pré-compense par `inverse_undistort` pour annuler le `undistort()` générique + // appliqué plus bas à tous les calques (écran compris) — sans quoi ce rect déjà correct + // se ferait déformer une seconde fois par cet undistort partagé. + let mut w_dst = if app_webcam_rect.is_some() { + scale_center(p.webcam.dst, webcam_size_scale) + } else { + fit_cam_aspect(scale_corner_br(p.webcam.dst, webcam_size_scale)) + }; + let mut w_dst_prev = if app_webcam_rect.is_some() { + scale_center(pp.webcam.dst, webcam_size_scale_prev) + } else { + fit_cam_aspect(scale_corner_br(pp.webcam.dst, webcam_size_scale_prev)) + }; + + // Full Camera : la caméra PREND le cadre — parité `computeCameraFullscreenRect` (TS). + // La cible est exactement [0,0,1,1] : pas de marge, pas de padding, pas d'arrondi, et + // plus rien de la composition (fond, écran, ombre) derrière. Le rect change de ratio en + // chemin, mais `cover_crop_uv` (plus bas) dérive la coupe source du ratio RÉEL de la + // boîte à chaque frame : la caméra n'est donc jamais étirée pendant l'animation. + let fullscreen_dst = |dst: [f32; 4], progress: f32| -> [f32; 4] { + if progress <= 0.0 { + return dst; + } + let lerp = |a: f32, b: f32| a + (b - a) * progress; + [lerp(dst[0], 0.0), lerp(dst[1], 0.0), lerp(dst[2], 1.0), lerp(dst[3], 1.0)] + }; + // Petit côté de la boîte caméra AVANT que Full Camera ne la fasse grandir. C'est la + // référence du rayon de coin : le zoom réactif est déjà dedans (il rétrécit la boîte, + // donc l'arrondi suit tout seul — parité `borderRadius * reactiveFactor` côté TS), alors + // que Full Camera ne fait pas grossir l'arrondi, il le DISSOUT (cf. `shape_fade`). + let w_nominal_min = (w_dst[2] * self.rw()).min(w_dst[3] * self.rh()); + w_dst = fullscreen_dst(w_dst, cam_progress); + w_dst_prev = fullscreen_dst(w_dst_prev, cam_progress_prev); + + // Contre-étirement "fit" : le canvas interne compose TOUJOURS en OUT_W×OUT_H (16:9), + // puis `blit_resized` étire tout, de façon non uniforme si besoin, vers la résolution + // de sortie demandée — voulu pour que le FOND (dessiné plus bas en dst=[0,0,1,1]) + // remplisse tout le cadre quel que soit le ratio choisi. Mais l'écran et la webcam ne + // doivent PAS être déformés par cet étirement : on rétrécit ici leur rect de + // destination (centré, dans cet espace 16:9 PRÉ-étirement) par l'inverse du plus fort + // des deux facteurs d'étirement, pour qu'après l'étirement final leur ratio d'origine + // reste préservé (letterboxé/pillarboxé sur le fond, qui lui reste plein cadre) — mode + // "fit"/contain. Si l'utilisateur veut un rendu "fill" (remplir sans bandes), il ajuste + // le crop lui-même ; le natif ne fait plus ce choix à sa place en étirant l'image. + // Le dessin du coin (SDF, shaders.hlsl) compare le rayon à `quad_px`, exprimé en px du + // RENDER TARGET : c'est donc dans cet espace-là qu'il faut le lui donner. + // + // Toutes les longueurs de la scène sont des FRACTIONS ; on les multiplie ici par ce + // qu'elles mesurent, dans l'espace du render target. C'est ce qui rend preview et export + // identiques : « un pixel » n'y désigne pas la même chose (la preview rastérise dans un + // cadre contain-fitté plus petit, cf. `preview_render_size`), alors qu'une fraction, si. + // `frame_min_px` est la référence des quantités relatives au CADRE ; un rayon de coin, + // lui, se mesure contre sa propre boîte — il doit rester en place quand on redimensionne + // la boîte, pas suivre le cadre. + let frame_min_px = self.rw().min(self.rh()); + let s_min_px = (s_dst[2] * self.rw()).min(s_dst[3] * self.rh()); + let app_screen_radius_frac = scene_ref.as_ref().and_then(|s| s.layout.screen_radius_frac); + let scene_roundness_frac = scene_ref.as_ref().map(|s| s.effects.roundness_frac); + let s_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { + (false, _, _) => 0.0, + // Preset en bloc : le rayon appartient à la boîte écran (parité exacte avec la caméra). + (true, Some(f), _) => f * s_min_px, + // Scène sans rayon imposé : slider Roundness, relatif au cadre. + (true, None, Some(f)) => f * frame_min_px, + // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. + (true, None, None) => p.screen.radius * lp.radius_scale, + }; + let w_px = [w_dst[2] * self.rw(), w_dst[3] * self.rh()]; + // Rayon caméra. Le slider Roundness ne s'y applique jamais (il ne vaut que pour l'ÉCRAN). + // Quand l'app le résout (`computeCompositeLayout`, source unique), on le prend : c'est la + // seule façon que les deux moitiés d'un layout en bloc soient encadrées à l'identique, + // l'écran consommant déjà `screen_radius_frac` du même calcul. La table ci-dessous en + // était une SECONDE, indépendante — fraction différente (0.12 vs 0.06 côté web) et sans + // bornes — donc écran et caméra ne pouvaient pas s'accorder. + let app_webcam_radius_frac = scene_ref.as_ref().and_then(|s| s.layout.webcam_radius_frac); + // Full Camera dissout la forme en même temps qu'elle prend le cadre : le rayon fond + // vers 0 avec `cam_progress`, donc le cercle devient un rect à coins de plus en plus + // francs puis un plein cadre net — aucun masque ne survit au plein écran (parité + // `computeCameraFullscreenRect`, qui ramène `maskShape` à "rectangle" et lerpe le + // rayon vers 0 pour exactement la même raison). + let shape_fade = (1.0 - cam_progress).clamp(0.0, 1.0); + let w_radius = shape_fade + * w_nominal_min + * match app_webcam_radius_frac { + Some(f) => f, + // Fallback (payload sans fraction, fixture/bench) : l'ancienne table, keyée sur la + // forme. Rectangle ET square n'ont qu'un léger arrondi (0.12) et ne diffèrent que + // par le ratio ; rounded est nettement plus arrondi (0.3) ; circle = demi-côté. + None => match lp.webcam_shape { + 1 => 0.5, + 3 => 0.3, + _ => 0.12, + }, + }; + + self.begin([0.0, 0.0, 0.0, 1.0]); + + // --- fond --- + // Parité web (frameRenderer.blurredBackgroundLayer) : le fond est le WALLPAPER sélectionné + // (image/couleur/gradient) et « Blur BG » floute CE wallpaper, PAS la vidéo. Le natif + // dupliquait la vidéo floutée → le « vieux flou ». Côté APP (scène présente) on dessine + // donc le wallpaper (couleur pour l'instant ; gradient/image rendus depuis la scène + // ensuite ; pour une couleur plate le flou est un no-op visuel). Côté fixture/bench + // (pas de scène) on garde le fond screen-flouté, dont le coût est mesuré (C4). + let scene_bg = self.scene.borrow().as_ref().map(|s| (s.background.clone(), s.effects.blur)); + if let Some((bg, blur_wallpaper)) = scene_bg { + match bg { + SceneBackground::Color { color } => { + let c = parse_hex(&color).unwrap_or(lp.bg_color); + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: c, + ..Default::default() + }); + } + SceneBackground::Gradient { angle_deg, stops } => { + let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(lp.bg_color); + let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + // angle CSS → direction unitaire (espace sortie, y vers le bas) : + // 0° = vers le haut, 90° = vers la droite. + let a = angle_deg.to_radians(); + let dir = [a.sin(), -a.cos()]; + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [c1[0], c1[1], c1[2], c1[3]], + mode: 5.0, + color: c0, + fx: [dir[0], dir[1], 0.0, 0.0], + ..Default::default() + }); + } + SceneBackground::Image { path } => { + // image bg (cover-fit, mise en cache) ; fallback couleur si chargement échoue + // (loggé — un fallback silencieux masquerait un chemin cassé, cf. le panic + // borrow qu'on a déjà eu : toute panne doit être visible/traçable). + if let Err(e) = self.draw_image_bg(&path, self.rw() / self.rh()) { + eprintln!("[compositor] wallpaper image \"{}\" : {:#}", path, e); + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }); + } + } + } + // « Blur BG » (parité web blurredBackgroundLayer) : floute CE wallpaper qu'on vient + // de dessiner (dual-Kawase, déjà utilisé pour le fond fixture ci-dessous). No-op + // visuel sur une couleur plate, effet réel sur gradient/image. + if blur_wallpaper { + self.blur_bg(18.0); + self.bind_compose_state(); + } + } else if cfg.bg_blur { + let over = 0.06; + self.draw_video( + &LayerCB { + dst: [-over, -over, 1.0 + 2.0 * over, 1.0 + 2.0 * over], + src: [0.0, 0.0, u_max, v_max], + quad_px: [self.rw(), self.rh()], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + ..Default::default() + }, + &sy, + &suv, + ); + self.blur_bg(18.0); + self.bind_compose_state(); + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: [0.0, 0.0, 0.0, 0.35], + ..Default::default() + }); + } else { + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }); + } + + // --- screen : crop du clip actif, puis zoom appliqué dans ce rect source (§8) --- + // `for_clip_window` conserve l'index pour distinguer plusieurs clips du même asset. + // `active_crop` déjà résolu plus haut (utilisé pour dimensionner `s_dst`) — une seule + // source de vérité pour ce lookup. + let s_px = [s_dst[2] * self.rw(), s_dst[3] * self.rh()]; + // Layouts "bloc" (side-by-side / top-bottom) : la boîte écran est un SLOT au ratio + // arbitraire, et le web y fait tenir l'image en `cover` (`computeCompositeLayout` + // renvoie `screenCover: true`, honoré par `frameRenderer`). Le natif l'ignorait, donc + // il étirait la source pour remplir le slot — visible dès que le clip est recadré, + // puisque le crop éloigne encore le ratio de la source de celui du slot. + // + // Le cover s'applique APRÈS le crop et le zoom, sur leur rect résultant : le crop + // décide quoi montrer, le zoom où regarder, le cover comment habiller la boîte. + let cover_box_ar = scene_ref + .as_ref() + .and_then(|s| s.layout.screen_cover.then_some(s_px[0] / s_px[1].max(0.0001))); + let cover = |uv: [f32; 4]| -> [f32; 4] { + match cover_box_ar { + Some(ar) => cover_uv_rect(uv, [stw as f32, sth as f32], ar), + None => uv, + } + }; + let [su0, sv0, su1, sv1] = + cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); + let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5); + // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant. + let [su0_p, sv0_p, su1_p, sv1_p] = + cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); + let (hu_p, hv_p) = ((su1_p - su0_p) * 0.5, (sv1_p - sv0_p) * 0.5); + // Géométrie du tilt, calculée UNE fois : l'ombre et l'écran doivent porter exactement le + // même quadrilatère. Deux calculs séparés, c'est une ombre qui se décolle dès qu'un des + // deux change. + let tilt = (!crate::regions::is_identity_rotation(zoom_rotation)) + .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], zoom_rotation)); + let quad_center_px = + [(s_dst[0] + s_dst[2] * 0.5) * self.rw(), (s_dst[1] + s_dst[3] * 0.5) * self.rh()]; + // L'ombre suit la silhouette réellement affichée : le rect arrondi quand l'écran est + // droit, le quadrilatère projeté quand il est incliné. Un rect droit derrière un écran + // penché ne se lisait pas comme son ombre mais comme une seconde surface. + if cfg.shadow { + let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; + let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; + let opacity = 0.45 * lp.shadow_scale; + match tilt.as_ref() { + None => self.draw_shadow(s_dst, s_px, s_radius, spread, offset, opacity), + Some(quad) => self.draw_quad_shadow( + &quad.corners, + quad_center_px, + // Même rayon que le plan incliné lui-même (cf. le dessin du mode 8). + s_radius * quad.scale, + spread, + offset, + opacity, + ), + } + } + if crate::regions::is_identity_rotation(zoom_rotation) { + self.draw_video( + &LayerCB { + dst: s_dst, + src: [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv], + quad_px: s_px, + radius_px: s_radius, + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + src_prev: [su0_p, sv0_p, su0_p + 2.0 * hu_p, sv0_p + 2.0 * hv_p], + dst_prev: s_dst_prev, + mb: [mb_taps, 1.0, 1.0, 0.0], + ..Default::default() + }, + &sy, + &suv, + ); + } else { + // Tilt 3D (zoom "rotation" iso/left/right) : warp bilinéaire inverse (mode 8, voir + // shaders.hlsl). Pas de motion blur dans ce chemin — le tilt est un effet bref, la + // simplification ne se voit pas. Les coins arrondis, eux, se voyaient : sans eux le + // plan a des arêtes de couteau qui tranchent le contenu en pleine phrase, et l'œil lit + // une découpe (« un overflow hidden qui tronque l'enregistrement ») là où il devrait + // lire une inclinaison. Ils sont donc rendus, dans le repère DU PLAN. + let quad = tilt.unwrap_or_else(|| { + crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], zoom_rotation) + }); + let corners = quad.corners; + // Taille du plan dans son propre repère, avant projection : c'est là que vit le rayon, + // pour qu'il reste un rayon constant le long du bord et non un arrondi qui s'étire avec + // la perspective. + let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; + let (cx_px, cy_px) = (quad_center_px[0], quad_center_px[1]); + let (min_x, max_x) = corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| { + (mn.min(x), mx.max(x)) + }); + let (min_y, max_y) = corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| { + (mn.min(y), mx.max(y)) + }); + let bbox_w = (max_x - min_x).max(1.0); + let bbox_h = (max_y - min_y).max(1.0); + let bbox_dst = [ + (cx_px + min_x) / self.rw(), + (cy_px + min_y) / self.rh(), + bbox_w / self.rw(), + bbox_h / self.rh(), + ]; + // coins en px LOCAUX à la bbox (0..bbox_w/h), pour matcher `i.local` du shader. + let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x, y - min_y] }; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + self.draw_video( + &LayerCB { + dst: bbox_dst, + src: [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv], + quad_px: [bbox_w, bbox_h], + // Le rayon suit la réduction du plan : l'écran incliné est plus petit, ses + // coins le sont d'autant, exactement comme s'il s'éloignait. + radius_px: s_radius * quad.scale, + mode: 8.0, + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], + ..Default::default() + }, + &sy, + &suv, + ); + } + + // --- curseur custom : suit le mapping src/dst (zoom+layout), click bounce, + // et flou de mouvement par fantômes le long de sa vélocité (frame-1 -> frame) --- + // Jeu de sprites résolu par l'app (art du thème + art intégrée pour les états qu'il ne + // fournit pas), sinon math dot+ring — fixture/bench sans scène uniquement. + let cursor_sprites: HashMap = self + .scene + .borrow() + .as_ref() + .map(|s| s.cursor.cursor_sprites.clone()) + .unwrap_or_default(); + // « Clip to canvas » : tronque le curseur aux bords de l'écran (utile quand le padding + // crée une marge et que la pointe, près du bord de la vidéo, dépasserait dedans). + // Rect englobant tout par défaut = pas d'effet (le mode 4/7 du shader clippe sur `fx`). + // Écran incliné : le rect droit d'origine rognerait le curseur sur les parties du plan + // qui débordent au-dessus/en dessous, donc on clippe sur la bbox du quad projeté. Un + // rect reste une approximation du quadrilatère — `fx` ne sait pas exprimer autre chose — + // mais qui ne coupe plus rien de ce qui est réellement affiché. + let cursor_bounds: [f32; 4] = match tilt.as_ref() { + None => s_dst, + Some(quad) => { + let (hx, hy) = quad.half_extents_px(); + [ + (quad_center_px[0] - hx) / self.rw(), + (quad_center_px[1] - hy) / self.rh(), + 2.0 * hx / self.rw(), + 2.0 * hy / self.rh(), + ] + } + }; + let cursor_clip_rect: [f32; 4] = match self.scene.borrow().as_ref() { + Some(s) if s.cursor.clip_to_bounds => cursor_bounds, + _ => [-1.0, -1.0, 3.0, 3.0], + }; + // « Show cursor » : piloté par la scène (contrat de l'app) quand elle est posée ; sinon + // par `cfg.cursor` (inspector / bench fixture). + let cursor_show = scene_ref + .as_ref() + .map(|s| s.cursor.show) + .unwrap_or(cfg.cursor); + if cursor_show { + let cursor_ref = self.cursor.borrow(); + if let Some(track) = cursor_ref.as_ref() { + let t = self.cursor_t_override.borrow().unwrap_or(frame / FPS); + // position sortie à un temps donné via un mapping screen (src rect + dst) + let map = |cxy: Option<(f32, f32)>, s0: [f32; 2], h: [f32; 2], dst: [f32; 4]| { + cxy.and_then(|(cx2, cy2)| { + let fx = (cx2 * u_max - s0[0]) / (2.0 * h[0]); + let fy = (cy2 * v_max - s0[1]) / (2.0 * h[1]); + if !(0.0..=1.0).contains(&fx) || !(0.0..=1.0).contains(&fy) { + return None; + } + // Écran incliné : le curseur vit SUR le plan, pas dans un calque + // au-dessus. On garde donc sa position dans le repère DU PLAN et c'est + // le dessin qui projette — position ET sprite. Le poser sur `dst`, le + // rect droit d'origine, le laissait flotter à côté de l'image, l'écart + // se comptant en dizaines de pixels là où le plan s'éloigne le plus. + Some(match tilt.as_ref() { + Some(&quad) => CursorPlacement::Tilted { + plane_pt: [fx, fy], + quad, + center_px: quad_center_px, + screen_px: s_px, + render_px: [self.rw(), self.rh()], + }, + None => CursorPlacement::Upright { + center: [dst[0] + fx * dst[2], dst[1] + fy * dst[3]], + }, + }) + }) + }; + let raw_xy = track.at(t); + // Hors de [0,1] = pointeur hors du rect source actuel (zoom serré / hors écran) — + // état normal en cours de lecture, pas une erreur : rien à dessiner cette frame. + let mapped = map(raw_xy, [su0, sv0], [hu, hv], s_dst); + if let Some(cur) = mapped { + // taille + amplitude du bounce pilotées par l'inspector (défauts = fixture). + // `padding_scale` : le curseur est un recouvrement synthétique, pas cuit dans + // la vidéo — quand le padding rétrécit l'écran, le curseur doit rétrécir + // pareil pour rester à l'échelle du contenu (sinon sa pointe semble se + // décaler/dériver à mesure que le padding grandit). + let bounce = 1.0 + (track.bounce(t) - 1.0) * lp.cursor_bounce_scale; + // Pas de facteur de tilt ici : sur un plan incliné la taille est convertie + // en fraction du plan puis projetée avec lui (voir `draw_cursor_sprite`), + // donc la réduction vient de la projection. L'ajouter en plus rétrécirait + // le curseur deux fois. + let sz = CURSOR_BASE_SIZE_FRAC + * frame_min_px + * lp.cursor_size_scale + * bounce + * padding_scale; + // flou de mouvement DU CURSEUR, indépendant de cfg.mblur_n (écran/vidéo). + // BUG corrigé : augmenter l'intensité ne faisait auparavant que sur-échantillonner + // (plus de taps) un écart figé d'1 frame (1/60s) -> la traînée ne s'allongeait + // JAMAIS, donc restait quasi invisible quel que soit le réglage. L'intensité doit + // étirer la FENÊTRE temporelle de la traînée, pas seulement sa densité d'échantillons. + // 0 -> 1 frame en arrière (net) ; 1 -> ~8 frames (~130 ms à 60fps, traînée nette). + let blur01 = lp.cursor_motion_blur.clamp(0.0, 1.0); + let has_scene = self.scene.borrow().is_some(); + let trail_frames = if has_scene { 1.0 + blur01 * 7.0 } else { 1.0 }; + // BUG corrigé : le plancher était 2 (pas 1) -> même à blur=0 le curseur + // passait TOUJOURS par le chemin additif multi-tap (poids 1/taps=0.5 chacun), + // et comme prev≠cur au pixel près, les deux copies à 0.5 d'alpha ne se + // recouvraient jamais parfaitement -> curseur en permanence semi-transparent + // (quasi invisible sur fond clair), même sans aucun flou demandé. + let taps = if has_scene { + (1.0 + blur01 * 10.0).round() as u32 // 0 -> 1 (net) ; 1 -> 11 (traînée) + } else { + cfg.mblur_n // fixture/bench : comportement historique inchangé + }; + // L'état est celui de l'instant rendu : la traînée de flou reprend le même + // sprite pour toutes ses copies, un changement d'état en plein mouvement + // n'a pas à laisser une traînée hybride. + let cursor_type = track.type_at(t).map(str::to_string); + let cursor_type = cursor_type.as_deref(); + if taps <= 1 { + self.draw_cur_themed( + &cursor_sprites, + cursor_type, + cur, + sz, + 1.0, + cursor_clip_rect, + ); + } else { + let tp = t - trail_frames / FPS; + let prev = map(track.at(tp), [su0_p, sv0_p], [hu_p, hv_p], s_dst_prev) + .unwrap_or(cur); + // Flou RÉEL, pas des copies discrètes : accumule les N échantillons dans un + // buffer ISOLÉ (transparent), pas directement sur la scène déjà composée. + // BUG précédent : additionner directement sur `self.rtv` revient à AJOUTER + // la couleur du curseur (blanc) à ce qu'il y a déjà dessous — sur un fond + // clair, ajouter du blanc*petit-alpha ne change presque rien de visible + // (déjà proche du blanc) -> curseur quasi invisible. En accumulant d'abord + // dans un buffer à part (parti de zéro, même mécanisme que le motion blur + // écran de `compose_frame_mb`), la somme reste correctement normalisée + // (alpha final ~1 si les échantillons se recouvrent), puis on la composite + // sur la scène par un blend "over" classique — correct quel que soit le fond. + self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); + self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); + let w = 1.0 / taps as f32; + self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); + for k in 0..taps { + let f = k as f32 / (taps - 1) as f32; + self.draw_cur_themed( + &cursor_sprites, + cursor_type, + prev.lerp(cur, f), + sz, + 1.0, + cursor_clip_rect, + ); + } + // composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié). + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.accum_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + // restaure l'état de composition standard (VS/PS/topologie quad-strip) pour + // le dessin de la webcam qui suit juste après. + self.bind_compose_state(); + } + } + } + } + + // --- webcam : sous-rect SOURCE couvrant la boîte de destination --- + // La coupe est dérivée du ratio RÉEL de la boîte (`cover_crop_uv`), donc la caméra + // n'est jamais étirée quel que soit le rect qu'on lui donne. + // + // Avant, la source était prise PLEIN CADRE pour rectangle/rounded, en supposant que + // « le dst matche le ratio de la source ». C'est vrai du placement par DÉFAUT + // (`fit_cam_aspect` façonne alors le dst), mais faux dès que l'app fournit le rect : + // le preset side-by-side donne à la caméra un slot de colonne au ratio arbitraire + // (cf. `computeCompositeLayout`, branche dual-frame — `webcamRect = webcamSlot`, sans + // aucun ajustement d'aspect), et la caméra y était étirée. L'hypothèse était donc + // portée par l'appelant ; la dériver ici la rend vraie par construction. + // + // Le center-crop carré de square/circle en est un cas particulier (boîte 1:1) — il n'a + // plus besoin d'être traité à part. + let (su0, sv0, su1, sv1) = cover_crop_uv( + [wcw, wch], + [wtw as f32, wth as f32], + w_px[0] / w_px[1].max(0.0001), + ); + // miroir = échanger les bornes u du rect source (flip horizontal). + let (u0, u1) = if lp.webcam_mirror { (su1, su0) } else { (su0, su1) }; + if lp.has_webcam { + // L'ombre portée appartient à la bulle flottante PiP : elle se retire avec elle + // (`shape_fade`), pour qu'au plein écran plus rien n'encadre la caméra. C'est une + // ombre légère NON paramétrable — indépendante du slider Shadow, qui ne pilote plus + // que l'écran (`WEBCAM_SHADOW_OPACITY`, pas `shadow_scale`) — et propre au PiP : les + // blocs side-by-side / top-bottom soudent la caméra à l'écran et n'en portent aucune + // (parité `preset.shadow` web, `null` hors PiP dans `compositeLayout.ts`). + let webcam_is_block = matches!( + scene_preset.as_deref(), + Some("dual-frame") | Some("vertical-stack"), + ); + if cfg.shadow && !webcam_is_block && shape_fade > 0.0 { + let strength = WEBCAM_SHADOW_OPACITY * shape_fade; + self.draw_shadow( + w_dst, + w_px, + w_radius, + WEBCAM_SHADOW_SPREAD_FRAC * frame_min_px, + [0.0, WEBCAM_SHADOW_OFFSET_FRAC * frame_min_px], + strength, + ); + } + self.draw_video( + &LayerCB { + dst: w_dst, + src: [u0, sv0, u1, sv1], + quad_px: w_px, + radius_px: w_radius, + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + src_prev: [u0, sv0, u1, sv1], // src fixe (pas de zoom webcam) + dst_prev: w_dst_prev, + mb: [mb_taps, 1.0, 1.0, 0.0], + ..Default::default() + }, + &wy, + &wuv, + ); + } + + // --- annotations : calque le plus haut, comme dans le DOM de la preview (le calque y est + // monté après la vidéo). Ancrées sur `s_dst`, le rect ÉCRAN — c'est le conteneur que reçoit + // l'overlay web (`layout.screenRect`) — et volontairement pas sur le rect de sortie, ni + // sujettes au crop de zoom : dans la preview l'overlay est frère de l'élément qui porte la + // transform, donc les annotations restent en place pendant que le contenu zoome dessous. + // `source_t`, la même base de temps que les zoom/speed regions : le temps SOURCE du clip, + // pas le compteur de frames. C'est ce qui garde une annotation alignée sur l'image quand + // une speed region répète ou saute des frames. + self.draw_annotations(scene_ref.as_ref(), source_t, s_dst); + Ok(()) + } + + /// Dessine les annotations visibles à `t`. `screen_dst` = rect écran en fractions de sortie. + /// + /// Seule la « figure » (flèche) est rendue à ce stade ; texte, image et flou suivront. Les + /// types non gérés sont ignorés silencieusement plutôt que dessinés de travers : mieux vaut + /// l'absence connue qu'un placeholder qui ferait croire à un bug de style. + unsafe fn draw_annotations(&self, scene: Option<&Scene>, t: f32, screen_dst: [f32; 4]) { + let Some(scene) = scene else { return }; + if scene.annotations.is_empty() { + return; + } + let visible = |a: &crate::scene::SceneAnnotation| { + t >= a.start_sec as f32 && t < a.end_sec as f32 + }; + // Une seule recopie du render target pour TOUTES les annotations flou de la frame — leur + // lecture doit voir l'image composée sans les flous eux-mêmes, sinon deux zones qui se + // recouvrent s'échantillonneraient l'une l'autre selon l'ordre de dessin. + let needs_copy = scene + .annotations + .iter() + .any(|a| visible(a) && a.kind == "blur" && a.blur.is_some()); + if needs_copy { + // `CopySubresourceRegion` et non `CopyResource` : les deux textures n'ont pas le même + // nombre de niveaux, on ne remplit que le mip 0 puis on laisse le GPU dériver le reste. + self.ctx.CopySubresourceRegion(&self.ann_copy, 0, 0, 0, 0, &self.rt, 0, None); + self.ctx.GenerateMips(&self.ann_copy_srv); + } + // La liste arrive déjà triée par zIndex croissant côté app, donc l'ordre d'itération EST + // l'ordre de peinture — pas de tri par frame. + for annotation in &scene.annotations { + if !visible(annotation) { + continue; + } + let dst = [ + screen_dst[0] + annotation.x * screen_dst[2], + screen_dst[1] + annotation.y * screen_dst[3], + annotation.w * screen_dst[2], + annotation.h * screen_dst[3], + ]; + let quad_px = [dst[2] * self.rw(), dst[3] * self.rh()]; + if quad_px[0] <= 0.0 || quad_px[1] <= 0.0 { + continue; + } + match annotation.kind.as_str() { + "figure" => { + let Some(figure) = annotation.figure.as_ref() else { continue }; + let (segments, half_stroke) = crate::regions::arrow_local_geometry( + &figure.direction, + figure.stroke_width, + quad_px, + ); + let rgba = parse_hex(&figure.color).unwrap_or([1.0, 1.0, 1.0, 1.0]); + self.draw_solid(&LayerCB { + dst, + quad_px, + mode: 9.0, + color: rgba, + fx: segments[0], + src_prev: segments[1], + dst_prev: segments[2], + mb: [1.0, half_stroke, 0.0, 0.0], + ..Default::default() + }); + } + "blur" => { + let Some(blur) = annotation.blur.as_ref() else { continue }; + // Le masque en tracé libre demande une liste de points côté GPU (buffer + // structuré), pas encore faite : on masque alors la BOÎTE ENGLOBANTE. + // + // Ce choix est délibéré et asymétrique. Ne rien dessiner laisserait passer en + // clair, dans le fichier exporté, ce que l'utilisateur a explicitement désigné + // comme à cacher — un masque de confidentialité qui ne masque pas est pire que + // pas de masque, parce qu'il donne confiance à tort. Sur-flouter une marge + // autour de la zone ne trahit personne. + let freehand_fallback = blur.shape == "freehand"; + let is_blur = if blur.style == "blur" { 1.0 } else { 0.0 }; + // `intensity` pilote le rayon du flou, `block_size` la grille de mosaïque — + // deux réglages distincts côté app, un seul paramètre ici selon le style. + let amount = if is_blur > 0.5 { blur.intensity } else { blur.block_size }; + // Le repli du tracé libre passe par le rectangle, pas l'ovale : un ovale + // inscrit dans la boîte englobante en retirerait les coins, donc une partie de + // ce que l'utilisateur a couvert. + let is_oval = if blur.shape == "oval" && !freehand_fallback { 1.0 } else { 0.0 }; + // La teinte n'a de sens qu'en mosaïque : elle sert à marquer visiblement une + // zone caviardée. Un flou teinté ne ressemblerait plus à un flou. + let tinted = if is_blur > 0.5 { 0.0 } else { 1.0 }; + let tint = if blur.color == "black" { + [0.0, 0.0, 0.0, 1.0] + } else { + [1.0, 1.0, 1.0, 1.0] + }; + self.ctx.PSSetShaderResources(2, Some(&[Some(self.ann_copy_srv.clone())])); + self.draw_solid(&LayerCB { + dst, + quad_px, + mode: 10.0, + color: tint, + fx: [is_blur, amount.max(1.0), is_oval, tinted], + ..Default::default() + }); + } + "image" => { + let Some(src) = annotation.image_path.as_ref() else { continue }; + if src.is_empty() { + continue; + } + // Cache indexé sur l'ID de l'annotation, pas sur la data URL : celle-ci pèse + // souvent des mégaoctets, et la prendre comme clé de HashMap la ferait hacher + // à chaque frame. La longueur, stockée à côté, sert de garde-fou quand + // l'utilisateur change l'image (une nouvelle image de longueur identique au + // bit près serait manquée jusqu'au rechargement — coût accepté en connaissance). + let key = annotation.id.clone(); + let cached = { + let cache = self.ann_img_cache.borrow(); + cache.get(&key).filter(|(_, _, _, len)| *len == src.len()).cloned() + }; + let Some((srv, iw, ih, _)) = cached.or_else(|| { + match self.load_image_srv(src) { + Ok((srv, w, h)) => { + let entry = (srv, w, h, src.len()); + self.ann_img_cache.borrow_mut().insert(key, entry.clone()); + Some(entry) + } + Err(e) => { + eprintln!("[annotation image] {}: {e}", annotation.id); + None + } + } + }) else { + continue; + }; + if iw == 0 || ih == 0 { + continue; + } + // `object-contain`, comme la preview : mise à l'échelle uniforme pour tenir + // DANS la boîte, centrée. On rétrécit le rect de destination au ratio de + // l'image plutôt que de recadrer la source, ce qui donne exactement ça. + let box_aspect = quad_px[0] / quad_px[1]; + let img_aspect = iw as f32 / ih as f32; + let (fit_w, fit_h) = if img_aspect > box_aspect { + (dst[2], dst[3] * (box_aspect / img_aspect)) + } else { + (dst[2] * (img_aspect / box_aspect), dst[3]) + }; + let fit = [ + dst[0] + (dst[2] - fit_w) * 0.5, + dst[1] + (dst[3] - fit_h) * 0.5, + fit_w, + fit_h, + ]; + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.draw_solid(&LayerCB { + dst: fit, + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [fit_w * self.rw(), fit_h * self.rh()], + // mode 7 = sprite RGBA avec alpha, déjà utilisé par les thèmes de curseur : + // exactement ce qu'il faut ici, donc aucun shader de plus. `fx` est son + // rect de clip — plein cadre, pour ne rien découper. + mode: 7.0, + color: [1.0, 1.0, 1.0, 1.0], + fx: [0.0, 0.0, 1.0, 1.0], + ..Default::default() + }); + } + "text" => { + let Some(text) = annotation.text.as_ref() else { continue }; + let Some(raster) = self.text_raster.as_ref() else { continue }; + if text.content.trim().is_empty() { + continue; + } + // `font_size_rel` est une fraction de la HAUTEUR DU RECT ÉCRAN (cf. le contrat + // et `annotationScale.ts`) : on la ramène en pixels de sortie ici, avec le même + // produit que la preview applique contre sa propre boîte. + let screen_h_px = screen_dst[3] * self.rh(); + let spec = crate::text::TextSpec { + content: text.content.clone(), + color: parse_hex(&text.color).unwrap_or([1.0, 1.0, 1.0, 1.0]), + // "transparent" ne parse pas en hex : alpha 0 => pas de fond, ce qui est + // exactement la sémantique CSS. + background: parse_hex(&text.background_color).unwrap_or([0.0, 0.0, 0.0, 0.0]), + font_size_px: text.font_size_rel * screen_h_px, + font_family: text.font_family.clone(), + bold: text.font_weight == "bold", + italic: text.font_style == "italic", + underline: text.text_decoration == "underline", + align: text.text_align.clone(), + box_px: [quad_px[0].round() as u32, quad_px[1].round() as u32], + }; + let key = spec.cache_key(); + let cached = { + let cache = self.text_cache.borrow(); + cache.get(&annotation.id).filter(|(_, k)| *k == key).map(|(srv, _)| srv.clone()) + }; + let Some(srv) = cached.or_else(|| match raster.rasterize(&self.dev, &spec) { + Ok(srv) => { + self.text_cache + .borrow_mut() + .insert(annotation.id.clone(), (srv.clone(), key)); + Some(srv) + } + Err(e) => { + eprintln!("[annotation texte] {}: {e}", annotation.id); + None + } + }) else { + continue; + }; + // Animation d'apparition. Elle est comptée en temps SOURCE (le seul dont on + // dispose ici) : dans une région accélérée, elle défile donc au rythme du + // clip. À vitesse 1 — le cas de toutes les annotations existantes — c'est + // exactement le timing de l'aperçu DOM. + let anim = crate::text_anim::text_animation_state( + text.animation.as_deref(), + (t - annotation.start_sec as f32) * 1000.0, + ); + // Les décalages sont donnés à la hauteur de référence : on les ramène à la + // sortie, comme la taille de police, pour que l'animation ait la même + // amplitude visuelle quelle que soit la résolution. + let anim_px = self.rh() / crate::text_anim::ANIMATION_REFERENCE_HEIGHT; + let (mut ax, mut ay, mut aw, mut ah) = ( + dst[0] + anim.translate_x * anim_px / self.rw(), + dst[1] + anim.translate_y * anim_px / self.rh(), + dst[2], + dst[3], + ); + if (anim.scale - 1.0).abs() > 1e-4 { + // Mise à l'échelle autour du CENTRE de la boîte : un texte qui grossit par + // son coin haut-gauche glisserait en biais au lieu de gonfler sur place. + let (cx, cy) = (ax + aw * 0.5, ay + ah * 0.5); + aw *= anim.scale; + ah *= anim.scale; + ax = cx - aw * 0.5; + ay = cy - ah * 0.5; + } + // Machine à écrire : on ne rogne que la LARGEUR, source et destination + // ensemble, ce qui reproduit le `inset(0 X% 0 0)` de l'aperçu sans redemander + // une rastérisation par caractère. + let reveal = anim.reveal.clamp(0.0, 1.0); + if reveal <= 0.0 { + continue; + } + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.draw_solid(&LayerCB { + dst: [ax, ay, aw * reveal, ah], + src: [0.0, 0.0, reveal, 1.0], + quad_px: [aw * reveal * self.rw(), ah * self.rh()], + // mode 11 : sprite en alpha DÉJÀ prémultiplié (ce que produit D2D). + mode: 11.0, + color: [1.0, 1.0, 1.0, anim.opacity], + ..Default::default() + }); + } + _ => {} + } + } + self.ctx.PSSetShaderResources(2, Some(&[None])); + } + + /// Flou de mouvement (§8) : moyenne de `n` sous-frames aux temps intermédiaires + /// (mêmes textures vidéo, params d'animation à frame+k/n). Résultat laissé dans le RT. + pub unsafe fn compose_frame_mb( + &self, + screen: *const AVFrame, + webcam: *const AVFrame, + frame: u32, + cfg: &Cfg, + ) -> Result<()> { + let n = cfg.mblur_n; + if n <= 1 { + return self.compose_frame(screen, webcam, frame as f32, cfg); + } + // accumulateur à zéro + self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); + let w = 1.0 / n as f32; + for k in 0..n { + let tf = frame as f32 + (k as f32 + 0.5) / n as f32 - 0.5; + self.compose_frame(screen, webcam, tf, cfg)?; // -> self.rt + // accum += rt * (1/n) (blend factor = 1/n, dest = ONE) + self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); + self.upload_cb(&LayerCB::default()); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + } + // recopie l'accumulateur dans le RT (pour rgb_to_nv12 qui échantillonne rt_srv) + let src: ID3D11Resource = self.accum.cast()?; + let dst: ID3D11Resource = self.rt.cast()?; + self.ctx.CopyResource(&dst, &src); + Ok(()) + } + + /// Rend le RT RGBA vers notre texture NV12 puis copie vers la surface `out_tex`/`slice`. + pub unsafe fn rgb_to_nv12(&self, out_tex: *mut c_void, slice: u32) -> Result<()> { + self.render_nv12(); + let src: ID3D11Resource = self.nv12.cast()?; + let dst_tex = ID3D11Texture2D::from_raw_borrowed(&out_tex).unwrap().clone(); + let dst: ID3D11Resource = dst_tex.cast()?; + self.ctx.CopySubresourceRegion(&dst, slice, 0, 0, 0, &src, 0, None); + Ok(()) + } + + /// Alloue (une fois par taille) les ressources du resize export : RGBA intermédiaire + + /// sa propre texture NV12 à `w`×`h`. `w`/`h` doivent être pairs (exigé par NV12 4:2:0, + /// le plan UV fait exactement la moitié) — l'appelant (export_multi côté napi) arrondit. + unsafe fn ensure_resize_target(&self, w: u32, h: u32) -> Result<()> { + if let Some(t) = self.resize_target.borrow().as_ref() { + if t.w == w && t.h == h { + return Ok(()); + } + } + let rd = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut rgba: Option = None; + self.dev.CreateTexture2D(&rd, None, Some(&mut rgba))?; + let rgba = rgba.unwrap(); + let mut rgba_rtv: Option = None; + self.dev.CreateRenderTargetView(&rgba, None, Some(&mut rgba_rtv))?; + let mut rgba_srv: Option = None; + self.dev.CreateShaderResourceView(&rgba, None, Some(&mut rgba_srv))?; + + // NV12 non-array à la taille cible (même contrainte que le NV12 principal). + let nvd = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut nv12: Option = None; + self.dev.CreateTexture2D(&nvd, None, Some(&mut nv12))?; + let nv12 = nv12.unwrap(); + let mk_rtv = |fmt: DXGI_FORMAT| -> Result { + let d = D3D11_RENDER_TARGET_VIEW_DESC { + Format: fmt, + ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D, + Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 { Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 } }, + }; + let mut rtv: Option = None; + self.dev.CreateRenderTargetView(&nv12, Some(&d), Some(&mut rtv))?; + Ok(rtv.unwrap()) + }; + let nv12_rtv_y = mk_rtv(DXGI_FORMAT_R8_UNORM)?; + let nv12_rtv_uv = mk_rtv(DXGI_FORMAT_R8G8_UNORM)?; + + *self.resize_target.borrow_mut() = Some(ResizeTarget { + w, + h, + rgba_rtv: rgba_rtv.unwrap(), + rgba_srv: rgba_srv.unwrap(), + nv12, + nv12_rtv_y, + nv12_rtv_uv, + }); + Ok(()) + } + + /// Redimensionne (bilinéaire) le RT composé (OUT_W×OUT_H) vers `resize_target.rgba`, avant + /// la conversion NV12 dans `rgb_to_nv12_scaled`. + /// + /// Étirement PLEIN CADRE volontaire, y compris non uniforme quand `target_w`×`target_h` + /// n'a pas le ratio de OUT_W×OUT_H : le fond (wallpaper) doit remplir tout le cadre de + /// sortie quel que soit le ratio choisi — ce n'est PAS lui qu'il faut préserver en "fit". + /// L'écran et la webcam, eux, sont protégés de cet étirement en amont, dans + /// `compose_frame` (rétrécissement inverse de leur rect de destination AVANT ce blit — + /// voir le commentaire sur `undistort` juste avant leur dessin) : ils gardent leur ratio + /// d'origine (letterboxé/pillarboxé sur le fond, qui lui reste plein cadre) sans qu'il + /// faille toucher au viewport ici. + unsafe fn blit_resized(&self, target_w: u32, target_h: u32) -> Result<()> { + self.ensure_resize_target(target_w, target_h)?; + let cache = self.resize_target.borrow(); + let t = cache.as_ref().unwrap(); + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.OMSetRenderTargets(Some(&[Some(t.rgba_rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: target_w as f32, Height: target_h as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + Ok(()) + } + + /// Lit le RT composité (résolu à `target_w`×`target_h`, via le même `blit_resized` + /// réutilisé par `rgb_to_nv12_scaled` pour l'export) vers un `Vec` RGBA8 + /// tightly-packed (`target_w * target_h * 4` octets, ordre R,G,B,A en mémoire — ce + /// que `putImageData(..., 'rgba8')` attend côté JS). + /// + /// Pourquoi un helper dédié plutôt qu'un open-coding dans `live.rs` : tout le + /// pattern GPU→CPU de ce fichier (staging `D3D11_USAGE_STAGING`, `CopyResource`, + /// `Map`/`D3D11_MAP_READ` + copie ligne par ligne qui respecte `RowPitch`) vit déjà + /// dans `dump_nv12`/`dump_raw` — le partager garde la connaissance D3D11 confinée + /// à ce fichier et assure que le live et l'export ne divergent pas sur un détail de + /// copie. La staging est cachée par taille (`live_readback_staging`) — recréée quand + /// `target_w`/`target_h` changent — pour ne pas payer une allocation par frame. + /// + /// Pré-requis : `target_w`/`target_h` ≥ 1. Aucun effet sur le pipeline d'export + /// (les sites d'appel de `rgb_to_nv12_scaled` et `blit_resized` ne sont pas touchés + /// — ce helper réutilise `blit_resized` mais n'est pas sur le chemin d'export). + pub unsafe fn readback_resized( + &self, + target_w: u32, + target_h: u32, + ) -> Result> { + // `ensure_resize_target` (partagé avec l'export) crée INCONDITIONNELLEMENT une + // texture NV12 en plus de la RGBA, même si ce chemin RGBA-only ne s'en sert jamais — + // et NV12 (4:2:0, chroma sous-échantillonnée 2×2) exige des dimensions PAIRES. + // Le canvas Electron (taille device-pixel arbitraire, ex. 910×513) atterrit souvent + // sur une dimension impaire → `CreateTexture2D` de la texture NV12 échouait avec + // E_INVALIDARG (0x80070057), et donc TOUT le readback live (jamais une seule frame + // publiée). On arrondit au pair supérieur ici uniquement — l'export appelle + // `rgb_to_nv12_scaled`/`blit_resized` directement avec ses propres dimensions et + // n'est pas concerné par cet arrondi. + let w = (target_w.max(1) + 1) & !1; + let h = (target_h.max(1) + 1) & !1; + // Dims RÉELLEMENT demandées par l'appelant — le buffer retourné doit rester à cette + // taille exacte (le canvas JS attend `target_w*target_h*4` octets pile), même si le GPU + // travaille en interne à `w`×`h` (arrondi pair) pour satisfaire la contrainte NV12. + let out_w = target_w.max(1); + let out_h = target_h.max(1); + + // 1) Resize GPU exactement comme `rgb_to_nv12_scaled` : remplit le `resize_target` + // RGBA à `w`×`h`. On s'arrête avant la conversion NV12 — on copie le RGBA. + self.blit_resized(w, h)?; + // BUG corrigé : un SRV n'est PAS la ressource (`ID3D11ShaderResourceView` et + // `ID3D11Texture2D` sont des interfaces COM sans rapport de parenté) — un + // `.cast::()` direct sur le SRV échoue avec E_NOINTERFACE + // (0x80004002, confirmé à l'exécution). Il faut passer par `GetResource()` + // (méthode de `ID3D11View`, implémentée par tout SRV/RTV) pour récupérer la + // ressource sous-jacente, ici directement en `ID3D11Resource` — le type que + // `CopyResource` attend de toute façon, donc pas besoin d'aller jusqu'à + // `ID3D11Texture2D`. + let rgba_resource: ID3D11Resource = { + let cache = self.resize_target.borrow(); + let t = cache.as_ref().unwrap(); + t.rgba_srv.GetResource()? + }; + + // 2) Staging texture CPU-readable à la taille cible, recréée paresseusement + // quand la taille change (cache : `live_readback_staging`). + let staging = { + let mut slot = self.live_readback_staging.borrow_mut(); + match slot.as_ref() { + Some((sw, sh, t)) if *sw == w && *sh == h => t.clone(), + _ => { + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + // Même format que `resize_target.rgba` créé dans + // `ensure_resize_target` (R8G8B8A8_UNORM) — la `CopyResource` + // est valide sans conversion GPU. + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + let tex = tex.unwrap(); + *slot = Some((w, h, tex.clone())); + tex + } + } + }; + + // 3) GPU → CPU : `CopyResource` resize_target → staging, puis `Map` + copie + // ligne par ligne qui respecte `RowPitch` (cf. `dump_nv12`/`dump_raw`). + // `ID3D11Texture2D` hérite réellement de `ID3D11Resource` (contrairement au + // SRV plus haut) donc ce `.cast()` est valide. + let dst: ID3D11Resource = staging.cast()?; + self.ctx.CopyResource(&dst, &rgba_resource); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + // Crop implicite : on ne lit que les `out_w`×`out_h` premiers pixels de la texture + // (arrondie pair) — le reliquat éventuel (au plus 1px en largeur/hauteur) est ignoré. + let mut out: Vec = vec![0u8; (out_w * out_h * 4) as usize]; + let row_bytes = (out_w * 4) as usize; + for y in 0..out_h as usize { + let src_row = (m.pData as *const u8).add(y * m.RowPitch as usize); + let dst_row = out.as_mut_ptr().add(y * row_bytes); + std::ptr::copy_nonoverlapping(src_row, dst_row, row_bytes); + } + self.ctx.Unmap(&staging, 0); + Ok(out) + } + + /// Readback du RT composité vers CPU **à sa résolution de rendu**, sans aucun resize. + /// + /// Contrairement à `readback_resized` (qui passe par `blit_resized` → un `resize_target` + /// incluant une texture NV12 jamais lue par ce chemin RGBA-only, puis une staging séparée), + /// on copie directement `rt → staging` : la `staging` du compositeur est DÉJÀ dimensionnée + /// à la résolution de rendu (`new_inner`), exactement le patron de `dump_raw`. Depuis la + /// refonte ratio, le RT est rastérisé à la géométrie de sortie ramenée au panneau — soit + /// précisément la taille que la preview veut afficher —, donc le resize de `readback_resized` + /// était devenu une copie identité doublée d'une alloc NV12 inutile, du coût pur à chaque + /// frame. `readback_resized` reste pour le golden test (qui readback à une taille arbitraire). + /// + /// Retourne `(render_w, render_h, pixels)` avec `pixels.len() == render_w * render_h * 4` + /// octets RGBA8 tightly-packed. L'appelant (`live.rs`) publie ces dims dans le packet ; le + /// canvas côté JS se dimensionne dessus (frame auto-descriptive), donc aucun couplage de + /// taille à maintenir des deux côtés. + pub unsafe fn readback_direct(&self) -> Result<(u32, u32, Vec)> { + let (rw, rh) = self.render_dims(); + self.ctx.CopyResource(&self.staging, &self.rt); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&self.staging, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + // Copie ligne par ligne qui respecte `RowPitch` (la staging peut être paddée par le + // driver) — même idiome que `dump_raw`/`readback_resized`. + let row = (rw * 4) as usize; + let mut out = vec![0u8; row * rh as usize]; + for y in 0..rh as usize { + let src = (m.pData as *const u8).add(y * m.RowPitch as usize); + let dst = out.as_mut_ptr().add(y * row); + std::ptr::copy_nonoverlapping(src, dst, row); + } + self.ctx.Unmap(&self.staging, 0); + Ok((rw, rh, out)) + } + + /// Comme `rgb_to_nv12`, mais redimensionne d'abord (bilinéaire, `ps_tex`/`sampler` déjà + /// utilisés partout ailleurs dans le fichier) le RT composé — toujours rendu en interne à + /// OUT_W×OUT_H, quelle que soit la taille de sortie demandée — vers `target_w`×`target_h` + /// avant la conversion NV12. Identique à `rgb_to_nv12` (donc coût inchangé) quand la cible + /// égale la résolution interne : le live et les exports "Source"/1080p ne paient rien pour + /// cette fonctionnalité. + pub unsafe fn rgb_to_nv12_scaled( + &self, + target_w: u32, + target_h: u32, + out_tex: *mut c_void, + slice: u32, + ) -> Result<()> { + // Raccourci : la cible est déjà la taille à laquelle on vient de rastériser + // → aucun resize à faire, on convertit le RT directement. Comparé à la + // taille de rendu COURANTE et non à une constante : une fois le RT aligné + // sur `output`, c'est justement le cas nominal. + // Produire le NV12 (partagé avec l'encodeur logiciel), puis le copier GPU→GPU + // vers le pool de l'encodeur matériel — la seule partie qui lui soit propre. + let src_tex = self.nv12_source(target_w, target_h)?; + let src: ID3D11Resource = src_tex.cast()?; + let dst_tex = ID3D11Texture2D::from_raw_borrowed(&out_tex).unwrap().clone(); + let dst: ID3D11Resource = dst_tex.cast()?; + self.ctx.CopySubresourceRegion(&dst, slice, 0, 0, 0, &src, 0, None); + Ok(()) + } + + /// Rastérise le NV12 de sortie à `target_w`×`target_h` et rend LA TEXTURE du + /// compositeur qui le porte. C'est la moitié commune aux deux encodeurs : le matériel + /// la copie GPU→GPU vers le pool AMF (`rgb_to_nv12_scaled` ci-dessus), le logiciel la + /// relit vers la RAM (`read_nv12_scaled` ci-dessous). Extraite pour que les deux + /// backends produisent le MÊME NV12 — sinon l'export CPU dériverait du matériel sur + /// un détail de conversion, exactement ce que l'iso doit empêcher. + unsafe fn nv12_source(&self, target_w: u32, target_h: u32) -> Result { + let (rw_i, rh_i) = self.render_dims(); + if target_w == rw_i && target_h == rh_i { + self.render_nv12(); + return Ok(self.nv12.clone()); + } + // Même séquence que `rgb_to_nv12_scaled`, dont c'est la partie « produire ». + self.blit_resized(target_w, target_h)?; + let cache = self.resize_target.borrow(); + let t = cache.as_ref().unwrap(); + self.ctx.OMSetRenderTargets(Some(&[Some(t.nv12_rtv_y.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(t.rgba_srv.clone())])); + let vp_y = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: target_w as f32, Height: target_h as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp_y])); + self.ctx.PSSetShader(&self.ps_y, None); + self.ctx.Draw(3, 0); + + self.ctx.OMSetRenderTargets(Some(&[Some(t.nv12_rtv_uv.clone())]), None); + let vp_uv = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: (target_w / 2) as f32, Height: (target_h / 2) as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp_uv])); + self.ctx.PSSetShader(&self.ps_uv, None); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + Ok(t.nv12.clone()) + } + + /// Le NV12 de sortie LU vers la mémoire système, plan Y puis plan UV. + /// + /// Pendant de `rgb_to_nv12_scaled` pour un encodeur LOGICIEL : `libopenh264` ne sait + /// pas prendre une texture D3D11, il veut des plans en RAM. C'est la seule copie + /// GPU→CPU du chemin d'export CPU, et elle est inévitable — le backend CPU rastérise + /// sur WARP (donc déjà en RAM côté pilote) mais D3D11 n'expose pas ces octets + /// autrement que par une staging. + /// + /// `dst_y`/`dst_uv` doivent tenir `target_h * pitch_y` et `target_h/2 * pitch_uv` + /// octets — typiquement les `data[0]`/`data[1]` d'une `AVFrame` NV12. + pub unsafe fn read_nv12_scaled( + &self, + target_w: u32, + target_h: u32, + dst_y: *mut u8, + pitch_y: usize, + dst_uv: *mut u8, + pitch_uv: usize, + ) -> Result<()> { + let src_tex = self.nv12_source(target_w, target_h)?; + + // Staging NV12 cachée par taille (même idiome que `live_readback_staging`) : + // une allocation par changement de résolution, pas une par frame. + let mut cache = self.nv12_readback_staging.borrow_mut(); + if cache.as_ref().map(|(w, h, _)| (*w, *h)) != Some((target_w, target_h)) { + let sd = D3D11_TEXTURE2D_DESC { + Width: target_w, + Height: target_h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + }; + let mut t: Option = None; + self.dev.CreateTexture2D(&sd, None, Some(&mut t))?; + *cache = Some((target_w, target_h, t.unwrap())); + } + let staging = &cache.as_ref().unwrap().2; + + let src: ID3D11Resource = src_tex.cast()?; + let dst: ID3D11Resource = staging.cast()?; + self.ctx.CopyResource(&dst, &src); + + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&dst, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + // Disposition NV12 mappée : Y sur `target_h` lignes de `RowPitch`, puis UV sur + // `target_h/2` lignes au même pitch. Copie ligne par ligne — les deux pitchs + // diffèrent (le driver pad, ffmpeg aligne sur son propre SIMD). + let row = (target_w as usize).min(m.RowPitch as usize).min(pitch_y); + for y in 0..target_h as usize { + let s = (m.pData as *const u8).add(y * m.RowPitch as usize); + std::ptr::copy_nonoverlapping(s, dst_y.add(y * pitch_y), row); + } + let uv_src = (m.pData as *const u8).add(m.RowPitch as usize * target_h as usize); + let uv_row = (target_w as usize).min(m.RowPitch as usize).min(pitch_uv); + for y in 0..(target_h as usize / 2) { + let s = uv_src.add(y * m.RowPitch as usize); + std::ptr::copy_nonoverlapping(s, dst_uv.add(y * pitch_uv), uv_row); + } + self.ctx.Unmap(&dst, 0); + Ok(()) + } + + /// Convertit le RT RGBA vers notre texture NV12 (§5) : Y pleine réso, UV demi-réso. + pub unsafe fn render_nv12(&self) { + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + + // passe Y : basculer le RT AVANT de binder le SRV (le RGBA RT était encore RTV via + // begin() ; D3D11 rejetterait le SRV d'une ressource encore liée en RTV). + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv_y.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); + let vp_y = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp_y])); + self.ctx.PSSetShader(&self.ps_y, None); + self.ctx.Draw(3, 0); + + // passe UV (demi-résolution) + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv_uv.clone())]), None); + let vp_uv = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw() / 2.0, Height: self.rh() / 2.0, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp_uv])); + self.ctx.PSSetShader(&self.ps_uv, None); + self.ctx.Draw(3, 0); + + // libère le SRV du RT (il redevient RTV au prochain begin()) + self.ctx.PSSetShaderResources(0, Some(&[None])); + } + + /// Debug : dump notre NV12 (Y puis UV entrelacé) en RAW, pour inspecter la conversion. + pub unsafe fn dump_nv12(&self, path: &str) -> Result<()> { + let sd = D3D11_TEXTURE2D_DESC { + Width: OUT_W, + Height: OUT_H, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + }; + let mut stg: Option = None; + self.dev.CreateTexture2D(&sd, None, Some(&mut stg))?; + let stg = stg.unwrap(); + let src: ID3D11Resource = self.nv12.cast()?; + let dstr: ID3D11Resource = stg.cast()?; + self.ctx.CopyResource(&dstr, &src); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&stg, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + let (rw_i, rh_i) = self.render_dims(); + let mut out = Vec::with_capacity((rw_i * rh_i * 3 / 2) as usize); + // plan Y + for y in 0..rh_i as usize { + let row = (m.pData as *const u8).add(y * m.RowPitch as usize); + out.extend_from_slice(std::slice::from_raw_parts(row, rw_i as usize)); + } + // plan UV : commence à RowPitch*Height (offset donné par le pitch), demi-hauteur + let uv_off = m.RowPitch as usize * rh_i as usize; + for y in 0..(rh_i / 2) as usize { + let row = (m.pData as *const u8).add(uv_off + y * m.RowPitch as usize); + out.extend_from_slice(std::slice::from_raw_parts(row, rw_i as usize)); + } + self.ctx.Unmap(&stg, 0); + std::fs::write(path, &out)?; + Ok(()) + } + + /// Recopie le RT en RAM (RGBA tightly-packed) — vérification uniquement. + pub unsafe fn dump_raw(&self, path: &str) -> Result<()> { + self.ctx.CopyResource(&self.staging, &self.rt); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&self.staging, 0, D3D11_MAP_READ, 0, Some(&mut m))?; + let (rw_i, rh_i) = self.render_dims(); + let mut out = vec![0u8; (rw_i * rh_i * 4) as usize]; + for y in 0..rh_i as usize { + let src = (m.pData as *const u8).add(y * m.RowPitch as usize); + let dst = out.as_mut_ptr().add(y * rw_i as usize * 4); + std::ptr::copy_nonoverlapping(src, dst, rw_i as usize * 4); + } + self.ctx.Unmap(&self.staging, 0); + std::fs::write(path, &out)?; + Ok(()) + } + + /// Blit du RT composité (RGBA) vers un render target externe (backbuffer swapchain), + /// mis à l'échelle dans le viewport `(x,y,w,h)` en pixels — sert la preview (§preview). + /// Passe de copie `ps_tex` : même échantillonnage que `render_nv12`, sans conversion. + /// Le caller a déjà clear le RTV (barres letterbox) avant l'appel. + pub unsafe fn blit_to(&self, rtv: &ID3D11RenderTargetView, x: f32, y: f32, w: f32, h: f32) { + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.rt_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + self.ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); + let vp = D3D11_VIEWPORT { + TopLeftX: x, TopLeftY: y, Width: w, Height: h, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.upload_cb(&LayerCB::default()); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + } + + /// Vide le cache de SRV décodeur. À appeler après la fermeture d'un jeu de décodeurs + /// (p.ex. après un export) pour ne pas retenir indéfiniment des textures de pool. + pub fn clear_srv_cache(&self) { + self.srv_cache.borrow_mut().clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Le pivot doit rester collé à `center` quand le sprite grandit — c'est exactement ce qui + /// était cassé (ancrage centré en dur : la pointe s'éloignait proportionnellement à la + /// taille). On dessine la même flèche à deux tailles et on vérifie que le point désigné + /// ne bouge pas. + #[test] + fn sprite_hotspot_stays_on_target_at_any_size() { + let center = [0.4, 0.6]; + let hotspot = [0.119, 0.0874]; // flèche intégrée : la pointe, près du coin haut-gauche + + for (w, h) in [(0.02, 0.04), (0.08, 0.16)] { + let dst = cursor_sprite_dst(center, w, h, hotspot); + let pivot = [dst[0] + dst[2] * hotspot[0], dst[1] + dst[3] * hotspot[1]]; + assert!((pivot[0] - center[0]).abs() < 1e-6, "x drifted at {w}x{h}: {pivot:?}"); + assert!((pivot[1] - center[1]).abs() < 1e-6, "y drifted at {w}x{h}: {pivot:?}"); + assert_eq!([dst[2], dst[3]], [w, h], "taille altérée"); + } + + // Et un pivot centré reste bien l'ancien comportement, pour les sprites qui le veulent + // (viseur, I-beam, poignées de redimensionnement). + assert_eq!(cursor_sprite_dst([0.5, 0.5], 0.2, 0.2, [0.5, 0.5]), [0.4, 0.4, 0.2, 0.2]); + } + + /// Le HLSL est compilé au démarrage du compositeur : jusqu'ici une faute dedans ne se voyait + /// qu'à l'exécution, donc après un rebuild du natif ET un relancement de l'app. `D3DCompile` + /// ne demande aucun device — le compilateur seul suffit, et ça tient en quelques + /// millisecondes. + #[test] + fn every_shader_entry_point_compiles() { + let hlsl = include_bytes!("shaders.hlsl"); + for (entry, target) in [ + (&b"vs_main\0"[..], &b"vs_5_0\0"[..]), + (&b"ps_main\0"[..], &b"ps_5_0\0"[..]), + (&b"vs_fs\0"[..], &b"vs_5_0\0"[..]), + (&b"ps_y\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_uv\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_blur\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_tex\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_kawase_down\0"[..], &b"ps_5_0\0"[..]), + (&b"ps_kawase_up\0"[..], &b"ps_5_0\0"[..]), + ] { + let name = String::from_utf8_lossy(&entry[..entry.len() - 1]).to_string(); + unsafe { compile(hlsl, entry, target) } + .unwrap_or_else(|e| panic!("{name} ne compile pas : {e}")); + } + } + + fn assert_rect(actual: [f32; 4], expected: [f32; 4]) { + for (actual, expected) in actual.into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-6, "actual={actual}, expected={expected}"); + } + } + + #[test] + fn decodes_a_base64_data_uri() { + // "Hi!" -> SGkh + assert_eq!(decode_data_uri("data:image/png;base64,SGkh").unwrap(), b"Hi!".to_vec()); + } + + #[test] + fn ignores_padding_and_line_breaks_inside_the_payload() { + // Un URI replié ou paddé doit décoder à l'identique : les caractères hors alphabet sont + // sautés, donc ils ne peuvent pas décaler le flux. + let folded = "data:image/png;base64,SGkh +=="; + assert_eq!(decode_data_uri(folded).unwrap(), b"Hi!".to_vec()); + } + + #[test] + fn a_plain_path_is_not_a_data_uri() { + // Le repli lecture-disque des wallpapers en dépend. + assert!(decode_data_uri("/wallpapers/x.jpg").is_none()); + assert!(decode_data_uri("C:/img/y.png").is_none()); + } + + #[test] + fn a_non_base64_data_uri_is_refused() { + // `data:image/svg+xml,` n'est pas du base64 : mieux vaut échouer que décoder du + // texte comme des octets. + assert!(decode_data_uri("data:image/svg+xml,").is_none()); + } + + #[test] + fn crop_maps_visible_frame_fractions_to_texture_uvs() { + let crop = SceneCrop { x: 0.25, y: 0.1, width: 0.5, height: 0.6 }; + assert_rect(screen_source_rect(0.8, 0.9, None, 1.0, [0.2, 0.7]), [0.0, 0.0, 0.8, 0.9]); + assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 1.0, [0.5, 0.5]), [0.2, 0.09, 0.6, 0.63]); + } + + #[test] + fn zoom_focus_is_applied_inside_the_crop() { + let crop = SceneCrop { x: 0.25, y: 0.1, width: 0.5, height: 0.6 }; + assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 2.0, [0.5, 0.5]), [0.3, 0.225, 0.5, 0.495]); + assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 2.0, [1.0, 1.0]), [0.4, 0.36, 0.6, 0.63]); + } + + // --- cover_crop_uv : la caméra n'est jamais étirée -------------------- + // Le ratio de la coupe source, ramené en pixels d'image, doit TOUJOURS égaler + // celui de la boîte : c'est la définition de « pas de déformation ». + + /// Ratio largeur/hauteur de la coupe, exprimé en pixels de l'image source. + fn crop_aspect(uv: (f32, f32, f32, f32), tex: [f32; 2]) -> f32 { + ((uv.2 - uv.0) * tex[0]) / ((uv.3 - uv.1) * tex[1]) + } + + /// L'invariant, balayé sur des boîtes très diverses — dont le slot en colonne + /// du preset side-by-side, qui est précisément le cas qui étirait la caméra. + #[test] + fn cover_crop_never_distorts_whatever_the_destination_box() { + let tex = [1024.0, 1024.0]; + for &cam in &[[1280.0, 720.0], [960.0, 720.0], [640.0, 480.0]] { + for &box_ar in &[0.35, 0.5, 0.75, 1.0, 16.0 / 9.0, 2.4] { + let uv = cover_crop_uv(cam, tex, box_ar); + let got = crop_aspect(uv, tex); + assert!( + (got - box_ar).abs() < 1e-3, + "cam {cam:?} boite {box_ar} → coupe de ratio {got}, attendu {box_ar}", + ); + } + } + } + + /// La coupe reste DANS l'image visible et centrée — on ne va jamais chercher + /// le padding décodeur au-delà de `visible`, qui contient des pixels indéfinis. + #[test] + fn cover_crop_stays_inside_the_visible_frame_and_is_centred() { + let (cam, tex) = ([1280.0, 720.0], [2048.0, 1024.0]); + for &box_ar in &[0.35, 1.0, 2.4] { + let (u0, v0, u1, v1) = cover_crop_uv(cam, tex, box_ar); + assert!(u0 >= 0.0 && v0 >= 0.0, "coupe hors image: {u0},{v0}"); + assert!(u1 <= cam[0] / tex[0] + 1e-6, "u1 {u1} deborde la largeur visible"); + assert!(v1 <= cam[1] / tex[1] + 1e-6, "v1 {v1} deborde la hauteur visible"); + let (mx, my) = (u0 + u1, v0 + v1); + assert!((mx - cam[0] / tex[0]).abs() < 1e-6, "pas centre en x"); + assert!((my - cam[1] / tex[1]).abs() < 1e-6, "pas centre en y"); + } + } + + /// L'écran en layout bloc : le cover s'applique au rect DÉJÀ réduit par le crop + /// et le zoom. Quel que soit ce rect de départ, ce qui atterrit dans la boîte a + /// le ratio de la boîte — c'est ce qui empêche l'étirement. + #[test] + fn cover_uv_rect_gives_the_box_aspect_whatever_the_crop_and_zoom_left() { + let tex = [2048.0, 1024.0]; + // rects source plausibles : plein cadre, bande verticale (crop portrait), zoom serré + for &uv in &[ + [0.0, 0.0, 0.9375, 0.7031], + [0.41, 0.04, 0.55, 0.67], + [0.30, 0.20, 0.55, 0.45], + ] { + for &box_ar in &[0.4, 0.75, 1.0, 1.9, 3.2] { + let out = cover_uv_rect(uv, tex, box_ar); + let got = ((out[2] - out[0]) * tex[0]) / ((out[3] - out[1]) * tex[1]); + assert!( + (got - box_ar).abs() / box_ar < 1e-3, + "uv {uv:?} boite {box_ar} -> ratio {got}", + ); + // le cover RÉDUIT : il ne va jamais chercher des pixels hors du rect source + assert!(out[0] >= uv[0] - 1e-6 && out[1] >= uv[1] - 1e-6, "deborde en haut/gauche"); + assert!(out[2] <= uv[2] + 1e-6 && out[3] <= uv[3] + 1e-6, "deborde en bas/droite"); + } + } + } + + /// Propriété de sûreté : quand la boîte a DÉJÀ le ratio de la source (tous les + /// placements qui étaient corrects — PiP par défaut, vertical-stack, et le + /// center-crop carré de square/circle), la coupe est la frame entière. Le + /// correctif ne peut donc pas déplacer un pixel de ces cas-là. + #[test] + fn cover_crop_is_the_whole_frame_when_the_box_already_matches() { + let (cam, tex) = ([1280.0, 720.0], [2048.0, 1024.0]); + let uv = cover_crop_uv(cam, tex, cam[0] / cam[1]); + assert!((uv.0).abs() < 1e-6 && (uv.1).abs() < 1e-6); + assert!((uv.2 - cam[0] / tex[0]).abs() < 1e-6); + assert!((uv.3 - cam[1] / tex[1]).abs() < 1e-6); + // et une boîte carrée sur une source 4:3 redonne bien le center-crop carré + // que l'ancien branchement `is_square_shape` codait à la main. + let (su0, _, su1, _) = cover_crop_uv([960.0, 720.0], tex, 1.0); + assert!((su0 - (960.0 - 720.0) * 0.5 / tex[0]).abs() < 1e-6); + assert!((su1 - (960.0 + 720.0) * 0.5 / tex[0]).abs() < 1e-6); + } +} diff --git a/crates/compositor/src/config.rs b/crates/compositor/src/config.rs new file mode 100644 index 0000000000..8a2069e2d1 --- /dev/null +++ b/crates/compositor/src/config.rs @@ -0,0 +1,47 @@ +//! Configurations cumulatives (§9) : chaque cfg ajoute une couche. Le delta de fps +//! entre deux lignes = le coût de la couche ajoutée. Même fixture, mêmes réglages sortie. + +#[derive(Clone)] +pub struct Cfg { + pub name: &'static str, + pub composite: bool, // C1+ : composite 2 sources (sinon décode+encode seul = C0) + pub rounded: bool, // C2+ : coins arrondis (SDF) + pub shadow: bool, // C3+ : ombres portées + pub bg_blur: bool, // C4+ : fond flouté (gaussien séparable) + pub zoom: bool, // C5+ : zoom animé + pub layout_anim: bool, // C6+ : animation de layout A<->B + pub cursor: bool, // C7+ : curseur custom + click bounce + pub mblur_n: u32, // C8 : flou de mouvement — nb de taps du flou par vélocité (1 = off) + pub desc: &'static str, +} + +impl Cfg { + pub fn by_name(name: &str) -> Option { + all().into_iter().find(|c| c.name == name) + } +} + +/// C0..C8, cumulatives. +pub fn all() -> Vec { + let base = Cfg { + name: "C0", + composite: false, + rounded: false, + shadow: false, + bg_blur: false, + zoom: false, + layout_anim: false, + cursor: false, + mblur_n: 1, + desc: "décode + encode, aucun composite", + }; + let c1 = Cfg { name: "C1", composite: true, desc: "+ fond, layout, 2 sources (E1)", ..base.clone() }; + let c2 = Cfg { name: "C2", rounded: true, desc: "+ coins arrondis (E2)", ..c1.clone() }; + let c3 = Cfg { name: "C3", shadow: true, desc: "+ ombres portées (E4)", ..c2.clone() }; + let c4 = Cfg { name: "C4", bg_blur: true, desc: "+ fond flouté (E3)", ..c3.clone() }; + let c5 = Cfg { name: "C5", zoom: true, desc: "+ zoom animé", ..c4.clone() }; + let c6 = Cfg { name: "C6", layout_anim: true, desc: "+ animation de layout", ..c5.clone() }; + let c7 = Cfg { name: "C7", cursor: true, desc: "+ curseur custom (bounce)", ..c6.clone() }; + let c8 = Cfg { name: "C8", mblur_n: 8, desc: "+ flou de mouvement (vélocité, 8 taps)", ..c7.clone() }; + vec![base, c1, c2, c3, c4, c5, c6, c7, c8] +} diff --git a/crates/compositor/src/cpu_frames.rs b/crates/compositor/src/cpu_frames.rs new file mode 100644 index 0000000000..c7a319308f --- /dev/null +++ b/crates/compositor/src/cpu_frames.rs @@ -0,0 +1,241 @@ +//! L'axe DÉCODAGE du backend CPU : une frame libavcodec en mémoire système devient une +//! texture NV12 D3D11, présentée exactement comme si D3D11VA l'avait produite. +//! +//! Pourquoi ce fichier existe séparément : le rendu et le décodage sont deux axes +//! indépendants (voir `d3d::Backend`). WARP couvre le premier et *rien* du second — aucun +//! rastériseur logiciel, sur aucune plateforme, ne décode de la vidéo. Le repli logiciel +//! demandait donc cette pièce-ci en plus, et c'est elle (avec `d3d.rs`) qu'un portage +//! Metal/Vulkan réécrit. `compositor.rs`, les shaders HLSL et le contrat de scène ne +//! bougent pas d'un octet. +//! +//! Le contrat tenu ici est minuscule et c'est ce qui rend le tout iso. Tout ce que le +//! compositeur lit d'une frame, c'est (`compositor::nv12_srvs` / `compositor::tex_dims`) : +//! - `data[0]` : un `ID3D11Texture2D*` NV12, +//! - `data[1]` : l'index de tranche d'array, +//! - `width`/`height` : les dimensions VISIBLES dans cette texture. +//! On remplit ces quatre champs et rien d'autre change. + +use crate::ffi::*; +use anyhow::{bail, Result}; +use std::ptr; +use windows::core::Interface; +use windows::Win32::Graphics::Direct3D11 as d3d11; +use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC}; + +/// Le flag d'algorithme de swscale. Bindgen ne génère pas les `SWS_*` d'algorithme (des +/// macros), et leurs valeurs sont figées par l'ABI de libswscale. `POINT` (plus proche +/// voisin) est le choix honnête : la conversion se fait à dimensions ÉGALES, donc aucun +/// rééchantillonnage n'a lieu — seul le convertisseur de format travaille, et le filtre +/// choisi n'a aucun effet sur la sortie. +const SWS_POINT: i32 = 0x10; + +/// Source de frames du backend CPU, attachée à un `Decoder` quand `Backend::Cpu`. +pub(crate) struct CpuFrames { + dev: d3d11::ID3D11Device, + ctx: d3d11::ID3D11DeviceContext, + sws: *mut SwsContext, + /// `(w, h, format source)` du contexte swscale courant. Un flux qui change de + /// résolution en cours de route (rare mais légal) le reconstruit au lieu de + /// convertir de travers. + sws_key: (i32, i32, i32), + /// NV12 en mémoire système : la cible de swscale, la source de l'upload. + nv12: *mut AVFrame, + /// La texture NV12 échantillonnée par les shaders. UNE seule, réécrite à chaque + /// frame — le `srv_cache` du compositeur (clé `(ptr, slice)`) n'a donc qu'une entrée + /// et ne recrée jamais de SRV, contrairement au pool tournant de D3D11VA. + // ponytail: une seule texture = le CPU peut attendre que le GPU ait fini de lire la + // frame précédente. Sur WARP tout est CPU et le pilote sérialise déjà ; si un backend + // GPU réutilise ce chemin un jour et que le Map bloque, double-bufferiser ici. + tex: Option, + tex_dims: (u32, u32), + /// La frame remise au compositeur. Ne possède aucun pixel : ses `data[0]`/`data[1]` + /// pointent la texture ci-dessus, exactement comme une frame `AV_PIX_FMT_D3D11`. + present: *mut AVFrame, +} + +impl CpuFrames { + pub(crate) fn new(gpu: &crate::d3d::Gpu) -> Result { + let present = unsafe { av_frame_alloc() }; + let nv12 = unsafe { av_frame_alloc() }; + if present.is_null() || nv12.is_null() { + bail!("av_frame_alloc (backend CPU)"); + } + Ok(CpuFrames { + dev: gpu.device.clone(), + ctx: gpu.context.clone(), + sws: ptr::null_mut(), + sws_key: (0, 0, -1), + nv12, + tex: None, + tex_dims: (0, 0), + present, + }) + } + + /// Convertit `src` (sortie décodeur, mémoire système) en NV12, l'uploade, et rend la + /// frame de présentation. Le pointeur reste valide jusqu'au prochain appel — même + /// contrat que `Decoder::next` côté matériel. + pub(crate) unsafe fn present(&mut self, src: *mut AVFrame) -> Result<*mut AVFrame> { + let (w, h) = ((*src).width, (*src).height); + if w <= 0 || h <= 0 { + bail!("frame décodée sans dimensions ({w}x{h})"); + } + self.ensure_sws(w, h, (*src).format)?; + self.ensure_nv12(w, h)?; + + // Les plans NV12 de destination sont ceux de `self.nv12` : swscale écrit + // directement au bon format, on n'entrelace rien à la main (10 bits, 4:2:2 et + // consorts passent donc aussi, là où une boucle écrite ici casserait en silence). + let converted = sws_scale( + self.sws, + (*src).data.as_ptr() as *const *const u8, + (*src).linesize.as_ptr(), + 0, + h, + (*self.nv12).data.as_mut_ptr(), + (*self.nv12).linesize.as_ptr(), + ); + if converted <= 0 { + bail!("sws_scale a converti {converted} lignes"); + } + + self.upload(w, h)?; + Ok(self.present) + } + + unsafe fn ensure_sws(&mut self, w: i32, h: i32, src_fmt: i32) -> Result<()> { + let key = (w, h, src_fmt); + if self.sws_key == key && !self.sws.is_null() { + return Ok(()); + } + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + self.sws = sws_getContext( + w, + h, + src_fmt as AVPixelFormat::Type, + w, + h, + AVPixelFormat::AV_PIX_FMT_NV12, + SWS_POINT, + ptr::null_mut(), + ptr::null_mut(), + ptr::null(), + ); + if self.sws.is_null() { + bail!("sws_getContext {w}x{h} fmt {src_fmt} → NV12"); + } + self.sws_key = key; + Ok(()) + } + + unsafe fn ensure_nv12(&mut self, w: i32, h: i32) -> Result<()> { + if (*self.nv12).width == w + && (*self.nv12).height == h + && (*self.nv12).format == AVPixelFormat::AV_PIX_FMT_NV12 as i32 + { + return Ok(()); + } + av_frame_unref(self.nv12); + (*self.nv12).width = w; + (*self.nv12).height = h; + (*self.nv12).format = AVPixelFormat::AV_PIX_FMT_NV12 as i32; + if av_frame_get_buffer(self.nv12, 32) < 0 { + bail!("av_frame_get_buffer NV12 {w}x{h}"); + } + Ok(()) + } + + /// (Re)crée la texture NV12 si les dimensions ont changé. NV12 impose des dimensions + /// paires : on arrondit AU-DESSUS pour la texture et on laisse `present.width/height` + /// aux dimensions visibles — c'est le même écart texture/visible que produit + /// l'alignement macrobloc de D3D11VA (1080 → 1088), et le compositeur le gère déjà. + unsafe fn ensure_tex(&mut self, w: i32, h: i32) -> Result<()> { + let dims = ((w as u32 + 1) & !1, (h as u32 + 1) & !1); + if self.tex.is_some() && self.tex_dims == dims { + return Ok(()); + } + let desc = d3d11::D3D11_TEXTURE2D_DESC { + Width: dims.0, + Height: dims.1, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: d3d11::D3D11_USAGE_DYNAMIC, + BindFlags: d3d11::D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: d3d11::D3D11_CPU_ACCESS_WRITE.0 as u32, + MiscFlags: 0, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + self.tex = Some(tex.ok_or_else(|| anyhow::anyhow!("CreateTexture2D NV12 sans texture"))?); + self.tex_dims = dims; + Ok(()) + } + + /// Copie le NV12 système dans la texture. `Map(WRITE_DISCARD)` rend UN pointeur pour + /// les deux plans : Y sur `tex_h` lignes de `RowPitch`, puis UV sur `tex_h/2` lignes + /// au même pitch — c'est la disposition NV12 mappée que documente D3D11. + unsafe fn upload(&mut self, w: i32, h: i32) -> Result<()> { + self.ensure_tex(w, h)?; + let tex = self.tex.clone().expect("texture créée juste au-dessus"); + let resource: d3d11::ID3D11Resource = tex.cast()?; + + let mut mapped = d3d11::D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&resource, 0, d3d11::D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))?; + + let dst = mapped.pData as *mut u8; + let pitch = mapped.RowPitch as usize; + let (tex_w, tex_h) = (self.tex_dims.0 as usize, self.tex_dims.1 as usize); + let src_y = (*self.nv12).data[0]; + let src_uv = (*self.nv12).data[1]; + let sp_y = (*self.nv12).linesize[0] as usize; + let sp_uv = (*self.nv12).linesize[1] as usize; + // Ne copier que ce qui existe des deux côtés : la texture est arrondie au pair et + // les lignes de swscale sont paddées à leur propre alignement SIMD. + let row = tex_w.min(sp_y).min(pitch); + for y in 0..tex_h.min(h as usize) { + ptr::copy_nonoverlapping(src_y.add(y * sp_y), dst.add(y * pitch), row); + } + let uv_base = dst.add(pitch * tex_h); + let uv_row = tex_w.min(sp_uv).min(pitch); + for y in 0..(tex_h / 2).min((h as usize).div_ceil(2)) { + ptr::copy_nonoverlapping(src_uv.add(y * sp_uv), uv_base.add(y * pitch), uv_row); + } + + self.ctx.Unmap(&resource, 0); + + // Le contrat que lit le compositeur, et rien de plus : texture, tranche, visible. + // `data` n'est adossé à aucun `buf[]`, donc `av_frame_free` ne libérera jamais la + // texture — c'est nous qui la possédons, via `self.tex`. + (*self.present).data[0] = tex.as_raw() as *mut u8; + (*self.present).data[1] = ptr::null_mut(); // tranche 0 : notre texture n'est pas un array + (*self.present).width = w; + (*self.present).height = h; + (*self.present).format = AVPixelFormat::AV_PIX_FMT_D3D11 as i32; + Ok(()) + } + + /// La frame de présentation courante (jamais nulle) — `Decoder::cur_frame` en backend CPU. + pub(crate) fn current(&self) -> *mut AVFrame { + self.present + } +} + +impl Drop for CpuFrames { + fn drop(&mut self) { + unsafe { + // `present` n'a que des pointeurs empruntés : les remettre à zéro avant de + // libérer, pour qu'aucun code ffmpeg ne croie posséder notre texture. + (*self.present).data[0] = ptr::null_mut(); + (*self.present).data[1] = ptr::null_mut(); + av_frame_free(&mut self.present); + av_frame_free(&mut self.nv12); + if !self.sws.is_null() { + sws_freeContext(self.sws); + } + } + } +} diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs new file mode 100644 index 0000000000..8d518c6e15 --- /dev/null +++ b/crates/compositor/src/cursor.rs @@ -0,0 +1,294 @@ +//! Piste curseur depuis un `.cursor.json` openscreen. Fournit position interpolée +//! et facteur de « click bounce » — consommés par frame (temps fractionnaire), donc +//! le motion blur du curseur vient gratuitement du supersampling temporel. + +use anyhow::{Context, Result}; + +// Paramètres de suivi auto — parité stricte avec +// `src/components/video-editor/videoPlayback/constants.ts` (AUTO_FOLLOW_PARAMS), partagés +// là-bas entre preview et export pour que la caméra suive le curseur à l'identique. +const AUTO_FOLLOW_MIN_FACTOR: f32 = 0.1; +const AUTO_FOLLOW_MAX_FACTOR: f32 = 0.25; +const AUTO_FOLLOW_RAMP_DISTANCE: f32 = 0.15; +const AUTO_FOLLOW_REFERENCE_MS: f32 = 1000.0 / 40.0; + +#[derive(Clone)] +pub struct CursorTrack { + /// (t_secondes, cx, cy) normalisés dans le cadre screen, triés. + samples: Vec<(f32, f32, f32)>, + /// Même piste après lissage exponentiel adaptatif — ce que le suivi auto du zoom doit + /// consommer. `samples` reste la piste BRUTE : le rendu du curseur lui-même et le click + /// bounce doivent coller à la position réelle, seule la caméra est amortie. + follow_samples: Vec<(f32, f32, f32)>, + /// instants de clic (secondes) dans la fenêtre. + clicks: Vec, + /// CHANGEMENTS d'état du curseur : (instant, `"arrow"` / `"text"` / `"pointer"` / …), triés. + /// Une fonction en escalier, pas une valeur par échantillon : l'état tient sur des secondes + /// entières alors que la position est échantillonnée à ~120 Hz, donc n'enregistrer que les + /// transitions garde cette liste minuscule et rend `type_at` trivial. + types: Vec<(f32, String)>, +} + +/// Interpolation linéaire dans une liste `(t, x, y)` triée ; saturation aux bornes. +fn sample_at(samples: &[(f32, f32, f32)], t: f32) -> Option<(f32, f32)> { + if samples.is_empty() { + return None; + } + if t <= samples[0].0 { + let s = samples[0]; + return Some((s.1, s.2)); + } + if t >= samples[samples.len() - 1].0 { + let s = *samples.last().unwrap(); + return Some((s.1, s.2)); + } + // recherche du segment encadrant + let i = samples.partition_point(|s| s.0 <= t); + let a = samples[i - 1]; + let b = samples[i]; + let f = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + Some((a.1 + (b.1 - a.1) * f, a.2 + (b.2 - a.2) * f)) +} + +/// Port de `advanceFollowFocus` (`cursorFollowUtils.ts`) : lissage exponentiel dont le facteur +/// croît avec la distance à la cible (loin = rattrape vite, près = décélère), corrigé en temps +/// pour être indépendant de la cadence. +/// +/// La version TS est **séquentielle** : elle avance un `prev` d'une frame à l'autre. Rejouer ça +/// par frame ici ferait dépendre l'image du chemin parcouru pour l'atteindre — deux rendus du +/// même instant divergeraient selon qu'on y arrive en lecture ou par un seek, et la preview ne +/// correspondrait plus à l'export. On applique donc le même filtre UNE fois, sur les +/// échantillons de télémétrie eux-mêmes : le résultat est identique en lecture linéaire et reste +/// une pure fonction de `t`. +fn smooth_follow_samples(samples: &[(f32, f32, f32)]) -> Vec<(f32, f32, f32)> { + let mut smoothed: Vec<(f32, f32, f32)> = Vec::with_capacity(samples.len()); + let mut prev: Option<(f32, f32, f32)> = None; + for &(t, x, y) in samples { + let Some((prev_t, px, py)) = prev else { + smoothed.push((t, x, y)); + prev = Some((t, x, y)); + continue; + }; + let dt_ms = (t - prev_t) * 1000.0; + if !(dt_ms > 0.0) { + // Horodatages dupliqués : on garde la valeur déjà lissée plutôt que de diviser par 0. + smoothed.push((t, px, py)); + prev = Some((t, px, py)); + continue; + } + let (dx, dy) = (x - px, y - py); + let distance = (dx * dx + dy * dy).sqrt(); + let ramp = (distance / AUTO_FOLLOW_RAMP_DISTANCE).min(1.0); + let base = AUTO_FOLLOW_MIN_FACTOR + (AUTO_FOLLOW_MAX_FACTOR - AUTO_FOLLOW_MIN_FACTOR) * ramp; + let factor = 1.0 - (1.0 - base).powf(dt_ms / AUTO_FOLLOW_REFERENCE_MS); + let (nx, ny) = (px + dx * factor, py + dy * factor); + smoothed.push((t, nx, ny)); + prev = Some((t, nx, ny)); + } + smoothed +} + +impl CursorTrack { + /// Seul point de construction : garantit que `follow_samples` est toujours dérivé des + /// échantillons courants. Une piste re-lissée (`smoothed`) recalcule donc aussi son suivi, + /// pour que la caméra suive la trajectoire que l'utilisateur voit réellement. + fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec, types: Vec<(f32, String)>) -> CursorTrack { + let follow_samples = smooth_follow_samples(&samples); + CursorTrack { samples, follow_samples, clicks, types } + } + + /// État du curseur au temps `t` : la dernière transition à `t` ou avant. `None` avant la + /// première (enregistrement sans état tagué → l'appelant retombe sur la flèche). + pub fn type_at(&self, t: f32) -> Option<&str> { + let i = self.types.partition_point(|(tc, _)| *tc <= t); + (i > 0).then(|| self.types[i - 1].1.as_str()) + } + + /// Nombre d'échantillons de la piste (utile au diag de chargement). + pub fn sample_count(&self) -> usize { + self.samples.len() + } + + /// Charge la fenêtre [offset_ms, offset_ms + dur_s*1000] et la ramène à t=0. + pub fn load(path: &str, offset_ms: f64, dur_s: f64) -> Result { + let txt = std::fs::read_to_string(path).with_context(|| format!("lecture {path}"))?; + let v: serde_json::Value = serde_json::from_str(&txt)?; + let arr = v["samples"].as_array().context("samples[]")?; + let mut samples = Vec::new(); + let mut clicks = Vec::new(); + let mut types: Vec<(f32, String)> = Vec::new(); + let end = offset_ms + dur_s * 1000.0; + for s in arr { + let tm = s["timeMs"].as_f64().unwrap_or(-1.0); + if tm < offset_ms || tm > end { + continue; + } + let t = ((tm - offset_ms) / 1000.0) as f32; + let cx = s["cx"].as_f64().unwrap_or(0.0) as f32; + let cy = s["cy"].as_f64().unwrap_or(0.0) as f32; + samples.push((t, cx, cy)); + if s["interactionType"].as_str() == Some("click") { + clicks.push(t); + } + // Seules les TRANSITIONS sont retenues — voir `types`. Les échantillons sans + // `cursorType` (macOS ne le tague pas toujours) n'interrompent pas l'état courant : + // c'est une absence d'information, pas un retour à la flèche. + if let Some(ct) = s["cursorType"].as_str() { + if types.last().map(|(_, prev)| prev.as_str()) != Some(ct) { + types.push((t, ct.to_string())); + } + } + } + samples.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + clicks.sort_by(|a, b| a.partial_cmp(b).unwrap()); + types.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + Ok(CursorTrack::new(samples, clicks, types)) + } + + /// Position lissée au temps `t`, pour le suivi auto du zoom. La télémétrie brute est + /// échantillonnée trop finement pour piloter une caméra directement : la suivre au sample + /// près donne un pan nerveux. Voir `smooth_follow_samples`. + pub fn follow_at(&self, t: f32) -> Option<(f32, f32)> { + sample_at(&self.follow_samples, t) + } + + /// Position (cx, cy) BRUTE au temps `t` (interpolation linéaire), ou None si hors piste. + pub fn at(&self, t: f32) -> Option<(f32, f32)> { + sample_at(&self.samples, t) + } + + /// Facteur d'échelle « click bounce » — parité `getNativeCursorClickBounceScale` (TS, + /// `nativeCursor.ts`) : le curseur PRESSE (rétrécit, 0..38% de la fenêtre d'animation) + /// PUIS REBONDIT (grossit, 38..100%), pas un simple pop qui ne fait que grossir puis + /// redécroître. Seul le clic le plus récent précédant `t` compte (au-delà de la fenêtre, + /// un clic antérieur n'a plus aucun effet — contrairement à l'ancienne décroissance + /// exponentielle à queue infinie qui masquait ce bug). + pub fn bounce(&self, t: f32) -> f32 { + const ANIM_S: f32 = 0.26; // NATIVE_CURSOR_CLICK_ANIMATION_MS (TS) = 260ms + const PRESS_FRAC: f32 = 0.38; + let mut last_tc: Option = None; + for &tc in &self.clicks { + if tc <= t { + last_tc = Some(tc); // clics triés croissant -> garde le plus récent <= t + } else { + break; + } + } + let Some(tc) = last_tc else { return 1.0 }; + let elapsed = (t - tc) / ANIM_S; + if elapsed >= 1.0 { + return 1.0; + } + if elapsed < PRESS_FRAC { + let press = (elapsed / PRESS_FRAC * std::f32::consts::PI).sin(); + 1.0 - press * 0.24 + } else { + let rebound = ((elapsed - PRESS_FRAC) / (1.0 - PRESS_FRAC) * std::f32::consts::PI).sin(); + 1.0 + rebound * 0.16 + } + } + + /// Piste repositionnée par un ressort-amortisseur (parité `cursorPathSmoothing.ts` : + /// resample à 240 Hz + intégration semi-implicite d'Euler). `factor` 0..1 = valeur brute + /// du slider (0 = passthrough, retourne un clone). Les clics restent sur leurs instants + /// bruts (le bounce est temporel, pas positionnel — ne doit pas suivre le lissage). + pub fn smoothed(&self, factor: f32) -> CursorTrack { + if self.samples.len() < 2 || factor <= 0.0 { + return CursorTrack::new(self.samples.clone(), self.clicks.clone(), self.types.clone()); + } + const STEP_S: f32 = 1.0 / 240.0; + let start = self.samples[0].0; + let end = self.samples[self.samples.len() - 1].0; + let step_count = (((end - start) / STEP_S).round() as usize).max(1); + let n = step_count + 1; + let mut times = Vec::with_capacity(n); + let mut raw_x = Vec::with_capacity(n); + let mut raw_y = Vec::with_capacity(n); + for i in 0..n { + let t = if i == n - 1 { end } else { start + i as f32 * STEP_S }; + let (cx, cy) = self.at(t).unwrap_or((0.0, 0.0)); + times.push(t); + raw_x.push(cx); + raw_y.push(cy); + } + let (stiffness, damping, mass) = cursor_spring_config(factor); + let xs = spring_smooth(&raw_x, stiffness, damping, mass, STEP_S); + let ys = spring_smooth(&raw_y, stiffness, damping, mass, STEP_S); + let samples = times.into_iter().zip(xs).zip(ys).map(|((t, x), y)| (t, x, y)).collect(); + // Comme les clics, les changements d'état gardent leurs instants bruts : le lissage + // déplace la trajectoire, pas la chronologie de ce que faisait l'utilisateur. + CursorTrack::new(samples, self.clicks.clone(), self.types.clone()) + } +} + +/// Ressort-amortisseur, intégration semi-implicite (symplectique) d'Euler — stable pour ces +/// raideurs à la grille 240 Hz (port direct de `springSmooth` en TS). +fn spring_smooth(targets: &[f32], stiffness: f32, damping: f32, mass: f32, step_s: f32) -> Vec { + let mut out = vec![0.0f32; targets.len()]; + if targets.is_empty() { + return out; + } + let mut x = targets[0]; + let mut v = 0.0f32; + out[0] = x; + for i in 1..targets.len() { + let accel = (-stiffness * (x - targets[i]) - damping * v) / mass; + v += accel * step_s; + x += v * step_s; + out[i] = x; + } + out +} + +/// Port direct de `getCursorSpringConfig` (TS) → (stiffness, damping, mass). N'accepte que +/// 0..1 (plage réelle du slider, cf. `RightPanes.tsx` : `smoothing * 100` sur un slider 0..100). +fn cursor_spring_config(smoothing_factor: f32) -> (f32, f32, f32) { + let clamped = smoothing_factor.clamp(0.0, 2.0); + if clamped <= 0.0 { + return (1000.0, 100.0, 1.0); + } + const LEGACY_MAX: f32 = 0.5; + if clamped <= LEGACY_MAX { + let n = (clamped / LEGACY_MAX).clamp(0.0, 1.0); + return (760.0 - n * 420.0, 34.0 + n * 24.0, 0.55 + n * 0.45); + } + let n = ((clamped - LEGACY_MAX) / (2.0 - LEGACY_MAX)).clamp(0.0, 1.0); + (340.0 - n * 180.0, 58.0 + n * 22.0, 1.0 + n * 0.35) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// L'état du curseur est une fonction en escalier : il tient jusqu'à la transition + /// suivante, il n'est pas interpolé, et avant la première il n'y en a pas. + #[test] + fn cursor_type_holds_until_the_next_transition() { + let track = CursorTrack::new( + vec![(0.0, 0.0, 0.0), (2.0, 1.0, 1.0)], + vec![], + vec![(0.5, "arrow".into()), (1.0, "text".into()), (1.5, "pointer".into())], + ); + + assert_eq!(track.type_at(0.0), None, "avant la première transition"); + assert_eq!(track.type_at(0.5), Some("arrow"), "à l'instant même de la transition"); + assert_eq!(track.type_at(0.9), Some("arrow"), "tient jusqu'à la suivante"); + assert_eq!(track.type_at(1.2), Some("text")); + assert_eq!(track.type_at(99.0), Some("pointer"), "la dernière tient jusqu'à la fin"); + } + + /// Le lissage déplace la trajectoire, pas la chronologie : les états doivent survivre + /// intacts à `smoothed()`, comme les clics. + #[test] + fn smoothing_preserves_cursor_types() { + let track = CursorTrack::new( + vec![(0.0, 0.0, 0.0), (0.5, 0.4, 0.4), (1.0, 1.0, 1.0)], + vec![0.25], + vec![(0.0, "arrow".into()), (0.6, "text".into())], + ); + + let smoothed = track.smoothed(0.4); + assert_eq!(smoothed.type_at(0.1), Some("arrow")); + assert_eq!(smoothed.type_at(0.7), Some("text")); + } +} diff --git a/crates/compositor/src/d3d.rs b/crates/compositor/src/d3d.rs new file mode 100644 index 0000000000..9733b86e02 --- /dev/null +++ b/crates/compositor/src/d3d.rs @@ -0,0 +1,220 @@ +//! Le device D3D11 unique du POC (§2). +//! Un seul `ID3D11Device`, feature level 11_1, flag VIDEO_SUPPORT (décodeur), +//! et `ID3D10Multithread::SetMultithreadProtected(TRUE)` — parce que le décodeur +//! ffmpeg et notre boucle de rendu toucheront le device depuis des threads distincts. + +use anyhow::{bail, Result}; +use std::sync::OnceLock; +use windows::core::Interface; +use windows::Win32::Foundation::HMODULE; +use windows::Win32::Graphics::Direct3D::{ + D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP, D3D_FEATURE_LEVEL, + D3D_FEATURE_LEVEL_11_1, +}; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_DEBUG, D3D11_CREATE_DEVICE_FLAG, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION, +}; + +/// Qui exécute le pipeline. Le rendu et le décodage sont DEUX axes distincts, et aucune +/// plateforme n'a de rastériseur logiciel qui décode aussi la vidéo (WARP ici, lavapipe +/// sous Linux, rien du tout sous macOS) — un backend fixe donc les deux ensemble. +/// +/// Le contrat de scène, les shaders HLSL et tout `compositor.rs` sont identiques d'un +/// backend à l'autre : c'est tout l'intérêt. Un portage Metal/Vulkan remplace ce que fait +/// ce fichier et `Decoder`, pas le moteur. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Backend { + /// GPU : rastérisation matérielle + décodage D3D11VA sur le même device (zéro copie). + /// Le seul backend qui puisse exporter — l'encodeur AMF exige lui aussi le vrai GPU. + Hardware, + /// CPU : rastérisation WARP + décodage logiciel libavcodec, uploadé en NV12. + /// Pour les hôtes sans GPU D3D11 utilisable (VM, RDP, Basic Render Driver). + Cpu, +} + +impl Backend { + fn driver(self) -> D3D_DRIVER_TYPE { + match self { + Backend::Hardware => D3D_DRIVER_TYPE_HARDWARE, + Backend::Cpu => D3D_DRIVER_TYPE_WARP, + } + } + + /// WARP REFUSE `VIDEO_SUPPORT` (`DXGI_ERROR_UNSUPPORTED`, mesuré dans + /// `tests/warp_device_cannot_decode.rs`) : ce flag n'a de sens que sur le device + /// matériel, où il conditionne D3D11VA. Le backend CPU ne décode pas sur le GPU, + /// il n'en a donc pas besoin. + fn base_flags(self) -> D3D11_CREATE_DEVICE_FLAG { + match self { + Backend::Hardware => { + D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT + } + Backend::Cpu => D3D11_CREATE_DEVICE_BGRA_SUPPORT, + } + } +} + +pub struct Gpu { + pub device: ID3D11Device, + pub context: ID3D11DeviceContext, + pub feature_level: D3D_FEATURE_LEVEL, + /// Lu par `Decoder::open` pour choisir D3D11VA ou le décodage logiciel. Porté par le + /// `Gpu` plutôt que passé partout : tout ce qui tient un device sait déjà qui il est. + pub backend: Backend, +} + +/// Une tentative `D3D11CreateDevice` à FL 11_1. Extraite pour que le chemin d'échec +/// puisse re-sonder avec d'autres flags/driver et dire POURQUOI la vraie tentative +/// a échoué (voir `diagnose`), au lieu de remonter un HRESULT nu. +fn try_create( + driver: D3D_DRIVER_TYPE, + flags: D3D11_CREATE_DEVICE_FLAG, +) -> windows::core::Result<(ID3D11Device, ID3D11DeviceContext, D3D_FEATURE_LEVEL)> { + let levels = [D3D_FEATURE_LEVEL_11_1]; + let mut device: Option = None; + let mut context: Option = None; + let mut got = D3D_FEATURE_LEVEL::default(); + unsafe { + D3D11CreateDevice( + None, + driver, + HMODULE::default(), + flags, + Some(&levels), + D3D11_SDK_VERSION, + Some(&mut device), + Some(&mut got), + Some(&mut context), + )?; + } + // Le SDK garantit les deux sorties quand l'appel réussit ; `E_UNEXPECTED` plutôt + // qu'un `unwrap` pour que l'impossible reste une erreur, pas un panic. + match (device, context) { + (Some(device), Some(context)) => Ok((device, context, got)), + _ => Err(windows::core::Error::from(windows::Win32::Foundation::E_UNEXPECTED)), + } +} + +/// Message d'échec ACTIONNABLE : re-sonde pour distinguer les deux causes réelles. +/// +/// Ce message reste utile MÊME maintenant que `create_auto` replie sur le backend CPU : +/// il part dans les logs à chaque repli, et c'est lui qui dit si l'utilisateur subit un +/// pilote à mettre à jour (réparable en cinq minutes) ou une VM sans GPU (structurel). +/// Sans lui, un utilisateur au rendu logiciel ne saurait jamais qu'il lui manque un +/// pilote. Et si WARP échoue aussi, c'est ce message-ci que `create_auto` remonte. +/// +/// PR #162 proposait de retomber sur `D3D_DRIVER_TYPE_WARP` en gardant tout le reste. +/// Mesuré (`tests/warp_device_cannot_decode.rs`) : WARP + `VIDEO_SUPPORT` ne se crée même +/// pas (`DXGI_ERROR_UNSUPPORTED`), et sans ce flag il n'expose aucun `ID3D11VideoDevice` +/// (`E_NOINTERFACE`, 0 profil décodeur). Comme `pipeline.rs` passe CE device à ffmpeg +/// comme `AVD3D11VADeviceContext`, un simple changement de driver type aurait produit zéro +/// frame. C'est ce qui a donné à `Backend::Cpu` sa forme : WARP pour le rendu PLUS un +/// décodage logiciel (`cpu_frames.rs`) — le rendu et le décodage sont deux axes. +fn diagnose(err: &windows::core::Error) -> String { + // Le décodeur est le point de rupture le plus probable (RDP, VM sans passthrough, + // Microsoft Basic Render Driver) : si l'appel passe SANS VIDEO_SUPPORT, l'adaptateur + // est là, c'est son décodeur qui manque. La sonde ne garde que BGRA — surtout pas + // `flags` moins VIDEO_SUPPORT, qui traînerait `DEBUG` avec lui : sans les Graphics + // Tools de Windows la couche debug fait échouer la sonde aussi, et on accuserait + // l'adaptateur à tort. Ce cas-là se lit déjà dans `{err}` + // (`DXGI_ERROR_SDK_COMPONENT_MISSING`), il n'a pas besoin de sa propre branche. + if try_create(D3D_DRIVER_TYPE_HARDWARE, D3D11_CREATE_DEVICE_BGRA_SUPPORT).is_ok() { + return format!( + "this display adapter has no D3D11 video decoder ({err}). OpenScreen decodes \ + every preview and export frame with D3D11VA on the same device it composites \ + with, so the decoder is not optional and there is no CPU path behind it. \ + Remote Desktop sessions and VMs without GPU passthrough land here: run on the \ + physical machine, or update the display driver." + ); + } + format!( + "no Direct3D 11 feature level 11_1 display adapter ({err}). OpenScreen's compositor \ + requires one for both preview and export. Update the display driver, or run on a \ + machine with a GPU that reaches feature level 11_1." + ) +} + +impl Gpu { + /// Crée le device conforme au §2. `debug=false` impératif dans tout run mesuré + /// (§10 : la couche debug valide et sérialise chaque appel — facteur, pas %). + /// + /// MATÉRIEL STRICT, sans repli : échoue plutôt que de rendre un device WARP. C'est ce + /// que veulent les tests et les goldens (mesurer ou comparer le chemin GPU n'a aucun + /// sens sur un rastériseur logiciel). Le chemin de production, lui, prend `create_auto`. + pub fn create(debug: bool) -> Result { + Gpu::create_backend(Backend::Hardware, debug) + } + + /// Le device de PRODUCTION : matériel si possible, backend CPU sinon. + /// + /// C'est ici que le repli devient automatique, et il ne l'est qu'accompagné : l'app + /// demande `probe()` et prévient l'utilisateur. Un basculement muet vers un rendu à + /// ~8 fps serait exactement le « l'app rame aujourd'hui » que cette branche corrige. + /// + /// Si les DEUX échouent, c'est le diagnostic MATÉRIEL qu'on remonte en tête : c'est + /// lui qui est actionnable (« pas de décodeur vidéo sur cet adaptateur »), pas + /// « WARP indisponible », qui ne dit rien à personne. + pub fn create_auto(debug: bool) -> Result { + let hw_err = match Gpu::create_backend(Backend::Hardware, debug) { + Ok(gpu) => return Ok(gpu), + Err(err) => err, + }; + eprintln!("[d3d] backend matériel indisponible ({hw_err:#}) — repli sur le backend CPU"); + Gpu::create_backend(Backend::Cpu, debug).map_err(|cpu_err| { + anyhow::anyhow!("{hw_err:#} (le repli logiciel a échoué aussi : {cpu_err:#})") + }) + } + + /// Le backend que cette machine obtiendra, sans créer de vue ni d'export. + /// + /// Mis en cache : créer un device coûte quelques dizaines de ms et la réponse ne + /// change pas en cours de session (un pilote qui tombe en marche est un redémarrage, + /// pas un rafraîchissement). `None` = ni matériel ni WARP — la vue échouera, et c'est + /// son message d'erreur, plus précis, qui doit parler. + pub fn probe() -> Option { + static PROBED: OnceLock> = OnceLock::new(); + *PROBED.get_or_init(|| { + for backend in [Backend::Hardware, Backend::Cpu] { + if Gpu::create_backend(backend, false).is_ok() { + return Some(backend); + } + } + None + }) + } + + /// Le device du backend demandé. `Backend::Cpu` ne diagnostique pas : si WARP + /// lui-même échoue, il n'y a plus rien derrière à proposer. + pub fn create_backend(backend: Backend, debug: bool) -> Result { + let mut flags = backend.base_flags(); + if debug { + flags |= D3D11_CREATE_DEVICE_DEBUG; + } + + let (device, context, got) = match try_create(backend.driver(), flags) { + Ok(gpu) => gpu, + Err(err) if backend == Backend::Cpu => { + bail!("WARP (rastériseur logiciel) indisponible sur cet hôte : {err}") + } + Err(err) => bail!("{}", diagnose(&err)), + }; + + if got != D3D_FEATURE_LEVEL_11_1 { + bail!("feature level obtenu {:?} != 11_1", got); + } + + // §2 : multithread-protected. Le décodeur ffmpeg soumet depuis son thread, + // notre compositeur depuis le nôtre — sans ça, corruption silencieuse. + let mt: ID3D11Multithread = context.cast()?; + unsafe { + let _prev = mt.SetMultithreadProtected(true); + if !mt.GetMultithreadProtected().as_bool() { + bail!("SetMultithreadProtected(TRUE) n'a pas pris"); + } + } + + Ok(Gpu { device, context, feature_level: got, backend }) + } +} diff --git a/crates/compositor/src/ffi.rs b/crates/compositor/src/ffi.rs new file mode 100644 index 0000000000..8ca4619da6 --- /dev/null +++ b/crates/compositor/src/ffi.rs @@ -0,0 +1,3 @@ +//! Bindings libav* bruts, générés par bindgen sur les headers ffmpeg 8.x (voir build.rs). +#![allow(non_upper_case_globals, non_camel_case_types, non_snake_case, dead_code)] +include!(concat!(env!("OUT_DIR"), "/ffi.rs")); diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs new file mode 100644 index 0000000000..f830c89d40 --- /dev/null +++ b/crates/compositor/src/lib.rs @@ -0,0 +1,21 @@ +//! Le compositeur natif D3D11 d'OpenScreen : décodage, pipeline, effets HLSL, scène, curseur, +//! audio, et la vue live embarquable (`live`). +//! +//! C'est du code de PRODUCTION. `compositor-view-napi` s'y lie pour produire +//! `compositor_view.node`, le binaire que l'app Electron charge — la preview comme l'export +//! passent par ici. Le POC de mesure (`poc-d3d`) n'est qu'un autre consommateur de cette +//! bibliothèque, pas l'inverse : la GUI Win32 et le harnais de bench vivent chez lui. + +pub mod audio; +pub mod compositor; +pub mod config; +mod cpu_frames; +pub mod cursor; +pub mod d3d; +pub mod ffi; +pub mod live; +pub mod pipeline; +pub mod regions; +pub mod scene; +pub mod text; +pub mod text_anim; diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs new file mode 100644 index 0000000000..bfdc93371e --- /dev/null +++ b/crates/compositor/src/live.rs @@ -0,0 +1,1648 @@ +//! Vue live : rend le compositing **hors-fenêtre** vers un `Vec` RGBA8 +//! (taille `set_rect`) destiné à être streamé dans un `` Electron via +//! `putImageData`. Option B (canvas) — l'ancienne option A (fenêtre D3D enfant +//! `WS_POPUP` + swapchain) supprimée : la glue TS n'a plus de surface native à +//! embarquer, elle draw chaque frame reçue comme une image bitmap. +//! +//! Pipeline interne : `Player` (decodeur lockstep screen/webcam) + +//! `Compositor::compose_frame` → RT RGBA rastérisé à la GÉOMÉTRIE DE RENDU (depuis la +//! refonte ratio : géométrie de sortie ramenée à la taille du panneau, plus le canvas +//! 16:9 figé d'avant). Le **post-traitement** : +//! - avant : blit du RT vers le backbuffer du swapchain, `Present`. +//! - maintenant : `comp.readback_direct()` copie le RT directement vers la staging +//! `D3D11_USAGE_STAGING` (déjà dimensionnée à la résolution de rendu), `Map`/ +//! `D3D11_MAP_READ`, copie ligne par ligne qui respecte `RowPitch` (même idiome que +//! `dump_nv12`/`dump_raw`), et stocke le `Vec` dans `Shared::latest_frame` pour +//! le `read_frame` napi. Plus de resize intermédiaire (`blit_resized`) : le RT est +//! déjà à la taille voulue, CSS met à l'échelle vers la boîte du panneau côté JS. +//! +//! Modèle de threads : la vue n'a plus de HWND/UI côté thread appelant. Le rendu vit +//! sur un thread dédié — le thread JS/UI n'est jamais bloqué. Les objets COM et la +//! staging restent sur ce thread de rendu ; la frame est publiée via un +//! `Mutex)>>` pour la traversée de threads vers +//! le napi — le `gen` est l'identité de la frame (cf. `LatestFrame`). + +use crate::compositor::{Compositor, LiveParams}; +use crate::regions::speed_at; +use crate::scene::Scene; +use crate::config::{self, Cfg}; +use crate::cursor::CursorTrack; +use windows::core::PCWSTR; +use crate::d3d::Gpu; +use crate::pipeline::Decoder; +use anyhow::Result; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// "#rrggbb" (ou "rrggbb") → [r, g, b, 1] en 0..1. None si invalide. +fn parse_hex_color(s: &str) -> Option<[f32; 4]> { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 { + return None; + } + let r = u8::from_str_radix(&h[0..2], 16).ok()? as f32 / 255.0; + let g = u8::from_str_radix(&h[2..4], 16).ok()? as f32 / 255.0; + let b = u8::from_str_radix(&h[4..6], 16).ok()? as f32 / 255.0; + Some([r, g, b, 1.0]) +} + +fn webcam_seek_time(screen_source_time_sec: f64, webcam_offset_sec: f64) -> f64 { + (screen_source_time_sec - webcam_offset_sec).max(0.0) +} + +/// Décodeurs déjà ouverts ET positionnés au bon playhead pour un clip à venir — le résultat +/// d'un préchargement en tâche de fond (voir `open_and_seek_clip`/`maybe_start_prefetch` +/// dans `render_thread`). Appliquer ceci à un `Player` (`apply_prefetched`) ne fait plus +/// aucune E/S : c'est ce qui rend la bascule à la frontière d'un clip instantanée au lieu de +/// payer un `Decoder::open` (ouverture fichier + parsing FFmpeg) synchrone pile au moment de +/// la transition — la pause perceptible observée en usage réel. +struct PrefetchedClip { + sdec: Decoder, + wdec: Decoder, + webcam_offset_sec: f64, + idx: u32, + /// Piste curseur du clip à venir, préchargée ici pour la même raison que les décodeurs : + /// sans ça, la bascule à la frontière restait synchrone sur CE point précis (lecture + + /// parsing JSON du `.cursor.json`, potentiellement des milliers d'échantillons pour un + /// enregistrement long) même après que le préchargement des décodeurs a supprimé le gros + /// de la pause perceptible — un second petit accroc au même endroit, pour la même raison + /// (une E/S synchrone pile à la frontière) qu'on venait de corriger pour les décodeurs. + cursor_track: Option, +} + +/// Ouvre + positionne la paire de décodeurs d'un clip (même travail que +/// `Player::set_active_clip`, mais autonome — sans instance `Player` existante, pour pouvoir +/// tourner sur un thread dédié pendant que le `Player` réel joue encore le clip actif). +unsafe fn open_and_seek_clip( + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + source_time_sec: f64, + gpu: &Gpu, +) -> Result { + let source_time_sec = source_time_sec.max(0.0); + let mut sdec = Decoder::open(screen_path, gpu)?; + let mut wdec = match Decoder::open(webcam_path, gpu) { + Ok(d) => d, + Err(_) => Decoder::open(screen_path, gpu)?, + }; + let sf = sdec.seek_to(source_time_sec)?; + let mut wf = wdec.seek_to(webcam_seek_time(source_time_sec, webcam_offset_sec))?; + if wf.is_null() { + wf = wdec.seek_to(0.0)?; + } + if sf.is_null() { + anyhow::bail!("clip préchargé vide au temps source {source_time_sec:.3}s (screen=\"{screen_path}\")"); + } + let idx = (source_time_sec * sdec.fps()).round().max(0.0) as u32; + let cursor_track = CursorTrack::load(&format!("{screen_path}.cursor.json"), 0.0, 24.0 * 3600.0).ok(); + Ok(PrefetchedClip { sdec, wdec, webcam_offset_sec, idx, cursor_track }) +} + +/// Lit deux sources en lockstep et compose la frame courante dans le RT du compositeur. +/// Partagé avec la GUI standalone (`app.rs`). +pub struct Player { + sdec: Decoder, + wdec: Decoder, + gpu: Gpu, + webcam_offset_sec: f64, + has_current_frame: bool, + use_current_on_next_step: bool, + idx: u32, +} + +impl Player { + pub unsafe fn open(screen: &str, webcam: &str, gpu: &Gpu) -> Result { + let wdec = match Decoder::open(webcam, gpu) { + Ok(d) => d, + Err(_) => Decoder::open(screen, gpu)?, + }; + Ok(Player { + sdec: Decoder::open(screen, gpu)?, + wdec, + gpu: Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + feature_level: gpu.feature_level, + backend: gpu.backend, + }, + webcam_offset_sec: 0.0, + has_current_frame: false, + use_current_on_next_step: false, + idx: 0, + }) + } + + /// Remplace atomiquement la paire de décodeurs du clip actif. Les nouvelles sources sont + /// ouvertes et positionnées au playhead source courant avant de libérer l'ancienne paire. + /// Synchrone (bloque le thread appelant le temps de l'ouverture) — `render_thread` préfère + /// `apply_prefetched` quand un préchargement en tâche de fond est déjà prêt ; ceci reste le + /// repli correct dans tous les autres cas (changement de clip explicite depuis l'app, + /// préchargement pas encore prêt, etc). + pub unsafe fn set_active_clip( + &mut self, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + source_time_sec: f64, + ) -> Result<()> { + let prefetched = + open_and_seek_clip(screen_path, webcam_path, webcam_offset_sec, source_time_sec, &self.gpu)?; + self.apply_prefetched(prefetched); + Ok(()) + } + + /// Bascule instantanément sur une paire de décodeurs déjà ouverte + positionnée — aucune + /// E/S ici, juste l'échange des champs. Utilisé par `set_active_clip` (juste après son + /// propre `open_and_seek_clip`) et directement par `render_thread` quand un préchargement + /// en tâche de fond est déjà prêt au moment de franchir la frontière du clip. + unsafe fn apply_prefetched(&mut self, prefetched: PrefetchedClip) { + self.sdec = prefetched.sdec; + self.wdec = prefetched.wdec; + self.webcam_offset_sec = prefetched.webcam_offset_sec; + self.has_current_frame = true; + self.use_current_on_next_step = true; + self.idx = prefetched.idx; + } + + /// Temps source courant du décodeur écran — utilisé par `render_thread` pour détecter le + /// franchissement de la fin de fenêtre du clip actif pendant la lecture libre. + pub(crate) unsafe fn screen_time_sec(&self) -> f64 { + self.sdec.cur_time_sec() + } + + /// Compose la frame suivante (→ `comp.rt`). Boucle sur EOF. `false` si fixture vide. + /// + /// L'écran pilote la cadence (1 frame/tick) ; la webcam suit son PROPRE temps source + /// (`screen_time - webcam_offset_sec`), pas un pas 1:1 avec l'écran — BUG corrigé : les + /// deux décodeurs avançaient d'exactement une frame par tick chacun, quelle que soit leur + /// cadence réelle. Écran et webcam sont capturés par des pipelines indépendants (souvent + /// à des fps différents), donc la webcam jouait 2× trop vite dès que sa cadence était + /// inférieure à celle de l'écran. Même logique que `advance_decoder_to` (pipeline.rs), + /// déjà correcte côté export — la preview live ne l'avait jamais reprise. La webcam boucle + /// aussi de façon INDÉPENDANTE à son propre EOF (un clip webcam plus court que l'écran ne + /// doit pas réinitialiser le décodeur écran). + pub unsafe fn step(&mut self, comp: &Compositor, cfg: &Cfg) -> Result { + let use_current = self.use_current_on_next_step; + self.use_current_on_next_step = false; + + let mut sf = if use_current { + self.sdec.cur_frame() + } else { + self.sdec.next()? + }; + if sf.is_null() { + sf = self.sdec.seek_to(0.0)?; + self.idx = 0; + } + if sf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + + let target_webcam_t = (self.sdec.cur_time_sec() - self.webcam_offset_sec).max(0.0); + let mut wf = if use_current { + self.wdec.cur_frame() + } else { + let cur = self.wdec.cur_frame(); + if cur.is_null() { + // Jamais décodée (nouvelle ouverture) : on saute directement au temps synchronisé. + self.wdec.seek_to(target_webcam_t)? + } else { + // Rattrape la webcam vers `target_webcam_t`, au pire une poignée de frames par + // tick (fps proches) — le garde-fou n'existe que contre un cas pathologique. + let mut wf = cur; + let mut guard = 0u32; + while self.wdec.cur_time_sec() < target_webcam_t { + match self.wdec.next()? { + f if f.is_null() => { + // Fin de la webcam avant l'écran : elle boucle SEULE — l'écran + // garde sa propre position, inchangée. + wf = self.wdec.seek_to(0.0)?; + break; + } + f => wf = f, + } + guard += 1; + if guard > 1000 { + break; + } + } + wf + } + }; + if wf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + + self.has_current_frame = true; + self.sync_time(comp); + comp.compose_frame(sf, wf, self.idx as f32, cfg)?; + self.idx = self.idx.wrapping_add(1); + Ok(true) + } + + /// Positionne `comp` sur le temps source RÉEL (pts) de la frame écran courante, pour que le + /// curseur ET les zoom/full-camera regions du clip actif restent exacts quelle + /// que soit la cadence réelle de l'enregistrement — BUG corrigé : tout dérivait auparavant + /// de `frame / 60.0` (un compteur de frames supposant 60fps pile), qui dérive + /// silencieusement de plus en plus au fil de la lecture dès que le fichier n'est pas + /// exactement à 60fps (30/59.94/etc. sont courants), au lieu de suivre le pts réel du + /// décodeur — exactement la cause du "zoom désynchronisé de la timeline" observé. + unsafe fn sync_time(&self, comp: &Compositor) { + let t = self.sdec.cur_time_sec() as f32; + comp.set_cursor_time(Some(t)); + comp.set_timeline_time(Some(t)); + } + + /// Recompose la frame courante (déjà décodée) — rafraîchit après un changement de param. + pub unsafe fn recompose(&self, comp: &Compositor, cfg: &Cfg) -> Result { + if !self.has_current_frame { + return Ok(false); + } + let sf = self.sdec.cur_frame(); + let wf = self.wdec.cur_frame(); + if sf.is_null() || wf.is_null() { + return Ok(false); + } + self.sync_time(comp); + let f = self.idx.saturating_sub(1); + comp.compose_frame(sf, wf, f as f32, cfg)?; + Ok(true) + } + + /// Seek à `target_sec` (secondes source du clip actif) : keyframe-seek + décodage-avant + /// (`Decoder::seek_to`, même mécanisme robuste que l'export) — remplace l'ancien modèle + /// "compte de frames" qui rewindait tout au frame 0 pour le moindre seek arrière et n'avait + /// aucun raccourci keyframe pour les seeks avant lointains (lent ET, combiné au bug de + /// `set_time`, incorrect au-delà de 6s sur un enregistrement réel). + pub unsafe fn present_frame(&mut self, comp: &Compositor, cfg: &Cfg, target_sec: f64) -> Result { + let sf = self.sdec.seek_to(target_sec)?; + let wf = self + .wdec + .seek_to(webcam_seek_time(target_sec, self.webcam_offset_sec))?; + if sf.is_null() || wf.is_null() { + self.has_current_frame = false; + return Ok(false); + } + self.has_current_frame = true; + self.use_current_on_next_step = false; + self.sync_time(comp); + // "idx" ne sert plus qu'au fallback fixture (jamais lu si une scène est posée) — dérivé + // du temps réel pour rester cohérent si jamais consulté. + self.idx = (target_sec * self.sdec.fps()).round().max(0.0) as u32; + comp.compose_frame(sf, wf, self.idx as f32, cfg)?; + Ok(true) + } +} + +/// Paramètres inspector pilotés depuis l'UI (setParam). Le thread de rendu les applique : +/// booléens/taps → reconstruits dans le `Cfg` ; valeurs continues → `set_live_params`. +#[derive(Clone, Copy, PartialEq)] +struct InspectorParams { + bg_blur: bool, + bg_color: [f32; 4], + shadow_scale: f32, + radius_scale: f32, + mblur_taps: u32, + padding: f32, + webcam_size_scale: f32, + webcam_mirror: bool, + webcam_shape: u32, + cursor_show: bool, + cursor_size_scale: f32, + cursor_bounce_scale: f32, + /// 0..1 : force du lissage ressort-amortisseur de la position (0 = brut). Reconstruit la + /// piste (voir `raw_cursor.smoothed()` dans `render_thread`) plutôt qu'un simple scalaire de + /// dessin — d'où le suivi séparé de sa dernière valeur appliquée. + cursor_smoothing: f32, + /// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran). + cursor_motion_blur: f32, +} + +impl Default for InspectorParams { + fn default() -> Self { + Self { + bg_blur: false, + bg_color: [0.10, 0.11, 0.14, 1.0], + shadow_scale: 1.0, + radius_scale: 1.0, + mblur_taps: 8, + padding: 0.0, + webcam_size_scale: 1.0, + webcam_mirror: false, + webcam_shape: 3, + cursor_show: true, + cursor_size_scale: 1.0, + cursor_bounce_scale: 1.0, + cursor_smoothing: 0.0, + cursor_motion_blur: 0.0, + } + } +} + +#[derive(Clone)] +struct ActiveClipRequest { + screen_path: String, + webcam_path: String, + webcam_offset_sec: f64, + /// Identité dans le flux `Scene.clips` trié (les chemins ne suffisent pas pour un asset partagé). + clip_index: usize, + /// Playhead exprimé sur l'horloge source écran du nouveau clip. + source_time_sec: f64, +} + +fn same_source_path(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) +} + +fn scene_clip_matches( + clip: &crate::scene::SceneClip, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> bool { + same_source_path(&clip.screen_path, screen_path) + && same_source_path(&clip.webcam_path, webcam_path) + && (clip.webcam_offset_sec - webcam_offset_sec).abs() <= 1e-6 +} + +fn find_scene_clip_index( + scene: &Scene, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> Option { + scene.clips.iter() + .position(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec)) + .or_else(|| scene.clips.iter().position(|clip| { + same_source_path(&clip.screen_path, screen_path) + && same_source_path(&clip.webcam_path, webcam_path) + })) +} + +/// Paths and the asset-level webcam offset are identical for multiple cuts of one recording, +/// so path lookup alone always returns clip 0. Prefer the explicit timeline identity. +fn resolve_scene_clip_index( + scene: &Scene, + requested_clip_index: usize, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, +) -> Option { + if scene.clips.get(requested_clip_index) + .is_some_and(|clip| scene_clip_matches(clip, screen_path, webcam_path, webcam_offset_sec)) + { + Some(requested_clip_index) + } else { + find_scene_clip_index(scene, screen_path, webcam_path, webcam_offset_sec) + } +} + +fn scene_for_clip(scene: &Scene, clip_index: usize) -> Scene { + match scene.clips.get(clip_index) { + Some(clip) => scene.for_clip_window( + clip_index, + clip.source_start_sec, + clip.source_end_sec, + ), + None => scene.clone(), + } +} + +/// Dernière frame readback vers CPU, prête pour le napi `read_frame`. +/// +/// `(gen, w, h, vec)` où `vec.len() == w*h*4` octets RGBA8 tightly-packed (R, G, B, A +/// en mémoire — cf. `Compositor::readback_resized`). `gen` est une génération monotone +/// (≥ 1, `0` réservé à « le consommateur n'a encore rien vu ») incrémentée à CHAQUE +/// publication, càd uniquement quand une nouvelle frame a réellement été composée (le +/// thread de rendu ne republie pas une frame identique — cf. `stepped || first`). Elle +/// est l'IDENTITÉ de la frame : le consommateur (`read_frame`) ne repaie le clone + l'IPC +/// que lorsqu'elle change. `None` = "aucune frame composée pour l'instant" (toutes les +/// lectures avant la 1re frame composée retournent `None` côté napi, jamais un buffer vide). +type LatestFrame = (u64, u32, u32, Vec); + +/// État partagé thread appelant → thread de rendu (commandes sans blocage). +struct Shared { + /// Résolution cible du preview (largeur, hauteur) en pixels devices — ce que la + /// zone canvas Electron affiche. Plus de HWND/HWND-parent : la preview est une + /// image bitmap posée sur un ``, la position CSS est gérée entièrement + /// côté web. Lecture/écriture exclusive via `Mutex`. + preview_size: Mutex<(u32, u32)>, + inspector: Mutex, + /// Temps source (secondes) demandé par l'app pour le clip actif (presentTime/seek), prioritaire + /// sur la lecture libre. En SECONDES (pas un index de frame) : `Player::present_frame` fait un + /// vrai seek keyframe (`Decoder::seek_to`, comme l'export) au lieu de compter des frames — + /// BUG corrigé : l'ancien `set_time` convertissait en index de frame à 60fps fixe PUIS le + /// wrappait modulo `FIXTURE_FRAMES` (360 = 6s) — un reliquat du bench fixture qui faisait + /// boucler silencieusement tout seek au-delà de 6s sur un enregistrement réel, exactement + /// la cause du "zoom timeline désynchronisé" observé. + requested_frame: Mutex>, + /// Changement de sources consommé par le thread de rendu, seul propriétaire des décodeurs. + active_clip_request: Mutex>, + /// scène de l'app (contrat) ; appliquée au compositeur quand `scene_dirty`. + scene: Mutex>, + scene_dirty: AtomicBool, + playing: AtomicBool, + stop: AtomicBool, + /// Dernière frame RGBA8 readback (taille + pixels R,G,B,A tightly-packed). Écrit + /// par le thread de rendu après chaque `compose_frame` réussi, lu par le napi + /// `read_frame` depuis le thread Node principal. `Mutex>` — + /// Option pour distinguer "pas de frame encore composée" (avant le 1er compose, + /// `read_frame` retourne `Ok(None)`) d'un buffer vide (qui n'arrive jamais). + latest_frame: Mutex>, + /// Erreur fatale du thread de rendu (device D3D11 introuvable, décodeur qui refuse + /// le fichier…). Le thread meurt sur la première erreur ; sans ce champ, elle + /// finissait dans un `eprintln!` que personne ne lit et l'utilisateur n'avait + /// qu'un canvas noir — exactement le « on dirait que l'app rame » de la PR #162. + /// `read_frame` la relaie en `Err` au prochain tour de la boucle de pull (~33 ms), + /// donc elle remonte jusqu'à l'UI par le chemin d'erreur qui existe déjà. + fatal: Mutex>, +} + +/// Handle d'une vue live. `Drop` arrête le rendu. +/// +/// Plus de fenêtre/OS : le handle ne porte plus de `HWND`. Toute la machinerie Win32 +/// (CreateWindowEx / SetWindowPos / DestroyWindow / register_overlay_class) a été +/// retirée — la preview est désormais purement hors-fenêtre, transportable via +/// mémoire. +pub struct LiveView { + shared: Arc, + thread: Option>, +} + +// `LiveView` ne référence plus aucune ressource Win32 non-`Send`. `Shared` non plus +// (`Mutex`, `AtomicBool`, `Option>`). Le `JoinHandle` est `Send`/`!Sync` +// mais on n'en extrait rien côté napi. Tout ce qui vit dans le thread de rendu +// (compositor, décodeurs, staging, GPU) y reste confiné. +unsafe impl Send for LiveView {} + +impl LiveView { + /// Crée une vue offscreen : pas de HWND/UI côté thread appelant. Démarre juste + /// le thread de rendu qui va composer chaque frame et publier le readback dans + /// `Shared::latest_frame` pour le napi `read_frame`. + /// + /// `w`/`h` sont la **résolution cible du preview** (taille du `` Electron + /// affichant la preview, en pixels device) — anciennement c'était le rect de la + /// fenêtre overlay ; maintenant c'est juste la taille du bitmap RGBA produit. + /// Ajustable à chaud via `set_rect(w, h)`. + pub fn create( + w: u32, + h: u32, + screen: &str, + webcam: &str, + cursor_json: &str, + ) -> Result { + let shared = Arc::new(Shared { + preview_size: Mutex::new((w.max(1), h.max(1))), + inspector: Mutex::new(InspectorParams::default()), + requested_frame: Mutex::new(None), + active_clip_request: Mutex::new(None), + scene: Mutex::new(None), + scene_dirty: AtomicBool::new(false), + playing: AtomicBool::new(true), + stop: AtomicBool::new(false), + latest_frame: Mutex::new(None), + fatal: Mutex::new(None), + }); + let sh = shared.clone(); + let (s, wc, cj) = (screen.to_string(), webcam.to_string(), cursor_json.to_string()); + let thread = std::thread::spawn(move || { + if let Err(e) = unsafe { render_thread(sh.clone(), &s, &wc, &cj) } { + eprintln!("[live] render thread error: {e:#}"); + if let Ok(mut fatal) = sh.fatal.lock() { + *fatal = Some(format!("{e:#}")); + } + } + }); + + Ok(LiveView { shared, thread: Some(thread) }) + } + + /// Met à jour la résolution cible du preview. Force le redimensionnement des + /// ressources GPU de readback (`Compositor::ensure_resize_target` / + /// `live_readback_staging`) au prochain tour du thread de rendu. + /// + /// Signature : `(w, h)` — l'ancienne `(x, y, w, h)` de la fenêtre overlay n'a + /// plus de sens (la position est gérée par CSS côté Electron). `set_rect` côté + /// napi doit s'aligner sur ce 2-param (la largeur/hauteur seule). + pub fn set_rect(&self, w: u32, h: u32) { + if let Ok(mut s) = self.shared.preview_size.lock() { + *s = (w.max(1), h.max(1)); + } + } + + /// Message de l'erreur qui a tué le thread de rendu, `None` tant qu'il tourne. + /// Définitif : le thread ne redémarre pas. + pub fn fatal_error(&self) -> Option { + self.shared.fatal.lock().ok().and_then(|guard| guard.clone()) + } + + /// Récupère la dernière frame readback (gen + taille + RGBA8 tightly-packed). + /// `None` si rien n'a encore été composé (jamais écrit). **Coût : O(w·h)** + /// (copie du `Vec` — nécessaire pour traverser la frontière thread + le + /// FFI vers le Buffer napi). Le `Vec` retourné a `len() == w*h*4`. + /// Préférer `latest_frame_since` sur le chemin chaud : il évite ce clone quand + /// le consommateur possède déjà la génération courante. + pub fn latest_frame(&self) -> Option<(u64, u32, u32, Vec)> { + self.shared + .latest_frame + .lock() + .ok() + .and_then(|guard| guard.as_ref().cloned()) + } + + /// Récupère la dernière frame UNIQUEMENT si sa génération est postérieure à + /// `since_gen`. `None` couvre les DEUX cas où le consommateur n'a rien à peindre : + /// - rien n'a encore été composé (aucune frame publiée), ou + /// - il possède déjà la génération courante (`gen <= since_gen`). + /// Dans ce second cas — l'essentiel du temps d'édition, preview en pause sur une + /// frame figée — on n'exécute PAS le clone `O(w·h)` : c'est tout l'intérêt du + /// compteur. Le consommateur passe la dernière génération qu'il a peinte (`0` au + /// départ) ; `None` ⇒ il ne fait rien, `Some` ⇒ il peint et retient `gen`. + pub fn latest_frame_since(&self, since_gen: u64) -> Option<(u64, u32, u32, Vec)> { + let guard = self.shared.latest_frame.lock().ok()?; + match guard.as_ref() { + Some((gen, w, h, px)) if *gen > since_gen => Some((*gen, *w, *h, px.clone())), + _ => None, + } + } + + /// Switch inspector (booléen). + pub fn set_param_bool(&self, key: &str, value: bool) { + if let Ok(mut p) = self.shared.inspector.lock() { + match key { + "backgroundBlur" => p.bg_blur = value, + "webcamMirror" => p.webcam_mirror = value, + "cursorShow" => p.cursor_show = value, + _ => {} + } + } + } + + /// Slider inspector (numérique). Conventions : `shadow`/`roundness`/`webcamSize`/ + /// `cursorSize`/`cursorClickBounce` = échelle (1 = défaut) ; `padding` = 0..1 ; + /// `motionBlur` = 0..1 mappé sur 1..16 taps. + pub fn set_param_num(&self, key: &str, value: f64) { + if let Ok(mut p) = self.shared.inspector.lock() { + let v = value as f32; + match key { + "shadow" => p.shadow_scale = v.max(0.0), + "roundness" => p.radius_scale = v.max(0.0), + "motionBlur" => p.mblur_taps = (1.0 + value.clamp(0.0, 1.0) * 15.0).round() as u32, + "padding" => p.padding = v.clamp(0.0, 1.0), + "webcamSize" => p.webcam_size_scale = v.max(0.05), + "cursorSize" => p.cursor_size_scale = v.max(0.0), + "cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0), + "cursorSmoothing" => p.cursor_smoothing = v.clamp(0.0, 1.0), + "cursorMotionBlur" => p.cursor_motion_blur = v.clamp(0.0, 1.0), + _ => {} + } + } + } + + /// Sélection de chaîne : couleur de fond "#rrggbb" ou forme webcam. + pub fn set_param_str(&self, key: &str, value: &str) { + if let Ok(mut p) = self.shared.inspector.lock() { + match key { + "backgroundColor" => { + if let Some(c) = parse_hex_color(value) { + p.bg_color = c; + } + } + "webcamShape" => { + p.webcam_shape = crate::compositor::webcam_shape_code(value); + } + _ => {} + } + } + } + + pub fn set_playing(&self, playing: bool) { + self.shared.playing.store(playing, Ordering::Relaxed); + } + + /// Ce que la vue est en train de faire : lecture libre (`true`) ou pause (`false`). + /// Lu par l'export, qui met les previews en pause le temps d'encoder et doit pouvoir + /// leur rendre CET état plutôt que d'en supposer un (voir `PausedPreviews`). + pub fn playing(&self) -> bool { + self.shared.playing.load(Ordering::Relaxed) + } + + /// Installe la scène de l'app (JSON `SceneDescription`). Parsé ici (hors thread de rendu) ; + /// appliqué au compositeur au prochain tour via le flag `scene_dirty`. JSON invalide → ignoré. + pub fn set_scene(&self, json: &str) { + match Scene::from_json(json) { + Ok(scene) => { + if let Ok(mut s) = self.shared.scene.lock() { + *s = Some(scene); + self.shared.scene_dirty.store(true, Ordering::Relaxed); + } + } + Err(e) => eprintln!("[live] set_scene: JSON invalide: {e:#}"), + } + } + + /// Positionne la vue sur le temps source `seconds` du clip actif — plus de conversion en + /// index de frame ni de wrap fixture ici (voir `requested_frame`). + pub fn set_time(&self, seconds: f64) { + if let Ok(mut r) = self.shared.requested_frame.lock() { + *r = Some(seconds.max(0.0)); + } + } + + /// Programme le remplacement de la paire screen/webcam sur le thread de rendu. L'identité + /// du clip et son playhead source voyagent avec les chemins pour rendre le switch atomique. + pub fn set_active_clip( + &self, + screen_path: &str, + webcam_path: &str, + webcam_offset_sec: f64, + clip_index: usize, + source_time_sec: f64, + ) { + if let Ok(mut request) = self.shared.active_clip_request.lock() { + *request = Some(ActiveClipRequest { + screen_path: screen_path.to_string(), + webcam_path: webcam_path.to_string(), + webcam_offset_sec, + clip_index, + source_time_sec: source_time_sec.max(0.0), + }); + } + } +} + +impl Drop for LiveView { + fn drop(&mut self) { + // 1. Stoper le thread (il observe `stop` en tête de boucle et sort proprement). + self.shared.stop.store(true, Ordering::SeqCst); + // 2. Join. À la sortie, le thread a relâché toutes ses ressources GPU (compositor, + // décodeurs, resize_target, staging) ; le `Shared` reste vivant tant qu'on n'a + // pas droppé notre `Arc` final. + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + // Plus rien à détruire côté Win32 — pas de HWND. + } +} + +/// A preview's transport — free-run or paused — readable AND writable without touching the +/// GPU. It is the only slice of `LiveView` an export needs: it pauses the previews to free +/// the 3D engine, then hands the transport back. A trait rather than `LiveView` itself so +/// that `PausedPreviews` can be tested with no D3D device (see this file's tests). +pub trait PreviewTransport { + fn playing(&self) -> bool; + fn set_playing(&self, playing: bool); +} + +impl PreviewTransport for LiveView { + fn playing(&self) -> bool { + // Fully-qualified on purpose: inside a trait impl, `self.playing()` resolving to the + // inherent method is a silent coincidence of method resolution, not a guarantee. + LiveView::playing(self) + } + + fn set_playing(&self, playing: bool) { + LiveView::set_playing(self, playing); + } +} + +/// What every preview was doing when an export paused them — enough to give EACH ONE back the +/// state it was found in, instead of resuming them all. +/// +/// That distinction is the fix for a visible bug, not a nicety. While editing, the preview is +/// PAUSED; the renderer only pushes `setPlaying` when the transport actually changes, so +/// nothing ever came along to re-pause a preview an export had resumed on its own authority. +/// It went back to free-running for its own account: its playhead left the moment the app +/// believed was on screen (the zoom then sampled at the wrong source time), and at the first +/// clip boundary it crossed, it swapped its scene over to ANOTHER clip +/// (`scene_for_clip`) — after which the zoom regions of the clip actually being displayed were +/// filtered out of the scene, and no amount of seeking brought them back (the app only pushes +/// `set_active_clip` when ITS active clip changes, and its own had not changed). Only a window +/// reload, which recreates the view, repaired it. +/// +/// `K` is the caller's identity for a view (the napi registry id). Previews created AFTER the +/// pause are absent from the snapshot and are left strictly alone: their transport belongs to +/// whoever created them. +pub struct PausedPreviews { + was_playing: Vec<(K, bool)>, +} + +impl Default for PausedPreviews { + fn default() -> Self { + Self { was_playing: Vec::new() } + } +} + +impl PausedPreviews { + /// Pauses every preview and reports what each one was doing. + pub fn pause<'a, V: PreviewTransport + 'a>(views: impl IntoIterator) -> Self { + let mut was_playing = Vec::new(); + for (key, view) in views { + was_playing.push((key, view.playing())); + view.set_playing(false); + } + Self { was_playing } + } + + /// Gives every preview the snapshot knows about the state it was found in. + pub fn restore<'a, V: PreviewTransport + 'a>(&self, views: impl IntoIterator) { + for (key, view) in views { + if let Some((_, was_playing)) = self.was_playing.iter().find(|(k, _)| *k == key) { + view.set_playing(*was_playing); + } + } + } +} + +/// Un préchargement en cours : quel `next_index` (dans `Scene.clips`) il prépare, et le canal +/// par lequel le thread de fond livre le résultat une fois prêt. +type PendingPrefetch = (usize, std::sync::mpsc::Receiver>); + +/// Combien de secondes avant la fin du clip actif on lance le préchargement du suivant en +/// tâche de fond. Assez large pour couvrir un `Decoder::open` typique (ouverture fichier + +/// `avformat_find_stream_info` + init D3D11VA), assez court pour ne pas garder deux paires de +/// décodeurs ouvertes plus longtemps que nécessaire. +const PREFETCH_LEAD_SEC: f64 = 0.75; + +/// Démarre le préchargement du clip suivant sur un thread dédié dès qu'on entre dans la +/// fenêtre `PREFETCH_LEAD_SEC` avant la fin du clip actif — pour que la bascule à la +/// frontière (`advance_to_next_scene_clip`) trouve les décodeurs déjà ouverts et positionnés +/// au lieu de payer l'E/S + le parsing FFmpeg sur le thread de rendu pile au moment de la +/// transition (la pause perceptible observée en usage réel). No-op si un préchargement est +/// déjà en cours, ou pour une scène à 1 clip (voir `advance_to_next_scene_clip`). +unsafe fn maybe_start_prefetch( + scene: &Scene, + active_clip_index: usize, + screen_time_sec: f64, + gpu: &Gpu, + prefetch: &mut Option, +) { + if scene.clips.len() <= 1 || prefetch.is_some() { + return; + } + let Some(clip) = scene.clips.get(active_clip_index) else { + return; + }; + let remaining = clip.source_end_sec - screen_time_sec; + if !(0.0..PREFETCH_LEAD_SEC).contains(&remaining) { + return; + } + let next_index = if active_clip_index + 1 < scene.clips.len() { + active_clip_index + 1 + } else { + 0 + }; + let next_clip = scene.clips[next_index].clone(); + // Copie légère (COM refcount, pas de nouveau device) — même motif que `Player::open`. + let gpu_clone = Gpu { + device: gpu.device.clone(), + context: gpu.context.clone(), + feature_level: gpu.feature_level, + backend: gpu.backend, + }; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = unsafe { + open_and_seek_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + &gpu_clone, + ) + }; + // L'appelant a pu abandonner ce préchargement entre-temps (changement de clip + // explicite, scène remplacée) — un receiver droppé fait juste échouer `send` + // silencieusement ; les décodeurs déjà ouverts sont libérés normalement (`Drop`). + let _ = tx.send(result); + }); + *prefetch = Some((next_index, rx)); +} + +/// Bascule le `Player` + le compositeur sur le clip suivant de `scene` (reboucle sur le +/// premier après le dernier). No-op pour une scène à 1 clip (le bouclage léger existant de +/// `Player::step` suffit et coûte moins cher qu'un `set_active_clip` — reopen des décodeurs). +/// +/// Partagée entre le déclenchement PROACTIF (seuil `source_end_sec` franchi) et le filet de +/// sécurité RÉACTIF de `render_thread` (le temps du décodeur a reculé — `Player::step` a +/// bouclé sur l'EOF RÉEL du fichier avant que le seuil ne soit jamais atteint : cas d'un clip +/// NON trimmé dont la dernière frame réelle a un PTS strictement inférieur au +/// `source_end_sec` déclaré, qui égale alors la durée totale du fichier — le seuil `>=` ne se +/// déclenche jamais dans ce cas, d'où le "ça boucle sur le 1er clip" observé malgré le +/// déclenchement proactif). +/// +/// Si `maybe_start_prefetch` a eu le temps de préparer ce même `next_index` à l'avance, la +/// bascule est instantanée (juste un échange de champs, `Player::apply_prefetched`) ; sinon +/// on retombe sur l'ouverture synchrone habituelle (`Player::set_active_clip`) — correct dans +/// tous les cas, juste plus lent quand le préchargement n'a pas eu le temps de finir. +#[allow(clippy::too_many_arguments)] +unsafe fn advance_to_next_scene_clip( + player: &mut Player, + comp: &Compositor, + scene: &Scene, + prefetch: &mut Option, + active_screen_path: &mut String, + active_webcam_path: &mut String, + active_webcam_offset_sec: &mut f64, + active_clip_index: &mut usize, + raw_cursor: &mut Option, + last_smoothing: &mut f32, +) { + if scene.clips.len() <= 1 { + return; + } + let next_index = if *active_clip_index + 1 < scene.clips.len() { + *active_clip_index + 1 + } else { + 0 + }; + let next_clip = &scene.clips[next_index]; + + // N'importe quel préchargement en cours ne concerne plus que CETTE frontière (on vient + // de la franchir, bien ou mal ciblée) — on le consomme s'il correspond, on l'abandonne + // sinon, dans tous les cas il ne doit pas survivre à cet appel. + let ready = prefetch.take().and_then(|(idx, rx)| { + if idx == next_index { rx.try_recv().ok() } else { None } + }); + + // Le curseur préchargé (voir `PrefetchedClip::cursor_track`) doit être extrait AVANT de + // passer `prefetched` (par valeur) à `apply_prefetched`, qui ne s'occupe que des + // décodeurs — sinon ce champ serait silencieusement perdu avec le reste de la struct. + let prefetched_cursor: Option> = match &ready { + Some(Ok(p)) => Some(p.cursor_track.clone()), + _ => None, + }; + + let applied = match ready { + Some(Ok(prefetched)) => { + player.apply_prefetched(prefetched); + Ok(()) + } + Some(Err(e)) => { + eprintln!("[live] préchargement du clip suivant: {e:#} — repli sur ouverture synchrone"); + player.set_active_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + ) + } + None => player.set_active_clip( + &next_clip.screen_path, + &next_clip.webcam_path, + next_clip.webcam_offset_sec, + next_clip.source_start_sec, + ), + }; + + match applied { + Ok(()) => { + *active_screen_path = next_clip.screen_path.clone(); + *active_webcam_path = next_clip.webcam_path.clone(); + *active_webcam_offset_sec = next_clip.webcam_offset_sec; + *active_clip_index = next_index; + comp.set_scene(Some(scene_for_clip(scene, *active_clip_index))); + // Réutilise le curseur préchargé s'il est disponible (voir plus haut) — sinon + // (préchargement pas encore prêt / raté) on retombe sur la lecture synchrone + // habituelle, comme avant cette optimisation. + *raw_cursor = match prefetched_cursor { + Some(track) => track, + None => { + let cursor_path = format!("{}.cursor.json", active_screen_path); + CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok() + } + }; + match raw_cursor { + Some(track) => comp.set_cursor(track.smoothed(0.0)), + None => comp.clear_cursor(), + } + *last_smoothing = -1.0; + } + Err(e) => eprintln!("[live] auto-advance clip: {e:#}"), + } +} + +/// Taille à laquelle la preview doit rastériser : la **géométrie de sortie** (donc +/// le ratio réel de l'export — la preview doit montrer ce qui sera rendu), ramenée +/// à ce que le canvas affiche réellement. +/// +/// Deux bornes, pour deux raisons distinctes : +/// - jamais plus grand que le **panneau** : les pixels en trop seraient réduits +/// dans la foulée par `readback_resized`, c'est du coût pur (sur un projet 4K +/// ce serait 8 Mpx rastérisés pour un canvas qui en affiche moins d'un) ; +/// - jamais plus grand que la **sortie** : au-delà, la preview serait plus +/// détaillée que l'export, donc mensongère. +/// +/// Sans scène, on ne connaît pas encore le ratio : on prend la taille du panneau +/// telle quelle (aucune composition n'a lieu tant que la scène n'est pas posée). +fn preview_render_size(scene: Option<&Scene>, pw: u32, ph: u32) -> (u32, u32) { + let (pw, ph) = (pw.max(2), ph.max(2)); + let Some(scene) = scene else { + return (pw, ph); + }; + let (ow, oh) = (scene.output.width.max(1) as f64, scene.output.height.max(1) as f64); + // "contain" : le plus grand cadre au ratio de sortie qui tienne dans le panneau. + let scale = (pw as f64 / ow).min(ph as f64 / oh).min(1.0); + // Arrondi via la MÊME règle que `new_sized` : la boucle de rendu compare cette + // taille à `comp.render_size()` (qui renvoie la valeur arrondie) pour décider de + // reconstruire. Sans ce passage par `normalize_render_size`, une cible impaire + // ne serait jamais égalée → reconstruction du compositeur à chaque frame. + Compositor::normalize_render_size((ow * scale).round() as u32, (oh * scale).round() as u32) +} + +/// Boucle de rendu (thread dédié) : décode → compose → resize → readback → publie +/// dans `Shared::latest_frame`. +unsafe fn render_thread( + shared: Arc, + screen: &str, + webcam: &str, + cursor_json: &str, +) -> Result<()> { + // Chemin de PRODUCTION : matériel si possible, backend CPU sinon (voir `create_auto`). + let gpu = Gpu::create_auto(false)?; + let mut comp = Compositor::new(&gpu)?; + // Vue live = le VRAI enregistrement, pas la fenêtre fixture (100s@6s, taillée pour l'ancien + // fixture POC). On charge toute la piste depuis t=0 ; 24h couvre large toute recording réelle. + // Gardée à part (raw_cursor) pour pouvoir régénérer une variante lissée sans relire le + // fichier à chaque changement du slider "smoothing" (voir la boucle plus bas). + let mut raw_cursor = CursorTrack::load(cursor_json, 0.0, 24.0 * 3600.0).ok(); + if let Some(track) = &raw_cursor { + comp.set_cursor(track.smoothed(0.0)); + } + let mut player = Player::open(screen, webcam, &gpu)?; + let mut active_screen_path = screen.to_string(); + let mut active_webcam_path = webcam.to_string(); + let mut active_webcam_offset_sec = 0.0f64; + let mut active_clip_index = 0usize; + // Copie de la Scene complète (tous les clips), tenue à jour à chaque push de l'app — + // permet à la boucle de lecture libre de connaître la fenêtre source + // [source_start_sec, source_end_sec) du clip actif et d'enchaîner elle-même sur le + // clip suivant (voir plus bas), sans dépendre d'un aller-retour JS par frontière de + // clip : la timeline est un niveau d'abstraction AU-DESSUS des clips, elle se lit + // dans son entièreté et l'utilisateur ne doit jamais remarquer la frontière. + let mut full_scene: Option = None; + // Préchargement du clip suivant en cours (voir `maybe_start_prefetch`) — `None` la + // plupart du temps, `Some` seulement dans la fenêtre `PREFETCH_LEAD_SEC` avant une + // frontière de clip. Invalidé (mis à `None`) dès que le contexte qui l'a déclenché + // devient obsolète (nouvelle scène, changement de clip explicite) pour ne jamais risquer + // d'appliquer les décodeurs d'un préchargement qui ne correspond plus à la situation. + let mut prefetch: Option = None; + + // config de base = C8 (tous effets) ; le fond flouté est piloté par le param live. + let mut cfg = config::all().pop().expect("au moins une config"); + // Migration D3D : le layout et le zoom viennent de l'app (contrat de scène), pas du planning + // fixture. On désactive l'animation de layout A↔B et le zoom codés en dur de `timeline()` — + // sinon la preview d'un vrai enregistrement joue la « scène fixture » (le bug d'animation vu). + // Layout statique (PiP) par défaut ; les zoom regions / presets seront rebranchés via la scène. + cfg.zoom = false; + cfg.layout_anim = false; + + let mut last = Instant::now(); + let mut acc = 0.0f64; + let mut first = true; + let mut last_preview_size: (u32, u32) = (0, 0); + let mut last_ip: Option = None; + let mut last_smoothing: f32 = -1.0; // force la 1re application (0.0 est une valeur valide) + // La vue live est TOUJOURS pilotée par la scène de l'app. Tant qu'aucune scène n'a été + // appliquée, on refuse de jouer le layout fixture (POC) : un fallback fixture ne ferait que + // MASQUER un scene-push cassé. On attend la scène avant de produire le 1er frame. + let mut scene_applied = false; + + while !shared.stop.load(Ordering::SeqCst) { + // params inspector : booléens/taps → cfg ; valeurs continues → live_params + let ip = *shared.inspector.lock().unwrap(); + let mut clip_changed = false; + let clip_request = shared.active_clip_request.lock().unwrap().take(); + if let Some(request) = clip_request { + // Un changement de clip explicite depuis l'app rend obsolète tout préchargement + // en cours (il visait la suite du clip qu'on est en train de quitter maintenant + // autrement) — sans ça, `advance_to_next_scene_clip` pourrait plus tard appliquer + // des décodeurs qui ne correspondent plus au contexte réel. + prefetch = None; + match player.set_active_clip( + &request.screen_path, + &request.webcam_path, + request.webcam_offset_sec, + request.source_time_sec, + ) { + Ok(()) => { + active_screen_path = request.screen_path; + active_webcam_path = request.webcam_path; + active_webcam_offset_sec = request.webcam_offset_sec; + let scene = shared.scene.lock().unwrap().clone(); + full_scene = scene.clone(); + if let Some(base_scene) = scene { + if let Some(index) = resolve_scene_clip_index( + &base_scene, + request.clip_index, + &active_screen_path, + &active_webcam_path, + active_webcam_offset_sec, + ) { + active_clip_index = index; + } else { + eprintln!( + "[live] set_active_clip: sources absentes de la scène (screen=\"{}\", webcam=\"{}\")", + active_screen_path, active_webcam_path + ); + } + comp.set_scene(Some(scene_for_clip(&base_scene, active_clip_index))); + scene_applied = true; + } + let cursor_path = format!("{}.cursor.json", active_screen_path); + raw_cursor = CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok(); + match &raw_cursor { + Some(track) => { + eprintln!( + "[live] cursor: path={} loaded=ok samples={}", + cursor_path, + track.sample_count(), + ); + comp.set_cursor(track.smoothed(0.0)); + } + None => { + eprintln!( + "[live] cursor: path={} loaded=FAIL — clear_cursor()", + cursor_path, + ); + comp.clear_cursor(); + } + } + last_smoothing = -1.0; + clip_changed = true; + } + Err(e) => eprintln!("[live] set_active_clip: {e:#}"), + } + } + cfg.bg_blur = ip.bg_blur; + cfg.mblur_n = ip.mblur_taps; + cfg.cursor = ip.cursor_show; + // TS falls `webcamPath` back to the screen asset's own path when a clip has no real + // camera (so the decoder pipeline always has something valid to open) — if we drew the + // PiP box in that case it would just duplicate the screen video into its own corner. + // `same_source_path` already exists for exactly this comparison (scene/clip matching). + let has_real_webcam = !same_source_path(&active_webcam_path, &active_screen_path); + comp.set_live_params(LiveParams { + bg_color: ip.bg_color, + shadow_scale: ip.shadow_scale, + radius_scale: ip.radius_scale, + padding: ip.padding, + webcam_size_scale: ip.webcam_size_scale, + webcam_mirror: ip.webcam_mirror, + webcam_shape: ip.webcam_shape, + cursor_size_scale: ip.cursor_size_scale, + cursor_bounce_scale: ip.cursor_bounce_scale, + cursor_motion_blur: ip.cursor_motion_blur, + has_webcam: has_real_webcam, + }); + // Lissage ressort-amortisseur : re-génère la piste (240 Hz) uniquement quand la valeur + // change (pas à chaque frame — le resample+ressort parcourt tout l'enregistrement). + if let Some(raw) = &raw_cursor { + if ip.cursor_smoothing != last_smoothing { + comp.set_cursor(raw.smoothed(ip.cursor_smoothing)); + last_smoothing = ip.cursor_smoothing; + } + } + // un changement de param doit se voir même en pause (édition live des sliders) : + // on recompose la frame courante dans la branche pause ci-dessous. + let ip_changed = last_ip != Some(ip); + last_ip = Some(ip); + + // scène de l'app : appliquée au compositeur quand elle change (dirty). + let scene_changed = shared.scene_dirty.swap(false, Ordering::Relaxed); + if scene_changed { + // La nouvelle scène peut avoir réordonné/modifié les clips — tout index visé par + // un préchargement en cours n'est plus fiable. + prefetch = None; + let scene = shared.scene.lock().unwrap().clone(); + full_scene = scene.clone(); + let scene = scene.map(|base_scene| { + scene_applied = true; + if let Some(index) = resolve_scene_clip_index( + &base_scene, + active_clip_index, + &active_screen_path, + &active_webcam_path, + active_webcam_offset_sec, + ) { + active_clip_index = index; + } + scene_for_clip(&base_scene, active_clip_index) + }); + comp.set_scene(scene); + } + + // résolution cible du preview (le canvas Electron) → force le recadrage des + // ressources GPU si elle change. BUG évité : sans ce suivi, redimensionner le + // panneau preview PENDANT une pause ne redéclenchait ni recompose ni readback + // (aucune des autres conditions de la branche pause ne couvrait "juste la + // résolution a changé") — le canvas restait figé à l'ancienne taille jusqu'à la + // reprise de lecture ou un autre changement de param/scène. + let (pw, ph) = *shared.preview_size.lock().unwrap(); + let resized = (pw, ph) != last_preview_size; + last_preview_size = (pw, ph); + + // Le compositeur rastérise à la géométrie de SORTIE (ramenée à la taille du + // canvas) et non plus dans un canvas 16:9 figé. Quand cette géométrie change + // — l'utilisateur change de ratio, ou redimensionne le panneau — on + // reconstruit le compositeur. Voir `Compositor::new_sized` pour le choix + // "reconstruire" plutôt que "redimensionner à chaud". + let want = preview_render_size(full_scene.as_ref(), pw, ph); + if want != comp.render_size() { + comp = Compositor::new_sized(&gpu, want.0, want.1)?; + // Le compositeur neuf est vierge : on repasse par les mécanismes + // d'invalidation existants plutôt que de recopier l'état à la main — + // une seule façon d'appliquer la scène, les params et le curseur. + shared.scene_dirty.store(true, Ordering::Relaxed); + last_ip = None; + last_smoothing = -1.0; + first = true; + continue; + } + + // Pas encore de scène → on ne compose RIEN (pas de fixture masquante). On attend + // la scène. Un scene-push cassé reste ainsi visible (preview silencieuse — le + // canvas reste sur sa frame précédente côté JS, ce qui est mieux qu'un fallback + // masquant). + if !scene_applied { + std::thread::sleep(Duration::from_millis(8)); + continue; + } + + // avance : seek app-piloté (presentTime) prioritaire, sinon lecture libre (60 fps) + let requested = shared.requested_frame.lock().unwrap().take(); + let now = Instant::now(); + let dt = (now - last).as_secs_f64().min(0.1); + last = now; + let mut stepped = false; + if let Some(target) = requested { + if player.present_frame(&comp, &cfg, target)? { + stepped = true; + } + acc = 0.0; // resynchronise l'accumulateur de lecture libre après un seek + } else if shared.playing.load(Ordering::Relaxed) { + // BUG corrigé : la lecture libre décodait toujours exactement 1 frame par tick de + // 1/60s réel, quelle que soit la speed region active au temps source courant — ni + // l'écran ni la webcam n'accéléraient/ralentissaient jamais en preview live (seul + // l'export, via `speed_segments_for_window`/`advance_decoder_to` dans pipeline.rs, + // retimait correctement). Mod 3 corrige déjà le fps-mismatch webcam/écran (la webcam + // suit le temps source RÉEL de l'écran, pas un pas 1:1) — reprend ici la même idée : + // l'accumulateur de temps réel est mis à l'échelle par le multiplicateur de vitesse + // actif, donc `step()` (qui resynchronise la webcam sur le temps écran courant, + // cf. plus haut) décode plus/moins de frames par seconde réelle selon la région. + let speed = full_scene + .as_ref() + .map(|scene| speed_at(&scene.speed_regions, active_clip_index, player.screen_time_sec())) + .unwrap_or(1.0); + acc += dt * speed; + let step = 1.0 / 60.0; + let mut n = 0; + // Cap proportionnel à la vitesse (borné) : à vitesse élevée, plus de frames doivent + // être décodées par tick réel pour ne pas prendre du retard sur l'accumulateur. + let max_steps = ((3.0 * speed.max(1.0)).ceil() as i32).min(64); + while acc >= step && n < max_steps { + // Timeline = niveau d'abstraction AU-DESSUS des clips : dès que le décodeur + // écran atteint la fin de fenêtre du clip actif, on enchaîne nous-mêmes sur + // le clip suivant (ou on reboucle sur le premier après le dernier) — sans + // dépendre d'un `active_clip_request` poussé par le JS en réaction au + // franchissement. Ce round-trip arrivait toujours trop tard : le décodeur + // avait déjà dépassé la fin de la fenêtre, voire atteint l'EOF brut du + // fichier et rebouclé sur lui-même — d'où le "retour au 1er clip" observé. + if let Some(scene) = &full_scene { + // Approche de la frontière : lance (ou laisse tourner) le préchargement + // du clip suivant en tâche de fond, pour que la bascule ci-dessous soit + // instantanée plutôt que de payer un `Decoder::open` synchrone pile au + // moment de la transition — la pause perceptible observée en usage réel. + maybe_start_prefetch( + scene, + active_clip_index, + player.screen_time_sec(), + &gpu, + &mut prefetch, + ); + if let Some(clip) = scene.clips.get(active_clip_index) { + if player.screen_time_sec() >= clip.source_end_sec { + advance_to_next_scene_clip( + &mut player, + &comp, + scene, + &mut prefetch, + &mut active_screen_path, + &mut active_webcam_path, + &mut active_webcam_offset_sec, + &mut active_clip_index, + &mut raw_cursor, + &mut last_smoothing, + ); + } + } + } + let screen_time_before_step = full_scene.as_ref().map(|_| player.screen_time_sec()); + if player.step(&comp, &cfg)? { + stepped = true; + } + // Filet de sécurité : un clip NON trimmé (source_end_sec == durée totale du + // fichier) peut ne jamais franchir le seuil ci-dessus si la dernière frame + // réelle a un PTS strictement inférieur à `source_end_sec` déclaré — `step()` + // finit alors par boucler tout seul sur l'EOF réel (temps qui recule + // brutalement). On détecte ce recul et on corrige immédiatement en enchaînant + // sur le clip suivant, plutôt que de rester bloqué sur le 1er clip. + if let (Some(scene), Some(t_before)) = (&full_scene, screen_time_before_step) { + if player.screen_time_sec() < t_before { + advance_to_next_scene_clip( + &mut player, + &comp, + scene, + &mut prefetch, + &mut active_screen_path, + &mut active_webcam_path, + &mut active_webcam_offset_sec, + &mut active_clip_index, + &mut raw_cursor, + &mut last_smoothing, + ); + } + } + acc -= step; + n += 1; + } + if acc > step { + acc = 0.0; + } + } else if first || ip_changed || scene_changed || clip_changed || resized { + // pause : recompose la frame courante (param / scène / clip / résolution changés). + let _ = player.recompose(&comp, &cfg); + stepped = true; + } + + if stepped || first { + if pw > 0 && ph > 0 { + // Step complet : `compose_frame` (déjà appelé par `step`/`present_frame`/ + // `recompose`) a rastérisé le RT à la géométrie de sortie ramenée au panneau. + // On lit ce RT DIRECTEMENT à sa résolution de rendu (`readback_direct` : copy + // rt → staging → Map/Unmap), sans le resize `blit_resized` qui, depuis la + // refonte ratio, n'était plus qu'une copie identité + une alloc NV12 inutile. + match comp.readback_direct() { + Ok((rw, rh, rgba)) => { + // Publie dans `latest_frame` : on remplace le buffer précédent + // (le canvas ne montre que la dernière frame, peu importe combien + // le renderer en a raté entre deux lectures napi). On incrémente + // la génération sous le MÊME lock que l'écriture du buffer, pour + // qu'un lecteur ne puisse jamais voir un `gen` neuf appairé à un + // buffer périmé (ou l'inverse). `+ 1` depuis la précédente, `1` au + // premier publish. Les dims publiées sont celles du RENDU (`rw`×`rh`) : + // le canvas JS s'y dimensionne (packet auto-descriptif) puis CSS met à + // l'échelle vers la boîte du panneau — plus de resize GPU intermédiaire. + if let Ok(mut slot) = shared.latest_frame.lock() { + let next_gen = slot.as_ref().map(|(g, ..)| g + 1).unwrap_or(1); + *slot = Some((next_gen, rw, rh, rgba)); + } + first = false; + } + Err(e) => { + eprintln!("[live] readback_direct: {e:#}"); + std::thread::sleep(Duration::from_millis(8)); + } + } + } + } else { + std::thread::sleep(Duration::from_millis(4)); + } + } + Ok(()) +} + +// ---------- harnais standalone (poc-d3d.exe --live) ---------- + +use windows::Win32::Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; + +extern "system" fn host_proc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + unsafe { + if msg == WM_DESTROY { + PostQuitMessage(0); + return LRESULT(0); + } + DefWindowProcW(hwnd, msg, wp, lp) + } +} + +/// Test hors Electron : fenêtre hôte top-level (juste pour drainer les messages Windows +/// du main thread) + une `LiveView` offscreen qui produit des frames RGBA8 dans un +/// `` HTML via le harnais d'affichage standalone. Valide le rendu threadé +/// + le readback CPU sans dépendre d'Electron. +pub fn run_standalone(screen: &str, webcam: &str, cursor_json: &str) -> Result<()> { + unsafe { + let hinst = HINSTANCE(GetModuleHandleW(None)?.0); + let cls = wide("PocD3DLiveHost"); + let wc = WNDCLASSW { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(host_proc), + hInstance: hinst, + lpszClassName: PCWSTR(cls.as_ptr()), + hbrBackground: windows::Win32::Graphics::Gdi::HBRUSH(std::ptr::null_mut()), + ..Default::default() + }; + RegisterClassW(&wc); + + let title = wide("poc-d3d — live embed test (offscreen RGBA8 readback)"); + let host = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(cls.as_ptr()), + PCWSTR(title.as_ptr()), + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, + CW_USEDEFAULT, + 1280, + 760, + HWND::default(), + HMENU::default(), + hinst, + None, + )?; + + // Résolution preview = client de la fenêtre host. Ajustable au resize du host. + let mut last = (0u32, 0u32); + let (mut w, mut h) = client_size(host); + last = (w, h); + let view = LiveView::create(w, h, screen, webcam, cursor_json)?; + let _ = ShowWindow(host, SW_SHOW); + println!("live embed: vue offscreen créée, thread de rendu démarré"); + println!(" touches : [B] flou de fond (param → D3D) [Espace] pause/lecture"); + + // état des paramètres pilotés au clavier (le MÊME set_param que l'addon napi appelle) + let mut blur = false; + let mut playing = true; + let set_title = |b: bool, p: bool| unsafe { + let t = wide(&format!( + "poc-d3d — live embed · flou: {} · {} (B / Espace)", + if b { "ON" } else { "off" }, + if p { "lecture" } else { "PAUSE" } + )); + let _ = SetWindowTextW(host, PCWSTR(t.as_ptr())); + }; + set_title(blur, playing); + + let mut msg = MSG::default(); + let mut running = true; + while running { + while PeekMessageW(&mut msg, HWND::default(), 0, 0, PM_REMOVE).as_bool() { + if msg.message == WM_QUIT { + running = false; + break; + } + if msg.message == WM_KEYDOWN { + match msg.wParam.0 as u32 { + 0x42 => { + // 'B' : bascule le fond flouté via set_param — chemin param → D3D + blur = !blur; + view.set_param_bool("backgroundBlur", blur); + set_title(blur, playing); + } + 0x20 => { + // Espace : pause/lecture + playing = !playing; + view.set_playing(playing); + set_title(blur, playing); + } + _ => {} + } + } + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + if !running { + break; + } + let (cw, ch) = client_size(host); + if (cw, ch) != last { + view.set_rect(cw, ch); + last = (cw, ch); + w = cw; + h = ch; + } + // Force `first=false` côté render thread : si la preview était en pause + // totale, on n'a pas publié de frame. On laisse le canvas vide ; le harnais + // standalone n'affiche pas réellement les pixels ici (l'embed Electron est + // le consumer réel). On imprime juste une frame de temps en temps pour + // confirmer que la chaîne fonctionne. + if let Some((_gen, fw, fh, _pixels)) = view.latest_frame() { + if (fw, fh) != (w, h) { + // garde-fou : la staging de readback suit `set_rect` côté thread + // de rendu, donc ce serait une désynchro transitoire — acceptable. + } + } + std::thread::sleep(Duration::from_millis(8)); + } + drop(view); + Ok(()) + } +} + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +unsafe fn client_size(hwnd: HWND) -> (u32, u32) { + let mut rc = RECT::default(); + let _ = GetClientRect(hwnd, &mut rc); + ((rc.right - rc.left).max(0) as u32, (rc.bottom - rc.top).max(0) as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn multiclip_scene() -> Scene { + Scene::from_json(r##"{ + "clips": [ + {"screenPath":"/shared-screen.mp4","webcamPath":"/shared-webcam.mp4","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":1.25,"hasAudio":true}, + {"screenPath":"/shared-screen.mp4","webcamPath":"/shared-webcam.mp4","sourceStartSec":20,"sourceEndSec":24,"webcamOffsetSec":1.25,"hasAudio":true}, + {"screenPath":"/distinct-screen.mp4","webcamPath":"/distinct-webcam.mp4","sourceStartSec":100,"sourceEndSec":104,"webcamOffsetSec":0.5,"hasAudio":true} + ], + "layout":{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}, + "effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}, + "background":{"kind":"color","color":"#000000"}, + "zoomRegions":[], + "cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}, + "cropByClip":[null,null,null], + "output":{"width":1920,"height":1080,"fps":30} + }"##).expect("multiclip scene") + } + + #[test] + fn explicit_index_disambiguates_clips_sharing_sources() { + let scene = multiclip_scene(); + assert_eq!(find_scene_clip_index(&scene, "/shared-screen.mp4", "/shared-webcam.mp4", 1.25), Some(0)); + assert_eq!(resolve_scene_clip_index(&scene, 1, "/shared-screen.mp4", "/shared-webcam.mp4", 1.25), Some(1)); + } + + #[test] + fn explicit_index_tracks_a_distinct_asset() { + let scene = multiclip_scene(); + assert_eq!(resolve_scene_clip_index(&scene, 2, "/distinct-screen.mp4", "/distinct-webcam.mp4", 0.5), Some(2)); + } + + #[test] + fn webcam_seek_uses_screen_source_time_and_offset() { + assert_eq!(webcam_seek_time(22.5, 1.25), 21.25); + assert_eq!(webcam_seek_time(0.5, 1.25), 0.0); + } + + // --- transport handed to an export and back ------------------------------- + // A real `LiveView` needs a D3D device and a decoder; the transport is the only + // part an export touches, so these exercise it through `PreviewTransport` alone. + + /// A preview reduced to its transport flag — no GPU, no render thread. + struct FakePreview(std::cell::Cell); + + impl FakePreview { + fn new(playing: bool) -> Self { + Self(std::cell::Cell::new(playing)) + } + } + + impl PreviewTransport for FakePreview { + fn playing(&self) -> bool { + self.0.get() + } + + fn set_playing(&self, playing: bool) { + self.0.set(playing); + } + } + + /// The regression this exists for: an export used to resume every preview it had + /// paused, so exporting while the editor sat paused left the preview free-running — + /// it drifted off the app's playhead and out of the zoom region the inspector still + /// showed, and only a window reload brought the zoom back. + #[test] + fn an_export_leaves_a_paused_preview_paused() { + let paused = FakePreview::new(false); + let snapshot = PausedPreviews::pause([(7, &paused)]); + assert!(!paused.playing(), "the export must free the GPU while it encodes"); + + snapshot.restore([(7, &paused)]); + assert!(!paused.playing(), "the app never asked for playback — it must still be paused"); + } + + /// The other half of "as found": a preview that WAS playing gets its playback back, + /// which is what the blanket resume happened to get right. + #[test] + fn an_export_gives_a_playing_preview_its_playback_back() { + let playing = FakePreview::new(true); + let snapshot = PausedPreviews::pause([(1, &playing)]); + assert!(!playing.playing(), "paused for the duration of the encode"); + + snapshot.restore([(1, &playing)]); + assert!(playing.playing()); + } + + /// Each preview gets ITS state back, not the majority's. + #[test] + fn each_preview_is_restored_independently() { + let (a, b) = (FakePreview::new(true), FakePreview::new(false)); + let snapshot = PausedPreviews::pause([(1, &a), (2, &b)]); + snapshot.restore([(1, &a), (2, &b)]); + assert_eq!((a.playing(), b.playing()), (true, false)); + } + + /// A preview born mid-export was never paused by it, so the export has no state of + /// its own to hand back — forcing one would overwrite what its creator just pushed. + #[test] + fn a_preview_created_during_an_export_keeps_its_own_transport() { + let existing = FakePreview::new(false); + let snapshot = PausedPreviews::pause([(1, &existing)]); + + let newborn = FakePreview::new(true); + snapshot.restore([(1, &existing), (2, &newborn)]); + assert!(newborn.playing(), "untouched: it is not in the snapshot"); + assert!(!existing.playing()); + } + + /// A preview destroyed during the export simply isn't there to restore — no panic, + /// and the survivors are still handled. + #[test] + fn a_preview_destroyed_during_an_export_is_skipped() { + let (kept, doomed) = (FakePreview::new(true), FakePreview::new(true)); + let snapshot = PausedPreviews::pause([(1, &kept), (2, &doomed)]); + drop(doomed); + snapshot.restore([(1, &kept)]); + assert!(kept.playing()); + } + + // --- taille de rastérisation de la preview --------------------------- + // Ces tests remplacent le filet géométrique qui verrouillait la + // compensation anisotrope : celle-ci n'existe plus (le RT porte la + // géométrie de sortie), donc la logique qui reste à couvrir est le choix + // de la taille. La non-régression pixel, elle, vit dans le golden + // (`tests/output_geometry_golden.rs`). + + fn scene_with_output(w: u32, h: u32) -> Scene { + Scene::from_json(&format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#000000"}},"zoomRegions":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":{w},"height":{h},"fps":null}}}}"## + )) + .expect("scene valide") + } + + /// Sans scène on ne connaît pas encore le ratio de sortie : on prend le + /// panneau tel quel (rien n'est composé tant que la scène n'est pas posée). + #[test] + fn preview_size_without_a_scene_is_the_panel() { + assert_eq!(preview_render_size(None, 800, 450), (800, 450)); + } + + /// Le ratio rendu est celui de la SORTIE, pas celui du panneau — sinon la + /// preview montrerait un cadrage que l'export ne produira pas. + #[test] + fn preview_size_follows_the_output_shape_not_the_panel_shape() { + let portrait = scene_with_output(1080, 1920); + let (w, h) = preview_render_size(Some(&portrait), 1600, 900); + assert!(h > w, "sortie portrait dans un panneau paysage → cadre portrait, obtenu {w}x{h}"); + let got = w as f64 / h as f64; + assert!((got - 1080.0 / 1920.0).abs() < 0.01, "ratio {got}, attendu 0.5625"); + } + + /// Jamais plus grand que le panneau : les pixels en trop seraient réduits + /// dans la foulée par le readback — c'est du coût pur. + #[test] + fn preview_size_never_exceeds_the_panel() { + let uhd = scene_with_output(3840, 2160); + let (w, h) = preview_render_size(Some(&uhd), 960, 540); + assert!(w <= 960 && h <= 540, "{w}x{h} depasse le panneau 960x540"); + } + + /// Jamais plus grand que la sortie : au-delà, la preview serait plus nette + /// que l'export, donc mensongère. + #[test] + fn preview_size_never_exceeds_the_output() { + let small = scene_with_output(640, 360); + let (w, h) = preview_render_size(Some(&small), 3000, 2000); + assert_eq!((w, h), (640, 360)); + } + + /// Anti-régression du bug de reconstruction en boucle : la taille produite + /// doit être un POINT FIXE de `normalize_render_size`. Si ce n'est pas le cas, + /// `want != comp.render_size()` reste vrai indéfiniment et le compositeur se + /// reconstruit à chaque frame (média qui disparaissent, VRAM qui sature). + /// On balaie beaucoup de tailles de panneau : une seule qui produit une + /// dimension impaire suffirait à faire boucler la preview en vrai. + #[test] + fn preview_size_is_always_a_fixed_point_of_the_render_size_rounding() { + let scene = scene_with_output(1920, 1080); + for pw in 200..1400 { + let (w, h) = preview_render_size(Some(&scene), pw, 900); + assert_eq!( + (w, h), + Compositor::normalize_render_size(w, h), + "panneau {pw}x900 → {w}x{h} n'est pas stable → reconstruction en boucle", + ); + } + } +} diff --git a/crates/compositor/src/pipeline.rs b/crates/compositor/src/pipeline.rs new file mode 100644 index 0000000000..aaa0349275 --- /dev/null +++ b/crates/compositor/src/pipeline.rs @@ -0,0 +1,1632 @@ +//! C0 (§9) : décode D3D11VA (screen) → encode h264_amf → mux MP4, sur NOTRE device. +//! Aucun composite. Mesuré au plus extérieur (§10) : Instant (mappe QPC) autour de +//! tout le run, deux lectures seulement. Rien dans la boucle ne peut fausser le fps. + +use crate::audio::{ + assemble_concatenated_pcm, build_audio_concat_plan, decode_clip_audio, + stretch_clip_pcm_by_speed, AacEncoder, PlanarPcm, +}; +use crate::compositor::{Compositor, OUT_H, OUT_W}; +use crate::config::Cfg; +use crate::cpu_frames::CpuFrames; +use crate::cursor::CursorTrack; +use crate::d3d::{Backend, Gpu}; +use crate::ffi::*; +use crate::regions::speed_segments_for_window; +use anyhow::{anyhow, bail, Result}; +use std::collections::HashMap; +use std::ffi::{c_void, CString}; +use std::ptr; +use std::time::Instant; +use windows::core::Interface; + +// Macros libav non générées par bindgen (function-like). Valeurs Windows/MSVC. +const AVERROR_EAGAIN: i32 = -11; // -EAGAIN (EAGAIN=11 sur MSVC) +const AVERROR_EOF: i32 = -541478725; // -MKTAG('E','O','F',' ') +const AVSEEK_FLAG_BACKWARD: i32 = 1; // seek vers la keyframe <= ts (macro non générée) + +// Accesseurs shim.c (AVFormatContext opaque côté bindgen). +extern "C" { + fn sn_fmt_stream(s: *mut AVFormatContext, i: i32) -> *mut AVStream; + fn sn_fmt_nb_streams(s: *mut AVFormatContext) -> u32; + fn sn_fmt_get_pb(s: *mut AVFormatContext) -> *mut AVIOContext; + fn sn_fmt_set_pb(s: *mut AVFormatContext, p: *mut AVIOContext); +} + +pub struct Stats { + pub frames: u64, + pub wall_s: f64, + pub fps: f64, + /// Durée de la vidéo exportée (secondes) = frames / cadence de sortie. Distincte de + /// `wall_s` (temps de rendu réel) — sert au message de succès ("vidéo de Xs exportée en Ys"). + pub video_duration_s: f64, +} + +/// Garde RAII sur une AVFrame (la libère au Drop). +pub struct FrameGuard(pub *mut AVFrame); +impl Drop for FrameGuard { + fn drop(&mut self) { + unsafe { av_frame_free(&mut self.0) }; + } +} + +/// Décode la n-ième frame d'une source sur NOTRE device (textures échantillonnables). +/// Sert le harnais de composition (S3+), hors mesure. Retourne une frame indépendante. +pub fn decode_frame_n(path: &str, gpu: &Gpu, n: u32) -> Result { + unsafe { decode_frame_n_inner(path, gpu, n) } +} + +unsafe fn decode_frame_n_inner(path: &str, gpu: &Gpu, n: u32) -> Result { + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + let vidx = av_find_best_stream(fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, ptr::null_mut(), 0); + if vidx < 0 { + bail!("aucun flux vidéo"); + } + let stream = sn_fmt_stream(fmt, vidx); + let codecpar = (*stream).codecpar; + let dec = avcodec_find_decoder((*codecpar).codec_id); + let dctx = avcodec_alloc_context3(dec); + averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; + allow_d3d11va_h264_baseline(dctx); + + let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + let hwdc = (*hwdev).data as *mut AVHWDeviceContext; + let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; + let dev_clone = gpu.device.clone(); + (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; + std::mem::forget(dev_clone); + averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?; + (*dctx).hw_device_ctx = av_buffer_ref(hwdev); + (*dctx).get_format = Some(get_hw_format); + averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2")?; + + let pkt = av_packet_alloc(); + let frame = av_frame_alloc(); + let mut got: u32 = 0; + let mut result: *mut AVFrame = ptr::null_mut(); + + 'outer: loop { + let r = av_read_frame(fmt, pkt); + if r == AVERROR_EOF { + avcodec_send_packet(dctx, ptr::null_mut()); + } else { + averr(r, "read_frame")?; + if (*pkt).stream_index != vidx { + av_packet_unref(pkt); + continue; + } + averr(avcodec_send_packet(dctx, pkt), "send_packet")?; + av_packet_unref(pkt); + } + loop { + let r = avcodec_receive_frame(dctx, frame); + if r == AVERROR_EAGAIN || r == AVERROR_EOF { + if r == AVERROR_EOF { + break 'outer; + } + break; + } + averr(r, "receive_frame")?; + if got == n { + result = av_frame_clone(frame); // frame indépendante, garde ses refs textures + break 'outer; + } + got += 1; + } + } + + av_frame_free(&mut (frame as *mut _)); + av_packet_free(&mut (pkt as *mut _)); + avcodec_free_context(&mut (dctx as *mut _)); + av_buffer_unref(&mut (hwdev as *mut _)); + avformat_close_input(&mut fmt); + + if result.is_null() { + bail!("frame {n} introuvable"); + } + Ok(FrameGuard(result)) +} + +fn averr(ret: i32, ctx: &str) -> Result<()> { + if ret < 0 { + let mut buf = [0i8; 256]; + unsafe { av_strerror(ret, buf.as_mut_ptr(), buf.len()) }; + let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_string_lossy(); + bail!("{ctx}: {ret} ({msg})"); + } + Ok(()) +} + +/// FFmpeg's DXVA/D3D11VA profile table accepts Constrained Baseline, Main and High, +/// but not plain H.264 Baseline. Chrome MediaRecorder emits plain Baseline even when +/// the bitstream uses the same hardware-decodable subset (no FMO/ASO); without this +/// opt-in FFmpeg rejects the profile before asking the D3D11 driver for a decoder. +/// Keep the mismatch allowance restricted to that exact profile rather than weakening +/// validation for every codec/profile handled by this shared decoder path. +unsafe fn allow_d3d11va_h264_baseline(dctx: *mut AVCodecContext) { + if (*dctx).profile == AV_PROFILE_H264_BASELINE as i32 { + (*dctx).hwaccel_flags |= AV_HWACCEL_FLAG_ALLOW_PROFILE_MISMATCH as i32; + } +} + +// D3D11_TEXTURE2D_DESC.BindFlags (valeurs SDK) +const D3D11_BIND_SHADER_RESOURCE: u32 = 0x8; +const D3D11_BIND_DECODER: u32 = 0x200; + +/// get_format du décodeur : impose la surface D3D11 (§5), sinon ffmpeg retombe en NV12 CPU. +/// Et surtout (§5) : crée un frames-context AVEC BIND_SHADER_RESOURCE, pour que le +/// compositeur HLSL de S3+ échantillonne directement les textures décodeur. +unsafe extern "C" fn get_hw_format( + ctx: *mut AVCodecContext, + mut fmts: *const AVPixelFormat::Type, +) -> AVPixelFormat::Type { + while *fmts != AVPixelFormat::AV_PIX_FMT_NONE { + if *fmts == AVPixelFormat::AV_PIX_FMT_D3D11 { + // frames-context manuel : impose BindFlags (sinon ffmpeg met BIND_DECODER seul, + // et les surfaces ne sont pas échantillonnables → §5). + let frames = av_hwframe_ctx_alloc((*ctx).hw_device_ctx); + if frames.is_null() { + return AVPixelFormat::AV_PIX_FMT_NONE; + } + let fc = (*frames).data as *mut AVHWFramesContext; + (*fc).format = AVPixelFormat::AV_PIX_FMT_D3D11; + (*fc).sw_format = AVPixelFormat::AV_PIX_FMT_NV12; + (*fc).width = (*ctx).coded_width; + (*fc).height = (*ctx).coded_height; + (*fc).initial_pool_size = 32; // DPB H.264 (refs) + frames en vol + let d3dfc = (*fc).hwctx as *mut AVD3D11VAFramesContext; + (*d3dfc).BindFlags = D3D11_BIND_DECODER | D3D11_BIND_SHADER_RESOURCE; + if av_hwframe_ctx_init(frames) < 0 { + av_buffer_unref(&mut (frames as *mut _)); + return AVPixelFormat::AV_PIX_FMT_NONE; + } + (*ctx).hw_frames_ctx = frames; + return AVPixelFormat::AV_PIX_FMT_D3D11; + } + fmts = fmts.add(1); + } + AVPixelFormat::AV_PIX_FMT_NONE +} + +pub fn run_c0(screen: &str, out: &str, gpu: &Gpu) -> Result { + discard_partial_output(out, unsafe { run_c0_inner(screen, out, gpu) }) +} + +/// Un run interrompu laisse le MP4 sans son `moov` : illisible, et portant exactement le nom du +/// fichier que l'utilisateur croit avoir exporté. Le retirer plutôt que le laisser traîner. +/// +/// Posé sur les façades plutôt que sur chaque `?` : les `*_inner` sortent par une trentaine de +/// points, tous concernés de la même façon. +/// +/// ponytail: seul le fichier est nettoyé ; les contextes ffmpeg alloués dans les `*_inner` fuient +/// toujours sur ces sorties-là (il faudrait une garde RAII par pointeur, comme `FrameGuard`). +/// Un export raté est rare et ne boucle pas — à reprendre si ça devient un mode de marche. +fn discard_partial_output(out: &str, result: Result) -> Result { + if result.is_err() { + let _ = std::fs::remove_file(out); + } + result +} + +unsafe fn run_c0_inner(screen: &str, out: &str, gpu: &Gpu) -> Result { + // ---- entrée : demux ---- + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(screen)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "avformat_open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + + let vidx = av_find_best_stream( + fmt, + AVMediaType::AVMEDIA_TYPE_VIDEO, + -1, + -1, + ptr::null_mut(), + 0, + ); + if vidx < 0 { + bail!("aucun flux vidéo"); + } + let stream = sn_fmt_stream(fmt, vidx); + let codecpar = (*stream).codecpar; + + // ---- décodeur D3D11VA sur NOTRE device ---- + let dec = avcodec_find_decoder((*codecpar).codec_id); + if dec.is_null() { + bail!("décodeur introuvable"); + } + let dctx = avcodec_alloc_context3(dec); + averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; + allow_d3d11va_h264_baseline(dctx); + + let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + if hwdev.is_null() { + bail!("av_hwdevice_ctx_alloc"); + } + let hwdc = (*hwdev).data as *mut AVHWDeviceContext; + let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; + // AddRef : ffmpeg Release ce device au teardown. On garde un +1 en fuyant un clone. + let dev_clone = gpu.device.clone(); + (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; + std::mem::forget(dev_clone); + averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?; + + (*dctx).hw_device_ctx = av_buffer_ref(hwdev); + (*dctx).get_format = Some(get_hw_format); + averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2(dec)")?; + + // ---- encodeur (ouvert paresseusement à la 1re frame : il lui faut ses dims + hw_frames_ctx) ---- + let mut enc: Option = None; + let mut ectx: *mut AVCodecContext = ptr::null_mut(); + + // ---- sortie : mux MP4 ---- + let mut octx: *mut AVFormatContext = ptr::null_mut(); + let outc = CString::new(out)?; + averr( + avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + let mut ostream: *mut AVStream = ptr::null_mut(); + + let pkt = av_packet_alloc(); + let opkt = av_packet_alloc(); + let frame = av_frame_alloc(); + + let mut frames: u64 = 0; + + // =========== MESURE : plus extérieure possible (§10) =========== + let t0 = Instant::now(); + + // pompe : read → decode → (open enc) → encode → mux + loop { + let r = av_read_frame(fmt, pkt); + if r == AVERROR_EOF { + break; + } + averr(r, "av_read_frame")?; + if (*pkt).stream_index != vidx { + av_packet_unref(pkt); + continue; + } + averr(avcodec_send_packet(dctx, pkt), "send_packet")?; + av_packet_unref(pkt); + + loop { + let r = avcodec_receive_frame(dctx, frame); + if r == AVERROR_EAGAIN || r == AVERROR_EOF { + break; + } + averr(r, "receive_frame")?; + + if enc.is_none() { + // config depuis la 1re frame décodée : dims réelles + frames_ctx D3D11 + let opened = VideoEncoder::open( + &ExportCodec::H264, + (*frame).width, + (*frame).height, + 60, + 8_000_000, + (*frame).hw_frames_ctx, + )?; + ectx = opened.ctx; + enc = Some(opened); + + ostream = avformat_new_stream(octx, ptr::null()); + if ostream.is_null() { + bail!("avformat_new_stream"); + } + averr( + avcodec_parameters_from_context((*ostream).codecpar, ectx), + "params_from_ctx", + )?; + (*ostream).time_base = (*ectx).time_base; + let mut pb: *mut AVIOContext = ptr::null_mut(); + averr( + avio_open(&mut pb, outc.as_ptr(), AVIO_FLAG_WRITE as i32), + "avio_open", + )?; + sn_fmt_set_pb(octx, pb); + averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; + } + + (*frame).pts = frames as i64; + enc.as_mut().unwrap().send(frame)?; + drain_encoder(ectx, octx, ostream, opkt)?; + frames += 1; + } + } + + // flush décodeur → encodeur + avcodec_send_packet(dctx, ptr::null_mut()); + loop { + let r = avcodec_receive_frame(dctx, frame); + if r == AVERROR_EAGAIN || r == AVERROR_EOF { + break; + } + averr(r, "flush receive_frame")?; + let Some(encoder) = enc.as_mut() else { + bail!("flush : première frame reçue au flush, encodeur jamais ouvert"); + }; + (*frame).pts = frames as i64; + encoder.send(frame)?; + drain_encoder(ectx, octx, ostream, opkt)?; + frames += 1; + } + // flush encodeur + if let Some(encoder) = enc.as_mut() { + encoder.send(ptr::null_mut())?; + drain_encoder(ectx, octx, ostream, opkt)?; + averr(av_write_trailer(octx), "write_trailer")?; + } + + let wall_s = t0.elapsed().as_secs_f64(); + // =========== fin mesure =========== + + // teardown + av_frame_free(&mut (frame as *mut _)); + av_packet_free(&mut (pkt as *mut _)); + av_packet_free(&mut (opkt as *mut _)); + let mut pb = sn_fmt_get_pb(octx); + if !pb.is_null() { + avio_closep(&mut pb); + sn_fmt_set_pb(octx, ptr::null_mut()); + } + avformat_free_context(octx); + // `enc` est libéré par son Drop en fin de portée (voir run_multi_inner). + avcodec_free_context(&mut (dctx as *mut _)); + av_buffer_unref(&mut (hwdev as *mut _)); + avformat_close_input(&mut fmt); + + let fps = frames as f64 / wall_s; + Ok(Stats { frames, wall_s, fps, video_duration_s: frames as f64 / 60.0 }) +} + +/// Boucle de PREVIEW mesurée : décode → compose → readback, sans encodeur. +/// +/// Pourquoi pas `run_composited` : celui-ci encode en h264_amf, qui exige le vrai GPU. +/// Le backend CPU ne peut donc pas le traverser, et les deux backends ne seraient pas +/// comparables. L'encodage est de toute façon un TROISIÈME axe (comme le rendu et le +/// décodage) et il n'a pas de repli logiciel ici — ce qui fait de la preview la seule +/// surface que le backend CPU vise réellement. C'est exactement ce que cette boucle mesure, +/// et c'est la même séquence que le thread de rendu de `live.rs`. +/// +/// `frames` = nombre de frames composées ; la source boucle (`seek_to(0)`) si elle est +/// plus courte, pour que les deux backends voient exactement la même charge. +/// +/// Rend aussi le DERNIER readback (`w`, `h`, RGBA8) : un backend qui compose du noir +/// serait rapide et parfaitement inutile, donc le chiffre ne veut rien dire sans l'image +/// qui va avec. C'est ce qui permet de comparer pixel à pixel les deux backends. +pub fn run_preview_bench( + screen: &str, + webcam: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + frames: u64, +) -> Result<(Stats, (u32, u32, Vec))> { + unsafe { + let mut sdec = Decoder::open(screen, gpu)?; + let mut wdec = Decoder::open(webcam, gpu)?; + + // Hors mesure : première frame de chaque source. Le premier décodage porte + // l'allocation du pool (matériel) ou de la texture NV12 + du contexte swscale + // (CPU) ; le compter fausserait surtout les runs courts. + let mut sf = sdec.next()?; + let mut wf = wdec.next()?; + if sf.is_null() || wf.is_null() { + bail!("source vide (screen ou webcam ne rend aucune frame)"); + } + comp.compose_frame(sf, wf, 0.0, cfg)?; + let _ = comp.readback_direct()?; + + let mut last = (0u32, 0u32, Vec::new()); + let t0 = Instant::now(); + for i in 0..frames { + sf = sdec.next()?; + if sf.is_null() { + sf = sdec.seek_to(0.0)?; + } + wf = wdec.next()?; + if wf.is_null() { + wf = wdec.seek_to(0.0)?; + } + if sf.is_null() || wf.is_null() { + bail!("source épuisée après rembobinage à la frame {i}"); + } + comp.compose_frame(sf, wf, i as f32, cfg)?; + // Le readback fait partie de la mesure : c'est ce que la preview paie + // réellement pour afficher une frame (GPU→CPU puis canvas). + last = comp.readback_direct()?; + } + let wall_s = t0.elapsed().as_secs_f64(); + + Ok(( + Stats { + frames, + wall_s, + fps: frames as f64 / wall_s, + video_duration_s: frames as f64 / 60.0, + }, + last, + )) + } +} + +/// Décodeur qui rend une frame à la fois (pour composer 2 sources en lockstep). +/// `pub(crate)` : réutilisé par la preview/playback (voir `app.rs`). +pub(crate) struct Decoder { + fmt: *mut AVFormatContext, + dctx: *mut AVCodecContext, + hwdev: *mut AVBufferRef, + vidx: i32, + pkt: *mut AVPacket, + frame: *mut AVFrame, + sent_eof: bool, + /// Backend CPU uniquement : convertit la frame système en texture NV12 et la présente + /// sous le même contrat que D3D11VA (voir `cpu_frames`). `None` en matériel — le + /// décodeur rend alors directement la texture du pool D3D11VA, sans copie. + cpu: Option, +} + +// SAFETY: `Decoder` only owns FFI pointers into FFmpeg's own heap-allocated state, which +// has no OS thread affinity — safe to create on one thread and hand off to another as long +// as it's touched from a single thread at a time (never concurrently), which is exactly the +// live-preview prefetch pattern in `live.rs`: a background thread opens+seeks a `Decoder`, +// then sends it across a channel to the render thread, which alone uses it from then on. +unsafe impl Send for Decoder {} + +impl Decoder { + pub(crate) unsafe fn open(path: &str, gpu: &Gpu) -> Result { + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + let vidx = av_find_best_stream(fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, ptr::null_mut(), 0); + if vidx < 0 { + bail!("aucun flux vidéo dans {path}"); + } + let stream = sn_fmt_stream(fmt, vidx); + let codecpar = (*stream).codecpar; + let dec = avcodec_find_decoder((*codecpar).codec_id); + let dctx = avcodec_alloc_context3(dec); + averr(avcodec_parameters_to_context(dctx, codecpar), "params_to_ctx")?; + allow_d3d11va_h264_baseline(dctx); + + // Backend CPU : on n'attache AUCUN hw_device_ctx et on ne force pas `get_format`, + // donc libavcodec choisit son décodeur logiciel et sort en mémoire système. Passer + // le device WARP à D3D11VA ne marcherait pas de toute façon — WARP n'expose pas + // d'`ID3D11VideoDevice` (`tests/warp_device_cannot_decode.rs`). + let cpu = if gpu.backend == Backend::Cpu { + // `threads = 0` : libavcodec prend le nombre de cœurs. C'est le seul réglage + // qui compte vraiment ici — sans lui le décodage logiciel est mono-thread et + // le benchmark mesurerait surtout ça. + (*dctx).thread_count = 0; + Some(CpuFrames::new(gpu)?) + } else { + None + }; + + let hwdev = if cpu.is_some() { + ptr::null_mut() + } else { + let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + let hwdc = (*hwdev).data as *mut AVHWDeviceContext; + let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; + let dev_clone = gpu.device.clone(); + (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; + std::mem::forget(dev_clone); + averr(av_hwdevice_ctx_init(hwdev), "hwdevice_ctx_init")?; + (*dctx).hw_device_ctx = av_buffer_ref(hwdev); + (*dctx).get_format = Some(get_hw_format); + hwdev + }; + averr(avcodec_open2(dctx, dec, ptr::null_mut()), "avcodec_open2")?; + + Ok(Decoder { + fmt, + dctx, + hwdev, + vidx, + pkt: av_packet_alloc(), + frame: av_frame_alloc(), + sent_eof: false, + cpu, + }) + } + + /// Dernière frame décodée (valide jusqu'au prochain `next`) — pour recomposer + /// la frame courante après un changement de config, sans réavancer (preview). + /// + /// En backend CPU c'est la frame de PRÉSENTATION (la texture NV12 uploadée), pas la + /// frame système du décodeur : `cur_frame` alimente `compose_frame` au même titre que + /// `next`, donc les deux doivent rendre la même chose. Le temps (`cur_time_sec`), lui, + /// continue de se lire sur la vraie frame décodée. + pub(crate) fn cur_frame(&self) -> *mut AVFrame { + match &self.cpu { + Some(cpu) => cpu.current(), + None => self.frame, + } + } + + /// Repositionne le flux à la première keyframe (t=0) et vide le codec — pour boucler + /// la playback sans réallouer les décodeurs. La fixture démarre sur un IDR (§11). + pub(crate) unsafe fn rewind(&mut self) -> Result<()> { + averr(av_seek_frame(self.fmt, self.vidx, 0, AVSEEK_FLAG_BACKWARD), "seek")?; + avcodec_flush_buffers(self.dctx); + self.sent_eof = false; + Ok(()) + } + + /// Time_base du flux vidéo (secondes par unité de pts). + unsafe fn tb_sec(&self) -> f64 { + let tb = (*sn_fmt_stream(self.fmt, self.vidx)).time_base; + if tb.den != 0 { tb.num as f64 / tb.den as f64 } else { 0.0 } + } + + /// Seek keyframe vers `seconds` puis décode-avant jusqu'à la 1re frame dont le temps + /// ≥ `seconds`. Réutilise le décodeur ouvert (pas de réouverture) : c'est LE point de + /// perf multiclip — un seul seek par frontière de clip, décodage séquentiel ensuite, + /// donc le débit par frame ne change pas. Renvoie la frame (ou null à EOF). + pub(crate) unsafe fn seek_to(&mut self, seconds: f64) -> Result<*mut AVFrame> { + let tb_sec = self.tb_sec(); + let target = if tb_sec > 0.0 { (seconds / tb_sec) as i64 } else { 0 }; + averr(av_seek_frame(self.fmt, self.vidx, target, AVSEEK_FLAG_BACKWARD), "seek_to")?; + avcodec_flush_buffers(self.dctx); + self.sent_eof = false; + loop { + let f = self.next()?; + if f.is_null() { + return Ok(ptr::null_mut()); + } + let pts = (*f).best_effort_timestamp; + // pas de pts fiable ou pas de time_base → on prend la 1re frame après la keyframe. + if pts == i64::MIN || tb_sec <= 0.0 { + return Ok(f); + } + if (pts as f64) * tb_sec >= seconds - tb_sec * 0.5 { + return Ok(f); + } + } + } + + /// Temps (s) de la frame courante, via son pts. 0 si pas de pts fiable. + pub(crate) unsafe fn cur_time_sec(&self) -> f64 { + let pts = (*self.frame).best_effort_timestamp; + if pts == i64::MIN { 0.0 } else { pts as f64 * self.tb_sec() } + } + + /// Cadence moyenne du flux (fps). 30 par défaut si indéterminée. + pub(crate) unsafe fn fps(&self) -> f64 { + let r = (*sn_fmt_stream(self.fmt, self.vidx)).avg_frame_rate; + if r.den != 0 && r.num != 0 { r.num as f64 / r.den as f64 } else { 30.0 } + } + + /// Durée réellement annoncée par le flux vidéo. La durée du stream est prioritaire ; + /// `nb_frames / fps` sert de repli pour les conteneurs qui omettent `duration`. + pub(crate) unsafe fn available_duration_sec(&self) -> Option { + let stream = sn_fmt_stream(self.fmt, self.vidx); + let duration = (*stream).duration; + let tb_sec = self.tb_sec(); + if duration > 0 && tb_sec > 0.0 { + let seconds = duration as f64 * tb_sec; + if seconds.is_finite() && seconds > 0.0 { + return Some(seconds); + } + } + let nb_frames = (*stream).nb_frames; + let fps = self.fps(); + if nb_frames > 0 && fps.is_finite() && fps > 0.0 { + Some(nb_frames as f64 / fps) + } else { + None + } + } + + /// Rend la prochaine frame (valide jusqu'au prochain appel), ou null à EOF. + pub(crate) unsafe fn next(&mut self) -> Result<*mut AVFrame> { + loop { + let r = avcodec_receive_frame(self.dctx, self.frame); + if r == 0 { + return match &mut self.cpu { + Some(cpu) => cpu.present(self.frame), + None => Ok(self.frame), + }; + } + if r == AVERROR_EOF { + return Ok(ptr::null_mut()); + } + if r != AVERROR_EAGAIN { + averr(r, "receive_frame")?; + } + if self.sent_eof { + return Ok(ptr::null_mut()); + } + let rr = av_read_frame(self.fmt, self.pkt); + if rr == AVERROR_EOF { + avcodec_send_packet(self.dctx, ptr::null_mut()); + self.sent_eof = true; + } else { + averr(rr, "read_frame")?; + if (*self.pkt).stream_index == self.vidx { + averr(avcodec_send_packet(self.dctx, self.pkt), "send_packet")?; + } + av_packet_unref(self.pkt); + } + } + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + unsafe { + av_frame_free(&mut self.frame); + av_packet_free(&mut self.pkt); + avcodec_free_context(&mut self.dctx); + av_buffer_unref(&mut self.hwdev); + avformat_close_input(&mut self.fmt); + } + } +} + +/// Avance un décodeur jusqu'au premier pts dans le référentiel écran qui atteint la cible. +/// `timeline_offset_sec` remet les pts webcam dans ce référentiel (`webcam + offset = screen`) : +/// chaque source garde ainsi sa cadence propre au lieu d'être consommée 1:1 avec l'autre. +unsafe fn advance_decoder_to( + decoder: &mut Decoder, + target_source_time: f64, + timeline_offset_sec: f64, +) -> Result { + loop { + if decoder.cur_frame().is_null() { + return Ok(false); + } + if decoder.cur_time_sec() + timeline_offset_sec >= target_source_time { + return Ok(true); + } + if decoder.next()?.is_null() { + return Ok(false); + } + } +} + +/// Frames-context de l'encodeur : NV12 sur notre device, bind RENDER_TARGET (§5) pour +/// que le compositeur rende directement dans les surfaces de l'encodeur. +unsafe fn make_enc_frames(gpu: &Gpu, w: i32, h: i32) -> Result<(*mut AVBufferRef, *mut AVBufferRef)> { + let hwdev = av_hwdevice_ctx_alloc(AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA); + let hwdc = (*hwdev).data as *mut AVHWDeviceContext; + let d3dctx = (*hwdc).hwctx as *mut AVD3D11VADeviceContext; + let dev_clone = gpu.device.clone(); + (*d3dctx).device = dev_clone.as_raw() as *mut ID3D11Device; + std::mem::forget(dev_clone); + averr(av_hwdevice_ctx_init(hwdev), "enc hwdevice init")?; + + let frames = av_hwframe_ctx_alloc(hwdev); + let fc = (*frames).data as *mut AVHWFramesContext; + (*fc).format = AVPixelFormat::AV_PIX_FMT_D3D11; + (*fc).sw_format = AVPixelFormat::AV_PIX_FMT_NV12; + (*fc).width = w; + (*fc).height = h; + (*fc).initial_pool_size = 32; // l'encodeur AMF garde plusieurs frames en vol + // NV12 array + RENDER_TARGET refusé par ce driver ; NV12 array sans bind aussi. + // Le combo array qui marche (prouvé par C0) = DECODER|SHADER_RESOURCE. On rend dans + // notre propre NV12 simple (RT) puis CopySubresourceRegion vers ce pool. GPU->GPU. + let d3dfc = (*fc).hwctx as *mut AVD3D11VAFramesContext; + (*d3dfc).BindFlags = D3D11_BIND_DECODER | D3D11_BIND_SHADER_RESOURCE; + averr(av_hwframe_ctx_init(frames), "enc frames init")?; + Ok((hwdev, frames)) +} + +/// C1..C8 (§9) : composite 2 sources → encode, effets gatés par `cfg`. Mesuré au plus extérieur (§10). +/// `progress(frames_encodées)` est appelé à chaque frame — no-op côté bench (mesure inchangée), +/// alimente la barre de progression côté GUI. La mesure reste enveloppante (§10) : la sonde est +/// un simple `SendMessage` throttlé (µs), négligeable devant ~8 ms/frame GPU. +pub fn run_composited( + screen: &str, + webcam: &str, + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + progress: &mut dyn FnMut(u64), +) -> Result { + discard_partial_output(out, unsafe { + run_c1_inner(screen, webcam, out, gpu, comp, cfg, progress) + }) +} + +unsafe fn run_c1_inner( + screen: &str, + webcam: &str, + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + progress: &mut dyn FnMut(u64), +) -> Result { + let mut sdec = Decoder::open(screen, gpu)?; + let mut wdec = Decoder::open(webcam, gpu)?; + let (mut enc_hwdev, mut enc_frames) = make_enc_frames(gpu, OUT_W as i32, OUT_H as i32)?; + + let mut enc = VideoEncoder::open( + &ExportCodec::H264, + OUT_W as i32, + OUT_H as i32, + 60, + 8_000_000, + enc_frames, + )?; + let ectx = enc.ctx; + + let mut octx: *mut AVFormatContext = ptr::null_mut(); + let outc = CString::new(out)?; + averr( + avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + let ostream = avformat_new_stream(octx, ptr::null()); + averr(avcodec_parameters_from_context((*ostream).codecpar, ectx), "params_from_ctx")?; + (*ostream).time_base = (*ectx).time_base; + let mut pb: *mut AVIOContext = ptr::null_mut(); + averr(avio_open(&mut pb, outc.as_ptr(), AVIO_FLAG_WRITE as i32), "avio_open")?; + sn_fmt_set_pb(octx, pb); + averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; + + let opkt = av_packet_alloc(); + let mut frames: u64 = 0; + + let t0 = Instant::now(); + loop { + let sf = sdec.next()?; + if sf.is_null() { + break; + } + let wf = wdec.next()?; + if wf.is_null() { + break; + } + comp.compose_frame(sf, wf, frames as f32, cfg)?; + + let outf = av_frame_alloc(); + averr(av_hwframe_get_buffer(enc_frames, outf, 0), "hwframe_get_buffer")?; + let out_tex = (*outf).data[0] as *mut c_void; + let out_slice = (*outf).data[1] as u32; + comp.rgb_to_nv12(out_tex, out_slice)?; + (*outf).pts = frames as i64; + enc.send(outf)?; + drain_encoder(ectx, octx, ostream, opkt)?; + av_frame_free(&mut (outf as *mut _)); + frames += 1; + progress(frames); + } + + enc.send(ptr::null_mut())?; + drain_encoder(ectx, octx, ostream, opkt)?; + averr(av_write_trailer(octx), "write_trailer")?; + + let wall_s = t0.elapsed().as_secs_f64(); + + av_packet_free(&mut (opkt as *mut _)); + let mut pb2 = sn_fmt_get_pb(octx); + if !pb2.is_null() { + avio_closep(&mut pb2); + sn_fmt_set_pb(octx, ptr::null_mut()); + } + avformat_free_context(octx); + // `enc` est libéré par son Drop en fin de portée (voir run_multi_inner). + av_buffer_unref(&mut enc_frames); + av_buffer_unref(&mut enc_hwdev); + + let fps = frames as f64 / wall_s; + Ok(Stats { frames, wall_s, fps, video_duration_s: frames as f64 / 60.0 }) +} + +/// Une source de clip pour l'export multiclip : fichiers screen+webcam + fenêtre source +/// (trim, en secondes). `webcam_offset_sec` : temps source webcam = temps source screen - offset. +pub struct ClipSource { + pub screen: String, + pub webcam: String, + pub source_start_sec: f64, + pub source_end_sec: f64, + pub webcam_offset_sec: f64, + pub has_audio: bool, +} + +/// Export **multiclip** : rend la timeline (clips ordonnés, avec trims) en un seul MP4. +/// Perf (contrainte §multiclip) : décodeurs ouverts une fois par source (cache) et réutilisés +/// entre clips du même asset ; **un seul seek keyframe par frontière de clip** ; décodage +/// séquentiel dans le clip → coût/frame identique au mono-clip (~120fps préservés). +pub fn run_composited_multi( + clips: &[ClipSource], + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + params: &ExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + discard_partial_output(out, unsafe { + run_multi_inner(clips, out, gpu, comp, cfg, params, progress) + }) +} + +/// Codec vidéo de sortie. L'encodeur concret est choisi à l'exécution (voir `candidates`). +/// VP9 a été essayé via un chemin logiciel (libvpx-vp9) mais retiré : trop lent pour être +/// utile en pratique, pas la peine de maintenir ce chemin. Choisir VP9 échoue avec un message +/// clair plutôt que de silencieusement retomber sur H264. +pub enum ExportCodec { + H264, + H265, +} + +/// Un candidat encodeur : son nom ffmpeg, et le format d'entrée qu'on lui présentera. +/// `AV_PIX_FMT_D3D11` = zéro-copie, la texture du compositeur part telle quelle ; tout autre +/// format impose une descente GPU→système par frame (voir `VideoEncoder::send`). +type EncoderCandidate = (&'static str, AVPixelFormat::Type); + +impl ExportCodec { + /// Ordre de préférence, du plus rapide au plus universel. Le premier dont `avcodec_open2` + /// réussit **vraiment** gagne : figurer dans la liste `-encoders` du build ne prouve rien + /// (les back-ends AMF/NVENC sont compilés en dur dans ffmpeg, sans le GPU correspondant + /// derrière), seule l'ouverture négocie avec le driver. + /// + /// - `*_amf` / `*_nvenc` — AMD / NVIDIA, avalent nos textures D3D11 directement. AMF + /// d'abord : c'est le chemin mesuré (§9, C0..C8), le garder en tête laisse les chiffres + /// du banc comparables sur la machine de dev. + /// - `*_mf` — MediaFoundation, c'est-à-dire *n'importe quel* MFT installé : le seul + /// candidat qui ne présuppose aucun vendeur, et celui qui rattrape le MFT logiciel de + /// Windows (VM, RDP, machine sans encodeur), son `hw_encoding` valant `false` par défaut. + /// **En NV12 seulement.** Il annonce `d3d11` et s'ouvre en d3d11, mais meurt ensuite au + /// premier envoi (« Failed to set D3D manager: 80004001 ») quand MediaFoundation lui a + /// résolu le MFT logiciel — mesuré ici. Comme le choix est acté à l'ouverture, ce + /// candidat-là ferait échouer l'export au lieu de glisser au suivant : ne pas le remettre. + /// - `*_qsv` — Intel ; n'accepte pas `AV_PIX_FMT_D3D11`, seulement du NV12 système. + /// ponytail: donc descente GPU→CPU puis remontée CPU→GPU sur Intel, une frame à la fois. + /// Le zéro-copie y demanderait un device QSV dérivé du nôtre et `AV_PIX_FMT_QSV` ; à + /// faire quand on aura une machine Intel pour le mesurer, pas avant. + /// - `libopenh264` / `libkvazaar` — dernier recours 100 % logiciel. **Pas** libx264/libx265 : + /// le ffmpeg vendorisé est le build LGPL, `--disable-libx264 --disable-libx265`. Ces deux + /// là y sont, et sont les seuls encodeurs logiciels H264/H265 dont on dispose. + fn candidates(&self) -> &'static [EncoderCandidate] { + const D3D11: AVPixelFormat::Type = AVPixelFormat::AV_PIX_FMT_D3D11; + const NV12: AVPixelFormat::Type = AVPixelFormat::AV_PIX_FMT_NV12; + const YUV420P: AVPixelFormat::Type = AVPixelFormat::AV_PIX_FMT_YUV420P; + match self { + ExportCodec::H264 => &[ + ("h264_amf", D3D11), + ("h264_nvenc", D3D11), + ("h264_qsv", NV12), + ("h264_mf", NV12), + ("libopenh264", YUV420P), + ], + ExportCodec::H265 => &[ + ("hevc_amf", D3D11), + ("hevc_nvenc", D3D11), + ("hevc_qsv", NV12), + ("hevc_mf", NV12), + ("libkvazaar", YUV420P), + ], + } + } +} + +/// L'encodeur vidéo retenu pour ce run, plus ce qu'il faut pour le nourrir. +/// +/// Le reste du pipeline continue de produire des frames GPU sans savoir qui encode : quand le +/// candidat retenu n'avale pas les textures D3D11, la descente vers la mémoire système se fait +/// ici et nulle part ailleurs. +struct VideoEncoder { + ctx: *mut AVCodecContext, + /// Frame système au format attendu par l'encodeur. Null si zéro-copie D3D11. + sw: *mut AVFrame, + /// Intermédiaire NV12 : `av_hwframe_transfer_data` ne convertit pas, il ne sait descendre + /// que vers le `sw_format` du pool. Non-null seulement si l'encodeur veut du planaire. + nv12: *mut AVFrame, +} + +impl Drop for VideoEncoder { + fn drop(&mut self) { + unsafe { + avcodec_free_context(&mut self.ctx); + if !self.sw.is_null() { + av_frame_free(&mut self.sw); + } + if !self.nv12.is_null() { + av_frame_free(&mut self.nv12); + } + } + } +} + +impl VideoEncoder { + /// Retient le premier candidat que cette machine accepte d'ouvrir, et dit lequel dans les + /// logs. `hw_frames` : le pool D3D11 dans lequel le compositeur rend. + /// + /// `OPENSCREEN_EXPORT_ENCODER=` n'essaie que celui-là. C'est le seul moyen d'exercer + /// les chemins non-AMD depuis une machine AMD, où `h264_amf` gagne toujours au premier tour + /// et laisse la descente mémoire système de `send` jamais exécutée. Le forçage ne retombe + /// délibérément sur rien : un repli silencieux sur AMF ferait croire au test d'être passé. + unsafe fn open( + codec: &ExportCodec, + w: i32, + h: i32, + fps: i32, + bit_rate: i64, + hw_frames: *mut AVBufferRef, + ) -> Result { + let forced = std::env::var("OPENSCREEN_EXPORT_ENCODER").ok(); + let mut refused: Vec = Vec::new(); + for &candidate in codec.candidates() { + let (name, pix_fmt) = candidate; + if forced.as_deref().is_some_and(|forced| forced != name) { + continue; + } + // Sans pool D3D11 (backend CPU), les candidats zéro-copie n'ont rien à consommer : + // les écarter ici plutôt que de leur passer un `hw_frames_ctx` nul, dont l'échec + // remonterait comme un refus de driver et masquerait la vraie raison. + if hw_frames.is_null() && pix_fmt == AVPixelFormat::AV_PIX_FMT_D3D11 { + refused.push(format!("{name}: pas de pool D3D11 (backend CPU)")); + continue; + } + let encoder = match Self::try_open(candidate, w, h, fps, bit_rate, hw_frames) { + Ok(encoder) => encoder, + Err(error) => { + refused.push(format!("{name}: {error}")); + continue; + } + }; + // Journalisé sans condition : un rapport de support doit dire qui a encodé. + eprintln!( + "[pipeline] encodeur vidéo : {name} ({}){}", + if encoder.sw.is_null() { "textures D3D11, zéro-copie" } else { "frames système" }, + if refused.is_empty() { + String::new() + } else { + format!(" — écartés : {}", refused.join(" ; ")) + }, + ); + return Ok(encoder); + } + match forced { + // Un nom forcé qui ne figure dans aucune liste ne produit aucun refus : sans ce cas + // le message serait un « aucun encodeur : » suivi de rien, et on chercherait le + // problème du côté du driver plutôt que du côté de la faute de frappe. + Some(name) if refused.is_empty() => { + bail!("OPENSCREEN_EXPORT_ENCODER={name} ne nomme aucun candidat de ce codec") + } + Some(name) => bail!("OPENSCREEN_EXPORT_ENCODER={name} inutilisable ici : {}", refused[0]), + None => bail!( + "aucun encodeur vidéo utilisable sur cette machine : {}", + refused.join(" ; "), + ), + } + } + + /// Ouvre un candidat. L'ouverture est la sonde : elle négocie pour de bon avec le driver + /// (c'est elle qui échoue sur une machine sans AMF), et tous les candidats de la liste + /// échouent proprement ici quand ils ne conviennent pas — mesuré sur cette machine : + /// `h264_nvenc` « Operation not permitted », `h264_qsv` « Unknown error ». + /// + /// ponytail: encoder une frame d'essai serait la sonde forte, mais l'essai a été fait et + /// retiré : tirer une frame quelconque du pool n'équivaut pas à une vraie frame composée, + /// et AMF la refusait dans C0 (pool du décodeur) — faux négatif qui coûtait 179→60 fps sur + /// un chemin qui marchait. Le vrai remède serait de sonder avec la 1re frame réelle, avant + /// l'écriture de l'en-tête MP4 ; à faire si un candidat se met à passer l'ouverture pour + /// mourir ensuite. + unsafe fn try_open( + (name, pix_fmt): EncoderCandidate, + w: i32, + h: i32, + fps: i32, + bit_rate: i64, + hw_frames: *mut AVBufferRef, + ) -> Result { + let cname = CString::new(name)?; + let enc = avcodec_find_encoder_by_name(cname.as_ptr()); + if enc.is_null() { + bail!("absent de ce build ffmpeg"); + } + let mut ctx = avcodec_alloc_context3(enc); + if ctx.is_null() { + bail!("avcodec_alloc_context3"); + } + (*ctx).width = w; + (*ctx).height = h; + (*ctx).pix_fmt = pix_fmt; + (*ctx).time_base = AVRational { num: 1, den: fps }; + (*ctx).framerate = AVRational { num: fps, den: 1 }; + (*ctx).bit_rate = bit_rate; + // Toutes nos sorties sont du MP4, qui veut SPS/PPS (et VPS en HEVC) dans l'extradata + // plutôt qu'en ligne dans le flux. Le muxer mov sait à défaut les repêcher dans le + // premier paquet, mais c'est un rattrapage : les encodeurs logiciels qu'on vient + // d'ajouter n'émettent pas d'extradata sans ce drapeau, et personne ici n'a de machine + // pour constater le MP4 bancal qui en sortirait. + (*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; + if pix_fmt == AVPixelFormat::AV_PIX_FMT_D3D11 { + (*ctx).hw_frames_ctx = av_buffer_ref(hw_frames); + } + if let Err(error) = averr(avcodec_open2(ctx, enc, ptr::null_mut()), "avcodec_open2(enc)") { + avcodec_free_context(&mut ctx); + return Err(error); + } + + // À partir d'ici le contexte est à nous : le mettre dans la struct d'abord, pour que + // le Drop le libère si l'allocation des tampons échoue. + let mut encoder = VideoEncoder { ctx, sw: ptr::null_mut(), nv12: ptr::null_mut() }; + if pix_fmt != AVPixelFormat::AV_PIX_FMT_D3D11 { + encoder.sw = alloc_sw_frame(pix_fmt, w, h)?; + if pix_fmt != AVPixelFormat::AV_PIX_FMT_NV12 { + encoder.nv12 = alloc_sw_frame(AVPixelFormat::AV_PIX_FMT_NV12, w, h)?; + } + } + Ok(encoder) + } + + /// Envoie une frame du compositeur (texture D3D11) à l'encodeur ; `frame` null = flush. + unsafe fn send(&mut self, frame: *mut AVFrame) -> Result<()> { + if self.sw.is_null() || frame.is_null() { + return averr(avcodec_send_frame(self.ctx, frame), "send_frame"); + } + // L'encodeur garde une référence sur les frames en vol : ne jamais réécrire par-dessus. + averr(av_frame_make_writable(self.sw), "frame_make_writable")?; + let landing = if self.nv12.is_null() { self.sw } else { self.nv12 }; + averr(av_hwframe_transfer_data(landing, frame, 0), "hwframe_transfer_data")?; + if !self.nv12.is_null() { + nv12_to_yuv420p(self.nv12, self.sw); + } + (*self.sw).pts = (*frame).pts; + averr(avcodec_send_frame(self.ctx, self.sw), "send_frame") + } + + /// Même chose depuis le backend CPU, où il n'y a PAS de frame D3D11 à descendre. + /// + /// `av_hwframe_transfer_data` suppose un pool `hw_frames_ctx`, et sur WARP il n'y en a + /// pas : `av_hwdevice_ctx_init(D3D11VA)` échoue faute d'`ID3D11VideoDevice` — le même + /// manque qui interdit le décodage matériel. Le compositeur lit donc son NV12 + /// directement en mémoire système, et le reste (conversion planaire, réutilisation des + /// tampons, pts) suit exactement le chemin logiciel de `send`. + unsafe fn send_composited( + &mut self, + comp: &Compositor, + w: u32, + h: u32, + pts: i64, + ) -> Result<()> { + debug_assert!(!self.sw.is_null(), "backend CPU : aucun candidat D3D11 ne doit gagner"); + averr(av_frame_make_writable(self.sw), "frame_make_writable")?; + let landing = if self.nv12.is_null() { self.sw } else { self.nv12 }; + comp.read_nv12_scaled( + w, + h, + (*landing).data[0], + (*landing).linesize[0] as usize, + (*landing).data[1], + (*landing).linesize[1] as usize, + )?; + if !self.nv12.is_null() { + nv12_to_yuv420p(self.nv12, self.sw); + } + (*self.sw).pts = pts; + averr(avcodec_send_frame(self.ctx, self.sw), "send_frame") + } +} + +/// Frame système allouée une fois, réutilisée à chaque envoi. +unsafe fn alloc_sw_frame(pix_fmt: AVPixelFormat::Type, w: i32, h: i32) -> Result<*mut AVFrame> { + let frame = av_frame_alloc(); + if frame.is_null() { + bail!("av_frame_alloc (tampon encodeur)"); + } + (*frame).format = pix_fmt; + (*frame).width = w; + (*frame).height = h; + if let Err(error) = averr(av_frame_get_buffer(frame, 0), "av_frame_get_buffer (tampon encodeur)") + { + av_frame_free(&mut (frame as *mut _)); + return Err(error); + } + Ok(frame) +} + +/// NV12 (Y puis UV entrelacé) → YUV420P (Y, U, V séparés), pour les encodeurs logiciels qui +/// ne prennent que du planaire. Les `linesize` des deux frames diffèrent : copier plan par +/// plan, ligne par ligne, jamais d'un bloc. +/// +/// ponytail: désentrelacement scalaire, O(w·h) par frame. Négligeable devant l'encodeur +/// logiciel qui suit (des dizaines de ms/frame) — c'est le seul chemin qui l'emprunte. +/// Brancher swscale si ce chemin devient un jour chaud. +unsafe fn nv12_to_yuv420p(src: *mut AVFrame, dst: *mut AVFrame) { + let (w, h) = ((*src).width as usize, (*src).height as usize); + for y in 0..h { + ptr::copy_nonoverlapping( + (*src).data[0].add(y * (*src).linesize[0] as usize), + (*dst).data[0].add(y * (*dst).linesize[0] as usize), + w, + ); + } + // `div_ceil` et non `/ 2` : en largeur ou hauteur impaire le plan chroma compte une colonne + // et une ligne de plus, que la division tronquée laisserait telles que `av_frame_get_buffer` + // les a rendues — un bord vert. D3D11 refuse les textures NV12 impaires, donc le cas n'est + // pas atteignable aujourd'hui ; au même coût, autant que la fonction soit juste seule. + for y in 0..h.div_ceil(2) { + let uv = (*src).data[1].add(y * (*src).linesize[1] as usize); + let u = (*dst).data[1].add(y * (*dst).linesize[1] as usize); + let v = (*dst).data[2].add(y * (*dst).linesize[2] as usize); + for x in 0..w.div_ceil(2) { + *u.add(x) = *uv.add(2 * x); + *v.add(x) = *uv.add(2 * x + 1); + } + } +} + +/// Résolution/cadence/codec de sortie. `fps: None` = dérivé du 1er clip (comportement +/// historique) ; `width`/`height` doivent être pairs (NV12 4:2:0) — l'appelant napi arrondit. +pub struct ExportParams { + pub width: u32, + pub height: u32, + pub fps: Option, + pub codec: ExportCodec, +} + +impl Default for ExportParams { + fn default() -> Self { + Self { width: OUT_W, height: OUT_H, fps: None, codec: ExportCodec::H264 } + } +} + +unsafe fn run_multi_inner( + clips: &[ClipSource], + out: &str, + gpu: &Gpu, + comp: &Compositor, + cfg: &Cfg, + params: &ExportParams, + progress: &mut dyn FnMut(u64), +) -> Result { + if clips.is_empty() { + bail!("aucun clip à exporter"); + } + let (out_w, out_h) = (params.width, params.height); + // décodeurs ouverts une fois par chemin, réutilisés entre clips (screen ≠ webcam → 2 maps + // pour deux &mut indépendants). + let mut screen_decs: HashMap = HashMap::new(); + let mut webcam_decs: HashMap = HashMap::new(); + + // fps de sortie : choix explicite de l'app si fourni, sinon dérivé du 1er clip (recordings + // uniformes) — comportement historique. + screen_decs.insert(clips[0].screen.clone(), Decoder::open(&clips[0].screen, gpu)?); + let out_fps = params + .fps + .unwrap_or_else(|| screen_decs[&clips[0].screen].fps().round().max(1.0) as u32) + as i32; + + // Curseur : la scène (déjà posée par l'appelant via comp.set_scene) pilote tout — même + // parité que le live. Piste par chemin ÉCRAN distinct (convention sidecar `.cursor.json`, + // temps ABSOLU non re-basé : chaque décodeur avance dans le même référentiel que la piste). + let scene = comp.scene_snapshot(); + let cursor_enabled = scene.as_ref().map(|s| s.cursor.show).unwrap_or(false); + let cursor_smoothing = scene.as_ref().map(|s| s.cursor.smoothing).unwrap_or(0.0); + let mut cursor_tracks: HashMap = HashMap::new(); + let mut cursor_active_path: Option = None; + + // ---- encodeur (choisi à l'exécution, cf. ExportCodec::candidates) + mux ---- + // Backend CPU : pas de pool D3D11 du tout. `av_hwdevice_ctx_init(D3D11VA)` échoue sur + // WARP (pas d'`ID3D11VideoDevice`), donc on n'essaie même pas — `VideoEncoder::open` + // écarte alors les candidats zéro-copie et le compositeur alimente l'encodeur en + // mémoire système via `send_composited`. + let software_frames = gpu.backend == Backend::Cpu; + let (mut enc_hwdev, mut enc_frames) = if software_frames { + (ptr::null_mut(), ptr::null_mut()) + } else { + make_enc_frames(gpu, out_w as i32, out_h as i32)? + }; + // débit proportionnel à la surface de sortie (référence : 8Mbps @ 1920x1080), plancher + // 2Mbps pour rester regardable sur les petites tailles. + let bit_rate = ((out_w as i64 * out_h as i64 * 8_000_000) / (1920 * 1080)).max(2_000_000); + let mut enc = VideoEncoder::open( + ¶ms.codec, + out_w as i32, + out_h as i32, + out_fps, + bit_rate, + enc_frames, + )?; + let ectx = enc.ctx; + + let mut octx: *mut AVFormatContext = ptr::null_mut(); + let outc = CString::new(out)?; + averr( + avformat_alloc_output_context2(&mut octx, ptr::null(), ptr::null(), outc.as_ptr()), + "alloc_output_context2", + )?; + let ostream = avformat_new_stream(octx, ptr::null()); + if ostream.is_null() { + bail!("video avformat_new_stream"); + } + averr(avcodec_parameters_from_context((*ostream).codecpar, ectx), "params_from_ctx")?; + (*ostream).time_base = (*ectx).time_base; + // Les deux streams doivent exister avant le header MP4 ; l'AAC reste ouvert pendant le + // rendu puis reçoit le PCM assemblé à partir des comptes de frames réellement produits. + let mut audio_encoder = AacEncoder::open(octx)?; + let mut pb: *mut AVIOContext = ptr::null_mut(); + averr(avio_open(&mut pb, outc.as_ptr(), AVIO_FLAG_WRITE as i32), "avio_open")?; + sn_fmt_set_pb(octx, pb); + averr(avformat_write_header(octx, ptr::null_mut()), "write_header")?; + + let opkt = av_packet_alloc(); + let mut frames: u64 = 0; + let mut clip_frame_counts = vec![0u64; clips.len()]; + let mut clip_pcm: Vec> = + std::iter::repeat_with(|| None).take(clips.len()).collect(); + let t0 = Instant::now(); + + for (clip_index, clip) in clips.iter().enumerate() { + if !screen_decs.contains_key(&clip.screen) { + screen_decs.insert(clip.screen.clone(), Decoder::open(&clip.screen, gpu)?); + } + if !webcam_decs.contains_key(&clip.webcam) { + webcam_decs.insert(clip.webcam.clone(), Decoder::open(&clip.webcam, gpu)?); + } + let sdec = screen_decs.get_mut(&clip.screen).unwrap(); + let wdec = webcam_decs.get_mut(&clip.webcam).unwrap(); + + let screen_available_duration = sdec.available_duration_sec(); + let webcam_available_duration = wdec.available_duration_sec(); + if screen_available_duration.is_none() || webcam_available_duration.is_none() { + eprintln!( + "[pipeline] warning: clip #{}: durée de flux indéterminée (screen={}, webcam={}); la borne demandée {:.3}s ne peut pas être entièrement validée", + clip_index, + screen_available_duration + .map(|v| format!("{v:.3}s")) + .unwrap_or_else(|| "inconnue".to_string()), + webcam_available_duration + .map(|v| format!("{v:.3}s")) + .unwrap_or_else(|| "inconnue".to_string()), + clip.source_end_sec, + ); + } + // Les bornes de clip sont en temps écran. La disponibilité webcam est donc translatée + // par le même offset que le seek (`webcam_time = screen_time - offset`). + let webcam_available_screen_end = + webcam_available_duration.map(|duration| duration + clip.webcam_offset_sec); + let mut source_end_sec = clip.source_end_sec; + if let Some(duration) = screen_available_duration { + source_end_sec = source_end_sec.min(duration); + } + if let Some(duration) = webcam_available_screen_end { + source_end_sec = source_end_sec.min(duration); + } + if source_end_sec + 1e-6 < clip.source_end_sec { + eprintln!( + "[pipeline] warning: clip #{} raccourci de {:.3}s (fin demandée {:.3}s, fin disponible {:.3}s; screen=\"{}\", webcam=\"{}\")", + clip_index, + clip.source_end_sec - source_end_sec, + clip.source_end_sec, + source_end_sec, + clip.screen, + clip.webcam, + ); + } + if source_end_sec <= clip.source_start_sec { + continue; + } + + let clip_scene = scene.as_ref().map(|base_scene| { + base_scene.for_clip_window(clip_index, clip.source_start_sec, source_end_sec) + }); + let speed_segments = speed_segments_for_window( + clip_scene + .as_ref() + .map(|s| s.speed_regions.as_slice()) + .unwrap_or(&[]), + clip.source_start_sec, + source_end_sec, + out_fps as f64, + ); + if clip_scene.is_some() { + comp.set_scene(clip_scene); + } + + // un seul seek keyframe, puis chaque décodeur avance selon son propre pts jusqu'aux + // temps source demandés par les spans de vitesse. + if sdec.seek_to(clip.source_start_sec)?.is_null() { + continue; // clip vide / au-delà de la source + } + if wdec + .seek_to((clip.source_start_sec - clip.webcam_offset_sec).max(0.0))? + .is_null() + { + continue; + } + + if cursor_enabled { + if !cursor_tracks.contains_key(&clip.screen) { + let path = format!("{}.cursor.json", clip.screen); + if let Ok(raw) = CursorTrack::load(&path, 0.0, 24.0 * 3600.0) { + cursor_tracks.insert(clip.screen.clone(), raw.smoothed(cursor_smoothing)); + } + // absente/illisible → pas d'entrée : ce clip s'exporte sans curseur (visible, + // pas masqué en un curseur fantôme d'un autre clip). + } + if cursor_active_path.as_deref() != Some(clip.screen.as_str()) { + if let Some(track) = cursor_tracks.get(&clip.screen) { + comp.set_cursor(track.clone()); + cursor_active_path = Some(clip.screen.clone()); + } else { + comp.clear_cursor(); + comp.set_cursor_time(None); + cursor_active_path = None; + } + } + } + + let frames_before_clip = frames; + 'clip_frames: for segment in &speed_segments { + for segment_frame in 0..segment.frame_count { + let target_source_time = segment.start_sec + + segment_frame as f64 * segment.speed / out_fps as f64; + if !advance_decoder_to(sdec, target_source_time, 0.0)? { + break 'clip_frames; + } + if !advance_decoder_to( + wdec, + target_source_time, + clip.webcam_offset_sec, + )? { + break 'clip_frames; + } + let sf = sdec.cur_frame(); + let wf = wdec.cur_frame(); + if sf.is_null() || wf.is_null() { + break 'clip_frames; + } + + comp.set_timeline_time(Some(target_source_time as f32)); + if cursor_enabled && cursor_active_path.is_some() { + comp.set_cursor_time(Some(target_source_time as f32)); + } + comp.compose_frame(sf, wf, frames as f32, cfg)?; + + if software_frames { + enc.send_composited(comp, out_w, out_h, frames as i64)?; + drain_encoder(ectx, octx, ostream, opkt)?; + } else { + let outf = av_frame_alloc(); + averr(av_hwframe_get_buffer(enc_frames, outf, 0), "hwframe_get_buffer")?; + let out_tex = (*outf).data[0] as *mut c_void; + let out_slice = (*outf).data[1] as u32; + comp.rgb_to_nv12_scaled(out_w, out_h, out_tex, out_slice)?; + (*outf).pts = frames as i64; + enc.send(outf)?; + drain_encoder(ectx, octx, ostream, opkt)?; + av_frame_free(&mut (outf as *mut _)); + } + frames += 1; + progress(frames); + } + } + clip_frame_counts[clip_index] = frames - frames_before_clip; + if clip.has_audio && clip_frame_counts[clip_index] > 0 { + match decode_clip_audio(&clip.screen, clip.source_start_sec, source_end_sec) { + Ok(Some(pcm)) => { + clip_pcm[clip_index] = Some(stretch_clip_pcm_by_speed( + &pcm, + &speed_segments, + out_fps as f64, + )); + } + Ok(None) => eprintln!( + "[pipeline] warning: clip #{} déclaré audio mais sans flux décodable; silence conservé", + clip_index, + ), + Err(error) => eprintln!( + "[pipeline] warning: décodage audio du clip #{} échoué ({error:#}); silence conservé", + clip_index, + ), + } + } + } + + comp.set_cursor_time(None); + comp.set_timeline_time(None); + comp.set_scene(scene); + + enc.send(ptr::null_mut())?; + drain_encoder(ectx, octx, ostream, opkt)?; + + let declared_audio: Vec = clips.iter().map(|clip| clip.has_audio).collect(); + let audio_plan = build_audio_concat_plan( + &clip_frame_counts, + &declared_audio, + out_fps as f64, + ); + let assembled_audio = assemble_concatenated_pcm(&clip_pcm, &audio_plan); + audio_encoder.encode(&assembled_audio, octx)?; + + averr(av_write_trailer(octx), "write_trailer")?; + let wall_s = t0.elapsed().as_secs_f64(); + + // teardown (les décodeurs du cache sont droppés en fin de scope). + av_packet_free(&mut (opkt as *mut _)); + let mut pb2 = sn_fmt_get_pb(octx); + if !pb2.is_null() { + avio_closep(&mut pb2); + sn_fmt_set_pb(octx, ptr::null_mut()); + } + avformat_free_context(octx); + // `enc` (donc le contexte encodeur) est libéré par son Drop en fin de portée — après + // ces unref, ce qui est l'ordre voulu : il garde sa propre référence sur le pool. + av_buffer_unref(&mut enc_frames); + av_buffer_unref(&mut enc_hwdev); + + let fps = frames as f64 / wall_s; + Ok(Stats { frames, wall_s, fps, video_duration_s: frames as f64 / out_fps as f64 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// L'ordre EST le contrat : tous les candidats zéro-copie d'abord, ceux qui exigent la + /// mémoire système ensuite (`*_qsv` et `*_mf` sont matériels eux aussi — ce qui les + /// distingue est le format d'entrée, pas le silicium). Un candidat système remonté + /// au-dessus d'un D3D11 coûterait une descente GPU→CPU par frame sur une machine qui n'en + /// a pas besoin, sans que rien n'échoue — donc sans que personne ne le voie. + #[test] + fn les_candidats_vont_du_zero_copie_a_la_memoire_systeme() { + for codec in [ExportCodec::H264, ExportCodec::H265] { + let candidates = codec.candidates(); + let last_d3d11 = candidates + .iter() + .rposition(|&(_, fmt)| fmt == AVPixelFormat::AV_PIX_FMT_D3D11) + .expect("au moins un candidat zéro-copie"); + let first_sw = candidates + .iter() + .position(|&(_, fmt)| fmt != AVPixelFormat::AV_PIX_FMT_D3D11) + .expect("au moins un candidat en mémoire système"); + assert!(last_d3d11 < first_sw, "candidats mal ordonnés : {candidates:?}"); + } + } + + /// Le dernier recours doit être 100 % logiciel, sinon une machine sans encodeur matériel + /// (VM, RDP) n'exporte pas du tout — la régression que cette sélection corrige. + /// libx264/libx265 sont GPL et absents du build LGPL vendorisé : les nommer ferait un + /// filet de sécurité qui n'existe pas. + #[test] + fn le_dernier_recours_est_un_encodeur_logiciel_present_dans_le_build_lgpl() { + let fallbacks: Vec<&str> = [ExportCodec::H264, ExportCodec::H265] + .iter() + .map(|codec| codec.candidates().last().expect("liste non vide").0) + .collect(); + assert_eq!(fallbacks, ["libopenh264", "libkvazaar"]); + for codec in [ExportCodec::H264, ExportCodec::H265] { + for &(name, _) in codec.candidates() { + assert!(!matches!(name, "libx264" | "libx265"), "{name} absent du build LGPL"); + } + } + } + + /// Le désentrelacement chroma est la seule vraie logique du chemin logiciel, et la seule + /// qui puisse se tromper en silence (image verte / couleurs inversées plutôt qu'une + /// erreur). Les deux frames ont des `linesize` différents — c'est exactement ce qu'un + /// `copy` d'un bloc raterait. + /// + /// 5x3 autant que 4x4 : en dimension impaire le plan chroma compte une colonne et une ligne + /// de plus que la moitié, et une division tronquée les laisserait non initialisées. + #[test] + fn nv12_vers_yuv420p_desentrelace_le_chroma() { + for (w, h) in [(4usize, 4usize), (5, 3)] { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + unsafe { + let src = + alloc_sw_frame(AVPixelFormat::AV_PIX_FMT_NV12, w as i32, h as i32).unwrap(); + let dst = + alloc_sw_frame(AVPixelFormat::AV_PIX_FMT_YUV420P, w as i32, h as i32).unwrap(); + + // luma = 10, 11, 12... ligne par ligne ; chroma = U pair, V impair, distinguables. + for y in 0..h { + let row = (*src).data[0].add(y * (*src).linesize[0] as usize); + for x in 0..w { + *row.add(x) = (10 + y * w + x) as u8; + } + } + for y in 0..ch { + let row = (*src).data[1].add(y * (*src).linesize[1] as usize); + for x in 0..cw { + *row.add(2 * x) = (100 + y * cw + x) as u8; // U + *row.add(2 * x + 1) = (200 + y * cw + x) as u8; // V + } + } + + nv12_to_yuv420p(src, dst); + + for y in 0..h { + let row = (*dst).data[0].add(y * (*dst).linesize[0] as usize); + for x in 0..w { + assert_eq!(*row.add(x), (10 + y * w + x) as u8, "luma ({x},{y}) en {w}x{h}"); + } + } + for y in 0..ch { + let u = (*dst).data[1].add(y * (*dst).linesize[1] as usize); + let v = (*dst).data[2].add(y * (*dst).linesize[2] as usize); + for x in 0..cw { + assert_eq!(*u.add(x), (100 + y * cw + x) as u8, "U ({x},{y}) en {w}x{h}"); + assert_eq!(*v.add(x), (200 + y * cw + x) as u8, "V ({x},{y}) en {w}x{h}"); + } + } + + av_frame_free(&mut (src as *mut _)); + av_frame_free(&mut (dst as *mut _)); + } + } + } +} + +unsafe fn drain_encoder( + ectx: *mut AVCodecContext, + octx: *mut AVFormatContext, + ostream: *mut AVStream, + opkt: *mut AVPacket, +) -> Result<()> { + loop { + let r = avcodec_receive_packet(ectx, opkt); + if r == AVERROR_EAGAIN || r == AVERROR_EOF { + return Ok(()); + } + averr(r, "receive_packet")?; + (*opkt).stream_index = (*ostream).index; + av_packet_rescale_ts(opkt, (*ectx).time_base, (*ostream).time_base); + averr( + av_interleaved_write_frame(octx, opkt), + "interleaved_write_frame", + ) + .map_err(|e| anyhow!("{e}"))?; + av_packet_unref(opkt); + } +} + +/// Nombre de frames du flux vidéo (borne de la barre de progression export). `nb_frames` +/// si présent (le cas de la fixture MP4), sinon estimé par durée × cadence, sinon fallback. +pub fn probe_frame_count(path: &str) -> Result { + unsafe { + let mut fmt: *mut AVFormatContext = ptr::null_mut(); + let cpath = CString::new(path)?; + averr( + avformat_open_input(&mut fmt, cpath.as_ptr(), ptr::null_mut(), ptr::null_mut()), + "open_input", + )?; + averr(avformat_find_stream_info(fmt, ptr::null_mut()), "find_stream_info")?; + let vidx = av_find_best_stream(fmt, AVMediaType::AVMEDIA_TYPE_VIDEO, -1, -1, ptr::null_mut(), 0); + let mut n: u64 = 0; + if vidx >= 0 { + let stream = sn_fmt_stream(fmt, vidx); + let nb = (*stream).nb_frames; + if nb > 0 { + n = nb as u64; + } else { + let afr = (*stream).avg_frame_rate; + let dur = (*stream).duration; + let tb = (*stream).time_base; + if afr.num != 0 && afr.den != 0 && dur > 0 && tb.den != 0 { + let secs = dur as f64 * tb.num as f64 / tb.den as f64; + n = (secs * afr.num as f64 / afr.den as f64).round() as u64; + } + } + } + avformat_close_input(&mut fmt); + if n == 0 { + n = crate::compositor::FIXTURE_FRAMES as u64; + } + Ok(n) + } +} diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs new file mode 100644 index 0000000000..0009397088 --- /dev/null +++ b/crates/compositor/src/regions.rs @@ -0,0 +1,1023 @@ +//! Zoom regions + camera-fullscreen regions — port des enveloppes ease-in/hold/ease-out du +//! web (`zoomRegionUtils.ts` / `cameraFullscreenUtils.ts`) vers le natif, pour que le timing +//! des transitions soit identique en preview ET en export. Inclut le "connected zoom pan" +//! (chaînage lissé entre deux régions rapprochées), le focus "auto" (suivi de la télémétrie +//! curseur) et la rotation 3D (présets iso/left/right, cf. `compositor.rs` pour le rendu du +//! tilt perspective — ce module ne fait que le calcul temporel, pas le rendu GPU). + +use crate::cursor::CursorTrack; +use crate::scene::{SceneCameraFullscreenRegion, SceneSpeedRegion, SceneZoomRegion}; + +/// Quantification commune vidéo/audio : le web retranche exactement 1 ms avant `ceil`. +pub const SPEED_FRAME_EPSILON_SEC: f64 = 0.001; +const MIN_SPEED_SEGMENT_SEC: f64 = 0.0001; + +#[derive(Debug, Clone, Copy)] +pub struct SpeedSegment { + pub start_sec: f64, + pub end_sec: f64, + pub speed: f64, + pub frame_count: u64, +} + +/// Découpe toute la fenêtre gardée en spans contigus ; hors région la vitesse vaut 1×. +/// Les régions sont ordonnées par début et, si un ancien payload en superpose, la première +/// conserve la portion déjà couverte pour ne jamais émettre deux fois le même temps source. +pub fn speed_segments_for_window( + regions: &[SceneSpeedRegion], + source_start_sec: f64, + source_end_sec: f64, + fps: f64, +) -> Vec { + if source_end_sec <= source_start_sec || !fps.is_finite() || fps <= 0.0 { + return Vec::new(); + } + let mut overlapping: Vec<&SceneSpeedRegion> = regions + .iter() + .filter(|r| r.start_sec < source_end_sec && r.end_sec > source_start_sec) + .collect(); + overlapping.sort_by(|a, b| { + a.start_sec + .partial_cmp(&b.start_sec) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut spans = Vec::new(); + let mut cursor = source_start_sec; + for region in overlapping { + let start = region.start_sec.max(source_start_sec).max(cursor); + let end = region.end_sec.min(source_end_sec); + if start > cursor { + push_speed_segment(&mut spans, cursor, start, 1.0, fps); + } + if end > start { + let speed = if region.speed.is_finite() && region.speed > 0.0 { + region.speed + } else { + 1.0 + }; + push_speed_segment(&mut spans, start, end, speed, fps); + cursor = end; + } + } + if cursor < source_end_sec { + push_speed_segment(&mut spans, cursor, source_end_sec, 1.0, fps); + } + spans +} + +/// Multiplicateur de vitesse actif au temps source `t` (temps ABSOLU de la source, même +/// convention que `SceneSpeedRegion.start_sec`/`end_sec` — pas de fenêtre de clip à soustraire). +/// 1.0 hors de toute région. Utilisé par la preview live (`live.rs`) pour moduler le nombre de +/// frames décodées par tick réel — contrairement à `speed_segments_for_window` (export), qui a +/// besoin de pré-découper toute la fenêtre en spans pour connaître le compte de frames total à +/// l'avance, la lecture live avance tick par tick et n'a besoin que de la vitesse "maintenant". +/// +/// BUG corrigé : ignorait `region.clip_index`, filtrant seulement par recouvrement temporel sur +/// la scène BRUTE (non filtrée par clip) — dès qu'un projet a plus d'un clip, deux clips peuvent +/// tout à fait partager la même fenêtre de temps source (chacun démarrant près de t=0 de son +/// propre fichier, cas courant), et la région du MAUVAIS clip matchait alors silencieusement (ou +/// aucune ne matchait quand le clip actif est censé être couvert par une région tournée d'un +/// autre index). Même garde-fou que `Scene::for_clip_window`'s `belongs` (scene.rs) : accepte la +/// région seulement si `clip_index` est absent (vieux payload) OU vaut `active_clip_index`. +pub fn speed_at(regions: &[SceneSpeedRegion], active_clip_index: usize, t: f64) -> f64 { + for region in regions { + let belongs = region.clip_index.map(|i| i == active_clip_index).unwrap_or(true); + if belongs && t >= region.start_sec && t < region.end_sec { + if region.speed.is_finite() && region.speed > 0.0 { + return region.speed; + } + return 1.0; + } + } + 1.0 +} + +fn push_speed_segment( + spans: &mut Vec, + start_sec: f64, + end_sec: f64, + speed: f64, + fps: f64, +) { + let duration = end_sec - start_sec; + if duration <= MIN_SPEED_SEGMENT_SEC { + return; + } + let frames = (((duration - SPEED_FRAME_EPSILON_SEC) / speed) * fps) + .ceil() + .max(0.0) as u64; + spans.push(SpeedSegment { start_sec, end_sec, speed, frame_count: frames }); +} + +// mêmes fenêtres de transition que le web (TRANSITION_WINDOW_MS etc., converties en secondes). +const TRANSITION_WINDOW_S: f32 = 1.01505; +const ZOOM_IN_TRANSITION_WINDOW_S: f32 = TRANSITION_WINDOW_S * 1.5; +const ZOOM_IN_OVERLAP_S: f32 = 0.5; +const FULLSCREEN_LEAD_OUT_WINDOW_S: f32 = TRANSITION_WINDOW_S * 1.5; +// port de `CHAINED_ZOOM_PAN_GAP_MS` / `CONNECTED_ZOOM_PAN_DURATION_MS` (TS). +const CHAINED_ZOOM_PAN_GAP_S: f32 = 1.5; +const CONNECTED_ZOOM_PAN_DURATION_S: f32 = 1.0; + +fn clamp01(v: f32) -> f32 { + v.clamp(0.0, 1.0) +} + +fn sample_cubic_bezier(a1: f32, a2: f32, t: f32) -> f32 { + let o = 1.0 - t; + 3.0 * a1 * o * o * t + 3.0 * a2 * o * t * t + t * t * t +} + +fn sample_cubic_bezier_derivative(a1: f32, a2: f32, t: f32) -> f32 { + let o = 1.0 - t; + 3.0 * a1 * o * o + 6.0 * (a2 - a1) * o * t + 3.0 * (1.0 - a2) * t * t +} + +/// Port direct de `cubicBezier` (TS) : Newton-Raphson puis bissection de repli. +fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 { + let target_x = clamp01(t); + let mut solved_t = target_x; + for _ in 0..8 { + let cur_x = sample_cubic_bezier(x1, x2, solved_t) - target_x; + let cur_d = sample_cubic_bezier_derivative(x1, x2, solved_t); + if cur_x.abs() < 1e-6 || cur_d.abs() < 1e-6 { + break; + } + solved_t -= cur_x / cur_d; + } + let (mut lower, mut upper) = (0.0f32, 1.0f32); + solved_t = clamp01(solved_t); + for _ in 0..10 { + let cur_x = sample_cubic_bezier(x1, x2, solved_t); + if (cur_x - target_x).abs() < 1e-6 { + break; + } + if cur_x < target_x { + lower = solved_t; + } else { + upper = solved_t; + } + solved_t = (lower + upper) * 0.5; + } + sample_cubic_bezier(y1, y2, solved_t) +} + +/// Port de `easeOutScreenStudio` (TS) : cubic-bezier(0.16, 1, 0.3, 1). +fn ease_out_screen_studio(t: f32) -> f32 { + cubic_bezier(0.16, 1.0, 0.3, 1.0, t) +} + +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +/// Port de `computeRegionStrength` (TS, `zoomRegionUtils.ts`) : 0 hors fenêtre, ease-in avant +/// `startSec` (le zoom anticipe légèrement), plein régime pendant la région, ease-out après +/// `endSec`. Les temps reçus sont les temps source échantillonnés par le pipeline, donc ces +/// enveloppes restent alignées quand une speed region répète ou saute des frames. +fn zoom_region_strength(region: &SceneZoomRegion, t: f32) -> f32 { + let start = region.start_sec as f32; + let end = region.end_sec as f32; + let zoom_in_end = start + ZOOM_IN_OVERLAP_S; + let lead_in_start = zoom_in_end - ZOOM_IN_TRANSITION_WINDOW_S; + let lead_out_end = end + TRANSITION_WINDOW_S; + if t < lead_in_start || t > lead_out_end { + return 0.0; + } + if t < zoom_in_end { + let progress = (t - lead_in_start) / ZOOM_IN_TRANSITION_WINDOW_S; + return ease_out_screen_studio(progress); + } + if t <= end { + return 1.0; + } + let progress = clamp01((t - end) / TRANSITION_WINDOW_S); + 1.0 - ease_out_screen_studio(progress) +} + +/// État de zoom complet au temps `t` : échelle, focus, ET tilt 3D (degrés X/Y/Z — rendu en +/// pixel shader par `compositor.rs`, ce module ne fait que le calcul temporel). +pub struct ZoomState { + pub scale: f32, + pub focus: [f32; 2], + pub rotation: [f32; 3], +} + +const IDENTITY_ZOOM: ZoomState = ZoomState { scale: 1.0, focus: [0.5, 0.5], rotation: [0.0, 0.0, 0.0] }; + +/// Port de `easeConnectedPan` (TS) : cubic-bezier(0.1, 0, 0.2, 1). +fn ease_connected_pan(t: f32) -> f32 { + cubic_bezier(0.1, 0.0, 0.2, 1.0, t) +} + +/// Port de `getRotation3D`/`ROTATION_3D_PRESETS` (TS, `types.ts`) — degrés (rotationX, Y, Z). +/// Angles des présets, en degrés X/Y/Z. +/// +/// Les valeurs d'origine (iso [-10,-16,0], left [0,-22,0], right [0,22,0]) produisaient un quad +/// dont AU MOINS UNE ARÊTE tombait à moins de 0.1° d'un axe de l'image : les deux bords verticaux +/// pour left/right (une rotation Y pure laisse les verticales verticales — c'est de la géométrie, +/// pas un réglage), le bord haut pour iso, dont la remontée due au rotateX était annulée par la +/// division perspective à cette distance-là. +/// +/// Une arête parfaitement verticale qui traverse du texte est indiscernable d'un `overflow: +/// hidden`. C'est ce qui a été rapporté trois fois comme « une troncature de l'enregistrement », +/// alors que le plan était rendu en entier — mesuré au pixel sur un export 1920×1080 : bord droit +/// à 1539, coin calculé à 1540, arrondis présents aux quatre coins. +/// +/// Chaque préset a donc maintenant ses trois composantes, choisies pour qu'aucune arête ne +/// s'approche d'un axe à moins de 2° (cf. `no_preset_has_an_axis_aligned_edge`) tout en gardant +/// l'identité du préset : left penche vers la gauche, right vers la droite, iso est le plus incliné. +fn rotation3d_for(rotation: &Option) -> [f32; 3] { + match rotation.as_deref() { + Some("iso") => [-12.0, -18.0, -2.0], + Some("left") => [-8.0, -16.0, -1.0], + Some("right") => [-8.0, 16.0, 1.0], + _ => [0.0, 0.0, 0.0], + } +} + +fn lerp_rotation3d(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] +} + +/// Les trois segments d'une flèche d'annotation, en unités du viewBox SVG (0..100), repris +/// VERBATIM des tracés de `ArrowSvgs.tsx` : hampe puis deux barbes, toutes à bouts ronds. Garder +/// les mêmes nombres est ce qui garantit que le rendu natif et la preview dessinent la même +/// flèche — inutile de réinventer une géométrie « équivalente ». +pub fn arrow_segments_viewbox(direction: &str) -> [[f32; 4]; 3] { + match direction { + "up" => [[50.0, 20.0, 50.0, 80.0], [50.0, 20.0, 35.0, 35.0], [50.0, 20.0, 65.0, 35.0]], + "down" => [[50.0, 20.0, 50.0, 80.0], [50.0, 80.0, 35.0, 65.0], [50.0, 80.0, 65.0, 65.0]], + "left" => [[80.0, 50.0, 20.0, 50.0], [20.0, 50.0, 35.0, 35.0], [20.0, 50.0, 35.0, 65.0]], + "up-right" => [[25.0, 75.0, 75.0, 25.0], [75.0, 25.0, 53.8, 25.0], [75.0, 25.0, 75.0, 46.2]], + "up-left" => [[75.0, 75.0, 25.0, 25.0], [25.0, 25.0, 25.0, 46.2], [25.0, 25.0, 46.2, 25.0]], + "down-right" => { + [[25.0, 25.0, 75.0, 75.0], [75.0, 75.0, 75.0, 53.8], [75.0, 75.0, 53.8, 75.0]] + } + "down-left" => { + [[75.0, 25.0, 25.0, 75.0], [25.0, 75.0, 46.2, 75.0], [25.0, 75.0, 25.0, 53.8]] + } + // "right" et tout ce qui n'est pas reconnu — même défaut que le schéma côté app. + _ => [[20.0, 50.0, 80.0, 50.0], [80.0, 50.0, 65.0, 35.0], [80.0, 50.0, 65.0, 65.0]], + } +} + +/// Passe les segments du viewBox aux px locaux du quad, et rend la demi-épaisseur du trait. +/// +/// Le SVG n'a pas de `preserveAspectRatio` explicite, donc il vaut `xMidYMid meet` : mise à +/// l'échelle **uniforme** au plus petit côté, centrée. La flèche n'est donc jamais étirée quand la +/// boîte n'est pas carrée, et `strokeWidth` suit la même échelle — c'est pour ça qu'il n'a pas +/// besoin de la convention de proportionnalité du `fontSize` : il est déjà exprimé dans le +/// viewBox, donc déjà relatif à la boîte. +pub fn arrow_local_geometry( + direction: &str, + stroke_width_viewbox: f32, + quad_px: [f32; 2], +) -> ([[f32; 4]; 3], f32) { + let scale = quad_px[0].min(quad_px[1]) / 100.0; + let off = [(quad_px[0] - 100.0 * scale) * 0.5, (quad_px[1] - 100.0 * scale) * 0.5]; + let to_local = |v: [f32; 4]| { + [ + off[0] + v[0] * scale, + off[1] + v[1] * scale, + off[0] + v[2] * scale, + off[1] + v[3] * scale, + ] + }; + let segments = arrow_segments_viewbox(direction); + let half_stroke = (stroke_width_viewbox.max(0.0) * scale) * 0.5; + ([to_local(segments[0]), to_local(segments[1]), to_local(segments[2])], half_stroke) +} + +/// Focus effectif d'une région à `t` : sa position fixe, sauf en mode "auto" où elle suit la +/// télémétrie curseur (port de `getResolvedFocus`, sans le clamp — le crop-window de +/// `compositor.rs` clampe déjà après coup, cf. `su0.clamp(...)`, donc redondant ici). +fn resolve_focus(region: &SceneZoomRegion, t: f32, cursor: Option<&CursorTrack>) -> [f32; 2] { + if region.focus_mode.as_deref() == Some("auto") { + if let Some(track) = cursor { + // `follow_at`, pas `at` : la caméra suit la piste LISSÉE. Suivre la télémétrie brute + // donne un pan nerveux — l'étage de lissage de `cursorFollowUtils.ts` manquait au + // portage. + if let Some((cx, cy)) = track.follow_at(t) { + return [cx, cy]; + } + } + } + [region.focus_x, region.focus_y] +} + +/// Paires de régions adjacentes assez proches pour être chaînées (port de +/// `getConnectedRegionPairs`, TS) : (index courant, index suivant, début transition, fin +/// transition), en secondes. Indices dans `regions` (pas d'id nécessaire — contrairement au +/// web qui matche par `region.id` car il travaille sur des objets isolés, ici tout vient du +/// même slice donc les positions suffisent). +fn connected_pairs(regions: &[SceneZoomRegion]) -> Vec<(usize, usize, f32, f32)> { + let mut order: Vec = (0..regions.len()).collect(); + order.sort_by(|&a, &b| regions[a].start_sec.partial_cmp(®ions[b].start_sec).unwrap()); + let mut pairs = Vec::new(); + for w in order.windows(2) { + let (ci, ni) = (w[0], w[1]); + let gap = regions[ni].start_sec as f32 - regions[ci].end_sec as f32; + if gap <= CHAINED_ZOOM_PAN_GAP_S { + let transition_start = regions[ci].end_sec as f32; + pairs.push((ci, ni, transition_start, transition_start + CONNECTED_ZOOM_PAN_DURATION_S)); + } + } + pairs +} + +/// État de zoom au temps `t` (secondes source du clip actif). Port de +/// `findDominantRegion` (TS) : régions chaînées d'abord (transition puis +/// hold), sinon la région "dominante" indépendante la plus forte (ties → la plus récente). +/// Hors de toute région → identité (échelle 1, focus centre, tilt nul). +pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&CursorTrack>) -> ZoomState { + if regions.is_empty() { + return IDENTITY_ZOOM; + } + let pairs = connected_pairs(regions); + + // 1) transition chaînée : pan lissé de la région courante vers la suivante. + for &(ci, ni, t_start, t_end) in &pairs { + if t < t_start || t > t_end { + continue; + } + let progress = ease_connected_pan(clamp01((t - t_start) / (t_end - t_start).max(1e-3))); + let (cur, next) = (®ions[ci], ®ions[ni]); + let cur_focus = resolve_focus(cur, t, cursor); + let next_focus = resolve_focus(next, t, cursor); + return ZoomState { + scale: lerp(cur.scale, next.scale, progress), + focus: [lerp(cur_focus[0], next_focus[0], progress), lerp(cur_focus[1], next_focus[1], progress)], + rotation: lerp_rotation3d(rotation3d_for(&cur.rotation), rotation3d_for(&next.rotation), progress), + }; + } + + // 2) palier chaîné : entre la fin de la transition et le début officiel de la région + // suivante, celle-ci est déjà pleinement active (anticipe son propre ease-in). + for &(_, ni, _, t_end) in &pairs { + let next = ®ions[ni]; + if t > t_end && t < next.start_sec as f32 { + return ZoomState { + scale: next.scale, + focus: resolve_focus(next, t, cursor), + rotation: rotation3d_for(&next.rotation), + }; + } + } + + // 3) région dominante indépendante — exclut celles déjà couvertes par une transition/palier + // chaîné ci-dessus (sinon leur propre ease-in/out "percerait" à travers la fenêtre chaînée). + let mut best: Option<(usize, f32)> = None; + for (i, r) in regions.iter().enumerate() { + let outgoing_past_end = + pairs.iter().any(|&(ci, _, _, _)| ci == i && t > regions[i].end_sec as f32); + let incoming_before_transition_end = pairs.iter().any(|&(_, ni, _, t_end)| ni == i && t < t_end); + if outgoing_past_end || incoming_before_transition_end { + continue; + } + let s = zoom_region_strength(r, t); + if s <= 0.0 { + continue; + } + let better = match best { + None => true, + Some((bi, bs)) => s > bs || (s == bs && r.start_sec > regions[bi].start_sec), + }; + if better { + best = Some((i, s)); + } + } + match best { + Some((i, strength)) => { + let r = ®ions[i]; + let focus = resolve_focus(r, t, cursor); + let scale = lerp(1.0, r.scale, strength); + // La référence (`zoomTransform.ts`) fait converger le point de focus vers le centre + // de l'écran LINÉAIREMENT : screen(f) = 0.5 + (f - 0.5)(1 - strength). Passer + // `lerp(0.5, f, strength)` comme centre de crop ne donne pas ça — le crop mappant + // screen(f) = 0.5 + (f - centre) * scale, on obtient + // 0.5 + (f - 0.5)(1 - strength) * scale, soit un facteur en trop qui retient le point + // loin du centre en milieu de rampe puis le rattrape. Ce balayage parasite se lit + // comme si une région manuelle suivait le curseur. On inverse donc le mapping pour + // trouver le centre qui produit la trajectoire de référence. + let ease = |f: f32| f - (f - 0.5) * (1.0 - strength) / scale.max(1e-3); + ZoomState { + scale, + focus: [ease(focus[0]), ease(focus[1])], + rotation: lerp_rotation3d([0.0, 0.0, 0.0], rotation3d_for(&r.rotation), strength), + } + } + None => IDENTITY_ZOOM, + } +} + +/// Port de `computeCameraFullscreenRegionStrength` (TS) : progrès EXACTEMENT contenu dans +/// [startSec, endSec] (contrairement au zoom, qui anticipe avant `startSec`) — ease-in depuis +/// 0 pile à `startSec`, plein régime, ease-out jusqu'à 0 pile à `endSec`. Fenêtres bornées à la +/// moitié de la durée de la région pour que les régions courtes s'animent pleinement sans +/// déborder. +fn camera_fullscreen_region_strength(region: &SceneCameraFullscreenRegion, t: f32) -> f32 { + let start = region.start_sec as f32; + let end = region.end_sec as f32; + if t <= start || t >= end { + return 0.0; + } + let half = (end - start) * 0.5; + let lead_in = TRANSITION_WINDOW_S.min(half); + let lead_out = FULLSCREEN_LEAD_OUT_WINDOW_S.min(half); + let lead_in_end = start + lead_in; + let lead_out_start = end - lead_out; + if t < lead_in_end { + let progress = if lead_in > 0.0 { (t - start) / lead_in } else { 1.0 }; + return ease_out_screen_studio(progress); + } + if t <= lead_out_start { + return 1.0; + } + let progress = if lead_out > 0.0 { (end - t) / lead_out } else { 0.0 }; + ease_out_screen_studio(progress) +} + +/// Progrès Full Camera (0..1) au temps `t` : 0 = webcam à sa taille normale, 1 = plein cadre. +/// Régions superposées (ne devrait pas arriver, gardé défensif comme le web) → la plus forte +/// gagne. +pub fn camera_fullscreen_progress_at(regions: &[SceneCameraFullscreenRegion], t: f32) -> f32 { + let mut strongest = 0.0f32; + for r in regions { + let s = camera_fullscreen_region_strength(r, t); + if s > strongest { + strongest = s; + } + } + strongest +} + +// ============ Rotation 3D (tilt perspective, présets iso/left/right) ================ +// Port de `computeRotation3DContainScale` (TS, `types.ts`) — même formule, même ordre de +// composition ("CSS rotateX rotateY rotateZ s'applique droite-à-gauche : Z d'abord, puis Y, +// puis X"). `compositor.rs` s'en sert pour construire le quad tilté (4 coins projetés) rendu +// via un warp bilinéaire inverse en pixel shader (mode 8) — ce module ne fait que la géométrie. + +/// `true` si la rotation est (quasi) neutre — mêmes seuils que `isRotation3DIdentity` (TS). +pub fn is_identity_rotation(r: [f32; 3]) -> bool { + r[0].abs() < 0.01 && r[1].abs() < 0.01 && r[2].abs() < 0.01 +} + +/// Projette un point local (x0,y0,0) par la rotation 3D `rot` (degrés X/Y/Z) puis la +/// perspective `perspective` (distance en px ; <=0 = orthographique). `None` si le point +/// passe derrière le plan de projection (cas pathologique, comme le `return 1` du TS). +fn project_corner(x0: f32, y0: f32, rot: [f32; 3], perspective: f32) -> Option<(f32, f32)> { + let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); + let (ca, sa) = (a.cos(), a.sin()); + let (cb, sb) = (b.cos(), b.sin()); + let (cg, sg) = (g.cos(), g.sin()); + let (mut px, mut py, mut pz) = (x0, y0, 0.0f32); + // rotateZ + let (zx, zy) = (px * cg - py * sg, px * sg + py * cg); + px = zx; + py = zy; + // rotateY + let (yx, yz) = (px * cb + pz * sb, -px * sb + pz * cb); + px = yx; + pz = yz; + // rotateX + let (xy, xz) = (py * ca - pz * sa, py * sa + pz * ca); + py = xy; + pz = xz; + if perspective > 0.0 { + let denom = perspective - pz; + if denom <= 0.0 { + return None; + } + let f = perspective / denom; + px *= f; + py *= f; + } + Some((px, py)) +} + +/// Les 4 coins d'un quad `width`×`height` réduit de `scale`, projetés. `None` si un coin part +/// derrière le plan de fuite. +fn project_scaled_corners( + width: f32, + height: f32, + scale: f32, + rot: [f32; 3], + perspective: f32, +) -> Option<[(f32, f32); 4]> { + let (hw, hh) = (width * 0.5 * scale, height * 0.5 * scale); + let source = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]; + let mut out = [(0.0f32, 0.0f32); 4]; + for (i, &(x0, y0)) in source.iter().enumerate() { + out[i] = project_corner(x0, y0, rot, perspective)?; + } + Some(out) +} + +/// Demi-étendue des coins projetés sur chaque axe. +fn projected_extents(corners: &[(f32, f32); 4]) -> (f32, f32) { + corners.iter().fold((0.0f32, 0.0f32), |(mx, my), &(x, y)| (mx.max(x.abs()), my.max(y.abs()))) +} + +/// Un écran incliné : ses 4 coins projetés, et la réduction qu'il a fallu pour qu'ils tiennent. +#[derive(Clone, Copy)] +pub struct TiltedQuad { + /// Coins TL, TR, BR, BL en px relatifs au CENTRE du rect d'origine. + pub corners: [(f32, f32); 4], + /// Facteur de containment. Le plan mesure donc `taille_du_rect × scale` dans son PROPRE repère, + /// avant projection — ce qu'il faut connaître pour y poser un rayon de coin à la bonne échelle. + pub scale: f32, +} + +/// Les 4 coins (TL, TR, BR, BL) du quad tilté en 3D, en px relatifs au CENTRE du rect d'origine +/// (0,0 = centre — l'appelant les recentre sur le centre réel à l'écran). `width`/`height` en +/// px = la taille du rect d'origine, aussi utilisée comme référence de perspective (comme le +/// web : la perspective/le containScale sont calculés sur la taille de l'élément lui-même). +pub fn rotated_quad_corners_px(width: f32, height: f32, rot: [f32; 3]) -> TiltedQuad { + // ROTATION_3D_PERSPECTIVE_FACTOR (TS) — à garder synchronisé avec `types.ts`, que la passe 3D + // de l'exporteur canvas lit encore. Distance de fuite = facteur × min(w,h) : plus le facteur + // est grand, plus la caméra est loin et plus la convergence des arêtes s'aplatit. À 2.6 elle + // était si faible que l'inclinaison ne se lisait plus (le bord haut d'iso ressortait à 0.08° de + // l'horizontale). + const PERSPECTIVE_FACTOR: f32 = 1.6; + let perspective = width.min(height) * PERSPECTIVE_FACTOR; + let (half_w, half_h) = (width * 0.5, height * 0.5); + + // BUG corrigé : l'échelle de containment était calculée en projetant les coins PLEINE TAILLE, + // puis on projetait les coins RÉDUITS. La division perspective n'étant pas linéaire en la + // taille d'entrée — le `z` d'un coin bouge quand on le rapproche du centre —, réduire d'un + // facteur mesuré sur le grand quad ne suffisait pas : le quad projeté débordait encore, et le + // render target le coupait net (bords droits en haut et à droite d'un écran pourtant penché). + // + // On mesure donc sur les coins RÉELLEMENT projetés et on répète : chaque passe multiplie + // l'échelle par le facteur de débordement observé. Ça converge en deux ou trois tours ; huit + // est une borne large qui coûte quelques multiplications une fois par frame. + let mut scale = 1.0f32; + let mut corners = match project_scaled_corners(width, height, scale, rot, perspective) { + Some(c) => c, + // Un coin derrière le plan de fuite : on rend le quad non tourné plutôt qu'une projection + // absurde (même repli qu'avant). + None => { + return TiltedQuad { + corners: [ + (-half_w, -half_h), + (half_w, -half_h), + (half_w, half_h), + (-half_w, half_h), + ], + scale: 1.0, + }; + } + }; + for _ in 0..8 { + let (max_x, max_y) = projected_extents(&corners); + if max_x <= 0.0 || max_y <= 0.0 { + break; + } + let fit = (half_w / max_x).min(half_h / max_y); + // `fit >= 1` : le quad tient déjà. On ne l'agrandit jamais — le containment ne fait que + // réduire, comme le web. + if fit >= 0.999 { + break; + } + scale *= fit; + match project_scaled_corners(width, height, scale, rot, perspective) { + Some(c) => corners = c, + None => break, + } + } + TiltedQuad { corners, scale } +} + +impl TiltedQuad { + /// Où tombe le point `(fx, fy)` du plan (0..1 depuis son coin haut-gauche), en px relatifs + /// au CENTRE du rect d'origine — même repère que `corners`. + /// + /// C'est la correspondance DIRECTE du warp que le pixel shader du mode 8 parcourt à + /// l'envers : lui part d'un pixel écran et cherche son (s, t) dans le quad, celle-ci part + /// d'un (s, t) et donne le pixel. Bilinéaire des deux côtés — donc tout ce qu'on pose sur + /// le plan incliné par cette fonction retombe exactement sur le contenu que le shader y a + /// dessiné. Sans elle, un recouvrement comme le curseur reste sur le rect droit d'origine + /// pendant que l'image, elle, est penchée. + pub fn point_px(&self, fx: f32, fy: f32) -> (f32, f32) { + let [tl, tr, br, bl] = self.corners; + let top = (tl.0 + (tr.0 - tl.0) * fx, tl.1 + (tr.1 - tl.1) * fx); + let bottom = (bl.0 + (br.0 - bl.0) * fx, bl.1 + (br.1 - bl.1) * fx); + (top.0 + (bottom.0 - top.0) * fy, top.1 + (bottom.1 - top.1) * fy) + } + + /// Demi-largeur / demi-hauteur de la bounding box des coins projetés, en px. + pub fn half_extents_px(&self) -> (f32, f32) { + projected_extents(&self.corners) + } +} + +#[cfg(test)] +mod zoom_focus_tests { + use super::*; + use crate::scene::SceneZoomRegion; + + fn region(scale: f32, focus_x: f32) -> SceneZoomRegion { + SceneZoomRegion { + id: "z1".into(), + clip_index: None, + start_sec: 2.0, + end_sec: 8.0, + scale, + focus_x, + focus_y: 0.5, + focus_mode: Some("manual".into()), + rotation: None, + } + } + + /// Où le point source `f` atterrit à l'écran (0..1) : le crop est centré sur `focus` et + /// couvre `1/scale` de la source, donc l'écran mappe `0.5 + (f - focus) * scale`. + fn screen_x(state: &ZoomState, f: f32) -> f32 { + 0.5 + (f - state.focus[0]) * state.scale + } + + #[test] + fn manual_focus_travels_to_centre_linearly_during_the_ramp() { + // L'invariant de `zoomTransform.ts` : screen(f) = 0.5 + (f - 0.5)(1 - progress). La + // régression corrigée ici ajoutait un facteur `scale`, qui retenait le point loin du + // centre en milieu de rampe puis le rattrapait — lu comme un pan parasite sur une région + // pourtant en mode manuel. + let f = 0.8; + let target_scale = 2.5; + let regions = [region(target_scale, f)]; + // Plusieurs instants de la fenêtre d'ease-in, pour balayer les progressions partielles. + for step in 0..=20 { + let t = 1.0 + step as f32 * 0.15; + let state = zoom_state_at(®ions, t, None); + // `progress` déduit du scale rendu, pour ne pas ré-implémenter l'easing dans le test. + let progress = (state.scale - 1.0) / (target_scale - 1.0); + let expected = 0.5 + (f - 0.5) * (1.0 - progress); + assert!( + (screen_x(&state, f) - expected).abs() < 1e-4, + "t={t} progress={progress} screen={} attendu={expected}", + screen_x(&state, f) + ); + } + } + + #[test] + fn manual_focus_is_dead_centre_at_full_strength() { + let f = 0.8; + let regions = [region(2.5, f)]; + let state = zoom_state_at(®ions, 5.0, None); + assert!((state.scale - 2.5).abs() < 1e-4, "plein régime attendu, scale={}", state.scale); + assert!((screen_x(&state, f) - 0.5).abs() < 1e-4); + assert!((state.focus[0] - f).abs() < 1e-4); + } + + #[test] + fn outside_every_region_the_frame_is_untouched() { + let regions = [region(2.5, 0.8)]; + let state = zoom_state_at(®ions, 0.0, None); + assert_eq!(state.scale, 1.0); + assert_eq!(state.focus, [0.5, 0.5]); + } +} + +#[cfg(test)] +mod arrow_tests { + use super::*; + + /// Ouverture d'une barbe par rapport au fût, en degrés. C'est ce paramètre — plus que la + /// longueur — qui décide si une pointe ressemble à une flèche ou à un crochet. + fn barb_opening_deg(dir: &str, barb: usize) -> f32 { + let segs = arrow_segments_viewbox(dir); + let tip = (segs[barb][0], segs[barb][1]); + // direction du fût vue depuis la pointe : c'est celle de ses deux extrémités qui n'est + // PAS la pointe. + let shaft = segs[0]; + let back = if (shaft[0] - tip.0).abs() + (shaft[1] - tip.1).abs() < 1e-3 { + (shaft[2] - tip.0, shaft[3] - tip.1) + } else { + (shaft[0] - tip.0, shaft[1] - tip.1) + }; + let b = (segs[barb][2] - tip.0, segs[barb][3] - tip.1); + let dot = back.0 * b.0 + back.1 * b.1; + let mag = (back.0.hypot(back.1)) * (b.0.hypot(b.1)); + (dot / mag).clamp(-1.0, 1.0).acos().to_degrees() + } + + #[test] + fn every_arrowhead_opens_at_the_same_angle() { + // La déformation des diagonales ne venait pas que de la taille : leurs barbes ouvraient à + // ~25° du fût quand les cardinales ouvrent à 45°, ce qui donnait une pointe étroite, + // avalée par le fût dès que le trait épaississait. Corriger la longueur seule ne suffisait + // pas — ce test verrouille l'angle, qui est le paramètre réellement visible. + for dir in ["up", "down", "left", "right", "up-right", "up-left", "down-right", "down-left"] + { + for barb in 1..=2 { + let deg = barb_opening_deg(dir, barb); + assert!( + (deg - 45.0).abs() < 1.0, + "{dir} barbe {barb} ouvre à {deg:.1}°, attendu 45°" + ); + } + } + } + + #[test] + fn a_diagonal_head_is_as_large_as_a_cardinal_one() { + // Les barbes diagonales faisaient 15,8 unités contre 21,2 pour les cardinales : une + // flèche en diagonale avait une tête ~25 % plus petite que sa voisine horizontale, ce + // qui se lisait comme une déformation. Ce test interdit la divergence de revenir. + let barb_len = |seg: [f32; 4]| ((seg[2] - seg[0]).powi(2) + (seg[3] - seg[1]).powi(2)).sqrt(); + let cardinal = barb_len(arrow_segments_viewbox("up")[1]); + for dir in ["up-right", "up-left", "down-right", "down-left"] { + for barb in 1..=2 { + let len = barb_len(arrow_segments_viewbox(dir)[barb]); + assert!( + (len - cardinal).abs() < 0.2, + "{dir} barbe {barb} = {len:.2}, cardinale = {cardinal:.2}" + ); + } + } + } + + #[test] + fn the_geometry_is_the_svg_geometry_verbatim() { + // Parité avec `ArrowSvgs.tsx` : si ces nombres divergent, le rendu et la preview + // dessinent deux flèches différentes. + assert_eq!( + arrow_segments_viewbox("right"), + [[20.0, 50.0, 80.0, 50.0], [80.0, 50.0, 65.0, 35.0], [80.0, 50.0, 65.0, 65.0]] + ); + assert_eq!( + arrow_segments_viewbox("up"), + [[50.0, 20.0, 50.0, 80.0], [50.0, 20.0, 35.0, 35.0], [50.0, 20.0, 65.0, 35.0]] + ); + } + + #[test] + fn an_unknown_direction_falls_back_to_right() { + // Même défaut que le schéma côté app, pour qu'une donnée abîmée dessine quelque chose + // de sensé plutôt que rien. + assert_eq!(arrow_segments_viewbox("sideways"), arrow_segments_viewbox("right")); + } + + #[test] + fn a_square_quad_maps_the_viewbox_one_to_one() { + let (segments, half) = arrow_local_geometry("right", 10.0, [100.0, 100.0]); + // échelle 1, aucun centrage à appliquer + assert_eq!(segments[0], [20.0, 50.0, 80.0, 50.0]); + assert!((half - 5.0).abs() < 1e-6); + } + + #[test] + fn a_wide_quad_scales_uniformly_and_centres() { + // `preserveAspectRatio` vaut `xMidYMid meet` par défaut : la flèche tient dans le PLUS + // PETIT côté et se centre — elle n'est jamais étirée. Ici 400x200 -> échelle 2, et + // 200px de marge horizontale à répartir, donc +100 sur les x. + let (segments, half) = arrow_local_geometry("right", 4.0, [400.0, 200.0]); + assert_eq!(segments[0], [100.0 + 40.0, 100.0, 100.0 + 160.0, 100.0]); + // l'épaisseur suit la même échelle uniforme + assert!((half - 4.0).abs() < 1e-6); + } + + #[test] + fn a_tall_quad_centres_vertically() { + let (segments, _) = arrow_local_geometry("up", 4.0, [100.0, 300.0]); + // échelle 1 (plus petit côté = 100), 200px de marge verticale -> +100 sur les y + assert_eq!(segments[0], [50.0, 100.0 + 20.0, 50.0, 100.0 + 80.0]); + } + + #[test] + fn a_negative_stroke_width_cannot_produce_a_negative_half_width() { + let (_, half) = arrow_local_geometry("right", -5.0, [100.0, 100.0]); + assert_eq!(half, 0.0); + } +} + +#[cfg(test)] +mod tilt_tests { + use super::*; + + /// `point_px` doit rendre les coins du plan sur les coins projetés, sans quoi tout ce qu'on + /// pose dessus (le curseur) se décale par rapport à l'image que le shader y a dessinée. + #[test] + fn the_plane_corners_map_to_the_projected_corners() { + let quad = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("iso".into()))); + for (i, (fx, fy)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].into_iter().enumerate() + { + let (x, y) = quad.point_px(fx, fy); + let (ex, ey) = quad.corners[i]; + assert!((x - ex).abs() < 1e-3 && (y - ey).abs() < 1e-3, "coin {i}: {x},{y} != {ex},{ey}"); + } + } + + /// Et le milieu du plan tombe au barycentre des quatre coins — la propriété qui distingue le + /// bilinéaire (ce que fait le shader) d'un simple placement dans la bounding box. + #[test] + fn the_plane_centre_maps_to_the_centroid() { + let quad = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("left".into()))); + let (x, y) = quad.point_px(0.5, 0.5); + let cx = quad.corners.iter().map(|c| c.0).sum::() / 4.0; + let cy = quad.corners.iter().map(|c| c.1).sum::() / 4.0; + assert!((x - cx).abs() < 1e-3 && (y - cy).abs() < 1e-3); + } + + /// La raison d'être du correctif : sur un écran incliné, la position tiltée n'est PAS la + /// position dans le rect droit. Si ces deux-là coïncidaient, le bug d'origine n'existerait + /// pas — et ce test tomberait le jour où quelqu'un remettrait le rect droit. + #[test] + fn a_tilted_plane_moves_the_cursor_off_the_upright_rect() { + let (w, h) = (1920.0f32, 1080.0f32); + let quad = rotated_quad_corners_px(w, h, rotation3d_for(&Some("iso".into()))); + let mut worst: f32 = 0.0; + for (fx, fy) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (0.5, 0.0)] { + let (x, y) = quad.point_px(fx, fy); + // Le même point posé sur le rect droit, dans le même repère centré. + let (ux, uy) = ((fx - 0.5) * w, (fy - 0.5) * h); + worst = worst.max((x - ux).hypot(y - uy)); + } + assert!(worst > 20.0, "ecart max au rect droit trop faible: {worst}px"); + } + + /// Le contrat du containment : après projection, aucun coin ne sort du rect d'origine. + /// C'est ce qui garantit que le quad incliné n'est jamais coupé par le bord du cadre. + #[test] + fn every_preset_stays_inside_the_original_rect() { + for (name, rot) in [ + ("iso", rotation3d_for(&Some("iso".into()))), + ("left", rotation3d_for(&Some("left".into()))), + ("right", rotation3d_for(&Some("right".into()))), + ] { + // Plusieurs formes de boîte : le débordement dépend du ratio. + for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { + let corners = rotated_quad_corners_px(w, h, rot).corners; + let (max_x, max_y) = projected_extents(&corners); + // Tolérance d'un demi-pixel : l'itération s'arrête à 0.1 % près. + assert!( + max_x <= w * 0.5 + 0.5 && max_y <= h * 0.5 + 0.5, + "{name} {w}x{h} : étendue ({max_x:.1}, {max_y:.1}) dépasse ({:.1}, {:.1})", + w * 0.5, + h * 0.5 + ); + } + } + } + + /// Aucune arête d'un préset ne doit longer un axe de l'image. C'est LE critère qui distingue + /// « un écran incliné » d'« un enregistrement tronqué » : un bord parfaitement vertical qui + /// coupe une phrase se lit comme un `overflow: hidden`, quelle que soit la justesse du reste. + #[test] + fn no_preset_has_an_axis_aligned_edge() { + for (name, rot) in [ + ("iso", rotation3d_for(&Some("iso".into()))), + ("left", rotation3d_for(&Some("left".into()))), + ("right", rotation3d_for(&Some("right".into()))), + ] { + let c = rotated_quad_corners_px(1920.0, 1080.0, rot).corners; + // haut, bas (contre l'horizontale) ; gauche, droite (contre la verticale) + let h_angle = |p: (f32, f32), q: (f32, f32)| (q.1 - p.1).atan2(q.0 - p.0).to_degrees(); + let v_angle = |p: (f32, f32), q: (f32, f32)| (q.0 - p.0).atan2(q.1 - p.1).to_degrees(); + let edges = [ + ("haut", h_angle(c[0], c[1])), + ("bas", h_angle(c[3], c[2])), + ("gauche", v_angle(c[0], c[3])), + ("droite", v_angle(c[1], c[2])), + ]; + for (edge, angle) in edges { + assert!( + angle.abs() >= 2.0, + "{name} : arête {edge} à {angle:.2}° de son axe — ça se lit comme une découpe" + ); + } + } + } + + #[test] + fn a_flat_quad_is_left_alone() { + // Sans rotation, aucun containment ne doit s'appliquer : les coins sont ceux du rect. + let corners = rotated_quad_corners_px(1000.0, 600.0, [0.0, 0.0, 0.0]).corners; + let (max_x, max_y) = projected_extents(&corners); + assert!((max_x - 500.0).abs() < 0.5 && (max_y - 300.0).abs() < 0.5); + } + + #[test] + fn the_tilt_actually_tilts() { + // Garde-fou contre un containment trop zélé qui aplatirait l'effet : les quatre coins + // d'un quad incliné ne peuvent pas rester alignés deux à deux comme un rectangle droit. + let corners = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("iso".into()))).corners; + let top_edge_slope = (corners[1].1 - corners[0].1).abs(); + assert!(top_edge_slope > 1.0, "arête supérieure horizontale : le tilt a disparu"); + } + + // ---- Mapping inverse du mode 8, reproduit à l'identique ------------------------------- + // Le pixel shader retrouve (s,t) dans le quad projeté en résolvant un système quadratique. + // Le miroir ci-dessous est une COPIE de `shaders.hlsl` (mode 8) : même algèbre, même choix + // de racine, mêmes tolérances. Il sert à interroger ce mapping sans GPU — un bord droit qui + // tranche un écran penché ne peut venir que de trois endroits (les coins, ce mapping, le + // rect source), et c'est le seul des trois qu'on ne pouvait pas encore examiner. + + fn cross2(a: (f32, f32), b: (f32, f32)) -> f32 { + a.0 * b.1 - a.1 * b.0 + } + + fn sub(a: (f32, f32), b: (f32, f32)) -> (f32, f32) { + (a.0 - b.0, a.1 - b.1) + } + + /// Point du quad pour un couple (s,t) — la direction que le shader inverse. + fn forward_bilinear(corners: &[(f32, f32); 4], s: f32, t: f32) -> (f32, f32) { + let (c00, c10, c11, c01) = (corners[0], corners[1], corners[2], corners[3]); + let e = sub(c10, c00); + let f = sub(c01, c00); + let g = (c00.0 - c10.0 - c01.0 + c11.0, c00.1 - c10.1 - c01.1 + c11.1); + (c00.0 + e.0 * s + f.0 * t + g.0 * s * t, c00.1 + e.1 * s + f.1 * t + g.1 * s * t) + } + + /// `None` = le shader rend ce pixel TRANSPARENT (donc un trou dans l'écran incliné). + fn shader_inverse_bilinear(corners: &[(f32, f32); 4], p: (f32, f32)) -> Option<(f32, f32)> { + let (c00, c10, c11, c01) = (corners[0], corners[1], corners[2], corners[3]); + let e = sub(c10, c00); + let f = sub(c01, c00); + let g = (c00.0 - c10.0 - c01.0 + c11.0, c00.1 - c10.1 - c01.1 + c11.1); + let h = sub(p, c00); + let k2 = cross2(g, f); + let k1 = cross2(e, f) + cross2(h, g); + let k0 = cross2(h, e); + // Pour une racine `t` candidate, le `s` correspondant — et si le couple tombe dans le quad. + let st_for_root = |t: f32| -> Option<(f32, f32)> { + let denom_x = e.0 + g.0 * t; + let denom_y = e.1 + g.1 * t; + let s = if denom_x.abs() > denom_y.abs() { + (h.0 - f.0 * t) / denom_x + } else { + (h.1 - f.1 * t) / denom_y + }; + ((-0.02..=1.02).contains(&s) && (-0.02..=1.02).contains(&t)).then_some((s, t)) + }; + // Seuil RELATIF. Un prését « left »/« right » est une rotation Y pure : le quad projeté + // est un trapèze symétrique dont `f` et `g` sont tous deux verticaux, donc k2 = 0 + // exactement — au bruit d'arrondi près, et ce bruit vaut quelques centièmes sur des + // produits en 10^6. Un seuil absolu de 0.001 le manquait, l'équation partait dans la + // branche quadratique avec un k2 ≈ 0, et `(-k1 + sqrt(k1²)) / 2k2` y perd toute + // précision : soustraire deux nombres presque égaux ne laisse que du bruit, divisé + // ensuite par un k2 minuscule. C'est ça qui amputait l'écran incliné. + if k2.abs() < 1e-5 * k1.abs() { + let t = if k1.abs() < 1e-6 { 0.0 } else { -k0 / k1 }; + return st_for_root(t); + } + let disc = k1 * k1 - 4.0 * k2 * k0; + if disc < 0.0 { + return None; + } + // Forme stable : `q` évite la soustraction catastrophique, et les deux racines s'en + // déduisent sans jamais retrancher deux quantités voisines. Le signe s'écrit en ternaire + // et non via `signum`, pour coller au shader — où `sign()` vaut 0 en 0 et annulerait `q`. + let sign_k1 = if k1 >= 0.0 { 1.0 } else { -1.0 }; + let q = -0.5 * (k1 + sign_k1 * disc.sqrt()); + let roots = [q / k2, if q.abs() > 0.0 { k0 / q } else { q / k2 }]; + // Les DEUX racines sont essayées : trancher sur `t` seul retenait parfois celle dont le + // `s` tombe hors du quad, et le pixel était alors déclaré dehors alors que l'autre racine + // le plaçait dedans. + st_for_root(roots[0]).or_else(|| st_for_root(roots[1])) + } + + #[test] + fn the_shader_mapping_covers_the_whole_tilted_quad() { + // Le bug rapporté : « une sorte d'overflow hidden qui tronque le screen recording ». Si le + // mapping inverse perd des pixels pourtant intérieurs au quad, le trou a exactement cette + // allure — un bord net, sans rapport avec la géométrie visible. + for (name, rot) in [ + ("iso", rotation3d_for(&Some("iso".into()))), + ("left", rotation3d_for(&Some("left".into()))), + ("right", rotation3d_for(&Some("right".into()))), + ] { + let corners = rotated_quad_corners_px(1920.0, 1080.0, rot).corners; + let mut dropped = Vec::new(); + let n = 64; + for i in 0..=n { + for j in 0..=n { + // On reste à un cheveu des arêtes : le contour exact est une frontière où le + // rejet est légitime. + let s = 0.002 + (i as f32 / n as f32) * 0.996; + let t = 0.002 + (j as f32 / n as f32) * 0.996; + let p = forward_bilinear(&corners, s, t); + match shader_inverse_bilinear(&corners, p) { + None => dropped.push((s, t)), + Some((s2, t2)) => { + // Retrouver le mauvais (s,t) est aussi grave : l'écran afficherait + // alors un morceau de lui-même au mauvais endroit. + if (s2 - s).abs() > 0.01 || (t2 - t).abs() > 0.01 { + dropped.push((s, t)); + } + } + } + } + } + assert!( + dropped.is_empty(), + "{name} : {} points intérieurs perdus par le mapping, p.ex. {:?}", + dropped.len(), + &dropped[..dropped.len().min(5)] + ); + } + } +} diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs new file mode 100644 index 0000000000..2de5695108 --- /dev/null +++ b/crates/compositor/src/scene.rs @@ -0,0 +1,614 @@ +//! Contrat de scène côté Rust — miroir exact de `SceneDescription` (TS, `src/native/sceneDescription.ts`). +//! L'app sérialise le document en JSON ; le natif le parse ici puis calcule la composition par frame, +//! ce qui **remplace le `timeline()` fixture** (placements A↔B + zoom codés en dur). Le natif possède +//! toute la maths par-frame (géométrie du layout, easing du zoom, application des effets) ; ce module +//! ne fait que le modèle de données + le parse. La conversion JS (camelCase) est gérée par serde. + +use serde::Deserialize; + +/// Un clip de la timeline (fichiers screen+webcam + fenêtre source). = `CompositorClipInput` (TS). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneClip { + pub screen_path: String, + pub webcam_path: String, + pub source_start_sec: f64, + pub source_end_sec: f64, + /// temps source webcam = temps source screen − ceci. + pub webcam_offset_sec: f64, + /// Une source sans piste audio décodable garde sa durée via du silence natif. + #[serde(default)] + pub has_audio: bool, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebcamPosition { + pub cx: f32, + pub cy: f32, +} + +/// Placement de la webcam (preset + réglages). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneLayout { + /// "picture-in-picture" | "dual-frame" | "vertical-stack" | "no-webcam". + pub preset: String, + /// échelle taille webcam (1 = défaut PiP du compositeur). + pub webcam_size: f32, + /// "rectangle" | "circle" | "square" | "rounded" — la forme RÉSOLUE par le layout, pas le + /// réglage brut de l'utilisateur : seul le PiP honore le sélecteur de forme, les layouts en + /// bloc découpent toujours un rectangle (côté app, cf. `computeCompositeLayout`). + pub webcam_shape: String, + pub webcam_mirror: bool, + /// position normalisée (0..1) du centre webcam, ou None → défaut du preset. + pub webcam_position: Option, + /// la webcam rétrécit pendant un zoom actif. + pub webcam_reactive_zoom: bool, + /// Rect webcam résolu côté app (0..1 fractions du cadre de sortie), en PARITÉ EXACTE avec + /// `computeCompositeLayout` (TS). Permet à TS et Rust de partager la même source de vérité : + /// le natif ne dérive PLUS ses propres placements pour PiP/dual-frame/vertical-stack — il + /// consomme ce rect directement et applique par-dessus les ajustements purement par-frame + /// (`webcam_size_scale`, `reactive_scale`, Full Camera). + /// + /// `#[serde(default)]` : champ ajouté après coup ; les anciens JSON (et les tests) omettent + /// ce champ, ce qui active le fallback `preset_placements` Rust historique (PiP codé en dur). + #[serde(default)] + pub webcam_rect: Option, + /// Rect ÉCRAN résolu côté app (mêmes fractions 0..1 du cadre de sortie que `webcam_rect`). + /// Déjà paddé et déjà au ratio du crop — le natif le consomme TEL QUEL, sans `padding_scale` + /// ni `fit_dst_to_aspect`. Sans lui, le natif gardait sa boîte écran codée en dur + /// (`preset_placements`) tout en respectant la boîte caméra de l'app : les deux ne + /// s'accordaient plus et la caméra du preset side-by-side sortait du cadre. + /// + /// `#[serde(default)]` : ancien payload / tests → None → fallback `preset_placements`. + #[serde(default)] + pub screen_rect: Option, + /// Rayon des coins de l'écran, en FRACTION du petit côté de sa propre boîte, quand le preset + /// en impose un (les layouts en bloc encadrent écran et caméra à l'identique). None → slider + /// Roundness. Une fraction, pas des px : cf. `SceneEffects::roundness_frac`. + #[serde(default)] + pub screen_radius_frac: Option, + /// L'écran doit-il REMPLIR sa boîte quitte à être rogné (`object-fit: cover`) plutôt que d'y + /// tenir en entier ? Vrai pour les layouts en bloc, dont la boîte écran est un slot au ratio + /// arbitraire : c'est ce que `computeCompositeLayout` renvoie sous `screenCover` et que + /// `frameRenderer` applique déjà côté web. Sans ce drapeau le natif étirait la source pour + /// remplir le slot — d'autant plus visible sur un clip recadré, le crop éloignant encore le + /// ratio de la source de celui du slot. + /// + /// `#[serde(default)]` : absent → `false` → comportement "contain" historique. + #[serde(default)] + pub screen_cover: bool, + /// Un layout résolu PAR CLIP visible, aligné par index sur `Scene::clips` / `crop_by_clip`. + /// Les champs scalaires ci-dessus sont ceux du PREMIER clip (repli pour un payload sans ce + /// tableau, et valeur de départ tant qu'aucun clip n'est actif). + /// + /// Par clip parce que la FORME de la source écran l'est : un clip est un enregistrement + /// d'écran + une caméra et un son optionnels, et rien n'impose à deux clips d'avoir été + /// enregistrés à la même taille ni au même ratio. Le crop n'est qu'une manière de plus de + /// faire varier cette forme — un 16:9 recadré en 9:16 doit se disposer exactement comme un + /// enregistrement nativement en 9:16. À ne pas confondre avec le ratio de la SCÈNE, global. + /// + /// `for_clip_window` recopie l'entrée du clip composé dans les champs scalaires, si bien que + /// `compose_frame` continue de lire un seul `layout` sans branche supplémentaire. + #[serde(default)] + pub layout_by_clip: Vec>, + /// Rayon des coins de la CAMÉRA, en fraction du petit côté de SA boîte — même règle que + /// `screen_radius_frac`, issu du même appel `computeCompositeLayout`. C'est la seule façon + /// que « le bloc encadre écran et caméra à l'identique » soit vrai : sans lui l'écran prenait + /// le rayon de l'app pendant que la caméra gardait la table Rust indépendante + /// (`min * 0.5 | 0.3 | 0.12`, non bornée), donc deux moitiés d'un même bloc arrondies par + /// deux formules différentes. + /// + /// `#[serde(default)]` : ancien payload / tests → None → table Rust historique. + #[serde(default)] + pub webcam_radius_frac: Option, +} + +/// La moitié du layout qui dépend de la FORME de la source, résolue pour un clip. +/// Voir `SceneLayout::layout_by_clip`. +/// +/// Les rayons sont des fractions du petit côté de LEUR boîte, exactement comme les champs +/// scalaires `screen_radius_frac`/`webcam_radius_frac` — aucune longueur ne traverse ce +/// contrat en pixels, par clip ou non. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedClipLayout { + pub screen_rect: SceneRect, + #[serde(default)] + pub webcam_rect: Option, + #[serde(default)] + pub screen_radius_frac: Option, + #[serde(default)] + pub webcam_radius_frac: Option, + #[serde(default)] + pub webcam_shape: Option, + #[serde(default)] + pub screen_cover: bool, +} + +/// Rect normalisé 0..1 du cadre de sortie : x, y en haut-gauche ; width, height. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneRect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// Effets de cadre (padding, blur, ombre, coins, motion blur). +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneEffects { + /// 0..1 inset supplémentaire de l'écran. + pub padding: f32, + pub blur: bool, + /// 0..1 force de l'ombre. + pub shadow: f32, + /// Slider Roundness, en FRACTION du petit côté du cadre de sortie. + /// + /// Toute longueur qui traverse ce contrat est une fraction, jamais un nombre de pixels, et + /// c'est porteur : le compositeur rastérise la preview dans un cadre contain-fitté petit et + /// l'export à la pleine résolution, donc « un pixel » ne désigne pas la même chose des deux + /// côtés de la frontière. Des valeurs absolues la traversaient et signifiaient en silence + /// « px du render target » — d'où le cercle PiP dégénéré en preview alors que l'export était + /// juste, et l'ombre proportionnellement plus faible en 4K qu'en 1080p. Une fraction n'a + /// pas d'unité à confondre : le natif multiplie par ce que sa référence mesure ici et + /// maintenant. + pub roundness_frac: f32, + /// 0..1 flou de mouvement. + pub motion_blur: f32, +} + +/// Fond derrière l'écran (parsé depuis `settings.wallpaper`). +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum SceneBackground { + Color { color: String }, + Gradient { + #[serde(rename = "angleDeg")] + angle_deg: f32, + stops: Vec, + }, + Image { path: String }, +} + +/// Une annotation de la timeline (temps en secondes, source du clip). +/// +/// Espace de coordonnées — à respecter au rendu : `x`/`y`/`w`/`h` sont des fractions du **rect +/// écran**, pas du cadre de sortie (le calque web reçoit `layout.screenRect` comme conteneur), et +/// elles ne subissent **pas** le crop de zoom : l'overlay est frère de l'élément qui porte la +/// transform, donc les annotations restent en place pendant que le contenu zoome dessous. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAnnotation { + #[serde(default)] + pub id: String, + /// Voir `SceneZoomRegion::clip_index`. + #[serde(default)] + pub clip_index: Option, + pub start_sec: f64, + pub end_sec: f64, + /// "text" | "image" | "figure" | "blur". + pub kind: String, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + /// Ordre de peinture ; l'app envoie déjà la liste triée croissante. + #[serde(default)] + pub z_index: i32, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub image_path: Option, + #[serde(default)] + pub figure: Option, + #[serde(default)] + pub blur: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAnnotationText { + pub content: String, + /// Chaînes CSS, parsées ici comme la couleur de fond (`parse_hex`) ; "transparent" = pas de + /// remplissage. + pub color: String, + pub background_color: String, + /// Taille de police en **fraction de la hauteur du rect écran**, comme tout le reste de ce + /// contrat : à multiplier par la hauteur du rect en pixels de sortie. La preview applique le + /// même produit contre sa propre boîte (`annotationScale.ts`), donc preview et rendu + /// s'accordent à n'importe quelle résolution. + pub font_size_rel: f32, + pub font_family: String, + pub font_weight: String, + pub font_style: String, + pub text_decoration: String, + pub text_align: String, + #[serde(default)] + pub animation: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAnnotationFigure { + /// "up" | "down" | "left" | "right" | "up-right" | "up-left" | "down-right" | "down-left". + pub direction: String, + pub color: String, + pub stroke_width: f32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAnnotationBlur { + /// "blur" | "mosaic". + pub style: String, + /// "rectangle" | "oval" | "freehand". + pub shape: String, + /// "white" | "black". + pub color: String, + pub intensity: f32, + pub block_size: f32, + /// Fractions du rect écran, même espace que le rect. + #[serde(default)] + pub freehand_points: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SceneAnnotationPoint { + pub x: f32, + pub y: f32, +} + +/// Une zone de zoom de la timeline (temps en secondes). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneZoomRegion { + /// Identifiant stable — nécessaire pour apparier les régions adjacentes (connected pan). + /// `#[serde(default)]` : champ ajouté après coup. + #[serde(default)] + pub id: String, + /// Index du clip dont les temps source portent cette région. `None` garde la compatibilité + /// avec les payloads antérieurs et déclenche le repli par chevauchement de fenêtre source. + #[serde(default)] + pub clip_index: Option, + pub start_sec: f64, + pub end_sec: f64, + /// échelle cible (>1 = zoom avant). + pub scale: f32, + pub focus_x: f32, + pub focus_y: f32, + /// "manual" | "auto" (suit la télémétrie curseur) | null (= manual). + #[serde(default)] + pub focus_mode: Option, + /// "iso" | "left" | "right" | null. + pub rotation: Option, +} + +/// Une zone de vitesse portée par le temps source d'un clip. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneSpeedRegion { + /// Index du clip dont les temps source portent cette région (voir `SceneZoomRegion`). + #[serde(default)] + pub clip_index: Option, + pub start_sec: f64, + pub end_sec: f64, + pub speed: f64, +} + +/// Une zone "Full Camera" de la timeline (temps en secondes) : la caméra PREND tout le cadre +/// pendant cette fenêtre (plein écran net — ni marge, ni arrondi, ni masque, ni fond derrière). +/// Pas de champs au-delà des bornes temporelles (miroir de `CameraFullscreenRegion`, TS). +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCameraFullscreenRegion { + /// Index du clip dont les temps source portent cette région (voir `SceneZoomRegion`). + #[serde(default)] + pub clip_index: Option, + pub start_sec: f64, + pub end_sec: f64, +} + +/// Rendu du curseur. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCursor { + pub show: bool, + /// échelle directe (1 = défaut). + pub size: f32, + pub smoothing: f32, + pub motion_blur: f32, + pub click_bounce: f32, + pub clip_to_bounds: bool, + /// id du thème (jeu de sprites) — informatif ici : le natif consomme `cursor_sprites`. + pub theme: String, + /// Sprites par état de curseur (`"arrow"`, `"text"`, `"pointer"`, `"resize-ew"`, …), chemins + /// absolus résolus côté app (compositorViewService, même mécanisme que le wallpaper image). + /// Le thème choisi n'y fournit que les états qu'il possède ; l'app complète le reste avec + /// l'art intégrée, si bien qu'ici la table est TOUJOURS complète ou vide. + /// + /// Vide → curseur math dot+ring, qui n'est qu'un filet de sécurité : ce n'est PAS à quoi + /// ressemble un pointeur système, et l'afficher en temps normal était le bug « le curseur + /// par défaut est un point dans un cercle ». + /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. + #[serde(default)] + pub cursor_sprites: std::collections::HashMap, +} + +/// Un sprite de curseur : image + point de pivot. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCursorSprite { + /// Chemin absolu d'un PNG/JPEG déchiffrable par le crate `image`. + pub path: String, + /// Pivot en FRACTION de l'image (0..1), pas en pixels : le sprite est redimensionné au + /// réglage « taille du curseur », et seule une fraction survit à cette mise à l'échelle. + /// Un pivot centré (0.5, 0.5) imposé à tous les sprites décalait la pointe d'autant plus + /// que le curseur était agrandi — le bug que ce champ corrige. + pub hotspot_x: f32, + pub hotspot_y: f32, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCrop { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneOutput { + pub width: u32, + pub height: u32, + /// null = fps du 1er clip. + pub fps: Option, +} + +/// Tout ce dont le natif a besoin pour composer la scène, sérialisé depuis un document. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Scene { + pub clips: Vec, + pub layout: SceneLayout, + pub effects: SceneEffects, + pub background: SceneBackground, + pub zoom_regions: Vec, + /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. + #[serde(default)] + pub annotations: Vec, + /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. + #[serde(default)] + pub speed_regions: Vec, + /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. + #[serde(default)] + pub camera_fullscreen_regions: Vec, + pub cursor: SceneCursor, + /// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS). + #[serde(default)] + pub crop_by_clip: Vec>, + /// État de rendu interne, positionné par `for_clip_window` (jamais envoyé par l'app). + #[serde(skip)] + pub(crate) active_clip_index: usize, + pub output: SceneOutput, +} + +impl Scene { + /// Parse le JSON produit par `buildSceneDescription` (TS). + pub fn from_json(json: &str) -> anyhow::Result { + Ok(serde_json::from_str(json)?) + } + + /// Copie de scène limitée aux régions du clip actif. `clipIndex` est l'identité fiable + /// lorsque plusieurs clips réutilisent les mêmes temps source ; son absence retombe sur le + /// chevauchement avec la fenêtre source pour accepter les anciens payloads. + pub(crate) fn for_clip_window( + &self, + clip_index: usize, + source_start_sec: f64, + source_end_sec: f64, + ) -> Scene { + let belongs = |region_clip_index: Option, start_sec: f64, end_sec: f64| { + let overlaps_window = end_sec > source_start_sec && start_sec < source_end_sec; + overlaps_window && region_clip_index.map(|i| i == clip_index).unwrap_or(true) + }; + let mut scene = self.clone(); + scene.zoom_regions.retain(|region| { + belongs(region.clip_index, region.start_sec, region.end_sec) + }); + scene.speed_regions.retain(|region| { + belongs(region.clip_index, region.start_sec, region.end_sec) + }); + scene.camera_fullscreen_regions.retain(|region| { + belongs(region.clip_index, region.start_sec, region.end_sec) + }); + scene.annotations.retain(|annotation| { + belongs(annotation.clip_index, annotation.start_sec, annotation.end_sec) + }); + // Le layout dépend de la FORME de la source du clip (dimensions natives × crop), qui + // varie d'un clip à l'autre. On installe donc celui du clip composé dans les champs + // scalaires : `compose_frame` continue de lire un seul `layout`, sans jamais avoir à + // savoir qu'il en existe un par clip. Absent (payload ancien) → on garde les scalaires. + if let Some(Some(l)) = scene.layout.layout_by_clip.get(clip_index).cloned() { + scene.layout.screen_rect = Some(l.screen_rect); + scene.layout.webcam_rect = l.webcam_rect; + scene.layout.screen_radius_frac = l.screen_radius_frac; + scene.layout.webcam_radius_frac = l.webcam_radius_frac; + scene.layout.screen_cover = l.screen_cover; + if let Some(shape) = l.webcam_shape { + scene.layout.webcam_shape = shape; + } + } + scene.active_clip_index = clip_index; + scene + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_minimal_scene_json() { + let json = r##"{ + "clips": [{"screenPath":"/s.mp4","webcamPath":"/w.mp4","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":0,"hasAudio":true}], + "layout": {"preset":"picture-in-picture","webcamSize":1.5,"webcamShape":"circle","webcamMirror":true,"webcamPosition":null,"webcamReactiveZoom":false}, + "effects": {"padding":0.5,"blur":true,"shadow":0.8,"roundnessFrac":0.0222,"motionBlur":0.0}, + "background": {"kind":"gradient","angleDeg":135,"stops":["#eaebed","#bcc0c6"]}, + "zoomRegions": [{"clipIndex":0,"startSec":1.0,"endSec":3.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"iso"}], + "speedRegions": [{"clipIndex":0,"startSec":1.0,"endSec":2.0,"speed":2.0}], + "cursor": {"show":true,"size":1,"smoothing":0.5,"motionBlur":0.2,"clickBounce":1,"clipToBounds":false,"theme":"default"}, + "cropByClip": [null], + "output": {"width":1920,"height":1080,"fps":null} + }"##; + let scene = Scene::from_json(json).expect("parse"); + assert_eq!(scene.clips.len(), 1); + assert_eq!(scene.clips[0].screen_path, "/s.mp4"); + assert_eq!(scene.layout.preset, "picture-in-picture"); + assert!(scene.layout.webcam_mirror); + assert!((scene.effects.roundness_frac - 0.0222).abs() < 1e-6); + match scene.background { + SceneBackground::Gradient { angle_deg, ref stops } => { + assert_eq!(angle_deg, 135.0); + assert_eq!(stops.len(), 2); + } + _ => panic!("expected gradient"), + } + assert_eq!(scene.zoom_regions[0].scale, 2.0); + assert_eq!(scene.zoom_regions[0].clip_index, Some(0)); + assert_eq!(scene.speed_regions[0].speed, 2.0); + assert!(scene.clips[0].has_audio); + assert_eq!(scene.crop_by_clip.len(), 1); + assert_eq!(scene.output.width, 1920); + } + + #[test] + fn parses_color_and_image_backgrounds() { + let color = r##"{"clips":[],"layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0},"background":{"kind":"color","color":"#123456"},"zoomRegions":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1280,"height":720,"fps":30}}"##; + let s = Scene::from_json(color).expect("parse color"); + match s.background { + SceneBackground::Color { ref color } => assert_eq!(color, "#123456"), + _ => panic!("expected color"), + } + assert_eq!(s.output.fps, Some(30.0)); + } + + #[test] + fn parses_webcam_rect_payload() { + // webcamRect est une fraction 0..1 du cadre de sortie ; sa présence doit désactiver + // le fallback `preset_placements` Rust côté `compose_frame` (voir `compositor.rs`). + let json = r##"{ + "clips": [], + "layout": { + "preset": "picture-in-picture", + "webcamSize": 0.25, + "webcamShape": "rounded", + "webcamMirror": false, + "webcamPosition": null, + "webcamReactiveZoom": false, + "webcamRect": { "x": 0.8125, "y": 0.8125, "width": 0.1666667, "height": 0.1666667 } + }, + "effects": {"padding": 0, "blur": false, "shadow": 0, "roundnessFrac": 0.0222, "motionBlur": 0}, + "background": {"kind":"color","color":"#000000"}, + "zoomRegions": [], + "cursor": {"show": true, "size": 1, "smoothing": 0, "motionBlur": 0, "clickBounce": 1, "clipToBounds": false, "theme": "default"}, + "cropByClip": [], + "output": {"width": 1920, "height": 1080, "fps": null} + }"##; + let s = Scene::from_json(json).expect("parse w/ webcamRect"); + let r = s + .layout + .webcam_rect + .expect("webcam_rect doit être présent pour ce payload"); + // bornes + ratio cohérent avec `computeCompositeLayout` (TS) pour le preset PiP @25%. + assert!((0.0..=1.0).contains(&r.x) && (0.0..=1.0).contains(&r.y)); + assert!(r.width > 0.0 && r.width <= 1.0); + assert!((r.width - r.height).abs() < 1e-5); + } + + #[test] + fn webcam_rect_field_optional_in_payload() { + // L'ancien payload sans `webcamRect` doit toujours parser sans erreur (le champ est + // `#[serde(default)]`) ; `webcam_rect` est alors None → fallback `preset_placements`. + let json = r##"{"clips":[],"layout":{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0},"background":{"kind":"color","color":"#000000"},"zoomRegions":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1920,"height":1080,"fps":null}}"##; + let s = Scene::from_json(json).expect("parse sans webcam_rect"); + assert!(s.layout.webcam_rect.is_none()); + assert_eq!(s.layout.preset, "picture-in-picture"); + } +} + +#[cfg(test)] +mod annotation_tests { + use super::*; + + /// Enveloppe minimale valide + les annotations passées en paramètre. + fn scene_json(annotations: &str) -> String { + format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#000000"}},"zoomRegions":[],"annotations":{annotations},"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + } + + #[test] + fn an_older_payload_without_annotations_still_parses() { + // `#[serde(default)]` : tout JSON produit avant ce champ doit continuer à charger. + let json = scene_json("[]").replace(r#""annotations":[],"#, ""); + let scene = Scene::from_json(&json).expect("parse sans annotations"); + assert!(scene.annotations.is_empty()); + } + + #[test] + fn parses_a_text_annotation_with_its_style() { + let json = scene_json( + r##"[{"id":"ann1","clipIndex":0,"startSec":1.0,"endSec":3.0,"kind":"text","x":0.25,"y":0.5,"w":0.4,"h":0.1,"zIndex":2,"text":{"content":"Bonjour","color":"#ffffff","backgroundColor":"transparent","fontSizeRel":0.0296,"fontFamily":"Inter","fontWeight":"bold","fontStyle":"normal","textDecoration":"none","textAlign":"center","animation":"fade"}}]"##, + ); + let scene = Scene::from_json(&json).expect("parse texte"); + let ann = &scene.annotations[0]; + assert_eq!(ann.kind, "text"); + assert_eq!(ann.clip_index, Some(0)); + assert_eq!(ann.z_index, 2); + assert!((ann.x - 0.25).abs() < 1e-6 && (ann.h - 0.1).abs() < 1e-6); + let text = ann.text.as_ref().expect("payload texte"); + assert_eq!(text.content, "Bonjour"); + assert_eq!(text.text_align, "center"); + assert_eq!(text.animation.as_deref(), Some("fade")); + assert!(ann.figure.is_none() && ann.blur.is_none()); + } + + #[test] + fn parses_figure_and_blur_payloads() { + let json = scene_json( + r##"[{"id":"f","startSec":0.0,"endSec":1.0,"kind":"figure","x":0.1,"y":0.1,"w":0.2,"h":0.2,"zIndex":0,"figure":{"direction":"up-left","color":"#34B27B","strokeWidth":6}}, + {"id":"b","startSec":0.0,"endSec":1.0,"kind":"blur","x":0.0,"y":0.0,"w":0.5,"h":0.5,"zIndex":1,"blur":{"style":"mosaic","shape":"freehand","color":"black","intensity":8,"blockSize":16,"freehandPoints":[{"x":0.1,"y":0.2},{"x":0.3,"y":0.4}]}}]"##, + ); + let scene = Scene::from_json(&json).expect("parse figure+blur"); + let figure = scene.annotations[0].figure.as_ref().expect("payload figure"); + assert_eq!(figure.direction, "up-left"); + assert!((figure.stroke_width - 6.0).abs() < 1e-6); + let blur = scene.annotations[1].blur.as_ref().expect("payload blur"); + assert_eq!(blur.shape, "freehand"); + let points = blur.freehand_points.as_ref().expect("points"); + assert_eq!(points.len(), 2); + assert!((points[1].x - 0.3).abs() < 1e-6); + } + + #[test] + fn for_clip_window_keeps_only_the_annotations_of_the_composed_clip() { + // Même règle que les zoom/speed/camera regions : bon clip ET recouvrement de la fenêtre. + let json = scene_json( + r##"[{"id":"keep","clipIndex":0,"startSec":1.0,"endSec":2.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}, + {"id":"other-clip","clipIndex":1,"startSec":1.0,"endSec":2.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}, + {"id":"out-of-window","clipIndex":0,"startSec":50.0,"endSec":51.0,"kind":"figure","x":0,"y":0,"w":0.1,"h":0.1,"zIndex":0}]"##, + ); + let scene = Scene::from_json(&json).expect("parse"); + let filtered = scene.for_clip_window(0, 0.0, 10.0); + assert_eq!( + filtered.annotations.iter().map(|a| a.id.as_str()).collect::>(), + vec!["keep"] + ); + } +} diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl new file mode 100644 index 0000000000..b9d438d7d7 --- /dev/null +++ b/crates/compositor/src/shaders.hlsl @@ -0,0 +1,541 @@ +// Compositeur — un draw par calque (quad). NV12->RGB maison (E1), coins arrondis SDF (E2). +// Tout écrit depuis les maths (§7), rien repris de l'ancien paradigme. + +cbuffer Layer : register(b0) +{ + float4 dst; // x,y,w,h dans l'espace sortie 0..1 (origine haut-gauche) + float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 + float2 quad_px; // taille du quad en pixels (pour les SDF) + float radius_px; // rayon des coins arrondis en px (0 = aucun) + float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, 4 = curseur + float4 color; // couleur pleine / teinte (ombre : rgb + opacité dans a) + float4 fx; // fx.x = spread ombre (px), fx.y,fx.z libres + float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) + float4 dst_prev; // dst à la frame précédente + float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) +}; + +struct VSOut +{ + float4 pos : SV_Position; + float2 uv : TEXCOORD0; // coords d'échantillonnage source + float2 local : TEXCOORD1; // coords pixel dans le quad (pour SDF) + float2 pout : TEXCOORD2; // position 0..1 sortie (pour la vélocité par pixel) +}; + +VSOut vs_main(uint vid : SV_VertexID) +{ + float2 c = float2(vid & 1, (vid >> 1) & 1); // strip: (0,0)(1,0)(0,1)(1,1) + float2 p = dst.xy + c * dst.zw; // 0..1 sortie + float2 ndc = float2(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0); + VSOut o; + o.pos = float4(ndc, 0.0, 1.0); + o.uv = src.xy + c * (src.zw - src.xy); + o.local = c * quad_px; + o.pout = p; + return o; +} + +Texture2D texY : register(t0); +Texture2D texUV : register(t1); +Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) +SamplerState samp : register(s0); + +// BT.709 limited -> RGB (§7 E1), matrice en dur, range mesuré en S1. +float3 yuv709_limited(float y, float2 cbcr) +{ + float Yf = (y * 255.0 - 16.0) / 219.0; + float Cb = (cbcr.x * 255.0 - 128.0) / 224.0; + float Cr = (cbcr.y * 255.0 - 128.0) / 224.0; + float3 rgb; + rgb.r = Yf + 1.5748 * Cr; + rgb.g = Yf - 0.1873 * Cb - 0.4681 * Cr; + rgb.b = Yf + 1.8556 * Cb; + return saturate(rgb); +} + +float3 sample_yuv(float2 uv) +{ + float y = texY.Sample(samp, uv); + float2 cbcr = texUV.Sample(samp, uv); + return yuv709_limited(y, cbcr); +} + +// SDF segment à bouts ronds — la primitive des flèches d'annotation, dont les tracés SVG sont +// trois segments `stroke-linecap="round"` (cf. ArrowSvgs.tsx). +float sd_segment(float2 p, float2 a, float2 b) +{ + float2 pa = p - a; + float2 ba = b - a; + float h = saturate(dot(pa, ba) / max(dot(ba, ba), 1e-6)); + return length(pa - ba * h); +} + +// SDF rectangle à coins arrondis (§7 E2) : <0 dedans. +float sd_round_rect(float2 p, float2 halfsz, float r) +{ + float2 q = abs(p) - halfsz + r; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +// Intersection de deux droites données par (normale, offset) : n·x = d. Cramer. +float2 line_cross(float2 n1, float d1, float2 n2, float d2) +{ + float det = n1.x * n2.y - n1.y * n2.x; + if (abs(det) < 1e-6) return float2(0.0, 0.0); // arêtes parallèles : quad dégénéré + return float2(d1 * n2.y - d2 * n1.y, d2 * n1.x - d1 * n2.x) / det; +} + +// Distance signée EXACTE à un quadrilatère convexe (<0 dedans). Le max des demi-plans suffit près +// des arêtes mais donne un coin en pointe ; ici on veut aussi la distance juste au coin, puisque +// c'est elle qui devient l'arrondi une fois le rayon retranché. +float sd_convex_quad(float2 p, float2 v0, float2 v1, float2 v2, float2 v3) +{ + float2 v[5] = { v0, v1, v2, v3, v0 }; + float inside = -1e9; + float border = 1e9; + [unroll] for (int k = 0; k < 4; k++) + { + float2 a = v[k]; + float2 e = v[k + 1] - a; + float2 n = float2(e.y, -e.x) / max(length(e), 1e-6); + inside = max(inside, dot(p - a, n)); + border = min(border, sd_segment(p, a, v[k + 1])); + } + return (inside < 0.0) ? -border : border; +} + +// (s, t, ok) du warp inverse du mode 8 pour une racine `t` donnée : `ok` = 1 quand le couple +// tombe dans le quad projeté (même marge 0.02 qu'ailleurs). Les deux racines doivent être +// essayées — trancher sur `t` seul retient parfois celle dont le `s` sort du quad, et le pixel +// est alors déclaré dehors alors que l'autre racine le plaçait dedans. +float3 quad_st_for_root(float t, float2 e, float2 f, float2 g, float2 h) +{ + float denomX = e.x + g.x * t; + float denomY = e.y + g.y * t; + float s = (abs(denomX) > abs(denomY)) ? (h.x - f.x * t) / denomX : (h.y - f.y * t) / denomY; + float ok = (s >= -0.02 && s <= 1.02 && t >= -0.02 && t <= 1.02) ? 1.0 : 0.0; + return float3(s, t, ok); +} + +// (s, t, ok) du point `P` dans le quad c00->c10->c11->c01 : le warp bilinéaire INVERSE, partagé +// par le mode 8 (écran incliné) et le mode 13 (curseur posé sur ce même écran). Les deux doivent +// résoudre exactement la même équation, sinon le curseur glisse par rapport au contenu — d'où +// une seule implémentation plutôt que deux copies. +float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11, float2 c01) +{ + float2 e = c10 - c00; + float2 f = c01 - c00; + float2 g = c00 - c10 - c01 + c11; + float2 h = P - c00; + float k2 = g.x * f.y - g.y * f.x; + float k1 = e.x * f.y - e.y * f.x + h.x * g.y - h.y * g.x; + float k0 = h.x * e.y - h.y * e.x; + // Seuil RELATIF. Les présets « left »/« right » sont une rotation Y pure : le quad + // projeté est un trapèze symétrique dont `f` et `g` sont tous deux verticaux, donc + // k2 = 0 EXACTEMENT — au bruit d'arrondi près, et ce bruit vaut quelques centièmes sur + // des produits en 10^6. Un seuil absolu de 0.001 le manquait : l'équation passait dans la + // branche quadratique avec k2 ≈ 0, où `(-k1 + sqrt(k1²)) / 2k2` ne renvoie que du bruit — + // soustraire deux nombres presque égaux, puis diviser par presque rien. La quasi-totalité + // du quad était rejetée, ce qui se voyait comme un écran incliné tranché net. + if (abs(k2) < 1e-5 * abs(k1)) + { + float t = (abs(k1) < 1e-6) ? 0.0 : -k0 / k1; + return quad_st_for_root(t, e, f, g, h); + } + float disc = k1 * k1 - 4.0 * k2 * k0; + if (disc < 0.0) return float3(0.0, 0.0, 0.0); + // Forme stable : `q` n'oppose jamais deux quantités voisines, et les deux racines + // s'en déduisent exactement. `sign()` est évité parce qu'il vaut 0 en 0, ce qui + // annulerait `q` là où la formule reste parfaitement définie. + float q = -0.5 * (k1 + (k1 >= 0.0 ? 1.0 : -1.0) * sqrt(disc)); + float3 r0 = quad_st_for_root(q / k2, e, f, g, h); + float3 r1 = quad_st_for_root(abs(q) > 0.0 ? k0 / q : q / k2, e, f, g, h); + return (r0.z > 0.5) ? r0 : r1; +} + +float4 ps_main(VSOut i) : SV_Target +{ + // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Même warp que le mode 8, mais + // échantillonnant la texture du curseur en alpha DROIT (comme le mode 7) au lieu de la + // vidéo NV12. Le curseur remplace un pointeur qui faisait partie de l'image capturée : il + // doit donc subir la même inclinaison qu'elle, sinon il se lit comme un autocollant plat + // collé par-dessus la scène. Corriger sa seule position ne suffisait pas. + // fx.xy/fx.zw = coins TL/TR (px locaux) ; src_prev.xy/.zw = BR/BL ; dst_prev = rect de clip + // « Clip to canvas » en espace sortie. + if (mode > 12.5) + { + if (i.pout.x < dst_prev.x || i.pout.x > dst_prev.x + dst_prev.z || + i.pout.y < dst_prev.y || i.pout.y > dst_prev.y + dst_prev.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); // hors du sprite projeté + } + float4 s = texImg.Sample(samp, saturate(float2(r.x, r.y))); + float a = s.a * color.a; + return float4(s.rgb * a, a); + } + + // mode 8 : écran tilté en 3D (zoom regions "rotation" : iso/left/right). `dst`/`quad_px` + // couvrent la BOUNDING BOX des 4 coins projetés (calculée côté CPU, `regions.rs`) ; ce + // shader retrouve où tombe chaque pixel DANS le quad tilté (warp bilinéaire inverse — pas + // de perspective-correct exact, mais indiscernable à l'œil pour un tilt de 10-22°) et + // échantillonne la vidéo à l'UV correspondant, sinon transparent (hors du quad projeté). + // fx.xy/fx.zw = coins TL/TR (px locaux, 0..quad_px) ; src_prev.xy/.zw = coins BR/BL. + // mode 11 : texte d'annotation, rastérisé par Direct2D (voir text.rs). D2D écrit sur une + // surface DXGI en alpha PRÉMULTIPLIÉ, donc contrairement au mode 7 (sprite curseur, alpha + // droit) il ne faut SURTOUT pas re-multiplier ici : les bords adoucis des glyphes + // deviendraient deux fois trop transparents et le texte paraîtrait délavé. + // `color.a` reste l'opacité globale (fondu d'animation). + // + // mode 12 : ombre du quad PROJETÉ. Même pénombre que le mode 2, mais portée par le + // quadrilatère incliné au lieu d'un rect droit — une ombre droite derrière un écran penché ne + // se lit pas comme son ombre, mais comme une seconde surface posée derrière. Les coins + // arrivent dans la même convention que le mode 8 (fx = TL/TR, src_prev = BR/BL, px locaux) ; + // mb.y = étalement de la pénombre en px. + if (mode > 11.5) + { + float2 quad[5] = { fx.xy, fx.zw, src_prev.xy, src_prev.zw, fx.xy }; + // Coins arrondis du même rayon que le plan (`radius_px`). Une ombre à coins vifs derrière + // un écran aux coins arrondis dépasse en pointe à chaque coin — visible, et d'autant plus + // que le rayon monte. On rentre donc chaque arête de `r`, et retrancher `r` à la distance + // du quadrilatère ainsi obtenu redonne un arrondi exactement tangent aux deux arêtes. + float r = max(radius_px, 0.0); + float2 v[4]; + [unroll] for (int k = 0; k < 4; k++) + { + // TL→TR→BR→BL tourne dans le sens horaire en y-bas, donc (e.y, -e.x) sort du quad. + // Division par la longueur plutôt que `normalize` : une arête dégénérée donnerait un + // NaN qui effacerait l'ombre entière. + float2 ep = quad[k] - quad[(k + 3) & 3]; // arête précédente + float2 ec = quad[k + 1] - quad[k]; // arête courante + float2 np = float2(ep.y, -ep.x) / max(length(ep), 1e-6); + float2 nc = float2(ec.y, -ec.x) / max(length(ec), 1e-6); + // Chaque arête rentrée de r : n·x = n·a - r. Leur intersection est le coin rentré. + v[k] = line_cross(np, dot(quad[(k + 3) & 3], np) - r, nc, dot(quad[k], nc) - r); + } + float d = sd_convex_quad(i.local, v[0], v[1], v[2], v[3]) - r; + float spread = max(mb.y, 1e-3); + float a = color.a * (1.0 - smoothstep(0.0, spread, d)); + return float4(color.rgb * a, a); + } + + if (mode > 10.5) + { + float4 s = texImg.Sample(samp, i.uv); + return s * color.a; + } + + // mode 10 : annotation « flou » — masque la zone en réutilisant l'image DÉJÀ composée, qui + // arrive dans `texImg` (recopie du render target : on ne peut pas échantillonner la cible sur + // laquelle on dessine). `i.pout` donne directement l'UV de sortie, donc aucun mapping à + // refaire. fx.x = 0 mosaïque / 1 flou ; fx.y = taille de bloc px (mosaïque) ou rayon px + // (flou) ; fx.z = 0 rectangle / 1 ovale ; fx.w = 1 si le masque doit être teinté. + if (mode > 9.5) + { + // Masque de forme, en coords locales normalisées du quad. + float2 n = i.local / max(quad_px, 1e-6); + float cov = 1.0; + if (fx.z > 0.5) + { + // Ovale inscrit : distance au centre en unités de demi-axes, adoucie sur ~1px. + float2 d = (n - 0.5) * 2.0; + float r = length(d); + float aa = 2.0 / max(min(quad_px.x, quad_px.y), 1.0); + cov = 1.0 - smoothstep(1.0 - aa, 1.0, r); + } + if (cov <= 0.0) return float4(0.0, 0.0, 0.0, 0.0); + + float3 rgb; + if (fx.x > 0.5) + { + // Flou : on échantillonne un niveau de mip de l'image composée. `log2(rayon)` donne + // le niveau dont un texel couvre à peu près le rayon demandé, et le filtrage + // trilinéaire lisse la transition entre deux niveaux quand le rayon varie. + // + // Un noyau de quelques taps espacés du rayon ne floute PAS : il superpose autant de + // copies décalées, ce qui se voit comme du texte fantôme. Atteindre un vrai lissage + // par taps demanderait un tap par pixel de rayon ; la pyramide de mips donne le même + // résultat à coût constant, et c'est le GPU qui l'a construite. + float lod = log2(max(fx.y, 1.0)); + rgb = texImg.SampleLevel(samp, i.pout, lod).rgb; + } + else + { + // Mosaïque : on quantifie l'UV sur une grille de `fx.y` px, alignée sur le quad pour + // que les blocs ne rampent pas quand l'annotation bouge. + float2 px_uv = dst.zw / max(quad_px, 1e-6); + float2 block = max(fx.y, 1.0) * px_uv; + float2 origin = dst.xy; + float2 q = origin + (floor((i.pout - origin) / block) + 0.5) * block; + // `SampleLevel(..., 0)` et non `Sample` : l'UV quantifié est une marche d'escalier, + // donc ses dérivées explosent en bord de bloc et le choix automatique de mip + // ramollirait justement les arêtes qui font la mosaïque. + rgb = texImg.SampleLevel(samp, q, 0.0).rgb; + } + + if (fx.w > 0.5) + { + // Teinte blanc/noir : la couleur choisie, mêlée à moitié, garde la forme lisible sans + // effacer complètement ce qu'il y a dessous. + rgb = lerp(rgb, color.rgb, 0.5); + } + float a = cov * color.a; + return float4(rgb * a, a); // prémultiplié + } + + // mode 9 : annotation « figure » — une flèche. Parité EXACTE avec `ArrowSvgs.tsx`, dont + // chaque direction est un tracé de trois segments à bouts ronds dans un viewBox 0..100 : + // une hampe et deux barbes. Trois `sd_segment` et un `min` reproduisent donc la forme telle + // quelle, pas une approximation. Les extrémités arrivent déjà converties en px locaux du + // quad (échelle uniforme centrée, comme le `preserveAspectRatio` par défaut du SVG). + // fx = hampe (a.xy, b.xy), src_prev = barbe 1, dst_prev = barbe 2 ; mb.y = demi-épaisseur px. + if (mode > 8.5) + { + float d = sd_segment(i.local, fx.xy, fx.zw); + d = min(d, sd_segment(i.local, src_prev.xy, src_prev.zw)); + d = min(d, sd_segment(i.local, dst_prev.xy, dst_prev.zw)); + // Couverture sur ~1 px : le trait reste net sans crénelage, et une flèche fine ne + // disparaît pas quand la demi-épaisseur descend sous le pixel. + float a = saturate(mb.y - d + 0.5) * color.a; + return float4(color.rgb * a, a); // prémultiplié, comme tous les autres modes + } + + if (mode > 7.5) + { + float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); // hors du quad projeté + } + float2 uv = float2(lerp(src.x, src.z, saturate(r.x)), lerp(src.y, src.w, saturate(r.y))); + // Coins arrondis DANS LE REPÈRE DU PLAN (`dst_prev.xy` = sa taille avant projection) : + // le rayon reste constant le long du bord, alors qu'un arrondi calculé dans la bbox + // s'étirerait avec la perspective. Sans cet arrondi, un écran penché a des arêtes de + // couteau qui coupent le contenu en pleine phrase, et ça se lit comme une troncature + // plutôt que comme une inclinaison. + // + // Inconditionnel, rayon 0 COMPRIS : `sd_round_rect` dégénère alors en SDF de rectangle et + // le feather de 1.5 px subsiste, ce qui est précisément ce qui fait lire une arête inclinée + // comme une arête. Sous l'ancienne garde `radius_px > 0`, un slider Roundness à 0 laissait + // la couverture du plan au seul test binaire `r.z < 0.5` ci-dessus : des marches d'escalier + // en escalier franc, soit la troncature même que cette branche existe pour éviter (d'où le + // symptôme « le tilt 3D est tronqué, mais pas au-dessus d'un certain arrondi »). L'ombre du + // mode 12 applique déjà son `max(radius_px, 0.0)` sans garde, pour la même raison. + float2 plane_px = dst_prev.xy; + float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; + float d = sd_round_rect(p, plane_px * 0.5, max(radius_px, 0.0)); + float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); + return float4(sample_yuv(uv) * tilt_a, tilt_a); // prémultiplié, comme les autres modes + } + + // mode 7 : sprite curseur thème (PNG alpha droite, arrow.png etc.). Prémultiplie ici + // (le blend state attend du prémultiplié partout ailleurs). fx = rect de clip "Clip to + // canvas" en espace sortie 0..1 [x,y,w,h] (= s_dst quand actif, sinon un rect englobant + // tout -> aucun effet). + if (mode > 6.5) + { + if (i.pout.x < fx.x || i.pout.x > fx.x + fx.z || i.pout.y < fx.y || i.pout.y > fx.y + fx.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float4 s = texImg.Sample(samp, i.uv); + float a = s.a * color.a; // color.a = opacité globale (fade éventuel) + return float4(s.rgb * a, a); + } + + // mode 6 : wallpaper image RGBA (cover-fit). src = rect uv déjà calculé (crop de + // recouvrement), i.uv l'interpole. Opaque. + if (mode > 5.5) + { + return float4(texImg.Sample(samp, i.uv).rgb, 1.0); + } + + // mode 5 : gradient linéaire 2 stops (parité web wallpaper dégradé). color = stop0, + // src.xyz = stop1, fx.xy = direction unitaire (espace sortie, y vers le bas). t est + // normalisé coin-à-coin (dénominateur = |dx|+|dy|) pour couvrir toute la diagonale. + if (mode > 4.5) + { + float2 dir = fx.xy; + float denom = max(abs(dir.x) + abs(dir.y), 1e-4); + float t = saturate(0.5 + dot(i.pout - 0.5, dir) / denom); + float3 g = lerp(color.rgb, src.xyz, t); + return float4(g, 1.0); // opaque, prémultiplié (a=1) + } + + // mode 4 : curseur custom (dot + ring, dessiné depuis les maths). color = teinte. + // fx = rect de clip "Clip to canvas" (mêmes conventions que le mode 7 ci-dessus). + if (mode > 3.5) + { + if (i.pout.x < fx.x || i.pout.x > fx.x + fx.z || i.pout.y < fx.y || i.pout.y > fx.y + fx.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float2 p = i.local - quad_px * 0.5; + float r = length(p); + float R = quad_px.x * 0.5; + float aa = 1.5; + float dot_r = R * 0.34; + float ring_r = R * 0.72; + float ring_w = R * 0.09; + float dot = 1.0 - smoothstep(dot_r - aa, dot_r + aa, r); + float ring = smoothstep(ring_r - ring_w - aa, ring_r - ring_w, r) + * (1.0 - smoothstep(ring_r + ring_w, ring_r + ring_w + aa, r)); + // liseré sombre fin sous le dot pour le contraste sur fond clair + float halo = (1.0 - smoothstep(dot_r + aa, dot_r + aa + 2.5, r)) * (1.0 - dot); + float a = saturate(dot + ring) * color.a; + float3 rgb = color.rgb * (dot + ring) + float3(0, 0, 0) * halo; + a = saturate(a + halo * 0.35 * color.a); + return float4(rgb * a, a); + } + + // mode 2 : ombre portée (§7 E4). Pénombre douce dérivée de la SDF du quad source, + // qui est inséré à l'intérieur du quad d'ombre (élargi de `spread` de chaque côté). + if (mode > 1.5) + { + // `quad_px`/`spread` sont en px de SORTIE : le render target porte la géométrie de + // sortie, donc aucune pré-déformation n'est nécessaire. (Historiquement le canvas + // était figé en 16:9 et étiré en fin de pipeline, d'où un facteur anisotrope transporté + // dans `mb.yz` que ce shader devait annuler — le halo ressortait elliptique sans lui.) + float spread = fx.x; + float2 halfsz = quad_px * 0.5 - spread; + float2 p = i.local - quad_px * 0.5; + float d = sd_round_rect(p, halfsz, radius_px); + float a = color.a * (1.0 - smoothstep(0.0, spread, d)); + return float4(color.rgb * a, a); + } + + float3 rgb; + if (mode < 0.5) + { + // flou de mouvement par vélocité (§8) : pour CE pixel sortie, uv à la frame + // précédente = même pixel remappé par (dst_prev, src_prev). On floute le long + // de uv_prev->uv_now (capture translation ET zoom). Early-out si immobile. + float2 uv_now = i.uv; + float2 localp = (i.pout - dst_prev.xy) / dst_prev.zw; + float2 uv_prev = src_prev.xy + localp * (src_prev.zw - src_prev.xy); + float2 duv = uv_now - uv_prev; + int taps = (int) mb.x; + if (taps <= 1 || dot(duv, duv) < 1e-9) + { + rgb = sample_yuv(uv_now); + } + else + { + float3 acc = 0.0; + [loop] for (int k = 0; k < 16; k++) + { + if (k >= taps) break; + float t = (float) k / (float) (taps - 1); + acc += sample_yuv(uv_prev + duv * t); + } + rgb = acc / (float) taps; + } + } + else + { + rgb = color.rgb; + } + + float alpha = color.a; + if (radius_px > 0.0) + { + // `quad_px` est en px de SORTIE (le render target porte la géométrie de sortie) et + // `radius_px` est un rayon réel en px de sortie : la SDF isotrope les compare dans le + // même espace, le coin est donc rond par construction. (Avant, le canvas figé en 16:9 + // était étiré en fin de pipeline et il fallait pré-déformer par `mb.yz` pour que le + // cercle ne ressorte pas elliptique.) + float2 halfsz = quad_px * 0.5; + float2 p = i.local - quad_px * 0.5; + float d = sd_round_rect(p, halfsz, radius_px); + alpha *= 1.0 - smoothstep(0.0, 1.5, d); // ~1.5px feather (§7 fwidth-like) + } + return float4(rgb * alpha, alpha); // prémultiplié +} + +// ============ RGB -> NV12 (§5) : deux passes vers les plans d'une texture NV12 ============ +// VS plein écran (triangle unique) qui expose l'UV. +struct FSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; }; +FSOut vs_fs(uint vid : SV_VertexID) +{ + FSOut o; + o.uv = float2((vid << 1) & 2, vid & 2); + o.pos = float4(o.uv * float2(2, -2) + float2(-1, 1), 0, 1); + return o; +} + +Texture2D rgbTex : register(t0); +SamplerState sampNV : register(s0); + +// BT.709 limited, RGB(0..1) -> Y' et Cb,Cr (inverse de yuv709_limited). +float rgb2y(float3 c) { return (16.0 + 219.0 * (0.2126*c.r + 0.7152*c.g + 0.0722*c.b)) / 255.0; } +float2 rgb2uv(float3 c) +{ + float yp = 0.2126*c.r + 0.7152*c.g + 0.0722*c.b; + float cb = (c.b - yp) / 1.8556; + float cr = (c.r - yp) / 1.5748; + return (128.0 + 224.0 * float2(cb, cr)) / 255.0; +} + +float ps_y(FSOut i) : SV_Target // plan Y (R8), pleine résolution +{ + return rgb2y(rgbTex.Sample(sampNV, i.uv).rgb); +} +float2 ps_uv(FSOut i) : SV_Target // plan UV (R8G8), demi-résolution (bilinéaire moyenne) +{ + return rgb2uv(rgbTex.Sample(sampNV, i.uv).rgb); +} + +// ============ Flou gaussien séparable (§7 E3) ============ +// fx.x = sigma (px), fx.y = pas de texel (1/dim), fx.zw = direction (1,0)|(0,1). +#define BLUR_R 24 +float4 ps_blur(FSOut i) : SV_Target +{ + float sigma = max(fx.x, 0.001); + float2 step = fx.y * fx.zw; + float4 acc = 0.0; + float wsum = 0.0; + [unroll] + for (int k = -BLUR_R; k <= BLUR_R; k++) + { + float w = exp(-0.5 * (k * k) / (sigma * sigma)); + acc += rgbTex.Sample(sampNV, i.uv + k * step) * w; + wsum += w; + } + return acc / wsum; +} +// simple copie/échantillonnage d'une texture RGBA (pour redessiner le fond flouté) +float4 ps_tex(FSOut i) : SV_Target { return rgbTex.Sample(sampNV, i.uv); } + +// ============ Dual-Kawase (fond flouté rapide) ============ +// fx.xy = texel de la texture SOURCE (1/w, 1/h), fx.z = offset. 5 taps (down) / 8 taps (up), +// bilinéaires, à résolution décroissante -> bien moins de samples qu'un gaussien large. +float4 ps_kawase_down(FSOut i) : SV_Target +{ + float2 hp = fx.xy * 0.5 * fx.z; + float2 uv = i.uv; + float4 s = rgbTex.Sample(sampNV, uv) * 4.0; + s += rgbTex.Sample(sampNV, uv - hp); + s += rgbTex.Sample(sampNV, uv + hp); + s += rgbTex.Sample(sampNV, uv + float2(hp.x, -hp.y)); + s += rgbTex.Sample(sampNV, uv - float2(hp.x, -hp.y)); + return s / 8.0; +} +float4 ps_kawase_up(FSOut i) : SV_Target +{ + float2 hp = fx.xy * 0.5 * fx.z; + float2 uv = i.uv; + float4 s = rgbTex.Sample(sampNV, uv + float2(-hp.x * 2.0, 0.0)); + s += rgbTex.Sample(sampNV, uv + float2(-hp.x, hp.y)) * 2.0; + s += rgbTex.Sample(sampNV, uv + float2(0.0, hp.y * 2.0)); + s += rgbTex.Sample(sampNV, uv + float2(hp.x, hp.y)) * 2.0; + s += rgbTex.Sample(sampNV, uv + float2(hp.x * 2.0, 0.0)); + s += rgbTex.Sample(sampNV, uv + float2(hp.x, -hp.y)) * 2.0; + s += rgbTex.Sample(sampNV, uv + float2(0.0, -hp.y * 2.0)); + s += rgbTex.Sample(sampNV, uv + float2(-hp.x, -hp.y)) * 2.0; + return s / 12.0; +} diff --git a/crates/compositor/src/text.rs b/crates/compositor/src/text.rs new file mode 100644 index 0000000000..551990c4ca --- /dev/null +++ b/crates/compositor/src/text.rs @@ -0,0 +1,290 @@ +//! Rastérisation du texte des annotations, via DirectWrite (mise en page) et Direct2D (dessin) +//! sur une surface DXGI partagée avec D3D11. +//! +//! Pourquoi DirectWrite et pas un rasterizer de glyphes maison : l'app expédie en 13 langues, +//! dont l'arabe, et le texte d'une annotation est saisi librement. Le façonnage (ligatures, +//! bidirectionnel, CJK, sélection de police de repli) est un travail que DirectWrite fait +//! correctement et qu'on ne réécrira pas. Le device D3D11 est déjà créé avec +//! `D3D11_CREATE_DEVICE_BGRA_SUPPORT` (cf. `d3d.rs`, dont le commentaire anticipait cet usage), +//! donc l'interop ne coûte aucun changement de pipeline. +//! +//! Pourquoi ça ne coûte rien par frame : le texte d'une annotation est un contenu STATIQUE. On le +//! rastérise une fois dans une texture, et la boucle de rendu ne fait plus qu'un quad texturé de +//! plus. Le travail cher — façonnage, mise en page, rendu des glyphes — sort du chemin chaud, et +//! le cache est invalidé sur le contenu, le style et la taille de boîte, JAMAIS sur la +//! transformation : déplacer, redimensionner ou animer une annotation n'est pas une raison de +//! re-rastériser (c'est l'affaire du vertex shader). + +use anyhow::{bail, Result}; +use windows::core::Interface; +use windows::Win32::Graphics::Direct2D::Common::{ + D2D1_ALPHA_MODE_PREMULTIPLIED, D2D1_COLOR_F, D2D1_PIXEL_FORMAT, D2D_POINT_2F, D2D_RECT_F, +}; +use windows::Win32::Graphics::Direct2D::{ + D2D1CreateFactory, ID2D1Factory, D2D1_DRAW_TEXT_OPTIONS_NONE, D2D1_FACTORY_TYPE_SINGLE_THREADED, + D2D1_FEATURE_LEVEL_DEFAULT, D2D1_RENDER_TARGET_PROPERTIES, D2D1_RENDER_TARGET_TYPE_DEFAULT, + D2D1_RENDER_TARGET_USAGE_NONE, +}; +use windows::Win32::Graphics::Direct3D11::{ + ID3D11Device, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, + D3D11_BIND_SHADER_RESOURCE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, +}; +use windows::Win32::Graphics::DirectWrite::{ + DWriteCreateFactory, IDWriteFactory, DWRITE_FACTORY_TYPE_SHARED, + DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_ITALIC, DWRITE_FONT_STYLE_NORMAL, + DWRITE_FONT_WEIGHT_BOLD, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_PARAGRAPH_ALIGNMENT_CENTER, + DWRITE_TEXT_ALIGNMENT_CENTER, DWRITE_TEXT_ALIGNMENT_LEADING, DWRITE_TEXT_ALIGNMENT_TRAILING, + DWRITE_TEXT_METRICS, DWRITE_TEXT_RANGE, +}; +use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_B8G8R8A8_UNORM; +use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC; +use windows::Win32::Graphics::Dxgi::IDXGISurface; + +/// Tout ce dont le rendu d'un texte dépend. Sert aussi de clé de cache : deux specs égales +/// donnent la même texture, donc `cache_key` couvre exactement ces champs. +#[derive(Clone, PartialEq)] +pub struct TextSpec { + pub content: String, + /// RGBA 0..1 (déjà parsé depuis la chaîne CSS côté appelant). + pub color: [f32; 4], + /// RGBA 0..1 ; alpha 0 = pas de fond (le CSS `transparent`). + pub background: [f32; 4], + pub font_size_px: f32, + pub font_family: String, + pub bold: bool, + pub italic: bool, + pub underline: bool, + /// "left" | "center" | "right". + pub align: String, + /// Taille de la boîte en px de sortie — la mise en page en dépend (retours à la ligne). + pub box_px: [u32; 2], +} + +impl TextSpec { + /// FNV-1a sur les champs. Utilisé pour décider s'il faut re-rastériser ; volontairement + /// insensible à tout ce qui n'affecte pas les pixels (position, opacité d'animation…). + pub fn cache_key(&self) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |bytes: &[u8]| { + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + }; + mix(self.content.as_bytes()); + mix(self.font_family.as_bytes()); + mix(&self.font_size_px.to_bits().to_le_bytes()); + for c in self.color.iter().chain(self.background.iter()) { + mix(&c.to_bits().to_le_bytes()); + } + mix(&[self.bold as u8, self.italic as u8, self.underline as u8]); + mix(self.align.as_bytes()); + mix(&self.box_px[0].to_le_bytes()); + mix(&self.box_px[1].to_le_bytes()); + h + } +} + +/// Chaîne UTF-16 terminée par un zéro, pour les API Win32 qui prennent un `PCWSTR`. +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +pub struct TextRasterizer { + d2d: ID2D1Factory, + dwrite: IDWriteFactory, +} + +impl TextRasterizer { + pub fn new() -> Result { + unsafe { + // SINGLE_THREADED : tout le rendu du compositeur vit sur un seul thread, et le mode + // multithread ajoute un verrou par appel pour rien. + let d2d: ID2D1Factory = + D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, None)?; + let dwrite: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?; + Ok(TextRasterizer { d2d, dwrite }) + } + } + + /// Rastérise `spec` dans une texture neuve et rend sa SRV. Le fond éventuel est dessiné + /// derrière le texte, ajusté aux métriques de la mise en page — comme le CSS, où + /// `backgroundColor` est porté par le `` et épouse donc le texte, pas la boîte. + pub unsafe fn rasterize( + &self, + dev: &ID3D11Device, + spec: &TextSpec, + ) -> Result { + let (w, h) = (spec.box_px[0].max(1), spec.box_px[1].max(1)); + if spec.content.is_empty() { + bail!("texte vide"); + } + + // B8G8R8A8 : le format qu'exige une cible de rendu D2D. DXGI expose déjà les composantes + // dans l'ordre RGBA au shader, donc rien à ré-échanger côté HLSL. + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut tex: Option = None; + dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + let tex = tex.unwrap(); + + let surface: IDXGISurface = tex.cast()?; + let props = D2D1_RENDER_TARGET_PROPERTIES { + r#type: D2D1_RENDER_TARGET_TYPE_DEFAULT, + pixelFormat: D2D1_PIXEL_FORMAT { + format: DXGI_FORMAT_B8G8R8A8_UNORM, + // PREMULTIPLIED : ce que D2D produit sur une surface DXGI. Le shader ne doit donc + // PAS re-multiplier par l'alpha (cf. mode 11 dans shaders.hlsl). + alphaMode: D2D1_ALPHA_MODE_PREMULTIPLIED, + }, + // 96 dpi = 1 unité D2D pour 1 pixel : la mise en page se fait donc directement en + // pixels de sortie, ce qui rend `font_size_px` littéral. + dpiX: 96.0, + dpiY: 96.0, + usage: D2D1_RENDER_TARGET_USAGE_NONE, + minLevel: D2D1_FEATURE_LEVEL_DEFAULT, + }; + let rt = self.d2d.CreateDxgiSurfaceRenderTarget(&surface, &props)?; + + let family = wide(&spec.font_family); + let locale = wide(""); + let format = self.dwrite.CreateTextFormat( + windows::core::PCWSTR(family.as_ptr()), + None, + if spec.bold { DWRITE_FONT_WEIGHT_BOLD } else { DWRITE_FONT_WEIGHT_NORMAL }, + if spec.italic { DWRITE_FONT_STYLE_ITALIC } else { DWRITE_FONT_STYLE_NORMAL }, + DWRITE_FONT_STRETCH_NORMAL, + spec.font_size_px.max(1.0), + windows::core::PCWSTR(locale.as_ptr()), + )?; + format.SetTextAlignment(match spec.align.as_str() { + "left" => DWRITE_TEXT_ALIGNMENT_LEADING, + "right" => DWRITE_TEXT_ALIGNMENT_TRAILING, + _ => DWRITE_TEXT_ALIGNMENT_CENTER, + })?; + // Centrage vertical : l'overlay web met `alignItems: center` sur le conteneur. + format.SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER)?; + + let text: Vec = spec.content.encode_utf16().collect(); + let layout = + self.dwrite.CreateTextLayout(&text, &format, w as f32, h as f32)?; + if spec.underline { + layout.SetUnderline( + true, + DWRITE_TEXT_RANGE { startPosition: 0, length: text.len() as u32 }, + )?; + } + + let color = D2D1_COLOR_F { + r: spec.color[0], + g: spec.color[1], + b: spec.color[2], + a: spec.color[3], + }; + let brush = rt.CreateSolidColorBrush(&color, None)?; + + rt.BeginDraw(); + rt.Clear(Some(&D2D1_COLOR_F { r: 0.0, g: 0.0, b: 0.0, a: 0.0 })); + if spec.background[3] > 0.0 { + let mut m = DWRITE_TEXT_METRICS::default(); + layout.GetMetrics(&mut m)?; + let bg = D2D1_COLOR_F { + r: spec.background[0], + g: spec.background[1], + b: spec.background[2], + a: spec.background[3], + }; + let bg_brush = rt.CreateSolidColorBrush(&bg, None)?; + rt.FillRectangle( + &D2D_RECT_F { + left: m.left, + top: m.top, + right: m.left + m.width, + bottom: m.top + m.height, + }, + &bg_brush, + ); + } + rt.DrawTextLayout( + D2D_POINT_2F { x: 0.0, y: 0.0 }, + &layout, + &brush, + D2D1_DRAW_TEXT_OPTIONS_NONE, + ); + // `EndDraw` rapporte une perte de device par son HRESULT plutôt qu'en échouant tout de + // suite : on le propage pour que l'appelant sache que la texture n'est pas exploitable. + rt.EndDraw(None, None)?; + + let mut srv: Option = None; + dev.CreateShaderResourceView(&tex, None, Some(&mut srv))?; + Ok(srv.unwrap()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(content: &str) -> TextSpec { + TextSpec { + content: content.into(), + color: [1.0, 1.0, 1.0, 1.0], + background: [0.0, 0.0, 0.0, 0.0], + font_size_px: 32.0, + font_family: "Inter".into(), + bold: true, + italic: false, + underline: false, + align: "center".into(), + box_px: [400, 120], + } + } + + #[test] + fn identical_specs_share_a_cache_key() { + assert_eq!(spec("Bonjour").cache_key(), spec("Bonjour").cache_key()); + } + + #[test] + fn the_key_changes_with_anything_that_changes_the_pixels() { + let base = spec("Bonjour").cache_key(); + let mut other = spec("Bonsoir"); + assert_ne!(other.cache_key(), base, "contenu"); + other = spec("Bonjour"); + other.font_size_px = 33.0; + assert_ne!(other.cache_key(), base, "taille"); + other = spec("Bonjour"); + other.italic = true; + assert_ne!(other.cache_key(), base, "style"); + other = spec("Bonjour"); + other.color = [1.0, 0.0, 0.0, 1.0]; + assert_ne!(other.cache_key(), base, "couleur"); + other = spec("Bonjour"); + other.align = "left".into(); + assert_ne!(other.cache_key(), base, "alignement"); + other = spec("Bonjour"); + // La taille de boîte compte : elle décide des retours à la ligne, donc des pixels. + other.box_px = [401, 120]; + assert_ne!(other.cache_key(), base, "boîte"); + } + + #[test] + fn the_key_is_stable_across_unicode_content() { + // Le façonnage est délégué à DirectWrite ; la clé ne doit pas pour autant se briser sur + // du non-ASCII (l'app expédie en 13 langues). + for text in ["مرحبا", "こんにちは", "Grüße", "Здравствуйте"] { + assert_eq!(spec(text).cache_key(), spec(text).cache_key()); + } + assert_ne!(spec("مرحبا").cache_key(), spec("こんにちは").cache_key()); + } +} diff --git a/crates/compositor/src/text_anim.rs b/crates/compositor/src/text_anim.rs new file mode 100644 index 0000000000..6a30bd0a00 --- /dev/null +++ b/crates/compositor/src/text_anim.rs @@ -0,0 +1,164 @@ +//! Animations d'apparition du texte d'annotation. +//! +//! Port VERBATIM de `src/lib/annotationTextAnimation.ts` : même durée, mêmes easings, mêmes +//! amplitudes. Les sept animations étaient déjà nommées dans le schéma, traduites dans les treize +//! langues et transportées jusqu'ici par la scène — mais rien ne les jouait. Reprendre les +//! constantes du TS plutôt que d'en réinventer garantit qu'un projet fait à l'époque de l'aperçu +//! DOM s'anime toujours pareil. + +/// Les décalages ci-dessous sont exprimés en px À CETTE HAUTEUR : l'appelant les met à l'échelle +/// de la sortie, exactement comme la taille de police (cf. `annotationScale.ts`). En pixels +/// absolus, la même animation sauterait de deux fois plus haut dans un rendu 4K que dans l'aperçu. +pub const ANIMATION_REFERENCE_HEIGHT: f32 = 1080.0; + +pub const TEXT_ANIMATION_DURATION_MS: f32 = 700.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TextAnimationState { + pub opacity: f32, + pub scale: f32, + pub translate_x: f32, + pub translate_y: f32, + /// Fraction du bloc révélée depuis la gauche (machine à écrire). 1 = tout visible. + pub reveal: f32, +} + +impl TextAnimationState { + /// L'état « rien à animer » — aussi celui d'une animation inconnue ou absente. + pub const IDLE: TextAnimationState = + TextAnimationState { opacity: 1.0, scale: 1.0, translate_x: 0.0, translate_y: 0.0, reveal: 1.0 }; +} + +fn clamp01(v: f32) -> f32 { + v.clamp(0.0, 1.0) +} + +fn ease_out_cubic(v: f32) -> f32 { + let t = clamp01(v); + 1.0 - (1.0 - t).powi(3) +} + +fn ease_out_back(v: f32) -> f32 { + let t = clamp01(v); + const C1: f32 = 1.70158; + const C3: f32 = C1 + 1.0; + 1.0 + C3 * (t - 1.0).powi(3) + C1 * (t - 1.0).powi(2) +} + +/// État de l'animation `animation` après `elapsed_ms` depuis le début de l'annotation. +pub fn text_animation_state(animation: Option<&str>, elapsed_ms: f32) -> TextAnimationState { + let name = animation.unwrap_or("none"); + if name == "none" { + return TextAnimationState::IDLE; + } + let progress = clamp01(elapsed_ms.max(0.0) / TEXT_ANIMATION_DURATION_MS); + let eased = ease_out_cubic(progress); + match name { + "fade" => TextAnimationState { opacity: eased, ..TextAnimationState::IDLE }, + "rise" => TextAnimationState { + opacity: eased, + translate_y: (1.0 - eased) * 18.0, + ..TextAnimationState::IDLE + }, + "pop" => TextAnimationState { + opacity: eased, + scale: ease_out_back(progress).max(0.72), + ..TextAnimationState::IDLE + }, + "slide-left" => TextAnimationState { + opacity: eased, + translate_x: (1.0 - eased) * -28.0, + ..TextAnimationState::IDLE + }, + "typewriter" => TextAnimationState { reveal: progress, ..TextAnimationState::IDLE }, + "pulse" => TextAnimationState { + scale: 1.0 + (progress * std::f32::consts::PI).sin() * 0.06, + ..TextAnimationState::IDLE + }, + // Un nom inconnu (projet plus récent que ce binaire) montre le texte tel quel plutôt que + // de le faire disparaître. + _ => TextAnimationState::IDLE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_animation_shows_the_text_as_is() { + for at in [0.0, 350.0, 5000.0] { + assert_eq!(text_animation_state(None, at), TextAnimationState::IDLE); + assert_eq!(text_animation_state(Some("none"), at), TextAnimationState::IDLE); + } + } + + #[test] + fn an_unknown_name_shows_the_text_rather_than_hiding_it() { + // Le pire comportement serait une opacité 0 : l'annotation disparaîtrait sans explication. + assert_eq!(text_animation_state(Some("kenburns"), 0.0), TextAnimationState::IDLE); + } + + #[test] + fn every_animation_settles_on_the_plain_text() { + // Propriété qui compte le plus : passée la durée, aucune animation ne laisse de trace. + for name in ["fade", "rise", "pop", "slide-left", "typewriter", "pulse"] { + let end = text_animation_state(Some(name), TEXT_ANIMATION_DURATION_MS + 1.0); + assert!((end.opacity - 1.0).abs() < 1e-3, "{name} : opacité {}", end.opacity); + assert!((end.scale - 1.0).abs() < 1e-3, "{name} : échelle {}", end.scale); + assert!(end.translate_x.abs() < 1e-3 && end.translate_y.abs() < 1e-3, "{name} : décalé"); + assert!((end.reveal - 1.0).abs() < 1e-3, "{name} : révélation {}", end.reveal); + } + } + + #[test] + fn fade_and_rise_start_invisible_and_below() { + let fade = text_animation_state(Some("fade"), 0.0); + assert_eq!(fade.opacity, 0.0); + let rise = text_animation_state(Some("rise"), 0.0); + assert_eq!(rise.opacity, 0.0); + assert!((rise.translate_y - 18.0).abs() < 1e-3, "part de 18px plus bas"); + } + + #[test] + fn slide_left_enters_from_the_right() { + // Signe négatif = le texte commence décalé vers la gauche et revient, comme le TS. + let s = text_animation_state(Some("slide-left"), 0.0); + assert!((s.translate_x + 28.0).abs() < 1e-3, "translate_x = {}", s.translate_x); + } + + #[test] + fn pop_overshoots_then_comes_back() { + // easeOutBack dépasse 1 avant de retomber : c'est ce qui donne le « pop ». + let mid = (0..=100) + .map(|i| text_animation_state(Some("pop"), i as f32 * 7.0).scale) + .fold(0.0f32, f32::max); + assert!(mid > 1.0, "aucun dépassement : échelle max {mid}"); + assert!(text_animation_state(Some("pop"), 0.0).scale >= 0.72, "plancher du TS respecté"); + } + + #[test] + fn pulse_swells_in_the_middle_and_never_moves() { + let mid = text_animation_state(Some("pulse"), TEXT_ANIMATION_DURATION_MS * 0.5); + assert!((mid.scale - 1.06).abs() < 1e-3, "échelle {}", mid.scale); + assert_eq!(mid.opacity, 1.0); + assert_eq!(mid.translate_x, 0.0); + } + + #[test] + fn typewriter_reveals_linearly_and_only_from_the_left() { + for (at, expected) in [(0.0, 0.0), (175.0, 0.25), (350.0, 0.5), (700.0, 1.0)] { + let s = text_animation_state(Some("typewriter"), at); + assert!((s.reveal - expected).abs() < 1e-3, "à {at}ms : {}", s.reveal); + // Le texte reste opaque : c'est la largeur qui se dévoile, pas l'alpha (l'aperçu DOM + // faisait exactement ça avec un `inset()`). + assert_eq!(s.opacity, 1.0); + } + } + + #[test] + fn a_negative_elapsed_time_is_the_start_not_the_end() { + // Peut arriver d'une frame calculée juste avant le début de l'annotation. + assert_eq!(text_animation_state(Some("fade"), -50.0).opacity, 0.0); + } +} diff --git a/crates/compositor/tests/output_geometry_golden.rs b/crates/compositor/tests/output_geometry_golden.rs new file mode 100644 index 0000000000..fca407d0df --- /dev/null +++ b/crates/compositor/tests/output_geometry_golden.rs @@ -0,0 +1,311 @@ +//! Golden pixel de la refonte « le RT est le cadre de sortie ». +//! +//! Le filet unitaire de `compositor::tests` verrouille la GÉOMÉTRIE (un carré +//! atterrit carré, un calque centré reste centré). Il ne peut rien dire des +//! PIXELS : or le contrat le plus fort de la refonte est « le 16:9 ne doit pas +//! bouger d'un pixel ». D'où ce golden, qui rend de vraies frames. +//! +//! Il est **piloté par l'environnement** et se saute proprement quand les +//! sources manquent — pas de fixture vidéo dans le dépôt, et la machine de CI +//! n'a pas forcément de GPU D3D11 : +//! +//! ```powershell +//! $env:OPENSCREEN_GOLDEN_SCREEN = "...\recording-.mp4" +//! $env:OPENSCREEN_GOLDEN_WEBCAM = "...\recording--webcam.webm" +//! cargo test --test output_geometry_golden -- --nocapture +//! ``` +//! +//! Mode d'emploi de la refonte : lancer AVANT la phase 1, garder la sortie, +//! relancer APRÈS, comparer. +//! - le hash du 16:9 doit être **identique** (c'est le contrat 4 : en 16:9 la +//! compensation est déjà l'identité, donc rien ne doit changer) ; +//! - le hash des autres formats CHANGE — c'est le but ; +//! - `grad_y` (énergie de gradient vertical) doit **monter** sur les formats +//! portrait : c'est la mesure du détail regagné, aujourd'hui perdu parce +//! que le canvas plafonne à 1080 lignes et que `blit_resized` agrandit. + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; +use openscreen_compositor::config; + +/// Instant fixe dans la source. Un seek explicite (`present_frame`) plutôt que +/// la lecture libre : le golden doit être reproductible à l'octet près. +const AT_SEC: f64 = 2.0; + +/// Mêmes formats que le filet unitaire, pour que les deux racontent la même +/// histoire. `native *` rappelle que « native » n'est borné par aucune liste. +const FORMATS: &[(&str, u32, u32)] = &[ + ("16-9", 1920, 1080), + ("9-16", 1080, 1920), + ("1-1", 1920, 1920), + ("4-5", 1536, 1920), + ("native-ultrawide", 3440, 1440), + ("4k-16-9", 3840, 2160), +]; + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// Énergie de gradient moyenne par axe (|Δluma| entre pixels adjacents). +/// Un agrandissement lisse les transitions et fait donc CHUTER cette valeur sur +/// l'axe agrandi — c'est exactement la perte que la phase 1 doit récupérer. +fn gradient_energy(rgba: &[u8], w: usize, h: usize) -> (f64, f64) { + let lum = |i: usize| { + 0.299 * rgba[i * 4] as f64 + 0.587 * rgba[i * 4 + 1] as f64 + 0.114 * rgba[i * 4 + 2] as f64 + }; + let (mut gx, mut gy) = (0.0, 0.0); + let (mut nx, mut ny) = (0usize, 0usize); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if x + 1 < w { + gx += (lum(i + 1) - lum(i)).abs(); + nx += 1; + } + if y + 1 < h { + gy += (lum(i + w) - lum(i)).abs(); + ny += 1; + } + } + } + (gx / nx.max(1) as f64, gy / ny.max(1) as f64) +} + +/// PPM P6 — pas de dépendance à encoder, et ça s'ouvre dans n'importe quel +/// visionneur. Permet l'inspection à l'œil en plus de la comparaison de hash. +fn write_ppm(path: &std::path::Path, rgba: &[u8], w: u32, h: u32) -> std::io::Result<()> { + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out) +} + +fn scene_json(screen: &str, webcam: &str, w: u32, h: u32) -> String { + // Chemins en slashes : le JSON n'échappe pas les backslashes Windows. + let (s, c) = (screen.replace('\\', "/"), webcam.replace('\\', "/")); + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"{c}","sourceStartSec":0,"sourceEndSec":30,"webcamOffsetSec":0,"hasAudio":true}}], + "layout": {{"preset":"picture-in-picture","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects": {{"padding":0.1,"blur":true,"shadow":1.0,"roundnessPx":24,"motionBlur":0.0}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#eaebed","#bcc0c6"]}}, + "zoomRegions": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{w},"height":{h},"fps":null}} + }}"## + ) +} + +#[test] +fn golden_frames_per_output_format() { + let (screen, webcam) = match ( + std::env::var("OPENSCREEN_GOLDEN_SCREEN"), + std::env::var("OPENSCREEN_GOLDEN_WEBCAM"), + ) { + (Ok(s), Ok(w)) => (s, w), + _ => { + println!( + "SKIP: definir OPENSCREEN_GOLDEN_SCREEN et OPENSCREEN_GOLDEN_WEBCAM \ + (chemins d'un enregistrement reel) pour produire le golden." + ); + return; + } + }; + + let out_dir = std::env::var("OPENSCREEN_GOLDEN_OUT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("target/golden")); + std::fs::create_dir_all(&out_dir).expect("creer le dossier de sortie"); + + // C8 = tous les effets (ombres, coins, fond flouté, motion blur) : le golden + // doit couvrir les calques que les 9 correctifs ont touchés. Zoom et anim de + // layout coupés — ce sont les plannings FIXTURE, pas le contrat de scène + // (même neutralisation que `live::render_thread`). + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + + let gpu = Gpu::create(false).expect("device d3d11"); + + println!("\n{:<18} {:>11} {:>18} {:>9} {:>9}", "format", "sortie", "hash", "grad_x", "grad_y"); + println!("{}", "-".repeat(72)); + + for &(name, w, h) in FORMATS { + // Un compositeur PAR FORMAT, rastérisant à la géométrie de sortie — c'est + // exactement ce que fait le chemin d'export. Avant la refonte il n'y avait + // qu'un seul compositeur 1920x1080 pour tous les formats. + let comp = Compositor::new_sized(&gpu, w, h).expect("compositor"); + let scene = Scene::from_json(&scene_json(&screen, &webcam, w, h)).expect("scene valide"); + comp.set_scene(Some(scene)); + comp.clear_cursor(); + + // Un Player par format : `present_frame` fait avancer les décodeurs, on + // repart donc d'un état propre pour que AT_SEC désigne bien la même + // image source d'un format à l'autre. + let rgba = unsafe { + let mut player = Player::open(&screen, &webcam, &gpu).expect("ouvrir les sources"); + player.present_frame(&comp, &cfg, AT_SEC).expect("composer la frame"); + comp.readback_resized(w, h).expect("readback") + }; + + assert_eq!( + rgba.len(), + (w as usize) * (h as usize) * 4, + "{name}: le readback ne fait pas w*h*4" + ); + + let (gx, gy) = gradient_energy(&rgba, w as usize, h as usize); + let hash = fnv1a(&rgba); + write_ppm(&out_dir.join(format!("{name}.ppm")), &rgba, w, h).expect("ecrire le ppm"); + + println!("{name:<18} {:>5}x{:<5} {hash:>18x} {gx:>9.3} {gy:>9.3}", w, h); + } + + println!("\nFrames ecrites dans {}", out_dir.display()); + println!( + "Apres la phase 1 : le hash du 16-9 doit etre INCHANGE ; grad_y doit MONTER \ + sur 9-16, 1-1, 4-5 et 4k-16-9." + ); +} + +/// Rend le preset side-by-side avec un rect webcam en COLONNE — exactement ce que +/// produit `computeCompositeLayout` (branche dual-frame : `webcamRect = webcamSlot`, +/// un slot de largeur fixe et de pleine hauteur, sans aucun ajustement d'aspect). +/// +/// C'est le cas qui étirait la caméra : le natif plaquait la frame entière sur ce +/// slot. Depuis `cover_crop_uv`, la coupe source suit le ratio de la boîte, donc la +/// caméra est rognée mais jamais déformée. Écrit un PPM pour inspection visuelle — +/// la propriété, elle, est verrouillée par les tests unitaires de `cover_crop_uv`. +#[test] +fn golden_side_by_side_webcam_is_not_stretched() { + let (screen, webcam) = match ( + std::env::var("OPENSCREEN_GOLDEN_SCREEN"), + std::env::var("OPENSCREEN_GOLDEN_WEBCAM"), + ) { + (Ok(s), Ok(w)) => (s, w), + _ => { + println!("SKIP: definir OPENSCREEN_GOLDEN_SCREEN / _WEBCAM"); + return; + } + }; + let out_dir = std::env::var("OPENSCREEN_GOLDEN_OUT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("target/golden")); + std::fs::create_dir_all(&out_dir).expect("dossier de sortie"); + + let (w, h) = (1920u32, 1080u32); + let (s, c) = (screen.replace('\\', "/"), webcam.replace('\\', "/")); + // slot webcam : colonne droite, ~31% de large, pleine hauteur → ratio ~0.55, + // très loin du 16:9 de la caméra. Sans cover, la tête est visiblement étirée. + let scene_json = format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"{c}","sourceStartSec":0,"sourceEndSec":30,"webcamOffsetSec":0,"hasAudio":true}}], + "layout": {{"preset":"dual-frame","webcamSize":1.0,"webcamShape":"rectangle","webcamMirror":false, + "webcamRect":{{"x":0.66,"y":0.06,"width":0.31,"height":0.88}},"webcamReactiveZoom":false}}, + "effects": {{"padding":0.0,"blur":true,"shadow":1.0,"roundnessPx":24,"motionBlur":0.0}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#b02a2a","#7a1414"]}}, + "zoomRegions": [], "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{w},"height":{h},"fps":null}} + }}"## + ); + + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + + let gpu = Gpu::create(false).expect("device d3d11"); + let comp = Compositor::new_sized(&gpu, w, h).expect("compositor"); + comp.set_scene(Some(Scene::from_json(&scene_json).expect("scene valide"))); + comp.clear_cursor(); + + let rgba = unsafe { + let mut player = Player::open(&screen, &webcam, &gpu).expect("ouvrir les sources"); + player.present_frame(&comp, &cfg, AT_SEC).expect("composer"); + comp.readback_resized(w, h).expect("readback") + }; + let path = out_dir.join("side-by-side.ppm"); + write_ppm(&path, &rgba, w, h).expect("ecrire le ppm"); + println!("side-by-side ecrit: {}", path.display()); +} + +/// Rend un clip RECADRÉ avec le `screenRect` que l'app résout — le chemin qui a +/// régressé. `compositor.rs::fit_screen` consomme ce rect TEL QUEL (il saute son +/// propre fit au ratio du crop, le rect étant censé y être déjà) : si le rect ne +/// porte pas le ratio du crop, la vidéo est étirée pour remplir une boîte mal +/// formée, sans rien en aval pour rattraper. +/// +/// `SCREEN_RECT_AR` doit rester le ratio du crop (0.30*1920 / 0.89*1080 ≈ 0.599). +/// Le PPM permet de vérifier à l'œil que le texte n'est pas étiré. +#[test] +fn golden_cropped_clip_is_not_stretched() { + let (screen, webcam) = match ( + std::env::var("OPENSCREEN_GOLDEN_SCREEN"), + std::env::var("OPENSCREEN_GOLDEN_WEBCAM"), + ) { + (Ok(s), Ok(w)) => (s, w), + _ => { + println!("SKIP: definir OPENSCREEN_GOLDEN_SCREEN / _WEBCAM"); + return; + } + }; + let out_dir = std::env::var("OPENSCREEN_GOLDEN_OUT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("target/golden")); + std::fs::create_dir_all(&out_dir).expect("dossier de sortie"); + + let (w, h) = (1920u32, 1080u32); + let (s, c) = (screen.replace('\\', "/"), webcam.replace('\\', "/")); + // Crop du rapport utilisateur : bande verticale 30% x 89% d'une source 16:9. + let (crop_w, crop_h) = (0.30f32, 0.89f32); + let crop_ar = (1920.0 * crop_w) / (1080.0 * crop_h); // ≈ 0.599 + // Le rect que l'app DOIT produire : contain de ce ratio dans le cadre de sortie. + let rect_h = 0.94f32; + let rect_w = rect_h * crop_ar * (h as f32 / w as f32); + let scene_json = format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"{c}","sourceStartSec":0,"sourceEndSec":30,"webcamOffsetSec":0,"hasAudio":true}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rectangle","webcamMirror":false, + "screenRect":{{"x":{sx},"y":{sy},"width":{rect_w},"height":{rect_h}}},"webcamReactiveZoom":false}}, + "effects": {{"padding":0.0,"blur":true,"shadow":1.0,"roundnessPx":24,"motionBlur":0.0}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#1e3a5f","#0d1b2a"]}}, + "zoomRegions": [], "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [{{"x":0.44,"y":0.06,"width":{crop_w},"height":{crop_h}}}], + "output": {{"width":{w},"height":{h},"fps":null}} + }}"##, + sx = 0.5 - rect_w * 0.5, + sy = 0.5 - rect_h * 0.5, + ); + + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + + let gpu = Gpu::create(false).expect("device d3d11"); + let comp = Compositor::new_sized(&gpu, w, h).expect("compositor"); + comp.set_scene(Some(Scene::from_json(&scene_json).expect("scene valide"))); + comp.clear_cursor(); + + let rgba = unsafe { + let mut player = Player::open(&screen, &webcam, &gpu).expect("ouvrir les sources"); + player.present_frame(&comp, &cfg, AT_SEC).expect("composer"); + comp.readback_resized(w, h).expect("readback") + }; + let path = out_dir.join("cropped-clip.ppm"); + write_ppm(&path, &rgba, w, h).expect("ecrire le ppm"); + println!("crop ar={crop_ar:.3} rect={rect_w:.3}x{rect_h:.3} -> {}", path.display()); +} diff --git a/crates/compositor/tests/warp_device_cannot_decode.rs b/crates/compositor/tests/warp_device_cannot_decode.rs new file mode 100644 index 0000000000..d9cbad14cc --- /dev/null +++ b/crates/compositor/tests/warp_device_cannot_decode.rs @@ -0,0 +1,80 @@ +//! Pourquoi le backend CPU décode en LOGICIEL et pas sur le device WARP (PR #162). +//! +//! La proposition initiale était de simplement retenter `D3D11CreateDevice` en +//! `D3D_DRIVER_TYPE_WARP` quand le matériel échoue. Ça ne suffit pas, et pas pour une +//! raison de vitesse : WARP n'a pas de décodeur vidéo du tout. Or le chemin matériel +//! passe le device de `Gpu` à ffmpeg comme `AVD3D11VADeviceContext` +//! (`pipeline.rs`, `(*d3dctx).device = ...`) — un device WARP branché là se créerait +//! puis ne produirait aucune frame. +//! +//! D'où la forme qu'a prise `Backend::Cpu` : WARP pour le RENDU, libavcodec en mémoire +//! système pour le DÉCODAGE, uploadé en NV12 par `cpu_frames.rs`. Deux axes, deux +//! solutions — c'est ce test qui dit pourquoi le second existe. +//! +//! S'il ÉCHOUE, c'est que WARP a gagné une capacité vidéo sur cette machine/version de +//! Windows : `Backend::Cpu` pourrait alors décoder directement sur son device et se +//! passer de tout `cpu_frames.rs`. + +#![cfg(windows)] + +use windows::core::Interface; +use windows::Win32::Foundation::HMODULE; +use windows::Win32::Graphics::Direct3D::{ + D3D_DRIVER_TYPE, D3D_DRIVER_TYPE_WARP, D3D_FEATURE_LEVEL, D3D_FEATURE_LEVEL_11_1, +}; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11VideoDevice, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_FLAG, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION, +}; + +fn create( + driver: D3D_DRIVER_TYPE, + flags: D3D11_CREATE_DEVICE_FLAG, +) -> windows::core::Result { + let levels = [D3D_FEATURE_LEVEL_11_1]; + let mut device: Option = None; + let mut context: Option = None; + let mut got = D3D_FEATURE_LEVEL::default(); + unsafe { + D3D11CreateDevice( + None, + driver, + HMODULE::default(), + flags, + Some(&levels), + D3D11_SDK_VERSION, + Some(&mut device), + Some(&mut got), + Some(&mut context), + )?; + } + device.ok_or_else(|| windows::core::Error::from(windows::Win32::Foundation::E_UNEXPECTED)) +} + +/// Exactement les flags de `Gpu::create`. Mesuré : `DXGI_ERROR_UNSUPPORTED` (0x887A0004) +/// — le device WARP ne se crée même pas avec `VIDEO_SUPPORT`. +#[test] +fn warp_rejects_the_video_support_flag_gpu_create_requires() { + let err = create( + D3D_DRIVER_TYPE_WARP, + D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT, + ) + .expect_err("WARP a accepté VIDEO_SUPPORT — le repli WARP redevient envisageable"); + assert_eq!(err.code().0 as u32, 0x887A_0004, "attendu DXGI_ERROR_UNSUPPORTED, eu {err}"); +} + +/// Et laisser tomber le flag ne sauve rien : le device se crée bien à FL 11_1, mais +/// il n'expose aucun `ID3D11VideoDevice`, donc zéro profil décodeur pour D3D11VA. +#[test] +fn warp_without_the_flag_still_exposes_no_video_device() { + let device = + create(D3D_DRIVER_TYPE_WARP, D3D11_CREATE_DEVICE_BGRA_SUPPORT).expect("WARP FL 11_1"); + let profiles = device + .cast::() + .map(|video| unsafe { video.GetVideoDecoderProfileCount() }); + assert!( + matches!(profiles, Err(_) | Ok(0)), + "WARP expose {profiles:?} profils décodeur — le repli WARP redevient envisageable" + ); +} diff --git a/crates/compositor/wrapper.h b/crates/compositor/wrapper.h new file mode 100644 index 0000000000..86612f96ae --- /dev/null +++ b/crates/compositor/wrapper.h @@ -0,0 +1,14 @@ +#include +#include +#include +#include +#include +#include +#include +#include +/* Software decode path (CPU backend) : les décodeurs logiciels sortent du YUV420P, + la chaîne D3D échantillonne du NV12. swscale était déjà LIÉ (build.rs) sans être + bindé — c'est la conversion la mieux optimisée qu'on ait déjà sous la main, et + elle couvre les formats exotiques (10 bits, 4:2:2) qu'un interleave écrit à la + main casserait silencieusement. */ +#include diff --git a/crates/fixture/fixture.json b/crates/fixture/fixture.json new file mode 100644 index 0000000000..4e26d4c6b5 --- /dev/null +++ b/crates/fixture/fixture.json @@ -0,0 +1,75 @@ +{ + "_comment": "Fixture gelée pour spike-native. Coupée en -c copy : le bitstream est EXACTEMENT celui produit par la capture openscreen. Ne pas ré-encoder — cela changerait la complexité du flux et viderait C0 de son sens.", + "frozen_at": "2026-07-17", + "duration_s": 6.0, + "frames_per_source": 360, + "sources": { + "screen": { + "file": "screen.mp4", + "role": "source 1 — quad 16:9 scalé/positionné, coins arrondis (§6)", + "provenance": { + "origin": "C:\\Users\\camil\\AppData\\Roaming\\openscreen\\recordings\\recording-1783845220910.mp4", + "origin_duration_s": 111.6, + "cut_offset_s": 100, + "cut_method": "ffmpeg -ss 100 -t 6 -c copy (IDR-aligned, GOP=60)" + }, + "codec": "h264", + "profile": "Constrained Baseline", + "profile_idc": 66, + "constraint_set1_flag": 1, + "level_idc": 42, + "width": 1920, + "height": 1080, + "pix_fmt": "yuv420p", + "frame_rate": "60/1", + "cfr": true, + "frames": 360, + "color_space": "bt709 (MESURÉ, non taggé dans le flux)", + "color_range": "limited/tv (MESURÉ, non taggé dans le flux)", + "d3d11va_decode": "OK — pixfmt:d3d11", + "content": "navigateur plein écran, scroll de page GitHub. MAD=1.367, 16.7% des frames en mouvement (MAD>1), 81.1% de doublons." + }, + "webcam": { + "file": "webcam.mp4", + "role": "source 2 — center-crop carré + masque circulaire (§6/E5). Simule une webcam HQ : deuxième flux screen, pas la vraie webcam.", + "provenance": { + "origin": "C:\\Users\\camil\\AppData\\Roaming\\openscreen\\recordings\\recording-1783894784128.mp4", + "origin_duration_s": 866.5, + "cut_offset_s": 428, + "cut_method": "ffmpeg -ss 428 -t 6 -c copy (IDR-aligned, GOP=60)" + }, + "codec": "h264", + "profile": "Constrained Baseline", + "profile_idc": 66, + "constraint_set1_flag": 1, + "level_idc": 42, + "width": 1920, + "height": 1032, + "_height_note": "1032, pas 1080 — capture fenêtrée. Sans conséquence : cette source est center-croppée en carré (1032x1032) par le §6.", + "pix_fmt": "yuv420p", + "frame_rate": "60/1", + "cfr": true, + "frames": 360, + "color_space": "bt709 (MESURÉ, non taggé)", + "color_range": "limited/tv (MESURÉ, non taggé)", + "d3d11va_decode": "OK — pixfmt:d3d11", + "content": "fenêtre Claude Code, texte qui défile. MAD=1.310, 21.1% des frames en mouvement, 78.1% de doublons." + }, + "cursor": { + "file": "screen.cursor.json", + "role": "télémétrie curseur de la source screen — consommée par C7 (curseur custom + click bounce) et par le suivi auto du zoom.", + "provenance": { + "origin": "C:\\Users\\camil\\AppData\\Roaming\\openscreen\\recordings\\recording-1783845220910.mp4.cursor.json", + "cut_method": "AUCUNE coupe — copier le fichier BRUT tel quel. Le loader fenêtre lui-même : CursorTrack::load(path, offset_ms=100_000, dur_s=6.0) filtre samples[] sur timeMs et rebase à 0 (crates/compositor/src/cursor.rs). L'offset correspond au cut_offset_s=100 de la source screen." + }, + "samples_total": 2582, + "samples_in_window": 140, + "clicks_in_window": 1 + } + }, + "decoder_surface_note": "Le décodeur D3D11VA aligne en macroblocs : 1080 -> texture 1920x1088, 1032 -> 1920x1040. Seules les N premières lignes sont utiles. Les SRV/UV doivent en tenir compte (§5).", + "e1_matrix": { + "decision": "BT.709 limited/tv pour LES DEUX sources — une seule matrice.", + "basis": "Mesuré, pas lu : aucune des deux sources ne tague color_range. Histogramme du plan Y sur 3 enregistrements x 12 frames : pic massif à Y=235 (21-71% des pixels), ZÉRO pixel à 255, zéro au-dessus de 242 (la queue 236-242 est du ringing autour du plat à 235), zéro sous 16. Le §4 supposait full range pour une capture d'écran — la mesure dit l'inverse." + } +} diff --git a/crates/poc-d3d/Cargo.toml b/crates/poc-d3d/Cargo.toml new file mode 100644 index 0000000000..34ba4e7ce4 --- /dev/null +++ b/crates/poc-d3d/Cargo.toml @@ -0,0 +1,18 @@ +# Le POC d'origine, conservé tel quel comme banc de mesure et pièce d'époque. +# Rien ici n'est packagé dans l'app : la GUI Win32 de preview/export (`app.rs`) et le +# harnais de bench fps (`bench.rs`) ne servent qu'à mesurer et à regarder. Tout ce qui +# est réellement expédié vit dans `openscreen-compositor` et `compositor-view-napi`. +[package] +name = "poc-d3d" +version.workspace = true +edition.workspace = true +description = "POC de mesure OpenScreen : GUI Win32 de preview/export + harnais de bench fps (§9/§10) au-dessus d'openscreen-compositor." + +[[bin]] +name = "poc-d3d" +path = "src/main.rs" + +[dependencies] +openscreen-compositor.workspace = true +anyhow.workspace = true +windows.workspace = true diff --git a/crates/poc-d3d/src/app.rs b/crates/poc-d3d/src/app.rs new file mode 100644 index 0000000000..6c49b7d2b8 --- /dev/null +++ b/crates/poc-d3d/src/app.rs @@ -0,0 +1,556 @@ +//! GUI native (Win32) : preview/playback du compositing + export avec barre de +//! progression et bilan (temps + fps). Rapproche le POC d'une intégration app : +//! le compositeur/pipeline mesuré alimente une vraie boucle de rendu interactive. +//! +//! Architecture : +//! - une fenêtre hôte, un enfant "preview" portant une swapchain DXGI flip sur le +//! device D3D11 partagé (blit zéro-copie du RT composité → backbuffer) ; +//! - des contrôles Win32 natifs (combo preset, Play/Pause, Export, barre, label) — +//! aucun rendu de texte maison ; +//! - un modèle mono-thread coopératif : WM_TIMER cadence la playback à 60 fps ; +//! l'export tourne sur le thread UI et rafraîchit la barre entre frames. + +use openscreen_compositor::compositor::{Compositor, FIXTURE_FRAMES, OUT_H, OUT_W}; +use openscreen_compositor::config::{self, Cfg}; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::live::Player; +use openscreen_compositor::pipeline; +use anyhow::Result; +use std::ffi::c_void; +use std::time::Instant; +use windows::core::{Interface, PCWSTR}; +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::Graphics::Direct3D11::ID3D11RenderTargetView; +use windows::Win32::Graphics::Dxgi::Common::{ + DXGI_ALPHA_MODE_IGNORE, DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_SAMPLE_DESC, +}; +use windows::Win32::Graphics::Dxgi::{ + IDXGIAdapter, IDXGIDevice, IDXGIFactory2, IDXGISwapChain1, DXGI_PRESENT, + DXGI_SCALING_STRETCH, DXGI_SWAP_CHAIN_DESC1, DXGI_SWAP_EFFECT_FLIP_DISCARD, + DXGI_USAGE_RENDER_TARGET_OUTPUT, +}; +use windows::Win32::Graphics::Gdi::{ + CreateFontW, GetSysColorBrush, UpdateWindow, COLOR_BTNFACE, HFONT, +}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::Input::KeyboardAndMouse::EnableWindow; +use windows::Win32::UI::Controls::{ + InitCommonControlsEx, ICC_PROGRESS_CLASS, ICC_STANDARD_CLASSES, INITCOMMONCONTROLSEX, + PBM_SETPOS, PBM_SETRANGE32, +}; +use windows::Win32::UI::WindowsAndMessaging::*; + +const PREVIEW_W: i32 = 1280; +const PREVIEW_H: i32 = 720; +const STRIP_H: i32 = 64; +const CLIENT_W: i32 = PREVIEW_W; +const CLIENT_H: i32 = PREVIEW_H + STRIP_H; + +const ID_COMBO: isize = 101; +const ID_PLAY: isize = 102; +const ID_EXPORT: isize = 103; +const ID_PROGRESS: isize = 104; +const ID_STATUS: isize = 105; +const TIMER_TICK: usize = 1; + +/// Chaîne UTF-16 terminée par NUL (durée de vie tenue par l'appelant). +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Plus grand rectangle 16:9 centré dans `(cw, ch)` → viewport letterbox de la preview. +fn letterbox(cw: f32, ch: f32) -> (f32, f32, f32, f32) { + let ar = OUT_W as f32 / OUT_H as f32; + let (mut w, mut h) = (cw, ch); + if cw / ch > ar { + w = ch * ar; + } else { + h = cw / ar; + } + ((cw - w) * 0.5, (ch - h) * 0.5, w, h) +} + +/// État applicatif complet (possédé par la fenêtre via GWLP_USERDATA). +struct App { + gpu: Gpu, + comp: Compositor, + player: Player, + cfgs: Vec, + cur: usize, + playing: bool, + exporting: bool, + total_frames: u64, + screen: String, + webcam: String, + out: String, + // win32 + preview: HWND, + combo: HWND, + play_btn: HWND, + export_btn: HWND, + progress: HWND, + status: HWND, + // dxgi (preview) + swap: Option, + bb_rtv: Option, + // cadence playback + last: Instant, + acc: f64, +} + +impl App { + /// Compose + affiche la 1re frame, avant l'ouverture de la fenêtre. + unsafe fn init_first_frame(&mut self) { + let cfg = self.cfgs[self.cur].clone(); + let _ = self.player.step(&self.comp, &cfg); + let _ = self.render(); + self.update_ready_status(); + self.last = Instant::now(); + } + + /// Cadence 60 fps par horloge murale (accumulateur), avec garde anti-spirale. + unsafe fn on_tick(&mut self) -> Result<()> { + if self.exporting || !self.playing { + return Ok(()); + } + let now = Instant::now(); + let dt = (now - self.last).as_secs_f64().min(0.1); + self.last = now; + self.acc += dt; + let step = 1.0 / 60.0; + let cfg = self.cfgs[self.cur].clone(); + let mut stepped = false; + let mut n = 0; + while self.acc >= step && n < 3 { + if self.player.step(&self.comp, &cfg)? { + stepped = true; + } + self.acc -= step; + n += 1; + } + if self.acc > step { + self.acc = 0.0; // largue le retard accumulé (fenêtre masquée, etc.) + } + if stepped { + self.render()?; + } + Ok(()) + } + + /// Blit du RT composité vers le backbuffer, letterboxé, puis Present (vsync). + unsafe fn render(&mut self) -> Result<()> { + let (Some(swap), Some(rtv)) = (self.swap.as_ref(), self.bb_rtv.as_ref()) else { + return Ok(()); + }; + let mut rc = RECT::default(); + let _ = GetClientRect(self.preview, &mut rc); + let cw = (rc.right - rc.left).max(1) as f32; + let ch = (rc.bottom - rc.top).max(1) as f32; + let (x, y, w, h) = letterbox(cw, ch); + self.gpu.context.ClearRenderTargetView(rtv, &[0.02, 0.02, 0.03, 1.0]); + self.comp.blit_to(rtv, x, y, w, h); + let _ = swap.Present(1, DXGI_PRESENT(0)); + Ok(()) + } + + unsafe fn on_command(&mut self, wp: WPARAM) -> Result<()> { + let id = (wp.0 & 0xffff) as isize; + let code = ((wp.0 >> 16) & 0xffff) as u32; + match id { + ID_PLAY => self.toggle_play(), + ID_EXPORT => self.run_export()?, + ID_COMBO if code == CBN_SELCHANGE => { + let sel = SendMessageW(self.combo, CB_GETCURSEL, WPARAM(0), LPARAM(0)).0; + if sel >= 0 && (sel as usize) < self.cfgs.len() { + self.cur = sel as usize; + if !self.playing { + let cfg = self.cfgs[self.cur].clone(); + let _ = self.player.recompose(&self.comp, &cfg); + let _ = self.render(); + } + self.update_ready_status(); + } + } + _ => {} + } + Ok(()) + } + + unsafe fn toggle_play(&mut self) { + self.playing = !self.playing; + let label = wide(if self.playing { "Pause" } else { "Play" }); + let _ = SetWindowTextW(self.play_btn, PCWSTR(label.as_ptr())); + self.last = Instant::now(); + self.acc = 0.0; + } + + /// Export coopératif (thread UI) : la barre avance via SendMessage+UpdateWindow sur le + /// contrôle (pas de re-pompage du message-loop → aucune réentrance dans notre wndproc). + /// La mesure fps reste enveloppante (§10) dans `run_composited`. + unsafe fn run_export(&mut self) -> Result<()> { + if self.exporting { + return Ok(()); + } + self.exporting = true; + self.playing = false; + let pl = wide("Play"); + let _ = SetWindowTextW(self.play_btn, PCWSTR(pl.as_ptr())); + let _ = EnableWindow(self.export_btn, false); + let _ = EnableWindow(self.play_btn, false); + let _ = EnableWindow(self.combo, false); + + SendMessageW(self.progress, PBM_SETRANGE32, WPARAM(0), LPARAM(self.total_frames as isize)); + SendMessageW(self.progress, PBM_SETPOS, WPARAM(0), LPARAM(0)); + + let cfg = self.cfgs[self.cur].clone(); + let s = wide(&format!("Exporting {} — {} …", cfg.name, cfg.desc)); + let _ = SetWindowTextW(self.status, PCWSTR(s.as_ptr())); + let _ = UpdateWindow(self.status); + let _ = UpdateWindow(self.progress); + + // sonde de progression : SendMessage throttlé au pourcent (µs, cf. §10). + let prog = self.progress; + let total = self.total_frames.max(1); + let mut last_pct: i64 = -1; + let mut cb = move |done: u64| { + let pct = (done as i64 * 100) / total as i64; + if pct != last_pct { + last_pct = pct; + SendMessageW(prog, PBM_SETPOS, WPARAM(done as usize), LPARAM(0)); + let _ = UpdateWindow(prog); + } + }; + let r = pipeline::run_composited( + &self.screen, &self.webcam, &self.out, &self.gpu, &self.comp, &cfg, &mut cb, + ); + self.comp.clear_srv_cache(); + + let _ = EnableWindow(self.export_btn, true); + let _ = EnableWindow(self.play_btn, true); + let _ = EnableWindow(self.combo, true); + self.exporting = false; + + match r { + Ok(st) => { + SendMessageW(self.progress, PBM_SETPOS, WPARAM(st.frames as usize), LPARAM(0)); + let msg = format!( + "Done — {} · {} frames · {:.2}s · {:.1} fps -> {}", + cfg.name, st.frames, st.wall_s, st.fps, self.out + ); + let w = wide(&msg); + let _ = SetWindowTextW(self.status, PCWSTR(w.as_ptr())); + } + Err(e) => { + let w = wide(&format!("Export failed: {e}")); + let _ = SetWindowTextW(self.status, PCWSTR(w.as_ptr())); + } + } + + // reprise de la playback + self.playing = true; + let pb = wide("Pause"); + let _ = SetWindowTextW(self.play_btn, PCWSTR(pb.as_ptr())); + self.last = Instant::now(); + self.acc = 0.0; + Ok(()) + } + + unsafe fn update_ready_status(&self) { + let cfg = &self.cfgs[self.cur]; + let s = wide(&format!( + "Ready - {} · {} ({} frames · export -> {})", + cfg.name, cfg.desc, self.total_frames, self.out + )); + let _ = SetWindowTextW(self.status, PCWSTR(s.as_ptr())); + } + + unsafe fn report_err(&self, e: &anyhow::Error) { + let w = wide(&format!("error: {e}")); + let _ = SetWindowTextW(self.status, PCWSTR(w.as_ptr())); + eprintln!("[app] error: {e:#}"); + } +} + +/// Police Segoe UI 9pt ClearType — les contrôles créés par CreateWindowEx héritent sinon +/// d'une vieille police bitmap système. Fuit un HFONT (durée de vie = process). +unsafe fn ui_font() -> HFONT { + let face = wide("Segoe UI"); + // (height=-12 ≈ 9pt @96dpi, weight=400, charset=DEFAULT(1), quality=CLEARTYPE(5)) + CreateFontW(-12, 0, 0, 0, 400, 0, 0, 0, 1, 0, 0, 5, 0, PCWSTR(face.as_ptr())) +} + +/// Crée l'enfant preview + les contrôles natifs. Renvoie leurs HWND. +unsafe fn create_children( + parent: HWND, + hinst: windows::Win32::Foundation::HINSTANCE, + cfgs: &[Cfg], +) -> Result<(HWND, HWND, HWND, HWND, HWND, HWND)> { + let btn_cls = wide("BUTTON"); + let combo_cls = wide("COMBOBOX"); + let static_cls = wide("STATIC"); + let prog_cls = wide("msctls_progress32"); + let prev_cls = wide("PocD3DPreview"); + + let preview = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(prev_cls.as_ptr()), + PCWSTR::null(), + WS_CHILD | WS_VISIBLE, + 0, 0, PREVIEW_W, PREVIEW_H, + parent, + HMENU::default(), + hinst, + None, + )?; + + let y = PREVIEW_H + 18; + let combo_style = + WS_CHILD.0 | WS_VISIBLE.0 | WS_VSCROLL.0 | (CBS_DROPDOWNLIST as u32) | (CBS_HASSTRINGS as u32); + let combo = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(combo_cls.as_ptr()), + PCWSTR::null(), + WINDOW_STYLE(combo_style), + 14, y - 3, 250, 340, + parent, + HMENU(ID_COMBO as *mut c_void), + hinst, + None, + )?; + for c in cfgs { + let item = wide(&format!("{} — {}", c.name, c.desc)); + SendMessageW(combo, CB_ADDSTRING, WPARAM(0), LPARAM(item.as_ptr() as isize)); + } + SendMessageW(combo, CB_SETCURSEL, WPARAM(cfgs.len() - 1), LPARAM(0)); + + let pl = wide("Pause"); + let play_btn = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(btn_cls.as_ptr()), + PCWSTR(pl.as_ptr()), + WINDOW_STYLE(WS_CHILD.0 | WS_VISIBLE.0 | (BS_PUSHBUTTON as u32)), + 278, y, 96, 30, + parent, + HMENU(ID_PLAY as *mut c_void), + hinst, + None, + )?; + + let ex = wide("Export"); + let export_btn = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(btn_cls.as_ptr()), + PCWSTR(ex.as_ptr()), + WINDOW_STYLE(WS_CHILD.0 | WS_VISIBLE.0 | (BS_PUSHBUTTON as u32)), + 382, y, 96, 30, + parent, + HMENU(ID_EXPORT as *mut c_void), + hinst, + None, + )?; + + let progress = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(prog_cls.as_ptr()), + PCWSTR::null(), + WS_CHILD | WS_VISIBLE, + 494, y + 4, 300, 22, + parent, + HMENU(ID_PROGRESS as *mut c_void), + hinst, + None, + )?; + + let st = wide("Ready"); + let status = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(static_cls.as_ptr()), + PCWSTR(st.as_ptr()), + WS_CHILD | WS_VISIBLE, + 808, y + 6, 458, 40, + parent, + HMENU(ID_STATUS as *mut c_void), + hinst, + None, + )?; + + let font = ui_font(); + for c in [combo, play_btn, export_btn, status] { + SendMessageW(c, WM_SETFONT, WPARAM(font.0 as usize), LPARAM(1)); + } + Ok((combo, play_btn, export_btn, progress, status, preview)) +} + +/// Crée la swapchain flip + RTV du backbuffer sur le device D3D11 partagé. +unsafe fn create_swapchain( + device: &windows::Win32::Graphics::Direct3D11::ID3D11Device, + hwnd: HWND, +) -> Result<(IDXGISwapChain1, ID3D11RenderTargetView)> { + let dxdev: IDXGIDevice = device.cast()?; + let adapter: IDXGIAdapter = dxdev.GetAdapter()?; + let factory: IDXGIFactory2 = adapter.GetParent()?; + let desc = DXGI_SWAP_CHAIN_DESC1 { + Width: PREVIEW_W as u32, + Height: PREVIEW_H as u32, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, + BufferCount: 2, + SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD, + Scaling: DXGI_SCALING_STRETCH, + AlphaMode: DXGI_ALPHA_MODE_IGNORE, + ..Default::default() + }; + let swap = factory.CreateSwapChainForHwnd(device, hwnd, &desc, None, None)?; + let bb: windows::Win32::Graphics::Direct3D11::ID3D11Texture2D = swap.GetBuffer(0)?; + let mut rtv: Option = None; + device.CreateRenderTargetView(&bb, None, Some(&mut rtv))?; + Ok((swap, rtv.unwrap())) +} + +extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + unsafe { + let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut App; + if ptr.is_null() { + return DefWindowProcW(hwnd, msg, wp, lp); + } + let app = &mut *ptr; + match msg { + WM_TIMER => { + if let Err(e) = app.on_tick() { + app.report_err(&e); + } + LRESULT(0) + } + WM_COMMAND => { + if let Err(e) = app.on_command(wp) { + app.report_err(&e); + } + LRESULT(0) + } + WM_DESTROY => { + let _ = KillTimer(hwnd, TIMER_TICK); + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcW(hwnd, msg, wp, lp), + } + } +} + +/// Point d'entrée GUI (appelé par `main` quand aucun argument bench n'est passé). +pub fn run_gui(screen: &str, webcam: &str, cursor_json: &str, out_dir: &str) -> Result<()> { + unsafe { run_gui_inner(screen, webcam, cursor_json, out_dir) } +} + +unsafe fn run_gui_inner(screen: &str, webcam: &str, cursor_json: &str, out_dir: &str) -> Result<()> { + std::fs::create_dir_all(out_dir).ok(); + let out = format!("{out_dir}/export.mp4"); + + let gpu = Gpu::create(false)?; + println!("d3d11 device ok (feature_level 0x{:X})", gpu.feature_level.0 as u32); + let mut comp = Compositor::new(&gpu)?; + if let Ok(track) = CursorTrack::load(cursor_json, 100_000.0, 6.0) { + comp.set_cursor(track); + } + let player = Player::open(screen, webcam, &gpu)?; + let total_frames = pipeline::probe_frame_count(screen).unwrap_or(FIXTURE_FRAMES as u64); + let cfgs = config::all(); + let cur = cfgs.len() - 1; // C8 (tous effets) par défaut + + let hinst = windows::Win32::Foundation::HINSTANCE(GetModuleHandleW(None)?.0); + + let icc = INITCOMMONCONTROLSEX { + dwSize: std::mem::size_of::() as u32, + dwICC: ICC_PROGRESS_CLASS | ICC_STANDARD_CLASSES, + }; + let _ = InitCommonControlsEx(&icc); + + let main_cls = wide("PocD3DMain"); + let prev_cls = wide("PocD3DPreview"); + let cursor = LoadCursorW(None, IDC_ARROW)?; + let wc_main = WNDCLASSW { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: PCWSTR(main_cls.as_ptr()), + hCursor: cursor, + hbrBackground: GetSysColorBrush(COLOR_BTNFACE), + ..Default::default() + }; + RegisterClassW(&wc_main); + let wc_prev = WNDCLASSW { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(wndproc), // USERDATA null sur l'enfant → DefWindowProcW + hInstance: hinst, + lpszClassName: PCWSTR(prev_cls.as_ptr()), + hCursor: cursor, + hbrBackground: windows::Win32::Graphics::Gdi::HBRUSH(std::ptr::null_mut()), + ..Default::default() + }; + RegisterClassW(&wc_prev); + + let style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; + let mut rc = RECT { left: 0, top: 0, right: CLIENT_W, bottom: CLIENT_H }; + let _ = AdjustWindowRectEx(&mut rc, style, false, WINDOW_EX_STYLE(0)); + let ww = rc.right - rc.left; + let wh = rc.bottom - rc.top; + + let title = wide("OpenScreen — POC D3D11 compositor · preview + export"); + let hwnd = CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(main_cls.as_ptr()), + PCWSTR(title.as_ptr()), + style, + CW_USEDEFAULT, CW_USEDEFAULT, ww, wh, + HWND::default(), + HMENU::default(), + hinst, + None, + )?; + + let (combo, play_btn, export_btn, progress, status, preview) = + create_children(hwnd, hinst, &cfgs)?; + let (swap, bb_rtv) = create_swapchain(&gpu.device, preview)?; + + let app = Box::new(App { + gpu, + comp, + player, + cfgs, + cur, + playing: true, + exporting: false, + total_frames, + screen: screen.to_string(), + webcam: webcam.to_string(), + out, + preview, + combo, + play_btn, + export_btn, + progress, + status, + swap: Some(swap), + bb_rtv: Some(bb_rtv), + last: Instant::now(), + acc: 0.0, + }); + let app_ptr = Box::into_raw(app); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, app_ptr as isize); + + (*app_ptr).init_first_frame(); + let _ = ShowWindow(hwnd, SW_SHOW); + let _ = UpdateWindow(hwnd); + SetTimer(hwnd, TIMER_TICK, 15, None); + + let mut msg = MSG::default(); + while GetMessageW(&mut msg, HWND::default(), 0, 0).0 > 0 { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + drop(Box::from_raw(app_ptr)); + Ok(()) +} diff --git a/crates/poc-d3d/src/bench.rs b/crates/poc-d3d/src/bench.rs new file mode 100644 index 0000000000..46673743a2 --- /dev/null +++ b/crates/poc-d3d/src/bench.rs @@ -0,0 +1,230 @@ +//! Harnais de mesure du POC (§9/§10) et aiguillage des modes. +//! +//! Vivait dans `lib.rs` de l'ancienne crate `poc-d3d`, quand bibliothèque et POC étaient le même +//! paquet. Rien ici n'est packagé : c'est du banc de mesure au-dessus d'`openscreen-compositor`. + +use anyhow::{Context as _, Result}; +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::{config, cursor, d3d, live, pipeline, scene}; +use std::fmt::Write as _; + +fn arg(args: &[String], k: &str, d: &str) -> String { + args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned().unwrap_or_else(|| d.to_string()) +} + +// Deux modes : +// GUI (défaut) : poc-d3d.exe [--fixture ] [--out ] → preview + export +// Bench (§9/10) : poc-d3d.exe --cfg C0..C8 [--fixture ] [--repeat N] [--out ] +// Live (POC) : poc-d3d.exe --live [--fixture ] → vue D3D enfant embarquée (test embed) +pub fn run() -> Result<()> { + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--live") { + let fixture = arg(&args, "--fixture", "fixture"); + return live::run_standalone( + &format!("{fixture}/screen.mp4"), + &format!("{fixture}/webcam.mp4"), + &format!("{fixture}/screen.cursor.json"), + ); + } + let is_bench = args.iter().any(|a| a == "--cfg" || a == "--bench"); + if is_bench { + run_bench(&args) + } else { + let fixture = arg(&args, "--fixture", "fixture"); + let out = arg(&args, "--out", "out"); + crate::app::run_gui( + &format!("{fixture}/screen.mp4"), + &format!("{fixture}/webcam.mp4"), + &format!("{fixture}/screen.cursor.json"), + &out, + ) + } +} + +// poc-d3d.exe --cfg C0..C8 --fixture --repeat 3 --out out/ +fn run_bench(args: &[String]) -> Result<()> { + let get = |k: &str, d: &str| -> String { arg(args, k, d) }; + let fixture = get("--fixture", "fixture"); + let out = get("--out", "out"); + let repeat: u32 = get("--repeat", "3").parse().unwrap_or(3); + let cfg_arg = get("--cfg", "C0..C8"); + + let screen = format!("{fixture}/screen.mp4"); + let webcam = format!("{fixture}/webcam.mp4"); + std::fs::create_dir_all(&out).ok(); + + // sélection des cfg + let all = config::all(); + let mut cfgs: Vec = if cfg_arg.contains("..") { + all + } else { + cfg_arg + .split(',') + .filter_map(|n| config::Cfg::by_name(n.trim())) + .collect() + }; + + // `--backend cpu` : rastérisation WARP + décodage logiciel (voir d3d::Backend). + // Il n'encode pas (AMF exige le GPU), donc il n'est mesurable qu'en mode preview — + // `--preview` est imposé plus bas plutôt que de laisser le run échouer sur l'encodeur. + let backend = match get("--backend", "hardware").as_str() { + "cpu" | "warp" => d3d::Backend::Cpu, + "hardware" | "gpu" => d3d::Backend::Hardware, + other => anyhow::bail!("--backend {other} inconnu (hardware|cpu)"), + }; + // `--export` : le VRAI chemin d'export (`run_composited_multi` → `VideoEncoder` + mux), + // sur le backend demandé. Seul moyen de mesurer l'encodage (le mode preview s'arrête au + // readback) et surtout de VÉRIFIER que le backend CPU sort un fichier lisible : sur un + // device WARP aucun encodeur matériel n'ouvre, donc `ExportCodec::candidates()` doit + // descendre jusqu'à libopenh264 tout seul. C'est cette descente que le test exerce. + let exporting = args.iter().any(|a| a == "--export"); + let preview_only = + !exporting && (args.iter().any(|a| a == "--preview") || backend == d3d::Backend::Cpu); + // Frames composées par run en mode preview. Assez pour noyer le bruit, assez court + // pour qu'un backend lent reste mesurable en une poignée de minutes. + let preview_frames: u64 = get("--frames", "300").parse().unwrap_or(300); + + // C0 = « décode + encode, aucun composite ». Sans encodeur, il n'a pas d'équivalent : + // le mesurer en preview reviendrait à composer quand même et à publier un C0 qui est + // en fait un C1. On le retire plutôt que d'imprimer une ligne trompeuse. + if preview_only { + cfgs.retain(|c| c.composite); + if cfgs.is_empty() { + anyhow::bail!("aucune cfg composite à mesurer (C0 n'a pas de sens sans encodeur)"); + } + } + + let gpu = d3d::Gpu::create_backend(backend, false)?; + println!( + "d3d11 device ok — backend {:?}, feature_level 0x{:X}{}", + backend, + gpu.feature_level.0 as u32, + if preview_only { ", mode preview (décode+compose+readback, sans encodeur)" } else { "" } + ); + let mut comp = Compositor::new(&gpu)?; + let track = cursor::CursorTrack::load(&format!("{fixture}/screen.cursor.json"), 100_000.0, 6.0)?; + comp.set_cursor(track); + + // `--scene ` : compose avec une VRAIE scène d'app au lieu du planning fixture. + // Sert à deux choses : sortir une preuve visuelle pour ce que seule une scène peut décrire + // (les annotations, qu'aucune UI ne crée encore pour certains types), et mesurer un + // avant/après perf sur une scène représentative. + let scene_arg = get("--scene", ""); + if !scene_arg.is_empty() { + let json = std::fs::read_to_string(&scene_arg) + .with_context(|| format!("lecture de la scène {scene_arg}"))?; + comp.set_scene(Some(scene::Scene::from_json(&json)?)); + println!("scène chargée depuis {scene_arg}"); + } + + if exporting { + let params = pipeline::ExportParams::default(); + for cfg in &cfgs { + // `ClipSource` n'est pas `Clone` et l'appel le prend par tranche : reconstruit + // par cfg plutôt que d'ajouter un derive pour le seul harnais de mesure. + let clip = pipeline::ClipSource { + screen: screen.clone(), + webcam: webcam.clone(), + source_start_sec: 0.0, + source_end_sec: 6.0, // la fixture entière (§ fixture.json : 6 s, 360 frames) + webcam_offset_sec: 0.0, + has_audio: false, + }; + let path = format!("{out}/{}_{:?}.mp4", cfg.name, backend).to_lowercase(); + let s = pipeline::run_composited_multi( + &[clip], &path, &gpu, &comp, cfg, ¶ms, &mut |_| {}, + )?; + println!( + "{:<4} {:>4}f {:>8.3}s {:>7.2} fps {:>7.2} ms/f → {}", + cfg.name, s.frames, s.wall_s, s.fps, 1000.0 / s.fps, path + ); + } + return Ok(()); + } + + let mut rows: Vec<(String, u64, f64, f64, f64, String)> = Vec::new(); // name, frames, best_wall, fps, ms/f, spread + let mut json = String::from("{\n \"runs\": [\n"); + + for cfg in &cfgs { + let mut fps_runs = Vec::new(); + let mut frames = 0u64; + for r in 0..repeat { + let path = format!("{out}/{}.mp4", cfg.name); + let s = if preview_only { + let (stats, (fw, fh, rgba)) = + pipeline::run_preview_bench(&screen, &webcam, &gpu, &comp, cfg, preview_frames)?; + // Preuve visuelle, et surtout comparable : un backend qui compose du noir + // afficherait un fps flatteur. Le PPM est nommé par backend pour qu'un + // diff hardware/cpu soit direct. + if r == 0 { + let name = format!("{out}/{}_{:?}.ppm", cfg.name, backend).to_lowercase(); + write_ppm(&name, fw, fh, &rgba)?; + } + stats + } else if cfg.composite { + pipeline::run_composited(&screen, &webcam, &path, &gpu, &comp, cfg, &mut |_| {})? + } else { + pipeline::run_c0(&screen, &path, &gpu)? + }; + frames = s.frames; + fps_runs.push(s.fps); + // Pas de MP4 produit en mode preview (aucun encodeur) — rien à extraire. + if r == 0 && !preview_only { + // extraction PNG f60/f180/f300 sur le 1er run (§11) + extract_pngs(&path, &out, cfg.name); + } + } + let best = fps_runs.iter().cloned().fold(f64::MIN, f64::max); + let worst = fps_runs.iter().cloned().fold(f64::MAX, f64::min); + let spread = if worst > 0.0 { 100.0 * (best - worst) / worst } else { 0.0 }; + let wall = frames as f64 / best; + let msf = 1000.0 / best; + println!( + "{:<4} {:>4}f {:>7.3}s {:>7.1} fps {:>6.2} ms/f spread {:.1}% {}", + cfg.name, frames, wall, best, msf, spread, cfg.desc + ); + rows.push((cfg.name.to_string(), frames, wall, best, msf, format!("{spread:.1}%"))); + let _ = write!( + json, + " {{ \"cfg\": \"{}\", \"frames\": {}, \"fps\": {:.2}, \"ms_per_frame\": {:.3}, \"spread_pct\": {:.1}, \"repeat\": {}, \"desc\": \"{}\" }}{}\n", + cfg.name, frames, best, msf, spread, repeat, cfg.desc, + if cfg.name == cfgs.last().unwrap().name { "" } else { "," } + ); + } + json.push_str(" ]\n}\n"); + std::fs::write(format!("{out}/report.json"), &json)?; + + // table markdown récap + println!("\ncfg frames wall_s fps ms/f spread"); + for (n, f, w, fps, msf, sp) in &rows { + println!("{n:<4} {f:<7} {w:<7.3} {fps:<8.1} {msf:<7.2} {sp}"); + } + println!("\nreport.json + out/C*.mp4 + out/C*_f{{60,180,300}}.png écrits dans {out}/"); + Ok(()) +} + +/// Écrit un readback RGBA8 en PPM binaire (P6, RGB) — format le plus bête qui se lise +/// partout, et qui se compare octet à octet entre deux backends sans passer par un codec. +fn write_ppm(path: &str, w: u32, h: u32, rgba: &[u8]) -> Result<()> { + let mut buf = format!("P6\n{w} {h}\n255\n").into_bytes(); + buf.reserve(rgba.len() / 4 * 3); + for px in rgba.chunks_exact(4) { + buf.extend_from_slice(&px[..3]); + } + std::fs::write(path, buf).with_context(|| format!("écriture {path}"))?; + Ok(()) +} + +/// Extrait 3 frames (f60/f180/f300) d'un MP4 via ffmpeg (§11) — vérification à l'œil. +fn extract_pngs(mp4: &str, out: &str, cfg: &str) { + for f in [60u32, 180, 300] { + let _ = std::process::Command::new("ffmpeg") + .args([ + "-v", "error", "-y", "-i", mp4, + "-vf", &format!("select=eq(n\\,{f})"), + "-frames:v", "1", + &format!("{out}/{cfg}_f{f}.png"), + ]) + .status(); + } +} diff --git a/crates/poc-d3d/src/main.rs b/crates/poc-d3d/src/main.rs new file mode 100644 index 0000000000..6a8a43f163 --- /dev/null +++ b/crates/poc-d3d/src/main.rs @@ -0,0 +1,9 @@ +//! Binaire du POC de mesure : GUI Win32 de preview/export, harnais de bench fps, mode live. +//! Tout le rendu vient d'`openscreen-compositor` — ce crate ne fait que le piloter et le mesurer. + +mod app; +mod bench; + +fn main() -> anyhow::Result<()> { + bench::run() +} diff --git a/crates/x.bat b/crates/x.bat new file mode 100644 index 0000000000..f4d7df1317 --- /dev/null +++ b/crates/x.bat @@ -0,0 +1,8 @@ +@echo off +REM Enveloppe de build, portable dans le repo (chemins via %~dp0) : +REM vcvars (INCLUDE/LIB pour libclang + linker MSVC), ffmpeg/bin sur PATH runtime, cargo. +REM Ajuste le chemin vcvars ci-dessous si ta version de Visual Studio diffère. +call "C:\Program Files\Microsoft Visual Studio\18\Insiders\VC\Auxiliary\Build\vcvars64.bat" >nul 2>&1 +set "PATH=%PATH%;%~dp0thirdparty\ffmpeg-n8.1.2-win64-lgpl-shared\bin" +cd /d "%~dp0" +"%USERPROFILE%\.cargo\bin\cargo.exe" %* diff --git a/design/DESIGN.md b/design/DESIGN.md new file mode 100644 index 0000000000..8450dcb04e --- /dev/null +++ b/design/DESIGN.md @@ -0,0 +1,156 @@ +# OpenScreen Design System + +> Category: Video & Productivity Tools +> Surface: Desktop web (Electron) +> Source: tokens extracted from `openscreen-editor-2.html` (canonical) + +A high-fidelity, professional dark-and-light desktop application design system +optimized for video recording, post-processing editing, and timeline +manipulation. Mint is the single brand accent; red is reserved strictly for +REC / cut / skip / trim / transcript-highlight states; amber covers warnings. + +--- + +## Color Palette + +### Light theme (`:root`) + +| Role | Token | Hex | Notes | +|---|---|---|---| +| Canvas | `--bg` | `#fafbfc` | Off-white, never pure white; soft radial gradient | +| Panel | `--surface` | `#ffffff` | Raised panels on canvas | +| Card | `--surface-1` | `#f7f8fa` | Tier 1 raised | +| Tier 2 | `--surface-2` | `#f3f5f8` | Raised on panel | +| Hover | `--surface-3` | `#eef0f3` | Active/hover state | +| Popover | `--surface-hi` | `#ffffff` | Highest elevation | +| Border | `--border` | `#e7e9ee` | Soft hairline | +| Border soft | `--border-soft` | `#f1f2f5` | Subtle dividers | +| Border hi | `--border-hi` | `#d1d5db` | Emphasis lines | +| Foreground | `--fg` | `#1f2937` | Slate, not pure black | +| Foreground emphasis | `--fg-emphasis` | `#111827` | Headlines | +| Muted | `--muted` | `#6b7280` | Body / labels | +| Meta | `--meta` | `#9ca3af` | Timestamps, captions | +| Brand (mint) | `--accent` | `#10b981` | Primary action, focus ring, toggle-on | +| Brand glow | `--brand-glow` | `rgba(16,185,129,0.35)` | Focus / hover | +| Danger | `--danger` | `#ef4444` | REC, skip, trim, transcript highlight | +| Warning | `--warn` | `#f59e0b` | Soft amber | +| Success | `--success` | `#10b981` | Alias of brand | + +### Dark theme (`:root[data-theme="dark"]`) + +| Role | Token | Hex | Notes | +|---|---|---|---| +| Canvas | `--bg` | `#0a0d12` | Near-black with faint blue cast | +| Panel | `--surface` | `#14181f` | Tier 1 panel | +| Card | `--surface-1` | `#14181f` | Same as panel base | +| Tier 2 | `--surface-2` | `#1c2029` | Raised | +| Hover | `--surface-3` | `#252a35` | Hover/active | +| Popover | `--surface-hi` | `#2e3440` | Highest | +| Border | `--border` | `#252a35` | Reads on canvas | +| Foreground | `--fg` | `#e6e9ef` | Off-white, never pure `#ffffff` | +| Muted | `--muted` | `#8b95a3` | Tuned for AA on dark | +| Brand (mint) | `--accent` | `#10b981` | Same hue, brighter tone on `--brand-lo: #34d399` | +| Danger | `--danger` | `#f87171` | Softer red for dark bg | + +### Traffic lights (macOS chrome) + +| Token | Hex | +|---|---| +| `--light-red` | `#ff5f57` | +| `--light-yellow` | `#febc2e` | +| `--light-green` | `#28c840` | + +--- + +## Typography + +- **Display:** system-ui stack — `system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif` +- **Body:** system-ui stack (same as display) +- **Mono:** `ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace` + +### Type scale (desktop editor, base 13px) + +| Token | Size | Use | +|---|---|---| +| `--fs-app` | 13px | Base UI | +| `--fs-app-sm` | 12px | Secondary | +| `--fs-app-lg` | 14px | Primary UI | +| `--fs-title` | 16px | Panel titles | +| `--fs-section` | 11px | Section headers (uppercase, tracked) | +| `--fs-display` | 24px | Hero numerals | + +Nothing below 11px. + +--- + +## Layout + +- **Radius:** 8px standard; `--r-xs: 4px`, `--r-sm: 6px`, `--r-md: 8px`, `--r-lg: 12px`, `--r-pill: 9999px` +- **Border weight:** 1px +- **Spacing:** 4px baseline grid (`--sp-1` through `--sp-6`) +- **Editor wireframe:** + - Titlebar: 34px + - Left utility rail: 48px + - Left panel: 320px + - Right properties: 320px + - Resize handle: 6px + - Bottom timeline/toolbar: 224px + +### Posture rules + +- **One accent, used at most twice per screen.** Default budget is eyebrow + primary CTA. +- **Red is reserved** for REC, cut, skip, trim, and transcript highlight only. It is not a brand color. +- **Mint is the single brand color** for active state, focus ring, toggle-on, and brand mark. +- **Off-white canvas, tiered surfaces.** Never use pure `#000` or pure `#fff` for editorial chrome. +- **Soft elevation, slate-based shadows.** Two `--elev-card` and `--elev-pop` are enough. +- **System-ui type, mono numerics.** No web fonts loaded; ships the declared fallback stack. + +--- + +## Motion + +- `--motion-fast: 120ms` — hover, focus ring +- `--motion-base: 180ms` — state transitions +- `--ease: cubic-bezier(0.2, 0, 0, 1)` — standard easing + +--- + +## Voice & Tone + +- **Adjectives:** high-fidelity, professional, calm, restrained. +- **Tone:** a confident dark-themed tool. Marketing prose is sparse; product UI carries the work. +- **Messaging pillar:** video recording, post-processing editing, and timeline manipulation — same three jobs the editor does. + +### Vocabulary + +- **Use:** Record, Trim, Cut, Skip, Timeline, Clip, Track, Transcript. +- **Avoid:** playful emoji feature labels (✨ 🚀 🎯), "AI-powered" in product chrome, generic SaaS copy ("supercharge your workflow"). + +--- + +## Imagery + +- **Style:** schematic, palette-derived. Scene illustration in preview is grayscale made from the same neutrals — not literal artwork. +- **Treatment:** replace placeholder scenes with real project footage when shipping. +- **Avoid:** stock photography, decorative illustration in tool chrome, hand-drawn mascots. + +--- + +## Files in this directory + +| File | Role | +|---|---| +| `openscreen-editor-2.html` | Latest editor (light + dark themes in one file). Canonical source of tokens. | +| `openscreen-editor.html` | First editor pass. Light-only. | +| `editor.html` | Earlier experimental editor with red REC accent. | +| `openscreen-landing.html` | Marketing landing page. | +| `index.html` | Launcher / overview. | +| `DESIGN.md` | This document. | + +--- + +## Open questions / follow-ups + +- `editor.html` uses a different red-dominant accent and predates the mint brand color. Keep as historical reference; do not import its tokens into new work. +- Landing page (`openscreen-landing.html`) is light-only; consider a dark variant once product photography is sourced. +- No logo asset is committed yet. Brand mark should be a simple wordmark or geometric mark in mint on the dark canvas. diff --git a/design/SKILL.md b/design/SKILL.md new file mode 100644 index 0000000000..cf8e245985 --- /dev/null +++ b/design/SKILL.md @@ -0,0 +1,16 @@ +--- +name: openscreen-design +description: Use this skill to generate well-branded interfaces and assets for OpenScreen (an AI-native screen-recording studio & video editor), either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping. +user-invocable: true +--- + +Read the README.md file within this skill, and explore the other available files. +If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand. +If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need. + +Quick orientation: +- `styles.css` is the single CSS entry point — link it and use the CSS custom properties (never hard-code hex). Dark is the default; add `data-theme="light"` on a wrapper for the light palette. +- Type: Geist (UI) + Geist Mono (numbers/technical). Base 13px. +- One brand hue: emerald `--accent`. Ration it. Timeline lanes add amber/orange/red semantic accents. +- Components live in `components/` (forms, display, editor); full-screen recreations in `ui_kits/editor/`. Icons are Lucide. +- No emoji, no gradients-as-decoration, no photographic backgrounds. Dense pro-tool density, glassy floating panels, hairline borders. diff --git a/design/_ds_manifest.json b/design/_ds_manifest.json new file mode 100644 index 0000000000..082a6cb6ba --- /dev/null +++ b/design/_ds_manifest.json @@ -0,0 +1 @@ +{"namespace":"DesignSystem_d5355e","components":[{"name":"Badge","sourcePath":"components/display/Badge.jsx"},{"name":"Card","sourcePath":"components/display/Card.jsx"},{"name":"Chip","sourcePath":"components/display/Chip.jsx"},{"name":"ProgressBar","sourcePath":"components/display/ProgressBar.jsx"},{"name":"ChatBubble","sourcePath":"components/editor/ChatBubble.jsx"},{"name":"FacetRailButton","sourcePath":"components/editor/FacetRailButton.jsx"},{"name":"MediaCard","sourcePath":"components/editor/MediaCard.jsx"},{"name":"ProposalCard","sourcePath":"components/editor/ProposalCard.jsx"},{"name":"TimelinePill","sourcePath":"components/editor/TimelinePill.jsx"},{"name":"Button","sourcePath":"components/forms/Button.jsx"},{"name":"IconButton","sourcePath":"components/forms/IconButton.jsx"},{"name":"SegmentedControl","sourcePath":"components/forms/SegmentedControl.jsx"},{"name":"Select","sourcePath":"components/forms/Select.jsx"},{"name":"Slider","sourcePath":"components/forms/Slider.jsx"},{"name":"Switch","sourcePath":"components/forms/Switch.jsx"},{"name":"TextField","sourcePath":"components/forms/TextField.jsx"}],"startingPoints":[{"name":"editor","path":"ui_kits/editor/index.html","previewPath":"ui_kits/editor/index.html","kind":"screen","section":"Editor","subtitle":"OpenScreen editor shell (chat + stage + timeline)","viewport":"1360x840"}],"cards":[{"path":"guidelines/brand-elevation.card.html","group":"Brand","viewport":"700x170","subtitle":"Resting card vs floating popover","name":"Elevation"},{"path":"guidelines/brand-logo.card.html","group":"Brand","viewport":"700x140","subtitle":"Icon mark + Geist wordmark lockup","name":"Logo"},{"path":"guidelines/brand-motion.card.html","group":"Brand","viewport":"700x150","subtitle":"One easing, 0.15s; record dot pulses","name":"Motion"},{"path":"guidelines/brand-radii.card.html","group":"Brand","viewport":"700x170","subtitle":"Nested-radius rhythm, 2 → fully round","name":"Radii"},{"path":"guidelines/colors-brand.card.html","group":"Colors","viewport":"700x150","subtitle":"Emerald — the single brand hue","name":"Brand / accent"},{"path":"guidelines/colors-semantic.card.html","group":"Colors","viewport":"700x150","subtitle":"Timeline lanes & status — rationed accents","name":"Semantic"},{"path":"guidelines/colors-surfaces.card.html","group":"Colors","viewport":"700x160","subtitle":"Near-black blue-grey, low → high","name":"Surfaces & borders"},{"path":"guidelines/colors-text.card.html","group":"Colors","viewport":"700x140","subtitle":"Primary → meta text ramp","name":"Text"},{"path":"components/display/display.card.html","group":"Components","viewport":"700x300","subtitle":"Badge · Chip · Card · ProgressBar","name":"Display"},{"path":"components/editor/editor.card.html","group":"Components","viewport":"700x520","subtitle":"ChatBubble · ProposalCard · MediaCard · TimelinePill · FacetRailButton","name":"Editor"},{"path":"components/forms/forms.card.html","group":"Components","viewport":"700x430","subtitle":"Button · IconButton · SegmentedControl · Switch · Slider · Select · TextField","name":"Forms"},{"path":"ui_kits/editor/index.html","group":"Editor","viewport":"1360x840","subtitle":"Full OpenScreen editor — Edit mode"},{"path":"guidelines/spacing-scale.card.html","group":"Spacing","viewport":"700x150","subtitle":"Common rungs — dense, off-grid","name":"Scale"},{"path":"guidelines/type-families.card.html","group":"Type","viewport":"700x170","subtitle":"Geist (UI) + Geist Mono (numeric)","name":"Families"},{"path":"guidelines/type-reading.card.html","group":"Type","viewport":"700x150","subtitle":"Transcript reads at 1.85 line-height","name":"Reading"},{"path":"guidelines/type-scale.card.html","group":"Type","viewport":"700x230","subtitle":"9.5px labels → 16.5px headings","name":"Scale"}],"templates":[],"hasThumbnailHtml":false,"globalCssPaths":["tokens/base.css","tokens/fonts.css","tokens/colors.css","tokens/typography.css","tokens/spacing.css","tokens/radii.css","tokens/elevation.css","tokens/effects.css","styles.css"],"tokens":[{"name":"--ease","value":"cubic-bezier(0.4, 0, 0.2, 1)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur","value":"0.15s","kind":"other","definedIn":"tokens/base.css"},{"name":"--brand-green","value":"#34b27b","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-hover","value":"#2d9e6c","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-active","value":"#27885c","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-ring","value":"rgba(52, 178, 123, 0.5)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-glow","value":"rgba(74, 222, 128, 0.4)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--brand-green-tint","value":"rgba(52, 178, 123, 0.13)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--rec-red","value":"#ef4444","kind":"color","definedIn":"tokens/colors.css"},{"name":"--rec-red-text","value":"#f87171","kind":"font","definedIn":"tokens/colors.css"},{"name":"--rec-amber","value":"#f59e0b","kind":"color","definedIn":"tokens/colors.css"},{"name":"--rec-amber-text","value":"#fbbf24","kind":"font","definedIn":"tokens/colors.css"},{"name":"--background","value":"0 0% 100%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--foreground","value":"20 14.3% 4.1%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--card","value":"0 0% 100%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--card-foreground","value":"20 14.3% 4.1%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--popover","value":"0 0% 100%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--popover-foreground","value":"20 14.3% 4.1%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--primary","value":"24 9.8% 10%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--primary-foreground","value":"60 9.1% 97.8%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--secondary","value":"60 4.8% 95.9%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--secondary-foreground","value":"24 9.8% 10%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--muted","value":"60 4.8% 95.9%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--muted-foreground","value":"25 5.3% 44.7%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--accent","value":"60 4.8% 95.9%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--accent-foreground","value":"24 9.8% 10%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--destructive","value":"0 84.2% 60.2%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--destructive-foreground","value":"60 9.1% 97.8%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--border","value":"20 5.9% 90%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--input","value":"20 5.9% 90%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--ring","value":"20 14.3% 4.1%","kind":"other","definedIn":"tokens/colors.css"},{"name":"--radius","value":"0.5rem","kind":"radius","definedIn":"tokens/spacing.css"},{"name":"--shell-0","value":"#050606","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-1","value":"#07080a","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-2","value":"#08090b","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-3","value":"#090a0c","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-4","value":"#09090b","kind":"color","definedIn":"tokens/colors.css"},{"name":"--shell-5","value":"#0b0c10","kind":"color","definedIn":"tokens/colors.css"},{"name":"--line-subtle","value":"rgba(255, 255, 255, 0.055)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--line","value":"rgba(255, 255, 255, 0.075)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--line-strong","value":"rgba(255, 255, 255, 0.10)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--fill-subtle","value":"rgba(255, 255, 255, 0.032)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--fill","value":"rgba(255, 255, 255, 0.045)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--fill-hover","value":"rgba(255, 255, 255, 0.075)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--text-hi","value":"#ffffff","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-1","value":"#e4e4e7","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-2","value":"#d9e2ee","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-3","value":"#a1a1aa","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-4","value":"rgba(255, 255, 255, 0.6)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-5","value":"rgba(255, 255, 255, 0.4)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-slate-500","value":"#64748b","kind":"font","definedIn":"tokens/colors.css"},{"name":"--color-bg","value":"hsl(var(--background))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--color-fg","value":"hsl(var(--foreground))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-card","value":"hsl(var(--card))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-popover","value":"hsl(var(--popover))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--text-body","value":"hsl(var(--foreground))","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-muted","value":"hsl(var(--muted-foreground))","kind":"font","definedIn":"tokens/colors.css"},{"name":"--border-default","value":"hsl(var(--border))","kind":"color","definedIn":"tokens/colors.css"},{"name":"--focus-ring","value":"0 0 0 3px rgba(16,185,129,0.32)","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--accent-primary","value":"var(--brand-green)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--font-sans","value":"\"Inter\", ui-sans-serif, system-ui, -apple-system,\n\t\t\"Segoe UI\", Roboto, Helvetica, Arial, sans-serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-mono","value":"\"IBM Plex Mono\", \"Fira Code\", ui-monospace,\n\t\tSFMono-Regular, Menlo, Consolas, monospace","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-marker","value":"\"Permanent Marker\", cursive","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-hand","value":"\"Caveat\", cursive","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-impact","value":"\"Bebas Neue\", \"Oswald\", sans-serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-serif","value":"\"Playfair Display\", Georgia, serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--font-caption-grotesk","value":"\"Space Grotesk\", sans-serif","kind":"font","definedIn":"tokens/typography.css"},{"name":"--fw-regular","value":"400","kind":"other","definedIn":"tokens/typography.css"},{"name":"--fw-medium","value":"500","kind":"other","definedIn":"tokens/typography.css"},{"name":"--fw-semibold","value":"600","kind":"other","definedIn":"tokens/typography.css"},{"name":"--fw-bold","value":"700","kind":"other","definedIn":"tokens/typography.css"},{"name":"--text-9","value":"9px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-10","value":"10px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-11","value":"11px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-12","value":"12px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-13","value":"13px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-sm","value":"14px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-base","value":"16px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-lg","value":"18px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-xl","value":"20px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-2xl","value":"24px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--lh-none","value":"1","kind":"other","definedIn":"tokens/typography.css"},{"name":"--lh-tight","value":"1.15","kind":"other","definedIn":"tokens/typography.css"},{"name":"--lh-snug","value":"1.35","kind":"other","definedIn":"tokens/typography.css"},{"name":"--lh-normal","value":"1.5","kind":"other","definedIn":"tokens/typography.css"},{"name":"--lh-relaxed","value":"1.6","kind":"other","definedIn":"tokens/typography.css"},{"name":"--tracking-tight","value":"-0.01em","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-normal","value":"0","kind":"font","definedIn":"tokens/typography.css"},{"name":"--space-0","value":"0","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-0-5","value":"2px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-1","value":"4px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-1-5","value":"6px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-2","value":"8px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-2-5","value":"10px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-3","value":"12px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-3-5","value":"14px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-4","value":"16px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-5","value":"20px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-6","value":"24px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-8","value":"32px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-10","value":"40px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-12","value":"48px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--radius-sm","value":"6px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-md","value":"9px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-lg","value":"11px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-xl","value":"12px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-2xl","value":"14px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-3xl","value":"16px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-window","value":"24px","kind":"radius","definedIn":"tokens/spacing.css"},{"name":"--radius-full","value":"9999px","kind":"radius","definedIn":"tokens/spacing.css"},{"name":"--control-h-xs","value":"28px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-h-sm","value":"32px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-h-md","value":"36px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-h-lg","value":"40px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--icon-xs","value":"11px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--icon-sm","value":"13px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--icon-md","value":"16px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--icon-lg","value":"20px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--rail-width","value":"clamp(286px, 20vw, 352px)","kind":"other","definedIn":"tokens/spacing.css"},{"name":"--workspace-pad","value":"14px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--radius-xs","value":"2px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-pill","value":"9999px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--elev-card","value":"inset 0 1px 0 rgba(255,255,255,0.04), 0 2px 10px -4px rgba(0,0,0,0.55)","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--elev-pop","value":"0 20px 50px -16px rgba(0,0,0,0.75), inset 0 1px 0 rgba(255,255,255,0.05)","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--glow-accent","value":"0 2px 10px -3px var(--accent-soft)","kind":"color","definedIn":"tokens/elevation.css"},{"name":"--elev-card","value":"0 1px 2px rgba(15,23,42,0.05), 0 2px 10px -3px rgba(15,23,42,0.08)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-theme=\"light\"]"},{"name":"--elev-pop","value":"0 20px 48px -16px rgba(15,23,42,0.22), 0 4px 12px rgba(15,23,42,0.08)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-theme=\"light\"]"},{"name":"--focus-ring","value":"0 0 0 3px rgba(16,185,129,0.2)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-theme=\"light\"]"},{"name":"--shadow-hud","value":"0 20px 60px rgba(0, 0, 0, 0.42),\n\t\tinset 0 1px 0 rgba(255, 255, 255, 0.06)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-panel","value":"0 24px 70px rgba(0, 0, 0, 0.42),\n\t\tinset 0 1px 0 rgba(255, 255, 255, 0.045)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-popover","value":"0 18px 42px rgba(0, 0, 0, 0.48),\n\t\tinset 0 1px 0 rgba(255, 255, 255, 0.045)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-card","value":"inset 0 1px 0 rgba(255, 255, 255, 0.035)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-card-hover","value":"0 10px 24px rgba(0, 0, 0, 0.22),\n\t\tinset 0 1px 0 rgba(255, 255, 255, 0.04)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-badge","value":"0 6px 14px rgba(0, 0, 0, 0.35)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-inset-hi","value":"inset 0 1px 0 rgba(255, 255, 255, 0.025)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--shadow-capture","value":"0 40px 90px rgba(0, 0, 0, 0.45),\n\t\t0 12px 32px rgba(0, 0, 0, 0.35)","kind":"shadow","definedIn":"tokens/effects.css"},{"name":"--glass-blur","value":"blur(24px) saturate(150%)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--glass-blur-strong","value":"blur(24px) saturate(140%)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--glass-shell","value":"linear-gradient(\n\t\t145deg,\n\t\trgba(13, 14, 17, 0.94) 0%,\n\t\trgba(8, 9, 12, 0.9) 100%\n\t)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--glass-hud","value":"rgba(7, 8, 10, 0.9)","kind":"color","definedIn":"tokens/effects.css"},{"name":"--glass-popover","value":"rgba(8, 9, 12, 0.96)","kind":"color","definedIn":"tokens/effects.css"},{"name":"--gradient-workspace","value":"radial-gradient(\n\t\t\tcircle at 18% 0%,\n\t\t\trgba(52, 178, 123, 0.08),\n\t\t\ttransparent 30%\n\t\t),\n\t\tlinear-gradient(180deg, #08090b 0%, #050606 100%)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--gradient-panel","value":"linear-gradient(\n\t\t\t180deg,\n\t\t\trgba(255, 255, 255, 0.035),\n\t\t\trgba(255, 255, 255, 0.012)\n\t\t),\n\t\t#090a0c","kind":"other","definedIn":"tokens/effects.css"},{"name":"--gradient-selected","value":"linear-gradient(\n\t\t145deg,\n\t\trgba(52, 178, 123, 0.13),\n\t\trgba(255, 255, 255, 0.045)\n\t)","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur-fast","value":"120ms","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur-base","value":"150ms","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur-mid","value":"200ms","kind":"other","definedIn":"tokens/effects.css"},{"name":"--dur-slow","value":"300ms","kind":"other","definedIn":"tokens/effects.css"},{"name":"--press-scale","value":"0.95","kind":"other","definedIn":"tokens/effects.css"},{"name":"--ring-focus","value":"0 0 0 2px var(--brand-green-ring)","kind":"color","definedIn":"tokens/effects.css"},{"name":"--ring-slider","value":"0 0 0 4px rgba(52, 178, 123, 0.12)","kind":"shadow","definedIn":"tokens/effects.css"}],"themes":[{"selector":".dark","label":"Dark"},{"selector":"[data-theme=\"light\"]","label":"Light"}],"fonts":[],"brandFonts":[{"family":"Inter","status":"ok","tokens":["--font-sans"],"path":"tokens/typography.css"},{"family":"IBM Plex Mono","status":"ok","tokens":["--font-mono"],"path":"tokens/typography.css"},{"family":"Permanent Marker","status":"ok","tokens":["--font-caption-marker"],"path":"tokens/typography.css"},{"family":"Caveat","status":"ok","tokens":["--font-caption-hand"],"path":"tokens/typography.css"},{"family":"Bebas Neue","status":"ok","tokens":["--font-caption-impact"],"path":"tokens/typography.css"},{"family":"Playfair Display","status":"ok","tokens":["--font-caption-serif"],"path":"tokens/typography.css"},{"family":"Space Grotesk","status":"ok","tokens":["--font-caption-grotesk"],"path":"tokens/typography.css"}],"source":"spa"} diff --git a/design/assets/logo-icon.png b/design/assets/logo-icon.png new file mode 100644 index 0000000000..7163a46553 Binary files /dev/null and b/design/assets/logo-icon.png differ diff --git a/design/components/display/Badge.jsx b/design/components/display/Badge.jsx new file mode 100644 index 0000000000..ae809b1497 --- /dev/null +++ b/design/components/display/Badge.jsx @@ -0,0 +1,45 @@ +import React from 'react'; + +/** + * OpenScreen Badge — the small status pill. Optional leading dot. + * tones: accent (default) · neutral · danger · warn. `soft` uses the + * tinted fill (default); pass soft={false} for a bordered outline. + */ +export function Badge({ + children, + tone = 'accent', + dot = false, + soft = true, + mono = true, + style = {}, +}) { + const map = { + accent: { fg: 'var(--accent)', bg: 'var(--accent-soft)', bd: 'var(--accent-border)', dot: 'var(--accent)' }, + neutral: { fg: 'var(--muted)', bg: 'var(--surface-2)', bd: 'var(--border)', dot: 'var(--muted)' }, + danger: { fg: 'var(--danger)', bg: 'var(--danger-soft)', bd: 'var(--danger)', dot: 'var(--danger)' }, + warn: { fg: 'var(--annotation)', bg: 'var(--annotation-wash)', bd: 'var(--annotation)', dot: 'var(--annotation)' }, + }; + const c = map[tone] || map.accent; + return ( + + {dot && } + {children} + + ); +} diff --git a/design/components/display/Card.jsx b/design/components/display/Card.jsx new file mode 100644 index 0000000000..44a5065504 --- /dev/null +++ b/design/components/display/Card.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +/** + * OpenScreen Card — the flat bordered surface container. `elevation` + * "card" (resting) or "pop" (floating). `level` picks the fill surface. + * Optional `title` renders a bordered header. + */ +export function Card({ + children, + title = null, + headerRight = null, + elevation = 'card', + level = 1, + radius = 14, + padding = 14, + style = {}, + bodyStyle = {}, +}) { + const fills = { 0: 'var(--surface)', 1: 'var(--surface-1)', 2: 'var(--surface-2)' }; + return ( +
+ {title && ( +
+ {title} + {headerRight && {headerRight}} +
+ )} +
{children}
+
+ ); +} diff --git a/design/components/display/Chip.jsx b/design/components/display/Chip.jsx new file mode 100644 index 0000000000..670e6d3cfd --- /dev/null +++ b/design/components/display/Chip.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +/** + * OpenScreen Chip — rounded action/filter pill with optional leading + * icon. Used for the quick-action row above the composer. Hover gives + * an accent wash + border. + */ +export function Chip({ children, icon = null, onClick, style = {} }) { + const [hover, setHover] = React.useState(false); + return ( + + ); +} diff --git a/design/components/display/ProgressBar.jsx b/design/components/display/ProgressBar.jsx new file mode 100644 index 0000000000..26cc7fcecb --- /dev/null +++ b/design/components/display/ProgressBar.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +/** + * OpenScreen ProgressBar — thin track with an emerald (or gradient) + * fill. Used for the recipe step progress and generic determinate + * progress. Height defaults to the 3px recipe bar. + */ +export function ProgressBar({ value = 0, height = 3, gradient = true, style = {} }) { + const pct = Math.max(0, Math.min(100, value)); + return ( +
+
+
+ ); +} diff --git a/design/components/display/display.card.html b/design/components/display/display.card.html new file mode 100644 index 0000000000..df22c869d7 --- /dev/null +++ b/design/components/display/display.card.html @@ -0,0 +1,58 @@ + + + + + + + + + + +
+ + diff --git a/design/components/editor/ChatBubble.jsx b/design/components/editor/ChatBubble.jsx new file mode 100644 index 0000000000..7d0959a035 --- /dev/null +++ b/design/components/editor/ChatBubble.jsx @@ -0,0 +1,54 @@ +import React from 'react'; + +/** + * OpenScreen ChatBubble — a single agent-conversation row. Handles the + * three roles: assistant (avatar + left bubble, meta header), user + * (right, emerald-tinted), system (centered pill). + */ +export function ChatBubble({ role = 'assistant', author, time, children, avatar = null }) { + if (role === 'system') { + return ( +
+
+ {children} +
+
+ ); + } + if (role === 'user') { + return ( +
+
+ {(author || time) && ( +
+ {author} + {time && {time}} +
+ )} +
+ {children} +
+
+
+ ); + } + // assistant + return ( +
+ +
+ {(author || time) && ( +
+ {author} + {time && {time}} +
+ )} +
+ {children} +
+
+
+ ); +} diff --git a/design/components/editor/FacetRailButton.jsx b/design/components/editor/FacetRailButton.jsx new file mode 100644 index 0000000000..9490d85f0c --- /dev/null +++ b/design/components/editor/FacetRailButton.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +/** + * OpenScreen FacetRailButton — an icon button in the vertical facet + * rail beside the inspector. Active facet gets the emerald-soft fill. + */ +export function FacetRailButton({ active = false, title, onClick, children, style = {} }) { + const [hover, setHover] = React.useState(false); + let color = active ? 'var(--accent)' : 'var(--muted)'; + let background = active ? 'var(--accent-soft)' : 'transparent'; + if (!active && hover) { color = 'var(--fg)'; background = 'var(--surface-3)'; } + return ( + + ); +} diff --git a/design/components/editor/MediaCard.jsx b/design/components/editor/MediaCard.jsx new file mode 100644 index 0000000000..68a0508adb --- /dev/null +++ b/design/components/editor/MediaCard.jsx @@ -0,0 +1,52 @@ +import React from 'react'; + +/** + * OpenScreen MediaCard — a clip tile in the media library. Gradient + * thumbnail with a film-strip glyph, a drag-handle affordance, and a + * name/duration/size footer. Selected → 1.5px emerald border. + */ +export function MediaCard({ + name, + duration, + size, + from = '#10b981', + to = '#0d986a', + selected = false, + draggable = true, + onClick, + onDragStart, + onDragEnd, + style = {}, +}) { + return ( + + ); +} diff --git a/design/components/editor/ProposalCard.jsx b/design/components/editor/ProposalCard.jsx new file mode 100644 index 0000000000..f8320a9dda --- /dev/null +++ b/design/components/editor/ProposalCard.jsx @@ -0,0 +1,49 @@ +import React from 'react'; + +/** + * OpenScreen ProposalCard — the agent's "Proposed cuts" card: a header + * with total + confidence badge, a list of time-range rows with striped + * clip chips, an apply/review action row, and a rationale footnote. + */ +export function ProposalCard({ + title = 'Proposed cuts', + total, + confidence = 'High confidence', + items = [], + rationale, + applyLabel = 'Apply', + onApply, + onReview, +}) { + return ( +
+
+ + {title} + {total != null && −{total}} + + {confidence} + +
+
+ {items.map((it, i) => ( +
+
+ ))} +
+
+ + +
+ {rationale && ( +
+ + {rationale} +
+ )} +
+ ); +} diff --git a/design/components/editor/TimelinePill.jsx b/design/components/editor/TimelinePill.jsx new file mode 100644 index 0000000000..91d4572f02 --- /dev/null +++ b/design/components/editor/TimelinePill.jsx @@ -0,0 +1,51 @@ +import React from 'react'; + +/** + * OpenScreen TimelinePill — a labelled marker on a timeline lane. + * tone maps to the four lane accents. `fixedWidth` (tag mode) sizes to + * content; otherwise it spans a range via left/width percentages. + */ +export function TimelinePill({ + tone = 'accent', + icon = null, + children, + leftPct = 0, + widthPct = null, + style = {}, +}) { + const map = { + accent: { c: 'var(--accent)', w: 'var(--accent-soft)' }, + annotation: { c: 'var(--annotation)', w: 'var(--annotation-wash)' }, + speed: { c: 'var(--speed)', w: 'var(--speed-wash)' }, + danger: { c: 'var(--danger)', w: 'var(--danger-soft)' }, + }; + const t = map[tone] || map.accent; + return ( + + {icon} + {children} + + ); +} diff --git a/design/components/editor/editor.card.html b/design/components/editor/editor.card.html new file mode 100644 index 0000000000..017c424c88 --- /dev/null +++ b/design/components/editor/editor.card.html @@ -0,0 +1,73 @@ + + + + + + + + + + +
+ + diff --git a/design/components/forms/Button.jsx b/design/components/forms/Button.jsx new file mode 100644 index 0000000000..da7b521d46 --- /dev/null +++ b/design/components/forms/Button.jsx @@ -0,0 +1,88 @@ +import React from 'react'; + +/** + * OpenScreen Button — the primary text action. + * variants: primary (emerald fill) · secondary (surface + border) · ghost (transparent). + * Icons are passed as children alongside a label, or use IconButton for icon-only. + */ +export function Button({ + variant = 'primary', + size = 'md', + icon = null, + iconRight = null, + disabled = false, + fullWidth = false, + onClick, + children, + style = {}, + ...rest +}) { + const sizes = { + sm: { h: 28, px: 10, fs: 12, gap: 6, radius: 8 }, + md: { h: 32, px: 14, fs: 13, gap: 7, radius: 9 }, + lg: { h: 40, px: 18, fs: 14, gap: 9, radius: 12 }, + }; + const s = sizes[size] || sizes.md; + + const variants = { + primary: { + background: 'var(--accent)', + color: '#fff', + border: '1px solid var(--accent)', + boxShadow: '0 2px 10px -3px var(--accent-soft)', + }, + secondary: { + background: 'var(--surface-1)', + color: 'var(--fg-2)', + border: '1px solid var(--border)', + }, + ghost: { + background: 'transparent', + color: 'var(--fg-2)', + border: '1px solid transparent', + }, + }; + const v = variants[variant] || variants.primary; + + const base = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: s.gap, + height: s.h, + padding: `0 ${s.px}px`, + borderRadius: s.radius, + fontFamily: 'var(--font-display)', + fontSize: s.fs, + fontWeight: 600, + lineHeight: 1, + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.45 : 1, + whiteSpace: 'nowrap', + ...v, + ...style, + }; + + const [hover, setHover] = React.useState(false); + const hoverStyle = !disabled && hover ? ( + variant === 'primary' ? { background: 'var(--brand-lo)' } + : variant === 'secondary' ? { borderColor: 'var(--border-hi)', color: 'var(--fg)' } + : { background: 'var(--surface-1)', borderColor: 'var(--border)' } + ) : {}; + + return ( + + ); +} diff --git a/design/components/forms/IconButton.jsx b/design/components/forms/IconButton.jsx new file mode 100644 index 0000000000..46421a7e8d --- /dev/null +++ b/design/components/forms/IconButton.jsx @@ -0,0 +1,59 @@ +import React from 'react'; + +/** + * OpenScreen IconButton — square, icon-only. The workhorse of toolbars, + * the topbar, and floating chrome. Ghost by default; `active` gives the + * emerald-soft selected look; `tone="danger"` for destructive. + */ +export function IconButton({ + size = 32, + active = false, + tone = 'default', + disabled = false, + title, + onClick, + children, + style = {}, + ...rest +}) { + const [hover, setHover] = React.useState(false); + + const rest_ = { default: 'var(--muted)', danger: 'var(--muted)' }[tone]; + let color = active ? 'var(--accent)' : rest_; + let background = active ? 'var(--accent-soft)' : 'transparent'; + + if (!disabled && hover && !active) { + if (tone === 'danger') { color = 'var(--danger)'; background = 'var(--danger-soft)'; } + else { color = 'var(--fg)'; background = 'var(--surface-2)'; } + } + + return ( + + ); +} diff --git a/design/components/forms/SegmentedControl.jsx b/design/components/forms/SegmentedControl.jsx new file mode 100644 index 0000000000..852654c116 --- /dev/null +++ b/design/components/forms/SegmentedControl.jsx @@ -0,0 +1,64 @@ +import React from 'react'; + +/** + * OpenScreen SegmentedControl — the pill-in-a-trough tab switcher. + * Used for Media/Edit/Rec stage modes, Image/Color/Gradient background + * tabs, Screen/Window source, etc. The active segment gets a raised + * surface chip; inactive segments are muted text. + */ +export function SegmentedControl({ + options = [], + value, + onChange, + size = 'md', + style = {}, +}) { + const s = size === 'sm' + ? { pad: '6px 8px', fs: 11.5, trough: 2, radius: 8, inner: 6 } + : { pad: '7px 14px', fs: 12.5, trough: 3, radius: 11, inner: 8 }; + + return ( +
+ {options.map((opt) => { + const val = typeof opt === 'string' ? opt : opt.value; + const label = typeof opt === 'string' ? opt : opt.label; + const active = val === value; + return ( + + ); + })} +
+ ); +} diff --git a/design/components/forms/Select.jsx b/design/components/forms/Select.jsx new file mode 100644 index 0000000000..9399542727 --- /dev/null +++ b/design/components/forms/Select.jsx @@ -0,0 +1,43 @@ +import React from 'react'; + +/** + * OpenScreen Select — native dropdown styled to match. Used for + * caption style, layout preset, transcript language, etc. + */ +export function Select({ + options = [], + value, + onChange, + fullWidth = true, + style = {}, + ...rest +}) { + return ( + + ); +} diff --git a/design/components/forms/Slider.jsx b/design/components/forms/Slider.jsx new file mode 100644 index 0000000000..fcab589fd7 --- /dev/null +++ b/design/components/forms/Slider.jsx @@ -0,0 +1,59 @@ +import React from 'react'; + +/** + * OpenScreen Slider — the labelled range control inside inspector cards. + * Renders the whole card: label (left) + mono value in emerald (right) + * above a filled range track. Pass `card={false}` for a bare track. + */ +export function Slider({ + label, + value = 0, + min = 0, + max = 100, + step = 1, + format, + onChange, + card = true, + style = {}, +}) { + const pct = ((value - min) / (max - min)) * 100; + const trackStyle = { + width: '100%', + display: 'block', + backgroundImage: `linear-gradient(var(--accent),var(--accent)), linear-gradient(var(--surface-3),var(--surface-3))`, + backgroundSize: `${pct}% 5px, 100% 5px`, + }; + const display = format ? format(value) : value; + + const input = ( + onChange && onChange(Number(e.target.value))} + style={trackStyle} + /> + ); + + if (!card) return input; + + return ( +
+
+ {label} + {display} +
+ {input} +
+ ); +} diff --git a/design/components/forms/Switch.jsx b/design/components/forms/Switch.jsx new file mode 100644 index 0000000000..ee13eb719f --- /dev/null +++ b/design/components/forms/Switch.jsx @@ -0,0 +1,47 @@ +import React from 'react'; + +/** + * OpenScreen Switch — the pill toggle used in inspector setting rows + * (Blur background, Mirror webcam, Shrink on zoom, Show cursor…). + * Track fills emerald when on; knob slides right. + */ +export function Switch({ checked = false, onChange, disabled = false, style = {} }) { + const W = 38, H = 22, KNOB = 16, PAD = 3; + return ( + + ); +} diff --git a/design/components/forms/TextField.jsx b/design/components/forms/TextField.jsx new file mode 100644 index 0000000000..1cc6298286 --- /dev/null +++ b/design/components/forms/TextField.jsx @@ -0,0 +1,69 @@ +import React from 'react'; + +/** + * OpenScreen TextField — one primitive for both single-line inputs and + * the multiline composer (same border/fill/radius vocabulary). + * `multiline` swaps for an auto-height +
+ + +
+
+ + + + + + +
+ + +
+
+
+ + 00:00:12.4 + 1920 × 1080 · 60 fps +
Aperçu · 16:9
+
+ +
+
+
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + +
+
+ + + +
+ + Scroll + Pan + + + Ctrl+ Scroll + Zoom + +
+ +
+ +
+ + + + +
+
+
0:00.0
+
0:30.0
+
1:00.0
+
1:30.0
+
2:00.0
+
2:30.0
+
3:00.0
+
3:30.0
+
4:00.0
+
4:30.0
+
5:00.0
+
5:30.0
+
6:00.0
+
6:30.0
+
7:00.0
+
+
+ + +
+ +
+ + + + +
+ + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

demo-n8n-as-code-1.mp4

+

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

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

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

+

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

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