Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4861824e7d | |||
| 5eb930e71d | |||
| 97cf15e3b7 | |||
| 94533bb071 | |||
| f407f41855 | |||
| 574e3e4e3f | |||
| ebd9967547 | |||
| 1e7c18b2c8 | |||
| 9aebc3f251 |
+119
-18
@@ -1,8 +1,18 @@
|
||||
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
|
||||
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
|
||||
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
|
||||
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
|
||||
# target boxes run.
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
||||
# apt distribution/component:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
|
||||
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
|
||||
#
|
||||
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
|
||||
# host and client packages always share a version line. The release-attach helpers are race-safe
|
||||
# (ensure_release create-or-fetch; upsert_asset only conflicts on same-name), so the jobs run parallel.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages
|
||||
# Box setup (once): see packaging/debian/README.md
|
||||
@@ -83,22 +93,15 @@ jobs:
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build release host + client
|
||||
- name: Build release clients
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||
# both client binaries must ship (build-client-deb.sh installs both).
|
||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
||||
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
||||
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
||||
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
||||
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
||||
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
||||
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built
|
||||
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
@@ -128,7 +131,7 @@ jobs:
|
||||
- name: Build .debs
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||
# host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
|
||||
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
||||
@@ -168,3 +171,101 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The HOST .deb — built on Ubuntu 24.04 (noble) with a from-source FFmpeg 8 BUNDLED, so it installs
|
||||
# on Ubuntu 24.04 LTS through 26.04 (see the file header + packaging/debian/README.md). Runs in
|
||||
# parallel with build-publish and publishes to the SAME distribution/component; the version step is
|
||||
# byte-identical so host and clients share a version line.
|
||||
build-publish-host:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Version + channel
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "host package version $V -> apt distribution '$DIST'"
|
||||
|
||||
# dpkg-shlibdeps/dpkg-deb + patchelf are baked into rust-ci-noble; python3 is for the
|
||||
# release-attach helper. Re-install defensively so the job stays green against the PREVIOUS
|
||||
# image on the same push (docker.yml bootstrap lag) — a no-op once the image ships them.
|
||||
- name: dpkg-dev + patchelf + python3
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends dpkg-dev patchelf python3
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
|
||||
# ci.yml's 26.04 target cache (mixing would poison both).
|
||||
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build release host
|
||||
env:
|
||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
|
||||
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
|
||||
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
|
||||
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
|
||||
# via PKG_CONFIG_PATH (set in rust-ci-noble).
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-tray
|
||||
|
||||
- name: Build host .deb (FFmpeg bundled)
|
||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||
# binary's rpath, so there is no `Depends: libavcodec62` to block install on 24.04. FFMPEG_PREFIX
|
||||
# defaults to /opt/ffmpeg (the image's ENV also sets it).
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for DEB in dist/*.deb; do
|
||||
echo "uploading $DEB"
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published host to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the host .deb to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
@@ -39,6 +39,12 @@ jobs:
|
||||
- image: punktfunk-rust-ci
|
||||
dockerfile: ci/rust-ci.Dockerfile
|
||||
context: ci
|
||||
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
|
||||
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
|
||||
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
||||
- image: punktfunk-rust-ci-noble
|
||||
dockerfile: ci/rust-ci-noble.Dockerfile
|
||||
context: ci
|
||||
- image: punktfunk-fedora-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
context: ci
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# LTS builder for the punktfunk HOST .deb — Ubuntu 24.04 (noble), the current Ubuntu LTS.
|
||||
#
|
||||
# WHY THIS EXISTS (see packaging/debian/README.md → "Ubuntu 24.04 LTS"):
|
||||
# The default builder (ci/rust-ci.Dockerfile) is Ubuntu 26.04, so the host .deb it produces bakes
|
||||
# in a glibc 2.41 floor and a hard `Depends: libavcodec62, …` (FFmpeg 8). Ubuntu 24.04 LTS ships
|
||||
# glibc 2.39 and FFmpeg 6.1 (libavcodec60), so that .deb is uninstallable there — apt reports the
|
||||
# deps as "too recent". Building the host on 24.04 instead lowers the glibc floor to 2.39 (the
|
||||
# binary then runs on 24.04 → 26.04), and the ONE library 24.04 is too old for — FFmpeg — is built
|
||||
# from source here and BUNDLED into the .deb (packaging/debian/build-deb.sh, BUNDLE_FFMPEG=1), so
|
||||
# the package no longer depends on the distro's libav* at all. Everything else the host links
|
||||
# (PipeWire, Wayland, xkbcommon, GL/EGL/GBM, Vulkan; opus is vendored via cmake) is soname-compatible
|
||||
# on 24.04, so this ONE universal host .deb replaces the 26.04-built one for every Ubuntu user.
|
||||
#
|
||||
# libcuda is deliberately NOT provided: the host dlopen's libcuda.so.1 at runtime (pf-zerocopy /
|
||||
# pf-encode) and never link-imports it, so — unlike the full-workspace rust-ci image, which builds
|
||||
# tests that DO link a cuda stub — this host-only build needs no NVIDIA driver package. NVENC/EGL
|
||||
# come from whatever driver the target runs, out of band.
|
||||
#
|
||||
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-rust-ci-noble); consumed by the
|
||||
# `build-publish-host` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci, the first deb.yml
|
||||
# run after this image is added uses the image from a PRIOR docker.yml push — seed it once manually
|
||||
# (docker build -f ci/rust-ci-noble.Dockerfile -t … ci && docker push) before the host job can run.
|
||||
FROM ubuntu:24.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
|
||||
dpkg-dev patchelf \
|
||||
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
|
||||
# encode backend the host auto-selects; zlib (libavformat). NVENC needs only headers (below), dlopen'd.
|
||||
nasm libva-dev libdrm-dev zlib1g-dev \
|
||||
# host link deps present on 24.04 with sonames compatible up to 26.04
|
||||
libpipewire-0.3-dev libwayland-dev libxkbcommon-dev \
|
||||
libgl-dev libegl-dev libgbm-dev libvulkan-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- FFmpeg 8 from source -> /opt/ffmpeg (shared libs + .pc files) ----------------------------------
|
||||
# libavcodec.so.62, matching the 26.04 line's soname so the host behaves identically. This is an
|
||||
# LGPL build (no --enable-gpl / --enable-nonfree) so bundling the .so's into an MIT/Apache .deb stays
|
||||
# license-clean — LGPL's relink clause is satisfied by dynamic linking, and the only encoders the host
|
||||
# calls (h264/hevc/av1 _nvenc + _vaapi, plus scale_vaapi/hwmap filters; software H.264 fallback is the
|
||||
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
|
||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||
ARG FFMPEG_TAG=n8.0
|
||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
|
||||
ARG NVHDR_TAG=n12.2.72.0
|
||||
RUN set -eux; \
|
||||
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
|
||||
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
|
||||
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
|
||||
make -C /tmp/nvhdr install PREFIX=/usr/local; \
|
||||
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
|
||||
cd /tmp/ffmpeg; \
|
||||
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
|
||||
--prefix=/opt/ffmpeg \
|
||||
--enable-shared --disable-static \
|
||||
--disable-doc --disable-programs --disable-debug \
|
||||
--enable-nvenc --enable-vaapi \
|
||||
--extra-cflags=-I/usr/local/include --extra-ldflags=-L/usr/local/lib; \
|
||||
make -j"$(nproc)"; make install; \
|
||||
cd /; rm -rf /tmp/ffmpeg /tmp/nvhdr; \
|
||||
# sanity: the soname we expect to bundle (libavcodec.so.62 on FFmpeg 8)
|
||||
test -e /opt/ffmpeg/lib/libavcodec.so.62
|
||||
|
||||
# ffmpeg-sys-next discovers FFmpeg via pkg-config; point it at the bundled build. PKG_CONFIG_PATH is
|
||||
# PREPENDED to pkg-config's default dirs (not a replacement — that's PKG_CONFIG_LIBDIR), so PipeWire /
|
||||
# Wayland / libva / … still resolve from the system. FFMPEG_PREFIX is read by build-deb.sh's bundler.
|
||||
ENV PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig \
|
||||
FFMPEG_PREFIX=/opt/ffmpeg
|
||||
|
||||
# Toolchain shared across CI users (jobs may run as different uids).
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
--component rustfmt,clippy \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo clippy --version && cargo fmt --version
|
||||
@@ -14,6 +14,9 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import CoreGraphics
|
||||
import CoreVideo
|
||||
#if os(macOS)
|
||||
import IOSurface
|
||||
#endif
|
||||
import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
@@ -228,6 +231,51 @@ public final class MetalVideoPresenter {
|
||||
/// The layer the hosting view installs (as a sublayer) and sizes to its bounds.
|
||||
public let layer: CAMetalLayer
|
||||
|
||||
#if os(macOS)
|
||||
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed
|
||||
/// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions.
|
||||
///
|
||||
/// Why this exists — the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp,
|
||||
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
|
||||
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
|
||||
/// displays, and the race survives glass pacing — a fully serialized one-in-flight present
|
||||
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
|
||||
/// using the image queue entirely and present the way video players do: render the planar CSC
|
||||
/// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary
|
||||
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of
|
||||
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout
|
||||
/// promotion, no compositing, no panic reports). Contents updates are transparent to the
|
||||
/// layer below when nil, so flipping modes just covers/uncovers the metal layer.
|
||||
public let surfaceLayer: CALayer = {
|
||||
let l = CALayer()
|
||||
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
||||
l.isOpaque = true
|
||||
l.actions = ["contents": NSNull(), "bounds": NSNull(), "position": NSNull()]
|
||||
return l
|
||||
}()
|
||||
|
||||
/// One IOSurface-backed render target of the windowed present pool. All pool state is
|
||||
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
|
||||
private struct SurfaceSlot {
|
||||
let surface: IOSurfaceRef
|
||||
let texture: MTLTexture
|
||||
/// Monotonic use stamp — the reuse picker takes the least-recently-rendered free slot.
|
||||
var seq: UInt64 = 0
|
||||
}
|
||||
|
||||
private var surfacePool: [SurfaceSlot] = []
|
||||
private var surfacePoolSize: CGSize = .zero
|
||||
private var surfaceSeq: UInt64 = 0
|
||||
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
||||
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
||||
private var lastHandedOff: Int?
|
||||
/// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed
|
||||
/// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`.
|
||||
private var surfacePresentsStaged = false
|
||||
/// Render-thread copy, so pool teardown happens exactly once on a mode flip.
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
|
||||
private let device: MTLDevice
|
||||
private let queue: MTLCommandQueue
|
||||
/// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per
|
||||
@@ -493,6 +541,18 @@ public final class MetalVideoPresenter {
|
||||
stagingLock.unlock()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
||||
/// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents
|
||||
/// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path.
|
||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
stagingLock.lock()
|
||||
surfacePresentsStaged = on
|
||||
stagingLock.unlock()
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's;
|
||||
/// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR`
|
||||
/// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
|
||||
@@ -588,9 +648,30 @@ public final class MetalVideoPresenter {
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
let targetFromLayout = drawableTarget
|
||||
#if os(macOS)
|
||||
let surfaceMode = surfacePresentsStaged
|
||||
#endif
|
||||
stagingLock.unlock()
|
||||
configure(hdr: false)
|
||||
var csc = planes.csc
|
||||
#if os(macOS)
|
||||
if surfaceMode != surfacePresentsActive {
|
||||
surfacePresentsActive = surfaceMode
|
||||
presenterLog.info(
|
||||
"stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)")
|
||||
if !surfaceMode {
|
||||
// Back to the metal path (fullscreen): drop the pool — at 5K it holds >100 MB,
|
||||
// and re-entering windowed mode rebuilds it in one frame.
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = .zero
|
||||
lastHandedOff = nil
|
||||
}
|
||||
}
|
||||
if surfaceMode {
|
||||
return renderPlanarToSurface(
|
||||
planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented)
|
||||
}
|
||||
#endif
|
||||
return encodePresent(
|
||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||
targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
|
||||
@@ -606,6 +687,118 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the
|
||||
/// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a
|
||||
/// plain CATransaction — an ordinary damaged-layer update on WindowServer's own composite
|
||||
/// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply
|
||||
/// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped
|
||||
/// with CLOCK_REALTIME then — the closest observable analogue of "reached glass" here (the
|
||||
/// composite follows within a refresh, so the meters' display stage reads slightly optimistic).
|
||||
private func renderPlanarToSurface(
|
||||
_ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform,
|
||||
onPresented: ((Int64?) -> Void)?
|
||||
) -> Bool {
|
||||
let decodedSize = CGSize(width: planes.width, height: planes.height)
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? targetFromLayout : decodedSize
|
||||
ensureSurfacePool(size: targetSize)
|
||||
guard let slotIndex = takeSurfaceSlot(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
let slot = surfacePool[slotIndex]
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = slot.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
pass.colorAttachments[0].storeAction = .store
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(pipelinePlanar)
|
||||
encoder.setFragmentTexture(planes.y, index: 0)
|
||||
encoder.setFragmentTexture(planes.cb, index: 1)
|
||||
encoder.setFragmentTexture(planes.cr, index: 2)
|
||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
let surface = slot.surface
|
||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||
let keepAlive: [Any] = [planes.y, planes.cb, planes.cr]
|
||||
commandBuffer.addCompletedHandler { _ in
|
||||
_ = keepAlive // ring textures pinned until the GPU finished sampling
|
||||
DispatchQueue.main.async {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer.contents = surface
|
||||
CATransaction.commit()
|
||||
onPresented?(
|
||||
Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||
}
|
||||
}
|
||||
commandBuffer.commit()
|
||||
lastHandedOff = slotIndex
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)build the pool at `size` — 4 BGRA8 IOSurface render targets (one on glass, one queued
|
||||
/// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty;
|
||||
/// the caller returns false and the ring's putBack + display-link retry take over.
|
||||
private func ensureSurfacePool(size: CGSize) {
|
||||
guard size != surfacePoolSize else { return }
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = size
|
||||
lastHandedOff = nil
|
||||
let w = Int(size.width)
|
||||
let h = Int(size.height)
|
||||
guard w > 0, h > 0 else { return }
|
||||
// 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||
let bytesPerRow = ((w * 4) + 255) & ~255
|
||||
let props: [String: Any] = [
|
||||
kIOSurfaceWidth as String: w,
|
||||
kIOSurfaceHeight as String: h,
|
||||
kIOSurfaceBytesPerElement as String: 4,
|
||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||
kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.renderTarget]
|
||||
desc.storageMode = .shared
|
||||
for _ in 0..<4 {
|
||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
else {
|
||||
surfacePool.removeAll()
|
||||
return
|
||||
}
|
||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||
private func takeSurfaceSlot() -> Int? {
|
||||
guard !surfacePool.isEmpty else { return nil }
|
||||
var free: Int?
|
||||
var busy: Int?
|
||||
for i in surfacePool.indices where i != lastHandedOff {
|
||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||
} else {
|
||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||
}
|
||||
}
|
||||
guard let pick = free ?? busy else { return nil }
|
||||
surfaceSeq += 1
|
||||
surfacePool[pick].seq = surfaceSeq
|
||||
return pick
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
|
||||
@@ -42,12 +42,20 @@ enum PresenterChoice: Equatable {
|
||||
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
||||
/// freeze-prone fallback.
|
||||
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||
explicit(setting: setting, env: env, allowStage1: allowStage1) ?? platformDefault
|
||||
}
|
||||
|
||||
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
|
||||
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
|
||||
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage — an
|
||||
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
|
||||
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
|
||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||
switch raw {
|
||||
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
||||
case "stage1": return allowStage1 ? .stage1 : nil
|
||||
case "stage2": return .stage2
|
||||
case "stage3": return .stage3
|
||||
default: return platformDefault
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +74,41 @@ enum PresenterChoice: Equatable {
|
||||
}
|
||||
|
||||
final class SessionPresenter {
|
||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
|
||||
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
|
||||
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
|
||||
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
|
||||
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
|
||||
/// decode is near-instant Metal compute, so a network clump of frames presents within the
|
||||
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
|
||||
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
|
||||
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
|
||||
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
|
||||
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
|
||||
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
|
||||
/// of stage-2 defaults there predate any panic report.
|
||||
static func pacing(
|
||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||
) -> PresentPacing {
|
||||
if choice == .stage3 { return .glass }
|
||||
#if os(macOS)
|
||||
if explicit == nil, codec == .pyrowave { return .glass }
|
||||
#endif
|
||||
return .arrival
|
||||
}
|
||||
|
||||
private var pump: StreamPump?
|
||||
private var stage2: Stage2Pipeline?
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
#if os(macOS)
|
||||
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
|
||||
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
|
||||
private var surfaceLayer: CALayer?
|
||||
private var surfacePresentsActive = false
|
||||
#endif
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
|
||||
@@ -112,20 +151,29 @@ final class SessionPresenter {
|
||||
#else
|
||||
let allowStage1 = false
|
||||
#endif
|
||||
let choice = PresenterChoice.resolve(
|
||||
let explicit = PresenterChoice.explicit(
|
||||
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||
allowStage1: allowStage1)
|
||||
let choice = explicit ?? PresenterChoice.platformDefault
|
||||
if choice != .stage1,
|
||||
let pipeline = Stage2Pipeline(
|
||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||
displayMeter: displayMeter,
|
||||
pacing: choice == .stage3 ? .glass : .arrival) {
|
||||
pacing: Self.pacing(
|
||||
for: choice, explicit: explicit, codec: connection.videoCodec)) {
|
||||
let metal = pipeline.layer
|
||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
||||
baseLayer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
#if os(macOS)
|
||||
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
|
||||
// contents) while the metal path presents, covering it while surface presents run.
|
||||
baseLayer.addSublayer(pipeline.surfaceLayer)
|
||||
surfaceLayer = pipeline.surfaceLayer
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
stage2 = pipeline
|
||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||
// (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the
|
||||
@@ -224,6 +272,12 @@ final class SessionPresenter {
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = contentsScale
|
||||
metalLayer.frame = snapped
|
||||
#if os(macOS)
|
||||
// The surface present target mirrors the metal layer's geometry exactly — its IOSurfaces
|
||||
// are sized to the same snapped pixel rect, so the contents composite is a 1:1 blit too.
|
||||
surfaceLayer?.contentsScale = contentsScale
|
||||
surfaceLayer?.frame = snapped
|
||||
#endif
|
||||
CATransaction.commit()
|
||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||
@@ -251,6 +305,31 @@ final class SessionPresenter {
|
||||
contentSize = size
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
||||
/// every layout, which fullscreen transitions always trigger). PyroWave sessions in a
|
||||
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
|
||||
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
|
||||
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
|
||||
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
|
||||
/// HDR/EDR presentation has no surface-contents equivalent wired.
|
||||
func setComposited(_ composited: Bool) {
|
||||
guard let stage2, let connection else { return }
|
||||
let wantsSurface = composited && connection.videoCodec == .pyrowave
|
||||
guard wantsSurface != surfacePresentsActive else { return }
|
||||
surfacePresentsActive = wantsSurface
|
||||
stage2.setSurfacePresents(wantsSurface)
|
||||
if !wantsSurface {
|
||||
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
||||
// entry shows the previous frame until the next present — no black flash).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer?.contents = nil
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||
/// stage-2 layer + link. Does not close the connection — that stays with whoever owns it.
|
||||
/// Idempotent.
|
||||
@@ -264,6 +343,11 @@ final class SessionPresenter {
|
||||
stage2 = nil
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
#if os(macOS)
|
||||
surfaceLayer?.removeFromSuperlayer()
|
||||
surfaceLayer = nil
|
||||
surfacePresentsActive = false
|
||||
#endif
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,11 @@ private final class VsyncClock: @unchecked Sendable {
|
||||
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
|
||||
/// present instead of queueing them behind the display — the hidden queue latency becomes
|
||||
/// explicit, correct frame drops.
|
||||
///
|
||||
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
|
||||
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
|
||||
/// "mismatched swapID's" KERNEL PANIC, and the one-in-flight gate removes that pattern — see
|
||||
/// `SessionPresenter.pacing` for the full rationale.
|
||||
public enum PresentPacing: Sendable {
|
||||
case arrival
|
||||
case glass
|
||||
@@ -625,6 +630,18 @@ public final class Stage2Pipeline {
|
||||
presenter.setDrawableTarget(size)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` — the
|
||||
/// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`.
|
||||
public var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||
|
||||
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setSurfacePresents`).
|
||||
public func setSurfacePresents(_ on: Bool) {
|
||||
presenter.setSurfacePresents(on)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
||||
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
||||
|
||||
@@ -692,6 +692,11 @@ public final class StreamLayerView: NSView {
|
||||
/// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows.
|
||||
private func layoutPresenter() {
|
||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||
// Present routing tracks the window's composited state (fullscreen transitions always
|
||||
// re-layout, so this stays current): windowed PyroWave presents via surface contents —
|
||||
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
|
||||
// not yet in a window counts as composited (the safe default).
|
||||
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
||||
// bounds — a pre-window layout would report point-sized dimensions.
|
||||
if window != nil, bounds.width > 0, bounds.height > 0 {
|
||||
|
||||
@@ -79,5 +79,44 @@ final class PresentPacingTests: XCTestCase {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||
}
|
||||
|
||||
/// `explicit` is nil exactly when `resolve` would fall back to the platform default — the
|
||||
/// distinction the codec-conditional pacing default rides on.
|
||||
func testPresenterChoiceExplicitIsNilWithoutASelection() {
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: nil, env: nil, allowStage1: true))
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: "garbage", env: nil, allowStage1: true))
|
||||
XCTAssertNil(PresenterChoice.explicit(setting: "stage1", env: nil, allowStage1: false))
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.explicit(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.explicit(setting: nil, env: "stage3", allowStage1: true), .stage3)
|
||||
}
|
||||
|
||||
// MARK: - Session pacing (the macOS PyroWave swapID-panic mitigation)
|
||||
|
||||
/// macOS PyroWave sessions under the DEFAULT stage-2 choice must get glass pacing (the
|
||||
/// one-in-flight gate is the "mismatched swapID's" kernel-panic mitigation); an EXPLICIT
|
||||
/// stage-2 pick must stay a faithful arrival-pacing A/B. Elsewhere the default is unchanged.
|
||||
func testPacingDefaultsPyroWaveToGlassOnMacOS() {
|
||||
#if os(macOS)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .glass,
|
||||
"defaulted macOS PyroWave must serialize presents (swapID-panic mitigation)")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: .stage2, codec: .pyrowave), .arrival,
|
||||
"an explicit stage-2 pick must keep arrival pacing (honest A/B)")
|
||||
#else
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .arrival)
|
||||
#endif
|
||||
// Non-PyroWave defaults keep arrival pacing under stage-2 everywhere.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .hevc), .arrival)
|
||||
// Stage-3 means glass regardless of codec or how it was chosen.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -365,9 +365,18 @@ pub fn open_idd_push(
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
sender: FrameChannelSender,
|
||||
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
||||
idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender)
|
||||
idd_push::IddPushCapturer::open(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
keepalive,
|
||||
sender,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, WinCaptureTarget};
|
||||
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::ffi::c_void;
|
||||
@@ -466,6 +466,120 @@ impl HdrP010Converter {
|
||||
}
|
||||
}
|
||||
|
||||
/// PyroWave LUMA pass PS — full-res, writes Y′ to a separate `R8_UNORM` texture. BT.709 limited from
|
||||
/// the 8-bit sRGB (gamma) BGRA slot, BYTE-IDENTICAL to the Linux `rgb2yuv.comp` `lumaY` (so the
|
||||
/// wavelet client — whose golden fixtures come from that shader — decodes the same colours). `Load`
|
||||
/// (texelFetch) reads the exact source texel: RTV pixel (x,y) → source texel (x,y).
|
||||
const PYRO_Y_PS: &str = r"
|
||||
Texture2D<float4> tx : register(t0);
|
||||
float main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||
float3 c = tx.Load(int3(int2(pos.xy), 0)).rgb;
|
||||
return 16.0/255.0 + 0.1826*c.r + 0.6142*c.g + 0.0620*c.b;
|
||||
}
|
||||
";
|
||||
|
||||
/// PyroWave CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to a separate `R8G8_UNORM` texture.
|
||||
/// **2×2 box average** (centre-sited) of the four luma-block RGB texels, then BT.709 limited Cb/Cr —
|
||||
/// BYTE-IDENTICAL to `rgb2yuv.comp` (which averages `(c00+c10+c01+c11)*0.25` then U/V), so the chroma
|
||||
/// siting matches the client's decoder. Even dimensions guarantee the 2×2 block is in-bounds.
|
||||
const PYRO_UV_PS: &str = r"
|
||||
Texture2D<float4> tx : register(t0);
|
||||
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||
int2 p = int2(pos.xy) * 2;
|
||||
float3 c00 = tx.Load(int3(p, 0)).rgb;
|
||||
float3 c10 = tx.Load(int3(p + int2(1,0), 0)).rgb;
|
||||
float3 c01 = tx.Load(int3(p + int2(0,1), 0)).rgb;
|
||||
float3 c11 = tx.Load(int3(p + int2(1,1), 0)).rgb;
|
||||
float3 a = (c00 + c10 + c01 + c11) * 0.25;
|
||||
float u = 128.0/255.0 - 0.1006*a.r - 0.3386*a.g + 0.4392*a.b;
|
||||
float v = 128.0/255.0 + 0.4392*a.r - 0.3989*a.g - 0.0403*a.b;
|
||||
return float2(u, v);
|
||||
}
|
||||
";
|
||||
|
||||
/// scRGB/BGRA → **separate** BT.709-limited YUV planes for the PyroWave wavelet encoder: a full-res
|
||||
/// `R8_UNORM` Y texture + a half-res `R8G8_UNORM` interleaved CbCr texture (design/pyrowave-windows-
|
||||
/// host-zerocopy.md). The wavelet encoder imports the two SEPARATE textures into its own Vulkan
|
||||
/// device — the NVIDIA D3D11→Vulkan import of a single *planar* NV12 texture is unreliable at
|
||||
/// arbitrary sizes (the vendored interop test: "only very specific resource sizes"), whereas simple
|
||||
/// single/two-component textures import reliably. Matches the validated Linux `rgb2yuv.comp` layout
|
||||
/// (R8 Y + RG8 CbCr) + colour math exactly, so the wavelet clients decode identically. The caller
|
||||
/// owns the two textures + their RTVs (shareable, per out-ring slot); this only records the passes.
|
||||
pub(crate) struct BgraToYuvPlanes {
|
||||
vs: ID3D11VertexShader,
|
||||
ps_y: ID3D11PixelShader,
|
||||
ps_uv: ID3D11PixelShader,
|
||||
}
|
||||
|
||||
impl BgraToYuvPlanes {
|
||||
pub(crate) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||
let yb = compile_shader(PYRO_Y_PS, s!("main"), s!("ps_5_0"))?;
|
||||
let uvb = compile_shader(PYRO_UV_PS, s!("main"), s!("ps_5_0"))?;
|
||||
let mut vs = None;
|
||||
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
||||
let mut ps_y = None;
|
||||
device.CreatePixelShader(&yb, None, Some(&mut ps_y))?;
|
||||
let mut ps_uv = None;
|
||||
device.CreatePixelShader(&uvb, None, Some(&mut ps_uv))?;
|
||||
Ok(Self {
|
||||
vs: vs.context("pyro vs")?,
|
||||
ps_y: ps_y.context("pyro y ps")?,
|
||||
ps_uv: ps_uv.context("pyro uv ps")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert `src_srv` (BGRA slot, WxH) → `y_rtv` (a full-res `R8_UNORM` texture) + `cbcr_rtv` (a
|
||||
/// half-res `R8G8_UNORM` texture). Two opaque passes; `w`/`h` are the full luma dims (even).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) unsafe fn convert(
|
||||
&self,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
src_srv: &ID3D11ShaderResourceView,
|
||||
y_rtv: &ID3D11RenderTargetView,
|
||||
cbcr_rtv: &ID3D11RenderTargetView,
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<()> {
|
||||
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
|
||||
ctx.VSSetShader(&self.vs, None);
|
||||
ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())]));
|
||||
ctx.IASetInputLayout(None);
|
||||
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
|
||||
// LUMA pass: full-res → the R8 Y texture.
|
||||
ctx.RSSetViewports(Some(&[D3D11_VIEWPORT {
|
||||
TopLeftX: 0.0,
|
||||
TopLeftY: 0.0,
|
||||
Width: w as f32,
|
||||
Height: h as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
}]));
|
||||
ctx.OMSetRenderTargets(Some(&[Some(y_rtv.clone())]), None);
|
||||
ctx.PSSetShader(&self.ps_y, None);
|
||||
ctx.Draw(3, 0);
|
||||
ctx.OMSetRenderTargets(Some(&[None]), None);
|
||||
|
||||
// CHROMA pass: half-res → the R8G8 CbCr texture.
|
||||
ctx.RSSetViewports(Some(&[D3D11_VIEWPORT {
|
||||
TopLeftX: 0.0,
|
||||
TopLeftY: 0.0,
|
||||
Width: (w / 2) as f32,
|
||||
Height: (h / 2) as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
}]));
|
||||
ctx.OMSetRenderTargets(Some(&[Some(cbcr_rtv.clone())]), None);
|
||||
ctx.PSSetShader(&self.ps_uv, None);
|
||||
ctx.Draw(3, 0);
|
||||
|
||||
ctx.OMSetRenderTargets(Some(&[None]), None);
|
||||
ctx.PSSetShaderResources(0, Some(&[None]));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
||||
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
||||
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
||||
@@ -829,8 +943,7 @@ use windows::Win32::Graphics::Direct3D11::{
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
DXGI_RATIONAL,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_RATIONAL,
|
||||
};
|
||||
|
||||
/// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT
|
||||
@@ -846,12 +959,17 @@ pub(crate) struct VideoConverter {
|
||||
}
|
||||
|
||||
impl VideoConverter {
|
||||
/// A BGRA/FP16-RGB → **NV12 (BT.709 limited SDR)** video-engine converter. `scrgb_input` picks
|
||||
/// the input colour space: `false` = 8-bit sRGB `BGRA` (the SDR ring); `true` = FP16 scRGB
|
||||
/// linear (the HDR ring, used by a PyroWave session that tone-maps the HDR desktop down to the
|
||||
/// 8-bit wavelet stream). The output is always studio-range BT.709 NV12 — the P010/BT.2020 HDR
|
||||
/// path is [`HdrP010Converter`]'s job, never this one.
|
||||
pub(crate) unsafe fn new(
|
||||
device: &ID3D11Device,
|
||||
context: &ID3D11DeviceContext,
|
||||
width: u32,
|
||||
height: u32,
|
||||
hdr: bool,
|
||||
scrgb_input: bool,
|
||||
) -> Result<Self> {
|
||||
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
|
||||
let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?;
|
||||
@@ -876,19 +994,15 @@ impl VideoConverter {
|
||||
.CreateVideoProcessor(&enumr, 0)
|
||||
.context("CreateVideoProcessor")?;
|
||||
|
||||
// Full-range RGB in → studio-range YUV out. HDR: scRGB linear (G10) → BT.2020 PQ (G2084).
|
||||
// SDR: sRGB (G22) → BT.709 (G22).
|
||||
let (in_cs, out_cs) = if hdr {
|
||||
(
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
||||
)
|
||||
// Full-range RGB in → studio-range BT.709 NV12 out. Input gamma follows the ring format:
|
||||
// scRGB linear (G10) for the FP16 HDR ring, sRGB (G22) for the 8-bit BGRA SDR ring. The
|
||||
// output is always BT.709 SDR (the video processor tone-maps the scRGB case).
|
||||
let in_cs = if scrgb_input {
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709
|
||||
} else {
|
||||
(
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
)
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709
|
||||
};
|
||||
let out_cs = DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709;
|
||||
vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs);
|
||||
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
|
||||
// One frame in, one frame out — no interpolation/auto-processing.
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
|
||||
use super::dxgi::{
|
||||
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, PyroFrameShare, VideoConverter,
|
||||
WinCaptureTarget,
|
||||
};
|
||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use pf_driver_proto::{control, frame};
|
||||
@@ -33,13 +36,15 @@ use windows::Win32::Foundation::{
|
||||
HANDLE, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
|
||||
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
|
||||
D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence,
|
||||
ID3D11RenderTargetView, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET,
|
||||
D3D11_BIND_SHADER_RESOURCE, D3D11_FENCE_FLAG_SHARED, D3D11_RESOURCE_MISC_SHARED,
|
||||
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
|
||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
|
||||
@@ -142,6 +147,18 @@ struct HostSlot {
|
||||
srv: ID3D11ShaderResourceView,
|
||||
}
|
||||
|
||||
/// One PyroWave output-ring slot: the two SEPARATE shareable plane textures the wavelet encoder
|
||||
/// imports (design/pyrowave-windows-host-zerocopy.md) plus their RTVs (the [`BgraToYuvPlanes`] CSC
|
||||
/// renders into them). Y is full-res `R8_UNORM`, CbCr is half-res `R8G8_UNORM`; both are
|
||||
/// `SHARED | SHARED_NTHANDLE`. Rotated per frame like `out_ring` so encode N and convert N+1 touch
|
||||
/// different textures.
|
||||
struct PyroOutSlot {
|
||||
y: ID3D11Texture2D,
|
||||
y_rtv: ID3D11RenderTargetView,
|
||||
cbcr: ID3D11Texture2D,
|
||||
cbcr_rtv: ID3D11RenderTargetView,
|
||||
}
|
||||
|
||||
/// RAII guard over an [`IDXGIKeyedMutex`]: [`acquire`](Self::acquire) does `AcquireSync(key, timeout)`,
|
||||
/// `Drop` does `ReleaseSync(key)`. So the lock is released even if the work between acquire and the end
|
||||
/// of the guard's scope `?`-returns or panics — the "leak the keyed-mutex lock → stall the driver on
|
||||
@@ -391,6 +408,29 @@ pub struct IddPushCapturer {
|
||||
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source):
|
||||
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
|
||||
want_444: bool,
|
||||
/// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md). When set the out-ring
|
||||
/// is created **shareable** (`SHARED | SHARED_NTHANDLE`) and a **shared fence** is signalled after
|
||||
/// each convert/copy, so the pyrowave encoder can zero-copy-import the NV12 texture into its own
|
||||
/// Vulkan device and order the read after the D3D11 convert. Also forces the NV12 4:2:0 SDR convert
|
||||
/// (never P010 / BGRA-passthrough) regardless of `display_hdr` / `want_444`.
|
||||
pyrowave: bool,
|
||||
/// PyroWave: the shared D3D11 timeline fence (created lazily on the first frame, `SHARED` flag).
|
||||
/// The capturer `Signal`s it after each frame's GPU convert; the encoder's Vulkan side waits it.
|
||||
pyro_fence: Option<ID3D11Fence>,
|
||||
/// PyroWave: the fence's persistent shared NT handle (raw), passed on EVERY frame. The encoder
|
||||
/// DUPLICATEs + imports it as a Vulkan timeline semaphore whenever it has none (first frame or
|
||||
/// after an encoder rebuild), so this original stays valid across rebuilds.
|
||||
pyro_fence_handle: Option<isize>,
|
||||
/// PyroWave: the monotonically increasing fence value (one `Signal` per emitted frame).
|
||||
pyro_fence_value: u64,
|
||||
/// PyroWave: the separate-plane output ring (Y R8 + CbCr R8G8 shareable textures + RTVs), used
|
||||
/// INSTEAD of `out_ring` for a pyrowave session. Built lazily; rebuilt on a mode change.
|
||||
pyro_ring: Vec<PyroOutSlot>,
|
||||
/// PyroWave: the BGRA→YUV-planes CSC (BT.709 limited, matching `rgb2yuv.comp`). Built lazily.
|
||||
pyro_conv: Option<BgraToYuvPlanes>,
|
||||
/// PyroWave: the last presented (Y, CbCr) textures — the repeat source (analogue of
|
||||
/// `last_present` for the two-plane path).
|
||||
pyro_last: Option<(ID3D11Texture2D, ID3D11Texture2D)>,
|
||||
/// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads
|
||||
/// its snapshot instead of running CCD queries inline on the frame path.
|
||||
desc_poller: DescriptorPoller,
|
||||
@@ -556,18 +596,20 @@ impl IddPushCapturer {
|
||||
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
|
||||
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
|
||||
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
sender: crate::FrameChannelSender,
|
||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||
pf_win_display::display_events::spawn_once();
|
||||
match Self::open_inner(target, preferred, client_10bit, want_444, sender) {
|
||||
match Self::open_inner(target, preferred, client_10bit, want_444, pyrowave, sender) {
|
||||
Ok(mut me) => {
|
||||
me._keepalive = keepalive;
|
||||
Ok(me)
|
||||
@@ -576,11 +618,13 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_inner(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
sender: crate::FrameChannelSender,
|
||||
) -> Result<Self> {
|
||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||
@@ -601,6 +645,7 @@ impl IddPushCapturer {
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
luid,
|
||||
sender.clone(),
|
||||
) {
|
||||
@@ -628,17 +673,27 @@ impl IddPushCapturer {
|
||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||
driver's reported adapter"
|
||||
);
|
||||
Self::open_on(target, preferred, client_10bit, want_444, drv, sender)
|
||||
Self::open_on(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
drv,
|
||||
sender,
|
||||
)
|
||||
.context("IDD-push rebind to the driver's reported render adapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_on(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
luid: LUID,
|
||||
sender: crate::FrameChannelSender,
|
||||
) -> Result<Self> {
|
||||
@@ -691,11 +746,46 @@ impl IddPushCapturer {
|
||||
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||
unsafe {
|
||||
// PyroWave is an 8-bit SDR wavelet codec with no 10-bit path, and the NVIDIA D3D11
|
||||
// VideoProcessor cannot ingest the FP16 HDR ring (CreateVideoProcessorInputView rejects
|
||||
// R16G16B16A16_FLOAT) — so a pyrowave session must run on an SDR (BGRA) composition.
|
||||
// Actively turn advanced color OFF on the virtual display (undoing any leftover HDR state
|
||||
// from a prior session on a reused/lingering monitor) and settle before sizing the ring,
|
||||
// mirroring the enable path's settle so the driver composes BGRA before we size BGRA.
|
||||
if pyrowave {
|
||||
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
||||
let settle = Instant::now();
|
||||
while settle.elapsed() < Duration::from_millis(250) {
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(true)
|
||||
{
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
"IDD push: PyroWave session but advanced color (HDR) could NOT be turned off \
|
||||
on the virtual display — the FP16 ring can't feed the wavelet encoder (a \
|
||||
physical display forcing HDR?); the session will likely fail its first frame"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target = target.target_id,
|
||||
settle_ms = settle.elapsed().as_millis() as u64,
|
||||
"IDD push: PyroWave — advanced color forced OFF (SDR/BGRA composition)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||
let enabled_hdr = client_10bit
|
||||
&& !pyrowave
|
||||
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||
if enabled_hdr {
|
||||
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||
@@ -721,9 +811,11 @@ impl IddPushCapturer {
|
||||
}
|
||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||
let display_hdr = enabled_hdr
|
||||
// PyroWave forced advanced color OFF above, so it is always SDR (never the FP16 ring).
|
||||
let display_hdr = !pyrowave
|
||||
&& (enabled_hdr
|
||||
|| pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(false));
|
||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||
@@ -853,6 +945,13 @@ impl IddPushCapturer {
|
||||
client_10bit,
|
||||
display_hdr,
|
||||
want_444,
|
||||
pyrowave,
|
||||
pyro_fence: None,
|
||||
pyro_fence_handle: None,
|
||||
pyro_fence_value: 0,
|
||||
pyro_ring: Vec::new(),
|
||||
pyro_conv: None,
|
||||
pyro_last: None,
|
||||
desc_poller: DescriptorPoller::spawn(
|
||||
target.target_id,
|
||||
DisplayDescriptor {
|
||||
@@ -1128,6 +1227,13 @@ impl IddPushCapturer {
|
||||
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit
|
||||
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
|
||||
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
||||
// PyroWave is an 8-bit SDR wavelet codec: always NV12 (BT.709 limited), never P010 /
|
||||
// BGRA-passthrough — an HDR desktop is tone-mapped down by the NV12 converter, a 4:4:4
|
||||
// negotiation is moot (pyrowave is 4:2:0). The client strips HDR/10-bit/444 when it selects
|
||||
// PyroWave, so this is the honest match.
|
||||
if self.pyrowave {
|
||||
return (DXGI_FORMAT_NV12, PixelFormat::Nv12);
|
||||
}
|
||||
if self.display_hdr {
|
||||
if self.want_444 {
|
||||
warn_444_hdr_downgrade_once();
|
||||
@@ -1215,6 +1321,8 @@ impl IddPushCapturer {
|
||||
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
||||
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
||||
self.hdr_p010_conv = None;
|
||||
self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode
|
||||
self.pyro_last = None;
|
||||
self.out_idx = 0;
|
||||
self.last_present = None;
|
||||
Ok(())
|
||||
@@ -1228,11 +1336,22 @@ impl IddPushCapturer {
|
||||
/// only when TWO consecutive samples agree on the same new descriptor (~½ s), so a
|
||||
/// single-sample transient during a topology re-probe never costs a ring recreate.
|
||||
fn poll_display_hdr(&mut self) {
|
||||
let (now, seq) = self.desc_poller.snapshot();
|
||||
let (mut now, seq) = self.desc_poller.snapshot();
|
||||
if seq == self.desc_seq {
|
||||
return; // no new sample since last consume
|
||||
}
|
||||
self.desc_seq = seq;
|
||||
// PyroWave forced advanced color OFF at open and never uses the FP16 ring. If a leftover or
|
||||
// late CCD sample reports the display as HDR, re-assert the disable and treat it as SDR — so
|
||||
// we never recreate the ring FP16 (which the wavelet encoder cannot feed).
|
||||
if self.pyrowave && now.hdr {
|
||||
// SAFETY: `set_advanced_color` is `unsafe` (CCD DisplayConfig calls); it takes a plain
|
||||
// `u32` target id + bool, forms no lasting borrow, and returns a bool.
|
||||
unsafe {
|
||||
let _ = pf_win_display::win_display::set_advanced_color(self.target_id, false);
|
||||
}
|
||||
now.hdr = false;
|
||||
}
|
||||
let current = DisplayDescriptor {
|
||||
hdr: self.display_hdr,
|
||||
width: self.width,
|
||||
@@ -1281,7 +1400,8 @@ impl IddPushCapturer {
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
|
||||
// NVENC registers it as encode input — matching the WGC YUV ring.
|
||||
// NVENC registers it as encode input — matching the WGC YUV ring. (PyroWave uses its own
|
||||
// shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.)
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
@@ -1302,6 +1422,73 @@ impl IddPushCapturer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PyroWave: build the separate-plane output ring (`OUT_RING` × {full-res R8 Y, half-res R8G8
|
||||
/// CbCr}, both `SHARED | SHARED_NTHANDLE` + RTV) if not yet built. The wavelet encoder imports the
|
||||
/// two SEPARATE textures (a single planar NV12 import is unreliable on NVIDIA); the
|
||||
/// [`BgraToYuvPlanes`] CSC renders into their RTVs.
|
||||
fn ensure_pyro_ring(&mut self) -> Result<()> {
|
||||
if !self.pyro_ring.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (w, h) = (self.width, self.height);
|
||||
// SAFETY: all D3D11 calls target `self.device`; every `&desc` is a fully-initialized stack
|
||||
// struct and every `Some(&mut _)` a live out-param; `?` rejects a failed HRESULT before use.
|
||||
// The created textures/RTVs belong to `self.device`.
|
||||
unsafe {
|
||||
let make = |dev: &ID3D11Device,
|
||||
fmt: DXGI_FORMAT,
|
||||
w: u32,
|
||||
h: u32|
|
||||
-> Result<(ID3D11Texture2D, ID3D11RenderTargetView)> {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: fmt,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
|
||||
| D3D11_RESOURCE_MISC_SHARED.0) as u32,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
dev.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(pyro plane)")?;
|
||||
let tex = tex.context("null pyro plane texture")?;
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
dev.CreateRenderTargetView(&tex, None, Some(&mut rtv))
|
||||
.context("CreateRenderTargetView(pyro plane)")?;
|
||||
Ok((tex, rtv.context("null pyro plane rtv")?))
|
||||
};
|
||||
for _ in 0..OUT_RING {
|
||||
let (y, y_rtv) = make(&self.device, DXGI_FORMAT_R8_UNORM, w, h)?;
|
||||
let (cbcr, cbcr_rtv) = make(&self.device, DXGI_FORMAT_R8G8_UNORM, w / 2, h / 2)?;
|
||||
self.pyro_ring.push(PyroOutSlot {
|
||||
y,
|
||||
y_rtv,
|
||||
cbcr,
|
||||
cbcr_rtv,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PyroWave: build the BGRA→YUV-planes CSC if not yet built.
|
||||
fn ensure_pyro_conv(&mut self) -> Result<()> {
|
||||
if self.pyro_conv.is_none() {
|
||||
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
|
||||
// failure before it is stored.
|
||||
self.pyro_conv = Some(unsafe { BgraToYuvPlanes::new(&self.device)? });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an
|
||||
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
|
||||
/// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
|
||||
@@ -1327,6 +1514,61 @@ impl IddPushCapturer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PyroWave: after this frame's GPU convert, `Signal` the shared fence and return the fence
|
||||
/// `(handle, value)` for the encoder — the persistent shared handle EVERY frame (the encoder
|
||||
/// imports it whenever it has no timeline yet, e.g. after a mode-switch rebuild) + the
|
||||
/// incrementing value. `None` for a non-PyroWave session. The fence + its shared handle are
|
||||
/// created lazily on the first call. `Flush` submits the queued convert + signal so the encoder's
|
||||
/// cross-API Vulkan timeline wait resolves promptly instead of blocking on a still-unsubmitted
|
||||
/// signal. The caller pairs the returned fence with the frame's CbCr texture into a
|
||||
/// [`PyroFrameShare`].
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs on the owning capture/encode thread that holds the immediate context; forms no lasting
|
||||
/// borrow of `self`'s COM objects.
|
||||
unsafe fn pyro_fence_signal(&mut self) -> Result<Option<(Option<isize>, u64)>> {
|
||||
if !self.pyrowave {
|
||||
return Ok(None);
|
||||
}
|
||||
if self.pyro_fence.is_none() {
|
||||
let dev5: ID3D11Device5 = self
|
||||
.device
|
||||
.cast()
|
||||
.context("ID3D11Device -> ID3D11Device5 (shared fence)")?;
|
||||
// windows-rs returns COM interfaces via an out-param (unlike the HANDLE-returning
|
||||
// CreateSharedHandle below).
|
||||
let mut fence_out: Option<ID3D11Fence> = None;
|
||||
dev5.CreateFence(0, D3D11_FENCE_FLAG_SHARED, &mut fence_out)
|
||||
.context("CreateFence(D3D11_FENCE_FLAG_SHARED)")?;
|
||||
let fence = fence_out.context("null D3D11 fence")?;
|
||||
// GENERIC_ALL (0x1000_0000) — the access the pyrowave interop test hands the handle.
|
||||
let handle: HANDLE = fence
|
||||
.CreateSharedHandle(None, 0x1000_0000, PCWSTR::null())
|
||||
.context("ID3D11Fence::CreateSharedHandle")?;
|
||||
self.pyro_fence = Some(fence);
|
||||
self.pyro_fence_handle = Some(handle.0 as isize);
|
||||
self.pyro_fence_value = 0;
|
||||
}
|
||||
self.pyro_fence_value += 1;
|
||||
let value = self.pyro_fence_value;
|
||||
let ctx4: ID3D11DeviceContext4 = self
|
||||
.context
|
||||
.cast()
|
||||
.context("ID3D11DeviceContext -> ID3D11DeviceContext4 (fence signal)")?;
|
||||
{
|
||||
let fence = self.pyro_fence.as_ref().expect("fence just created");
|
||||
ctx4.Signal(fence, value)
|
||||
.context("ID3D11 fence Signal after convert")?;
|
||||
}
|
||||
// Submit the queued convert + signal so the encoder's Vulkan timeline wait can resolve.
|
||||
self.context.Flush();
|
||||
// Pass the persistent shared handle EVERY frame (not once): the encoder can be rebuilt on a
|
||||
// client mode-switch, and a rebuilt encoder needs to re-import the fence into its fresh Vulkan
|
||||
// device. The encoder imports only when it has no timeline yet (and DUPLICATES the handle so
|
||||
// this original stays valid for the next rebuild).
|
||||
Ok(Some((self.pyro_fence_handle, value)))
|
||||
}
|
||||
|
||||
fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
|
||||
self.log_driver_status_once();
|
||||
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
||||
@@ -1391,13 +1633,34 @@ impl IddPushCapturer {
|
||||
if seq == self.last_seq || slot >= self.slots.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.ensure_out_ring()?;
|
||||
// Build the converter BEFORE acquiring the slot so nothing between Acquire and Release can
|
||||
// `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
||||
self.ensure_converter()?;
|
||||
// Build the ring + converter BEFORE acquiring the slot so nothing between Acquire and Release
|
||||
// can `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
||||
// PyroWave uses its OWN two-plane ring (`pyro_ring`); everything else the single NV12/BGRA ring.
|
||||
let i = self.out_idx;
|
||||
let out = self.out_ring[i].clone();
|
||||
let (out, pyro_slot) = if self.pyrowave {
|
||||
self.ensure_pyro_ring()?;
|
||||
self.ensure_pyro_conv()?;
|
||||
let s = &self.pyro_ring[i];
|
||||
(
|
||||
None,
|
||||
Some((
|
||||
s.y.clone(),
|
||||
s.y_rtv.clone(),
|
||||
s.cbcr.clone(),
|
||||
s.cbcr_rtv.clone(),
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
self.ensure_out_ring()?;
|
||||
self.ensure_converter()?;
|
||||
(Some(self.out_ring[i].clone()), None)
|
||||
};
|
||||
let (_, pf) = self.out_format();
|
||||
let ring_len = if self.pyrowave {
|
||||
self.pyro_ring.len()
|
||||
} else {
|
||||
self.out_ring.len()
|
||||
};
|
||||
|
||||
// Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the
|
||||
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
|
||||
@@ -1414,14 +1677,30 @@ impl IddPushCapturer {
|
||||
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
|
||||
// the slot back to the driver.
|
||||
unsafe {
|
||||
if self.display_hdr {
|
||||
if self.pyrowave {
|
||||
// PyroWave: BGRA slot SRV → separate R8 Y + R8G8 CbCr planes (BT.709 SDR) via the
|
||||
// CSC shader; the shared fence signalled just after (`pyro_fence_signal`) orders
|
||||
// the encoder's cross-device Vulkan read after this convert. (The pyrowave session
|
||||
// forced the display SDR, so the slot is BGRA.)
|
||||
let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot");
|
||||
if let Some(conv) = self.pyro_conv.as_ref() {
|
||||
conv.convert(
|
||||
&self.context,
|
||||
&s.srv,
|
||||
y_rtv,
|
||||
cbcr_rtv,
|
||||
self.width,
|
||||
self.height,
|
||||
)?;
|
||||
}
|
||||
} else if self.display_hdr {
|
||||
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
|
||||
if let Some(conv) = self.hdr_p010_conv.as_ref() {
|
||||
conv.convert(
|
||||
&self.device,
|
||||
&self.context,
|
||||
&s.srv,
|
||||
&out,
|
||||
out.as_ref().expect("out ring"),
|
||||
self.width,
|
||||
self.height,
|
||||
)?;
|
||||
@@ -1430,19 +1709,24 @@ impl IddPushCapturer {
|
||||
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
|
||||
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
|
||||
// copy-engine move; the slot releases back to the driver immediately.
|
||||
self.context.CopyResource(&out, &s.tex);
|
||||
self.context
|
||||
.CopyResource(out.as_ref().expect("out ring"), &s.tex);
|
||||
} else {
|
||||
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
|
||||
if let Some(conv) = self.video_conv.as_ref() {
|
||||
conv.convert(&s.tex, &out)?;
|
||||
conv.convert(&s.tex, out.as_ref().expect("out ring"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `_lock` drops here → `ReleaseSync(0)`.
|
||||
}
|
||||
self.out_idx = (i + 1) % self.out_ring.len();
|
||||
self.out_idx = (i + 1) % ring_len;
|
||||
self.last_seq = seq;
|
||||
self.last_present = Some((out.clone(), pf));
|
||||
if let Some((y, _, cbcr, _)) = pyro_slot.as_ref() {
|
||||
self.pyro_last = Some((y.clone(), cbcr.clone()));
|
||||
} else {
|
||||
self.last_present = Some((out.as_ref().expect("out ring").clone(), pf));
|
||||
}
|
||||
let now = Instant::now();
|
||||
if self.recovering_since.take().is_some() {
|
||||
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
|
||||
@@ -1517,14 +1801,33 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
self.last_fresh = now; // feeds the driver-death watch
|
||||
// Build the frame. For PyroWave the encode input is the Y plane
|
||||
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
|
||||
// after the convert above. SAFETY: on the owning capture/encode thread.
|
||||
let (texture, pyro) = if let Some((y, _, cbcr, _)) = pyro_slot {
|
||||
// SAFETY: on the owning capture/encode thread holding the immediate context.
|
||||
let (fence_handle, fence_value) =
|
||||
unsafe { self.pyro_fence_signal() }?.expect("pyrowave session signals its fence");
|
||||
(
|
||||
y,
|
||||
Some(PyroFrameShare {
|
||||
cbcr,
|
||||
fence_handle,
|
||||
fence_value,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(out.expect("out ring texture"), None)
|
||||
};
|
||||
Ok(Some(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
pts_ns: now_ns(),
|
||||
format: pf,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: out,
|
||||
texture,
|
||||
device: self.device.clone(),
|
||||
pyro,
|
||||
}),
|
||||
cursor: None,
|
||||
}))
|
||||
@@ -1535,8 +1838,46 @@ impl IddPushCapturer {
|
||||
// new driver frame) never re-hands a slot that may still be encoding under pipeline_depth>1 — the
|
||||
// out-ring rotation IS the texture-ownership contract, and repeats must honor it too (audit §5.3).
|
||||
// OUT_RING(3) > the max pipeline_depth(2) guarantees the rotated slot is not in flight.
|
||||
let (src, pf) = self.last_present.clone()?;
|
||||
let i = self.out_idx;
|
||||
// PyroWave: copy the last Y+CbCr into a fresh two-plane slot; texture = Y, CbCr + fence in `pyro`.
|
||||
if self.pyrowave {
|
||||
let (src_y, src_cbcr) = self.pyro_last.clone()?;
|
||||
let slot = self.pyro_ring.get(i)?;
|
||||
let (dst_y, dst_cbcr) = (slot.y.clone(), slot.cbcr.clone());
|
||||
// SAFETY: GPU copies on the owning thread's immediate context; src/dst are our own pyro-ring
|
||||
// plane textures of identical format/size.
|
||||
unsafe {
|
||||
self.context.CopyResource(&dst_y, &src_y);
|
||||
self.context.CopyResource(&dst_cbcr, &src_cbcr);
|
||||
}
|
||||
self.out_idx = (i + 1) % self.pyro_ring.len();
|
||||
self.pyro_last = Some((dst_y.clone(), dst_cbcr.clone()));
|
||||
// Fence the copies above so the encoder reads completed textures. SAFETY: owning thread.
|
||||
let (fence_handle, fence_value) = match unsafe { self.pyro_fence_signal() } {
|
||||
Ok(Some(f)) => f,
|
||||
_ => {
|
||||
tracing::warn!("pyrowave: fence signal failed on a repeat frame — dropping it");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
return Some(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
pts_ns: now_ns(),
|
||||
format: self.out_format().1,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: dst_y,
|
||||
device: self.device.clone(),
|
||||
pyro: Some(PyroFrameShare {
|
||||
cbcr: dst_cbcr,
|
||||
fence_handle,
|
||||
fence_value,
|
||||
}),
|
||||
}),
|
||||
cursor: None,
|
||||
});
|
||||
}
|
||||
let (src, pf) = self.last_present.clone()?;
|
||||
let dst = self.out_ring.get(i)?.clone();
|
||||
// SAFETY: GPU copy on the owning thread's immediate context; src/dst are our out-ring textures of
|
||||
// identical format/size (src is a previous out-ring slot; dst the next).
|
||||
@@ -1553,6 +1894,7 @@ impl IddPushCapturer {
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: dst,
|
||||
device: self.device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
})
|
||||
|
||||
@@ -127,6 +127,7 @@ impl Capturer for SyntheticNv12Capturer {
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: self.default_tex.clone(),
|
||||
device: self.device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
})
|
||||
|
||||
@@ -299,12 +299,23 @@ fn pump(
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
let built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
let mode = connector.mode();
|
||||
// The wavelet bitstream has no VUI: the negotiated Welcome colour signalling IS
|
||||
// the session's colour contract (BT.709 limited SDR today, BT.2020 PQ once the
|
||||
// HDR leg lands), and the chroma the host resolved sizes the plane ring.
|
||||
let color = crate::video::ColorDesc {
|
||||
primaries: connector.color.primaries,
|
||||
transfer: connector.color.transfer,
|
||||
matrix: connector.color.matrix,
|
||||
full_range: connector.color.full_range != 0,
|
||||
};
|
||||
match params.vulkan.as_ref() {
|
||||
Some(vk) => Decoder::new_pyrowave(
|
||||
vk,
|
||||
mode.width,
|
||||
mode.height,
|
||||
connector.shard_payload as usize,
|
||||
connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||
color,
|
||||
),
|
||||
None => Err(anyhow::anyhow!(
|
||||
"pyrowave session without a presenter device"
|
||||
|
||||
@@ -489,6 +489,8 @@ impl Decoder {
|
||||
width: u32,
|
||||
height: u32,
|
||||
shard_payload: usize,
|
||||
chroma444: bool,
|
||||
color: ColorDesc,
|
||||
) -> Result<Decoder> {
|
||||
Ok(Decoder {
|
||||
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
||||
@@ -496,6 +498,8 @@ impl Decoder {
|
||||
width,
|
||||
height,
|
||||
shard_payload,
|
||||
chroma444,
|
||||
color,
|
||||
)?)),
|
||||
codec_id: ffmpeg::codec::Id::HEVC,
|
||||
vaapi_fails: 0,
|
||||
|
||||
@@ -347,12 +347,20 @@ unsafe fn build_ring(
|
||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||
width: u32,
|
||||
height: u32,
|
||||
chroma444: bool,
|
||||
) -> Result<Vec<PlaneSet>> {
|
||||
// 4:2:0 = half-res chroma; 4:4:4 = full-res. The presenter's planar CSC samples with
|
||||
// normalized UVs, so the chroma plane resolution is transparent to it.
|
||||
let (cw, ch) = if chroma444 {
|
||||
(width, height)
|
||||
} else {
|
||||
(width / 2, height / 2)
|
||||
};
|
||||
let mut ring: Vec<PlaneSet> = Vec::with_capacity(RING);
|
||||
for _ in 0..RING {
|
||||
let built = (|| -> Result<PlaneSet> {
|
||||
let (y, ym, yv) = make_plane(device, mem_props, width, height)?;
|
||||
let (cb, cbm, cbv) = match make_plane(device, mem_props, width / 2, height / 2) {
|
||||
let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
device.destroy_image_view(yv, None);
|
||||
@@ -361,7 +369,7 @@ unsafe fn build_ring(
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let (cr, crm, crv) = match make_plane(device, mem_props, width / 2, height / 2) {
|
||||
let (cr, crm, crv) = match make_plane(device, mem_props, cw, ch) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] {
|
||||
@@ -409,6 +417,13 @@ pub struct PyroWaveDecoder {
|
||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Session-fixed negotiated chroma ([`Welcome::chroma_format`]): 4:4:4 = full-res
|
||||
/// chroma planes + `Chroma444` pyrowave decoders (the seq-header bit is
|
||||
/// decoder-enforced upstream, so a mismatch fails loudly, never silently).
|
||||
chroma444: bool,
|
||||
/// Session colour signalling ([`Welcome::color`]): the wavelet bitstream has no VUI,
|
||||
/// so the negotiated `ColorInfo` is the contract the presenter CSC configures from.
|
||||
color: ColorDesc,
|
||||
/// The wire shard payload — the parse-window size for chunk-aligned AUs (§4.4): each
|
||||
/// window holds whole self-delimiting codec packets, zero-padded to the window.
|
||||
wire_window: usize,
|
||||
@@ -424,17 +439,19 @@ impl PyroWaveDecoder {
|
||||
width: u32,
|
||||
height: u32,
|
||||
shard_payload: usize,
|
||||
chroma444: bool,
|
||||
color: ColorDesc,
|
||||
) -> Result<PyroWaveDecoder> {
|
||||
if !vkd.pyrowave_decode {
|
||||
bail!("presenter device lacks the PyroWave compute feature set");
|
||||
}
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
// SAFETY: the handles in `vkd` are the presenter's live instance/device (it
|
||||
// outlives the decoder — same contract the FFmpeg Vulkan backend relies on);
|
||||
// `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime.
|
||||
unsafe { Self::new_inner(vkd, width, height, shard_payload) }
|
||||
unsafe { Self::new_inner(vkd, width, height, shard_payload, chroma444, color) }
|
||||
}
|
||||
|
||||
unsafe fn new_inner(
|
||||
@@ -442,6 +459,8 @@ impl PyroWaveDecoder {
|
||||
width: u32,
|
||||
height: u32,
|
||||
shard_payload: usize,
|
||||
chroma444: bool,
|
||||
color: ColorDesc,
|
||||
) -> Result<PyroWaveDecoder> {
|
||||
let static_fn = ash::StaticFn {
|
||||
get_instance_proc_addr: std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
|
||||
@@ -496,7 +515,11 @@ impl PyroWaveDecoder {
|
||||
device: pw_dev,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
chroma: if chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
// The fragment-iDWT path is for Mali/Adreno-class mobile GPUs only.
|
||||
fragment_path: false,
|
||||
};
|
||||
@@ -513,7 +536,7 @@ impl PyroWaveDecoder {
|
||||
let mem_props = instance.get_physical_device_memory_properties(
|
||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
||||
);
|
||||
let ring = match build_ring(&device, &mem_props, width, height) {
|
||||
let ring = match build_ring(&device, &mem_props, width, height, chroma444) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
pw::pyrowave_decoder_destroy(pw_dec);
|
||||
@@ -557,6 +580,8 @@ impl PyroWaveDecoder {
|
||||
mem_props,
|
||||
width,
|
||||
height,
|
||||
chroma444,
|
||||
color,
|
||||
wire_window: shard_payload.max(64),
|
||||
})
|
||||
}
|
||||
@@ -569,14 +594,19 @@ impl PyroWaveDecoder {
|
||||
/// The old ring is RETIRED, not destroyed: the presenter / frame channel may still
|
||||
/// reference its views (see [`RETIRE_HANDOVERS`]).
|
||||
unsafe fn reconfigure(&mut self, width: u32, height: u32) -> Result<()> {
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
if !self.chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (resize to {width}x{height})");
|
||||
}
|
||||
let dinfo = pw::pyrowave_decoder_create_info {
|
||||
device: self.pw_dev,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
// Chroma is session-fixed (negotiated); a resize never changes it.
|
||||
chroma: if self.chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
fragment_path: false,
|
||||
};
|
||||
let mut new_dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
||||
@@ -584,7 +614,8 @@ impl PyroWaveDecoder {
|
||||
pw::pyrowave_decoder_create(&dinfo, &mut new_dec),
|
||||
"decoder_create (mid-stream resize)",
|
||||
)?;
|
||||
let new_ring = match build_ring(&self.device, &self.mem_props, width, height) {
|
||||
let new_ring =
|
||||
match build_ring(&self.device, &self.mem_props, width, height, self.chroma444) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
pw::pyrowave_decoder_destroy(new_dec);
|
||||
@@ -886,15 +917,10 @@ impl PyroWaveDecoder {
|
||||
],
|
||||
width: w,
|
||||
height: h,
|
||||
// No VUI in the bitstream: BT.709 limited is the fixed contract with the
|
||||
// host's CSC (plan §4.7 CscRows note; sequence-header signaling is a
|
||||
// follow-up once the C API exposes it).
|
||||
color: ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 1,
|
||||
matrix: 1,
|
||||
full_range: false,
|
||||
},
|
||||
// No VUI in the bitstream: the negotiated Welcome `ColorInfo` is the contract
|
||||
// with the host's CSC (BT.709 limited for SDR sessions; BT.2020 PQ once the
|
||||
// HDR leg lands — design/pyrowave-444-hdr.md).
|
||||
color: self.color,
|
||||
keyframe: true,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -53,12 +53,17 @@ ffmpeg-next = { version = "8", optional = true }
|
||||
libloading = "0.8"
|
||||
# Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`.
|
||||
libvpl-sys = { path = "../libvpl-sys", optional = true }
|
||||
# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only under
|
||||
# `pyrowave`. The Windows backend is the NV12 zero-copy D3D11→Vulkan encoder; same crate as Linux.
|
||||
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
# SECURITY_ATTRIBUTES — the PyroWave backend's IDXGIResource1::CreateSharedHandle signature.
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
|
||||
@@ -38,6 +38,9 @@ use std::os::raw::c_char;
|
||||
/// uses. PyroWave carries no VUI, so the colour contract is fixed by this shader: the Phase-2
|
||||
/// client CSC must assume BT.709 limited range.
|
||||
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
|
||||
/// The 4:4:4 twin (`rgb2yuv444.comp`): one invocation per pixel, full-res interleaved CbCr,
|
||||
/// same BT.709-limited coefficients byte-for-byte.
|
||||
const CSC444_SPV: &[u8] = include_bytes!("rgb2yuv444.spv");
|
||||
/// Fixed cursor-overlay texture size (px) — mirrors `vulkan_video.rs`; the shared CSC shader bounds
|
||||
/// sampling by its push constant, so one allocation fits every pointer bitmap.
|
||||
const CURSOR_MAX: u32 = 256;
|
||||
@@ -46,13 +49,6 @@ const IMPORT_CACHE_CAP: usize = 16;
|
||||
/// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta;
|
||||
/// the rate controller itself never exceeds the budget).
|
||||
const BS_SLACK: usize = 256 * 1024;
|
||||
/// Chunked-mode window framing (§4.4): 4-byte prefix per shard-sized window.
|
||||
const WINDOW_PREFIX: usize = 4;
|
||||
/// Window kinds: whole packets / an oversized packet's fragments.
|
||||
const WIN_PACKED: u16 = 0;
|
||||
const WIN_FRAG_FIRST: u16 = 1;
|
||||
const WIN_FRAG_CONT: u16 = 2;
|
||||
const WIN_FRAG_LAST: u16 = 3;
|
||||
|
||||
/// The DRM modifiers the PyroWave device can import as a SAMPLED image of the capture's
|
||||
/// packed-RGB format. The capture advertises these for the pyrowave passthrough instead of
|
||||
@@ -197,6 +193,9 @@ pub struct PyroWaveEncoder {
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
/// Session-fixed negotiated chroma: 4:4:4 = full-res RG8 chroma plane + per-pixel CSC
|
||||
/// (`rgb2yuv444.comp`) + `Chroma444` pyrowave objects.
|
||||
chroma444: bool,
|
||||
/// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`.
|
||||
frame_budget: usize,
|
||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary and pad every codec
|
||||
@@ -218,17 +217,31 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize {
|
||||
}
|
||||
|
||||
impl PyroWaveEncoder {
|
||||
pub fn open(width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result<Self> {
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
pub fn open(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
chroma: crate::ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
// SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it
|
||||
// establishes itself (valid instance/device, correctly-chained create-infos that
|
||||
// `DeviceHold` keeps alive); all handles are freshly created and owned by the result.
|
||||
unsafe { Self::open_inner(width, height, fps.max(1), bitrate_bps.max(1_000_000)) }
|
||||
unsafe {
|
||||
Self::open_inner(
|
||||
width,
|
||||
height,
|
||||
fps.max(1),
|
||||
bitrate_bps.max(1_000_000),
|
||||
chroma.is_444(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn open_inner(w: u32, h: u32, fps: u32, bitrate: u64) -> Result<Self> {
|
||||
unsafe fn open_inner(w: u32, h: u32, fps: u32, bitrate: u64, chroma444: bool) -> Result<Self> {
|
||||
let entry = ash::Entry::load().context("load vulkan loader")?;
|
||||
|
||||
let mut hold = DeviceHold {
|
||||
@@ -381,7 +394,11 @@ impl PyroWaveEncoder {
|
||||
device: pw_dev,
|
||||
width: w as i32,
|
||||
height: h as i32,
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
chroma: if chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
};
|
||||
let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||
if let Err(e) = pw_check(
|
||||
@@ -392,8 +409,10 @@ impl PyroWaveEncoder {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// ---- CSC planes: full-res R8 luma + half-res RG8 chroma, storage-written by the CSC
|
||||
// and sampled directly by pyrowave (R/G view swizzles synthesize Cb/Cr) ----
|
||||
// ---- CSC planes: full-res R8 luma + RG8 chroma (half-res for 4:2:0, full-res for
|
||||
// 4:4:4), storage-written by the CSC and sampled directly by pyrowave (R/G view
|
||||
// swizzles synthesize Cb/Cr) ----
|
||||
let (cw, ch) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
|
||||
let (y_img, y_mem, y_view) = make_plain_image(
|
||||
&device,
|
||||
&mem_props,
|
||||
@@ -406,8 +425,8 @@ impl PyroWaveEncoder {
|
||||
&device,
|
||||
&mem_props,
|
||||
vk::Format::R8G8_UNORM,
|
||||
w / 2,
|
||||
h / 2,
|
||||
cw,
|
||||
ch,
|
||||
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::SAMPLED,
|
||||
)?;
|
||||
|
||||
@@ -420,7 +439,11 @@ impl PyroWaveEncoder {
|
||||
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE),
|
||||
None,
|
||||
)?;
|
||||
let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?;
|
||||
let spv = ash::util::read_spv(&mut std::io::Cursor::new(if chroma444 {
|
||||
CSC444_SPV
|
||||
} else {
|
||||
CSC_SPV
|
||||
}))?;
|
||||
let shader =
|
||||
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
|
||||
let sb = |b: u32, t: vk::DescriptorType| {
|
||||
@@ -566,7 +589,8 @@ impl PyroWaveEncoder {
|
||||
gpu = %props.device_name_as_c_str().unwrap_or(c"?").to_string_lossy(),
|
||||
mode = %format!("{w}x{h}@{fps}"),
|
||||
budget_kib = frame_budget / 1024,
|
||||
"PyroWave encoder open (intra-only wavelet, BT.709 limited 4:2:0)"
|
||||
chroma = if chroma444 { "4:4:4" } else { "4:2:0" },
|
||||
"PyroWave encoder open (intra-only wavelet, BT.709 limited)"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
@@ -608,6 +632,7 @@ impl PyroWaveEncoder {
|
||||
width: w,
|
||||
height: h,
|
||||
fps,
|
||||
chroma444,
|
||||
frame_budget,
|
||||
wire_chunk: None,
|
||||
bitstream: Vec::new(),
|
||||
@@ -971,7 +996,12 @@ impl PyroWaveEncoder {
|
||||
0,
|
||||
&pc_bytes,
|
||||
);
|
||||
// 4:2:0: one invocation per 2x2 luma block (per chroma sample); 4:4:4: per pixel.
|
||||
if self.chroma444 {
|
||||
dev.cmd_dispatch(self.cmd, w.div_ceil(8), h.div_ceil(8), 1);
|
||||
} else {
|
||||
dev.cmd_dispatch(self.cmd, (w / 2).div_ceil(8), (h / 2).div_ceil(8), 1);
|
||||
}
|
||||
|
||||
// CSC storage writes -> pyrowave's sampled reads (images stay GENERAL — the layout
|
||||
// pyrowave's GPU-buffer contract accepts without transitions).
|
||||
@@ -1024,17 +1054,19 @@ impl PyroWaveEncoder {
|
||||
),
|
||||
// Two-component chroma image: view swizzles R/G synthesize the Cb/Cr planes
|
||||
// (the documented NV12-style hand-off, pyrowave.h `pyrowave_gpu_buffers`).
|
||||
// The view extent is the chroma IMAGE's own mip0 extent (it's a separate
|
||||
// image, not a planar aspect): half-res for 4:2:0, full-res for 4:4:4.
|
||||
plane(
|
||||
self.uv_img,
|
||||
w / 2,
|
||||
h / 2,
|
||||
if self.chroma444 { w } else { w / 2 },
|
||||
if self.chroma444 { h } else { h / 2 },
|
||||
rg8,
|
||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
|
||||
),
|
||||
plane(
|
||||
self.uv_img,
|
||||
w / 2,
|
||||
h / 2,
|
||||
if self.chroma444 { w } else { w / 2 },
|
||||
if self.chroma444 { h } else { h / 2 },
|
||||
rg8,
|
||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G,
|
||||
),
|
||||
@@ -1077,8 +1109,8 @@ impl PyroWaveEncoder {
|
||||
// boundary by design.
|
||||
let cap = self.frame_budget + BS_SLACK;
|
||||
self.bitstream.resize(cap, 0);
|
||||
// Chunked mode reserves 4 bytes per window for the framing prefix.
|
||||
let boundary = self.wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(cap);
|
||||
// Chunked mode reserves the 4-byte window prefix from the packetize boundary (shared helper).
|
||||
let boundary = crate::pyrowave_wire::packet_boundary(self.wire_chunk, cap);
|
||||
let mut n: usize = 0;
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n),
|
||||
@@ -1101,67 +1133,15 @@ impl PyroWaveEncoder {
|
||||
"packetize",
|
||||
)?;
|
||||
packets.truncate(out_n.max(1));
|
||||
let au = if let Some(chunk) = self.wire_chunk {
|
||||
// Window framing (§4.4): each `chunk`-sized window opens with a 4-byte prefix
|
||||
// (u16 used-length + u16 kind) and carries either WHOLE self-delimiting codec
|
||||
// packets (PACKED — several small ones share a window) or one fragment of an
|
||||
// oversized packet (FRAG chain — pyrowave 32×32 blocks are atomic and may
|
||||
// exceed a shard). A lost shard zeroes its window (used = 0) — the receiver
|
||||
// skips it and drops any fragment chain it interrupts.
|
||||
let payload_max = chunk - WINDOW_PREFIX;
|
||||
let mut au: Vec<u8> = Vec::with_capacity((packets.len() + 1) * chunk);
|
||||
// The currently-open PACKED window: (start offset of its prefix, bytes used).
|
||||
let mut open: Option<(usize, usize)> = None;
|
||||
let close = |au: &mut Vec<u8>, open: &mut Option<(usize, usize)>, chunk: usize| {
|
||||
if let Some((start, used)) = open.take() {
|
||||
au[start..start + 2].copy_from_slice(&(used as u16).to_le_bytes());
|
||||
au[start + 2..start + 4].copy_from_slice(&WIN_PACKED.to_le_bytes());
|
||||
au.resize(start + chunk, 0);
|
||||
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
|
||||
// emits BT.709 LIMITED — patch the bit HONEST so VUI-honoring clients don't wash out blacks.
|
||||
if let Some(p) = packets.first() {
|
||||
crate::pyrowave_wire::mark_limited_range(&mut self.bitstream, p.offset);
|
||||
}
|
||||
};
|
||||
for p in &packets {
|
||||
let bytes = &self.bitstream[p.offset..p.offset + p.size];
|
||||
if p.size <= payload_max {
|
||||
let fits = open.is_some_and(|(_, used)| used + p.size <= payload_max);
|
||||
if !fits {
|
||||
close(&mut au, &mut open, chunk);
|
||||
let start = au.len();
|
||||
au.resize(start + WINDOW_PREFIX, 0);
|
||||
open = Some((start, 0));
|
||||
}
|
||||
au.extend_from_slice(bytes);
|
||||
if let Some((_, used)) = open.as_mut() {
|
||||
*used += p.size;
|
||||
}
|
||||
} else {
|
||||
// Oversized packet: its own FRAG chain of full windows.
|
||||
close(&mut au, &mut open, chunk);
|
||||
let mut off = 0usize;
|
||||
while off < p.size {
|
||||
let take = (p.size - off).min(payload_max);
|
||||
let kind = if off == 0 {
|
||||
WIN_FRAG_FIRST
|
||||
} else if off + take == p.size {
|
||||
WIN_FRAG_LAST
|
||||
} else {
|
||||
WIN_FRAG_CONT
|
||||
};
|
||||
let start = au.len();
|
||||
au.resize(start + WINDOW_PREFIX, 0);
|
||||
au[start..start + 2].copy_from_slice(&(take as u16).to_le_bytes());
|
||||
au[start + 2..start + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
au.extend_from_slice(&bytes[off..off + take]);
|
||||
au.resize(start + chunk, 0);
|
||||
off += take;
|
||||
}
|
||||
}
|
||||
}
|
||||
close(&mut au, &mut open, chunk);
|
||||
au
|
||||
} else {
|
||||
let p = &packets[0];
|
||||
self.bitstream[p.offset..p.offset + p.size].to_vec()
|
||||
};
|
||||
// Frame into the wire AU via the shared helper (byte-identical on Linux + Windows): the dense
|
||||
// single packet, or the datagram-aligned windowed AU (§4.4).
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
self.frame_count += 1;
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
@@ -1205,7 +1185,11 @@ impl Encoder for PyroWaveEncoder {
|
||||
device: self.pw_dev,
|
||||
width: self.width as i32,
|
||||
height: self.height as i32,
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
chroma: if self.chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
};
|
||||
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||
@@ -1327,10 +1311,16 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Decode an AU with a standalone pyrowave decoder and return the full YUV420P planes.
|
||||
/// This is the golden oracle for both the Phase-1 smoke check (plane means) and the Apple
|
||||
/// Metal port's committed PSNR fixtures (`pyrowave_dump_golden`).
|
||||
unsafe fn decode_planes(w: u32, h: u32, au: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
/// Decode an AU with a standalone pyrowave decoder and return the full planar YUV
|
||||
/// (half-res chroma for 4:2:0, full-res for 4:4:4). This is the golden oracle for the
|
||||
/// smoke checks (plane means) and the Apple Metal port's committed PSNR fixtures
|
||||
/// (`pyrowave_dump_golden`).
|
||||
unsafe fn decode_planes_chroma(
|
||||
w: u32,
|
||||
h: u32,
|
||||
au: &[u8],
|
||||
chroma444: bool,
|
||||
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let mut dev: pw::pyrowave_device = std::ptr::null_mut();
|
||||
assert_eq!(
|
||||
pw::pyrowave_create_default_device(&mut dev),
|
||||
@@ -1340,7 +1330,11 @@ mod tests {
|
||||
device: dev,
|
||||
width: w as i32,
|
||||
height: h as i32,
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
chroma: if chroma444 {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||
} else {
|
||||
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||
},
|
||||
fragment_path: false,
|
||||
};
|
||||
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
||||
@@ -1354,11 +1348,16 @@ mod tests {
|
||||
);
|
||||
assert!(pw::pyrowave_decoder_decode_is_ready(dec, false));
|
||||
|
||||
let (cw, ch) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
|
||||
let mut y = vec![0u8; (w * h) as usize];
|
||||
let mut cb = vec![0u8; (w * h / 4) as usize];
|
||||
let mut cr = vec![0u8; (w * h / 4) as usize];
|
||||
let mut cb = vec![0u8; (cw * ch) as usize];
|
||||
let mut cr = vec![0u8; (cw * ch) as usize];
|
||||
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
|
||||
buf.format = pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P;
|
||||
buf.format = if chroma444 {
|
||||
pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV444P
|
||||
} else {
|
||||
pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P
|
||||
};
|
||||
buf.width = w as i32;
|
||||
buf.height = h as i32;
|
||||
buf.data = [
|
||||
@@ -1366,7 +1365,7 @@ mod tests {
|
||||
cb.as_mut_ptr() as *mut _,
|
||||
cr.as_mut_ptr() as *mut _,
|
||||
];
|
||||
buf.row_stride_in_bytes = [w as usize, (w / 2) as usize, (w / 2) as usize];
|
||||
buf.row_stride_in_bytes = [w as usize, cw as usize, cw as usize];
|
||||
buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
|
||||
assert_eq!(
|
||||
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
|
||||
@@ -1377,10 +1376,15 @@ mod tests {
|
||||
(y, cb, cr)
|
||||
}
|
||||
|
||||
/// Plane means of an upstream-decoded AU — the Phase-1 smoke assertion.
|
||||
unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8]) -> (f64, f64, f64) {
|
||||
unsafe fn decode_planes(w: u32, h: u32, au: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
// SAFETY: forwarded — same contract as the caller.
|
||||
let (y, cb, cr) = unsafe { decode_planes(w, h, au) };
|
||||
unsafe { decode_planes_chroma(w, h, au, false) }
|
||||
}
|
||||
|
||||
/// Plane means of an upstream-decoded AU — the smoke assertion.
|
||||
unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8], chroma444: bool) -> (f64, f64, f64) {
|
||||
// SAFETY: forwarded — same contract as the caller.
|
||||
let (y, cb, cr) = unsafe { decode_planes_chroma(w, h, au, chroma444) };
|
||||
let mean = |v: &[u8]| v.iter().map(|&x| x as f64).sum::<f64>() / v.len() as f64;
|
||||
(mean(&y), mean(&cb), mean(&cr))
|
||||
}
|
||||
@@ -1394,7 +1398,8 @@ mod tests {
|
||||
#[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"]
|
||||
fn pyrowave_smoke() {
|
||||
let (w, h) = (256u32, 256u32);
|
||||
let mut enc = PyroWaveEncoder::open(w, h, 60, 40_000_000).expect("open");
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, 40_000_000, crate::ChromaFormat::Yuv420).expect("open");
|
||||
assert!(!enc.caps().supports_rfi);
|
||||
|
||||
let colors = [
|
||||
@@ -1414,7 +1419,7 @@ mod tests {
|
||||
"AU exceeds rate budget"
|
||||
);
|
||||
// SAFETY: test-only FFI into the vendored decoder with locally-owned buffers.
|
||||
let (ym, cbm, crm) = unsafe { decode_plane_means(w, h, &au.data) };
|
||||
let (ym, cbm, crm) = unsafe { decode_plane_means(w, h, &au.data, false) };
|
||||
let (ye, cbe, cre) = bt709(*c);
|
||||
assert!(
|
||||
(ym - ye).abs() < 3.0 && (cbm - cbe).abs() < 3.0 && (crm - cre).abs() < 3.0,
|
||||
@@ -1508,6 +1513,68 @@ mod tests {
|
||||
assert!(enc.poll().expect("poll").is_some());
|
||||
}
|
||||
|
||||
/// The 4:4:4 twin of `pyrowave_smoke`: per-pixel CSC into full-res RG8 chroma +
|
||||
/// `Chroma444` pyrowave objects, verified by upstream's own 4:4:4 CPU decode. The
|
||||
/// busy-card leg then drives the rate controller at the ~2.6 bpp operating point —
|
||||
/// exactly the regime that overran upstream's 4:2:0-sized payload staging before
|
||||
/// `patches/0001-payload-data-444-sizing.patch` (the Phase-0 finding): it must stay
|
||||
/// within budget, decode, and be run-to-run deterministic (the overrun was not).
|
||||
#[test]
|
||||
#[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"]
|
||||
fn pyrowave_smoke_444() {
|
||||
let (w, h) = (256u32, 256u32);
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, 40_000_000, crate::ChromaFormat::Yuv444).expect("open");
|
||||
let colors = [
|
||||
[40u8, 40, 200, 255],
|
||||
[40, 200, 40, 255],
|
||||
[200, 40, 40, 255],
|
||||
[128, 128, 128, 255],
|
||||
];
|
||||
for (i, c) in colors.iter().enumerate() {
|
||||
enc.submit(&cpu_frame(w, h, i as u64 * 16_666_667, *c))
|
||||
.expect("submit");
|
||||
let au = enc.poll().expect("poll").expect("one AU per frame");
|
||||
assert!(au.keyframe);
|
||||
assert!(
|
||||
au.data.len() <= enc.frame_budget + BS_SLACK,
|
||||
"AU exceeds rate budget"
|
||||
);
|
||||
// SAFETY: test-only FFI into the vendored decoder with locally-owned buffers.
|
||||
let (ym, cbm, crm) = unsafe { decode_plane_means(w, h, &au.data, true) };
|
||||
let (ye, cbe, cre) = bt709(*c);
|
||||
assert!(
|
||||
(ym - ye).abs() < 3.0 && (cbm - cbe).abs() < 3.0 && (crm - cre).abs() < 3.0,
|
||||
"frame {i}: decoded plane means (Y {ym:.1}, Cb {cbm:.1}, Cr {crm:.1}) vs \
|
||||
expected (Y {ye:.1}, Cb {cbe:.1}, Cr {cre:.1})"
|
||||
);
|
||||
}
|
||||
|
||||
// Busy content at the 4:4:4 operating point (~2.6 bpp).
|
||||
let budget_bps = w as u64 * h as u64 * 60 * 26 / 10;
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, budget_bps, crate::ChromaFormat::Yuv444).expect("open");
|
||||
let mut sizes = Vec::new();
|
||||
for _ in 0..3 {
|
||||
enc.submit(&test_card(w, h, 7)).expect("busy submit");
|
||||
let au = enc.poll().expect("poll").expect("busy AU");
|
||||
assert!(
|
||||
au.data.len() <= enc.frame_budget + BS_SLACK,
|
||||
"busy 4:4:4 AU exceeds rate budget ({} > {})",
|
||||
au.data.len(),
|
||||
enc.frame_budget + BS_SLACK
|
||||
);
|
||||
// Upstream's own decoder accepts it (a corrupt stream errors or garbles).
|
||||
// SAFETY: test-only FFI with locally-owned buffers.
|
||||
let _ = unsafe { decode_planes_chroma(w, h, &au.data, true) };
|
||||
sizes.push(au.data.len());
|
||||
}
|
||||
assert!(
|
||||
sizes.windows(2).all(|s| s[0] == s[1]),
|
||||
"identical input produced varying AU sizes (the Phase-0 overrun signature): {sizes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A deterministic busy BGRA test card (gradients + checker + LCG noise) — flat fills
|
||||
/// exercise almost none of the entropy decoder, this hits every subband.
|
||||
fn test_card(w: u32, h: u32, seed: u32) -> CapturedFrame {
|
||||
@@ -1558,7 +1625,8 @@ mod tests {
|
||||
// Odd-block geometry on purpose: 256 aligns clean, 144 → aligned 160 exercises the
|
||||
// block-grid overhang. ~1.6 bpp at 60 fps.
|
||||
let (w, h) = (256u32, 144u32);
|
||||
let mut enc = PyroWaveEncoder::open(w, h, 60, 4_000_000).expect("open");
|
||||
let mut enc =
|
||||
PyroWaveEncoder::open(w, h, 60, 4_000_000, crate::ChromaFormat::Yuv420).expect("open");
|
||||
|
||||
let dump = |name: &str, bytes: &[u8]| {
|
||||
std::fs::write(dir.join(name), bytes).expect("write fixture");
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#version 450
|
||||
// RGB(A) -> full-res Y + FULL-res interleaved CbCr (BT.709 limited range): the 4:4:4 twin of
|
||||
// rgb2yuv.comp — one invocation per pixel, no chroma box filter, no siting. Same coefficients
|
||||
// byte-for-byte (the wavelet clients' planar CSC decodes both layouts identically), same
|
||||
// cursor-as-metadata blend, same source-edge clamp for the 32-aligned coded extent.
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
layout(binding = 0) uniform sampler2D rgb; // packed RGB input (sampled; BGRA import ok)
|
||||
layout(binding = 1, r8) uniform writeonly image2D yImg; // full-res Y
|
||||
layout(binding = 2, rg8) uniform writeonly image2D uvImg; // full-res UV (interleaved)
|
||||
layout(binding = 3) uniform sampler2D cursorTex; // straight-alpha RGBA cursor (top-left)
|
||||
|
||||
layout(push_constant) uniform Push {
|
||||
ivec2 curOrigin; // top-left of the cursor in frame pixels (position - hotspot)
|
||||
ivec2 curSize; // cursor w,h in pixels; x <= 0 => disabled
|
||||
} pc;
|
||||
|
||||
float lumaY(vec3 c) { return 16.0/255.0 + 0.1826*c.r + 0.6142*c.g + 0.0620*c.b; }
|
||||
|
||||
vec3 withCursor(ivec2 p, vec3 col) {
|
||||
if (pc.curSize.x <= 0) return col;
|
||||
ivec2 cp = p - pc.curOrigin;
|
||||
if (cp.x < 0 || cp.y < 0 || cp.x >= pc.curSize.x || cp.y >= pc.curSize.y) return col;
|
||||
vec4 c = texelFetch(cursorTex, cp, 0);
|
||||
return mix(col, c.rgb, c.a);
|
||||
}
|
||||
|
||||
void main() {
|
||||
ivec2 sz = imageSize(yImg);
|
||||
ivec2 rmax = textureSize(rgb, 0) - 1;
|
||||
ivec2 p = ivec2(gl_GlobalInvocationID.xy);
|
||||
if (p.x >= sz.x || p.y >= sz.y) return;
|
||||
vec3 c = withCursor(p, texelFetch(rgb, min(p, rmax), 0).rgb);
|
||||
imageStore(yImg, p, vec4(lumaY(c), 0, 0, 1));
|
||||
float U = 128.0/255.0 - 0.1006*c.r - 0.3386*c.g + 0.4392*c.b;
|
||||
float V = 128.0/255.0 + 0.4392*c.r - 0.3989*c.g - 0.0403*c.b;
|
||||
imageStore(uvImg, p, vec4(U, V, 0, 1));
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,197 @@
|
||||
//! Shared PyroWave AU wire-framing (design/pyrowave-codec-plan.md §4.4) — the single source of
|
||||
//! truth for the on-wire access-unit shape, used by BOTH the Linux (dmabuf/CSC) and Windows (NV12
|
||||
//! zero-copy) host encoders. It turns pyrowave's packetized bitstream into either the **dense**
|
||||
//! single-packet AU or the **datagram-aligned** windowed AU. Pure (no GPU/FFI) so it is unit-tested
|
||||
//! on any platform and both encoders emit byte-identical framing — the clients parse this exact
|
||||
//! layout, so it must stay in ONE place.
|
||||
//!
|
||||
//! Datagram-aligned AU: each `chunk`-sized window opens with a 4-byte prefix (`u16` used-length +
|
||||
//! `u16` kind) and carries either WHOLE self-delimiting codec packets (`WIN_PACKED` — several small
|
||||
//! ones share a window) or one fragment of an oversized ATOMIC packet (a `FRAG` chain — pyrowave's
|
||||
//! 32×32 blocks are atomic and can exceed a shard). A lost shard zeroes its window (`used = 0`) so
|
||||
//! the receiver skips it and drops any fragment chain it interrupts. Padding after `used` is zeroed.
|
||||
|
||||
/// The 4-byte per-window framing prefix (`u16` used-length + `u16` kind).
|
||||
pub(crate) const WINDOW_PREFIX: usize = 4;
|
||||
/// Window kinds: whole packets / an oversized packet's fragments.
|
||||
const WIN_PACKED: u16 = 0;
|
||||
const WIN_FRAG_FIRST: u16 = 1;
|
||||
const WIN_FRAG_CONT: u16 = 2;
|
||||
const WIN_FRAG_LAST: u16 = 3;
|
||||
|
||||
/// The packetize boundary to request from pyrowave: for a `wire_chunk` shard it is the shard payload
|
||||
/// minus the 4-byte window prefix (so a whole codec packet + its prefix fits one shard); for the
|
||||
/// dense case it is the whole-bitstream cap (one packet per AU).
|
||||
pub(crate) fn packet_boundary(wire_chunk: Option<usize>, dense_cap: usize) -> usize {
|
||||
wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(dense_cap)
|
||||
}
|
||||
|
||||
/// Patch the frame's `BitstreamSequenceHeader` to signal `ycbcr_range = LIMITED`. pyrowave's C API
|
||||
/// fills the header with `= {}` (all VUI fields zeroed) and offers NO way to set colour/range, so it
|
||||
/// signals `ycbcr_range = 0 = YCBCR_RANGE_FULL` — but BOTH host CSCs (`rgb2yuv.comp` on Linux, the
|
||||
/// D3D11 `BgraToYuvPlanes` on Windows) always emit BT.709 **LIMITED** Y′CbCr (black = Y′16). A client
|
||||
/// that honours the VUI (the Apple wavelet decoder reads `(word1 >> 30) & 1`) then skips the
|
||||
/// limited→full expansion and shows washed-out, raised blacks. Patching the bit makes the bitstream
|
||||
/// HONEST for every client — clients that hardcode limited (the Vulkan `video_pyrowave` path) are
|
||||
/// unaffected, and pyrowave's own decode ignores the flag (it reconstructs raw Y′CbCr). The other
|
||||
/// zeroed VUI fields (BT.709 primaries / transform / transfer) are already correct.
|
||||
///
|
||||
/// `seq_offset` is the byte offset of the frame's 8-byte `BitstreamSequenceHeader` in `bitstream` —
|
||||
/// the SOF packet's offset. `ycbcr_range` is bit 30 of the little-endian second word, i.e. bit 6 of
|
||||
/// byte `seq_offset + 7` (`0x40`).
|
||||
pub(crate) fn mark_limited_range(bitstream: &mut [u8], seq_offset: usize) {
|
||||
if let Some(b) = bitstream.get_mut(seq_offset + 7) {
|
||||
*b |= 0x40;
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
|
||||
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
|
||||
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
|
||||
pub(crate) fn build_au(
|
||||
packets: &[(usize, usize)],
|
||||
bitstream: &[u8],
|
||||
wire_chunk: Option<usize>,
|
||||
) -> Vec<u8> {
|
||||
let Some(chunk) = wire_chunk else {
|
||||
// Dense (default): boundary == whole buffer → the AU is exactly one pyrowave packet.
|
||||
let (off, size) = packets[0];
|
||||
return bitstream[off..off + size].to_vec();
|
||||
};
|
||||
let payload_max = chunk - WINDOW_PREFIX;
|
||||
let mut au: Vec<u8> = Vec::with_capacity((packets.len() + 1) * chunk);
|
||||
// The currently-open PACKED window: (start offset of its prefix, bytes used).
|
||||
let mut open: Option<(usize, usize)> = None;
|
||||
let close = |au: &mut Vec<u8>, open: &mut Option<(usize, usize)>, chunk: usize| {
|
||||
if let Some((start, used)) = open.take() {
|
||||
au[start..start + 2].copy_from_slice(&(used as u16).to_le_bytes());
|
||||
au[start + 2..start + 4].copy_from_slice(&WIN_PACKED.to_le_bytes());
|
||||
au.resize(start + chunk, 0);
|
||||
}
|
||||
};
|
||||
for &(off, size) in packets {
|
||||
let bytes = &bitstream[off..off + size];
|
||||
if size <= payload_max {
|
||||
let fits = open.is_some_and(|(_, used)| used + size <= payload_max);
|
||||
if !fits {
|
||||
close(&mut au, &mut open, chunk);
|
||||
let start = au.len();
|
||||
au.resize(start + WINDOW_PREFIX, 0);
|
||||
open = Some((start, 0));
|
||||
}
|
||||
au.extend_from_slice(bytes);
|
||||
if let Some((_, used)) = open.as_mut() {
|
||||
*used += size;
|
||||
}
|
||||
} else {
|
||||
// Oversized packet: its own FRAG chain of full windows.
|
||||
close(&mut au, &mut open, chunk);
|
||||
let mut o = 0usize;
|
||||
while o < size {
|
||||
let take = (size - o).min(payload_max);
|
||||
let kind = if o == 0 {
|
||||
WIN_FRAG_FIRST
|
||||
} else if o + take == size {
|
||||
WIN_FRAG_LAST
|
||||
} else {
|
||||
WIN_FRAG_CONT
|
||||
};
|
||||
let start = au.len();
|
||||
au.resize(start + WINDOW_PREFIX, 0);
|
||||
au[start..start + 2].copy_from_slice(&(take as u16).to_le_bytes());
|
||||
au[start + 2..start + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
au.extend_from_slice(&bytes[o..o + take]);
|
||||
au.resize(start + chunk, 0);
|
||||
o += take;
|
||||
}
|
||||
}
|
||||
}
|
||||
close(&mut au, &mut open, chunk);
|
||||
au
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Walk a windowed AU back into the flat codec-packet stream (the client's parse), asserting the
|
||||
/// framing invariants the encoder promises: whole windows, in-bounds `used`, zeroed padding.
|
||||
fn walk(au: &[u8], chunk: usize) -> Vec<u8> {
|
||||
assert_eq!(au.len() % chunk, 0, "AU is a whole number of windows");
|
||||
let mut out = Vec::new();
|
||||
let mut frag: Vec<u8> = Vec::new();
|
||||
for win in au.chunks(chunk) {
|
||||
let used = u16::from_le_bytes([win[0], win[1]]) as usize;
|
||||
let kind = u16::from_le_bytes([win[2], win[3]]);
|
||||
assert!(WINDOW_PREFIX + used <= win.len(), "window overrun");
|
||||
assert!(
|
||||
win[WINDOW_PREFIX + used..].iter().all(|&b| b == 0),
|
||||
"non-zero padding after used"
|
||||
);
|
||||
let body = &win[WINDOW_PREFIX..WINDOW_PREFIX + used];
|
||||
match kind {
|
||||
0 => out.extend_from_slice(body),
|
||||
1 => frag = body.to_vec(),
|
||||
2 => frag.extend_from_slice(body),
|
||||
3 => {
|
||||
frag.extend_from_slice(body);
|
||||
out.extend_from_slice(&frag);
|
||||
frag.clear();
|
||||
}
|
||||
k => panic!("unknown window kind {k}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_is_the_single_packet() {
|
||||
let bs = (0u8..=200).collect::<Vec<u8>>();
|
||||
let au = build_au(&[(10, 50)], &bs, None);
|
||||
assert_eq!(au, bs[10..60]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_windows_pack_small_packets_and_reconstruct() {
|
||||
// Three small packets that share windows; walking must reproduce them concatenated in order.
|
||||
let bs: Vec<u8> = (0..255u32).map(|i| i as u8).collect();
|
||||
let packets = [(0, 20), (20, 20), (40, 100)];
|
||||
let chunk = 64; // payload_max = 60
|
||||
let au = build_au(&packets, &bs, Some(chunk));
|
||||
let flat = walk(&au, chunk);
|
||||
let mut expect = Vec::new();
|
||||
for &(o, s) in &packets {
|
||||
expect.extend_from_slice(&bs[o..o + s]);
|
||||
}
|
||||
assert_eq!(flat, expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_packet_fragments_and_reassembles() {
|
||||
// One atomic packet larger than a window → a FRAG chain the walk reassembles exactly.
|
||||
let bs: Vec<u8> = (0..1000u32).map(|i| i as u8).collect();
|
||||
let chunk = 64; // payload_max = 60
|
||||
let au = build_au(&[(0, 500)], &bs, Some(chunk));
|
||||
assert_eq!(walk(&au, chunk), bs[0..500]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_reserves_the_window_prefix() {
|
||||
assert_eq!(packet_boundary(Some(1408), 999_999), 1404);
|
||||
assert_eq!(packet_boundary(None, 777), 777);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_limited_range_sets_only_the_range_bit() {
|
||||
let mut bs = vec![0u8; 16];
|
||||
mark_limited_range(&mut bs, 0);
|
||||
// ycbcr_range = bit 30 of the LE second word = bit 6 of byte 7 (0x40); nothing else touched.
|
||||
assert_eq!(bs[7], 0x40);
|
||||
assert!(bs[..7].iter().all(|&b| b == 0));
|
||||
assert!(bs[8..].iter().all(|&b| b == 0));
|
||||
// Idempotent; an out-of-range offset is a silent no-op (never panics).
|
||||
mark_limited_range(&mut bs, 0);
|
||||
assert_eq!(bs[7], 0x40);
|
||||
mark_limited_range(&mut bs, 100);
|
||||
}
|
||||
}
|
||||
@@ -2788,6 +2788,7 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
@@ -2973,6 +2974,7 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
@@ -3114,6 +3116,7 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
@@ -3261,6 +3264,7 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
|
||||
@@ -1811,6 +1811,7 @@ mod tests {
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
@@ -1913,6 +1914,7 @@ mod tests {
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
//! PyroWave host encoder (Windows) — **separate-plane zero-copy D3D11→Vulkan** via pyrowave's own
|
||||
//! compat device (design/pyrowave-windows-host-zerocopy.md). The opt-in wired-LAN intra-only wavelet
|
||||
//! codec, the Windows twin of `enc/linux/pyrowave.rs`.
|
||||
//!
|
||||
//! Shape (deliberately minimal — no `ash`, no hand-rolled external-memory import): pyrowave owns its
|
||||
//! OWN Vulkan device, selected by the render GPU's vendor/device-id
|
||||
//! (`pyrowave_create_device_by_compat`). The capturer's CSC produces TWO SEPARATE D3D11 plane
|
||||
//! textures — a full-res `R8` **Y** + a half-res `R8G8` **CbCr** (BT.709 limited, matching the Linux
|
||||
//! `rgb2yuv.comp` layout the wavelet clients decode) — each shared to that device as an NT handle
|
||||
//! (`VK_EXTERNAL_MEMORY_HANDLE_TYPE_D3D11_TEXTURE_BIT`) via `pyrowave_image_create`. Separate
|
||||
//! single/two-component textures import reliably on NVIDIA at any size, unlike a single planar NV12
|
||||
//! texture (the vendored interop test: "only very specific resource sizes"). A shared
|
||||
//! D3D11/D3D12 fence — signalled by the capturer *after* the convert — is imported as a Vulkan
|
||||
//! timeline semaphore (`pyrowave_sync_object_create`) so the wavelet read is ordered after the
|
||||
//! D3D11 convert. `pyrowave_encoder_encode_gpu_synchronous` performs the acquire (waiting the fence
|
||||
//! value), the encode, and the release in ONE pyrowave-owned submission, referencing the external
|
||||
//! image with `VK_QUEUE_FAMILY_EXTERNAL`. The dangerous cross-API import (incl. the NVIDIA
|
||||
//! video-layout workaround) stays entirely inside validated pyrowave/Granite. Every AU is a
|
||||
//! keyframe; the AU/wire-chunk framing is the shared [`crate::pyrowave_wire`] helper (byte-identical
|
||||
//! to Linux).
|
||||
//!
|
||||
//! The capture side (a BGRA→YUV CSC into two shareable plane textures + a shared fence, gated on the
|
||||
//! pyrowave session flag) lives in `pf-capture` (`windows/idd_push.rs`); the CbCr plane + fence ride
|
||||
//! the frame on [`pf_frame::dxgi::D3d11Frame::pyro`], the Y plane on `D3d11Frame::texture`.
|
||||
// Every `unsafe` block in this module carries a `// SAFETY:` proof (the crate root enforces it).
|
||||
|
||||
use crate::pyrowave_wire;
|
||||
use crate::{EncodedFrame, Encoder, EncoderCaps};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use pf_frame::{CapturedFrame, FramePayload};
|
||||
use pyrowave_sys as pw;
|
||||
use std::collections::VecDeque;
|
||||
use windows::core::{Interface, PCWSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, DuplicateHandle, DUPLICATE_SAME_ACCESS, HANDLE};
|
||||
use windows::Win32::Graphics::Direct3D11::ID3D11Texture2D;
|
||||
use windows::Win32::Graphics::Dxgi::IDXGIResource1;
|
||||
use windows::Win32::System::Threading::GetCurrentProcess;
|
||||
|
||||
/// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta).
|
||||
const BS_SLACK: usize = 256 * 1024;
|
||||
/// Bound the per-texture image-import cache. The IDD out-ring is a small fixed set (OUT_RING=3);
|
||||
/// this only ever grows past it if the capturer recreates its out-ring within one encoder's life
|
||||
/// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed.
|
||||
const IMPORT_CACHE_CAP: usize = 8;
|
||||
|
||||
// --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the
|
||||
// pyrowave C API are generated; these plain #define / flags-typedef values are stable spec
|
||||
// constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32
|
||||
// literals assign straight into the generated struct fields. ---
|
||||
// The usage the validated interop helper (`create_pyrowave_image_from_d3d11`) requests.
|
||||
const VK_IMAGE_USAGE_TRANSFER_SRC_BIT: u32 = 0x0000_0001;
|
||||
const VK_IMAGE_USAGE_TRANSFER_DST_BIT: u32 = 0x0000_0002;
|
||||
const VK_IMAGE_USAGE_SAMPLED_BIT: u32 = 0x0000_0004;
|
||||
/// `VK_QUEUE_FAMILY_EXTERNAL` (`~0u32 - 1`): the image is owned by an external (D3D11) queue family;
|
||||
/// pyrowave's acquire/release transitions ownership in/out across the interop boundary.
|
||||
const VK_QUEUE_FAMILY_EXTERNAL: u32 = 0xFFFF_FFFE;
|
||||
|
||||
fn pw_check(r: pw::pyrowave_result, what: &str) -> Result<()> {
|
||||
if r == pw::pyrowave_result_PYROWAVE_SUCCESS {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("pyrowave {what} failed: result {r}")
|
||||
}
|
||||
}
|
||||
|
||||
fn budget_for(bitrate_bps: u64, fps: u32) -> usize {
|
||||
((bitrate_bps / (8 * fps.max(1) as u64)) as usize).max(64 * 1024)
|
||||
}
|
||||
|
||||
pub struct PyroWaveEncoder {
|
||||
// pyrowave owns the whole Vulkan device (create_device_by_compat) — no ash on this side.
|
||||
pw_dev: pw::pyrowave_device,
|
||||
pw_enc: pw::pyrowave_encoder,
|
||||
// The imported shared fence (a Vulkan timeline semaphore aliasing the capturer's D3D11 fence).
|
||||
// Null until the capturer delivers the fence handle on the first frame (or after a rebuild).
|
||||
sync: pw::pyrowave_sync_object,
|
||||
// Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot):
|
||||
// the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar
|
||||
// NV12 import is unreliable on NVIDIA at arbitrary sizes).
|
||||
y_images: Vec<(isize, pw::pyrowave_image)>,
|
||||
cbcr_images: Vec<(isize, pw::pyrowave_image)>,
|
||||
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
/// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`.
|
||||
frame_budget: usize,
|
||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
|
||||
wire_chunk: Option<usize>,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
}
|
||||
|
||||
// SAFETY: used only from the single encode thread; the pyrowave handles are owned and only touched
|
||||
// from that thread, and pyrowave only submits GPU work inside the API calls we make (mirrors the
|
||||
// Linux `PyroWaveEncoder`'s `unsafe impl Send`). The D3D11 texture pointers travel as plain `isize`
|
||||
// cache keys, never dereferenced here.
|
||||
unsafe impl Send for PyroWaveEncoder {}
|
||||
|
||||
impl PyroWaveEncoder {
|
||||
pub fn open(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
chroma: crate::ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
if chroma.is_444() {
|
||||
// Negotiation can't reach here yet: `can_encode_444` returns false for PyroWave
|
||||
// until the full-res-chroma BgraToYuvPlanes variant lands
|
||||
// (design/pyrowave-444-hdr.md Phase 3). Threaded now so that flip is one-file.
|
||||
bail!("pyrowave 4:4:4 encode not implemented yet (Phase 3)");
|
||||
}
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
let fps = fps.max(1);
|
||||
// Select pyrowave's device by the SELECTED render adapter's vendor/device-id — NOT by LUID:
|
||||
// in Session 0 (the host service context) the Vulkan ICD reports `deviceLUIDValid = false`,
|
||||
// so a by-LUID match would find nothing, while the vendor/device-id match + the external
|
||||
// import both work (design doc Stage 0; `pyrowave_c.cpp` guards LUID use behind validity).
|
||||
let (vid, pid) = pf_gpu::selected_gpu()
|
||||
.map(|s| (s.info.vendor_id, s.info.device_id))
|
||||
.unwrap_or((0, 0));
|
||||
// SAFETY: `create_device_by_compat` builds pyrowave's own instance/device from the
|
||||
// vendor/device-id (null uuids/luid = "don't constrain by those"); the out-param is a live
|
||||
// local. `confirm_interop_support` / `encoder_create` take that just-created non-null
|
||||
// device; on any failure we destroy what we created before returning. All pointers are
|
||||
// freshly created and owned by the returned struct (or freed on the error path).
|
||||
unsafe {
|
||||
let mut pw_dev: pw::pyrowave_device = std::ptr::null_mut();
|
||||
pw_check(
|
||||
pw::pyrowave_create_device_by_compat(
|
||||
vid,
|
||||
pid,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
&mut pw_dev,
|
||||
),
|
||||
"create_device_by_compat",
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"open a PyroWave Vulkan device for GPU {vid:04x}:{pid:04x} (render adapter)"
|
||||
)
|
||||
})?;
|
||||
|
||||
// The make-or-break gate (design doc Risk 1): confirm this device can do the
|
||||
// external-memory interop the zero-copy import needs. In a service context where the
|
||||
// import is unavailable this fails HERE (clean HEVC renegotiation) instead of at the
|
||||
// first frame's import.
|
||||
if !pw::pyrowave_device_confirm_interop_support(pw_dev) {
|
||||
pw::pyrowave_device_destroy(pw_dev);
|
||||
bail!(
|
||||
"the PyroWave Vulkan device does not confirm external-memory interop support \
|
||||
(D3D11→Vulkan zero-copy import unavailable on this GPU / in this session \
|
||||
context) — the session should renegotiate to HEVC"
|
||||
);
|
||||
}
|
||||
|
||||
let einfo = pw::pyrowave_encoder_create_info {
|
||||
device: pw_dev,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
};
|
||||
let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||
if let Err(e) = pw_check(
|
||||
pw::pyrowave_encoder_create(&einfo, &mut pw_enc),
|
||||
"encoder_create",
|
||||
) {
|
||||
pw::pyrowave_device_destroy(pw_dev);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let frame_budget = budget_for(bitrate_bps.max(1_000_000), fps);
|
||||
tracing::info!(
|
||||
gpu = format!("{vid:04x}:{pid:04x}"),
|
||||
mode = %format!("{width}x{height}@{fps}"),
|
||||
budget_kib = frame_budget / 1024,
|
||||
"PyroWave encoder open (Windows NV12 zero-copy, intra-only wavelet, BT.709 limited 4:2:0)"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
pw_dev,
|
||||
pw_enc,
|
||||
sync: std::ptr::null_mut(),
|
||||
y_images: Vec::new(),
|
||||
cbcr_images: Vec::new(),
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
frame_budget,
|
||||
wire_chunk: None,
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Import one capturer plane D3D11 texture (`R8_UNORM` Y or `R8G8_UNORM` CbCr) into pyrowave's
|
||||
/// Vulkan device. Creates a fresh shared NT handle from the texture (the capturer marked the ring
|
||||
/// `SHARED | SHARED_NTHANDLE`); `pyrowave_image_create` takes ownership of the handle and closes
|
||||
/// it on import. Single/two-component textures import reliably on NVIDIA at any size — unlike a
|
||||
/// planar NV12 — so no MUTABLE_FORMAT / planar-layout workaround is involved.
|
||||
///
|
||||
/// # Safety
|
||||
/// `texture` must be a live `ID3D11Texture2D` of format `vk_format`, sized `w`×`h`, created
|
||||
/// shareable, on the same physical GPU as `pw_dev`. The returned `pyrowave_image` is owned by the
|
||||
/// caller (destroyed in `Drop`/eviction). Takes `pw_dev` by value (not `&self`) so the cache
|
||||
/// closures don't double-borrow the encoder.
|
||||
unsafe fn import_plane(
|
||||
pw_dev: pw::pyrowave_device,
|
||||
texture: &ID3D11Texture2D,
|
||||
vk_format: pw::VkFormat,
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<pw::pyrowave_image> {
|
||||
// The shared NT handle (mirrors the interop test's `create_pyrowave_image_from_d3d11`).
|
||||
let res: IDXGIResource1 = texture
|
||||
.cast()
|
||||
.context("ID3D11Texture2D -> IDXGIResource1 (plane not created shareable?)")?;
|
||||
// GENERIC_ALL (0x1000_0000) — the access the interop test hands the shared handle.
|
||||
let handle: HANDLE = res
|
||||
.CreateSharedHandle(None, 0x1000_0000, PCWSTR::null())
|
||||
.context("IDXGIResource1::CreateSharedHandle(plane texture)")?;
|
||||
|
||||
// Zero-init then set the fields we need (pNext/queue-family/initialLayout stay 0 = null /
|
||||
// UNDEFINED) — robust against however bindgen renders `Default` for the raw-pointer fields.
|
||||
let mut ici: pw::VkImageCreateInfo = std::mem::zeroed();
|
||||
ici.sType = pw::VkStructureType_VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
||||
ici.imageType = pw::VkImageType_VK_IMAGE_TYPE_2D;
|
||||
ici.format = vk_format;
|
||||
ici.extent = pw::VkExtent3D {
|
||||
width: w,
|
||||
height: h,
|
||||
depth: 1,
|
||||
};
|
||||
ici.mipLevels = 1;
|
||||
ici.arrayLayers = 1;
|
||||
ici.samples = pw::VkSampleCountFlagBits_VK_SAMPLE_COUNT_1_BIT;
|
||||
ici.tiling = pw::VkImageTiling_VK_IMAGE_TILING_OPTIMAL;
|
||||
ici.usage = VK_IMAGE_USAGE_SAMPLED_BIT
|
||||
| VK_IMAGE_USAGE_TRANSFER_SRC_BIT
|
||||
| VK_IMAGE_USAGE_TRANSFER_DST_BIT;
|
||||
ici.sharingMode = pw::VkSharingMode_VK_SHARING_MODE_EXCLUSIVE;
|
||||
let info = pw::pyrowave_image_create_info {
|
||||
device: pw_dev,
|
||||
external_handle: handle.0 as usize as pw::pyrowave_os_handle,
|
||||
handle_type:
|
||||
pw::VkExternalMemoryHandleTypeFlagBits_VK_EXTERNAL_MEMORY_HANDLE_TYPE_D3D11_TEXTURE_BIT,
|
||||
image_create_info: &ici,
|
||||
};
|
||||
let mut image: pw::pyrowave_image = std::ptr::null_mut();
|
||||
if let Err(e) = pw_check(pw::pyrowave_image_create(&info, &mut image), "image_create") {
|
||||
// pyrowave only closes the handle on a SUCCESSFUL import — close it ourselves on failure.
|
||||
let _ = CloseHandle(handle);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
/// Import (cache) a plane texture by its stable per-slot pointer, evicting the oldest when the
|
||||
/// cache is over cap (the out-ring is small + fixed; growth only happens on a mid-life ring
|
||||
/// recreate). Returns the cached-or-fresh `pyrowave_image`.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same contract as [`import_plane`].
|
||||
unsafe fn cached_plane(
|
||||
cache: &mut Vec<(isize, pw::pyrowave_image)>,
|
||||
make: impl FnOnce() -> Result<pw::pyrowave_image>,
|
||||
key: isize,
|
||||
) -> Result<pw::pyrowave_image> {
|
||||
if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return Ok(*img);
|
||||
}
|
||||
let img = make()?;
|
||||
if cache.len() >= IMPORT_CACHE_CAP {
|
||||
let (_, old) = cache.remove(0);
|
||||
pw::pyrowave_image_destroy(old);
|
||||
}
|
||||
cache.push((key, img));
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Import the capturer's shared fence as a Vulkan timeline semaphore. Called only when this
|
||||
/// encoder has no timeline yet (the first frame, or a fresh encoder after a mode-switch rebuild).
|
||||
/// pyrowave takes ownership of the handle and CLOSES it on import, so we hand it a private
|
||||
/// **duplicate** of the capturer's persistent handle — leaving the original valid for the next
|
||||
/// rebuild's re-import (the capturer passes the same handle on every frame).
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be the capturer's live shared D3D11/D3D12 fence NT handle on `self.pw_dev`'s GPU.
|
||||
unsafe fn import_fence(&mut self, handle: isize) -> Result<()> {
|
||||
let mut dup = HANDLE::default();
|
||||
DuplicateHandle(
|
||||
GetCurrentProcess(),
|
||||
HANDLE(handle as *mut core::ffi::c_void),
|
||||
GetCurrentProcess(),
|
||||
&mut dup,
|
||||
0,
|
||||
false,
|
||||
DUPLICATE_SAME_ACCESS,
|
||||
)
|
||||
.context("DuplicateHandle(shared fence for pyrowave import)")?;
|
||||
let info = pw::pyrowave_sync_object_create_info {
|
||||
device: self.pw_dev,
|
||||
external_handle: dup.0 as usize as pw::pyrowave_os_handle,
|
||||
// D3D11 fence == D3D12 fence on Windows 10+; must be imported as TIMELINE.
|
||||
handle_type:
|
||||
pw::VkExternalSemaphoreHandleTypeFlagBits_VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_D3D12_FENCE_BIT,
|
||||
semaphore_type: pw::VkSemaphoreType_VK_SEMAPHORE_TYPE_TIMELINE,
|
||||
import_flags: 0,
|
||||
};
|
||||
let mut sync: pw::pyrowave_sync_object = std::ptr::null_mut();
|
||||
if let Err(e) = pw_check(
|
||||
pw::pyrowave_sync_object_create(&info, &mut sync),
|
||||
"sync_object_create",
|
||||
) {
|
||||
// pyrowave only closes the handle on a SUCCESSFUL import — close the dup on failure.
|
||||
let _ = CloseHandle(dup);
|
||||
return Err(e);
|
||||
}
|
||||
self.sync = sync;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One frame, synchronously: import (cache) the two plane textures + fence → encode (pyrowave
|
||||
/// owns the submission: acquire waits the capturer's fence value, references both images as
|
||||
/// `QUEUE_FAMILY_EXTERNAL`, release hands them back) → packetize into an `EncodedFrame`.
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
let FramePayload::D3d11(d3d) = &frame.payload else {
|
||||
bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)")
|
||||
};
|
||||
let share = d3d.pyro.as_ref().context(
|
||||
"pyrowave (Windows): the frame carries no PyroWave payload — the capturer was not opened \
|
||||
in pyrowave mode (session_plan::output_format must set OutputFormat::pyrowave)",
|
||||
)?;
|
||||
|
||||
// Import the fence whenever this encoder has no timeline yet — the first frame, OR a fresh
|
||||
// encoder after a client mode-switch rebuild (the capturer passes the persistent handle on
|
||||
// every frame precisely so a rebuilt encoder can re-import it).
|
||||
if self.sync.is_null() {
|
||||
let h = share
|
||||
.fence_handle
|
||||
.context("pyrowave (Windows): frame carried no shared fence handle")?;
|
||||
self.import_fence(h)?;
|
||||
}
|
||||
|
||||
// Import (cache) the two SEPARATE plane textures by their stable per-slot pointers: the
|
||||
// full-res R8 Y on `d3d.texture`, the half-res R8G8 CbCr on `share.cbcr`. `pw_dev` is a Copy
|
||||
// handle so the cache closures don't borrow `self` alongside `&mut self.*_images`.
|
||||
let (w, h) = (self.width, self.height);
|
||||
let pw_dev = self.pw_dev;
|
||||
let y_img = {
|
||||
let key = d3d.texture.as_raw() as isize;
|
||||
let tex = &d3d.texture;
|
||||
Self::cached_plane(
|
||||
&mut self.y_images,
|
||||
|| Self::import_plane(pw_dev, tex, pw::VkFormat_VK_FORMAT_R8_UNORM, w, h),
|
||||
key,
|
||||
)?
|
||||
};
|
||||
let cbcr_img = {
|
||||
let key = share.cbcr.as_raw() as isize;
|
||||
let tex = &share.cbcr;
|
||||
Self::cached_plane(
|
||||
&mut self.cbcr_images,
|
||||
|| Self::import_plane(pw_dev, tex, pw::VkFormat_VK_FORMAT_R8G8_UNORM, w / 2, h / 2),
|
||||
key,
|
||||
)?
|
||||
};
|
||||
|
||||
// Plane views built BY HAND exactly like the Linux encoder (`enc/linux/pyrowave.rs`): Y from
|
||||
// the R8 image (full-res, IDENTITY), Cb/Cr from the R8G8 image (half-res) with R/G swizzle to
|
||||
// synthesize the two chroma planes from the interleaved CbCr — the documented NV12-style
|
||||
// hand-off. All GENERAL layout (pyrowave's GPU-buffer contract accepts it without transitions).
|
||||
let y_vk = pw::pyrowave_image_get_handle(y_img);
|
||||
let cbcr_vk = pw::pyrowave_image_get_handle(cbcr_img);
|
||||
let plane = |image, pw_w, pw_h, fmt, swizzle| pw::pyrowave_image_view {
|
||||
image,
|
||||
width: pw_w,
|
||||
height: pw_h,
|
||||
image_format: fmt,
|
||||
view_format: fmt,
|
||||
mip_level: 0,
|
||||
layer: 0,
|
||||
aspect: pw::VkImageAspectFlagBits_VK_IMAGE_ASPECT_COLOR_BIT,
|
||||
swizzle,
|
||||
layout: pw::VkImageLayout_VK_IMAGE_LAYOUT_GENERAL,
|
||||
};
|
||||
let r8 = pw::VkFormat_VK_FORMAT_R8_UNORM;
|
||||
let rg8 = pw::VkFormat_VK_FORMAT_R8G8_UNORM;
|
||||
let buffers = pw::pyrowave_gpu_buffers {
|
||||
planes: [
|
||||
plane(
|
||||
y_vk,
|
||||
w,
|
||||
h,
|
||||
r8,
|
||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_IDENTITY,
|
||||
),
|
||||
plane(
|
||||
cbcr_vk,
|
||||
w / 2,
|
||||
h / 2,
|
||||
rg8,
|
||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
|
||||
),
|
||||
plane(
|
||||
cbcr_vk,
|
||||
w / 2,
|
||||
h / 2,
|
||||
rg8,
|
||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// Acquire the two external images (owned by the D3D11 queue family), waiting the capturer's
|
||||
// fence value so the wavelet read is ordered after the D3D11 CSC; release hands them back.
|
||||
// pyrowave owns the submission (no explicit command buffer).
|
||||
let refs = [
|
||||
pw::pyrowave_gpu_external_reference {
|
||||
image: y_img,
|
||||
queue_family_index: VK_QUEUE_FAMILY_EXTERNAL,
|
||||
},
|
||||
pw::pyrowave_gpu_external_reference {
|
||||
image: cbcr_img,
|
||||
queue_family_index: VK_QUEUE_FAMILY_EXTERNAL,
|
||||
},
|
||||
];
|
||||
let acquire = pw::pyrowave_gpu_sync_operation {
|
||||
images: refs.as_ptr(),
|
||||
num_images: refs.len(),
|
||||
sync: pw::pyrowave_sync_point {
|
||||
semaphore: pw::pyrowave_sync_object_get_semaphore(self.sync),
|
||||
value: share.fence_value,
|
||||
},
|
||||
};
|
||||
let release = pw::pyrowave_gpu_sync_operation {
|
||||
images: refs.as_ptr(),
|
||||
num_images: refs.len(),
|
||||
// No release signal needed (null semaphore): encode is synchronous and the out-ring depth
|
||||
// guarantees the slot is not reused before the next synchronous encode completes (the same
|
||||
// contract the NVENC path relies on).
|
||||
sync: std::mem::zeroed(),
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
};
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_encode_gpu_synchronous(
|
||||
self.pw_enc,
|
||||
&acquire,
|
||||
&release,
|
||||
&buffers,
|
||||
&rc,
|
||||
),
|
||||
"encode_gpu_synchronous",
|
||||
)?;
|
||||
|
||||
// ---- packetize (shared framing helper — byte-identical to the Linux encoder) ----
|
||||
let cap = self.frame_budget + BS_SLACK;
|
||||
self.bitstream.resize(cap, 0);
|
||||
let boundary = pyrowave_wire::packet_boundary(self.wire_chunk, cap);
|
||||
let mut n: usize = 0;
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n),
|
||||
"compute_num_packets",
|
||||
)?;
|
||||
if n == 0 || (self.wire_chunk.is_none() && n != 1) {
|
||||
bail!("pyrowave: unexpected packet count {n} at boundary {boundary}");
|
||||
}
|
||||
let mut packets = vec![pw::pyrowave_packet { offset: 0, size: 0 }; n];
|
||||
let mut out_n: usize = 0;
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_packetize(
|
||||
self.pw_enc,
|
||||
packets.as_mut_ptr(),
|
||||
boundary,
|
||||
&mut out_n,
|
||||
self.bitstream.as_mut_ptr() as *mut std::ffi::c_void,
|
||||
cap,
|
||||
),
|
||||
"packetize",
|
||||
)?;
|
||||
packets.truncate(out_n.max(1));
|
||||
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
|
||||
// emits BT.709 LIMITED — patch the bit HONEST so VUI-honoring clients don't wash out blacks.
|
||||
if let Some(p) = packets.first() {
|
||||
pyrowave_wire::mark_limited_range(&mut self.bitstream, p.offset);
|
||||
}
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
pts_ns: frame.pts_ns,
|
||||
// Every frame is independently decodable — the codec's whole recovery story.
|
||||
keyframe: true,
|
||||
recovery_anchor: false,
|
||||
chunk_aligned: self.wire_chunk.is_some(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder for PyroWaveEncoder {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
// SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this struct
|
||||
// owns and pyrowave waits its own fence before packetize returns.
|
||||
unsafe { self.encode_frame(frame) }
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
// All defaults: no RFI (every frame is intra), no HDR (8-bit SDR codec), 4:2:0 only.
|
||||
EncoderCaps::default()
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
Ok(self.pending.pop_front())
|
||||
}
|
||||
|
||||
fn reset(&mut self) -> bool {
|
||||
// Cheap in-place rebuild: recreate only the pyrowave encoder object (no rate-control /
|
||||
// reference state to preserve). The device, imported textures and fence survive.
|
||||
// SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder.
|
||||
unsafe {
|
||||
pw::pyrowave_encoder_destroy(self.pw_enc);
|
||||
let einfo = pw::pyrowave_encoder_create_info {
|
||||
device: self.pw_dev,
|
||||
width: self.width as i32,
|
||||
height: self.height as i32,
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
};
|
||||
let mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||
let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
|
||||
if r != pw::pyrowave_result_PYROWAVE_SUCCESS {
|
||||
tracing::error!(result = ?r, "pyrowave: encoder rebuild failed");
|
||||
return false;
|
||||
}
|
||||
self.pw_enc = enc;
|
||||
}
|
||||
self.pending.clear();
|
||||
true
|
||||
}
|
||||
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
// Rate control is a plain per-frame byte budget — an in-place retarget is free (no IDR,
|
||||
// nothing in flight). Phase 3 pins the session rate and bypasses ABR; this faithfully
|
||||
// applies whatever the caller asks until then.
|
||||
self.frame_budget = budget_for(bps.max(1_000_000), self.fps);
|
||||
tracing::debug!(
|
||||
mbps = bps / 1_000_000,
|
||||
budget_kib = self.frame_budget / 1024,
|
||||
"pyrowave: per-frame rate budget retargeted in place"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn set_wire_chunking(&mut self, shard_payload: usize) {
|
||||
// Sanity floor: a boundary below one block header + payload word is meaningless.
|
||||
if shard_payload >= 64 {
|
||||
self.wire_chunk = Some(shard_payload);
|
||||
tracing::info!(
|
||||
shard_payload,
|
||||
"pyrowave: datagram-aligned packetization on (partial-frame loss mode)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
// Synchronous per-frame encode: nothing buffered beyond `pending`.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PyroWaveEncoder {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: owned handles, destroyed exactly once; pyrowave objects (encoder, images, sync) go
|
||||
// before the device they borrow (per pyrowave.h).
|
||||
unsafe {
|
||||
pw::pyrowave_encoder_destroy(self.pw_enc);
|
||||
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
|
||||
pw::pyrowave_image_destroy(img);
|
||||
}
|
||||
if !self.sync.is_null() {
|
||||
pw::pyrowave_sync_object_destroy(self.sync);
|
||||
}
|
||||
pw::pyrowave_device_destroy(self.pw_dev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pf_frame::dxgi::{D3d11Frame, PyroFrameShare};
|
||||
use pf_frame::PixelFormat;
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_1};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4,
|
||||
ID3D11Fence, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_CPU_ACCESS_WRITE,
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_FENCE_FLAG_SHARED, D3D11_MAPPED_SUBRESOURCE,
|
||||
D3D11_MAP_WRITE, D3D11_RESOURCE_MISC_SHARED, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
|
||||
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
|
||||
/// Decode a dense PyroWave AU with upstream's own decoder → YUV420P plane means (the golden
|
||||
/// oracle, mirroring the Linux `decode_plane_means`).
|
||||
///
|
||||
/// # Safety
|
||||
/// `au` must be a complete dense PyroWave AU for a `w`×`h` 4:2:0 frame.
|
||||
unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8]) -> (f64, f64, f64) {
|
||||
let mut dev: pw::pyrowave_device = std::ptr::null_mut();
|
||||
assert_eq!(
|
||||
pw::pyrowave_create_default_device(&mut dev),
|
||||
pw::pyrowave_result_PYROWAVE_SUCCESS
|
||||
);
|
||||
let dinfo = pw::pyrowave_decoder_create_info {
|
||||
device: dev,
|
||||
width: w as i32,
|
||||
height: h as i32,
|
||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
||||
fragment_path: false,
|
||||
};
|
||||
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
||||
assert_eq!(
|
||||
pw::pyrowave_decoder_create(&dinfo, &mut dec),
|
||||
pw::pyrowave_result_PYROWAVE_SUCCESS
|
||||
);
|
||||
assert_eq!(
|
||||
pw::pyrowave_decoder_push_packet(dec, au.as_ptr() as *const _, au.len()),
|
||||
pw::pyrowave_result_PYROWAVE_SUCCESS
|
||||
);
|
||||
assert!(pw::pyrowave_decoder_decode_is_ready(dec, false));
|
||||
let mut y = vec![0u8; (w * h) as usize];
|
||||
let mut cb = vec![0u8; (w * h / 4) as usize];
|
||||
let mut cr = vec![0u8; (w * h / 4) as usize];
|
||||
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
|
||||
buf.format = pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P;
|
||||
buf.width = w as i32;
|
||||
buf.height = h as i32;
|
||||
buf.data = [
|
||||
y.as_mut_ptr() as *mut _,
|
||||
cb.as_mut_ptr() as *mut _,
|
||||
cr.as_mut_ptr() as *mut _,
|
||||
];
|
||||
buf.row_stride_in_bytes = [w as usize, (w / 2) as usize, (w / 2) as usize];
|
||||
buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
|
||||
assert_eq!(
|
||||
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
|
||||
pw::pyrowave_result_PYROWAVE_SUCCESS
|
||||
);
|
||||
pw::pyrowave_decoder_destroy(dec);
|
||||
pw::pyrowave_device_destroy(dev);
|
||||
let mean = |v: &[u8]| v.iter().map(|&x| x as f64).sum::<f64>() / v.len() as f64;
|
||||
(mean(&y), mean(&cb), mean(&cr))
|
||||
}
|
||||
|
||||
/// Create a shareable `format` plane texture (`bpp` bytes/texel), fill each texel with `bytes`
|
||||
/// via a CPU staging copy, and return it. Mirrors the capturer's SHARED|SHARED_NTHANDLE +
|
||||
/// RENDER_TARGET out-ring textures.
|
||||
///
|
||||
/// # Safety
|
||||
/// `bytes.len() == bpp`; runs on a live D3D11 device/context.
|
||||
unsafe fn make_plane(
|
||||
device: &ID3D11Device,
|
||||
context: &ID3D11DeviceContext,
|
||||
w: u32,
|
||||
h: u32,
|
||||
format: DXGI_FORMAT,
|
||||
bpp: usize,
|
||||
bytes: &[u8],
|
||||
) -> ID3D11Texture2D {
|
||||
let mut desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0 | D3D11_RESOURCE_MISC_SHARED.0)
|
||||
as u32,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.expect("CreateTexture2D(plane default)");
|
||||
let tex = tex.unwrap();
|
||||
desc.BindFlags = 0;
|
||||
desc.MiscFlags = 0;
|
||||
desc.Usage = D3D11_USAGE_STAGING;
|
||||
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE.0 as u32;
|
||||
let mut staging: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut staging))
|
||||
.expect("CreateTexture2D(plane staging)");
|
||||
let staging = staging.unwrap();
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
context
|
||||
.Map(&staging, 0, D3D11_MAP_WRITE, 0, Some(&mut mapped))
|
||||
.expect("Map(plane staging)");
|
||||
let pitch = mapped.RowPitch as usize;
|
||||
let base = mapped.pData as *mut u8;
|
||||
for row in 0..(h as usize) {
|
||||
let r = base.add(row * pitch);
|
||||
for x in 0..(w as usize) {
|
||||
for (b, &v) in bytes.iter().enumerate() {
|
||||
*r.add(x * bpp + b) = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
context.Unmap(&staging, 0);
|
||||
context.CopyResource(&tex, &staging);
|
||||
tex
|
||||
}
|
||||
|
||||
/// End-to-end zero-copy smoke: distinct solid Y/Cb/Cr filled into SEPARATE shareable plane
|
||||
/// textures (full-res R8 Y + half-res R8G8 CbCr) → shared to pyrowave's own Vulkan device (the
|
||||
/// SESSION-0-relevant `create_device_by_compat` + `D3D11_TEXTURE_BIT` import + shared-fence path)
|
||||
/// → encode → upstream-decode. Returns the decoded plane means. A flat gray can't detect a plane
|
||||
/// swap / spatial error, so this fills Y≠Cb≠Cr.
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs on a real D3D11 + Vulkan-1.3 GPU; all COM/FFI handles are locally owned.
|
||||
unsafe fn run_case(w: u32, h: u32) -> (f64, f64, f64) {
|
||||
// A fresh D3D11 device on the default hardware adapter.
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_1]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.expect("D3D11CreateDevice");
|
||||
let device = device.unwrap();
|
||||
let context = context.unwrap();
|
||||
|
||||
// Full-res R8 Y (=100) + half-res R8G8 CbCr (=180,60) — the exact layout the encoder ingests.
|
||||
let y_tex = make_plane(&device, &context, w, h, DXGI_FORMAT_R8_UNORM, 1, &[100]);
|
||||
let cbcr_tex = make_plane(
|
||||
&device,
|
||||
&context,
|
||||
w / 2,
|
||||
h / 2,
|
||||
DXGI_FORMAT_R8G8_UNORM,
|
||||
2,
|
||||
&[180, 60],
|
||||
);
|
||||
|
||||
// Shared fence signalled after the fills (mirrors the capturer's convert→signal ordering).
|
||||
let dev5: ID3D11Device5 = device.cast().expect("ID3D11Device5");
|
||||
let mut fence: Option<ID3D11Fence> = None;
|
||||
dev5.CreateFence(0, D3D11_FENCE_FLAG_SHARED, &mut fence)
|
||||
.expect("CreateFence");
|
||||
let fence = fence.unwrap();
|
||||
let fence_handle = fence
|
||||
.CreateSharedHandle(None, 0x1000_0000, windows::core::PCWSTR::null())
|
||||
.expect("fence CreateSharedHandle");
|
||||
let ctx4: ID3D11DeviceContext4 = context.cast().expect("ID3D11DeviceContext4");
|
||||
ctx4.Signal(&fence, 1).expect("Signal");
|
||||
context.Flush();
|
||||
|
||||
// Encode the shared textures through the real backend.
|
||||
let mut enc = PyroWaveEncoder::open(w, h, 60, 100_000_000, crate::ChromaFormat::Yuv420)
|
||||
.expect("PyroWaveEncoder::open");
|
||||
let frame = CapturedFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
pts_ns: 0,
|
||||
format: PixelFormat::Nv12,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: y_tex,
|
||||
device: device.clone(),
|
||||
pyro: Some(PyroFrameShare {
|
||||
cbcr: cbcr_tex,
|
||||
fence_handle: Some(fence_handle.0 as isize),
|
||||
fence_value: 1,
|
||||
}),
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit(&frame).expect("submit");
|
||||
let au = enc.poll().expect("poll").expect("one AU per frame");
|
||||
assert!(au.keyframe, "every pyrowave AU is a keyframe");
|
||||
assert!(!au.data.is_empty(), "AU is non-empty");
|
||||
// The dense AU starts with the 8-byte BitstreamSequenceHeader; the range VUI must read
|
||||
// LIMITED (bit 30 = byte 7 bit 6 = 0x40) — `mark_limited_range` corrects pyrowave's zeroed
|
||||
// default so VUI-honoring clients (Apple) don't wash out blacks.
|
||||
assert_eq!(
|
||||
au.data[7] & 0x40,
|
||||
0x40,
|
||||
"sequence header must signal ycbcr_range=LIMITED"
|
||||
);
|
||||
decode_plane_means(w, h, &au.data)
|
||||
}
|
||||
|
||||
/// The Windows NV12 zero-copy path end-to-end on a real GPU. `#[ignore]`d (needs D3D11 + a
|
||||
/// Vulkan-1.3 device); build anywhere, run on the GPU host:
|
||||
/// cargo test -p pf-encode --features pyrowave --no-run
|
||||
/// <bin> --ignored --nocapture pyrowave_win_smoke
|
||||
/// Runs both a known-good square size and real streaming sizes to characterize the documented
|
||||
/// NVIDIA NV12 D3D11→Vulkan import size sensitivity (design doc Risk 4 / the interop-test note).
|
||||
#[test]
|
||||
#[ignore = "needs a real D3D11 + Vulkan-1.3 GPU (run on the Windows host, not the build box)"]
|
||||
fn pyrowave_win_smoke() {
|
||||
for (w, h) in [(1024u32, 1024u32), (1280, 720), (1920, 1080), (2560, 1440)] {
|
||||
// SAFETY: single-threaded test; `run_case` owns every COM/FFI handle it touches.
|
||||
let (ym, cbm, crm) = unsafe { run_case(w, h) };
|
||||
eprintln!(
|
||||
"{w}x{h}: decoded means Y={ym:.1} Cb={cbm:.1} Cr={crm:.1} (expect 100/180/60)"
|
||||
);
|
||||
assert!(
|
||||
(ym - 100.0).abs() < 6.0 && (cbm - 180.0).abs() < 6.0 && (crm - 60.0).abs() < 6.0,
|
||||
"{w}x{h}: NV12 round-trip means (Y {ym:.1}, Cb {cbm:.1}, Cr {crm:.1}) drifted from \
|
||||
the filled 100/180/60 — chroma plane mapping wrong (swap? wrong plane?)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1746,6 +1746,7 @@ mod tests {
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
|
||||
+62
-10
@@ -48,7 +48,19 @@ impl Codec {
|
||||
} else {
|
||||
0u8
|
||||
};
|
||||
#[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
|
||||
// Windows: the wavelet encoder rides on top of whatever GPU backend the box has (NVENC/AMF/
|
||||
// QSV) — it opens its OWN Vulkan device by the render GPU's vendor/device-id and
|
||||
// zero-copy-imports the capturer's NV12 D3D11 texture, so the H.26x backend is irrelevant to
|
||||
// it. Only a software/GPU-less host keeps the bit off (no Vulkan GPU to open). Whether the
|
||||
// Session-0 external-memory import actually works is confirmed at encoder open
|
||||
// (`pyrowave_device_confirm_interop_support`); a failed open renegotiates to HEVC.
|
||||
#[cfg(all(target_os = "windows", feature = "pyrowave"))]
|
||||
let pyro = if windows_resolved_backend() != WindowsBackend::Software {
|
||||
punktfunk_core::quic::CODEC_PYROWAVE
|
||||
} else {
|
||||
0u8
|
||||
};
|
||||
#[cfg(not(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave")))]
|
||||
let pyro = 0u8;
|
||||
let base = (|| {
|
||||
/// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
|
||||
@@ -236,10 +248,11 @@ fn open_video_backend(
|
||||
if fps == 0 || fps > 1000 {
|
||||
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
||||
}
|
||||
// 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
|
||||
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
|
||||
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
|
||||
let chroma = if chroma.is_444() && codec != Codec::H265 {
|
||||
// 4:4:4 is HEVC- and PyroWave-only. The negotiator should never pass `Yuv444` for another
|
||||
// codec (it gates on the codec + `can_encode_444`), but defend the contract here so a future
|
||||
// caller can't silently emit a stream no decoder expects: an unsupported 4:4:4 request
|
||||
// degrades to 4:2:0 with a warning.
|
||||
let chroma = if chroma.is_444() && codec != Codec::H265 && codec != Codec::PyroWave {
|
||||
tracing::warn!(
|
||||
?codec,
|
||||
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
||||
@@ -255,7 +268,7 @@ fn open_video_backend(
|
||||
if codec == Codec::PyroWave {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
@@ -357,7 +370,16 @@ fn open_video_backend(
|
||||
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
|
||||
normal sessions negotiate it instead)"
|
||||
);
|
||||
pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
|
||||
// The lab override forces the wavelet stream onto a session negotiated for
|
||||
// another codec — that session's chroma may be HEVC-4:4:4, which the
|
||||
// pyrowave encoder doesn't do yet, so pin the override to 4:2:0.
|
||||
pyrowave::PyroWaveEncoder::open(
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
@@ -399,10 +421,22 @@ fn open_video_backend(
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// The Windows host leg is blocked on the .173 D3D11-interop debt (plan Phase 0 §3);
|
||||
// host_wire_caps never advertises the bit here, so this only guards a forged preference.
|
||||
// A NEGOTIATED PyroWave session (client advertised + preferred it) routes straight to the
|
||||
// NV12 zero-copy wavelet backend (design/pyrowave-windows-host-zerocopy.md) — placed FIRST,
|
||||
// like the Linux branch. It opens its own Vulkan device by the render GPU's vendor/device-id
|
||||
// and imports the capturer's shared NV12 texture; the H.26x backend selection below is moot.
|
||||
if codec == Codec::PyroWave {
|
||||
anyhow::bail!("PyroWave host encode is not available on Windows yet");
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
let _ = (format, cuda, bit_depth);
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
anyhow::bail!(
|
||||
"session negotiated PyroWave but this host was built without --features \
|
||||
punktfunk-host/pyrowave (the advertisement bit should not have been set)"
|
||||
);
|
||||
}
|
||||
let _ = cuda; // always false on Windows (no Cuda payload)
|
||||
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
||||
@@ -835,6 +869,13 @@ pub fn vaapi_codec_support() -> CodecSupport {
|
||||
pub fn can_encode_444(codec: Codec) -> bool {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
if codec == Codec::PyroWave {
|
||||
// PyroWave does its own RGB→YCbCr CSC (capture always hands it a full-chroma source),
|
||||
// so 4:4:4 needs no GPU encode probe — only the full-res-chroma CSC variant:
|
||||
// `rgb2yuv444.comp` on Linux (landed, design/pyrowave-444-hdr.md Phase 2); the
|
||||
// Windows `BgraToYuvPlanes` twin is Phase 3.
|
||||
return cfg!(target_os = "linux");
|
||||
}
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
}
|
||||
@@ -1260,6 +1301,17 @@ mod vk_util;
|
||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||
#[path = "enc/linux/pyrowave.rs"]
|
||||
mod pyrowave;
|
||||
// The Windows PyroWave encoder — NV12 zero-copy D3D11→Vulkan via pyrowave's own compat device
|
||||
// (design/pyrowave-windows-host-zerocopy.md). Same module name as the Linux one (per-platform
|
||||
// `#[path]`, mutually-exclusive cfg) so `crate::pyrowave::*` is flat on both.
|
||||
#[cfg(all(target_os = "windows", feature = "pyrowave"))]
|
||||
#[path = "enc/windows/pyrowave.rs"]
|
||||
mod pyrowave;
|
||||
// Shared PyroWave AU wire-framing (§4.4) — the single source of truth both platform backends emit,
|
||||
// so the on-wire access-unit layout the clients parse can never drift between Linux and Windows.
|
||||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave"))]
|
||||
#[path = "enc/pyrowave_wire.rs"]
|
||||
mod pyrowave_wire;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -34,10 +34,35 @@ pub struct WinCaptureTarget {
|
||||
pub wudf_pid: u32,
|
||||
}
|
||||
|
||||
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
|
||||
/// The PyroWave (Windows) zero-copy sharing payload attached to a captured frame: the SECOND plane
|
||||
/// texture + the cross-device fence the wavelet encoder needs (design/pyrowave-windows-host-
|
||||
/// zerocopy.md). The wavelet encoder ingests **two SEPARATE** shareable plane textures — the full-res
|
||||
/// `R8_UNORM` **Y** rides [`D3d11Frame::texture`], and the half-res `R8G8_UNORM` **CbCr** rides
|
||||
/// [`cbcr`](Self::cbcr) — because importing a single *planar* NV12 texture into Vulkan is unreliable
|
||||
/// on NVIDIA at arbitrary sizes; separate single/two-component textures import reliably. `None` on
|
||||
/// every non-PyroWave frame (NVENC/AMF/QSV encode the in-place NV12/BGRA and need no cross-device
|
||||
/// fence). The encoder makes each texture's shared handle on demand.
|
||||
pub struct PyroFrameShare {
|
||||
/// The half-res `R8G8_UNORM` interleaved CbCr plane (created `SHARED | SHARED_NTHANDLE`). The
|
||||
/// full-res Y plane is [`D3d11Frame::texture`].
|
||||
pub cbcr: ID3D11Texture2D,
|
||||
/// The shared D3D11/D3D12 **fence** NT handle (raw), passed on EVERY frame; the encoder imports
|
||||
/// it (duplicating) whenever it has no timeline yet (first frame or after an encoder rebuild).
|
||||
pub fence_handle: Option<isize>,
|
||||
/// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan
|
||||
/// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC.
|
||||
pub fence_value: u64,
|
||||
}
|
||||
|
||||
/// A GPU-resident captured texture (the Windows zero-copy path: NVENC/AMF/QSV encode it in place;
|
||||
/// the PyroWave backend imports it — plus the second plane in [`pyro`](Self::pyro) — into its own
|
||||
/// Vulkan device). For a PyroWave frame, `texture` is the full-res `R8_UNORM` Y plane.
|
||||
pub struct D3d11Frame {
|
||||
pub texture: ID3D11Texture2D,
|
||||
pub device: ID3D11Device,
|
||||
/// PyroWave zero-copy sharing info (the CbCr plane + fence); `None` unless this is a PyroWave
|
||||
/// session. See [`PyroFrameShare`].
|
||||
pub pyro: Option<PyroFrameShare>,
|
||||
}
|
||||
// SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers.
|
||||
// D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
|
||||
|
||||
@@ -115,6 +115,13 @@ pub struct OutputFormat {
|
||||
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
|
||||
/// 4:2:0 session.
|
||||
pub chroma_444: bool,
|
||||
/// A PyroWave (wavelet) session on Windows: the IDD-push capturer must make its NV12 out-ring
|
||||
/// **shareable** (`SHARED | SHARED_NTHANDLE`) and signal a **shared fence** after each convert,
|
||||
/// so the pyrowave encoder can zero-copy-import the texture into its own Vulkan device
|
||||
/// (design/pyrowave-windows-host-zerocopy.md). Also forces the NV12 4:2:0 SDR convert branch
|
||||
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
|
||||
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
|
||||
pub pyrowave: bool,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
@@ -130,6 +137,8 @@ impl OutputFormat {
|
||||
hdr,
|
||||
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
||||
chroma_444: false,
|
||||
// GameStream never negotiates PyroWave (native punktfunk/1 only).
|
||||
pyrowave: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,15 @@ pub fn capture_virtual_output(
|
||||
// proactively enables advanced color and selects the per-frame conversion. There is NO fallback:
|
||||
// if it can't open or the driver doesn't attach, the session fails cleanly and the client
|
||||
// reconnects.
|
||||
pf_capture::open_idd_push(target, pref, want.hdr, want.chroma_444, keep, sender)
|
||||
pf_capture::open_idd_push(
|
||||
target,
|
||||
pref,
|
||||
want.hdr,
|
||||
want.chroma_444,
|
||||
want.pyrowave,
|
||||
keep,
|
||||
sender,
|
||||
)
|
||||
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
|
||||
}
|
||||
|
||||
|
||||
@@ -521,10 +521,20 @@ fn resolve_bitrate_kbps_for(
|
||||
codec: crate::encode::Codec,
|
||||
requested: u32,
|
||||
mode: &punktfunk_core::config::Mode,
|
||||
chroma: crate::encode::ChromaFormat,
|
||||
bit_depth: u8,
|
||||
) -> u32 {
|
||||
if requested == 0 && codec == crate::encode::Codec::PyroWave {
|
||||
let bps =
|
||||
mode.width as u64 * mode.height as u64 * u64::from(mode.refresh_hz.max(1)) * 16 / 10;
|
||||
// ~1.6 bpp for 4:2:0. 4:4:4 doubles the samples per pixel (3 vs 1.5) but chroma
|
||||
// compresses better than luma → ×1.625 ≈ 2.6 bpp; 16-bit planes add ~15 % (both
|
||||
// factors measured against the Phase-0 fixture matrix, design/pyrowave-444-hdr.md).
|
||||
let bpp_x10: u64 = if chroma.is_444() { 26 } else { 16 };
|
||||
let mut bps =
|
||||
mode.width as u64 * mode.height as u64 * u64::from(mode.refresh_hz.max(1)) * bpp_x10
|
||||
/ 10;
|
||||
if bit_depth >= 10 {
|
||||
bps = bps * 115 / 100;
|
||||
}
|
||||
return u32::try_from(bps / 1000)
|
||||
.unwrap_or(MAX_BITRATE_KBPS)
|
||||
.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS);
|
||||
@@ -1458,18 +1468,58 @@ mod tests {
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
};
|
||||
use crate::encode::ChromaFormat;
|
||||
// Automatic (0) on PyroWave → the ~1.6 bpp operating point, not the 20 Mbps H.26x
|
||||
// default (which would turn wavelets to mush — plan §4.6).
|
||||
let kbps = resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 0, &mode);
|
||||
let kbps = resolve_bitrate_kbps_for(
|
||||
crate::encode::Codec::PyroWave,
|
||||
0,
|
||||
&mode,
|
||||
ChromaFormat::Yuv420,
|
||||
8,
|
||||
);
|
||||
assert_eq!(kbps, 1920 * 1080 * 60 * 16 / 10 / 1000);
|
||||
// 4:4:4 scales the pin to ~2.6 bpp, 10-bit adds 15 % (design/pyrowave-444-hdr.md §2.5).
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(
|
||||
crate::encode::Codec::PyroWave,
|
||||
0,
|
||||
&mode,
|
||||
ChromaFormat::Yuv444,
|
||||
8
|
||||
),
|
||||
1920 * 1080 * 60 * 26 / 10 / 1000
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(
|
||||
crate::encode::Codec::PyroWave,
|
||||
0,
|
||||
&mode,
|
||||
ChromaFormat::Yuv444,
|
||||
10
|
||||
),
|
||||
(1920u64 * 1080 * 60 * 26 / 10 * 115 / 100 / 1000) as u32
|
||||
);
|
||||
// An explicit client rate is honored (clamped like any other codec)...
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 130_000, &mode),
|
||||
resolve_bitrate_kbps_for(
|
||||
crate::encode::Codec::PyroWave,
|
||||
130_000,
|
||||
&mode,
|
||||
ChromaFormat::Yuv420,
|
||||
8
|
||||
),
|
||||
130_000
|
||||
);
|
||||
// ...and the H.26x codecs keep the legacy default.
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(crate::encode::Codec::H265, 0, &mode),
|
||||
resolve_bitrate_kbps_for(
|
||||
crate::encode::Codec::H265,
|
||||
0,
|
||||
&mode,
|
||||
ChromaFormat::Yuv420,
|
||||
8
|
||||
),
|
||||
DEFAULT_BITRATE_KBPS
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,14 +187,8 @@ pub(super) async fn negotiate(
|
||||
// needed; the actual pads are created lazily by the input thread).
|
||||
let gamepad = resolve_gamepad(hello.gamepad);
|
||||
|
||||
// Resolve the encoder bitrate (client request clamped to a sane range, or a
|
||||
// codec-aware host default — PyroWave pins ~1.6 bpp for the mode).
|
||||
let bitrate_kbps = resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode);
|
||||
tracing::info!(
|
||||
requested_kbps = hello.bitrate_kbps,
|
||||
resolved_kbps = bitrate_kbps,
|
||||
"encoder bitrate"
|
||||
);
|
||||
// (The encoder bitrate is resolved below, AFTER bit depth + chroma: PyroWave's automatic
|
||||
// ~bpp pin scales with both — design/pyrowave-444-hdr.md §2.5.)
|
||||
|
||||
// Resolve the audio channel count (client request → stereo / 5.1 / 7.1). The capturer opens
|
||||
// at this count: PipeWire synthesizes the requested positions (padding with silence when the
|
||||
@@ -255,19 +249,24 @@ pub(super) async fn negotiate(
|
||||
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
|
||||
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
|
||||
// topology, and 4:4:4 routed to DDA, which was removed.)
|
||||
let capture_supports_444 =
|
||||
crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444());
|
||||
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
|
||||
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
|
||||
// gate is `can_encode_444` (the full-res-chroma CSC variant existing on this OS).
|
||||
let capture_supports_444 = codec == crate::encode::Codec::PyroWave
|
||||
|| crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444());
|
||||
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
||||
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
||||
// the cheap gates already pass. The result is cached process-wide (a negative latches until
|
||||
// restart — acceptable: a GPU either supports HEVC 4:4:4 or it doesn't, and a transient open
|
||||
// failure here is rare since the session's own encoder isn't open yet).
|
||||
let gpu_supports_444 = if codec == crate::encode::Codec::H265
|
||||
&& host_wants_444
|
||||
let gpu_supports_444 = if matches!(
|
||||
codec,
|
||||
crate::encode::Codec::H265 | crate::encode::Codec::PyroWave
|
||||
) && host_wants_444
|
||||
&& client_supports_444
|
||||
&& capture_supports_444
|
||||
{
|
||||
tokio::task::spawn_blocking(|| crate::encode::can_encode_444(crate::encode::Codec::H265))
|
||||
tokio::task::spawn_blocking(move || crate::encode::can_encode_444(codec))
|
||||
.await
|
||||
.context("4:4:4 capability probe task")?
|
||||
} else {
|
||||
@@ -299,6 +298,17 @@ pub(super) async fn negotiate(
|
||||
bit_depth
|
||||
};
|
||||
|
||||
// Resolve the encoder bitrate (client request clamped to a sane range, or a codec-aware
|
||||
// host default). Resolved AFTER depth + chroma: PyroWave's Automatic rate is a ~bpp pin
|
||||
// for the negotiated mode that scales with both (design/pyrowave-444-hdr.md §2.5).
|
||||
let bitrate_kbps =
|
||||
resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode, chroma, bit_depth);
|
||||
tracing::info!(
|
||||
requested_kbps = hello.bitrate_kbps,
|
||||
resolved_kbps = bitrate_kbps,
|
||||
"encoder bitrate"
|
||||
);
|
||||
|
||||
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
|
||||
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||
|
||||
@@ -1262,7 +1262,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// so re-resolve it for the new mode. Explicit client rates stay put (the operator knows
|
||||
// the link), and the H.26x codecs keep their mode-independent rate (ABR owns it).
|
||||
let mode_bitrate = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &new_mode)
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &new_mode, plan.chroma, plan.bit_depth)
|
||||
} else {
|
||||
bitrate_kbps
|
||||
};
|
||||
|
||||
@@ -179,6 +179,11 @@ impl SessionPlan {
|
||||
// 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the
|
||||
// default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4.
|
||||
chroma_444: self.chroma.is_444(),
|
||||
// PyroWave (Windows): the IDD-push capturer makes its NV12 out-ring shareable + signals a
|
||||
// shared fence so the wavelet encoder can zero-copy-import the texture into its own Vulkan
|
||||
// device. Inert on Linux (the wavelet backend ingests dmabufs / CPU RGB there — handled
|
||||
// by the `gpu` flips above, not this flag).
|
||||
pyrowave: self.codec == crate::encode::Codec::PyroWave,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
|
||||
index f5ac6dcc..ad4e9746 100644
|
||||
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
|
||||
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_encoder.cpp
|
||||
@@ -187,8 +187,13 @@ void Encoder::Impl::init_block_meta()
|
||||
meta_buffer = device->create_buffer(info);
|
||||
device->set_name(*meta_buffer, "meta-buffer");
|
||||
|
||||
- // Worst case estimate.
|
||||
- info.size = aligned_width * aligned_height * 2;
|
||||
+ // Worst case estimate. PUNKTFUNK PATCH (patches/0001-payload-data-444-sizing.patch):
|
||||
+ // 4:4:4 carries 3 samples per pixel vs 4:2:0's 1.5 — the same per-sample headroom
|
||||
+ // needs twice the bytes, or busy 4:4:4 content overruns this buffer on the GPU and
|
||||
+ // corrupts the adjacent meta/bucket allocations (nondeterministic bad bitstreams and
|
||||
+ // crashes at ANY target bitrate). Validated on RTX 5070 Ti @1080p/4K, 8/16-bit.
|
||||
+ info.size = VkDeviceSize(aligned_width) * aligned_height *
|
||||
+ (chroma == ChromaSubsampling::Chroma444 ? 4 : 2);
|
||||
payload_data = device->create_buffer(info);
|
||||
device->set_name(*payload_data, "payload-data");
|
||||
|
||||
@@ -8,3 +8,10 @@ vulkan-headers: 015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9
|
||||
Tree is pruned to what the pyrowave-sys standalone build needs
|
||||
(see the rm -rf list in the script). All parts are MIT-licensed
|
||||
(pyrowave, Granite) or Apache-2.0/MIT (volk, Vulkan-Headers).
|
||||
|
||||
Local patches (crates/pyrowave-sys/patches/, re-applied on re-vendor):
|
||||
0001-payload-data-444-sizing.patch — encoder payload_data worst-case buffer
|
||||
was sized for 4:2:0's 1.5 samples/px; busy 4:4:4 (3 samples/px) overran it
|
||||
on the GPU → nondeterministic corrupt bitstreams/crashes at any bitrate.
|
||||
Found + validated 2026-07-18 (RTX 5070 Ti, 1080p/4K, 8/16-bit); to be
|
||||
reported upstream.
|
||||
|
||||
@@ -187,8 +187,13 @@ void Encoder::Impl::init_block_meta()
|
||||
meta_buffer = device->create_buffer(info);
|
||||
device->set_name(*meta_buffer, "meta-buffer");
|
||||
|
||||
// Worst case estimate.
|
||||
info.size = aligned_width * aligned_height * 2;
|
||||
// Worst case estimate. PUNKTFUNK PATCH (patches/0001-payload-data-444-sizing.patch):
|
||||
// 4:4:4 carries 3 samples per pixel vs 4:2:0's 1.5 — the same per-sample headroom
|
||||
// needs twice the bytes, or busy 4:4:4 content overruns this buffer on the GPU and
|
||||
// corrupts the adjacent meta/bucket allocations (nondeterministic bad bitstreams and
|
||||
// crashes at ANY target bitrate). Validated on RTX 5070 Ti @1080p/4K, 8/16-bit.
|
||||
info.size = VkDeviceSize(aligned_width) * aligned_height *
|
||||
(chroma == ChromaSubsampling::Chroma444 ? 4 : 2);
|
||||
payload_data = device->create_buffer(info);
|
||||
device->set_name(*payload_data, "payload-data");
|
||||
|
||||
|
||||
@@ -18,11 +18,24 @@ here.)
|
||||
|
||||
Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput`
|
||||
udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI
|
||||
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself (built in the Ubuntu
|
||||
26.04 rust-ci image, so the lib soname package names match the target). The NVIDIA driver
|
||||
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself. The NVIDIA driver
|
||||
(`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of
|
||||
band, like on the RPM side.
|
||||
|
||||
## Ubuntu 24.04 LTS (and why it needs a special build)
|
||||
|
||||
`punktfunk-host` needs **FFmpeg 8** (libavcodec62), but Ubuntu 24.04 LTS ships FFmpeg 6.1
|
||||
(libavcodec60). So a host `.deb` built the obvious way — on the same Ubuntu 26.04 image as the
|
||||
client (`ci/rust-ci.Dockerfile`) — declares `Depends: libavcodec62, …` and a glibc-2.41 floor that
|
||||
24.04's apt can't satisfy ("the required packages are too recent"). To fix that, the host `.deb` is
|
||||
instead built on an **Ubuntu 24.04 image** (`ci/rust-ci-noble.Dockerfile`) that carries a from-source
|
||||
FFmpeg 8, and that FFmpeg is **bundled into the package** (`build-deb.sh BUNDLE_FFMPEG=1` → the
|
||||
libav* land in `/usr/lib/punktfunk-host`, the binary's rpath points there, and the libav* sonames are
|
||||
dropped from `Depends`). The result is **one** host `.deb` that installs on **Ubuntu 24.04 LTS through
|
||||
26.04** (glibc floor 2.39; no distro-FFmpeg dependency). The client/web/scripting `.deb`s still build
|
||||
on 26.04 (the native client needs SDL3 / GTK4 ≥ 4.20, absent on 24.04) — install the client on the box
|
||||
you stream *to*, which is independent of the host's distro.
|
||||
|
||||
## Install on a host (one-time)
|
||||
|
||||
The registry is public, so no apt auth is needed — just trust the repo's signing key:
|
||||
@@ -144,5 +157,17 @@ VERSION=0.0.1 bash packaging/debian/build-deb.sh # -> dist/punktfunk-host_0.0.
|
||||
```
|
||||
|
||||
Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing.
|
||||
Build it in the rust-ci image (or on an Ubuntu 26.04 box) so the resolved `Depends` match the
|
||||
hosts; building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
|
||||
Building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
|
||||
|
||||
That plain invocation hard-depends on the build box's system FFmpeg, so it only installs on a box
|
||||
with the same libav* soname. For the **universal** package CI ships (installs on 24.04 LTS → 26.04),
|
||||
build it in the noble image with FFmpeg bundled:
|
||||
|
||||
```sh
|
||||
docker build -f ci/rust-ci-noble.Dockerfile -t pf-noble ci
|
||||
docker run --rm -v "$PWD:/src" -w /src pf-noble \
|
||||
bash -lc 'VERSION=0.0.1 BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh'
|
||||
```
|
||||
|
||||
`BUNDLE_FFMPEG=1` needs `patchelf` and an FFmpeg install at `FFMPEG_PREFIX` (default `/opt/ffmpeg`,
|
||||
which the noble image provides).
|
||||
|
||||
@@ -13,13 +13,23 @@
|
||||
# FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a
|
||||
# specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band.
|
||||
#
|
||||
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-deb.sh
|
||||
# BUNDLE_FFMPEG=1 (Ubuntu 24.04 LTS builds, ci/rust-ci-noble.Dockerfile): instead of hard-depending
|
||||
# on the distro's libav* — which don't exist on 24.04 (it ships FFmpeg 6.1 / libavcodec60, the host
|
||||
# needs 8 / libavcodec62) — copy a from-source FFmpeg into /usr/lib/punktfunk-host, repoint the
|
||||
# binary's rpath there, and drop the libav*/libsw*/libpostproc sonames from the auto Depends. Set
|
||||
# FFMPEG_PREFIX to that FFmpeg's install prefix (default /opt/ffmpeg, as the noble image sets it).
|
||||
# See packaging/debian/README.md → "Ubuntu 24.04 LTS".
|
||||
#
|
||||
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] [BUNDLE_FFMPEG=1] bash packaging/debian/build-deb.sh
|
||||
# Output: dist/punktfunk-host_<version>_<arch>.deb
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
PKG="punktfunk-host"
|
||||
BUNDLE_FFMPEG="${BUNDLE_FFMPEG:-0}"
|
||||
FFMPEG_PREFIX="${FFMPEG_PREFIX:-/opt/ffmpeg}"
|
||||
LIBDIR_REL="usr/lib/$PKG" # bundled FFmpeg lands here: /usr/lib/punktfunk-host
|
||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$ROOTDIR"
|
||||
|
||||
@@ -119,8 +129,41 @@ printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <n
|
||||
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
|
||||
| gzip -9n > "$DOCDIR/changelog.Debian.gz"
|
||||
|
||||
# --- bundled FFmpeg (Ubuntu 24.04 LTS builds) --------------------------------
|
||||
# Copy the from-source libav*/libsw*/libpostproc .so's into /usr/lib/punktfunk-host and repoint the
|
||||
# binary at them, so the package carries FFmpeg 8 instead of depending on a distro libavcodec62 that
|
||||
# 24.04 doesn't have. BUNDLED_LIBS is fed to dpkg-shlibdeps below so the libs' OWN external deps
|
||||
# (libva2, libdrm2, …, all present on 24.04) still become Depends.
|
||||
BUNDLED_LIBS=""
|
||||
if [ "$BUNDLE_FFMPEG" = "1" ]; then
|
||||
command -v patchelf >/dev/null || { echo "BUNDLE_FFMPEG=1 needs patchelf" >&2; exit 1; }
|
||||
[ -d "$FFMPEG_PREFIX/lib" ] || { echo "FFMPEG_PREFIX=$FFMPEG_PREFIX has no lib/ — build FFmpeg first" >&2; exit 1; }
|
||||
DEST="$STAGE/$LIBDIR_REL"
|
||||
install -d "$DEST"
|
||||
# cp -a preserves the SONAME symlink chain (libavcodec.so -> .so.62 -> .so.62.x.x); the loader
|
||||
# resolves the binary's DT_NEEDED (libavcodec.so.62) to the middle link.
|
||||
shopt -s nullglob
|
||||
for so in "$FFMPEG_PREFIX"/lib/lib{avcodec,avformat,avutil,avfilter,avdevice,swscale,swresample,postproc}.so*; do
|
||||
cp -a "$so" "$DEST/"
|
||||
done
|
||||
shopt -u nullglob
|
||||
ls "$DEST"/libavcodec.so.* >/dev/null 2>&1 || { echo "no libav* found under $FFMPEG_PREFIX/lib" >&2; exit 1; }
|
||||
# Each bundled lib finds its siblings (libavcodec needs libavutil) via its own $ORIGIN RUNPATH;
|
||||
# patch only the real versioned files, not the symlinks. The executable then finds the top-level
|
||||
# libs via ../lib/$PKG, written as DT_RPATH (--force-rpath) so it's also searched transitively —
|
||||
# belt-and-suspenders against DT_RUNPATH's non-transitivity.
|
||||
for so in "$DEST"/*.so.*; do
|
||||
[ -L "$so" ] && continue
|
||||
patchelf --set-rpath '$ORIGIN' "$so"
|
||||
done
|
||||
patchelf --force-rpath --set-rpath "\$ORIGIN/../lib/$PKG" "$STAGE/usr/bin/$PKG"
|
||||
BUNDLED_LIBS="$(printf '%s ' "$DEST"/*.so.*)"
|
||||
echo "==> bundled FFmpeg from $FFMPEG_PREFIX into /$LIBDIR_REL"
|
||||
fi
|
||||
|
||||
# --- dependencies ------------------------------------------------------------
|
||||
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header).
|
||||
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header). In bundle mode the
|
||||
# bundled .so's are appended so their external deps (libva2/libdrm2/…) are captured too.
|
||||
SHLIB_TMP="$(mktemp -d)"
|
||||
mkdir -p "$SHLIB_TMP/debian"
|
||||
cat > "$SHLIB_TMP/debian/control" <<EOF
|
||||
@@ -130,8 +173,16 @@ Package: $PKG
|
||||
Architecture: any
|
||||
Depends: \${shlibs:Depends}
|
||||
EOF
|
||||
SHDEPS_RAW="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \
|
||||
| sed -n 's/^shlibs:Depends=//p')"
|
||||
# In bundle mode the libav* live in FFMPEG_PREFIX/lib — not a standard loader path, and the
|
||||
# target/release binary carries no rpath (only the staged copy does) — so dpkg-shlibdeps can't
|
||||
# locate libavcodec.so.62 and exits 2. Point it there via LD_LIBRARY_PATH. Stderr is captured so a
|
||||
# future resolution failure is visible instead of swallowed.
|
||||
SHDEPS_RAW="$(
|
||||
cd "$SHLIB_TMP"
|
||||
if [ "$BUNDLE_FFMPEG" = "1" ]; then export LD_LIBRARY_PATH="$FFMPEG_PREFIX/lib"; fi
|
||||
dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" $BUNDLED_LIBS 2>"$SHLIB_TMP/err" \
|
||||
| sed -n 's/^shlibs:Depends=//p'
|
||||
)" || { echo "dpkg-shlibdeps failed (exit $?):" >&2; sed 's/^/ /' "$SHLIB_TMP/err" >&2; rm -rf "$SHLIB_TMP"; exit 1; }
|
||||
rm -rf "$SHLIB_TMP"
|
||||
[ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
|
||||
|
||||
@@ -139,8 +190,12 @@ rm -rf "$SHLIB_TMP"
|
||||
# GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves
|
||||
# libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are
|
||||
# provided by whatever driver the host runs, so this must never be a package dependency.
|
||||
# In bundle mode also drop the FFmpeg sonames: they're shipped inside the package (/usr/lib/$PKG),
|
||||
# not pulled from apt, so a `Depends: libavcodec62` would wrongly re-block install on 24.04.
|
||||
FILTER='^(libnvidia-compute|libcuda)'
|
||||
[ "$BUNDLE_FFMPEG" = "1" ] && FILTER='^(libnvidia-compute|libcuda|libav|libsw|libpostproc)'
|
||||
SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \
|
||||
| grep -ivE '^(libnvidia-compute|libcuda)' | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
|
||||
| grep -ivE "$FILTER" | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
|
||||
[ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; }
|
||||
|
||||
# Manual additions shlibdeps can't see:
|
||||
|
||||
@@ -55,6 +55,15 @@ mkdir -p "$(dirname "$DEST")"
|
||||
rm -rf "$DEST"
|
||||
cp -a "$WORK/pyrowave" "$DEST"
|
||||
|
||||
# Local patches on top of the pin (crates/pyrowave-sys/patches/*.patch, applied
|
||||
# in order). Each patch documents its upstream status; drop it when a vendor
|
||||
# bump includes the fix.
|
||||
for p in "$REPO_ROOT"/crates/pyrowave-sys/patches/*.patch; do
|
||||
[ -e "$p" ] || continue
|
||||
git -C "$REPO_ROOT" apply "$p"
|
||||
echo "applied $(basename "$p")"
|
||||
done
|
||||
|
||||
cat > "$DEST/PUNKTFUNK-VENDOR.txt" <<EOF
|
||||
Vendored by scripts/vendor-pyrowave.sh — do not edit by hand.
|
||||
|
||||
@@ -66,6 +75,13 @@ vulkan-headers: $VKHDR_COMMIT
|
||||
Tree is pruned to what the pyrowave-sys standalone build needs
|
||||
(see the rm -rf list in the script). All parts are MIT-licensed
|
||||
(pyrowave, Granite) or Apache-2.0/MIT (volk, Vulkan-Headers).
|
||||
|
||||
Local patches (crates/pyrowave-sys/patches/, re-applied on re-vendor):
|
||||
0001-payload-data-444-sizing.patch — encoder payload_data worst-case buffer
|
||||
was sized for 4:2:0's 1.5 samples/px; busy 4:4:4 (3 samples/px) overran it
|
||||
on the GPU → nondeterministic corrupt bitstreams/crashes at any bitrate.
|
||||
Found + validated 2026-07-18 (RTX 5070 Ti, 1080p/4K, 8/16-bit); to be
|
||||
reported upstream.
|
||||
EOF
|
||||
|
||||
echo "Vendored pyrowave@${PYROWAVE_COMMIT:0:12} (Granite ${GRANITE_COMMIT:0:12}) into $DEST"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/host",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
+29
-8
@@ -92,19 +92,40 @@ export const discoverUnits = (
|
||||
// no scripts dir — fine
|
||||
}
|
||||
const modules = path.join(pluginsDir, "node_modules");
|
||||
try {
|
||||
for (const pkg of fs.readdirSync(modules).sort()) {
|
||||
if (!pkg.startsWith("punktfunk-plugin-")) continue;
|
||||
// Read a plugin package's manifest (`module`/`main` entry) and add it as a unit.
|
||||
const addPlugin = (dir: string, name: string): void => {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(modules, pkg, "package.json"), "utf8"),
|
||||
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
|
||||
) as { main?: string; module?: string };
|
||||
const rel = manifest.module ?? manifest.main ?? "index.js";
|
||||
const file = path.join(modules, pkg, rel);
|
||||
if (!fileIsSafe(file, log)) continue;
|
||||
units.push({ name: pkg, file });
|
||||
const file = path.join(dir, rel);
|
||||
if (!fileIsSafe(file, log)) return;
|
||||
units.push({ name, file });
|
||||
} catch (e) {
|
||||
log(`[runner] skipping ${pkg}: unreadable package.json (${e})`);
|
||||
log(`[runner] skipping ${name}: unreadable package.json (${e})`);
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (const pkg of fs.readdirSync(modules).sort()) {
|
||||
// Unscoped convention: `punktfunk-plugin-*`.
|
||||
if (pkg.startsWith("punktfunk-plugin-")) {
|
||||
addPlugin(path.join(modules, pkg), pkg);
|
||||
continue;
|
||||
}
|
||||
// Scoped convention: `@punktfunk/plugin-*` (first-party). A scoped name resolves cleanly
|
||||
// from a single registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect`
|
||||
// as shared (hoisted) deps rather than bundling its own copy of each.
|
||||
if (pkg === "@punktfunk") {
|
||||
try {
|
||||
for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) {
|
||||
if (scoped.startsWith("plugin-")) {
|
||||
addPlugin(path.join(modules, pkg, scoped), `${pkg}/${scoped}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// no @punktfunk scope dir — fine
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -83,6 +83,25 @@ describe("discovery", () => {
|
||||
expect(logs.join("\n")).toContain("REFUSING");
|
||||
expect(logs.join("\n")).toContain("evil.ts");
|
||||
});
|
||||
|
||||
test("discovers scoped @punktfunk/plugin-* packages (not other scoped pkgs)", () => {
|
||||
const d = mkdirs("discover-scoped");
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-y", "package.json"),
|
||||
JSON.stringify({ name: "@punktfunk/plugin-y", module: "dist/index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-y", "dist", "index.js"),
|
||||
"export default { name: 'y', main: async () => {} };",
|
||||
);
|
||||
// A non-plugin scoped package (e.g. the SDK itself) is ignored.
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", "host", "package.json"),
|
||||
JSON.stringify({ name: "@punktfunk/host" }),
|
||||
);
|
||||
const units = discoverUnits(d);
|
||||
expect(units.map((u) => u.name)).toEqual(["@punktfunk/plugin-y"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supervision", () => {
|
||||
|
||||
Reference in New Issue
Block a user