diff --git a/.gitattributes b/.gitattributes index 441b9c43f..2c38460da 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,3 +16,6 @@ CMakeLists.txt text eol=lf # so the next one-line edit showed up as a whole-file rewrite in two locales. *.md text eol=lf *.mdx text eol=lf + +# Adopted measurement manifests hash exact bytes; keep their artifacts unchanged. +workbench/measurements/** -text diff --git a/.github/scripts/previous-stable-tag.mjs b/.github/scripts/previous-stable-tag.mjs new file mode 100644 index 000000000..6b42864e9 --- /dev/null +++ b/.github/scripts/previous-stable-tag.mjs @@ -0,0 +1,41 @@ +// Prints the tag a release's notes start from: the highest stable tag (vX.Y.Z, +// no suffix) below the given version, by SemVer, or an empty line when there is +// none. The tags come on stdin as `git tag -l 'v*'` lists them: every tag, +// whether or not it is reachable from the release commit. +// +// build.yml used to guess that tag's name by arithmetic and fall back to +// `git describe` when the guess did not exist (#1002). 2.0.0 guessed v1.0.0, +// which never existed, and `git describe` on release/v2.0.0 found the release's +// own last RC: v2.0.0 shipped with an empty changelog. A guess that did exist +// could still be wrong: 1.13.0 compared against v1.12.0 and repeated the PRs of +// v1.12.1 and v1.12.2. Only a tag that exists can come out of here, which also +// keeps the RC notes' `git log ..` from dying on `unknown revision`, as +// v1.9.4-rc.1's did when the guess named a 1.9.3 that never shipped. + +import { readFileSync } from "node:fs"; +import { argv } from "node:process"; + +const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/; + +const parse = (tag) => tag.match(STABLE_TAG)?.slice(1).map(Number); +const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; + +/** + * @param {string} version Stable version being released, e.g. "2.0.0". + * @param {string[]} tags Tag names; anything but vX.Y.Z is ignored. + * @returns {string} The highest vX.Y.Z below `version`, or "" when there is none. + */ +export function previousStableTag(version, tags) { + const current = parse(`v${version}`); + if (!current) throw new Error(`invalid version ${JSON.stringify(version)}; expected X.Y.Z`); + const below = tags + .map((tag) => [tag, parse(tag)]) + .filter(([, parts]) => parts && compare(parts, current) < 0); + below.sort(([, a], [, b]) => compare(b, a)); + return below[0]?.[0] ?? ""; +} + +// Only run when invoked directly, so the test can import the function. +if (import.meta.filename === argv[1]) { + console.log(previousStableTag(argv[2], readFileSync(0, "utf8").split(/\s+/))); +} diff --git a/.github/scripts/previous-stable-tag.test.mjs b/.github/scripts/previous-stable-tag.test.mjs new file mode 100644 index 000000000..38be7047c --- /dev/null +++ b/.github/scripts/previous-stable-tag.test.mjs @@ -0,0 +1,78 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { previousStableTag } from "./previous-stable-tag.mjs"; + +// Real tags of this repository, in the order `git tag -l` prints them: by name, so v1.9.x +// comes after v1.13.0 and picking by position or as text gets it wrong. The v0.0.0-* tags +// host release assets; `git describe` once picked one of them for v2.0.0-rc.1's notes. +const TAGS = [ + "v0.0.0", + "v0.0.0-onnxruntime-1.27.1", + "v1.0.1", + "v1.10.0", + "v1.12.0", + "v1.12.1", + "v1.12.1-rc.1", + "v1.12.2", + "v1.12.2-rc.1", + "v1.13.0", + "v1.13.0-rc.6", + "v1.9.2", + "v1.9.3-rc.1", + "v1.9.6", + "v2.0.0", + "v2.0.0-rc.15", +]; + +describe("previousStableTag", () => { + it("compares a major against the last stable of the line before", () => { + // The arithmetic guessed v1.0.0, which does not exist, and the fallback found + // v2.0.0-rc.15: v2.0.0 shipped with an empty changelog (#1002). + expect(previousStableTag("2.0.0", TAGS)).toBe("v1.13.0"); + }); + + it("compares a minor against the last patch, not the .0", () => { + // Against v1.12.0, 1.13.0's notes repeated every PR of v1.12.1 and v1.12.2. + expect(previousStableTag("1.13.0", TAGS)).toBe("v1.12.2"); + }); + + it("compares a patch against the patch before it", () => { + expect(previousStableTag("1.12.2", TAGS)).toBe("v1.12.1"); + }); + + it("is empty when no stable tag sorts below", () => { + // A first release: its RCs exist, nothing before them does. + expect(previousStableTag("1.0.0", ["v1.0.0", "v1.0.0-rc.1", "v1.0.0-rc.2"])).toBe(""); + }); + + it("ignores pre-release tags", () => { + // 1.9.3 shipped only as rc.1, so the last release before 1.9.4 is v1.9.2. + expect(previousStableTag("1.9.4", TAGS)).toBe("v1.9.2"); + }); + + it("rejects a version that is not X.Y.Z", () => { + expect(() => previousStableTag("2.0.0-rc.1", TAGS)).toThrow(/invalid version/); + }); + + // The direct-invocation guard makes the script print nothing at all if it ever stops + // matching, and the workflow would read that as "no previous release". So this runs it + // exactly as build.yml does: relative path from the repo root, the tags on stdin. + it("runs the way build.yml invokes it", () => { + const repoRoot = join(import.meta.dirname, "../.."); + const workflow = readFileSync(join(repoRoot, ".github/workflows/build.yml"), "utf8"); + expect(workflow).toContain( + `git tag -l 'v*' | node .github/scripts/previous-stable-tag.mjs "$STABLE_VERSION"`, + ); + + const run = (version) => + execFileSync("node", [".github/scripts/previous-stable-tag.mjs", version], { + cwd: repoRoot, + input: `${TAGS.join("\n")}\n`, + encoding: "utf8", + }); + expect(run("2.0.0")).toBe("v1.13.0\n"); + expect(run("0.0.0")).toBe("\n"); + }); +}); diff --git a/.github/scripts/refresh-nix-npm-hash.sh b/.github/scripts/refresh-nix-npm-hash.sh new file mode 100755 index 000000000..f9c9c8c48 --- /dev/null +++ b/.github/scripts/refresh-nix-npm-hash.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Rewrites `npmDepsHash` in nix/package.nix for the package-lock.json in the working tree. +# +# Called by prerelease.yml and promote.yml right after set-release-version.mjs, in the same +# commit. The lockfile's root `version` is part of what the hash covers, so every version bump +# changes it; that commit is `[skip ci]`, and refreshing the hash anywhere later left `nix build` +# red from the release until a separate bump PR landed. Needs Nix on PATH. +set -euo pipefail + +hash=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json) +if [[ -z "$hash" ]]; then + echo "::error::prefetch-npm-deps returned an empty hash" + exit 1 +fi +sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${hash}\";|" nix/package.nix +grep -q "npmDepsHash = \"${hash}\";" nix/package.nix || { + echo "::error::npmDepsHash line not found in nix/package.nix" + exit 1 +} +echo "npmDepsHash: ${hash}" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 387bed2b9..eff4443b6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,10 +52,12 @@ jobs: uses: ./.github/actions/setup # 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. + # timestamps); no model is fetched here. The binary is built by + # build-whisper-stt.yml and staged below, from the run built from this + # commit's helper sources — without that step the installer ships without + # speech-to-text. See + # technical-documentation/architecture/transcription-and-captions.md, and + # technical-documentation/engineering/release-and-secrets.md for the run. - name: Stage whisper-stt binaries shell: bash env: @@ -376,6 +378,13 @@ jobs: - name: Stage ONNX Runtime run: npm run fetch:onnxruntime + # Fourth step spelled out for the same reason: `build:mac`, `build:win` and + # `build:linux` chain it before electron-builder. It writes the licence texts of the + # bundled npm packages and of the crates in the addon; scripts/before-pack.cjs + # refuses to package without them. + - name: Generate third-party licences + run: npm run licenses:generate + - name: Package .app bundle run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never env: @@ -649,9 +658,9 @@ jobs: # bsdtar (libarchive-tools) is fpm's mtree generator for the pacman target. # rpmbuild (rpm) is what fpm shells out to for the rpm target; without it the - # build fails at packaging, not at config parse. + # build fails at packaging, not at config parse. zsync generates AppImageUpdate sidecars. - name: Install Linux packaging dependencies - run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm + run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm zsync # patchelf is what build-linux-compositor-addon.mjs renames the ffmpeg symbols # with, so the addon cannot bind to Chromium's bundled ffmpeg — an unconditional @@ -706,6 +715,21 @@ jobs: exit 1 fi + - name: Verify AppImage update information and sidecars + run: | + VERSION="$(node -p 'require("./package.json").version')" + EXPECTED='gh-releases-zsync|getopenscreen|openscreen|latest|Openscreen-Linux-*.AppImage.zsync' + # Helper-only prereleases can be selected by AppImageUpdate's latest-all channel. + # RCs keep using electron-updater; their .zsync is available by its explicit URL. + if [[ "$VERSION" == *-* ]]; then EXPECTED=''; fi + while IFS= read -r -d '' IMAGE; do + test -s "${IMAGE}.zsync" \ + || { echo "::error::${IMAGE}.zsync missing — external AppImage updates have no sidecar"; exit 1; } + INFO="$("$IMAGE" --appimage-updateinformation)" + [[ "$INFO" == "$EXPECTED" ]] \ + || { echo "::error::Unexpected AppImage update information in ${IMAGE}: ${INFO}"; exit 1; } + done < <(find release -type f -name '*.AppImage' -print0) + # The Linux counterpart of the Windows job's "Verify native binaries load under # package identity", added for the same reason and after the same kind of miss: # 1.9.1 shipped three sonames that nothing declared and nothing bundled — libgbm.so.1 @@ -745,16 +769,12 @@ jobs: name: openscreen-linux path: | release/**/*.AppImage + release/**/*.AppImage.zsync release/**/*.deb release/**/*.pacman release/**/*.rpm release/**/latest-linux.yml - # Still no *.zsync, and there never will be: zsync was electron-updater's old delta - # format and app-builder-lib 26.x dropped it in favour of the block map it embeds - # directly in the AppImage. That glob had matched nothing since the dependency bump, - # silently — `if-no-files-found: error` evaluates the union of these patterns, so one - # dead glob among live ones never fails. Hence the explicit assertion below. - # + # .zsync serves external AppImage tools; electron-updater uses the embedded blockmap. # latest-linux.yml serves all four formats from one file: each updater picks its own # extension out of the `files:` list. app-update.yml and the `package-type` marker are # already inside the deb/rpm/pacman payloads — only this feed was missing. @@ -778,8 +798,8 @@ jobs: - name: Checkout code uses: actions/checkout@v7 with: - # Full history + tags: the RC notes below are built from `git log` over the - # range since the previous RC tag, and resolving that tag needs the tags. + # Full history + tags: the previous release's tag is picked from every tag in the + # repo, and the RC notes below are built from `git log` over the range since it. fetch-depth: 0 - name: Resolve release tag @@ -818,16 +838,11 @@ jobs: # Compute the previous stable tag for auto-generated release notes. We don't use # GitHub's "most recent prior release by date" because the fork carries re-published # upstream releases whose published_at is more recent than the fork's own first release. - # For SemVer X.Y.Z: previous is vX.Y.(Z-1) if Z>0, else vX.(Y-1).0, else v(X-1).0.0. + # Picked by SemVer among the tags that exist, never guessed from the version number: + # the script says what guessing cost (#1002). Left EMPTY when no stable tag sorts + # below this version; each consumer below decides what "no previous release" means. STABLE_VERSION="${VERSION%%-*}" - IFS='.' read -r PX PY PZ <<< "$STABLE_VERSION" - if (( PZ > 0 )); then - NOTES_START_TAG="v${PX}.${PY}.$((PZ - 1))" - elif (( PY > 0 )); then - NOTES_START_TAG="v${PX}.$((PY - 1)).0" - else - NOTES_START_TAG="v$((PX - 1)).0.0" - fi + NOTES_START_TAG="$(git tag -l 'v*' | node .github/scripts/previous-stable-tag.mjs "$STABLE_VERSION")" # For an RC, compare against the PREVIOUS RC of the same line, not the previous # stable. Deriving the start tag from STABLE_VERSION alone made every RC of a # line span the same range, so each re-cut just repeated the last RC's notes @@ -843,27 +858,6 @@ jobs: fi done fi - # Everything above computes what the previous release was *called* and - # never checks that it exists. A version line that stopped at its RC - # makes that a name for nothing: 1.9.3 shipped only as rc.1, so - # v1.9.4-rc.1 asked git for v1.9.3..v1.9.4-rc.1 and the publish step - # died on `unknown revision` -- after all four platforms had already - # built, and with publish-msstore sitting behind publish-release, so - # the same gap would silently block a stable release's Store - # deployment too. The rc walk-down above handles a skipped RC; nothing - # handled a skipped stable, and it does not run at all for an rc.1. - if ! git rev-parse -q --verify "refs/tags/${NOTES_START_TAG}" >/dev/null; then - # The nearest tag reachable from the release commit's parent, which - # is what "since the last release" meant in the first place. Left - # EMPTY when no tag is reachable at all, rather than filled with the - # root commit: the stable path hands this to `gh release create - # --notes-start-tag`, which is the API's previous_tag_name and takes - # a tag NAME -- a commit SHA there is not a lenient fallback, it is - # an invalid argument. Each consumer below decides what "no previous - # release" means for it. - NOTES_START_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" - echo "Previous-release tag did not exist; using ${NOTES_START_TAG:-}" - fi echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/bump-nix-package.yml b/.github/workflows/bump-nix-package.yml deleted file mode 100644 index 5090b184c..000000000 --- a/.github/workflows/bump-nix-package.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Bump Nix package on release - -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: "Release tag to bump (e.g. v1.5.0)" - required: true - type: string - -# GITHUB_TOKEN only pushes the branch here — the PR itself is opened with the -# PAT below, so no `pull-requests: write` is needed (and it never worked). -permissions: - contents: write - -jobs: - bump: - runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease - steps: - - name: Resolve tag and version - id: meta - env: - GH_EVENT_TAG: ${{ github.event.release.tag_name }} - INPUT_TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - TAG="${GH_EVENT_TAG:-$INPUT_TAG}" - if [[ -z "$TAG" ]]; then - echo "::error::No tag resolved from release event or workflow input" - exit 1 - fi - VERSION="${TAG#v}" - BRANCH="chore/bump-nix-${VERSION}" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" - - - name: Checkout main - uses: actions/checkout@v7 - with: - ref: main - fetch-depth: 0 - - - name: Install Nix - uses: cachix/install-nix-action@v27 - with: - nix_path: nixpkgs=channel:nixos-unstable - extra_nix_config: | - experimental-features = nix-command flakes - - - name: Compute npmDepsHash - id: hash - run: | - set -euo pipefail - HASH=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json) - if [[ -z "$HASH" ]]; then - echo "::error::prefetch-npm-deps returned an empty hash" - exit 1 - fi - echo "hash=$HASH" >> "$GITHUB_OUTPUT" - echo "Computed npmDepsHash: $HASH" - - - name: Update nix/package.nix - env: - VERSION: ${{ steps.meta.outputs.version }} - HASH: ${{ steps.hash.outputs.hash }} - run: | - set -euo pipefail - # Update version line: ` version = "";` - sed -i -E "s|^([[:space:]]*version[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${VERSION}\";|" nix/package.nix - # Update npmDepsHash line: ` npmDepsHash = "";` - sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${HASH}\";|" nix/package.nix - - echo "=== diff ===" - git --no-pager diff nix/package.nix || true - - - name: Create PR - env: - # NOT GITHUB_TOKEN: the repo has "Allow GitHub Actions to create and - # approve pull requests" turned off, so `gh pr create` dies with - # "GitHub Actions is not permitted to create or approve pull requests" - # — it did exactly that on v1.7.0, after pushing the branch, and #136 - # had to be opened by hand. The PAT every other release workflow - # already uses has no such restriction, and its PRs trigger CI. - GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} - VERSION: ${{ steps.meta.outputs.version }} - HASH: ${{ steps.hash.outputs.hash }} - BRANCH: ${{ steps.meta.outputs.branch }} - TAG: ${{ steps.meta.outputs.tag }} - run: | - set -euo pipefail - - if git diff --quiet -- nix/package.nix; then - echo "nix/package.nix already at v${VERSION} with this hash — nothing to do." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - # Replace any prior bump branch to keep the workflow idempotent. - git push origin --delete "$BRANCH" 2>/dev/null || true - git checkout -b "$BRANCH" - git add nix/package.nix - git commit -m "chore: bump nix package to v${VERSION}" - git push -u origin "$BRANCH" - - gh pr create \ - --title "chore: bump nix package to v${VERSION}" \ - --base main \ - --head "$BRANCH" \ - --body "$(cat <"$1"; shift; exec "$@"' \ + xvfb-diag "$XVFB_DIAG/run-cli-$RUN_CLI_N.display" ./result/bin/openscreen "$@" || rc=$? + # The status that reaches the caller must stay the command's own, + # or the HUNG/OK/FAILED accounting above would read the echo's. + echo "run_cli #$RUN_CLI_N: DISPLAY=$(cat "$XVFB_DIAG/run-cli-$RUN_CLI_N.display" 2>/dev/null || echo unknown), Xvfb output in $XVFB_DIAG/run-cli-$RUN_CLI_N.xvfb.log" + return "$rc" } SANDBOX="" @@ -398,6 +436,14 @@ jobs: # xvfb-run folds the command's stderr into stdout, so the stderr # capture is empty and every milestone lands in the other file. grep -a milestone "/tmp/sources.$i.out" || echo " (none at all)" + elif [ "$RC" -eq 0 ] && ! python3 -c "import json,sys; sys.exit(0 if json.load(open(sys.argv[1]))['displays'] else 1)" "/tmp/sources.$i.json"; then + # Exit 0 is not an answer on its own. Electron 43 settles the + # combined screen+window call after its 3s ready timer with the + # screen dropped, so `displays: []` is #462's other face, and + # Xvfb always has one screen to report. + FAILED=$((FAILED + 1)) + echo "returned no display (#462). Last milestone:" + grep -a milestone "/tmp/sources.$i.out" | tail -1 || echo " (none)" elif [ "$RC" -eq 0 ]; then OK=$((OK + 1)) LAST_OK="/tmp/sources.$i.json" @@ -432,10 +478,8 @@ jobs: PY fi - # Noted, not acted on. Enumeration is flaky here and the export - # assertion below is the more - # important question; a flaky attempt costs an annotation rather than - # the answer. Verdict at the end, once both have had their say. + # Annotated here, decided at the end: export runs either way, so a + # failed enumeration cannot hide whether the addon works. if [ "$HUNG" -gt 0 ]; then echo "::warning::openscreen sources had to be killed on $HUNG of $ATTEMPTS attempts. Check the milestones above for how far it got before the outer bound fired." fi @@ -544,24 +588,50 @@ jobs: fi fi + # The allocations the invocations above actually got, in one place: + # most callers redirect run_cli's stdout into per-attempt files, so + # the per-invocation echo above lands there rather than here. An + # invocation number plus these two files is the whole mapping #462 + # asks for, and the artifact step after this one keeps the files + # once the runner is gone. + echo "--- Xvfb allocations (run_cli -> display -> server output) ---" + for d in "$XVFB_DIAG"/*.display; do + [ -e "$d" ] || break + n=${d##*/run-cli-}; n=${n%.display} + printf 'run_cli #%s: DISPLAY=%s, server output: run-cli-%s.xvfb.log (%s bytes)\n' \ + "$n" "$(cat "$d")" "$n" "$(wc -c <"$XVFB_DIAG/run-cli-$n.xvfb.log" 2>/dev/null || echo 0)" + done + # One verdict, after both questions have been asked. Enumeration being # flaky must not hide whether export works, which is the whole point of # having packaged the compositor addon. # - # The gate is "did enumeration ever answer" and "does export work", not - # "did all five attempts pass". Requiring FAILED -eq 0 made the job red - # by construction: the standing numbers on this runner are 1/5, 3/5 and - # 4/5 ok, so a run where export is perfect and four enumerations succeed - # still failed. A post-merge check that is red by design is one that gets - # muted, which costs more than the flakiness it was reporting. The - # per-attempt warnings above keep that flakiness visible without letting - # it decide the build; tighten this to $ATTEMPTS once the capture failure - # is understood and fixed. + # Every enumeration must answer with a display, and export must work. + # The gate used to be "did enumeration ever answer", because the call + # failed on 1 to 4 attempts in 5 for a reason nobody had found. #462 + # found it: asking for screens and windows in one getSources call is + # what fails under X11, and the get-sources handler now asks one type + # at a time (under Xvfb: 0 failures in 32 runs, against 7 in 16 + # combined). A flake left after that is a regression, not runner luck. # - # Export, on the other hand, is a hard gate on every trigger again. It no - # longer depends on a capability this host does not have, so there is - # nothing left to excuse: if it fails now, the package is broken. + # Export is a hard gate on every trigger. It does not depend on a + # capability this host lacks, so if it fails, the package is broken. echo "=== verdict: enumeration $OK/$ATTEMPTS ok, export $EXPORT_OK, $RUN_CLI_N run_cli invocations ===" - if [ "$EXPORT_OK" -ne 1 ] || [ "$OK" -eq 0 ]; then + if [ "$EXPORT_OK" -ne 1 ] || [ "$OK" -ne "$ATTEMPTS" ]; then exit 1 fi + + # The Xvfb allocation files the smoke step records per run_cli + # invocation, and nothing else: #462's open question is below the + # Electron API, so this evidence has to outlive the runner. always(), + # because the run that hangs is exactly the one where the smoke step + # has already failed. Warn rather than error when absent: a job whose + # build died before the smoke step has no allocations to keep. + - name: Upload Xvfb diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: xvfb-diagnostics + path: /tmp/xvfb-diag/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/nix-check.yml b/.github/workflows/nix-check.yml index 49b51cbe3..768644e16 100644 --- a/.github/workflows/nix-check.yml +++ b/.github/workflows/nix-check.yml @@ -4,8 +4,8 @@ name: Nix # package-lock.json resolves to. The two have to agree or `nix build` fails # outright, and nothing here ever checked that they did. # -# The only thing that refreshed the hash was bump-nix-package.yml, which fires -# on stable releases. `src` is this repo's own tree, not a fetched tarball, so +# The only thing that refreshed the hash used to be a bump PR opened after each +# stable release (releases now refresh it in their own version-bump commit). `src` is this repo's own tree, not a fetched tarball, so # every lockfile change landing between two releases left main's hash pointing # at dependencies that no longer existed. It last matched on 2026-07-05 (v1.6.0) # while package-lock.json moved ten more times, so `nix run github:…` was broken diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 965a6e93e..e43b6ff20 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -75,6 +75,13 @@ jobs: NEXT: ${{ steps.version.outputs.next }} run: node .github/scripts/release-milestone-migrate.mjs + - name: Install Nix + uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + extra_nix_config: | + experimental-features = nix-command flakes + - name: Create or reuse the release branch and bump package.json env: TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} @@ -82,6 +89,9 @@ jobs: NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail + # Taken from the dispatched ref before the release branch is checked out: a frozen + # branch cut before this script existed would not have it. + cp .github/scripts/refresh-nix-npm-hash.sh "$RUNNER_TEMP/" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # ONE release branch per STABLE version (release/vX.Y.Z), created at rc.1 and @@ -109,7 +119,9 @@ jobs: # Writes package-lock.json too. A sed over package.json alone left the # lockfile behind on every release up to 1.9.0; see the script header. node .github/scripts/set-release-version.mjs "${PRERELEASE}" - git add package.json package-lock.json + # The lockfile's version is part of Nix's npm hash: refresh it in this same commit. + bash "$RUNNER_TEMP/refresh-nix-npm-hash.sh" + git add package.json package-lock.json nix/package.nix git commit -m "chore(release): bump to ${PRERELEASE} [skip ci]" || echo "(version already at ${PRERELEASE})" git push "$REMOTE" "$BRANCH" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 7bc090534..358ddb739 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -59,12 +59,22 @@ jobs: STABLE_VERSION: ${{ steps.version.outputs.stable_version }} run: node .github/scripts/release-milestone-close.mjs + - name: Install Nix + uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + extra_nix_config: | + experimental-features = nix-command flakes + - name: Bump the version to stable on the release branch env: TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} STABLE_VERSION: ${{ steps.version.outputs.stable_version }} run: | set -euo pipefail + # Taken from the dispatched ref before the release branch is checked out: a frozen + # branch cut before this script existed would not have it. + cp .github/scripts/refresh-nix-npm-hash.sh "$RUNNER_TEMP/" # Promote checks out the FROZEN release branch (created by prerelease.yml) and # rewrites the version there. This guarantees the stable tag points at the # same code that was tested as the RC plus any cherry-picked bugfixes. @@ -75,9 +85,11 @@ jobs: # Writes package-lock.json too. A sed over package.json alone left the # lockfile behind on every release up to 1.9.0; see the script header. node .github/scripts/set-release-version.mjs "${STABLE_VERSION}" + # The lockfile's version is part of Nix's npm hash: refresh it in this same commit. + bash "$RUNNER_TEMP/refresh-nix-npm-hash.sh" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json + git add package.json package-lock.json nix/package.nix git commit --allow-empty -m "chore(release): bump to ${STABLE_VERSION} [skip ci]" || true git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml index 8c867d01f..834eda7e5 100644 --- a/.github/workflows/update-homebrew-cask.yml +++ b/.github/workflows/update-homebrew-cask.yml @@ -222,7 +222,7 @@ jobs: homepage "https://github.com/${REPO}" auto_updates false - depends_on macos: ">= :big_sur" + depends_on macos: ">= :ventura" app "Openscreen.app" diff --git a/.gitignore b/.gitignore index 24ad425eb..cf03b63b5 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,10 @@ result-* # Auto-caption model + ORT wasm — regenerated at build by scripts/fetch-caption-model.mjs /caption-assets/ +# Third-party licence texts, regenerated before every package by +# scripts/generate-third-party-licenses.mjs (`npm run licenses:generate`). +/build-licenses/ + # Native STT artifacts — whisper-stt-server binaries + ggml backend sidecars. /electron/native/bin/ /electron/native/models/ @@ -95,6 +99,9 @@ result-* /tools/stt-eval/whispercpp-dtw-poc/fixtures/*.wav /tools/stt-eval/whispercpp-dtw-poc/results/ +# Word-timing harness (tools/stt-eval/word-timing): TTS corpus and results. +/tools/stt-eval/word-timing/data/ + opencode.json opencode.json @@ -121,6 +128,7 @@ poc/out/ workbench/reports/ workbench/.build/ workbench/runs/ +workbench/.agent-evidence/ # Fixture d'évaluation — enregistrement réel de l'utilisateur, jamais versionné workbench/fixtures/ diff --git a/AGENTS.md b/AGENTS.md index 72034752e..4590ea9e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,10 +93,11 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta **Launch the app** -- Normal: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O toggles the HUD). -- **Set `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1` in the environment you launch from, or the HUD is invisible in every screenshot you take.** It is a module-scope constant (`electron/windows.ts:20`), read once as the main process loads, so it cannot be turned on afterwards — you relaunch or you work blind. The main process prints `[content-protection] OFF for the HUD window` when it took effect; if that line is missing, stop and relaunch rather than hunting a HUD you will never see. What it does and when to unset it: the HUD notes below. -- The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover Electron process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` processes before relaunching. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. -- **From a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. +- **A release candidate is tested on its CI-built artifact, not on a dev build.** Getting it, installing it and launching it on each platform: [Running the pass on a CI-built artifact](technical-documentation/testing/manual-e2e-checklist.md#running-the-pass-on-a-ci-built-artifact), in the checklist. The notes below on `electron.exe`, worktrees and stale native binaries are about dev builds. +- Dev build: `npm run dev` — Vite serves the renderer and `vite-plugin-electron` opens the Electron window. The main process logs `Global shortcut registered: CommandOrControl+Shift+O` when ready (Ctrl/Cmd+Shift+O brings the app's window, HUD or editor, to the front). +- **Set `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1` in the environment you launch from, or the HUD is invisible in every screenshot you take** (Windows, and macOS before 26). It is a module-scope constant in `electron/windows.ts`, read once as the main process loads, so it cannot be turned on afterwards — you relaunch or you work blind. An installed build only gets it when you start its executable from the shell where it is set: a Start-menu, Finder or installer launch does not pass it on. The main process prints `[content-protection] OFF for the HUD window` when it took effect; if that line is missing, or the HUD is missing from your first screenshot, stop and relaunch rather than hunting a HUD you will never see. What it does and when to unset it: the HUD notes below. +- The app is single-instance through `app.requestSingleInstanceLock()`, which keys on the `userData` path. If a leftover process still holds it, a new launch quits silently (exit 0, no window) — kill leftover `electron` or `Openscreen` processes before relaunching, including a copy the installer started for you. The lock is held by the OS and dies with the process, so there is nothing to clean up on disk. A dev build and the installed `Openscreen` resolve different `userData` paths and can run side by side. +- **Dev build from a git worktree** (no `node_modules`/native binaries): junction/symlink `node_modules` from the main checkout (deps are usually identical — check `package-lock.json`), and copy the prebuilt native capture binaries from `electron/native/bin//` (gitignored — rebuilding needs the full VS/Xcode toolchain). Then `npm run dev` works normally. - **Those binaries are frozen at whenever someone last built them, and nothing warns you.** They are not rebuilt by `npm run dev` or `npm run build`, so a helper older than the native change you came to test will run happily and silently exercise the old code path — the recording succeeds, and the thing you wanted to see is simply absent. Before trusting any native result, date the binary against the commit and search it for a string the change introduced — from the repo root: ```powershell @@ -111,35 +112,40 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta **Granting access** -- `request_access` resolves names against installed apps. A **dev build runs as `electron.exe`** (or `Electron.app`), *not* the installed `Openscreen` — grant **`electron.exe`** or the dev window stays masked in screenshots. Non-allowlisted windows are masked (solid rectangles); the screenshot note lists their process names to add. +- `request_access` resolves names against installed apps. A **dev build runs as `electron.exe`** (or `Electron.app`), *not* the installed `Openscreen` — grant **`electron.exe`** or the dev window stays masked in screenshots. An installed build, CI artifact included, is the other way round: grant **`Openscreen`**. Non-allowlisted windows are masked (solid rectangles); the screenshot note lists their process names to add. - **Start the app before asking for it.** `electron.exe` is not an installed app, so the resolver only finds it once the process exists *and* owns a window; ask any earlier and the call fails with `doesn't match any installed or running application` — and one unresolvable name short-circuits the whole request, including the names that would have resolved. Granting `Openscreen` instead is not a workaround: it resolves to `…\programs\openscreen\openscreen.exe`, so the dev window stays masked while the grant reports success. +- **Some windows a pass needs are not OpenScreen's.** Apple's source picker (macOS 15.2+) and the macOS permission prompts are drawn by system processes, the Linux portal picker by the desktop's portal. Which process owns each is not recorded yet: grant the one the screenshot note names, and write it into the checklist's grant step for the next run. **The HUD widget** (recording controller) - **It is invisible in screenshots by default.** The HUD (and the Notes window) call `setContentProtection(true)` so the recording controls never end up baked into a recording — the same `SetWindowDisplayAffinity` that WGC honours also hides them from *your* screenshots. The window is there, and clicks land, but you are aiming blind at a rectangle you cannot see. Set **`OPENSCREEN_DISABLE_CONTENT_PROTECTION=1`** in the app's environment to turn it off for a session; every skipped window logs a warning. Unset it before recording anything real, or the HUD ends up in the video. -- **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window. The native ScreenCaptureKit helper still keeps the HUD and an open Notes window out of full-display recordings through `SCContentFilter(excludingWindows:)`; ordinary OpenScreen windows remain recordable. `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables Electron's protection to re-test against a future Electron. +- **On macOS 26+ content protection is auto-disabled, so the HUD *is* visible and screenshottable with no flag.** That OS never displays a content-protected window at all — not just absent from captures, but never painted, leaving a tray icon, a live renderer and nothing on screen (confirmed on macOS 26.5 / Electron 41.2.1). `applyContentProtection` therefore skips the call there and logs a warning per window. The native ScreenCaptureKit helper still keeps the HUD and an open Notes window out of full-display recordings through `SCContentFilter(excludingWindows:)`; ordinary OpenScreen windows remain recordable, except in a capture started from Apple's picker, which leaves out the whole app (#996). `OPENSCREEN_FORCE_CONTENT_PROTECTION=1` re-enables Electron's protection to re-test against a future Electron. - The HUD is what opens the editor (clapper icon, tooltip *Open Studio*), so without that flag a whole slice of the app is unreachable from automation: killing the app to redeploy a native addon leaves you unable to reopen a project. +- **The HUD and the editor never share the screen.** Opening the editor closes the HUD, and closing the editor, the last window, quits the app. The way back is the editor's Record mode (see the flow below). - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 820×560 at construction, then resized to fit its content — measured 904×698 with the bar at the bottom and mostly empty reserve above it). It is **click-through** (`setIgnoreMouseEvents(ignore)`): 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. - **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** While the window is input-transparent Chromium delivers it no pointer events at all, so the main process samples the OS cursor instead: the `hud-overlay-cursor` poll in `electron/windows.ts` reads `screen.getCursorScreenPoint()` while the HUD is click-through and pushes the window-relative point to the renderer, which hit-tests it with `elementFromPoint(…).closest("[data-hud-interactive='true']")`. One path, both platforms — there is no platform branch. **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. What the poll keys off is the OS cursor's position *relative to the window*, so a resize or re-anchor that slides the bar under a motionless pointer produces a fresh sample too. What it can never key off is synthesised input: Playwright's `.click()`, `javascript_tool`-dispatched pointer events and everything like them move no pointer at all, so they never put one on a control. They arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. (Until #385 the lift was Electron's `{ forward: true }` — a global `WH_MOUSE_LL` hook on Windows — which Windows can revoke without telling the app, leaving the HUD painted and permanently dead. The poll replaced it. The rule for you is unchanged, because both mechanisms key off the real cursor.) -- 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: "Choose a screen or window to record"). +- Control row (left→right): drag handle; horizontal/vertical bar toggle; **source** button (`Screen` until a source is picked, then its name); system audio; microphone; **camera**; a gear, *Device settings*, which picks the microphone and the camera and shows a level meter and a preview; cursor mode (editable or system cursor); **record**; *Open Studio* while idle, or pause, restart and cancel while recording; notes; language (the locale code, e.g. `EN`); *Hide recording bar*; *Quit OpenScreen*. Per platform: on macOS 15.2+ the source button opens Apple's system picker and the HUD hides until it closes; on macOS 13 and 14 there is no microphone; on Linux there is no source button (the portal asks when recording starts) and no notes button. +- Record is never disabled for want of a source. With none picked, its tooltip reads "Choose a screen or window to record", and a click opens the picker, then starts recording once a source is chosen. -**The tray icon** (bottom-right notification area) +**The tray icon** (notification area on Windows, menu bar on macOS, the desktop's indicator area on Linux) -- Because the HUD skips the taskbar and can be minimized/hidden, the **system-tray icon is the reliable way to refocus the app**: **left-click or double-click reopens/focuses the HUD** (`showMainWindow`). Its icon swaps to a red dot while recording. -- **Right-click → context menu**: *Open* / *Quit* when idle, or ***Stop Recording*** while recording (mirrors the HUD's stop). Tooltip shows `OpenScreen` or `Recording: `. Use this to stop a recording if the HUD isn't reachable. +- Because the HUD skips the taskbar and can be minimized/hidden, the **system-tray icon is the reliable way to refocus the app**: **a click or double-click brings back the app's window** (`showMainWindow`: the HUD, or the editor while it is open), and so does its menu's *Open*. Its icon swaps to a red dot while recording. +- **Right-click → context menu**: when idle, *Open*, *Check for Updates* (not on a Store, Flathub, Snap or Nix install), *Update Settings* (packaged builds that update themselves), *About OpenScreen*, *Save Diagnostics*, *Permissions…* (macOS), *Quit*; while recording, only ***Stop Recording*** (mirrors the HUD's stop). Tooltip shows `OpenScreen` or `Recording: `. Use this to stop a recording if the HUD isn't reachable. **End-to-end flow (record → edit)** -1. On the HUD: click the **webcam** toggle to enable the camera, then the **source** button → pick the *Screens*/*Windows* tab → select a thumbnail → **Select**. -2. Click **record**; the HUD switches to a red stop button with a running timer (a countdown overlay may show first). -3. Stop via the HUD's red button (or tray → *Stop Recording*). The **editor window opens** with the screen recording and the webcam PiP. -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. +1. On the HUD: click the **camera** toggle (the gear picks which camera), then the **source** button → pick the *Screens*/*Windows* tab → select a thumbnail → **Select**. On macOS 15.2+ the source button opens Apple's picker instead; on Linux there is no source button, and the portal's picker appears once you press record. +2. Click **record**; the HUD switches to a red stop button with a running timer, beside pause, restart and cancel (a countdown overlay may show first). +3. Stop via the HUD's red button (or tray → *Stop Recording*). The **editor window opens** in Edit mode with the screen recording, the webcam PiP and, when the cursor was recorded as editable, zooms already placed where it clicked or paused (Record mode's *Auto-zoom after recording*, on by default). +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**, or the top bar's arrows, undo/redo). +5. Capture a screenshot as proof. Clean up: quit the app (*Quit OpenScreen* on the HUD, or tray → *Quit*); for a dev build, also stop `npm run dev` and remove temporary worktree junctions. + +The other way in is the editor's **Record** mode: the HUD's settings plus *Auto-zoom after recording* and *Hide desktop icons*. Its **Start recording** closes the editor and hands over to the HUD, which starts the take by itself. **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). +- 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 → Advanced → 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. +- In a source checkout, ffmpeg lives at `crates/thirdparty/ffmpeg-*/bin/ffmpeg.exe` (also needed on `PATH` for the compositor addon to load). Measuring a CI artifact's export needs any `ffmpeg`/`ffprobe`, not this one. ## PR & commit conventions diff --git a/README.md b/README.md index de72920f8..23cb6784d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

A free, open-source desktop app for recording your screen and turning the result into polished product demos and walkthroughs.

- Editing a recording in OpenScreen: wallpaper and video effects, an AI-assisted cut driven from the chat, then export + Editing a recording in OpenScreen: one click restyles the whole video, deleting a sentence in the transcript cuts it from the video, and the editor's agent adds a zoom on every click

@@ -37,7 +37,7 @@ A raw capture goes in and comes out ready to post — on X, Reddit, YouTube, a d - Record microphone and system audio. - Webcam overlay with picture-in-picture, eight snap positions, adjustable roundness, mirroring, and shape options. - Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position; auto-zoom follows your cursor as you work. -- Custom cursor size, smoothing, and click effects, with cursor themes and post-recording path smoothing. +- Custom cursor size, smoothing, and click effects, with post-recording path smoothing. - Automatic captions for voiceovers, transcribed on-device with no upload (works offline), with an editable transcript you can cut from and optional subtitle translation. - AI editing assistant: describe the edit you want in chat and it applies to the timeline — cuts, zooms, speed ramps, annotations, camera framing. Bring your own key (Claude, OpenAI, Gemini, Mistral, OpenRouter, MiniMax, or any OpenAI-compatible endpoint); nothing is enabled by default. - Wallpapers, solid colors, gradients, or your own background image. @@ -176,7 +176,7 @@ You may need to grant screen recording permissions depending on your desktop env Everything in the editor and export is the same on macOS, Windows, and Linux: zooms, backgrounds, motion blur, crop/trim/speed, blur regions, annotations, auto-captions, AI editing, projects, export, and all languages. All three now record through a native capture pipeline; the remaining differences are narrower than they used to be: - **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available. -- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so cursor themes and the editable cursor overlay work there too. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs your user in the `input` group. Without that, recording is unaffected and every cursor sample is simply a move. +- **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so the editable cursor overlay works there too. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs your user in the `input` group. Without that, recording is unaffected and every cursor sample is simply a move. - **Webcam**: every platform saves the webcam as a separate file next to the screen recording. Windows captures it natively in the recording helper; macOS and Linux record it in the app. It works as a picture-in-picture overlay on all three. - **System audio** support varies by OS: - **macOS**: works on every supported version. On macOS 14.2+ you'll be prompted to grant audio capture permission. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d53cfc019..e7c74448d 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,12 +1,13 @@ # 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 +bundle the third-party 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. +npm dependencies are not listed here. Their code ships bundled inside the +application by Vite, and their licence texts are in `THIRD-PARTY-LICENSES.txt`, +generated at build time and shipped beside this file. --- @@ -38,6 +39,23 @@ distributed by their own registries, not redistributed inside our binaries. replace them with your own build of the same FFmpeg version by overwriting the DLLs in `resources/electron/native/bin/win32-*/`. +## web-demuxer — WebAssembly demuxer + +- **Component**: `wasm/web-demuxer.wasm`, inside the application bundle. +- **Used by**: the editor's streaming decoder, audio waveforms and caption audio + extraction, which demux recordings in the renderer with it. +- **License**: MIT for web-demuxer itself — Copyright (c) 2024 ForeverSc, under the + same permission notice as OpenScreen's own [LICENSE](LICENSE). The module also + contains FFmpeg code compiled to WebAssembly, under the **GNU Lesser General + Public License v2.1 or later**, as web-demuxer's README states. +- **Upstream**: the npm package `web-demuxer` 4.0.0, + . The shipped file is byte-identical + to that release's `dist/wasm-files/web-demuxer.wasm`. +- **Corresponding source**: the web-demuxer repository at the 4.0.0 release, whose + `lib/` directory holds the FFmpeg-derived sources and their build scripts. +- **Replacing it**: the module is a separate file loaded at runtime, so it can be + replaced with your own build of the same version. + ## whisper.cpp and ggml - **Components**: `whisper-stt-server` and its ggml backend sidecars, under @@ -123,6 +141,115 @@ distributed by their own registries, not redistributed inside our binaries. so nothing of PipeWire's ships inside our installers beyond the compiled result of its headers (inline functions and struct layouts). +## Fonts — Inter, Lora, Oswald, Caveat, IBM Plex Mono + +- **Components**: the Regular and Bold `.ttf` of each family, under + `resources/fonts/`. +- **Used by**: the native compositor, which draws captions and annotations with + these files and nothing else (it registers them privately at start and never + reads the fonts installed on the machine), and the editor's font picker. +- **License**: **SIL Open Font License 1.1** — . + Each family's copyright notice and licence text ship beside its files as + `-OFL.txt`, as the licence requires. +- **Unmodified**: every file is redistributed exactly as upstream publishes it, + which is also why the Reserved Font Names "Lora" and "Plex" are kept. +- **Upstream**: + - Inter — Copyright 2016 The Inter Project Authors. `extras/ttf/` of the v4.1 + release, . + - Lora 3.021 — Copyright 2011 The Lora Project Authors. `fonts/ttf/` of + at `2d53b449b6`. + - Oswald 4.103 — Copyright 2016 The Oswald Project Authors. `fonts/ttf/` of + at `89795261ac`. + - Caveat 2.000 — Copyright 2014 The Caveat Project Authors. `fonts/ttf/` of + at `59745e818e`. + - IBM Plex Mono 2.3 — Copyright © 2017 IBM Corp. `ofl/ibmplexmono/` of + at `23e54b51dd`. + +## Recordly — helper code + +- **Components**: parts of the zoom, motion-smoothing and custom-cursor code, and a + few lines elsewhere, the native capture helpers included. +- **License**: MIT — Copyright (c) 2026 webadderall, under the same permission + notice as OpenScreen's own [LICENSE](LICENSE). Published by Recordly under MIT, + before its relicensing in March 2026. + +## Rust crates — compiled into the compositor addon + +- **Component**: `compositor_view.node`, under + `resources/electron/native/bin/-/`. +- **What is listed**: every third-party crate in the addon's dependency graph in + `crates/Cargo.lock`, across all platforms. Build-time crates (procedural macros, + build scripts) are included even though none of their code ships. +- **Licenses**: as each crate publishes them on crates.io. Where a crate offers a + choice, OpenScreen uses it under MIT or Apache-2.0. Each crate's licence text is + in its source package at the version listed. +- **Full texts**: the licence and NOTICE files of every crate compiled into the + addon are in `THIRD-PARTY-LICENSES.txt`, shipped beside this file. +- **MIT OR Apache-2.0** (155): android_system_properties 0.1.5, anyhow 1.0.103, + arrayvec 0.7.8, ash 0.38.0+1.3.281, autocfg 1.5.1, bit-set 0.8.0, bit-vec 0.8.0, + bitflags 1.3.2, bitflags 2.13.1, bumpalo 3.20.3, cc 1.2.67, cexpr 0.6.0, cfg-if + 1.0.4, core-foundation 0.9.4, core-foundation-sys 0.8.7, core-graphics-types + 0.1.3, cosmic-text 0.19.0, crc32fast 1.5.0, ctor 0.2.9, document-features + 0.2.12, either 1.16.0, equivalent 1.0.2, fdeflate 0.3.7, find-msvc-tools 0.1.9, + flate2 1.1.9, font-types 0.11.3, font-types 0.12.2, foreign-types 0.5.0, + foreign-types-macros 0.2.4, foreign-types-shared 0.3.1, futures-core 0.3.33, + futures-task 0.3.33, futures-util 0.3.33, glob 0.3.3, gpu-alloc 0.6.2, + gpu-alloc-types 0.3.1, gpu-allocator 0.27.0, gpu-descriptor 0.3.2, + gpu-descriptor-types 0.2.0, hashbrown 0.15.5, hashbrown 0.17.1, heck 0.5.0, + image 0.25.10, indexmap 2.14.0, itertools 0.13.0, itoa 1.0.18, jni-sys 0.3.1, + jni-sys 0.4.1, jni-sys-macros 0.4.1, js-sys 0.3.103, khronos-egl 6.0.0, libc + 0.2.186, linebender_resource_handle 0.1.1, litrs 1.0.0, lock_api 0.4.14, log + 0.4.33, matrixmultiply 0.3.11, memmap2 0.9.11, metal 0.29.0, metal 0.31.0, + minimal-lexical 0.2.1, naga 24.0.0, ndarray 0.16.1, ndarray 0.17.2, ndk-sys + 0.5.0+25.2.9519653, num-complex 0.4.6, num-integer 0.1.47, num-traits 0.2.19, + once_cell 1.21.4, ort 2.0.0-rc.13, ort-sys 2.0.0-rc.13, parking_lot 0.12.5, + parking_lot_core 0.9.12, paste 1.0.15, pin-project-lite 0.2.17, pkg-config + 0.3.33, png 0.18.1, pollster 0.4.0, portable-atomic 1.15.0, portable-atomic-util + 0.2.7, presser 0.3.1, prettyplease 0.2.37, proc-macro2 1.0.106, profiling + 1.0.18, quote 1.0.46, range-alloc 0.1.5, rangemap 1.7.1, rawpointer 0.2.1, + read-fonts 0.37.0, read-fonts 0.41.0, regex 1.13.1, regex-automata 0.4.16, + regex-syntax 0.8.11, renderdoc-sys 1.1.0, roxmltree 0.20.0, rustc-hash 1.1.0, + rustc-hash 2.1.3, rustversion 1.0.23, scopeguard 1.2.0, semver 1.0.28, serde + 1.0.228, serde_core 1.0.228, serde_derive 1.0.228, serde_json 1.0.150, shlex + 1.3.0, shlex 2.0.1, skrifa 0.40.0, skrifa 0.44.0, smallvec 1.15.2, smol_str + 0.3.6, static_assertions 1.1.0, swash 0.2.10, syn 2.0.119, syn 3.0.3, sys-locale + 0.3.2, thiserror 1.0.69, thiserror 2.0.19, thiserror-impl 1.0.69, thiserror-impl + 2.0.19, ttf-parser 0.25.1, unicode-bidi 0.3.18, unicode-script 0.5.8, + unicode-segmentation 1.13.3, unicode-width 0.1.14, unicode-xid 0.2.6, + version_check 0.9.5, wasm-bindgen 0.2.126, wasm-bindgen-futures 0.4.76, + wasm-bindgen-macro 0.2.126, wasm-bindgen-macro-support 0.2.126, + wasm-bindgen-shared 0.2.126, web-sys 0.3.103, wgpu 24.0.5, wgpu-core 24.0.5, + wgpu-hal 24.0.4, wgpu-types 24.0.0, windows 0.58.0, windows_aarch64_gnullvm + 0.52.6, windows_aarch64_msvc 0.52.6, windows_i686_gnu 0.52.6, + windows_i686_gnullvm 0.52.6, windows_i686_msvc 0.52.6, windows_x86_64_gnu + 0.52.6, windows_x86_64_gnullvm 0.52.6, windows_x86_64_msvc 0.52.6, windows-core + 0.58.0, windows-implement 0.58.0, windows-interface 0.58.0, windows-link 0.2.1, + windows-result 0.2.0, windows-strings 0.1.0, windows-sys 0.61.2, windows-targets + 0.52.6, yazi 0.2.1, zeno 0.3.3. +- **MIT** (26): block 0.1.6, cfg_aliases 0.2.2, convert_case 0.6.0, core_maths + 0.1.1, fontconfig-parser 0.5.8, fontdb 0.23.0, harfrust 0.5.2, libm 0.2.16, + malloc_buf 0.0.6, napi 2.16.17, napi-build 2.3.2, napi-derive 2.16.13, + napi-derive-backend 1.0.75, napi-sys 2.4.0, nom 7.1.3, objc 0.2.7, ordered-float + 4.6.0, redox_syscall 0.5.18, simd-adler32 0.3.10, slab 0.4.12, strum 0.26.3, + strum_macros 0.26.4, tracing 0.1.44, tracing-core 0.1.36, xml-rs 0.8.28, zmij + 1.0.23. +- **MIT OR Apache-2.0 OR Zlib** (9): bytemuck 1.25.1, bytemuck_derive 1.11.0, + glow 0.16.0, miniz_oxide 0.8.9, raw-window-handle 0.6.2, tinyvec 1.12.0, + tinyvec_macros 0.1.1, zune-core 0.5.1, zune-jpeg 0.5.15. +- **Apache-2.0** (7): clang-sys 1.8.1, codespan-reporting 0.11.1, gl_generator + 0.14.0, glutin_wgl_sys 0.6.1, khronos_api 3.1.0, spirv 0.3.0+sdk-1.3.268.0, + unicode-linebreak 0.1.5. +- **Unlicense OR MIT** (5): aho-corasick 1.1.4, byteorder-lite 0.1.0, memchr + 2.8.3, termcolor 1.4.1, winapi-util 0.1.11. +- **Zlib** (2): foldhash 0.1.5, slotmap 1.1.1. +- **ISC** (2): libloading 0.8.9, libloading 0.9.0. +- **BSD-3-Clause OR Apache-2.0** (2): moxcms 0.8.1, pxfm 0.1.30. +- **0BSD OR MIT OR Apache-2.0** (1): adler2 2.0.1. +- **BSD-3-Clause** (1): bindgen 0.70.1. +- **CC0-1.0** (1): hexf-parse 0.2.1. +- **Apache-2.0 OR GPL-2.0-only** (1): self_cell 1.3.0. +- **(MIT OR Apache-2.0) AND Unicode-3.0** (1): unicode-ident 1.0.24. + ## OpenScreen native helpers `wgc-capture` (Windows Graphics Capture), the ScreenCaptureKit helper (macOS), diff --git a/biome.json b/biome.json index 4b65d5382..fb2c6a910 100644 --- a/biome.json +++ b/biome.json @@ -111,6 +111,10 @@ }, "javascript": { "formatter": { "quoteStyle": "double" } }, "overrides": [ + { + "includes": ["workbench/measurements/**/*.json"], + "formatter": { "enabled": false } + }, { "includes": ["*.ts", "*.tsx", "*.mts", "*.cts"], "linter": { diff --git a/crates/Cargo.toml b/crates/Cargo.toml index abd74df99..a65f9aaef 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -60,7 +60,6 @@ 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", @@ -69,6 +68,7 @@ features = [ "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_Security", # IDXGIResource1::CreateSharedHandle (shared_frames.rs) "Win32_System_Performance", # QueryPerformanceCounter / Frequency (§10) "Win32_System_LibraryLoader", # GetModuleHandleW (hInstance) "Win32_UI_WindowsAndMessaging", # fenêtres (harnais live + GUI du POC) diff --git a/crates/README.md b/crates/README.md index 605c5a366..5613ecdfc 100644 --- a/crates/README.md +++ b/crates/README.md @@ -49,7 +49,7 @@ compositeur (WGSL natif à l'identique) + la carte des coûts. - **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. +- Compositeur HLSL (`src/shaders.hlsl`) compilé au build (`build.rs`), embarqué en bytecode. ## Prérequis diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs index b2a80ac61..78d351706 100644 --- a/crates/compositor-view-napi/src/lib.rs +++ b/crates/compositor-view-napi/src/lib.rs @@ -11,8 +11,9 @@ 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::export_control::{ExportCancelled, ExportControl}; +use openscreen_compositor::frame_geometry::FootageQuad; use openscreen_compositor::gif_export::{GifExportParams, GifStats}; -use openscreen_compositor::gif_export_control::{GifExportCancelled, GifExportControl}; use openscreen_compositor::live::{LiveView, PausedPreviews}; use openscreen_compositor::scene::Scene; use openscreen_compositor::{config, pipeline}; @@ -87,6 +88,56 @@ pub fn segmentation_runtime_available() -> bool { openscreen_compositor::segmentation::runtime_available() } +pub struct SegmentFrameTask { + model_path: String, + rgba: Vec, +} + +impl Task for SegmentFrameTask { + type Output = Vec; + type JsValue = Buffer; + + fn compute(&mut self) -> Result { + let rgb: Vec = self + .rgba + .chunks_exact(4) + .flat_map(|p| [p[0], p[1], p[2]]) + .collect(); + // ponytail: une session ONNX chargée par appel (quelques dizaines de ms), parce qu'il + // n'y a qu'un appel par caméra et par ouverture du panneau. À garder en cache si un + // appelant se met à segmenter en continu. + let mut segmenter = openscreen_compositor::segmentation::Segmenter::load( + std::path::Path::new(&self.model_path), + ) + .map_err(|e| Error::from_reason(format!("{e:#}")))?; + segmenter + .run(&rgb) + .map(<[u8]>::to_vec) + .map_err(|e| Error::from_reason(format!("{e:#}"))) + } + + fn resolve(&mut self, _env: Env, mask: Self::Output) -> Result { + Ok(mask.into()) + } +} + +/// Masque du sujet pour UNE image, sans vue : la vignette de recadrage du panneau caméra s'en +/// sert pour montrer le fond choisi sur sa propre frame. +/// +/// `rgba` est une frame RGBA8 déjà réduite à la taille du modèle (`segmentation::MODEL_WIDTH` x +/// `MODEL_HEIGHT`), soit le `getImageData` d'un canvas tel quel. Le retour a le format de +/// `Segmenter::run` : un octet par pixel, 0 = fond, 255 = sujet. +/// +/// `AsyncTask`, comme `remux_seekable` : charger la session prend plus longtemps que l'inférence, +/// et ce temps n'a rien à faire sur le thread principal de Node. +#[napi] +pub fn segment_frame(model_path: String, rgba: Buffer) -> AsyncTask { + AsyncTask::new(SegmentFrameTask { + model_path, + rgba: rgba.to_vec(), + }) +} + #[napi] pub fn create_view( rect: CompositorViewRect, @@ -147,6 +198,16 @@ pub struct FramePacket { /// 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, + /// Le métrage dans cette image : ses coins TL, TR, BR, BL (x, y en fractions de l'image, + /// huit nombres), là où l'éditeur pose le gimbal d'un flou. Absent avant toute composition. + pub footage: Option>, + /// Le métrage passe de ses coins à l'image par leur homographie (caméra réelle), pas par + /// leur interpolation bilinéaire. + pub footage_projective: bool, + /// Clip actif de la scène quand la frame a été composée (`FramePosition`). + pub clip_index: u32, + /// Temps source de la frame écran composée, en secondes. + pub source_time_sec: f64, } /// Renvoie la dernière frame readback du thread de rendu SI elle est plus récente que @@ -186,19 +247,97 @@ pub fn read_frame(id: i32, since_gen: f64) -> Result> { v.latest_frame_since(since_gen.max(0.0) as u64) } }; - Ok(slot.map(|(gen, w, h, pixels)| { + Ok(slot.map(|(gen, w, h, pixels, footage, position)| { 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), + footage: footage_corners(footage), + footage_projective: footage.is_some_and(|q| q.projective), + clip_index: position.clip_index, + source_time_sec: position.source_time_sec, + } + })) +} + +/// Les coins du métrage (TL, TR, BR, BL) aplatis en huit nombres, tels que JS les lit. +fn footage_corners(footage: Option) -> Option> { + footage.map(|q| q.corners.iter().flatten().map(|&v| v as f64).collect()) +} + +/// Une frame de preview posée dans une texture partagée : de quoi l'importer côté GPU +/// (`sharedTexture.importSharedTexture`) au lieu d'en recevoir les pixels. +#[napi(object)] +pub struct SharedFramePacket { + pub gen: f64, + /// Case de l'anneau qui porte la frame, à rendre avec `gen` par `release_shared_frame` + /// quand Chromium l'a relâchée. + pub slot: u32, + /// Handle NT de la texture, 8 octets little-endian : la forme qu'attend Electron + /// (`SharedTextureHandle.ntHandle`). Valable dans ce processus seulement. + pub handle: Buffer, + pub width: u32, + pub height: u32, + pub footage: Option>, + pub footage_projective: bool, + pub clip_index: u32, + pub source_time_sec: f64, +} + +/// Livrer les frames de la vue par textures partagées plutôt que par `read_frame`. Rend +/// `false` quand la machine ne le peut pas (hors Windows, backend logiciel) : la vue reste +/// alors au readback. +#[napi] +pub fn set_shared_frames(id: i32, enabled: bool) -> bool { + registry() + .lock() + .unwrap() + .get(&id) + .is_some_and(|v| v.set_shared_frames(enabled)) +} + +/// Le pendant de `read_frame` pour une vue en textures partagées : la dernière frame posée +/// dans l'anneau, si elle est plus récente que `since_gen`. Sa case reste tenue jusqu'à +/// `release_shared_frame`. `Ok(None)` aussi quand la vue relit en RAM. +#[napi] +pub fn read_shared_frame(id: i32, since_gen: f64) -> Result> { + let frame = match registry().lock().unwrap().get(&id) { + None => return Ok(None), + Some(v) => { + // Même relais que `read_frame` : sans lui, un thread de rendu mort ne se verrait + // que par un canvas noir. + if let Some(fatal) = v.fatal_error() { + return Err(Error::from_reason(fatal)); + } + v.take_shared_frame(since_gen.max(0.0) as u64) } + }; + Ok(frame.map(|f| SharedFramePacket { + gen: f.gen as f64, + slot: f.slot, + handle: f.handle.to_le_bytes().to_vec().into(), + width: f.width, + height: f.height, + footage: footage_corners(f.footage), + footage_projective: f.footage.is_some_and(|q| q.projective), + clip_index: f.position.clip_index, + source_time_sec: f.position.source_time_sec, })) } +/// Chromium a relâché la frame `gen` de la case `slot` (`allReferencesReleased` côté +/// Electron) : le thread de rendu peut y réécrire. Sans effet sur une vue détruite. +#[napi] +pub fn release_shared_frame(id: i32, slot: u32, gen: f64) { + if let Some(v) = registry().lock().unwrap().get(&id) { + v.release_shared_frame(slot, gen.max(0.0) as u64); + } +} + /// Param live (inspector). Le type de valeur route vers le bon setter : -/// bool = switch (backgroundBlur…), number = slider (shadow/roundness/motionBlur), +/// bool = switch (webcamMirror…), number = slider (shadow/roundness/motionBlur/backgroundBlur), /// string = sélection (backgroundColor "#rrggbb"). #[napi] pub fn set_param(id: i32, key: String, value: Either3) { @@ -442,6 +581,9 @@ pub struct ExportParamsInput { /// "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, + /// Débit vidéo visé, en bits/s, calculé par l'app d'après la taille et la cadence. + /// Absent ou nul → le repli du pipeline, qui ignore la cadence. + pub bitrate: Option, } /// Export multiclip mesuré (worker libuv). Rend la vraie timeline (clips + trims) en un MP4. @@ -455,6 +597,7 @@ pub struct ExportMultiTask { clips: Vec, scene_json: Option, params: Option, + control: ExportControl, on_progress: Option>, } @@ -463,12 +606,14 @@ impl Task for ExportMultiTask { type JsValue = ExportStats; fn compute(&mut self) -> Result { + self.control.check().map_err(mp4_task_error)?; // 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:#}")))?; + self.control.check().map_err(mp4_task_error)?; let mut cfg = config::all().pop().expect("au moins une config"); // C8 cfg.zoom = false; cfg.layout_anim = false; @@ -496,6 +641,7 @@ impl Task for ExportMultiTask { export_params.height = height; if let Some(p) = &self.params { export_params.fps = p.fps; + export_params.bit_rate = p.bitrate.filter(|&b| b > 0).map(i64::from); if let Some(codec) = &p.codec { export_params.codec = match codec.as_str() { "h264" => pipeline::ExportCodec::H264, @@ -527,7 +673,7 @@ impl Task for ExportMultiTask { comp.set_scene(scene); let mut progress = throttled_progress(self.on_progress.take()); - let s = pipeline::run_composited_multi( + let s = pipeline::run_composited_multi_cancellable( &self.clips, &self.out_path, &gpu, @@ -535,8 +681,9 @@ impl Task for ExportMultiTask { &cfg, &export_params, &mut progress, + &self.control, ) - .map_err(|e| Error::from_reason(format!("{e:#}")))?; + .map_err(mp4_task_error)?; Ok((s.frames as u32, s.wall_s, s.fps, s.video_duration_s)) } @@ -545,11 +692,34 @@ impl Task for ExportMultiTask { } } +/// Le pendant MP4 de `create_gif_export_control` : même contrôle, passé à `export_multi`. Sa +/// présence dit aussi au TS que cet addon sait annuler un MP4 — un `.node` plus ancien ignore le +/// contrôle, et son export va au bout. +#[napi] +pub fn create_mp4_export_control() -> External { + External::new(ExportControl::default()) +} + +#[napi] +pub fn cancel_mp4_export(control: External) -> bool { + control.cancel() +} + +fn mp4_task_error(error: anyhow::Error) -> Error { + if error.is::() { + Error::from_reason("MP4_EXPORT_CANCELLED") + } else { + Error::from_reason(format!("{error:#}")) + } +} + /// 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). +/// `control` optionnel (`create_mp4_export_control`) : l'annuler arrête le rendu entre deux +/// frames, rejette avec `MP4_EXPORT_CANCELLED` et ne laisse aucun fichier. #[napi] pub fn export_multi( clips: Vec, @@ -557,6 +727,7 @@ pub fn export_multi( scene_json: Option, params: Option, on_progress: Option, + control: Option>, ) -> Result> { let clips = clips .into_iter() @@ -574,16 +745,15 @@ pub fn export_multi( clips, scene_json, params, + control: control.map(|c| (*c).clone()).unwrap_or_default(), on_progress: make_progress_tsfn(on_progress)?, })) } /// Sortie GIF native (slice 1) — taille, cadence, compteur de loop, dithering. -/// Tout optionnel : absent → 854×480, 12 fps, boucle infinie, pas de -/// dithering. Les défauts sont choisis pour un export « petit / net » : -/// GIF est un format 256-couleurs, 12 fps est la cadence historique de -/// `gif.js` côté renderer, et 854×480 tient confortablement dans la -/// palette 8 bits sans banding visible sur du contenu de présentation. +/// Tout optionnel : absent → 854×480, 12 fps, boucle infinie, dithering +/// Floyd-Steinberg. GIF est un format 256-couleurs et 12 fps est la cadence +/// historique de `gif.js` côté renderer. #[napi(object)] pub struct GifParamsInput { pub width: Option, @@ -591,9 +761,8 @@ pub struct GifParamsInput { pub fps: Option, /// Compteur de loop GIF : `None` ou `0` = infini, sinon `n` boucles. pub loop_count: Option, - /// Floyd-Steinberg error diffusion avant quantification. Off par - /// défaut (qualité acceptable sans, et ça double تقريبًا le coût - /// CPU du quantize par frame). + /// Floyd-Steinberg error diffusion à la quantification. Actif par défaut : + /// sans lui, un fond en dégradé se découpe en bandes (cf. `GifExportParams`). pub dither: Option, } @@ -612,17 +781,17 @@ pub struct GifParamsInput { /// `Compositor` (équivalent de `cfg.cursor = false` dans /// `run_composited_multi`). #[napi] -pub fn create_gif_export_control() -> External { - External::new(GifExportControl::default()) +pub fn create_gif_export_control() -> External { + External::new(ExportControl::default()) } #[napi] -pub fn cancel_gif_export(control: External) -> bool { +pub fn cancel_gif_export(control: External) -> bool { control.cancel() } fn gif_task_error(error: anyhow::Error) -> Error { - if error.is::() { + if error.is::() { Error::from_reason("GIF_EXPORT_CANCELLED") } else { Error::from_reason(format!("{error:#}")) @@ -639,7 +808,7 @@ pub struct ExportGifTask { scene_json: Option, out_path: PathBuf, params: GifExportParams, - control: GifExportControl, + control: ExportControl, on_progress: Option>, } @@ -737,7 +906,7 @@ pub fn export_gif( scene_json: Option, params: Option, on_progress: Option, - control: Option>, + control: Option>, ) -> Result> { // Deliberately the same argument shape as `export_multi`: the caller builds // one clip list and one scene, and picks the container. Cursor comes from @@ -759,7 +928,7 @@ pub fn export_gif( height: p.height, fps: p.fps, loop_count: p.loop_count, - dither: p.dither.unwrap_or(false), + dither: p.dither.unwrap_or(GifExportParams::default().dither), }) .unwrap_or_default(); Ok(AsyncTask::new(ExportGifTask { @@ -824,3 +993,32 @@ pub fn remux_seekable(input_path: String, output_path: String) -> AsyncTask Result { + Ok(f64::from(openscreen_compositor::audio::loudness_gain_db(&self.path))) + } + + fn resolve(&mut self, _env: Env, out: Self::Output) -> Result { + Ok(out) + } +} + +/// Le gain de normalisation de loudness (dB) que l'export applique à ce fichier voix — le +/// même nombre, par la même fonction, pour que la preview de l'éditeur joue la voix au +/// niveau où l'export l'écrira. Voir `openscreen_compositor::audio::loudness_gain_db`. +/// +/// `AsyncTask` : la mesure décode tout l'audio du fichier, ce qui se compte en secondes sur +/// un long enregistrement. Le résultat est mis en cache dans le processus, donc l'export qui +/// suit ne le refait pas. +#[napi] +pub fn loudness_gain_db(path: String) -> AsyncTask { + AsyncTask::new(LoudnessGainTask { path }) +} diff --git a/crates/compositor/Cargo.toml b/crates/compositor/Cargo.toml index face980ee..cc9ef6459 100644 --- a/crates/compositor/Cargo.toml +++ b/crates/compositor/Cargo.toml @@ -2,7 +2,7 @@ name = "openscreen-compositor" version.workspace = true edition.workspace = true -description = "Compositeur natif multiplateforme d'OpenScreen : décodage, pipeline, effets, scène, curseur, audio, et la vue live embarquable. Code de production — l'addon Electron (compositor-view-napi) s'y lie, le POC (poc-d3d) le consomme aussi.\n\nWindows : D3D11 + D3D11VA + shaders HLSL compilés à l'exécution. macOS : Metal + VideoToolbox + shaders MSL (port de ce PR)." +description = "Compositeur natif multiplateforme d'OpenScreen : décodage, pipeline, effets, scène, curseur, audio, et la vue live embarquable. Code de production — l'addon Electron (compositor-view-napi) s'y lie, le POC (poc-d3d) le consomme aussi.\n\nWindows : D3D11 + D3D11VA + shaders HLSL compilés au build (build.rs). macOS : Metal + VideoToolbox + shaders MSL (port de ce PR)." [lib] name = "openscreen_compositor" @@ -12,6 +12,11 @@ path = "src/lib.rs" bindgen = "0.70" cc = "1" +# D3DCompile, pour compiler `shaders.hlsl` au build (`compile_hlsl` dans build.rs). La version +# est celle du workspace ; les features, les deux seules dont le build a besoin. +[target.'cfg(windows)'.build-dependencies] +windows = { version = "0.58", features = ["Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D_Fxc"] } + [features] default = ["segmentation"] # Segmentation IA de la webcam via ONNX Runtime (EP CPU). diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs index 99b11e2f6..c55d8ee38 100644 --- a/crates/compositor/build.rs +++ b/crates/compositor/build.rs @@ -171,6 +171,81 @@ fn main() { if let Ok(prefix) = env::var("OPENSCREEN_FFMPEG_SYMBOL_PREFIX") { prefix_ffmpeg_symbols(&ffi_path, &prefix); } + + // `cfg(windows)` = l'HÔTE : d3dcompiler n'existe que là, et la cible Windows ne se + // construit que depuis Windows (MSVC, import libs ffmpeg). + if target_os == "windows" { + #[cfg(windows)] + compile_hlsl(&out); + } +} + +/// Les shaders du compositeur Windows (`src/shaders.hlsl`), compilés ICI et embarqués en +/// bytecode (`shader!` dans `compositor_windows.rs`), plutôt qu'à chaque construction d'un +/// `Compositor`. À l'exécution, FXC y passait plusieurs secondes sur l'uber-shader (0,5 s à la +/// mi-septembre 2026, 4 à 16 s selon la charge dix jours plus tard), payées deux fois à +/// l'ouverture d'un projet et une fois à chaque changement de taille de rendu : autant de temps +/// sans image dans la preview. Une faute dans le HLSL fait maintenant échouer `cargo check`, au +/// lieu de l'exécution. +#[cfg(windows)] +fn compile_hlsl(out: &Path) { + use windows::core::PCSTR; + use windows::Win32::Graphics::Direct3D::Fxc::{D3DCompile, D3DCOMPILE_OPTIMIZATION_LEVEL3}; + use windows::Win32::Graphics::Direct3D::{ID3DBlob, D3D_SHADER_MACRO}; + + fn blob_bytes(blob: &ID3DBlob) -> Vec { + unsafe { + std::slice::from_raw_parts(blob.GetBufferPointer() as *const u8, blob.GetBufferSize()) + .to_vec() + } + } + + const SRC: &str = "src/shaders.hlsl"; + println!("cargo:rerun-if-changed={SRC}"); + let hlsl = std::fs::read(SRC).expect(SRC); + // `ps_main` avec les modèles 3D : la variante que seuls ces calques utilisent (le haut de + // `shaders.hlsl`). La liste se termine par une entrée nulle. + let models = [ + D3D_SHADER_MACRO { Name: PCSTR(b"LAYER_MODELS\0".as_ptr()), Definition: PCSTR(b"1\0".as_ptr()) }, + D3D_SHADER_MACRO { Name: PCSTR::null(), Definition: PCSTR::null() }, + ]; + // Tous les points d'entrée que `Compositor::new_inner` instancie, avec leur profil, le nom du + // `.cso` qu'en lit `shader!` et leurs macros. + for (entry, profile, cso, defines) in [ + ("vs_main", "vs_5_0", "vs_main", None), + ("ps_main", "ps_5_0", "ps_main", None), + ("ps_main", "ps_5_0", "ps_main_models", Some(models.as_ptr())), + ("vs_fs", "vs_5_0", "vs_fs", None), + ("ps_y", "ps_5_0", "ps_y", None), + ("ps_uv", "ps_5_0", "ps_uv", None), + ("ps_blur", "ps_5_0", "ps_blur", None), + ("ps_tex", "ps_5_0", "ps_tex", None), + ("ps_kawase_down", "ps_5_0", "ps_kawase_down", None), + ("ps_kawase_up", "ps_5_0", "ps_kawase_up", None), + ] { + let (entry_z, profile_z) = (format!("{entry}\0"), format!("{profile}\0")); + let (mut code, mut errors): (Option, Option) = (None, None); + let compiled = unsafe { + D3DCompile( + hlsl.as_ptr().cast(), + hlsl.len(), + PCSTR(b"shaders.hlsl\0".as_ptr()), + defines, + None, + PCSTR(entry_z.as_ptr()), + PCSTR(profile_z.as_ptr()), + D3DCOMPILE_OPTIMIZATION_LEVEL3, + 0, + &mut code, + Some(&mut errors), + ) + }; + if let Err(e) = compiled { + let log = errors.as_ref().map(blob_bytes).unwrap_or_default(); + panic!("{SRC} : {cso} ne compile pas ({e})\n{}", String::from_utf8_lossy(&log)); + } + std::fs::write(out.join(format!("{cso}.cso")), blob_bytes(&code.unwrap())).expect(cso); + } } /// Flags `-I` supplémentaires pour que clang trouve les en-têtes « freestanding » qu'il diff --git a/crates/compositor/examples/live_free_run_bench_macos.rs b/crates/compositor/examples/live_free_run_bench_macos.rs new file mode 100644 index 000000000..8b3ec5c3b --- /dev/null +++ b/crates/compositor/examples/live_free_run_bench_macos.rs @@ -0,0 +1,125 @@ +//! Bench de la lecture libre de la preview (macOS) : rejoue la boucle de `render_thread` sur +//! un seul clip, à une vitesse donnée, et mesure le retard que la vue prend sur l'horloge. +//! +//! Même arithmétique que `render_thread` : l'accumulateur avance de `dt × vitesse`, `step()` +//! compose chaque frame due jusqu'à `max_steps` par tick (au-delà le reliquat est abandonné), +//! puis relit le RT UNE fois par tick, comme la publication macOS (pas de textures partagées). +//! `dt` est borné à 0,1 s comme dans `render_thread` : un tick plus long perd de l'horloge. +//! +//! Utilisation : +//! `cargo run --release --example live_free_run_bench_macos -- [vitesse=4] [secondes=8] [largeur=1920] [hauteur=1080]` + +#[cfg(target_os = "macos")] +fn main() -> anyhow::Result<()> { + use openscreen_compositor::compositor::Compositor; + use openscreen_compositor::config; + use openscreen_compositor::d3d::Gpu; + use openscreen_compositor::frame_geometry::live_params_from_scene; + use openscreen_compositor::live::Player; + use openscreen_compositor::scene::Scene; + use std::time::{Duration, Instant}; + + let mut args = std::env::args().skip(1); + let path = args.next().unwrap_or_else(|| { + eprintln!("usage: live_free_run_bench_macos [vitesse=4] [secondes=8] [largeur=1920] [hauteur=1080]"); + std::process::exit(2); + }); + let speed: f64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(4.0); + let seconds: f64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(8.0); + let width: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1920); + let height: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1080); + + let source = path.replace('\\', "/"); + let scene_json = format!( + r##"{{ + "clips": [{{"screenPath":"{source}","webcamPath":"","sourceStartSec":0,"sourceEndSec":100000,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#303030"}}, + "zoomRegions": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{width},"height":{height},"fps":30}} + }}"## + ); + + let gpu = Gpu::create(false)?; + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + let comp = Compositor::new_sized(&gpu, width, height)?; + let scene = Scene::from_json(&scene_json)?; + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene.clone())); + comp.clear_cursor(); + + unsafe { + let mut player = Player::open(&path, "", &gpu)?; + player.set_programme_clock(Some(&scene), 0); + anyhow::ensure!(player.present_frame(&comp, &cfg, 0.0)?, "aucune frame à 0 s"); + // Temps source réellement joué, cumulé frame par frame : un clip plus court que la mesure + // reboucle à 0 (EOF), et la seule position finale compterait alors une seule passe. + let mut played = 0.0f64; + + let mut acc = 0.0f64; + let mut last = Instant::now(); + let t0 = last; + let (mut ticks, mut adopted, mut capped, mut published) = (0u64, 0u64, 0u64, 0u64); + // Ce que l'horloge de l'app a parcouru, en temps source : la cible que la vue devrait tenir. + let mut clock_source = 0.0f64; + while t0.elapsed().as_secs_f64() < seconds { + let now = Instant::now(); + let dt = (now - last).as_secs_f64().min(0.1); + last = now; + ticks += 1; + acc += dt * speed; + clock_source += (now - t0).as_secs_f64() * speed - clock_source; + let max_steps = ((3.0 * speed.max(1.0)).ceil() as i32).min(64); + let mut n = 0; + let mut stepped = false; + loop { + let before = player.screen_time_sec(); + if !player.step(&comp, &cfg, before + acc)? { + break; + } + stepped = true; + adopted += 1; + let after = player.screen_time_sec(); + played += if after >= before { after - before } else { after }; + acc = if after >= before { (acc - (after - before)).max(0.0) } else { 0.0 }; + n += 1; + if n >= max_steps { + capped += 1; + acc = 0.0; + break; + } + } + if stepped { + comp.readback_direct()?; + published += 1; + } else { + std::thread::sleep(Duration::from_millis(4)); + } + } + let wall = t0.elapsed().as_secs_f64(); + println!( + "vitesse {speed}x, {width}x{height}, {wall:.2} s : horloge {:.2} s source, vue {played:.2} s source, retard {:.2} s source ({:.2} s de lecture)", + clock_source, + clock_source - played, + (clock_source - played) / speed + ); + println!( + "ticks {ticks} ({:.1}/s), frames adoptées {adopted} ({:.1}/s), plafond atteint {capped} fois, publiées {published} ({:.1}/s)", + ticks as f64 / wall, + adopted as f64 / wall, + published as f64 / wall + ); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("live_free_run_bench_macos : macOS uniquement"); +} diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index ee90c1374..6b13df4c5 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -5,11 +5,14 @@ use crate::ffi::*; use crate::regions::SpeedSegment; -use crate::scene::{SceneAudio, SceneAudioTrack}; +use crate::scene::{SceneAudio, SceneAudioTrack, SceneAudioTrackKind}; use anyhow::{bail, Result}; +use std::collections::{HashMap, VecDeque}; use std::f32::consts::PI; use std::ffi::CString; use std::ptr; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::SystemTime; pub const AUDIO_OUTPUT_SAMPLE_RATE: i32 = 48_000; pub const AUDIO_OUTPUT_CHANNELS: usize = 2; @@ -28,25 +31,25 @@ const PASSTHROUGH_EPSILON: f64 = 1e-3; pub type PlanarPcm = Vec>; -/// Apply the editor's output trim to the assembled timeline. +/// Apply the editor's output trim to the assembled timeline, then keep it under the ceiling. /// -/// One stage, and keeping it that way takes some resisting. This runs on the assembled -/// timeline — trimmed, speed-adjusted, concatenated — while the editor preview plays the -/// untouched SOURCE file, seeked. A linear gain is the only operation that means the same -/// thing on both, which is what lets the editor claim that what you hear is what you export. +/// This runs on the assembled timeline — trimmed, speed-adjusted, concatenated — while the +/// editor preview plays the untouched SOURCE file, seeked. A linear gain is the one operation +/// that means the same thing on both, which is why the level work that has to match the +/// preview happens elsewhere, as static gains: the voice's loudness normalisation is a gain +/// per FILE (`loudness_gain_db`), applied to each clip's audio before assembly and asked for +/// by the preview too. /// -/// Three things that look like they belong here and do not: -/// - a filter or a compressor, which carries state across cuts here and not in the preview; -/// - a loudness normaliser, whose makeup is a single scalar measured over the whole assembled -/// programme — the preview never holds that programme, and the value moves with every trim; -/// - a sync offset, which shipped here once. It is expressed in TIMELINE seconds at this -/// point in the pipeline, but the preview would apply it in SOURCE seconds, so a 2x speed -/// region halved it; and because the shift is uniform over the assembled programme, near a -/// cut the export pulls audio across the junction while the preview only has the active -/// asset loaded. +/// The limiter is the one stage here that the preview does not run. It is stateful, so it +/// would not see the same signal on both sides, and it only acts on peaks above +/// `LIMITER_CEILING`: below that the output is the trimmed input, sample for sample. What +/// it replaced was a hard clamp at full scale, which squared off every peak the trim or the +/// normalisation pushed over. /// -/// Any of them means either rendering the export's audio assembly preview-side, or accepting -/// and documenting a divergence — not a quiet extra stage in this function. +/// A sync offset shipped here once and was removed: it is expressed in TIMELINE seconds at +/// this point, but the preview would apply it in SOURCE seconds, so a 2x speed region halved +/// it, and near a cut the export pulled audio across the junction while the preview only has +/// the active asset loaded. /// /// The bound mirrors `AUDIO_GAIN_DB_LIMIT` in editorSettings.ts. The result stays the same /// length so video and following clips cannot drift. @@ -61,11 +64,258 @@ pub fn finish_audio(mut pcm: PlanarPcm, settings: SceneAudio) -> PlanarPcm { let trim = 10.0f32.powf(settings.gain_db.clamp(-12.0, 12.0) / 20.0); for sample in pcm.iter_mut().flatten() { - *sample = (*sample * trim).clamp(-1.0, 1.0); + *sample *= trim; } + limit_peaks(&mut pcm); pcm } +/// Highest sample the export writes: −1.5 dBFS. The limiter reads samples, not the waveform +/// between them, and the AAC encoder overshoots a little on top: measured, a −1.0 dBFS ceiling +/// came out of the encoder at −0.9 dBTP. The extra half decibel keeps the file under the usual +/// −1 dBTP delivery limit. +pub const LIMITER_CEILING: f32 = 0.841_395_1; +/// How early the gain starts to come down before a peak, as a straight ramp: 5 ms. +const LIMITER_LOOKAHEAD: usize = 240; +/// Time constant of the recovery once a peak has passed. +const LIMITER_RELEASE_SEC: f32 = 0.08; + +/// Gain that brings sample `index` of every channel under the ceiling, 1 when it already is. +fn gain_to_ceiling(pcm: &[Vec], index: usize) -> f32 { + let peak = pcm.iter().fold(0.0f32, |peak, channel| peak.max(channel[index].abs())); + if peak > LIMITER_CEILING { + LIMITER_CEILING / peak + } else { + 1.0 + } +} + +/// Look-ahead peak limiter, linked across channels so the stereo image does not move. +/// +/// The gain each sample needs (`gain_to_ceiling`) is held at its minimum over the next +/// `LIMITER_LOOKAHEAD` samples, then averaged over the last `LIMITER_LOOKAHEAD`. Every value +/// in that average comes from a window that contains the current sample, so the average never +/// exceeds what the sample needs: the ceiling holds exactly, and the gain still reaches each +/// peak as a straight 5 ms ramp instead of a step. The recovery after it is exponential, and +/// a signal that never crosses the ceiling passes through untouched. +fn limit_peaks(pcm: &mut PlanarPcm) { + let len = pcm.first().map(Vec::len).unwrap_or(0); + let window = LIMITER_LOOKAHEAD as isize; + let release = 1.0 - (-1.0 / (LIMITER_RELEASE_SEC * AUDIO_OUTPUT_SAMPLE_RATE as f32)).exp(); + // Rising minimum of the needed gain over the look-ahead window, as (index, gain). + let mut held: VecDeque<(usize, f32)> = VecDeque::new(); + let mut ring = vec![1.0f32; LIMITER_LOOKAHEAD]; + let mut ring_sum = LIMITER_LOOKAHEAD as f64; + let mut next = 0usize; + let mut gain = 1.0f32; + // Starting a window early fills the average with minima that already cover sample 0. + for index in (1 - window)..len as isize { + let horizon = ((index + window) as usize).min(len); + while next < horizon { + let needed = gain_to_ceiling(pcm, next); + while held.back().is_some_and(|&(_, g)| g >= needed) { + held.pop_back(); + } + held.push_back((next, needed)); + next += 1; + } + let first = index.max(0) as usize; + while held.front().is_some_and(|&(i, _)| i < first) { + held.pop_front(); + } + let minimum = held.front().map_or(1.0, |&(_, g)| g); + let slot = (index + window) as usize % LIMITER_LOOKAHEAD; + ring_sum += f64::from(minimum - ring[slot]); + ring[slot] = minimum; + if index < 0 { + continue; + } + let sample = index as usize; + // The last `min` only absorbs the running sum's rounding: the average already obeys it. + gain = ((ring_sum / LIMITER_LOOKAHEAD as f64) as f32) + .min(gain + (1.0 - gain) * release) + .min(gain_to_ceiling(pcm, sample)); + if gain < 1.0 { + for channel in pcm.iter_mut() { + channel[sample] *= gain; + } + } + } +} + +/// Integrated loudness every voice source is brought to: −16 LUFS, the usual delivery target +/// for spoken content online (it is what Apple Podcasts asks for). +pub const LOUDNESS_TARGET_LUFS: f64 = -16.0; +/// Most the normalisation will raise a file. Without a denoiser, a recording that is mostly +/// room noise (a microphone left open over a silent demo) would otherwise come out as loud +/// hiss; a voice quieter than −28 LUFS lands below the target instead. +pub const LOUDNESS_MAX_BOOST_DB: f64 = 12.0; + +/// ITU-R BS.1770-4 K-weighting at 48 kHz — the pre-filter's high shelf, then the RLB +/// high-pass — as `[b0, b1, b2, a1, a2]`. +const K_WEIGHTING: [[f64; 5]; 2] = [ + [ + 1.535_124_859_586_97, + -2.691_696_189_406_38, + 1.198_392_810_852_85, + -1.690_659_293_182_41, + 0.732_480_774_215_85, + ], + [1.0, -2.0, 1.0, -1.990_047_454_833_98, 0.990_072_250_366_21], +]; +/// 100 ms: four of these make a 400 ms gating block, and one is the 75 % overlap hop. +const LOUDNESS_STEP: usize = AUDIO_OUTPUT_SAMPLE_RATE as usize / 10; + +/// Integrated loudness per ITU-R BS.1770-4, gated as EBU R128 specifies (−70 LUFS absolute, +/// −10 LU relative). Fed in order, one window at a time, so a long recording never sits in +/// memory whole: only one number per 100 ms is kept. +struct LoudnessMeter { + /// Transposed direct form II state, per channel and per filter stage. + state: [[[f64; 2]; 2]; AUDIO_OUTPUT_CHANNELS], + /// Channel-summed, K-weighted mean square of each complete 100 ms step. + steps: Vec, + partial: f64, + partial_len: usize, +} + +impl LoudnessMeter { + fn new() -> Self { + Self { + state: [[[0.0; 2]; 2]; AUDIO_OUTPUT_CHANNELS], + steps: Vec::new(), + partial: 0.0, + partial_len: 0, + } + } + + fn push(&mut self, pcm: &[Vec]) { + let len = pcm.first().map(Vec::len).unwrap_or(0); + for index in 0..len { + let mut energy = 0.0f64; + for (channel, state) in self.state.iter_mut().enumerate() { + let sample = pcm.get(channel).and_then(|plane| plane.get(index)); + let mut x = f64::from(sample.copied().unwrap_or(0.0)); + for (c, z) in K_WEIGHTING.iter().zip(state.iter_mut()) { + let y = c[0] * x + z[0]; + z[0] = c[1] * x - c[3] * y + z[1]; + z[1] = c[2] * x - c[4] * y; + x = y; + } + energy += x * x; + } + self.partial += energy; + self.partial_len += 1; + if self.partial_len == LOUDNESS_STEP { + self.steps.push(self.partial / LOUDNESS_STEP as f64); + self.partial = 0.0; + self.partial_len = 0; + } + } + } + + /// LUFS, or `None` when no 400 ms block clears the absolute gate: silence has no loudness + /// to correct. + fn integrated(&self) -> Option { + let lufs = |power: f64| -0.691 + 10.0 * power.log10(); + let mean = |powers: &[f64]| powers.iter().sum::() / powers.len() as f64; + let audible: Vec = self + .steps + .windows(4) + .map(|block| block.iter().sum::() / 4.0) + .filter(|&power| lufs(power) > -70.0) + .collect(); + if audible.is_empty() { + return None; + } + let relative_gate = lufs(mean(&audible)) - 10.0; + let gated: Vec = audible + .into_iter() + .filter(|&power| lufs(power) > relative_gate) + .collect(); + Some(lufs(mean(&gated))) + } +} + +/// One minute at a time: 23 MB of PCM, whatever the recording's length. +const LOUDNESS_WINDOW_SEC: f64 = 60.0; +/// A day of windows. Only a file whose timestamps never end would get here. +const LOUDNESS_MAX_WINDOWS: usize = 24 * 60; + +/// Integrated loudness of every audio track in `path`, mixed exactly as the export mixes +/// them (`decode_clip_audio`), over the whole file. `Ok(None)`: no audio, or only silence. +pub fn measure_file_loudness(path: &str) -> Result> { + let mut meter = LoudnessMeter::new(); + for window in 0..LOUDNESS_MAX_WINDOWS { + let start = window as f64 * LOUDNESS_WINDOW_SEC; + let Some((pcm, more)) = decode_audio_window(path, start, start + LOUDNESS_WINDOW_SEC)? + else { + return Ok(None); + }; + // The last window is zero-padded past the end of the file; silence never clears + // the absolute gate, so the padding does not move the result. + meter.push(&pcm); + if !more { + break; + } + } + Ok(meter.integrated()) +} + +/// The gain, in dB, that takes audio measured at `loudness` to the target: negative for a +/// hot recording, positive up to `LOUDNESS_MAX_BOOST_DB` for a quiet one, 0 for silence. +fn loudness_gain_for(loudness: Option) -> f32 { + loudness.map_or(0.0, |lufs| { + (LOUDNESS_TARGET_LUFS - lufs).min(LOUDNESS_MAX_BOOST_DB) as f32 + }) +} + +/// Loudness-normalisation gain for one voice source, in dB. +/// +/// Measured over the WHOLE file, not over the part the timeline keeps. That makes it a +/// property of the recording rather than of the edit: a trim does not move the level of what +/// is left, a timeline cut from several takes brings each take to the target on its own, and +/// the editor preview — which plays the source file, not the assembled programme — can apply +/// the very same number (it asks for it through the addon's `loudnessGainDb`). +/// +/// Cached per process by path, size and modification time, so the preview's request and +/// every export job of every clip cut from the same file share one measurement. A file that +/// cannot be read measures as 0 dB, the same degradation a clip without audio gets. +pub fn loudness_gain_db(path: &str) -> f32 { + type Key = (String, u64, Option); + static CACHE: OnceLock>>>> = OnceLock::new(); + let Ok(metadata) = std::fs::metadata(path) else { + return 0.0; + }; + let key = (path.to_owned(), metadata.len(), metadata.modified().ok()); + let cell = CACHE + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .entry(key) + .or_default() + .clone(); + // Measured outside the map's lock: another file never waits on this one, and a second + // request for the same file waits for the first measurement instead of repeating it. + *cell.get_or_init(|| match measure_file_loudness(path) { + Ok(loudness) => loudness_gain_for(loudness), + Err(error) => { + eprintln!("[audio] loudness of {path} not measured ({error:#}); left as recorded"); + 0.0 + } + }) +} + +/// Multiply every sample by the gain `gain_db` stands for. A no-op at 0 dB. +pub fn apply_gain_db(pcm: &mut PlanarPcm, gain_db: f32) { + if gain_db == 0.0 { + return; + } + let gain = 10.0f32.powf(gain_db / 20.0); + for sample in pcm.iter_mut().flatten() { + *sample *= gain; + } +} + 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 @@ -233,6 +483,16 @@ impl Drop for AudioTrackDecoder { /// 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> { + Ok(decode_audio_window(path, source_start_sec, source_end_sec)?.map(|(pcm, _)| pcm)) +} + +/// `decode_clip_audio`, plus whether the file still has audio past `source_end_sec`: what a +/// caller walking a whole file one window at a time needs to know where to stop. +fn decode_audio_window( + path: &str, + source_start_sec: f64, + source_end_sec: f64, +) -> Result> { unsafe { decode_clip_audio_inner(path, source_start_sec, source_end_sec) } } @@ -240,7 +500,7 @@ unsafe fn decode_clip_audio_inner( path: &str, source_start_sec: f64, source_end_sec: f64, -) -> Result> { +) -> Result> { let mut fmt: *mut AVFormatContext = ptr::null_mut(); let cpath = CString::new(path)?; averr( @@ -402,14 +662,15 @@ unsafe fn decode_clip_audio_inner( let target_samples = (((source_end_sec - source_start_sec).max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64) .round()) as usize; + // A track stops either on a frame at or past the window's end, or at the end of the file. + let more = tracks.iter().any(|track| track.reached_end); 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, + Ok(Some(( + mix_aligned_tracks(&aligned, source_start_sec, target_samples), + more, ))) } @@ -444,16 +705,10 @@ fn mix_aligned_tracks( } } } - // 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); - } - } - } + // Sommer plusieurs pistes peut dépasser la pleine échelle, et on ne l'écrête PLUS ici : le + // flottant garde ces crêtes intactes jusqu'au gain de loudness (qui baisse souvent un mix + // chaud) puis au limiteur de `finish_audio`, seul endroit où le signal touche un plafond. + // Écrêter ici cassait des crêtes que la suite aurait ramenées sous la pleine échelle. mixed } @@ -1399,59 +1654,169 @@ pub fn assemble_concatenated_pcm( /// /// A track whose file has no decodable audio is skipped — the same degradation a /// stream-less clip gets. +/// +/// Voiceovers go in first, because they are part of what the music ducks under: once they +/// are summed, the programme IS the voice — the recording's own audio plus every take — and +/// `duck_curve` reads it to lower each music bed while someone speaks. pub fn mix_external_tracks(mut programme: PlanarPcm, tracks: &[SceneAudioTrack]) -> PlanarPcm { - let programme_len = programme.first().map(Vec::len).unwrap_or(0); - if programme_len == 0 { + if programme.first().map_or(true, Vec::is_empty) { return programme; } - for track in tracks { - let offset = (track.start_sec.max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as usize; - // A track that starts at or past the programme end contributes nothing — - // skip it before decoding anything. - if offset >= programme_len { - continue; - } - let trim_start = track.trim_start_sec.max(0.0); - let Some(trim_end_full) = track.trim_end_sec else { - // Without a concrete end there is no safe window to decode (see the doc - // comment); the renderer always resolves one, so this only guards a - // hand-written scene. - continue; - }; - // Cap the decode window at the room left in the programme. Everything past - // `offset` that overflows is discarded by `overlay_track_pcm` anyway, so - // decoding it only wastes time and memory — a three-hour track placed at - // second 9 of a ten-second export must not buffer three hours of PCM. - let remaining_sec = (programme_len - offset) as f64 / AUDIO_OUTPUT_SAMPLE_RATE as f64; - let trim_end = trim_end_full.min(trim_start + remaining_sec); - // The track's own length, before that cap. The fades belong to the track, not to - // whatever the programme had room for — capping first and measuring after is what - // made a fade-out ramp down at the truncation point instead of at the real end. - let full_len = - ((trim_end_full - trim_start).max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64) as usize; - if trim_end <= trim_start { - continue; + let (voice, music): (Vec<&SceneAudioTrack>, Vec<&SceneAudioTrack>) = tracks + .iter() + .partition(|track| track.kind == SceneAudioTrackKind::Voiceover); + for track in voice { + overlay_external_track(&mut programme, track, &[]); + } + if !music.is_empty() { + let duck = duck_curve(&programme); + for track in music { + overlay_external_track(&mut programme, track, &duck); } - let decoded = match decode_clip_audio(&track.path, trim_start, trim_end) { - Ok(Some(pcm)) => pcm, - _ => continue, - }; - // The app's own range is -60..+12 dB (the inspector slider); clamping at - // -12 here floored every quiet bed at a tenth of the attenuation asked for. - let gain = 10.0f32.powf(track.gain_db.clamp(-60.0, 12.0) / 20.0); - overlay_track_pcm( - &mut programme, - &decoded, - offset, - gain, - track.fade_in_sec.max(0.0), - track.fade_out_sec.max(0.0), - full_len, - ); } programme } +/// One entry of `mix_external_tracks`: decode its window, level it, sum it in under `duck`. +fn overlay_external_track(programme: &mut PlanarPcm, track: &SceneAudioTrack, duck: &[f32]) { + let programme_len = programme.first().map(Vec::len).unwrap_or(0); + let offset = (track.start_sec.max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64).round() as usize; + // A track that starts at or past the programme end contributes nothing — + // skip it before decoding anything. + if offset >= programme_len { + return; + } + let trim_start = track.trim_start_sec.max(0.0); + let Some(trim_end_full) = track.trim_end_sec else { + // Without a concrete end there is no safe window to decode (see the doc + // comment); the renderer always resolves one, so this only guards a + // hand-written scene. + return; + }; + // Cap the decode window at the room left in the programme. Everything past + // `offset` that overflows is discarded by `overlay_track_pcm` anyway, so + // decoding it only wastes time and memory — a three-hour track placed at + // second 9 of a ten-second export must not buffer three hours of PCM. + let remaining_sec = (programme_len - offset) as f64 / AUDIO_OUTPUT_SAMPLE_RATE as f64; + let trim_end = trim_end_full.min(trim_start + remaining_sec); + // The track's own length, before that cap. The fades belong to the track, not to + // whatever the programme had room for — capping first and measuring after is what + // made a fade-out ramp down at the truncation point instead of at the real end. + let full_len = + ((trim_end_full - trim_start).max(0.0) * AUDIO_OUTPUT_SAMPLE_RATE as f64) as usize; + if trim_end <= trim_start { + return; + } + let decoded = match decode_clip_audio(&track.path, trim_start, trim_end) { + Ok(Some(pcm)) => pcm, + _ => return, + }; + // The app's own range is -60..+12 dB (the inspector slider); clamping at + // -12 here floored every quiet bed at a tenth of the attenuation asked for. + // A voiceover is voice, levelled like the recording (see `loudness_gain_db`); its + // own gain then trims from there, exactly as the preview applies it. + let normalisation = match track.kind { + SceneAudioTrackKind::Voiceover => loudness_gain_db(&track.path), + SceneAudioTrackKind::Music => 0.0, + }; + let gain = 10.0f32.powf((track.gain_db.clamp(-60.0, 12.0) + normalisation) / 20.0); + overlay_track_pcm( + programme, + &decoded, + offset, + gain, + track.fade_in_sec.max(0.0), + track.fade_out_sec.max(0.0), + full_len, + duck, + ); +} + +/// How far a music bed dips while someone speaks. +pub const DUCK_DEPTH_DB: f32 = -10.0; +/// A 10 ms stretch of the voice counts as speech above this RMS level. The voice is already +/// loudness-normalised here, so speech sits well above it, and a room's noise floor — even +/// raised by the normalisation's full boost — well below. +const DUCK_THRESHOLD_DBFS: f32 = -35.0; +/// Pauses shorter than this keep the music down: the gaps between words and phrases. +const DUCK_HOLD_SEC: f64 = 0.5; +/// The dip starts this long before the voice does, so the first syllable is already clear. +const DUCK_ATTACK_SEC: f64 = 0.25; +/// And takes this long to come back up once a pause outlasts the hold. +const DUCK_RELEASE_SEC: f64 = 0.6; +/// Resolution of the envelope: 10 ms. +const DUCK_BLOCK: usize = AUDIO_OUTPUT_SAMPLE_RATE as usize / 100; + +/// The gain a music bed takes under `voice`, one linear value per `DUCK_BLOCK` of the +/// programme: 1 in the clear, `DUCK_DEPTH_DB` while someone speaks, with straight ramps in dB +/// between. The export holds the whole voice, so it looks ahead: the dip is already complete +/// when the first word starts. +fn duck_curve(voice: &PlanarPcm) -> Vec { + let len = voice.first().map(Vec::len).unwrap_or(0); + let blocks = len.div_ceil(DUCK_BLOCK); + let threshold = 10.0f32.powf(DUCK_THRESHOLD_DBFS / 10.0); + let speaking: Vec = (0..blocks) + .map(|block| { + let range = block * DUCK_BLOCK..((block + 1) * DUCK_BLOCK).min(len); + let samples = (range.len() * voice.len()) as f32; + let energy: f32 = voice + .iter() + .map(|channel| channel[range.clone()].iter().map(|s| s * s).sum::()) + .sum(); + energy / samples > threshold + }) + .collect(); + let blocks_of = |sec: f64| (sec * 100.0).round() as usize; + let (attack, hold, release) = ( + blocks_of(DUCK_ATTACK_SEC), + blocks_of(DUCK_HOLD_SEC), + blocks_of(DUCK_RELEASE_SEC), + ); + // Down from `attack` blocks before any speech until `hold` blocks after it. + let mut ducked = vec![false; blocks]; + let mut last = None; + for block in 0..blocks { + if speaking[block] { + last = Some(block); + } + ducked[block] = last.is_some_and(|spoken| block - spoken <= hold); + } + let mut next = None; + for block in (0..blocks).rev() { + if speaking[block] { + next = Some(block); + } + ducked[block] |= next.is_some_and(|spoken| spoken - block <= attack); + } + let (down, up) = ( + DUCK_DEPTH_DB / attack as f32, + -DUCK_DEPTH_DB / release as f32, + ); + let mut db = 0.0f32; + ducked + .into_iter() + .map(|down_here| { + db = if down_here { + (db + down).max(DUCK_DEPTH_DB) + } else { + (db + up).min(0.0) + }; + 10.0f32.powf(db / 20.0) + }) + .collect() +} + +/// `duck_curve` at one programme sample, interpolated between its blocks so the level never +/// steps. An empty curve is no ducking at all. +fn duck_gain_at(curve: &[f32], sample: usize) -> f32 { + let block = sample / DUCK_BLOCK; + let Some(&from) = curve.get(block).or(curve.last()) else { + return 1.0; + }; + let to = curve.get(block + 1).copied().unwrap_or(from); + from + (to - from) * (sample % DUCK_BLOCK) as f32 / DUCK_BLOCK as f32 +} + /// Sum one decoded track into the programme at `offset` samples, scaled by `gain`, /// truncated at the programme's end. Split out of `mix_external_tracks` so the /// placement/gain/clamp math is testable without ffmpeg, exactly like @@ -1467,6 +1832,8 @@ fn overlay_track_pcm( // `decoded` may be shorter because the decode window was capped at the room left in the // programme; the ramps belong to the track, not to the room. full_len: usize, + // `duck_curve` over the programme for a music bed, empty for a voiceover. + duck: &[f32], ) { let programme_len = programme.first().map(Vec::len).unwrap_or(0); if offset >= programme_len { @@ -1486,7 +1853,10 @@ fn overlay_track_pcm( let count = source.len().min(room); let dst = &mut programme[channel]; for k in 0..count { - dst[offset + k] += source[k] * gain * fade_envelope(k, envelope_len, fade_in, fade_out); + dst[offset + k] += source[k] + * gain + * fade_envelope(k, envelope_len, fade_in, fade_out) + * duck_gain_at(duck, offset + k); } } } @@ -1878,7 +2248,7 @@ mod tests { fn overlay_sums_at_offset_with_gain() { let mut programme = planar(&[0.1, 0.1, 0.1, 0.1]); // ×2 gain, placed at sample offset 1. - overlay_track_pcm(&mut programme, &planar(&[0.2, 0.2]), 1, 2.0, 0.0, 0.0, 0); + overlay_track_pcm(&mut programme, &planar(&[0.2, 0.2]), 1, 2.0, 0.0, 0.0, 0, &[]); assert_eq!(programme[0], vec![0.1, 0.5, 0.5, 0.1]); assert_eq!(programme[1], vec![0.1, 0.5, 0.5, 0.1]); } @@ -1887,14 +2257,14 @@ mod tests { fn overlay_truncates_a_track_that_runs_past_the_programme() { let mut programme = planar(&[0.0, 0.0, 0.0]); // A 4-sample track placed at offset 2 has room for only 1 sample. - overlay_track_pcm(&mut programme, &planar(&[1.0, 1.0, 1.0, 1.0]), 2, 1.0, 0.0, 0.0, 0); + overlay_track_pcm(&mut programme, &planar(&[1.0, 1.0, 1.0, 1.0]), 2, 1.0, 0.0, 0.0, 0, &[]); assert_eq!(programme[0], vec![0.0, 0.0, 1.0]); } #[test] fn overlay_past_the_end_is_a_no_op() { let mut programme = planar(&[0.3, 0.3]); - overlay_track_pcm(&mut programme, &planar(&[1.0]), 5, 1.0, 0.0, 0.0, 0); + overlay_track_pcm(&mut programme, &planar(&[1.0]), 5, 1.0, 0.0, 0.0, 0, &[]); assert_eq!(programme[0], vec![0.3, 0.3]); } @@ -1907,8 +2277,7 @@ mod tests { gain_db: 0.0, trim_start_sec: 2.0, trim_end_sec: Some(1.0), // end <= start: empty window, never decoded - fade_in_sec: 0.0, - fade_out_sec: 0.0, + ..Default::default() }]; // The empty window is skipped before any decode, so the programme is // untouched even though the path does not exist. @@ -1928,8 +2297,7 @@ mod tests { gain_db: 0.0, trim_start_sec: 0.0, trim_end_sec: Some(3600.0), - fade_in_sec: 0.0, - fade_out_sec: 0.0, + ..Default::default() }]; let out = mix_external_tracks(programme, &tracks); assert_eq!(out[0], vec![0.4, 0.4]); @@ -1938,7 +2306,7 @@ mod tests { #[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. + // hors pleine échelle. 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]); @@ -1965,11 +2333,15 @@ mod tests { } #[test] - fn multi_track_sum_is_clamped_to_full_scale() { + fn a_multi_track_sum_past_full_scale_is_not_clipped_at_decode() { + // The loudness gain comes next and often brings a hot mix down; squaring the peaks + // off here would bake in a clip the rest of the chain could have avoided. The + // limiter in `finish_audio` is the one place the signal meets a ceiling. 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]); + assert!((mixed[0][0] - 1.6).abs() < 1e-6); + assert!((mixed[0][1] + 1.6).abs() < 1e-6); } #[test] @@ -1999,12 +2371,13 @@ mod tests { /// else stands between what the editor plays and what this writes. #[test] fn output_trim_is_the_same_scalar_the_preview_applies() { + // 0.2 × +12 dB is 0.796: under the limiter's ceiling, so nothing but the trim acts. for gain_db in [-12.0f32, -6.0206, 0.0, 6.0206, 12.0] { let result = finish_audio( - planar(&[0.25, -0.25]), + planar(&[0.2, -0.2]), SceneAudio { gain_db }, ); - let expected = (0.25 * 10.0f32.powf(gain_db / 20.0)).clamp(-1.0, 1.0); + let expected = 0.2 * 10.0f32.powf(gain_db / 20.0); assert!( (result[0][0] - expected).abs() < 1e-6, "gain {gain_db} dB: got {}, want {expected}", @@ -2075,7 +2448,7 @@ mod tests { // A 4-sample fade-in at 48 kHz is far below one sample of real time, so // ask for the whole decoded length in seconds. let four = 4.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64; - overlay_track_pcm(&mut programme, &decoded, 0, 1.0, four, 0.0, 0); + overlay_track_pcm(&mut programme, &decoded, 0, 1.0, four, 0.0, 0, &[]); assert_eq!(programme[0][0], 0.0); assert!(programme[0][1] > 0.0 && programme[0][1] < 1.0); assert!(programme[0][3] > programme[0][1]); @@ -2091,7 +2464,7 @@ mod tests { let decoded = planar(&[1.0, 1.0, 1.0, 1.0]); let four = 4.0 / AUDIO_OUTPUT_SAMPLE_RATE as f64; // The track really runs eight samples; the programme had room for four. - overlay_track_pcm(&mut programme, &decoded, 0, 1.0, 0.0, four, 8); + overlay_track_pcm(&mut programme, &decoded, 0, 1.0, 0.0, four, 8, &[]); // Nothing audible has started to ramp: the fade belongs to samples 4..8, which the // programme never reaches. for k in 0..4 { @@ -2107,19 +2480,279 @@ mod tests { let mut programme = planar(&[0.0]); let decoded = planar(&[1.0]); let gain = 10.0f32.powf(-40.0 / 20.0); - overlay_track_pcm(&mut programme, &decoded, 0, gain, 0.0, 0.0, 0); + overlay_track_pcm(&mut programme, &decoded, 0, gain, 0.0, 0.0, 0, &[]); assert!((programme[0][0] - gain).abs() < 1e-9); assert!(programme[0][0] < 10.0f32.powf(-12.0 / 20.0)); } + /// `secs` of a 1 kHz tone per segment, at the segment's level in dBFS (sine peak, as + /// EBU Tech 3341 states its test signals), on both channels, phase continuous. + fn tone_segments(segments: &[(f32, f64)]) -> PlanarPcm { + let rate = AUDIO_OUTPUT_SAMPLE_RATE as f64; + let mut plane = Vec::new(); + for &(dbfs, secs) in segments { + let amplitude = 10.0f32.powf(dbfs / 20.0); + for _ in 0..(secs * rate).round() as usize { + let t = plane.len() as f64 / rate; + plane.push(amplitude * (2.0 * std::f64::consts::PI * 1000.0 * t).sin() as f32); + } + } + vec![plane.clone(), plane] + } + + fn loudness_of(pcm: &PlanarPcm) -> Option { + let mut meter = LoudnessMeter::new(); + meter.push(pcm); + meter.integrated() + } + + fn assert_lufs(measured: Option, expected: f64) { + let measured = measured.expect("a signal above the absolute gate has a loudness"); + assert!( + (measured - expected).abs() <= 0.1, + "measured {measured:.3} LUFS, EBU Tech 3341 wants {expected} ±0.1" + ); + } + + // The EBU Tech 3341 minimum-requirement cases that exercise what this meter computes: + // calibration, the relative gate and the absolute gate. + #[test] + fn a_stereo_1khz_tone_reads_its_own_level_in_lufs() { + assert_lufs(loudness_of(&tone_segments(&[(-23.0, 20.0)])), -23.0); + assert_lufs(loudness_of(&tone_segments(&[(-33.0, 20.0)])), -33.0); + } + + #[test] + fn the_relative_gate_ignores_the_quiet_passages() { + assert_lufs( + loudness_of(&tone_segments(&[(-36.0, 10.0), (-23.0, 60.0), (-36.0, 10.0)])), + -23.0, + ); + assert_lufs( + loudness_of(&tone_segments(&[(-26.0, 20.0), (-20.0, 20.1), (-26.0, 20.0)])), + -23.0, + ); + } + + #[test] + fn the_absolute_gate_ignores_near_silence() { + assert_lufs( + loudness_of(&tone_segments(&[ + (-72.0, 10.0), + (-36.0, 10.0), + (-23.0, 60.0), + (-36.0, 10.0), + (-72.0, 10.0), + ])), + -23.0, + ); + // Nothing but silence and a tone under the gate: no loudness to correct. + assert_eq!(loudness_of(&tone_segments(&[(-80.0, 5.0)])), None); + assert_eq!(loudness_of(&vec![vec![0.0; 48_000]; 2]), None); + } + + #[test] + fn feeding_the_meter_in_windows_changes_nothing() { + // The file walk feeds one minute at a time; the filter state and the 100 ms steps + // must carry across the seams, uneven ones included. + let pcm = tone_segments(&[(-30.0, 3.0), (-18.0, 4.0), (-40.0, 3.0)]); + let mut meter = LoudnessMeter::new(); + let mut start = 0; + for length in [1usize, 4799, 4801, 100_000, 7] { + let end = start + length; + meter.push(&pcm.iter().map(|plane| plane[start..end].to_vec()).collect::>()); + start = end; + } + meter.push(&pcm.iter().map(|plane| plane[start..].to_vec()).collect::>()); + let whole = loudness_of(&pcm).unwrap(); + assert!((meter.integrated().unwrap() - whole).abs() < 1e-9); + } + + #[test] + fn the_gain_brings_a_file_to_the_target_and_caps_the_boost() { + assert_eq!(loudness_gain_for(Some(-23.0)), 7.0); + assert_eq!(loudness_gain_for(Some(-10.0)), -6.0); + // A room-noise recording at −50 LUFS is raised 12 dB, not 34. + assert_eq!(loudness_gain_for(Some(-50.0)), LOUDNESS_MAX_BOOST_DB as f32); + assert_eq!(loudness_gain_for(None), 0.0); + } + + /// A 16-bit stereo 48 kHz WAV of `pcm`: ffmpeg reads it, so the test walks the same + /// decode as a real recording without a media fixture. + fn write_wav(path: &std::path::Path, pcm: &PlanarPcm) { + let frames = pcm[0].len(); + let data_len = (frames * 4) as u32; + let mut bytes = Vec::with_capacity(44 + frames * 4); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); // PCM + bytes.extend_from_slice(&2u16.to_le_bytes()); + bytes.extend_from_slice(&48_000u32.to_le_bytes()); + bytes.extend_from_slice(&(48_000u32 * 4).to_le_bytes()); + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.extend_from_slice(&16u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_len.to_le_bytes()); + for index in 0..frames { + for plane in pcm { + let value = (plane[index].clamp(-1.0, 1.0) * 32767.0).round() as i16; + bytes.extend_from_slice(&value.to_le_bytes()); + } + } + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn a_file_is_measured_whole_across_its_windows() { + // 125 s crosses two window seams and ends inside a third, zero-padded window: the + // walk must neither stop early nor count the padding. + let path = std::env::temp_dir().join(format!("openscreen-loudness-{}.wav", std::process::id())); + write_wav(&path, &tone_segments(&[(-23.0, 5.0), (-29.0, 115.0), (-23.0, 5.0)])); + let file = path.to_str().unwrap(); + let (_, more) = decode_audio_window(file, 60.0, 120.0).unwrap().unwrap(); + assert!(more, "audio continues past 120 s"); + let (_, more) = decode_audio_window(file, 120.0, 180.0).unwrap().unwrap(); + assert!(!more, "the file ends at 125 s"); + let measured = measure_file_loudness(file).unwrap(); + let expected = loudness_of(&tone_segments(&[(-23.0, 5.0), (-29.0, 115.0), (-23.0, 5.0)])); + let _ = std::fs::remove_file(&path); + assert!( + (measured.unwrap() - expected.unwrap()).abs() < 0.05, + "file walk {measured:?} vs whole signal {expected:?}" + ); + } + + #[test] + fn an_unreadable_file_is_left_as_recorded() { + assert_eq!(loudness_gain_db("/no/such/recording.mp4"), 0.0); + } + + #[test] + fn a_signal_under_the_ceiling_leaves_finishing_untouched() { + let pcm = sine(1.0); + let result = finish_audio(pcm.clone(), SceneAudio { gain_db: 0.0 }); + assert_eq!(result, pcm, "the limiter must be transparent below its ceiling"); + } + + #[test] + fn the_limiter_holds_its_ceiling_and_the_length() { + // A tone at +6 dBFS with a +12 dB trim on top, and a burst 20 dB hotter still: the + // old clamp squared every one of these off at full scale. + let mut pcm = sine(1.0); + for plane in pcm.iter_mut() { + for (index, sample) in plane.iter_mut().enumerate() { + *sample *= if (24_000..24_480).contains(&index) { 40.0 } else { 4.0 }; + } + } + let len = pcm[0].len(); + let result = finish_audio(pcm, SceneAudio { gain_db: 12.0 }); + assert_eq!(result[0].len(), len); + let peak = result.iter().flatten().fold(0.0f32, |peak, s| peak.max(s.abs())); + assert!(peak <= LIMITER_CEILING * (1.0 + 1e-6), "peak {peak} over the ceiling"); + assert!(peak > LIMITER_CEILING * 0.99, "the ceiling is used, not undershot: {peak}"); + } + + #[test] + fn the_gain_ramps_down_before_a_peak_and_recovers_after_it() { + // A steady 0.5 with one sample at 2.0 halfway: the level must glide down over the + // look-ahead to exactly what the peak needs, and come back up afterwards, instead of + // stepping at the peak. + let spike = 24_000; + let mut plane = vec![0.5f32; 48_000]; + plane[spike] = 2.0; + let mut pcm = vec![plane.clone(), plane]; + limit_peaks(&mut pcm); + let out = &pcm[0]; + assert_eq!(out[spike - LIMITER_LOOKAHEAD - 1], 0.5, "untouched before the ramp"); + assert!((out[spike] - LIMITER_CEILING).abs() < 1e-6, "the peak lands on the ceiling"); + let floor = 0.5 * LIMITER_CEILING / 2.0; + let step = (0.5 - floor) / LIMITER_LOOKAHEAD as f32; + for index in spike - LIMITER_LOOKAHEAD..spike - 1 { + let fall = out[index] - out[index + 1]; + assert!( + fall >= 0.0 && fall <= step * 1.01, + "sample {index}: a {fall} drop is not a straight {step} ramp" + ); + } + // Five release time constants later the gain is back within 1 %. + let later = spike + (5.0 * LIMITER_RELEASE_SEC * 48_000.0) as usize; + assert!(out[later] > 0.495 && out[later] <= 0.5, "not recovered: {}", out[later]); + } + + #[test] + fn the_limiter_moves_both_channels_together() { + // A peak on the left alone must lower the right by the same gain, or the stereo + // image would lurch towards the right on every limited peak. + let mut pcm = vec![vec![0.5f32; 4_800], vec![0.5f32; 4_800]]; + pcm[0][2_400] = 2.0; + limit_peaks(&mut pcm); + assert!((pcm[1][2_400] - 0.5 * LIMITER_CEILING / 2.0).abs() < 1e-6); + } + + /// The music's level under a voice, in dB, at `sec` into the programme. + fn duck_db_at(curve: &[f32], sec: f64) -> f32 { + 20.0 * duck_gain_at(curve, (sec * AUDIO_OUTPUT_SAMPLE_RATE as f64) as usize).log10() + } + + #[test] + fn music_dips_under_the_voice_and_comes_back_up() { + // 2 s of room tone, 2 s of speech-level signal, 3 s of room tone. + let voice = tone_segments(&[(-120.0, 2.0), (-20.0, 2.0), (-120.0, 3.0)]); + let curve = duck_curve(&voice); + assert_eq!(duck_db_at(&curve, 0.5), 0.0, "in the clear"); + // Looking ahead: already down when the first word starts, half way down half an + // attack earlier. + assert!((duck_db_at(&curve, 2.0) - DUCK_DEPTH_DB).abs() < 0.01); + assert!((duck_db_at(&curve, 2.0 - DUCK_ATTACK_SEC / 2.0) - DUCK_DEPTH_DB / 2.0).abs() < 0.5); + assert!((duck_db_at(&curve, 3.0) - DUCK_DEPTH_DB).abs() < 0.01); + // Held through the hold after the last word, then released over the release. + assert!((duck_db_at(&curve, 4.0 + DUCK_HOLD_SEC - 0.05) - DUCK_DEPTH_DB).abs() < 0.01); + let back = 4.0 + DUCK_HOLD_SEC + DUCK_RELEASE_SEC + 0.05; + assert_eq!(duck_db_at(&curve, back), 0.0, "back up once the pause is long enough"); + // Never a step: from one 10 ms block to the next, the level moves by at most one + // attack increment. + let steepest = curve + .windows(2) + .map(|pair| (20.0 * (pair[1] / pair[0]).log10()).abs()) + .fold(0.0f32, f32::max); + let attack_step = -DUCK_DEPTH_DB / (DUCK_ATTACK_SEC * 100.0) as f32; + assert!(steepest <= attack_step + 1e-3, "a {steepest} dB step in the envelope"); + } + + #[test] + fn a_breath_between_words_keeps_the_music_down() { + // Releasing in every gap between words is the pumping a ducker must not do. + let voice = tone_segments(&[(-20.0, 1.0), (-120.0, 0.3), (-20.0, 1.0)]); + let curve = duck_curve(&voice); + for sec in [1.05, 1.15, 1.25] { + assert!((duck_db_at(&curve, sec) - DUCK_DEPTH_DB).abs() < 0.01, "released at {sec} s"); + } + } + + #[test] + fn no_voice_leaves_the_music_alone() { + // Silence, and a noise floor raised by the normalisation's full boost: neither is + // someone speaking. + let floor = -50.0 + LOUDNESS_MAX_BOOST_DB as f32; + for voice in [tone_segments(&[(-120.0, 2.0)]), tone_segments(&[(floor, 2.0)])] { + assert!(duck_curve(&voice).iter().all(|&gain| gain == 1.0)); + } + } + #[test] - fn output_is_clipped_to_full_scale_and_keeps_its_length() { - // The trim can push a hot signal past full scale; the timeline must come back the - // same length either way, or video and the following clips drift against it. - let result = finish_audio(planar(&[0.9, -0.9, 0.1]), SceneAudio { gain_db: 12.0 }); - assert_eq!(result[0].len(), 3); - assert_eq!(result[0][0], 1.0); - assert_eq!(result[0][1], -1.0); - assert!((result[0][2] - 0.1 * 10.0f32.powf(12.0 / 20.0)).abs() < 1e-6); + fn a_music_bed_is_summed_under_the_duck_curve_and_a_voiceover_is_not() { + let voice = tone_segments(&[(-120.0, 1.0), (-20.0, 1.0)]); + let curve = duck_curve(&voice); + let bed = vec![vec![0.5f32; 96_000]; 2]; + let mut ducked = vec![vec![0.0f32; 96_000]; 2]; + overlay_track_pcm(&mut ducked, &bed, 0, 1.0, 0.0, 0.0, 0, &curve); + let mut flat = vec![vec![0.0f32; 96_000]; 2]; + overlay_track_pcm(&mut flat, &bed, 0, 1.0, 0.0, 0.0, 0, &[]); + assert_eq!(flat[0][72_000], 0.5, "no curve, no dip"); + assert!((ducked[0][24_000] - 0.5).abs() < 1e-6, "in the clear"); + let under = 0.5 * 10.0f32.powf(DUCK_DEPTH_DB / 20.0); + assert!((ducked[0][72_000] - under).abs() < 1e-4, "under the voice: {}", ducked[0][72_000]); } } diff --git a/crates/compositor/src/audio_jobs.rs b/crates/compositor/src/audio_jobs.rs index 8d97be40c..5f690ac07 100644 --- a/crates/compositor/src/audio_jobs.rs +++ b/crates/compositor/src/audio_jobs.rs @@ -21,7 +21,9 @@ //! n'a pas d'état partagé entre contextes, et le décodeur vidéo du parcours en a un autre //! sur le même chemin, en lecture seule lui aussi. -use crate::audio::{decode_clip_audio, stretch_clip_pcm_by_speed, PlanarPcm}; +use crate::audio::{ + apply_gain_db, decode_clip_audio, loudness_gain_db, stretch_clip_pcm_by_speed, PlanarPcm, +}; use crate::regions::SpeedSegment; use std::collections::VecDeque; use std::thread::JoinHandle; @@ -34,7 +36,9 @@ use std::thread::JoinHandle; /// est tout ce qu'on cherche ici. const MAX_INFLIGHT_AUDIO_JOBS: usize = 4; -/// Le corps d'un job : décode la fenêtre gardée du clip et l'étire sur ses spans de vitesse. +/// Le corps d'un job : décode la fenêtre gardée du clip, l'étire sur ses spans de vitesse et +/// l'amène au niveau de loudness cible avec le gain mesuré sur le fichier entier +/// (`loudness_gain_db`, celui que la preview applique aussi). /// /// Rend `None` quand le clip se déclare audio mais n'a pas de flux décodable, ou quand le /// décodage échoue — dans les deux cas l'export continue et le clip sort muet, comme avant @@ -49,7 +53,11 @@ pub fn decode_and_stretch_clip_audio( out_fps: f64, ) -> Option { match decode_clip_audio(screen_path, source_start_sec, source_end_sec) { - Ok(Some(pcm)) => Some(stretch_clip_pcm_by_speed(&pcm, speed_segments, out_fps)), + Ok(Some(pcm)) => { + let mut pcm = stretch_clip_pcm_by_speed(&pcm, speed_segments, out_fps); + apply_gain_db(&mut pcm, loudness_gain_db(screen_path)); + Some(pcm) + } Ok(None) => { eprintln!( "[pipeline] warning: clip #{clip_index} déclaré audio mais sans flux décodable; silence conservé" diff --git a/crates/compositor/src/camera.rs b/crates/compositor/src/camera.rs index 7c3e1adc4..63daacb99 100644 --- a/crates/compositor/src/camera.rs +++ b/crates/compositor/src/camera.rs @@ -1,12 +1,14 @@ -//! Caméra 3D réelle, en orbite, pour `follow-cursor`. +//! Caméra 3D réelle, en orbite, pour le préset `orbit`. //! //! L'ÉCRAN NE BOUGE PAS. L'œil tourne autour de lui sur une sphère : l'azimut suit la position -//! horizontale du pointeur (à droite, on voit l'écran par la droite et son côté droit vient vers -//! nous), l'élévation sa position verticale (en haut, la caméra monte et plonge sur le haut de -//! l'écran), autour d'une petite élévation de repos. L'œil regarde toujours son point visé, haut du -//! monde fixe (le `lookAt` classique) : le roulis est donc nul PAR CONSTRUCTION. L'axe horizontal -//! de l'image reste horizontal dans le monde, et une verticale qui passe par le point visé reste -//! verticale à l'image. Ce qui penche encore, c'est la perspective, et elle seule. +//! horizontale d'un point de l'écran (à droite, on voit l'écran par la droite et son côté droit +//! vient vers nous), l'élévation sa position verticale (en haut, la caméra monte et plonge sur le +//! haut de l'écran), autour d'une petite élévation de repos. Ce point est le pointeur lissé en +//! focus auto (`follow`), le point de focus de la région en manuel (`fixed`). L'œil regarde +//! toujours son point visé, haut du monde fixe (le `lookAt` classique) : le roulis est donc nul +//! PAR CONSTRUCTION. L'axe horizontal de l'image reste horizontal dans le monde, et une verticale +//! qui passe par le point visé reste verticale à l'image. Ce qui penche encore, c'est la +//! perspective, et elle seule. //! //! Pourquoi une orbite et pas un pivot sur place : depuis un œil fixe, tourner la caméra n'est //! presque qu'un pan à plat, l'angle sous lequel on voit l'écran change à peine. L'orientation ne @@ -156,14 +158,50 @@ fn envelope_fit(unit: [f32; 2], w: f32) -> f32 { if fit < 1.0 { fit * FIT_MARGIN } else { 1.0 } } +/// Le point à viser pour que la caméra cadre la fenêtre du zoom (le rectangle de demi-côtés +/// `half` autour de `aim`, ce que montre un zoom à plat) comme un zoom à plat la cadre, vue sous +/// `angles` à `dist`. Sur chaque axe, `u` place la visée dans sa portée : à 0, la fenêtre touche le +/// bord gauche (ou haut) de l'écran, et c'est son bord à elle qui tombe au bord de l'image ; à 1, +/// le bord droit (ou bas) ; entre les deux, un mélange linéaire qui la centre à mi-course. Vu de +/// biais, le côté proche grandit : viser le centre de la fenêtre pousserait son bord proche hors de +/// l'image, et le coin de l'écran avec lui. Au zoom 1, la fenêtre est l'écran entier et `u` vaut +/// 0,5 : c'est `centring`. Quatre passes. +fn frame_window( + aim: [f32; 2], + half: [f32; 2], + u: [f32; 2], + dist: f32, + angles: [f32; 2], + fit: f32, +) -> [f32; 2] { + let edge = half.map(|h| h * fit); + let mut target = aim; + for _ in 0..4 { + let v = orbit_view(target, dist, angles, dist * fit); + let [tl, tr, br, bl] = corners(2.0 * half[0], 2.0 * half[1]) + .map(|[x, y]| v.project([aim[0] + x, aim[1] + y, 0.0]).unwrap_or([x, y])); + let (left, right) = (tl[0].min(bl[0]), tr[0].max(br[0])); + let (top, bottom) = (tl[1].min(tr[1]), bl[1].max(br[1])); + let err = [ + (left + edge[0]) + ((right - edge[0]) - (left + edge[0])) * u[0], + (top + edge[1]) + ((bottom - edge[1]) - (top + edge[1])) * u[1], + ]; + // Déplacer la visée de `d` fait glisser l'image de `d × fit` : on la déplace de l'écart. + target = [target[0] + err[0] / fit, target[1] + err[1] / fit]; + } + target +} + impl View { /// La caméra qui filme une boîte écran de `box_px` px (zoom compris) sous `pose`. /// /// - L'œil est sur l'orbite de `pose.orbit`, à `DISTANCE × min(boîte) × zoom^(−DOLLY)` du point /// visé : la vue se resserre moitié par travelling, moitié par focale. - /// - Au zoom 1, le point visé est décalé pour centrer l'écran (`centring`) et la focale réduite - /// pour qu'il tienne dans sa boîte depuis toute l'orbite (`envelope_fit`). Dès le zoom - /// `FULL_VIEW_ZOOM`, le point visé tombe au centre de l'image, grossi du zoom. + /// - La fenêtre du zoom autour de `pose.aim` est cadrée comme sous un zoom à plat + /// (`frame_window`) : centrée à mi-course, son bord au bord de l'image quand elle touche celui + /// de l'écran, coin de l'écran compris. Au zoom 1, c'est l'écran entier, et la focale est + /// réduite pour qu'il tienne dans sa boîte depuis toute l'orbite (`envelope_fit`). Dès le zoom + /// `FULL_VIEW_ZOOM`, l'écran est grossi exactement du zoom. pub fn new(box_px: [f32; 2], pose: CameraPose) -> View { let w = pose.weight.clamp(0.0, 1.0); let [bw, bh] = box_px; @@ -174,9 +212,11 @@ impl View { let fit = envelope_fit(unit, w); let fit = fit + (1.0 - fit) * fade; let angles = orbit_angles(pose.orbit, w); - let shift = centring(unit, angles).map(|c| c * m * (1.0 - fade)); let dist = DISTANCE * m * zoom.powf(-DOLLY); - let target = [(pose.aim[0] - 0.5) * bw + shift[0], (pose.aim[1] - 0.5) * bh + shift[1]]; + let aim = [(pose.aim[0] - 0.5) * bw, (pose.aim[1] - 0.5) * bh]; + let r = reach(zoom); + let u = pose.aim.map(|a| if r > 1e-4 { ((a - (0.5 - r)) / (2.0 * r)).clamp(0.0, 1.0) } else { 0.5 }); + let target = frame_window(aim, [0.5 * bw / zoom, 0.5 * bh / zoom], u, dist, angles, fit); orbit_view(target, dist * (1.0 - PRESS * pose.press * w), angles, dist * fit) } @@ -244,8 +284,6 @@ const FOLLOW_LOOKAHEAD_S: f32 = 0.25; /// Pas et longueur du noyau : ω·τ va jusqu'à 10, la queue coupée pèse 5·10⁻⁴. const FOLLOW_STEP_S: f32 = 1.0 / 60.0; const FOLLOW_TAPS: usize = 120; -/// La vue comptée plus large que `1/zoom` de l'écran, pour la portée du point visé. -const VIEW_MARGIN: f32 = 1.1; /// Ce que le cadreur rend pour une frame, en fractions de l'écran recadré : le point visé et le /// pointeur lissé. @@ -259,8 +297,25 @@ impl Follow { pub const CENTRE: Follow = Follow { aim: [0.5; 2], orbit: [0.5; 2] }; } -/// Où `follow-cursor` vise à `t` et où il place l'œil, pour une région de zoom `zoom`. Sans -/// piste : le centre, caméra au repos. +/// Portée du point visé au zoom `zoom` : il reste dans `0,5 ± reach`, la portée du focus d'un +/// zoom à plat (le gimbal de l'aperçu). Comme sous les autres caméras, la vue va jusqu'au bord de +/// l'écran : visé en butée, `frame_window` pose le bord de l'écran au bord de l'image, et le +/// padding montre ce qu'il y a au-delà. Au zoom 1, le centre. +fn reach(zoom: f32) -> f32 { + (0.5 - 0.5 / zoom.max(1.0)).max(0.0) +} + +/// L'orbite en focus manuel, posée par le point de focus de la région (`point`, fractions de +/// l'écran recadré) comme par un pointeur garé là : l'œil sur l'orbite de ce point, le point visé +/// le même borné à la portée du zoom. Sans piste : rien ne bouge pendant la région. +pub fn fixed(point: [f32; 2], zoom: f32) -> Follow { + let r = reach(zoom); + let orbit = point.map(|p| p.clamp(0.0, 1.0)); + Follow { aim: orbit.map(|p| p.clamp(0.5 - r, 0.5 + r)), orbit } +} + +/// Où l'orbite en focus auto vise à `t` et où elle place l'œil, pour une région de zoom `zoom`. +/// Sans piste : le centre, caméra au repos. /// /// Le pointeur (lu dans l'image source recadrée, borné à l'écran et à la fenêtre du clip) passe /// par la réponse impulsionnelle d'un ressort critique, `h(τ) = ω²·τ·e^(−ωτ)`, avancée de @@ -269,11 +324,10 @@ impl Follow { /// région. Pas de zone morte : la caméra vit avec le pointeur, le lissage la garde calme. /// /// - `orbit` : le pointeur lissé, sur tout l'écran. -/// - `aim` : le même, borné avant lissage à `0,5 ± (0,5 − 0,5·VIEW_MARGIN/zoom)`, là où la vue -/// reste dans l'écran ; au zoom 1, le centre. +/// - `aim` : le même, borné avant lissage à la portée du zoom (`reach`) ; au zoom 1, le centre. pub fn follow(frame: &CameraFrame, t: f32, zoom: f32) -> Follow { let Some(track) = frame.track else { return Follow::CENTRE }; - let reach = (0.5 - 0.5 * VIEW_MARGIN / zoom.max(1.0)).max(0.0); + let reach = reach(zoom); let [x0, y0, x1, y1] = frame.crop; let size = [(x1 - x0).max(1e-6), (y1 - y0).max(1e-6)]; let (mut aim, mut orbit, mut total) = ([0.0f32; 2], [0.0f32; 2], 0.0f32); @@ -300,7 +354,7 @@ mod tests { const BOX: [f32; 2] = [1536.0, 864.0]; fn pose(orbit: [f32; 2], zoom: f32) -> CameraPose { - let reach = (0.5 - 0.5 * VIEW_MARGIN / zoom).max(0.0); + let reach = reach(zoom); let aim = orbit.map(|o| o.clamp(0.5 - reach, 0.5 + reach)); CameraPose { weight: 1.0, aim, orbit, zoom, press: 0.0 } } @@ -316,7 +370,7 @@ mod tests { fn envelope() -> Vec<(f32, f32, [f32; 2], View, [f32; 2])> { let mut out = Vec::new(); for zoom in [1.0f32, 1.25, 1.5, 1.8, 2.2, 3.5, 5.0] { - let reach = (0.5 - 0.5 * VIEW_MARGIN / zoom).max(0.0); + let reach = reach(zoom); for k in [2, 5, 8, 10] { let weight = k as f32 / 10.0; let z = 1.0 + (zoom - 1.0) * weight; @@ -388,16 +442,27 @@ mod tests { assert!(0.0 < a && a < b && b < c, "{a} {b} {c}"); } - /// Dès `FULL_VIEW_ZOOM`, le point visé tombe au point principal. + /// Dès `FULL_VIEW_ZOOM`, la fenêtre du zoom (ce que montre un zoom à plat autour du point visé) + /// est cadrée comme par un zoom à plat, à 1 px près : centrée quand la visée est à mi-course, + /// son bord au bord de l'image quand la visée est en butée. #[test] - fn the_camera_looks_at_its_aim() { - for zoom in [2.0f32, 3.5] { - let p = pose([0.62, 0.41], zoom); + fn the_camera_frames_the_zoom_window_like_a_flat_zoom() { + let (hw, hh) = (BOX[0] * 0.5, BOX[1] * 0.5); + for zoom in [2.0f32, 2.2, 3.5, 5.0] { let bx = BOX.map(|b| b * zoom); - let v = View::new(bx, p); - let aim = [(p.aim[0] - 0.5) * bx[0], (p.aim[1] - 0.5) * bx[1], 0.0]; - let q = v.project(aim).unwrap(); - assert!(q[0].abs() < 1e-2 && q[1].abs() < 1e-2, "{q:?}"); + let window = |p: [f32; 2]| { + let v = View::new(bx, CameraPose { weight: 1.0, aim: p, orbit: p, zoom, press: 0.0 }); + let aim = [(p[0] - 0.5) * bx[0], (p[1] - 0.5) * bx[1]]; + let [tl, tr, br, bl] = corners(BOX[0], BOX[1]) + .map(|[x, y]| v.project([aim[0] + x, aim[1] + y, 0.0]).unwrap()); + [tl[0].min(bl[0]), tr[0].max(br[0]), tl[1].min(tr[1]), bl[1].max(br[1])] + }; + let (r, c) = (reach(zoom), window([0.5, 0.5])); + assert!((c[0] + c[1]).abs() < 1.0 && (c[2] + c[3]).abs() < 1.0, "zoom {zoom} centre : {c:?}"); + let lo = window([0.5 - r, 0.5 - r]); + assert!((lo[0] + hw).abs() < 1.0 && (lo[2] + hh).abs() < 1.0, "zoom {zoom} haut-gauche : {lo:?}"); + let hi = window([0.5 + r, 0.5 + r]); + assert!((hi[1] - hw).abs() < 1.0 && (hi[3] - hh).abs() < 1.0, "zoom {zoom} bas-droite : {hi:?}"); } } @@ -622,20 +687,38 @@ mod tests { let settled = settled.expect("jamais posée"); println!("95 % du saut {settled:.2} s après lui"); assert!((0.6..=1.0).contains(&settled), "{settled}"); - // Le point visé : borné à 0,5 ± 0,225 au zoom 2. + // Le point visé : borné à 0,5 ± 0,25 au zoom 2. let posed = at(8.0); - assert!((posed.aim[0] - 0.725).abs() < 1e-3 && (posed.aim[1] - 0.275).abs() < 1e-3, "{posed:?}"); + assert!((posed.aim[0] - 0.75).abs() < 1e-3 && (posed.aim[1] - 0.25).abs() < 1e-3, "{posed:?}"); assert!((posed.orbit[0] - 0.8).abs() < 1e-3); } - /// Le point visé reste là où la vue reste dans l'écran (au centre au zoom 1), l'orbite lit - /// tout l'écran. Recadrage compris ; sans piste, le centre. + /// Visé en butée sur un coin, comme un zoom à plat au bord du gimbal, la vue montre le coin de + /// l'écran en entier : dès le zoom 2, il tombe au coin de l'image, là où le zoom à plat le met ; + /// en deçà, un peu dedans, la focale y étant encore réduite. Même sans padding, il n'est jamais + /// coupé (il l'était de près de 10 % avec l'ancienne marge, de 4 % avec la visée seule). + #[test] + fn at_its_limit_the_view_frames_the_screen_corner() { + for zoom in [1.5f32, 1.8, 2.2, 3.5, 5.0] { + let (bx, edge) = (BOX.map(|b| b * zoom), 0.5 - reach(zoom)); + let corner = [edge, 1.0 - edge]; + let pose = CameraPose { weight: 1.0, aim: corner, orbit: corner, zoom, press: 0.0 }; + let [x, y] = View::new(bx, pose).project([-bx[0] * 0.5, bx[1] * 0.5, 0.0]).unwrap(); + // Au-delà du coin bas-gauche de la boîte, en boîtes : positif = hors de la boîte. + let past = [(-BOX[0] * 0.5 - x) / BOX[0], (y - BOX[1] * 0.5) / BOX[1]]; + println!("zoom {zoom} : coin de l'écran à {past:.3?} boîte de celui d'un zoom à plat"); + assert!(past.iter().all(|p| (-0.06..0.001).contains(p)), "zoom {zoom} : {past:?}"); + } + } + + /// Le point visé va aussi loin que le focus d'un zoom à plat (au centre au zoom 1), l'orbite + /// lit tout l'écran. Recadrage compris ; sans piste, le centre. #[test] - fn the_aim_keeps_the_view_on_the_screen() { + fn the_aim_goes_as_far_as_a_flat_zoom() { let corner = track(|_| (0.99, 0.01)); for zoom in [1.0f32, 1.5, 2.0, 3.0] { let got = follow(&whole(&corner), 5.0, zoom); - let reach = (0.5 - 0.5 * VIEW_MARGIN / zoom).max(0.0); + let reach = reach(zoom); assert!( (got.aim[0] - (0.5 + reach)).abs() < 1e-3 && (got.aim[1] - (0.5 - reach)).abs() < 1e-3, "{zoom} {got:?}" diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 2f5a6da92..10e4c90c5 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -43,15 +43,21 @@ pub use crate::frame_geometry::{ live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, }; use crate::frame_geometry::{ - cursor_model_cb, cursor_sprite_cb, cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, - tilted_screen_cb, CursorPlacement, CursorPlanInput, FrameGeometryInput, ShadowCaster, - SpriteShape, + cursor_glass_cb, cursor_model_cb, cursor_sprite_cb, cursor_sprite_dst, parse_hex, plan_cursor, + plan_frame, tilted_screen_cb, CursorPlacement, CursorPlanInput, FrameGeometryInput, + ShadowCaster, SpriteShape, }; -use crate::scene::{Scene, SceneBackground}; +use crate::scene::{Scene, SceneBackground, WallpaperMotion}; const LAYER_WGSL: &str = include_str!("vk_shaders/layer.wgsl"); const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl"); +/// `layer.wgsl` avec ou sans ses modeles 3D (modes 15 a 17) : la constante que le shader teste +/// pour les compiler, prefixee au source (pourquoi deux pipelines : le haut de `layer.wgsl`). +fn layer_source(models: bool) -> String { + format!("const LAYER_MODELS: bool = {models};\n{LAYER_WGSL}") +} + /// Budget du cache de textures image (`img_cache`), en octets. /// /// Doit tenir le JEU ACTIF d'une frame -- au pire un wallpaper d'ecran ET un @@ -61,10 +67,22 @@ const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl"); /// en parcourant les 18 wallpapers livres) en laissant le jeu actif resident. const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024; -/// `&LayerCB` -> `&[u8; 128]`. `LayerCB` est `#[repr(C, align(16))]`, son layout -/// EST le buffer uniforme WGSL (16 vec4 + 1 vec2 + 2 f32 = 128 octets). +/// Taille du buffer uniforme d'un calque : `LayerCB` entier (176 octets), le `struct Layer` de +/// `layer.wgsl`. `blur.wgsl` n'en lit que les 128 premiers. +const LAYER_BYTES: u64 = std::mem::size_of::() as u64; + +/// `&LayerCB` -> ses octets. `LayerCB` est `#[repr(C, align(16))]`, son layout EST le buffer +/// uniforme WGSL (dix vec4 et un vec2 + 2 f32 = 176 octets). fn layer_bytes(cb: &LayerCB) -> &[u8] { - unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, 128) } + unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, LAYER_BYTES as usize) } +} + +/// Le maillage de Prism Glow tel que le lit le WGSL (`PrismMesh`) : les triangles, les +/// polygones, un point par vec4 (le pas d'un tableau uniform), puis les boîtes. +fn prism_mesh_bytes() -> Vec { + use crate::prism_mesh::{BOXES, POLYS, TRIS}; + let points = POLYS.iter().map(|&[x, y]| [x, y, 0.0, 0.0]); + TRIS.iter().copied().chain(points).chain(BOXES).flatten().flat_map(f32::to_ne_bytes).collect() } /// Un calque de fond deja lie, en attente de son `draw`. `_buf`/`_tex`/`_view` @@ -77,10 +95,25 @@ fn layer_bytes(cb: &LayerCB) -> &[u8] { /// ET le fond de la bulle webcam sont desormais construits par les memes /// methodes. struct BgDraw { - _buf: wgpu::Buffer, + layer: LayerBind, _tex: Option, _view: Option, +} + +/// Un calque pret a dessiner : son uniforme (qui doit vivre jusqu'au draw), son bind group, et +/// s'il est l'un des modeles 3D, que seul `pipeline_models` compile (`LayerCB::needs_models`). +/// `draw_layer` en tire le pipeline : c'est le mode du calque qui decide, pas le site d'appel. +struct LayerBind { + _buf: wgpu::Buffer, bind: wgpu::BindGroup, + models: bool, +} + +/// Le fond tel que la passe 1 (et son flou) l'a laisse dans le RT, et tout ce qui l'a decide +/// (`key`, cf. `compose_frame`). +struct BgCache { + key: crate::frame_geometry::BackgroundKey, + tex: wgpu::Texture, } /// Une copie RT -> staging DEJA SOUMISE, dont le mapping est arme mais pas @@ -241,7 +274,7 @@ struct WebcamMask { } /// Pyramide de profondeur de champ (cf. `Compositor::dof_pyramid`). `_tex` garde la -/// texture en vie ; `view` porte tous les niveaux (binding 4 du mode 8), `mips` un +/// texture en vie ; `view` porte tous les niveaux (binding 6 des modes 8 et 18), `mips` un /// niveau chacune (cible du remplissage puis de `generate_mips`). struct DofPyramid { _tex: wgpu::Texture, @@ -258,16 +291,23 @@ pub struct Compositor { // Pipeline de calque (VS + FS `layer.wgsl`), sampler lineaire, bind group // layout (uniform + 2 textures + sampler). Immuables apres `new_sized`. + // `pipeline` n'a pas les modeles 3D (modes 15 a 17) : ils passent par `pipeline_models`. pipeline: wgpu::RenderPipeline, /// Meme shader et meme layout que `pipeline`, blend ADDITIF pondere par la /// constante de blend. Sert a sommer les copies de la trainee du curseur /// dans `accum` ; cf. `blend_add` cote Windows. pipeline_add: wgpu::RenderPipeline, + /// `pipeline` et `pipeline_add` avec les modeles 3D : le curseur modelise, l'impact de son + /// clic, l'appareil et son ombre, et rien d'autre (cf. `layer_source`, `draw_layer`). + pipeline_models: wgpu::RenderPipeline, + pipeline_add_models: wgpu::RenderPipeline, /// Copie plein ecran d'`accum` vers le RT en « over » premultiplie /// (`blur.wgsl` : `vs_fullscreen` + `fs_copy`). Utilise le layout du blur. pipeline_copy: wgpu::RenderPipeline, bind_group_layout: wgpu::BindGroupLayout, sampler: wgpu::Sampler, + /// Le maillage de Prism Glow (`prism_mesh.rs`) tel que le lit le WGSL (`PrismMesh`, binding 7). + prism_mesh: wgpu::Buffer, // Chaine de blur Kawase du fond (`blur.wgsl`) : layout dedie (uniform + 1 // tex + sampler), 2 pipelines (down/up), 3 textures de pyramide (1/2, 1/4, @@ -278,6 +318,10 @@ pub struct Compositor { blur_half: wgpu::TextureView, blur_qtr: wgpu::TextureView, blur_oct: wgpu::TextureView, + /// Rendu isole de l'ecran cadre (ombre, cadre, metrage, appareil), transparent autour, que + /// le mode 18 recompose le long de sa trajectoire (`FrameGeometry::screen_trail`). Taille et + /// format du RT : il est dessine par le meme pipeline. + trail_view: wgpu::TextureView, // Render target offscreen + ring de staging de la relecture (recrees au resize). rt: wgpu::Texture, @@ -309,6 +353,8 @@ pub struct Compositor { scene: RefCell>, cursor: RefCell>, cursor_time: RefCell>, + /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`). + footage: std::cell::Cell>, timeline_time: RefCell>, /// Temps programme (secondes de sortie) -- cf. `FrameGeometryInput::programme_time`. programme_time: RefCell>, @@ -333,8 +379,11 @@ pub struct Compositor { /// touche depuis appartient au jeu actif et ne peut pas etre evince -- voir /// `cached_image`. img_frame_start: std::cell::Cell, + /// Le dernier fond fixe compose. Un fond qui ne bouge pas est le meme a chaque frame : on le + /// recopie au lieu de le redessiner (passe 1 de `compose_frame`). + bg_cache: RefCell>, /// Champs de distance des sprites de curseur (mode 15), R16F, par chemin, avec leur forme. - /// Pas d'eviction : seuls les seize sprites du theme par defaut y passent (~2,6 Mo en tout). + /// Pas d'eviction : seuls les sprites du theme en cours y passent (~2,6 Mo pour les seize). sdf_cache: RefCell>, /// Copie mipmappee de la frame composee, lue par les annotations « flou » @@ -402,10 +451,14 @@ impl Compositor { feature_level: gpu.feature_level, }; - let module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("layer.wgsl"), - source: wgpu::ShaderSource::Wgsl(LAYER_WGSL.into()), - }); + let mk_module = |label: &str, models: bool| { + gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label), + source: wgpu::ShaderSource::Wgsl(layer_source(models).into()), + }) + }; + let module = mk_module("layer.wgsl", false); + let module_models = mk_module("layer.wgsl (modeles)", true); let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor { label: Some("layer"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -440,7 +493,7 @@ impl Compositor { ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, - min_binding_size: wgpu::BufferSize::new(128), + min_binding_size: wgpu::BufferSize::new(LAYER_BYTES), }, count: None, }, @@ -463,26 +516,47 @@ impl Compositor { // plans, et renumeroter aurait touche tous les bind groups // pour un gain nul. tex_entry(5), + // Pyramide de profondeur de champ (modes 8 et 18), `dummy` ailleurs : le + // mode 18 lit a la fois le metrage, son rendu isole (binding 4) et elle. + tex_entry(6), + // Le maillage de Prism Glow (mode 15). Un uniform et non des tables dans le + // shader : lavapipe les recopiait a chaque pixel de chaque calque, et une + // frame sans curseur se rendait 3,4 fois plus lentement. + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, ], }); + let prism_mesh = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("prism-mesh"), + contents: &prism_mesh_bytes(), + usage: wgpu::BufferUsages::UNIFORM, + }); let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("layer"), bind_group_layouts: &[&bind_group_layout], push_constant_ranges: &[], }); - // Deux pipelines pour le MEME shader de calque : seul le blend change. - let mk_layer = |label: &str, blend: wgpu::BlendState| { + // Deux pipelines par source de calque : seul le blend change. + let mk_layer = |label: &str, module: &wgpu::ShaderModule, blend: wgpu::BlendState| { gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { - module: &module, + module, entry_point: Some("vs_main"), compilation_options: wgpu::PipelineCompilationOptions::default(), buffers: &[], }, fragment: Some(wgpu::FragmentState { - module: &module, + module, entry_point: Some("fs_main"), compilation_options: wgpu::PipelineCompilationOptions::default(), targets: &[Some(wgpu::ColorTargetState { @@ -501,22 +575,23 @@ impl Compositor { cache: None, }) }; - let pipeline = mk_layer("layer", wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); + let over = wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING; + let pipeline = mk_layer("layer", &module, over); + let pipeline_models = mk_layer("layer-models", &module_models, over); // SOMME pondere : `src * constante + dst`. La constante (posee par pass // via `set_blend_constant`) vaut 1/taps, donc N copies d'un curseur // parfaitement immobile redonnent exactement ce curseur. Transcription // du `blend_add` D3D11 (BLEND_FACTOR / ONE / OP_ADD sur couleur ET // alpha) ; l'alpha doit suivre la couleur, sinon la somme n'est plus // premultipliee et la composition finale delave la trainee. - let add = wgpu::BlendComponent { + let sum = wgpu::BlendComponent { src_factor: wgpu::BlendFactor::Constant, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add, }; - let pipeline_add = mk_layer( - "layer-add", - wgpu::BlendState { color: add, alpha: add }, - ); + let add = wgpu::BlendState { color: sum, alpha: sum }; + let pipeline_add = mk_layer("layer-add", &module, add); + let pipeline_add_models = mk_layer("layer-add-models", &module_models, add); // --- Chaine de blur Kawase du fond (`blur.wgsl`) --- let blur_module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -624,29 +699,7 @@ impl Compositor { multiview: None, cache: None, }); - let mk_pyr = |dw: u32, dh: u32, label: &str| { - gpu.device - .create_texture(&wgpu::TextureDescriptor { - label: Some(label), - size: wgpu::Extent3d { - width: dw.max(1), - height: dh.max(1), - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - .create_view(&wgpu::TextureViewDescriptor::default()) - }; - let blur_half = mk_pyr(w / 2, h / 2, "blur-half"); - let blur_qtr = mk_pyr(w / 4, h / 4, "blur-qtr"); - let blur_oct = mk_pyr(w / 8, h / 8, "blur-oct"); - + let (blur_half, blur_qtr, blur_oct, trail_view) = Self::make_blur_targets(&gpu, w, h); let (rt, rt_view, accum, accum_view, readback_bpr) = Self::make_targets(&gpu, w, h); let (ann_copy, ann_copy_view, ann_copy_mips) = Self::make_ann_copy(&gpu, w, h); // Profondeur 1 par defaut = chemin synchrone historique, a l'octet et a @@ -671,15 +724,19 @@ impl Compositor { render_h: h, pipeline, pipeline_add, + pipeline_models, + pipeline_add_models, pipeline_copy, bind_group_layout, sampler, + prism_mesh, blur_bgl, blur_down, blur_up, blur_half, blur_qtr, blur_oct, + trail_view, rt, rt_view, _accum: accum, @@ -692,6 +749,7 @@ impl Compositor { scene: RefCell::new(None), cursor: RefCell::new(None), cursor_time: RefCell::new(None), + footage: std::cell::Cell::new(None), timeline_time: RefCell::new(None), programme_time: RefCell::new(None), text_raster: crate::text::TextRasterizer::new().ok(), @@ -699,6 +757,7 @@ impl Compositor { sdf_cache: RefCell::new(std::collections::HashMap::new()), img_tick: std::cell::Cell::new(0), img_frame_start: std::cell::Cell::new(0), + bg_cache: RefCell::new(None), ann_copy, ann_copy_view, ann_copy_mips, @@ -716,6 +775,88 @@ impl Compositor { }) } + /// Le meme compositeur, rasterisant a `w`x`h` : seules les cibles qui ont la taille du + /// rendu sont reallouees. Cf. `compositor_windows::Compositor::resized`. Ici, reconstruire + /// le compositeur entier refaisait en plus tous les shaders et pipelines wgpu. + /// + /// Les rings de relecture repartent vides a leur profondeur : leurs buffers ont l'ancienne + /// taille, comme les cibles YUV, reconstruites a la premiere demande. Une copie encore en + /// vol est perdue, comme avec la reconstruction ; la preview n'en laisse aucune + /// (`readback_direct` draine). + pub fn resized(self, w: u32, h: u32) -> Result { + let (w, h) = Self::normalize_render_size(w, h); + let gpu = &self.gpu; + let (blur_half, blur_qtr, blur_oct, trail_view) = Self::make_blur_targets(gpu, w, h); + let (rt, rt_view, accum, accum_view, readback_bpr) = Self::make_targets(gpu, w, h); + let (ann_copy, ann_copy_view, ann_copy_mips) = Self::make_ann_copy(gpu, w, h); + let depth = self.readback.borrow().depth; + let readback = RefCell::new(ReadbackRing { + depth, + free: (0..depth).map(|_| Self::make_staging(gpu, readback_bpr, h)).collect(), + pending: std::collections::VecDeque::new(), + }); + let readback_yuv = RefCell::new(ReadbackRing { + depth: self.readback_yuv.borrow().depth, + free: Vec::new(), + pending: std::collections::VecDeque::new(), + }); + Ok(Compositor { + render_w: w, + render_h: h, + blur_half, + blur_qtr, + blur_oct, + trail_view, + rt, + rt_view, + _accum: accum, + accum_view, + readback_bpr, + readback, + yuv: RefCell::new(None), + readback_yuv, + ann_copy, + ann_copy_view, + ann_copy_mips, + bg_cache: RefCell::new(None), + ..self + }) + } + + /// Pyramide du blur Kawase du fond (1/2, 1/4, 1/8 du rendu) et cible de la + /// trainee de l'ecran (pleine taille), dans cet ordre. + fn make_blur_targets( + gpu: &Gpu, + w: u32, + h: u32, + ) -> (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView, wgpu::TextureView) { + let mk = |dw: u32, dh: u32, label: &str| { + gpu.device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: dw.max(1), + height: dh.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&wgpu::TextureViewDescriptor::default()) + }; + ( + mk(w / 2, h / 2, "blur-half"), + mk(w / 4, h / 4, "blur-qtr"), + mk(w / 8, h / 8, "blur-oct"), + mk(w, h, "screen-trail"), + ) + } + /// RT RGBA8, cible d'accumulation de meme geometrie, et `bytes_per_row` de /// la relecture (padde a 256). /// @@ -746,7 +887,8 @@ impl Compositor { view_formats: &[], }) }; - let rt = mk("rt", wgpu::TextureUsages::COPY_SRC); + // COPY_DST : le fond fixe y est recopie (`bg_cache`). + let rt = mk("rt", wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST); let accum = mk("accum", wgpu::TextureUsages::empty()); let rt_view = rt.create_view(&wgpu::TextureViewDescriptor::default()); let accum_view = accum.create_view(&wgpu::TextureViewDescriptor::default()); @@ -904,12 +1046,9 @@ impl Compositor { /// Un buffer de staging de la ring. La taille depend de `bpr` (donc de la /// largeur de rendu) et de la hauteur : changer la geometrie de rendu impose - /// de les reallouer -- ce que fait `new_sized`, puisque la preview - /// RECONSTRUIT le compositeur au resize (`live.rs`) au lieu de le - /// redimensionner a chaud. Aucune copie ne peut donc etre en vol au moment - /// ou la taille change : l'ancien compositeur (et sa ring) est detruit - /// entier, wgpu gardant ses buffers vivants jusqu'a la fin des soumissions - /// qui les referencent. + /// de les reallouer -- ce que fait `resized`, qui remplace la ring entiere. + /// L'ancienne est detruite avec ses buffers, wgpu les gardant vivants + /// jusqu'a la fin des soumissions qui les referencent. fn make_staging(gpu: &Gpu, bpr: u32, h: u32) -> wgpu::Buffer { gpu.device.create_buffer(&wgpu::BufferDescriptor { label: Some("readback"), @@ -928,13 +1067,14 @@ impl Compositor { src: &wgpu::TextureView, dst: &wgpu::TextureView, src_px: [f32; 2], + off: f32, ) { let cb = LayerCB { mode: -1.0, color: [1.0, 1.0, 1.0, 1.0], // Convention HLSL/Metal (`compositor_windows::blur_bg`) : texel de la - // SOURCE en .xy, offset 2.2 en .z. Parite des trois backends. - fx: [1.0 / src_px[0], 1.0 / src_px[1], 2.2, 0.0], + // SOURCE en .xy, offset en .z. Parite des trois backends. + fx: [1.0 / src_px[0], 1.0 / src_px[1], off, 0.0], ..Default::default() }; let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -979,28 +1119,30 @@ impl Compositor { rpass.draw(0..3, 0..1); } - /// Floute le RT (le fond deja dessine) : dual-Kawase 3 down (RT -> 1/2 -> - /// 1/4 -> 1/8) + 3 up (1/8 -> 1/4 -> 1/2 -> RT). ~gaussien a cout constant. - fn blur_bg(&self, encoder: &mut wgpu::CommandEncoder) { - let (rw, rh) = (self.render_w as f32, self.render_h as f32); - let (hw, hh) = ( - (self.render_w / 2).max(1) as f32, - (self.render_h / 2).max(1) as f32, - ); - let (qw, qh) = ( - (self.render_w / 4).max(1) as f32, - (self.render_h / 4).max(1) as f32, - ); - let (ow, oh) = ( - (self.render_w / 8).max(1) as f32, - (self.render_h / 8).max(1) as f32, - ); - self.blur_pass(encoder, &self.blur_down, &self.rt_view, &self.blur_half, [rw, rh]); - self.blur_pass(encoder, &self.blur_down, &self.blur_half, &self.blur_qtr, [hw, hh]); - self.blur_pass(encoder, &self.blur_down, &self.blur_qtr, &self.blur_oct, [qw, qh]); - self.blur_pass(encoder, &self.blur_up, &self.blur_oct, &self.blur_qtr, [ow, oh]); - self.blur_pass(encoder, &self.blur_up, &self.blur_qtr, &self.blur_half, [qw, qh]); - self.blur_pass(encoder, &self.blur_up, &self.blur_half, &self.rt_view, [hw, hh]); + /// Floute le RT (le fond deja dessine) : dual-Kawase, `levels` down (RT -> 1/2 -> + /// 1/4 -> 1/8) puis autant d'up, niveaux et ecart tires de la force + /// (`background_blur_steps`, partage avec D3D/Metal). ~gaussien a cout borne. + fn blur_bg(&self, encoder: &mut wgpu::CommandEncoder, amount: f32) { + let Some((levels, off)) = crate::frame_geometry::background_blur_steps(amount) else { + return; + }; + let dims = |d: u32| { + [(self.render_w / d).max(1) as f32, (self.render_h / d).max(1) as f32] + }; + let pyramid = [ + (&self.rt_view, dims(1)), + (&self.blur_half, dims(2)), + (&self.blur_qtr, dims(4)), + (&self.blur_oct, dims(8)), + ]; + for i in 0..levels { + let (src, src_px) = pyramid[i]; + self.blur_pass(encoder, &self.blur_down, src, pyramid[i + 1].0, src_px, off); + } + for i in (0..levels).rev() { + let (src, src_px) = pyramid[i + 1]; + self.blur_pass(encoder, &self.blur_up, src, pyramid[i].0, src_px, off); + } } /// Dimensions paires (NV12 4:2:0), min 2x2. Symetrie avec les autres backends. @@ -1125,11 +1267,14 @@ impl Compositor { /// `LayerCB` de l'ombre d'un ecran INCLINE (mode 12) : la penombre suit le /// quadrilatere projete, pas son rect englobant. Port de /// `compositor_macos::draw_quad_shadow`. + #[allow(clippy::too_many_arguments)] fn quad_shadow_cb( &self, corners: &[(f32, f32); 4], center_px: [f32; 2], radius: f32, + // Le slot d'un layout en bloc, qui rogne le plan (`shadow_mask_fields`). + mask: Option, spread: f32, offset_px: [f32; 2], opacity: f32, @@ -1147,6 +1292,11 @@ impl Compositor { let [tr0, tr1] = local(corners[1]); let [br0, br1] = local(corners[2]); let [bl0, bl1] = local(corners[3]); + let (mask_rect, mask_radius) = crate::frame_geometry::shadow_mask_fields( + mask, + [center_px[0] + min_x - spread, center_px[1] + min_y - spread], + [rw, rh], + ); LayerCB { dst: [ (center_px[0] + min_x - spread + offset_px[0]) / rw, @@ -1160,31 +1310,51 @@ impl Compositor { color: [0.0, 0.0, 0.0, opacity], fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], + dst_prev: mask_rect, // Le spread vit ici et NON dans `fx.x` : `fx` porte deja les coins. - mb: [0.0, spread, 1.0, 0.0], + mb: [0.0, spread, 1.0, mask_radius], ..Default::default() } } + /// L'ecran cadre, dans son ordre : ombre, cadre de fenetre (sous l'ecran), ecran, appareil + /// (devant lui). Dans la passe d'avant-plan, ou dans le rendu isole du mode 18. + fn draw_screen_group( + &self, + rpass: &mut wgpu::RenderPass<'_>, + shadow: &Option, + window_frame: &Option, + screen: &LayerBind, + device_frame: &Option, + ) { + for layer in [shadow.as_ref(), window_frame.as_ref(), Some(screen), device_frame.as_ref()] + .into_iter() + .flatten() + { + self.draw_layer(rpass, layer, false); + } + } + fn make_bind( &self, cb: &LayerCB, planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>, dummy: &wgpu::TextureView, - ) -> (wgpu::Buffer, wgpu::BindGroup) { - self.make_bind_b4(cb, planes, dummy, None) + ) -> LayerBind { + self.make_bind_b4(cb, planes, dummy, None, None) } - /// `make_bind`, binding 4 impose. Seul le mode 8 s'en sert, pour y lier la pyramide - /// de profondeur de champ a la place du masque webcam qu'il ne lit pas -- jamais le - /// binding 1, qui porte la luma. + /// `make_bind`, binding 4 impose (le rendu isole du mode 18, a la place du masque webcam + /// qu'il ne lit pas), et la pyramide de profondeur de champ en binding 6 (modes 8 et 18). + /// Le mode 15 y met le champ de son sprite et le sprite. fn make_bind_b4( &self, cb: &LayerCB, planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>, dummy: &wgpu::TextureView, b4: Option<&wgpu::TextureView>, - ) -> (wgpu::Buffer, wgpu::BindGroup) { + dof: Option<&wgpu::TextureView>, + ) -> LayerBind { let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("layer-uniform"), contents: layer_bytes(cb), @@ -1228,9 +1398,33 @@ impl Compositor { binding: 5, resource: wgpu::BindingResource::TextureView(v), }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(dof.unwrap_or(dummy)), + }, + wgpu::BindGroupEntry { binding: 7, resource: self.prism_mesh.as_entire_binding() }, ], }); - (uniform, bind) + LayerBind { _buf: uniform, bind, models: cb.needs_models() } + } + + /// Dessine un calque par le pipeline plat que l'appelant a lie (`pipeline`, ou `pipeline_add` + /// si `add`) -- sauf un modele 3D, qui passe le temps de son draw par la variante + /// « modeles » de ce pipeline, la seule a le compiler (pourquoi : le haut de `layer.wgsl`). + fn draw_layer(&self, rpass: &mut wgpu::RenderPass<'_>, layer: &LayerBind, add: bool) { + rpass.set_bind_group(0, &layer.bind, &[]); + if layer.models { + let (models, flat) = if add { + (&self.pipeline_add_models, &self.pipeline_add) + } else { + (&self.pipeline_models, &self.pipeline) + }; + rpass.set_pipeline(models); + rpass.draw(0..4, 0..1); + rpass.set_pipeline(flat); + } else { + rpass.draw(0..4, 0..1); + } } /// Charge un PNG/JPEG (chemin fichier ou data URI) en texture RGBA8. Port @@ -1283,6 +1477,7 @@ impl Compositor { return Ok(hit.clone()); } let sdf = crate::cursor_sdf::CursorSdf::load(path)?; + let texels = sdf.f16_bytes(); let size = wgpu::Extent3d { width: sdf.width, height: sdf.height, depth_or_array_layers: 1 }; let tex = self.gpu.device.create_texture(&wgpu::TextureDescriptor { label: Some("cursor-sdf"), @@ -1301,7 +1496,7 @@ impl Compositor { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - &sdf.f16_bytes(), + &texels, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(sdf.width * 2), @@ -1371,6 +1566,10 @@ impl Compositor { /// /// Err plutot qu'un repli maison : chaque appelant a son propre message et /// son propre repli, et un echec silencieux redonnerait le noir qu'on corrige. + /// + /// `motion` anime l'image au temps programme `programme_t` ; la bulle webcam + /// passe `WallpaperMotion::None`. + #[allow(clippy::too_many_arguments)] fn image_bg_draw( &self, path: &str, @@ -1378,6 +1577,8 @@ impl Compositor { quad_px: [f32; 2], radius_px: f32, aspect: f32, + motion: WallpaperMotion, + programme_t: f32, dummy: &wgpu::TextureView, ) -> Result { let (tex, iw, ih) = self.cached_image(path)?; @@ -1390,17 +1591,20 @@ impl Compositor { let vis = ai / aspect; [0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5] }; + let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots(motion, programme_t, aspect); let cb = LayerCB { dst, src, quad_px, radius_px, mode: 6.0, + fx: [0.0, 0.0, anim[0], anim[1]], + mb, ..Default::default() }; let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); - let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), dummy); - Ok(BgDraw { _buf: buf, _tex: Some(tex), _view: Some(view), bind }) + let layer = self.make_bind(&cb, Some((&view, &view, &view)), dummy); + Ok(BgDraw { layer, _tex: Some(tex), _view: Some(view) }) } /// Prepare le fond du mode « personnalise », peint DANS la bulle webcam juste @@ -1425,10 +1629,7 @@ impl Compositor { dummy: &wgpu::TextureView, ) -> BgDraw { const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; - let flat = |cb: LayerCB| { - let (buf, bind) = self.make_bind(&cb, None, dummy); - BgDraw { _buf: buf, _tex: None, _view: None, bind } - }; + let flat = |cb: LayerCB| BgDraw { layer: self.make_bind(&cb, None, dummy), _tex: None, _view: None }; let solid = |color: [f32; 4]| LayerCB { dst, quad_px, @@ -1442,29 +1643,25 @@ impl Compositor { flat(solid(parse_hex(color).unwrap_or(BLACK))) } // Le mouvement ne vaut que pour le fond d'ecran : la bulle garde son degrade immobile. - Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { - let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); - let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + Some(SceneBackground::Gradient { angle_deg, stops, offsets, .. }) => { // angle CSS -> direction unitaire, meme convention que le fond // d'ecran (dont la direction se lit en espace SORTIE : le degrade // traverse le cadre, la bulle n'en montre que sa tranche). let a = angle_deg.to_radians(); flat(LayerCB { dst, - src: [c1[0], c1[1], c1[2], c1[3]], quad_px, radius_px, - mode: 5.0, - color: c0, fx: [a.sin(), -a.cos(), 0.0, 0.0], - ..Default::default() + ..crate::frame_geometry::gradient_layer(stops, offsets, BLACK) }) } - Some(SceneBackground::Image { path }) => { + Some(SceneBackground::Image { path, .. }) => { // Le cover-fit se mesure sur la BULLE, pas sur la sortie : c'est // elle que l'image doit remplir sans etirement. let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 }; - match self.image_bg_draw(path, dst, quad_px, radius_px, aspect, dummy) { + let still = WallpaperMotion::None; + match self.image_bg_draw(path, dst, quad_px, radius_px, aspect, still, 0.0, dummy) { Ok(d) => d, Err(e) => { // Meme contrat que le fond d'ecran : un chemin casse est @@ -1569,7 +1766,7 @@ impl Compositor { // `color.a = 1` n'est pas decoratif : `fs_main` calcule son alpha en // `layer.color.a * alpha_mask`, donc le defaut (0) rendrait un quad // entierement transparent. - let (_uniform, bind) = self.make_bind( + let layer = self.make_bind( &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], src, @@ -1602,8 +1799,7 @@ impl Compositor { occlusion_query_set: None, }); rpass.set_pipeline(&self.pipeline); - rpass.set_bind_group(0, &bind, &[]); - rpass.draw(0..4, 0..1); + self.draw_layer(&mut rpass, &layer, false); } encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { @@ -1908,6 +2104,11 @@ impl Compositor { *self.webcam_mask.borrow_mut() = None; } + /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`). + pub fn footage_quad(&self) -> Option { + self.footage.get() + } + /// Rend une frame dans le RT interne. Le screen `screen`/`webcam` sont des /// carriers `linux_frames` ; la geometrie vient de `plan_frame`. Coeur : /// fond uni + ecran cover-fit. `readback_direct` lit ensuite le RT. @@ -1983,6 +2184,7 @@ impl Compositor { timeline_t_override: *self.timeline_time.borrow(), programme_time: *self.programme_time.borrow(), }); + self.footage.set(Some(g.footage_quad([rw, rh]))); // (`wtw`/`wth` sont les dims de la TEXTURE webcam, consommees par le // cover-crop du calque PiP plus bas.) @@ -1991,32 +2193,27 @@ impl Compositor { // cfg.bg_blur) floute ensuite ce fond, avant l'ecran. enum BgLayer { Gradient(LayerCB), - Image(String), + Image(String, WallpaperMotion), } let (bg_clear, bg_layer) = match scene_ref.as_ref().map(|s| s.background.clone()) { Some(SceneBackground::Color { color }) => { (parse_hex(&color).unwrap_or(lp.bg_color), None) } - Some(SceneBackground::Gradient { angle_deg, stops, motion }) => { - 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); + Some(SceneBackground::Gradient { angle_deg, stops, offsets, motion }) => { let a = angle_deg.to_radians(); let (anim, mb) = - crate::frame_geometry::gradient_motion_slots(motion, g.programme_t, rw / rh); + crate::frame_geometry::wallpaper_motion_slots(motion, g.programme_t, rw / rh); let cb = LayerCB { dst: [0.0, 0.0, 1.0, 1.0], - src: [c1[0], c1[1], c1[2], c1[3]], quad_px: [rw, rh], - mode: 5.0, - color: c0, fx: [a.sin(), -a.cos(), anim[0], anim[1]], mb, - ..Default::default() + ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color) }; ([0.0, 0.0, 0.0, 1.0], Some(BgLayer::Gradient(cb))) } - Some(SceneBackground::Image { path }) => { - ([0.0, 0.0, 0.0, 1.0], Some(BgLayer::Image(path))) + Some(SceneBackground::Image { path, motion }) => { + ([0.0, 0.0, 0.0, 1.0], Some(BgLayer::Image(path, motion))) } None => (lp.bg_color, None), }; @@ -2046,20 +2243,20 @@ impl Compositor { // sinon. Place par plan_frame (cover-fit + coins arrondis) ; // `src = g.cut` (crop utilisateur + zoom en UV texture) dans les deux cas. // - // FLOU DE VELOCITE, ET POURQUOI SEULEMENT SUR LE MODE 0. `src_prev`/ - // `dst_prev` decrivent le MEME calque a la frame precedente ; le shader - // remappe chaque pixel de sortie par ce couple pour retrouver l'UV qu'il - // occupait alors, et floute le long du segment. `src_prev = g.cut` et non - // un `cut` d'avant : la coupe est identique aux deux frames (`plan_frame` - // ne fait varier que le rect de DESTINATION entre `s_dst` et - // `s_dst_prev`), ce que Windows documente aussi. Le mouvement vient donc - // entierement de `dst_prev`. + // FLOU DE VELOCITE. Au mode 0, `src_prev`/`dst_prev` decrivent le MEME + // calque a la frame precedente ; le shader remappe chaque pixel de sortie + // par ce couple pour retrouver l'UV qu'il occupait alors, et floute le long + // du segment. `src_prev = g.cut` et non un `cut` d'avant : la coupe est + // identique aux deux frames (`plan_frame` ne fait varier que le rect de + // DESTINATION entre `s_dst` et `s_dst_prev`), ce que Windows documente + // aussi. Le mouvement vient donc entierement de `dst_prev`. // - // Le mode 8 n'en recoit PAS, et ce n'est pas un oubli : ces deux champs y - // portent deja les coins projetes du quad (BR/BL dans `src_prev`, - // `plane_px` dans `dst_prev`). Les deux sens ne peuvent pas cohabiter dans - // un meme draw. macOS et Windows sautent egalement le flou sur le chemin - // incline, pour la meme raison. + // Au mode 8, ces deux champs portent deja les coins projetes du quad + // (BR/BL dans `src_prev`, `plane_px` dans `dst_prev`) : le plan d'avant + // arrive dans ses propres champs (`trail_a`/`trail_b`/`trail_mb`, + // `FrameGeometry::tilt_pixel_trail`), meme flou borne a une frame. + // Dans les deux modes, ce flou par pixel s'efface quand l'ecran CADRE est + // floute en bloc (mode 18, `trail` plus bas). // Sous un cadre de fenetre, l'ecran garde ses coins HAUTS carres (`mb.w` au mode 0, // `dst_prev.z` au mode 8) ; 0 sans cadre, soit le rendu d'avant. let square_top = g.screen_square_top(); @@ -2067,18 +2264,23 @@ impl Compositor { // `color.z` au mode 8) : l'arrondi du haut se fait par le cadre. 0 sans fenetre. let top_lift = g.screen_top_lift_px([rw, rh]); let screen_layer = match tilt.as_ref() { - None => LayerCB { - dst: g.s_dst, - src: g.cut, - quad_px: s_px, - radius_px: g.s_radius, - mode: 0.0, - color: [1.0, 1.0, 1.0, 1.0], - src_prev: g.cut, - dst_prev: g.s_dst_prev, - mb: [g.mb_taps, g.mb_amount, top_lift, square_top], - ..Default::default() - }, + None => { + // Sous le masque d'un layout en bloc, rogne au slot (`mask_flat_screen`). + let (dst, src, quad_px, radius_px) = + g.mask_flat_screen(g.s_dst, g.cut, s_px, g.s_radius, [rw, rh]); + LayerCB { + dst, + src, + quad_px, + radius_px, + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: g.cut, + dst_prev: g.s_dst_prev, + mb: [g.screen_pixel_taps([rw, rh]), g.mb_amount, top_lift, square_top], + ..Default::default() + } + } // Mode 8, partage avec Windows et macOS (`tilted_screen_cb`). Some(quad) => tilted_screen_cb( quad, @@ -2090,17 +2292,20 @@ impl Compositor { top_lift, dof, [rw, rh], + g.screen_mask, + g.tilt_pixel_trail([rw, rh]), ), }; - // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; - // `_screen_uniform` garde le buffer uniforme en vie (reference par le bind). + // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; `LayerBind` + // garde le buffer uniforme en vie (reference par le bind). let dummy = self.dummy_view(); - // Binding 4 EXPLICITE sur le mode 8 : la pyramide si l'effet tourne, sinon le - // repli habituel (`k = 0`, le shader n'y lit rien). - let (_screen_uniform, screen_bind) = self.make_bind_b4( + // Binding 6 du mode 8 : la pyramide si l'effet tourne, sinon `dummy` (`k = 0`, le + // shader n'y lit rien). + let screen_bind = self.make_bind_b4( &screen_layer, Some((&sy, &su, &sv)), &dummy, + None, dof_pyramid.map(|p| &p.view), ); // Remplissage de la pyramide : UN draw du mode 0 en UV plein vers son niveau 0 @@ -2135,7 +2340,7 @@ impl Compositor { // cadre de fenetre, c'est le CADRE qui la porte (`shadow_caster`), sinon la barre de // titre flotterait au-dessus de l'ombre. let screen_shadow = cfg.shadow.then(|| { - let spread = crate::frame_geometry::SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; + let spread = crate::frame_geometry::SCREEN_SHADOW_SPREAD_FRAC * g.screen_unit_px; let offset = g.screen_shadow_offset(); let opacity = 0.45 * lp.shadow_scale; // Un appareil porte l'ombre de sa silhouette 3D (mode 17), pas celle d'un quad. @@ -2145,9 +2350,8 @@ impl Compositor { ShadowCaster::Upright { dst, size_px, radius } => { self.shadow_cb(dst, size_px, radius, spread, offset, opacity) } - ShadowCaster::Tilted { corners, center_px, radius } => { - self.quad_shadow_cb(&corners, center_px, radius, spread, offset, opacity) - } + ShadowCaster::Tilted { corners, center_px, radius, mask } => self + .quad_shadow_cb(&corners, center_px, radius, mask, spread, offset, opacity), }, }; self.make_bind(&cb, None, &dummy) @@ -2159,19 +2363,41 @@ impl Compositor { // du metrage et sa lunette mord dessus. Le shader s'arrete au plan du metrage dans // l'ouverture, donc il ne recouvre jamais l'image. let device_frame = g.device_frame_cb([rw, rh]).map(|cb| self.make_bind(&cb, None, &dummy)); + // Flou de mouvement de l'ecran CADRE (`FrameGeometry::screen_trail`) : l'ombre, le cadre, + // le metrage et l'appareil ci-dessus passent dans un rendu isole (`trail_view`), que le + // mode 18 recompose sur le fond, droit ou incline ; binding 4 = ce rendu, les plans = le + // metrage, binding 6 = la pyramide, pour le relire hors de la sortie comme le mode 8. + let trail = g.screen_trail([rw, rh]).then(|| { + self.make_bind_b4( + &g.screen_trail_cb([rw, rh], dof), + Some((&sy, &su, &sv)), + &dummy, + Some(&self.trail_view), + dof_pyramid.map(|p| &p.view), + ) + }); + + // Le fond de la frame d'avant, s'il est celui qu'on demande (`BackgroundKey`) : la passe 1 + // le recopie au lieu de le redessiner. + let bg_desc = scene_ref.as_ref().map(|s| &s.background); + let bg_cached = self + .bg_cache + .borrow() + .as_ref() + .is_some_and(|c| c.key.matches(bg_desc, cfg.bg_blur, lp.bg_color, [rw, rh])); + let bg_wanted = bg_layer.is_some(); // Fond (gradient mode 5 OU image mode 6), dessine dans la passe de fond. - let bg_draw = bg_layer.and_then(|bl| match bl { + let bg_draw = bg_layer.filter(|_| !bg_cached).and_then(|bl| match bl { BgLayer::Gradient(cb) => { - let (buf, bind) = self.make_bind(&cb, None, &dummy); - Some(BgDraw { _buf: buf, _tex: None, _view: None, bind }) + Some(BgDraw { layer: self.make_bind(&cb, None, &dummy), _tex: None, _view: None }) } // Le wallpaper couvre tout le cadre, donc dst plein et pas de coins : // `image_bg_draw` sert aussi la bulle webcam, qui elle en a. - BgLayer::Image(path) => { - match self - .image_bg_draw(&path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, rw / rh, &dummy) - { + BgLayer::Image(path, motion) => { + let full = [0.0, 0.0, 1.0, 1.0]; + let t = g.programme_t; + match self.image_bg_draw(&path, full, [0.0, 0.0], 0.0, rw / rh, motion, t, &dummy) { Ok(d) => Some(d), Err(e) => { eprintln!("[fond image] \"{path}\" : {e:#}"); @@ -2182,7 +2408,8 @@ impl Compositor { }); // Webcam PiP (mode 0) -- placee par plan_frame (`g.w_dst`, coins - // `g.w_radius`), gardee par `g.shape_fade > 0` (webcam visible). + // `g.w_radius`). `g.shape_fade` ne regit que la bulle (coins, ombre) : il + // vaut 0 pendant un palier Full Camera, ou la webcam remplit le cadre. // `webcam_planes` garde les vues en vie pendant le pass. // `lp.has_webcam` is the gate Windows (`compositor_windows.rs`) and macOS // (`compositor_macos.rs`) both apply and this backend did not. It is false @@ -2190,7 +2417,7 @@ impl Compositor { // the SCREEN video, because `open_and_seek_clip` falls back to it rather // than leave the pair half-open. Without this check a recording with no // camera drew its own screen picture inside the PiP box. - let webcam_planes = if lp.has_webcam && g.shape_fade > 0.0 && !webcam.is_null() { + let webcam_planes = if lp.has_webcam && !webcam.is_null() { self.nv12_srvs(webcam).ok() } else { None @@ -2343,16 +2570,15 @@ impl Compositor { // replis, meme ordre. Seul le texte diverge, tinte cote shader (atlas R8) // au lieu d'une couleur bakee dans la texture. struct AnnDraw { - _buf: wgpu::Buffer, + layer: LayerBind, /// Gardent l'atlas / la texture image en vie jusqu'au submit. `None` /// pour les quads qui n'echantillonnent rien (plaque de fond, fleche). _glyphs: Option, _tex: Option, - bind: wgpu::BindGroup, } impl AnnDraw { - fn plain(buf: wgpu::Buffer, bind: wgpu::BindGroup) -> AnnDraw { - AnnDraw { _buf: buf, _glyphs: None, _tex: None, bind } + fn plain(layer: LayerBind) -> AnnDraw { + AnnDraw { layer, _glyphs: None, _tex: None } } } // FENETRE TEMPORELLE. Sans ce test, TOUTES les annotations du projet sont @@ -2370,6 +2596,8 @@ impl Compositor { .as_ref() .is_some_and(|s| s.annotations.iter().any(|a| a.kind == "blur" && visible(a))); let mut ann_draws: Vec = Vec::new(); + // Les flous de confidentialite, dessines sur le metrage avant le curseur. + let mut privacy_draws: Vec = Vec::new(); if let Some(scene) = scene_ref.as_ref() { // La liste arrive deja triee par zIndex cote app : l'ordre d'iteration // EST l'ordre de peinture. @@ -2411,8 +2639,7 @@ impl Compositor { mb: [1.0, half_stroke, 0.0, 0.0], ..Default::default() }; - let (buf, bind) = self.make_bind(&cb, None, &dummy); - ann_draws.push(AnnDraw::plain(buf, bind)); + ann_draws.push(AnnDraw::plain(self.make_bind(&cb, None, &dummy))); } "blur" => { let Some(blur) = a.blur.as_ref() else { continue }; @@ -2455,12 +2682,12 @@ impl Compositor { }; // La copie mipmappee au binding 1 (texY), la ou le mode 10 // la lit. - let (buf, bind) = self.make_bind( + let layer = self.make_bind( &cb, Some((&self.ann_copy_view, &self.ann_copy_view, &self.ann_copy_view)), &dummy, ); - ann_draws.push(AnnDraw::plain(buf, bind)); + privacy_draws.push(AnnDraw::plain(layer)); } "image" => { let Some(src) = a.image_path.as_ref().filter(|s| !s.is_empty()) else { @@ -2517,13 +2744,8 @@ impl Compositor { fx: [0.0, 0.0, 1.0, 1.0], ..Default::default() }; - let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy); - ann_draws.push(AnnDraw { - _buf: buf, - _glyphs: None, - _tex: Some(tex), - bind, - }); + let layer = self.make_bind(&cb, Some((&view, &view, &view)), &dummy); + ann_draws.push(AnnDraw { layer, _glyphs: None, _tex: Some(tex) }); } "text" => { let Some(raster) = self.text_raster.as_ref() else { continue }; @@ -2568,6 +2790,7 @@ impl Compositor { let anim = crate::text_anim::text_animation_state( text.animation.as_deref(), (g.source_t - a.start_sec as f32) * 1000.0, + ((a.end_sec - a.start_sec) * 1000.0) as f32, ); let anim_px = rh / crate::text_anim::ANIMATION_REFERENCE_HEIGHT; let (mut ax, mut ay, mut aw, mut ah) = ( @@ -2651,8 +2874,7 @@ impl Compositor { ), ..Default::default() }; - let (pbuf, pbind) = self.make_bind(&plate, None, &dummy); - ann_draws.push(AnnDraw::plain(pbuf, pbind)); + ann_draws.push(AnnDraw::plain(self.make_bind(&plate, None, &dummy))); } } @@ -2665,14 +2887,9 @@ impl Compositor { ..Default::default() }; // Atlas R8 au binding 1 (texY) que le mode 11 echantillonne. - let (buf, bind) = + let layer = self.make_bind(&cb, Some((&glyphs.view, &glyphs.view, &glyphs.view)), &dummy); - ann_draws.push(AnnDraw { - _buf: buf, - _glyphs: Some(glyphs), - _tex: None, - bind, - }); + ann_draws.push(AnnDraw { layer, _glyphs: Some(glyphs), _tex: None }); } _ => {} } @@ -2689,12 +2906,18 @@ impl Compositor { // sont PAS dessinees sur le RT mais dans `accum`, puis compositees en une // fois -- cf. le commentaire au point de dessin. struct CursorDraw { - _bufs: Vec, /// Le sprite, et pour le curseur modelise son champ de distance. _tex: Vec<(wgpu::Texture, wgpu::TextureView)>, - binds: Vec, + /// Une copie par echantillon de la trainee, le sprite (modes 7 et 13) ou le curseur + /// modelise (mode 15). + binds: Vec, + /// L'union des quads de `binds` (x0, y0, x1, y1 en fractions de la sortie) : tout ce + /// que la trainee peint dans `accum`, donc tout ce que sa recopie doit relire. + bounds: [f32; 4], /// L'impact des clics (mode 16), dessine SOUS le curseur, sur le RT. - impacts: Vec, + impacts: Vec, + /// Le cristal de Prism Glow : il lit la copie de l'image composee (`CursorPlan::glass`). + glass: bool, } let cursor_draw: Option = (|| { let track = cursor_ref.as_ref()?; @@ -2737,13 +2960,18 @@ impl Compositor { }) .collect() }; - let (mut bufs, mut binds) = (Vec::new(), Vec::new()); - let mut impacts = Vec::new(); - for cb in &plan.impacts { - let (buf, bind) = self.make_bind(cb, None, &dummy); - bufs.push(buf); - impacts.push(bind); - } + let mut binds = Vec::new(); + let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + let mut grow = |dst: [f32; 4]| { + bounds = [ + bounds[0].min(dst[0]), + bounds[1].min(dst[1]), + bounds[2].max(dst[0] + dst[2]), + bounds[3].max(dst[1] + dst[3]), + ]; + }; + let impacts: Vec = + plan.impacts.iter().map(|cb| self.make_bind(cb, None, &dummy)).collect(); let sprites = scene_ref .as_ref() @@ -2773,11 +3001,13 @@ impl Compositor { let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); // Curseur modelise (mode 15) : ce meme sprite extrude, `plan_cursor` en a tire la - // pose. Sprite au binding 1 (texY), champ au binding 2 (texU). Parite Windows/macOS. + // pose. La copie de l'image composee au binding 1 (texY), que refracte le cristal de + // Prism Glow, le champ au binding 4 (texMask), le sprite au binding 6 (texDof). + // Parite Windows/macOS. if let Some(pose) = plan.model { match self.cursor_sdf(&sprite.path) { Ok((sdf, shape)) => { - let shape = SpriteShape { hotspot, ..shape }; + let shape = crate::frame_geometry::model_shape(sprite, shape); let sdf_view = sdf.create_view(&wgpu::TextureViewDescriptor::default()); for placement in placements { let Some(cb) = cursor_model_cb( @@ -2790,22 +3020,48 @@ impl Compositor { ) else { continue; }; - let (buf, bind) = - self.make_bind(&cb, Some((&view, &sdf_view, &view)), &dummy); - bufs.push(buf); - binds.push(bind); + binds.push(self.make_bind_b4( + &cb, + Some((&self.ann_copy_view, &dummy, &dummy)), + &dummy, + Some(&sdf_view), + Some(&view), + )); + grow(cb.dst); } return (!binds.is_empty()).then_some(CursorDraw { - _bufs: bufs, _tex: vec![(tex, view), (sdf, sdf_view)], binds, + bounds, impacts, + glass: plan.glass, }); } Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), } } + // Le verre a plat de Glass Lens (mode 19) : la copie de l'image composee qu'il + // refracte au binding 1 (texY), comme le cristal du mode 15. Parite Windows/macOS. + if let Some(name) = sprite.glass.as_deref().filter(|_| plan.model.is_none()) { + for placement in placements { + let Some(cb) = + cursor_glass_cb(placement, plan.size_px, name, plan.alpha, plan.clip, [rw, rh]) + else { + continue; + }; + binds.push(self.make_bind(&cb, Some((&self.ann_copy_view, &dummy, &dummy)), &dummy)); + grow(cb.dst); + } + return (!binds.is_empty()).then_some(CursorDraw { + _tex: vec![(tex, view)], + binds, + bounds, + impacts, + glass: plan.glass, + }); + } + for placement in placements { // Geometrie partagee avec Windows et macOS (`cursor_sprite_cb`) : // mode 7 droit, ou mode 13 pose sur le plan -- le clip vit alors @@ -2819,11 +3075,16 @@ impl Compositor { [rw, rh], ); // Sprite RGBA au binding 1 (texY) que le mode 7 echantillonne. - let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy); - bufs.push(buf); - binds.push(bind); + binds.push(self.make_bind(&cb, Some((&view, &view, &view)), &dummy)); + grow(cb.dst); } - Some(CursorDraw { _bufs: bufs, _tex: vec![(tex, view)], binds, impacts }) + Some(CursorDraw { + _tex: vec![(tex, view)], + binds, + bounds, + impacts, + glass: false, + }) })(); // Bind group de la passe de composition d'`accum` (layout du blur : // uniform + texture + sampler). Construit hors de la pass, comme les @@ -2865,7 +3126,7 @@ impl Compositor { }); // Passe 0 : pyramide de profondeur de champ, niveau 0 vide d'abord (le « over » // rend alors la source telle quelle), puis ses mips. - if let (Some(p), Some((_buf, bind))) = (dof_pyramid, &dof_fill) { + if let (Some(p), Some(fill)) = (dof_pyramid, &dof_fill) { { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("dof-pyramid-pass"), @@ -2882,25 +3143,19 @@ impl Compositor { occlusion_query_set: None, }); rpass.set_pipeline(&self.pipeline); - rpass.set_bind_group(0, bind, &[]); - rpass.draw(0..4, 0..1); + self.draw_layer(&mut rpass, fill, false); } self.generate_mips(&mut encoder, &p.mips); } - // Passe 1 : fond (clear a `bg_clear` + gradient mode 5 eventuel). - { + // Passe 0 bis : l'ecran cadre, isole sur transparent, quand il est floute en bloc. + if trail.is_some() { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("bg-pass"), + label: Some("screen-trail-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.rt_view, + view: &self.trail_view, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: bg_clear[0] as f64, - g: bg_clear[1] as f64, - b: bg_clear[2] as f64, - a: bg_clear[3] as f64, - }), + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store, }, })], @@ -2908,19 +3163,77 @@ impl Compositor { timestamp_writes: None, occlusion_query_set: None, }); - if let Some(bg) = &bg_draw { - rpass.set_pipeline(&self.pipeline); - rpass.set_bind_group(0, &bg.bind, &[]); - rpass.draw(0..4, 0..1); - } + rpass.set_pipeline(&self.pipeline); + self.draw_screen_group(&mut rpass, &screen_shadow, &window_frame, &screen_bind, &device_frame); } - // Blur du fond (avant l'ecran), si active par la scene/l'inspector. - if cfg.bg_blur { - self.blur_bg(&mut encoder); + // Passe 1 : fond (clear a `bg_clear` + gradient mode 5 eventuel) puis son flou -- ou, s'il + // n'a pas change (`BackgroundKey`), la copie qu'en a gardee la frame d'avant. + let extent = wgpu::Extent3d { + width: self.render_w, + height: self.render_h, + depth_or_array_layers: 1, + }; + if bg_cached { + let cache = self.bg_cache.borrow(); + let tex = &cache.as_ref().expect("bg_cached").tex; + encoder.copy_texture_to_texture(tex.as_image_copy(), self.rt.as_image_copy(), extent); + } else { + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bg-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg_clear[0] as f64, + g: bg_clear[1] as f64, + b: bg_clear[2] as f64, + a: bg_clear[3] as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + if let Some(bg) = &bg_draw { + rpass.set_pipeline(&self.pipeline); + self.draw_layer(&mut rpass, &bg.layer, false); + } + } + // Blur du fond (avant l'ecran), si active par la scene/l'inspector. + self.blur_bg(&mut encoder, cfg.bg_blur); + // Pas de cache pour un fond anime, ni pour un fond qui n'a pas pu se charger : la frame + // suivante reessaie. + let key = (bg_draw.is_some() == bg_wanted) + .then(|| { + crate::frame_geometry::BackgroundKey::of(bg_desc, cfg.bg_blur, lp.bg_color, [rw, rh]) + }) + .flatten(); + if let Some(key) = key { + let mut cache = self.bg_cache.borrow_mut(); + let tex = match cache.take() { + Some(c) if c.tex.size() == extent => c.tex, + _ => self.gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("bg-cache"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }), + }; + encoder.copy_texture_to_texture(self.rt.as_image_copy(), tex.as_image_copy(), extent); + *cache = Some(BgCache { key, tex }); + } } - // Passe 2 : avant-plan (ecran + webcam), compose par-dessus le fond - // (eventuellement floute) avec `LoadOp::Load`. Les annotations sont dans - // une passe a part, cf. plus bas. + // Passe 2 : l'ecran, compose par-dessus le fond (eventuellement floute) avec + // `LoadOp::Load`. Puis les flous de confidentialite, le curseur, la camera et les autres + // annotations, dans cet ordre : celui de Windows et macOS. { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("fg-pass"), @@ -2940,54 +3253,32 @@ impl Compositor { // Chaque ombre est dessinee JUSTE AVANT le calque qu'elle porte : // elle doit passer sous lui mais au-dessus du fond (et, pour la // camera, au-dessus de l'ecran). - if let Some((_buf, bind)) = &screen_shadow { - rpass.set_bind_group(0, bind, &[]); - rpass.draw(0..4, 0..1); - } - if let Some((_buf, bind)) = &window_frame { - rpass.set_bind_group(0, bind, &[]); - rpass.draw(0..4, 0..1); - } - rpass.set_bind_group(0, &screen_bind, &[]); - rpass.draw(0..4, 0..1); - if let Some((_buf, bind)) = &device_frame { - rpass.set_bind_group(0, bind, &[]); - rpass.draw(0..4, 0..1); - } - if let Some((_buf, bind)) = &webcam_shadow { - rpass.set_bind_group(0, bind, &[]); - rpass.draw(0..4, 0..1); - } - // Fond personnalise : ENTRE l'ombre et la camera. C'est ce sandwich qui - // remplace la branche « mode 3 » du shader — la camera, decoupee, se - // fond dessus par alpha ; l'ombre reste dessous, elle appartient a la - // bulle et non a son contenu. - if let Some(bg) = &webcam_bg { - rpass.set_bind_group(0, &bg.bind, &[]); - rpass.draw(0..4, 0..1); - } - if let Some((_buf, bind)) = &webcam_draw { - rpass.set_bind_group(0, bind, &[]); - rpass.draw(0..4, 0..1); + match &trail { + Some(layer) => self.draw_layer(&mut rpass, layer, false), + None => self.draw_screen_group( + &mut rpass, + &screen_shadow, + &window_frame, + &screen_bind, + &device_frame, + ), } } // Fige la frame composee pour les annotations « flou ». ICI et nulle part - // ailleurs : apres l'ecran et la camera (sinon un flou masquerait du vide) - // et avant la premiere annotation (sinon deux flous qui se recouvrent + // ailleurs : apres l'ecran (sinon un flou masquerait du vide) + // et avant le premier flou (sinon deux flous qui se recouvrent // s'echantillonnent l'un l'autre). Une passe de rendu ne peut pas lire sa // propre cible, d'ou la copie -- et d'ou le fait que les annotations // doivent avoir leur propre passe. if needs_ann_copy { self.generate_ann_mips(&mut encoder); } - // Passe 3 : annotations puis curseur net, par-dessus tout le reste. Elle - // existe meme sans flou : deux passes consecutives sur la MEME cible avec - // `LoadOp::Load` ne coutent rien de plus qu'une seule sur un GPU - // desktop, et un seul chemin de code vaut mieux qu'un branchement qui ne - // serait exerce que dans un projet sur dix. - { + // Passe 2 bis : les flous de confidentialite, sur le metrage, AVANT le curseur (parite + // Windows et macOS). Le curseur reste net par-dessus, et le cristal de Prism Glow, qui + // refracte l'image composee, n'y voit que des pixels deja floutes. + if !privacy_draws.is_empty() { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("ann-pass"), + label: Some("privacy-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.rt_view, resolve_target: None, @@ -3001,21 +3292,44 @@ impl Compositor { occlusion_query_set: None, }); rpass.set_pipeline(&self.pipeline); - for a in &ann_draws { - rpass.set_bind_group(0, &a.bind, &[]); - rpass.draw(0..4, 0..1); + for a in &privacy_draws { + self.draw_layer(&mut rpass, &a.layer, false); } + } + // Le cristal de Prism Glow refracte l'image telle qu'elle est composee a cet instant, + // flous compris : sa copie dans `ann_copy` (libre, les flous l'ont deja lue ; le mip 0 + // suffit), une fois pour toutes les copies de la trainee. + if cursor_draw.as_ref().is_some_and(|c| c.glass) { + encoder.copy_texture_to_texture( + self.rt.as_image_copy(), + self.ann_copy.as_image_copy(), + wgpu::Extent3d { width: self.render_w, height: self.render_h, depth_or_array_layers: 1 }, + ); + } + // Le curseur net, et l'impact des clics sous lui. La trainee, elle, a besoin de sa + // propre cible : elle suit. + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("cursor-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); // L'impact des clics, sous le curseur et sa trainee (dessinee plus bas). - for bind in cursor_draw.iter().flat_map(|c| &c.impacts) { - rpass.set_bind_group(0, bind, &[]); - rpass.draw(0..4, 0..1); + for layer in cursor_draw.iter().flat_map(|c| &c.impacts) { + self.draw_layer(&mut rpass, layer, false); } - // Curseur en dernier : au-dessus de l'ecran et des annotations. - // Une seule copie = curseur net, il tient dans cette pass. La - // trainee, elle, a besoin de sa propre cible (voir plus bas). if let Some(c) = cursor_draw.as_ref().filter(|c| c.binds.len() == 1) { - rpass.set_bind_group(0, &c.binds[0], &[]); - rpass.draw(0..4, 0..1); + self.draw_layer(&mut rpass, &c.binds[0], false); } } // TRAINEE DU CURSEUR : flou REEL, pas des copies discretes. @@ -3051,11 +3365,10 @@ impl Compositor { occlusion_query_set: None, }); rpass.set_pipeline(&self.pipeline_add); - for (k, bind) in c.binds.iter().enumerate() { + for (k, layer) in c.binds.iter().enumerate() { let w = crate::frame_geometry::cursor_tap_weight(k as u32, c.binds.len() as u32) as f64; rpass.set_blend_constant(wgpu::Color { r: w, g: w, b: w, a: w }); - rpass.set_bind_group(0, bind, &[]); - rpass.draw(0..4, 0..1); + self.draw_layer(&mut rpass, layer, true); } } let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -3072,41 +3385,108 @@ impl Compositor { timestamp_writes: None, occlusion_query_set: None, }); - rpass.set_pipeline(&self.pipeline_copy); - rpass.set_bind_group(0, abind, &[]); - rpass.draw(0..3, 0..1); + // Rien n'a ete peint dans `accum` hors des quads de la trainee : la recopie s'y borne + // au lieu de repasser sur toute la sortie, ce qui coutait 0,5 ms par frame 1080p + // (Radeon 610M) pour un curseur de quelques dizaines de pixels. + let (w, h) = (self.render_w as f32, self.render_h as f32); + let x0 = (c.bounds[0] * w).floor().clamp(0.0, w) as u32; + let y0 = (c.bounds[1] * h).floor().clamp(0.0, h) as u32; + let x1 = (c.bounds[2] * w).ceil().clamp(0.0, w) as u32; + let y1 = (c.bounds[3] * h).ceil().clamp(0.0, h) as u32; + if x1 > x0 && y1 > y0 { + rpass.set_scissor_rect(x0, y0, x1 - x0, y1 - y0); + rpass.set_pipeline(&self.pipeline_copy); + rpass.set_bind_group(0, abind, &[]); + rpass.draw(0..3, 0..1); + } } - self.gpu.context.submit(std::iter::once(encoder.finish())); - Ok(()) - } - - /// Clear le RT a la couleur de fond (ecran absent). - fn clear_rt(&self) -> Result<()> { - let bg = self.live_params.borrow().bg_color; - let mut encoder = self.gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("clear"), - }); - encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("clear-pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.rt_view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: bg[0] as f64, - g: bg[1] as f64, - b: bg[2] as f64, - a: bg[3] as f64, - }), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - }); - self.gpu.context.submit(std::iter::once(encoder.finish())); - Ok(()) + // La camera, au-dessus de l'ecran et du curseur. + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("webcam-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); + if let Some(layer) = &webcam_shadow { + self.draw_layer(&mut rpass, layer, false); + } + // Fond personnalise : ENTRE l'ombre et la camera. C'est ce sandwich qui + // remplace la branche « mode 3 » du shader — la camera, decoupee, se + // fond dessus par alpha ; l'ombre reste dessous, elle appartient a la + // bulle et non a son contenu. + if let Some(bg) = &webcam_bg { + self.draw_layer(&mut rpass, &bg.layer, false); + } + if let Some(layer) = &webcam_draw { + self.draw_layer(&mut rpass, layer, false); + } + } + // Passe 3 : les autres annotations, par-dessus tout le reste (les flous sont passes avant + // le curseur). Elle existe meme sans annotation : deux passes consecutives sur la MEME + // cible avec `LoadOp::Load` ne coutent rien de plus qu'une seule sur un GPU desktop, et un + // seul chemin de code vaut mieux qu'un branchement qui ne serait exerce que dans un projet + // sur dix. + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ann-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); + for a in &ann_draws { + self.draw_layer(&mut rpass, &a.layer, false); + } + } + self.gpu.context.submit(std::iter::once(encoder.finish())); + Ok(()) + } + + /// Clear le RT a la couleur de fond (ecran absent). + fn clear_rt(&self) -> Result<()> { + let bg = self.live_params.borrow().bg_color; + let mut encoder = self.gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("clear"), + }); + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("clear-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg[0] as f64, + g: bg[1] as f64, + b: bg[2] as f64, + a: bg[3] as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + self.gpu.context.submit(std::iter::once(encoder.finish())); + Ok(()) } fn dummy_view(&self) -> wgpu::TextureView { @@ -3829,6 +4209,29 @@ mod tests { assert_eq!(got, pattern, "la memoire exportee ne porte pas ce que wgpu y a ecrit"); } + /// Un cran de padding en format Auto change la taille de rendu de la preview. `resized` + /// realloue les cibles et garde le reste : le masque webcam, et la boite aux lettres ou le + /// worker de segmentation depose les suivants. + #[test] + fn resizing_keeps_the_segmentation_and_reads_back_at_the_new_size() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let (w, h) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + comp.set_webcam_mask(&vec![255u8; (w * h) as usize], w, h).expect("masque"); + let inbox = std::sync::Arc::clone(&comp.seg_inbox); + + let comp = comp.resized(181, 321).expect("resized"); + + assert_eq!(comp.render_size(), (182, 322), "arrondi au pair, comme new_sized"); + assert!(comp.webcam_mask.borrow().is_some(), "le masque webcam s'est perdu"); + assert!( + std::sync::Arc::ptr_eq(&inbox, &comp.seg_inbox), + "le worker deposerait ses masques dans une boite que plus personne ne lit", + ); + let (rw, rh, rgba) = unsafe { comp.readback_direct() }.expect("readback"); + assert_eq!((rw, rh, rgba.len()), (182, 322, 182 * 322 * 4)); + } + /// La disposition NV12 doit etre EXACTEMENT celle que le pilote produit pour /// une image NV12 lineaire, parce que c'est elle qu'on decrira a VAAPI dans /// un `AVDRMFrameDescriptor`. Les valeurs ci-dessous ne sont pas devinees : @@ -3869,7 +4272,7 @@ mod tests { planes: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView), ) -> (u32, u32, Vec) { let dummy = comp.dummy_view(); - let (_buf, bind) = comp.make_bind(cb, Some(planes), &dummy); + let layer = comp.make_bind(cb, Some(planes), &dummy); let mut encoder = comp.gpu.device.create_command_encoder( &wgpu::CommandEncoderDescriptor { label: Some("test-layer") }, ); @@ -3889,8 +4292,7 @@ mod tests { occlusion_query_set: None, }); rpass.set_pipeline(&comp.pipeline); - rpass.set_bind_group(0, &bind, &[]); - rpass.draw(0..4, 0..1); + comp.draw_layer(&mut rpass, &layer, false); } comp.gpu.context.submit(std::iter::once(encoder.finish())); unsafe { comp.readback_direct().expect("readback_direct") } @@ -4009,8 +4411,9 @@ mod tests { let mut encoder = gpu .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("test-blur") }); - comp.blur_pass(&mut encoder, &comp.pipeline_copy, &src_view, &comp.rt_view, [w as f32, h as f32]); - comp.blur_bg(&mut encoder); + comp.blur_pass(&mut encoder, &comp.pipeline_copy, &src_view, &comp.rt_view, [w as f32, h as f32], 0.0); + // 0,5 = l'ancien interrupteur : trois niveaux, ecart 2,2 (`HP` ci-dessous). + comp.blur_bg(&mut encoder, 0.5); gpu.context.submit(std::iter::once(encoder.finish())); let (_, _, px) = unsafe { comp.readback_direct().expect("readback_direct") }; @@ -4381,6 +4784,11 @@ mod tests { shadow: bool, ) -> Vec { let scene = Scene::from_json(&pip_scene_json(effect)).expect("scene json"); + compose_pip_scene(comp, gpu, scene, shadow) + } + + /// `compose_pip` sur une scene deja construite, pour les tests qui la retouchent. + fn compose_pip_scene(comp: &Compositor, gpu: &Gpu, scene: Scene, shadow: bool) -> Vec { comp.set_live_params(live_params_from_scene(&scene)); comp.set_has_webcam(true); comp.set_scene(Some(scene)); @@ -4388,7 +4796,7 @@ mod tests { let screen = FakeFrame::new(gpu, 128, 128, |_, _| 126); let webcam = FakeFrame::new(gpu, 64, 64, |_, _| Y_WHITE); let mut cfg = Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = false; @@ -4488,6 +4896,32 @@ mod tests { ); } + /// Pendant le palier d'une region Full Camera, la camera remplit le cadre. + /// `shape_fade` y vaut 0, ce qui ne doit retirer que la bulle (coins, ombre) : + /// le gate de `webcam_planes` le lisait aussi et ne dessinait plus la webcam + /// du tout, si bien que le plein cadre montrait le fond et l'ecran. + #[test] + fn a_full_camera_hold_fills_the_frame_with_the_camera() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let mut scene = Scene::from_json(&pip_scene_json(NO_EFFECT)).expect("scene json"); + scene.camera_fullscreen_regions.push(crate::scene::SceneCameraFullscreenRegion { + clip_index: None, + start_sec: 2.0, + end_sec: 8.0, + continues_before: false, + continues_after: false, + }); + // 5 s : la montee (~1 s) est finie, le retour (~1,5 s) n'a pas commence. + comp.set_timeline_time(Some(5.0)); + let camera = camera_pixels(&compose_pip_scene(&comp, &gpu, scene, true)); + let frame = 320 * 180; + assert!( + camera > frame * 95 / 100, + "palier Full Camera : {camera} pixels de camera sur {frame}" + ); + } + /// Le tour complet, celui qui a besoin d'ONNX Runtime : capture -> inference /// -> masque -> composite, entraine par `compose_frame` seul. Se saute /// proprement sans la bibliotheque, ce que fait la CI — cf. @@ -4713,12 +5147,23 @@ mod tests { /// Ecran gris sur fond gris moyen, avec ou sans cadre de fenetre (`frame` est insere tel /// quel dans `effects`), droit ou incline (`rotation`, JSON). fn compose_framed(comp: &Compositor, gpu: &Gpu, frame: &str, rotation: &str) -> Vec { + compose_framed_shadow(comp, gpu, frame, rotation, 0.6) + } + + /// `compose_framed`, avec l'ombre de l'ecran `shadow` (0 l'eteint). + fn compose_framed_shadow( + comp: &Compositor, + gpu: &Gpu, + frame: &str, + rotation: &str, + shadow: f32, + ) -> Vec { let json = format!( r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded", "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, - "effects":{{"padding":0.2,"blur":false,"shadow":0.6,"roundnessFrac":0.03,"motionBlur":0{frame}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":{shadow},"roundnessFrac":0.03,"motionBlur":0{frame}}}, "background":{{"kind":"color","color":"#6070a0"}}, "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}], "annotations":[], @@ -4734,7 +5179,7 @@ mod tests { let screen = FakeFrame::new(gpu, 640, 360, |_, _| 60); let webcam = FakeFrame::new(gpu, 64, 64, |_, _| 60); let mut cfg = Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = false; @@ -4749,6 +5194,318 @@ mod tests { } } + /// Ecran sous chrome de fenetre clair, zoome x1,3 de 1 s a 6 s sous `rotation` (JSON), a + /// l'instant `t` de la timeline, avec le flou de mouvement `blur` (0..1). + fn compose_zooming_window(comp: &Compositor, gpu: &Gpu, rotation: &str, t: f32, blur: f32) -> Vec { + let json = format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.3,"blur":false,"shadow":0.6,"roundnessFrac":0.03,"motionBlur":{blur},"frame":"window-light"}}, + "background":{{"kind":"color","color":"#6070a0"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":1,"endSec":6,"scale":1.3,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}], + "annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[null], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ); + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + let screen = FakeFrame::new(gpu, 640, 360, |_, _| 60); + let webcam = FakeFrame::new(gpu, 64, 64, |_, _| 60); + let mut cfg = Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.cursor = false; + cfg.mblur_n = 1; + cfg.shadow = true; + unsafe { + comp.set_timeline_time(Some(t)); + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + let (_, _, rgba) = comp.readback_direct().expect("readback_direct"); + rgba + } + } + + /// Le flou de mouvement prend l'ecran CADRE en bloc, droit comme incline : pendant la rampe + /// du zoom, la barre de titre claire file avec le metrage au lieu de rester nette (elle perd + /// des pixels francs, etales sur le fond et l'ecran). Immobile, au palier du zoom comme + /// apres lui, le rendu flou est celui du rendu net, a l'octet. + #[test] + fn the_window_frame_trails_with_the_screen_under_motion_blur() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let bright = |rgba: &[u8]| { + rgba.chunks_exact(4).filter(|p| p[0] > 215 && p[1] > 215 && p[2] > 215).count() + }; + let out_dir = std::env::var("OPENSCREEN_FRAME_OUT").ok(); + for (label, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { + for still in [3.5, 9.0] { + assert!( + compose_zooming_window(&comp, &gpu, rotation, still, 0.0) + == compose_zooming_window(&comp, &gpu, rotation, still, 1.0), + "{label} a t={still} : immobile, le flou ne doit rien changer" + ); + } + let mut best = (0.0f32, 0.0f32); + // La rampe de sortie du zoom, ~6,0 s -> 6,8 s. + for k in 0..=9 { + let t = 5.9 + k as f32 * 0.1; + let sharp = compose_zooming_window(&comp, &gpu, rotation, t, 0.0); + let blurred = compose_zooming_window(&comp, &gpu, rotation, t, 1.0); + // Encore zoome, la barre est hors champ : rien a mesurer a cet instant. + if bright(&sharp) < 1000 { + continue; + } + let drop = 1.0 - bright(&blurred) as f32 / bright(&sharp) as f32; + if drop > best.0 { + best = (drop, t); + if let Some(dir) = &out_dir { + for (name, rgba) in [("sharp", sharp), ("blurred", blurred)] { + let path = format!("{dir}/linux-trail-{label}-{name}.png"); + image::RgbaImage::from_raw(1280, 720, rgba) + .expect("dimensions du readback") + .save(&path) + .unwrap_or_else(|e| panic!("ecriture {path} : {e}")); + } + } + } + } + eprintln!("{label} : barre de titre -{:.1} % de pixels francs a t={}", best.0 * 100.0, best.1); + let best = best.0; + assert!(best > 0.05, "{label} : la barre de titre reste nette pendant le zoom (au mieux {best:.3})"); + } + } + + /// Un seul ecran dans `rect` (fractions de la sortie `out`), zoome x`scale` sous `iso` de 2 a + /// 6 s, fond uni sans ombre ni padding. Meme scene que `tests/screen_trail_render.rs` (D3D11). + fn trail_scene_json(rect: [f32; 4], out: (u32, u32), scale: f32, blur: f32, dof: bool) -> String { + let [x, y, w, h] = rect; + let (ow, oh) = out; + format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":{x},"y":{y},"width":{w},"height":{h}}}}}, + "effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0.02,"motionBlur":{blur},"depthOfField":{dof}}}, + "background":{{"kind":"color","color":"#6070a0"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":2,"endSec":6,"scale":{scale},"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"iso"}}], + "annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip":[null], + "output":{{"width":{ow},"height":{oh},"fps":30}}}}"## + ) + } + + fn compose_trail(comp: &Compositor, screen: &FakeFrame, json: &str, t: f32) -> Vec { + let scene = Scene::from_json(json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + let mut cfg = Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.mblur_n = 1; + cfg.shadow = false; + unsafe { + comp.set_timeline_time(Some(t)); + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + } + } + + /// Un tap du mode 18 dont le warp inverse n'a PAS de solution (au-dela du pli du warp + /// bilineaire d'`iso`) est ecarte : il valait l'origine du plan, renvoyee sur le coin + /// haut-gauche de l'ecran courant, et le fond loin de l'ecran prenait la couleur de ce coin + /// (un repere masque par l'arrondi y reparaissait). Petit ecran centre (15 %), zoom x1,6 : + /// loin de l'ecran, le rendu floute reste le fond, au bit pres. Pendant D3D11 : + /// `tests/screen_trail_render.rs`. + #[test] + fn unsolvable_trail_taps_leave_the_background_alone() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + // Metrage rouge, repere vert dans son coin haut-gauche. + let (w, h) = (640u32, 360u32); + let marker = |col: u32, row: u32| col < 48 && row < 48; + let y: Vec = (0..w * h).map(|i| if marker(i % w, i / w) { 173 } else { 63 }).collect(); + let uv: Vec = (0..w * (h / 2)) + .map(|i| { + let [u, v] = if marker(i % w & !1, i / w * 2) { [42, 26] } else { [102, 240] }; + if i % 2 == 0 { u } else { v } + }) + .collect(); + let screen = FakeFrame::from_planes(&gpu, w, h, &y, &uv); + let json = |blur| trail_scene_json([0.425, 0.425, 0.15, 0.15], (1280, 720), 1.6, blur, false); + let far = |x: u32, y: u32| !(320..960).contains(&x) || !(180..540).contains(&y); + let mut leaks = 0; + for t in [1.5, 1.7, 1.9, 6.1] { + let sharp = compose_trail(&comp, &screen, &json(0.0), t); + let blurred = compose_trail(&comp, &screen, &json(1.0), t); + let n = (0..1280 * 720u32) + .filter(|&i| far(i % 1280, i / 1280)) + .filter(|&i| sharp[i as usize * 4..][..3] != blurred[i as usize * 4..][..3]) + .count(); + eprintln!("t={t} : {n} px du fond changent avec le flou"); + leaks += n; + } + assert_eq!(leaks, 0, "le flou de mouvement a peint loin de l'ecran"); + } + + /// Hors de la sortie, le mode 18 relit le metrage comme le mode 8 l'a dessine, profondeur de + /// champ comprise : le meme ecran physique, zoome x2 sous `iso`, rendu sur la sortie et au + /// centre d'une sortie deux fois plus grande (ou rien ne sort du cadre), coincide aux bords de + /// la sortie. Le repli lisait le metrage NET. Pendant D3D11 : `tests/screen_trail_render.rs`. + #[test] + fn off_canvas_trail_taps_keep_the_depth_of_field() { + let Some(gpu) = gpu() else { return }; + let (w, h) = (640u32, 360u32); + // Rayures noires et blanches de 2 px : la profondeur de champ les grise. + let screen = FakeFrame::new(&gpu, w, h, |col, _| if col / 2 % 2 == 0 { Y_BLACK } else { Y_WHITE }); + let (ow, oh) = (1280u32, 720u32); + let normal = Compositor::new_sized(&gpu, ow, oh).expect("Compositor::new_sized"); + let padded = Compositor::new_sized(&gpu, 2 * ow, 2 * oh).expect("Compositor::new_sized"); + let json = |k: f32, dof| { + let r = [0.5 - 0.4 / k, 0.5 - 0.4 / k, 0.8 / k, 0.8 / k]; + trail_scene_json(r, ((ow as f32 * k) as u32, (oh as f32 * k) as u32), 2.0, 1.0, dof) + }; + let edges: Vec<(u32, u32)> = + (oh / 4..oh * 3 / 4).flat_map(|y| (0..24).chain(ow - 24..ow).map(move |x| (x, y))).collect(); + let at = |rgba: &[u8], w: u32, (x, y): (u32, u32)| rgba[((y * w + x) * 4) as usize]; + for t in [1.5, 1.7] { + let a = compose_trail(&normal, &screen, &json(1.0, true), t); + let b = compose_trail(&padded, &screen, &json(2.0, true), t); + let net = compose_trail(&normal, &screen, &json(1.0, false), t); + let crop = |(x, y): (u32, u32)| (x + ow / 2, y + oh / 2); + let diffs: Vec = edges.iter().map(|&p| at(&a, ow, p).abs_diff(at(&b, 2 * ow, crop(p)))).collect(); + let mean = diffs.iter().map(|&d| d as f32).sum::() / diffs.len() as f32; + let max = diffs.iter().copied().max().unwrap_or(0); + let dof = edges.iter().map(|&p| at(&a, ow, p).abs_diff(at(&net, ow, p))).max().unwrap_or(0); + // La pyramide est bien liee : elle grise les rayures sans les assombrir. + let level = |rgba: &[u8]| edges.iter().map(|&p| at(rgba, ow, p) as f32).sum::() / edges.len() as f32; + let (lit, flat) = (level(&a), level(&net)); + eprintln!("t={t} : bord de la sortie, ecart moyen {mean:.2}, max {max} (profondeur de champ : {dof}, niveau {lit:.1} / {flat:.1})"); + assert!(dof > 60, "t={t} : la profondeur de champ n'agit pas au bord, le test ne prouve rien"); + assert!((lit - flat).abs() < 4.0, "t={t} : la profondeur de champ assombrit le bord ({lit:.1} / {flat:.1})"); + assert!(mean < 1.0 && max <= 16, "t={t} : le bord perd la profondeur de champ ({mean:.2}, {max})"); + } + } + + /// Un degrade de fond est opaque et montre chacun de ses stops, meme quand le premier est a + /// 0. `gradient_layer` range la position du premier stop dans `color.a` ; la queue commune + /// du WGSL la prenait pour une opacite, et le fond disparaissait sous la couleur de clear + /// (#807). Rouge / vert / bleu a 0, 0.5, 1 de gauche a droite : chaque tiers doit porter sa + /// couleur, pas le noir du clear. + #[test] + fn a_gradient_whose_first_stop_is_at_zero_stays_opaque() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let json = r##"{"clips":[{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}], + "layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{"x":0.3,"y":0.3,"width":0.4,"height":0.4}}, + "effects":{"padding":0.4,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}, + "background":{"kind":"gradient","angleDeg":90, + "stops":["rgb(255, 0, 0)","rgb(0, 255, 0)","rgb(0, 0, 255)"], + "offsets":[0,0.5,1]}, + "zoomRegions":[],"annotations":[], + "cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}, + "cropByClip":[null], + "output":{"width":1280,"height":720,"fps":30}}"##; + let scene = Scene::from_json(json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + let screen = FakeFrame::new(&gpu, 640, 360, |_, _| 60); + let webcam = FakeFrame::new(&gpu, 64, 64, |_, _| 60); + let mut cfg = Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.shadow = false; + cfg.mblur_n = 1; + let rgba = unsafe { + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + }; + // Rangee du haut, hors de l'ecran : que du fond. + let px = |x: usize| { + let i = (4 * 1280 + x) * 4; + [rgba[i], rgba[i + 1], rgba[i + 2]] + }; + let (left, mid, right) = (px(8), px(640), px(1271)); + assert!(left[0] > 200 && left[1] < 60 && left[2] < 60, "gauche {left:?} : rouge attendu"); + assert!(mid[1] > 200 && mid[0] < 60 && mid[2] < 60, "milieu {mid:?} : vert attendu"); + assert!(right[2] > 200 && right[0] < 60 && right[1] < 60, "droite {right:?} : bleu attendu"); + } + + /// Le fond fixe est recopie d'une frame a l'autre (`bg_cache`) au lieu d'etre redessine, et + /// un fond qui change ne laisse rien du precedent : un compositeur qui a rendu A, A, B puis A + /// rend chacun a l'octet comme un compositeur neuf. Un fond anime n'est jamais repris. + #[test] + fn a_static_background_is_reused_and_a_changed_one_redrawn() { + let Some(gpu) = gpu() else { return }; + let screen = FakeFrame::new(&gpu, 640, 360, |_, _| 60); + let webcam = FakeFrame::new(&gpu, 64, 64, |_, _| 60); + let render = |comp: &Compositor, background: &str| { + let json = format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.3,"y":0.3,"width":0.4,"height":0.4}}}}, + "effects":{{"padding":0.4,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}}, + "background":{background}, + "zoomRegions":[],"annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[null], + "output":{{"width":640,"height":360,"fps":30}}}}"## + ); + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + let mut cfg = Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.shadow = false; + cfg.mblur_n = 1; + unsafe { + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + } + }; + let fresh = || Compositor::new_sized(&gpu, 640, 360).expect("Compositor::new_sized"); + let a = r##"{"kind":"gradient","angleDeg":90,"stops":["rgb(255, 0, 0)","rgb(0, 0, 255)"],"offsets":[0,1]}"##; + let b = r##"{"kind":"color","color":"#00ff00"}"##; + + let comp = fresh(); + let a1 = render(&comp, a); + assert!(comp.bg_cache.borrow().is_some(), "un fond fixe doit etre garde"); + let a2 = render(&comp, a); + let b1 = render(&comp, b); + let a3 = render(&comp, a); + assert!(a1 == a2, "le fond recopie doit etre celui qui a ete dessine"); + assert!(b1 == render(&fresh(), b), "un autre fond ne doit rien garder du precedent"); + assert!(a3 == a1, "revenir au premier fond doit le redessiner a l'identique"); + assert!(a1 != b1, "garde : les deux fonds different"); + + let aurora = r##"{"kind":"gradient","angleDeg":90,"stops":["rgb(255, 0, 0)","rgb(0, 0, 255)"],"offsets":[0,1],"motion":"aurora"}"##; + let comp = fresh(); + render(&comp, aurora); + assert!(comp.bg_cache.borrow().is_none(), "un fond anime ne doit pas etre garde"); + } + /// Le cadre de fenetre (mode 14) se dessine sur Linux comme ailleurs : `"none"` rend /// l'image d'avant a l'octet, le theme clair ajoute une barre claire, droite comme /// inclinee, et les deux themes different. `OPENSCREEN_FRAME_OUT` recoit les PNG. @@ -4859,6 +5616,22 @@ mod tests { } } + /// L'ombre d'un appareil est un modele elle aussi (mode 17, `device_shadow_cb`) : comme + /// l'appareil, elle n'existe que dans `pipeline_models`, et le pipeline plat n'en peindrait + /// rien. Elle doit donc changer l'image, a plat comme incline. + #[test] + fn a_device_frame_casts_its_shadow() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let laptop = r#","frame":"laptop""#; + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { + let lit = compose_framed_shadow(&comp, &gpu, laptop, rotation, 0.6); + let bare = compose_framed_shadow(&comp, &gpu, laptop, rotation, 0.0); + let shaded = lit.chunks_exact(4).zip(bare.chunks_exact(4)).filter(|(a, b)| a != b).count(); + assert!(shaded > 2_000, "{name} : l'ombre de l'appareil ne change que {shaded} px"); + } + } + // ----------------------------------------------------------------------- // Curseur modélisé (mode 15) : pendant de `tests/cursor_model_render.rs` (Windows). // ----------------------------------------------------------------------- @@ -4866,11 +5639,11 @@ mod tests { /// Les états que le rendu passe en revue (hotspots de `DEFAULT_CURSOR_SPRITES`), et s'ils /// sont centrés : ni tangage ni lacet. const MODEL_STATES: [(&str, [f32; 2], bool); 6] = [ - ("arrow", [0.119, 0.0874], false), - ("pointer", [0.3893, 0.0032], false), - ("text", [0.4375, 0.5333], true), - ("open-hand", [0.4375, 0.1781], false), - ("resize-ew", [0.4881, 0.4706], true), + ("arrow", [0.1205, 0.0881], false), + ("pointer", [0.3874, 0.0032], false), + ("text", [0.4355, 0.5369], true), + ("open-hand", [0.4375, 0.1724], false), + ("resize-ew", [0.485, 0.4706], true), ("not-allowed", [0.5, 0.5], true), ]; @@ -4890,7 +5663,7 @@ mod tests { "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, "effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0.03,"motionBlur":0}}, "background":{{"kind":"gradient","angleDeg":135,"stops":["#5b6ee1","#e8a0bf"]}}, - "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":{rotation}}}], + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}], "annotations":[], "cursor":{{"show":{show},"size":{size},"smoothing":0,"motionBlur":0,"clickBounce":2.5{model3d},"clipToBounds":false,"theme":"{theme}", "cursorSprites":{{{sprites}}}}}, @@ -4928,16 +5701,52 @@ mod tests { comp.set_cursor(track.clone()); comp.set_cursor_time(Some(2.0)); comp.set_timeline_time(Some(2.0)); + let cfg = model_cfg(); + unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + } + } + + fn model_cfg() -> crate::config::Cfg { let mut cfg = crate::config::Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = true; cfg.mblur_n = 1; cfg.shadow = false; - unsafe { - comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); - comp.readback_direct().expect("readback_direct").2 + cfg + } + + /// Le pixel du hotspot et l'unité du modèle (px) que `plan_cursor` donne à la scène que + /// `compose_model` rend. + fn model_tip(json: &str, track: &crate::cursor::CursorTrack) -> ([f32; 2], f32) { + let scene = crate::scene::Scene::from_json(json).expect("scene json"); + let (cfg, live, render_px, src) = (model_cfg(), live_params_from_scene(&scene), [1280.0, 720.0], [640.0, 360.0]); + let g = plan_frame(&FrameGeometryInput { + render_px, + screen_tex_px: src, + screen_visible_px: src, + webcam_visible_px: src, + u_max: 1.0, + v_max: 1.0, + frame: 0.0, + cfg: &cfg, + live, + scene: Some(&scene), + cursor: Some(track), + timeline_t_override: Some(2.0), + programme_time: None, + }); + let input = CursorPlanInput { render_px, u_max: 1.0, v_max: 1.0, cfg: &cfg, live, scene: Some(&scene), track, t: 2.0 }; + let plan = plan_cursor(&g, &input).expect("un curseur a dessiner"); + match plan.placement { + CursorPlacement::Tilted { plane_pt, quad, center_px, .. } => { + let (x, y) = quad.point_px(plane_pt[0], plane_pt[1]); + ([center_px[0] + x, center_px[1] + y], plan.size_px * quad.scale) + } + CursorPlacement::Upright { center } => ([center[0] * render_px[0], center[1] * render_px[1]], plan.size_px), } } @@ -5033,6 +5842,22 @@ mod tests { .collect() } + /// Les pixels que couvre un curseur de VERRE (le cristal de Prism Glow, qui laisse voir le + /// contenu et le réfracte) : opaques, ou changés sur les deux teintes sans y être plus sombres + /// (une ombre ne fait qu'assombrir ; le verre réfracte, reflète et luit). + fn model_covered(on_blue: &[u8], on_orange: &[u8], bare: &[u8], bare_orange: &[u8]) -> Vec { + let opaque = model_opaque(on_blue, on_orange, bare); + (0..1280 * 720) + .map(|i| { + let lit = |on: &[u8], under: &[u8]| { + let (a, b) = (&on[i * 4..i * 4 + 3], &under[i * 4..i * 4 + 3]); + a != b && model_luma(a) >= model_luma(b) - 2.0 + }; + opaque[i] || (lit(on_blue, bare) && lit(on_orange, bare_orange)) + }) + .collect() + } + fn model_iou(a: &[bool], b: &[bool]) -> f32 { let inter = a.iter().zip(b).filter(|(x, y)| **x && **y).count(); let union = a.iter().zip(b).filter(|(x, y)| **x || **y).count(); @@ -5085,8 +5910,9 @@ mod tests { let touch = compose_model(&comp, &screen, &json(Some(true), "default", true), &clicked); model_save(&format!("{name}-hover"), &hover); model_save(&format!("{name}-touch"), &touch); - assert!(absent == off && absent == other, "{name}: le chemin plat a change"); + assert!(absent == off, "{name}: le chemin plat a change"); assert!(absent != hover, "{name}: le reglage allume ne change rien"); + assert!(other == hover, "{name}: le libelle du theme a change le rendu natif"); let (a, b) = (model_split(&hover, &bare), model_split(&touch, &bare)); println!( "{name} : blanc {}, noir {}, ombre {} (apex a {:.1} px, posee {:.1} px), centroides {:?} / {:?}", @@ -5101,9 +5927,37 @@ mod tests { } } + /// La trainee du curseur modelise s'accumule par `pipeline_add_models` : le pipeline plat ne + /// compile pas le mode 15 et n'en peindrait rien. En mouvement, flou du curseur au maximum, + /// le modele doit donc bien apparaitre. + #[test] + fn the_modelled_cursor_draws_its_trail() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let screen = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + // Traverse 60 % de la largeur en une seconde autour de t = 2 s : ~13 px par frame, donc + // plusieurs copies dans `accum`. + let moving = crate::cursor::CursorTrack::new( + vec![(1.5, 0.2, 0.45), (2.5, 0.8, 0.45)], + vec![], + vec![(0.0, "arrow".to_string())], + ); + let json = |show: bool| { + model_scene_json("null", Some(true), "default", show, 3.0) + .replace(r#""motionBlur":0,"clickBounce""#, r#""motionBlur":1,"clickBounce""#) + }; + assert!(json(true).contains(r#""motionBlur":1,"clickBounce""#), "garde : le flou du curseur"); + let bare = compose_model(&comp, &screen, &json(false), &moving); + let trail = compose_model(&comp, &screen, &json(true), &moving); + let drawn = bare.chunks_exact(4).zip(trail.chunks_exact(4)).filter(|(a, b)| a != b).count(); + // Taille 3 : ~1000 px de silhouette a l'arret, etales ici par la trainee. + assert!(drawn > 1000, "trainee du curseur modelise : {drawn} px dessines"); + } + /// Chaque état garde son art et sa silhouette : posé au centre de l'écran (vu de face), le /// modèle couvre ce que couvre le sprite plat, avec ses couleurs ; il porte une ombre en - /// l'air, suit l'inclinaison du plan, et un autre thème (ou le réglage éteint) reste plat. + /// l'air et suit l'inclinaison du plan. Le réglage éteint garde le sprite plat. #[test] fn every_modelled_state_keeps_its_art_footprint_and_shadow() { let Some(gpu) = gpu() else { return }; @@ -5122,16 +5976,27 @@ mod tests { let touch = compose_model(&comp, &blue, &on, &clicked); let touch_b = compose_model(&comp, &orange, &on, &clicked); let hover = compose_model(&comp, &blue, &on, &still); - let tilted = compose_model(&comp, &blue, &json(r#""iso""#, Some(true), "default", true), &still); + let iso = json(r#""iso""#, Some(true), "default", true); + let tilted = compose_model(&comp, &blue, &iso, &still); + // Le modèle incliné posé en `x` sur la ligne du focus : le préset iso (`left`) amène le + // bord droit du plan vers la caméra. + let across = |x: f32| { + let track = crate::cursor::CursorTrack::new(vec![(0.0, x, 0.5), (9.0, x, 0.5)], vec![], vec![(0.0, key.to_string())]); + compose_model(&comp, &blue, &iso, &track) + }; + let (near, far) = (across(0.8), across(0.2)); let sprite = compose_model(&comp, &blue, &flat, &still); let sprite_b = compose_model(&comp, &orange, &flat, &still); let absent = compose_model(&comp, &blue, &json("null", None, "default", true), &still); let other = compose_model(&comp, &blue, &json("null", Some(true), "other", true), &still); model_save(&format!("art-{key}-touch"), &touch); model_save(&format!("art-{key}-iso"), &tilted); - if absent != sprite || other != sprite { + if absent != sprite { failures.push(format!("{key}: le sprite plat a change")); } + if other != hover { + failures.push(format!("{key}: le libelle du theme a change le rendu natif")); + } let (m3d, m2d) = (model_opaque(&touch, &touch_b, &bare), model_opaque(&sprite, &sprite_b, &bare)); let overlap = model_iou(&m3d, &m2d); @@ -5147,9 +6012,10 @@ mod tests { .count() as f32; let body = |rgba: &[u8]| (0..1280 * 720).filter(|&i| model_neutral(&rgba[i * 4..i * 4 + 3]) && differs(rgba, i)).count() as f32; let (flat_body, tilted_body) = (body(&hover), body(&tilted)); + let (near_body, far_body) = (body(&near), body(&far)); println!( "{key} : IoU {overlap:.3}, a 2 px pres {near3d:.3} / {near2d:.3}, sprite {area} px, palette 3D {pal3d:?} / sprite {pal2d:?}, \ - ombre {shadow} px, corps a plat {flat_body} / incline {tilted_body}" + ombre {shadow} px, corps a plat {flat_body} / incline {tilted_body}, proche {near_body} / lointain {far_body}" ); let (min_iou, min_near, palette_tol) = if centred { (0.75, 0.98, 0.07) } else { (0.6, 0.9, 0.2) }; if overlap <= min_iou || near3d < min_near || near2d < min_near { @@ -5163,9 +6029,273 @@ mod tests { if shadow <= 0.3 * area { failures.push(format!("{key}: pas d'ombre en l'air ({shadow} px)")); } - // Le préset iso réduit le plan (unité 51,5 contre 62,6 px) et incline le modèle. - if !(tilted_body < 0.9 * flat_body && tilted_body > 0.3 * flat_body) { - failures.push(format!("{key}: le modele ne suit pas le plan ({tilted_body} / {flat_body})")); + // Au focus du zoom, l'angle fixe garde au modèle sa taille à plat (`plan_cursor`) ; + // ailleurs, le plan l'emporte avec lui : sa perspective le grossit du côté proche et le + // réduit du côté lointain. Mesuré : ×1,5 de l'un à l'autre ; un modèle resté de face sur + // le même plan n'y gagne que ×1,0 à 1,1. + if !((0.85..1.15).contains(&(tilted_body / flat_body)) && near_body > 1.25 * far_body) { + failures.push(format!( + "{key}: le modele ne suit pas le plan ({tilted_body} / {flat_body}, proche {near_body} / lointain {far_body})" + )); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Pendant de `the_sculpted_cursors_stand_at_the_hotspot` (Windows) : la flèche et la main + /// sculptées des thèmes d'origine (`sculpt.rs`) passent par le WGSL, tiennent au hotspot, en + /// bas à droite de lui, et portent leur ombre en l'air. Le cristal de Prism Glow est de verre : + /// son corps compte ce qu'il couvre, et une bonne part de lui change avec la teinte de l'écran. + #[test] + fn the_sculpted_cursors_stand_at_the_hotspot() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let blue = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let (y, uv) = model_screen_planes(true); + let orange = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let extruded = model_scene_json("null", Some(true), "default", true, 5.0); + let hidden = model_scene_json("null", Some(true), "default", false, 5.0); + let bare = compose_model(&comp, &blue, &hidden, &model_track("arrow", false, 0.5)); + let bare_orange = compose_model(&comp, &orange, &hidden, &model_track("arrow", false, 0.5)); + let mut failures = Vec::new(); + for state in ["arrow", "pointer"] { + let still = model_track(state, false, 0.5); + let sprite = compose_model(&comp, &blue, &extruded, &still); + for theme in ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout", "glass-lens"] { + // Le sprite reste celui du theme par defaut : seul le nom pose le modele. + let json = extruded + .replace(&format!(r#"/{state}.png","#), &format!(r#"/{state}.png","sculpt":"{theme}/{state}","#)); + let (hover, hover_b) = (compose_model(&comp, &blue, &json, &still), compose_model(&comp, &orange, &json, &still)); + model_save(&format!("sculpt-{theme}-{state}"), &hover); + if hover == sprite { + failures.push(format!("{theme}/{state}: le sprite extrude au lieu du modele")); + } + let (tip, u) = model_tip(&json, &still); + let glass = matches!(theme, "prism-glow" | "glass-lens"); + let mask = if glass { + model_covered(&hover, &hover_b, &bare, &bare_orange) + } else { + model_opaque(&hover, &hover_b, &bare) + }; + let (mut body, mut c, mut near) = (0usize, [0.0f32; 2], f32::MAX); + for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) { + let (x, y) = ((i % 1280) as f32, (i / 1280) as f32); + body += 1; + c = [c[0] + x, c[1] + y]; + near = near.min((x - tip[0]).hypot(y - tip[1])); + } + let c = [c[0] / body.max(1) as f32, c[1] / body.max(1) as f32]; + let shadow = (0..1280 * 720) + .filter(|&i| { + let (a, b) = (&hover[i * 4..i * 4 + 3], &bare[i * 4..i * 4 + 3]); + !mask[i] && a != b && model_luma(a) < model_luma(b) - 6.0 + }) + .count(); + println!("{theme}/{state} : unite {u:.1} px, corps {body} px, a {near:.1} px du hotspot {tip:?}, centroide {c:?}, ombre {shadow} px"); + if (body as f32) < 0.12 * u * u { + failures.push(format!("{theme}/{state}: {body} px de corps pour {u:.0} px d'unite")); + } + if near > 0.08 * u { + failures.push(format!("{theme}/{state}: le modele est a {near:.1} px du hotspot")); + } + if !(c[0] > tip[0] && c[1] > tip[1] + 0.2 * u) { + failures.push(format!("{theme}/{state}: corps en {c:?}, pas en bas a droite de {tip:?}")); + } + if shadow * 10 < body * 3 { + failures.push(format!("{theme}/{state}: pas d'ombre en l'air ({shadow} px)")); + } + if glass { + let through = (0..1280 * 720).filter(|&i| mask[i] && hover[i * 4..i * 4 + 3] != hover_b[i * 4..i * 4 + 3]).count(); + println!("{theme}/{state} : {through} px du corps laissent voir l'ecran"); + if through * 10 < body * 3 { + failures.push(format!("{theme}/{state}: le cristal ne laisse voir l'ecran que sur {through} px")); + } + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Pendant de `the_flat_glass_lens_refracts_the_picture` (Windows) : Glass Lens, 3D éteinte, + /// passe par le verre à plat du WGSL (mode 19), sans ombre, si bien que tout pixel changé est + /// le sien. Il tient au hotspot, en bas à droite de lui ; l'écran se voit au travers ; le trait + /// le cerne ; et le verre déplace les barres de l'écran. + #[test] + fn the_flat_glass_lens_refracts_the_picture() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let blue = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let (y, uv) = model_screen_planes(true); + let orange = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let flat = model_scene_json("null", Some(false), "glass-lens", true, 5.0); + let hidden = model_scene_json("null", Some(false), "glass-lens", false, 5.0); + let bare = compose_model(&comp, &blue, &hidden, &model_track("arrow", false, 0.5)); + let bare_orange = compose_model(&comp, &orange, &hidden, &model_track("arrow", false, 0.5)); + let mut failures = Vec::new(); + for state in ["arrow", "pointer"] { + let still = model_track(state, false, 0.5); + let sprite = compose_model(&comp, &blue, &flat, &still); + let json = flat.replace(&format!(r#"/{state}.png","#), &format!(r#"/{state}.png","glass":"glass-lens/{state}","#)); + let (on_blue, on_orange) = (compose_model(&comp, &blue, &json, &still), compose_model(&comp, &orange, &json, &still)); + model_save(&format!("glass-flat-{state}"), &on_blue); + if on_blue == sprite { + failures.push(format!("glass-lens/{state}: le sprite plat au lieu du verre")); + } + let (tip, u) = model_tip(&json, &still); + let rgb = |img: &[u8], i: usize| [img[i * 4], img[i * 4 + 1], img[i * 4 + 2]]; + let (mut body, mut c, mut near) = (0usize, [0.0f32; 2], f32::MAX); + let (mut through, mut line, mut moved) = (0usize, 0usize, 0usize); + for i in 0..1280 * 720 { + let (a, b) = (rgb(&on_blue, i), rgb(&on_orange, i)); + if a == rgb(&bare, i) && b == rgb(&bare_orange, i) { + continue; + } + let (x, y) = ((i % 1280) as f32, (i / 1280) as f32); + body += 1; + c = [c[0] + x, c[1] + y]; + near = near.min((x - tip[0]).hypot(y - tip[1])); + if a != b { + through += 1; + moved += ((model_luma(&a) - model_luma(&rgb(&bare, i))).abs() > 25.0) as usize; + } else if model_luma(&a) < 60.0 { + line += 1; + } + } + let c = [c[0] / body.max(1) as f32, c[1] / body.max(1) as f32]; + println!( + "glass-lens/{state} a plat : unite {u:.1} px, corps {body} px, a {near:.1} px du hotspot {tip:?}, \ + centroide {c:?}, {through} px laissent voir l'ecran, {line} px de trait, {moved} px deplaces" + ); + if (body as f32) < 0.12 * u * u { + failures.push(format!("glass-lens/{state}: {body} px de corps pour {u:.0} px d'unite")); + } + if near > 0.08 * u { + failures.push(format!("glass-lens/{state}: le verre est a {near:.1} px du hotspot")); + } + if !(c[0] > tip[0] && c[1] > tip[1] + 0.2 * u) { + failures.push(format!("glass-lens/{state}: corps en {c:?}, pas en bas a droite de {tip:?}")); + } + if through * 10 < body * 5 { + failures.push(format!("glass-lens/{state}: l'ecran ne se voit que sur {through} px")); + } + if line * 20 < body { + failures.push(format!("glass-lens/{state}: {line} px de trait seulement")); + } + if moved * 50 < through { + failures.push(format!("glass-lens/{state}: le verre ne deplace l'ecran que sur {moved} px")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Le métrage, puis ses flous de confidentialité, puis le curseur, net par-dessus (parité + /// Windows et macOS) : le cristal de Prism Glow réfracte l'image déjà floutée. Sous le flou, + /// des rayures fines décalées d'une colonne donnent la même image floutée ; si le verre lisait + /// la vidéo brute, il montrerait les rayures, et les deux rendus différeraient à travers lui. + #[test] + fn the_crystal_refracts_the_blurred_picture() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + // Moitié gauche grise, moitié droite (la zone floutée) rayée une colonne sur deux. + let (w, h) = (640u32, 360u32); + let stripes = |phase: u32| { + let y: Vec = (0..w * h) + .map(|i| if i % w < w / 2 { 150 } else if (i % w + phase) % 2 == 0 { 40 } else { 220 }) + .collect(); + FakeFrame::from_planes(&gpu, w, h, &y, &vec![128; (w * (h / 2)) as usize]) + }; + let (a, b) = (stripes(0), stripes(1)); + let blur = r#""annotations":[{"id":"b","startSec":0,"endSec":10,"kind":"blur","x":0.5,"y":0.0,"w":0.5,"h":1.0,"blur":{"style":"blur","shape":"rectangle","color":"white","intensity":24,"blockSize":16}}]"#; + let json = |show: bool| { + model_scene_json("null", Some(true), "default", show, 5.0) + .replace(r#"/arrow.png","#, r#"/arrow.png","sculpt":"prism-glow/arrow","#) + .replace(r#""annotations":[]"#, blur) + }; + let mut failures = Vec::new(); + // Tout entier dans la zone, puis à cheval sur son bord. + for x in [0.7f32, 0.48] { + let track = crate::cursor::CursorTrack::new(vec![(0.0, x, 0.45), (9.0, x, 0.45)], vec![], vec![(0.0, "arrow".to_string())]); + let (ra, rb) = (compose_model(&comp, &a, &json(true), &track), compose_model(&comp, &b, &json(true), &track)); + let bare = compose_model(&comp, &a, &json(false), &track); + model_save(&format!("glass-over-blur-{x}"), &ra); + let px = |img: &[u8], i: usize, c: usize| img[i * 4 + c] as i32; + let hidden = (0..1280 * 720).filter(|&i| (0..3).any(|c| (px(&ra, i, c) - px(&rb, i, c)).abs() > 24)).count(); + // Le serti net du curseur : des sauts francs, entre voisins, de ce qu'il change. + let d = |i: usize, c: usize| px(&ra, i, c) - px(&bare, i, c); + let sharp = (0..1280 * 720 - 1) + .filter(|&i| i % 1280 < 1279 && (0..3).any(|c| (d(i, c) - d(i + 1, c)).abs() > 60)) + .count(); + println!("cristal en x = {x} : {sharp} bords francs, {hidden} px qui suivent les rayures cachées"); + if sharp < 50 { + failures.push(format!("x = {x} : {sharp} bords francs, le curseur n'est pas net par-dessus le flou")); + } + if hidden > 0 { + failures.push(format!("x = {x} : {hidden} px montrent les rayures cachées sous le flou")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Les thèmes cerclés gardent le trait de leur dessin (`design/cursors/`) : sur la + /// fleche comme sur la main, le modele est pour une bonne part de la couleur du trait (le + /// plateau, le jonc, les rainures entre les doigts), autour de la couleur du corps, et montre + /// ce qu'il porte devant : l'etoile jaune de Star Sprout, le calque et les tirets jaunes de la + /// fleche de Pop Coral, les tirets corail de sa main. Les parts comptent les pixels francs : + /// ceux que l'antialiasing mêle aux bords n'entrent dans aucune. + #[test] + fn the_rimmed_models_keep_the_outline_of_their_art() { + type Rgb = [i32; 3]; + let navy = |[r, g, b]: Rgb| b > r + 20 && r < 110 && g < 130; + let black = |[r, g, b]: Rgb| r.max(g).max(b) < 70; + let mint = |[r, g, b]: Rgb| g > 200 && g > r + 15 && b > 150; + let ivory = |[r, g, b]: Rgb| r > 200 && g > 190 && b > 160 && r - b < 70; + let coral = |[r, g, b]: Rgb| r > 200 && g < 150 && b < 140; + let yellow = |[r, g, b]: Rgb| r > 200 && g > 150 && b < 120; + // (thème, état, trait, corps, ornement, parts minimales du trait, du corps, de l'ornement) + let cases: [(&str, &str, &dyn Fn(Rgb) -> bool, &dyn Fn(Rgb) -> bool, &dyn Fn(Rgb) -> bool, [f32; 3]); 6] = [ + ("studio-ink", "arrow", &black, &ivory, &|_| false, [0.3, 0.15, 0.0]), + ("studio-ink", "pointer", &black, &ivory, &|_| false, [0.2, 0.3, 0.0]), + ("pop-coral", "arrow", &navy, &coral, &yellow, [0.2, 0.2, 0.05]), + ("pop-coral", "pointer", &navy, &yellow, &coral, [0.18, 0.3, 0.02]), + ("star-sprout", "arrow", &navy, &mint, &yellow, [0.25, 0.2, 0.03]), + ("star-sprout", "pointer", &navy, &ivory, &yellow, [0.25, 0.2, 0.03]), + ]; + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let blue = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let (y, uv) = model_screen_planes(true); + let orange = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let extruded = model_scene_json("null", Some(true), "default", true, 5.0); + let hidden = model_scene_json("null", Some(true), "default", false, 5.0); + let bare = compose_model(&comp, &blue, &hidden, &model_track("arrow", false, 0.5)); + let mut failures = Vec::new(); + for (theme, state, rim, body, extra, [min_rim, min_body, min_extra]) in cases { + let still = model_track(state, false, 0.5); + let json = extruded + .replace(&format!(r#"/{state}.png","#), &format!(r#"/{state}.png","sculpt":"{theme}/{state}","#)); + let (hover, hover_b) = (compose_model(&comp, &blue, &json, &still), compose_model(&comp, &orange, &json, &still)); + let mask = model_opaque(&hover, &hover_b, &bare); + let (mut total, mut counts) = (0usize, [0usize; 3]); + for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) { + let p = [hover[i * 4] as i32, hover[i * 4 + 1] as i32, hover[i * 4 + 2] as i32]; + total += 1; + for (k, class) in [rim, body, extra].iter().enumerate() { + counts[k] += usize::from(class(p)); + } + } + let [rim_share, body_share, extra_share] = counts.map(|k| k as f32 / total.max(1) as f32); + println!("{theme}/{state} : {total} px, trait {rim_share:.3}, corps {body_share:.3}, ornement {extra_share:.3}"); + if !(min_rim..0.7).contains(&rim_share) { + failures.push(format!("{theme}/{state}: {rim_share:.3} de trait, il a disparu ou tout mange")); + } + if body_share < min_body { + failures.push(format!("{theme}/{state}: {body_share:.3} de couleur du corps")); + } + if extra_share < min_extra { + failures.push(format!("{theme}/{state}: {extra_share:.3} d'ornement")); } } assert!(failures.is_empty(), "{failures:#?}"); @@ -5248,11 +6378,12 @@ mod tests { let touch = compose_model(&comp, &screen, &json, &contact); model_save(&format!("tap-{}", if rotation == "null" { "flat" } else { "iso" }), &touch); assert!(!is_red(px(&touch, m[0] as i32, (m[1] + 2.0) as i32)), "{rotation}: la pointe manque la pastille"); + // Au-dela du bord de la pointe, que le geste du clic deplace ('quiet' reste au repos). // L'ecart du a l'impact, le long de deux demi-droites (haut, gauche) : meme rayon, a la // perspective pres sous `iso` (l'anneau y est une ellipse). let (on, off) = (compose_model(&comp, &screen, &json, &ring), compose_model(&comp, &screen, &quiet, &ring)); let peak = |dx: i32, dy: i32| { - (4..45) + (8..45) .map(|r| { let (a, b) = (px(&on, m[0] as i32 + dx * r, m[1] as i32 + dy * r), px(&off, m[0] as i32 + dx * r, m[1] as i32 + dy * r)); ((0..3).map(|c| (a[c] - b[c]).abs()).sum::(), r) @@ -5262,14 +6393,20 @@ mod tests { }; let (up, left) = (peak(0, -1), peak(-1, 0)); println!("{rotation} : pastille en {m:?}, anneau haut {up:?}, gauche {left:?}"); - assert!(up.0 > 60 && left.0 > 60, "{rotation}: anneau absent ({up:?} {left:?})"); + // Le preset incliné projette le trait plus obliquement sur un bord; son contraste + // mesuré est inférieur à celui du cas plat, sans que l'anneau disparaisse. + let min_delta = if rotation == "null" { 60 } else { 40 }; + assert!( + up.0 > min_delta && left.0 > min_delta, + "{rotation}: anneau absent ({up:?} {left:?})" + ); let tol = if rotation == "null" { 1 } else { 2 }; assert!((up.1 - left.1).abs() <= tol, "{rotation}: anneau decentre ({up:?} {left:?})"); } } // ----------------------------------------------------------------------- - // Camera reelle (`follow-cursor`) : pendant de `tests/follow_camera_render.rs` (Windows). + // Camera reelle (`orbit`, focus auto) : pendant de `tests/follow_camera_render.rs` (Windows). // ----------------------------------------------------------------------- /// Le warp projectif du WGSL pose chaque ligne verticale d'une grille la ou la camera la @@ -5286,7 +6423,7 @@ mod tests { let screen = FakeFrame::from_planes(&gpu, w, h, &y, &uv); // Profondeur de champ coupee (allumee par defaut) : l'orbite a une vraie profondeur, et un // trait floute ne se mesure plus a +-8 px. - let json = model_scene_json(r#""follow-cursor""#, None, "none", true, 0.05) + let json = model_scene_json(r#""orbit","focusMode":"auto""#, None, "none", true, 0.05) .replace(r#""scale":1,"#, r#""scale":2.2,"#) .replace(r#""roundnessFrac":0.03"#, r#""roundnessFrac":0,"depthOfField":false"#); let scene = crate::scene::Scene::from_json(&json).expect("scene json"); diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 3dd550ce0..07ea2dd7c 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -37,7 +37,7 @@ pub use crate::frame_geometry::{ use crate::frame_geometry::{parse_hex, FrameGeometryInput, ShadowCaster, SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC}; -use crate::scene::{Scene, SceneBackground}; +use crate::scene::{Scene, SceneBackground, WallpaperMotion}; use anyhow::{anyhow, Result}; use metal::foreign_types::ForeignType; use std::cell::RefCell; @@ -261,6 +261,8 @@ pub struct Compositor { scene: RefCell>, cursor: RefCell>, cursor_time: RefCell>, + /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`). + footage: std::cell::Cell>, timeline_time: RefCell>, /// Temps programme (secondes de sortie) — cf. `FrameGeometryInput::programme_time`. programme_time: RefCell>, @@ -283,7 +285,7 @@ pub struct Compositor { /// appartient au jeu actif et ne peut pas être évincé — voir `cached_image`. img_frame_start: std::cell::Cell, /// Champs de distance des sprites de curseur (mode 15), R16F, par chemin, avec leur forme. - /// Pas d'éviction : seuls les seize sprites du thème par défaut y passent (~2,6 Mo en tout). + /// Pas d'éviction : seuls les sprites du thème en cours y passent (~2,6 Mo pour les seize). sdf_cache: RefCell< std::collections::HashMap, >, @@ -316,10 +318,25 @@ pub struct Compositor { pipeline_fs_tex: metal::RenderPipelineState, /// `vs_main` + `ps_main` en additif : les échantillons de traînée du curseur. pipeline_add: metal::RenderPipelineState, + /// `pipeline_main` et `pipeline_add` compilés AVEC les modèles 3D (`LAYER_MODELS`, cf. + /// l'en-tête de `shaders.metal`). Les deux autres ne les ont pas ; `draw_layer` bascule sur + /// ceux-ci le temps du draw d'un modèle (`LayerCB::needs_models`). + pipeline_main_models: metal::RenderPipelineState, + pipeline_add_models: metal::RenderPipelineState, + /// La passe en cours a été ouverte sur `pipeline_add` (cf. `begin_pass`) : dit à + /// `draw_layer` laquelle des deux variantes « modèles » prendre. + layer_add: std::cell::Cell, + /// Le dernier fond fixe composé et sa clé (`BackgroundKey`) : recopié dans le RT tant + /// que la clé tient, au lieu d'être redessiné. + bg_cache: RefCell>, /// Buffer d'accumulation ISOLÉ (transparent) pour la traînée. Accumuler directement sur /// le RT reviendrait à AJOUTER du blanc à ce qui est déjà dessous : sur un fond clair, /// le curseur disparaît. Même raisonnement que côté D3D11. accum: metal::Texture, + /// Rendu isolé de l'écran cadré (ombre, cadre, métrage, appareil), transparent autour, que le + /// mode 18 recompose le long de sa trajectoire (`FrameGeometry::screen_trail`). Distinct + /// d'`accum`, que le curseur remplit dans la même frame. + trail: metal::Texture, /// Pyramide dual-Kawase du flou de fond : demi, quart, huitième de la taille de rendu. /// Dérivée de la taille de rendu et non d'une constante — sinon le rayon effectif du /// flou changerait avec la résolution de sortie. @@ -389,6 +406,138 @@ fn make_texture( device.new_texture(&desc) } +/// Tout ce qui a la taille du rendu, et rien d'autre : ce que `new_sized` alloue et que +/// `resized` réalloue. Cf. `compositor_windows::Targets`. +struct Targets { + rt: metal::Texture, + rt_read: metal::Texture, + nv12_y: metal::Texture, + nv12_uv: metal::Texture, + nv12_read_y: metal::Texture, + nv12_read_uv: metal::Texture, + accum: metal::Texture, + trail: metal::Texture, + blur_half: metal::Texture, + blur_quarter: metal::Texture, + blur_eighth: metal::Texture, + ann_copy: metal::Texture, +} + +fn make_targets(device: &metal::Device, rw: u32, rh: u32) -> Targets { + let rt_usage = metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead; + + let rt = make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + rw, + rh, + metal::MTLStorageMode::Private, + rt_usage, + ); + let rt_read = make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + rw, + rh, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + let nv12_y = make_texture( + device, + metal::MTLPixelFormat::R8Unorm, + rw, + rh, + metal::MTLStorageMode::Private, + rt_usage, + ); + // NV12 : le plan chroma est entrelacé ET demi-résolution dans les deux axes. + // Le dimensionner comme le plan luma — ce que faisait la première version — + // produisait un UV 4x trop grand, donc un `read_nv12_scaled` qui lit au-delà + // de ce que la passe a écrit. + let nv12_uv = make_texture( + device, + metal::MTLPixelFormat::RG8Unorm, + rw / 2, + rh / 2, + metal::MTLStorageMode::Private, + rt_usage, + ); + let nv12_read_y = make_texture( + device, + metal::MTLPixelFormat::R8Unorm, + rw, + rh, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + let nv12_read_uv = make_texture( + device, + metal::MTLPixelFormat::RG8Unorm, + rw / 2, + rh / 2, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + let accum = make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + rw, + rh, + metal::MTLStorageMode::Private, + rt_usage, + ); + let trail = make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + rw, + rh, + metal::MTLStorageMode::Private, + rt_usage, + ); + let mut pyramid = [2u32, 4, 8].map(|d| { + make_texture( + device, + metal::MTLPixelFormat::RGBA8Unorm, + (rw / d).max(1), + (rh / d).max(1), + metal::MTLStorageMode::Private, + rt_usage, + ) + }); + let blur_eighth = pyramid[2].clone(); + let blur_quarter = pyramid[1].clone(); + let blur_half = std::mem::replace(&mut pyramid[0], blur_quarter.clone()); + let ann_copy = { + let d = metal::TextureDescriptor::new(); + d.set_texture_type(metal::MTLTextureType::D2); + d.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + d.set_width(rw as u64); + d.set_height(rh as u64); + d.set_storage_mode(metal::MTLStorageMode::Private); + d.set_usage(rt_usage); + // Assez de niveaux pour que `log2(rayon)` du mode 10 en trouve toujours un. + d.set_mipmap_level_count( + (32 - rw.max(rh).max(1).leading_zeros()).max(1) as u64, + ); + device.new_texture(&d) + }; + + Targets { + rt, + rt_read, + nv12_y, + nv12_uv, + nv12_read_y, + nv12_read_uv, + accum, + trail, + blur_half, + blur_quarter, + blur_eighth, + ann_copy, + } +} + /// Comment un draw se mélange à ce qui est déjà dans la cible. #[derive(Clone, Copy, PartialEq)] enum Blend { @@ -401,6 +550,18 @@ enum Blend { Add, } +/// `shaders.metal` avec les modèles 3D : la source dont seuls les pipelines de ces calques sont +/// tirés (cf. son en-tête). +fn msl_source_with_models() -> String { + format!("#define LAYER_MODELS 1\n{}", include_str!("shaders.metal")) +} + +/// Le fond tel que la passe de fond (et son flou) l'a laissé dans le RT, et la clé qui le décrit. +struct BgCache { + key: crate::frame_geometry::BackgroundKey, + tex: metal::Texture, +} + /// Un pipeline state à une seule pièce jointe couleur. fn make_pipeline( device: &metal::Device, @@ -464,60 +625,20 @@ impl Compositor { let cache = CVMetalTextureCache::new(gpu.device.as_ptr() as *const std::ffi::c_void)?; let device = &gpu.device; - let rt_usage = metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead; - - let rt = make_texture( - device, - metal::MTLPixelFormat::RGBA8Unorm, - rw, - rh, - metal::MTLStorageMode::Private, - rt_usage, - ); - let rt_read = make_texture( - device, - metal::MTLPixelFormat::RGBA8Unorm, - rw, - rh, - metal::MTLStorageMode::Shared, - metal::MTLTextureUsage::ShaderRead, - ); - let nv12_y = make_texture( - device, - metal::MTLPixelFormat::R8Unorm, - rw, - rh, - metal::MTLStorageMode::Private, - rt_usage, - ); - // NV12 : le plan chroma est entrelacé ET demi-résolution dans les deux axes. - // Le dimensionner comme le plan luma — ce que faisait la première version — - // produisait un UV 4x trop grand, donc un `read_nv12_scaled` qui lit au-delà - // de ce que la passe a écrit. - let nv12_uv = make_texture( - device, - metal::MTLPixelFormat::RG8Unorm, - rw / 2, - rh / 2, - metal::MTLStorageMode::Private, - rt_usage, - ); - let nv12_read_y = make_texture( - device, - metal::MTLPixelFormat::R8Unorm, - rw, - rh, - metal::MTLStorageMode::Shared, - metal::MTLTextureUsage::ShaderRead, - ); - let nv12_read_uv = make_texture( - device, - metal::MTLPixelFormat::RG8Unorm, - rw / 2, - rh / 2, - metal::MTLStorageMode::Shared, - metal::MTLTextureUsage::ShaderRead, - ); + let Targets { + rt, + rt_read, + nv12_y, + nv12_uv, + nv12_read_y, + nv12_read_uv, + accum, + trail, + blur_half, + blur_quarter, + blur_eighth, + ann_copy, + } = make_targets(device, rw, rh); // --- Compilation MSL --- let msl_source = include_str!("shaders.metal"); @@ -565,27 +686,25 @@ impl Compositor { metal::MTLPixelFormat::RGBA8Unorm, Blend::Add, )?; - let accum = make_texture( + let library_models = device + .new_library_with_source(&msl_source_with_models(), &metal::CompileOptions::new()) + .map_err(|e| anyhow!("MTLDevice::new_library_with_source (modèles) a échoué : {e}"))?; + let pipeline_main_models = make_pipeline( device, + &library_models, + "vs_main", + "ps_main", metal::MTLPixelFormat::RGBA8Unorm, - rw, - rh, - metal::MTLStorageMode::Private, - rt_usage, - ); - let mut pyramid = [2u32, 4, 8].map(|d| { - make_texture( - device, - metal::MTLPixelFormat::RGBA8Unorm, - (rw / d).max(1), - (rh / d).max(1), - metal::MTLStorageMode::Private, - rt_usage, - ) - }); - let blur_eighth = pyramid[2].clone(); - let blur_quarter = pyramid[1].clone(); - let blur_half = std::mem::replace(&mut pyramid[0], blur_quarter.clone()); + Blend::Over, + )?; + let pipeline_add_models = make_pipeline( + device, + &library_models, + "vs_main", + "ps_main", + metal::MTLPixelFormat::RGBA8Unorm, + Blend::Add, + )?; let pipeline_kdown = make_pipeline( device, &library, "vs_fs", "ps_kawase_down", metal::MTLPixelFormat::RGBA8Unorm, Blend::Replace, @@ -594,20 +713,6 @@ impl Compositor { device, &library, "vs_fs", "ps_kawase_up", metal::MTLPixelFormat::RGBA8Unorm, Blend::Replace, )?; - let ann_copy = { - let d = metal::TextureDescriptor::new(); - d.set_texture_type(metal::MTLTextureType::D2); - d.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); - d.set_width(rw as u64); - d.set_height(rh as u64); - d.set_storage_mode(metal::MTLStorageMode::Private); - d.set_usage(rt_usage); - // Assez de niveaux pour que `log2(rayon)` du mode 10 en trouve toujours un. - d.set_mipmap_level_count( - (32 - rw.max(rh).max(1).leading_zeros()).max(1) as u64, - ); - device.new_texture(&d) - }; Ok(Compositor { gpu: Gpu { @@ -621,6 +726,7 @@ impl Compositor { scene: RefCell::new(None), cursor: RefCell::new(None), cursor_time: RefCell::new(None), + footage: std::cell::Cell::new(None), timeline_time: RefCell::new(None), programme_time: RefCell::new(None), live_params: RefCell::new(LiveParams::default()), @@ -642,7 +748,12 @@ impl Compositor { pipeline_fs_uv, pipeline_fs_tex, pipeline_add, + pipeline_main_models, + pipeline_add_models, + layer_add: std::cell::Cell::new(false), + bg_cache: RefCell::new(None), accum, + trail, blur_half, blur_quarter, blur_eighth, @@ -665,6 +776,45 @@ impl Compositor { }) } + /// Le même compositeur, rastérisant à `w`×`h` : seules les cibles (`Targets`) sont + /// réallouées. Cf. `compositor_windows::Compositor::resized`. Ici, reconstruire le + /// compositeur entier recompilait en plus `shaders.metal`. + pub fn resized(self, w: u32, h: u32) -> Result { + let (rw, rh) = Self::normalize_render_size(w, h); + let Targets { + rt, + rt_read, + nv12_y, + nv12_uv, + nv12_read_y, + nv12_read_uv, + accum, + trail, + blur_half, + blur_quarter, + blur_eighth, + ann_copy, + } = make_targets(&self.gpu.device, rw, rh); + Ok(Compositor { + render_w: rw, + render_h: rh, + rt, + rt_read, + nv12_y, + nv12_uv, + nv12_read_y, + nv12_read_uv, + accum, + trail, + blur_half, + blur_quarter, + blur_eighth, + ann_copy, + bg_cache: RefCell::new(None), + ..self + }) + } + /// Arrondit `(w, h)` au multiple de 2 supérieur — nécessaire pour NV12 4:2:0. pub fn normalize_render_size(w: u32, h: u32) -> (u32, u32) { ((w.max(1) + 1) & !1, (h.max(1) + 1) & !1) @@ -809,7 +959,20 @@ impl Compositor { enc.set_fragment_texture(0, Some(y)); enc.set_fragment_texture(1, Some(uv)); } - enc.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4); + // Un modèle 3D passe, le temps de son draw, par la variante « modèles » du pipeline de la + // passe, la seule à le compiler ; le pipeline plat reprend ensuite la main. + if cb.needs_models() { + let (models, flat) = if self.layer_add.get() { + (&self.pipeline_add_models, &self.pipeline_add) + } else { + (&self.pipeline_main_models, &self.pipeline_main) + }; + enc.set_render_pipeline_state(models); + enc.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4); + enc.set_render_pipeline_state(flat); + } else { + enc.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4); + } } /// Quad de couleur pleine / gradient / ombre — tout ce qui n'échantillonne pas la vidéo. @@ -898,10 +1061,7 @@ impl Compositor { /// Champ de distance du sprite `path` (texture(4) du mode 15) et sa forme, calculés au /// premier appel. Parité `compositor_windows::cursor_sdf`. R16Float : filtrable sur tous /// les GPU Apple, contrairement au R32Float. - fn cursor_sdf( - &self, - path: &str, - ) -> Result<(metal::Texture, crate::frame_geometry::SpriteShape)> { + fn cursor_sdf(&self, path: &str) -> Result<(metal::Texture, crate::frame_geometry::SpriteShape)> { if let Some(hit) = self.sdf_cache.borrow().get(path) { return Ok(hit.clone()); } @@ -986,13 +1146,17 @@ impl Compositor { enc: &metal::RenderCommandEncoderRef, path: &str, output_aspect: f32, + motion: WallpaperMotion, + programme_t: f32, ) -> Result<()> { - self.draw_image_in(enc, path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, output_aspect) + let full = [0.0, 0.0, 1.0, 1.0]; + self.draw_image_in(enc, path, full, [0.0, 0.0], 0.0, output_aspect, motion, programme_t) } /// `draw_image_bg` pour un rect quelconque — la bulle webcam s'en sert avec ses coins /// arrondis. `output_aspect` est le ratio du RECT visé, pas celui de la sortie : le crop - /// « cover » se calcule contre la zone qu'on remplit. + /// « cover » se calcule contre la zone qu'on remplit. `motion` anime l'image au temps + /// programme `programme_t` ; la bulle passe `WallpaperMotion::None`. #[allow(clippy::too_many_arguments)] unsafe fn draw_image_in( &self, @@ -1002,6 +1166,8 @@ impl Compositor { quad_px: [f32; 2], radius_px: f32, output_aspect: f32, + motion: WallpaperMotion, + programme_t: f32, ) -> Result<()> { let (tex, iw, ih) = self.cached_image(path)?; let ai = iw as f32 / ih.max(1) as f32; @@ -1013,6 +1179,7 @@ impl Compositor { let vis = ai / ao; // rogne verticalement (0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5) }; + let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots(motion, programme_t, ao); enc.set_fragment_texture(2, Some(&tex)); self.draw_solid( enc, @@ -1022,6 +1189,8 @@ impl Compositor { quad_px, radius_px, mode: 6.0, + fx: [0.0, 0.0, anim[0], anim[1]], + mb, ..Default::default() }, ); @@ -1062,30 +1231,28 @@ impl Compositor { self.draw_solid(enc, &solid(parse_hex(color).unwrap_or(BLACK))); } // Le mouvement ne vaut que pour le fond d'écran : la bulle garde son dégradé immobile. - Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { - let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); - let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + Some(SceneBackground::Gradient { angle_deg, stops, offsets, .. }) => { // angle CSS → direction unitaire, même convention que le fond d'écran. let a = angle_deg.to_radians(); self.draw_solid( enc, &LayerCB { dst, - src: [c1[0], c1[1], c1[2], c1[3]], quad_px, radius_px, - mode: 5.0, - color: c0, fx: [a.sin(), -a.cos(), 0.0, 0.0], - ..Default::default() + ..crate::frame_geometry::gradient_layer(stops, offsets, BLACK) }, ); } - Some(SceneBackground::Image { path }) => { + Some(SceneBackground::Image { path, .. }) => { // Même contrat que le fond d'écran : un chemin cassé est loggé puis remplacé par // du noir. Un fallback silencieux redonnerait le bug qu'on corrige. let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 }; - if let Err(e) = self.draw_image_in(enc, path, dst, quad_px, radius_px, aspect) { + let still = WallpaperMotion::None; + if let Err(e) = + self.draw_image_in(enc, path, dst, quad_px, radius_px, aspect, still, 0.0) + { eprintln!("[compositor] fond webcam \"{path}\" : {e:#}"); self.draw_solid(enc, &solid(BLACK)); } @@ -1124,21 +1291,31 @@ impl Compositor { Ok(()) } - /// Dual-Kawase sur le contenu courant du RT : trois passes DOWN puis trois UP, la - /// dernière réécrivant le RT. Port des six `fs_pass` de `compositor_windows::blur_bg`, - /// mêmes tailles et mêmes texels. - unsafe fn blur_bg(&self, cmd: &metal::CommandBufferRef) -> Result<()> { - let off = 2.2; // spread par passe + /// Dual-Kawase sur le contenu courant du RT : `levels` passes DOWN puis autant d'UP, la + /// dernière réécrivant le RT. Port de `compositor_windows::blur_bg`, mêmes tailles, mêmes + /// texels, même force → niveaux (`background_blur_steps`). + unsafe fn blur_bg(&self, cmd: &metal::CommandBufferRef, amount: f32) -> Result<()> { + let Some((levels, off)) = crate::frame_geometry::background_blur_steps(amount) else { + return Ok(()); + }; let (rw, rh) = (self.render_w as f32, self.render_h as f32); - let (hw, hh) = (rw * 0.5, rh * 0.5); - // DOWN : texel = 1/(dims de la SOURCE échantillonnée) - self.fs_pass(cmd, &self.blur_half, &self.rt, &self.pipeline_kdown, [1.0 / rw, 1.0 / rh, off, 0.0])?; - self.fs_pass(cmd, &self.blur_quarter, &self.blur_half, &self.pipeline_kdown, [1.0 / hw, 1.0 / hh, off, 0.0])?; - self.fs_pass(cmd, &self.blur_eighth, &self.blur_quarter, &self.pipeline_kdown, [2.0 / hw, 2.0 / hh, off, 0.0])?; - // UP - self.fs_pass(cmd, &self.blur_quarter, &self.blur_eighth, &self.pipeline_kup, [4.0 / hw, 4.0 / hh, off, 0.0])?; - self.fs_pass(cmd, &self.blur_half, &self.blur_quarter, &self.pipeline_kup, [2.0 / hw, 2.0 / hh, off, 0.0])?; - self.fs_pass(cmd, &self.rt, &self.blur_half, &self.pipeline_kup, [1.0 / hw, 1.0 / hh, off, 0.0])?; + let pyramid = [ + (&self.rt, rw, rh), + (&self.blur_half, rw * 0.5, rh * 0.5), + (&self.blur_quarter, rw * 0.25, rh * 0.25), + (&self.blur_eighth, rw * 0.125, rh * 0.125), + ]; + // texel = 1/(dims de la SOURCE échantillonnée) + for i in 0..levels { + let (src, sw, sh) = pyramid[i]; + let (dst, ..) = pyramid[i + 1]; + self.fs_pass(cmd, dst, src, &self.pipeline_kdown, [1.0 / sw, 1.0 / sh, off, 0.0])?; + } + for i in (0..levels).rev() { + let (src, sw, sh) = pyramid[i + 1]; + let (dst, ..) = pyramid[i]; + self.fs_pass(cmd, dst, src, &self.pipeline_kup, [1.0 / sw, 1.0 / sh, off, 0.0])?; + } Ok(()) } @@ -1212,6 +1389,8 @@ impl Compositor { corners: &[(f32, f32); 4], center_px: [f32; 2], radius: f32, + // Le slot d'un layout en bloc, qui rogne le plan (`shadow_mask_fields`). + mask: Option, spread: f32, offset_px: [f32; 2], opacity: f32, @@ -1229,6 +1408,11 @@ impl Compositor { let [tr0, tr1] = local(corners[1]); let [br0, br1] = local(corners[2]); let [bl0, bl1] = local(corners[3]); + let (mask_rect, mask_radius) = crate::frame_geometry::shadow_mask_fields( + mask, + [center_px[0] + min_x - spread, center_px[1] + min_y - spread], + [rw, rh], + ); self.draw_solid( enc, &LayerCB { @@ -1244,14 +1428,15 @@ impl Compositor { 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], + dst_prev: mask_rect, + mb: [0.0, spread, 1.0, mask_radius], ..Default::default() }, ); } /// Écran incliné (mode 8) : le calque partagé (`frame_geometry::tilted_screen_cb`), avec la - /// pyramide de profondeur de champ en texture(2). + /// pyramide de profondeur de champ en texture(5). #[allow(clippy::too_many_arguments)] unsafe fn draw_tilted_screen( &self, @@ -1265,14 +1450,18 @@ impl Compositor { // Sous le chrome de fenêtre : la remontée de son contour intérieur au-dessus de // l'écran (`screen_top_lift_px`), qui carre les coins hauts. 0 ailleurs. top_lift: f32, + // Le slot d'un layout en bloc, qui rogne le plan (`FrameGeometry::screen_mask`). + mask: Option, + // Le plan à la frame d'avant, pour son flou de mouvement (`FrameGeometry::tilt_pixel_trail`). + trail: Option, y: &metal::Texture, uv: &metal::Texture, dof_pyramid: Option<&metal::Texture>, ) { let render_px = [self.render_w as f32, self.render_h as f32]; - // texture(2) EXPLICITE : `draw_video` ne lie que 0/1, et le slot 2 garde sinon ce que - // le draw précédent y a laissé. `None` quand l'effet est coupé : `k = 0`, rien n'y est lu. - enc.set_fragment_texture(2, dof_pyramid.map(|t| &**t)); + // La pyramide en texture(5), que `draw_video` ne lie pas. `None` quand l'effet est + // coupé : `k = 0`, rien n'y est lu. + enc.set_fragment_texture(5, dof_pyramid.map(|t| &**t)); // La pyramide liée décide seule si la profondeur de champ tourne. let cb = crate::frame_geometry::tilted_screen_cb( quad, @@ -1284,11 +1473,44 @@ impl Compositor { top_lift, dof_pyramid.is_some(), render_px, + mask, + trail, ); self.draw_video(enc, &cb, y, uv); } + /// Le fond gardé (`bg_cache`) : copie le RT dans `tex`, ou `tex` dans le RT (`into_rt`). + unsafe fn blit_background(&self, cmd: &metal::CommandBufferRef, tex: &metal::Texture, into_rt: bool) { + let (src, dst) = if into_rt { (tex, &self.rt) } else { (&self.rt, tex) }; + let blit = cmd.new_blit_command_encoder(); + blit.copy_from_texture( + src, 0, 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { width: self.render_w as u64, height: self.render_h as u64, depth: 1 }, + dst, 0, 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + blit.end_encoding(); + } + + /// Fige l'image composée dans `ann_copy` (mip 0), qu'y lisent les flous (mips dérivés, + /// `mips`) et le cristal de Prism Glow. + unsafe fn snapshot_rt(&self, cmd: &metal::CommandBufferRef, mips: bool) { + let blit = cmd.new_blit_command_encoder(); + blit.copy_from_texture( + &self.rt, 0, 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { width: self.render_w as u64, height: self.render_h as u64, depth: 1 }, + &self.ann_copy, 0, 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + if mips { + blit.generate_mipmaps(&self.ann_copy); + } + blit.end_encoding(); + } + /// Annotations : calque le plus haut, ancré sur `s_ann` — le rect écran SANS ZOOM, le /// conteneur que reçoit l'overlay web. Port de `compositor_windows::draw_annotations`. /// @@ -1297,6 +1519,9 @@ impl Compositor { /// grossir les sous-titres sous un zoom (issue #179, puis #397 sur Linux). L'arithmétique /// elle-même vit dans `frame_geometry::annotation_dst_in`, partagée par les trois backends. /// Le flou, lui, se place par `g.privacy_mask` : un masque doit rester sur ce qu'il cache. + /// + /// `privacy` : les flous de confidentialité seuls, dessinés sur le métrage avant le curseur ; + /// sinon toutes les autres annotations, le calque le plus haut. unsafe fn draw_annotations( &self, cmd: &metal::CommandBufferRef, @@ -1304,30 +1529,21 @@ impl Compositor { t: f32, s_ann: [f32; 4], g: &crate::frame_geometry::FrameGeometry, + privacy: bool, ) -> Result<()> { let Some(scene) = scene else { return Ok(()) }; - if scene.annotations.is_empty() { - return Ok(()); - } let (rw, rh) = (self.render_w as f32, self.render_h as f32); let visible = |a: &crate::scene::SceneAnnotation| { - t >= a.start_sec as f32 && t < a.end_sec as f32 + t >= a.start_sec as f32 && t < a.end_sec as f32 && (a.kind == "blur") == privacy }; + if !scene.annotations.iter().any(visible) { + return Ok(()); + } // UNE seule recopie pour toutes les annotations flou de la frame : leur lecture doit // voir l'image composée SANS les flous eux-mêmes, sinon deux zones qui se recouvrent // s'échantillonneraient l'une l'autre selon l'ordre de dessin. - if scene.annotations.iter().any(|a| a.kind == "blur" && visible(a)) { - let blit = cmd.new_blit_command_encoder(); - blit.copy_from_texture( - &self.rt, 0, 0, - metal::MTLOrigin { x: 0, y: 0, z: 0 }, - metal::MTLSize { width: rw as u64, height: rh as u64, depth: 1 }, - &self.ann_copy, 0, 0, - metal::MTLOrigin { x: 0, y: 0, z: 0 }, - ); - // Seul le mip 0 est rempli ; le GPU dérive le reste. - blit.generate_mipmaps(&self.ann_copy); - blit.end_encoding(); + if privacy { + self.snapshot_rt(cmd, true); } let enc = self.begin_pass(cmd, &self.rt, None, &self.pipeline_main)?; @@ -1493,6 +1709,7 @@ impl Compositor { let anim = crate::text_anim::text_animation_state( text.animation.as_deref(), (t - a.start_sec as f32) * 1000.0, + ((a.end_sec - a.start_sec) * 1000.0) as f32, ); let anim_px = rh / crate::text_anim::ANIMATION_REFERENCE_HEIGHT; let (mut ax, mut ay, mut aw, mut ah) = ( @@ -1929,6 +2146,8 @@ impl Compositor { ca.set_store_action(metal::MTLStoreAction::Store); let enc = cmd.new_render_command_encoder(&desc); enc.set_render_pipeline_state(pipeline); + // La famille du pipeline de calque de cette passe, pour `draw_layer`. + self.layer_add.set(std::ptr::eq(pipeline, &self.pipeline_add)); Ok(enc) } @@ -1937,7 +2156,8 @@ impl Compositor { /// `frame_geometry::cursor_sprite_cb`, partagée avec Windows et Linux. /// /// Avec `model`, le même sprite extrudé (mode 15, `cursor_model_cb`) : le sprite en - /// texture(2), son champ de distance en texture(4). + /// texture(2), son champ de distance en texture(4), l'enregistrement que l'appelant a lié en + /// texture(0) et (1). #[allow(clippy::too_many_arguments)] unsafe fn draw_cursor_sprite( &self, @@ -1948,29 +2168,38 @@ impl Compositor { sprite: &crate::scene::SceneCursorSprite, clip: [f32; 4], model: Option, - ) -> Result<()> { + ) -> Result> { let (tex, iw, ih) = self.cached_image(sprite.path.as_str())?; // Sans champ de distance, repli sur le sprite plat plutôt qu'aucun curseur. Parité Linux. if let Some(pose) = model { match self.cursor_sdf(sprite.path.as_str()) { Ok((sdf, shape)) => { - let shape = crate::frame_geometry::SpriteShape { - hotspot: [sprite.hotspot_x, sprite.hotspot_y], - ..shape - }; - if let Some(cb) = crate::frame_geometry::cursor_model_cb( + let shape = crate::frame_geometry::model_shape(sprite, shape); + let Some(cb) = crate::frame_geometry::cursor_model_cb( placement, size_px, pose, shape, a, clip, - ) { - enc.set_fragment_texture(2, Some(&tex)); - enc.set_fragment_texture(4, Some(&sdf)); - self.draw_solid(enc, &cb); - } - return Ok(()); + ) else { + return Ok(None); + }; + enc.set_fragment_texture(2, Some(&tex)); + enc.set_fragment_texture(4, Some(&sdf)); + self.draw_solid(enc, &cb); + return Ok(Some(cb.dst)); } Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), } } let (rw, rh) = (self.render_w as f32, self.render_h as f32); + // Le verre à plat de Glass Lens (mode 19) : la copie qu'il réfracte est déjà en + // texture(5) (`CursorPlan::glass`). Parité Windows. + if model.is_none() { + let glass = sprite.glass.as_deref().and_then(|name| { + crate::frame_geometry::cursor_glass_cb(placement, size_px, name, a, clip, [rw, rh]) + }); + if let Some(cb) = glass { + self.draw_solid(enc, &cb); + return Ok(Some(cb.dst)); + } + } let ar = iw as f32 / ih.max(1) as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; let cb = crate::frame_geometry::cursor_sprite_cb( @@ -1983,7 +2212,7 @@ impl Compositor { ); enc.set_fragment_texture(2, Some(&tex)); self.draw_solid(enc, &cb); - Ok(()) + Ok(Some(cb.dst)) } /// Curseur thématisé : le sprite de l'état courant, sinon la flèche, sinon rien. @@ -2006,14 +2235,17 @@ impl Compositor { a: f32, clip: [f32; 4], model: Option, - ) { - let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); - if let Some(sprite) = sprite { - if let Err(e) = self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, model) - { - eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path); - } - } + ) -> Option<[f32; 4]> { + let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow"))?; + self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, model).unwrap_or_else(|e| { + eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path); + None + }) + } + + /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`). + pub fn footage_quad(&self) -> Option { + self.footage.get() } /// Compose la frame : fond, ombre écran, écran, ombre caméra, caméra — puis miroir @@ -2085,6 +2317,7 @@ impl Compositor { timeline_t_override: *self.timeline_time.borrow(), programme_time: *self.programme_time.borrow(), }); + self.footage.set(Some(g.footage_quad([rw, rh]))); let cmd_buf = self.gpu.context.new_command_buffer(); // Profondeur de champ : pyramide remplie seulement sur une frame inclinée qui la lit, @@ -2094,51 +2327,75 @@ impl Compositor { } else { None }; - let enc = self.begin_pass( - cmd_buf, - &self.rt, - Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)), - &self.pipeline_main, - )?; - // Les deux plans écran restent liés par défaut : les quads de couleur ne les - // échantillonnent pas, mais Metal veut des slots renseignés pour les draws qui, eux, - // le font. - enc.set_fragment_texture(0, Some(&sy)); - enc.set_fragment_texture(1, Some(&suv)); - - // --- fond --- (parité `compositor_windows.rs`, section « fond ») - match scene_ref.as_ref().map(|s| s.background.clone()) { - Some(SceneBackground::Color { color }) => { - let c = parse_hex(&color).unwrap_or(lp.bg_color); - self.draw_solid( - enc, - &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, color: c, ..Default::default() }, - ); - } - Some(SceneBackground::Gradient { angle_deg, stops, motion }) => { - let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(lp.bg_color); - let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); - let a = angle_deg.to_radians(); - let (anim, mb) = - crate::frame_geometry::gradient_motion_slots(motion, g.programme_t, rw / rh); - self.draw_solid( - enc, - &LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - src: [c1[0], c1[1], c1[2], c1[3]], - mode: 5.0, - color: c0, - fx: [a.sin(), -a.cos(), anim[0], anim[1]], - mb, - ..Default::default() - }, - ); + // Le fond de la frame d'avant, flou compris, s'il est celui qu'on demande + // (`BackgroundKey`) : recopié au lieu d'être redessiné. + let bg_desc = scene_ref.as_ref().map(|s| &s.background); + let bg_blur = scene_ref.as_ref().map_or(0.0, |s| s.effects.blur); + let bg_cached = self + .bg_cache + .borrow() + .as_ref() + .is_some_and(|c| c.key.matches(bg_desc, bg_blur, lp.bg_color, [rw, rh])); + if bg_cached { + if let Some(c) = self.bg_cache.borrow().as_ref() { + self.blit_background(cmd_buf, &c.tex, true); } - Some(SceneBackground::Image { path }) => { - // Repli couleur en cas d'échec, mais LOGGÉ : un fallback silencieux masquerait - // un chemin cassé. - if let Err(e) = self.draw_image_bg(enc, &path, rw / rh) { - eprintln!("[compositor] wallpaper image \"{path}\" : {e:#}"); + } else { + let enc = self.begin_pass( + cmd_buf, + &self.rt, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)), + &self.pipeline_main, + )?; + // Les deux plans écran restent liés par défaut : les quads de couleur ne les + // échantillonnent pas, mais Metal veut des slots renseignés pour les draws qui, eux, + // le font. + enc.set_fragment_texture(0, Some(&sy)); + enc.set_fragment_texture(1, Some(&suv)); + + // --- fond --- (parité `compositor_windows.rs`, section « fond ») + // Faux si l'image du fond n'a pas pu se charger : son repli n'est pas gardé. + let mut bg_drawn = true; + match scene_ref.as_ref().map(|s| s.background.clone()) { + Some(SceneBackground::Color { color }) => { + let c = parse_hex(&color).unwrap_or(lp.bg_color); + self.draw_solid( + enc, + &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, color: c, ..Default::default() }, + ); + } + Some(SceneBackground::Gradient { angle_deg, stops, offsets, motion }) => { + let a = angle_deg.to_radians(); + let (anim, mb) = + crate::frame_geometry::wallpaper_motion_slots(motion, g.programme_t, rw / rh); + self.draw_solid( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + fx: [a.sin(), -a.cos(), anim[0], anim[1]], + mb, + ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color) + }, + ); + } + Some(SceneBackground::Image { path, motion }) => { + // Repli couleur en cas d'échec, mais LOGGÉ : un fallback silencieux masquerait + // un chemin cassé. + if let Err(e) = self.draw_image_bg(enc, &path, rw / rh, motion, g.programme_t) { + eprintln!("[compositor] wallpaper image \"{path}\" : {e:#}"); + bg_drawn = false; + self.draw_solid( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }, + ); + } + } + None => { self.draw_solid( enc, &LayerCB { @@ -2150,41 +2407,62 @@ impl Compositor { ); } } - None => { - self.draw_solid( - enc, - &LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - mode: 1.0, - color: lp.bg_color, - ..Default::default() - }, - ); - } - } - // « Blur BG » (parité web `blurredBackgroundLayer`) : floute CE wallpaper qu'on vient - // de dessiner, pas la vidéo. No-op visuel sur une couleur plate, effet réel sur un - // gradient ou une image. Il lui faut ses propres passes, d'où la coupure ici. - enc.end_encoding(); - if scene_ref.as_ref().map(|s| s.effects.blur).unwrap_or(false) { - self.blur_bg(cmd_buf)?; + // « Blur BG » (parité web `blurredBackgroundLayer`) : floute CE wallpaper qu'on vient + // de dessiner, pas la vidéo. No-op visuel sur une couleur plate, effet réel sur un + // gradient ou une image. Il lui faut ses propres passes, d'où la coupure ici. + enc.end_encoding(); + if let Some(blur) = scene_ref.as_ref().map(|s| s.effects.blur) { + self.blur_bg(cmd_buf, blur)?; + } + // Pas de cache pour un fond animé (`of` rend `None`). + let key = bg_drawn + .then(|| crate::frame_geometry::BackgroundKey::of(bg_desc, bg_blur, lp.bg_color, [rw, rh])) + .flatten(); + if let Some(key) = key { + // L'ancienne texture est reprise : `resized` vide le cache, sa taille est la bonne. + let old = self.bg_cache.borrow_mut().take().map(|c| c.tex); + let tex = old.unwrap_or_else(|| { + make_texture( + &self.gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + self.render_w, + self.render_h, + metal::MTLStorageMode::Private, + metal::MTLTextureUsage::ShaderRead, + ) + }); + self.blit_background(cmd_buf, &tex, false); + *self.bg_cache.borrow_mut() = Some(BgCache { key, tex }); + } } - let enc = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; - enc.set_fragment_texture(0, Some(&sy)); - enc.set_fragment_texture(1, Some(&suv)); - // --- écran : ombre puis vidéo --- let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh]; // Géométrie du tilt calculée UNE fois : l'ombre et l'écran doivent porter exactement // le même quadrilatère, sinon l'ombre se décolle dès que l'un des deux change. let tilt = g.screen_tilt(s_px); + // Flou de mouvement de l'écran CADRÉ (`FrameGeometry::screen_trail`) : ombre, cadre, + // métrage et appareil se dessinent dans un rendu isolé et transparent, que le mode 18 + // recompose ensuite sur le fond le long de la trajectoire de la boîte, ou du plan incliné. + let trail = g.screen_trail([rw, rh]); + let mut enc = if trail { + self.begin_pass( + cmd_buf, + &self.trail, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)), + &self.pipeline_main, + )? + } else { + self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)? + }; + enc.set_fragment_texture(0, Some(&sy)); + enc.set_fragment_texture(1, Some(&suv)); let quad_center_px = [ (g.s_dst[0] + g.s_dst[2] * 0.5) * rw, (g.s_dst[1] + g.s_dst[3] * 0.5) * rh, ]; if cfg.shadow { - let spread = SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; + let spread = SCREEN_SHADOW_SPREAD_FRAC * g.screen_unit_px; let offset = g.screen_shadow_offset(); let opacity = 0.45 * lp.shadow_scale; // L'ombre suit la silhouette réellement affichée : rect arrondi quand l'écran est @@ -2199,8 +2477,8 @@ impl Compositor { ShadowCaster::Upright { dst, size_px, radius } => { self.draw_shadow(enc, dst, size_px, radius, spread, offset, opacity) } - ShadowCaster::Tilted { corners, center_px, radius } => self.draw_quad_shadow( - enc, &corners, center_px, radius, spread, offset, opacity, + ShadowCaster::Tilted { corners, center_px, radius, mask } => self.draw_quad_shadow( + enc, &corners, center_px, radius, mask, spread, offset, opacity, ), } } @@ -2212,19 +2490,22 @@ impl Compositor { let square_top = g.screen_square_top(); let top_lift = g.screen_top_lift_px([rw, rh]); let [su0, sv0, su1, sv1] = g.cut; + // Sous le masque d'un layout en bloc, rogné au slot (`FrameGeometry::mask_flat_screen`). + let (dst, src, quad_px, radius_px) = + g.mask_flat_screen(g.s_dst, [su0, sv0, su1, sv1], s_px, g.s_radius, [rw, rh]); match tilt.as_ref() { None => self.draw_video( enc, &LayerCB { - dst: g.s_dst, - src: [su0, sv0, su1, sv1], - quad_px: s_px, - radius_px: g.s_radius, + dst, + src, + quad_px, + radius_px, mode: 0.0, color: [0.0, 0.0, 0.0, 1.0], src_prev: [su0, sv0, su1, sv1], dst_prev: g.s_dst_prev, - mb: [g.mb_taps, g.mb_amount, top_lift, square_top], + mb: [g.screen_pixel_taps([rw, rh]), g.mb_amount, top_lift, square_top], ..Default::default() }, &sy, @@ -2239,6 +2520,8 @@ impl Compositor { g.focus_plane, g.s_radius, top_lift, + g.screen_mask, + g.tilt_pixel_trail([rw, rh]), &sy, &suv, dof_pyramid.as_ref(), @@ -2250,9 +2533,23 @@ impl Compositor { if let Some(cb) = g.device_frame_cb([rw, rh]) { self.draw_solid(enc, &cb); } + if trail { + enc.end_encoding(); + enc = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; + // texture(2) = l'écran cadré isolé ; texture(5) = la pyramide, avec laquelle le repli + // relit le métrage hors de la sortie comme le mode 8 l'a dessiné. + enc.set_fragment_texture(2, Some(&self.trail)); + enc.set_fragment_texture(5, dof_pyramid.as_ref().map(|t| &**t)); + self.draw_video(enc, &g.screen_trail_cb([rw, rh], dof_pyramid.is_some()), &sy, &suv); + } enc.end_encoding(); + // --- flous de confidentialité : sur le métrage, AVANT le curseur (parité Windows). Le + // curseur reste net par-dessus, et le cristal de Prism Glow, qui réfracte l'image + // composée, n'y voit que des pixels déjà floutés. + self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann, &g, true)?; + // --- curseur --- (parité `compositor_windows.rs`, section « curseur custom ») if let Some(track) = cursor_ref.as_ref() { let plan = crate::frame_geometry::plan_cursor( @@ -2275,6 +2572,12 @@ impl Compositor { .map(|s| s.cursor.cursor_sprites.clone()) .unwrap_or_default(); let kind = plan.cursor_type.as_deref(); + // Le cristal de Prism Glow réfracte l'image telle qu'elle est composée à cet + // instant, flous compris : sa copie en texture(5), une fois pour toutes les copies + // de la traînée (`ann_copy` est libre, les flous l'ont déjà lue). + if plan.glass { + self.snapshot_rt(cmd_buf, false); + } // L'impact des clics (mode 16, sans texture), posé sur l'écran SOUS le curseur. if !plan.impacts.is_empty() { let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; @@ -2285,6 +2588,9 @@ impl Compositor { } if plan.taps <= 1 { let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; + if plan.glass { + e.set_fragment_texture(5, Some(&self.ann_copy)); + } self.draw_cur_themed( e, &sprites, @@ -2307,11 +2613,17 @@ impl Compositor { Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)), &self.pipeline_add, )?; + if plan.glass { + e.set_fragment_texture(5, Some(&self.ann_copy)); + } + // L'union des quads peints dans `accum` (x0, y0, x1, y1 en fractions de la + // sortie) : la seule région que la recopie doit relire. + let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; for k in 0..plan.taps { let f = k as f32 / (plan.taps - 1) as f32; let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps); e.set_blend_color(w, w, w, w); - self.draw_cur_themed( + let drawn = self.draw_cur_themed( e, &sprites, kind, @@ -2321,12 +2633,36 @@ impl Compositor { plan.clip, plan.model, ); + if let Some(d) = drawn { + bounds = [ + bounds[0].min(d[0]), + bounds[1].min(d[1]), + bounds[2].max(d[0] + d[2]), + bounds[3].max(d[1] + d[3]), + ]; + } } e.end_encoding(); let c = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_fs_tex)?; c.set_fragment_texture(0, Some(&self.accum)); - c.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + // Rien n'a été peint dans `accum` hors des quads de la traînée : la recopie + // s'y borne au lieu de repasser sur toute la sortie (0,5 ms par frame 1080p + // mesurée sur Linux, Radeon 610M). + let (w, h) = (self.render_w as f32, self.render_h as f32); + let x0 = (bounds[0] * w).floor().clamp(0.0, w) as u64; + let y0 = (bounds[1] * h).floor().clamp(0.0, h) as u64; + let x1 = (bounds[2] * w).ceil().clamp(0.0, w) as u64; + let y1 = (bounds[3] * h).ceil().clamp(0.0, h) as u64; + if x1 > x0 && y1 > y0 { + c.set_scissor_rect(metal::MTLScissorRect { + x: x0, + y: y0, + width: x1 - x0, + height: y1 - y0, + }); + c.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 3); + } c.end_encoding(); } } @@ -2431,8 +2767,9 @@ impl Compositor { // --- annotations : calque le plus haut, ancré sur le rect ÉCRAN SANS ZOOM --- // `s_ann`, pas `s_dst` : le zoom vit dans la boîte depuis l'issue #179, donc `s_dst` // grandit avec lui et emmenait annotations et sous-titres dans le mouvement. Le flou de - // confidentialité est l'exception : il suit le contenu, d'où `&g`. - self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann, &g)?; + // confidentialité est l'exception : il suit le contenu, d'où `&g` ; il est passé avant le + // curseur (plus haut). + self.draw_annotations(cmd_buf, scene_ref.as_ref(), g.source_t, g.s_ann, &g, false)?; // Ni miroir RGBA ni attente ici : le miroir ne sert qu'à `readback_direct` (la // preview), et l'export ne lit jamais le RGBA — le blit pleine résolution était payé @@ -3108,7 +3445,7 @@ mod tests { let screen = FakeFrame::new(128, 128, |_, _| 126); let webcam = FakeFrame::new(64, 64, |_, _| Y_WHITE); let mut cfg = crate::config::Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = false; @@ -3440,7 +3777,7 @@ mod tests { // // Le seul golden de l'effet qui tourne en CI : celui de Windows est opt-in (source vidéo), // celui de Linux aussi (`OPENSCREEN_LINUX_COMPOSE`). C'est aussi le premier vrai passage de - // `fill_dof_pyramid`, de `generate_mipmaps` et du `texture(2)` du mode 8. Le piège qu'il + // `fill_dof_pyramid`, de `generate_mipmaps` et du `texture(5)` du mode 8. Le piège qu'il // garde : un `level(lod)` qui retomberait au niveau 0 laisserait macOS net là où Windows // floute. // ----------------------------------------------------------------------- @@ -3666,11 +4003,11 @@ mod tests { /// Les états que le rendu passe en revue (hotspots de `DEFAULT_CURSOR_SPRITES`), et s'ils /// sont centrés : ni tangage ni lacet. const MODEL_STATES: [(&str, [f32; 2], bool); 6] = [ - ("arrow", [0.119, 0.0874], false), - ("pointer", [0.3893, 0.0032], false), - ("text", [0.4375, 0.5333], true), - ("open-hand", [0.4375, 0.1781], false), - ("resize-ew", [0.4881, 0.4706], true), + ("arrow", [0.1205, 0.0881], false), + ("pointer", [0.3874, 0.0032], false), + ("text", [0.4355, 0.5369], true), + ("open-hand", [0.4375, 0.1724], false), + ("resize-ew", [0.485, 0.4706], true), ("not-allowed", [0.5, 0.5], true), ]; @@ -3729,7 +4066,7 @@ mod tests { comp.set_cursor_time(Some(2.0)); comp.set_timeline_time(Some(2.0)); let mut cfg = crate::config::Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = true; @@ -3888,8 +4225,9 @@ mod tests { let touch = compose_model(&comp, &screen, &json(Some(true), "default", true), &clicked); model_save(&format!("{name}-hover"), &hover); model_save(&format!("{name}-touch"), &touch); - assert!(absent == off && absent == other, "{name}: le chemin plat a change"); + assert!(absent == off, "{name}: le chemin plat a change"); assert!(absent != hover, "{name}: le reglage allume ne change rien"); + assert!(other == hover, "{name}: le libelle du theme a change le rendu natif"); let (a, b) = (model_split(&hover, &bare), model_split(&touch, &bare)); println!( "{name} : blanc {}, noir {}, ombre {} (apex a {:.1} px, posee {:.1} px), centroides {:?} / {:?}", @@ -3906,7 +4244,7 @@ mod tests { /// Chaque état garde son art et sa silhouette : posé au centre de l'écran (vu de face), le /// modèle couvre ce que couvre le sprite plat, avec ses couleurs ; il porte une ombre en - /// l'air, suit l'inclinaison du plan, et un autre thème (ou le réglage éteint) reste plat. + /// l'air et suit l'inclinaison du plan. Le réglage éteint garde le sprite plat. #[test] fn every_modelled_state_keeps_its_art_footprint_and_shadow() { let Ok(gpu) = crate::d3d::Gpu::create(false) else { @@ -3928,16 +4266,27 @@ mod tests { let touch = compose_model(&comp, &blue, &on, &clicked); let touch_b = compose_model(&comp, &orange, &on, &clicked); let hover = compose_model(&comp, &blue, &on, &still); - let tilted = compose_model(&comp, &blue, &json(r#""iso""#, Some(true), "default", true), &still); + let iso = json(r#""iso""#, Some(true), "default", true); + let tilted = compose_model(&comp, &blue, &iso, &still); + // Le modèle incliné posé en `x` sur la ligne du focus : le préset iso (`left`) amène le + // bord droit du plan vers la caméra. + let across = |x: f32| { + let track = crate::cursor::CursorTrack::new(vec![(0.0, x, 0.5), (9.0, x, 0.5)], vec![], vec![(0.0, key.to_string())]); + compose_model(&comp, &blue, &iso, &track) + }; + let (near, far) = (across(0.8), across(0.2)); let sprite = compose_model(&comp, &blue, &flat, &still); let sprite_b = compose_model(&comp, &orange, &flat, &still); let absent = compose_model(&comp, &blue, &json("null", None, "default", true), &still); let other = compose_model(&comp, &blue, &json("null", Some(true), "other", true), &still); model_save(&format!("art-{key}-touch"), &touch); model_save(&format!("art-{key}-iso"), &tilted); - if absent != sprite || other != sprite { + if absent != sprite { failures.push(format!("{key}: le sprite plat a change")); } + if other != hover { + failures.push(format!("{key}: le libelle du theme a change le rendu natif")); + } let (m3d, m2d) = (model_opaque(&touch, &touch_b, &bare), model_opaque(&sprite, &sprite_b, &bare)); let overlap = model_iou(&m3d, &m2d); @@ -3953,9 +4302,10 @@ mod tests { .count() as f32; let body = |rgba: &[u8]| (0..1280 * 720).filter(|&i| model_neutral(&rgba[i * 4..i * 4 + 3]) && differs(rgba, i)).count() as f32; let (flat_body, tilted_body) = (body(&hover), body(&tilted)); + let (near_body, far_body) = (body(&near), body(&far)); println!( "{key} : IoU {overlap:.3}, a 2 px pres {near3d:.3} / {near2d:.3}, sprite {area} px, palette 3D {pal3d:?} / sprite {pal2d:?}, \ - ombre {shadow} px, corps a plat {flat_body} / incline {tilted_body}" + ombre {shadow} px, corps a plat {flat_body} / incline {tilted_body}, proche {near_body} / lointain {far_body}" ); let (min_iou, min_near, palette_tol) = if centred { (0.75, 0.98, 0.07) } else { (0.6, 0.9, 0.2) }; if overlap <= min_iou || near3d < min_near || near2d < min_near { @@ -3969,9 +4319,14 @@ mod tests { if shadow <= 0.3 * area { failures.push(format!("{key}: pas d'ombre en l'air ({shadow} px)")); } - // Le préset iso réduit le plan (unité 51,5 contre 62,6 px) et incline le modèle. - if !(tilted_body < 0.9 * flat_body && tilted_body > 0.3 * flat_body) { - failures.push(format!("{key}: le modele ne suit pas le plan ({tilted_body} / {flat_body})")); + // Au focus du zoom, l'angle fixe garde au modèle sa taille à plat (`plan_cursor`) ; + // ailleurs, le plan l'emporte avec lui : sa perspective le grossit du côté proche et le + // réduit du côté lointain. Mesuré : ×1,5 de l'un à l'autre ; un modèle resté de face sur + // le même plan n'y gagne que ×1,0 à 1,1. + if !((0.85..1.15).contains(&(tilted_body / flat_body)) && near_body > 1.25 * far_body) { + failures.push(format!( + "{key}: le modele ne suit pas le plan ({tilted_body} / {flat_body}, proche {near_body} / lointain {far_body})" + )); } } assert!(failures.is_empty(), "{failures:#?}"); @@ -4057,11 +4412,12 @@ mod tests { let touch = compose_model(&comp, &screen, &json, &contact); model_save(&format!("tap-{}", if rotation == "null" { "flat" } else { "iso" }), &touch); assert!(!is_red(px(&touch, m[0] as i32, (m[1] + 2.0) as i32)), "{rotation}: la pointe manque la pastille"); + // Au-delà du bord de la pointe, que le geste du clic déplace (`quiet` reste au repos). // L'écart dû à l'impact, le long de deux demi-droites (haut, gauche) : même rayon, à la // perspective près sous `iso` (l'anneau y est une ellipse). let (on, off) = (compose_model(&comp, &screen, &json, &ring), compose_model(&comp, &screen, &quiet, &ring)); let peak = |dx: i32, dy: i32| { - (4..45) + (8..45) .map(|r| { let (a, b) = (px(&on, m[0] as i32 + dx * r, m[1] as i32 + dy * r), px(&off, m[0] as i32 + dx * r, m[1] as i32 + dy * r)); ((0..3).map(|c| (a[c] - b[c]).abs()).sum::(), r) @@ -4071,7 +4427,13 @@ mod tests { }; let (up, left) = (peak(0, -1), peak(-1, 0)); println!("{rotation} : pastille en {m:?}, anneau haut {up:?}, gauche {left:?}"); - assert!(up.0 > 60 && left.0 > 60, "{rotation}: anneau absent ({up:?} {left:?})"); + // Le preset incliné projette le trait plus obliquement sur un bord; son contraste + // mesuré est inférieur à celui du cas plat, sans que l'anneau disparaisse. + let min_delta = if rotation == "null" { 60 } else { 40 }; + assert!( + up.0 > min_delta && left.0 > min_delta, + "{rotation}: anneau absent ({up:?} {left:?})" + ); let tol = if rotation == "null" { 1 } else { 2 }; assert!((up.1 - left.1).abs() <= tol, "{rotation}: anneau décentré ({up:?} {left:?})"); } @@ -4095,6 +4457,12 @@ mod tests { &metal::CompileOptions::new(), ) .expect("shaders.metal doit compiler"); + // La variante des modèles 3D (`LAYER_MODELS`) : la seule à compiler leurs branches. + device + .new_library_with_source(&super::msl_source_with_models(), &metal::CompileOptions::new()) + .expect("shaders.metal doit compiler avec LAYER_MODELS") + .get_function("ps_main", None) + .expect("ps_main absent de la variante LAYER_MODELS"); for name in [ "vs_main", "vs_fs", @@ -4125,4 +4493,30 @@ mod tests { let comp = super::Compositor::new_sized(&gpu, 640, 360).expect("Compositor::new_sized"); assert_eq!(comp.render_size(), (640, 360)); } + + /// Un cran de padding en format Auto change la taille de rendu de la preview. `resized` + /// réalloue les cibles et garde le reste : le masque webcam, et la boîte aux lettres où le + /// worker de segmentation dépose les suivants. + #[test] + fn resizing_keeps_the_segmentation_and_reads_back_at_the_new_size() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let (w, h) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + comp.set_webcam_mask(&vec![255u8; (w * h) as usize], w, h).expect("masque"); + let inbox = std::sync::Arc::clone(&comp.seg_inbox); + + let comp = comp.resized(181, 321).expect("resized"); + + assert_eq!(comp.render_size(), (182, 322), "arrondi au pair, comme new_sized"); + assert!(comp.webcam_mask.borrow().is_some(), "le masque webcam s'est perdu"); + assert!( + std::sync::Arc::ptr_eq(&inbox, &comp.seg_inbox), + "le worker déposerait ses masques dans une boîte que plus personne ne lit", + ); + let (rw, rh, rgba) = unsafe { comp.readback_direct() }.expect("readback"); + assert_eq!((rw, rh, rgba.len()), (182, 322, 182 * 322 * 4)); + } } diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index da983553b..26dc8f67b 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -16,17 +16,17 @@ use crate::frame_geometry::{ WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC, }; use crate::cursor::CursorTrack; -use crate::scene::{Scene, SceneBackground, SceneCrop, SceneCursorSprite}; +use crate::scene::{Scene, SceneBackground, SceneCrop, SceneCursorSprite, WallpaperMotion}; 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::core::Interface; +use windows::Win32::Foundation::RECT; use windows::Win32::Graphics::Direct3D::{ - ID3DBlob, D3D11_SRV_DIMENSION_TEXTURE2DARRAY, D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, + D3D11_SRV_DIMENSION_TEXTURE2D, D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, }; use windows::Win32::Graphics::Direct3D11::*; use windows::Win32::Graphics::Dxgi::Common::*; @@ -75,9 +75,16 @@ struct WebcamMask { height: u32, } -/// Pyramide de la profondeur de champ (mode 8) : la vidéo en RGBA8 à demi-résolution de la -/// texture DÉCODEUR, avec sa chaîne de mips. Dimensionnée sur la texture décodeur et remplie en -/// UV plein (0..1) : ses UV sont ceux de t0/t1, et le calcul d'UV du mode 8 ne change pas. +/// Pyramide de la profondeur de champ (modes 8 et 18, en t5) : la vidéo en RGBA8 à +/// demi-résolution de la texture DÉCODEUR, avec sa chaîne de mips. Dimensionnée sur la texture +/// décodeur et remplie en UV plein (0..1) : ses UV sont ceux de t0/t1, et le calcul d'UV du +/// mode 8 ne change pas. +/// Le fond tel que la passe de fond (et son flou) l'a laissé dans le RT, et la clé qui le décrit. +struct BgCache { + key: crate::frame_geometry::BackgroundKey, + tex: ID3D11Texture2D, +} + struct DofPyramid { rtv: ID3D11RenderTargetView, srv: ID3D11ShaderResourceView, @@ -93,7 +100,10 @@ pub struct Compositor { rt_srv: ID3D11ShaderResourceView, staging: ID3D11Texture2D, vs: ID3D11VertexShader, + /// Le pixel shader des calques, SANS les modèles 3D (modes 15 à 17) : ils passent par + /// `ps_models`, et `draw_layer` choisit d'après le mode (le haut de `shaders.hlsl`). ps: ID3D11PixelShader, + ps_models: ID3D11PixelShader, vs_fs: ID3D11VertexShader, ps_y: ID3D11PixelShader, ps_uv: ID3D11PixelShader, @@ -129,6 +139,16 @@ pub struct Compositor { accum_rtv: ID3D11RenderTargetView, accum_srv: ID3D11ShaderResourceView, blend_add: ID3D11BlendState, + /// Rastérisation avec scissor, pour la seule recopie de la traînée du curseur. + rs_scissor: ID3D11RasterizerState, + /// Rendu isolé de l'écran cadré (ombre, cadre, métrage, appareil), transparent autour, que le + /// mode 18 recompose le long de sa trajectoire (`FrameGeometry::screen_trail`). Distinct de + /// `accum`, que le curseur et `compose_frame_mb` remplissent dans la même frame. + trail_rtv: ID3D11RenderTargetView, + trail_srv: ID3D11ShaderResourceView, + /// Le dernier fond fixe composé et sa clé (`BackgroundKey`) : recopié dans le RT tant + /// que la clé tient, au lieu d'être redessiné. + bg_cache: RefCell>, /// 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`. @@ -137,6 +157,8 @@ pub struct Compositor { /// (`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>, + /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`). + footage: std::cell::Cell>, /// 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. @@ -144,9 +166,11 @@ pub struct Compositor { timeline_t_override: RefCell>, /// Temps programme (secondes de sortie) — cf. `FrameGeometryInput::programme_time`. programme_time: 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>, + /// A private copy of a decoder surface, with its two plane views (Y, UV). + /// Keyed by the decoder texture's pointer, which pins nothing: `nv12_srvs` re-checks + /// the copy's size and format on every hit, so a new texture landing on an old address + /// is never handed a copy sized for the old one. `clear_srv_cache` frees stale entries. + 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). @@ -176,7 +200,7 @@ pub struct Compositor { /// appartient au jeu actif et ne peut pas être évincé — voir `cached_image`. img_frame_start: std::cell::Cell, /// Champs de distance des sprites de curseur (mode 15), R16F, par chemin, avec leur forme. - /// Pas d'éviction : seuls les seize sprites du thème par défaut y passent (~2,6 Mo en tout). + /// Pas d'éviction : seuls les sprites du thème en cours y passent (~2,6 Mo pour les seize). sdf_cache: RefCell>, /// Masque de segmentation du sujet webcam, R8 à la résolution du modèle. Écrit par /// `set_webcam_mask` depuis le thread d'inférence, lu au moment de dessiner la webcam. @@ -190,23 +214,28 @@ pub struct Compositor { /// 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 **forme** fausse dès que la sortie n'est pas 16:9 → rattrapée à + /// l'époque 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. + /// Rendre cette taille variable a retiré la cause commune, et `apply_undistort` + /// avec elle : le RT porte la géométrie de sortie. L'export le crée à la taille de + /// sortie, la preview à cette géométrie ramenée au panneau (`preview_render_size`). + /// `OUT_W`/`OUT_H` ne sont plus qu'une valeur par défaut : la taille du RT de + /// `Compositor::new`, et la référence en px des placements de repli + /// (`preset_placements`, la fixture `timeline`) quand la scène n'apporte pas ses + /// rects. Une scène de l'app, qui fournit ses rects, ne s'y réfère pas. 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`. + /// Ressources de resize, allouées paresseusement à la première cible qui diffère de la + /// taille de rendu. Le RT suivant la sortie, le cas nominal n'y passe pas et ne paie rien. + /// 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`. + /// Cache de la staging texture de `readback_resized`, dimensionnée à la dernière taille + /// demandée (le `staging` principal, lui, suit la taille de rendu). Recréée quand la taille + /// change. La preview n'y passe plus : elle relit le RT tel quel (`readback_direct`). live_readback_staging: RefCell>, /// Cible + staging pour extraire la frame webcam à la résolution du modèle de /// segmentation. Créée à la première capture, jamais redimensionnée : le modèle a une @@ -239,10 +268,9 @@ pub struct Compositor { cpu_backend: bool, } -/// 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). +/// Ressources d'un resize à une taille cible : RGBA intermédiaire (le RT composé, rendu à la +/// taille de rendu, redimensionné en bilinéaire) + sa propre texture NV12 à cette même taille +/// cible (le NV12 principal du `Compositor` reste à la taille de rendu). struct ResizeTarget { w: u32, h: u32, @@ -253,6 +281,32 @@ struct ResizeTarget { nv12_rtv_uv: ID3D11RenderTargetView, } +/// Tout ce qui a la taille du rendu, et rien d'autre : ce que `new_inner` alloue et que +/// `resized` réalloue. Le reste du compositeur ne dépend pas de la taille et la traverse. +struct Targets { + rt: ID3D11Texture2D, + rtv: ID3D11RenderTargetView, + rt_srv: ID3D11ShaderResourceView, + staging: ID3D11Texture2D, + nv12: ID3D11Texture2D, + rtv_y: ID3D11RenderTargetView, + rtv_uv: ID3D11RenderTargetView, + half_a_rtv: ID3D11RenderTargetView, + half_a_srv: ID3D11ShaderResourceView, + half_b_rtv: ID3D11RenderTargetView, + half_b_srv: ID3D11ShaderResourceView, + q_rtv: ID3D11RenderTargetView, + q_srv: ID3D11ShaderResourceView, + e_rtv: ID3D11RenderTargetView, + e_srv: ID3D11ShaderResourceView, + ann_copy: ID3D11Texture2D, + ann_copy_srv: ID3D11ShaderResourceView, + accum: ID3D11Texture2D, + accum_rtv: ID3D11RenderTargetView, + accum_srv: ID3D11ShaderResourceView, + trail_rtv: ID3D11RenderTargetView, + trail_srv: ID3D11ShaderResourceView, +} @@ -264,33 +318,13 @@ struct ResizeTarget { -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()) + +/// Bytecode d'un point d'entrée de `shaders.hlsl`, compilé par `build.rs` (`compile_hlsl`) : +/// aucun `D3DCompile` à l'exécution, donc rien à payer quand le compositeur se construit. +macro_rules! shader { + ($entry:literal) => { + include_bytes!(concat!(env!("OUT_DIR"), "/", $entry, ".cso")) + }; } impl Compositor { @@ -305,10 +339,8 @@ impl Compositor { /// 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. + /// sur le chemin GPU chaud. Pour changer de taille, `resized` rend un + /// compositeur dont seules les cibles sont neuves. /// /// Les dimensions passées sont arrondies via `normalize_render_size` (pair, /// ≥2 — contrainte NV12). L'appelant qui décide de reconstruire DOIT comparer @@ -332,10 +364,73 @@ impl Compositor { (((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(); + /// Le même compositeur, rastérisant à `w`×`h` : seules les cibles (`Targets`) sont + /// réallouées. Scène, paramètres, curseur, caches d'images et segmentation webcam restent en + /// place. + /// + /// Reconstruire le compositeur entier, comme le faisait la preview, rechargeait le modèle de + /// segmentation et repartait sans masque : l'effet webcam s'éteignait jusqu'à la première + /// inférence. Un format Auto change de forme à chaque cran de padding, donc à chaque cran. + pub fn resized(self, w: u32, h: u32) -> Result { + let (w, h) = Self::normalize_render_size(w, h); + let Targets { + rt, + rtv, + rt_srv, + staging, + nv12, + rtv_y, + rtv_uv, + half_a_rtv, + half_a_srv, + half_b_rtv, + half_b_srv, + q_rtv, + q_srv, + e_rtv, + e_srv, + ann_copy, + ann_copy_srv, + accum, + accum_rtv, + accum_srv, + trail_rtv, + trail_srv, + } = unsafe { Self::make_targets(&self.dev, w, h)? }; + Ok(Compositor { + rt, + rtv, + rt_srv, + staging, + nv12, + rtv_y, + rtv_uv, + half_a_rtv, + half_a_srv, + half_b_rtv, + half_b_srv, + q_rtv, + q_srv, + e_rtv, + e_srv, + ann_copy, + ann_copy_srv, + accum, + accum_rtv, + accum_srv, + trail_rtv, + trail_srv, + render_size: Cell::new((w, h)), + // Caches dimensionnés à l'ancienne taille : recréés à la demande. + resize_target: RefCell::new(None), + bg_cache: RefCell::new(None), + live_readback_staging: RefCell::new(None), + nv12_readback_staging: RefCell::new(None), + ..self + }) + } + unsafe fn make_targets(dev: &ID3D11Device, out_w: u32, out_h: u32) -> Result { // --- render target RGBA8 (gamma natif de la vidéo ; voir note couleur docs) --- let mut td = D3D11_TEXTURE2D_DESC { Width: out_w, @@ -364,81 +459,6 @@ impl Compositor { 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 { @@ -471,38 +491,6 @@ impl Compositor { 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 { @@ -534,8 +522,9 @@ impl Compositor { 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))?; + let (trail_rtv, trail_srv) = mk_rgba(out_w, out_h)?; - // accumulateur pleine réso (RGBA) + blend additif pondéré (facteur = 1/N) + // accumulateur pleine réso (RGBA) let ad = D3D11_TEXTURE2D_DESC { Width: out_w, Height: out_h, @@ -572,6 +561,134 @@ impl Compositor { let mut ann_copy_srv: Option = None; dev.CreateShaderResourceView(&ann_copy, None, Some(&mut ann_copy_srv))?; + Ok(Targets { + rt, + rtv: rtv.unwrap(), + rt_srv: rt_srv.unwrap(), + staging: staging.unwrap(), + nv12, + rtv_y, + rtv_uv, + half_a_rtv, + half_a_srv, + half_b_rtv, + half_b_srv, + 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(), + trail_rtv, + trail_srv, + }) + } + + unsafe fn new_inner(gpu: &Gpu, out_w: u32, out_h: u32) -> Result { + let dev = gpu.device.clone(); + let ctx = gpu.context.clone(); + let Targets { + rt, + rtv, + rt_srv, + staging, + nv12, + rtv_y, + rtv_uv, + half_a_rtv, + half_a_srv, + half_b_rtv, + half_b_srv, + q_rtv, + q_srv, + e_rtv, + e_srv, + ann_copy, + ann_copy_srv, + accum, + accum_rtv, + accum_srv, + trail_rtv, + trail_srv, + } = Self::make_targets(&dev, out_w, out_h)?; + + // --- shaders --- + let mut vs: Option = None; + dev.CreateVertexShader(shader!("vs_main"), None, Some(&mut vs))?; + let mut ps: Option = None; + dev.CreatePixelShader(shader!("ps_main"), None, Some(&mut ps))?; + let mut ps_models: Option = None; + dev.CreatePixelShader(shader!("ps_main_models"), None, Some(&mut ps_models))?; + + // shaders RGB->NV12 + let mut vs_fs: Option = None; + dev.CreateVertexShader(shader!("vs_fs"), None, Some(&mut vs_fs))?; + let mut ps_y: Option = None; + dev.CreatePixelShader(shader!("ps_y"), None, Some(&mut ps_y))?; + let mut ps_uv: Option = None; + dev.CreatePixelShader(shader!("ps_uv"), 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))?; + + // shaders de flou + copie + let mut ps_blur: Option = None; + dev.CreatePixelShader(shader!("ps_blur"), None, Some(&mut ps_blur))?; + let mut ps_tex: Option = None; + dev.CreatePixelShader(shader!("ps_tex"), None, Some(&mut ps_tex))?; + + // shaders dual-Kawase + let mut ps_kdown: Option = None; + dev.CreatePixelShader(shader!("ps_kawase_down"), None, Some(&mut ps_kdown))?; + let mut ps_kup: Option = None; + dev.CreatePixelShader(shader!("ps_kawase_up"), None, Some(&mut ps_kup))?; + + // blend additif pondéré (facteur = 1/N) de l'accumulateur let mut bla = D3D11_BLEND_DESC::default(); bla.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC { BlendEnable: true.into(), @@ -585,16 +702,28 @@ impl Compositor { }; let mut blend_add: Option = None; dev.CreateBlendState(&bla, Some(&mut blend_add))?; + // L'état par défaut (aucun lié), plus le scissor. Sans culling : le triangle plein écran + // n'a pas à dépendre de son sens de parcours. + let rsd = D3D11_RASTERIZER_DESC { + FillMode: D3D11_FILL_SOLID, + CullMode: D3D11_CULL_NONE, + ScissorEnable: true.into(), + DepthClipEnable: true.into(), + ..Default::default() + }; + let mut rs_scissor: Option = None; + dev.CreateRasterizerState(&rsd, Some(&mut rs_scissor))?; Ok(Compositor { dev, ctx, rt, - rtv: rtv.unwrap(), - rt_srv: rt_srv.unwrap(), - staging: staging.unwrap(), + rtv, + rt_srv, + staging, vs: vs.unwrap(), ps: ps.unwrap(), + ps_models: ps_models.unwrap(), vs_fs: vs_fs.unwrap(), ps_y: ps_y.unwrap(), ps_uv: ps_uv.unwrap(), @@ -618,13 +747,18 @@ impl Compositor { e_rtv, e_srv, ann_copy, - ann_copy_srv: ann_copy_srv.unwrap(), + ann_copy_srv, accum, - accum_rtv: accum_rtv.unwrap(), - accum_srv: accum_srv.unwrap(), + accum_rtv, + accum_srv, blend_add: blend_add.unwrap(), + rs_scissor: rs_scissor.unwrap(), + trail_rtv, + trail_srv, + bg_cache: RefCell::new(None), cursor: RefCell::new(None), cursor_t_override: RefCell::new(None), + footage: std::cell::Cell::new(None), timeline_t_override: RefCell::new(None), programme_time: RefCell::new(None), srv_cache: RefCell::new(HashMap::new()), @@ -708,41 +842,101 @@ impl Compositor { *self.scene.borrow_mut() = s; } - /// Crée les SRV Y (R8) et UV (R8G8) sur la tranche d'array de la frame décodeur. + /// The Y (R8) and UV (R8G8) views of the decoder frame — over a private COPY, never + /// over the decoder surface itself. + /// + /// Two documented D3D11 rules rule out the direct path. The first is explicit, on + /// `D3D11_BIND_DECODER`: "you cannot use texture arrays that are created with this + /// flag in calls to ID3D11Device::CreateShaderResourceView". The ffmpeg pool IS such + /// an array (`initial_pool_size` = 32, see `get_hw_format`). The second explains what + /// we were seeing: between the video engine and the 3D pipeline "there is no automatic + /// hazard tracking" — the surface stays the decoder's reference frame, so it may be + /// rewritten WHILE the shader samples it. ffmpeg's `ID3D11VideoContext` is in fact the + /// SAME object as our immediate context (it comes out of a QueryInterface on it), and + /// `SetMultithreadProtected(TRUE)` only makes an individual call atomic, never a + /// sequence. + /// + /// Measured on a Snapdragon X Elite (Adreno X1-85), same build, same recording, the + /// path picked by an environment variable: 768 black frames out of 792 when sampling + /// the decoder surface, 2 out of 545 through the copy. The black was OPAQUE and total, + /// so not "one frame late" but no frame at all. + /// + /// `CopySubresourceRegion` is issued on the immediate context, so it IS ordered against + /// the draws that follow; the destination texture is `ArraySize = 1` and carries only + /// `BIND_SHADER_RESOURCE`, which also takes it out of the restriction above. The cost + /// is one GPU→GPU copy per frame and per source, with nothing going back to system + /// memory. + /// + /// What hid the cause for so long: exporting the SAME scene never produced a black + /// frame (28 342 verified), because the export drains the GPU every frame through the + /// encoder. Everything that slowed the live loop — one more readback, a lock, a pause — + /// cut the black proportionally without ever removing it. 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"))? + let src = ID3D11Texture2D::from_raw_borrowed(&tex_ptr) + .ok_or_else(|| anyhow::anyhow!("frame has no D3D11 texture"))? .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()) + // Cache hit: the pool reuses the same textures, so this allocates once per decoder + // after warmup. The COPY itself happens on EVERY frame — that is what freezes the + // slice's contents before the decoder takes it back. + // + // The key is a bare address and pins nothing. A decoder replaced without + // `clear_srv_cache` (a mid-stream resolution change, `CpuFrames::ensure_tex`) can + // hand a NEW texture the address of an old one, and a copy into a smaller destination + // is dropped without an error: the picture freezes. So a hit only counts while the + // destination still has the source's size and format. + let mut sd = D3D11_TEXTURE2D_DESC::default(); + src.GetDesc(&mut sd); + let key = tex_ptr as usize; + let cached = self.srv_cache.borrow().get(&key).cloned().filter(|(dst, ..)| { + let mut dd = D3D11_TEXTURE2D_DESC::default(); + dst.GetDesc(&mut dd); + (dd.Width, dd.Height, dd.Format) == (sd.Width, sd.Height, sd.Format) + }); + let (dst, y, uv) = match cached { + Some(v) => v, + None => { + let dd = D3D11_TEXTURE2D_DESC { + Width: sd.Width, + Height: sd.Height, + MipLevels: 1, + ArraySize: 1, + Format: sd.Format, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut dst: Option = None; + self.dev.CreateTexture2D(&dd, None, Some(&mut dst))?; + let dst = dst.unwrap(); + let mk = |fmt: DXGI_FORMAT| -> Result { + let mut d = D3D11_SHADER_RESOURCE_VIEW_DESC { + Format: fmt, + ViewDimension: D3D11_SRV_DIMENSION_TEXTURE2D, + ..Default::default() + }; + d.Anonymous.Texture2D = D3D11_TEX2D_SRV { MostDetailedMip: 0, MipLevels: 1 }; + let mut srv: Option = None; + self.dev.CreateShaderResourceView(&dst, 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, (dst.clone(), y.clone(), uv.clone())); + (dst, y, uv) + } }; - 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())); + + self.ctx.CopySubresourceRegion(&dst, 0, 0, 0, 0, &src, slice, None); Ok((y, uv)) } @@ -777,6 +971,26 @@ impl Compositor { self.ctx.ClearRenderTargetView(&self.rtv, &clear); } + /// Garde le fond que le RT porte à cet instant (`bg_cache`), sous `key`. + unsafe fn keep_background(&self, key: crate::frame_geometry::BackgroundKey) -> Result<()> { + // L'ancienne texture est reprise : `resized` vide le cache, sa taille est la bonne. + let old = self.bg_cache.borrow_mut().take().map(|c| c.tex); + let tex = match old { + Some(tex) => tex, + None => { + let mut d = D3D11_TEXTURE2D_DESC::default(); + self.rt.GetDesc(&mut d); + d.BindFlags = 0; + let mut tex: Option = None; + self.dev.CreateTexture2D(&d, None, Some(&mut tex))?; + tex.ok_or_else(|| anyhow::anyhow!("CreateTexture2D (fond gardé)"))? + } + }; + self.ctx.CopyResource(&tex, &self.rt); + *self.bg_cache.borrow_mut() = Some(BgCache { key, tex }); + Ok(()) + } + /// Passe plein écran générique (triangle unique) : `srv` -> `rtv` via `ps`, avec `fx`. unsafe fn fs_pass( &self, @@ -804,32 +1018,37 @@ impl Compositor { } /// 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 + /// Chaîne down (RT→960→480→240) puis up (240→480→960→RT), tronquée au nombre de niveaux + /// que demande la force (`background_blur_steps`). ~5-8 taps par passe à résolution + /// décroissante, vs 2 passes gaussiennes 49-tap. Le RT devient le fond. + pub unsafe fn blur_bg(&self, amount: f32) { + let Some((levels, off)) = crate::frame_geometry::background_blur_steps(amount) else { + return; + }; // 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]); + let pyramid = [ + (&self.rtv, &self.rt_srv, rw_i, rh_i), + (&self.half_a_rtv, &self.half_a_srv, half_w, half_h), + (&self.q_rtv, &self.q_srv, half_w / 2, half_h / 2), + (&self.e_rtv, &self.e_srv, half_w / 4, half_h / 4), + ]; + // texel = 1/(dims de la SOURCE échantillonnée) + let fx = |(w, h): (u32, u32)| [1.0 / w.max(1) as f32, 1.0 / h.max(1) as f32, off, 0.0]; + for i in 0..levels { + let (_, src, sw, sh) = pyramid[i]; + let (dst, _, dw, dh) = pyramid[i + 1]; + self.fs_pass(dst, src, &self.ps_kdown, dw, dh, fx((sw, sh))); + } + for i in (0..levels).rev() { + let (_, src, sw, sh) = pyramid[i + 1]; + let (dst, _, dw, dh) = pyramid[i]; + self.fs_pass(dst, src, &self.ps_kup, dw, dh, fx((sw, sh))); + } } /// Remplit la pyramide de profondeur de champ depuis la frame écran et rend sa SRV. @@ -877,9 +1096,9 @@ impl Compositor { let pyr = self.dof_pyramid.borrow(); let pyr = pyr.as_ref().expect("pyramide allouée ci-dessus"); self.bind_compose_state(); - // Délie t2 : la pyramide y est peut-être encore liée depuis le mode 8 précédent, et une - // ressource liée en lecture ET en écriture est retirée d'office par le runtime. - self.ctx.PSSetShaderResources(2, Some(&[None])); + // Délie t5 : la pyramide y est peut-être encore liée depuis le mode 8 ou 18 précédent, et + // une ressource liée en lecture ET en écriture est retirée d'office par le runtime. + self.ctx.PSSetShaderResources(5, Some(&[None])); self.ctx.OMSetRenderTargets(Some(&[Some(pyr.rtv.clone())]), None); self.ctx.ClearRenderTargetView(&pyr.rtv, &[0.0, 0.0, 0.0, 0.0]); self.ctx.RSSetViewports(Some(&[D3D11_VIEWPORT { @@ -923,6 +1142,15 @@ impl Compositor { self.ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())])); } + /// Dessine le quad d'un calque, ses ressources déjà liées. Le pixel shader suit son mode : + /// `ps_models` pour un modèle 3D (`LayerCB::needs_models`), `ps` pour tout le reste, qui ne + /// paie pas ainsi les registres de ces modèles. + unsafe fn draw_layer(&self, cb: &LayerCB) { + self.upload_cb(cb); + self.ctx.PSSetShader(if cb.needs_models() { &self.ps_models } else { &self.ps }, None); + self.ctx.Draw(4, 0); + } + /// Calque vidéo NV12. pub unsafe fn draw_video( &self, @@ -930,16 +1158,14 @@ impl Compositor { 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); + self.draw_layer(cb); } /// Calque couleur pleine (fond). pub unsafe fn draw_solid(&self, cb: &LayerCB) { - self.upload_cb(cb); - self.ctx.Draw(4, 0); + self.draw_layer(cb); } /// Fond wallpaper image (cover-fit). `path` = chemin absolu (résolu côté app). Décodé et @@ -993,13 +1219,22 @@ impl Compositor { Ok((srv, w, h)) } - unsafe fn draw_image_bg(&self, path: &str, output_aspect: f32) -> Result<()> { - self.draw_image_in(path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, output_aspect) + unsafe fn draw_image_bg( + &self, + path: &str, + output_aspect: f32, + motion: WallpaperMotion, + programme_t: f32, + ) -> Result<()> { + let full = [0.0, 0.0, 1.0, 1.0]; + self.draw_image_in(path, full, [0.0, 0.0], 0.0, output_aspect, motion, programme_t) } /// `draw_image_bg` pour un rect quelconque — la bulle webcam s'en sert avec ses coins /// arrondis. `output_aspect` est le ratio du RECT visé, pas celui de la sortie : le crop - /// « cover » se calcule contre la zone qu'on remplit. + /// « cover » se calcule contre la zone qu'on remplit. `motion` anime l'image au temps + /// programme `programme_t` ; la bulle passe `WallpaperMotion::None`. + #[allow(clippy::too_many_arguments)] unsafe fn draw_image_in( &self, path: &str, @@ -1007,15 +1242,14 @@ impl Compositor { quad_px: [f32; 2], radius_px: f32, output_aspect: f32, + motion: WallpaperMotion, + programme_t: f32, ) -> Result<()> { let (srv, iw, ih) = self.cached_image(path)?; let ai = iw as f32 / ih as f32; - // Le fond remplit TOUJOURS le cadre (dst=[0,0,1,1], jamais rétréci par `undistort`), - // mais le canvas interne est un 16:9 fixe étiré ensuite vers le VRAI ratio de sortie - // (`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). + // Crop « cover » contre le ratio de la zone remplie (`output_aspect`) : l'image garde ses + // proportions, rognée sur l'axe en trop. Le RT porte la géométrie de sortie, donc rien ne + // la ré-étire ensuite. let ao = output_aspect; let (u0, v0, u1, v1) = if ai > ao { let vis = ao / ai; // rogne horizontalement @@ -1024,16 +1258,18 @@ impl Compositor { 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 { + let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots(motion, programme_t, ao); + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.draw_layer(&LayerCB { dst, src: [u0, v0, u1, v1], quad_px, radius_px, mode: 6.0, + fx: [0.0, 0.0, anim[0], anim[1]], + mb, ..Default::default() }); - self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); - self.ctx.Draw(4, 0); Ok(()) } @@ -1070,9 +1306,7 @@ impl Compositor { self.draw_solid(&solid(parse_hex(color).unwrap_or(BLACK))); } // Le mouvement ne vaut que pour le fond d'écran : la bulle garde son dégradé immobile. - Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { - let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); - let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + Some(SceneBackground::Gradient { angle_deg, stops, offsets, .. }) => { // angle CSS → direction unitaire, même convention que le fond d'écran. let a = angle_deg.to_radians(); let dir = [a.sin(), -a.cos()]; @@ -1080,18 +1314,18 @@ impl Compositor { dst, quad_px, radius_px, - src: [c1[0], c1[1], c1[2], c1[3]], - mode: 5.0, - color: c0, fx: [dir[0], dir[1], 0.0, 0.0], - ..Default::default() + ..crate::frame_geometry::gradient_layer(stops, offsets, BLACK) }); } - Some(SceneBackground::Image { path }) => { + Some(SceneBackground::Image { path, .. }) => { // Même contrat que le fond d'écran : un chemin cassé est loggé puis remplacé par // du noir. Un fallback silencieux redonnerait le bug qu'on corrige. let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 }; - if let Err(e) = self.draw_image_in(path, dst, quad_px, radius_px, aspect) { + let still = WallpaperMotion::None; + if let Err(e) = + self.draw_image_in(path, dst, quad_px, radius_px, aspect, still, 0.0) + { eprintln!("[compositor] fond webcam \"{}\" : {:#}", path, e); self.draw_solid(&solid(BLACK)); } @@ -1550,7 +1784,7 @@ impl Compositor { /// 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]) { + unsafe fn draw_cursor(&self, center: [f32; 2], size_px: f32, a: f32, clip: [f32; 4]) -> [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]; @@ -1562,6 +1796,7 @@ impl Compositor { fx: clip, ..Default::default() }); + dst } /// Curseur thème (sprite PNG, ex. arrow.png) dont le PIVOT `hotspot` (fraction 0..1 de @@ -1579,7 +1814,7 @@ impl Compositor { sprite: &SceneCursorSprite, clip: [f32; 4], model: Option, - ) -> Result<()> { + ) -> Result> { let path = sprite.path.as_str(); let (srv, iw, ih) = self.cached_image(path)?; // Sans champ de distance, repli sur le sprite plat plutôt que sur le curseur math. @@ -1587,22 +1822,32 @@ impl Compositor { if let Some(pose) = model { match self.cursor_sdf(path) { Ok((sdf, shape)) => { - let shape = - SpriteShape { hotspot: [sprite.hotspot_x, sprite.hotspot_y], ..shape }; - if let Some(cb) = crate::frame_geometry::cursor_model_cb( + let shape = crate::frame_geometry::model_shape(sprite, shape); + let Some(cb) = crate::frame_geometry::cursor_model_cb( placement, size_px, pose, shape, a, clip, - ) { - self.upload_cb(&cb); - self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); - self.ctx.PSSetShaderResources(4, Some(&[Some(sdf)])); - self.ctx.Draw(4, 0); - self.ctx.PSSetShaderResources(4, Some(&[None])); - } - return Ok(()); + ) else { + return Ok(None); + }; + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.ctx.PSSetShaderResources(4, Some(&[Some(sdf)])); + self.draw_layer(&cb); + self.ctx.PSSetShaderResources(4, Some(&[None])); + return Ok(Some(cb.dst)); } Err(e) => eprintln!("[curseur] champ de \"{path}\" : {e:#}"), } } + // Le verre à plat de Glass Lens (mode 19) : la copie qu'il réfracte est déjà en t5 + // (`CursorPlan::glass`). + if model.is_none() { + let glass = sprite.glass.as_deref().and_then(|name| { + crate::frame_geometry::cursor_glass_cb(placement, size_px, name, a, clip, [self.rw(), self.rh()]) + }); + if let Some(cb) = glass { + self.draw_layer(&cb); + return Ok(Some(cb.dst)); + } + } 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 cb = crate::frame_geometry::cursor_sprite_cb( @@ -1613,10 +1858,9 @@ impl Compositor { clip, [self.rw(), self.rh()], ); - self.upload_cb(&cb); self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); - self.ctx.Draw(4, 0); - Ok(()) + self.draw_layer(&cb); + Ok(Some(cb.dst)) } /// Sprite de l'état courant (`cursor_type`, ex. `"text"`), à défaut celui de la flèche, @@ -1636,31 +1880,25 @@ impl Compositor { a: f32, clip: [f32; 4], model: Option, - ) { + ) -> Option<[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, model).is_ok() { - return; + if let Ok(dst) = self.draw_cursor_sprite(placement, size_px, a, sprite, clip, model) { + return dst; } } // 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); + Some(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. + /// Le quad d'ombre est élargi de `spread` px et décalé de `offset_px`, des px de SORTIE + /// (même convention que `radius_px` pour l'arrondi normal, cf. `compose_frame`), convertis + /// ici en fractions du render target, axe par axe. Le RT porte la géométrie de sortie : le + /// halo reste isotrope sans correction. `mb.yz` reste à 1 et le shader ne le lit plus : du + /// temps du canvas figé en 16:9, il portait l'étirement de sortie que la SDF devait annuler. pub unsafe fn draw_shadow( &self, dst: [f32; 4], @@ -1694,11 +1932,14 @@ impl Compositor { /// `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. + #[allow(clippy::too_many_arguments)] pub unsafe fn draw_quad_shadow( &self, corners: &[(f32, f32); 4], center_px: [f32; 2], radius: f32, + // Le slot d'un layout en bloc, qui rogne le plan (`shadow_mask_fields`). + mask: Option, spread: f32, offset_px: [f32; 2], opacity: f32, @@ -1719,6 +1960,11 @@ impl Compositor { let [tr0, tr1] = local(corners[1]); let [br0, br1] = local(corners[2]); let [bl0, bl1] = local(corners[3]); + let (mask_rect, mask_radius) = crate::frame_geometry::shadow_mask_fields( + mask, + [center_px[0] + min_x - spread, center_px[1] + min_y - spread], + [self.rw(), self.rh()], + ); self.draw_solid(&LayerCB { dst: [ origin_x / self.rw(), @@ -1732,11 +1978,17 @@ impl Compositor { 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], + dst_prev: mask_rect, + mb: [0.0, spread, 1.0, mask_radius], ..Default::default() }); } + /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`). + pub fn footage_quad(&self) -> Option { + self.footage.get() + } + /// 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( @@ -1780,6 +2032,7 @@ impl Compositor { timeline_t_override: *self.timeline_t_override.borrow(), programme_time: *self.programme_time.borrow(), }); + self.footage.set(Some(g.footage_quad([self.rw(), self.rh()]))); let scene_preset = g.scene_preset.clone(); let mb_taps = g.mb_taps; let mb_amount = g.mb_amount; @@ -1802,8 +2055,6 @@ impl Compositor { let dof = g.depth_of_field_on(self.cpu_backend); let dof_srv = if dof { Some(self.fill_dof_pyramid(&sy, &suv, stw, sth)?) } else { None }; - 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 @@ -1812,90 +2063,120 @@ impl Compositor { // 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, motion } => { - 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()]; - let (anim, mb) = crate::frame_geometry::gradient_motion_slots( - motion, - g.programme_t, - self.rw() / self.rh(), - ); - 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], anim[0], anim[1]], - mb, - ..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); + // Le fond de la frame d'avant, s'il est celui qu'on demande (`BackgroundKey`), est recopié + // au lieu d'être redessiné. Côté scène seulement : sans scène, le fond est la vidéo floutée. + let bg_size = [self.rw(), self.rh()]; + let bg_cached = scene_ref.as_ref().is_some_and(|s| { + self.bg_cache + .borrow() + .as_ref() + .is_some_and(|c| { + c.key.matches(Some(&s.background), s.effects.blur, lp.bg_color, bg_size) + }) + }); + if bg_cached { + self.bind_compose_state(); + if let Some(c) = self.bg_cache.borrow().as_ref() { + self.ctx.CopyResource(&self.rt, &c.tex); + } + } else { + self.begin([0.0, 0.0, 0.0, 1.0]); + // Faux si l'image du fond n'a pas pu se charger : son repli n'est pas gardé. + let mut bg_drawn = true; + 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: lp.bg_color, + color: c, ..Default::default() }); } + SceneBackground::Gradient { angle_deg, stops, offsets, motion } => { + // 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()]; + let (anim, mb) = crate::frame_geometry::wallpaper_motion_slots( + motion, + g.programme_t, + self.rw() / self.rh(), + ); + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + fx: [dir[0], dir[1], anim[0], anim[1]], + mb, + ..crate::frame_geometry::gradient_layer(&stops, &offsets, lp.bg_color) + }); + } + SceneBackground::Image { path, motion } => { + // 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). + let aspect = self.rw() / self.rh(); + if let Err(e) = self.draw_image_bg(&path, aspect, motion, g.programme_t) { + eprintln!("[compositor] wallpaper image \"{}\" : {:#}", path, e); + bg_drawn = false; + 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); + // « 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 > 0.0 { + self.blur_bg(blur_wallpaper); + self.bind_compose_state(); + } + } else if cfg.bg_blur > 0.0 { + 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(cfg.bg_blur); 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], + 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() - }, - &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() + }); + } else { + self.draw_solid(&LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + mode: 1.0, + color: lp.bg_color, + ..Default::default() + }); + } + // Pas de cache pour un fond animé (`of` rend `None`). + let key = scene_ref.as_ref().filter(|_| bg_drawn).and_then(|s| { + crate::frame_geometry::BackgroundKey::of( + Some(&s.background), + s.effects.blur, + lp.bg_color, + bg_size, + ) }); + if let Some(key) = key { + self.keep_background(key)?; + } } // --- screen : crop du clip actif, puis zoom appliqué dans ce rect source (§8) --- @@ -1924,8 +2205,16 @@ impl Compositor { // Avec un cadre de fenêtre, c'est le CADRE qui porte l'ombre (`shadow_caster`), sinon // elle tomberait sous l'écran seul et la barre de titre flotterait au-dessus. let render_px = [self.rw(), self.rh()]; + // Flou de mouvement de l'écran CADRÉ (`FrameGeometry::screen_trail`) : ombre, cadre, + // métrage et appareil se dessinent dans un rendu isolé et transparent, que le mode 18 + // recompose ensuite sur le fond le long de la trajectoire de la boîte, ou du plan incliné. + let trail = g.screen_trail(render_px); + if trail { + self.ctx.OMSetRenderTargets(Some(&[Some(self.trail_rtv.clone())]), None); + self.ctx.ClearRenderTargetView(&self.trail_rtv, &[0.0, 0.0, 0.0, 0.0]); + } if cfg.shadow { - let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; + let spread = SCREEN_SHADOW_SPREAD_FRAC * g.screen_unit_px; let offset = g.screen_shadow_offset(); let opacity = 0.45 * lp.shadow_scale; // Un appareil porte l'ombre de sa silhouette 3D (mode 17), pas celle d'un quad. @@ -1937,9 +2226,8 @@ impl Compositor { self.draw_shadow(dst, size_px, radius, spread, offset, opacity) } // Même rayon que le plan incliné lui-même (cf. le dessin du mode 8). - ShadowCaster::Tilted { corners, center_px, radius } => { - self.draw_quad_shadow(&corners, center_px, radius, spread, offset, opacity) - } + ShadowCaster::Tilted { corners, center_px, radius, mask } => self + .draw_quad_shadow(&corners, center_px, radius, mask, spread, offset, opacity), } } } @@ -1958,10 +2246,9 @@ impl Compositor { // rendus dans le repère DU PLAN : sans eux le plan a des arêtes de couteau qui // tranchent le contenu en pleine phrase, et l'œil lit une découpe là où il devrait // lire une inclinaison. - // t2 EXPLICITE : `draw_video` ne lie que t0/t1, et t2 garde sinon ce que le draw - // précédent y a laissé (un wallpaper, un sprite). `None` quand l'effet est coupé : - // `k = 0`, le shader n'y lit rien. - self.ctx.PSSetShaderResources(2, Some(&[dof_srv.clone()])); + // La pyramide de profondeur de champ en t5, que `draw_video` ne lie pas. `None` quand + // l'effet est coupé : `k = 0`, le shader n'y lit rien. + self.ctx.PSSetShaderResources(5, Some(&[dof_srv.clone()])); self.draw_video( &crate::frame_geometry::tilted_screen_cb( &quad, @@ -1973,23 +2260,33 @@ impl Compositor { top_lift, dof, render_px, + g.screen_mask, + g.tilt_pixel_trail(render_px), ), &sy, &suv, ); - self.ctx.PSSetShaderResources(2, Some(&[None])); + self.ctx.PSSetShaderResources(5, Some(&[None])); } else { + // Sous le masque d'un layout en bloc, rogné au slot (`FrameGeometry::mask_flat_screen`). + let (dst, src, quad_px, radius_px) = g.mask_flat_screen( + s_dst, + [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv], + s_px, + s_radius, + render_px, + ); 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, + dst, + src, + quad_px, + radius_px, 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, mb_amount, top_lift, square_top], + mb: [g.screen_pixel_taps(render_px), mb_amount, top_lift, square_top], ..Default::default() }, &sy, @@ -2002,6 +2299,21 @@ impl Compositor { if let Some(cb) = g.device_frame_cb(render_px) { self.draw_solid(&cb); } + if trail { + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); + // t2 = l'écran cadré isolé ; t5 = la pyramide, avec laquelle le repli relit le + // métrage hors de la sortie comme le mode 8 l'a dessiné. + self.ctx.PSSetShaderResources(2, Some(&[Some(self.trail_srv.clone())])); + self.ctx.PSSetShaderResources(5, Some(&[dof_srv.clone()])); + self.draw_video(&g.screen_trail_cb(render_px, dof), &sy, &suv); + self.ctx.PSSetShaderResources(2, Some(&[None])); + self.ctx.PSSetShaderResources(5, Some(&[None])); + } + + // --- flous de confidentialité : sur le métrage, AVANT le curseur. Le curseur reste net + // par-dessus, et le cristal de Prism Glow, qui réfracte l'image composée, n'y voit que des + // pixels déjà floutés. Les autres annotations restent le calque le plus haut (plus bas). + self.draw_annotations(scene_ref.as_ref(), source_t, s_ann, &g, true); // --- curseur custom : suit le mapping src/dst (zoom+layout), click bounce, // et flou de mouvement (parité `compositor_macos.rs` et `compositor_linux.rs`) --- @@ -2026,6 +2338,13 @@ impl Compositor { .map(|s| s.cursor.cursor_sprites.clone()) .unwrap_or_default(); let cursor_type = plan.cursor_type.as_deref(); + // Le cristal de Prism Glow réfracte l'image telle qu'elle est composée à cet + // instant, flous compris : sa copie en t5, une fois pour toutes les copies de la + // traînée (`ann_copy` est libre, les flous l'ont déjà lue). + if plan.glass { + self.ctx.CopySubresourceRegion(&self.ann_copy, 0, 0, 0, 0, &self.rt, 0, None); + self.ctx.PSSetShaderResources(5, Some(&[Some(self.ann_copy_srv.clone())])); + } // L'impact des clics (mode 16, sans texture), posé sur l'écran SOUS le curseur. for cb in &plan.impacts { self.draw_solid(cb); @@ -2045,11 +2364,14 @@ impl Compositor { // buffer ISOLÉ (transparent), pas directement sur la scène déjà composée. self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); + // L'union des quads peints dans `accum` (x0, y0, x1, y1 en fractions de la + // sortie) : la seule région que la recopie doit relire. + let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; for k in 0..plan.taps { let f = k as f32 / (plan.taps - 1) as f32; let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps); self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); - self.draw_cur_themed( + let drawn = self.draw_cur_themed( &cursor_sprites, cursor_type, plan.prev_placement.lerp(plan.placement, f), @@ -2058,6 +2380,14 @@ impl Compositor { plan.clip, plan.model, ); + if let Some(d) = drawn { + bounds = [ + bounds[0].min(d[0]), + bounds[1].min(d[1]), + bounds[2].max(d[0] + d[2]), + bounds[3].max(d[1] + d[3]), + ]; + } } // composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié). self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); @@ -2075,12 +2405,30 @@ impl Compositor { }; self.ctx.RSSetViewports(Some(&[vp])); self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff); - self.ctx.Draw(3, 0); + // Rien n'a été peint dans `accum` hors des quads de la traînée : la recopie + // s'y borne au lieu de repasser sur toute la sortie (0,5 ms par frame 1080p + // mesurée sur Linux, Radeon 610M). + let (rw, rh) = (self.rw(), self.rh()); + let rect = RECT { + left: (bounds[0] * rw).floor().clamp(0.0, rw) as i32, + top: (bounds[1] * rh).floor().clamp(0.0, rh) as i32, + right: (bounds[2] * rw).ceil().clamp(0.0, rw) as i32, + bottom: (bounds[3] * rh).ceil().clamp(0.0, rh) as i32, + }; + if rect.right > rect.left && rect.bottom > rect.top { + self.ctx.RSSetState(&self.rs_scissor); + self.ctx.RSSetScissorRects(Some(&[rect])); + self.ctx.Draw(3, 0); + self.ctx.RSSetState(None); + } 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(); } + if plan.glass { + self.ctx.PSSetShaderResources(5, Some(&[None])); + } } } @@ -2199,12 +2547,13 @@ impl Compositor { // la transform, donc les annotations restent en place pendant que le contenu zoome dessous. // Ce fut `s_dst` tant que le zoom vivait dans la coupe source ; depuis l'issue #179 il vit // dans la BOÎTE, et `s_dst` emmenait annotations et sous-titres avec lui. - // Exception : le flou de confidentialité suit le contenu (`FrameGeometry::privacy_mask`), - // d'où la géométrie entière passée en plus de `s_ann`. + // Les flous de confidentialité, eux, sont passés avant le curseur (plus haut) : ils suivent + // le contenu (`FrameGeometry::privacy_mask`), d'où la géométrie entière passée en plus de + // `s_ann`. // `source_t`, la même base de temps que les zoom/speed regions : le temps SOURCE du clip, // pas le compteur de frames. C'est ce qui garde une annotation alignée sur l'image quand // une speed region répète ou saute des frames. - self.draw_annotations(scene_ref.as_ref(), source_t, s_ann, &g); + self.draw_annotations(scene_ref.as_ref(), source_t, s_ann, &g, false); Ok(()) } @@ -2216,19 +2565,23 @@ impl Compositor { /// sous-titres sous un zoom (issue #179, puis #397 sur Linux). L'arithmétique elle-même vit /// dans `frame_geometry::annotation_dst_in`, partagée par les trois backends. Le flou, lui, /// se place par `g.privacy_mask` : un masque doit rester sur ce qu'il cache. + /// + /// `privacy` : les flous de confidentialité seuls, dessinés sur le métrage avant le curseur ; + /// sinon toutes les autres annotations, le calque le plus haut. unsafe fn draw_annotations( &self, scene: Option<&Scene>, t: f32, s_ann: [f32; 4], g: &crate::frame_geometry::FrameGeometry, + privacy: bool, ) { 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 + t >= a.start_sec as f32 && t < a.end_sec as f32 && (a.kind == "blur") == privacy }; // 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 @@ -2447,6 +2800,7 @@ impl Compositor { let anim = crate::text_anim::text_animation_state( text.animation.as_deref(), (t - annotation.start_sec as f32) * 1000.0, + ((annotation.end_sec - annotation.start_sec) * 1000.0) as f32, ); // 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 @@ -2613,17 +2967,14 @@ impl Compositor { Ok(()) } - /// Redimensionne (bilinéaire) le RT composé (OUT_W×OUT_H) vers `resize_target.rgba`, avant - /// la conversion NV12 dans `rgb_to_nv12_scaled`. + /// Redimensionne (bilinéaire) le RT composé, à sa taille de rendu, vers `resize_target.rgba` + /// (`target_w`×`target_h`), 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. + /// Plein cadre, sans viewport ni bandes. Le RT porte déjà la géométrie de sortie (l'export + /// le crée à la taille de sortie) et les appelants demandent cette géométrie : l'échelle est + /// donc la même sur les deux axes, à l'arrondi au pair de `normalize_render_size` près. Rien + /// n'est pré-compensé en amont : la passe `undistort`, qui rétrécissait l'écran et la webcam + /// avant un étirement non uniforme, a disparu avec le canvas figé en 16:9. 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(); @@ -2784,12 +3135,17 @@ impl Compositor { 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é. + /// Le RT de la dernière composition, à `render_size()`, pour qui le copie ailleurs sans + /// passer par la RAM : l'anneau de textures partagées de la preview (`shared_frames`). + pub fn render_target(&self) -> &ID3D11Texture2D { + &self.rt + } + + /// Comme `rgb_to_nv12`, mais vers `target_w`×`target_h` : si la cible diffère de la taille + /// de rendu, le RT composé est d'abord redimensionné (bilinéaire, `ps_tex`/`sampler` déjà + /// utilisés partout ailleurs dans le fichier, cf. `blit_resized`). Le RT suivant la sortie, + /// c'est l'exception : dans le cas nominal la cible est la taille de rendu, et le coût est + /// celui de `rgb_to_nv12`. pub unsafe fn rgb_to_nv12_scaled( &self, target_w: u32, @@ -3117,29 +3473,85 @@ mod tests { ); } + /// Un NV12 sans données, à la taille voulue : tout ce que `nv12_srvs` lit d'une frame. + fn nv12_frame(gpu: &crate::d3d::Gpu, w: u32, h: u32) -> (Box, ID3D11Texture2D) { + let desc = 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_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + unsafe { + let mut tex: Option = None; + gpu.device.CreateTexture2D(&desc, None, Some(&mut tex)).expect("texture NV12"); + let tex = tex.expect("texture NV12"); + let mut frame: Box = Box::new(std::mem::zeroed()); + frame.data[0] = tex.as_raw() as *mut u8; + frame.data[1] = std::ptr::null_mut(); + (frame, tex) + } + } - /// 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. + /// Un décodeur remplacé sans `clear_srv_cache` (changement de résolution en cours de flux, + /// `CpuFrames::ensure_tex`) peut donner à une texture NEUVE l'adresse d'une ancienne. La + /// copie privée gardée pour l'ancienne est alors trop petite, `CopySubresourceRegion` la + /// saute sans erreur et l'image gèle. On ne peut pas choisir l'adresse d'une texture ; + /// l'entrée de la petite est donc replacée sous la clé de la grande, l'état exact où le + /// cache se retrouve après un tel recyclage. #[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 a_larger_source_at_a_cached_address_gets_a_copy_of_its_own_size() { + let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else { + eprintln!("pas de device D3D11 — test sauté"); + return; + }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + let (small, _small_tex) = nv12_frame(&gpu, 64, 64); + let (large, _large_tex) = nv12_frame(&gpu, 128, 96); + let (small_key, large_key) = (small.data[0] as usize, large.data[0] as usize); + + unsafe { comp.nv12_srvs(&*small) }.expect("source de 64x64"); + { + let mut cache = comp.srv_cache.borrow_mut(); + let stale = cache.remove(&small_key).expect("entrée de la petite source"); + cache.insert(large_key, stale); } + unsafe { comp.nv12_srvs(&*large) }.expect("source de 128x96"); + + let (dst, ..) = comp.srv_cache.borrow().get(&large_key).cloned().expect("entrée remplacée"); + let mut dd = D3D11_TEXTURE2D_DESC::default(); + unsafe { dst.GetDesc(&mut dd) }; + assert_eq!((dd.Width, dd.Height), (128, 96), "la copie a gardé la taille de l'ancienne texture"); } + /// Un cran de padding en format Auto change la taille de rendu de la preview. `resized` + /// réalloue les cibles et garde le reste : le masque webcam, et la boîte aux lettres où le + /// worker de segmentation dépose les suivants. + #[test] + fn resizing_keeps_the_segmentation_and_reads_back_at_the_new_size() { + let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else { + eprintln!("pas de device D3D11 — test sauté"); + return; + }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + let (w, h) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + comp.set_webcam_mask(&vec![255u8; (w * h) as usize], w, h).expect("masque"); + let inbox = std::sync::Arc::clone(&comp.seg_inbox); + + let comp = comp.resized(181, 321).expect("resized"); + + assert_eq!(comp.render_size(), (182, 322), "arrondi au pair, comme new_sized"); + assert!(comp.webcam_mask.borrow().is_some(), "le masque webcam s'est perdu"); + assert!( + std::sync::Arc::ptr_eq(&inbox, &comp.seg_inbox), + "le worker déposerait ses masques dans une boîte que plus personne ne lit", + ); + let (rw, rh, rgba) = unsafe { comp.readback_direct() }.expect("readback"); + assert_eq!((rw, rh, rgba.len()), (182, 322, 182 * 322 * 4)); + } } diff --git a/crates/compositor/src/config.rs b/crates/compositor/src/config.rs index 9e74a914e..aad7073a0 100644 --- a/crates/compositor/src/config.rs +++ b/crates/compositor/src/config.rs @@ -7,7 +7,7 @@ pub struct Cfg { 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 bg_blur: f32, // C4+ : fond flouté, force 0..1 (0 = off) pub zoom: bool, // C5+ : zoom animé pub layout_anim: bool, // C6+ : animation de layout A<->B pub cursor: bool, // C7+ : curseur custom + click bounce @@ -40,7 +40,7 @@ pub fn all() -> Vec { composite: false, rounded: false, shadow: false, - bg_blur: false, + bg_blur: 0.0, zoom: false, layout_anim: false, cursor: false, @@ -50,7 +50,7 @@ pub fn all() -> Vec { 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 c4 = Cfg { name: "C4", bg_blur: 0.5, 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() }; diff --git a/crates/compositor/src/cpu_frames_windows.rs b/crates/compositor/src/cpu_frames_windows.rs index 790f23a7f..ea0e921fb 100644 --- a/crates/compositor/src/cpu_frames_windows.rs +++ b/crates/compositor/src/cpu_frames_windows.rs @@ -41,8 +41,9 @@ pub(crate) struct CpuFrames { /// 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. + /// frame — le `srv_cache` du compositeur (clé : le pointeur de texture) n'a donc qu'une + /// entrée et ne recrée jamais de SRV, contrairement au pool tournant de D3D11VA. Quand + /// `ensure_tex` la réalloue à une autre taille, `nv12_srvs` voit l'écart et refait la copie. // 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. diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 5ce803f38..d9c104a82 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -12,8 +12,13 @@ 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; -// Convergence du curseur dessiné sur le point cliqué (`pinned_at`). La tenue couvre le contact du -// curseur modélisé (27 à 74 ms après le clic), plus une image à 24 i/s. +/// Part de la vue zoomée, par axe, où le pointeur bouge sans que la vue le suive : sa moitié +/// centrale (`follow_in_view`). Au-delà, la vue le rattrape juste assez pour le ramener au bord de +/// cette zone. Sans elle, la vue suivait chaque petit geste et le cadrage paraissait nerveux. +const FOLLOW_DEAD_ZONE: f32 = 0.5; + +// Convergence du curseur dessiné sur le point cliqué (`pinned_at`). L'approche accompagne la +// plongée du curseur modélisé, et la tenue couvre son appui (du clic à 80 ms après). const PIN_APPROACH_S: f32 = 0.25; const PIN_HOLD_S: f32 = 0.1; const PIN_RELEASE_S: f32 = 0.25; @@ -37,14 +42,25 @@ pub struct CursorTrack { /// réellement cliqué. `smoothed()` la garde telle quelle, alors que la piste lissée passe /// ailleurs à cet instant (cf. `pinned_at`). click_points: Vec<(f32, f32)>, + /// CHANGEMENTS de visibilité : `(instant, visible)`, triés. Comme `types`, c'est une + /// fonction en escalier. Une piste ancienne sans champ `visible` reste visible. + visibility: Vec<(f32, bool)>, /// 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 toutes les 33 ms (~30 Hz, cf. /// `CURSOR_SAMPLE_INTERVAL_MS` côté Electron), donc n'enregistrer que les /// transitions garde cette liste minuscule et rend `type_at` trivial. types: Vec<(f32, String)>, + /// Les vues du suivi à zone morte déjà rejouées, par `(t0, fin, échelle)` : une par région auto + /// (`follow_in_view`). Partagé entre clones, qui ont les mêmes échantillons. + view_cache: + std::sync::Arc>)>>>, } +type ViewKey = (u32, u32, u32); +/// Assez pour toutes les régions auto d'un clip ; au-delà, la plus ancienne est rejouée. +const VIEW_CACHE_LEN: usize = 32; + /// 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() { @@ -62,7 +78,11 @@ fn sample_at(samples: &[(f32, f32, f32)], t: f32) -> Option<(f32, f32)> { 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 }; + 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)) } @@ -92,27 +112,67 @@ fn smooth_follow_samples(samples: &[(f32, f32, f32)]) -> Vec<(f32, f32, f32)> { 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); + let (nx, ny) = follow_step((px, py), (x, y), dt_ms); smoothed.push((t, nx, ny)); prev = Some((t, nx, ny)); } smoothed } +/// Un pas du lissage de suivi : `from` avance vers `to` d'un facteur qui croît avec la distance +/// (loin = rattrape vite, près = décélère), corrigé en temps sur `dt_ms`. +fn follow_step(from: (f32, f32), to: (f32, f32), dt_ms: f32) -> (f32, f32) { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + 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); + (from.0 + dx * factor, from.1 + dy * factor) +} + 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. - pub(crate) fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec, types: Vec<(f32, String)>) -> CursorTrack { + pub(crate) fn new( + samples: Vec<(f32, f32, f32)>, + clicks: Vec, + types: Vec<(f32, String)>, + ) -> CursorTrack { + CursorTrack::new_with_visibility(samples, clicks, types, Vec::new()) + } + + pub(crate) fn new_with_visibility( + samples: Vec<(f32, f32, f32)>, + clicks: Vec, + types: Vec<(f32, String)>, + visibility: Vec<(f32, bool)>, + ) -> CursorTrack { let follow_samples = smooth_follow_samples(&samples); - let click_points = - clicks.iter().map(|&tc| sample_at(&samples, tc).unwrap_or((0.0, 0.0))).collect(); - CursorTrack { samples, follow_samples, clicks, click_points, types } + let click_points = clicks + .iter() + .map(|&tc| sample_at(&samples, tc).unwrap_or((0.0, 0.0))) + .collect(); + CursorTrack { + samples, + follow_samples, + clicks, + click_points, + visibility, + types, + view_cache: Default::default(), + } + } + + /// Visibilité enregistrée au temps `t`. Les anciens sidecars qui n'ont pas ce champ sont + /// visibles par défaut, comme les lecteurs TypeScript. + pub fn visible_at(&self, t: f32) -> bool { + let i = self.visibility.partition_point(|(tc, _)| *tc <= t); + if i == 0 { + true + } else { + self.visibility[i - 1].1 + } } /// État du curseur au temps `t` : la dernière transition à `t` ou avant. `None` avant la @@ -135,6 +195,21 @@ impl CursorTrack { let mut samples = Vec::new(); let mut clicks = Vec::new(); let mut types: Vec<(f32, String)> = Vec::new(); + let mut visibility_at_offset = true; + let mut visibility_sample_time = f64::NEG_INFINITY; + // A clipped track inherits the last recorded state at its source offset. Using the first + // later sample would reveal a cursor early when that sample is the transition to visible. + for s in arr { + let tm = s["timeMs"].as_f64().unwrap_or(-1.0); + if tm >= 0.0 && tm <= offset_ms && tm >= visibility_sample_time { + visibility_sample_time = tm; + visibility_at_offset = s + .get("visible") + .and_then(|value| value.as_bool()) + .unwrap_or(true); + } + } + let mut visibility = vec![(0.0, visibility_at_offset)]; let end = offset_ms + dur_s * 1000.0; for s in arr { let tm = s["timeMs"].as_f64().unwrap_or(-1.0); @@ -145,7 +220,14 @@ impl CursorTrack { 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") { + let visible = s + .get("visible") + .and_then(|value| value.as_bool()) + .unwrap_or(true); + if visibility.last().map(|(_, previous)| *previous) != Some(visible) { + visibility.push((t, visible)); + } + if visible && s["interactionType"].as_str() == Some("click") { clicks.push(t); } // Seules les TRANSITIONS sont retenues — voir `types`. Le helper @@ -155,7 +237,10 @@ impl CursorTrack { // (`pointer`/`text`) : un thème restait collé après le retour à la // flèche. Null / absence = reset vers `arrow`. let ct = match s.get("cursorType") { - Some(v) => v.as_str().filter(|label| !label.is_empty()).unwrap_or("arrow"), + Some(v) => v + .as_str() + .filter(|label| !label.is_empty()) + .unwrap_or("arrow"), None => "arrow", }; if types.last().map(|(_, prev)| prev.as_str()) != Some(ct) { @@ -165,7 +250,10 @@ impl CursorTrack { 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)) + visibility.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + Ok(CursorTrack::new_with_visibility( + samples, clicks, types, visibility, + )) } /// Position lissée au temps `t`, pour le suivi auto du zoom. La télémétrie brute est @@ -175,6 +263,97 @@ impl CursorTrack { sample_at(&self.follow_samples, t) } + /// Le centre de la vue d'un zoom auto d'échelle `scale` à `t`, parti de `follow_at(t0)` en + /// `t0` : `follow_at` avec une zone morte. La vue ne bouge pas tant que le pointeur reste dans + /// la part `FOLLOW_DEAD_ZONE` de sa largeur autour de son centre ; il en sort, elle le + /// rattrape avec le même lissage, juste assez pour le ramener au bord de la zone. Bornée à ce + /// qu'une vue de `1/scale` peut atteindre, pour que la zone se mesure contre la vue affichée. + /// + /// Rejouée depuis `t0` sur les échantillons BRUTS, puis mise en cache jusqu'à `t_end` : une + /// fonction pure de `(t0, t, t_end)`, donc la même en preview et à l'export, après un seek + /// comme en lecture. Le cache ne garde rien après la fenêtre de la région. + pub fn follow_in_view(&self, t0: f32, t: f32, t_end: f32, scale: f32) -> Option<(f32, f32)> { + let t_end = t_end.max(t0); + let t = t.clamp(t0, t_end); + let states = self.view_states(t0, t_end, scale)?; + let half = 0.5 / scale.max(1.0); + let reach = |v: f32| v.clamp(half, 1.0 - half); + // Après le dernier échantillon, la vue continue de rattraper le pointeur resté immobile + // jusqu'au bord de la zone, au lieu de se figer à mi-course. + let &(at, vx, vy) = states.last()?; + if t > at { + let (_, x, y) = *self.samples.last()?; + let dead = half * FOLLOW_DEAD_ZONE; + let pull = |cursor: f32, view: f32| cursor - (cursor - view).clamp(-dead, dead); + let (nx, ny) = follow_step((vx, vy), (pull(x, vx), pull(y, vy)), (t - at) * 1000.0); + return Some((reach(nx), reach(ny))); + } + if states.len() == 1 { + return Some((vx, vy)); + } + // Entre deux états, linéaire, comme `follow_at`. + let i = states.partition_point(|s| s.0 < t).max(1); + let (a, b) = (states[i - 1], states[i]); + let f = if b.0 > a.0 { + ((t - a.0) / (b.0 - a.0)).clamp(0.0, 1.0) + } else { + 1.0 + }; + Some((a.1 + (b.1 - a.1) * f, a.2 + (b.2 - a.2) * f)) + } + + /// Les états `(t, x, y)` de `t0` à la fin de la fenêtre d'une région. Le dernier point est + /// interpolé à `t_end` si le curseur a encore des échantillons après cette fenêtre. + fn view_states( + &self, + t0: f32, + t_end: f32, + scale: f32, + ) -> Option>> { + let t_end = t_end.max(t0); + let key = (t0.to_bits(), t_end.to_bits(), scale.to_bits()); + if let Some((_, v)) = self.view_cache.lock().ok()?.iter().find(|(k, _)| *k == key) { + return Some(v.clone()); + } + let half = 0.5 / scale.max(1.0); + let dead = half * FOLLOW_DEAD_ZONE; + let reach = |v: f32| v.clamp(half, 1.0 - half); + // La cible : le point qui ramène le pointeur au bord de la zone, la vue elle-même dedans. + let pull = |cursor: f32, view: f32| cursor - (cursor - view).clamp(-dead, dead); + let (x0, y0) = self.follow_at(t0)?; + let mut view = (reach(x0), reach(y0)); + let mut states = vec![(t0, view.0, view.1)]; + let first = self.samples.partition_point(|s| s.0 <= t0); + let end = self.samples.partition_point(|s| s.0 <= t_end); + for &(ts, x, y) in &self.samples[first..end] { + let at = states.last().map_or(t0, |s| s.0); + let (nx, ny) = + follow_step(view, (pull(x, view.0), pull(y, view.1)), (ts - at) * 1000.0); + view = (reach(nx), reach(ny)); + states.push((ts, view.0, view.1)); + } + let at = states.last().map_or(t0, |s| s.0); + if at < t_end { + if let Some(&(ts, x, y)) = self.samples.get(end) { + let (nx, ny) = + follow_step(view, (pull(x, view.0), pull(y, view.1)), (ts - at) * 1000.0); + let f = ((t_end - at) / (ts - at)).clamp(0.0, 1.0); + states.push(( + t_end, + view.0 + (nx - view.0) * f, + view.1 + (ny - view.1) * f, + )); + } + } + let states = std::sync::Arc::new(states); + let mut cache = self.view_cache.lock().ok()?; + if cache.len() >= VIEW_CACHE_LEN { + cache.remove(0); + } + cache.push((key, states.clone())); + Some(states) + } + /// Position (cx, cy) au temps `t` (interpolation linéaire), ou None si hors piste. Brute, sauf /// sur une piste `smoothed()`. pub fn at(&self, t: f32) -> Option<(f32, f32)> { @@ -192,7 +371,9 @@ impl CursorTrack { /// l'emporte. Continue et de pente continue en `t` ; hors de ces fenêtres, `at(t)` à l'identique. pub fn pinned_at(&self, t: f32) -> Option<(f32, f32)> { let (mut x, mut y) = self.at(t)?; - let lo = self.clicks.partition_point(|&tc| tc <= t - PIN_HOLD_S - PIN_RELEASE_S); + let lo = self + .clicks + .partition_point(|&tc| tc <= t - PIN_HOLD_S - PIN_RELEASE_S); let hi = self.clicks.partition_point(|&tc| tc < t + PIN_APPROACH_S); for i in lo..hi.max(lo) { let d = t - self.clicks[i]; @@ -202,16 +383,27 @@ impl CursorTrack { 1.0 - smoothstep01((d - PIN_HOLD_S) / PIN_RELEASE_S) }; let (cx, cy) = self.click_points[i]; - (x, y) = if w >= 1.0 { (cx, cy) } else { (x + (cx - x) * w, y + (cy - y) * w) }; + (x, y) = if w >= 1.0 { + (cx, cy) + } else { + (x + (cx - x) * w, y + (cy - y) * w) + }; } Some((x, y)) } /// Les clics de `(lo, hi]` avec leur position brute, triés. - pub fn clicks_with_points(&self, lo: f32, hi: f32) -> impl Iterator + '_ { + pub fn clicks_with_points( + &self, + lo: f32, + hi: f32, + ) -> impl Iterator + '_ { let a = self.clicks.partition_point(|&tc| tc <= lo); let b = self.clicks.partition_point(|&tc| tc <= hi).max(a); - self.clicks[a..b].iter().copied().zip(self.click_points[a..b].iter().copied()) + self.clicks[a..b] + .iter() + .copied() + .zip(self.click_points[a..b].iter().copied()) } /// Facteur d'échelle « click bounce ». Cette fonction est la SEULE référence de la courbe : @@ -225,7 +417,9 @@ impl CursorTrack { pub fn bounce(&self, t: f32) -> f32 { const ANIM_S: f32 = 0.26; // 260 ms const PRESS_FRAC: f32 = 0.38; - let Some(tc) = self.last_click_at(t) else { return 1.0 }; + let Some(tc) = self.last_click_at(t) else { + return 1.0; + }; let elapsed = (t - tc) / ANIM_S; if elapsed >= 1.0 { return 1.0; @@ -234,7 +428,8 @@ impl CursorTrack { 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(); + let rebound = + ((elapsed - PRESS_FRAC) / (1.0 - PRESS_FRAC) * std::f32::consts::PI).sin(); 1.0 + rebound * 0.16 } } @@ -265,7 +460,12 @@ impl CursorTrack { /// 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()); + return CursorTrack::new_with_visibility( + self.samples.clone(), + self.clicks.clone(), + self.types.clone(), + self.visibility.clone(), + ); } const STEP_S: f32 = 1.0 / 240.0; let start = self.samples[0].0; @@ -276,22 +476,61 @@ impl CursorTrack { 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 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(); + // Une phase masquée coupe le ressort. Au retour, le curseur repart du premier point + // réellement visible au lieu de traverser la trajectoire cachée avec du retard. + let mut xs = Vec::with_capacity(n); + let mut ys = Vec::with_capacity(n); + let mut run_start = 0; + while run_start < n { + let visible = self.visible_at(times[run_start]); + let mut run_end = run_start + 1; + while run_end < n && self.visible_at(times[run_end]) == visible { + run_end += 1; + } + xs.extend(spring_smooth( + &raw_x[run_start..run_end], + stiffness, + damping, + mass, + STEP_S, + )); + ys.extend(spring_smooth( + &raw_y[run_start..run_end], + stiffness, + damping, + mass, + STEP_S, + )); + run_start = run_end; + } + 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. Les points // cliqués restent ceux de la piste brute. CursorTrack { click_points: self.click_points.clone(), - ..CursorTrack::new(samples, self.clicks.clone(), self.types.clone()) + ..CursorTrack::new_with_visibility( + samples, + self.clicks.clone(), + self.types.clone(), + self.visibility.clone(), + ) } } @@ -323,7 +562,11 @@ impl CursorTrack { let idx = self.samples.partition_point(|s| s.0 <= t); let start_t = self.samples[0].0; - let mut arrival_t = if idx > 0 { self.samples[idx - 1].0 } else { start_t }; + let mut arrival_t = if idx > 0 { + self.samples[idx - 1].0 + } else { + start_t + }; if idx > 0 { let mut i = idx - 1; @@ -375,7 +618,13 @@ impl CursorTrack { /// 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 { +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; @@ -412,6 +661,132 @@ fn cursor_spring_config(smoothing_factor: f32) -> (f32, f32, f32) { mod tests { use super::*; + /// Zone morte du suivi auto : un pointeur qui tremble au centre de la vue ne la bouge pas ; + /// un pointeur qui part loin est rattrapé, jusqu'au bord de la zone et pas au-delà. + /// Les vues d'une région sont rejouées une fois puis relues : même réponse dans n'importe quel + /// ordre, et la même qu'une piste neuve qui les rejoue. + #[test] + fn the_cached_follow_view_answers_like_a_fresh_replay() { + let make = || { + CursorTrack::new( + (0..=150) + .map(|i| { + let t = i as f32 / 30.0; + (t, 0.5 + 0.3 * (t * 1.3).sin(), 0.5 + 0.2 * (t * 0.7).cos()) + }) + .collect(), + vec![], + vec![], + ) + }; + let (cached, times) = (make(), [4.9f32, 0.2, 3.3, 0.0, 2.71, 4.0]); + for t in times { + let _ = cached.follow_in_view(0.4, t, 5.0, 2.0); + } + for t in times { + assert_eq!( + cached.follow_in_view(0.4, t, 5.0, 2.0), + make().follow_in_view(0.4, t, 5.0, 2.0), + "t {t}" + ); + } + // Une autre région (autre départ, autre échelle) a ses propres vues. + assert_ne!( + cached.follow_in_view(1.0, 3.0, 5.0, 3.0), + cached.follow_in_view(0.4, 3.0, 5.0, 2.0) + ); + } + + #[test] + fn the_follow_view_cache_stops_at_its_region_end() { + let track = CursorTrack::new( + (0..=300) + .map(|i| { + let t = i as f32 / 30.0; + (t, 0.5 + 0.2 * (t * 0.7).sin(), 0.5) + }) + .collect(), + vec![], + vec![], + ); + + let at_end = track.follow_in_view(0.0, 2.0, 2.0, 2.0).unwrap(); + assert_eq!(track.follow_in_view(0.0, 8.0, 2.0, 2.0), Some(at_end)); + + let cache = track.view_cache.lock().unwrap(); + let states = &cache[0].1; + assert!(states.iter().all(|(t, _, _)| *t <= 2.0)); + assert_eq!(states.last().unwrap().0, 2.0); + } + + /// Après le dernier échantillon, la vue continue de rattraper un pointeur immobile hors de la + /// zone morte, jusqu'à son bord, au lieu de se figer. + #[test] + fn the_follow_view_keeps_settling_after_the_last_sample() { + // Le pointeur saute à 0,8 au dernier échantillon (2 s) ; bord de la zone à ×2 : 0,675. + let track = CursorTrack::new( + (0..=60) + .map(|i| (i as f32 / 30.0, if i == 60 { 0.8 } else { 0.5 }, 0.5)) + .collect(), + vec![], + vec![], + ); + let at = |t: f32| track.follow_in_view(0.0, t, 9.0, 2.0).unwrap().0; + assert!( + at(2.0) < at(2.5) && at(2.5) < at(4.0), + "{} {} {}", + at(2.0), + at(2.5), + at(4.0) + ); + assert!((at(8.0) - 0.675).abs() < 1e-3, "{}", at(8.0)); + } + + #[test] + fn the_auto_follow_holds_still_inside_its_dead_zone() { + // ×2 : vue de 0,5, zone morte de ±0,125 autour de son centre. + let track = |f: fn(f32) -> f32| { + CursorTrack::new( + (0..=300) + .map(|i| { + let t = i as f32 / 30.0; + (t, f(t), 0.5) + }) + .collect(), + vec![], + vec![], + ) + }; + let jitter = track(|t| 0.5 + 0.08 * (t * 7.0).sin()); + for i in 0..=90 { + let (x, y) = jitter + .follow_in_view(0.0, i as f32 / 10.0, 9.0, 2.0) + .unwrap(); + assert!( + (x - 0.5).abs() < 1e-6 && (y - 0.5).abs() < 1e-6, + "t {} : {x}", + i as f32 / 10.0 + ); + } + // Sans zone morte, le suivi lissé, lui, tremble. + let swing = (0..=90) + .map(|i| jitter.follow_at(i as f32 / 10.0).unwrap().0) + .fold(0.0f32, |m, x| m.max((x - 0.5).abs())); + assert!(swing > 0.02, "{swing}"); + + // Un saut à 0,8 à 2 s : la vue rattrape jusqu'à laisser le pointeur au bord de la zone. + let jump = track(|t| if t < 2.0 { 0.5 } else { 0.8 }); + let settled = jump.follow_in_view(0.0, 9.0, 9.0, 2.0).unwrap().0; + assert!((settled - (0.8 - 0.125)).abs() < 1e-3, "{settled}"); + let early = jump.follow_in_view(0.0, 2.1, 9.0, 2.0).unwrap().0; + assert!( + early > 0.5 && early < settled, + "rattrapage progressif : {early}" + ); + // Une pure fonction de (t0, t, t_end) : l'ordre des appels ne change rien. + assert_eq!(jump.follow_in_view(0.0, 2.1, 9.0, 2.0).unwrap().0, early); + } + /// 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] @@ -419,14 +794,30 @@ mod tests { 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())], + 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(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"); + 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 @@ -444,6 +835,74 @@ mod tests { assert_eq!(smoothed.type_at(0.7), Some("text")); } + #[test] + fn load_preserves_hidden_intervals_and_ignores_hidden_clicks() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!( + "openscreen-cursor-visibility-{}-{}.json", + std::process::id(), + unique + )); + std::fs::write( + &path, + r#"{"samples":[ + {"timeMs":0,"cx":0.1,"cy":0.1,"visible":true}, + {"timeMs":100,"cx":0.2,"cy":0.2,"visible":false,"interactionType":"click"}, + {"timeMs":600,"cx":0.9,"cy":0.9,"visible":true,"interactionType":"click"} + ]}"#, + ) + .expect("write temp sidecar"); + let path_str = path.to_str().expect("utf-8 temp path"); + let track = CursorTrack::load(path_str, 0.0, 1.0).expect("load sidecar"); + let clipped = CursorTrack::load(path_str, 200.0, 0.5).expect("load clipped sidecar"); + let _ = std::fs::remove_file(&path); + + assert!(track.visible_at(0.0)); + assert!(!track.visible_at(0.1)); + assert!(!track.visible_at(0.59)); + assert!(track.visible_at(0.6)); + assert_eq!( + track.clicks, + vec![0.6], + "le clic masqué ne doit pas animer le curseur" + ); + assert!( + !clipped.visible_at(0.0), + "la fenêtre commence dans la phase masquée" + ); + assert!( + !clipped.visible_at(0.39), + "l'état masqué tient jusqu'à la transition" + ); + assert!( + clipped.visible_at(0.4), + "la transition visible garde son temps relatif" + ); + + let smoothed = track.smoothed(0.5); + assert!( + !smoothed.visible_at(0.3), + "le lissage garde la phase masquée" + ); + assert!(smoothed.visible_at(0.6)); + let reappeared = smoothed.at(0.6).expect("position at reappearance"); + assert!( + (reappeared.0 - 0.9).abs() < 0.02 && (reappeared.1 - 0.9).abs() < 0.02, + "le ressort redémarre au point visible, sans traîne: {reappeared:?}" + ); + } + + #[test] + fn legacy_tracks_without_visibility_stay_visible() { + let track = CursorTrack::new(vec![(0.0, 0.1, 0.1), (1.0, 0.9, 0.9)], vec![], vec![]); + assert!(track.visible_at(0.0)); + assert!(track.visible_at(0.5)); + assert!(track.visible_at(10.0)); + } + /// JSON null et une clé `cursorType` absente resetent vers la flèche, /// au lieu de garder le dernier `pointer`/`text`. #[test] @@ -516,8 +975,14 @@ mod tests { } // Continue : pas de saut d'une milliseconde à l'autre. for k in 0..2000 { - let (a, b) = (track.pinned_at(k as f32 * 0.001).unwrap(), track.pinned_at((k + 1) as f32 * 0.001).unwrap()); - assert!((a.0 - b.0).abs() < 4e-3 && (a.1 - b.1).abs() < 4e-3, "saut en {k} ms : {a:?} -> {b:?}"); + let (a, b) = ( + track.pinned_at(k as f32 * 0.001).unwrap(), + track.pinned_at((k + 1) as f32 * 0.001).unwrap(), + ); + assert!( + (a.0 - b.0).abs() < 4e-3 && (a.1 - b.1).abs() < 4e-3, + "saut en {k} ms : {a:?} -> {b:?}" + ); } println!("au clic, piste en {lag:?}, point cliqué {clicked:?}"); } @@ -544,9 +1009,21 @@ mod tests { assert_eq!(b(1.0), 1.0, "fraction 0"); assert!(near(b(1.0 + DT), 1.0, 2e-3)); let press_end = 1.0 + 0.38 * 0.26; - assert!(near(b(press_end - DT), 1.0, 2e-3), "fraction 0.38⁻ : {}", b(press_end - DT)); - assert!(near(b(press_end + DT), 1.0, 2e-3), "fraction 0.38⁺ : {}", b(press_end + DT)); - assert!(near(b(1.26 - DT), 1.0, 2e-3), "fraction 1⁻ : {}", b(1.26 - DT)); + assert!( + near(b(press_end - DT), 1.0, 2e-3), + "fraction 0.38⁻ : {}", + b(press_end - DT) + ); + assert!( + near(b(press_end + DT), 1.0, 2e-3), + "fraction 0.38⁺ : {}", + b(press_end + DT) + ); + assert!( + near(b(1.26 - DT), 1.0, 2e-3), + "fraction 1⁻ : {}", + b(1.26 - DT) + ); // Creux de la pression (fraction 0.19 → +49.4 ms), pic du rebond (0.69 → +179.4 ms). assert!(near(b(1.0494), 0.76, 1e-4), "creux : {}", b(1.0494)); @@ -558,18 +1035,26 @@ mod tests { // serait encore dans son rebond (≈1.0037), le second l'écrase à 1.0 pile. let double = CursorTrack::new(vec![], vec![1.0, 1.1], vec![]); assert_eq!(double.bounce(1.1), 1.0, "redémarrage à la fraction 0"); - assert!(near(double.bounce(1.1494), 0.76, 1e-4), "nouveau creux : {}", double.bounce(1.1494)); - assert!(near(double.bounce(1.2794), 1.16, 1e-4), "nouveau pic : {}", double.bounce(1.2794)); - assert_eq!(double.bounce(1.3601), 1.0, "neutre après la fenêtre du second clic"); + assert!( + near(double.bounce(1.1494), 0.76, 1e-4), + "nouveau creux : {}", + double.bounce(1.1494) + ); + assert!( + near(double.bounce(1.2794), 1.16, 1e-4), + "nouveau pic : {}", + double.bounce(1.2794) + ); + assert_eq!( + double.bounce(1.3601), + 1.0, + "neutre après la fenêtre du second clic" + ); } #[test] fn auto_hide_disabled_always_full_opacity() { - let track = CursorTrack::new( - vec![(0.0, 0.5, 0.5), (10.0, 0.5, 0.5)], - vec![], - vec![], - ); + let track = CursorTrack::new(vec![(0.0, 0.5, 0.5), (10.0, 0.5, 0.5)], vec![], vec![]); assert_eq!(track.opacity_at(0.0, false), 1.0); assert_eq!(track.opacity_at(5.0, false), 1.0); assert_eq!(track.opacity_at(10.0, false), 1.0); @@ -579,7 +1064,13 @@ mod tests { fn auto_hide_fades_out_after_idle_timeout() { // Le curseur est stationnaire à (0.5, 0.5) de 0 à 5s. let track = CursorTrack::new( - vec![(0.0, 0.5, 0.5), (1.0, 0.5, 0.5), (2.0, 0.5, 0.5), (3.0, 0.5, 0.5), (4.0, 0.5, 0.5)], + vec![ + (0.0, 0.5, 0.5), + (1.0, 0.5, 0.5), + (2.0, 0.5, 0.5), + (3.0, 0.5, 0.5), + (4.0, 0.5, 0.5), + ], vec![], vec![], ); @@ -590,7 +1081,10 @@ mod tests { // Entre 1.5s et 1.8s, estompage linéaire let op_mid = track.opacity_at(1.65, true); - assert!((op_mid - 0.5).abs() < 0.05, "mi-parcours d'estompage: {op_mid}"); + assert!( + (op_mid - 0.5).abs() < 0.05, + "mi-parcours d'estompage: {op_mid}" + ); // À 1.8s et au-delà, opacité 0.0 assert_eq!(track.opacity_at(1.8, true), 0.0); diff --git a/crates/compositor/src/cursor_sdf.rs b/crates/compositor/src/cursor_sdf.rs index 9bd449ed8..161793a40 100644 --- a/crates/compositor/src/cursor_sdf.rs +++ b/crates/compositor/src/cursor_sdf.rs @@ -112,6 +112,9 @@ impl CursorSdf { size: [w as f32 / long, h as f32 / long], hotspot: [0.0; 2], top: top_row as f32 / fh as f32, + max_height: 0.0, + thick: crate::frame_geometry::MODEL_THICK, + sculpt: 0, }, } } @@ -402,11 +405,11 @@ mod tests { fn the_shipped_arrow_loads() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../public/cursors/default/arrow.png"); let sdf = CursorSdf::load(path).expect("arrow.png"); - assert_eq!((sdf.width, sdf.height), (42 * 4, 70 * 4)); - assert_eq!(sdf.shape.size, [0.6, 1.0]); + assert_eq!((sdf.width, sdf.height), (83 * 4, 139 * 4)); + assert_eq!(sdf.shape.size, [83.0 / 139.0, 1.0]); assert!(sdf.shape.top < 0.02, "haut de la silhouette : {}", sdf.shape.top); - // Le hotspot (0.119, 0.0874) est la pointe de l'incrustation, dans la silhouette. - assert!(sdf.sample([0.119, 0.0874]) < 0.0); + // Le hotspot (0.1205, 0.0881) est la pointe de l'incrustation, dans la silhouette. + assert!(sdf.sample([0.1205, 0.0881]) < 0.0); assert!(sdf.sample([0.9, 0.1]) > 0.0); assert_eq!(sdf.f16_bytes().len(), sdf.texels.len() * 2); } diff --git a/crates/compositor/src/gif_export_control.rs b/crates/compositor/src/export_control.rs similarity index 70% rename from crates/compositor/src/gif_export_control.rs rename to crates/compositor/src/export_control.rs index bf4ad1243..8542c55ea 100644 --- a/crates/compositor/src/gif_export_control.rs +++ b/crates/compositor/src/export_control.rs @@ -1,4 +1,4 @@ -//! Cooperative GIF cancellation and publication of a completed output only. +//! Cooperative export cancellation (MP4 and GIF) and publication of a completed output only. use anyhow::{bail, Context, Result}; use std::fs::{self, File, OpenOptions}; @@ -11,20 +11,20 @@ const CANCELLED: u8 = 1; const COMMITTING: u8 = 2; #[derive(Clone, Default)] -pub struct GifExportControl(Arc); +pub struct ExportControl(Arc); #[derive(Debug)] -pub struct GifExportCancelled; +pub struct ExportCancelled; -impl std::fmt::Display for GifExportCancelled { +impl std::fmt::Display for ExportCancelled { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("GIF export cancelled") + f.write_str("export cancelled") } } -impl std::error::Error for GifExportCancelled {} +impl std::error::Error for ExportCancelled {} -impl GifExportControl { +impl ExportControl { /// False means publication has already won the race. Repeated cancellation /// of the same pending job is harmless. pub fn cancel(&self) -> bool { @@ -36,7 +36,7 @@ impl GifExportControl { pub fn check(&self) -> Result<()> { if self.0.load(Ordering::Acquire) == CANCELLED { - return Err(GifExportCancelled.into()); + return Err(ExportCancelled.into()); } Ok(()) } @@ -44,20 +44,20 @@ impl GifExportControl { fn begin_commit(&self) -> Result<()> { match self.0.compare_exchange(RUNNING, COMMITTING, Ordering::AcqRel, Ordering::Acquire) { Ok(_) => Ok(()), - Err(CANCELLED) => Err(GifExportCancelled.into()), - Err(_) => bail!("GIF export control has already been used"), + Err(CANCELLED) => Err(ExportCancelled.into()), + Err(_) => bail!("export control has already been used"), } } } static NEXT_OUTPUT: AtomicU64 = AtomicU64::new(0); -struct StagedGif { +struct StagedOutput { path: PathBuf, published: bool, } -impl Drop for StagedGif { +impl Drop for StagedOutput { fn drop(&mut self) { if !self.published { let _ = fs::remove_file(&self.path); @@ -66,27 +66,31 @@ impl Drop for StagedGif { } /// Keep the destination intact until rendering and flushing have succeeded. -/// `render` owns the file so it is closed before rename/cleanup on Windows. -pub(crate) fn with_gif_output( +/// `render` owns the file so it is closed before rename/cleanup on Windows; a +/// renderer that reopens the path (ffmpeg) must close it before returning too. +/// The staged name ends with the target's extension, which is what ffmpeg picks +/// the container from. +pub(crate) fn with_staged_output( target: &Path, - control: &GifExportControl, + control: &ExportControl, render: impl FnOnce(File, &Path) -> Result, ) -> Result { control.check()?; let parent = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); fs::create_dir_all(parent)?; + let ext = target.extension().map(|e| e.to_string_lossy()).unwrap_or_default(); let (mut staged, file) = loop { let nonce = NEXT_OUTPUT.fetch_add(1, Ordering::Relaxed); - let path = parent.join(format!(".openscreen-gif-{}-{nonce}.partial", std::process::id())); + let path = parent.join(format!(".openscreen-{ext}-{}-{nonce}.partial.{ext}", std::process::id())); match OpenOptions::new().write(true).create_new(true).open(&path) { - Ok(file) => break (StagedGif { path, published: false }, file), + Ok(file) => break (StagedOutput { path, published: false }, file), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e).context("creating temporary GIF output"), + Err(e) => return Err(e).context("creating temporary export output"), } }; let result = render(file, &staged.path).and_then(|stats| { control.begin_commit()?; - fs::rename(&staged.path, target).context("publishing completed GIF output")?; + fs::rename(&staged.path, target).context("publishing completed export output")?; staged.published = true; Ok(stats) }); @@ -95,7 +99,7 @@ pub(crate) fn with_gif_output( if cleanup.kind() != std::io::ErrorKind::NotFound { // A failed cleanup is an error, even if cancellation caused it. // Do not report a clean cancellation while leaving an output. - bail!("could not remove partial GIF {}: {cleanup}", staged.path.display()); + bail!("could not remove partial export {}: {cleanup}", staged.path.display()); } } } @@ -128,28 +132,41 @@ mod tests { #[test] fn cancellation_before_rendering_never_opens_output() { let dir = TestDir::new(); - let control = GifExportControl::default(); + let control = ExportControl::default(); assert!(control.cancel()); - let result = with_gif_output(&dir.0.join("out.gif"), &control, |_, _| -> Result<()> { + let result = with_staged_output(&dir.0.join("out.gif"), &control, |_, _| -> Result<()> { panic!("cancelled job must not render"); }); - assert!(result.unwrap_err().is::()); + assert!(result.unwrap_err().is::()); assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 0); } + #[test] + fn staged_output_keeps_the_target_extension() { + // ffmpeg picks the MP4 muxer from the file name; a bare `.partial` cannot be opened. + let dir = TestDir::new(); + let control = ExportControl::default(); + with_staged_output(&dir.0.join("out.mp4"), &control, |_, staged| { + assert_eq!(staged.extension().unwrap(), "mp4"); + Ok(()) + }) + .unwrap(); + dir.assert_only("out.mp4"); + } + #[test] fn cancellation_discards_partial_output_and_preserves_destination() { let dir = TestDir::new(); let target = dir.0.join("out.gif"); fs::write(&target, b"original GIF").unwrap(); - let control = GifExportControl::default(); - let result = with_gif_output(&target, &control, |mut file, _| { + let control = ExportControl::default(); + let result = with_staged_output(&target, &control, |mut file, _| { file.write_all(b"partial replacement")?; assert!(control.cancel()); // Even a renderer that has just completed cannot publish now. Ok(42) }); - assert!(result.unwrap_err().is::()); + assert!(result.unwrap_err().is::()); assert_eq!(fs::read(&target).unwrap(), b"original GIF"); dir.assert_only("out.gif"); } @@ -159,8 +176,8 @@ mod tests { let dir = TestDir::new(); let target = dir.0.join("out.gif"); fs::write(&target, b"original").unwrap(); - let control = GifExportControl::default(); - assert_eq!(with_gif_output(&target, &control, |mut file, _| { + let control = ExportControl::default(); + assert_eq!(with_staged_output(&target, &control, |mut file, _| { file.write_all(b"finished GIF")?; Ok(7) }).unwrap(), 7); @@ -173,8 +190,8 @@ mod tests { fn render_failure_is_not_misreported_as_cancellation() { let dir = TestDir::new(); let target = dir.0.join("out.gif"); - let control = GifExportControl::default(); - let result = with_gif_output(&target, &control, |mut file, _| -> Result<()> { + let control = ExportControl::default(); + let result = with_staged_output(&target, &control, |mut file, _| -> Result<()> { file.write_all(b"partial")?; control.cancel(); bail!("encoder failed") @@ -188,8 +205,8 @@ mod tests { let dir = TestDir::new(); let target = dir.0.join("destination-directory"); fs::create_dir(&target).unwrap(); - let control = GifExportControl::default(); - assert!(with_gif_output(&target, &control, |mut file, _| { + let control = ExportControl::default(); + assert!(with_staged_output(&target, &control, |mut file, _| { file.write_all(b"finished GIF")?; Ok(()) }).is_err()); @@ -200,7 +217,7 @@ mod tests { #[test] fn cancellation_and_commit_have_exactly_one_winner() { for _ in 0..64 { - let control = GifExportControl::default(); + let control = ExportControl::default(); let cancel_control = control.clone(); let barrier = Arc::new(Barrier::new(2)); let cancel_barrier = barrier.clone(); diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 77f1b0ef2..0decdb21f 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -27,21 +27,21 @@ use crate::config::Cfg; use crate::scene::{Scene, SceneCrop}; -/// Constant buffer d'un calque : **128 octets**, un par draw. +/// Constant buffer d'un calque : **176 octets**, un par draw. /// /// C'est le contrat partagé par les trois côtés — `cbuffer Layer` dans `shaders.hlsl`, -/// `struct Layer` dans `shaders.metal`, et ce struct. Les trois doivent s'accorder champ -/// pour champ ET octet pour octet : un décalage ne produit pas d'erreur, il produit un -/// shader qui lit `color` là où on a écrit `fx`. +/// `struct Layer` dans `shaders.metal` et `vk_shaders/layer.wgsl`, et ce struct. Ils doivent +/// s'accorder champ pour champ ET octet pour octet : un décalage ne produit pas d'erreur, il +/// produit un shader qui lit `color` là où on a écrit `fx`. /// /// `align(16)` vient de la version macOS ; sous `repr(C)` seul, les offsets sont déjà -/// 0/16/32/40/44/48/64/80/96/112 des deux côtés — l'alignement Rust ne change que +/// 0/16/32/40/44/48/64/80/96/112/128/144/160 des deux côtés — l'alignement Rust ne change que /// l'adresse du struct, pas son contenu, et Windows le `copy_nonoverlapping` dans un /// constant buffer mappé où l'alignement source est sans effet. Les deux formes étaient /// donc compatibles ; les unifier évite qu'elles cessent de l'être. /// /// (Le commentaire d'origine annonçait « 64 octets ». Il n'a jamais été juste : dix champs, -/// trente-deux `f32`.) +/// trente-deux `f32`. Les trois derniers, le flou de mouvement de l'écran incliné, en font 176.) #[repr(C, align(16))] #[derive(Clone, Copy, Default)] pub struct LayerCB { @@ -55,6 +55,25 @@ pub struct LayerCB { pub src_prev: [f32; 4], pub dst_prev: [f32; 4], pub mb: [f32; 4], // mb[0] = nombre de taps de motion blur + /// Mode 8 : le plan à la frame PRÉCÉDENTE, coins TL, TR (`trail_a`) puis BR, BL (`trail_b`) + /// en px locaux comme `fx`/`src_prev`, et `trail_mb` = `[taps, force, 0, 0]` de son flou de + /// mouvement, ceux du mode 0 (`mb.xy`). `trail_mb` nul ailleurs : aucun tap, rien n'est lu. + /// Mode 18 incliné : les mêmes coins d'avant, en fractions de la sortie (`screen_trail_cb`). + pub trail_a: [f32; 4], + pub trail_b: [f32; 4], + pub trail_mb: [f32; 4], +} + +impl LayerCB { + /// Le calque est l'un des modèles 3D — le curseur modelé (mode 15), l'impact de son clic + /// (16), l'appareil et son ombre (17) — ou le verre à plat de Glass Lens (19), qui en reprend + /// les formes : seule la variante « modèles » du shader de calque les compile. Dans le même + /// shader que les autres, leurs branches fixaient l'occupation de TOUS les calques (168 VGPR + /// sur RADV) ; les trois backends décident donc du shader d'un calque par ce test, au moment de + /// le dessiner (cf. le haut de `vk_shaders/layer.wgsl`). + pub fn needs_models(&self) -> bool { + (self.mode > 14.5 && self.mode < 17.5) || self.mode > 18.5 + } } pub const OUT_W: u32 = 1920; @@ -363,6 +382,23 @@ pub(crate) fn cover_uv_rect(uv: [f32; 4], tex: [f32; 2], box_ar: f32) -> [f32; 4 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] } +/// `cover_uv_rect`, la fenêtre centrée sur `at` (UV) au lieu du centre de `uv`, et bornée à `uv` : +/// le remplissage du format, qui suit le curseur lissé dans l'enregistrement. Même taille que la +/// fenêtre centrée, donc même grossissement ; seule sa position change. +pub(crate) fn follow_cover_uv_rect( + uv: [f32; 4], + tex: [f32; 2], + box_ar: f32, + at: [f32; 2], +) -> [f32; 4] { + let c = cover_uv_rect(uv, tex, box_ar); + let (hw, hh) = (0.5 * (c[2] - c[0]), 0.5 * (c[3] - c[1])); + // `max` puis `min`, pas `clamp` : à la précision flottante près la fenêtre peut dépasser `uv` + // d'un epsilon, et `clamp` panique quand sa borne basse passe la haute. + let cx = at[0].max(uv[0] + hw).min(uv[2] - hw); + let cy = at[1].max(uv[1] + hh).min(uv[3] - hh); + [cx - hw, cy - hh, cx + hw, cy + hh] +} pub const HALF_W: u32 = OUT_W / 2; pub const HALF_H: u32 = OUT_H / 2; pub const FIXTURE_FRAMES: u32 = 360; @@ -381,6 +417,26 @@ pub fn dof_pyramid_levels(w: u32, h: u32) -> u32 { const DOF_PYRAMID_LEVELS: u32 = 5; DOF_PYRAMID_LEVELS.min(32 - w.max(h).max(1).leading_zeros()) } +/// Flou du fond : force 0..1 → (niveaux de la pyramide dual-Kawase, écart par passe), `None` +/// à 0. Le rayon d'un dual-Kawase croît comme `2^niveaux × écart` : on vise un rayon +/// proportionnel à la force et on prend le moins de niveaux qui l'atteint sans écarter les taps +/// au point de dédoubler l'image. 0,5 retombe exactement sur l'ancien interrupteur « Blur BG » +/// (3 niveaux, écart 2,2). Les trois backends lisent ceci, ils floutent donc pareil. +pub fn background_blur_steps(amount: f32) -> Option<(usize, f32)> { + const RADIUS_AT_FULL: f32 = 2.0 * 8.0 * 2.2; + if !(amount > 0.001) { + return None; + } + let radius = amount.min(1.0) * RADIUS_AT_FULL; + let levels: usize = if radius <= 4.0 { + 1 + } else if radius <= 12.0 { + 2 + } else { + 3 + }; + Some((levels, radius / (1 << levels) as f32)) +} /// 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 @@ -476,13 +532,32 @@ pub(crate) fn concentric_radius(r: f32, bx: f32, by: f32) -> f32 { } /// Position du slider Roundness sur sa course, 0..1, lue dans la scène : `roundnessFrac` est la -/// valeur du slider (px de sortie) sur le petit côté de la sortie (`sceneDescription.ts`). -/// `ROUNDNESS_SLIDER_MAX_PX` est le miroir du maximum du slider (`paramUnits.ts`). +/// valeur du slider (px) sur la référence FIXE de 1080 px (`sceneDescription.ts`), et non plus +/// sur le petit côté de la sortie — qui suit la source, si bien qu'une prise 4K recevait des +/// coins deux fois moins ronds qu'une prise 1080p. `ROUNDNESS_SLIDER_MAX_PX` est le miroir du +/// maximum du slider (`paramUnits.ts`). pub(crate) const ROUNDNESS_SLIDER_MAX_PX: f32 = 64.0; pub(crate) fn roundness_slider_position(scene: &crate::scene::Scene) -> f32 { - let out_min = scene.output.width.min(scene.output.height).max(1) as f32; - (scene.effects.roundness_frac * out_min / ROUNDNESS_SLIDER_MAX_PX).clamp(0.0, 1.0) + (scene.effects.roundness_frac * SHADOW_TUNING_REF_PX / ROUNDNESS_SLIDER_MAX_PX).clamp(0.0, 1.0) +} + +/// L'UNITÉ DE L'ÉCRAN, px : le petit côté du cadre qu'aurait cet écran dans une sortie à SON ratio. +/// +/// `content_px` est l'enregistrement affiché au repos (crop compris, zoom exclu, en entier même +/// quand un slot en `cover` en rogne une partie). Divisé par `padding_scale`, il vaut exactement le +/// petit côté du cadre (`frame_min_px`) quand la sortie a le ratio de l'enregistrement — Auto, ou +/// un format fixe qui lui correspond : le rendu y est inchangé. Quand le format diffère (un clip +/// 16:9 dans une sortie 9:16), l'écran n'occupe plus qu'une bande du cadre et l'unité rétrécit +/// avec lui : le curseur, l'ombre et les coins gardent leur proportion À L'ÉCRAN au lieu de +/// grossir de 1,8× à côté de lui. +/// +/// Ce n'est pas `frame_unit_px` : celle-ci est faite pour qu'un cadre d'appareil ait la même +/// épaisseur autour de n'importe quel clip d'une sortie, donc elle vaut `frame_min_px × +/// padding_scale` pour tout clip contenu dans la zone paddée — y compris la bande 16:9 d'une +/// sortie 9:16, où elle ne corrigerait rien. +pub(crate) fn screen_unit_px(content_px: [f32; 2], padding_scale: f32) -> f32 { + content_px[0].min(content_px[1]) / padding_scale.max(1e-3) } /// Le rayon des coins du métrage au BOUT de la course de Roundness sous chaque cadre, en unités @@ -707,6 +782,20 @@ pub(crate) fn device_kind_id(kind: crate::scene::SceneFrame) -> f32 { } } +/// Le verre noir de la face avant, tel que le mode 17 le rend de face : l'albédo `DEV_GLASS_*` +/// des shaders sous leur lampe (`MODEL_AMBIENT + MODEL_DIFFUSE · n·l`, n = +z au repos). Le verre +/// posé sous le métrage d'un bloc (`FrameGeometry::window_frame_cb`) prolonge ainsi la lunette +/// sans couture. Miroir des trois shaders : teintes et coefficients. +pub(crate) fn device_glass_rgba(dark: bool) -> [f32; 4] { + const GLASS_LIGHT: [f32; 3] = [0.031, 0.034, 0.040]; + const GLASS_GRAPHITE: [f32; 3] = [0.043, 0.047, 0.055]; + const MODEL_AMBIENT: f32 = 0.36; + const MODEL_DIFFUSE: f32 = 0.75; + let lit = MODEL_AMBIENT + MODEL_DIFFUSE * MODEL_LIGHT[2]; + let [r, g, b] = if dark { GLASS_GRAPHITE } else { GLASS_LIGHT }; + [r * lit, g * lit, b * lit, 1.0] +} + /// Les huit coins d'une boîte alignée. fn box_corners(lo: [f32; 3], hi: [f32; 3]) -> [[f32; 3]; 8] { std::array::from_fn(|i| { @@ -911,10 +1000,10 @@ pub(crate) fn screen_corner_radius_px(r: f32, s_px: [f32; 2]) -> f32 { /// unité du cadre `u` (px) : le filet à gauche, à droite et en bas, la barre de titre en haut (cf. /// `WindowFrame::margins`). /// -/// L'écran ne rétrécit plus pour faire place au cadre : **le métrage a la même taille avec et sans -/// cadre**, et c'est le cadre qui pousse vers l'extérieur — dans le padding, et au-delà du canvas -/// s'il le faut, où la sortie le coupe. Rétrécir l'image pour loger un objet décoratif, c'était -/// l'inverse de ce qu'on veut : le métrage est le sujet, le cadre son écrin. +/// Le compositeur ne rétrécit jamais la boîte écran qu'il reçoit pour loger le cadre : le cadre +/// pousse vers l'extérieur. C'est le layout de l'app qui lui fait place : le padding se mesure +/// depuis le bord extérieur de tout ce que le cadre dessine (`computeCompositeLayout`), si bien +/// qu'au repos il ne sort jamais du canvas, et c'est l'ensemble qui est centré. pub(crate) fn window_frame_margins(s_px: [f32; 2], u: f32) -> [f32; 4] { let (bar, line) = (WINDOW_FRAME_BAR_FRAC * u, WINDOW_FRAME_LINE_FRAC * u); let (sw, sh) = (s_px[0].max(1.0), s_px[1].max(1.0)); @@ -938,7 +1027,24 @@ pub enum ShadowCaster { /// Rect droit (mode 2) : `dst` en fractions de sortie, `size_px` sa taille, `radius` en px. Upright { dst: [f32; 4], size_px: [f32; 2], radius: f32 }, /// Quad incliné (mode 12) : coins TL, TR, BR, BL relatifs à `center_px`, rayon du plan. - Tilted { corners: [(f32, f32); 4], center_px: [f32; 2], radius: f32 }, + /// `mask` : le slot d'un layout en bloc, qui rogne le plan — l'ombre est alors celle de + /// leur intersection, ce qu'on voit (`shadow_mask_fields`). + Tilted { corners: [(f32, f32); 4], center_px: [f32; 2], radius: f32, mask: Option }, +} + +/// Les champs du mode 12 qui rognent l'ombre d'un plan incliné au slot qui le masque : `dst_prev` +/// = le slot en px LOCAUX à la boîte de l'ombre (x0, y0, x1, y1), `mb.w` = le rayon de ses coins. +/// `origin_px` : l'origine de ce repère local, en px de sortie, décalage de l'ombre NON compris — +/// le slot et le plan portent le même. Des zéros sans masque : le shader n'en fait rien. +pub fn shadow_mask_fields( + mask: Option, + origin_px: [f32; 2], + render_px: [f32; 2], +) -> ([f32; 4], f32) { + let Some(m) = mask else { return ([0.0; 4], 0.0) }; + let [x, y, w, h] = m.rect; + let (x0, y0) = (x * render_px[0] - origin_px[0], y * render_px[1] - origin_px[1]); + ([x0, y0, x0 + w * render_px[0], y0 + h * render_px[1]], m.radius_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`. @@ -1099,7 +1205,7 @@ pub fn cursor_sprite_cb( src_prev: [br0, br1, bl0, bl1], // Le clip vit ici et NON dans `fx` (mode 7) : `fx` porte les coins. dst_prev: clip, - // `mb.x` : 1 = warp projectif (caméra réelle). + // `mb.x` : 1 = warp projectif (caméra réelle ou appareil, cf. `screen_tilt`). mb: [quad.warp_flag(), 0.0, 0.0, 0.0], ..Default::default() } @@ -1107,15 +1213,80 @@ pub fn cursor_sprite_cb( } } +// ============ Verre à plat (mode 19) ============ +// +// Glass Lens, 3D éteinte : la lentille de son modèle (`sculpt.rs`, `s_glass` dans les shaders) +// vue de face, posée sur l'image comme le sprite plat (le quad projeté et le warp inverse du +// mode 13), mais dessinée par le shader, qui réfracte la copie de l'image composée +// (`CursorPlan::glass`) au travers d'un relief tiré des mêmes formes. Le PNG du thème ne sert plus +// qu'au sélecteur. +// +// Emplacements du `LayerCB` au mode 19 : +// dst, quad_px bbox du rect du modèle projeté (sortie 0..1, px) +// fx, src_prev coins TL, TR puis BR, BL, en px locaux à la bbox (comme au mode 13) +// dst_prev rect de clip « Clip to canvas » (sortie 0..1) +// mb.x 1 = warp projectif (caméra réelle) +// src.xy l'unité du modèle en fractions de la sortie (x, y) : le décalage de la réfraction +// color.rg coin haut-gauche du rect, repère du modèle (unités), comme au mode 15 +// color.b l'unité du modèle en px de sortie : l'antialiasing +// color.a opacité +// radius_px rapport w/h du rect (`sprite_size` des shaders) +// trail_a.x le curseur sculpté (`SpriteShape::sculpt`) +// Texture : la copie de l'image composée, t5/texture(5) (`texDof`) sous Windows et macOS, au +// binding 1 (`texY`) sous Linux, comme pour le cristal du mode 15. + +/// Le verre à plat du curseur sculpté `name` posé en `placement` : la boîte du modèle a pour plus +/// grand côté `size_px`, comme au mode 15. `None` pour un nom qui n'est pas un modèle. +pub fn cursor_glass_cb( + placement: CursorPlacement, + size_px: f32, + name: &str, + alpha: f32, + clip: [f32; 4], + render_px: [f32; 2], +) -> Option { + let shape = crate::sculpt::sculpted_shape(name)?; + let [rw, rh] = render_px; + let sprite_px = [shape.size[0] * size_px, shape.size[1] * size_px]; + let mut cb = cursor_sprite_cb(placement, sprite_px, shape.hotspot, alpha, clip, render_px); + if cb.mode < 12.5 { + // Écran droit (mode 7) : son rect, sous la forme du mode 13. Le warp bilinéaire d'un + // rect est exact. + let [w, h] = [cb.dst[2] * rw, cb.dst[3] * rh]; + cb.quad_px = [w, h]; + cb.fx = [0.0, 0.0, w, 0.0]; + cb.src_prev = [w, h, 0.0, h]; + cb.dst_prev = clip; + } + // L'unité telle que le plan la montre : le bord gauche du rect projeté (TL -> BL). + let unit_px = (cb.src_prev[2] - cb.fx[0]).hypot(cb.src_prev[3] - cb.fx[1]) / shape.size[1]; + cb.mode = 19.0; + cb.src = [unit_px / rw, unit_px / rh, 0.0, 0.0]; + cb.color = [shape.origin()[0], shape.origin()[1], unit_px, alpha]; + cb.radius_px = shape.size[0] / shape.size[1]; + cb.trail_a = [shape.sculpt as f32, 0.0, 0.0, 0.0]; + Some(cb) +} + /// Gain de l'éclairage de la caméra réelle : une lampe posée sur la caméra, dont la lumière /// décroît avec le carré de la distance. 1 = la décroissance physique ; moins, pour que le /// contenu reste lisible. L'œil en orbite passe loin de l'axe : à 22° d'azimut, l'écart d'un bord /// à l'autre vaut `0,42·gain` en 16:9, soit ±4 % ici. pub const CAMERA_LIGHT_GAIN: f32 = 0.2; +/// Le plan incliné une frame plus tôt, et le réglage du flou : ce que le mode 8 floute +/// (`FrameGeometry::tilt_trail`, `tilted_screen_cb`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TiltTrail { + /// Coins TL, TR, BR, BL du quad à la frame précédente, en px relatifs au centre de la boîte + /// COURANTE — le repère de `TiltedQuad::corners`. + pub corners: [(f32, f32); 4], + /// `[taps, force]` : ceux du mode 0 (`FrameGeometry::mb_taps`, `mb_amount`). + pub mb: [f32; 2], +} + /// `LayerCB` de l'écran incliné (mode 8), pour les trois backends : le quad projeté est dessiné -/// dans sa BBOX et le fragment remonte au (s, t) du plan par le warp inverse. Pas de flou de -/// mouvement sur ce chemin : `src_prev`/`dst_prev` y portent déjà les coins et le plan. +/// dans sa BBOX et le fragment remonte au (s, t) du plan par le warp inverse. /// /// - `src` : la coupe source en UV texture ; `center_px` : le centre du rect `s_px` à l'écran ; /// - `radius` : le rayon de l'écran droit, que le plan réduit de `quad.scale` ; @@ -1125,10 +1296,19 @@ pub const CAMERA_LIGHT_GAIN: f32 = 0.2; /// du cadre quand le rayon dépasse la barre (`color.z` = la même hauteur, px du plan) ; /// - `dof` : la profondeur de champ tourne (`FrameGeometry::depth_of_field_on`). /// -/// Sous la caméra réelle (`quad.projective`) : `dst_prev.w` = 1 (warp projectif), et `color.xy` -/// porte l'éclairage, `1 + color.x·(s − 0,5) + color.y·(t − 0,5)` : la lampe de la caméra, fixe -/// dans le monde comme l'œil, éclaire un peu plus le côté proche. Nuls sous un angle fixe, dont le -/// rendu reste celui d'avant à l'octet. +/// `quad.projective` : `dst_prev.w` = 1 (warp projectif). Sous la caméra réelle (`quad.lamp`), +/// `color.xy` porte l'éclairage, `1 + color.x·(s − 0,5) + color.y·(t − 0,5)` : la lampe de la +/// caméra, fixe dans le monde comme l'œil, éclaire un peu plus le côté proche. Nuls sous un angle +/// fixe, dont l'exposition reste celle d'avant. +/// +/// Sous le masque d'un layout en bloc (`mask`) : le plan penche DANS le slot, qui le rogne. Il est +/// dessiné dans le rect du slot au lieu de sa bbox, et `color.w` porte le rayon des coins du slot, +/// que le shader applique à ce rect. Nul sans masque, comme avant. +/// +/// `trail` : le plan à la frame précédente (`FrameGeometry::tilt_pixel_trail`). Le shader floute +/// le contenu le long du chemin qu'a parcouru chaque pixel depuis, borné à une frame et réglé comme +/// le mode 0 (`trail_a`, `trail_b`, `trail_mb`). `None`, ou un seul tap : l'échantillon net, le +/// rendu d'avant à l'octet. #[allow(clippy::too_many_arguments)] pub fn tilted_screen_cb( quad: &crate::regions::TiltedQuad, @@ -1140,6 +1320,8 @@ pub fn tilted_screen_cb( top_lift_px: f32, dof: bool, render_px: [f32; 2], + mask: Option, + trail: Option, ) -> LayerCB { let [rw, rh] = render_px; let corners = quad.corners; @@ -1172,7 +1354,7 @@ pub fn tilted_screen_cb( } else { [0.0; 4] }; - LayerCB { + let mut cb = LayerCB { dst: [(center_px[0] + min_x) / rw, (center_px[1] + min_y) / rh, bbox_w / rw, bbox_h / rh], src, quad_px: [bbox_w, bbox_h], @@ -1189,7 +1371,24 @@ pub fn tilted_screen_cb( // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`). mb: quad.depth_mb(s_px, focus_plane, dof), ..Default::default() - } + }; + if let Some(trail) = trail { + // Même repère local que les coins courants : sans mouvement, les deux quads coïncident + // au bit près et le shader garde son échantillon net. + let [a0, a1] = local(trail.corners[0]); + let [b0, b1] = local(trail.corners[1]); + let [c0, c1] = local(trail.corners[2]); + let [d0, d1] = local(trail.corners[3]); + cb.trail_a = [a0, a1, b0, b1]; + cb.trail_b = [c0, c1, d0, d1]; + cb.trail_mb = [trail.mb[0], trail.mb[1], 0.0, 0.0]; + } + if let Some(mask) = mask { + reframe_warped(&mut cb, mask.rect, render_px); + // Négatif : coins hauts carrés, sous la barre de la fenêtre (`slot_alpha`, shaders). + cb.color[3] = if mask.square_top { -mask.radius_px } else { mask.radius_px }; + } + cb } /// `LayerCB` d'une ombre portée par un quadrilatère (mode 12). `corners` (TL, TR, BR, BL) en px @@ -1453,24 +1652,73 @@ fn same_source_path(a: &str, b: &str) -> bool { a.eq_ignore_ascii_case(b) } -/// Période commune des mouvements de fond (s). Chaque période du shader (20, 24, 30, 40, 12, -/// 120 s) la divise, donc replier le temps dessus ne crée aucun raccord visible — et garde au +/// Période commune des mouvements de fond (s). Chaque période du shader (6, 10, 12, 15, 20, +/// 30 s) la divise, donc replier le temps dessus ne crée aucun raccord visible — et garde au /// shader un temps borné, là où un `f32` de plusieurs heures perdrait la finesse du bruit. -pub const GRADIENT_MOTION_PERIOD_S: f32 = 120.0; +pub const WALLPAPER_MOTION_PERIOD_S: f32 = 120.0; + +/// Ce qui décide à lui seul du fond composé — sa description, la force de son flou, la couleur +/// de repli, la taille de rendu —, gardé avec sa copie (`bg_cache`) : tant que le fond demandé +/// lui est égal, le backend recopie le fond de la frame d'avant au lieu de le redessiner, ce qui +/// coûtait plus que l'écran lui-même (1,65 ms par frame 1080p sur une Radeon 610M, un wallpaper +/// 4K échantillonné sans mips). +/// +/// La comparaison de chaque frame (`matches`) ne construit ni ne copie rien : une image en data +/// URI pèse plusieurs Mo. Seul `of`, au moment de garder un nouveau fond, la clone. +#[derive(Clone, PartialEq)] +pub struct BackgroundKey { + background: Option, + blur: f32, + fallback: [f32; 4], + render_px: [f32; 2], +} + +impl BackgroundKey { + /// La clé d'un fond fixe ; `None` pour un fond animé, qui change à chaque frame. + pub fn of( + background: Option<&crate::scene::SceneBackground>, + blur: f32, + fallback: [f32; 4], + render_px: [f32; 2], + ) -> Option { + use crate::scene::{SceneBackground as B, WallpaperMotion}; + if let Some(B::Gradient { motion, .. } | B::Image { motion, .. }) = background { + if *motion != WallpaperMotion::None { + return None; + } + } + Some(BackgroundKey { background: background.cloned(), blur, fallback, render_px }) + } + + /// Le fond demandé est celui-ci. + pub fn matches( + &self, + background: Option<&crate::scene::SceneBackground>, + blur: f32, + fallback: [f32; 4], + render_px: [f32; 2], + ) -> bool { + self.background.as_ref() == background + && self.blur == blur + && self.fallback == fallback + && self.render_px == render_px + } +} -/// Emplacements libres du mode 5 pour le fond animé : `fx.zw` = (temps programme replié, indice -/// du mouvement), `mb.x` = aspect w/h de la sortie (les nappes de l'aurore restent rondes). +/// Emplacements libres des modes 5 (dégradé) et 6 (image) pour le fond animé : `fx.zw` = (temps +/// programme replié, indice du mouvement), `mb.x` = aspect w/h du rect rempli (les nappes de +/// l'aurore restent rondes). /// -/// `GradientMotion::None` rend les zéros d'avant l'animation, emplacement pour emplacement : le -/// dégradé immobile reste celui d'aujourd'hui, octet pour octet. Fonction pure du temps +/// `WallpaperMotion::None` rend les zéros d'avant l'animation, emplacement pour emplacement : le +/// fond immobile reste celui d'aujourd'hui, octet pour octet. Fonction pure du temps /// programme, jamais d'un état porté de frame en frame — preview et export, lecture et seek /// tombent sur la même image. -pub fn gradient_motion_slots( - motion: crate::scene::GradientMotion, +pub fn wallpaper_motion_slots( + motion: crate::scene::WallpaperMotion, programme_t: f32, aspect: f32, ) -> ([f32; 2], [f32; 4]) { - use crate::scene::GradientMotion as M; + use crate::scene::WallpaperMotion as M; let index = match motion { M::None => return ([0.0, 0.0], [0.0; 4]), M::Drift => 1.0, @@ -1478,11 +1726,76 @@ pub fn gradient_motion_slots( M::Waves => 3.0, }; ( - [programme_t.rem_euclid(GRADIENT_MOTION_PERIOD_S), index], + [programme_t.rem_euclid(WALLPAPER_MOTION_PERIOD_S), index], [aspect, 0.0, 0.0, 0.0], ) } +/// Un dégradé linéaire en calque du mode 5, stops compris : quatre nœuds `[r, g, b, position]` +/// dans `color`, `src_prev`, `dst_prev` puis `src`, dans l'ordre du dégradé. Le shader va de nœud +/// en nœud comme CSS, donc un stop du milieu est peint là où la vignette le montre. Avant, seuls +/// le premier et le dernier stop arrivaient au shader, et le milieu d'un dégradé à trois stops +/// (celui de l'ancien éditeur) disparaissait du rendu. +/// +/// Positions : celles de l'app quand il y en a une par stop, sinon réparties à égale distance +/// (scène plus ancienne). Bornées à 0..1 et jamais en recul sur la précédente, comme en CSS. Un +/// stop illisible est sauté ; sans aucun stop lisible, `fallback` remplit tout. Moins de quatre +/// stops : le dernier se répète, en segments de longueur nulle qui ne peignent rien. Deux stops +/// à 0 et 1 rendent donc exactement l'ancien `lerp(c0, c1, t)`. +pub fn gradient_layer(stops: &[String], offsets: &[f32], fallback: [f32; 4]) -> LayerCB { + let mut knots: Vec<[f32; 4]> = Vec::with_capacity(stops.len().max(4)); + let mut floor = 0.0f32; + for (i, stop) in stops.iter().enumerate() { + let Some(c) = parse_hex(stop) else { continue }; + let raw = if offsets.len() == stops.len() { + offsets[i] + } else if stops.len() > 1 { + i as f32 / (stops.len() - 1) as f32 + } else { + 0.0 + }; + let at = if raw.is_finite() { raw.clamp(0.0, 1.0) } else { floor }.max(floor); + floor = at; + // Un stop translucide (le preset v1.5 `rgba(235,230,44,0.55)` vit dans des projets + // enregistrés) est prémultiplié : le fond est la couche du dessous, posée sur le clear + // noir, donc honorer son alpha revient exactement à `rgb · a`. CSS interpole aussi en + // prémultiplié, d'où la rampe juste entre deux stops d'alphas différents. Le nœud n'a + // pas de place pour l'alpha (w = position), et le shader rend le mode 5 opaque. + knots.push([c[0] * c[3], c[1] * c[3], c[2] * c[3], at]); + } + if knots.is_empty() { + knots.push([fallback[0], fallback[1], fallback[2], 0.0]); + } + // ponytail: quatre nœuds, les emplacements libres du mode 5. Au-delà (les presets v1.5 en + // avaient jusqu'à sept), on retire tour à tour le stop intérieur que ses voisins reproduisent + // le mieux. Une rampe en texture lèverait la limite si un dégradé plus riche devenait courant. + while knots.len() > 4 { + let miss = |i: usize| { + let (p, k, n) = (knots[i - 1], knots[i], knots[i + 1]); + let span = n[3] - p[3]; + if span <= 1e-6 { + return 0.0; // stop de largeur nulle : il ne peint rien + } + let f = (k[3] - p[3]) / span; + (0..3).map(|c| (k[c] - (p[c] + (n[c] - p[c]) * f)).abs()).sum::() + }; + let drop = (1..knots.len() - 1).min_by(|&a, &b| miss(a).total_cmp(&miss(b))).unwrap_or(1); + knots.remove(drop); + } + while knots.len() < 4 { + let last = knots[knots.len() - 1]; + knots.push(last); + } + LayerCB { + mode: 5.0, + color: knots[0], + src_prev: knots[1], + dst_prev: knots[2], + src: knots[3], + ..Default::default() + } +} + /// True when this clip really has a camera to draw. /// /// TWO ways the app says "no camera", and both must be caught here, because the @@ -1607,9 +1920,15 @@ pub struct FrameGeometry { /// Part dynamique du tilt (parallaxe, `regions::dynamic_tilt`), ajoutée à la base à la /// projection. Nulle quand la base est neutre. pub zoom_rotation_dyn: [f32; 3], - /// Caméra réelle de `follow-cursor` (`camera.rs`), `None` sans elle. Jamais en même temps + /// Caméra réelle de `orbit` (`camera.rs`), `None` sans elle. Jamais en même temps /// qu'une `zoom_rotation` non nulle. pub camera: Option, + /// La rotation de base une frame d'écran plus tôt : avec `s_dst_prev` et `camera_prev`, le + /// zoom d'AVANT, que le mode 8 floute (`tilt_trail`). + pub zoom_rotation_prev: [f32; 3], + /// La caméra réelle une frame plus tôt (poids, visée, orbite, zoom), `None` si elle n'y était + /// pas active — même quand elle l'est à cette frame-ci, ou l'inverse. + pub camera_prev: Option, pub padding_scale: f32, /// Coupe source de l'écran en UV texture (crop utilisateur + zoom). pub cut: [f32; 4], @@ -1637,6 +1956,9 @@ pub struct FrameGeometry { pub s_ann: [f32; 4], pub s_radius: f32, pub frame_min_px: f32, + /// L'unité de l'ÉCRAN (`screen_unit_px`) : ce que mesurent le curseur, l'ombre de l'écran et + /// ses coins sans cadre. + pub screen_unit_px: f32, pub w_dst: [f32; 4], pub w_dst_prev: [f32; 4], pub w_px: [f32; 2], @@ -1647,6 +1969,64 @@ pub struct FrameGeometry { /// déjà la boîte rétrécie, et `s_radius` le rayon des coins de l'écran — des seuls coins BAS /// sous une barre de titre (fenêtre, navigateur), des quatre pour les autres appareils. pub window_frame: Option, + /// Layouts en bloc : le slot de l'écran, un conteneur qui le MASQUE (cf. `ScreenMask`). + /// `None` ailleurs, et le rendu est celui d'avant, à l'octet. + pub screen_mask: Option, +} + +/// Le conteneur d'un layout en bloc (côte à côte, haut/bas) : un masque fixe, overflow hidden. +/// +/// L'écran y garde la géométrie de tous les layouts — le zoom dans la boîte (#179), la 3D sur le +/// métrage — et seul son DESSIN est rogné au slot. Sans lui, la boîte zoomée débordait sur la +/// caméra et dans l'espace qui les sépare, et le bloc ne tenait plus. Le masque, lui, ne zoome +/// ni ne penche jamais : le layout garde son rect et ses coins, et son ombre est la sienne. +/// +/// Sous un cadre, le cadre EST ce conteneur : fenêtre ou appareil, il reste au repos autour du +/// slot, à plat, et le slot est son ouverture. Le métrage zoome et penche dedans. C'est l'inverse +/// des autres layouts, où l'appareil zoome et penche avec l'écran (`FrameGeometry::frame_box`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScreenMask { + /// Le slot, en fractions de la sortie : `layout.screenRect`, que le zoom ne touche pas. + pub rect: [f32; 4], + /// Le rayon de ses coins, en px du render target : celui de l'écran au repos. + pub radius_px: f32, + /// Coins HAUTS carrés : le slot est sous la barre de la fenêtre qui le contient. À plat, le + /// mode 0 le sait déjà (`screen_square_top`) ; incliné, le mode 8 le lit dans le signe de son + /// rayon (`tilted_screen_cb`). + pub square_top: bool, +} + +impl ScreenMask { + /// `dst` rogné au slot, en fractions de la sortie. `None` s'il n'en reste rien ; `dst` tel + /// quel, à l'octet, s'il y tient déjà (une intersection recalculée en différerait d'un ulp). + fn clip(&self, dst: [f32; 4]) -> Option<[f32; 4]> { + let [x, y, w, h] = self.rect; + if dst[0] >= x && dst[1] >= y && dst[0] + dst[2] <= x + w && dst[1] + dst[3] <= y + h { + return Some(dst); + } + let r = intersect_rect(dst, self.rect); + (r[2] > 0.0 && r[3] > 0.0).then_some(r) + } +} + +/// Redessine un calque WARPÉ (modes 8 et 16 : coins TL/TR dans `fx`, BR/BL dans `src_prev`, en px +/// locaux à `dst` ; au mode 8, ceux de la frame d'avant dans `trail_a`/`trail_b`) dans une autre +/// boîte `dst`. Le warp ne dépend pas de la boîte de dessin, qui ne fait que borner les pixels +/// rastérisés : il suffit de reporter les coins dans son repère. +fn reframe_warped(cb: &mut LayerCB, dst: [f32; 4], render_px: [f32; 2]) { + let shift = [(dst[0] - cb.dst[0]) * render_px[0], (dst[1] - cb.dst[1]) * render_px[1]]; + let reframe = |c: &mut [f32; 4]| { + *c = [c[0] - shift[0], c[1] - shift[1], c[2] - shift[0], c[3] - shift[1]]; + }; + reframe(&mut cb.fx); + reframe(&mut cb.src_prev); + // La traînée, seulement s'il y en a une : sans elle, ses champs restent nuls. + if cb.trail_mb[0] > 0.0 { + reframe(&mut cb.trail_a); + reframe(&mut cb.trail_b); + } + cb.dst = dst; + cb.quad_px = [dst[2] * render_px[0], dst[3] * render_px[1]]; } /// Rect de destination d'une annotation dans un rect d'ancrage, en fractions de la sortie. @@ -1689,21 +2069,59 @@ impl FrameGeometry { self.zoom_rotation, self.zoom_rotation_dyn, ); - // Sous un cadre d'APPAREIL, l'écran passe au warp PROJECTIF même sous un angle fixe. - // Le mode 17 lance ses rayons dans la perspective exacte ; le warp bilinéaire s'en - // écarte de ~8 % de la largeur au milieu des bords (mesuré par - // `the_device_screen_face_lands_on_the_footage_plane`), soit une lunette à cent - // pixels du bord de l'image. Les quatre coins sont les mêmes des deux côtés, donc - // l'homographie qu'ils définissent EST cette projection exacte : passer le drapeau - // recolle l'image sur le modèle, partout et pas seulement aux coins. Sans appareil — - // sans cadre ou sous le chrome plat — rien ne change, à l'octet. - if self.window_frame.as_ref().is_some_and(|f| f.kind.is_device()) { + // Un angle fixe se dessine au warp BILINÉAIRE de ses coins : le rendu des angles fixes + // de la v1.13.0, à l'octet. Sous un cadre d'APPAREIL seulement, l'écran passe au warp + // PROJECTIF : le mode 17 lance ses rayons dans la perspective exacte, et le warp + // bilinéaire s'en écarte de ~8 % de la largeur au milieu des bords (mesuré par + // `the_device_screen_face_lands_on_the_footage_plane`), soit une lunette à cent pixels + // du bord de l'image. Les quatre coins sont les mêmes des deux côtés, donc + // l'homographie qu'ils définissent EST cette projection exacte. Pas sous le masque d'un + // bloc : l'appareil y reste au repos, le métrage penche seul dans son ouverture, au + // warp de l'écran sans cadre. + if self.window_frame.as_ref().is_some_and(|f| f.kind.is_device()) + && self.screen_mask.is_none() + { quad.projective = true; } quad }) } + /// Le plan incliné une frame d'écran plus tôt, avec le réglage du flou : d'où part la + /// traînée de l'écran cadré (`screen_trail_cb`), ou celle du seul métrage sous un masque de + /// bloc (`tilt_pixel_trail`). `None` quand l'écran est droit ou le flou coupé. + /// + /// La frame d'avant est celle du mode 0 : la boîte au zoom d'avant (`s_dst_prev`), et ici la + /// rotation de base d'avant (un angle fixe entre avec le zoom) ou la caméra réelle d'avant + /// (`camera_prev`). Parallaxe et impact restent ceux de la frame, comme le focus du mode 0 : le + /// flou suit le zoom et la caméra, borné à une frame. + pub fn tilt_trail(&self, render_px: [f32; 2]) -> Option { + if !self.tilted() || self.mb_taps < 2.0 || self.mb_amount <= 0.001 { + return None; + } + let d = self.s_dst_prev; + let px = [d[2] * render_px[0], d[3] * render_px[1]]; + let quad = match self.camera_prev { + Some(pose) => crate::camera::View::new(px, pose).quad(px), + None => crate::regions::rotated_quad_corners_px( + px[0], + px[1], + self.zoom_rotation_prev, + self.zoom_rotation_dyn, + ), + }; + // Les coins d'avant, reportés au centre de la boîte COURANTE (le repère des siens). + let now = self.screen_center_px(render_px); + let shift = [ + (d[0] + d[2] * 0.5) * render_px[0] - now[0], + (d[1] + d[3] * 0.5) * render_px[1] - now[1], + ]; + Some(TiltTrail { + corners: quad.corners.map(|(x, y)| (x + shift[0], y + shift[1])), + mb: [self.mb_taps, self.mb_amount], + }) + } + /// L'écran passe-t-il par le plan projeté (modes 8, 12, 13, 14) plutôt que par le rect droit ? pub fn tilted(&self) -> bool { self.camera.is_some() || !crate::regions::is_identity_rotation(self.zoom_rotation) @@ -1747,6 +2165,97 @@ impl FrameGeometry { } } + /// L'écran cadré se déplace-t-il EN BLOC sous le flou de mouvement sur cette frame ? + /// + /// La coupe est la même aux deux frames : tout le mouvement de l'écran vient de sa boîte, + /// `s_dst_prev` → `s_dst`, et incliné, du plan qui la montre (`tilt_trail`). L'ombre, le cadre + /// et l'appareil sont taillés dans cette boîte et posés sur ce plan. C'est donc un objet + /// rigide, et on le floute comme tel (mode 18, `screen_trail_cb`) : flouter le seul métrage + /// laissait un contenu qui file dans un cadre aux bords nets, et sous une caméra 3D, c'était + /// le cas de tout zoom. + /// + /// Incliné, le plan a bougé quand ses coins ont bougé : sans mouvement, ceux de `tilt_trail` + /// sont les siens au bit près, et le rendu reste celui d'avant, à l'octet. + /// + /// Pas sous un masque de bloc : la case ne bouge pas, seul le métrage bouge dedans, et le + /// flou par pixel (modes 0 et 8) reste le bon. + pub fn screen_trail(&self, render_px: [f32; 2]) -> bool { + if self.mb_taps < 2.0 || self.mb_amount <= 0.001 || self.screen_mask.is_some() { + return false; + } + match self.screen_tilt_in(render_px) { + None => self.s_dst != self.s_dst_prev, + Some(quad) => self.tilt_trail(render_px).is_some_and(|t| t.corners != quad.corners), + } + } + + /// Taps du flou PAR PIXEL du mode 0 de l'écran : un seul quand `screen_trail` floute déjà + /// l'objet entier, sinon le métrage serait flouté deux fois. + pub fn screen_pixel_taps(&self, render_px: [f32; 2]) -> f32 { + if self.screen_trail(render_px) { 1.0 } else { self.mb_taps } + } + + /// La traînée PAR PIXEL du mode 8 : `tilt_trail`, sauf quand `screen_trail` floute déjà + /// l'objet entier. Le masque de confidentialité, lui, lit toujours `tilt_trail` : les deux + /// flous étalent le secret sur le même chemin. + pub fn tilt_pixel_trail(&self, render_px: [f32; 2]) -> Option { + self.tilt_trail(render_px).filter(|_| !self.screen_trail(render_px)) + } + + /// Le calque du mode 18 : le rendu isolé de l'écran cadré (t2), recomposé le long de sa + /// trajectoire. `src` = la coupe (le métrage relu directement là où le rendu isolé s'arrête + /// au bord de la sortie), `quad_px` et `radius_px` = l'écran et ses coins : ce repli ne lit le + /// métrage que dans l'ouverture arrondie, jamais sous la lunette d'un appareil. + /// + /// À plat, la trajectoire est celle de la boîte : `fx` = la courante, `dst_prev` = la + /// précédente. Incliné (`mb.z` = 1), celle du plan : ses coins TL, TR, BR, BL courants dans + /// `fx`/`src_prev` et ceux de `tilt_trail` dans `trail_a`/`trail_b`, en fractions de la + /// sortie comme la boîte ; `quad_px` et `radius_px` sont alors pris dans le plan, comme au + /// mode 8. `mb.w` = le warp du plan (`TiltedQuad::warp_flag`, bilinéaire sous un angle fixe, + /// projectif sous la caméra réelle ou un appareil) : le shader passe par lui dans les deux + /// sens, comme le mode 8 qui a dessiné le métrage. + /// + /// Le métrage relu hors de la sortie l'est comme le mode 8 l'a dessiné : incliné, `trail_mb` + /// porte SA profondeur de champ (son `mb` ; `dof` = `depth_of_field_on`, vrai quand la + /// pyramide est liée) et `color.xy` sa lampe. Nuls à plat : le repli relit le métrage net. + pub fn screen_trail_cb(&self, render_px: [f32; 2], dof: bool) -> LayerCB { + let s_px = [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]]; + let flat = LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: self.cut, + quad_px: s_px, + radius_px: self.s_radius, + mode: 18.0, + fx: self.s_dst, + dst_prev: self.s_dst_prev, + mb: [self.mb_taps, self.mb_amount, 0.0, 0.0], + ..Default::default() + }; + let (Some(quad), Some(trail)) = (self.screen_tilt_in(render_px), self.tilt_trail(render_px)) + else { + return flat; + }; + let c = self.screen_center_px(render_px); + let out = |(x, y): (f32, f32)| [(c[0] + x) / render_px[0], (c[1] + y) / render_px[1]]; + let [tl, tr, br, bl] = quad.corners.map(out); + let [ptl, ptr, pbr, pbl] = trail.corners.map(out); + // Le calque que le mode 8 dessine pour ce plan : sa profondeur de champ et sa lampe ne + // dépendent ni du rayon, ni du chrome, ni d'un masque. + let plane = tilted_screen_cb(&quad, s_px, c, self.cut, self.focus_plane, 0.0, 0.0, dof, render_px, None, None); + LayerCB { + quad_px: s_px.map(|v| v * quad.scale), + radius_px: self.s_radius * quad.scale, + color: [plane.color[0], plane.color[1], 0.0, 0.0], + fx: [tl[0], tl[1], tr[0], tr[1]], + src_prev: [br[0], br[1], bl[0], bl[1]], + trail_a: [ptl[0], ptl[1], ptr[0], ptr[1]], + trail_b: [pbr[0], pbr[1], pbl[0], pbl[1]], + mb: [self.mb_taps, self.mb_amount, 1.0, quad.warp_flag()], + trail_mb: plane.mb, + ..flat + } + } + /// Sous le chrome de fenêtre, de combien son contour INTÉRIEUR remonte au-dessus du bord haut /// de l'écran, en px de l'écran droit : la barre de titre moins le filet. 0 ailleurs. /// @@ -1756,16 +2265,49 @@ impl FrameGeometry { /// sortirait. Le shader rogne alors ses coins hauts par ce même arc, pris au contour /// intérieur du filet (`sd_screen_under_bar`) — l'écran suit le cadre, il ne s'arrondit pas /// de son côté. Le mode 0 le lit dans `mb.z`, le mode 8 dans `color.z` (px du plan). + /// + /// Sous le masque d'un bloc, la fenêtre reste au repos autour du slot : la remontée est la + /// sienne, et le métrage droit, rogné au slot, épouse son contour. Incliné, il penche DANS la + /// fenêtre, arrondi de partout comme sans cadre : 0. pub fn screen_top_lift_px(&self, render_px: [f32; 2]) -> f32 { match self.window_frame.as_ref() { + Some(_) if self.screen_mask.is_some() && self.tilted() => 0.0, Some(f) if f.kind == crate::scene::SceneFrame::Window => { - let (s_w, s_h) = (self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]); + let b = self.frame_box(); + let (s_w, s_h) = (b[2] * render_px[0], b[3] * render_px[1]); (f.margins[1] * s_h - f.margins[0] * s_w).max(0.0) } _ => 0.0, } } + /// La boîte autour de laquelle le cadre se construit. Sous le masque d'un bloc, le cadre EST + /// le conteneur (`ScreenMask`) : le slot au repos, ni zoomé ni incliné. Ailleurs, la boîte + /// écran : le cadre zoome avec elle (#179). + fn frame_box(&self) -> [f32; 4] { + self.screen_mask.map_or(self.s_dst, |m| m.rect) + } + + /// Le plan du cadre : celui de l'écran, sauf sous le masque d'un bloc, où le cadre reste à + /// plat pendant que le métrage penche dedans. + fn frame_tilt(&self, render_px: [f32; 2]) -> Option { + if self.screen_mask.is_some() { + return None; + } + self.screen_tilt_in(render_px) + } + + /// Le rayon des coins du métrage autour duquel le cadre s'arrondit : celui du slot au repos + /// sous le masque d'un bloc, celui de la boîte écran ailleurs. + fn frame_screen_radius(&self) -> f32 { + self.screen_mask.map_or(self.s_radius, |m| m.radius_px) + } + + fn frame_center_px(&self, render_px: [f32; 2]) -> [f32; 2] { + let b = self.frame_box(); + [(b[0] + b[2] * 0.5) * render_px[0], (b[1] + b[3] * 0.5) * render_px[1]] + } + /// Le plan incliné de l'écran, `None` quand il est droit. Même appel que les backends : un /// calcul déterministe, donc le même quadrilatère au bit près. fn screen_tilt_in(&self, render_px: [f32; 2]) -> Option { @@ -1784,15 +2326,18 @@ impl FrameGeometry { /// 0..1 : un warp bilinéaire est entièrement fixé par ses quatre coins, donc le prolonger /// donne exactement le plan que le mode 8 dessine, et le cadre penche avec l'écran sans /// aucune trigonométrie de plus. Sous la caméra réelle, le prolongement est celui de - /// l'homographie, et le drapeau rendu (`TiltedQuad::warp_flag`) le dit au mode 14. + /// l'homographie, et le drapeau rendu (`TiltedQuad::warp_flag`) le dit au mode 14. Sous le + /// masque d'un bloc, le cadre est droit autour du slot (`frame_box`), coins relatifs au centre + /// de celui-ci. fn window_frame_corners( &self, margins: [f32; 4], render_px: [f32; 2], ) -> ([(f32, f32); 4], f32, f32) { let [ml, mt, mr, mb] = margins; - let quad = self.screen_tilt_in(render_px).unwrap_or_else(|| { - let (hw, hh) = (self.s_dst[2] * render_px[0] * 0.5, self.s_dst[3] * render_px[1] * 0.5); + let quad = self.frame_tilt(render_px).unwrap_or_else(|| { + let b = self.frame_box(); + let (hw, hh) = (b[2] * render_px[0] * 0.5, b[3] * render_px[1] * 0.5); crate::regions::TiltedQuad { corners: [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)], scale: 1.0, @@ -1819,46 +2364,93 @@ impl FrameGeometry { /// APPAREIL ne passe pas par ici : son ombre suit la silhouette du modèle (`device_shadow_cb`), /// qu'un quad plat ne sait pas porter. pub fn shadow_caster(&self, render_px: [f32; 2]) -> ShadowCaster { - let center_px = self.screen_center_px(render_px); - match (&self.window_frame, self.screen_tilt_in(render_px)) { + // Le cadre porte l'ombre, là où il est : au repos autour du slot sous le masque d'un bloc. + if let Some(frame) = &self.window_frame { + return match self.frame_tilt(render_px) { + None => { + let [ml, mt, mr, mb] = frame.margins; + let d = self.frame_box(); + let dst = [ + d[0] - ml * d[2], + d[1] - mt * d[3], + d[2] * (1.0 + ml + mr), + d[3] * (1.0 + mt + mb), + ]; + ShadowCaster::Upright { + dst, + size_px: [dst[2] * render_px[0], dst[3] * render_px[1]], + radius: frame.radius[0], + } + } + Some(_) => { + let (corners, scale, _) = self.window_frame_corners(frame.margins, render_px); + ShadowCaster::Tilted { + corners, + center_px: self.frame_center_px(render_px), + radius: frame.radius[0] * scale, + mask: None, + } + } + }; + } + // Sous le masque d'un layout en bloc, l'ombre est celle de ce qu'on voit. Droite, la boîte + // zoomée couvre tout le slot : c'est le slot, qui ne zoome pas. Inclinée, le plan rogné + // par le slot (`mask` du quad). + match (self.screen_mask, self.screen_tilt_in(render_px)) { + (Some(mask), None) => ShadowCaster::Upright { + dst: mask.rect, + size_px: [mask.rect[2] * render_px[0], mask.rect[3] * render_px[1]], + radius: mask.radius_px, + }, (None, None) => ShadowCaster::Upright { dst: self.s_dst, size_px: [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]], radius: self.s_radius, }, - (None, Some(quad)) => ShadowCaster::Tilted { + (mask, Some(quad)) => ShadowCaster::Tilted { corners: quad.corners, - center_px, + center_px: self.screen_center_px(render_px), radius: self.s_radius * quad.scale, + mask, }, - (Some(frame), None) => { - let [ml, mt, mr, mb] = frame.margins; - let d = self.s_dst; - let dst = [ - d[0] - ml * d[2], - d[1] - mt * d[3], - d[2] * (1.0 + ml + mr), - d[3] * (1.0 + mt + mb), - ]; - ShadowCaster::Upright { - dst, - size_px: [dst[2] * render_px[0], dst[3] * render_px[1]], - radius: frame.radius[0], - } - } - (Some(frame), Some(_)) => { - let (corners, scale, _) = self.window_frame_corners(frame.margins, render_px); - ShadowCaster::Tilted { corners, center_px, radius: frame.radius[0] * scale } - } } } + /// L'écran droit (mode 0) sous le masque d'un layout en bloc : `dst` rogné au slot, `src` + /// d'autant (même mapping image→écran), et les coins du slot. Sans masque, les valeurs du + /// backend repartent telles quelles, à l'octet. Le flou de mouvement n'a rien à suivre : il + /// lit la frame précédente par `dst_prev`/`src_prev`, que le masque ne touche pas. + pub fn mask_flat_screen( + &self, + dst: [f32; 4], + src: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + render_px: [f32; 2], + ) -> ([f32; 4], [f32; 4], [f32; 2], f32) { + let Some(mask) = self.screen_mask else { return (dst, src, quad_px, radius_px) }; + let clipped = intersect_rect(dst, mask.rect); + let u = |x: f32| src[0] + (x - dst[0]) / dst[2] * (src[2] - src[0]); + let v = |y: f32| src[1] + (y - dst[1]) / dst[3] * (src[3] - src[1]); + let [x0, y0, w, h] = clipped; + ( + clipped, + [u(x0), v(y0), u(x0 + w), v(y0 + h)], + [w * render_px[0], h * render_px[1]], + mask.radius_px, + ) + } + /// Décalage de l'ombre portée de l'écran, en px de sortie : vers le bas sous un écran droit /// ou un angle fixe (inchangé) ; sous la caméra réelle, le long de la lumière qui éclaire aussi /// la flèche modélisée (`MODEL_LIGHT`), pour que les deux ombres tombent du même côté. La - /// direction glisse avec le poids de la caméra : aucun saut à l'entrée du zoom. + /// direction glisse avec le poids de la caméra : aucun saut à l'entrée du zoom. Sous le masque + /// d'un layout en bloc, l'ombre est celle du slot, que la caméra ne fait pas tourner. pub fn screen_shadow_offset(&self) -> [f32; 2] { - let off = SCREEN_SHADOW_OFFSET_FRAC * self.frame_min_px; + let off = SCREEN_SHADOW_OFFSET_FRAC * self.screen_unit_px; + if self.screen_mask.is_some() { + return [0.0, off]; + } let Some(pose) = self.camera else { return [0.0, off] }; let (lx, ly) = (-MODEL_LIGHT[0], -MODEL_LIGHT[1]); let n = lx.hypot(ly); @@ -1868,16 +2460,32 @@ impl FrameGeometry { [off * dir[0] / len, off * dir[1] / len] } - /// Le calque du cadre (mode 14), à dessiner après l'ombre et AVANT l'écran. `None` sans cadre. + /// Le calque du cadre qui passe SOUS l'écran, à dessiner après l'ombre et AVANT lui : le + /// chrome de fenêtre (mode 14). `None` sans cadre. /// /// Une seule forme pour le cas droit et le cas incliné : le mode 14 fait toujours le warp /// inverse du mode 8, et sur un rect ce warp est l'identité exacte (le terme quadratique est /// nul). Le calque se construit donc ici, une fois, pour les trois backends. + /// + /// Sous le masque d'un bloc, un APPAREIL y pose le verre de son écran (mode 1, le slot et ses + /// coins) : là où le métrage incliné ne couvre plus l'ouverture, on voit l'écran éteint, pas + /// un trou vers le fond d'écran. Ailleurs, l'appareil n'a rien sous l'écran. pub fn window_frame_cb(&self, render_px: [f32; 2]) -> Option { - let frame = self.window_frame.as_ref().filter(|f| !f.kind.is_device())?; + let frame = self.window_frame.as_ref()?; let [rw, rh] = render_px; + if frame.kind.is_device() { + let mask = self.screen_mask?; + return Some(LayerCB { + dst: mask.rect, + quad_px: [mask.rect[2] * rw, mask.rect[3] * rh], + radius_px: mask.radius_px, + mode: 1.0, + color: device_glass_rgba(frame.dark), + ..Default::default() + }); + } let (corners, scale, warp) = self.window_frame_corners(frame.margins, render_px); - let center = self.screen_center_px(render_px); + let center = self.frame_center_px(render_px); 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) = @@ -1886,7 +2494,8 @@ impl FrameGeometry { let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; let [tl, tr, br, bl] = corners.map(local); let [ml, mt, mr, mb] = frame.margins; - let (s_w, s_h) = (self.s_dst[2] * rw, self.s_dst[3] * rh); + let b = self.frame_box(); + let (s_w, s_h) = (b[2] * rw, b[3] * rh); // Dimensions dans le repère du plan, avant projection, comme `plane_px` au mode 8. let plane_px = [s_w * (1.0 + ml + mr) * scale, s_h * (1.0 + mt + mb) * scale]; let (bar_px, line_px) = (mt * s_h * scale, ml * s_w * scale); @@ -1914,14 +2523,16 @@ impl FrameGeometry { /// La caméra qui voit le plan de l'écran, reconstruite pour les calques lancés de rayons /// (mode 17). Sous un angle fixe ou la caméra réelle c'est celle du plan ; à plat, l'identité /// avec la MÊME distance de fuite que les présets. Le relief du modèle, lui, est vu d'un œil - /// reculé sur la même droite (`DEV_EYE_MIN`, `DeviceView::project`). + /// reculé sur la même droite (`DEV_EYE_MIN`, `DeviceView::project`). Sous le masque d'un bloc, + /// l'appareil au repos autour du slot (`frame_box`). fn device_view(&self, render_px: [f32; 2]) -> Option { let [rw, rh] = render_px; - let (s_w, s_h) = (self.s_dst[2] * rw, self.s_dst[3] * rh); + let b = self.frame_box(); + let (s_w, s_h) = (b[2] * rw, b[3] * rh); if !(s_w > 1.0) || !(s_h > 1.0) { return None; } - let quad = self.screen_tilt_in(render_px); + let quad = self.frame_tilt(render_px); let perspective = quad .as_ref() .map(|q| q.perspective) @@ -1938,15 +2549,17 @@ impl FrameGeometry { perspective, offset: quad.as_ref().map(|q| q.offset).unwrap_or([0.0; 2]), unit, - center: self.screen_center_px(render_px), + center: self.frame_center_px(render_px), half: [0.5 * s_w / u, 0.5 * s_h / u], }) } - /// L'unité du cadre (`frame_unit_px`) de CETTE boîte écran, zoom compris, en px de la boîte + /// L'unité du cadre (`frame_unit_px`) de la boîte autour de laquelle il se construit — la boîte + /// écran, zoom compris, ou le slot au repos sous le masque d'un bloc —, en px de la boîte /// droite : ce que mesurent les marges, les rayons et le modèle d'un appareil. pub fn frame_unit_px(&self, render_px: [f32; 2]) -> f32 { - frame_unit_px([self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]], render_px) + let b = self.frame_box(); + frame_unit_px([b[2] * render_px[0], b[3] * render_px[1]], render_px) } /// Le calque de l'appareil modelé (mode 17), à dessiner APRÈS l'écran. `None` sans cadre ou @@ -1994,8 +2607,9 @@ impl FrameGeometry { // shader lit l'angle dans `dst_prev.x`, jamais une constante. let deck_angle = device_deck_angle(view.half, bb, thick); let u = self.frame_unit_px(render_px); - // UNE forme de coin pour le métrage et l'ouverture : le rayon du métrage, en unités. - let aperture_radius = self.s_radius / u; + // UNE forme de coin pour le métrage et l'ouverture : le rayon du métrage, en unités (celui + // du slot au repos sous le masque d'un bloc, qui rogne le métrage à cette ouverture). + let aperture_radius = self.frame_screen_radius() / u; // Le recouvrement se compte en PIXELS : exprimé en unités, il fondait à 1,2 px sur un petit // écran, sous la somme des deux antialiasings (celui du métrage, celui de la lunette). let overlap = DEV_OVERLAP_PX / view.unit.max(1.0); @@ -2121,16 +2735,25 @@ impl FrameGeometry { upright }; let dst = pad_rect(rect, render_px, PRIVACY_MASK_PAD_PX * zoom_k); + // Sous le masque d'un layout en bloc, rien n'est dessiné hors du slot : rien à y + // cacher, et un masque y flouterait la caméra. + let (dst, clipped) = match self.screen_mask { + Some(m) => { + let c = m.clip(dst)?; + (c, c != dst) + } + None => (dst, false), + }; let mut mask = PrivacyMask::upright(dst, render_px, zoom_k); // Un ovale inscrit dans la boîte ÉLARGIE ne contient plus l'ovale courant dès que // les deux centres diffèrent : son bord laisserait passer une frange du secret. On - // retombe alors sur le rectangle, comme le tracé libre. - mask.oval_ok = !trail; + // retombe alors sur le rectangle, comme le tracé libre. Même chose dans la boîte + // ROGNÉE par le slot. + mask.oval_ok = !trail && !clipped; return Some(mask); } - // Écran incliné (le mode 8 n'a pas de flou de mouvement : pas de trace à couvrir). - // Même quad et même centre que le dessin de l'écran et que `plan_cursor`. + // Écran incliné. Même quad et même centre que le dessin de l'écran et que `plan_cursor`. let s_px = [self.s_dst[2] * rw, self.s_dst[3] * rh]; let quad = self.screen_tilt(s_px)?; let centre = [ @@ -2150,17 +2773,6 @@ impl FrameGeometry { [centre[0] + px, centre[1] + py] }; let pts = [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)]; - let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); - let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); - for [px, py] in pts { - min_x = min_x.min(px); - min_y = min_y.min(py); - max_x = max_x.max(px); - max_y = max_y.max(py); - } - // Plancher d'un pixel : un masque très fin ferait diverger le warp inverse. - let quad_px = [(max_x - min_x).max(1.0), (max_y - min_y).max(1.0)]; - let local = pts.map(|[px, py]| [px - min_x, py - min_y]); // La perspective grossit localement le côté proche : la force suit l'arête la plus // agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus // fin, rapporté au contenu, qu'au repos. @@ -2170,15 +2782,116 @@ impl FrameGeometry { .max(len(pts[3], pts[2]) / ew) .max(len(pts[0], pts[3]) / eh) .max(len(pts[1], pts[2]) / eh); + let strength = (zoom_k * tilt_k).max(1.0); + // Le mode 8 étale aussi chaque pixel vers là où le plan était une frame plus tôt + // (`tilt_trail`) : une copie traînée du secret sort du quad courant. Le masque garde la + // perspective du quad courant, élargi de la plus grande avance d'un de ses coins : chaque + // coin d'avant tombe dedans, donc tout le secret d'avant aussi (un quad convexe contient + // l'enveloppe de ses coins), et la traînée, qui va de l'un à l'autre. Même warp que le + // shader sur les coins d'avant (le drapeau projectif est celui du plan). Sans mouvement, + // la traînée EST le quad (au bit près) : rien de plus à couvrir. + let trail = self.tilt_trail(render_px).filter(|t| t.corners != quad.corners); + let pts = match trail { + Some(trail) => { + let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad }; + let then = |fx: f32, fy: f32| { + let (px, py) = before.point_px(fx, fy); + [centre[0] + px, centre[1] + py] + }; + let prev = [then(x0, y0), then(x1, y0), then(x1, y1), then(x0, y1)]; + let reach = (0..4) + .map(|i| (prev[i][0] - pts[i][0]).hypot(prev[i][1] - pts[i][1])) + .fold(0.0f32, f32::max); + grow_quad(pts, reach + 0.5) + } + None => pts, + }; + let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); + let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); + for [px, py] in pts { + min_x = min_x.min(px); + min_y = min_y.min(py); + max_x = max_x.max(px); + max_y = max_y.max(py); + } + // Plancher d'un pixel : un masque très fin ferait diverger le warp inverse. + let mut quad_px = [(max_x - min_x).max(1.0), (max_y - min_y).max(1.0)]; + let mut local = pts.map(|[px, py]| [px - min_x, py - min_y]); + let mut dst = [min_x / rw, min_y / rh, quad_px[0] / rw, quad_px[1] / rh]; + // Sous le masque d'un layout en bloc, dessiné dans la part du slot qu'il couvre : les + // coins passent dans le repère de cette boîte, le warp (et son ovale) ne bouge pas. + if let Some(m) = self.screen_mask { + let c = m.clip(dst)?; + let shift = [(c[0] - dst[0]) * rw, (c[1] - dst[1]) * rh]; + local = local.map(|[px, py]| [px - shift[0], py - shift[1]]); + quad_px = [c[2] * rw, c[3] * rh]; + dst = c; + } Some(PrivacyMask { - dst: [min_x / rw, min_y / rh, quad_px[0] / rw, quad_px[1] / rh], + dst, quad_px, warp: Some(local), projective: quad.projective, - strength: (zoom_k * tilt_k).max(1.0), - oval_ok: true, + strength, + // Un ovale inscrit dans le quad élargi ne couvrirait plus sûrement l'ovale d'avant. + oval_ok: trail.is_none(), }) } + + /// Où tombe le métrage dans l'image (`FootageQuad`) : là où `privacy_mask` pose un masque, + /// donc là où l'éditeur doit poser le gimbal d'un flou pour qu'il couvre ce qu'il cache. + pub fn footage_quad(&self, render_px: [f32; 2]) -> FootageQuad { + let [rw, rh] = render_px; + let s_px = [self.s_dst[2] * rw, self.s_dst[3] * rh]; + if let Some(quad) = self.screen_tilt(s_px).filter(|_| self.tilted() && rw > 0.0 && rh > 0.0) { + let centre = self.screen_center_px(render_px); + let at = |fx: f32, fy: f32| { + let (px, py) = quad.point_px(fx, fy); + [(centre[0] + px) / rw, (centre[1] + py) / rh] + }; + return FootageQuad { + corners: [at(0.0, 0.0), at(1.0, 0.0), at(1.0, 1.0), at(0.0, 1.0)], + projective: quad.projective, + }; + } + let [x, y, w, h] = self.s_dst; + FootageQuad { corners: [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], projective: false } + } +} + +/// Le métrage dans l'image : ses coins TL, TR, BR, BL en fractions de la sortie, et la +/// correspondance d'un point `(u, v)` du métrage à l'image, bilinéaire entre eux ou, sous la +/// caméra réelle, l'homographie qu'ils définissent (`TiltedQuad::point_px`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FootageQuad { + pub corners: [[f32; 2]; 4], + pub projective: bool, +} + +/// Le quad convexe `pts` (TL, TR, BR, BL) dont chaque côté recule de `d` px vers l'extérieur : +/// ses côtés restent parallèles aux siens, donc sa perspective aussi, et il contient tout point +/// à moins de `d` de lui. +fn grow_quad(pts: [[f32; 2]; 4], d: f32) -> [[f32; 2]; 4] { + // L'extérieur d'un côté est d'un bord ou de l'autre selon le sens de parcours. + let area: f32 = (0..4) + .map(|i| { + let (a, b) = (pts[i], pts[(i + 1) % 4]); + a[0] * b[1] - b[0] * a[1] + }) + .sum(); + let side = area.signum(); + let normal = |i: usize| { + let (a, b) = (pts[i], pts[(i + 1) % 4]); + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let len = ex.hypot(ey).max(1e-6); + [side * ey / len, -side * ex / len] + }; + std::array::from_fn(|i| { + // Le coin glisse sur la bissectrice jusqu'à la rencontre des deux côtés reculés. + let (na, nb) = (normal((i + 3) % 4), normal(i)); + let k = d / (1.0 + na[0] * nb[0] + na[1] * nb[1]).max(0.2); + [pts[i][0] + (na[0] + nb[0]) * k, pts[i][1] + (na[1] + nb[1]) * k] + }) } /// Marge ajoutée autour d'un masque de confidentialité, en px de la boîte écran AU REPOS : un @@ -2241,6 +2954,13 @@ fn union_rect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { [x0, y0, x1 - x0, y1 - y0] } +/// Vide (largeur ou hauteur nulle) quand les deux rects ne se touchent pas. +fn intersect_rect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + let (x0, y0) = (a[0].max(b[0]), a[1].max(b[1])); + let (x1, y1) = ((a[0] + a[2]).min(b[0] + b[2]), (a[1] + a[3]).min(b[1] + b[3])); + [x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0)] +} + fn pad_rect(r: [f32; 4], render_px: [f32; 2], pad_px: f32) -> [f32; 4] { let (px, py) = (pad_px / render_px[0], pad_px / render_px[1]); [r[0] - px, r[1] - py, r[2] + 2.0 * px, r[3] + 2.0 * py] @@ -2332,25 +3052,27 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { let source_t_prev = clock.source_at(clock.at(source_t) - 1.0 / FPS); // le focus "auto" (suivi curseur) réutilise la même piste que le rendu du curseur. let cursor_for_zoom = cursor; - // La rotation 3D (mode 8, pas de motion blur dans ce chemin — cf. le commentaire au - // point d'appel) n'est calculée QUE pour la frame courante ; `pp` ne sert qu'au zoom - // écran normal (vélocité pour le motion blur du chemin non-tilté). + // `pp` porte le zoom de la frame précédente, pour le flou de mouvement ; celui du plan + // incliné y ajoute sa rotation de base et le poids de la caméra réelle (`tilt_trail`). let mut zoom_rotation = [0.0f32; 3]; + let mut zoom_rotation_prev = [0.0f32; 3]; let mut zoom_tilt = 0.0f32; - let mut zoom_click_impact = 0.0f32; let mut zoom_camera = 0.0f32; + let mut zoom_camera_prev = 0.0f32; + let mut zoom_strength = 0.0f32; + let mut zoom_strength_prev = 0.0f32; let mut zoom_aim = [0.5f32; 2]; let mut zoom_orbit = [0.5f32; 2]; - // Curseur masqué → pas de piste pour ce qui anime le plan (parallaxe, impact, caméra - // `follow-cursor`) : l'export ne charge la piste que si le curseur est affiché - // (`timeline_walk`), la preview toujours. Sans cette porte, la preview pencherait un - // plan que l'export laisse immobile. + let (mut zoom_aim_prev, mut zoom_orbit_prev) = ([0.5f32; 2], [0.5f32; 2]); + // Curseur masqué → pas de piste pour ce qui anime le plan (parallaxe, impact, orbite en + // focus auto) : un plan ne bouge que sous un pointeur qu'on voit. Preview et export + // chargent la piste dans tous les cas (le focus auto la suit), cette porte fait le reste. let parallax_track = cursor_for_zoom.filter(|_| scene.is_some_and(|s| s.cursor.show)); let active_crop = scene.and_then(|scene| { scene.crop_by_clip.get(scene.active_clip_index).copied().flatten() }); if !zoom_regions.is_empty() { - // La caméra `follow-cursor` lit le curseur dans l'image SOURCE recadrée — pas dans la + // L'orbite en focus auto lit le curseur dans l'image SOURCE recadrée — pas dans la // coupe zoomée, qu'un focus auto recentre sur lui — et ignore ce qu'une coupe retire. let camera = crate::regions::CameraFrame { track: parallax_track, @@ -2366,19 +3088,26 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { p.focus = zs.focus; zoom_rotation = zs.rotation; zoom_tilt = zs.tilt; - zoom_click_impact = zs.click_impact; zoom_camera = zs.camera; + zoom_strength = zs.strength; zoom_aim = zs.aim; zoom_orbit = zs.orbit; + // Même `CameraFrame` qu'à la frame courante : la caméra réelle d'avant (poids, visée, + // orbite) sert au flou du mode 8. Échelle et focus n'en dépendent pas. let zs_p = crate::regions::zoom_state_in( zoom_regions, source_t_prev, cursor_for_zoom, - &crate::regions::CameraFrame::NONE, + &camera, &clock, ); pp.zoom = zs_p.scale; pp.focus = zs_p.focus; + zoom_rotation_prev = zs_p.rotation; + zoom_camera_prev = zs_p.camera; + zoom_strength_prev = zs_p.strength; + zoom_aim_prev = zs_p.aim; + zoom_orbit_prev = zs_p.orbit; } // 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). @@ -2386,11 +3115,17 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { crate::regions::camera_fullscreen_progress_at(cam_regions, source_t, &clock); let cam_progress_prev = crate::regions::camera_fullscreen_progress_at(cam_regions, source_t_prev, &clock); - // 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) + let shape_fade = + crate::regions::camera_fullscreen_shape_at(cam_regions, source_t, &clock); + // rétrécissement réactif : la webcam garde 70 % de sa taille pendant un zoom actif, quel + // que soit son niveau (elle suivait 1/zoom, et rétrécissait donc d'autant plus que le zoom + // était profond : ×0,6 au zoom maximal). L'enveloppe est celle de la région : elle descend + // avec le ease-in du zoom, tient 0,7 sur le span, remonte avec le ease-out. Une région à + // échelle 1 ne zoome rien : elle laisse la caméra à sa taille. Ignoré pendant Full Camera + // (voir ci-dessus). + let reactive_scale = |strength: f32, zoom: f32, progress: f32| -> f32 { + if webcam_reactive && progress <= 0.0 && zoom > 1.0 && strength.is_finite() { + (1.0 - 0.3 * strength.clamp(0.0, 1.0)).clamp(0.7, 1.0) } else { 1.0 } @@ -2404,8 +3139,9 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // 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); + let webcam_size_scale = base_size_scale * reactive_scale(zoom_strength, p.zoom, cam_progress); + let webcam_size_scale_prev = + base_size_scale * reactive_scale(zoom_strength_prev, 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 : @@ -2434,29 +3170,26 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { 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); + // Variante ancrée à l'ANCRE de la caméra (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 (résolue côté app via `computeCompositeLayout`) — l'ancrer au coin bas-droite + // comme le fait `fit_cam_aspect` (pensé pour le placement par DÉFAUT) réancrerait la + // webcam posée n'importe où d'autre à ce coin (bug rapporté : webcam au coin bas-gauche, + // rendu natif décalé). + // + // L'app pose la caméra à marge constante de son ancre (un coin ou un milieu de bord) : + // `x = marge + fx·(W − 2·marge − w)`, donc le point de la boîte à la fraction `fx` vaut + // `marge + fx·(W − 2·marge)` quelle que soit sa largeur. Rétrécir autour de ce point-là, + // c'est exactement la mise en page de l'app à la taille réduite : une caméra de coin garde + // sa marge aux deux bords, une caméra de milieu de bord reste centrée sur lui. Rétrécir + // vers son propre centre l'arrachait de son coin pendant chaque zoom. Sans ancre (vieux + // payload), le centre. + let anchor = scene.map(|s| s.layout.webcam_anchor_fractions()).unwrap_or([0.5, 0.5]); + let scale_anchored = |dst: [f32; 4], s: f32| -> [f32; 4] { + let (ax, ay) = (dst[0] + dst[2] * anchor[0], dst[1] + dst[3] * anchor[1]); let (nw, nh) = (dst[2] * s, dst[3] * s); - [cx - nw * 0.5, cy - nh * 0.5, nw, nh] + [ax - nw * anchor[0], ay - nh * anchor[1], 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 @@ -2472,13 +3205,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { _ => 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). + // devienne `aspect`, sans jamais dépasser sa boîte d'origine. La boîte se mesure en px du + // render target, qui porte la géométrie de sortie : son ratio est celui qu'on verra. let fit_dst_to_aspect = |dst: [f32; 4], aspect: f32| -> [f32; 4] { let box_w_px = dst[2] * rw; let box_h_px = dst[3] * rh; @@ -2511,10 +3239,10 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // déborde le padding — c'est la géométrie de `applyZoomTransform` (TS). let s_box = fit_screen(p.screen.dst); let s_box_prev = fit_screen(pp.screen.dst); - // Le cadre ne touche PAS à la boîte écran : le métrage a la même taille avec et sans - // cadre, et le cadre pousse vers l'extérieur — dans le padding, et au-delà du canvas s'il - // le faut, où la sortie le coupe. Ombre, masques, annotations et curseurs, ancrés sur - // `s_dst`, n'ont donc rien à rattraper. + // Le cadre ne touche PAS à la boîte écran : il pousse vers l'extérieur, dans la place que + // le layout de l'app lui a faite (le padding se mesure depuis son bord extérieur, + // `computeCompositeLayout`). Ombre, masques, annotations et curseurs, ancrés sur `s_dst`, + // n'ont donc rien à rattraper. // Le cadre et son thème. `resolved` ramène les anciennes valeurs `window-light` / // `window-dark` au chrome de fenêtre, et `theme_override` leur rend le thème qu'elles // nommaient : un projet qui les porte encore rend exactement ce qu'il rendait, sans que @@ -2552,28 +3280,61 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { .screen_cover .then_some((s_base[2] * rw) / (s_base[3] * rh).max(0.0001)) }); - let cover = |uv: [f32; 4]| -> [f32; 4] { - match cover_box_ar { - Some(ar) => cover_uv_rect(uv, [stw as f32, sth as f32], ar), - None => uv, + // Remplissage du format (`screen_follow`) : la fenêtre du cover suit le curseur lissé, + // en UV. Sans piste, elle reste centrée. + let follow = cover_box_ar.is_some() && scene.is_some_and(|s| s.layout.screen_follow); + let follow_at = |t: f32| -> Option<[f32; 2]> { + let (x, y) = cursor.filter(|_| follow)?.follow_at(t)?; + Some([x * u_max, y * v_max]) + }; + let cover = |uv: [f32; 4], at: Option<[f32; 2]>| -> [f32; 4] { + match (cover_box_ar, at) { + (Some(ar), Some(at)) => follow_cover_uv_rect(uv, [stw as f32, sth as f32], ar, at), + (Some(ar), None) => cover_uv_rect(uv, [stw as f32, sth as f32], ar), + (None, _) => uv, } }; // La coupe RÉFÉRENCE (zoom entier) est celle qui remplissait la boîte paddée avant // ce correctif ; la coupe DESSINÉE ne porte plus que le crop. `remap_box` reporte la // seconde à travers le mapping de la première, ce qui conserve le cadrage exact. // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant. - let cut_ref = cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); - let cut_ref_prev = cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); - let cut = cover(screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus)); - // Impact du clic : mêmes piste, coupe, porte et budget que la parallaxe sous un angle fixe ; - // sous la caméra réelle, les mêmes clics font reculer l'œil. + let cut_ref = + cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus), follow_at(source_t)); + let cut_ref_prev = cover( + screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus), + follow_at(source_t_prev), + ); + // Sous le remplissage, la coupe dessinée est le crop ENTIER : la fenêtre bouge, et une + // coupe dessinée qui ne la contiendrait plus laisserait un trou dans le slot. Le masque du + // slot (`screen_mask`) rogne le reste. + let crop_cut = screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus); + let cut = if follow { crop_cut } else { cover(crop_cut, None) }; + // Ce que l'écran montre au repos : la fenêtre couverte, suivie ou non. Le curseur, les coins + // et l'ombre se mesurent sur elle, pas sur la coupe entière que le remplissage dessine. + let window = cover(crop_cut, follow_at(source_t)); + let shown = |i: usize| (crop_cut[i + 2] - crop_cut[i]) / (window[i + 2] - window[i]).max(1e-6); + let screen_unit_px = + screen_unit_px([s_base[2] * rw * shown(0), s_base[3] * rh * shown(1)], padding_scale); + // Impact du clic, un réglage du curseur qui vaut sous toutes les caméras : sous un angle + // fixe, mêmes piste, coupe, porte et budget que la parallaxe ; sous la caméra réelle, les + // mêmes clics font reculer l'œil ; sur un écran droit, l'écran lui-même (`pressed`). let (impact, press) = match (scene, parallax_track) { - (Some(s), Some(track)) if zoom_click_impact > 0.0 => { - let (tilt, press) = click_impact_at(s, cfg, &lp, track, source_t, cut, [u_max, v_max]); - (tilt.map(|d| d * zoom_click_impact), press * zoom_click_impact) + (Some(s), Some(track)) if s.cursor.click_impact => { + click_impact_at(s, cfg, &lp, track, source_t, cut, [u_max, v_max]) } _ => ([0.0; 3], 0.0), }; + // Sur un écran droit, le clic fait reculer l'écran comme l'œil recule sous la caméra réelle + // (`camera::PRESS`) : la boîte rapetisse autour de son centre, puis revient. Le faire + // basculer depuis l'écran droit amènerait des arêtes sur les axes (la règle des 2°). Là où + // un angle fixe est installé, la bascule prend le relais (`tilt_gate`) ; sous la caméra + // réelle, l'œil (son poids). Même recul aux deux frames : comme la bascule, l'impact + // n'entre pas dans le flou de mouvement. + let flat_press = press * (1.0 - crate::regions::tilt_gate(zoom_tilt)) * (1.0 - zoom_camera); + let pressed = |b: [f32; 4]| -> [f32; 4] { + let k = 1.0 / (1.0 - crate::camera::PRESS * flat_press); + [b[0] + b[2] * (1.0 - k) * 0.5, b[1] + b[3] * (1.0 - k) * 0.5, b[2] * k, b[3] * k] + }; // Sous la caméra réelle, la visée et l'orbite vivent dans le recadrage, comme le focus : // même report dans la coupe (un cover la rogne). La mise au point suit le pointeur lissé // (l'orbite), pas le point visé : celui-ci reste au centre au zoom 1 et bute sur sa portée @@ -2585,11 +3346,21 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { zoom: p.zoom, press, }); + // La caméra réelle de la frame d'avant, dès qu'elle y était active — y compris quand elle + // ne l'est plus à celle-ci (fin de sa moitié d'une transition vers un angle fixe) ; `None` + // quand elle ne l'était pas, et le mode 8 reprend alors la rotation d'avant. + let camera_prev = (zoom_camera_prev > 0.0).then(|| crate::camera::CameraPose { + weight: zoom_camera_prev, + aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim_prev, cut), + orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit_prev, cut), + zoom: pp.zoom, + press, + }); let focus_plane = match camera { Some(pose) => pose.orbit, None => focus_in_cut(u_max, v_max, active_crop, p.focus, cut), }; - let s_dst = remap_box(s_base, cut_ref, cut); + let s_dst = pressed(remap_box(s_base, cut_ref, cut)); // Parallaxe : calculée ici, une fois la coupe connue — elle mesure la vitesse du curseur // en coupes VISIBLES par seconde. La coupe visible est `cut_ref`, zoom compris : `cut` // ne porte plus que le crop depuis #179, et sous un x2 le même geste traverse deux fois @@ -2607,29 +3378,25 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { zoom_tilt, impact, ); - let s_dst_prev = remap_box(s_base_prev, cut_ref_prev, cut); + let s_dst_prev = pressed(remap_box(s_base_prev, cut_ref_prev, cut)); // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) // — SAUF quand l'app a résolu un placement explicite (`app_webcam_rect`, drag-to-reposition - // compris). Ce rect est déjà exprimé en fraction du VRAI output (calculé côté web par + // compris). Ce rect est déjà exprimé en fraction de la sortie (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é. + // corrects. `fit_cam_aspect`/`scale_corner_br` (chemin preset par défaut) y sont donc + // inadaptés : ils le réancreraient au coin bas-droite, en ignorant la position choisie par + // l'utilisateur (rapport : webcam glissée au coin bas-gauche, rendue ailleurs), et + // recalculeraient un aspect déjà juste. On garde seulement `scale_anchored` (zoom réactif, + // préserve l'ancre et l'aspect), et le rect est dessiné tel quel : le render target porte + // la géométrie de sortie, rien ne le déforme après coup. let mut w_dst = if app_webcam_rect.is_some() { - scale_center(p.webcam.dst, webcam_size_scale) + scale_anchored(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) + scale_anchored(pp.webcam.dst, webcam_size_scale_prev) } else { fit_cam_aspect(scale_corner_br(pp.webcam.dst, webcam_size_scale_prev)) }; @@ -2654,16 +3421,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { 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. + // Aucun contre-étirement ici : le render target porte la géométrie de sortie, rien n'est + // étiré après coup, donc aucun rect n'est rétréci pour compenser. // 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. // @@ -2676,7 +3435,6 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // la boîte, pas suivre le cadre. let frame_min_px = rw.min(rh); let s_px = [s_dst[2] * rw, s_dst[3] * rh]; - let s_min_px = s_px[0].min(s_px[1]); let app_screen_radius_frac = scene.and_then(|s| s.layout.screen_radius_frac); let scene_roundness_frac = scene.map(|s| s.effects.roundness_frac); // ---- Le rayon des coins, en UN seul endroit ---- @@ -2695,33 +3453,53 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // chaque bordure garde son épaisseur tout autour de chaque coin. Sous le chrome de // fenêtre, les coins HAUTS du métrage restent carrés, à ras de la barre : l'arrondi du // haut se fait une seule fois, par le cadre (`screen_square_top`, `screen_top_lift_px`). - let outer_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { + let outer_radius_of = |s_px: [f32; 2]| match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { (false, _, _) => 0.0, (true, _, _) if frame_kind != crate::scene::SceneFrame::None => { let t = scene.map(roundness_slider_position).unwrap_or(0.0); t * frame_roundness_cap(frame_kind) * frame_unit_px(s_px, [rw, rh]) } // 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, + (true, Some(f), _) => f * s_px[0].min(s_px[1]), + // Scène sans rayon imposé : slider Roundness, en px d'une référence 1080 rapportée à + // l'écran (`screen_unit_px`) — ni à la source, ni au cadre. + (true, None, Some(f)) => f * screen_unit_px, // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. (true, None, None) => p.screen.radius * lp.radius_scale, }; - let s_radius = screen_corner_radius_px(outer_radius, s_px); + let s_radius = screen_corner_radius_px(outer_radius_of(s_px), s_px); + // Layouts en bloc : le slot masque l'écran (`ScreenMask`), avec les coins de l'écran AU + // REPOS — la boîte, elle, grandit avec le zoom. `screen_cover` est le drapeau du slot : + // l'app le lève pour ces deux layouts, sur un clip qui a une caméra, et pour le + // remplissage du format, qui exclut les cadres (`formatFillAvailability`). Un cadre sous + // un slot est donc toujours dans un bloc : il y devient le conteneur, au repos autour. + let screen_mask = scene.is_some_and(|s| s.layout.screen_cover).then(|| { + let slot_px = [s_base[2] * rw, s_base[3] * rh]; + ScreenMask { + rect: s_base, + radius_px: screen_corner_radius_px(outer_radius_of(slot_px), slot_px), + square_top: frame_kind == crate::scene::SceneFrame::Window, + } + }); + // Le cadre se construit autour de la boîte écran, zoom compris ; sous le masque d'un bloc, + // autour du slot au repos et de ses coins, qui ne zooment pas. + let (f_px, f_radius) = match screen_mask { + Some(m) => ([m.rect[2] * rw, m.rect[3] * rh], m.radius_px), + None => (s_px, s_radius), + }; let window_frame = frame_margins.map(|margins| { - let (l, t, b) = (margins[0] * s_px[0], margins[1] * s_px[1], margins[3] * s_px[1]); + let (l, t, b) = (margins[0] * f_px[0], margins[1] * f_px[1], margins[3] * f_px[1]); // La fenêtre n'a qu'un rayon (mode 14) : celui de ses coins BAS, où le métrage s'arrondit // sous le filet. En haut, le contour intérieur du chrome a le rayon du métrage, remonté // sous la barre (`sd_screen_under_bar`) : le même arc, rentré du filet. let top = match frame_kind { - crate::scene::SceneFrame::Window => concentric_radius(s_radius, l, b), - _ => concentric_radius(s_radius, l, t), + crate::scene::SceneFrame::Window => concentric_radius(f_radius, l, b), + _ => concentric_radius(f_radius, l, t), }; // Portable et moniteur : la coque garde ses rayons, seule l'ouverture suit le slider. let radius = match device_shell_radius(frame_kind) { - Some(shell) => shell.map(|r| r * frame_unit_px(s_px, [rw, rh])), - None => [top, concentric_radius(s_radius, l, b)], + Some(shell) => shell.map(|r| r * frame_unit_px(f_px, [rw, rh])), + None => [top, concentric_radius(f_radius, l, b)], }; WindowFrame { kind: frame_kind, dark: frame_dark, margins, radius } }); @@ -2733,12 +3511,11 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // était une SECONDE, indépendante — fraction différente (0.12 vs 0.06 côté web) et sans // bornes — donc écran et caméra ne pouvaient pas s'accorder. let app_webcam_radius_frac = scene.and_then(|s| s.layout.webcam_radius_frac); - // Full Camera dissout la forme en même temps qu'elle prend le cadre : le rayon fond - // vers 0 avec `cam_progress`, donc le cercle devient un rect à coins de plus en plus - // francs puis un plein cadre net — aucun masque ne survit au plein écran (parité - // `computeCameraFullscreenRect`, qui ramène `maskShape` à "rectangle" et lerpe le - // rayon vers 0 pour exactement la même raison). - let shape_fade = (1.0 - cam_progress).clamp(0.0, 1.0); + // Full Camera dissout la forme en prenant le cadre : le rayon fond vers 0, donc le cercle + // devient un rect à coins de plus en plus francs puis un plein cadre net — aucun masque + // ne survit au plein écran. Il fond sur sa propre courbe, en retard sur le rect + // (`camera_fullscreen_shape_at`). `computeCameraFullscreenRect` (TS) le lerpe encore + // avec le progrès, mais ce rayon-là n'habille qu'une `

@@ -47,8 +47,8 @@
1.5× - 0:37.3 - 1.8× + 0:37.3 + 1.8×
diff --git a/design/components/forms/ChoiceRow.jsx b/design/components/forms/ChoiceRow.jsx new file mode 100644 index 000000000..fd64dee57 --- /dev/null +++ b/design/components/forms/ChoiceRow.jsx @@ -0,0 +1,79 @@ +import React from 'react'; + +/** + * OpenScreen ChoiceRow — a fixed choice picked in one click. Replaces a select or a + * dropdown whenever the options are few and known (Format, frame style, theme, focus + * mode, caption font, shadow and bounce levels…): every value stays visible. + * Source: `ChoiceRow` in src/components/ai-edition/RightPanes.tsx, CSS + * `.choiceRow / .choiceBtn / .choiceRowTiles` in NewEditorShell.module.css. + * + * Equal grid cells, 4px gap. The pressed option carries the app's one selected look: + * accent border on the accent tint, fg text. `tiles` makes 44px cells for icons that + * draw their choice. A value that matches no option (a stored level between two named + * ones) presses nothing. Only a long list (the 15 translation targets) stays a Select. + * + * options: string | { value, label, icon? }; `null` leaves a hole in the grid. + */ +export function ChoiceRow({ + label, + options = [], + value, + onChange, + columns, + tiles = false, + disabled = false, + style = {}, +}) { + const norm = options.map((o) => (o == null || typeof o === 'object' ? o : { value: o, label: o })); + return ( +
+ {label && {label}} +
+ {norm.map((o, i) => { + if (o == null) return
+
+ ); +} diff --git a/design/components/forms/Select.jsx b/design/components/forms/Select.jsx index 939954272..9e3975780 100644 --- a/design/components/forms/Select.jsx +++ b/design/components/forms/Select.jsx @@ -1,8 +1,9 @@ import React from 'react'; /** - * OpenScreen Select — native dropdown styled to match. Used for - * caption style, layout preset, transcript language, etc. + * OpenScreen Select — native dropdown styled to match. Only for a LONG list + * (the 15 translation targets). A fixed choice of a few values is a ChoiceRow: + * one click, every value visible (commit e4c56004). */ export function Select({ options = [], diff --git a/design/components/forms/Slider.jsx b/design/components/forms/Slider.jsx index fcab589fd..e75aef88e 100644 --- a/design/components/forms/Slider.jsx +++ b/design/components/forms/Slider.jsx @@ -1,9 +1,15 @@ 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. + * OpenScreen Slider — a continuous amount (padding, roundness, blur 0–100 %, cursor + * size). Source: `.sliderCell` in src/components/ai-edition/NewEditorShell.module.css. + * + * A free row, never a card: label left, the value right in --muted body text (not mint, + * not mono), a 6px track filled in the brand colour. When the number has no unit (a + * cursor size of "30.0") show no value at all: `format` returns null and the track's + * position is the value. When the number means nothing to a user (shadow, click + * bounce), use a ChoiceRow of named levels instead of a slider. + * Pass `bare` for the track alone. */ export function Slider({ label, @@ -13,16 +19,10 @@ export function Slider({ step = 1, format, onChange, - card = true, + bare = false, 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 = ( @@ -33,25 +33,24 @@ export function Slider({ step={step} value={value} onChange={(e) => onChange && onChange(Number(e.target.value))} - style={trackStyle} + style={{ + width: '100%', + display: 'block', + backgroundImage: `linear-gradient(var(--brand),var(--brand)), linear-gradient(var(--border-hi),var(--border-hi))`, + backgroundSize: `${pct}% 6px, 100% 6px`, + }} /> ); - if (!card) return input; + if (bare) return input; return ( -
-
- {label} - {display} +
+
+ {label} + {display != null && ( + {display} + )}
{input}
diff --git a/design/components/forms/forms.card.html b/design/components/forms/forms.card.html index 6f469756b..a4c7927bd 100644 --- a/design/components/forms/forms.card.html +++ b/design/components/forms/forms.card.html @@ -1,4 +1,4 @@ - + @@ -23,13 +23,16 @@ } const Button = pick('Button'), IconButton = pick('IconButton'), SegmentedControl = pick('SegmentedControl'), Switch = pick('Switch'), - Slider = pick('Slider'), Select = pick('Select'), TextField = pick('TextField'); + Slider = pick('Slider'), Select = pick('Select'), TextField = pick('TextField'), + ChoiceRow = pick('ChoiceRow'); const I = (n, s=15) => ; function Demo(){ const [mode,setMode] = React.useState('Edit'); const [on,setOn] = React.useState(true); - const [sh,setSh] = React.useState(24); + const [pad,setPad] = React.useState(24); + const [sh,setSh] = React.useState(0.6); + const [fmt,setFmt] = React.useState('16:9'); const [q,setQ] = React.useState(''); React.useEffect(()=>{ window.lucide && window.lucide.createIcons(); }); return ( @@ -50,6 +53,11 @@ {}} />
+
CHOICEROW · a fixed choice, one click +
+
+
SWITCH setOn(!on)} /> @@ -57,10 +65,10 @@
SLIDER - `${v}%`} /> + `${v}%`} />
-
SELECT - {}} />
TEXTFIELD diff --git a/design/cursors/3d-concept.png b/design/cursors/3d-concept.png new file mode 100644 index 000000000..4b7a01c98 Binary files /dev/null and b/design/cursors/3d-concept.png differ diff --git a/design/cursors/3d-direction.md b/design/cursors/3d-direction.md new file mode 100644 index 000000000..f715a93cb --- /dev/null +++ b/design/cursors/3d-direction.md @@ -0,0 +1,74 @@ +# Modelled cursor themes + +`3d-concept.png` is a visual reference, not a render from OpenScreen. + +With the 3D option on, mode 15 ray-marches the arrow and hand of Studio Ink, Pop +Coral, Pixel Candy, Star Sprout and Glass Lens as sculpted models: signed distance +functions written in the compositor's three shaders (HLSL, Metal, WGSL), not a PNG +face or a height map. Prism Glow's arrow and hand are glass crystals: a triangle mesh +traced on its drawing, ray-traced in a navy rim. `crates/compositor/src/sculpt.rs` +names the ten models and holds their bounding boxes and hotspots. The scene names +the model (`"/"`); text, resize, move and the other OS states keep +their built-in art and the compositor's beveled extrusion. + +| Theme | Arrow model | Hand model | +| --- | --- | --- | +| Studio Ink | Black rim, a raised ivory band, a recessed satin black field. | Ivory glove cushion in a black rim, black ridges between the fingers. | +| Prism Glow | A glass crystal cut along the facets of its drawing, set in a thin navy rim. The picture under it shows through, refracted with a slight dispersion; each facet glows faintly in its drawn colour, and a light line marks its folds. | The same crystal: a facet for each plane of the drawn hand, the fingers and thumb included. | +| Pop Coral | Cut paper: flat-topped sheets, no rounded bead or gloss. A coral sheet on a navy sheet, a yellow sheet behind them offset down-left, two yellow click dashes. | A yellow sheet on a navy sheet, the navy showing between the fingers, a coral sheet behind offset down-left, two coral click dashes. | +| Pixel Candy | Simplified pixel art, one cube per pixel: a plum outline, a pink body with a pale highlight down the lit edge and a darker shade along the outline. The 3D model and the 2D sprite come from the same grid. | The same, a pixel hand: the index up, three knuckles apart, the thumb. | +| Star Sprout | Glossy mint cushion framed by the navy outline of its drawing, carrying a star with a face and two leaves. | Ivory glove cushion in the same navy frame, navy ridges between the fingers, a mint cuff and the same star. | +| Glass Lens | A clear glass lens in a flat-topped graphite outline, and beyond it a rounded rim of glass. The picture under it shows through, refracted: magnified by the lens, bent along its edge with a slight dispersion. | The same glass glove, the outline running between the fingers as ridges. | + +Studio Ink, Pop Coral and Star Sprout keep their drawn outline, as in +`3d-concept.png` (and `star-sprout/3d-reference.png` for the Star Sprout hand). +Each piece is a tray in the outline colour under its silhouette, a rounded bead +along the stroke and a puffy colour cushion inside it; the grooves between the +fingers become ridges of the outline colour. The three arrows share one polygon +and the three gloves one construction, each glove measured on its own drawing +(`RIM_POLY`, `RIM_GLOVE`). Pop Coral turns the same shapes into flat paper +sheets instead (`s_paper`). What stands in front is theme-specific: Studio +Ink's ivory band, Pop Coral's yellow and coral layers and click dashes, Star +Sprout's cuff, star, leaves and eyes. + +Prism Glow is built from its 2D drawing alone (`prism-glow/source.png`), not from +`3d-concept.png`. `prism-glow/model/` holds the mesh: `prism_geo.py` lists its +vertices, heights and facets and writes `prism.json`; `build_blend.py` builds +`prism-glow.blend` and `prism-glow.glb` from it in Blender; `export_compositor.py` +writes the mesh into `crates/compositor/src/prism_mesh.rs` and the three shaders. +The compositor ray-traces the crystal, its triangles grouped in bounding boxes that +a ray skips when it misses them: refraction per colour +channel, total internal reflection, and an exit through its flat base onto the +picture under the cursor. That picture is a copy of the composed frame taken just +before the cursor is drawn: the recording, then its privacy blurs, then the cursor, +sharp on top, so only blurred pixels show through the glass. The rim is its +silhouette extruded, a distance field +like the other models: it gives the coverage and the shadows. + +Glass Lens takes the Studio Ink arrow and glove (`s_glass2`) and widens their +outline by `GLASS_RIM`: a graphite band of `GLASS_LINE`, then the glass rim. In +volume (`s_glass`), a glass slab runs under the whole silhouette, rounded across +the rim, and the lens rises `GLASS_DOME` above it inside the band. A ray that hits +the glass is refracted per colour channel, crosses to the flat underside of the +slab, leaves it and lands on the picture under the cursor, the same copy of the +composed frame the Prism Glow crystal refracts (`glass_shade`); three samples +along the refraction keep a compressed edge from breaking into sparkles. Its +shadow is lighter than an opaque model's. With the 3D option off, the theme stays +glass: mode 19 (`cursor_glass`) draws the same relief seen face on, its height +read off the same distances (`s_glass_relief`), and refracts the picture the same +way, the air under the slab standing in for the model's hover. + +All models share one close lamp: a gradient and a highlight even on flat faces, +soft self shadows, ambient occlusion and a studio environment in the +reflections, plus the compositor's cast and contact shadows on the screen. The +hotspot stays on the arrow tip and the index fingertip through hover, tilt, yaw, +click and size changes. + +To change a model, edit it in all three shaders (`sculpt_proto`, +`sculpt_material`) and keep `sculpt.rs` in step: its boxes must contain the +shapes, and a test checks that the shaders mirror its constants. Pixel Candy's +voxel grids are tables written by `scripts/generate-pixel-candy-voxels.mjs`: +change a grid there and run the script; it rewrites all three shaders, and a +test checks the tables agree. To change Prism Glow, edit `prism_geo.py`, run it, +run `build_blend.py` in Blender, then `export_compositor.py`; a test checks that +the three shaders carry the mesh of `prism_mesh.rs`. diff --git a/design/cursors/README.md b/design/cursors/README.md new file mode 100644 index 000000000..da17f3d39 --- /dev/null +++ b/design/cursors/README.md @@ -0,0 +1,44 @@ +# Original OpenScreen cursor themes + +These six themes replace the removed Sweezy packs. The artwork was created for +OpenScreen as transparent raster images. The PNGs in this directory are the +original high-resolution masters; there is no SVG conversion step. Glass Lens is +the exception: it has no raster master (see below). + +| Theme | Intent | +| --- | --- | +| Studio Ink | Quiet, high-contrast choice for product demos | +| Prism Glow | Vivid color without losing the cursor silhouette | +| Pop Coral | Warm, playful choice for casual recordings | +| Pixel Candy | A small retro option for people who liked pixel packs | +| Star Sprout | An original tiny character for people who liked mascot packs | +| Glass Lens | A clear lens of glass in a defined outline: the recording shows through the cursor | + +Each `source.png` contains an arrow on the left and a hand on the right. Run +`node scripts/generate-original-cursor-themes.mjs` to crop, remove low-alpha +fringe pixels, and resize them into transparent 128 × 128 PNGs under +`public/cursors//`. It prints the normalized tip hotspots; copy them into +`src/lib/cursor/cursorThemes.ts` after changing a master. + +With the 3D option on, the compositor draws the arrow and hand of Studio Ink, +Pop Coral, Pixel Candy and Star Sprout as models sculpted in its shaders +(`crates/compositor/src/sculpt.rs`), not from these PNGs. Prism Glow's arrow and +hand are glass crystals meshed on its drawing (`prism-glow/model/`). The theme +picker and the flat cursor keep using the PNGs. Text, resize, move and other +states remain state-accurate through the built-in art and receive the shared 3D +extrusion. + +Glass Lens is glass with the 3D option off too: the compositor draws its arrow +and hand as its model seen face on (mode 19), a lens that refracts the picture +under it, instead of a PNG. Its shapes are the Studio Ink arrow and glove, and +`node scripts/generate-glass-lens-cursor.mjs` draws its picker PNGs from the +same distance fields, read from the shaders, then prints their hotspots. +`glass-lens/renders.png` shows the compositor's own renders, 2D then 3D, on a +light page, a dark page and a checker. + +`contact-sheet.png` shows the sprites enlarged on a light background; +`dark-32px.png` shows them at their 32-pixel reference size on a dark background. +The 3D modeling direction and the reference sheet the models follow are in +`3d-direction.md` and `3d-concept.png`; `star-sprout/3d-reference.png` is a closer +view of the Star Sprout hand. +The consolidated acceptance list is in `requirements.md`. diff --git a/design/cursors/contact-sheet.png b/design/cursors/contact-sheet.png new file mode 100644 index 000000000..860315a9f Binary files /dev/null and b/design/cursors/contact-sheet.png differ diff --git a/design/cursors/dark-32px.png b/design/cursors/dark-32px.png new file mode 100644 index 000000000..e44b6f0d5 Binary files /dev/null and b/design/cursors/dark-32px.png differ diff --git a/design/cursors/glass-lens/renders.png b/design/cursors/glass-lens/renders.png new file mode 100644 index 000000000..0cfd86cbe Binary files /dev/null and b/design/cursors/glass-lens/renders.png differ diff --git a/design/cursors/pixel-candy/source.png b/design/cursors/pixel-candy/source.png new file mode 100644 index 000000000..daeed19be Binary files /dev/null and b/design/cursors/pixel-candy/source.png differ diff --git a/design/cursors/pop-coral/source.png b/design/cursors/pop-coral/source.png new file mode 100644 index 000000000..74a642e90 Binary files /dev/null and b/design/cursors/pop-coral/source.png differ diff --git a/design/cursors/prism-glow/model/build_blend.py b/design/cursors/prism-glow/model/build_blend.py new file mode 100644 index 000000000..4a941e04c --- /dev/null +++ b/design/cursors/prism-glow/model/build_blend.py @@ -0,0 +1,265 @@ +"""Build the Prism Glow cursor in Blender from prism.json, then save prism-glow.blend and export +prism-glow.glb next to this script. + + blender --background --python build_blend.py + +Per cursor, two objects: the faceted crystal (a closed mesh, one flat face per facet of the 2D art, +with its colour in the face attribute `concept`) and the navy rim (a filled 2D curve with a hole, +extruded and rounded). Units: 1 = 1000 px of source.png, origin on the hotspot, y up, z toward the +viewer, rim top at z = 0, everything's underside at z = -0.04. +""" + +import json +import math +import os + +import bmesh +import bpy +from mathutils import Matrix, Vector + +HERE = globals().get("HERE") or os.path.dirname(os.path.abspath(__file__)) +S = 0.001 +T_RIM = 40.0 # rim and crystal-body thickness, px +BEVEL = 5.0 # rim edge rounding, px +HAND_AT = ((1220 - 208) * S, -(50 - 42) * S) # the hand stands where it is in source.png + +D = json.load(open(os.path.join(HERE, "prism.json"))) + + +def srgb_to_linear(c): + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def hex_rgba(h): + return tuple(srgb_to_linear(int(h[i : i + 2], 16) / 255) for i in (1, 3, 5)) + (1.0,) + + +def signed_area(pts): + return 0.5 * sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(pts, pts[1:] + pts[:1])) + + +def model_xy(sh, x, y): + hx, hy = sh["hotspot"] + return ((x - hx) * S, -(y - hy) * S) + + +def fresh_collection(name): + col = bpy.data.collections.get(name) or bpy.data.collections.new(name) + if col.name not in bpy.context.scene.collection.children: + bpy.context.scene.collection.children.link(col) + for ob in list(col.objects): + bpy.data.objects.remove(ob) + return col + + +def build_crystal(name, sh): + V = sh["verts"] + bm = bmesh.new() + concept = bm.faces.layers.float_color.new("concept") + top = {k: bm.verts.new((*model_xy(sh, x, y), z * S)) for k, (x, y, z) in V.items()} + outline = list(sh["outline"]) + if signed_area([model_xy(sh, *V[k][:2]) for k in outline]) < 0: + outline.reverse() + bot = {k: bm.verts.new((*model_xy(sh, *V[k][:2]), -T_RIM * S)) for k in outline} + for f, hexcol in zip(sh["facets"], sh["colors"]): + vs = [top[k] for k in f] + if signed_area([v.co.xy[:] for v in vs]) < 0: + vs.reverse() + bm.faces.new(vs)[concept] = hex_rgba(hexcol) + side = hex_rgba("#1a3cc8") + for a, b in zip(outline, outline[1:] + outline[:1]): + bm.faces.new((top[a], bot[a], bot[b], top[b]))[concept] = side + bm.faces.new([bot[k] for k in reversed(outline)])[concept] = side + bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 3], + quad_method="BEAUTY", ngon_method="BEAUTY") + bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) + me = bpy.data.meshes.get(name) or bpy.data.meshes.new(name) + bm.to_mesh(me) + bm.free() + # glTF carries colours per corner: mirror the face colour there + corner = me.attributes.get("Col") or me.attributes.new("Col", "FLOAT_COLOR", "CORNER") + for p in me.polygons: + p.use_smooth = False + for li in p.loop_indices: + corner.data[li].color = me.attributes["concept"].data[p.index].color + me.color_attributes.active_color = corner + return bpy.data.objects.new(name, me) + + +def inset(loop, d): + """Offset a closed counter-clockwise loop inward by d (mitred).""" + out = [] + for i in range(len(loop)): + p0, p1, p2 = Vector(loop[i - 1]), Vector(loop[i]), Vector(loop[(i + 1) % len(loop)]) + e0, e1 = (p1 - p0).normalized(), (p2 - p1).normalized() + n0, n1 = Vector((-e0.y, e0.x)), Vector((-e1.y, e1.x)) + m = (n0 + n1).normalized() if (n0 + n1).length > 1e-6 else n0 + out.append(tuple(p1 + m * (d / max(m.dot(n0), 0.25)))) + return out + + +def build_rim(name, sh): + outer = [model_xy(sh, x, y) for x, y in sh["silhouette"]] + if signed_area(outer) < 0: + outer.reverse() + inner = [model_xy(sh, *sh["verts"][k][:2]) for k in sh["outline"]] + # the bevel rounds outward from the spline: pull the outer loop in so the silhouette stays exact + outer = inset(outer, BEVEL * S) + cu = bpy.data.curves.get(name) or bpy.data.curves.new(name, "CURVE") + cu.splines.clear() + cu.dimensions = "2D" + cu.fill_mode = "BOTH" + cu.extrude = (T_RIM / 2 - BEVEL) * S + cu.bevel_depth = BEVEL * S + cu.bevel_resolution = 3 + for loop in (outer, inner): + sp = cu.splines.new("POLY") + sp.points.add(len(loop) - 1) + for p, (x, y) in zip(sp.points, loop): + p.co = (x, y, 0.0, 1.0) + sp.use_cyclic_u = True + ob = bpy.data.objects.new(name, cu) + ob.location.z = -T_RIM / 2 * S + return ob + + +def nodes_of(name): + m = bpy.data.materials.get(name) or bpy.data.materials.new(name) + m.use_nodes = True + m.node_tree.nodes.clear() + return m, m.node_tree, m.node_tree.nodes.new("ShaderNodeOutputMaterial") + + +def material_crystal(): + """Clear glass whose facets glow with their colour in the art (the look chosen on 30/09/2026).""" + m, nt, out = nodes_of("Prism Crystal") + bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled") + attr = nt.nodes.new("ShaderNodeAttribute") + attr.attribute_name = "concept" + nt.links.new(attr.outputs["Color"], bsdf.inputs["Emission Color"]) + bsdf.inputs["Base Color"].default_value = (1, 1, 1, 1) + bsdf.inputs["Emission Strength"].default_value = 0.45 + bsdf.inputs["Transmission Weight"].default_value = 1.0 + bsdf.inputs["IOR"].default_value = 1.61 + bsdf.inputs["Roughness"].default_value = 0.0 + nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) + return m + + +def material_rim(): + m, nt, out = nodes_of("Prism Rim") + bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled") + bsdf.inputs["Base Color"].default_value = hex_rgba("#040c5a") + bsdf.inputs["Roughness"].default_value = 0.22 + bsdf.inputs["Coat Weight"].default_value = 1.0 + bsdf.inputs["Coat Roughness"].default_value = 0.05 + nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) + return m + + +def build(): + col = fresh_collection("Prism Glow") + crystal, rim = material_crystal(), material_rim() + obs = [] + for key, at in (("arrow", (0.0, 0.0)), ("hand", HAND_AT)): + c = build_crystal(f"prism_{key}_crystal", D[key]) + r = build_rim(f"prism_{key}_rim", D[key]) + c.data.materials.clear() + c.data.materials.append(crystal) + r.data.materials.clear() + r.data.materials.append(rim) + for ob in (c, r): + ob.location.x, ob.location.y = at + col.objects.link(ob) + obs += [c, r] + return obs + + +def studio(): + """Studio HDRI for light and reflections, the editor screenshot as the screen, a 3/4 camera.""" + sc = bpy.context.scene + w = sc.world or bpy.data.worlds.new("World") + sc.world = w + w.use_nodes = True + nt = w.node_tree + nt.nodes.clear() + env = nt.nodes.new("ShaderNodeTexEnvironment") + env.image = bpy.data.images.load(os.path.join(bpy.utils.system_resource("DATAFILES"), "studiolights", "world", "studio.exr"), check_existing=True) + env.image.pack() # the install path differs per machine; 100 KB inside the .blend does not + bg, flat = nt.nodes.new("ShaderNodeBackground"), nt.nodes.new("ShaderNodeBackground") + flat.inputs["Color"].default_value = (0.06, 0.06, 0.07, 1) + lp, mix, out = nt.nodes.new("ShaderNodeLightPath"), nt.nodes.new("ShaderNodeMixShader"), nt.nodes.new("ShaderNodeOutputWorld") + nt.links.new(env.outputs["Color"], bg.inputs["Color"]) + nt.links.new(lp.outputs["Is Camera Ray"], mix.inputs["Fac"]) + nt.links.new(bg.outputs["Background"], mix.inputs[1]) + nt.links.new(flat.outputs["Background"], mix.inputs[2]) + nt.links.new(mix.outputs["Shader"], out.inputs["Surface"]) + + shot = os.path.normpath(os.path.join(HERE, "..", "..", "..", "..", "public", "preview4.png")) + screen = bpy.data.objects.get("screen") + if screen is None and os.path.exists(shot): + me = bpy.data.meshes.new("screen") + me.from_pydata([(-3, -1.66, 0), (3, -1.66, 0), (3, 1.66, 0), (-3, 1.66, 0)], [], [(0, 1, 2, 3)]) + uv = me.uv_layers.new() + for li, st in enumerate(((0, 0), (1, 0), (1, 1), (0, 1))): + uv.data[li].uv = st + m, mnt, mout = nodes_of("screen") + em, tex = mnt.nodes.new("ShaderNodeEmission"), mnt.nodes.new("ShaderNodeTexImage") + tex.image = bpy.data.images.load(shot, check_existing=True) + mnt.links.new(tex.outputs["Color"], em.inputs["Color"]) + mnt.links.new(em.outputs["Emission"], mout.inputs["Surface"]) + me.materials.append(m) + screen = bpy.data.objects.new("screen", me) + screen.location = (1.9, -1.0, -T_RIM * S - 0.0005) + sc.collection.objects.link(screen) + + target = Vector((0.679, -0.43, 0.0)) + cam = bpy.data.objects.get("cam_34") or bpy.data.objects.new("cam_34", bpy.data.cameras.new("cam_34")) + if cam.name not in sc.collection.objects: + sc.collection.objects.link(cam) + eye = target + Matrix.Rotation(math.radians(-28), 3, "Y") @ Matrix.Rotation(math.radians(-38), 3, "X") @ Vector((0, 0, 4.4)) + f = (target - eye).normalized() + r = f.cross(Vector((0, 1, 0))).normalized() + cam.location = eye + cam.rotation_euler = Matrix((r, r.cross(f), -f)).transposed().to_euler() + cam.data.lens = 85 + sc.camera = cam + sc.render.engine = "CYCLES" + sc.cycles.device = "GPU" + sc.cycles.samples = 128 + sc.cycles.transmission_bounces = sc.cycles.max_bounces = 24 + sc.render.resolution_x, sc.render.resolution_y = 1600, 1000 + sc.view_settings.view_transform = "AgX" + sc.view_settings.look = "AgX - Medium High Contrast" + + +def export_glb(obs, path): + """The rims are curves; glTF wants meshes: bake them into temporary twins for the export.""" + dg = bpy.context.evaluated_depsgraph_get() + twins = [] + for ob in obs: + if ob.type == "CURVE": + name = ob.name + me = bpy.data.meshes.new_from_object(ob.evaluated_get(dg)) + ob.name = name + "_curve" # the twin takes the name the glTF node should carry + tw = bpy.data.objects.new(name, me) + tw.matrix_world = ob.matrix_world + me.materials.append(ob.data.materials[0]) + bpy.context.scene.collection.objects.link(tw) + twins.append((tw, ob, name)) + bpy.ops.object.select_all(action="DESELECT") + for ob in [o for o in obs if o.type == "MESH"] + [tw for tw, _, _ in twins]: + ob.select_set(True) + bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True, + export_vertex_color="ACTIVE", export_apply=True) + for tw, ob, name in twins: + me = tw.data + bpy.data.objects.remove(tw) + bpy.data.meshes.remove(me) + ob.name = name + + +obs = build() +studio() +export_glb(obs, os.path.join(HERE, "prism-glow.glb")) +bpy.ops.wm.save_as_mainfile(filepath=os.path.join(HERE, "prism-glow.blend"), relative_remap=True) diff --git a/design/cursors/prism-glow/model/export_compositor.py b/design/cursors/prism-glow/model/export_compositor.py new file mode 100644 index 000000000..15cd261af --- /dev/null +++ b/design/cursors/prism-glow/model/export_compositor.py @@ -0,0 +1,363 @@ +"""Write the Prism Glow meshes into the compositor: `crates/compositor/src/prism_mesh.rs` and the +matching tables in the three shaders, between their `prism mesh` markers. + + python export_compositor.py # after build_blend.py has written prism-glow.glb + +Everything is expressed in the mode 15 model frame: the unit is the side of the theme's 128 px +sprite (the flat cursor's size), origin on the hotspot, x right, y DOWN, z toward the camera, +rim top at z = 0. The source-to-sprite transform is the one scripts/generate-original-cursor-themes.mjs +applies, so the crystal lands exactly on the flat cursor. +""" + +import json +import math +import struct +from pathlib import Path + +import cv2 +import numpy as np + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[3] +SRC = REPO / "crates" / "compositor" / "src" +SPRITE, INNER, ALPHA_MIN = 128, 112, 28 # generate-original-cursor-themes.mjs +BEVEL_PX = 12.0 # rounding of the rim's edges, source px +EDGE_FOLD_DEG = 25.0 +LEAF = 8 # triangles per bounding box at most +BOX_PAD = 1e-3 # model units: past prism_tri's edge slack + + +def glb_meshes(path): + """{node name: (positions (n,3), triangle indices (m,3), colours (n,3))}, glTF frame (y up).""" + b = path.read_bytes() + n = struct.unpack_from("= ALPHA_MIN + ys, xs = np.nonzero(a) + w, h = xs.max() - xs.min() + 1, ys.max() - ys.min() + 1 + scale = INNER / max(w, h) + tx, ty = (SPRITE - w * scale) / 2, (SPRITE - h * scale) / 2 + hx = (tx + (hotspot[0] - x0 - xs.min()) * scale) / SPRITE + hy = (ty + (hotspot[1] - ys.min()) * scale) / SPRITE + return scale / SPRITE, (hx, hy), ty / SPRITE + + +def crystal_triangles(pos, tri, col, sheet, s): + """Triangles of the crystal (facets and walls; the flat bottom is left to the shader), each + with its outward normal, its colour in the art and which of its edges are real fold lines.""" + # glTF (x, y up, z) -> Blender (x, y, z up), hotspot at the origin, then the model frame + blender = np.c_[pos[:, 0], -pos[:, 2], pos[:, 1]] - np.array([sheet[0], sheet[1], 0.0]) + model = np.c_[blender[:, 0], -blender[:, 1], blender[:, 2]] * (1000.0 * s) + zmin = model[:, 2].min() + tris = [] + for t in tri: + a, b, c = model[t] + nb = np.cross(blender[t[1]] - blender[t[0]], blender[t[2]] - blender[t[0]]) + nb /= np.linalg.norm(nb) + n = np.array([nb[0], -nb[1], nb[2]]) # the y mirror keeps the normal outward + if n[2] < -0.9 and abs(a[2] - zmin) < 1e-6: + continue + tris.append({"v": (a, b, c), "n": n, "col": col[t[0]], "key": tuple(map(tuple, np.round(model[t], 6)))}) + # real edges: the mesh's boundary, a change of facet colour, or a sharp fold + owners = {} + for i, t in enumerate(tris): + for e in range(3): + k = tuple(sorted((t["key"][(e + 1) % 3], t["key"][(e + 2) % 3]))) + owners.setdefault(k, []).append(i) + for i, t in enumerate(tris): + bits = 0 + for e in range(3): + k = tuple(sorted((t["key"][(e + 1) % 3], t["key"][(e + 2) % 3]))) + other = [j for j in owners[k] if j != i] + real = not other or np.abs(tris[other[0]]["col"] - t["col"]).sum() > 1e-3 or ( + tris[other[0]]["n"] @ t["n"] < math.cos(math.radians(EDGE_FOLD_DEG))) + bits |= int(real) << e + t["flags"] = (1 if abs(t["n"][2]) < 0.2 else 0) + 2 * bits + return tris, float(zmin), float(model[:, 2].max()), model + + +def cluster(tris): + """The triangles regrouped by neighbourhood, and one bounding box per group: a ray that + misses a box skips all its triangles. Groups are halves of the centroids' longest extent, + split until they hold LEAF triangles at most; the triangles come back in group order.""" + groups = [] + + def split(idx): + if len(idx) <= LEAF: + groups.append(idx) + return + cent = np.array([np.mean(tris[i]["v"], axis=0) for i in idx]) + axis = int(np.argmax(cent.max(0) - cent.min(0))) + order = [idx[j] for j in np.argsort(cent[:, axis], kind="stable")] + split(order[: len(order) // 2]) + split(order[len(order) // 2 :]) + + split(list(range(len(tris)))) + ordered, leaves = [], [] + for g in groups: + pts = np.array([v for i in g for v in tris[i]["v"]]) + leaves.append((pts.min(0) - BOX_PAD, pts.max(0) + BOX_PAD, len(ordered), len(g))) + ordered += [tris[i] for i in g] + return ordered, leaves + + +def main(): + img = cv2.imread(str(HERE.parent / "source.png"), cv2.IMREAD_UNCHANGED) + geo = json.loads((HERE / "prism.json").read_text()) + meshes = glb_meshes(HERE / "prism-glow.glb") + shapes = [] + for kind, node, (x0, x1), sheet in ( + ("arrow", "prism_arrow_crystal", (0, 887), (0.0, 0.0)), + ("hand", "prism_hand_crystal", (887, 1774), ((1220 - 208) / 1000, -(50 - 42) / 1000)), + ): + hot = geo[kind]["hotspot"] + s, (hx, hy), top = sprite_transform(img, x0, x1, hot) + tris, zmin, zmax, model = crystal_triangles(*meshes[node], sheet, s) + tris, leaves = cluster(tris) + sil = [((x - hot[0]) * s, (y - hot[1]) * s) for x, y in geo[kind]["silhouette"]] + out = [((geo[kind]["verts"][k][0] - hot[0]) * s, (geo[kind]["verts"][k][1] - hot[1]) * s) + for k in geo[kind]["outline"]] + sx = [p[0] for p in sil] + # the draw box: the sprite's full height (1 unit), the silhouette's width + bx0, bx1 = min(sx) - 0.01, max(sx) + 0.01 + shapes.append({ + "kind": kind, "tris": tris, "leaves": leaves, "sil": sil, "out": out, "thick": -zmin, + "height": zmax, + "size": (bx1 - bx0, 1.0), "hotspot": (-bx0 / (bx1 - bx0), hy), "top": top, + "per_px": s, + }) + print(f"{kind}: {len(tris)} triangles in {len(leaves)} boxes, {len(sil)} silhouette points, " + f"box {bx1 - bx0:.4f} x 1, hotspot ({-bx0 / (bx1 - bx0):.4f}, {hy:.4f}), " + f"height {zmax:.4f}, thick {-zmin:.4f}") + write_rust(shapes) + write_shaders(shapes) + + +def f(x): + return f"{x:.6f}".rstrip("0").rstrip(".") if abs(x) > 5e-7 else "0.0" + + +def fl(x): # a float literal every language reads as a float + s = f(x) + return s if "." in s else s + ".0" + + +def bevel(shapes): + return BEVEL_PX * shapes[0]["per_px"] + + +def polygons(shapes): + """Silhouettes, then outlines, in one table; their starts.""" + poly, sil_start, out_start = [], [], [] + for sh in shapes: + sil_start.append(len(poly)) + poly += sh["sil"] + for sh in shapes: + out_start.append(len(poly)) + poly += sh["out"] + return poly, sil_start, out_start + + +def records(shapes): + """Per triangle, four float4: (v0, flags), (e1, r), (e2, g), (n, b).""" + rows = [] + for sh in shapes: + for t in sh["tris"]: + a, b, c = t["v"] + e1, e2 = b - a, c - a + r, g, bl = t["col"] + rows += [(*a, t["flags"]), (*e1, r), (*e2, g), (*t["n"], bl)] + return rows + + +def boxes(shapes): + """Per box, two float4: (lo, first triangle), (hi, triangle count); each model's first box.""" + rows, box_start, k = [], [], 0 + for sh in shapes: + box_start.append(len(rows) // 2) + for lo, hi, first, count in sh["leaves"]: + rows += [(*lo, k + first), (*hi, count)] + k += len(sh["tris"]) + return rows, box_start + + +def write_rust(shapes): + tri_start, rows = [], records(shapes) + k = 0 + for sh in shapes: + tri_start.append(k) + k += len(sh["tris"]) + poly, sil_start, out_start = polygons(shapes) + box_rows, box_start = boxes(shapes) + lines = [ + "//! Les deux curseurs de Prism Glow en MAILLAGE : les facettes tracées sur l'art 2D", + "//! (`design/cursors/prism-glow/model`), taillées en cristal. Fichier GÉNÉRÉ par", + "//! `design/cursors/prism-glow/model/export_compositor.py` : ne pas l'éditer à la main.", + "//!", + "//! Repère du modèle du mode 15 : unité = côté du sprite 128 px du thème, origine au hotspot,", + "//! x à droite, y vers le bas, z vers la caméra, dessus du serti en z = 0. Chaque triangle du", + "//! cristal tient en quatre float4 : (v0, drapeaux), (e1, r), (e2, g), (normale, b) ; drapeaux =", + "//! paroi (1) + 2 × arêtes réelles (bit k : l'arête opposée au sommet k). Le fond plat, en", + "//! z = -épaisseur, est laissé au shader. `POLYS` tient les silhouettes (bord extérieur du", + "//! serti) puis les contours du cristal (son bord intérieur). `BOXES` range les triangles", + "//! par voisinage : deux float4 par boîte, (coin bas, premier triangle), (coin haut, nombre).", + "", + "/// Un des deux modèles : ses triangles et sa silhouette dans les tables, sa boîte de dessin.", + "pub struct PrismModel {", + " pub tri_start: usize,", + " pub tri_count: usize,", + " pub sil_start: usize,", + " pub sil_count: usize,", + " pub out_start: usize,", + " pub out_count: usize,", + " pub box_start: usize,", + " pub box_count: usize,", + " /// Largeur de la boîte, hauteur 1 (le sprite), en unités du modèle.", + " pub size: [f32; 2],", + " /// Hotspot, fraction de la boîte.", + " pub hotspot: [f32; 2],", + " /// Haut de la silhouette, fraction de la hauteur.", + " pub top: f32,", + " /// Sommet du cristal au-dessus de z = 0, et épaisseur sous z = 0.", + " pub height: f32,", + " pub thick: f32,", + "}", + "", + "/// Arrondi des arêtes du serti, en unités du modèle (`PRISM_BEVEL` des shaders).", + f"pub const BEVEL: f32 = {fl(bevel(shapes))};", + "", + "/// Flèche puis main, dans l'ordre des identifiants du shader.", + "pub const MODELS: [PrismModel; 2] = [", + ] + for i, sh in enumerate(shapes): + lines += [ + " PrismModel {", + f" tri_start: {tri_start[i]},", + f" tri_count: {len(sh['tris'])},", + f" sil_start: {sil_start[i]},", + f" sil_count: {len(sh['sil'])},", + f" out_start: {out_start[i]},", + f" out_count: {len(sh['out'])},", + f" box_start: {box_start[i]},", + f" box_count: {len(sh['leaves'])},", + f" size: [{fl(sh['size'][0])}, {fl(sh['size'][1])}],", + f" hotspot: [{fl(sh['hotspot'][0])}, {fl(sh['hotspot'][1])}],", + f" top: {fl(sh['top'])},", + f" height: {fl(sh['height'])},", + f" thick: {fl(sh['thick'])},", + " },", + ] + lines += ["];", "", f"pub const TRIS: [[f32; 4]; {len(rows)}] = ["] + lines += [f" [{', '.join(fl(v) for v in r)}]," for r in rows] + lines += ["];", "", f"pub const POLYS: [[f32; 2]; {len(poly)}] = ["] + lines += [f" [{fl(x)}, {fl(y)}]," for x, y in poly] + lines += ["];", "", f"pub const BOXES: [[f32; 4]; {len(box_rows)}] = ["] + lines += [f" [{', '.join(fl(v) for v in r)}]," for r in box_rows] + lines += ["];", ""] + (SRC / "prism_mesh.rs").write_text("\n".join(lines), encoding="utf-8", newline="\n") + + +def table_lines(shapes, lang): + rows = records(shapes) + poly, sil_start, out_start = polygons(shapes) + box_rows, box_start = boxes(shapes) + bcounts = [len(sh["leaves"]) for sh in shapes] + counts = [len(sh["tris"]) for sh in shapes] + scounts = [len(sh["sil"]) for sh in shapes] + ocounts = [len(sh["out"]) for sh in shapes] + if lang == "hlsl": + v4 = lambda r: f"float4({', '.join(fl(v) for v in r)})" + v2 = lambda p: f"float2({fl(p[0])}, {fl(p[1])})" + head4 = f"static const float4 PRISM_TRIS[{len(rows)}] = {{" + headb = f"static const float4 PRISM_BOXES[{len(box_rows)}] = {{" + head2 = f"static const float2 PRISM_POLY[{len(poly)}] = {{" + tail = "};" + ints = lambda name, vals: f"static const int {name}[2] = {{ {vals[0]}, {vals[1]} }};" + scalar = f"static const float PRISM_BEVEL = {fl(bevel(shapes))};" + elif lang == "metal": + v4 = lambda r: f"float4({', '.join(fl(v) for v in r)})" + v2 = lambda p: f"float2({fl(p[0])}, {fl(p[1])})" + head4 = f"constant float4 PRISM_TRIS[{len(rows)}] = {{" + headb = f"constant float4 PRISM_BOXES[{len(box_rows)}] = {{" + head2 = f"constant float2 PRISM_POLY[{len(poly)}] = {{" + tail = "};" + ints = lambda name, vals: f"constant int {name}[2] = {{ {vals[0]}, {vals[1]} }};" + scalar = f"constant float PRISM_BEVEL = {fl(bevel(shapes))};" + else: + ints = lambda name, vals: f"var {name} = array({vals[0]}, {vals[1]});" + scalar = f"const PRISM_BEVEL: f32 = {fl(bevel(shapes))};" + out = [ints("PRISM_TRI_START", [0, counts[0]]), ints("PRISM_TRI_COUNT", counts), + ints("PRISM_SIL_START", sil_start), ints("PRISM_SIL_COUNT", scounts), + ints("PRISM_OUT_START", out_start), ints("PRISM_OUT_COUNT", ocounts), + ints("PRISM_BOX_START", box_start), ints("PRISM_BOX_COUNT", bcounts)] + if lang == "wgsl": + # The triangles, polygons and boxes themselves go in a uniform buffer that compositor_linux.rs + # fills from prism_mesh.rs (a polygon point per vec4, the stride of a uniform array). As + # a `const` or `var` table, lavapipe copies them into every invocation of every + # layer: a frame without any cursor rendered 3.4 times slower. + return out + ["struct PrismMesh {", f" tris: array, {len(rows)}>,", + f" poly: array, {len(poly)}>,", + f" boxes: array, {len(box_rows)}>,", "}", scalar] + out.append(head4) + out += [f" {v4(r)}," for r in rows] + out[-1] = out[-1].rstrip(",") + out += [tail, headb] + out += [f" {v4(r)}," for r in box_rows] + out[-1] = out[-1].rstrip(",") + out += [tail, head2] + out += [f" {v2(p)}," for p in poly] + out[-1] = out[-1].rstrip(",") + out += [tail, scalar] + return out + + +BEGIN = "prism mesh: generated by design/cursors/prism-glow/model/export_compositor.py" +END = "end of the prism mesh" + + +def write_shaders(shapes): + for name, lang in (("shaders.hlsl", "hlsl"), ("shaders.metal", "metal"), ("vk_shaders/layer.wgsl", "wgsl")): + path = SRC / name + text = path.read_text(encoding="utf-8") + a, b = text.find(BEGIN), text.find(END) + if a < 0 or b < 0: + print(f"{name}: no markers yet, tables not written") + continue + start = text.index("\n", a) + 1 + stop = text.rindex("\n", 0, b) + 1 + text = text[:start] + "\n".join(table_lines(shapes, lang)) + "\n" + text[stop:] + path.write_text(text, encoding="utf-8", newline="\n") + print(f"{name}: tables written") + + +if __name__ == "__main__": + main() diff --git a/design/cursors/prism-glow/model/prism-glow.blend b/design/cursors/prism-glow/model/prism-glow.blend new file mode 100644 index 000000000..8edc4553b Binary files /dev/null and b/design/cursors/prism-glow/model/prism-glow.blend differ diff --git a/design/cursors/prism-glow/model/prism-glow.glb b/design/cursors/prism-glow/model/prism-glow.glb new file mode 100644 index 000000000..2aaa2d145 Binary files /dev/null and b/design/cursors/prism-glow/model/prism-glow.glb differ diff --git a/design/cursors/prism-glow/model/prism.json b/design/cursors/prism-glow/model/prism.json new file mode 100644 index 000000000..5aae7a713 --- /dev/null +++ b/design/cursors/prism-glow/model/prism.json @@ -0,0 +1,985 @@ +{ + "arrow": { + "hotspot": [ + 208, + 42 + ], + "verts": { + "A": [ + 237, + 100, + 0.0 + ], + "B": [ + 443, + 290, + 0.0 + ], + "F": [ + 712, + 549, + 0.0 + ], + "K": [ + 543, + 563, + 0.0 + ], + "J": [ + 612, + 744, + 0.0 + ], + "I": [ + 492, + 807, + 0.0 + ], + "H": [ + 394, + 597, + 0.0 + ], + "G": [ + 241, + 732, + 0.0 + ], + "D": [ + 241, + 491, + 0.0 + ], + "C": [ + 366, + 374, + 108.0 + ], + "E": [ + 458, + 539, + 76.0 + ], + "X": [ + 515, + 449, + 66.0 + ] + }, + "outline": [ + "A", + "B", + "F", + "K", + "J", + "I", + "H", + "G", + "D" + ], + "facets": [ + [ + "A", + "C", + "D" + ], + [ + "A", + "B", + "C" + ], + [ + "B", + "X", + "C" + ], + [ + "B", + "F", + "X" + ], + [ + "C", + "X", + "E" + ], + [ + "X", + "F", + "E" + ], + [ + "E", + "F", + "K" + ], + [ + "C", + "E", + "H" + ], + [ + "C", + "H", + "G" + ], + [ + "C", + "G", + "D" + ], + [ + "E", + "I", + "H" + ], + [ + "E", + "J", + "I" + ], + [ + "E", + "K", + "J" + ] + ], + "colors": [ + "#01f8ee", + "#cadffc", + "#a643fc", + "#c348fc", + "#033df4", + "#002cc0", + "#124efb", + "#00208a", + "#001b80", + "#00b7fb", + "#0022b7", + "#008afc", + "#00fbfb" + ], + "silhouette": [ + [ + 214, + 40 + ], + [ + 207, + 43 + ], + [ + 204, + 49 + ], + [ + 204, + 793 + ], + [ + 207, + 796 + ], + [ + 212, + 796 + ], + [ + 229, + 784 + ], + [ + 381, + 653 + ], + [ + 470, + 846 + ], + [ + 475, + 853 + ], + [ + 481, + 854 + ], + [ + 642, + 770 + ], + [ + 654, + 762 + ], + [ + 654, + 753 + ], + [ + 588, + 594 + ], + [ + 628, + 588 + ], + [ + 768, + 576 + ], + [ + 773, + 572 + ], + [ + 773, + 568 + ], + [ + 768, + 560 + ], + [ + 675, + 464 + ], + [ + 604, + 393 + ], + [ + 478, + 272 + ], + [ + 284, + 96 + ], + [ + 221, + 42 + ] + ] + }, + "hand": { + "hotspot": [ + 1220, + 50 + ], + "verts": { + "I1": [ + 1180, + 122, + 0.0 + ], + "I2": [ + 1221, + 85, + 0.0 + ], + "I3": [ + 1261, + 90, + 0.0 + ], + "I4": [ + 1290, + 132, + 0.0 + ], + "IT1": [ + 1224, + 118, + 46.0 + ], + "IT2": [ + 1274, + 135, + 35.0 + ], + "IT3": [ + 1191, + 262, + 54.0 + ], + "IL": [ + 1179, + 265, + 0.0 + ], + "P1": [ + 1292, + 427, + 0.0 + ], + "W1": [ + 1321, + 456, + 0.0 + ], + "M1": [ + 1323, + 334, + 0.0 + ], + "M2": [ + 1356, + 302, + 0.0 + ], + "M3": [ + 1395, + 309, + 0.0 + ], + "M4": [ + 1415, + 340, + 0.0 + ], + "MT1": [ + 1354, + 334, + 46.0 + ], + "MT2": [ + 1390, + 344, + 35.0 + ], + "P2": [ + 1339, + 479, + 59.0 + ], + "MB1": [ + 1386, + 481, + 54.0 + ], + "MB2": [ + 1415, + 462, + 0.0 + ], + "RL": [ + 1441, + 467, + 0.0 + ], + "R1": [ + 1443, + 368, + 0.0 + ], + "R2": [ + 1476, + 338, + 0.0 + ], + "R3": [ + 1515, + 345, + 0.0 + ], + "R4": [ + 1536, + 377, + 0.0 + ], + "RT1": [ + 1480, + 367, + 43.0 + ], + "RT2": [ + 1513, + 381, + 34.0 + ], + "RB": [ + 1448, + 475, + 57.0 + ], + "RB1": [ + 1476, + 498, + 54.0 + ], + "RB2": [ + 1535, + 489, + 0.0 + ], + "PK": [ + 1567, + 502, + 0.0 + ], + "K1": [ + 1568, + 403, + 0.0 + ], + "K2": [ + 1600, + 375, + 0.0 + ], + "K3": [ + 1637, + 382, + 0.0 + ], + "K4": [ + 1658, + 412, + 0.0 + ], + "KT1": [ + 1601, + 404, + 40.0 + ], + "KT2": [ + 1640, + 420, + 30.0 + ], + "K5": [ + 1658, + 608, + 0.0 + ], + "PR": [ + 1651, + 622, + 0.0 + ], + "BR": [ + 1522, + 818, + 0.0 + ], + "BL": [ + 1208, + 820, + 0.0 + ], + "HC": [ + 1406, + 640, + 76.0 + ], + "Q": [ + 1178, + 572, + 49.0 + ], + "TB": [ + 1128, + 682, + 0.0 + ], + "TT2": [ + 1015, + 490, + 0.0 + ], + "TL": [ + 1013, + 427, + 0.0 + ], + "TT": [ + 1069, + 402, + 0.0 + ], + "TR": [ + 1133, + 449, + 0.0 + ], + "TT1": [ + 1065, + 435, + 43.0 + ], + "TH": [ + 1144, + 541, + 40.0 + ] + }, + "outline": [ + "I1", + "I2", + "I3", + "I4", + "P1", + "W1", + "M1", + "M2", + "M3", + "M4", + "MB2", + "RL", + "R1", + "R2", + "R3", + "R4", + "RB2", + "PK", + "K1", + "K2", + "K3", + "K4", + "K5", + "PR", + "BR", + "BL", + "TB", + "TT2", + "TL", + "TT", + "TR", + "TH", + "Q", + "IL" + ], + "facets": [ + [ + "IT1", + "IT2", + "IT3" + ], + [ + "IT1", + "I2", + "I3", + "IT2" + ], + [ + "I1", + "I2", + "IT1" + ], + [ + "I1", + "IT1", + "IT3", + "IL" + ], + [ + "I3", + "I4", + "IT2" + ], + [ + "IT2", + "I4", + "P1", + "IT3" + ], + [ + "IL", + "IT3", + "P1", + "Q" + ], + [ + "Q", + "P1", + "W1" + ], + [ + "Q", + "W1", + "P2" + ], + [ + "Q", + "P2", + "HC" + ], + [ + "MT1", + "MT2", + "P2" + ], + [ + "MT1", + "M2", + "M3", + "MT2" + ], + [ + "M1", + "M2", + "MT1" + ], + [ + "M1", + "MT1", + "P2", + "W1" + ], + [ + "M3", + "M4", + "MT2" + ], + [ + "MT2", + "M4", + "MB2", + "MB1", + "P2" + ], + [ + "RT1", + "RT2", + "RB" + ], + [ + "RT1", + "R2", + "R3", + "RT2" + ], + [ + "R1", + "R2", + "RT1" + ], + [ + "R1", + "RT1", + "RB", + "RL" + ], + [ + "R3", + "R4", + "RT2" + ], + [ + "RT2", + "R4", + "RB2", + "RB1", + "RB" + ], + [ + "KT1", + "KT2", + "PK" + ], + [ + "KT1", + "K2", + "K3", + "KT2" + ], + [ + "K1", + "K2", + "KT1" + ], + [ + "K1", + "KT1", + "PK" + ], + [ + "K3", + "K4", + "KT2" + ], + [ + "KT2", + "K4", + "K5", + "PR", + "PK" + ], + [ + "P2", + "MB1", + "MB2", + "RL", + "RB", + "RB1", + "RB2", + "PK", + "HC" + ], + [ + "HC", + "PK", + "BR" + ], + [ + "PK", + "PR", + "BR" + ], + [ + "HC", + "BR", + "BL" + ], + [ + "Q", + "HC", + "BL" + ], + [ + "TT1", + "TH", + "TT2" + ], + [ + "TL", + "TT", + "TT1", + "TT2" + ], + [ + "TT", + "TR", + "TH", + "TT1" + ], + [ + "TT2", + "TH", + "TB" + ], + [ + "TH", + "Q", + "TB" + ], + [ + "Q", + "BL", + "TB" + ] + ], + "colors": [ + "#b2fcfc", + "#01fbfc", + "#00faf3", + "#01faf4", + "#00b7f4", + "#00a5eb", + "#bf54fc", + "#662ffa", + "#7237fa", + "#7651fc", + "#88fbfc", + "#00e8ee", + "#00f5f4", + "#76fafb", + "#00beed", + "#00b1f0", + "#8cfcfc", + "#00f3f3", + "#00e1ed", + "#01edf3", + "#00cef6", + "#00b7f7", + "#82fbfc", + "#01f1f5", + "#00e0ee", + "#01f1f3", + "#00c0f2", + "#6f31fb", + "#0129a6", + "#1542fb", + "#0021af", + "#002eba", + "#01f7fc", + "#bffcfc", + "#01fafc", + "#00e3f0", + "#00ddee", + "#00beef", + "#008cf5" + ], + "silhouette": [ + [ + 1209, + 50 + ], + [ + 1169, + 85 + ], + [ + 1145, + 109 + ], + [ + 1143, + 113 + ], + [ + 1142, + 404 + ], + [ + 1137, + 404 + ], + [ + 1073, + 360 + ], + [ + 1066, + 361 + ], + [ + 988, + 401 + ], + [ + 978, + 409 + ], + [ + 976, + 477 + ], + [ + 977, + 500 + ], + [ + 1060, + 640 + ], + [ + 1179, + 846 + ], + [ + 1186, + 852 + ], + [ + 1197, + 856 + ], + [ + 1530, + 856 + ], + [ + 1543, + 852 + ], + [ + 1556, + 839 + ], + [ + 1695, + 623 + ], + [ + 1695, + 401 + ], + [ + 1651, + 348 + ], + [ + 1647, + 346 + ], + [ + 1589, + 338 + ], + [ + 1584, + 340 + ], + [ + 1566, + 357 + ], + [ + 1538, + 317 + ], + [ + 1531, + 311 + ], + [ + 1469, + 301 + ], + [ + 1464, + 303 + ], + [ + 1445, + 320 + ], + [ + 1416, + 276 + ], + [ + 1412, + 273 + ], + [ + 1358, + 264 + ], + [ + 1348, + 264 + ], + [ + 1330, + 280 + ], + [ + 1328, + 256 + ], + [ + 1329, + 200 + ], + [ + 1327, + 121 + ], + [ + 1283, + 61 + ], + [ + 1278, + 57 + ], + [ + 1225, + 50 + ] + ] + } +} diff --git a/design/cursors/prism-glow/model/prism_geo.py b/design/cursors/prism-glow/model/prism_geo.py new file mode 100644 index 000000000..28a115496 --- /dev/null +++ b/design/cursors/prism-glow/model/prism_geo.py @@ -0,0 +1,157 @@ +"""Prism Glow cursor as a faceted mesh: the facets traced from ../source.png, and their heights. + +Coordinates are source-PNG pixels (x right, y down); `z` is the height above the navy rim's top in +the same pixels (unlisted vertices sit on the rim). `outline` is the crystal's edge against the rim, +`facets` are the flat faces of the 2D art, each an ordered polygon. + + python prism_geo.py # writes prism.json: vertices, facets, their colours in the art, silhouettes + +Then build the Blender scene and the glTF from it with build_blend.py. +""" + +import json +from pathlib import Path + +import cv2 +import numpy as np + +HERE = Path(__file__).resolve().parent + +ARROW = { + "hotspot": (208, 42), + "verts": { + "A": (237, 100), "B": (443, 290), "F": (712, 549), "K": (543, 563), "J": (612, 744), + "I": (492, 807), "H": (394, 597), "G": (241, 732), "D": (241, 491), + "C": (366, 374), "E": (458, 539), "X": (515, 449), + }, + "outline": ["A", "B", "F", "K", "J", "I", "H", "G", "D"], + "z": {"C": 108, "E": 76, "X": 66}, + "facets": [ + ["A", "C", "D"], ["A", "B", "C"], ["B", "X", "C"], ["B", "F", "X"], + ["C", "X", "E"], ["X", "F", "E"], ["E", "F", "K"], + # the notch H sits on the E-G line: one concept facet, folded along C-H + ["C", "E", "H"], ["C", "H", "G"], ["C", "G", "D"], + ["E", "I", "H"], ["E", "J", "I"], ["E", "K", "J"], + ], +} + +HAND = { + "hotspot": (1220, 50), + "verts": { + # index finger + "I1": (1180, 122), "I2": (1221, 85), "I3": (1261, 90), "I4": (1290, 132), + "IT1": (1224, 118), "IT2": (1274, 135), "IT3": (1191, 262), "IL": (1179, 265), + "P1": (1292, 427), + # middle finger + "W1": (1321, 456), "M1": (1323, 334), "M2": (1356, 302), "M3": (1395, 309), + "M4": (1415, 340), "MT1": (1354, 334), "MT2": (1390, 344), "P2": (1339, 479), + "MB1": (1386, 481), "MB2": (1415, 462), + # ring finger + "RL": (1441, 467), "R1": (1443, 368), "R2": (1476, 338), "R3": (1515, 345), + "R4": (1536, 377), "RT1": (1480, 367), "RT2": (1513, 381), "RB": (1448, 475), + "RB1": (1476, 498), "RB2": (1535, 489), + # pinky + "PK": (1567, 502), "K1": (1568, 403), "K2": (1600, 375), "K3": (1637, 382), + "K4": (1658, 412), "KT1": (1601, 404), "KT2": (1640, 420), "K5": (1658, 608), + "PR": (1651, 622), + # palm + "BR": (1522, 818), "BL": (1208, 820), "HC": (1406, 640), "Q": (1178, 572), + # thumb + "TB": (1128, 682), "TT2": (1015, 490), "TL": (1013, 427), "TT": (1069, 402), + "TR": (1133, 449), "TT1": (1065, 435), "TH": (1144, 541), + }, + # Each fingertip is cut like a gem: a table (the white facet), crown facets down to the rim. + # The thumb web (TH, Q) rises above the rim so the thumb's lower facets are not flat. + "z": { + "IT1": 46, "IT2": 35, "IT3": 54, + "MT1": 46, "MT2": 35, "P2": 59, "MB1": 54, + "RT1": 43, "RT2": 34, "RB": 57, "RB1": 54, + "KT1": 40, "KT2": 30, + "TT1": 43, "TH": 40, "Q": 49, + "HC": 76, + }, + "outline": [ + "I1", "I2", "I3", "I4", "P1", "W1", "M1", "M2", "M3", "M4", "MB2", "RL", "R1", "R2", + "R3", "R4", "RB2", "PK", "K1", "K2", "K3", "K4", "K5", "PR", "BR", "BL", "TB", "TT2", + "TL", "TT", "TR", "TH", "Q", "IL", + ], + "facets": [ + # index: table, crown, sides + ["IT1", "IT2", "IT3"], ["IT1", "I2", "I3", "IT2"], ["I1", "I2", "IT1"], + ["I1", "IT1", "IT3", "IL"], ["I3", "I4", "IT2"], ["IT2", "I4", "P1", "IT3"], + ["IL", "IT3", "P1", "Q"], + # palm under the index (W1 sits on the P1-P2 line) + ["Q", "P1", "W1"], ["Q", "W1", "P2"], ["Q", "P2", "HC"], + # middle finger + ["MT1", "MT2", "P2"], ["MT1", "M2", "M3", "MT2"], ["M1", "M2", "MT1"], + ["M1", "MT1", "P2", "W1"], ["M3", "M4", "MT2"], ["MT2", "M4", "MB2", "MB1", "P2"], + # ring finger + ["RT1", "RT2", "RB"], ["RT1", "R2", "R3", "RT2"], ["R1", "R2", "RT1"], + ["R1", "RT1", "RB", "RL"], ["R3", "R4", "RT2"], ["RT2", "R4", "RB2", "RB1", "RB"], + # pinky + ["KT1", "KT2", "PK"], ["KT1", "K2", "K3", "KT2"], ["K1", "K2", "KT1"], + ["K1", "KT1", "PK"], ["K3", "K4", "KT2"], ["KT2", "K4", "K5", "PR", "PK"], + # palm + ["P2", "MB1", "MB2", "RL", "RB", "RB1", "RB2", "PK", "HC"], + ["HC", "PK", "BR"], ["PK", "PR", "BR"], ["HC", "BR", "BL"], ["Q", "HC", "BL"], + # thumb + ["TT1", "TH", "TT2"], ["TL", "TT", "TT1", "TT2"], ["TT", "TR", "TH", "TT1"], + ["TT2", "TH", "TB"], ["TH", "Q", "TB"], ["Q", "BL", "TB"], + ], +} + + +def area(poly): + p = np.asarray(poly, float) + return 0.5 * np.sum(p[:, 0] * np.roll(p[:, 1], -1) - np.roll(p[:, 0], -1) * p[:, 1]) + + +def check(shape, name): + """The facets tile the outline exactly: no gap, no overlap, no stray vertex.""" + v = shape["verts"] + outline = abs(area([v[k] for k in shape["outline"]])) + facets = sum(abs(area([v[k] for k in f])) for f in shape["facets"]) + unused = set(v) - {k for f in shape["facets"] for k in f} + assert abs(facets / outline - 1) < 1e-9 and not unused, (name, facets / outline, unused) + + +def facet_colors(shape, img): + """Median colour of each facet in the art, away from its edges.""" + v = shape["verts"] + out = [] + for f in shape["facets"]: + mask = np.zeros(img.shape[:2], np.uint8) + cv2.fillPoly(mask, [np.array([v[k] for k in f], np.int32)], 255) + inner = cv2.erode(mask, np.ones((7, 7), np.uint8)) + px = img[(inner if inner.any() else mask) > 0][:, :3] + b, g, r = np.median(px, axis=0) + out.append("#%02x%02x%02x" % (int(r), int(g), int(b))) + return out + + +def silhouette(img, x0, x1): + """Outer edge of the navy rim, from the art's alpha.""" + a = (img[:, :, 3] > 127).astype(np.uint8) + a[:, :x0] = 0 + a[:, x1:] = 0 + contours, _ = cv2.findContours(a, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) + c = max(contours, key=cv2.contourArea) + return cv2.approxPolyDP(c, 1.2, True)[:, 0, :].tolist() + + +if __name__ == "__main__": + img = cv2.imread(str(HERE.parent / "source.png"), cv2.IMREAD_UNCHANGED) + out = {} + for shape, name, (x0, x1) in ((ARROW, "arrow", (0, 887)), (HAND, "hand", (887, 1774))): + check(shape, name) + z = shape["z"] + out[name] = { + "hotspot": shape["hotspot"], + "verts": {k: [*p, float(z.get(k, 0))] for k, p in shape["verts"].items()}, + "outline": shape["outline"], + "facets": shape["facets"], + "colors": facet_colors(shape, img), + "silhouette": silhouette(img, x0, x1), + } + (HERE / "prism.json").write_text(json.dumps(out, indent=1) + "\n", newline="\n") + print({k: f"{len(v['facets'])} facets" for k, v in out.items()}) diff --git a/design/cursors/prism-glow/source.png b/design/cursors/prism-glow/source.png new file mode 100644 index 000000000..e604db40b Binary files /dev/null and b/design/cursors/prism-glow/source.png differ diff --git a/design/cursors/requirements.md b/design/cursors/requirements.md new file mode 100644 index 000000000..88ab1ede1 --- /dev/null +++ b/design/cursors/requirements.md @@ -0,0 +1,67 @@ +# Cursor refresh requirements + +This file consolidates the cursor decisions from the design and implementation +conversation. It is the working acceptance list for the replacement themes and +their 3D models. + +## Replacement artwork + +- Remove the Sweezy packs because redistribution rights are unclear or + insufficient. +- Ship original OpenScreen cursor artwork as clean, transparent PNGs. Do not + vectorize the generated artwork or ship SVG intermediates. +- Preserve cursor readability and useful choices for users who liked the old + pixel, character, colorful, glossy, or playful cursors. +- Keep the range professional, attractive, and varied without copying + Screen Studio or third-party packs. +- Keep correct arrow-tip and raised-fingertip hotspots and preserve other OS + cursor states through the built-in artwork. + +## 3D option + +- Every replacement theme and supported cursor state must work with the + existing 3D cursor option. +- Every replacement arrow and hand is a real volume, modelled in the + compositor's shaders (`crates/compositor/src/sculpt.rs`): lighting, normals, + self shadows and contact shadows respond to cursor tilt and rotation. A PNG + face or a height map is not a model. +- A rounded extrusion of the PNG silhouette remains the fallback for cursor + states that do not have a dedicated theme model. Each pack adds + style-specific volume: gloves with separate fingers, beveled voxels, flat + cut-paper sheets, cushions set in the rim of their drawn outline, and a + faceted glass crystal that refracts the picture under it. +- Model the arrow and hand separately for every sculpted theme. Do not apply + one contour, material, or roundness rule to all designs. +- Preserve the cursor hotspot through hover, tilt, yaw, click, and size changes. +- Reuse the compositor's 3D lighting and contact shadows. The surface must + change with the compositor camera; a static image of a 3D render is not a + model. + +## Theme-specific shape and outline decisions + +| Theme | Arrow | Hand | +| --- | --- | --- | +| Studio Ink | Black rim, raised ivory band, recessed satin black field. | Ivory glove cushion; the black outline stays as a physical rim, with black ridges between the fingers. | +| Prism Glow | A glass crystal cut along the drawing's facets, in a thin navy rim; the picture under it shows through, refracted with a slight dispersion, privacy blurs included; the facets glow faintly in their drawn colours. Modelled from the 2D drawing alone, keeping its polygonal simplicity. | The same, a crystal hand. | +| Pop Coral | Cut paper: a coral sheet on a navy sheet, flat tops, matte; the yellow offset becomes a flat sheet behind; the click dashes become flat pieces. | A yellow sheet on a navy sheet, flat tops, matte; the coral offset becomes a flat sheet behind; the coral click dashes become flat pieces. | +| Pixel Candy | Simplified pixel art that reads at 20 px as at full size; the 3D model is one cube per pixel of the same grid as the sprite. | A pixel hand from the same kind of grid: index, three knuckles, thumb. | +| Star Sprout | Puffy mint cushion; the navy outline stays as a physical rim (a navy tray and a rounded bead) around it, the star and its leaves in front, each in its own navy rim. | Puffy ivory glove in the same navy rim, with navy ridges between the fingers; separate mint cuff framed in navy; the star and leaves in front, as in `star-sprout/3d-reference.png`. | +| Glass Lens | A glass lens with a defined dark outline and an outer rim of glass, after the macOS glass material: the picture under it is refracted by real shader optics (magnified by the lens, bent and slightly dispersed along its edge), in 3D and, unlike the other themes, in 2D too. | The same, a glass glove with dark ridges between the fingers. | + +The 2D PNG may keep a drawn contour where it helps readability. Its contour +must not be copied blindly onto the 3D material: decide per model whether it +becomes an inlay, side material, separate piece, or disappears. + +## Review references + +- `contact-sheet.png`: 2D PNG themes. +- `3d-concept.png`: the 3D reference sheet the eight sculpted models follow + (Prism Glow's crystal follows its 2D drawing instead), not rendered by the + app. +- `star-sprout/3d-reference.png`: a closer view of the Star Sprout hand; also + not rendered by the app. +- `3d-direction.md`: per-theme modeling notes. +- `README.md`: source PNG and preparation workflow. + +The intended deliverable is the actual in-app 3D treatment, not just these +references. diff --git a/design/cursors/star-sprout/3d-reference.png b/design/cursors/star-sprout/3d-reference.png new file mode 100644 index 000000000..760ed6b62 Binary files /dev/null and b/design/cursors/star-sprout/3d-reference.png differ diff --git a/design/cursors/star-sprout/source.png b/design/cursors/star-sprout/source.png new file mode 100644 index 000000000..930675346 Binary files /dev/null and b/design/cursors/star-sprout/source.png differ diff --git a/design/cursors/studio-ink/source.png b/design/cursors/studio-ink/source.png new file mode 100644 index 000000000..dc5b8a39b Binary files /dev/null and b/design/cursors/studio-ink/source.png differ diff --git a/design/guidelines/colors-semantic.card.html b/design/guidelines/colors-semantic.card.html index adb7bdc50..063102d8d 100644 --- a/design/guidelines/colors-semantic.card.html +++ b/design/guidelines/colors-semantic.card.html @@ -1,15 +1,20 @@ - + -
Annotationannotation · #fbbf24
-
1.5×speed · #fb923c
-
Skip / cutdanger · #f87171
-
Zoom 1.8×accent · #10b981
+
demo.mp4clip · var(--accent)
+
Zoom 1.8×zoom · #3b82f6
+
Annotationannotation
+
1.5×speed
+
Trimdanger
+
Cameracamera · #a855f7
diff --git a/design/openscreen-editor.html b/design/openscreen-editor.html index 8a740b1c5..7f693b1bf 100644 --- a/design/openscreen-editor.html +++ b/design/openscreen-editor.html @@ -77,6 +77,12 @@ --speed: #f97316; /* orange-500 */ --speed-soft: rgba(249, 115, 22, 0.14); + /* timeline candy (tokens/v4.css): the clip is the brand green, zooms blue */ + --clip: var(--accent); + --zoom: #3b82f6; + --candy-glow: 12%; + --playhead: #6c55ff; + /* semantic — success = brand (DS has no separate green) */ --success: #10b981; --success-soft: rgba(16, 185, 129, 0.10); @@ -216,6 +222,7 @@ --annotation-soft: rgba(251, 191, 36, 0.16); --speed: #fb923c; /* orange-400 */ --speed-soft: rgba(251, 146, 60, 0.16); + --candy-glow: 35%; /* success = brand */ --success: #10b981; @@ -1193,7 +1200,7 @@ cursor: pointer; transition: background 150ms ease, color 150ms ease; } - .pane-tabs button.is-active { background: var(--brand); color: var(--accent-on); box-shadow: 0 1px 2px rgba(22,23,29,.12); } + .pane-tabs button.is-active { background: var(--surface-hi); color: var(--fg-emphasis); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,.2); } .pane-section { display: none; } .pane-section.is-active { display: block; } @@ -1212,18 +1219,20 @@ .bg-grid { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 5px; padding: 0 var(--sp-4) var(--sp-4); } .bg-thumb { aspect-ratio: 1/1; - border-radius: 10px; + border-radius: 8px; border: 1px solid transparent; cursor: pointer; transition: transform 150ms ease, border-color 150ms ease; } .bg-thumb:hover { transform: translateY(-1px); } + .bg-add { display: grid; place-items: center; border: 1px dashed var(--border-hi); background: transparent; color: var(--muted); } + .bg-add:hover { border-color: var(--accent); color: var(--accent); } .bg-thumb.is-active { border-color: var(--brand); box-shadow: 0 0 0 2px color-mix(in oklab, var(--brand), transparent 70%); @@ -1312,11 +1321,8 @@ .pane-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 12px 14px; + padding: 12px 0; margin: 8px var(--sp-4); - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg); } .pane-row .label { font: 500 13px var(--font-body); color: var(--fg-2); } .pane-row .info { color: var(--meta); font-size: 12px; margin-left: 4px; } @@ -1346,25 +1352,24 @@ .slider-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; + grid-template-columns: 1fr; + gap: 8px; padding: 0 var(--sp-4) var(--sp-4); } + /* a free row, like .pane-row: no card around the label, value and track */ .slider-cell { - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg); - padding: 10px 12px; + padding: 12px 0; display: grid; grid-template-rows: auto auto; - gap: 8px; + gap: 10px; } - .slider-cell .head { display: flex; align-items: center; gap: 6px; } - .slider-cell .label { font: 500 11px var(--font-body); color: var(--fg-2); } - .slider-cell .val { font: 500 11px var(--font-mono); color: var(--brand); margin-left: auto; } + .slider-cell .head { display: flex; align-items: center; gap: 6px; min-height: 20px; } + .slider-cell .label { font: 500 13px var(--font-body); color: var(--fg-2); } + /* --muted body text, not mint mono: mint text is 2.3:1 on the light row */ + .slider-cell .val { font: 500 12px var(--font-body); font-variant-numeric: tabular-nums; color: var(--muted); margin-left: auto; } .slider-cell input[type=range] { - width: 100%; height: 4px; - background: var(--border); + width: 100%; height: 6px; + background: var(--border-hi); border-radius: 2px; outline: none; -webkit-appearance: none; @@ -1390,7 +1395,7 @@ .slider-cell input[type=range]:disabled { cursor: not-allowed; } .section-label { - font: 500 12px var(--font-body); + font: 600 13px/1.3 var(--font-body); color: var(--fg-2); margin: 4px var(--sp-4) 8px; } @@ -1417,6 +1422,17 @@ outline: none; } .field select:focus, .field input:focus { border-color: var(--brand); } + /* ChoiceRow: a fixed choice in one click (src/components/ai-edition/NewEditorShell.module.css) */ + .choice-row { display: grid; grid-auto-columns: minmax(0, 1fr); grid-auto-flow: column; gap: 4px; } + .choice-btn { + min-width: 0; min-height: 32px; padding: 0 6px; + border: 1px solid transparent; border-radius: var(--r-md); + background: color-mix(in oklab, var(--fg) 6%, transparent); + color: var(--fg-2); font: 500 12.5px/1.2 var(--font-body); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; + } + .choice-btn:hover { background: color-mix(in oklab, var(--fg) 11%, transparent); } + .choice-btn.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--fg); } .transcript-list { display: grid; @@ -1705,7 +1721,7 @@ left: 120px; width: 2px; bottom: 0; - background-color: var(--accent); + background-color: var(--playhead); z-index: 10; pointer-events: none; } @@ -1716,7 +1732,7 @@ left: -3px; width: 8px; height: 8px; - background: var(--accent); + background: var(--playhead); transform: rotate(45deg); } @@ -1726,35 +1742,40 @@ gap: 4px; position: relative; } + /* the recording clip: a solid candy card in the brand green (--clip), dark ink #0b1220 + on it (mint cannot carry white: 2.3:1 against 8.2:1). .tlClip in EditorShellV4.module.css */ .track-block { - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--r-md); + background: linear-gradient(180deg, color-mix(in oklab, var(--clip) 78%, white), var(--clip)) border-box; + border: 1px solid transparent; + border-radius: 12px; + color: #0b1220; overflow: visible; position: relative; display: flex; flex-direction: column; cursor: pointer; - box-shadow: var(--elev-card-soft); + box-shadow: 0 3px 10px color-mix(in oklab, var(--clip) var(--candy-glow), transparent); transition: border-color var(--motion-fast) var(--ease), box-shadow var(--motion-fast) var(--ease); } .track-block.block-1 { flex: 36.5; } .track-block.block-2 { flex: 63.5; } - .track-block:hover { border-color: var(--border-hi); } + /* selection = a halo ring set off by a gap: a border does not show on a solid card */ .track-block.is-selected { - border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent), 0 4px 12px -4px var(--accent-soft); + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--clip); } + .track-block .track-title, + .track-block .track-subtitle { color: #0b1220; } + .track-block .track-subtitle span { color: rgb(11 18 32 / 0.5); } /* visual timeline strip — hatched cut regions + transparent keep regions */ .track-visual-timeline { height: 28px; - border-bottom: 1px solid var(--border); - border-radius: var(--r-md) var(--r-md) 0 0; + border-bottom: 1px solid rgb(11 18 32 / 0.15); + border-radius: 12px 12px 0 0; overflow: hidden; display: flex; - background: var(--surface); + background: transparent; } .segment { height: 100%; @@ -1763,11 +1784,11 @@ background: transparent; } .segment.cut { - background-color: var(--accent-wash); + background-color: rgb(11 18 32 / 0.08); background-image: repeating-linear-gradient( 45deg, - color-mix(in oklch, var(--accent) 32%, transparent) 0, - color-mix(in oklch, var(--accent) 32%, transparent) 8px, + rgb(11 18 32 / 0.18) 0, + rgb(11 18 32 / 0.18) 8px, transparent 8px, transparent 16px ); @@ -1963,28 +1984,21 @@ gap: 6px; height: auto; padding: 5px 10px 6px; - background: - linear-gradient( - 180deg, - color-mix(in oklch, var(--_pill) 14%, var(--bg)) 0%, - color-mix(in oklch, var(--_pill) 6%, var(--bg)) 60%, - var(--bg) 100% - ); - border: 1.5px solid var(--_pill); - border-radius: var(--r-md); - color: var(--fg); + /* candy (.lanePill): solid gradient + glow, dark ink on every lane colour */ + background: linear-gradient(180deg, color-mix(in oklab, var(--_pill) 72%, white), var(--_pill)) border-box; + border: 1px solid transparent; + border-radius: 8px; + color: #0b1220; font: 600 11.5px/1 var(--font-display); letter-spacing: 0.01em; font-variant-numeric: tabular-nums; white-space: nowrap; pointer-events: auto; - box-shadow: - 0 4px 12px -8px color-mix(in oklch, var(--_pill) 60%, transparent), - inset 0 1px 0 color-mix(in oklch, var(--_pill) 30%, transparent); + box-shadow: 0 2px 6px color-mix(in oklab, var(--_pill) var(--candy-glow), transparent); } /* per-type frame color (uses a private var so the gradient + shadow above can share it without re-declaring the body three times) */ - .zoom-pill { --_pill: var(--accent); } + .zoom-pill { --_pill: var(--zoom); } .speed-pill { --_pill: var(--speed); } .annotation-pin { --_pill: var(--annotation); } @@ -1993,24 +2007,17 @@ flex-shrink: 0; width: 12px; height: 12px; - color: color-mix(in oklch, var(--_pill) 70%, var(--fg)); + color: currentColor; } /* value = main text inside the pill */ .pill-value { - color: var(--fg); + color: inherit; font-variant-numeric: tabular-nums; } /* subtle hover lift for interactive annotation pins */ .annotation-pin { cursor: pointer; } - .annotation-pin:hover { - background-image: - linear-gradient( - 180deg, - color-mix(in oklch, var(--annotation) 22%, var(--bg)) 0%, - color-mix(in oklch, var(--annotation) 10%, var(--bg)) 100% - ); - } + .annotation-pin:hover { filter: brightness(1.06); } .annotation-pin:focus-visible { outline: none; box-shadow: @@ -2029,7 +2036,7 @@ .speed-pill::after, .annotation-pin::before, .annotation-pin::after { - content: ''; + content: none; /* retired: candy pills carry no end caps */ position: absolute; top: 50%; width: 7px; @@ -3407,6 +3414,7 @@

Arrière-plan

+
@@ -3515,22 +3523,20 @@

Layout

- -
- + +
+ + + + +
Mirror Webcam
- Shrink on Zoom ? + Shrink on zoom
@@ -3591,7 +3597,7 @@

Cursor

- Clip to Canvas ? + Clip to canvas
diff --git a/design/readme.md b/design/readme.md index 1213b8b69..a5ac6d49a 100644 --- a/design/readme.md +++ b/design/readme.md @@ -33,8 +33,11 @@ How OpenScreen writes. - **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`. +- **Casing:** Sentence case everywhere, section labels included (`Background`, `Frame`, + `Camera shape`), 600 13px `--fg-2`. Do not introduce new UPPERCASE mono eyebrows in the + inspector. +- **Say it once.** No hint sentence under a setting that repeats its label, and no "disabled + because…" text: an option that does nothing right now is hidden. See EDITOR CONTROLS. - **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. @@ -53,10 +56,11 @@ How OpenScreen writes. - **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. + hue (`#10b981`) used sparingly for primary actions, active states, and focus. On the timeline + the recording clip owns the emerald (`--clip`), zooms are **blue** (`--zoom`), and the other + lanes add **amber** annotations, **orange** speed ramps, **red/danger** trims: all solid + "candy" with dark ink. 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 @@ -64,7 +68,7 @@ How OpenScreen writes. 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. + choices get a 1px emerald border on the accent tint. - **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. @@ -75,21 +79,71 @@ How OpenScreen writes. - **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. + shadow. **Inspector settings are not cards**: they are free rows grouped by section labels and + space; a slider row pairs a label (left) with a `--muted` body-font value (right). - **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 + transform for every interactive element. Sliders have a white knob on a 6px brand-filled + track. 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. +- **Press/active/selected:** active tabs are raised (`--surface-hi`, `--fg-emphasis`), not + accent-filled; a pressed ChoiceRow cell gets an `--accent` border on `--accent-soft`; selected + timeline clips and pills get a halo ring in their own colour; toggles slide a white 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. +## EDITOR CONTROLS + +The inspector and timeline direction the app ships (verify against the cited source before +changing a rule; the app is the source of truth). + +**Choosing a value** +- **A fixed choice is a ChoiceRow, not a select.** Equal grid cells, 4px gap, 32px tall (44px + `tiles` when the icon draws the choice), radius `--r-md`, fill + `color-mix(in oklab, var(--fg) 6%, transparent)`. Pressed = `--accent` border, `--accent-soft` + fill, `--fg` text. Source: `ChoiceRow` in `src/components/ai-edition/RightPanes.tsx`, + `.choiceRow / .choiceBtn` in `NewEditorShell.module.css` (commit e4c56004). +- **A select only for a long list**: the 15 translation targets. +- **Named levels, not numbers, when the number means nothing to a user.** Shadow is + None / Light / Medium / Strong, click bounce None / Light / Strong (`SHADOW_LEVELS`, + `CLICK_BOUNCE_LEVELS`). A stored value between two levels presses nothing. +- **Sliders for continuous amounts** only: padding, roundness, blur amount 0–100 %, cursor size. + A value with no unit (cursor size) shows no number; the track's position is the value. + +**Laying out settings** +- **Free rows, no card around each setting** (`.paneRow`, `.sliderCell`). Rows are grouped by + sentence-case section labels (`.sectionLabel`: 600 13px, `--fg-2`) and by space. +- **Slider value text is `--muted`, body font**: not mint (2.3:1 on the light row), not mono. +- **No explanatory hint text.** A label says what a setting is; do not repeat it in a sentence + below. An option that does nothing in the current context is **hidden**, not disabled with a + reason (commit 605422e8). Exception: a warning that prevents a real surprise. +- **Tabs** (`.paneTabs`): the active tab is raised (`--surface-hi`, `--fg-emphasis`, 600, small + shadow), never an accent fill. +- **Wallpaper grid**: 8 per row, 5px gap; upload is the last tile, dashed with a "+". +- **Pane structure**: Background is a section of the Composition pane, not a facet of its own. + There is no Fit button. Export offers named destinations (Web / YouTube, Social, Studio, + README GIF) with the details under an "Advanced" disclosure. + +**Timeline** +- **Candy, dark ink.** The recording clip is the brand green (`--clip: var(--accent)`): a solid + card, gradient `color-mix(in oklab, var(--clip) 78%, white)` to `--clip`, glow + `--candy-glow` (12 % light, 35 % dark). Text and waveform on it are dark ink `#0b1220`: mint + cannot carry white (2.3:1 against 8.2:1). Selection is a halo ring, `0 0 0 2px var(--bg), + 0 0 0 4px var(--clip)`. +- **Lane pills are candy too** (`.lanePill`): no border, no gloss, dark ink. Zoom is blue + (`--zoom: #3b82f6`) so it never reads as the clip. Annotation amber, speed orange, trim + `--danger`, camera `#a855f7`. Source: `src/components/ai-edition/v4/EditorShellV4.module.css`, + tokens in `tokens/v4.css`. +- **Playhead violet** (`--playhead`), its own hue so it reads over every lane. +- **No seek bar.** The ruler seeks; the transport sits centred in the timeline header row, + between the tools and the shortcut hints (commits f7fe8908, 98445ff5). +- **Edit clip trim track** is drawn as the clip's candy card with notch grips inside its ends, + on a faint groove for the whole source (commit 7aa00935). + --- ## ICONOGRAPHY @@ -136,7 +190,7 @@ Root: `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 +- `forms/` — Button, IconButton, SegmentedControl, ChoiceRow, Switch, Slider, Select, TextField - `display/` — Badge, Chip, Card, ProgressBar - `editor/` — ChatBubble, ProposalCard, MediaCard, TimelinePill, FacetRailButton @@ -145,4 +199,5 @@ Root: ## Intentional additions None. The component inventory is exactly what the v4 editor defines. `TextField` merges the -`` and `