Compare commits

..

1 Commits

Author SHA1 Message Date
enricobuehler 5f9a06d51f fix(packaging): install punktfunk-host on Ubuntu 24.04 LTS via a noble builder that bundles FFmpeg 8
The host .deb was built on the Ubuntu 26.04 rust-ci image, so dpkg-shlibdeps
baked in `Depends: libavcodec62` (FFmpeg 8) and a glibc-2.41 floor — making it
uninstallable on Ubuntu 24.04 LTS (FFmpeg 6.1 / libavcodec60, glibc 2.39; apt
reports the deps as "too recent"). The source floor (ffmpeg-next 8, libavcodec
>=61 APIs) means a straight 24.04 rebuild would fail too.

Build the host on Ubuntu 24.04 instead — lowering the glibc floor to 2.39 so one
binary runs on 24.04 -> 26.04 — and bundle a from-source LGPL FFmpeg 8 into the
package so it no longer depends on the distro libav*. Everything else the host
links is soname-compatible on 24.04 (opus is vendored via cmake; NVENC/libcuda
are dlopen-only, never link-time), and the only FFmpeg encoders used are
*_nvenc / *_vaapi (software H.264 fallback is the BSD-2 openh264 crate, not
FFmpeg libx264), so an LGPL build keeps the bundle license-clean.

- ci/rust-ci-noble.Dockerfile (new): ubuntu:24.04 builder; nv-codec-headers +
  FFmpeg 8 (--enable-nvenc --enable-vaapi, shared) -> /opt/ffmpeg; PKG_CONFIG_PATH.
- packaging/debian/build-deb.sh: BUNDLE_FFMPEG=1 copies libav*/libsw*/libpostproc
  into /usr/lib/punktfunk-host, patchelf-sets the rpath ($ORIGIN per-lib + binary
  --force-rpath), feeds them to dpkg-shlibdeps (captures libva2/libdrm2), and drops
  the libav* sonames from Depends. Normal (non-bundle) path unchanged.
- .gitea/workflows/deb.yml: split into build-publish (client/web/scripting on the
  26.04 image) and build-publish-host (noble image, BUNDLE_FFMPEG=1); parallel,
  identical version step, same apt distribution -> one universal host .deb.
- .gitea/workflows/docker.yml: build+push punktfunk-rust-ci-noble.
- packaging/debian/README.md: document the 24.04 LTS path + bundled local build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:08:55 +02:00
51 changed files with 290 additions and 2999 deletions
@@ -264,11 +264,14 @@ final class SessionModel: ObservableObject {
// PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both // PyroWave (wired LAN) is a pure opt-in: picking it in the codec setting both
// advertises the bit and prefers it the host never auto-selects it, and the // advertises the bit and prefers it the host never auto-selects it, and the
// picker only offers it when the Metal decode probe passed (simdgroup floor A13; // picker only offers it when the Metal decode probe passed (simdgroup floor A13;
// every M-series Mac and the ATV 4K gen 3 pass). The decoder self-configures from // every M-series Mac and the ATV 4K gen 3 pass). The codec is 8-bit 4:2:0 SDR
// the per-frame sequence header (4:2:0/4:4:4, SDR/PQ design/pyrowave-444-hdr.md), // BT.709 by contract, so the opt-in also drops the HDR/10-bit/4:4:4 caps for this
// so the session keeps the user's HDR/10-bit/4:4:4 caps exactly like HEVC/AV1. // session HDR sessions stay HEVC/AV1 (plan §4.7).
if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported { if preferredCodec == PunktfunkConnection.codecPyroWave, MetalWaveletDecoder.supported {
videoCodecs |= PunktfunkConnection.codecPyroWave videoCodecs |= PunktfunkConnection.codecPyroWave
videoCaps &= ~(PunktfunkConnection.videoCap10Bit
| PunktfunkConnection.videoCapHDR
| PunktfunkConnection.videoCap444)
} }
let result = Result { try PunktfunkConnection( let result = Result { try PunktfunkConnection(
host: host.address, port: host.port, host: host.address, port: host.port,
@@ -14,9 +14,6 @@
#if canImport(Metal) && canImport(QuartzCore) #if canImport(Metal) && canImport(QuartzCore)
import CoreGraphics import CoreGraphics
import CoreVideo import CoreVideo
#if os(macOS)
import IOSurface
#endif
import Metal import Metal
import QuartzCore import QuartzCore
import os import os
@@ -196,11 +193,13 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
// display-referred SDR. (When the display IS in an HDR mode — requested per session via // display-referred SDR. (When the display IS in an HDR mode — requested per session via
// AVDisplayManager, see StreamViewIOS — tvOS presents pf_frag_hdr's PQ passthrough instead: // AVDisplayManager, see StreamViewIOS — tvOS presents pf_frag_hdr's PQ passthrough instead:
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.) // in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084 fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff → texture2d<float> lumaTex [[texture(0)]],
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the texture2d<float> chromaTex [[texture(1)]],
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only. constant CscUniform& csc [[buffer(0)]]) {
static inline float3 pqToSdr(float3 pq) { // YCbCr → full-range PQ RGB via the per-frame rows (as pf_frag_hdr).
float3 pq = sampleRgb(lumaTex, chromaTex, in.uv, csc);
// ST 2084 EOTF: PQ code value → linear light, 1.0 = 10,000 nits.
const float m1 = 2610.0/16384.0; const float m1 = 2610.0/16384.0;
const float m2 = 78.84375; const float m2 = 78.84375;
const float c1 = 3424.0/4096.0; const float c1 = 3424.0/4096.0;
@@ -208,49 +207,20 @@ static inline float3 pqToSdr(float3 pq) {
const float c3 = 18.6875; const float c3 = 18.6875;
float3 p = pow(pq, 1.0/m2); float3 p = pow(pq, 1.0/m2);
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1); float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
// Scene-referred with diffuse white at 1.0 (the same 203-nit anchor the EDR path uses).
float3 t = lin * (10000.0/203.0); float3 t = lin * (10000.0/203.0);
// BT.2020 → BT.709 primaries while still linear; negatives are out-of-gamut, floor them.
float3 t709 = float3( float3 t709 = float3(
dot(t, float3( 1.6605, -0.5876, -0.0728)), dot(t, float3( 1.6605, -0.5876, -0.0728)),
dot(t, float3(-0.1246, 1.1329, -0.0083)), dot(t, float3(-0.1246, 1.1329, -0.0083)),
dot(t, float3(-0.0182, -0.1006, 1.1187))); dot(t, float3(-0.0182, -0.1006, 1.1187)));
t709 = max(t709, 0.0); t709 = max(t709, 0.0);
// Extended Reinhard: 1.0 stays put, the 1000-nit knee lands at display white, above rolls off.
const float w = 1000.0/203.0; const float w = 1000.0/203.0;
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709)); float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
// BT.709 OETF — the same encoding the SDR stream arrives in, so both paths present alike.
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018); float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
return e; return float4(e, 1.0);
}
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]],
constant CscUniform& csc [[buffer(0)]]) {
// YCbCr → full-range PQ RGB via the per-frame rows (as pf_frag_hdr), then the tail.
return float4(pqToSdr(sampleRgb(lumaTex, chromaTex, in.uv, csc)), 1.0);
}
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
// fold in depth-10 MSB packing) → PQ RGB → the shared SDR tail. Used when a PQ pyrowave
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions
// (the IOSurface present path — the DCP-panic mitigation — is BGRA8). The passthrough planar
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]],
texture2d<float> cbTex [[texture(1)]],
texture2d<float> crTex [[texture(2)]],
constant CscUniform& csc [[buffer(0)]]) {
constexpr sampler s(filter::linear, address::clamp_to_edge);
#ifdef PF_BILINEAR_LUMA
float lumaY = lumaTex.sample(s, in.uv).r;
#else
float lumaY = catmullRomLuma(lumaTex, s, in.uv);
#endif
float2 cuv = chromaUV(lumaTex, cbTex, in.uv);
float3 yuv = float3(lumaY, cbTex.sample(s, cuv).r, crTex.sample(s, cuv).r);
float3 pq = saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
dot(csc.r1.xyz, yuv) + csc.r1.w,
dot(csc.r2.xyz, yuv) + csc.r2.w));
return float4(pqToSdr(pq), 1.0);
} }
""" """
@@ -258,51 +228,6 @@ public final class MetalVideoPresenter {
/// The layer the hosting view installs (as a sublayer) and sizes to its bounds. /// The layer the hosting view installs (as a sublayer) and sizes to its bounds.
public let layer: CAMetalLayer 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 device: MTLDevice
private let queue: MTLCommandQueue private let queue: MTLCommandQueue
/// SDR (BT.709 8-bit bgra8) and HDR (BT.2020 PQ 10-bit rgba16Float) pipelines. Selected per /// SDR (BT.709 8-bit bgra8) and HDR (BT.2020 PQ 10-bit rgba16Float) pipelines. Selected per
@@ -314,11 +239,6 @@ public final class MetalVideoPresenter {
private let pipelineHDRToneMap: MTLRenderPipelineState? private let pipelineHDRToneMap: MTLRenderPipelineState?
/// PyroWave's 3-plane SDR path (pf_frag_planar bgra8) see `renderPlanar`. /// PyroWave's 3-plane SDR path (pf_frag_planar bgra8) see `renderPlanar`.
private let pipelinePlanar: MTLRenderPipelineState private let pipelinePlanar: MTLRenderPipelineState
/// PyroWave planar HDR passthrough (pf_frag_planar rgba16Float; the layer's PQ colour
/// space + EDR interpret the samples) and the planar PQSDR tone-map (pf_frag_planar_tm
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents).
private let pipelinePlanarHDR: MTLRenderPipelineState
private let pipelinePlanarToneMap: MTLRenderPipelineState
private var textureCache: CVMetalTextureCache? private var textureCache: CVMetalTextureCache?
/// The PyroWave Metal decoder records on the presenter's device + queue: one device means /// The PyroWave Metal decoder records on the presenter's device + queue: one device means
@@ -369,8 +289,6 @@ public final class MetalVideoPresenter {
let pipelineHDR: MTLRenderPipelineState let pipelineHDR: MTLRenderPipelineState
let pipelineHDRToneMap: MTLRenderPipelineState? let pipelineHDRToneMap: MTLRenderPipelineState?
let pipelinePlanar: MTLRenderPipelineState let pipelinePlanar: MTLRenderPipelineState
let pipelinePlanarHDR: MTLRenderPipelineState
let pipelinePlanarToneMap: MTLRenderPipelineState
do { do {
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF // DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is // (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
@@ -408,18 +326,8 @@ public final class MetalVideoPresenter {
let planar = MTLRenderPipelineDescriptor() let planar = MTLRenderPipelineDescriptor()
planar.vertexFunction = vtx planar.vertexFunction = vtx
planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar") planar.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
planar.colorAttachments[0].pixelFormat = .bgra8Unorm planar.colorAttachments[0].pixelFormat = .bgra8Unorm // PyroWave is 8-bit SDR
pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar) pipelinePlanar = try device.makeRenderPipelineState(descriptor: planar)
let planarHdr = MTLRenderPipelineDescriptor()
planarHdr.vertexFunction = vtx
planarHdr.fragmentFunction = library.makeFunction(name: "pf_frag_planar")
planarHdr.colorAttachments[0].pixelFormat = .rgba16Float // PQ passthrough
pipelinePlanarHDR = try device.makeRenderPipelineState(descriptor: planarHdr)
let planarTm = MTLRenderPipelineDescriptor()
planarTm.vertexFunction = vtx
planarTm.fragmentFunction = library.makeFunction(name: "pf_frag_planar_tm")
planarTm.colorAttachments[0].pixelFormat = .bgra8Unorm
pipelinePlanarToneMap = try device.makeRenderPipelineState(descriptor: planarTm)
} catch { } catch {
return nil return nil
} }
@@ -460,7 +368,6 @@ public final class MetalVideoPresenter {
return MetalVideoPresenter( return MetalVideoPresenter(
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR, device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar, pipelineHDRToneMap: pipelineHDRToneMap, pipelinePlanar: pipelinePlanar,
pipelinePlanarHDR: pipelinePlanarHDR, pipelinePlanarToneMap: pipelinePlanarToneMap,
textureCache: textureCache, layer: layer) textureCache: textureCache, layer: layer)
} }
@@ -468,8 +375,6 @@ public final class MetalVideoPresenter {
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState, device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?, pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
pipelinePlanar: MTLRenderPipelineState, pipelinePlanar: MTLRenderPipelineState,
pipelinePlanarHDR: MTLRenderPipelineState,
pipelinePlanarToneMap: MTLRenderPipelineState,
textureCache: CVMetalTextureCache, layer: CAMetalLayer textureCache: CVMetalTextureCache, layer: CAMetalLayer
) { ) {
self.device = device self.device = device
@@ -478,8 +383,6 @@ public final class MetalVideoPresenter {
self.pipelineHDR = pipelineHDR self.pipelineHDR = pipelineHDR
self.pipelineHDRToneMap = pipelineHDRToneMap self.pipelineHDRToneMap = pipelineHDRToneMap
self.pipelinePlanar = pipelinePlanar self.pipelinePlanar = pipelinePlanar
self.pipelinePlanarHDR = pipelinePlanarHDR
self.pipelinePlanarToneMap = pipelinePlanarToneMap
self.textureCache = textureCache self.textureCache = textureCache
self.layer = layer self.layer = layer
} }
@@ -590,18 +493,6 @@ public final class MetalVideoPresenter {
stagingLock.unlock() 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; /// 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` /// `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 /// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the
@@ -697,51 +588,12 @@ public final class MetalVideoPresenter {
) -> Bool { ) -> Bool {
stagingLock.lock() stagingLock.lock()
let targetFromLayout = drawableTarget let targetFromLayout = drawableTarget
#if os(macOS)
let surfaceMode = surfacePresentsStaged
#endif
stagingLock.unlock() stagingLock.unlock()
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path; configure(hdr: false)
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
#if os(macOS)
configure(hdr: planes.pq && !surfaceMode)
#else
configure(hdr: planes.pq)
#endif
var csc = planes.csc 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
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
// 8-bit tvOS without display headroom, or a not-yet-flipped layer tone-maps
// in-shader instead (the pipeline must match the drawable's pixel format).
#if os(tvOS)
let planarPassthrough = hdrActive && hdrPassthroughActive
#else
let planarPassthrough = hdrActive
#endif
let planarPipeline: MTLRenderPipelineState =
planes.pq
? (planarPassthrough ? pipelinePlanarHDR : pipelinePlanarToneMap)
: pipelinePlanar
return encodePresent( return encodePresent(
decodedSize: CGSize(width: planes.width, height: planes.height), decodedSize: CGSize(width: planes.width, height: planes.height),
targetFromLayout: targetFromLayout, pipeline: planarPipeline, targetFromLayout: targetFromLayout, pipeline: pipelinePlanar,
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented, presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
// The ring textures stay valid by ring depth; retaining them here also pins the // The ring textures stay valid by ring depth; retaining them here also pins the
// slot's set until the sample completes (mirrors the biplanar keep-alive). // slot's set until the sample completes (mirrors the biplanar keep-alive).
@@ -754,118 +606,6 @@ 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(planes.pq ? pipelinePlanarToneMap : 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 /// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule /// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
/// the present and the on-glass callback. /// the present and the on-glass callback.
@@ -47,9 +47,6 @@ struct WaveletLayout {
let width: Int let width: Int
let height: Int let height: Int
/// Full-res chroma (4:4:4): chroma components get the full band set including level 0,
/// exactly like luma upstream `init_block_meta` with `Chroma444`.
let chroma444: Bool
let alignedWidth: Int let alignedWidth: Int
let alignedHeight: Int let alignedHeight: Int
/// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset = /// blockMeta[component][level][band] = (blockOffset32x32, blockStride32x32); -1 offset =
@@ -62,10 +59,9 @@ struct WaveletLayout {
func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level } func levelWidth(_ level: Int) -> Int { (alignedWidth / 2) >> level }
func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level } func levelHeight(_ level: Int) -> Int { (alignedHeight / 2) >> level }
init(width: Int, height: Int, chroma444: Bool) { init(width: Int, height: Int) {
self.width = width self.width = width
self.height = height self.height = height
self.chroma444 = chroma444
let align = { (v: Int) in let align = { (v: Int) in
max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize) max((v + Self.alignment - 1) & ~(Self.alignment - 1), Self.minimumImageSize)
} }
@@ -82,7 +78,7 @@ struct WaveletLayout {
let ah = alignedHeight let ah = alignedHeight
for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) { for level in stride(from: Self.decompositionLevels - 1, through: 0, by: -1) {
for component in 0..<3 { for component in 0..<3 {
if level == 0 && component != 0 && !chroma444 { continue } // 4:2:0: no top-level chroma if level == 0 && component != 0 { continue } // 4:2:0: no top-level chroma
for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 { for band in (level == Self.decompositionLevels - 1 ? 0 : 1)..<4 {
let levelW = (aw / 2) >> level let levelW = (aw / 2) >> level
let levelH = (ah / 2) >> level let levelH = (ah / 2) >> level
@@ -112,9 +108,6 @@ struct ParsedWaveletFrame {
var decodedBlocks: Int var decodedBlocks: Int
/// VUI bits from the sequence header (BitstreamSequenceHeader). /// VUI bits from the sequence header (BitstreamSequenceHeader).
var bt2020: Bool var bt2020: Bool
/// PQ transfer HDR session: 16-bit studio-code planes + EDR present (the host stamps
/// this bit iff the session negotiated 10-bit the depth is coupled to the transfer).
var pq: Bool
var fullRange: Bool var fullRange: Bool
/// The frame's YCbCrRGB signal for the presenter's planar CSC. PyroWave today is always /// The frame's YCbCrRGB signal for the presenter's planar CSC. PyroWave today is always
@@ -210,7 +203,6 @@ enum WaveletBitstream {
var totalBlocks = 0 var totalBlocks = 0
var decodedBlocks = 0 var decodedBlocks = 0
var bt2020 = false var bt2020 = false
var pq = false
var fullRange = false var fullRange = false
var sawSOF = false var sawSOF = false
@@ -228,28 +220,22 @@ enum WaveletBitstream {
// siting[31]. // siting[31].
let code = (word1 >> 24) & 0x3 let code = (word1 >> 24) & 0x3
guard code == 0 else { return false } // only START_OF_FRAME is defined guard code == 0 else { return false } // only START_OF_FRAME is defined
let chroma444 = (word1 >> 26) & 1 != 0 let chromaRes = (word1 >> 26) & 1
guard chromaRes == 0 else { return false } // host contract: 4:2:0
let w = Int(word0 & 0x3fff) + 1 let w = Int(word0 & 0x3fff) + 1
let h = Int((word0 >> 14) & 0x3fff) + 1 let h = Int((word0 >> 14) & 0x3fff) + 1
guard w >= 2, h >= 2, chroma444 || (w % 2 == 0 && h % 2 == 0) else { guard w >= 2, h >= 2, w % 2 == 0, h % 2 == 0 else { return false }
return false
}
if sawSOF { if sawSOF {
// One frame, one geometry a second SOF must agree. // One frame, one geometry a second SOF must agree.
guard layout?.width == w, layout?.height == h, guard layout?.width == w, layout?.height == h else { return false }
layout?.chroma444 == chroma444
else { return false }
} else { } else {
sawSOF = true sawSOF = true
let l = WaveletLayout(width: w, height: h, chroma444: chroma444) let l = WaveletLayout(width: w, height: h)
layout = l layout = l
offsets = [UInt32](repeating: .max, count: l.blockCount32) offsets = [UInt32](repeating: .max, count: l.blockCount32)
payload.reserveCapacity(64 * 1024 / 4) payload.reserveCapacity(64 * 1024 / 4)
totalBlocks = Int(word1 & 0xff_ffff) totalBlocks = Int(word1 & 0xff_ffff)
bt2020 = (word1 >> 29) & 1 != 0 bt2020 = (word1 >> 29) & 1 != 0
// transfer_function bit: PQ an HDR session (16-bit studio-code
// planes by the negotiated coupling design/pyrowave-444-hdr.md).
pq = (word1 >> 28) & 1 != 0
fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0 fullRange = (word1 >> 30) & 1 == 0 // YCBCR_RANGE_FULL = 0
} }
pos += 8 pos += 8
@@ -294,7 +280,7 @@ enum WaveletBitstream {
return ParsedWaveletFrame( return ParsedWaveletFrame(
layout: layout, offsets: offsets, payload: payload, layout: layout, offsets: offsets, payload: payload,
totalBlocks: totalBlocks, decodedBlocks: decodedBlocks, totalBlocks: totalBlocks, decodedBlocks: decodedBlocks,
bt2020: bt2020, pq: pq, fullRange: fullRange) bt2020: bt2020, fullRange: fullRange)
} }
} }
} }
@@ -307,8 +293,6 @@ public struct WaveletPlanes: @unchecked Sendable {
public let cb: MTLTexture public let cb: MTLTexture
public let cr: MTLTexture public let cr: MTLTexture
public let csc: CscUniform public let csc: CscUniform
/// PQ (HDR) stream: the presenter picks the HDR/tone-map planar pipeline + EDR config.
public let pq: Bool
public var width: Int { y.width } public var width: Int { y.width }
public var height: Int { y.height } public var height: Int { y.height }
} }
@@ -367,8 +351,6 @@ public final class MetalWaveletDecoder {
private var slots: [Slot] = [] private var slots: [Slot] = []
private var nextSlot = 0 private var nextSlot = 0
/// The ring's plane format facts (from the last SOF): PQ 16-bit UNORM planes.
private var hdr16 = false
/// The current geometry (from the last SOF that built the resources) the pump reports /// The current geometry (from the last SOF that built the resources) the pump reports
/// decoded-size changes to the resize overlay from this. PUMP THREAD. /// decoded-size changes to the resize overlay from this. PUMP THREAD.
@@ -427,9 +409,8 @@ public final class MetalWaveletDecoder {
au: au, chunkAligned: chunkAligned, windowSize: windowSize) au: au, chunkAligned: chunkAligned, windowSize: windowSize)
else { return false } else { return false }
if layout?.width != frame.layout.width || layout?.height != frame.layout.height if layout?.width != frame.layout.width || layout?.height != frame.layout.height {
|| layout?.chroma444 != frame.layout.chroma444 || hdr16 != frame.pq { guard rebuild(layout: frame.layout) else { return false }
guard rebuild(layout: frame.layout, hdr16: frame.pq) else { return false }
} }
guard let layout, !slots.isEmpty else { return false } guard let layout, !slots.isEmpty else { return false }
@@ -469,7 +450,7 @@ public final class MetalWaveletDecoder {
dequant.setBuffer(slot.payload, offset: 0, index: 1) dequant.setBuffer(slot.payload, offset: 0, index: 1)
for level in 0..<WaveletLayout.decompositionLevels { for level in 0..<WaveletLayout.decompositionLevels {
for component in 0..<3 { for component in 0..<3 {
if level == 0 && component != 0 && !layout.chroma444 { continue } // 4:2:0 if level == 0 && component != 0 { continue } // 4:2:0
for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 { for band in (level == WaveletLayout.decompositionLevels - 1 ? 0 : 1)..<4 {
let meta = layout.blockMeta[component][level][band] let meta = layout.blockMeta[component][level][band]
let w = layout.levelWidth(level) let w = layout.levelWidth(level)
@@ -508,20 +489,15 @@ public final class MetalWaveletDecoder {
let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1) let grid = MTLSize(width: (rx + 15) / 16, height: (ry + 15) / 16, depth: 1)
let group = MTLSize(width: 64, height: 1, depth: 1) let group = MTLSize(width: 64, height: 1, depth: 1)
if inputLevel == 0 { if inputLevel == 0 {
// Final full-res pass: luma only in 4:2:0 (chroma finished at level 1); all // 4:2:0: the final full-res pass is luma only (chroma finished at level 1).
// three components in 4:4:4 (chroma runs the full pyramid like luma).
idwt.setComputePipelineState(idwtShiftPipeline) idwt.setComputePipelineState(idwtShiftPipeline)
let components = layout.chroma444 ? 3 : 1 idwt.setTexture(coefficients[0][0], index: 0)
for component in 0..<components { idwt.setTexture(slot.y, index: 1)
idwt.setTexture(coefficients[component][0], index: 0) idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
let out = component == 0 ? slot.y : (component == 1 ? slot.cb : slot.cr)
idwt.setTexture(out, index: 1)
idwt.dispatchThreadgroups(grid, threadsPerThreadgroup: group)
}
} else { } else {
for component in 0..<3 { for component in 0..<3 {
idwt.setTexture(coefficients[component][inputLevel], index: 0) idwt.setTexture(coefficients[component][inputLevel], index: 0)
if component != 0 && inputLevel == 1 && !layout.chroma444 { if component != 0 && inputLevel == 1 {
// 4:2:0 chroma emits its final half-res plane one level early. // 4:2:0 chroma emits its final half-res plane one level early.
idwt.setComputePipelineState(idwtShiftPipeline) idwt.setComputePipelineState(idwtShiftPipeline)
idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1) idwt.setTexture(component == 1 ? slot.cb : slot.cr, index: 1)
@@ -537,9 +513,7 @@ public final class MetalWaveletDecoder {
let planes = WaveletPlanes( let planes = WaveletPlanes(
y: slot.y, cb: slot.cb, cr: slot.cr, y: slot.y, cb: slot.cb, cr: slot.cr,
csc: CscRows.rows( csc: CscRows.rows(frame.cscSignal, depth: 8, msbPacked: false))
frame.cscSignal, depth: frame.pq ? 10 : 8, msbPacked: frame.pq),
pq: frame.pq)
cmd.addCompletedHandler { buffer in cmd.addCompletedHandler { buffer in
completion(buffer.error == nil ? planes : nil) completion(buffer.error == nil ? planes : nil)
} }
@@ -550,9 +524,9 @@ public final class MetalWaveletDecoder {
/// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream /// (Re)allocate every size-dependent resource for `layout`'s geometry. Also the mid-stream
/// resize path: a Reconfigure shows up here as new SOF dims. /// resize path: a Reconfigure shows up here as new SOF dims.
private func rebuild(layout newLayout: WaveletLayout, hdr16 newHdr16: Bool) -> Bool { private func rebuild(layout newLayout: WaveletLayout) -> Bool {
waveletLog.info( waveletLog.info(
"pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks, \(newLayout.chroma444 ? "4:4:4" : "4:2:0", privacy: .public)\(newHdr16 ? " HDR16" : "", privacy: .public))") "pyrowave: building decoder \(newLayout.width)x\(newLayout.height) (aligned \(newLayout.alignedWidth)x\(newLayout.alignedHeight), \(newLayout.blockCount32) blocks)")
var coeff: [[MTLTexture]] = [] var coeff: [[MTLTexture]] = []
var lls: [[MTLTexture]] = [] var lls: [[MTLTexture]] = []
for component in 0..<3 { for component in 0..<3 {
@@ -586,22 +560,19 @@ public final class MetalWaveletDecoder {
var newSlots: [Slot] = [] var newSlots: [Slot] = []
for i in 0..<Self.ringDepth { for i in 0..<Self.ringDepth {
let planeFormat: MTLPixelFormat = newHdr16 ? .r16Unorm : .r8Unorm
let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in let plane = { (w: Int, h: Int, name: String) -> MTLTexture? in
let desc = MTLTextureDescriptor.texture2DDescriptor( let desc = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: planeFormat, width: w, height: h, mipmapped: false) pixelFormat: .r8Unorm, width: w, height: h, mipmapped: false)
desc.usage = [.shaderRead, .shaderWrite] desc.usage = [.shaderRead, .shaderWrite]
desc.storageMode = .private desc.storageMode = .private
let t = self.device.makeTexture(descriptor: desc) let t = self.device.makeTexture(descriptor: desc)
t?.label = name t?.label = name
return t return t
} }
let cw = newLayout.chroma444 ? newLayout.width : newLayout.width / 2
let ch = newLayout.chroma444 ? newLayout.height : newLayout.height / 2
guard guard
let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"), let y = plane(newLayout.width, newLayout.height, "pyrowave Y[\(i)]"),
let cb = plane(cw, ch, "pyrowave Cb[\(i)]"), let cb = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cb[\(i)]"),
let cr = plane(cw, ch, "pyrowave Cr[\(i)]"), let cr = plane(newLayout.width / 2, newLayout.height / 2, "pyrowave Cr[\(i)]"),
let offsets = device.makeBuffer( let offsets = device.makeBuffer(
length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared), length: max(newLayout.blockCount32 * 4, 4), options: .storageModeShared),
let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared) let payload = device.makeBuffer(length: 64 * 1024, options: .storageModeShared)
@@ -614,7 +585,6 @@ public final class MetalWaveletDecoder {
slots = newSlots slots = newSlots
nextSlot = 0 nextSlot = 0
layout = newLayout layout = newLayout
hdr16 = newHdr16
return true return true
} }
@@ -42,20 +42,12 @@ enum PresenterChoice: Equatable {
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the /// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
/// freeze-prone fallback. /// freeze-prone fallback.
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice { 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 let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw { switch raw {
case "stage1": return allowStage1 ? .stage1 : nil case "stage1": return allowStage1 ? .stage1 : platformDefault
case "stage2": return .stage2 case "stage2": return .stage2
case "stage3": return .stage3 case "stage3": return .stage3
default: return nil default: return platformDefault
} }
} }
@@ -74,41 +66,10 @@ enum PresenterChoice: Equatable {
} }
final class SessionPresenter { 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 pump: StreamPump?
private var stage2: Stage2Pipeline? private var stage2: Stage2Pipeline?
private var stage2Link: CADisplayLink? private var stage2Link: CADisplayLink?
private var metalLayer: CAMetalLayer? 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? private var connection: PunktfunkConnection?
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's /// 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 /// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
@@ -151,29 +112,20 @@ final class SessionPresenter {
#else #else
let allowStage1 = false let allowStage1 = false
#endif #endif
let explicit = PresenterChoice.explicit( let choice = PresenterChoice.resolve(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter), setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"], env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
allowStage1: allowStage1) allowStage1: allowStage1)
let choice = explicit ?? PresenterChoice.platformDefault
if choice != .stage1, if choice != .stage1,
let pipeline = Stage2Pipeline( let pipeline = Stage2Pipeline(
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter, endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter, displayMeter: displayMeter,
pacing: Self.pacing( pacing: choice == .stage3 ? .glass : .arrival) {
for: choice, explicit: explicit, codec: connection.videoCodec)) {
let metal = pipeline.layer let metal = pipeline.layer
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which // The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout(). // sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
baseLayer.addSublayer(metal) baseLayer.addSublayer(metal)
metalLayer = 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 stage2 = pipeline
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger // The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
// (frame arrival is see Stage2Pipeline's header). timestamptargetTimestamp is the // (frame arrival is see Stage2Pipeline's header). timestamptargetTimestamp is the
@@ -272,12 +224,6 @@ final class SessionPresenter {
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
metalLayer.contentsScale = contentsScale metalLayer.contentsScale = contentsScale
metalLayer.frame = snapped 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() CATransaction.commit()
// Hand the resulting pixel size to the render thread (it must not read layer geometry // 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 // cross-thread) this is what the presenter sizes its drawable to. Uses the SNAPPED size so
@@ -305,31 +251,6 @@ final class SessionPresenter {
contentSize = size 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 /// 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. /// stage-2 layer + link. Does not close the connection that stays with whoever owns it.
/// Idempotent. /// Idempotent.
@@ -343,11 +264,6 @@ final class SessionPresenter {
stage2 = nil stage2 = nil
metalLayer?.removeFromSuperlayer() metalLayer?.removeFromSuperlayer()
metalLayer = nil metalLayer = nil
#if os(macOS)
surfaceLayer?.removeFromSuperlayer()
surfaceLayer = nil
surfacePresentsActive = false
#endif
connection = nil connection = nil
} }
@@ -122,11 +122,6 @@ private final class VsyncClock: @unchecked Sendable {
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before /// 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 /// present instead of queueing them behind the display the hidden queue latency becomes
/// explicit, correct frame drops. /// 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 { public enum PresentPacing: Sendable {
case arrival case arrival
case glass case glass
@@ -630,18 +625,6 @@ public final class Stage2Pipeline {
presenter.setDrawableTarget(size) 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` /// 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 /// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
/// it; see `MetalVideoPresenter.setDisplayHeadroom`. /// it; see `MetalVideoPresenter.setDisplayHeadroom`.
@@ -692,11 +692,6 @@ public final class StreamLayerView: NSView {
/// the view's physical-pixel size (bounds backing), so a window resize / retina move follows. /// the view's physical-pixel size (bounds backing), so a window resize / retina move follows.
private func layoutPresenter() { private func layoutPresenter() {
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1) 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 // 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. // bounds a pre-window layout would report point-sized dimensions.
if window != nil, bounds.width > 0, bounds.height > 0 { if window != nil, bounds.width > 0, bounds.height > 0 {
@@ -79,44 +79,5 @@ final class PresentPacingTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2) 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 #endif
@@ -57,7 +57,7 @@ final class PyroWaveParserTests: XCTestCase {
func testLayoutMatchesUpstreamBlockSpace() { func testLayoutMatchesUpstreamBlockSpace() {
// init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from // init_block_meta's walk for 256x144 (aligned 256x160): level extents halve from
// 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4). // 128x80; per (comp,level,band) count32 = ceil(ceil(w/8)/4) * ceil(ceil(h/8)/4).
let layout = WaveletLayout(width: width, height: height, chroma444: false) let layout = WaveletLayout(width: width, height: height)
XCTAssertEqual(layout.alignedWidth, 256) XCTAssertEqual(layout.alignedWidth, 256)
XCTAssertEqual(layout.alignedHeight, 160) XCTAssertEqual(layout.alignedHeight, 160)
XCTAssertEqual(layout.levelWidth(0), 128) XCTAssertEqual(layout.levelWidth(0), 128)
@@ -85,7 +85,7 @@ final class PyroWaveParserTests: XCTestCase {
} }
func testDenseParseFillsOffsetsAndCountsBlocks() throws { func testDenseParseFillsOffsetsAndCountsBlocks() throws {
let layout = WaveletLayout(width: width, height: height, chroma444: false) let layout = WaveletLayout(width: width, height: height)
var au = sof(totalBlocks: 4) var au = sof(totalBlocks: 4)
au += packet(blockIndex: 0) au += packet(blockIndex: 0)
au += packet(blockIndex: 3) au += packet(blockIndex: 3)
@@ -273,17 +273,6 @@ final class PyroWaveGoldenTests: XCTestCase {
try assertMatchesReference(decoded, prefix: "ref-chunked") try assertMatchesReference(decoded, prefix: "ref-chunked")
} }
/// 4:4:4: the chroma components run the full pyramid like luma (no level-0 skip, no
/// early half-res emit) the layout + dispatch structure Phase 4 added
/// (design/pyrowave-444-hdr.md). The fixture comes from the 4:4:4 host encoder; the
/// reference is upstream's own 4:4:4 decode (full-res chroma planes).
func testDense444GoldenFrame() throws {
try XCTSkipIf(!MetalWaveletDecoder.supported, "no capable Metal device")
let au = try fixture("au-dense444")
let decoded = try decode(au: au, chunkAligned: false, windowSize: 0)
try assertMatchesReference(decoded, prefix: "ref-dense444")
}
/// Phase-4 partial delivery: zero a mid-AU window (a lost shard) the frame must still /// Phase-4 partial delivery: zero a mid-AU window (a lost shard) the frame must still
/// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as /// decode (blocks > half) and stay recognizably the same picture (holes reconstruct as
/// localized blur, not garbage). /// localized blur, not garbage).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -11
View File
@@ -365,18 +365,9 @@ pub fn open_idd_push(
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
want_444: bool, want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>, keepalive: Box<dyn Send>,
sender: FrameChannelSender, sender: FrameChannelSender,
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> { ) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
idd_push::IddPushCapturer::open( idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender)
target, .map(|c| Box::new(c) as Box<dyn Capturer>)
preferred,
client_10bit,
want_444,
pyrowave,
keepalive,
sender,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
} }
+15 -238
View File
@@ -12,7 +12,7 @@
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget}; pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, WinCaptureTarget};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::ffi::c_void; use std::ffi::c_void;
@@ -466,229 +466,6 @@ 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);
}
";
/// PyroWave 4:4:4 CHROMA pass PS — FULL-res, per-pixel (no box filter, no siting), the Windows twin
/// of the Linux `rgb2yuv444.comp` chroma math.
const PYRO_UV444_PS: &str = r"
Texture2D<float4> tx : register(t0);
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
float3 c = tx.Load(int3(int2(pos.xy), 0)).rgb;
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;
return float2(u, v);
}
";
/// Shared HLSL for the PyroWave **HDR** passes: scRGB FP16 → PQ-encoded BT.2020 → 10-bit studio
/// codes MSB-packed into 16-bit UNORM — the SAME colour math as [`HDR_P010_COMMON`] (verified by
/// `hdr_p010_selftest`), restated over `Load`ed texels so the pyrowave passes stay texel-exact like
/// their SDR twins. The wavelet client decodes these planes with the same CSC rows as the P010 path.
const PYRO_HDR_COMMON: &str = r"
Texture2D<float4> tx : register(t0);
static const float3x3 BT709_TO_BT2020 = {
0.627403914, 0.329283038, 0.043313048,
0.069097292, 0.919540405, 0.011362303,
0.016391439, 0.088013308, 0.895595253
};
float3 pq_oetf(float3 L) {
const float m1 = 0.1593017578125;
const float m2 = 78.84375;
const float c1 = 0.8359375;
const float c2 = 18.8515625;
const float c3 = 18.6875;
float3 Lp = pow(saturate(L), m1);
return pow((c1 + c2 * Lp) / (1.0 + c3 * Lp), m2);
}
float3 scrgb_to_pq2020_rgb(float3 scrgb) {
float3 nits = max(scrgb, 0.0) * 80.0;
return pq_oetf(mul(BT709_TO_BT2020, nits) / 10000.0);
}
static const float KR = 0.2627;
static const float KG = 0.6780;
static const float KB = 0.0593;
float y_unorm(float3 pq) {
float y = KR * pq.r + KG * pq.g + KB * pq.b;
float code = clamp(64.0 + 876.0 * y, 64.0, 940.0);
return (code * 64.0) / 65535.0;
}
float2 cbcr_unorm(float3 pq) {
float y = KR * pq.r + KG * pq.g + KB * pq.b;
float cbc = clamp(512.0 + 896.0 * (pq.b - y) / 1.8814, 64.0, 960.0);
float crc = clamp(512.0 + 896.0 * (pq.r - y) / 1.4746, 64.0, 960.0);
return float2((cbc * 64.0) / 65535.0, (crc * 64.0) / 65535.0);
}
";
/// PyroWave HDR LUMA pass PS — full-res, writes PQ Y studio codes to an `R16_UNORM` texture.
const PYRO_HDR_Y_PS: &str = r"
#include_common
float main(float4 pos : SV_POSITION) : SV_TARGET {
float3 pq = scrgb_to_pq2020_rgb(tx.Load(int3(int2(pos.xy), 0)).rgb);
return y_unorm(pq);
}
";
/// PyroWave HDR 4:2:0 CHROMA pass PS — half-res, centre-sited 2×2 box in scRGB-LINEAR space (the
/// pyrowave family's siting, matching the SDR pass + `rgb2yuv.comp`, NOT the P010 path's
/// left-cositing), then PQ + studio Cb/Cr into an `R16G16_UNORM` texture.
const PYRO_HDR_UV_PS: &str = r"
#include_common
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
int2 p = int2(pos.xy) * 2;
float3 a = max(tx.Load(int3(p, 0)).rgb, 0.0);
float3 b = max(tx.Load(int3(p + int2(1,0), 0)).rgb, 0.0);
float3 c = max(tx.Load(int3(p + int2(0,1), 0)).rgb, 0.0);
float3 d = max(tx.Load(int3(p + int2(1,1), 0)).rgb, 0.0);
float3 pq = scrgb_to_pq2020_rgb((a + b + c + d) * 0.25);
return cbcr_unorm(pq);
}
";
/// PyroWave HDR 4:4:4 CHROMA pass PS — full-res, per-pixel.
const PYRO_HDR_UV444_PS: &str = r"
#include_common
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
float3 pq = scrgb_to_pq2020_rgb(tx.Load(int3(int2(pos.xy), 0)).rgb);
return cbcr_unorm(pq);
}
";
/// scRGB/BGRA → **separate** YUV planes for the PyroWave wavelet encoder: a full-res Y texture + a
/// (half- or full-res) interleaved CbCr texture (design/pyrowave-windows-host-zerocopy.md +
/// design/pyrowave-444-hdr.md). SDR mode reads the BGRA slot and writes BT.709-limited 8-bit planes
/// (`R8_UNORM`/`R8G8_UNORM`), byte-identical to the Linux `rgb2yuv(444).comp`; HDR mode reads the
/// scRGB FP16 slot and writes P010-style 10-bit studio codes MSB-packed into 16-bit planes
/// (`R16_UNORM`/`R16G16_UNORM`), colour math identical to [`HdrP010Converter`]. 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, whereas simple single/
/// two-component textures import reliably. 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,
/// Full-res chroma pass (4:4:4) — the chroma viewport skips the /2.
chroma444: bool,
}
impl BgraToYuvPlanes {
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
let (y_src, uv_src) = match (hdr, chroma444) {
(false, false) => (PYRO_Y_PS.to_string(), PYRO_UV_PS.to_string()),
(false, true) => (PYRO_Y_PS.to_string(), PYRO_UV444_PS.to_string()),
(true, false) => (
PYRO_HDR_Y_PS.replace("#include_common", PYRO_HDR_COMMON),
PYRO_HDR_UV_PS.replace("#include_common", PYRO_HDR_COMMON),
),
(true, true) => (
PYRO_HDR_Y_PS.replace("#include_common", PYRO_HDR_COMMON),
PYRO_HDR_UV444_PS.replace("#include_common", PYRO_HDR_COMMON),
),
};
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
let yb = compile_shader(&y_src, s!("main"), s!("ps_5_0"))?;
let uvb = compile_shader(&uv_src, 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")?,
chroma444,
})
}
/// Convert `src_srv` (BGRA slot for SDR / scRGB FP16 slot for HDR, WxH) → `y_rtv` (full-res Y
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
/// passes; `w`/`h` are the full luma dims (even for 4:2:0).
#[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 (4:2:0) or full-res (4:4:4) → the CbCr texture.
let (cw, ch) = if self.chroma444 {
(w, h)
} else {
(w / 2, h / 2)
};
ctx.RSSetViewports(Some(&[D3D11_VIEWPORT {
TopLeftX: 0.0,
TopLeftY: 0.0,
Width: cw as f32,
Height: ch 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`]. /// 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 /// 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. /// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
@@ -1052,7 +829,8 @@ use windows::Win32::Graphics::Direct3D11::{
}; };
use windows::Win32::Graphics::Dxgi::Common::{ 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_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_RATIONAL, DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, 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 /// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT
@@ -1068,17 +846,12 @@ pub(crate) struct VideoConverter {
} }
impl 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( pub(crate) unsafe fn new(
device: &ID3D11Device, device: &ID3D11Device,
context: &ID3D11DeviceContext, context: &ID3D11DeviceContext,
width: u32, width: u32,
height: u32, height: u32,
scrgb_input: bool, hdr: bool,
) -> Result<Self> { ) -> Result<Self> {
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?; let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?; let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?;
@@ -1103,15 +876,19 @@ impl VideoConverter {
.CreateVideoProcessor(&enumr, 0) .CreateVideoProcessor(&enumr, 0)
.context("CreateVideoProcessor")?; .context("CreateVideoProcessor")?;
// Full-range RGB in → studio-range BT.709 NV12 out. Input gamma follows the ring format: // Full-range RGB in → studio-range YUV out. HDR: scRGB linear (G10) → BT.2020 PQ (G2084).
// scRGB linear (G10) for the FP16 HDR ring, sRGB (G22) for the 8-bit BGRA SDR ring. The // SDR: sRGB (G22) → BT.709 (G22).
// output is always BT.709 SDR (the video processor tone-maps the scRGB case). let (in_cs, out_cs) = if hdr {
let in_cs = if scrgb_input { (
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709 DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709,
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
)
} else { } else {
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709 (
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
)
}; };
let out_cs = DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709;
vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs); vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs);
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs); vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
// One frame in, one frame out — no interpolation/auto-processing. // One frame in, one frame out — no interpolation/auto-processing.
+26 -398
View File
@@ -19,10 +19,7 @@
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{ use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, PyroFrameShare, VideoConverter,
WinCaptureTarget,
};
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use pf_driver_proto::{control, frame}; use pf_driver_proto::{control, frame};
@@ -36,16 +33,13 @@ use windows::Win32::Foundation::{
HANDLE, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0, HANDLE, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0,
}; };
use windows::Win32::Graphics::Direct3D11::{ use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence, ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
ID3D11RenderTargetView, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
D3D11_BIND_SHADER_RESOURCE, D3D11_FENCE_FLAG_SHARED, D3D11_RESOURCE_MISC_SHARED, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
}; };
use windows::Win32::Graphics::Dxgi::Common::{ use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
}; };
use windows::Win32::Graphics::Dxgi::{ use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1, CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
@@ -148,18 +142,6 @@ struct HostSlot {
srv: ID3D11ShaderResourceView, 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)`, /// 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 /// `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 /// of the guard's scope `?`-returns or panics — the "leak the keyed-mutex lock → stall the driver on
@@ -409,32 +391,6 @@ pub struct IddPushCapturer {
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source): /// 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. /// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
want_444: bool, want_444: bool,
/// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md +
/// design/pyrowave-444-hdr.md). When set, frames come from the separate-plane `pyro_ring`
/// (shareable Y + CbCr textures the mode-aware [`BgraToYuvPlanes`] CSC writes) and a **shared
/// fence** is signalled after each convert, so the pyrowave encoder zero-copy-imports the two
/// textures into its own Vulkan device ordered after the D3D11 convert. The composition is
/// PINNED to the negotiated depth: SDR sessions force advanced color OFF (8-bit BGRA → R8
/// planes), 10-bit sessions enable it like H.26x (scRGB FP16 → R16 studio-code planes);
/// `want_444` sizes the chroma plane full-res.
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 /// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads
/// its snapshot instead of running CCD queries inline on the frame path. /// its snapshot instead of running CCD queries inline on the frame path.
desc_poller: DescriptorPoller, desc_poller: DescriptorPoller,
@@ -600,20 +556,18 @@ impl IddPushCapturer {
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA /// 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 /// 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). /// 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( pub fn open(
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
want_444: bool, want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>, keepalive: Box<dyn Send>,
sender: crate::FrameChannelSender, sender: crate::FrameChannelSender,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> { ) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so // 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. // the stall log can correlate DWM holes with OS display events for the session's lifetime.
pf_win_display::display_events::spawn_once(); pf_win_display::display_events::spawn_once();
match Self::open_inner(target, preferred, client_10bit, want_444, pyrowave, sender) { match Self::open_inner(target, preferred, client_10bit, want_444, sender) {
Ok(mut me) => { Ok(mut me) => {
me._keepalive = keepalive; me._keepalive = keepalive;
Ok(me) Ok(me)
@@ -622,13 +576,11 @@ impl IddPushCapturer {
} }
} }
#[allow(clippy::too_many_arguments)]
fn open_inner( fn open_inner(
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
want_444: bool, want_444: bool,
pyrowave: bool,
sender: crate::FrameChannelSender, sender: crate::FrameChannelSender,
) -> Result<Self> { ) -> Result<Self> {
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the // The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
@@ -649,7 +601,6 @@ impl IddPushCapturer {
preferred, preferred,
client_10bit, client_10bit,
want_444, want_444,
pyrowave,
luid, luid,
sender.clone(), sender.clone(),
) { ) {
@@ -677,27 +628,17 @@ impl IddPushCapturer {
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \ "IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
driver's reported adapter" driver's reported adapter"
); );
Self::open_on( Self::open_on(target, preferred, client_10bit, want_444, drv, sender)
target, .context("IDD-push rebind to the driver's reported render adapter")
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( fn open_on(
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
want_444: bool, want_444: bool,
pyrowave: bool,
luid: LUID, luid: LUID,
sender: crate::FrameChannelSender, sender: crate::FrameChannelSender,
) -> Result<Self> { ) -> Result<Self> {
@@ -750,41 +691,6 @@ impl IddPushCapturer {
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section` // - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
// into `me` leaves it valid (see the `MappedSection` doc comment). // into `me` leaves it valid (see the `MappedSection` doc comment).
unsafe { unsafe {
// An SDR-negotiated PyroWave session must run on an SDR (BGRA) composition — its CSC
// reads 8-bit BGRA (and the NVIDIA D3D11 VideoProcessor can't ingest the FP16 ring
// anyway). Actively turn advanced color OFF (undoing any leftover HDR state from a
// prior session on a reused/lingering monitor) and settle before sizing the ring. An
// HDR-negotiated (10-bit) PyroWave session instead enables HDR below exactly like the
// H.26x path and rides the FP16 scRGB ring through the pyro HDR CSC
// (design/pyrowave-444-hdr.md Phase 3).
if pyrowave && !client_10bit {
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 // 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 // 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 // settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
@@ -815,12 +721,9 @@ impl IddPushCapturer {
} }
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) — // 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. // there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
// An SDR PyroWave session forced advanced color OFF above; guard against a physical let display_hdr = enabled_hdr
// display forcing HDR anyway (the wavelet SDR CSC can't read FP16). || pf_win_display::win_display::advanced_color_enabled(target.target_id)
let display_hdr = (!pyrowave || client_10bit) .unwrap_or(false);
&& (enabled_hdr
|| pf_win_display::win_display::advanced_color_enabled(target.target_id)
.unwrap_or(false));
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was // 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 // 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 // could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
@@ -950,13 +853,6 @@ impl IddPushCapturer {
client_10bit, client_10bit,
display_hdr, display_hdr,
want_444, 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( desc_poller: DescriptorPoller::spawn(
target.target_id, target.target_id,
DisplayDescriptor { DisplayDescriptor {
@@ -1232,16 +1128,6 @@ impl IddPushCapturer {
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit /// 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. /// full-chroma source): the stream downgrades to 4:2:0 with a warning.
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) { fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
// PyroWave never uses this out-ring (it has its own separate-plane `pyro_ring`); the
// format here only labels the frame. SDR sessions label NV12 (BT.709 limited), HDR
// (negotiated 10-bit) sessions P010 — matching the studio-code planes the pyro CSC writes.
if self.pyrowave {
return if self.display_hdr {
(DXGI_FORMAT_P010, PixelFormat::P010)
} else {
(DXGI_FORMAT_NV12, PixelFormat::Nv12)
};
}
if self.display_hdr { if self.display_hdr {
if self.want_444 { if self.want_444 {
warn_444_hdr_downgrade_once(); warn_444_hdr_downgrade_once();
@@ -1329,8 +1215,6 @@ impl IddPushCapturer {
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format 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.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
self.hdr_p010_conv = None; 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.out_idx = 0;
self.last_present = None; self.last_present = None;
Ok(()) Ok(())
@@ -1344,26 +1228,11 @@ impl IddPushCapturer {
/// only when TWO consecutive samples agree on the same new descriptor (~½ s), so a /// 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. /// single-sample transient during a topology re-probe never costs a ring recreate.
fn poll_display_hdr(&mut self) { fn poll_display_hdr(&mut self) {
let (mut now, seq) = self.desc_poller.snapshot(); let (now, seq) = self.desc_poller.snapshot();
if seq == self.desc_seq { if seq == self.desc_seq {
return; // no new sample since last consume return; // no new sample since last consume
} }
self.desc_seq = seq; self.desc_seq = seq;
// A PyroWave session's composition is PINNED to the NEGOTIATED depth: its encoder was
// opened for fixed plane formats (R8 SDR / R16 HDR), so a mid-session "Use HDR" flip
// can't be followed like H.26x does — re-assert the negotiated state instead and treat
// the descriptor accordingly (the ring is never recreated at the wrong format).
if self.pyrowave && now.hdr != self.client_10bit {
// 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,
self.client_10bit,
);
}
now.hdr = self.client_10bit;
}
let current = DisplayDescriptor { let current = DisplayDescriptor {
hdr: self.display_hdr, hdr: self.display_hdr,
width: self.width, width: self.width,
@@ -1412,8 +1281,7 @@ impl IddPushCapturer {
}, },
Usage: D3D11_USAGE_DEFAULT, Usage: D3D11_USAGE_DEFAULT,
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and // 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. (PyroWave uses its own // NVENC registers it as encode input — matching the WGC YUV ring.
// shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.)
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0, CPUAccessFlags: 0,
MiscFlags: 0, MiscFlags: 0,
@@ -1434,91 +1302,6 @@ impl IddPushCapturer {
Ok(()) 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")?))
};
// Plane formats/geometry follow the negotiated session: 16-bit UNORM planes for an
// HDR (10-bit) session (P010-style studio codes from the pyro HDR CSC), full-res
// chroma for 4:4:4 (design/pyrowave-444-hdr.md Phase 3).
let (yf, cf) = if self.display_hdr {
(DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM)
} else {
(DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM)
};
let (cw, ch) = if self.want_444 {
(w, h)
} else {
(w / 2, h / 2)
};
for _ in 0..OUT_RING {
let (y, y_rtv) = make(&self.device, yf, w, h)?;
let (cbcr, cbcr_rtv) = make(&self.device, cf, cw, ch)?;
self.pyro_ring.push(PyroOutSlot {
y,
y_rtv,
cbcr,
cbcr_rtv,
});
}
}
Ok(())
}
/// PyroWave: build the (mode-aware) RGB→YUV-planes CSC if not yet built. The mode is
/// session-fixed: SDR/BGRA vs HDR/scRGB input, half- vs full-res chroma — the composition
/// is pinned to the negotiated depth (`poll_display_hdr`), so the converter never needs a
/// mid-session mode swap.
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, self.display_hdr, self.want_444)?
});
}
Ok(())
}
/// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an /// 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. /// 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`). /// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
@@ -1544,61 +1327,6 @@ impl IddPushCapturer {
Ok(()) 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>> { fn try_consume(&mut self) -> Result<Option<CapturedFrame>> {
self.log_driver_status_once(); self.log_driver_status_once();
// Follow the display: a "Use HDR" flip recreates the ring at the matching format. // Follow the display: a "Use HDR" flip recreates the ring at the matching format.
@@ -1663,34 +1391,13 @@ impl IddPushCapturer {
if seq == self.last_seq || slot >= self.slots.len() { if seq == self.last_seq || slot >= self.slots.len() {
return Ok(None); return Ok(None);
} }
// Build the ring + converter BEFORE acquiring the slot so nothing between Acquire and Release self.ensure_out_ring()?;
// can `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot). // Build the converter BEFORE acquiring the slot so nothing between Acquire and Release can
// PyroWave uses its OWN two-plane ring (`pyro_ring`); everything else the single NV12/BGRA ring. // `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
self.ensure_converter()?;
let i = self.out_idx; let i = self.out_idx;
let (out, pyro_slot) = if self.pyrowave { let out = self.out_ring[i].clone();
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 (_, 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 // 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 // ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
@@ -1707,30 +1414,14 @@ impl IddPushCapturer {
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing // A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
// the slot back to the driver. // the slot back to the driver.
unsafe { unsafe {
if self.pyrowave { if self.display_hdr {
// PyroWave: ring slot SRV (BGRA for SDR, scRGB FP16 for HDR) → the two separate
// plane textures via the mode-aware CSC; the shared fence signalled just after
// (`pyro_fence_signal`) orders the encoder's cross-device Vulkan read after this
// convert. The composition format is pinned to the negotiated depth.
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. // 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() { if let Some(conv) = self.hdr_p010_conv.as_ref() {
conv.convert( conv.convert(
&self.device, &self.device,
&self.context, &self.context,
&s.srv, &s.srv,
out.as_ref().expect("out ring"), &out,
self.width, self.width,
self.height, self.height,
)?; )?;
@@ -1739,24 +1430,19 @@ impl IddPushCapturer {
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma // 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 // 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. // copy-engine move; the slot releases back to the driver immediately.
self.context self.context.CopyResource(&out, &s.tex);
.CopyResource(out.as_ref().expect("out ring"), &s.tex);
} else { } else {
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC. // 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() { if let Some(conv) = self.video_conv.as_ref() {
conv.convert(&s.tex, out.as_ref().expect("out ring"))?; conv.convert(&s.tex, &out)?;
} }
} }
} }
// `_lock` drops here → `ReleaseSync(0)`. // `_lock` drops here → `ReleaseSync(0)`.
} }
self.out_idx = (i + 1) % ring_len; self.out_idx = (i + 1) % self.out_ring.len();
self.last_seq = seq; self.last_seq = seq;
if let Some((y, _, cbcr, _)) = pyro_slot.as_ref() { self.last_present = Some((out.clone(), pf));
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(); let now = Instant::now();
if self.recovering_since.take().is_some() { if self.recovering_since.take().is_some() {
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring // A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
@@ -1831,33 +1517,14 @@ impl IddPushCapturer {
} }
} }
self.last_fresh = now; // feeds the driver-death watch 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 { Ok(Some(CapturedFrame {
width: self.width, width: self.width,
height: self.height, height: self.height,
pts_ns: now_ns(), pts_ns: now_ns(),
format: pf, format: pf,
payload: FramePayload::D3d11(D3d11Frame { payload: FramePayload::D3d11(D3d11Frame {
texture, texture: out,
device: self.device.clone(), device: self.device.clone(),
pyro,
}), }),
cursor: None, cursor: None,
})) }))
@@ -1868,46 +1535,8 @@ impl IddPushCapturer {
// new driver frame) never re-hands a slot that may still be encoding under pipeline_depth>1 — the // 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 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. // OUT_RING(3) > the max pipeline_depth(2) guarantees the rotated slot is not in flight.
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 (src, pf) = self.last_present.clone()?;
let i = self.out_idx;
let dst = self.out_ring.get(i)?.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 // 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). // identical format/size (src is a previous out-ring slot; dst the next).
@@ -1924,7 +1553,6 @@ impl IddPushCapturer {
payload: FramePayload::D3d11(D3d11Frame { payload: FramePayload::D3d11(D3d11Frame {
texture: dst, texture: dst,
device: self.device.clone(), device: self.device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}) })
@@ -127,7 +127,6 @@ impl Capturer for SyntheticNv12Capturer {
payload: FramePayload::D3d11(D3d11Frame { payload: FramePayload::D3d11(D3d11Frame {
texture: self.default_tex.clone(), texture: self.default_tex.clone(),
device: self.device.clone(), device: self.device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}) })
-12
View File
@@ -299,24 +299,12 @@ fn pump(
#[cfg(all(target_os = "linux", feature = "pyrowave"))] #[cfg(all(target_os = "linux", feature = "pyrowave"))]
let built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE { let built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE {
let mode = connector.mode(); 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() { match params.vulkan.as_ref() {
Some(vk) => Decoder::new_pyrowave( Some(vk) => Decoder::new_pyrowave(
vk, vk,
mode.width, mode.width,
mode.height, mode.height,
connector.shard_payload as usize, connector.shard_payload as usize,
connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
color,
connector.bit_depth >= 10,
), ),
None => Err(anyhow::anyhow!( None => Err(anyhow::anyhow!(
"pyrowave session without a presenter device" "pyrowave session without a presenter device"
-6
View File
@@ -489,9 +489,6 @@ impl Decoder {
width: u32, width: u32,
height: u32, height: u32,
shard_payload: usize, shard_payload: usize,
chroma444: bool,
color: ColorDesc,
hdr16: bool,
) -> Result<Decoder> { ) -> Result<Decoder> {
Ok(Decoder { Ok(Decoder {
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new( backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
@@ -499,9 +496,6 @@ impl Decoder {
width, width,
height, height,
shard_payload, shard_payload,
chroma444,
color,
hdr16,
)?)), )?)),
codec_id: ffmpeg::codec::Id::HEVC, codec_id: ffmpeg::codec::Id::HEVC,
vaapi_fails: 0, vaapi_fails: 0,
+21 -85
View File
@@ -258,12 +258,11 @@ unsafe fn make_plane(
mem_props: &vk::PhysicalDeviceMemoryProperties, mem_props: &vk::PhysicalDeviceMemoryProperties,
w: u32, w: u32,
h: u32, h: u32,
fmt: vk::Format,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { ) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
let img = device.create_image( let img = device.create_image(
&vk::ImageCreateInfo::default() &vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D) .image_type(vk::ImageType::TYPE_2D)
.format(fmt) .format(vk::Format::R8_UNORM)
.extent(vk::Extent3D { .extent(vk::Extent3D {
width: w, width: w,
height: h, height: h,
@@ -307,7 +306,7 @@ unsafe fn make_plane(
&vk::ImageViewCreateInfo::default() &vk::ImageViewCreateInfo::default()
.image(img) .image(img)
.view_type(vk::ImageViewType::TYPE_2D) .view_type(vk::ImageViewType::TYPE_2D)
.format(fmt) .format(vk::Format::R8_UNORM)
.subresource_range(vk::ImageSubresourceRange { .subresource_range(vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR, aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0, base_mip_level: 0,
@@ -348,21 +347,12 @@ unsafe fn build_ring(
mem_props: &vk::PhysicalDeviceMemoryProperties, mem_props: &vk::PhysicalDeviceMemoryProperties,
width: u32, width: u32,
height: u32, height: u32,
chroma444: bool,
fmt: vk::Format,
) -> Result<Vec<PlaneSet>> { ) -> 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); let mut ring: Vec<PlaneSet> = Vec::with_capacity(RING);
for _ in 0..RING { for _ in 0..RING {
let built = (|| -> Result<PlaneSet> { let built = (|| -> Result<PlaneSet> {
let (y, ym, yv) = make_plane(device, mem_props, width, height, fmt)?; let (y, ym, yv) = make_plane(device, mem_props, width, height)?;
let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch, fmt) { let (cb, cbm, cbv) = match make_plane(device, mem_props, width / 2, height / 2) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
device.destroy_image_view(yv, None); device.destroy_image_view(yv, None);
@@ -371,7 +361,7 @@ unsafe fn build_ring(
return Err(e); return Err(e);
} }
}; };
let (cr, crm, crv) = match make_plane(device, mem_props, cw, ch, fmt) { let (cr, crm, crv) = match make_plane(device, mem_props, width / 2, height / 2) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] { for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] {
@@ -419,16 +409,6 @@ pub struct PyroWaveDecoder {
mem_props: vk::PhysicalDeviceMemoryProperties, mem_props: vk::PhysicalDeviceMemoryProperties,
width: u32, width: u32,
height: 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,
/// Session-fixed negotiated depth ≥10: the planes are `R16_UNORM` carrying the host's
/// P010-style studio codes (the presenter samples them with depth-10 MSB-packed rows).
hdr16: bool,
/// The wire shard payload — the parse-window size for chunk-aligned AUs (§4.4): each /// 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. /// window holds whole self-delimiting codec packets, zero-padded to the window.
wire_window: usize, wire_window: usize,
@@ -444,20 +424,17 @@ impl PyroWaveDecoder {
width: u32, width: u32,
height: u32, height: u32,
shard_payload: usize, shard_payload: usize,
chroma444: bool,
color: ColorDesc,
hdr16: bool,
) -> Result<PyroWaveDecoder> { ) -> Result<PyroWaveDecoder> {
if !vkd.pyrowave_decode { if !vkd.pyrowave_decode {
bail!("presenter device lacks the PyroWave compute feature set"); bail!("presenter device lacks the PyroWave compute feature set");
} }
if !chroma444 && (width % 2 != 0 || height % 2 != 0) { if width % 2 != 0 || height % 2 != 0 {
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})"); 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 // SAFETY: the handles in `vkd` are the presenter's live instance/device (it
// outlives the decoder — same contract the FFmpeg Vulkan backend relies on); // outlives the decoder — same contract the FFmpeg Vulkan backend relies on);
// `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime. // `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime.
unsafe { Self::new_inner(vkd, width, height, shard_payload, chroma444, color, hdr16) } unsafe { Self::new_inner(vkd, width, height, shard_payload) }
} }
unsafe fn new_inner( unsafe fn new_inner(
@@ -465,9 +442,6 @@ impl PyroWaveDecoder {
width: u32, width: u32,
height: u32, height: u32,
shard_payload: usize, shard_payload: usize,
chroma444: bool,
color: ColorDesc,
hdr16: bool,
) -> Result<PyroWaveDecoder> { ) -> Result<PyroWaveDecoder> {
let static_fn = ash::StaticFn { let static_fn = ash::StaticFn {
get_instance_proc_addr: std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>( get_instance_proc_addr: std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
@@ -522,11 +496,7 @@ impl PyroWaveDecoder {
device: pw_dev, device: pw_dev,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
chroma: if chroma444 { chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
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. // The fragment-iDWT path is for Mali/Adreno-class mobile GPUs only.
fragment_path: false, fragment_path: false,
}; };
@@ -543,27 +513,7 @@ impl PyroWaveDecoder {
let mem_props = instance.get_physical_device_memory_properties( let mem_props = instance.get_physical_device_memory_properties(
vk::PhysicalDevice::from_raw(vkd.physical_device as u64), vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
); );
// 16-bit sessions decode into R16_UNORM storage planes; STORAGE_IMAGE support for let ring = match build_ring(&device, &mem_props, width, height) {
// R16_UNORM is optional in Vulkan (universal on desktop) — probe it so an exotic
// device fails with a clear message instead of a validation error.
let plane_fmt = if hdr16 {
let props = instance.get_physical_device_format_properties(
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
vk::Format::R16_UNORM,
);
if !props
.optimal_tiling_features
.contains(vk::FormatFeatureFlags::STORAGE_IMAGE)
{
pw::pyrowave_decoder_destroy(pw_dec);
pw::pyrowave_device_destroy(pw_dev);
bail!("this GPU lacks R16_UNORM STORAGE_IMAGE — cannot decode a 10-bit PyroWave session");
}
vk::Format::R16_UNORM
} else {
vk::Format::R8_UNORM
};
let ring = match build_ring(&device, &mem_props, width, height, chroma444, plane_fmt) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
pw::pyrowave_decoder_destroy(pw_dec); pw::pyrowave_decoder_destroy(pw_dec);
@@ -607,9 +557,6 @@ impl PyroWaveDecoder {
mem_props, mem_props,
width, width,
height, height,
chroma444,
color,
hdr16,
wire_window: shard_payload.max(64), wire_window: shard_payload.max(64),
}) })
} }
@@ -622,19 +569,14 @@ impl PyroWaveDecoder {
/// The old ring is RETIRED, not destroyed: the presenter / frame channel may still /// The old ring is RETIRED, not destroyed: the presenter / frame channel may still
/// reference its views (see [`RETIRE_HANDOVERS`]). /// reference its views (see [`RETIRE_HANDOVERS`]).
unsafe fn reconfigure(&mut self, width: u32, height: u32) -> Result<()> { unsafe fn reconfigure(&mut self, width: u32, height: u32) -> Result<()> {
if !self.chroma444 && (width % 2 != 0 || height % 2 != 0) { if width % 2 != 0 || height % 2 != 0 {
bail!("pyrowave 4:2:0 needs even dimensions (resize to {width}x{height})"); bail!("pyrowave 4:2:0 needs even dimensions (resize to {width}x{height})");
} }
let dinfo = pw::pyrowave_decoder_create_info { let dinfo = pw::pyrowave_decoder_create_info {
device: self.pw_dev, device: self.pw_dev,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
// Chroma is session-fixed (negotiated); a resize never changes it. 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
},
fragment_path: false, fragment_path: false,
}; };
let mut new_dec: pw::pyrowave_decoder = std::ptr::null_mut(); let mut new_dec: pw::pyrowave_decoder = std::ptr::null_mut();
@@ -642,18 +584,7 @@ impl PyroWaveDecoder {
pw::pyrowave_decoder_create(&dinfo, &mut new_dec), pw::pyrowave_decoder_create(&dinfo, &mut new_dec),
"decoder_create (mid-stream resize)", "decoder_create (mid-stream resize)",
)?; )?;
let new_ring = match build_ring( let new_ring = match build_ring(&self.device, &self.mem_props, width, height) {
&self.device,
&self.mem_props,
width,
height,
self.chroma444,
if self.hdr16 {
vk::Format::R16_UNORM
} else {
vk::Format::R8_UNORM
},
) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
pw::pyrowave_decoder_destroy(new_dec); pw::pyrowave_decoder_destroy(new_dec);
@@ -955,10 +886,15 @@ impl PyroWaveDecoder {
], ],
width: w, width: w,
height: h, height: h,
// No VUI in the bitstream: the negotiated Welcome `ColorInfo` is the contract // No VUI in the bitstream: BT.709 limited is the fixed contract with the
// with the host's CSC (BT.709 limited for SDR sessions; BT.2020 PQ once the // host's CSC (plan §4.7 CscRows note; sequence-header signaling is a
// HDR leg lands — design/pyrowave-444-hdr.md). // follow-up once the C API exposes it).
color: self.color, color: ColorDesc {
primaries: 1,
transfer: 1,
matrix: 1,
full_range: false,
},
keyframe: true, keyframe: true,
})) }))
} }
-5
View File
@@ -53,17 +53,12 @@ ffmpeg-next = { version = "8", optional = true }
libloading = "0.8" libloading = "0.8"
# Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`. # Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`.
libvpl-sys = { path = "../libvpl-sys", optional = true } 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 = [ windows = { version = "0.62", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D11",
"Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common", "Win32_Graphics_Dxgi_Common",
# SECURITY_ATTRIBUTES — the PyroWave backend's IDXGIResource1::CreateSharedHandle signature.
"Win32_Security",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
"Win32_System_Threading", "Win32_System_Threading",
+4 -5
View File
@@ -104,14 +104,13 @@ impl Codec {
} }
} }
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit; /// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit).
/// PyroWave rides 16-bit UNORM planes carrying P010-style studio codes — the wavelet is /// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
/// depth-agnostic, design/pyrowave-444-hdr.md). H.264 is always 8-bit (High10 is neither an /// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
/// NVENC nor a VCN encode mode — negotiation never asks). `true` here is only the
/// *codec-level* gate: the active GPU/backend must still pass /// *codec-level* gate: the active GPU/backend must still pass
/// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit. /// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit.
pub fn supports_10bit(self) -> bool { pub fn supports_10bit(self) -> bool {
matches!(self, Codec::H265 | Codec::Av1 | Codec::PyroWave) matches!(self, Codec::H265 | Codec::Av1)
} }
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would /// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
+102 -185
View File
@@ -38,9 +38,6 @@ use std::os::raw::c_char;
/// uses. PyroWave carries no VUI, so the colour contract is fixed by this shader: the Phase-2 /// 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. /// client CSC must assume BT.709 limited range.
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv"); 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 /// 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. /// sampling by its push constant, so one allocation fits every pointer bitmap.
const CURSOR_MAX: u32 = 256; const CURSOR_MAX: u32 = 256;
@@ -49,6 +46,13 @@ const IMPORT_CACHE_CAP: usize = 16;
/// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta; /// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta;
/// the rate controller itself never exceeds the budget). /// the rate controller itself never exceeds the budget).
const BS_SLACK: usize = 256 * 1024; 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 /// 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 /// packed-RGB format. The capture advertises these for the pyrowave passthrough instead of
@@ -193,9 +197,6 @@ pub struct PyroWaveEncoder {
width: u32, width: u32,
height: u32, height: u32,
fps: 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)`. /// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`.
frame_budget: usize, frame_budget: usize,
/// Datagram-aligned mode (plan §4.4): packetize at this boundary and pad every codec /// Datagram-aligned mode (plan §4.4): packetize at this boundary and pad every codec
@@ -217,31 +218,17 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize {
} }
impl PyroWaveEncoder { impl PyroWaveEncoder {
pub fn open( pub fn open(width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result<Self> {
width: u32, if width % 2 != 0 || height % 2 != 0 {
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})"); bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
} }
// SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it // SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it
// establishes itself (valid instance/device, correctly-chained create-infos that // establishes itself (valid instance/device, correctly-chained create-infos that
// `DeviceHold` keeps alive); all handles are freshly created and owned by the result. // `DeviceHold` keeps alive); all handles are freshly created and owned by the result.
unsafe { unsafe { Self::open_inner(width, height, fps.max(1), bitrate_bps.max(1_000_000)) }
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, chroma444: bool) -> Result<Self> { unsafe fn open_inner(w: u32, h: u32, fps: u32, bitrate: u64) -> Result<Self> {
let entry = ash::Entry::load().context("load vulkan loader")?; let entry = ash::Entry::load().context("load vulkan loader")?;
let mut hold = DeviceHold { let mut hold = DeviceHold {
@@ -394,11 +381,7 @@ impl PyroWaveEncoder {
device: pw_dev, device: pw_dev,
width: w as i32, width: w as i32,
height: h as i32, height: h as i32,
chroma: if chroma444 { chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
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(); let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut();
if let Err(e) = pw_check( if let Err(e) = pw_check(
@@ -409,10 +392,8 @@ impl PyroWaveEncoder {
return Err(e); return Err(e);
} }
// ---- CSC planes: full-res R8 luma + RG8 chroma (half-res for 4:2:0, full-res for // ---- CSC planes: full-res R8 luma + half-res RG8 chroma, storage-written by the CSC
// 4:4:4), storage-written by the CSC and sampled directly by pyrowave (R/G view // and sampled directly by pyrowave (R/G view swizzles synthesize Cb/Cr) ----
// 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( let (y_img, y_mem, y_view) = make_plain_image(
&device, &device,
&mem_props, &mem_props,
@@ -425,8 +406,8 @@ impl PyroWaveEncoder {
&device, &device,
&mem_props, &mem_props,
vk::Format::R8G8_UNORM, vk::Format::R8G8_UNORM,
cw, w / 2,
ch, h / 2,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::SAMPLED, vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::SAMPLED,
)?; )?;
@@ -439,11 +420,7 @@ impl PyroWaveEncoder {
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE), .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE),
None, None,
)?; )?;
let spv = ash::util::read_spv(&mut std::io::Cursor::new(if chroma444 { let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?;
CSC444_SPV
} else {
CSC_SPV
}))?;
let shader = let shader =
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?; device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
let sb = |b: u32, t: vk::DescriptorType| { let sb = |b: u32, t: vk::DescriptorType| {
@@ -589,8 +566,7 @@ impl PyroWaveEncoder {
gpu = %props.device_name_as_c_str().unwrap_or(c"?").to_string_lossy(), gpu = %props.device_name_as_c_str().unwrap_or(c"?").to_string_lossy(),
mode = %format!("{w}x{h}@{fps}"), mode = %format!("{w}x{h}@{fps}"),
budget_kib = frame_budget / 1024, budget_kib = frame_budget / 1024,
chroma = if chroma444 { "4:4:4" } else { "4:2:0" }, "PyroWave encoder open (intra-only wavelet, BT.709 limited 4:2:0)"
"PyroWave encoder open (intra-only wavelet, BT.709 limited)"
); );
Ok(Self { Ok(Self {
@@ -632,7 +608,6 @@ impl PyroWaveEncoder {
width: w, width: w,
height: h, height: h,
fps, fps,
chroma444,
frame_budget, frame_budget,
wire_chunk: None, wire_chunk: None,
bitstream: Vec::new(), bitstream: Vec::new(),
@@ -996,12 +971,7 @@ impl PyroWaveEncoder {
0, 0,
&pc_bytes, &pc_bytes,
); );
// 4:2:0: one invocation per 2x2 luma block (per chroma sample); 4:4:4: per pixel. dev.cmd_dispatch(self.cmd, (w / 2).div_ceil(8), (h / 2).div_ceil(8), 1);
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 // CSC storage writes -> pyrowave's sampled reads (images stay GENERAL — the layout
// pyrowave's GPU-buffer contract accepts without transitions). // pyrowave's GPU-buffer contract accepts without transitions).
@@ -1054,19 +1024,17 @@ impl PyroWaveEncoder {
), ),
// Two-component chroma image: view swizzles R/G synthesize the Cb/Cr planes // 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 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( plane(
self.uv_img, self.uv_img,
if self.chroma444 { w } else { w / 2 }, w / 2,
if self.chroma444 { h } else { h / 2 }, h / 2,
rg8, rg8,
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R, pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
), ),
plane( plane(
self.uv_img, self.uv_img,
if self.chroma444 { w } else { w / 2 }, w / 2,
if self.chroma444 { h } else { h / 2 }, h / 2,
rg8, rg8,
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G, pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G,
), ),
@@ -1109,8 +1077,8 @@ impl PyroWaveEncoder {
// boundary by design. // boundary by design.
let cap = self.frame_budget + BS_SLACK; let cap = self.frame_budget + BS_SLACK;
self.bitstream.resize(cap, 0); self.bitstream.resize(cap, 0);
// Chunked mode reserves the 4-byte window prefix from the packetize boundary (shared helper). // Chunked mode reserves 4 bytes per window for the framing prefix.
let boundary = crate::pyrowave_wire::packet_boundary(self.wire_chunk, cap); let boundary = self.wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(cap);
let mut n: usize = 0; let mut n: usize = 0;
pw_check( pw_check(
pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n), pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n),
@@ -1133,16 +1101,67 @@ impl PyroWaveEncoder {
"packetize", "packetize",
)?; )?;
packets.truncate(out_n.max(1)); packets.truncate(out_n.max(1));
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC let au = if let Some(chunk) = self.wire_chunk {
// emits BT.709 LIMITED — patch the bits HONEST so VUI-honoring clients don't wash out // Window framing (§4.4): each `chunk`-sized window opens with a 4-byte prefix
// blacks. (Linux capture has no HDR path, so this side never stamps BT.2020/PQ.) // (u16 used-length + u16 kind) and carries either WHOLE self-delimiting codec
if let Some(p) = packets.first() { // packets (PACKED — several small ones share a window) or one fragment of an
crate::pyrowave_wire::stamp_color_bits(&mut self.bitstream, p.offset, false); // 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
// Frame into the wire AU via the shared helper (byte-identical on Linux + Windows): the dense // skips it and drops any fragment chain it interrupts.
// single packet, or the datagram-aligned windowed AU (§4.4). let payload_max = chunk - WINDOW_PREFIX;
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect(); let mut au: Vec<u8> = Vec::with_capacity((packets.len() + 1) * chunk);
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_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 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()
};
self.frame_count += 1; self.frame_count += 1;
self.pending.push_back(EncodedFrame { self.pending.push_back(EncodedFrame {
data: au, data: au,
@@ -1186,11 +1205,7 @@ impl Encoder for PyroWaveEncoder {
device: self.pw_dev, device: self.pw_dev,
width: self.width as i32, width: self.width as i32,
height: self.height as i32, height: self.height as i32,
chroma: if self.chroma444 { chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
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 mut enc: pw::pyrowave_encoder = std::ptr::null_mut();
let r = pw::pyrowave_encoder_create(&einfo, &mut enc); let r = pw::pyrowave_encoder_create(&einfo, &mut enc);
@@ -1312,16 +1327,10 @@ mod tests {
) )
} }
/// Decode an AU with a standalone pyrowave decoder and return the full planar YUV /// Decode an AU with a standalone pyrowave decoder and return the full YUV420P planes.
/// (half-res chroma for 4:2:0, full-res for 4:4:4). This is the golden oracle for the /// This is the golden oracle for both the Phase-1 smoke check (plane means) and the Apple
/// smoke checks (plane means) and the Apple Metal port's committed PSNR fixtures /// Metal port's committed PSNR fixtures (`pyrowave_dump_golden`).
/// (`pyrowave_dump_golden`). unsafe fn decode_planes(w: u32, h: u32, au: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
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(); let mut dev: pw::pyrowave_device = std::ptr::null_mut();
assert_eq!( assert_eq!(
pw::pyrowave_create_default_device(&mut dev), pw::pyrowave_create_default_device(&mut dev),
@@ -1331,11 +1340,7 @@ mod tests {
device: dev, device: dev,
width: w as i32, width: w as i32,
height: h as i32, height: h as i32,
chroma: if chroma444 { chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
} else {
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
},
fragment_path: false, fragment_path: false,
}; };
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut(); let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
@@ -1349,16 +1354,11 @@ mod tests {
); );
assert!(pw::pyrowave_decoder_decode_is_ready(dec, false)); 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 y = vec![0u8; (w * h) as usize];
let mut cb = vec![0u8; (cw * ch) as usize]; let mut cb = vec![0u8; (w * h / 4) as usize];
let mut cr = vec![0u8; (cw * ch) as usize]; let mut cr = vec![0u8; (w * h / 4) as usize];
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed(); let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
buf.format = if chroma444 { buf.format = pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P;
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.width = w as i32;
buf.height = h as i32; buf.height = h as i32;
buf.data = [ buf.data = [
@@ -1366,7 +1366,7 @@ mod tests {
cb.as_mut_ptr() as *mut _, cb.as_mut_ptr() as *mut _,
cr.as_mut_ptr() as *mut _, cr.as_mut_ptr() as *mut _,
]; ];
buf.row_stride_in_bytes = [w as usize, cw as usize, cw as usize]; 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()]; buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
assert_eq!( assert_eq!(
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf), pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
@@ -1377,15 +1377,10 @@ mod tests {
(y, cb, cr) (y, cb, cr)
} }
unsafe fn decode_planes(w: u32, h: u32, au: &[u8]) -> (Vec<u8>, Vec<u8>, Vec<u8>) { /// 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) {
// SAFETY: forwarded — same contract as the caller. // SAFETY: forwarded — same contract as the caller.
unsafe { decode_planes_chroma(w, h, au, false) } let (y, cb, cr) = unsafe { decode_planes(w, h, au) };
}
/// 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; let mean = |v: &[u8]| v.iter().map(|&x| x as f64).sum::<f64>() / v.len() as f64;
(mean(&y), mean(&cb), mean(&cr)) (mean(&y), mean(&cb), mean(&cr))
} }
@@ -1399,8 +1394,7 @@ mod tests {
#[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"] #[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"]
fn pyrowave_smoke() { fn pyrowave_smoke() {
let (w, h) = (256u32, 256u32); let (w, h) = (256u32, 256u32);
let mut enc = let mut enc = PyroWaveEncoder::open(w, h, 60, 40_000_000).expect("open");
PyroWaveEncoder::open(w, h, 60, 40_000_000, crate::ChromaFormat::Yuv420).expect("open");
assert!(!enc.caps().supports_rfi); assert!(!enc.caps().supports_rfi);
let colors = [ let colors = [
@@ -1420,7 +1414,7 @@ mod tests {
"AU exceeds rate budget" "AU exceeds rate budget"
); );
// SAFETY: test-only FFI into the vendored decoder with locally-owned buffers. // SAFETY: test-only FFI into the vendored decoder with locally-owned buffers.
let (ym, cbm, crm) = unsafe { decode_plane_means(w, h, &au.data, false) }; let (ym, cbm, crm) = unsafe { decode_plane_means(w, h, &au.data) };
let (ye, cbe, cre) = bt709(*c); let (ye, cbe, cre) = bt709(*c);
assert!( assert!(
(ym - ye).abs() < 3.0 && (cbm - cbe).abs() < 3.0 && (crm - cre).abs() < 3.0, (ym - ye).abs() < 3.0 && (cbm - cbe).abs() < 3.0 && (crm - cre).abs() < 3.0,
@@ -1514,68 +1508,6 @@ mod tests {
assert!(enc.poll().expect("poll").is_some()); 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 /// A deterministic busy BGRA test card (gradients + checker + LCG noise) — flat fills
/// exercise almost none of the entropy decoder, this hits every subband. /// exercise almost none of the entropy decoder, this hits every subband.
fn test_card(w: u32, h: u32, seed: u32) -> CapturedFrame { fn test_card(w: u32, h: u32, seed: u32) -> CapturedFrame {
@@ -1626,8 +1558,7 @@ mod tests {
// Odd-block geometry on purpose: 256 aligns clean, 144 → aligned 160 exercises the // Odd-block geometry on purpose: 256 aligns clean, 144 → aligned 160 exercises the
// block-grid overhang. ~1.6 bpp at 60 fps. // block-grid overhang. ~1.6 bpp at 60 fps.
let (w, h) = (256u32, 144u32); let (w, h) = (256u32, 144u32);
let mut enc = let mut enc = PyroWaveEncoder::open(w, h, 60, 4_000_000).expect("open");
PyroWaveEncoder::open(w, h, 60, 4_000_000, crate::ChromaFormat::Yuv420).expect("open");
let dump = |name: &str, bytes: &[u8]| { let dump = |name: &str, bytes: &[u8]| {
std::fs::write(dir.join(name), bytes).expect("write fixture"); std::fs::write(dir.join(name), bytes).expect("write fixture");
@@ -1679,19 +1610,5 @@ mod tests {
dump("ref-chunked-y.bin", &y); dump("ref-chunked-y.bin", &y);
dump("ref-chunked-cb.bin", &cb); dump("ref-chunked-cb.bin", &cb);
dump("ref-chunked-cr.bin", &cr); dump("ref-chunked-cr.bin", &cr);
// 4:4:4 dense AU + its reference (full-res chroma planes) — the Apple 4:4:4 layout's
// golden (design/pyrowave-444-hdr.md Phase 4). Same odd-block geometry.
let mut enc =
PyroWaveEncoder::open(w, h, 60, 6_500_000, crate::ChromaFormat::Yuv444).expect("open");
enc.submit(&test_card(w, h, 13)).expect("444 submit");
let au = enc.poll().expect("poll").expect("444 AU");
assert!(!au.chunk_aligned);
dump("au-dense444.bin", &au.data);
// SAFETY: test-only FFI with locally-owned buffers.
let (y, cb, cr) = unsafe { decode_planes_chroma(w, h, &au.data, true) };
dump("ref-dense444-y.bin", &y);
dump("ref-dense444-cb.bin", &cb);
dump("ref-dense444-cr.bin", &cr);
} }
} }
@@ -1,37 +0,0 @@
#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.
-209
View File
@@ -1,209 +0,0 @@
//! 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** YCbCr (black = Y16). 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 YCbCr). 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. The colour bits live in the little-endian second word's top byte
/// (`seq_offset + 7`): `color_primaries` bit 27 (`0x08`), `transfer_function` bit 28 (`0x10`),
/// `ycbcr_transform` bit 29 (`0x20`), `ycbcr_range` bit 30 (`0x40`); `chroma_siting` bit 31 stays 0
/// (CENTER — the pyrowave CSCs use the centre-sited 2×2 box, unlike the left-cosited P010 path).
/// Range is ALWAYS stamped LIMITED (both CSCs emit studio range); `bt2020_pq` additionally stamps
/// BT.2020 primaries + PQ transfer + BT.2020 matrix — upstream's own enum semantics
/// (`pyrowave_common.hpp`), matching the session's negotiated `ColorInfo`.
pub(crate) fn stamp_color_bits(bitstream: &mut [u8], seq_offset: usize, bt2020_pq: bool) {
if let Some(b) = bitstream.get_mut(seq_offset + 7) {
*b |= 0x40;
if bt2020_pq {
*b |= 0x08 | 0x10 | 0x20;
}
}
}
/// 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 stamp_color_bits_sets_range_and_hdr_bits() {
let mut bs = vec![0u8; 16];
stamp_color_bits(&mut bs, 0, false);
// 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).
stamp_color_bits(&mut bs, 0, false);
assert_eq!(bs[7], 0x40);
stamp_color_bits(&mut bs, 100, false);
// HDR adds BT.2020 primaries (0x08) + PQ transfer (0x10) + BT.2020 matrix (0x20);
// chroma_siting (0x80) stays CENTER.
stamp_color_bits(&mut bs, 0, true);
assert_eq!(bs[7], 0x78);
}
}
-4
View File
@@ -2788,7 +2788,6 @@ mod tests {
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(), texture: tex.clone(),
device: device.clone(), device: device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}; };
@@ -2974,7 +2973,6 @@ mod tests {
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(), texture: tex.clone(),
device: device.clone(), device: device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}; };
@@ -3116,7 +3114,6 @@ mod tests {
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(), texture: tex.clone(),
device: device.clone(), device: device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}; };
@@ -3264,7 +3261,6 @@ mod tests {
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(), texture: tex.clone(),
device: device.clone(), device: device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}; };
@@ -1811,7 +1811,6 @@ mod tests {
payload: FramePayload::D3d11(D3d11Frame { payload: FramePayload::D3d11(D3d11Frame {
texture: tex.clone(), texture: tex.clone(),
device: device.clone(), device: device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}; };
@@ -1914,7 +1913,6 @@ mod tests {
payload: FramePayload::D3d11(D3d11Frame { payload: FramePayload::D3d11(D3d11Frame {
texture: tex.clone(), texture: tex.clone(),
device: device.clone(), device: device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}; };
@@ -1,943 +0,0 @@
//! 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,
/// Session-fixed negotiated chroma: 4:4:4 = full-res CbCr plane + `Chroma444` pyrowave objects.
chroma444: bool,
/// Session-fixed negotiated depth ≥10: the capturer's HDR CSC writes P010-style studio codes
/// into 16-bit UNORM planes (`R16_UNORM` Y + `R16G16_UNORM` CbCr) and the sequence header is
/// stamped BT.2020/PQ.
hdr16: bool,
/// 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,
bit_depth: u8,
) -> Result<Self> {
let chroma444 = chroma.is_444();
// A negotiated 10-bit session rides 16-bit UNORM planes carrying the P010-style
// studio codes the capturer's HDR CSC writes (design/pyrowave-444-hdr.md §2.2) —
// the wire is depth-agnostic, only the plane formats and the CSC change.
let hdr16 = bit_depth >= 10;
if !chroma444 && (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 \
(D3D11Vulkan 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: 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(
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,
chroma = if chroma444 { "4:4:4" } else { "4:2:0" },
hdr = hdr16,
"PyroWave encoder open (Windows separate-plane zero-copy, intra-only wavelet)"
);
Ok(Self {
pw_dev,
pw_enc,
sync: std::ptr::null_mut(),
y_images: Vec::new(),
cbcr_images: Vec::new(),
width,
height,
fps,
chroma444,
hdr16,
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);
// Plane geometry/formats follow the negotiated session: chroma half- or full-res,
// 8-bit (SDR BT.709) or 16-bit UNORM (HDR: P010-style studio codes from the CSC).
let (cw, ch) = if self.chroma444 {
(w, h)
} else {
(w / 2, h / 2)
};
let (yf, cf) = if self.hdr16 {
(
pw::VkFormat_VK_FORMAT_R16_UNORM,
pw::VkFormat_VK_FORMAT_R16G16_UNORM,
)
} else {
(
pw::VkFormat_VK_FORMAT_R8_UNORM,
pw::VkFormat_VK_FORMAT_R8G8_UNORM,
)
};
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, yf, 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, cf, cw, ch),
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 buffers = pw::pyrowave_gpu_buffers {
planes: [
plane(
y_vk,
w,
h,
yf,
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_IDENTITY,
),
plane(
cbcr_vk,
cw,
ch,
cf,
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
),
plane(
cbcr_vk,
cw,
ch,
cf,
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 studio range — patch the bits HONEST so VUI-honoring clients don't wash out
// blacks; an HDR session additionally stamps BT.2020 primaries + PQ + BT.2020 matrix
// (matching the negotiated ColorInfo).
if let Some(p) = packets.first() {
pyrowave_wire::stamp_color_bits(&mut self.bitstream, p.offset, self.hdr16);
}
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_R16G16_UNORM, DXGI_FORMAT_R16_UNORM, 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], chroma444: bool) -> (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: 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();
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 (cw2, ch2) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
let mut y = vec![0u8; (w * h) as usize];
let mut cb = vec![0u8; (cw2 * ch2) as usize];
let mut cr = vec![0u8; (cw2 * ch2) as usize];
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
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 = [
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, cw2 as usize, cw2 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, hdr: bool, chroma444: bool) -> (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();
// Distinct plane fills at the session's plane formats/geometry. 16-bit fills use
// v16 = v8 * 257 (0xVV,0xVV LE), whose UNORM value equals v8/255 EXACTLY — so the
// 8-bit decode means expect the same 100/180/60 in every mode.
let (cw, ch) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
let (y_tex, cbcr_tex) = if hdr {
(
make_plane(
&device,
&context,
w,
h,
DXGI_FORMAT_R16_UNORM,
2,
&[0x64, 0x64],
),
make_plane(
&device,
&context,
cw,
ch,
DXGI_FORMAT_R16G16_UNORM,
4,
&[0xB4, 0xB4, 0x3C, 0x3C],
),
)
} else {
(
make_plane(&device, &context, w, h, DXGI_FORMAT_R8_UNORM, 1, &[100]),
make_plane(
&device,
&context,
cw,
ch,
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,
if chroma444 {
crate::ChromaFormat::Yuv444
} else {
crate::ChromaFormat::Yuv420
},
if hdr { 10 } else { 8 },
)
.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"
);
if hdr {
assert_eq!(
au.data[7] & 0x78,
0x78,
"HDR sequence header must signal BT.2020 primaries + PQ + BT.2020 matrix"
);
}
decode_plane_means(w, h, &au.data, chroma444)
}
/// 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() {
// The SDR 4:2:0 base case across real streaming sizes (the NVIDIA import
// size-sensitivity check), then every other (hdr, chroma) mode at two sizes —
// the R16/R16G16 and full-res-chroma imports are new surface for the same quirk.
let mut cases = vec![
(1024u32, 1024u32, false, false),
(1280, 720, false, false),
(1920, 1080, false, false),
(2560, 1440, false, false),
];
for &(hdr, c444) in &[(false, true), (true, false), (true, true)] {
cases.push((1280, 720, hdr, c444));
cases.push((1920, 1080, hdr, c444));
}
for (w, h, hdr, c444) in cases {
// SAFETY: single-threaded test; `run_case` owns every COM/FFI handle it touches.
let (ym, cbm, crm) = unsafe { run_case(w, h, hdr, c444) };
eprintln!(
"{w}x{h} hdr={hdr} 444={c444}: 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} hdr={hdr} 444={c444}: round-trip means (Y {ym:.1}, Cb {cbm:.1}, \
Cr {crm:.1}) drifted from the filled 100/180/60 plane mapping/format wrong"
);
}
}
}
-1
View File
@@ -1746,7 +1746,6 @@ mod tests {
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(), texture: tex.clone(),
device: device.clone(), device: device.clone(),
pyro: None,
}), }),
cursor: None, cursor: None,
}; };
+11 -76
View File
@@ -48,19 +48,7 @@ impl Codec {
} else { } else {
0u8 0u8
}; };
// Windows: the wavelet encoder rides on top of whatever GPU backend the box has (NVENC/AMF/ #[cfg(not(all(target_os = "linux", feature = "pyrowave")))]
// 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 pyro = 0u8;
let base = (|| { let base = (|| {
/// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream /// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
@@ -248,11 +236,10 @@ fn open_video_backend(
if fps == 0 || fps > 1000 { if fps == 0 || fps > 1000 {
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz"); anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
} }
// 4:4:4 is HEVC- and PyroWave-only. The negotiator should never pass `Yuv444` for another // 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
// codec (it gates on the codec + `can_encode_444`), but defend the contract here so a future // `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
// caller can't silently emit a stream no decoder expects: an unsupported 4:4:4 request // no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
// degrades to 4:2:0 with a warning. let chroma = if chroma.is_444() && codec != Codec::H265 {
let chroma = if chroma.is_444() && codec != Codec::H265 && codec != Codec::PyroWave {
tracing::warn!( tracing::warn!(
?codec, ?codec,
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0" "4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
@@ -268,7 +255,7 @@ fn open_video_backend(
if codec == Codec::PyroWave { if codec == Codec::PyroWave {
#[cfg(feature = "pyrowave")] #[cfg(feature = "pyrowave")]
{ {
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma) return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave")); .map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
} }
#[cfg(not(feature = "pyrowave"))] #[cfg(not(feature = "pyrowave"))]
@@ -370,17 +357,8 @@ fn open_video_backend(
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \ that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
normal sessions negotiate it instead)" normal sessions negotiate it instead)"
); );
// The lab override forces the wavelet stream onto a session negotiated for pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
// another codec — that session's chroma may be HEVC-4:4:4, which the .map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
// 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"))] #[cfg(not(feature = "pyrowave"))]
{ {
@@ -421,29 +399,10 @@ fn open_video_backend(
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
// A NEGOTIATED PyroWave session (client advertised + preferred it) routes straight to the // The Windows host leg is blocked on the .173 D3D11-interop debt (plan Phase 0 §3);
// NV12 zero-copy wavelet backend (design/pyrowave-windows-host-zerocopy.md) — placed FIRST, // host_wire_caps never advertises the bit here, so this only guards a forged preference.
// 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 { if codec == Codec::PyroWave {
#[cfg(feature = "pyrowave")] anyhow::bail!("PyroWave host encode is not available on Windows yet");
{
let _ = (format, cuda);
return pyrowave::PyroWaveEncoder::open(
width,
height,
fps,
bitrate_bps,
chroma,
bit_depth,
)
.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) let _ = cuda; // always false on Windows (no Cuda payload)
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software // NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
@@ -876,13 +835,6 @@ pub fn vaapi_codec_support() -> CodecSupport {
pub fn can_encode_444(codec: Codec) -> bool { pub fn can_encode_444(codec: Codec) -> bool {
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Mutex, OnceLock}; 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 (Phase 2) and the mode-aware `BgraToYuvPlanes` on
// Windows (Phase 3) — both landed (design/pyrowave-444-hdr.md).
return true;
}
if codec != Codec::H265 { if codec != Codec::H265 {
return false; return false;
} }
@@ -968,12 +920,6 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
if !codec.supports_10bit() { if !codec.supports_10bit() {
return false; return false;
} }
if codec == Codec::PyroWave {
// PyroWave needs no GPU encode probe (the wavelet is depth-agnostic) — only the HDR
// capture CSC (scRGB FP16 → 16-bit studio-code planes), which exists on the Windows
// IDD-push path only (design/pyrowave-444-hdr.md Phase 3; Linux capture has no HDR).
return cfg!(target_os = "windows");
}
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly // Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
// selected adapter before the next Welcome, mirroring `can_encode_444`. // selected adapter before the next Welcome, mirroring `can_encode_444`.
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new(); static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
@@ -1314,17 +1260,6 @@ mod vk_util;
#[cfg(all(target_os = "linux", feature = "pyrowave"))] #[cfg(all(target_os = "linux", feature = "pyrowave"))]
#[path = "enc/linux/pyrowave.rs"] #[path = "enc/linux/pyrowave.rs"]
mod pyrowave; 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)] #[cfg(test)]
mod tests { mod tests {
+1 -26
View File
@@ -34,35 +34,10 @@ pub struct WinCaptureTarget {
pub wudf_pid: u32, pub wudf_pid: u32,
} }
/// The PyroWave (Windows) zero-copy sharing payload attached to a captured frame: the SECOND plane /// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
/// 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 struct D3d11Frame {
pub texture: ID3D11Texture2D, pub texture: ID3D11Texture2D,
pub device: ID3D11Device, 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. // 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 // D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
-9
View File
@@ -115,13 +115,6 @@ pub struct OutputFormat {
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every /// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
/// 4:2:0 session. /// 4:2:0 session.
pub chroma_444: bool, 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 { impl OutputFormat {
@@ -137,8 +130,6 @@ impl OutputFormat {
hdr, hdr,
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only). // The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
chroma_444: false, chroma_444: false,
// GameStream never negotiates PyroWave (native punktfunk/1 only).
pyrowave: false,
} }
} }
} }
+1 -4
View File
@@ -1009,10 +1009,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// else decodes the codec); only device loss ends the session. // else decodes the codec); only device loss ends the session.
#[cfg(all(target_os = "linux", feature = "pyrowave"))] #[cfg(all(target_os = "linux", feature = "pyrowave"))]
DecodedImage::PyroWave(f) => { DecodedImage::PyroWave(f) => {
// The wavelet stream carries the negotiated ColorInfo (no VUI): an st.hdr = false; // 8-bit SDR codec
// HDR (PQ) pyrowave session presents through the HDR10 path exactly
// like the H.26x codecs (design/pyrowave-444-hdr.md Phase 3).
st.hdr = f.color.is_pq();
match presenter.present( match presenter.present(
&window, &window,
FrameInput::PyroWave(f), FrameInput::PyroWave(f),
+4 -24
View File
@@ -39,7 +39,7 @@ impl Presenter {
#[cfg(windows)] #[cfg(windows)]
FrameInput::D3d11(d) => Some(d.color.is_pq()), FrameInput::D3d11(d) => Some(d.color.is_pq()),
#[cfg(all(target_os = "linux", feature = "pyrowave"))] #[cfg(all(target_os = "linux", feature = "pyrowave"))]
FrameInput::PyroWave(f) => Some(f.color.is_pq()), FrameInput::PyroWave(f) => Some(f.color.is_pq()), // always SDR today
}; };
if let Some(pq) = frame_pq { if let Some(pq) = frame_pq {
// A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind // A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind
@@ -750,31 +750,11 @@ impl Presenter {
&[planar.desc_set], &[planar.desc_set],
&[], &[],
); );
// An HDR (PQ) pyrowave session carries P010-style 10-bit studio codes MSB-packed let rows = csc_rows(color, 8, false);
// into 16-bit planes (design/pyrowave-444-hdr.md §2.2) — same sampling scale as
// the P010 path; SDR sessions are plain 8-bit BT.709 limited. Depth follows the
// colour contract (negotiation couples 10-bit ⟺ PQ for this codec).
let (depth, msb_packed) = if color.is_pq() {
(10, true)
} else {
(8, false)
};
let rows = csc_rows(color, depth, msb_packed);
// Mode 1 = PQ→SDR tonemap (PQ stream without an HDR10 surface); mode 0 passes
// the transfer through — identical to the NV12 arm above.
let mode = if color.is_pq() && !self.hdr_active {
1.0f32
} else {
0.0
};
let peak = std::env::var("PUNKTFUNK_TONEMAP_PEAK")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
let mut pc = [0f32; 16]; let mut pc = [0f32; 16];
pc[..12].copy_from_slice(bytemuck_rows(&rows)); pc[..12].copy_from_slice(bytemuck_rows(&rows));
pc[12] = mode; pc[12] = 0.0; // SDR passthrough — PyroWave has no PQ path
pc[13] = peak; pc[13] = 0.0;
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64); let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
self.device.cmd_push_constants( self.device.cmd_push_constants(
self.cmd_buf, self.cmd_buf,
+2 -6
View File
@@ -203,15 +203,11 @@ impl Presenter {
vk::Format::R8G8B8A8_UNORM vk::Format::R8G8B8A8_UNORM
}; };
self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it
self.csc = CscPass::new(&self.device, self.video_format)?;
// The planar (PyroWave) pass renders to the same intermediate — rebuild it at the
// new format too (an HDR pyrowave session needs the 10-bit intermediate exactly
// like the H.26x path; 8-bit PQ bands visibly).
#[cfg(all(target_os = "linux", feature = "pyrowave"))] #[cfg(all(target_os = "linux", feature = "pyrowave"))]
if let Some(p) = self.csc_planar.take() { if let Some(p) = &self.csc_planar {
p.destroy(&self.device); p.destroy(&self.device);
self.csc_planar = Some(CscPass::new_planar(&self.device, self.video_format)?);
} }
self.csc = CscPass::new(&self.device, self.video_format)?;
if let Some(v) = self.video.take() { if let Some(v) = self.video.take() {
unsafe { unsafe {
self.device.destroy_framebuffer(v.framebuffer, None); self.device.destroy_framebuffer(v.framebuffer, None);
+1 -2
View File
@@ -315,8 +315,7 @@ impl Presenter {
ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device), ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device),
}); });
let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?; let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?;
// Starts SDR like `csc`; an HDR (PQ) pyrowave session rebuilds it at the 10-bit // PyroWave is 8-bit SDR only, so the planar pass never needs the HDR10 rebuild.
// intermediate via `set_hdr_mode`, exactly like the H.26x pass.
#[cfg(all(target_os = "linux", feature = "pyrowave"))] #[cfg(all(target_os = "linux", feature = "pyrowave"))]
let csc_planar = if pyrowave_ok { let csc_planar = if pyrowave_ok {
Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?) Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?)
+2 -10
View File
@@ -131,16 +131,8 @@ pub fn capture_virtual_output(
// proactively enables advanced color and selects the per-frame conversion. There is NO fallback: // 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 // if it can't open or the driver doesn't attach, the session fails cleanly and the client
// reconnects. // reconnects.
pf_capture::open_idd_push( pf_capture::open_idd_push(target, pref, want.hdr, want.chroma_444, keep, sender)
target, .map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
pref,
want.hdr,
want.chroma_444,
want.pyrowave,
keep,
sender,
)
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
+5 -55
View File
@@ -521,20 +521,10 @@ fn resolve_bitrate_kbps_for(
codec: crate::encode::Codec, codec: crate::encode::Codec,
requested: u32, requested: u32,
mode: &punktfunk_core::config::Mode, mode: &punktfunk_core::config::Mode,
chroma: crate::encode::ChromaFormat,
bit_depth: u8,
) -> u32 { ) -> u32 {
if requested == 0 && codec == crate::encode::Codec::PyroWave { if requested == 0 && codec == crate::encode::Codec::PyroWave {
// ~1.6 bpp for 4:2:0. 4:4:4 doubles the samples per pixel (3 vs 1.5) but chroma let bps =
// compresses better than luma → ×1.625 ≈ 2.6 bpp; 16-bit planes add ~15 % (both mode.width as u64 * mode.height as u64 * u64::from(mode.refresh_hz.max(1)) * 16 / 10;
// 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) return u32::try_from(bps / 1000)
.unwrap_or(MAX_BITRATE_KBPS) .unwrap_or(MAX_BITRATE_KBPS)
.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS); .clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS);
@@ -1468,58 +1458,18 @@ mod tests {
height: 1080, height: 1080,
refresh_hz: 60, refresh_hz: 60,
}; };
use crate::encode::ChromaFormat;
// Automatic (0) on PyroWave → the ~1.6 bpp operating point, not the 20 Mbps H.26x // 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). // default (which would turn wavelets to mush — plan §4.6).
let kbps = resolve_bitrate_kbps_for( let kbps = resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 0, &mode);
crate::encode::Codec::PyroWave,
0,
&mode,
ChromaFormat::Yuv420,
8,
);
assert_eq!(kbps, 1920 * 1080 * 60 * 16 / 10 / 1000); 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)... // An explicit client rate is honored (clamped like any other codec)...
assert_eq!( assert_eq!(
resolve_bitrate_kbps_for( resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 130_000, &mode),
crate::encode::Codec::PyroWave,
130_000,
&mode,
ChromaFormat::Yuv420,
8
),
130_000 130_000
); );
// ...and the H.26x codecs keep the legacy default. // ...and the H.26x codecs keep the legacy default.
assert_eq!( assert_eq!(
resolve_bitrate_kbps_for( resolve_bitrate_kbps_for(crate::encode::Codec::H265, 0, &mode),
crate::encode::Codec::H265,
0,
&mode,
ChromaFormat::Yuv420,
8
),
DEFAULT_BITRATE_KBPS DEFAULT_BITRATE_KBPS
); );
} }
+13 -23
View File
@@ -187,8 +187,14 @@ pub(super) async fn negotiate(
// needed; the actual pads are created lazily by the input thread). // needed; the actual pads are created lazily by the input thread).
let gamepad = resolve_gamepad(hello.gamepad); let gamepad = resolve_gamepad(hello.gamepad);
// (The encoder bitrate is resolved below, AFTER bit depth + chroma: PyroWave's automatic // Resolve the encoder bitrate (client request clamped to a sane range, or a
// ~bpp pin scales with both — design/pyrowave-444-hdr.md §2.5.) // 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"
);
// Resolve the audio channel count (client request → stereo / 5.1 / 7.1). The capturer opens // 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 // at this count: PipeWire synthesizes the requested positions (padding with silence when the
@@ -249,24 +255,19 @@ pub(super) async fn negotiate(
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host // 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 // 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.) // topology, and 4:4:4 routed to DDA, which was removed.)
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma let capture_supports_444 =
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444());
// 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 // 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 // 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 // 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 // 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). // failure here is rare since the session's own encoder isn't open yet).
let gpu_supports_444 = if matches!( let gpu_supports_444 = if codec == crate::encode::Codec::H265
codec, && host_wants_444
crate::encode::Codec::H265 | crate::encode::Codec::PyroWave
) && host_wants_444
&& client_supports_444 && client_supports_444
&& capture_supports_444 && capture_supports_444
{ {
tokio::task::spawn_blocking(move || crate::encode::can_encode_444(codec)) tokio::task::spawn_blocking(|| crate::encode::can_encode_444(crate::encode::Codec::H265))
.await .await
.context("4:4:4 capability probe task")? .context("4:4:4 capability probe task")?
} else { } else {
@@ -298,17 +299,6 @@ pub(super) async fn negotiate(
bit_depth 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 // 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 // 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, // `--data-port` yields `direct = true` (stream straight to the client's reported address,
+1 -1
View File
@@ -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 // 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). // 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 { let mode_bitrate = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
resolve_bitrate_kbps_for(plan.codec, 0, &new_mode, plan.chroma, plan.bit_depth) resolve_bitrate_kbps_for(plan.codec, 0, &new_mode)
} else { } else {
bitrate_kbps bitrate_kbps
}; };
@@ -179,11 +179,6 @@ impl SessionPlan {
// 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the // 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. // default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4.
chroma_444: self.chroma.is_444(), 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,
} }
} }
} }
@@ -1,20 +0,0 @@
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,10 +8,3 @@ vulkan-headers: 015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9
Tree is pruned to what the pyrowave-sys standalone build needs Tree is pruned to what the pyrowave-sys standalone build needs
(see the rm -rf list in the script). All parts are MIT-licensed (see the rm -rf list in the script). All parts are MIT-licensed
(pyrowave, Granite) or Apache-2.0/MIT (volk, Vulkan-Headers). (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.
+2 -7
View File
@@ -187,13 +187,8 @@ void Encoder::Impl::init_block_meta()
meta_buffer = device->create_buffer(info); meta_buffer = device->create_buffer(info);
device->set_name(*meta_buffer, "meta-buffer"); device->set_name(*meta_buffer, "meta-buffer");
// Worst case estimate. PUNKTFUNK PATCH (patches/0001-payload-data-444-sizing.patch): // Worst case estimate.
// 4:4:4 carries 3 samples per pixel vs 4:2:0's 1.5 — the same per-sample headroom info.size = aligned_width * aligned_height * 2;
// 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); payload_data = device->create_buffer(info);
device->set_name(*payload_data, "payload-data"); device->set_name(*payload_data, "payload-data");
+11 -13
View File
@@ -36,14 +36,11 @@ Bandwidth. At the codec's ~1.6 bits-per-pixel operating point (4:2:0, 60 fps):
| 2560×1440 @ 60 | ≈ 355 Mbps | | 2560×1440 @ 60 | ≈ 355 Mbps |
| 3840×2160 @ 60 | ≈ 800 Mbps | | 3840×2160 @ 60 | ≈ 800 Mbps |
120 Hz doubles these, 4:4:4 multiplies them by ~1.6, and an HDR (10-bit) session adds ~15 %. 120 Hz doubles these. Gigabit Ethernet tops out around 940 Mbps of payload, so 4K60 wants
Gigabit Ethernet tops out around 940 Mbps of payload, so 4K60 wants 2.5GbE (or a lower 2.5GbE (or a lower rate). **Do not run this over Wi-Fi** — that's what HEVC/AV1 are for.
rate). **Do not run this over Wi-Fi** — that's what HEVC/AV1 are for.
PyroWave follows the same 4:4:4 and HDR settings as HEVC/AV1: a session negotiates Also: PyroWave is **8-bit SDR only**. An HDR session stays on HEVC/AV1 regardless of this
full-chroma 4:4:4 when your client's 4:4:4 setting is on, and HDR (BT.2020 PQ, carried in setting.
16-bit planes) when the host's display pipeline is HDR — currently the Windows host only
(the Linux host's capture path has no HDR source yet, so Linux-hosted sessions are SDR).
## Turning it on ## Turning it on
@@ -56,10 +53,10 @@ full-chroma 4:4:4 when your client's 4:4:4 setting is on, and HDR (BT.2020 PQ, c
gamepad console, or launch with `PUNKTFUNK_PREFER_PYROWAVE=1`. gamepad console, or launch with `PUNKTFUNK_PREFER_PYROWAVE=1`.
- Apple (Mac, Apple TV 4K, iPad — wired networking strongly recommended): set - Apple (Mac, Apple TV 4K, iPad — wired networking strongly recommended): set
**Settings → Codec → PyroWave (wired LAN)**. The option appears only on devices whose **Settings → Codec → PyroWave (wired LAN)**. The option appears only on devices whose
GPU passes the decode probe (Apple Silicon and A13-class or newer). The decoder follows GPU passes the decode probe (Apple Silicon and A13-class or newer); picking it forces
the stream: 4:2:0 or 4:4:4, SDR or HDR, per what the session negotiated. the session SDR.
3. Leave the bitrate on Automatic: a PyroWave session pins itself to the ~1.6 bpp rate for 3. Leave the bitrate on Automatic: a PyroWave session pins itself to the ~1.6 bpp rate for
your mode (≈200 Mbps at 1080p60; ~2.6 bpp for 4:4:4, +15 % for HDR). An explicit bitrate is honored if you set one, but the your mode (≈200 Mbps at 1080p60). An explicit bitrate is honored if you set one, but the
adaptive-bitrate controller stays off either way — this codec has no useful low-rate adaptive-bitrate controller stays off either way — this codec has no useful low-rate
regime, so under sustained loss the right move is switching back to HEVC, not degrading. regime, so under sustained loss the right move is switching back to HEVC, not degrading.
The pin follows the resolution: a mid-stream resize (e.g. Match window) re-pins the rate The pin follows the resolution: a mid-stream resize (e.g. Match window) re-pins the rate
@@ -69,6 +66,7 @@ The stats overlay shows `pyrowave` as the decode path when the mode is active.
## Current limits ## Current limits
- Linux and Windows hosts; Linux clients (including docked Deck) and Apple clients (native - Linux host + Linux client (including docked Deck) and Apple clients (native Metal decode
Metal decode on Mac / Apple TV 4K / iPad) today. on Mac / Apple TV 4K / iPad) today; the Windows host is tracked on the
- HDR needs a Windows host (the Linux host's capture path has no HDR source yet). [roadmap](/docs/roadmap).
- 8-bit SDR, 4:2:0 only.
-16
View File
@@ -55,15 +55,6 @@ mkdir -p "$(dirname "$DEST")"
rm -rf "$DEST" rm -rf "$DEST"
cp -a "$WORK/pyrowave" "$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 cat > "$DEST/PUNKTFUNK-VENDOR.txt" <<EOF
Vendored by scripts/vendor-pyrowave.sh — do not edit by hand. Vendored by scripts/vendor-pyrowave.sh — do not edit by hand.
@@ -75,13 +66,6 @@ vulkan-headers: $VKHDR_COMMIT
Tree is pruned to what the pyrowave-sys standalone build needs Tree is pruned to what the pyrowave-sys standalone build needs
(see the rm -rf list in the script). All parts are MIT-licensed (see the rm -rf list in the script). All parts are MIT-licensed
(pyrowave, Granite) or Apache-2.0/MIT (volk, Vulkan-Headers). (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 EOF
echo "Vendored pyrowave@${PYROWAVE_COMMIT:0:12} (Granite ${GRANITE_COMMIT:0:12}) into $DEST" echo "Vendored pyrowave@${PYROWAVE_COMMIT:0:12} (Granite ${GRANITE_COMMIT:0:12}) into $DEST"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@punktfunk/host", "name": "@punktfunk/host",
"version": "0.1.1", "version": "0.1.0",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.", "description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module", "type": "module",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
+11 -32
View File
@@ -92,40 +92,19 @@ export const discoverUnits = (
// no scripts dir — fine // no scripts dir — fine
} }
const modules = path.join(pluginsDir, "node_modules"); const modules = path.join(pluginsDir, "node_modules");
// 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(dir, "package.json"), "utf8"),
) as { main?: string; module?: string };
const rel = manifest.module ?? manifest.main ?? "index.js";
const file = path.join(dir, rel);
if (!fileIsSafe(file, log)) return;
units.push({ name, file });
} catch (e) {
log(`[runner] skipping ${name}: unreadable package.json (${e})`);
}
};
try { try {
for (const pkg of fs.readdirSync(modules).sort()) { for (const pkg of fs.readdirSync(modules).sort()) {
// Unscoped convention: `punktfunk-plugin-*`. if (!pkg.startsWith("punktfunk-plugin-")) continue;
if (pkg.startsWith("punktfunk-plugin-")) { try {
addPlugin(path.join(modules, pkg), pkg); const manifest = JSON.parse(
continue; fs.readFileSync(path.join(modules, pkg, "package.json"), "utf8"),
} ) as { main?: string; module?: string };
// Scoped convention: `@punktfunk/plugin-*` (first-party). A scoped name resolves cleanly const rel = manifest.module ?? manifest.main ?? "index.js";
// from a single registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` const file = path.join(modules, pkg, rel);
// as shared (hoisted) deps rather than bundling its own copy of each. if (!fileIsSafe(file, log)) continue;
if (pkg === "@punktfunk") { units.push({ name: pkg, file });
try { } catch (e) {
for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) { log(`[runner] skipping ${pkg}: unreadable package.json (${e})`);
if (scoped.startsWith("plugin-")) {
addPlugin(path.join(modules, pkg, scoped), `${pkg}/${scoped}`);
}
}
} catch {
// no @punktfunk scope dir — fine
}
} }
} }
} catch { } catch {
-19
View File
@@ -83,25 +83,6 @@ describe("discovery", () => {
expect(logs.join("\n")).toContain("REFUSING"); expect(logs.join("\n")).toContain("REFUSING");
expect(logs.join("\n")).toContain("evil.ts"); 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", () => { describe("supervision", () => {
+2 -3
View File
@@ -109,9 +109,8 @@ export function AppShell({ children }: { children: ReactNode }) {
</header> </header>
<main className="flex-1"> <main className="flex-1">
{/* Mobile: a 16px side gutter (matching the top bar) so content isn't overly narrow; {/* pb-24 leaves room for the fixed bottom nav on mobile. */}
pb-24 leaves room for the fixed bottom nav. Roomier padding from sm up. */} <div className="mx-auto max-w-[1700px] p-6 pb-24 sm:p-10 sm:pb-10">
<div className="mx-auto max-w-[1700px] px-4 py-6 pb-24 sm:p-10 sm:pb-10">
{children} {children}
</div> </div>
</main> </main>
+3 -3
View File
@@ -33,7 +33,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-4 sm:p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
)); ));
@@ -67,7 +67,7 @@ const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0 sm:p-6 sm:pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)); ));
CardContent.displayName = "CardContent"; CardContent.displayName = "CardContent";
@@ -77,7 +77,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-4 pt-0 sm:p-6 sm:pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />
)); ));