Commit Graph
20 Commits
Author SHA1 Message Date
enricobuehler deeb8b6700 feat(pf-encode): build against FFmpeg 9
apple / swift (pull_request) Successful in 1m53s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m34s
ci / web (pull_request) Successful in 2m32s
ci / docs-site (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 26s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m23s
android / android (pull_request) Successful in 6m47s
ci / rust-arm64 (pull_request) Successful in 8m49s
nix / flake (pull_request) Failing after 16m7s
ci / rust (pull_request) Successful in 23m39s
ffmpeg-next 8.1.0 could not accept FFmpeg 9 at all: ffmpeg-sys-next's version probe
covered avcodec majors 56..62 (the range is exclusive of its end), so libavcodec 63 fell
outside what it knew how to bind. 9.0.0 widens that to 56..63, which is what actually
unblocks Arch. Bump both pins — the unconditional Linux dep and the optional Windows
amf-qsv one — and the lock with them.

No API drift to fix. The crate major is a CEILING, not a target: one source tree still
spans FFmpeg 7.x/libavcodec 61, 8.x/62 and 9.x/63 via per-version cfgs, and every wrapper
symbol the NVENC-libav, VAAPI and amf-qsv backends name survives 8.1.0 -> 9.0.0
unchanged. The three hand-written #[repr(C)] hwcontext mirrors are the parts no compiler
checks, so they were re-read against the real headers rather than trusted:
AVCUDADeviceContext and AVD3D11VAFramesContext are byte-identical across 7.1/8/9, and
AVD3D11VADeviceContext gained two trailing UINTs in 8 that 7.1 lacks — which is why that
mirror deliberately stops at the common prefix, and why its assertions now say what they
do and do not buy you. They pin our layout, not libav's; a green build is not evidence.

The CI image is the step that makes this reach users. arch.yml deliberately runs no -Syu
("the image's snapshot IS the build environment"), so the builder stayed frozen on ffmpeg
8 no matter what Arch shipped, and a canary built from that snapshot could not satisfy the
soname dep the PKGBUILD now derives. Re-keying ci/ rebuilds it against ffmpeg 9.

Ubuntu and Windows deliberately stay put: the noble .deb bundles its own FFmpeg 8 behind
an rpath and strips the libav sonames from its Depends, and Windows bundles BtbN DLLs into
the signed installer — neither is exposed to the break, BtbN publishes no FFmpeg 9 build,
and moving either would re-qualify an encode stack to buy nothing.

Verified end to end on 192.168.1.21 (CachyOS, system ffmpeg 2:9.0-5, RTX 5070 Ti): host
builds clean and links libavcodec.so.63/libavutil.so.61/libavfilter.so.12/libswscale.so.10
with no unresolved sonames; the ffmpeg-8 compat shim is gone and the service runs with
NRestarts=0 and answers 401 on :47990; pf-encode's 67 tests pass; and a live synthetic
encode drives real NVENC hardware through FFmpeg 9's libavcodec to a decodable 1080p HEVC
stream (180/180 frames, FEC loopback 0 mismatches) with libavcodec.so.63 and
libnvidia-encode both mapped into the encoding process.
2026-08-08 02:35:20 +02:00
enricobuehler 01294e3a53 refactor(pf-encode): WP4 — one split policy, shared with the libav path
The libav NVENC path carried its own inline copy of the split decision and had
already drifted from the direct-SDK selector: it hard-coded a 2-way split
regardless of engine count, and had no depth rule at all. That is the drift the
shared resolver was extracted to prevent, and the copy quietly reintroduced it.

Routing it through `resolve_split_mode` needed the policy to MOVE. `nvenc_core`
is gated on `feature = "nvenc"`, but the libav path is precisely the build where
that feature is OFF (`PUNKTFUNK_NVENC_DIRECT=0`, and the featureless packages --
the packaging gap this project has been bitten by before). So
resolve_split_mode / max_forced_split_mode / clamp_to_engines, plus a new
`forced_split_width`, now live in `codec.rs`, which is always compiled and
already owned SPLIT_FORCE_PIXEL_RATE.

That means the NV_ENC_SPLIT_ENCODE_MODE values had to be hand-written as plain
constants, since the SDK enum does not exist without the feature. They are
therefore pinned: `nvenc_split_constants_match_the_sdk` (feature-gated, the only
place both are visible at once) asserts all five against the real enum, so the
copies cannot rot.

⚠ Only the FORCED outcomes are actionable on the libav side -- libavcodec's
`split_encode_mode` AVOption is its own vocabulary and our DISABLE is the NVENC
enum's 15, which would be meaningless there. DISABLE/AUTO both map to "leave the
option unset", which is exactly today's behaviour (unset = the driver's auto).
`engines = 0` ("not probed") maps to 2-way, preserving what that site always did;
a 3-NVENC part gets the wider split only on the direct-SDK path, which is the one
that actually probes.

⚠⚠ VERIFICATION GAP: .133 went down mid-change (no ping), so the WINDOWS leg is
UNVERIFIED. This matters more than usual -- the Windows backend imported
resolve_split_mode from nvenc_core and that import had to move too, which a grep
caught rather than a compiler. Re-run before trusting it:
  cargo clippy -p pf-encode --features nvenc --all-targets -- -D warnings

Verified .21: clippy -D warnings clean BOTH with and without the nvenc feature
(the featureless build is the whole point of the move) and with
nvenc,vulkan-encode; 65 unit tests incl. the new constant-parity test; 25/25
NVENC on-hardware; punktfunk-host clippy clean. fmt clean.
2026-08-07 08:16:35 +02:00
enricobuehler aa070f2d55 feat(ffi): hand-mirrored C structs are now layout-checked at compile time
The sharpest memory-safety risk left in this codebase is not an `unsafe` block — it is a
hand-written `#[repr(C)]` mirror of an external C struct. Get a field offset wrong and nothing fails
to compile and nothing reliably crashes: the library reads a pointer, a length or a pitch out of the
wrong bytes. Eleven such structs across five files had NO check at all.

Guarded here, each next to the struct it protects:

* `AVCUDADeviceContext`, and `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext`. `ffmpeg-sys-next`
  binds none of them, so these mirrors are the only definitions — and we WRITE through them
  (`cuda_ctx`, `device`, `bind_flags`). ⚠ The D3D11VA pair is duplicated VERBATIM in two crates
  (pf-encode's `ffmpeg_win.rs`, pf-client-core's `video_d3d11.rs`) because neither can depend on the
  other; they must agree with libav and with each other, and now a drift in either is a build error.
* The six cuda.h structs. Three were already asserted — but only in `#[cfg(test)]`, so the check ran
  when someone ran the tests and never in a release build. They are `const` now. The other three,
  including `CUDA_MEMCPY2D` which is filled on EVERY zero-copy frame, had nothing.
* `MsghdrX`, Darwin's `msghdr_x`, which `libc` does not expose. Its layout is not reviewable by eye:
  the 32-bit fields force padding before each following pointer, so `msg_iov` sits at 16 and not 12.
  `sendmsg_x`/`recvmsg_x` take the pointer and length from it.
* `IPolicyConfigVtbl` — the sharpest of the set. It mirrors an UNDOCUMENTED COM interface, and
  `set_default_endpoint` is called by SLOT INDEX through a ten-entry `_reserved` gap that carries no
  names to anchor a review. A field added or resized above it does not break the build; it calls a
  different function pointer through a mismatched signature.

Every assertion is `const _: () = assert!(..)`, so it holds on every build including release and
cannot be skipped. The compiler verified the numbers — the sizes and offsets asserted here are the
ones the target actually produces, on each platform that compiles the struct.

Verified: Linux .21 fmt + both CI clippy steps rc=0 (CUDA + libav CUDA mirrors); Windows .47 full CI
clippy set rc=0 + pf-capture tests (D3D11VA pair, COM vtable); macOS `cargo check -p punktfunk-core`
(MsghdrX — the only platform that compiles it).
2026-07-29 08:48:42 +02:00
enricobuehler d72822ced7 refactor(encode/linux): second fence off — CudaHw::new's pointer walk gets its proof
`linux/mod.rs`'s fifteen sites are the same kind as `video_vulkan.rs`'s, not the ash kind: nine raw
pointer dereferences and six libav calls, all inside `CudaHw::new`, which had no `unsafe` block and
therefore no proof of the one thing worth proving here — that the pointer chain it walks is live.

The marker STAYS (`cu_ctx: *mut c_void` is a `CUcontext` the caller must supply valid). The body is
now two blocks, one per phase, because there are two distinct arguments to make. Both turn on the
same non-obvious fact: `av_hwdevice_ctx_alloc`/`av_hwframe_ctx_alloc` return null or a ref whose
`data` libav has ALREADY initialized, and `AvBuffer::from_raw` rejects null — so the `?` leaves
before any of the field stores below it can run. That is what makes the `(*dev_ctx)`/`(*fc)` writes
in-bounds stores on live allocations rather than a hope, and it is exactly the reasoning that was
missing. The device block also records the ordering constraint that was implicit: `cuda_ctx` must be
stored BEFORE `av_hwdevice_ctx_init`, which reads it.

Two files now need no exemption: 14 fenced -> 12. Both were removable for the same reason — their
sites are pointer dereferences, where a proof carries an argument, unlike the ash backends where it
could only restate the call. That is the criterion for which fence to attack next, not file size.

Verified on .21: fmt + `clippy --workspace --all-targets -- -D warnings` + the feature-gated
`-p pf-encode --features nvenc,vulkan-encode,pyrowave` step, all rc=0 with no allow in either file.
2026-07-29 00:20:35 +02:00
enricobuehler 6de325a6b6 fix(ci): the unsafe lint said warn while CI enforced it as deny, and main went red
windows-host / package (push) Failing after 13m8s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m55s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m5s
android / android (push) Failing after 6m25s
ci / bench (push) Successful in 8m21s
deb / build-publish (push) Successful in 8m44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 32s
deb / build-publish-host (push) Successful in 10m2s
ci / rust (push) Failing after 17m18s
ci / rust-arm64 (push) Successful in 16m6s
arch / build-publish (push) Failing after 17m19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m12s
deb / build-publish-client-arm64 (push) Successful in 14m5s
flatpak / build-publish (push) Failing after 8m59s
apple / swift (push) Failing after 7m20s
apple / screenshots (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 17m16s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 26m3s
`unsafe_op_in_unsafe_fn = "warn"` was adopted workspace-wide in 39513528 on the assumption that
`warn` is a soft setting you can clear at leisure. It is not: ci.yml runs `cargo clippy … -D
warnings`, which promotes it to a hard error, so main has failed on EVERY commit since — Linux
`rust` and `rust-arm64` both dying on `pf-client-core` with 70 E0133 errors, and windows-host.yml
alongside them. A lint level that understates its own severity is worse than a strict one, so this
states what CI already does — `deny` — and writes the exemptions down instead.

Fourteen GPU/FFI backend files take `#![allow(unsafe_op_in_unsafe_fn)]`, each with its reason and
the workspace Cargo.toml carrying the argument in full. They are not "not done yet": measured
across them, 64% of the sites are a single third-party FFI call (ash, pyrowave-sys, libav, the
NVENC/AMF entry tables), and of the 44 `unsafe fn`s only 4 have a body containing no unsafe
operation at all. Since pf-encode also denies `undocumented_unsafe_blocks`, narrowing them means a
hand-written SAFETY comment per line that could only restate the signature — the exact noise that
made `unsafe` stop meaning anything here before. Everything else stays at zero and enforced, and
each allow is removable on its own terms.

Two smaller things this had to clear, both invisible to the job that would have caught them:

- `service.rs`: `undocumented_unsafe_blocks` wants the proof on EACH block, and a comment covering
  a group of consecutive `unsafe` statements only credits the first — so the two `OwnedHandle`
  wraps became their own statements. Windows-gated, so only the .47 gate sees it. 37a42078 fixed
  the same class of failure in pf-frame/dxgi.rs concurrently; that fix is taken as-is here.
- `clients/linux/ui_settings.rs`: `show`'s deletion left its doc comment stranded on `show_scoped`,
  which silently merged two unrelated doc blocks onto one fn. Removed.

Verified by running the real CI commands, not proxies: on .21 fmt + `clippy --workspace
--all-targets -- -D warnings` + the feature-gated `-p pf-encode --features
nvenc,vulkan-encode,pyrowave` + build + test, all rc=0; on the Intel box .47 all four
windows-host.yml clippy invocations, all rc=0. The arm64 job lints pf-client-core and
punktfunk-client-linux, both covered here.
2026-07-28 22:32:30 +02:00
enricobuehler 7adc1db672 refactor(encode): AVBufferRef ownership moves into an RAII handle
`CudaHw::new` and `VaapiHw::new` are the same shape — alloc a hwdevice, deref it
to fill fields, init, alloc a frames ctx, deref, init — and both unwound by hand:
`av_buffer_unref` on every failure branch (three in CUDA, two in VAAPI) plus a
hand-written `Drop` repeating the pair. That shape has two failure modes and the
compiler can see neither: add a branch and forget the cleanup (leak), or let two
cleanup paths run (double-unref, an abort inside glibc).

`libav::AvBuffer` owns the ref instead. It null-checks on the way in — the check
each caller open-coded — and unrefs exactly once on drop, so an early `?` releases
whatever was built so far and the failure branches carry no cleanup at all. Both
hand-written `Drop` impls are gone, and `linux/mod.rs` goes from seven
`av_buffer_unref` calls to zero.

The one subtlety, called out at both structs: these two fields must stay declared
frames-BEFORE-device. Fields drop in declaration order, and the code being replaced
deliberately unref'd frames first (a frames ctx holds its own reference on its
device). Refcounting makes either order sound, but a field reorder should not
silently change what ships, so the ordering is load-bearing and commented as such.

Also adds `cuda_hw_alloc_drop_cycles` — the RAII path had NO test coverage: the
NVENC smoke tests take the CPU path and never construct a `CudaHw`, and the VAAPI
twin's tests need AMD/Intel silicon. Looping construct/drop is what catches the
double-unref (abort) and the leak (allocator growth) this refactor is about.

Verified on Nobara (RTX 5070 Ti): `cargo check -p pf-encode --all-targets` clean at
exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and
`cuda_hw_alloc_drop_cycles` passes against a real CUDA device — eight
construct/drop cycles, no abort. `VaapiHw` is compile-verified only; it is the
identical shape but no AMD/Intel box was reachable to run its two ignored tests.
2026-07-28 21:31:41 +02:00
enricobuehler 28c50d1c5b fix(encode): every backend signals its colour, so no decoder has to guess
audit / cargo-audit (push) Successful in 2m38s
audit / bun-audit (push) Successful in 13s
ci / rust (push) Failing after 12s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 6m59s
ci / rust-arm64 (push) Successful in 10m2s
android / android (push) Successful in 13m6s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
arch / build-publish (push) Failing after 14m19s
deb / build-publish (push) Successful in 11m13s
deb / build-publish-host (push) Failing after 4m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
docker / build-push-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 35s
windows-host / package (push) Failing after 7m59s
windows-host / winget-source (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 7m11s
apple / swift (push) Successful in 5m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m21s
flatpak / build-publish (push) Successful in 6m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 22m57s
Three encode paths shipped a bitstream with no colour description at all,
leaving primaries/transfer/matrix/range "unspecified":

- Vulkan Video HEVC (`vk_build.rs`) built an SPS with no VUI whatsoever.
  This is the DEFAULT backend for AMD/Intel Linux hosts on HEVC/AV1.
- Vulkan Video AV1 packed `color_description_present_flag = 0`.
- The openh264 software path wrote nothing (it converts BT.709 limited and
  relied on decoders defaulting to that).
- The libav-NVENC Linux path excluded packed-RGB 4:2:0, on the belief that
  "NVENC's internal CSC writes its own VUI". It doesn't: libavcodec derives
  `colourDescriptionPresentFlag` from the AVCodecContext colour fields, so
  leaving them unspecified emits none. Reachable on a CPU/dmabuf capture, a
  build without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0.

Unsignalled looks fine on every punktfunk client — `csc_rows` falls back to
BT.709 on "unspecified" — which is why this survived. Vendor TV decoders do
not: they guess colorimetry from RESOLUTION, and an LG webOS panel reads a
4K SDR stream as BT.2020 and renders it visibly washed out.

All four now signal BT.709 limited, which is what every host CSC actually
produces (`rgb2yuv.comp`, `convert_bt709`, the swscale paths) and what the
Welcome's `ColorInfo::SDR_BT709` already advertises out-of-band. NVENC,
VAAPI, QSV, AMF and the Windows libav path were already correct.

Two tests, both parsing the REAL emitted bitstream rather than re-asserting
the constants: an independent bit-walk of the AV1 sequence header (the
packed OBU must stay identical to the `StdVideoAV1ColorConfig` handed to the
driver), and an H.264 SPS/VUI parse proving openh264 honours the request
instead of dropping it.

Not yet verified on hardware: the HEVC VUI depends on the driver's SPS
writer emitting `vui_parameters()`. PUNKTFUNK_VULKAN_ENCODE=0 falls back to
VAAPI if a driver mishandles it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3c56ff5717b2c9a0871953127da3dadd6a84220d)
2026-07-28 17:01:59 +02:00
enricobuehler 188f55d3b1 fix(encode/nvenc): the host advertises what the driver lists, not a superset
Every NVIDIA host advertised a static H.264|HEVC|AV1 superset, so a 1st-gen
Maxwell (GTX 960M, no HEVC/AV1 encode) offered HEVC — a client that believed
it got ~15 s of blank video and a disconnect instead of a stream. Both OSes
now ask the driver itself (nvEncGetEncodeGUIDs) on one throwaway direct-SDK
session: Linux on the shared CUDA context, Windows on the selected render
adapter, wired into host_wire_caps AND the GameStream serverinfo mask (which
had been left on the superset for NVIDIA on both OSes). Fails open — an
unanswerable probe keeps the historical superset, so it can only ever narrow
the advertisement to codecs the GPU really encodes.

The HEVC 4:4:4 answer rides the same session on Linux instead of opening a
libav hevc_nvenc FREXT probe: that open is the prime suspect for the field
bug where one probe wedges NVENC process-wide (NV_ENC_ERR_INVALID_VERSION on
every later session until a host restart), and the direct backend re-checks
the same caps bit at session open anyway. The ffmpeg probe remains only for
hosts that really stream over libav (PUNKTFUNK_NVENC_DIRECT=0 or a build
without the nvenc feature), where ffmpeg's NVENC client runs regardless. The
10-bit probe deliberately stays libav — Linux HDR rides the libav P010 path.

On-hardware: .136 (RTX 5070 Ti) 14/14 nvenc tests in one process incl. the
probe followed by real sessions and dirty teardown; .173 (Windows RTX) probe
+ 47 release lib tests. The Windows probe test documents the pre-existing
MSVC debug-link failure (LNK2019 via the sdk crate's unused lazy loader) —
run it with --release, the same reason windows-host.yml gates with clippy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 0346ec8090568eb499e8cb7d735305b28471185e)
2026-07-28 17:01:59 +02:00
enricobuehlerandClaude Opus 5 bf9386ecb2 test(pf-encode): pin the typed-EINVAL classifier's chain-survival contract (Phase 8)
ci / docs-site (push) Failing after 52s
android / android (push) Failing after 6m28s
ci / web (push) Successful in 1m5s
ci / bench (push) Successful in 6m40s
decky / build-publish (push) Successful in 26s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
arch / build-publish (push) Successful in 12m51s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / rust-arm64 (push) Successful in 9m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 17s
deb / build-publish-client-arm64 (push) Successful in 8m54s
deb / build-publish-host (push) Successful in 10m31s
deb / build-publish (push) Successful in 11m17s
windows-host / package (push) Successful in 10m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m25s
docker / build-push-arm64cross (push) Skipped
docker / deploy-docs (push) Skipped
apple / swift (push) Successful in 5m25s
ci / rust (push) Successful in 22m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m7s
apple / screenshots (push) Successful in 23m50s
Rides on 25765c53 (the concurrent session landed the same typed-errno
fix first — this branch's twin commit was dropped in the rebase; these
are its surviving residuals):
- Two tests pinning what the ladder's step-down rests on: the typed
  ffmpeg::Error survives with_context layers as a downcastable source
  (an eager format! anywhere between open_with and the ladder would
  silently break it), and the phrase WITHOUT the type no longer
  classifies.
- The CudaHw::new eager-format guard note: those bail!s must NOT be
  converted to typed errors — a hwdevice/hwframes EINVAL is a config
  error no bitrate can fix, and enrolling it would burn ~10 doomed
  opens before surfacing the real failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:13:49 +02:00
enricobuehlerandClaude Opus 5 1cefd37603 fix(pf-encode): arbitrate NVENC split-encode vs sub-frame readback (Phase 8)
Verified against nvEncodeAPI.h's own splitEncodeMode doc (user-prompted
— the audit's 'exclusive for HEVC' one-liner deserved checking):
- H.264: split 'is not applicable' — hard-DISABLE the mode so the
  written config, CeilingKey, the split diagnostic log and the
  rejection-retry stay truthful (the retry used to re-open a
  byte-identical session after an H.264 'split rejection'). The libav
  path's operator arm gains the codec gate its auto arm always had.
- HEVC: split 'not supported if … subframe mode' — when WE force split
  (TWO/THREE/AUTO_FORCED, the 4K120 throughput lever), sub-frame yields
  with a logged escape (PUNKTFUNK_SPLIT_ENCODE=0 chooses sub-frame).
  ⚠ Keyed on FORCED modes only, never != DISABLE: AUTO(0) is the
  resolver's fallthrough for every sub-950Mpix session, and the wider
  key would have disarmed the Phase-3 chunked-poll feature fleet-wide
  (critic catch). Under AUTO the driver arbitrates — the shipped state.
- AV1: untouched — per-tile sub-frame + split are legal together.

The arbitration is a pure nvenc_core fn called by each backend BEFORE
the ladder, the ceiling key and the chunked-poll latch — all three see
the post-arbitration truth. A drop inside build_init_params would have
left poll_chunk busy-polling its whole budget every AU (numSlices stays
0 without reportSliceOffsets; both loop exits dead — critic catch).
Linux latches subframe_forced beside subframe_on at query_caps (no env
re-reads after open); Windows records the arbitrated state so
reconfigure presents exactly the params the open had (also closes the
pre-existing mid-session env-flip hazard there).

Truth-table tests in nvenc_core; PUNKTFUNK_NVENC_SUBFRAME documented
(it never was); PUNKTFUNK_SPLIT_ENCODE row updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:08:44 +02:00
enricobuehlerandClaude Fable 5 25765c53ec fix(encode): classify libav-NVENC open failures by errno, not English strerror text
ci / docs-site (push) Successful in 58s
ci / web (push) Successful in 59s
apple / swift (push) Successful in 5m17s
ci / bench (push) Successful in 7m27s
deb / build-publish (push) Failing after 8m0s
ci / rust-arm64 (push) Failing after 9m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
ci / rust (push) Failing after 9m31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish-host (push) Successful in 10m23s
arch / build-publish (push) Successful in 12m54s
android / android (push) Successful in 15m8s
deb / build-publish-client-arm64 (push) Successful in 7m54s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 5m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 6m35s
windows-host / package (push) Successful in 19m10s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 24s
apple / screenshots (push) Successful in 23m39s
The bitrate-probe ladder stepped down on format!("{e:#}").contains(
"Invalid argument") — an English substring over the WHOLE context chain,
which also fired on any other wrapped EINVAL (e.g. a CUDA-context errno)
and gated a ~10-step ladder on strerror wording. The root ffmpeg::Error
survives the anyhow chain; downcast and match Error::Other{errno:EINVAL}
instead. Same fix for the intra-refresh ENOSYS probe in the open path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:05:24 +02:00
enricobuehler 3fac1a6da1 fix(encode/linux): stop the capability probes racing each other's libav log level
Every capability probe drops libav's log level to AV_LOG_FATAL around an encoder open it
expects to fail, then restores what was there before. That level is one process-global int
and the probes genuinely overlap — they are reached both from `/serverinfo` and from session
bring-up. Two overlapping save/restore pairs interleave as get(INFO) → get(FATAL) →
set(INFO) → set(FATAL), and the process is then pinned at AV_LOG_FATAL permanently: every
later libav diagnostic silently dropped, including the ones you want when a stream fails to
open later on.

Replaces the four hand-rolled save/restore pairs with one RAII guard over a single shared
mutex. The audit filed two sites; there are four — the NVENC 4:4:4 and 10-bit probes in
`linux/mod.rs` and both VAAPI probes, which is why the lock has to be shared across the two
modules rather than kept local to either.

Being RAII also closes a smaller hole the old shape had: the restore was a statement after
the open, so any early return added to those functions later would have leaked the quiet
level. Now it cannot.

The guard is poison-tolerant — a probe that panicked mid-window has already restored the
level through `Drop`, so refusing the lock forever afterwards would be strictly worse than
proceeding. It is not re-entrant, which is safe here because no probe body reaches another
probe (they only call encoder-open helpers); that invariant is written down at the type.

Serialising the probes costs nothing measurable: they are process-once behind their caches
and each already pays for a real encoder open.

Verified with the canonical Linux gate (docker linux/amd64): fmt, clippy --all-targets at
default and at nvenc,vulkan-encode,pyrowave, and the pf-encode test leg (37 passed).
2026-07-25 13:00:39 +02:00
enricobuehlerandClaude Opus 5 ef239691df feat(encode): make cursor blending a queryable capability, not an assumption
`open_video`'s `cursor_blend` argument was a request with no answer: lib.rs did
`let _ = cursor_blend;` and only three backends ever read `CapturedFrame::cursor`.
So a session could ask for a composited pointer, get a backend that silently
discards it, and stream with no mouse cursor and nothing in the logs. Two
separately-confirmed audit findings — the VAAPI dmabuf path and the libav-NVENC CUDA
path — are symptoms of that one hole.

`EncoderCaps::blends_cursor` makes it a fact each backend states. The four exhaustive
`EncoderCaps { .. }` constructors mean adding the field is a compile error until every
backend answers, which is the enforcement mechanism for future backends rather than a
side effect. Vulkan Video answers from its ACTUAL configured source rather than
statically: only the CSC path composites (`prep_cursor` feeds the compute shader),
while the RGB-direct/EFC front-end and the native-NV12 source have no compositing
stage at all and merely warn once that the pointer is being dropped.

`open_video` warns when a session asked for blending and the opened backend cannot
deliver it. A warning is deliberately all it does: `open_video` cannot re-plan
capture, so refusing would trade a missing pointer for a dead session. The host owns
`plan.cursor_blend` and is the only layer that can fall back to capturer-side
compositing — this gives it something to base that on.

Enforcement is NOT included. The reviewed design proposed refusing the client's
host-composite flip to keep the client drawing its own pointer, but `CursorRenderMode`
is client->host only: there is no host->client counterpart, so refusing yields no
pointer at all — the same failure it claimed to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 02:41:57 +02:00
enricobuehlerandClaude Fable 5 c1d54b835b fix(encode/nvenc): cache the level ceiling, expose the applied bitrate, force 2-way split at 4K120
Three encode-time fixes from the 4K120 field analysis (sessions pinned ~107 of
120 fps, 14-15 ms reported encode):

- Ceiling truth: the codec-level bitrate clamp (binary search at session open)
  now records its result in a process-lifetime advisory cache keyed by
  GPU/config, so an ABR overshoot opens — or in-place reconfigures — straight
  AT the ceiling instead of re-running the ~6-open search (and a full rebuild
  + IDR) on every overshoot. New `Encoder::applied_bitrate_bps()` exposes the
  post-clamp rate so the session loop can stop pacing/acking a phantom
  requested rate (consumed host-side in a follow-up).

- Clamp-search hygiene: only NVENC parameter/caps rejections steer the search
  now (`NvCallError` keeps the raw status downcastable); a transient failure
  (busy engine, session limit, OOM, driver skew) propagates instead of
  shrinking into — and now caching — a bogus ceiling. The floor fallback also
  records the split mode it actually opened with, so a later reconfigure
  re-presents the real session params.

- Split-frame selection: one shared `resolve_split_mode` replaces the two
  byte-identical direct-SDK copies; the force-2-way threshold moves to
  `SPLIT_FORCE_PIXEL_RATE` (950 Mpix/s, shared with the libav path) because
  4K120 = 995,328,000 px/s missed the old `> 1e9` gate by 0.47% and stayed on
  AUTO — which never engages at 2160 px height, leaving the second NVENC
  engine idle in exactly the mode the threshold existed for. The Linux
  session-ready info! line now carries the final split mode (journals are
  INFO+; this was undiagnosable from user logs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:44:54 +02:00
enricobuehlerandClaude Opus 5 4063ddc93d perf(encode/nvenc-linux): swscale the packed 3-bpp expand instead of a per-pixel loop
`submit_cpu`'s RGB24/BGR24 → rgb0/bgr0 expand ran `w*h` iterations per frame, each
building two bounds-checked sub-slices for a 3-byte copy plus a pad-byte store — a
shape LLVM will not vectorise into the byte shuffle it actually is. At 3840x2160
that is 8.3M iterations on the encode thread, per frame.

It is not an edge case. The module header says the portal commonly negotiates
packed 24-bit RGB, and pf-capture offers `VideoFormat::RGB` first because wlroots
commonly fixates it — so this was the mainstream CPU path, running roughly an order
of magnitude slower than the 4-bpp sibling branch (a plain row memcpy) purely
because of how it was written.

swscale's packed-RGB expanders are SIMD, the sibling VAAPI backend already routes
RGB24/BGR24 through them, and this file already owned the `sws_getContext` /
`sws_freeContext` lifecycle — so this is net subtractive rather than new machinery:
the existing CSC condition widens to include `expand`, and the hand-written loop
goes away. No new field, no second context, nothing added to `Drop`: `expand` is
false whenever `want_444` (see the `nvenc_pixel`/`expand` binding), so the three
users are mutually exclusive and one context serves whichever applies. The `expand`
field itself is gone with its only reader.

`sws_setColorspaceDetails` is now applied ONLY for the 4:4:4/HDR users. The expand
is a pure byte shuffle — NVENC does the RGB→YUV itself downstream — and handing it
a matrix and range would have silently range-converted every packed-RGB session,
which is exactly what the module header promises does not happen.

Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc+vulkan-encode+pyrowave). The pixel path itself is unverifiable without an
NVIDIA box: `nvenc_hdr10_smoke` and friends are #[ignore]d, so the channel order
and the pad byte want an on-glass check on the CachyOS 5070 Ti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Opus 5 c7081b2a82 fix(encode/nvenc-linux): stop leaking the SwsContext on open's early returns
`sws_getContext` hands back a raw pointer whose only `sws_freeContext` lives in
`Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED `Self`, which does not
exist on either of `open`'s early returns: the intra-refresh-unsupported path
(which recurses into `Self::open`) and the plain error return. Both sit between
the context's creation and the `Ok(NvencEncoder { … })` that would give it an
owner, so every failed open leaked one.

That is not a once-per-process wart. `open_nvenc_probed` walks an EINVAL bitrate
ladder — requested rate, then the codec-level cap, then ×3/4 down to 50 Mb/s —
re-entering `open` at each step, so a GPU that refuses the requested bitrate leaks
a context per step (up to ~10). The intra-refresh retry adds one more.

Fixed structurally rather than with a guard: the block depends only on values known
before the encoder open (`want_444`, `want_hdr10`, `cuda`, `format`, `nvenc_pixel`,
the dimensions, `full_range_444`), so moving it BELOW `video.open_with(opts)` — to
just above the struct construction, with nothing fallible in between — makes the
leak unrepresentable instead of merely handled. No behaviour change: same context,
same colourspace details, same field.

Found while designing the WP1.4 swscale expand, which would have promoted this from
a 4:4:4/HDR-only leak (the only sessions that build a context today) to one on every
packed-RGB session. Fixing it first is the prerequisite for that change.

Verified `-D warnings --all-targets` + tests on Linux (default; shipped
nvenc+vulkan-encode+pyrowave).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:32:37 +02:00
enricobuehlerandClaude Fable 5 cd36c46388 perf(encode/nvenc-linux): LN2 v1 — stream-ordered submit via NvEncSetIOCudaStreams
Bind the session's IO streams to the encode thread's high-priority copy
stream: in sync-retrieve depth-1 use the per-frame input copy and cursor
blend now enqueue with NO cuStreamSynchronize and encode_picture orders
after them on the stream. Same stream both directions, so the encode's
completion is inserted into the stream and later work (the next frame's
copy into a reused ring slot) waits for it.

Soundness gate: the fast path engages only when pending is empty (true
depth-1 usage) — every prior encode was drained by a blocking poll, and
the caller holds the frame payload across the matching poll (contract now
documented on Encoder::submit; both host loops already comply). Pipelined
callers and PUNKTFUNK_NVENC_ASYNC mode keep the blocking copies.

True zero-copy input registration (registering the worker-owned IPC
buffer directly) stays the LN2 v2 follow-up — it needs a contiguous
worker-pool NV12 layout and a registration<->IPC-mapping lifetime tie.

PUNKTFUNK_NVENC_STREAM_ORDERED=0 restores the old blocking behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:38:22 +02:00
enricobuehlerandClaude Opus 4.8 00f759ec72 style(pf-encode): clear the clippy gate on the HDR/PyroWave additions
`cargo clippy --workspace --all-targets --locked -- -D warnings` was red on
main — three lints landed with the GNOME 50 HDR + PyroWave 4:4:4 work:

* pyrowave_wire.rs: `aw / 2 >> level` tripped clippy::precedence. Rust already
  binds `/` tighter than `>>`, so this always parsed as `(aw / 2) >> level`
  (subband dim at half res, then one halving per DWT level) — the parens are
  purely explicit, no change in behaviour.
* linux/mod.rs: `probe_can_encode_10bit` sat after `mod hdr_tests`
  (clippy::items_after_test_module) — moved above the test module, unchanged.

Lint-only; no functional change. fmt/clippy/test all green afterwards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 10:23:47 +02:00
enricobuehlerandClaude Fable 5 0e977817f9 feat(hdr): GNOME 50 HDR screencast capture + Linux Main10 encode
ci / docs-site (push) Successful in 58s
ci / rust (push) Failing after 1m3s
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m22s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
windows-host / package (push) Failing after 5m38s
ci / bench (push) Successful in 6m12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m38s
apple / screenshots (push) Successful in 6m25s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-host (push) Failing after 7m30s
deb / build-publish (push) Successful in 11m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m59s
arch / build-publish (push) Successful in 11m57s
android / android (push) Successful in 15m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m46s
GNOME 50 (Mutter MR 4928, PipeWire >= 1.6) added HDR screen sharing for
monitor streams: 10-bit PQ formats (xRGB_210LE/xBGR_210LE) with MANDATORY
BT.2020 + SMPTE-2084 colorimetry props, advertised while the mirrored
monitor is in BT.2100 colour mode. Wire the Linux host into it end-to-end
on the GameStream desktop-mirror path (PUNKTFUNK_VIDEO_SOURCE=portal):

* pf-frame: PixelFormat::X2Rgb10/X2Bgr10 (DRM XR30/XB30; X2Bgr10 is the
  Windows Rgb10a2 layout) + fourccs.
* pf-capture: want_hdr portal offer — HDR-only LINEAR-dmabuf pods with
  MANDATORY PQ/BT.2020 props (SHM excluded: Mutter's SHM record path
  paints 8-bit ARGB32 regardless of format; tiled excluded: the EGL
  de-tile blit is 8-bit RGBA8), negotiated-colorimetry parse, generic
  HDR10 hdr_meta(), packed-10-bit CPU cursor blend, a process-wide SDR
  downgrade latch on negotiation timeout, and a DisplayConfig BT.2100
  colour-mode probe (gnome_hdr_monitor_active).
* pf-encode: libav NVENC X2RGB10->P010 swscale (BT.2020 limited) ->
  HEVC Main10 / 10-bit AV1 with PQ VUI; VAAPI 10-bit on both paths (CPU
  P010 upload + dmabuf XR30 scale_vaapi p010/bt2020); can_encode_10bit
  now probes for real on Linux; 10-bit sessions route around the
  8-bit-only Vulkan-video/direct-NVENC backends.
* GameStream: host_hdr_capable() Linux arm, live monitor-HDR check at
  RTSP honor time, capturer-pool reuse keyed on HDR-ness, gs_bit_depth
  covers the new formats. New `punktfunk-host hdr-probe` diagnostic and
  a PUNKTFUNK_SPIKE_HDR spike lever.
* Native plane stays honestly 8-bit via capturer_supports_hdr(): Mutter
  RecordVirtual streams are SDR-only upstream (GNOME 50 and 51-dev), so
  virtual-display sources cannot deliver HDR yet.

Validated on the RTX 5070 Ti (GNOME 50.3 / PipeWire 1.6.8): the Main10
probes pass and the ignored nvenc_hdr10_smoke GPU test emits an IDR that
ffprobe reads as Main 10 / yuv420p10le / bt2020nc / smpte2084 / limited.
Live HDR capture negotiation still needs an HDR monitor on glass; VAAPI
10-bit needs the AMD box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:30:55 +02:00
enricobuehlerandClaude Opus 4.8 9a36ea2132 refactor(host/W6.2): extract the video encode backends into the pf-encode crate
encode.rs + encode/* (NVENC, VAAPI, native AMF, AMF/QSV ffmpeg, direct-SDK
NVENC/CUDA, raw Vulkan-Video, PyroWave, openh264) move into crates/pf-encode
behind one Encoder trait + open_video selector (plan §W6). The crate speaks the
shared frame vocabulary (pf-frame: CapturedFrame/PixelFormat + the DXGI identity
D3d11Frame/make_device) and pf-zerocopy (CUDA context/buffers), and NEVER
pf-capture — the capture→encode edge is one-way (ZeroCopyPolicy, prior commit).

Dep moves: the heavy encoder deps (ffmpeg-next, the NVENC SDK, openh264,
pyrowave-sys) move from the host to pf-encode; the host's
nvenc/amf-qsv/vulkan-encode/pyrowave features now FORWARD to pf-encode/*. The
host keeps a mod-encode shim (pub use pf_encode) so every crate::encode::* path
(negotiator + GameStream/native/mgmt planes) is unchanged.

resolve_render_adapter_luid moves from the host's windows/win_adapter.rs into
pf-gpu (both pf-encode and pf-capture need it as a peer of GPU selection); its 5
call sites (encode amf/nvenc, capture idd_push/synthetic_nv12, vdisplay manager)
rewire to pf_gpu::resolve_render_adapter_luid and win_adapter.rs is deleted.
pf-frame's make_device gains a # Safety section (public-unsafe-fn lint, latent
since the pf-frame carve — a full-workspace -D warnings clippy catches it).

Verified: Linux clippy -D warnings (pf-encode + host nvenc,vulkan-encode,pyrowave
--all-targets) + 13/13 pf-encode + 299/299 host tests; Windows clippy -D warnings
(pf-encode nvenc,amf-qsv --all-targets + host nvenc,amf-qsv --all-targets)
Finished exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:42:51 +02:00