Compare commits

...
Author SHA1 Message Date
enricobuehler 6ff62b087c feat(client): say which GPU can do Vulkan Video, and why not when it can't
Field report from an Intel Arc + NVIDIA laptop: pinning the Vulkan rung on the Arc
iGPU silently produced D3D11VA, and there was no way to tell whether the build had
tried at all. That ambiguity was ours, in three places.

The "unavailable" log printed three of the FIVE conjuncts that gate Vulkan Video.
A device with 1.3, the features and a decode queue family — but no codec extension
— logged dev_is_13=true features_ok=true decode_family=true next to the word
"unavailable" and named nothing actionable. It now prints all five, plus which
base extensions are missing, which codec extensions are present, the decode
family's own advertised codec operations, and the device name and vendor. It also
no longer says "VAAPI/software" on Windows, where the rung below is D3D11VA.

The native-vulkan PIN refusal logged `video_decode` alone. On a device that
decodes something but not THIS codec, that reads as a contradiction: refused, yet
video_decode=true. It now carries the caps mask and the codec bit that was wanted,
so "your GPU can't" is distinguishable from "we asked for the wrong thing" — only
the second is our bug.

And `--probe-decode` is new: per-adapter Vulkan Video capability with no session,
no surface and no logical device. For each GPU it answers usable yes/no, the
driver's own decode ops, the extensions, and — when the answer is no — which
conjunct failed, in words. Separate from --list-adapters, which the desktop shells
parse line-by-line for their GPU picker and which therefore keeps printing bare
names.

The listing is ordered like pick_device (discrete first) and marks entry 0 as the
default presenter, because that ordering is very likely the reporter's actual
answer: pick_device ranks DISCRETE_GPU above INTEGRATED_GPU, Vulkan Video decodes
on the PRESENTER's device by design (that is what makes it zero-copy), and
PUNKTFUNK_DECODER does not move the presenter. So on a hybrid laptop, pinning the
decoder while the dGPU presents probes the wrong GPU entirely —
PUNKTFUNK_VK_DEVICE=<index> is the knob that moves it, and the index printed is
that value.

To keep the probe honest, VIDEO_BASE and VIDEO_CODECS moved to module scope and
the five-way AND became video_decode_gate(), called by both the probe and device
creation. A probe holding its own copy of the rule is one that eventually reports
a capability the session then refuses — which reads to everyone as a decoder bug
rather than a probe bug.

Gates: fmt clean; clippy -D warnings over punktfunk-client-session and
pf-presenter. The Linux container was unavailable (the host's disk filled and took
the docker daemon with it), so this ran on the macOS host target only — the
container leg is owed, and CI covers it on the PR.
2026-08-07 12:35:55 +02:00
enricobuehler bbbcf321e5 Merge origin/main into worktree-native-decode-m0
ci / web (pull_request) Successful in 1m19s
apple / swift (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
android / android (pull_request) Successful in 3m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m23s
ci / rust-arm64 (pull_request) Successful in 5m36s
nix / flake (pull_request) Failing after 11m59s
ci / rust (pull_request) Successful in 14m27s
main moved 93 commits while this branch ran. Two conflicts, both where main's new
work sat next to M10's excision:

packaging/flatpak/io.unom.Punktfunk.yml — main added the vendored gamescope WSI
layer (the only route to HDR on a Deck) and, before it, a vulkan-headers module.
Took both: this branch predates them and deletes neither. But the headers module's
stated consumer was pf-ffvk's bindgen over FFmpeg's hwcontext_vulkan.h, and M10
deleted pf-ffvk — so it now reads as dead weight to the next person. It is not:
the WSI layer IS a Vulkan layer, compiles against those headers, and builds after
it, so module order is the dependency. Rewrote the rationale to say so, including
why dropping it would be expensive to discover — flatpak.yml has no pull_request:
trigger, so a manifest break reaches main invisibly and a tag then ships no Linux
flatpak. Also recorded that the native decoder needs nothing from there: pf-vkdecode
reaches Vulkan through ash, which is pure Rust bindings, no bindgen, no C headers.

crates/pf-console-ui/src/screens/settings.rs — main restructured the gamepad
settings into TABS, which removed the per-row section headers; this branch had left
Some("Video") untouched from the merge base and added the pre-M10 decoder migration
next to it. Git could not tell those apart. Took main's structure (no header, its
deliberate change) with this branch's migration layered on: a stored `vulkan`,
`vaapi` or `d3d11va` names no preset in the tabbed list and would render as "—",
then silently rewrite the user's preference on the next save.

Gates on the merged tree, Linux container: fmt clean; cargo check --workspace
--all-targets clean; clippy --workspace --all-targets -D warnings clean; tests
green across pf-vkdecode (187), pf-client-core (163), pf-console-ui (58) and
punktfunk-host (447 of 448 — the one failure is the pre-existing
gamestream::stream::tests::sender_delivers_batches, a UDP-loopback EINTR under
qemu that fails identically on a pristine HEAD).
2026-08-07 10:50:32 +02:00
enricobuehler 3608de25ed Merge pull request 'fix(ci): runner hygiene stops eating its own jobs' (#84) from worktree-ci-runner-hygiene into main
ci / bun-nix (push) Successful in 27s
ci / docs-site (push) Successful in 1m20s
apple / swift (push) Successful in 1m25s
ci / web (push) Successful in 1m28s
deb / build-publish-client-arm64 (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 3m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
decky / build-publish (push) Successful in 43s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish (push) Successful in 3m53s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
deb / build-publish-host (push) Successful in 4m3s
docker / builders-arm64cross (push) Successful in 10s
android / android (push) Successful in 6m31s
apple / screenshots (push) Successful in 5m45s
windows-host / package (push) Failing after 7m11s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 8m0s
ci / rust (push) Successful in 10m1s
docker / deploy-docs (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m33s
Reviewed-on: #84
2026-08-07 08:37:42 +00:00
enricobuehler 75dfab1d35 fix(host): a reconnecting session inherits its launch instead of starting it again
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m48s
ci / web (pull_request) Successful in 2m34s
android / android (pull_request) Successful in 5m51s
ci / docs-site (pull_request) Successful in 1m48s
ci / rust (pull_request) Canceled after 6m55s
ci / rust-arm64 (pull_request) Canceled after 6m35s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 3m6s
Two defects, found while tracing M8's codec-fallback reconnect and recorded
verbatim in d5e23146 as out of scope there.

A client retry re-sends Hello::launch verbatim, and the host launched
unconditionally. Steam and Epic URIs hide it — the launcher focuses the running
copy — but a gog:/custom: target really did start a SECOND COPY of the game. The
client cannot fix it by dropping the field: on Linux the per-session gamescope is
re-adopted through pf-vdisplay's display registry, whose reuse key includes the
launch command, so a retry without it orphans the running game.

And the retry minted a fresh launch_stamp, so procscan refused to adopt a game
started more than 2 s before it — the game was minutes old, so a reconnected
session had no game-exit detection for the rest of its life.

Both are now answered by a launch registry (launchreg.rs): one record per (client
fingerprint, library id), written at launch time and INDEPENDENT OF THE
TERMINATION POLICY. That independence is the point. The existing fingerprint-keyed
reclaim only exists under GameOnSessionEnd::Always — under the default Keep,
arm_grace is never called, so nothing was recorded at all in exactly the
configuration the defect was reported in.

The design correction that matters: at launch time the host knows NOTHING about
the game's processes — that is the premise of the whole lease design. So identity
flows BACKWARDS from the watcher, which publishes the concrete ProcRefs it
adopted, and the registry's liveness is Scanner::alive over that recorded set,
re-verified by (pid, start). Never a re-scan by spec: a later scan would find a
copy the player started since, and adopting that is what procscan's rule 1
forbids. The published set is never cleared on exit either — the last thing the
watcher saw is what makes a quit game read Gone rather than "no opinion", which
is how it becomes relaunchable at once instead of being suppressed for the window.

On rule 1: an adopting session inherits the older floor, so its own find() admits
what the ORIGINAL session's lease already admitted for its whole life. That is the
correct reading of "the same launch, continued" and not a new exposure — rule 1
forbids adopting processes that PREDATE the launch, and these postdate it.

The match rule is pure and total (covers()): liveness is authoritative where it
has an opinion, and only Unknown falls through to the tie-breakers — a live holder,
or a 90 s in-flight window for a re-dial while the launcher is still working. Gone
beats both, deliberately: a title that crashed on startup must relaunch at once.

Both race orders are handled and neither is relied on. Teardown-first takes the
Running arm; handshake-first (a fast re-dial on a half-open connection) takes the
holders>0 arm, and the old teardown then sees superseded() and does nothing —
without which, under Always, it would arm a grace the new session had already
passed its chance to reprieve, and the reaper would kill the new session's game.

Two tradeoffs taken deliberately: a custom: command with no detection hints stays
Unknown forever, so that reconnect trades game-exit detection for not
double-spawning; and IN_FLIGHT_WINDOW is a fixed 90 s rather than sharing
disconnect_grace_seconds, because the two have opposite failure costs — grace
being wrong leaves a game running, this being wrong silently swallows a launch the
player asked for.

Gates: fmt clean; clippy -p punktfunk-host --all-targets -D warnings green in the
Linux container; 418 passed, +9 exactly the new tests. One failure,
gamestream::stream::tests::sender_delivers_batches, is pre-existing and
environmental — a UDP-loopback EINTR under qemu at stream.rs:1697, outside every
hunk in this change (the last is at +448), and it fails identically on a pristine
HEAD. I reproduced both the failure and its location myself rather than taking it
on report.

⚠ OWED: the Windows leg is COMPILE-UNVERIFIED. cargo check --target
x86_64-pc-windows-msvc dies in ring's C build on macOS and xcheck.sh does not
cover punktfunk-host. The Windows edits are small restructures of existing
branches plus a bool assignment, reasoned through but seen by no compiler. Run it
on .133 before this merges.

I narrowed that exposure by inspection afterwards, and it is smaller than the
blanket warning suggests. The change presents exactly two things to a Windows
compiler that a Linux one did not already see. launchreg gates only alive_count
(lines 227/231), whose cfg(any(linux, windows)) arm calls
Scanner::system().alive(procs) — the identical call gamelease.rs:563 already makes
in code that compiles on Windows today. And the Windows launch arm at
native/stream.rs:1666 reads only ungated bindings the Linux arm type-checks thirty
lines below it (adopt_launch:1658, spawned_now:1663, launch_claim:1463) and calls
only the pre-existing library::launch_title. No new type, no new signature, no
Windows-only API.

That is an argument, not a compile. The run on .133 is still owed.
2026-08-07 10:14:21 +02:00
enricobuehler 138a1f1b2f fix(host/windows): the staging-dir SID checks document their unsafe blocks
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m34s
ci / bun-nix (pull_request) Successful in 22s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m4s
android / android (pull_request) Successful in 4m28s
ci / rust (pull_request) Successful in 9m12s
clippy's undocumented_unsafe_blocks (deny) flagged the three blocks that
81039581 introduced: the SAFETY comment sat outside the closure, so
IsValidSid/EqualSid inside it read as undocumented, and from_raw_parts
shared a comment that only covered the GetLengthSid line above it. Windows
host clippy is the only leg that lints this cfg(windows) code, red since.
2026-08-07 10:11:04 +02:00
enricobuehler 39cfb7234c fix(ci/docker): a cache-hit builders job stops failing on a login it never uses
The LAN-registry docker login only serves the Push step (Reconcile and
Tag-for-release authenticate via curl -u), but it ran unguarded — so a
hit=true leg landing on a host with a misconfigured docker daemon failed at
login with nothing to push (run 16044/16013 f44 leg). Gate it like Build/Push.
2026-08-07 10:11:02 +02:00
enricobuehler c3cdee9bf5 fix(ci/prune): the 2-minute image prune stops deleting images mid-pull
docker image prune -af --filter until=2h keyed on image CREATION time, so a
base image built days ago that merely had no container at that instant was
"aged" — including one a job had just pulled and not yet created. Measured
2026-08-07: three job failures, each coinciding with a prune tick to the
second ("No such image: …punktfunk-rust-ci:latest", every step cancelled),
plus a 4-7 GB re-pull of every idle base image within minutes.

The routine tick now retires only what this host actually accretes — per-SHA
app tags older than 2h (their creation time IS the local build time) — then
sweeps dangling layers, which cannot touch a tagged image. The blanket -a
prune survives only in the near-ENOSPC burst guard, where one re-pull beats
every concurrent job dying.

docker-reclaim.{sh,service,timer} are the hourly leak reclaimer that so far
lived hand-installed on home-runner-1 only; home-runner-2 went without it and
re-accumulated 176 leaked volumes (~60 GB) until jobs died of ENOSPC on
2026-08-06/07. Checked in so both hosts install the same files from here.
2026-08-07 10:10:59 +02:00
enricobuehler dee97e893c fix(vkdecode): the address the driver keeps is now the address we keep
The AV1 use-after-free fix (cdd1f3ef) stabilised the wrong half. NVIDIA was
measured retaining pColorConfig, so StoredParamsAv1 boxed the colour and timing
blocks — but OwnedStdAv1SequenceHeader kept the Std struct ITSELF inline, so the
pStdSequenceHeader we handed vkCreateVideoSessionParametersKHR was a stack
address inside ensure_parameters, dead the moment it returned. The fix worked
because of WHICH pointer that driver happened to hold. A driver retaining the
outer one instead — no more of a spec violation than retaining pColorConfig was —
reproduces the original bug exactly: plausible pictures, wrong content, no error
and no counter moved.

The same shape was in the shipping codecs, one step further from evidence: the
H.264 and H.265 create paths pointed pStdSPSs/pStdPPSs/pStdVPSs at function-local
Vecs, and both Add paths handed over the wrapper's inline std field and then moved
the wrapper. Those are spec-legal — the object stores copies — and have never
misbehaved on the fleet. They are fixed anyway, because that is precisely what was
true of H.264/H.265 before the same class of bug was found in them, and a
correctness argument that reduces to which vendor we tested is not one.

So: the Std struct is boxed inside each owning wrapper (one level out from what
_color_backing already did), and the contiguous create-time arrays are now fields
of the stored parameters, assembled at their final address. Identical bytes at
identical offsets — only where they live changed.

The line drawn deliberately, in prose at session.rs:29: Std DATA is pinned; the
VkVideoSessionParametersCreateInfoKHR chain itself is not. Retention there would
be a different and far more extreme class of driver bug, and pinning it needs a
self-referential struct over lifetime-parameterised builders.

⚠ NOT hardware-verified. No GPU has run this — the fleet is unreachable and the
250/250 parity that proved this code bit-exact cannot be re-run. That is why the
change is constrained to address stability alone, and why it ships five CPU-only
tests instead: three capture the pointer handed to Vulkan, perform the real move,
and assert it survives — each verified FAILING first, with genuinely differing
addresses, not a tautology. Two more pin the create-array ownership; those fail
before the fix as compile errors rather than assertions, because the pre-fix bug
there is a dangling pointer and asserting on it is UB.

Also: caps.rs claimed the borrow checker pins a profile chain between wire() and
its last use. False at exactly one site — decoder.rs took a raw *const, ending the
borrow, leaving nothing but inspection to stop a future editor moving the chain
before create_query_pool. Correct today, guarded by prose, which is how the first
bug shipped. It is now compiler-enforced: the pointer write and the create call
live inside one helper that takes the profile by reference, so the borrow is held
across both by the signature. An audit cleared the chains otherwise — no entry
point we pass one to retains it.

Gates: fmt clean; clippy -D warnings over pf-vkdecode AND pf-client-core in the
Linux container (its only real consumer, which cannot build on macOS at all —
wol.rs uses deps its manifest gates to linux/windows, so workspace clippy has
never passed there and does not now); 187 lib tests green on Linux, up from 182.
2026-08-07 09:57:39 +02:00
enricobuehler 93b8528d09 Merge pull request 'fix(encode): NVENC split-frame encode never engaged for HDR — engage it, measured' (#83) from worktree-nvenc-s1-split-reconfigure into main
ci / bun-nix (push) Successful in 24s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m16s
apple / swift (push) Successful in 1m35s
deb / build-publish-client-arm64 (push) Successful in 1m34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 8s
windows-host / canary-manifest (push) Skipped
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 6s
ci / rust-arm64 (push) Successful in 3m30s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 6s
docker / builders-arm64cross (push) Skipped
deb / build-publish (push) Successful in 3m55s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m3s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
deb / build-publish-host (push) Successful in 4m56s
android / android (push) Successful in 6m46s
apple / screenshots (push) Successful in 5m37s
windows-host / package (push) Failing after 5m23s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 7m56s
ci / rust (push) Successful in 9m35s
docker / deploy-docs (push) Successful in 6m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m45s
Reviewed-on: #83
2026-08-07 07:57:29 +00:00
enricobuehler b5205fef52 Merge pull request 'fix(client/apple): audio stops crackling on lossy, bunching Wi-Fi' (#82) from worktree-audio-wifi-distortion into main
ci / rust-arm64 (push) Failing after 13s
arch / build-publish (push) Failing after 22s
ci / bun-nix (push) Successful in 21s
apple / swift (push) Successful in 1m32s
ci / web (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m27s
deb / build-publish-host (push) Failing after 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 1m2s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 8s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
deb / build-publish (push) Successful in 4m33s
android / android (push) Successful in 5m53s
docker / deploy-docs (push) Successful in 37s
deb / build-publish-client-arm64 (push) Successful in 4m31s
ci / rust (push) Successful in 6m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 5m47s
flatpak / build-publish (push) Failing after 4m28s
windows-host / package (push) Failing after 6m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
release / apple (push) Successful in 11m44s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
apple / screenshots (push) Successful in 6m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m15s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m57s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 12m58s
Reviewed-on: #82
2026-08-07 07:37:19 +00:00
enricobuehler 515a3c2912 feat(pf-encode): wire split arbitration on Windows too
ci / rust (pull_request) Failing after 26s
ci / bun-nix (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m19s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m35s
ci / rust-arm64 (pull_request) Successful in 2m10s
android / android (pull_request) Successful in 4m22s
The last coverage gap, and only worth building once S1 proved it possible: the
Windows backend drives NV_ENC_DEVICE_TYPE_DIRECTX, and an in-place splitEncodeMode
change had never been tested there. It works (071358cb), so the arbiter is now
ungated from Linux-only to the union of both direct-SDK backends and wired into
windows/nvenc.rs: the submit stamp, the feed hook on AU completion,
apply_split_mode, split_key, arm_split_arbiter, and set_send_spread_us.

Same gates as Linux, and they are correctness conditions rather than preferences:
opt-in while it earns trust, an operator PUNKTFUNK_SPLIT_ENCODE pin always wins,
a cached verdict short-circuits, >=2 engines, never H.264, and the sub-frame
trade is only entered when the host has actually reported a send spread to price
it with. The one Windows-specific difference is that `async_rt` is a real
possibility here (opt-in two-thread retrieve) and the arbiter refuses it, because
under pipelined retrieve the submit->AU span includes queue depth and the
comparison would be noise.

⚠ Two more instances of the same item-level dead_code trap, caught by the Windows
run and not by reasoning -- that is now 4 and 5:
- `clear_split_verdicts` is called only by the Linux on-hw test, so it is dead on
  Windows; gated to `all(test, target_os = "linux")`.
- The arbiter methods first landed inside `impl Encoder` rather than the inherent
  impl (the anchor I used, supports_chunked_poll, is a trait method), which the
  compiler caught as "not a member of trait Encoder".

Verified .158 (RTX 4090 / Ada, driver 610.88, D3D11): clippy --features nvenc
--all-targets -D warnings clean, and 2 on-hardware NVENC tests green including S1
re-run with the arbitration code in place (engines=2 latched, DISABLE->TWO_FORCED
accepted, zero IDRs, reverse accepted). Verified .21: clippy clean with AND
without the nvenc feature, 65 unit tests, 25/25 NVENC on-hardware. fmt clean.
2026-08-07 09:28:34 +02:00
enricobuehler 8c994965d4 docs(licensing): say what the licence gate cannot see
The plan's M10 checklist named "the about.toml carve-out that puts FFmpeg
outside the automated licence gate". There is no such stanza — I looked, on this
branch and on origin/main. The carve-out is structural, which is worse: cargo-about
walks the CARGO graph, so a native library reached through a permissively-licensed
-sys crate is invisible to it. ffmpeg-sys-next is WTFPL and passes the gate
cleanly while the LGPL libavcodec it link-imports is never harvested at all.

So about.toml's own claim to be "exactly the regression guard we want against a
copyleft dependency silently entering the linked set" was overstated: it did not
catch FFmpeg entering and would not catch the next one. The comment now says so,
and says where the LGPL obligations are actually discharged instead.

The one genuinely good piece of news is recorded too: since M10 the client links
no FFmpeg, so for every client artifact the crate graph and the linked set
coincide and the gate finally means what it appears to mean. The gap is the
host's alone.

Gate: cargo about generate about.hbs --fail — passes.
2026-08-07 09:27:30 +02:00
enricobuehler b27135308f fix(scripts): the xcframework never absorbs a Homebrew libopus
ci / docs-site (pull_request) Failing after 2s
ci / web (pull_request) Failing after 5s
android / android (pull_request) Failing after 14s
ci / bun-nix (pull_request) Successful in 22s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m52s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 5m45s
ci / rust (pull_request) Failing after 7m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m22s
On a Mac with brew's opus installed, audiopus_sys found it via pkg-config and
statically linked it into the aarch64 slice — a lib built for the RUNNING
macOS (minos 26.0, tripping the script's own version guard) and existing only
for the host arch, so the x86_64 slice silently fell back to the vendored
build and the two slices shipped different libopus builds. Force the vendored
CMake build for every slice (OPUS_NO_PKG_CONFIG=1), with the CMake policy
floor modern CMake (>=4) needs to accept libopus's old cmake_minimum_required.
2026-08-07 09:23:37 +02:00
enricobuehler 64c92da356 fix(client/apple): the jitter ring deepens on the sessions that actually starve
The shared JitterPolicy grew an adaptive target floor — clustered genuine
underruns raise the live target a step at a time up to max_target_ms, a long
quiet spell relaxes it back — and the three Rust rings all run it via
note_read. The Apple ring is the one hand-written mirror, and it mirrored the
shed half but not the growth half: its target was pinned at the 20 ms base
forever. On Wi-Fi that bunches arrivals (power-save is the classic; the field
MacBook report is the symptom), 20 ms is regularly shorter than one delivery
stall, so the ring re-primed through every stall for the whole session —
crackle that never got better, on exactly the client where a Moonlight with a
deeper buffer sounds fine on the same host and network.

The ring now carries the full mirror of note_read: 3 underruns inside a 5 s
window grow the target 10 ms (capped at COREAUDIO's 70), 30 s of quiet gives a
step back, and the write-side hard trim follows the grown target (including
the Rust policy's target+quantum guard, which the mirror also lacked). New
tests pin the mirror to the Rust suite's expectations — growth, relax, the
cap — plus the field scenario end to end: bunched 60 ms deliveries with every
fourth burst 30 ms late converge to a silence-free tail instead of crackling
forever.
2026-08-07 09:23:29 +02:00
enricobuehler 81d257c7fa fix(client/audio): the in-core decoder conceals lost packets like every other client
A field report: game audio on a MacBook (M1) crackles over Wi-Fi against a host
that plays clean to other clients. The Apple client is the one client whose
Opus decode lives in core (punktfunk_connection_next_audio_pcm — AudioToolbox
has no multistream path), and that decoder only ever decoded packets that
ARRIVED. The Linux, Windows and Android decode loops all feed an
AudioGapTracker and synthesize libopus packet-loss concealment for every
packet the wire lost; the in-core path had the tracker sitting unused in the
same crate. So on Apple every lost 5 ms datagram — at ~200 packets/s over
Wi-Fi, a steady trickle — landed in the playout ring as a hard time-domain
gap: a click per loss, sustained crackle under real loss. The redundant-plane
recovery (0xD2) hides single losses when the host grants it, which is exactly
why the survivors are the burstier gaps that need concealing most.

The decode now runs through the same accounting as everyone else: concealed
frames land in front of the arriving frame in one contiguous buffer (the
embedder just writes it to its ring), a DTX marker advances the accounting
without being decoded, and the output buffer is pre-sized for a full
concealment run so the borrow-until-next-call pointer can never dangle.
Unit-tested against real libopus: gaps, duplicates, DTX-after-loss, and the
50 ms cap.
2026-08-07 09:23:15 +02:00
enricobuehler 071358cbf7 test(pf-encode): S1 on WINDOWS/D3D11 — passes; Windows arbitration is buildable
Everything the split-encode programme rests on had been proven only on
Linux/CUDA. The Windows backend drives NV_ENC_DEVICE_TYPE_DIRECTX, so none of it
transferred by assumption -- and if the driver refused an in-place split change
there, Windows arbitration would simply not be buildable.

RESULT on the RTX Windows box (RTX 4090 / AD102, driver 610.88, D3D11):
  engines=2, latched by query_caps  (WP1.1's probe, validated on Windows
                                     hardware rather than inferred from Linux)
  DISABLE -> TWO_FORCED via nvEncReconfigureEncoder, resetEncoder=0: ACCEPTED,
  ZERO IDRs, and the reverse likewise.

So the foundation now holds across three platform x arch x driver combinations:
Linux/CUDA Blackwell 610.57.04, Linux/CUDA Ada 610.43.03, Windows/D3D11 Ada
610.88.

 UNBLOCKS ALL FUTURE WINDOWS ON-HARDWARE TESTING. pf-encode's nvenc test
binaries were believed unlinkable on Windows ("NvEncodeAPICreateInstance
unresolved", recorded as pre-existing and worked around by only ever running
clippy there). They link fine given the SDK import library:

  RUSTFLAGS='-L native=C:\Users\Public\nvenc -l nvencodeapi'

`-L` alone is not enough -- without a `-l` nothing pulls the archive in, which is
why the earlier attempt still failed. ⚠ This is TEST-BINARY-LOCAL and must stay
that way: production deliberately dlopens NVENC rather than link-loading it, and
an unconditional link-load is the known crash class on non-NVIDIA Windows hosts.

⚠ Box note: the RTX Windows box answers on .158, not the .173 in its memory
entry, and `Administrator@` there resets the connection right after
SSH2_MSG_SERVICE_ACCEPT in a way that reads like the host being down -- the
working login is "Enrico Bühler"@192.168.1.158.
2026-08-07 09:23:05 +02:00
enricobuehler 5a1ec6198e docs(ci): the Windows host's FFmpeg tree is no longer shared with the client
windows-host.yml called FFMPEG_DIR "the same BtbN lgpl-shared x64 tree the
Windows CLIENT links against". Since M10 the client links no libav* at all and
windows.yml sets no FFMPEG_DIR, so the sentence pointed a reader at a link that
does not exist. The provisioning script still fetches the tree — for the host
alone — which is the part worth saying out loud, because the next person to read
it will wonder why a client-provisioning step still mentions FFmpeg.
2026-08-07 09:16:32 +02:00
enricobuehler 0430d907bb fix(pf-encode): gate forced_split_width to Linux — WP4 broke the Windows build
The verification gap flagged in 01294e3a was real. `.133` came back up and the
WP4 commit failed Windows clippy: `forced_split_width` is used only by the libav
NVENC path (`enc/linux/mod.rs`), but it was added to `codec.rs`, which compiles
everywhere -- so it is dead code on Windows and `-D warnings` rejects it.

Third time this crate has hit the same item-level dead_code trap (see
`subframe_env_forced`, and the arbiter items in `nvenc_core`), and the third time
it was caught by actually running the Windows check rather than by reasoning
about it. The comment on the gate says so, since the pattern is clearly not
self-evident from the code.

Verified .21: clippy -D warnings clean both WITH and WITHOUT the nvenc feature,
65 unit tests. Verified .133: Windows clippy --features nvenc --all-targets
-D warnings clean, zero errors, zero dead_code. fmt clean.
2026-08-07 09:01:05 +02:00
enricobuehler 5c05246098 feat: M10 — FFmpeg is gone from the client
cargo tree -p punktfunk-client-session finds no ffmpeg. The host still does,
which is the whole point: pf-encode keeps libavcodec unconditionally and no
host workflow, packaging script or licence file was touched.

Deleted: crates/pf-ffvk, video_vulkan.rs, video_vaapi.rs, video_libav.rs, the
libavcodec half of video_d3d11.rs, the av_log machinery, ffmpeg::codec::Id as
the decoder's vocabulary (the quic CODEC_* wire constants now serve, which is
why the evidence table was keyed on them), DecodedImage::VkFrame and ::Dmabuf,
the presenter's AVVkFrame lane, and the ffmpeg-fallback feature with
everything behind it. DrmFrameGuard collapses from an enum to a newtype, which
removes an unsafe impl Send. Roughly 25,000 lines.

Then the CI, packaging, licensing and docs work the plan's §6 lists: the
Windows workflows lose FFMPEG_DIR, PF_FFVK_VULKAN_INCLUDE and their PATH
prepend; the MSIX loses its DLL wildcard; the client .deb stops emitting libav
sonames on its own because depends come from dpkg-shlibdeps; arch, flatpak and
nix drop the dependency; and the README's "FFmpeg 7 or 8" contract narrows to
the host.

Three defects reached users' machines in the first cut, and none was in the
deletion itself.

All three desktop Settings UIs offer vulkan, vaapi and d3d11va as stored
decoder values, so those strings sit in shipped settings files today. Refusing
them by name — which is the correct rule for a stale pin — would have bricked
every upgraded client whose owner ever touched that dropdown. They now migrate
onto the native rung for the same hardware family, at decoder construction AND
at each dialog's lookup, because a legacy value that matches no preset
displays as "Automatic" and silently rewrites the user's preference on the
next save.

M9's evidence filter was deleted on the argument that with no libavcodec twin
below, barring an unproven rung removes hardware decode rather than moving
down one rung. That is true on Windows and false on Linux for Intel and every
unknown vendor id, where prefer_vulkan_first is false and the order is
native-vaapi → native-vk: a rung that has decoded nothing anywhere sitting
above one that is 250/250 on three drivers. Every Intel Linux desktop would
have moved from libavcodec VAAPI, shipping for years, onto pf-vaadec by
default — and a rung that constructs and then produces wrong pixels leaves
only by the error-streak demotion, which this codebase already documents as
not tripping on the B580's strobing. The filter is restored as a narrow, pure,
testable rule: an unproven rung yields to a proven one, and to nothing else.
Windows deliberately passes no rung below, because that vendor family is the
one with a measured wrong-pixel report against Vulkan decode, and trading no
evidence for evidence of corruption is the wrong direction.

And the notices still said FFmpeg was bundled. The root file is what both
desktop clients include_str! and what the MSIX ships, three lines under the
new card saying no FFmpeg is bundled; Apple's Acknowledgements said it too, on
iOS, tvOS and macOS. The generator now emits four per-client files scoped by
transitive closure — 0 FFmpeg mentions in each, verified — while the root file
keeps it for the host. That also ends the standing false attribution of
ffmpeg-next, GTK4, windows-rs and the NVENC SDK to an iPhone.

Windows has no reachable box, so it was compiled instead: a cross clippy at
-D warnings on x86_64 and aarch64-pc-windows-msvc with the C toolchain stubbed
so build scripts run without linking. That gate immediately caught an
include_str! path one directory too deep, which nothing else could have.

Gates: container clippy -D warnings, 160 tests, workspace check, both Windows
targets clean, client ffmpeg count 0 and host 2. The four decode crates are
untouched, so the hardware rungs' 250/250 stands.

⚠ Owed and unrun: no GPU has executed any of this milestone. M8's on-glass
software check, M7's D3D11 and VAAPI AV1 hardware legs, and M9's field bake
all still want hardware, and the bake window and criteria remain the user's.
2026-08-07 08:58:47 +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 38554c1c6e feat(client): M9's code half — native first, FFmpeg behind an off-by-default feature
`ffmpeg-fallback` on pf-client-core, default off on the crate. With it off the
libavcodec rungs are not compiled, pf-ffvk leaves the dependency graph, and no
ladder or demotion arm names them; with it on each sits exactly where it sits
today, directly below its native twin. That is the switch which makes M10 a
deletion rather than a redesign.

The bake window and the regression criteria are the user's, per the plan, and
nothing here claims the M9 gate is met.

The hard part was not the feature, it was honesty. Two of the four native
rungs have never decoded a frame on any hardware — native VAAPI at all, and
native D3D11VA's AV1 leg — and making those the default would assert evidence
that does not exist. So admission is per rung and per codec: a pair with
hardware evidence joins `auto` always; a pair without it joins only when
nothing proven is left below it (a build with no FFmpeg twin, where the
alternative is not a proven rung but the CPU) or when the user asks with
PUNKTFUNK_NATIVE_FIRST=1. Pins bypass it, so a lab run can still reach any
rung.

The shipping default therefore changes in exactly three ways, all
evidence-backed: AV1 `auto` takes native Vulkan (250/250 bit-identical on an
RTX 5070 Ti), Windows H.264/H.265 `auto` takes native D3D11VA above its FFmpeg
twin (parity on two GPUs plus a 30-minute soak), and a failing Vulkan rung on
Windows demotes to native D3D11VA first. Everything unproven is byte-for-byte
as it was.

The evidence state is written where it cannot rot: a table in video.rs's
module docs, the same facts in code as `native_evidence()`, a test asserting
them in both feature states, and a per-session log line carrying the rung, the
codec, whether hardware has verified that pair and the evidence string — at
WARN when it has not. A support engineer reading a log can now tell proven
from assumed without asking anyone.

Termination needed a new guarantee. With the FFmpeg twins gone, two native
rungs in opposite per-vendor orders could hand a session back and forth
forever, so a rung once entered is never re-entered and the walk is monotone
to software. The never-delivered fall-through still works: with the feature on
it is unchanged, and with it off it is redundant, because the next candidate
already IS the rung below.

⚠ ffmpeg-next remains a hard dependency of pf-client-core, deliberately. What
is left off-feature is three type-level residues — the codec-id vocabulary,
the AVVkFrame guard that is pf-presenter's public import, and a pixel-format
in one signature — every one of them an M10 §6 line item. Deleting them here
would mean deleting the presenter's FFmpeg lane, 55 call sites, in a milestone
whose gates cannot run a GPU. No libavcodec decoder is opened in a default
build.

⚠ video_d3d11.rs was gated item by item rather than wholesale, and nothing in
this tree compiles it — it needs a Windows check before anyone trusts it.

Gates: both feature states, container clippy -D warnings and 158/159 tests,
workspace check. The four decode crates are untouched, so the hardware rungs'
250/250 stands.
2026-08-07 07:00:29 +02:00
enricobuehler d5e23146c0 feat(client): M8 — the software rung is openh264 and rav1d, and swscale is gone
The ladder's last rung no longer runs FFmpeg. H.264 decodes through openh264,
AV1 through rav1d, and HEVC is refused outright: no permissively licensed
software HEVC decoder exists, so an HEVC session that exhausts its hardware
rungs now tears down and re-dials advertising HEVC-less caps, and the host
picks H.264. The plan calls that a first-class path; it is one.

swscale is deleted, and with it the BT.601 default that its correction code
existed to undo. Colour on the H.264 lane now comes from the same
pf-bitstream planner every hardware rung submits from — openh264 reports no
VUI at all — and AV1's comes per-picture from the sequence header. One colour
source, one CSC: the old default is unrepresentable rather than merely fixed.
Frames reach the presenter as three tightly-packed planes through the planar
CSC pass, which had to be un-gated from the pyrowave feature and its device
probe, since the last rung must exist on devices that failed that probe.

rav1d rather than the dav1d crate, deliberately and against the plan's
literal wording: dav1d-sys is system-deps-only, so it would add a system
library and a .pc file to every client package — in the milestone family
whose excision checklist exists to delete exactly that. rav1d is the same
decoder, same licence, statically linked. The cost is honest: no-asm builds
on both decoders, and software throughput is still unmeasured.

The colour test is the milestone's exit criterion, so it is built to fail.
Three fixtures, and a mutation check: hardcoding the swscale default turns the
red bar to [255,24,0], and swapping Cb/Cr turns red to blue — a silent error
no metadata assertion could catch. Review then disproved the range half of it
numerically: with eight saturated bars, decoding the full-range fixture with
the wrong range gives max error ZERO, because a mismatch only pushes values
outside [0,1] where the shader clamps. A mid-tone was added; the wrong range
now costs 11, well past the tolerance. The exit criterion I set was
satisfiable by a test that proved nothing.

Two blocking defects, both emergent rather than local.

Software AV1 on a 10-bit stream never reached its typed refusal: rav1d is
built 8-bit-only and returns ENOPROTOOPT, which the send loop turned into a
generic error, so the pump's typed downcast missed and every AU failed
identically — a permanent freeze on precisely the shipping case, since AV1 is
advertised only where hardware AV1 exists and hardware AV1 plus HDR is Main
10. The shape is now read from the sequence header before any byte reaches
the decoder, exactly as the H.264 leg reads the active SPS.

And the new Reconnecting phase was the first state that is not streaming, not
connecting, and still holding a live stream — which opened all three guards
that had made a second launch impossible. Pressing A assigned over `stream`
where every other site shuts down first, and StreamState has no Drop, so the
old pump was detached: a second live session still submitting to a Vulkan
device that gets destroyed underneath it. Nothing about the reconnect was
wrong in isolation; the defect lived between a new state and three guards
nobody re-examined. Start is now defensive and the retry raises the
connecting modal, so the UI matches the state and B can cancel.

Also closed: retry_caps was computed, tested and never applied, so a shape
refusal could end a session reporting no codec available while a working
retry existed; the retry inherited force_software sticky-true, landing an
HEVC→H.264 fallback on software H.264 with working hardware H.264; it
re-dialled with a stale mode; the CPU present arm had no survivable-failure
handling where the pyrowave arm — same pass — has it; HEVC is no longer
advertised when the decoder is pinned to software; and the software rung now
feeds the recovery-point SEI it already had in hand to the re-anchor gate.

⚠ Two host-side gaps found while tracing, neither in scope here: Hello::launch
is NOT idempotent (gog:/custom: targets spawn a second copy on a retry; the
field is kept verbatim because dropping it orphans the gamescope display whose
reuse key includes the command), and a reconnected session can never adopt a
game predating its own launch stamp, so it has no game-exit detection.

⚠ OWED: the on-glass software run. ~200 lines of new Vulkan on a path that
only runs because the GPU already failed, and no driver has seen it. The
review's minimum check is sync validation enabled, a non-multiple-of-16 mode,
a mid-session resize and demotion, and both colour matrices.

Gates: container clippy -D warnings over four crates, 236 tests, workspace
check. pf-vkdecode and pf-bitstream are byte-for-byte untouched, so the
hardware rungs' 250/250 stands.
2026-08-07 06:18:23 +02:00
enricobuehler a20cd44ed4 feat(client): native VAAPI AV1 — the third rung, and two failure-path defects
The libva AV1 layouts, the AuPlan conversion and the Linux rung's AV1 arm,
completing AV1 across all three hardware backends. Pin-only.

Layouts measured, not transcribed: the committed probe grew the AV1
structures and every size and offset it printed against libva 2.23.0 is a
compile-time assertion. Three that a hand-count gets wrong — the picture
buffer is align 8 because anchor_frames_list is a pointer, inserting seven
bytes of padding; seg_info and film_grain_info carry their own padding tails
inside the parent; and THREE of AV1's six bit-field unions are narrower than
a word (one uint8_t, two uint16_t), so a u32 packer over any of them writes
through its neighbour.

This is the fifth way this program has had to spell "which pictures does this
frame use", and it is unlike the other four: ref_frame_map is indexed by SLOT
and holds actual VASurfaceIDs rather than indices into anything, ref_frame_idx
is indexed by NAME and holds slots taken from the header — not from the
plan's refs, where a lost reference leaves a hole and a hole is not a slot —
global motion is picture-level, and there is no per-reference size field at
all. Established from va_dec_av1.h and libavcodec's vaapi_av1.c, and stated
in the module docs so the next reader does not re-derive it.

Review verified the whole happy path — every layout assertion re-measured,
every packer width and bit position, the reference convention, the
num_elements buffer shape — and found both defects on FAILURE paths, neither
reachable on the vendored vector.

A conversion refusal permanently desynced the ledger. The mutation block sat
after the tile walk, so any tile-shape refusal left the planner holding a
picture with no ledger slot — and the resulting UnresolvedReference fires
before that block too, so it never repaired. Every later access unit
hard-errored until a shown key frame: one lost packet costing a GOP. The
arm's own doc already warned that skipping conversion would desynchronise the
slot map; the refusal door did exactly what the skip door was written to
avoid. The block is hoisted, and a tile-shape refusal on an already-damaged
plan is now concealed rather than refused.

Fixing that exposed a sharper edge: the conversion can release a slot and
reassign it to the refused picture in one call, so the binding would still
hold the PREVIOUS picture's surface — a wrong reference rather than a missing
one, which nothing downstream could notice. The caller now clears the binding
unconditionally on the refusal path.

And a damaged frame's surface was never written yet was bound as a reference
and left in pending, so a later clean show_existing_frame would claim it with
damaged = false and ship uninitialised GPU memory to the presenter — on
several drivers another client's framebuffer. The justification quoted half
of va_dec_av1.h; its next sentence gives the remedy, which is to point the
problematic index at an alternative buffer. Damaged frames now submit as they
do on the other two arms, with live surfaces substituted for invalid entries
and reported as a bitmask — preferring a reference that really decoded over
the decode target, and keeping libavcodec's deliberate all-invalid map on a
shown key frame.

Film grain is refused rather than decoded wrong: libva wants two surfaces,
one ungrained for prediction and one grained for output, and libavcodec
allocates a second frame for exactly that. The gate now sits after the
mutation block so a grained frame costs itself rather than the GOP, and stays
per-AU rather than per-sequence because a stream that merely DECLARES the tool
decodes here perfectly.

⚠ Residual, flagged not fixed: a picture decoded from substituted references
can still be shown by a later show_existing_frame. It is decoded memory now
rather than uninitialised, and it is what the H.264/H.265 arms do, but
tracking "this was concealed" through to display needs new session state.

Gates: macOS fmt/clippy/125 tests/cargo-doc, container clippy -D warnings over
seven crates and 548 tests, workspace check. pf-bitstream's diff is
comment-only — verified — so the Vulkan rung's 250/250 stands untouched.

Nothing here has decoded a frame: no VAAPI hardware is reachable.
2026-08-07 04:22:23 +02:00
enricobuehler ef40890c80 feat(client): native D3D11VA AV1 — wired, and four defects it exposed
The AV1 arm of the native D3D11VA rung, parity-required because today's
FFmpeg d3d11va rung already decodes AV1 Profile 0 and the excision must not
silently drop it. Pin-only, as that rung is today.

decode() walks the temporal unit frame by frame; submit() splits into
decode_into and present, because AV1 decodes frames that are never shown. The
proven H.264/H.265 body is byte-for-byte unchanged — review diffed it against
HEAD mechanically and found only a rename plus one refusal arm — and the
VideoProcessorBlt hand-off is untouched. That mattered more than anything
else here: those two codecs are hardware-proven, .173 is powered off, and no
gate that runs could have caught a regression in them.

Every descriptor value comes from libavcodec's dxva2_av1.c read verbatim, not
from symmetry with the other codecs: three buffers and no qmatrix (AV1
transmits none), NumMBsInBuffer zero on all three, ConfigBitstreamRaw 1,
surface alignment 128, pool +8, and the session sized from the SEQUENCE
header's max frame size — sizing from the frame would rebuild the decoder and
drop every reference the first time a stream legally resized downward.

Two places where following the H.264/HEVC pattern would have been wrong.
libav pads the bitstream buffer and grows only its descriptor's DataSize,
never a tile's, because a tile's size is exact — charging padding to the last
record is corruption, not filler. And the committed tile records were one per
tile GROUP spanning the whole OBU, header and frame header included, where
libav emits one per TILE addressing the payload past its tile_size_minus_1;
the vendored vector is single-tile, so the old tests passed either way.

Review then found four more defects in the already-committed conversion, each
confirmed against libavcodec AND Chromium's D3D11 AV1 accelerator:

Tile widths and heights were the coded minus-1 where the field is a
superblock COUNT — every tile declared one superblock short, on every frame,
with a comment asserting the opposite of the truth.

StatusReportFeedbackNumber must be zero for AV1. Both reference
implementations disable it specifically for this codec — libav's note reads
"breaks decoding on some drivers (tested on NVIDIA 457.09)", Chromium's "it
crashes :|" — while both set it for H.264 and HEVC, which is why this rung's
proven codecs never showed it. It would likely have presented as a hang or a
rejected submission rather than bad pixels, sending the next session after
the tile records instead.

frame_refs[].Index is an index INTO RefFrameMapTextureIndex, not a surface
index; the neighbouring line already filled that map correctly. Measured:
1636 reference entries on the vendored vector where the two differ.

qm_y/u/v need the 0xFF "no matrix" sentinel — 0 is a valid matrix index, and
274 of 274 frames transmit no quantiser matrix, so every one was being
dequantized against matrix 0.

Also closed: the slot leak the Vulkan rung had already found and documented
(a frame refreshing no slot is never reported removed, so nine of them
exhaust the ledger); a tile-grid check that could not fire, replaced with
libav's own cols*rows guard; per-reference sizes now taken from the
reference's own header via RefState rather than the current frame's; and the
render size clamped against the decoded picture in both rungs, since AV1
permits a render size larger than the frame.

The parity leg was rewired through the real decode path — it previously
called the internals directly, so its hidden-frame assertion described the
harness's own counter rather than production withholding anything.

Gates: macOS fmt/clippy/383 tests, container clippy -D warnings over four
crates and 499 tests, and on Windows .133 (.173 is powered off) clean checks
plus 97 pf-dxvadec tests. All 8 Vulkan gpu_parity legs re-verified bit-exact
on the RTX 5070 Ti after the shared-code change.

No AV1 frame has been decoded through this rung anywhere: it needs .173 back.
2026-08-07 03:01:49 +02:00
enricobuehler 185332a866 fix(vkdecode): H.264 and H.265 session parameters own what the driver keeps
The same use-after-free the AV1 rung was just fixed for, closed in the two
rungs that ship. session.rs and session_h265.rs handed their Std parameter
sets to vkCreateVideoSessionParametersKHR and dropped the backings when the
call returned; NVIDIA 610.57.04 was measured retaining such a pointer to
decode-record time, which is what made AV1 diverge on 250 of 250 frames.

Nothing was known to be broken here — both rungs are bit-exact on four
drivers — but that was luck rather than correctness: the freed blocks happen
to still hold the right bytes in that window. The native Vulkan rung sits in
the auto ladder above FFmpeg-Vulkan on shipping clients, so this was live
code, and its failure mode is silent wrong pixels rather than a crash.

StoredParams and StoredParamsH265 hold the parameters object together with
every wrapper it points at, so an object whose backing is gone cannot be
built. create_parameters_object takes the wrappers by value; the Add arm
adopts them only after a successful update, so a failed update drops what it
never stored; the Recreate arm replaces, destroys the old object, then drops
its backings, written explicitly so the ordering survives later edits. The
Add-vs-Recreate decision table and the VPS ledger are untouched — only
ownership moved.

params.rs still carried the refuted claim as a type-level contract, that
Vulkan "copies all parameter data before returning" and keeping the wrapper
alive across the call "is the whole obligation". Corrected to the measured
truth.

The tests are what stop this returning, and each was verified by sabotage:
inlining the H.264 PPS box fails at pps pScalingLists, inlining the H.265 SPS
DPB box fails at sps pDecPicBufMgr, and making either adopt drop instead of
store fails both session tests. Two lessons are recorded in them. Pointer
equality cannot be the assertion, because the Std struct carries pointers by
value and a stale one compares equal — the read-back is the discriminator, so
the tests clobber the dead stack first to make a dangling read deterministic
rather than lucky. And the first H.265 draft read six of eight pointers and
let the sabotage through, so it now reads every one with a labelled assert.

⚠ One site of this class remains, deliberately: the VkVideoProfileInfoKHR
chains, where wire()'s borrow dies with its enclosing block while the object
created from it lives on — three session creates, an image, a buffer, and a
query pool built from a raw pointer into a stack chain. It spans six modules
and all three codecs, and a profile is enums a driver resolves at create time
with no per-frame deref, so the risk is materially lower. It wants its own
pass with its own hardware verification.

Gates: macOS fmt/clippy/196 tests, container clippy -D warnings, pf-vkdecode
182/182 and pf-client-core 140/140. On the RTX 5070 Ti, all 8 gpu_parity legs
re-verified green after the change — H.264, H.265, Main 10 and AV1 all still
bit-identical to libavcodec.
2026-08-07 01:04:17 +02:00
enricobuehler cdd1f3efce fix(vkdecode): AV1 is bit-exact — the bug was a use-after-free, not the driver
250/250 frames bit-identical to libavcodec on NVIDIA 610.57.04, and all four
other parity legs (H.264, H.265, Main 10, both four-byte-prefix twins) still
green.

session_av1 built the sequence header, handed pStdSequenceHeader to
vkCreateVideoSessionParametersKHR, and dropped the backing the instant the
call returned — on the documented assumption that Vulkan copies parameter
data before returning. NVIDIA does not. It keeps the pointer and dereferences
pColorConfig when a decode is RECORDED. The freed block became our own next
allocation, whose bytes read back as mono_chrome = 1, and a monochrome frame
skips exactly loop_filter_level[2..3] (AV1 7.14).

That is the whole fingerprint two earlier rounds chased: luma bit-exact,
chroma off by small amounts, and rewriting the chroma levels in the bitstream
changing nothing — the driver read them correctly and then discarded them,
because it believed the stream had no chroma. StoredParamsAv1 now holds the
parameters object and its Std backing in one value, so an object whose
backing is gone is unrepresentable.

The road there is worth recording, because two well-evidenced conclusions
were wrong before this one was right. A software oracle reproduced the
divergence exactly by disabling chroma deblocking, and a GPU probe showed
chroma levels [8,12] and [63,63] producing byte-identical output — which
looked conclusive and was not. libavcodec's own Vulkan AV1 hwaccel is
bit-exact on this same driver, which proved the hardware fine and the defect
ours. ffmpeg never hits it: with VK_KHR_video_maintenance2 it uses inline
session parameters and never creates a parameters object at all.

The proof is direct rather than inferred: a throwaway Vulkan capture layer
dumped both submissions and every byte of our AV1 picture info already
matched libavcodec's, including the loop filter block; only the session
parameters layer differed. Watching the block's address showed correct bytes
at create and our next allocation at decode.

Ruled out on hardware, so nobody re-tests them: filmGrainSupport,
maxCodedExtent, maxDpbSlots/maxActiveReferences, VkVideoDecodeUsageInfoKHR,
the tile-start sentinel, the setup slot's SavedOrderHints, a NULL
pTimingInfo, and heap luck.

Two earlier fixes are confirmed against libavcodec's captured wire bytes and
kept: CDEF secondary strengths carry the coded value rather than the spec's
in-place fixup, and LoopRestorationSize is log2-based. The refuted
driver-ignores-chroma-levels claim is corrected everywhere it was written
down, and that probe test now passes and points at the lifetime of everything
a submission points at before blaming a vendor.

⚠ Adjacent and NOT fixed: session.rs and session_h265.rs drop their Std
backings the same way, and those sets carry embedded pointers too. Both are
measured bit-exact on four drivers, so nothing is known to be wrong — but the
contract now rests on a driver behaviour measured FALSE for AV1 on a shipping
driver. The SAFETY comments asserting it have been corrected; the structure
is deliberately untouched pending its own pass.

Gates: macOS fmt/clippy/336 tests, container clippy -D warnings, all green;
8/8 gpu_parity and 3/3 gpu_smoke legs verified on the RTX 5070 Ti.
2026-08-07 00:39:34 +02:00
enricobuehler 1062aa780f test(pf-encode): measure the bits/frame curve — no crossover, split always wins
WP0's real deliverable, and the hole every previous measurement in this
programme had. All prior timings ran against driver-zeroed buffers, so rate
control had nothing to code (~300 B/AU against an 833 KB quota) and only the
PIXEL-proportional half of the encode cost was ever exercised -- while the 4K60
HDR field report was a BITS/FRAME problem at 6.8 Mbit/frame.

Adds `pf_zerocopy::cuda::write_plane_from_host`, the exact mirror of the existing
read_plane_to_host. No new loader entry was needed: cuMemcpy2DAsync_v2 was
already in the table and CUDA_MEMCPY2D just needed the reverse memory types.
Linux-only by construction (pf-zerocopy's `imp` is cfg'd to linux).

⚠ Two harness mistakes found and fixed by looking at bytes/AU rather than
trusting the knob:
- Pure per-pixel noise is INCOMPRESSIBLE, so a low bitrate target does not
  produce low bits/frame -- it OVERSHOOTS. At a nominal 50 Mbps the encoder
  emitted 719 KB/AU against a 104 KB quota, and the three lowest rows of the
  first sweep all sat at the same ~5.7 Mbit/frame. Sweeping nominal bitrate
  measures nothing.
- So the sweep moves CONTENT DETAIL (block size) instead, and the x-axis is the
  bits/frame the encoder ACTUALLY produced, never the one requested.

  4K60 HEVC 8-bit, real content, single-engine vs forced-2:

    bits/frame   Ada 4090            Blackwell 5070 Ti
    0.2-0.3 Mb   4567 -> 2381 1.92x  5549 -> 3552 1.56x
    ~1.1-1.2 Mb  5060 -> 2626 1.93x  5867 -> 4082 1.44x
    ~3.3 Mb      8478 -> 4455 1.90x  9286 -> 5862 1.58x
    ~9.6 Mb     16237 -> 8114 2.00x 16435 -> 9275 1.77x

RESULTS. (1) Encode time scales strongly with bits/frame -- 4.6 ms to 16.2 ms
across the range on Ada -- confirming the hypothesis' core claim. (2) There is NO
CROSSOVER: split wins at every point on both architectures (Ada ~1.9-2.0x and
notably flat, Blackwell 1.44-1.77x). So the arbitration's encode-side answer is
essentially always "split", which makes the sub-frame handicap the only decision
that actually matters -- exactly the part already built and unit-pinned.
(3) It corroborates the field capture: at ~6.8 Mbit/frame these curves put
single-engine 4K60 around 10-13 ms, and the field report was 10.3 ms on a 4090.
That reads as real ASIC time, not the retrieve-queue inflation it might have been.

⚠ Caveat the data itself shows: cost is NOT monotonic in bits/frame alone. The
1px row lands at the HIGHEST bits/frame yet encodes FASTER than the 4px row on
both boxes (Ada 10148 vs 16237 us) -- pure noise defeats motion estimation, which
gives up early, where semi-structured content makes it search hard. Content
structure is a real term, so "bits/frame" is a good axis but not a complete cost
model.

Verified .21: clippy -D warnings clean (pf-encode + pf-zerocopy), 64 unit tests,
25/25 NVENC on-hardware. Curves run on both Ada and Blackwell. fmt clean.
2026-08-07 00:30:09 +02:00
enricobuehler 50b3fd1012 fix(pf-encode): drop the 10-bit short circuit — measured wrong on Ada, twice
WP1.3, and the measurement that justifies it. `resolve_split_mode`'s 10-bit rule
sat ABOVE the pixel-rate arm and took no codec, so it (D1) vetoed 10-bit 4K120 --
the very case the pixel-rate arm exists for -- and (D2) applied an HEVC-Main10-on-
Ada result to AV1 10-bit, which has no such measurement. Both fixed: the
pixel-rate arm now comes first, and what remains is codec-scoped to HEVC and only
applies BELOW that bar, where a second engine buys nothing anyway.

The rule rested on one datapoint: 5120x1440@240 Main10 on Ada, forced-2 7.6 ms
vs 2.8 ms single-engine -- split 2.7x SLOWER. Dropping the short circuit flips
that exact configuration's behaviour, so it was re-measured on a 4090 (AD102,
driver 610.43.03), 400 Mbps, sub-frame pinned off, via a new mode-parameterizable
Main10 A/B test (PF_AB_MODE=WxHxFPS reproduces the original operating point).

  Ada 4090          single    forced-2   ratio
  3840x2160@60      4483 us   2178 us    2.06x split WINS
  5120x1440@240     3689 us   2813 us    1.31x split WINS  <- the veto's origin
  3840x2160@120     4148 us   2189 us    1.89x split WINS

  Blackwell 5070 Ti
  3840x2160@60      4216 us   2477 us    1.70x split WINS
  5120x1440@240     4651 us   3894 us    1.19x split WINS

Split wins for Main10 at every mode on BOTH architectures, including the config
the veto came from. The original number does not reproduce.

⚠ Caveats, unchanged from the rest of this work: content is trivial (297-300 B/AU
against an 833 KB CBR quota -- zeroed VRAM), so this is the pixel-proportional
term and the bits/frame regime is still unmeasured; debug build; and the driver
differs from whenever the original was taken.

Also validated on Ada in the same session -- the whole spike set reproduces on a
SECOND architecture and an OLDER driver (610.43.03 vs 610.57.04): S1a in-place
split switch accepted with zero IDRs both directions; S1b takes effect
(|C-B|=12 vs |C-A|=1921, the cleanest run yet); S1c pair flip passes; D5 confirmed
(AUTO+sub-frame 4424 vs DISABLE 4409, 15 us apart -- and AUTO without sub-frame
2310 ~= TWO_FORCED 2314, so the arm stays); engines=2 with THREE_FORCED correctly
clamped to mode 2; arbitration converged with exactly 1 keyframe.

Verified: .21 clippy -D warnings clean + 64 unit tests; .133 Windows clippy
-D warnings clean (the resolver signature grew a `codec` param, so both backends
moved); Ada + Blackwell on-hardware as above. fmt clean.
2026-08-06 23:58:24 +02:00
enricobuehler 2366c4fe31 feat(pf-encode,host): price the HEVC sub-frame trade so arbitration can cover it
The named next step after WP3's first increment. That increment deliberately
REFUSED to arbitrate HEVC-with-sub-frame -- the fleet default, and the reported
field case -- because engaging split there gives up sub-frame readback, whose
whole value is that the send overlaps the encode. An encoder measuring only
encode time would see split as ~2x faster, take it, and make end-to-end latency
worse while reporting a win. This supplies the missing number.

The real comparison is encode_1eng + send_of_last_slice against
encode_2eng + send_of_whole_AU, so the challenger owes roughly
spread x (slices-1)/slices. Split across the two sides that can each see half:

- Host: new `Encoder::set_send_spread_us` (defaulted, forwarded by
  TrackedEncoder -- same trap class as set_wire_chunking, and unforwarded it
  would fail SILENTLY IN THE SAFE DIRECTION, which is the hardest kind to
  notice). The send thread is the only place a paced send is observed and the
  encode loop the only place the encoder can be touched, so it goes over an
  AtomicU32 like encoder_ceiling_kbps, EWMA-smoothed 3:1 per completed AU: one
  content spike must not flip a verdict that then gets cached.
- Encoder: turns the raw spread into the handicap, because only it knows
  `slices`. SplitArbiter::with_handicap charges it to the challenger before the
  comparison. A unit test runs identical encode numbers with a cheap and an
  expensive send and asserts the verdict REVERSES -- with an expensive send the
  arm that looks twice as fast is a loss end to end, and the incumbent must
  hold. That is precisely the regression an encode-only arbiter ships.

Gate now opens for HEVC+sub-frame only when a spread has actually been reported
(and slices >= 2); with no hint it still refuses, so behaviour is unchanged until
the host feeds it.

Two mechanics this needed:
- apply_split_mode became a PAIR flip (split + sub-frame), routed through
  resolve_split_subframe and restoring from `subframe_opened_with` so a session
  that never had sub-frame can never gain it. It also recomputes
  `subframe_chunks`, which reconfigure_bitrate does NOT -- spike S1c's finding;
  leave it stale and supports_chunked_poll keeps saying yes while numSlices never
  advances, so poll_chunk busy-polls its whole budget every AU.
- The arbiter is now fed from BOTH completion points. A sub-frame session
  finishes through poll_chunk, so the incumbent arm of an HEVC experiment would
  otherwise never deliver a sample -- only the challenger, with sub-frame
  dropped, comes through poll.

Verified .21: clippy -D warnings clean for pf-encode AND punktfunk-host with
nvenc, 63 unit tests (1 new), 23/23 NVENC on-hardware green. Verified .133:
Windows clippy -D warnings clean, zero dead_code. fmt clean.
2026-08-06 23:41:40 +02:00
enricobuehler f34f311c69 Merge pull request 'fix(brand): capitalize "Punktfunk" in user-facing text; name the legal copyright holder' (#81) from worktree-docs-title-casing into main
apple / swift (push) Successful in 1m35s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m17s
ci / rust-arm64 (push) Successful in 2m23s
ci / web (push) Successful in 1m43s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
arch / build-publish (push) Failing after 4m37s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
deb / build-publish-host (push) Successful in 4m21s
apple / screenshots (push) Successful in 6m3s
deb / build-publish-client-arm64 (push) Successful in 5m7s
nix / flake (push) Failing after 3m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 10s
docker / builders-arm64cross (push) Skipped
decky / build-publish (push) Successful in 45s
ci / rust (push) Failing after 7m22s
deb / build-publish (push) Successful in 5m11s
android / android (push) Successful in 14m37s
docker / deploy-docs (push) Successful in 6m48s
release / apple (push) Successful in 9m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m56s
windows-drivers / probe-and-proto (push) Successful in 2m0s
windows-drivers / driver-build (push) Successful in 9m47s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 17m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 6m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 11m31s
windows-host / package (push) Failing after 49m58s
windows-host / canary-manifest (push) Skipped
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 15m1s
Reviewed-on: #81
2026-08-06 21:33:33 +00:00
enricobuehler 3b283dc26e feat(pf-encode): WP3 — live split arbitration, measured on the session, no IDR
The fix S1 unlocked. Rather than predict the right split mode at open — which
cannot work, because the decision depends on bits/frame and an Automatic client's
steady-state bitrate is unknown at open (ABR climbs in place afterwards) — the
encoder now measures both arms on the live session and keeps the winner. S1
proved nvEncReconfigureEncoder takes a changed splitEncodeMode with
resetEncoder=0, emits no IDR, and actually applies it, so the experiment is
invisible on the wire.

Deliberately measures instead of modelling: hard-coded per-arch constants are
exactly how the rule this replaces went wrong (one 5120x1440@240 Ada datapoint
generalised into a fleet-wide 10-bit veto). A measurement tracks driver updates
for free.

`SplitArbiter` (pure state machine, unit-tested without a GPU): measure incumbent
-> switch -> SETTLE -> measure challenger -> keep the winner, else switch back.
Verdicts cache per (gpu, codec, mode, depth, chroma) so later sessions open
straight into the winning arm; the key is CeilingKey minus split_mode, since the
split mode is the thing being decided.

⚠ SETTLE_FRAMES=16 is load-bearing, not padding: split-encode does not reach
steady state on the first frame (a FRESH TWO_FORCED session measured early-half
3280us vs late-half 1996), so judging an arm right after switching reads the
transient — intermittently, which would then be cached. A unit test feeds exactly
that transient and asserts the arbiter still sees the steady state.

Safety gates, all correctness conditions rather than preferences: opt-in
(PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1) while it earns trust; an operator
PUNKTFUNK_SPLIT_ENCODE pin always wins; skip if a verdict is already cached; sync
depth-1 only (async_rt.is_none(), same gate chunked poll uses — under pipelined
retrieve the submit->AU span includes queue depth and the comparison is noise);
needs >=2 engines; never H.264.

⚠ And the one that bounds this increment: NO SUB-FRAME TRADE. For HEVC, forcing
split gives up sub-frame readback, which costs send/encode overlap the ENCODER
CANNOT SEE — it measures encode time only, so it would reliably prefer split and
silently make end-to-end latency worse. So arbitration runs only where nothing is
traded: sub-frame already off, or AV1 (both features legal). Pricing that trade
needs the host's send cost and is the next work package.

Challenger choice tests the question worth asking — anything not already the
widest forced split is challenged BY the widest ("are we leaving engines idle?").
The naive "challenge whatever we are not" spent the experiment re-proving that
splitting beats not-splitting, while parking the session on the slow arm to do
it, because 4K60 sits on the fallthrough AUTO.

⚠ Every new nvenc_core item is linux-gated: the arbiter is wired into the Linux
backend only for now and nvenc_core compiles on Windows too. Caught by the .133
check, not by reasoning — the first cut failed Windows clippy with 12 dead_code
errors, the exact item-level trap this file already carries a scar from.

Verified .21: clippy --features nvenc --all-targets -D warnings clean, 62 unit
tests (4 new arbiter tests), 23/23 NVENC on-hardware green including a new
end-to-end convergence test asserting ZERO extra IDRs and a cached verdict.
Verified .133: Windows clippy -D warnings clean, zero dead_code. fmt clean.
2026-08-06 23:26:47 +02:00
enricobuehler e6bcac8770 chore(license): name the copyright holder "unom - Enrico Bühler"
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 3m58s
ci / rust (pull_request) Successful in 8m28s
nix / flake (pull_request) Successful in 13m20s
windows-drivers / probe-and-proto (pull_request) Successful in 2m56s
windows-drivers / driver-build (pull_request) Successful in 10m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 18m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 24m1s
"unom" alone is not the legal entity name. Updated the copyright line in
all nine first-party license files -- the root MIT/Apache pair and the
hand-maintained copies under clients/apple, clients/decky,
packaging/windows/drivers and packaging/windows/pf-vkhdr-layer (there is
no script that syncs these, so each is edited directly).

The Linux and Windows clients' About screens pick this up automatically:
both `include_str!` the root LICENSE-MIT / LICENSE-APACHE at compile time.

Deliberately untouched:
- Third-party license texts (Geist OFL, FFmpeg, VB-CABLE, the vendored
  pyrowave/Granite tree, KDE protocol XMLs, the os-icon licenses) -- those
  are other parties' copyrights.
- Publisher/author/maintainer metadata, which is identity rather than
  license text and is reported separately for a decision.
2026-08-06 23:16:16 +02:00
enricobuehler 9c7409ea40 Merge pull request 'fix(web/plugins): a plugin's interface loads again, instead of an empty panel' (#80) from worktree-plugin-ui-frame-ancestors into main
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m10s
ci / web (push) Successful in 1m10s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
deb / build-publish-client-arm64 (push) Successful in 3m33s
deb / build-publish (push) Failing after 2m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 10s
docker / builders-arm64cross (push) Skipped
deb / build-publish-host (push) Successful in 5m37s
ci / rust (push) Successful in 6m37s
arch / build-publish (push) Successful in 8m50s
docker / deploy-docs (push) Failing after 6m26s
nix / flake (push) Canceled after 15m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14m28s
windows-host / package (push) Canceled after 17m47s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #80
2026-08-06 21:15:58 +00:00
enricobuehler b0ec759fad fix(brand): capitalize "Punktfunk" in user-facing text
The docs site's page title read "punktfunk docs". Fixed that and swept the
rest of the tree for the same defect, capitalizing the brand wherever it is
shown to a human and leaving it lowercase where it is a technical identifier
(CLI/package names, `punktfunk://` scheme, PnP enumerator, TLS SNI, logcat
tag, config paths, CMS tenant id).

User-visible fixes:

- docs-site: page title -> "Punktfunk Docs"; API reference title, meta
  description and the branded bar's aria-label; the BrandMark/Wordmark SVG
  accessible names (the web console already had these capitalized -- the
  docs site had drifted from it).
- Android: six strings of live UI copy -- the local-network permission
  dialog (x2), the connect-screen error banner, and the no-controller
  explainer.
- Apple: the "No Hosts" empty-state text and the fallback display name for
  a host that advertises no instance name.
- Windows client: the `--discover` progress line.
- KWin fake-input: the application name passed to `authenticate()` (the
  grant is cached per-exe, so the string is display-only).
- THIRD-PARTY-NOTICES: fixed in both generators (about.hbs and
  gen-third-party-notices.py) and applied to the three checked-in outputs
  so they match what a regeneration now produces.

Every changed line differs from the original only by letter case, so line
lengths are unchanged and no formatter width rule is affected.
`cargo fmt --all --check` passes.
2026-08-06 23:10:25 +02:00
enricobuehler 96fc3eca10 test(vkdecode): the AV1 rung finally has pixels to answer to
A parity and smoke harness for AV1, mirroring the H.264 and H.265 legs that
proved those rungs bit-identical to libavcodec on four drivers before either
ran on glass. This was the milestone's largest test gap: the adversarial
review found four blocking defects in the AV1 conversion — flags unset on
274 frames of 274, a units error in LoopRestorationSize, per-reference info
describing the wrong picture, film-grain fields left zero — and every one of
them would have shown on frame 1 of a parity run, while clippy and 164 green
unit tests said nothing at all.

The golden is 250 per-frame SHA-256s in DISPLAY order, not 274. The vector
carries 274 coded frames in 250 temporal units; the 24 extras are hidden
ALTREFs, decoded and referenced but never shown, and the rung delivers what
dpb.outputs names. The count is re-derived from the planner rather than
assumed.

Cross-checked between ffmpeg 8.1.1 on macOS arm64 and 8.0.1 on Linux x86_64,
whose raw outputs are byte-identical — and then against a third party neither
build knows about: the vendored vector ships upstream's own per-frame MD5s,
and re-running those reproduces all 250. The golden agrees with a decode
nobody in this program performed. I reproduced both independently before
committing.

8-bit NV12, traced from the sequence header rather than presumed
(seq_profile 0, high_bitdepth 0, mono_chrome 0), so the P010 scar does not
apply here — and the header says which check to make if a Main 10 golden is
ever added. film_grain_params_present is 0, which is load-bearing: grain
synthesis is part of the Vulkan decode profile, so this golden is only
comparable against a grain-less profile key.

Anti-vacuity is the point of the exercise, so it is structural. The golden
guard asserts the exact count, that every line is a bare digest, and that all
entries are DISTINCT — 250 copies of one digest would let a decoder frozen on
a single frame pass parity. The parity body asserts the golden set and the
access-unit count before it touches hardware, so an IVF reader returning
nothing cannot become "0 frames compared, pass". The agent verified the
guards fire by mutating the golden three ways.

assert_bit_identical now names the FIRST divergent frame, which is what
localises a defect; that improves all six legs, not just AV1.

AV1 has no four-byte-start-code twin, deliberately: OBUs are
length-delimited, so there is no prefix for a driver to mis-skip. Documented
where a reader would otherwise see an omission.

Nothing here has run on a GPU. The harness exists precisely so the four
review defects can be answered by measurement instead of argument.
2026-08-06 22:38:04 +02:00
enricobuehler 185a69c806 fix(web/plugins): a plugin's interface loads again, instead of an empty panel
ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m49s
ci / rust (pull_request) Successful in 6m23s
nix / flake (pull_request) Failing after 12m37s
Two separate things had to be wrong for this, and both were.

The frame's own policy locked it out. Plugin UIs moved to their own origin so a
plugin cannot act as the logged-in operator, and the plugin origin names the
console as the only page allowed to frame it. It built that name from the
incoming request — but Nitro hands the app a synthetic request with no TLS
socket, so an HTTPS console reads back as `http:`. The header said
`frame-ancestors http://host:47992` while the operator was on
`https://host:47992`, and the browser refused the frame outright
(ERR_BLOCKED_BY_RESPONSE). Nothing on screen said so; the reason was only in
devtools. The scheme now comes from the listener's own TLS state, stamped at
bind time, with x-forwarded-proto winning when something in front terminated
TLS for us — the one case where the browser's scheme is not ours.

And the port was shut. 47993 was added to the firewall definitions, but an
already-open firewall does not pick a new port up: ufw expands an app profile
into rules when you allow it and keeps those, so editing the profile on upgrade
changes nothing, and firewalld needs a reload. Every upgraded Linux host kept a
47992-only rule, silently. The packages now notice on upgrade and print the one
command that fixes it, without touching the running firewall. The NixOS module
and the container image never learned the port at all; both now open it.

Also: the console no longer mounts the frame while it is still checking whether
that origin is reachable. A firewalled port drops rather than refuses, so the
check hangs for the browser's whole connect timeout, and mounting meanwhile is
exactly the empty panel with no explanation. The card that follows now names
both causes it can be — an untrusted certificate for that port, or a closed one
— because from a browser the two are indistinguishable.

The rule is now a pure function with tests, since its failure mode is a
well-formed header that only a browser rejects.

Verified on glass against home-worker-5 (.21) and its ROM Manager plugin: the
frame was refused before, renders the plugin's UI after.
2026-08-06 22:33:57 +02:00
enricobuehler 9a1d8be4cc fix(pf-encode): AUTO split is conditional on sub-frame — do NOT retire the arm
Last change's docs concluded "AUTO never splits, retire the arm" from the
sub-frame-ON measurement alone. Measured the missing leg before implementing it,
and the conclusion was wrong.

On .21 at 4K, plain AUTO (env unset, the resolver's fallthrough):
  sub-frame ON  -> 5023/5157 us/frame ~= DISABLE 4979/5000   (does NOT split)
  sub-frame OFF -> 2401/2352 us/frame ~= TWO_FORCED 2319/2378 (DOES split)

So AUTO is CONDITIONAL, not dead. Retiring it would have silently cost every
sub-frame-off session its second engine -- a regression introduced while
"cleaning up" an arm that looked inert. Split and sub-frame are mutually
unsupported for HEVC, so the driver resolves AUTO to no-split only in that
combination.

Fix is disclosure, not removal:
- resolve_split_subframe debug-logs the inert HEVC + AUTO + sub-frame case, which
  is the fleet default shape: "split_mode=AUTO" has meant "no split" for every
  default session and nothing said so. Deliberately NOT rewritten to DISABLE --
  the mode we pass is what the driver was actually given, and the ceiling-cache
  key must keep describing that.
- New unit test `auto_survives_the_arbitration_in_both_subframe_states` pins the
  contract so the arm cannot be simplified away later.
- The resolver doc now records both measured legs instead of "AUTO is dead".

Also in this change:
- WP1.6: `resolve_subframe`'s doc said "Windows passes `false`". Stale since the
  2026-07-31 .173 A/B flipped Windows to caps-gated default-on. It mattered:
  it made the AUTO-plus-sub-frame dead combination look Linux-only when it is
  fleet-wide.
- Windows session-ready log parity: split_mode + engines + subframe. The Windows
  line had no split_mode at all, so a Windows field report could not answer "did
  this session actually split?" -- the question that started this whole thread.

Verified: fmt clean; .21 clippy -p pf-encode --features nvenc --all-targets
-D warnings clean, 58 unit tests (1 new), 22/22 NVENC on-hardware tests green;
.133 Windows clippy --features nvenc --all-targets -D warnings clean (15m cold,
zero errors or warnings) -- the Windows backend is cfg'd out on both macOS and
the Linux box, so that leg needed a real Windows host.
2026-08-06 22:25:10 +02:00
enricobuehler a404830456 feat(client): wire AV1 into the native Vulkan rung, pin-only
The third codec arm in video_vk_native, AV1 admitted to native_codec and to
native_vulkan_gate by pin only. It stays out of `auto` on the same rule M5's
D3D11VA rung follows: `auto` admission is earned with hardware evidence, and
this has decoded nothing on a device.

is_integrity_warning_av1 did not exist, so the client could not have
concealed AV1 damage at all. Added, exhaustive, no wildcard: all three AV1
warnings really are damage, because AV1 has no spec-legal-but-noisy signal
to mis-classify — no reorder envelope to announce, no MMCO to rebase — and
the exhaustive match is what stops a future variant defaulting to clean.

The blocking defect review found was two safety mechanisms cancelling each
other. After a failure the decoder skipped to the next key frame answering
Ok(None), and because AV1's planner has no flush its store kept planning
cleanly, so those AUs carried no warnings and the client read them as proof
the rung works — clearing the demotion streak and resetting its clock on
every one. The streak could then never reach the threshold, which made the
never-delivered fall-through to FFmpeg-Vulkan unreachable, which is the
documented backstop for exactly three things: a level above maxLevelIdc, a
sequence header disagreeing with the Welcome, and film grain. Film grain is
the probe's own admitted assumption, so a grain stream would have frozen the
screen for the session while DecodeHealth reported run 0 — recovered.

AV1 now answers the wait with an error, as H.264 and H.265 already do
through AwaitingIdr, so all three codecs are indistinguishable to the
demotion machinery. That matters more than the extra precision of a third
state: only the H.26x paths have hardware evidence, and they are proven WITH
that behaviour.

The obvious form of that fix would have wedged the decoder. A key frame can
sit behind a skipped frame inside the same temporal unit — the vendored
vector has 24 two-frame units — so erroring out of the per-plan loop would
never reach it and the wait would never end. Skips are therefore counted per
frame and the error raised only when the whole unit was skipped, with the
metadata-only unit staying a clean Ok(None).

Also closed: a refused temporal unit left an already-decoded frame in the
ready queue, which shipped on the next AU as a clean success — putting a
picture from a refused AU on screen, clearing the streak again, and latching
delivered so the fall-through was disabled for good. The error arm now
drains and releases unshown.

MAX_DELIVERABLE is derived rather than picked: HOLD_HEADROOM minus the
pipeline's own hold, pinned to pf-vkdecode's constant so a hardcoded depth
fails the build. At the previous 8 the queue plus the presenter's 4-7 stood
against a headroom of 8, so it capped memory without preventing the
exhaustion it named, and a frame waiting 8 AUs burned 16 of the 17 query
slots — where a re-armed slot reads as Failed and becomes a fabricated
driver-corruption verdict in the very counter the Ally X signal lives in.
The trim now runs after this AU's frame is taken, or at the derived depth it
would drop a two-output unit's first frame and invert display order inside
one AU.

Its justification was also wrong: the claim that a temporal unit may carry a
show_existing_frame alongside a shown frame is disproved by this repo's own
golden — 250 units, 250 shown, zero show_existing. The bound is kept as
defence in depth against a non-conformant or multi-operating-point stream,
and now says so.

Gates: macOS fmt/clippy/392 tests, container clippy -D warnings over six
crates, 851 tests, workspace check. No hardware: the rung is pin-only and
has still never decoded a frame on a device.
2026-08-06 22:19:22 +02:00
enricobuehler 88f29a9411 feat(pf-encode): use every NVENC engine the GPU has, not a hard-coded two
WP1.1 plus the engine-count fix. `resolve_split_mode` forced TWO_FORCED at high
pixel rate regardless of hardware, so a 3-NVENC part (GB202, AD102 workstation)
left a third of its encode silicon idle, and a 1-NVENC part paid a wasted session
open to discover it could not split.

Probes NV_ENC_CAPS_NUM_ENCODER_ENGINES in both direct-SDK backends' query_caps
(the cap is `= 49` in both linux_sys and windows_sys of the vendored SDK 0.4.0 --
the caps enum is cfg-selected per-OS, so that was checked) and latches it on a
backend field. NOT on EncoderCaps: nine backends construct that struct as
exhaustive literals, so a new field would be a 9-site change of which 7 are
unrelated codecs passing a meaningless value, and the only consumer is the
resolver.

New `max_forced_split_mode(engines)`: 1 -> DISABLE, 2 -> TWO, 3 -> THREE, and
>3 -> AUTO_FORCED, because NV_ENC_SPLIT_ENCODE_MODE cannot NAME more than three
(NVENCAPI 12.1; values 4..14 are unallocated, so a future API may extend it) and
AUTO_FORCED = "split, driver picks how many" is measurably a real split (2.01x vs
disabled on .21). 0 = unprobed keeps the historical two-engine assumption.

⚠ WHY THE CLAMP EXISTS, measured on .21 (RTX 5070 Ti, 2 NVENC, 4K HEVC):
requesting THREE_FORCED was HONOURED -- session opened in mode 3 -- and ran at
2303 us/frame, identical to TWO_FORCED's 2308. The driver does not reject an
over-ask; it silently encodes narrower. So the rejection fallback cannot find the
ceiling and PUNKTFUNK_SPLIT_ENCODE=3 on a 2-engine card would have logged a
3-way split over a 2-way encode. Operator overrides are now clamped with a warn.
The ordering trap is covered by a test: on a >3-engine part hw_max is
AUTO_FORCED (1), which is not "narrower than" TWO_FORCED (2) despite comparing
smaller, so a naive min() would collapse a legitimate 3-way request to AUTO.

Also adds `engines` and `subframe` to the Linux session-ready log: split_mode
alone is ambiguous between "used both engines" and "left a third idle", and
since the driver honours an over-wide request the mode cannot be read without
the ceiling it was chosen from. This is the line a field report needs.

--- and a correction to S1b, in the same change ---

Re-running S1b afterwards flipped its verdict to "the driver appears to have
IGNORED the in-place split change", contradicting the isolated runs that produced
the |C-B|=34 figure already written into the design docs. Investigated rather
than re-rolled.

The switched leg was landing MIDWAY between the arms (~3600 us against A~5050,
B~2300) and the nearest-neighbour verdict flipped on noise. Cause: split-encode
does not reach steady state on the first frame -- a FRESH TWO_FORCED session
shows it too (early-half 3280 us vs late-half 1996 in one run), so it is split
warmup generally, not something specific to reconfiguring in place. A single
median over the whole window cannot see that. The test now reports early-half vs
late-half and gives a switched leg SETTLE=16 frames before its window opens,
every leg the same length. With that, 4/4 runs agree: the switched leg reaches
~2030 us against a fresh-split ~2000 and a single-engine ~4900.

⚠ S1b's CONCLUSION stands (the switch does take effect) but the evidence behind
the committed number did not reproduce; the docs are corrected rather than left
implying a cleaner result than the harness could support.
⚠⚠ This is a WP3 REQUIREMENT, not just a test fix: a live-session arbitration
that switches arms and immediately measures will misjudge the arm it just chose,
because the encoder needs ~16 frames to settle. The settle window has to be part
of the arbitration, and it is now a measured number rather than a guess.

Verified on .21: clippy --features nvenc --all-targets -D warnings clean,
57 unit tests (3 new), all 23 NVENC on-hardware tests green, fmt clean. The 3
failing on-hw tests in a full --ignored run are VAAPI (no AMD/Intel GPU on that
box -- their own ignore reason says so), pre-existing and unrelated.
2026-08-06 21:54:00 +02:00
enricobuehler 50fa228bab Merge pull request 'feat(client/android): copy a host's punktfunk:// link' (#79) from worktree-android-copy-link into main
ci / web (push) Failing after 28s
ci / bun-nix (push) Successful in 23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 13s
ci / docs-site (push) Successful in 1m15s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
android / android (push) Successful in 5m17s
ci / rust-arm64 (push) Successful in 6m17s
ci / rust (push) Failing after 6m15s
docker / deploy-docs (push) Successful in 6m29s
Reviewed-on: #79
2026-08-06 19:32:31 +00:00
enricobuehler 70b81ac3d7 test(pf-encode): S1c + the D5 confirm — pair flips in place, AUTO really is dead
S1c `nvenc_cuda_split_subframe_pair_reconfigure`: the leg S1a/S1b excluded. Both
pinned sub-frame OFF to isolate the split variable, but a real HEVC arbitration
cannot -- split and sub-frame are mutually unsupported there, so engaging split
means flipping enableSubFrameWrite in the same breath, a second init param and
the one the reconfigure path deliberately pins. RESULT on .21: the PAIR moves in
place, accepted, ZERO IDRs, both directions.

It also pins the invariant that makes this safe to build on: `subframe_chunks` is
latched ONLY in the init path (~line 1625) and is NOT recomputed by
reconfigure_bitrate, so a caller flipping sub-frame in place must clear it too or
supports_chunked_poll keeps reporting true and poll_chunk busy-polls its whole
budget every AU against a numSlices that never advances. The test performs the
correct sequence and asserts the state stays coherent, so WP3 has a worked
example rather than a warning.

`nvenc_cuda_auto_split_with_subframe`: the D5 confirm -- the one claim in the
design's defect list that was only ever inferred. The driver reports no "mode I
actually chose", so it is settled by timing, at 4K where the gap is ~2x.
RESULT: AUTO (env unset) + sub-frame 4904 us/frame, DISABLE + sub-frame 5062,
TWO_FORCED without sub-frame 3464. AUTO sits 158 us from DISABLE and 1440 from
TWO_FORCED ⇒ D5 CONFIRMED: plain AUTO does not split while sub-frame is on, so
the resolver's AUTO fallthrough reads as "let the driver decide" and means
"never split".

⚠ TRAP, hit on this test's first run and now documented in it: the env knob
CANNOT express plain AUTO. `0` is DISABLE and `1` is AUTO_FORCED, and
resolve_split_subframe counts AUTO_FORCED as forced, so passing `1` silently
disarms sub-frame and measures a different configuration entirely -- which
produced a spurious "D5 REFUTED". Plain AUTO is only reachable as the resolver's
fallthrough with the env unset. The leg now asserts sub-frame resolved TRUE, so
the test can no longer answer the wrong question quietly.

Verified on .21: clippy --features nvenc --all-targets -D warnings clean, all 4
spikes green, the normal 54-test suite unaffected, cargo fmt --all --check clean.
2026-08-06 21:27:18 +02:00
enricobuehler f33e442b43 feat(client/android): copy a host's punktfunk:// link
ci / bun-nix (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 3m22s
ci / rust (pull_request) Failing after 7m33s
Android was the one app that could open a punktfunk:// link but never hand
one out, so every Android link had to be typed by hand — and the host's
stable record id, which is the part that keeps a link working after the box
changes address, isn't shown anywhere in the UI to type.

Both homes now offer Copy link: the touch grid's card overflow menu, and the
controller home's host options (Up on a tile). A pinned card copies its own
profile with it, matching Linux and Apple; a host card copies none and so
keeps honouring the host's binding, exactly like tapping it does.

The URL is the shared self-emitted form (DeepLinks.forHost), already covered
by the cross-language vector tests, so the three emitters stay in step.

Android 13+ draws its own clipboard confirmation and we add nothing on top of
it; below that we say so ourselves, as a toast in the console home, which
renders neither banner.
2026-08-06 21:20:36 +02:00
enricobuehler cab3aa1726 feat(vkdecode): M7's Vulkan AV1 rung — GPU half, and the review that saved it
caps_av1 / session_av1 / decoder_av1, over the CPU half already committed,
sharing the picture pool, bitstream ring, op ring, DPB settling and frame
delivery with H.264 and H.265 rather than forking them. AV1 session
parameters carry exactly one sequence header — no PPS, no VPS — so the
parameters ledger is two-state: current, or recreate.

The GPU plumbing came through review clean. The damage was all in the
conversion committed two rounds ago, which nothing tested against a
reference, and none of it would have failed a gate: clippy was clean, the
tests were green, and the rung would have decoded its own conformance vector
wrong on essentially every frame on AMD, silently.

Four blocking defects, each measured on the vendored vector rather than
argued:

Nine StdVideoDecodeAV1PictureInfo flags were never set. Four change
reconstruction — allow_screen_content_tools on 274 frames of 274,
allow_warped_motion on 273, is_filter_switchable on 172, force_integer_mv on
1 — and RADV reads three of them directly. The block already set
allow_intrabc, which is only codeable when screen-content tools are on, so
it contradicted itself.

LoopRestorationSize sent the pixel size where the field is log2(size) - 5.
cros-codecs stores 64/128/256; RADV names its destination
log2_restoration_size_minus5 and reads 1/2/3. Nothing truncates, nothing
errors, and every frame with loop restoration reconstructs against a
nonsense unit size.

Per-reference Std info answered questions about the wrong picture: every
reference carried the CURRENT frame's type, and RefFrameSignBias was never
set at all. Sign bias is what tells a decoder a reference lies in the
future, and this vector is the hidden-ALTREF one, so all-zero meant every
reference was treated as past. Fixed at the source: pf-bitstream now records
a RefState when a picture is stored — its own frame type, sign-bias mask,
saved order hints — and carries it on the slot, so all three backends get
answers about the reference rather than about the frame reading it.

Film grain's six chroma-scaling fields were zero, which defeats the profile
machinery that exists to refuse devices unable to synthesise grain.

The reference-name compaction is fixed in the PLANNER, once. AuPlan::refs is
now name-indexed with holes preserved, so a lost reference can no longer
renumber every later AV1 reference name — a class that was live in both
conversions and armed for the VAAPI rung that does not exist yet. The DXVA
twin had a second name-versus-slot confusion: it read global motion by DPB
slot from an array the spec indexes by reference name, and slot 0's matrix
is all-zero rather than identity, so 273 references were given a zero warp.

Also closed: pTileOffsets/pTileSizes were sized to tileCount while RADV
reads AV1_MAX_NUM_TILES entries unconditionally — a 4-byte allocation read a
kilobyte deep — now fixed 256-entry arrays with zeroed tails. And the test
guarding the lost-reference refusal re-implemented the predicate inline, so
deleting the guard left it green; both now call one named function.

The bitstream layout now matches libavcodec: raw tile payloads only,
frameHeaderOffset 0. The review established the spec-literal layout was NOT
wrong — AV1 has no start-code scanning, so the 3-versus-4-byte and
slices-only scars do not transfer, and no driver in the fleet reads
frameHeaderOffset — but matching the validated reference deletes code,
uploads 5835 fewer bytes over the vector, and removes the untested-driver
tail.

Upstream, and the third of its kind: the vendored parser writes
ref_frame_sign_bias[i] in the same loop body where it writes
order_hints[LAST_FRAME + i], so its array is shifted one down and index 7 is
never written. Corrected in RefState::of with the shift documented, the
vendored tree untouched, and pinned by a test that recomputes the bias from
order_hints through the parser's own get_relative_dist.

Gates: macOS fmt/clippy/tests, container clippy -D warnings over six crates,
845 tests, workspace check. No hardware: nothing here has reached a driver.
2026-08-06 21:15:39 +02:00
enricobuehler 4b57d11dd8 test(pf-encode): S1 spike — splitEncodeMode CAN change in place, no IDR
Two on-hardware spikes answering the gate on the split-encode engagement
program (design/nvenc-split-encode-engagement-implementation-plan.md).

S1a `nvenc_cuda_split_reconfigure_in_place`: can splitEncodeMode change via
nvEncReconfigureEncoder with resetEncoder=0, without an IDR? Our "reconfigure
must present the SAME init params as the open" rule (windows/nvenc.rs:620) is
our own invariant and had never been tested against a driver. It reports rather
than asserts the verdict -- both outcomes are legitimate findings -- and only
asserts what would invalidate the measurement (session live, engines >= 2, the
arms actually differ). Sub-frame is pinned off so the driver can't reject for
the wrong reason (HEVC forced-split and sub-frame are mutually unsupported).

S1b `nvenc_cuda_split_reconfigure_takes_effect`: the other half -- a driver that
accepts the parameter and quietly ignores it looks identical to one that honours
it. Three legs at 4K (fresh DISABLE / fresh TWO_FORCED / DISABLE->TWO in place);
if C tracks B and not A, the switch is real.

RESULT on .21 (RTX 5070 Ti, GB203 Blackwell, driver 610.57.04):
  NV_ENC_CAPS_NUM_ENCODER_ENGINES = 2
  S1a: accepted, ZERO IDRs, both directions.
  S1b: A fresh DISABLE 5054 us/frame, B fresh TWO_FORCED 2453,
       C switched in place 2419 -- |C-B|=34 vs |C-A|=2635. It takes effect,
       and split is a clean ~2x at 4K.

Two limits, both recorded in the test docs rather than the commit only. The
frames come out at 427 B/AU against an 833 KB CBR quota: the driver hands back
zeroed VRAM, so the rotated buffers are identical and rate control skip-codes
everything. So this measures the PIXEL-proportional half of the cost only --
the bits/frame regime the field case lives in is untested here, and the test
prints an explicit INCONCLUSIVE-on-content line when it detects that. And this
is Blackwell 8-bit; the Ada Main10 question is untouched.

Verified on .21: clippy -p pf-encode --features nvenc --all-targets -D warnings
clean, both spikes green, cargo fmt --all --check clean.
2026-08-06 21:11:52 +02:00
enricobuehler eecd04247f feat(client): AV1 is advertised on hardware, not on a decoder existing
The standing open item M7 was meant to close. `decodable_codecs` answered the
AV1 bit from `ffmpeg::decoder::find(AV1)`, which says yes on every build that
links libdav1d — a software decoder. So the client told the host "send me
AV1" on machines that would then decode a 4K stream on the CPU, and codec
negotiation happens once at Welcome, so there is nothing to fall back to
afterwards. A promise the client cannot keep is worse than not making it.

`av1_hardware_decodable` answers from device facts only: the presenter's
Vulkan device listing DECODE_AV1 among its decode queue family's codec
operations, or — on Windows — the D3D11 import path, which is the same gate
the D3D11VA rung sits behind and that rung decodes AV1 Profile 0 today.

VAAPI is deliberately not consulted: asking libva costs opening a display,
and this is called too early and too often for that. The Vulkan bit covers
the Mesa devices where VAAPI AV1 exists in practice, and a machine with
VAAPI AV1 but no Vulkan AV1 loses the advertisement, not a working path.

The test pins what the gate must not accept: a device that decodes H.264 and
H.265 but lists no AV1 operation, and a device whose caps word claims AV1
while it has no decode queue at all.

Gates: macOS fmt/clippy, container clippy -D warnings over six crates, 805
tests, workspace check.
2026-08-06 19:27:24 +02:00
enricobuehler b7cd023643 feat(dxvadec): M7's DXVA AV1 conversion — a fourth way to name a reference
One AuPlan into DXVA_PicParams_AV1, over the layouts the SDK header measured.

AV1 on DXVA needs TWO reference arrays that mean different things at once,
and this program has now written down four spellings of the same question.
`frame_refs[7]` is indexed by reference NAME and each entry carries a
SURFACE index — where Vulkan's `referenceNameSlotIndices` carries a SLOT —
plus that reference's own global motion. `RefFrameMapTextureIndex[8]` is
indexed by SLOT and states the whole reference store, which is what
`RefFrameList` is for the other codecs and why a long-term reference no
frame names still has to appear in it.

The test asserts that difference is exercised rather than assumed: it fails
if the run never saw the store hold a picture the frame did not name, which
is precisely the distinction the Ally X class of bug lives in.

Three transpositions that would each have been silent:

Global motion is signalled per reference SLOT in the frame header and stored
per reference NAME in DXVA, so the conversion reads by one and writes by the
other. Carrying the Vulkan shape across would leave every warped reference
at identity.

CDEF strengths pack two fields to a byte, primary in the low six bits and
secondary in the top two, where the AV1 syntax keeps parallel arrays.

DXVA wants log2 of the loop-restoration unit size; the parser records the
size. And the superres denominator is the real one here — SUPERRES_NUM when
superres is off — where Vulkan's `coded_denom` is the denominator less nine.

Film grain rides only where the sequence enables it and the frame applies
it, its scaling points transposed into [value, scaling] pairs, and an
over-count refused rather than truncated: fewer points than the stream
declared is different grain, not less of it.

Gates: macOS fmt/clippy/349 tests, container clippy -D warnings over six
crates, 804 tests, workspace check.
2026-08-06 19:23:42 +02:00
enricobuehler 5d1556132e feat(dxvadec): M7's DXVA AV1 layouts, measured against Microsoft's own header
The blocker on this rung was never the code — it was that `dxva.rs` is the
most safety-critical file in the backend and nothing in it is type-checked
against Windows, so M5's layouts only became trustworthy once a libavcodec
byte capture had verified them field by field. AV1 turns out not to need
that capture: `DXVA_PicParams_AV1` ships in the Windows SDK's OWN `dxva.h`
(10.0.26100.0 and 10.0.28000.0 on .173), which is the declaration the driver
was compiled against and therefore outranks any mirror.

So `layout-probe-av1.c` is committed beside pf-vaadec's probe, compiled with
MSVC against that header on .173, and every number below came out of it:
DXVA_PicParams_AV1 is 912 bytes with alignment 1, PicEntry 36, Tile 16, and
each offset is a compile-time assertion. The nested blocks are asserted
through their own types too, so a wrong internal layout cannot hide behind a
right outer one. Every assertion passed on the first build, which is the
result worth having: the transcription and the compiler agree.

The bit-field words are measured, not assumed. C bit-field allocation order
is ABI-defined rather than standardised, so the probe sets one member at a
time and prints the word, and the tests check each packer against what MSVC
produced — tx_mode at bits 22-23, reference_frame_update at 26, film grain's
sixteen-bit word with matrix_coeff_is_identity at 12, and so on.

Two places AV1 puts things where the other two codecs would not, both now
written down where a conversion will read them:

Global motion is per REFERENCE, inside DXVA_PicEntry_AV1 — where Vulkan
hangs one global-motion block off the picture info. A conversion carrying
the Vulkan shape across would leave every warped reference at identity.

CDEF strengths are packed two-to-a-byte, primary in the low six bits and
secondary in the top two, where both the AV1 syntax and Vulkan's Std block
keep parallel arrays.

A zeroed block names NO reference: 0 is a valid surface index, so a
memset-style default would quietly point every unused reference at surface
0, which decodes, and decodes wrong.

Gates: macOS fmt/clippy/347 tests, container clippy -D warnings over six
crates, 802 tests, workspace check.
2026-08-06 18:26:02 +02:00
enricobuehler 83cfabda89 feat(vkdecode): M7's Vulkan CPU half — AV1 into the Std structures
The sequence header and the picture info, converted for
VK_KHR_video_decode_av1. Same shape as the H.264 and H.265 conversions, and
the same ownership contract: boxed backing beside the Std struct that points
at it, movable wrapper, no mutation, not Clone.

AV1 puts almost the whole frame header in the PICTURE info rather than in a
parameter set, so StdVideoDecodeAV1PictureInfo carries eight pointers to
per-frame blocks — tile info, quantisation, segmentation, loop filter, CDEF,
loop restoration, global motion, film grain — and the tile info carries four
more arrays of its own. Session parameters, by contrast, hold exactly one
sequence header. That asymmetry is why params_av1 is the small module here
and pic_av1 the large one.

The plan now carries the parsed frame header whole. The client needs a
digest — size, depth, colour, keyframe — but a backend needs nearly all of
the header, so AuPlan carries it the way its H.264 and H.265 siblings carry
their activated parameter sets: a backend builds from exactly what was
parsed, never by re-reading the access unit.

referenceNameSlotIndices holds DPB SLOT indices, not positions in the
reference list, and that is the HEVC RPS defect's exact shape in a narrower
place. Measured rather than argued: over the vendored vector the two
readings disagree 566 times across 274 frames, and the test fails if they
ever stop disagreeing, because then it would no longer be able to tell the
conventions apart.

Two places where transcription would have been wrong, both caught by the
types and then by asking the spec:

The parser's film-grain point arrays are 16 entries where the Std ones are
14 (luma) and 10 (chroma) — the spec's own maxima. The counts are validated
against the Std capacity and the copy is bounded by them; a stream declaring
more is refused, because a decoder handed fewer scaling points than the
stream declared synthesises different grain.

`coded_denom` is the superres denominator less SUPERRES_DENOM_MIN and only
meaningful where superres is in use, and `UsesLr` is derived — no frame
header codes it — from whether any plane's restoration type is not NONE.

Film grain rides only where the sequence enables it AND the frame applies
it, with the apply_grain flag set from whether a block is attached, so the
flag and the pointer cannot disagree.

Gates: macOS fmt/clippy/345 tests, container clippy -D warnings over six
crates, 800 tests, workspace check.
2026-08-06 18:00:59 +02:00
enricobuehler 7f83ec6c2f feat(bitstream): M7 begins — the AV1 planner, and it plans frames the stream hides
The third planner in this crate, and the foundation every AV1 rung will
consume. Same contract as its H.264 and H.265 siblings: an access unit in, a
plan out, with the vendored cros-codecs parser reading the bitstream and this
module owning the reference ledger, the output bookkeeping and the
concealment posture.

AV1's reference model is simpler than H.264's and entirely explicit — eight
numbered slots, `ref_frame_idx` naming what a frame reads and
`refresh_frame_flags` naming what it writes — so the planner is bookkeeping
rather than derivation, and a frame naming an empty slot is a lost reference
with no spec process that might legitimately have emptied it.

Two things measurement changed, both before a line of backend code depends on
them.

`plan_au` returns a VECTOR. An AV1 temporal unit may carry several frames,
and the vendored vector does: 250 units, 274 frames, 24 units carrying two.
Measured, those 24 extras are not `show_existing_frame` (there are none in
this vector) but HIDDEN frames — decoded, never displayed, referenced later.
A planner that took the last header in each unit would have decoded 250
frames and silently dropped 24 REFERENCES, and the damage would have
surfaced as missing-reference concealment on frames that were never damaged.

A picture is not removed until its LAST slot goes. One picture routinely
occupies several slots at once — a key frame refreshes all eight — so a slot
being overwritten does not mean its picture is gone. Reporting it removed
would free a surface under a live reference, which is precisely the shape
this program exists to catch. Tested directly, and asserted to report once
rather than once per slot.

What this does not cover is written down rather than left to be assumed: the
vector uses `show_existing_frame` zero times, so the display-only path and
its key-frame slot reset are exercised by no test here, and the test asserts
that count is zero so the day it changes the claim gets revisited.

Per-backend conversions are deliberately absent. Vulkan, DXVA and libva
disagree about what a reference list indexes — the disagreement that made
HEVC unplayable on every driver — so each belongs beside its siblings in
pf-vkdecode / pf-dxvadec / pf-vaadec, where its own convention is written
down and tested.

Gates: macOS fmt/clippy/344 tests, container clippy -D warnings over six
crates, 799 tests, workspace check.
2026-08-06 17:18:34 +02:00
enricobuehler a6e51215fd feat(client): M6's rung is wired — libva, dlopen'd, no libavcodec
The native VAAPI decoder now runs end to end: pf-vaadec's plans go into
libva's buffers, the surface comes back as DRM-PRIME dmabufs, and the
presenter imports them exactly as it does the FFmpeg rung's. Pin-only —
`PUNKTFUNK_DECODER=native-vaapi` — for the reason M5's D3D11VA rung was:
`auto` admission is earned with hardware parity and a soak, and this rung
has decoded nothing yet.

libva is dlopen'd rather than linked, so the pf-lxcheck2 container compiles
and clippies the whole thing without libva-dev, and a machine without a
VAAPI runtime gets a clean refusal instead of a packaging dependency.

The surface pool is not the slot map. `SlotMap::assign` hands out the lowest
free slot, and a slot freed by an access unit's own removals is free by the
time that unit's picture takes it — measured at 225 of the vendored vector's
250 access units. A surface bound by slot index would therefore decode, on
nine frames in ten, into the surface still holding the picture on screen. So
`plan_to_va` now takes the decode target as a parameter, bound by the caller
at activation time the way pf-vkdecode binds a pool image, and a surface is
free only when no live picture is bound to it, no output is owed for it, and
no consumer holds it.

Measured rather than transcribed, as everywhere else here: layout-probe.c
grew the export descriptor (312 bytes, objects[4]/layers[4]), the buffer-type
enumerators — VASliceParameterBufferType is 4 and VASliceDataBufferType is 5,
not the 3 and 4 that counting off the header suggests — and the config,
attribute and generic-value layouts. All pinned as compile-time assertions,
which is how the 12-byte VAGenericValue in the first draft was caught: the C
union holds a pointer, so it is 8-aligned and 16 bytes.

The plane walk lives in pf-vaadec, pure and unit-tested on macOS, because it
is the one structure the DRIVER writes and we read: SEPARATE_LAYERS returns
NV12 as two layers, and taking layers[0] is the green screen this project has
already paid for. It also refuses what it cannot express rather than guessing
— a bogus object count, a plane naming an object that is not there, objects
disagreeing on tiling.

Own DecodedImage variant, same payload type. The physical hand-off is
identical to the FFmpeg rung's, so the presenter keeps ONE arm and one
demotion streak; the variant exists so the compiler asks which rung decoded
wherever that matters. Both D3D11VA rungs share a variant and `1573a987` had
to fix the consequence afterwards — a "native" soak that could silently have
been an FFmpeg soak. Here the four uncovered matches were compile errors.

Buffers are destroyed by us, not by vaEndPicture: va.h is explicit that the
user must call vaDestroyBuffer, and the libva 0.x behaviour is long gone.
Leaking two per picture at 60 fps exhausts the driver's store in minutes.

pf-vaadec's presenter headroom was 4, written against no consumer. The Vulkan
rung had already measured the client pipeline at four to seven held frames;
it is 8 now, pinned to that crate's constant so a re-measurement moves both.

Gates: macOS fmt/clippy/341 tests/cargo doc, and in the container clippy
-D warnings over six crates, 795 tests, workspace check.

Hardware legs are still owed — no AMD/Mesa or Intel box was reachable.
2026-08-06 16:56:38 +02:00
enricobuehler 61b96c3837 feat(client): M6's HEVC conversion — the CPU half is complete
The H.265 twin of plan_to_va, and with it pf-vaadec covers both codecs end to
end from an AuPlan to the buffers a vaRenderPicture call carries. What remains
for the rung is the Linux-only plumbing.

HEVC differs from H.264 in four ways that each had to be got right rather than
assumed, and they are why this is a separate module instead of a parameter:

ReferenceFrames is 15 entries, not 16.

The reference sets are FLAGS, not arrays. There is no RefPicSetStCurrBefore
here: membership is ORed into each DPB entry's own flags. Vulkan wants slot
indices in identically named arrays, DXVA wants list positions in them, and
VAAPI wants neither — three spellings of one idea, and confusing the first two
is what made HEVC unplayable on every driver.

The per-slice lists are INDICES into ReferenceFrames, not pictures and not
surfaces. So the DPB array is built first and every list entry resolved
through it; a picture a slice names that is not in the marked DPB is a refusal
rather than something to paper over, because there is nothing to fall back to.

The offset is in BYTES. slice_data() is byte-aligned by byte_alignment(), so
header_bit_size / 8 is exact — and a header that is not a whole number of
bytes is an error rather than a rounded offset, which would decode garbage
from the first inter picture.

Two conversions that are NOT copies, and would have been silently wrong as
copies: libva takes the derived ChromaOffsetLX (equation 7-56) where the
parser stores the coded delta, so putting the delta there would tint every
weighted-predicted block; and only 32x32 matrixIds 0 and 3 exist, where the
parser keeps six slots. The IQ matrix is Optional and gated on
scaling_list_enabled_flag for the reason review round 13 found on the DXVA
side — a driver MUST apply what it is handed, so a table of parser defaults
dequantises every residual to zero.

The weight table is only filled where 7.3.6.1 says one is coded, and the
chroma denominator is clamped into a legal shift so a malformed stream cannot
panic a decode thread.

Tests walk both HEVC vectors — the 250-frame 8-bit one and the 50-frame
Main 10 one, so a depth field wired to a constant would show — asserting per
slice that the start code was trimmed, the byte offset is inside the slice,
and every used list index points at a DPB entry that is actually valid. Per
picture it asserts that exactly the three current sets carry RPS flags and
nothing else does, and the walk fails if it never saw an RPS flag or a
reference at all, so it cannot pass vacuously.
2026-08-06 15:15:04 +02:00
enricobuehler 6c379f8fef feat(client): M6's HEVC layouts, and a third way to spell a reference set
The HEVC twin of pf-vaadec's H.264 buffer layouts, measured the same way: the
committed probe extended to cover va_dec_hevc.h, every size and offset read
off real libva 2.23.0 headers and pinned as const assertions —
VAPictureHEVC 28, VAPictureParameterBufferHEVC 604,
VASliceParameterBufferHEVC 264, VAIQMatrixBufferHEVC 1016 — and every
bit-field position read back out of a real header rather than counted by eye.

The finding worth carrying: HEVC's reference plumbing is a THIRD convention,
and this program has now been bitten by confusing two of them.

  Vulkan takes DPB SLOT indices in RefPicSetStCurrBefore/After/LtCurr.
  Writing reference-list positions there is what made HEVC unplayable on every
  driver until it was root-caused.

  DXVA takes positions into RefPicList[] in identically named arrays.

  VAAPI takes neither. It marks set membership as FLAGS on the DPB entries
  themselves — VA_PICTURE_HEVC_RPS_ST_CURR_BEFORE / _AFTER / _LT_CURR — and
  its per-slice RefPicList[2][15] holds INDICES INTO ReferenceFrames, not
  pictures and not surfaces.

Three spellings of one idea, identical names on two of them, and different
referents on all three. The conversion will say which it is writing, every
time, and the docs now hold all three side by side.

Two more asymmetries with the H.264 side, recorded where they will be read:
ReferenceFrames is 15 entries here, not 16; and the offset is
slice_data_byte_offset — BYTES, where H.264 wants bits — over the same
definition. slice_data() is byte-aligned by byte_alignment(), so the parser's
header_bit_size / 8 is exact rather than rounded, which the conversion will
assert rather than assume.

Tests cover the probe's measured bit patterns plus a disjointness sweep over
every field of pic_fields and slice_parsing_fields — two probe vectors per
word would not catch a shift typo that overlapped two neighbours, and these
words are 20 and 14 fields wide.
2026-08-06 15:09:07 +02:00
enricobuehler 7a31f1089e feat(client): M6's conversion half — one AuPlan into libva's buffers
The second half of pf-vaadec: picture parameters, inverse-quantization
matrices and one slice-parameter record per slice, over the same transaction
discipline pf-dxvadec uses — validate, resolve references against the
PRE-removal slot map, then apply removals and assign the setup slot last. A
half-applied DPB update is the shape of a corrupt reference, so nothing
mutates until every fallible step has passed.

Three things VAAPI wants that neither other backend does, all of which the
existing plan already carries:

A bit offset. slice_data_bit_offset is where slice_data() begins, counted from
and including the NAL header byte with emulation-prevention bytes removed —
DXVA takes a byte offset, Vulkan takes nothing. It costs no new parsing: the
vendored parser records exactly that as SliceHeader::header_bit_size, because
cros-codecs' own production backend is VAAPI.

The slice data without its start code, since that offset is relative to the
NAL header byte. SlicePlan::data is start-code-inclusive and the prefix is
three OR four bytes — the host emits four on every access unit — so it is
measured per slice rather than assumed. Assuming it is the defect that made
HEVC unplayable on every driver.

The per-slice reference lists. DXVA's short-format slice control expresses no
lists at all; VAAPI wants RefPicList0/1 in 8.2.4.2 order, which is what the
plan's derived lists already are.

And the distinction that cost M5 a defect, now written down in a third place:
reference_frames is documented "in DPB", the same statement DXVA's
RefFrameList makes and the opposite of Vulkan's pReferenceSlots. It is filled
from the marked-DPB snapshot; the per-slice lists come from the slice's own.
Getting that backwards loses a long-term reference no slice happens to name.

Weight tables follow 7.3.3's presence rule rather than being copied
unconditionally: flagged only where the PPS actually enables explicit
weighting for that slice type and list. Flagging them otherwise hands the
driver defaults as though the stream had coded them. The vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32] — an upstream inconsistency, not a semantic one — so the narrow side
widens.

Envelope refusals are errors, never silent narrowings: slice groups, separate
colour planes, a capacity mismatch, a reference holding no slot, lists past
their array bounds, a slice range outside its access unit.

Tests: 15. The one that matters walks all 250 access units of the vendored
conformance vector through H264Planner and this conversion, asserting per
slice that the range lies inside its access unit, that the declared size
matches it, that the start code really was trimmed, and that the header
neither is zero bits nor outruns the slice — plus that reference_frames
carries exactly as many valid entries as the marked DPB and every entry past
it is invalidated. It also asserts it saw a multi-slice picture and a
non-empty reference set, so a splitter bug cannot make it vacuous. Gates:
rustfmt, clippy, cargo doc with no unresolved links, and the container's
clippy -D warnings, tests and workspace check.
2026-08-06 14:22:33 +02:00
enricobuehler 509843f0d4 test(client): the D3D11VA rung's ten-bit path, measured too
The companion to the Vulkan ten-bit leg, over the same vector and the same
P010 goldens — one golden file serves both rungs because a D3D11 P010 surface
and Vulkan's 3PACK16 family hold the ten bits in the same place.

This is the rung where the gap mattered most. D3D11VA exposes no per-picture
status query at all, so its HDR evidence was a session that built a Main10
decoder and streamed without complaint — which is precisely what a Main10
stream decoding to garbage would also produce. Now there is a number.

It exercises geometry the eight-bit legs cannot reach: P010 samples are two
bytes, so a row is width * 2 rather than width, and HEVC's 128-line granule
pads a 240-line picture to a 256-line surface — so the chroma plane starts a
long way from where the display height alone would put it. Getting either
wrong is the smeared-rows failure this project has already paid for once, and
it would have looked like a decoder fault.

The run body now takes the stream format and the expected access-unit count
rather than assuming the eight-bit envelope and 250 frames.

A CPU guard pins the vector at ten bits — 4:2:0, both depths minus8 == 2,
320x240, 50 access units. A regenerated eight-bit vector would otherwise turn
this into a second run of the eight-bit path under a ten-bit name, passing,
because its goldens would have been regenerated with it.

Hardware: HEVC Main 10 50/50 bit-identical on the RTX 4090 and on the AMD
Radeon iGPU, alongside the unchanged eight-bit legs at 250/250 on both. With
the Vulkan leg's two drivers that is four independent drivers across two
rungs for the ten-bit path, where yesterday there were none.
2026-08-06 13:53:23 +02:00
enricobuehler c91a482b4e test(vkdecode): the ten-bit path finally has pixels
Every golden set in this program was eight-bit. So the strongest thing anyone
could say about ten-bit decode was that a Main10 session BUILDS and streams
clean — which is not the same claim, and is exactly the shape of claim this
program has been burned by. A Main10 stream decoding to garbage logs just as
cleanly: HEVC Main10 on D3D11VA has no per-picture status query at all, and on
the Vulkan side the devices that matter report queryResultStatusSupport=false.
The HDR legs were measuring that the pipe ran, not that the pixels were right.

So: a Main10 vector and its goldens, and a ten-bit leg that runs them.

The vector is 50 frames of 320x240 HEVC Main 10 4:2:0 from libx265 — 48 KB,
generated by a command recorded in the golden file's header along with
everything else needed to regenerate it. The goldens come from libavcodec's
software decoder and were cross-checked between two independent builds on two
architectures (ffmpeg 8.1.1 Homebrew/macOS-arm64 and 8.0.1 Ubuntu/x86_64),
which agreed on all 50.

The goldens are P010, NOT yuv420p10le, and that distinction is the whole
reason this could have quietly gone wrong: P010 puts the ten bits in the HIGH
bits of each little-endian 16-bit word with the low six zeroed, which is what
a D3D11 P010 surface and Vulkan's G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16
both contain. Hashing LSB-aligned samples against MSB-aligned ones would fail
on every frame on every driver, for a reason that has nothing to do with
decoding. One golden file therefore serves both native rungs.

The readback is now depth-aware. Its only eight-bit assumption was the second
region's buffer_offset, which is a BYTE offset where the extents around it are
TEXELS — that plus the buffer size are the whole change, because
bufferRowLength = 0 already packs rows at the copy extent. The expected pool
format moved onto the readback so the sizing and the per-frame assertion come
from one source; a readback sized for eight bits that then accepted a ten-bit
frame would hash half a picture and blame the decoder.

A CPU guard asserts the vector really is ten-bit — 4:2:0, both depths
minus8 == 2, 320x240, 50 access units, 50 planned outputs. Without it a
regenerated eight-bit vector would turn the ten-bit leg into a second run of
the eight-bit path wearing a ten-bit name, and it would PASS, because its
goldens would have been regenerated alongside it. That guard is not ignored,
so it runs on macOS and in the container rather than only on the fleet.

Hardware: HEVC Main 10 50/50 bit-identical on NVIDIA 610.43.03 (Linux) and on
the Steam Deck's RADV/VanGogh — first run on both, which also confirms the
P010/3PACK16 layout match rather than assuming it. The four eight-bit legs are
unchanged and still green on both boxes.
2026-08-06 13:32:31 +02:00
enricobuehler d3e000768d feat(client): M6 begins — the libva layouts, measured rather than transcribed
The VAAPI rung's crate, in the shape the other two native rungs established:
everything that can be a pure decision or a pure conversion lives in a
cross-platform crate the ordinary gates run, and only the parts that genuinely
need a device stay behind a platform cfg. This lands the first half of that —
the buffer layouts and the decoder-creation decisions — with the conversion to
follow.

Route: minimal FFI rather than cros-libva. The plan of record permits either
("cros-libva (or minimal FFI)"), and hand-declaring keeps the crate building
and testing on macOS and in the Linux container, which is the property that
made pf-dxvadec's defects findable on a laptop instead of on a box.

The layouts are not eyeballed. A C probe compiled against real libva 2.23.0
headers printed sizeof/alignof/offsetof for every field and set individual
bit-fields to read the resulting word back; those numbers are pinned as const
assertions, so a transcription slip is a compile error rather than a driver
reading the wrong byte. The probe is committed beside them, with the command
that runs it, because evidence that cannot be re-run is a claim.

What the probe settled that a reader would otherwise get wrong: VAPictureH264
is 36 bytes and is embedded 81 times across the two buffers, so its size is
load-bearing for every later offset; the three DEPRECATED FMO fields still
occupy bytes 624..628, and dropping them would shift everything after; and C
bit-fields allocate from the least significant bit on this ABI — proven, since
that is ABI-defined rather than standardised.

Groundwork for the conversion, established here so the next work package
starts from facts:

slice_data_bit_offset needs no new parsing. VAAPI is the only backend that
wants a bit position — DXVA takes a byte offset, Vulkan takes none — and the
vendored parser already records exactly it as SliceHeader::header_bit_size,
computed as (nalu.size - epb) * 8 - bits_left: from and including the NAL
header byte, emulation-prevention bytes removed. That is the field's
definition verbatim, and it is there because cros-codecs' own production
backend is VAAPI.

The slice data buffer starts at the NAL header byte, so the start code is
skipped — SlicePlan::data is start-code-inclusive and the prefix is three OR
four bytes, the host emitting four on 100% of access units.

reference_frames is the marked DPB, the same statement DXVA's RefFrameList
makes, so it comes from the dpb_refs snapshot; Vulkan's pReferenceSlots is the
opposite and takes the access unit's own set. All three conventions now have a
written home, which is the distinction that cost M5 a defect.

Unlike DXVA short-format, VAAPI wants the per-slice reference lists and the
full prediction weight tables inline — hence the 3128-byte slice record. One
wrinkle recorded rather than left to be discovered: the vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32], and libva wants i16 for both.

Profile selection resolves H.264 to High for every 8-bit 4:2:0 stream instead
of reading profile_idc, because High is a superset for the tools our hosts
emit and picking Main for a stream that turns out to use 8x8 transforms is a
mid-stream failure where picking High is not. 4:4:4 and 10-bit H.264 are
refused rather than narrowed to an 8-bit profile — that class of silent
narrowing decodes to garbage instead of failing.

11 tests: the probe's bit patterns, a disjointness check per bit-field word
(two probe vectors alone would not catch a shift typo that overlapped two
fields), and the envelope refusals. Gates: rustfmt, clippy, cargo doc with no
unresolved links, and the Linux container's clippy -D warnings, tests and
workspace check.
2026-08-06 13:04:07 +02:00
enricobuehler 1573a9876b fix(client): a d3d11va soak could not tell which rung it soaked
The `stats:` line's decode-path tag is derived from the DecodedImage variant,
and both D3D11VA rungs deliver DecodedImage::D3d11 — they share the hand-off
ring on purpose. So a native-d3d11va session and an FFmpeg-d3d11va session
emitted a byte-identical tag, and nothing downstream could tell them apart.
The native Vulkan rung never had this: it carries its own variant, hence its
own `native-vulkan` tag.

That is not cosmetic, and it was found the only way it could be — by running
the rung on glass and having to grep the log to prove which one had answered.
A native pin that fails to initialise falls through to the FFmpeg rung by
design; the line it then emits is exactly the line the native rung would have
emitted. M5's owed soak and M9's vendor-matrix bake both rest on attributing
a session to a rung, and until now the machine-readable half of that evidence
could not do it. This project has already shipped one measurement that could
not tell "clean" from "unmeasured"; this is the same shape.

D3d11Frame now records which rung wrote the surface, keyed off the pin
constant itself rather than a second field the two rungs could set
inconsistently — the native rung passes DECODER_PIN into the hand-off and
nothing else does.

The stats line stays additive for every shipping session: the only value that
changes belongs to a rung that is pin-only and deliberately absent from the
automatic ladder, and the Windows shell passes the line through opaquely
rather than matching on the tag.

Verified on glass on .173, both directions: pinned native-d3d11va gives 85
windows tagged `native-d3d11va` and 0 plain, pinned d3d11va gives 64 plain and
0 native, zero errors either way. Gates: clippy -D warnings on Windows, the
Linux container's clippy/tests/workspace check, rustfmt.
2026-08-06 12:34:31 +02:00
enricobuehler 3041d9bb43 test(client): M5's decoded pixels now answer to libavcodec's
The native D3D11VA rung had no pixel evidence at all. Its DXVA bytes were
checked against libavcodec's own captured bytes, and its Intel bring-up proved
the driver accepts the submission — but nothing had ever compared what came
out. This is that comparison, against the same goldens and the same reference
the Vulkan rung was held to: libavcodec's SOFTWARE decode, which is ground
truth rather than a peer implementation, so the two rungs' verdicts are now
directly comparable numbers.

It reads back the DECODE surface, before the VideoProcessorBlt, so what is
hashed is the half this rung is responsible for; the hand-off is the shared,
field-proven half and is deliberately not in the measurement.

Finding, recorded rather than papered over: this rung presents in DECODE
order. It never consults AuPlan::dpb.outputs — submit blits setup_slot and
returns. The native Vulkan rung keeps a display-order queue for exactly that
reason, and libavcodec's D3D11VA rung reorders internally, so this rung
differs from both. It cannot bite on punktfunk streams, which are zero-reorder
and carry no B pictures, but that is a convention of our hosts rather than a
structural guarantee, and a stream that did reorder would present out of order
with nothing to say so.

Both vendored vectors DO reorder — the H.265 one's first B picture at AU 3 is
what localised the RPS slot defect — so a harness hashing in decode order
would report a permutation against display-order goldens and read like a
decoder fault. Instead each decoded surface is hashed against the PicId the
planner gave it and the hashes are emitted in the planner's own output order.
The reordering is the test's, done by the planner the rung already trusts, and
`both_vendored_vectors_really_do_reorder` asserts the reason so the docs
cannot go stale silently.

The crop reads the chroma plane at RowPitch * texture height, not display
height: the decode pool is aligned to the codec's granule and is taller than
the picture. That is the 1088-row smear this project has already paid for.

Two CPU guards run in ordinary CI. This file needs its own Annex-B splitter
(pf-client-core does not depend on the vendored parser), and a splitter that
disagreed with pf-bitstream's would fail on hardware as a frame-count mismatch
that reads like a decoder defect; instead it fails on CPU, saying so.

PF_DXVA_ADAPTER pins a GPU by description substring and every run prints the
adapters it saw — .173 enumerates its AMD iGPU alongside the 4090, and which
one answered is a fact worth printing rather than inferring.

Hardware: H.264 and H.265 both 250/250 bit-identical on NVIDIA GeForce RTX
4090 and on the AMD Radeon iGPU, Windows. Gates: clippy -D warnings and the
lib tests on Windows, the Linux container's clippy/tests/workspace check, and
rustfmt.
2026-08-06 12:17:24 +02:00
enricobuehler 5d0b269e58 test(vkdecode): parity over the start-code form the host actually emits
Both vendored vectors carry three-byte Annex-B start codes throughout. The
real host emits four-byte ones on 100% of access units in both codecs —
1514/1514 H.264 and 1133/1133 HEVC, measured off the M0 NVENC corpus through
the capture hook's own .idx offsets. So every parity verdict this program has
recorded was taken on a prefix form that never ships, and the one form that
does ship was exercised by nothing.

That gap is not hypothetical. Submitting four-byte start codes to
vkCmdDecodeVideoKHR unchanged is exactly what made HEVC unplayable on every
driver tested: drivers are validated on the three-byte form, and a fixed
+3 + 2 skip into a four-byte-prefixed slice reads a nonsense pps_id — the
115 and 119 both NVIDIAs printed. H.264 was never safe here by structure,
only by its vendored encoder's convention, which is why the cure lives in the
shared ring layer and why this coverage is generic over both codecs.

Each codec's parity body now takes its access units as a parameter and runs
twice: once over the vector as it sits, once over the same vector rewritten
to four-byte prefixes. Prefix width carries no information, so both runs must
reproduce the same goldens — sharing one body is what makes that an equality
rather than two assertions that can drift.

The rewrite copies nalu.data[nalu.offset..], the same nal_size bytes the
parser hands the planner, so trailing_zero_8bits are dropped exactly where
the production parser drops them: the only difference between the two streams
is the width of every prefix.

Two CPU guards keep the new legs from passing vacuously, which is the failure
mode they are most exposed to — a rewrite that quietly returned its input
would make them trivially green and nothing on the fleet would notice. They
assert the original really does carry three-byte prefixes, that the rewritten
stream carries none, that the NAL count is preserved exactly, and that the
planner still yields 250 pictures.

Hardware: all four legs 250/250 bit-identical to libavcodec on two
independent driver stacks — AMD VanGogh on RADV/Mesa 26.0-devel (the Steam
Deck) and NVIDIA 610.43.03 on Linux. NVIDIA is the family that rejected the
four-byte form outright, so it is the meaningful witness for this regression.
2026-08-06 12:02:03 +02:00
enricobuehler db15c2615d fix(vkdecode): HEVC decoded from the wrong references, and told drivers the
wrong slice offsets

Two independent defects, both in this crate. pf-bitstream is untouched — its
HEVC plans were sound all along, which the D3D11VA rung proves by rendering
correctly from the same AuPlans.

The corrupter: StdVideoDecodeH265PictureInfo's RefPicSetStCurrBefore,
StCurrAfter and LtCurr carry DPB SLOT indices. We wrote positions in the
reference list. libavcodec's Vulkan HEVC hwaccel — the implementation every
driver is validated against — writes the index into its own DPB array and
passes that same value as slotIndex, while packing pReferenceSlots densely
over the used entries; the two numberings are provably different there, and
the RPS arrays follow the slot.

The two readings coincide on a freshly anchored stream, because the references
then occupy slots 0..n in reference-list order. They first diverge at the
vendored vector's first B picture, AU 3, where refs are slots 0, 2, 1 — so we
named slots 1 and 2 where the picture wanted 2 and 1, and every later access
unit inherited the error through its own references. That is why this shipped
and why review could not see it: correct for the opening pictures, wrong from
the first reordering onward.

It also accounts for the measurements exactly. Display order maps to decode
order as display 0 from AU 0, display 3 from AU 1, display 2 from AU 2,
display 1 from AU 3 — so the three frames that matched on AMD are precisely
the three access units where positions and slots agree, and 250 - 3 = 247 is
the divergence count that was measured. From AU 6 the named slots stop being
merely wrong and become unbindable by that operation, which is where NVIDIA
stopped reporting a verdict at all.

The diagnostic: every slice offset must point at a THREE-byte start code.
libavcodec discards the stream's prefix and writes 00 00 01, so that is the
only pattern drivers are validated on, and pf-dxvadec's packer already
normalised for exactly this reason and said so in its docs. This path uploaded
the prefix verbatim. 249 of 250 HEVC slice segments in the vendored vector
carry a four-byte prefix; all 500 H.264 slices carry three.

That derives the driver's own complaint bit for bit. A decoder reaching the
slice header by a fixed skip lands on the NAL header's second byte, reads
first_slice_segment_in_pic_flag as 0, and then takes six bits of the real
slice header as the tail of a long ue(v): 0xd0 gives 115, 0xe0 gives 119. A
P-slice header and a B-slice header — which is why exactly two bogus pps_id
values ever appeared.

⚠ H.264 was NOT protected structurally, only by its encoder's convention, and
the real host does not share that convention: every one of 1514 H.264 access
units and 1133 HEVC access units captured from an NVENC host prefixes its
slices with FOUR bytes. The vendored H.264 vector is therefore not
representative of what ships, and its bit-exactness was passing on a prefix
form the field never sends. The normalisation lives in the shared ring layer
and covers both codecs for that reason.

rebased_offsets is replaced by pack_slices, which trims the leading zero byte
and computes the offsets from the trimmed lengths in one call, so the bytes and
the offsets cannot drift apart; upload and the CPU test go through the same
pack_into.

Hardware, after the fix — H.264 AND H.265 both 250/250 bit-identical to
libavcodec, all four smoke legs green:

  NVIDIA RTX 4090      610.88     Windows   coincide
  AMD Adrenalin        25.10.30.02 Windows  distinct
  NVIDIA RTX 5070 Ti   610.43.03  Linux     coincide

On glass on the 4090 against a real NVENC host, 2800x1260 HEVC through the
auto ladder: 73 one-second windows all native-vulkan, fps avg 59.3 of 60,
decode 1.1 ms, e2e 4.5 ms p50, and ZERO driver-reported status failures where
the same session before the fix logged 1489 in 181 seconds and had dragged ABR
down to a 5 Mb/s target. No refusals, demotions, PlanWarnings, concealment,
DEVICE_LOSTs or panics.

Both defects now have CPU tests that were confirmed FAILING before the fix:
one walks every access unit of both vendored vectors and asserts each declared
offset opens on a three-byte start code, its own NAL header and a
first_slice_segment_in_pic_flag consistent with the segment index; the other
resolves every RPS entry by slot and asserts that 247 access units disagree
with the positional reading, so it cannot go vacuous on a stream where the two
happen to agree.
2026-08-06 09:56:25 +02:00
enricobuehler 5c6b09a5c5 test(vkdecode): the HEVC GPU legs, which find M3 broken on every driver
M3 was recorded as code complete. Its exit criteria named the HEVC gpu_smoke
and gpu_parity legs, and the goldens for them were committed — 250 per-frame
NV12 hashes, cross-checked between two independent FFmpeg builds, with a
header saying they are "consumed the same way by the HEVC parity test". No
such test existed. Both GPU files were H.264 only, with zero references to
h265, so nothing had ever decoded a single HEVC frame through this crate on
hardware.

They exist now, and the first run answered. On AMD Adrenalin 25.10.30.02
(distinct mode, queryResultStatusSupport=false) 247 of 250 frames diverge
from libavcodec, and that device's smoke leg PASSES — because smoke only
reads the driver's verdict and that driver reports none. That is the Ally X
class, reproduced in-house on demand: output that is wrong everywhere the
picture is looked at and clean everywhere the decoder is asked. Both NVIDIA
drivers reject the stream outright and name the cause themselves,
"Invalid PPS/SPS id in slice header (pps_id=119 / 115)" — the identical two
values, and the smoke leg dies at the identical AU 9, on a 4090 under 610.88
on Windows and on an RTX 5070 Ti under 610.43.03 on Linux. Same wrong values,
same access unit, two GPU generations, two operating systems: deterministic,
and therefore ours rather than any driver's.

It is not an ordering fault. Five of the divergent hashes appear nowhere in
the 250 goldens, so the pixels are wrong rather than correct-but-reordered.
Parity dies at frame 1 while smoke dies at AU 9 only because smoke holds four
frames before it looks; the first inter-predicted picture is already corrupt.

The legs are committed ahead of the fix deliberately. They are the regression
test for the defect, they are #[ignore]d so no CI leg changes colour, and the
evidence above is worth recording in the order it was obtained.

Adding a third and fourth copy of ~150 lines of unsafe Vulkan bring-up was
not acceptable, so it moved to tests/common. The two behavioural differences
between the callers are now named parameters rather than accidents: the parity
legs read back on a graphics queue and require one, while the smoke legs
accept a decode-only device and fall back to the decode family — which also
decides whether pool images are EXCLUSIVE or CONCURRENT, so it is load-bearing
rather than cosmetic. H.264 came through the refactor unchanged, verified two
ways: argument-by-argument against the previous file, and on hardware, still
250/250 bit-identical on NVIDIA Windows, AMD Windows and now NVIDIA Linux.

The loader is deliberately leaked at teardown. ash::Entry owns the Arc<Library>,
so dropping it unloads the Vulkan loader with every ICD and implicit layer;
harmless while each binary held one GPU leg, but each now holds two, and the
second would re-open a loader the first had torn down.

Three guards run without a GPU, because everything above is #[ignore]d: the
golden file's count and digest shape, the HEVC access-unit split agreeing with
what the CPU planner emits (with iraps == 1 pinning "no CRA anywhere", so a
re-synced vector that opens with one fails here rather than as a frame-count
mismatch on the fleet), the vector staying Main 4:2:0 8-bit since both legs
hard-code that probe, and a refusal to run the smoke legs with
PF_VKD_TEST_READBACK set, which would quietly grow the pool a usage flag
production never carries.
2026-08-06 09:47:30 +02:00
enricobuehler 9c10b97e31 fix(client): M5's DXVA bytes now answer to libavcodec's own
The native D3D11VA rung was committed with zero hardware evidence and, more
to the point, zero evidence of any kind: every claim it made about the DXVA
structures rested on reading the specification and reading libavcodec, and
reading is exactly the method that produced the four defects the last review
found. Three of those lived where a smoke test cannot look — in the buffer
descriptors — so a passing session would have proved nothing about them.

So this compares our submission against libavcodec's, byte for byte, on the
same access units of the same two vendored vectors. The reference side comes
from an FFmpeg n8.1 cross-built for Windows with the DXVA paths instrumented
(the recipe is in the harness docs); the comparison covers picture parameters,
quantisation matrices AND the buffer descriptors, 250 AUs per codec:

    H.264 / HEVC picture parameters    250 AUs, no undocumented divergence
    H.264 / HEVC quantisation matrices 250 AUs, no undocumented divergence
    H.264 / HEVC buffer descriptors    250 AUs, no undocumented divergence

It found a real defect immediately. The DXVA short slice record is TEN bytes
— dxva.h packs these bitstream-layout structs to a byte — and this crate
declared it `repr(C)`, which pads {u32,u32,u16} to twelve. libavcodec's own
descriptors say so twice over: 20 bytes of slice control for a two-slice
H.264 picture, 10 for a one-slice HEVC one. Record 0 survives either way
(its fields sit at 0/4/8 regardless), so the mistake is invisible on a
single-slice stream and displaces every later record by two bytes on a
multi-slice one — which punktfunk hosts emit. Both records are now
`repr(C, packed)`, and the HEVC slice-control test grew a second record
because one record is the shape that hid this.

The audit that followed matters more than the fix. Per-field offset asserts
cannot see TAIL padding, which is what this was, so all six hand-declared
structs now also assert that their size equals the last field's offset plus
that field's own size. Under that rule the slice records were the only place
packed and natural alignment disagree — 1040, 232, 224 and 1000 were right
all along, and now provably rather than luckily. The module docs claimed
`repr(C)` "reproduces MSVC's default packing exactly for that shape"; that
was a guess wearing a proof's clothes, and it is gone.

Two differences are documented rather than fixed, each with the argument for
why it is inert. libavcodec seeds prev_poc_msb = 1 << 16 at every IDR, so its
POCs are the specification's plus 65536 uniformly; every use a driver makes
of those fields is a difference, and references match on FrameNumList, so the
harness compares POCs relative to that constant and requires it to hold on
every AU rather than importing a magic number into a derivation the Vulkan
rung shares. And HEVC's loop_filter_across_tiles_enabled_flag is inferred 1
by 7.4.3.3.1 when the PPS codes no tiles while libav leaves it 0, with tiles
disabled either way. Both ride a channel that always prints, and both are
guarded by tests that synthesise the differences an allowance must NOT
absorb — a documented divergence that swallows a real defect would be worse
than no harness at all.

Everything checkable without a capture is now a non-ignored test: the buffer
set and order per codec, NumMBsInBuffer's codec asymmetry (mb_width*mb_height
on H.264's bitstream and slice-control buffers, zero everywhere for HEVC),
the three 7.4.5 scaling-list cases, contiguous slice records tiling DataSize,
the 128-byte padding charged to the last record and no other. That is the
part which would have caught the last round's defects with no hardware at all.
2026-08-06 09:46:53 +02:00
enricobuehler 31087697a9 feat(client): M5 — native D3D11VA decode, pin-only pending hardware
The Windows fallback rung, and auto's first choice on Intel, now has a
native implementation driven by pf-bitstream's plans instead of
libavcodec. New crate pf-dxvadec holds everything that can be a pure
function — the DXVA structure layouts, both codec conversions, bitstream
packing, config selection — deliberately CROSS-PLATFORM, because a
cfg(windows) module is verified by a remote cargo check and nothing
else, and this milestone's riskiest code is exactly the part no local
test can see. Only the FFI lives in video_d3d11_native.rs.

windows-rs does not generate dxva.h at the pinned rev, so the DXVA
structures are hand-declared: compile-time assertions on every struct
size AND every field offset, packed bitfield words as plain integers
with named builders and the bit positions written beside the C
declaration, and a const zeroed() per struct so construction needs no
unsafe at all. The crate's only unsafe is a sealed byte view over those
PODs. Review round 13 checked all seven layouts field by field in
declaration order — sizes, widths, array lengths, the PicEntry
index/flag packing, and every named bit's position and width.

The decode pool reproduces libavcodec's rather than inventing one:
ONE texture with ArraySize = pool size, BIND_DECODER and nothing else,
MiscFlags 0, aligned 16 for H.264 and 128 for HEVC. That is deliberate.
This rung's predecessor records that a hand-built pool which validated
on NVIDIA was rejected by Intel at the first SubmitDecoderBuffers — and
Intel is the vendor this rung exists for. The VideoProcessorBlt into
shareable RGBA is untouched: importing a multiplanar NV12 D3D11 texture
into Vulkan device-losts on NVIDIA, so that hand-off is load-bearing
field-proven code. It was extracted into a shared HandoffRing so both
rungs fill one implementation; the review diffed the blit statement by
statement, including the keyed-mutex pairing.

Review round 13's four defects are fixed. The blocking one: the HEVC
quantisation matrix was submitted unconditionally, and the vendored
parser leaves it ALL ZEROS unless the stream codes one — unlike FFmpeg,
which seeds the spec defaults. On a stream saying 'use the default
matrices' the driver is obliged to apply what it is handed, so every
residual would dequantise to zero and the picture would drift to flat
prediction. It is now gated on scaling_list_enabled_flag exactly as
libav gates it, with the Table 7-5/7-6 defaults supplied when enabled
but uncoded.

Second: NumMBsInBuffer was 0 where libav's H.264 path sets
mb_width * mb_height. This module's whole method is verbatim
reproduction on precisely the call that once failed for Intel, so an
omitted descriptor field is the same class of bug as the pool.

Third, and the one to watch on hardware: RefFrameList carried the
frame's reference set rather than the pictures marked used for
reference. Vulkan defines pReferenceSlots as the slots this operation
uses, so a subset is correct there; DXVA defines RefFrameList as a
statement about the DPB. The list DERIVATION survives a subset — which
is exactly why a smoke test would have passed — but a long-term
reference held across frames that none of them name would vanish and
reappear, and a driver keeping per-reference state is entitled to
discard it in between. That is the Ally X symptom shape. pf-bitstream
now exposes a per-AU DPB snapshot for both codecs and the converters
build the array from it, frame references first, marked tail appended.
121 of the 250 vendored AUs carry a marked picture the frame never
names, so this is exercised, not theoretical.

Fourth: the session identity omitted bit depth and chroma, while the
Windows host flips an HDR desktop to PQ in-band with a new SPS — a
depth change at unchanged size would have decoded 10-bit samples into
an NV12 pool. Identity now derives from the SPS per AU and rebuilds.

Wired PIN-ONLY (PUNKTFUNK_DECODER=native-d3d11va), absent from every
auto arm. Nothing has decoded a frame yet, and M2's discipline was that
auto admission comes only after hardware parity. A runtime streak
demotes to the FFmpeg D3D11VA rung first, then software.

Also scaffolded: a byte-diff harness against libavcodec's own DXVA
picture parameters, with the FFmpeg patch and capture recipe in its
docs. Nothing here is checked against libav's actual bytes the way M3
was checked against its pixels, and that is the cheap way to buy the
confidence before hardware.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + pf-dxvadec +
punktfunk-core; tests 73/131/63/129/354 green; cargo check --workspace
clean; Windows cargo check and clippy -D warnings clean on .173.
2026-08-06 06:11:21 +02:00
enricobuehler 2a57ee36f8 feat(client): M4 — the decoder's own verdict reaches the session
This program exists because a field corruption was architecturally
undetectable through FFmpeg: no decode-status read, no corrupt-frame
flag, errors only as scraped log lines, and no recovery-point signal so
intra-refresh healing was invisible. The native decoder has all of those.
M4 is where they stop being internal.

DecodeHealth counts, per session and without allocating per frame, what
the three answers actually are: damaged (the stream arrived incomplete),
refused (the rung would not decode it at all) and driver-failed (the
hardware says it could not decode what arrived), plus the current and
worst concealment run — the figures that separate one bad AU from a
stream that never came back. They ride the stats line additively, so an
FFmpeg session and a healthy native session emit byte-identical output
to today. The status-query capability is reported too: without it a
clean report cannot be told from an unmeasured one, which is the whole
nb_queries=0 lesson.

The headline is local recovery. Until now the pump could only learn that
intra-refresh healing finished from wire flags the host sends; absent
those it froze until the 500 ms backstop forced an IDR. The parsed
recovery-point SEI now feeds the re-anchor gate directly, so a session
lifts on the picture that is actually clean. Wire semantics are
untouched for every client that never calls it.

Detection now asks for recovery instead of erroring — an integrity
warning ticking the error streak would demote the native rung on exactly
the lossy links it exists to diagnose, where an FFmpeg rung conceals
silently and keeps its job.

Review round 12 found that trade had removed the escape hatch entirely.
Concealment returning Ok(None) reset the demotion streak, and worse: the
driver-verdict ledger is only populated when a frame ships, so under
continuous concealment no verdict was ever read and the erroring arm
could not fire at all. A host framing regression of the 0.23.0
slice-wire class — which does not self-heal, and which a keyframe does
not clear — would have frozen indefinitely with no demotion and a clean
integrity line, where before it demoted to FFmpeg-Vulkan and showed a
picture. Now only an answer that proves the rung works clears the
streak: a shipped frame, or a clean no-frame. Concealment neither ticks
nor clears, so a lossy link still cannot demote a healthy rung while a
driver failure interleaved with concealment reaches the threshold again.

Two more honesty defects from the same round. A rung refusing every AU
reported no integrity line at all — the founding failure mode, wearing
the shape of a clean bill of health; refusals are now counted. And
driver-failed could be non-zero on a device that cannot produce driver
verdicts, because a degraded timeline read looked the same as one; the
attribution is now withheld inside the counter rather than at call
sites, so the self-contradictory line is unrepresentable.

Local recovery also no longer trusts any recovery-point SEI: only one
whose target advances past an outstanding wave counts as a new wave, so
an encoder re-announcing the current wave with a decreasing count — legal,
and what x264 intra-refresh does — cannot lift the freeze early onto a
partially stale picture. Frames buffered across an arm are dropped by
decode order for the same reason.

Fault injection is a first-class tool now (PUNKTFUNK_AU_FAULT, inert
unless set, env read once). Its test replays the vendored vectors
through the real planners and asserts a negative the plan assumed away:
truncation and bit flips are PROVABLY invisible to the parser — Annex-B
carries no NALU length, so a cut slice is just a shorter slice and a
flipped payload byte is syntactically perfect. Only dropped AUs are
parser-detectable; the rest need the driver verdict, which is why the
status query matters. The H.265 leg found a second: three of that
vector's faulted AUs are sub-layer non-reference pictures, so dropping
them damages nothing and silence is correct — the test asserts both
verdicts and guards that neither half goes vacuous.

Per-frame decode latency was deliberately NOT built. Polling answers
only 'complete by now', and the pump polls once per AU, so every sample
would quantise up by as much as a frame interval — 8.3 ms at 120 Hz
against decodes of 0.1-2 ms. Sampling faster needs a spin or a second
thread on a decoder that is deliberately not Sync. A blocking per-frame
wait is the field scar that once capped a stream at 51 fps. The honest
sampled stat stands.

Also fixed, pre-existing: the re-anchor gate re-armed on every damaged
AU, so sustained damage permanently zeroed the mark count — meaning the
wire's two-mark rule could never complete on exactly the lossy links it
was written for.

Field note recorded while wiring this: intra_refresh_recovery is set by
exactly one encoder backend (Linux libav-NVENC under
PUNKTFUNK_INTRA_REFRESH). AMF and QSV run a wave with no wire mark, and
AMF emits no recovery-point SEI either, so AMD/Windows intra-refresh
sessions still have no clean recovery point by either route.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + punktfunk-core; tests
69/131/129/354/41 plus 5 fault-detection green; cargo check --workspace
clean.
2026-08-06 04:30:40 +02:00
enricobuehler e4d8573475 feat(client): the native rung now decodes HEVC as well as H.264
The last piece of M3 WP-2 — VkH265Decoder was built and hardware-gated
but nothing drove it. video_vk_native.rs holds a two-arm codec enum and
forwards to it; the ledger, release tokens, status-query settling and
timeline waits are byte-for-byte what they were, since they were always
codec-agnostic over one DecodedVkFrame contract. The forwarders are
written out per arm rather than macro'd so the unchanged H.264 arm is
visible to a reviewer.

The picture's own format now reaches the presenter, which picks bit
depth and MSB packing from it instead of assuming the H.264 envelope.
That incidentally fixes a live bug on the SHIPPING FFmpeg-Vulkan path:
it derived ten-bit-ness by comparing against the 10-bit 4:2:0 format
alone, so a 10-bit two-plane 4:4:4 surface — which its own format table
accepts, and which NVIDIA reports for HEVC RExt — got 8-bit range and
transfer maths. Reachable today with Full chroma plus 10-bit: decoded
correctly, displayed wrong.

Review round 11 caught a regression this WP would otherwise have
shipped. pf-vkdecode refuses a stream whose (chroma, depth) pair has no
picture format on the device, but the session is built lazily from the
first SPS, so the refusal arrived AFTER construction — past the point
where a native init failure falls through to FFmpeg-Vulkan. It burned
the error streak instead and demoted to VAAPI/D3D11VA, which on
NVIDIA/Linux means software. Turning on Full chroma on any non-NVIDIA
GPU was enough: a 4K HEVC session that ran on FFmpeg-Vulkan before this
branch would have landed on software decode.

Both halves are fixed. The negotiated chroma and bit depth — already at
the call site, the PyroWave arm four lines up uses them — are threaded
into the backend, which probes the same caps path ensure_state would
run, so the whole class refuses at CONSTRUCTION where the fall-through
already exists. For the legs no negotiation can carry (a level above
maxLevelIdc, an SPS that disagrees with the Welcome) the decoder latches
'never delivered a frame' and routes that first streak to FFmpeg-Vulkan
rather than down the hardware ladder. H.264 is deliberately not probed:
its envelope is fixed, so a probe would only add a profile guess on the
bit-exact path; it gets the latch as its backstop.

Two more from the round. Planner warnings are typed again rather than
Debug strings — pf-vkdecode simply lacked the h265 re-export its h264
twin already had — which restores the H.264 log rendering exactly and
unblocks M4, whose job is counting concealment by kind. And concealment
is now the integrity set only: NonZeroReorder is documented spec-legal
and fully planned, but the client treated every warning as damage, so
the opening IDR and every ABR renegotiation's IDR were released unshown
and re-anchored — a visible hitch on a healthy stream.

Also: a raw-format newtype so a neighbouring i32 field cannot be passed
to the colour maths, the presenter's depth table now pinned against
pf-vkdecode's actual output vocabulary rather than the FFmpeg lane's,
a per-format warn latch, and four stale docs.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode; tests 69/125/108/40 green;
cargo check --workspace clean.
2026-08-06 03:02:09 +02:00
enricobuehler 6d8f3b45b5 feat(pf-vkdecode): the GPU half of HEVC decode — session, pools, recording
M3 WP-2 complete. caps_h265.rs builds the profile the stream actually
needs (profile idc + chroma + bit depths, all three stated on every
Vulkan object) and resolves its picture format — Main to NV12, Main 10
to P010, RExt 4:4:4 to the two-plane 4:4:4 formats — validating it
against the format list of every role the chosen arrangement creates
images in. A Main 10 stream on an 8-bit-only device is refused BEFORE a
session exists, never narrowed: decoding 10-bit into an 8-bit surface is
the silent-wrongness class this crate exists to refuse. session_h265.rs
adds the three-array parameters ledger; decoder_h265.rs adds
VkH265Decoder, mirroring VkH264Decoder method-for-method so the client
wiring is a two-arm dispatch away.

H.264 and H.265 now SHARE the machinery instead of duplicating it:
derive_arrangement (one coincide/distinct/layered decision table),
ring::rebased_offsets (the slices-only rebase — non-VCL NALUs in the
decode range hang VCN firmware), session::bind_session_memory, and a
parameterised build_frame. A DecodeProfile enum replaces the bare
profile idc that images.rs and ring.rs used to take: both codecs' idc
types are c_uint, so handing an H.265 idc to the H.264 path COMPILED
SILENTLY and built a mismatched profile chain. That is now
unrepresentable.

The VPS leg is the ledger's real work. The vendored parser attaches a
VPS to an SPS only when it saw the NALU, and clients join live streams,
so VpsSource is Parsed-or-FromSps and is stored BY VALUE: re-activating
a VPS-less SPS is Current (no churn), but the real VPS arriving under
the same id is a content change and RECREATES onto it, because Vulkan
cannot replace a stored parameter set.

Review round 10 (adversarial) confirmed the hardware-proven H.264 path
is NOT regressed — derive_arrangement's check order and error identity
are byte-for-byte the original, build_frame's call sites still pass the
granularity-aligned extent (the 1088-row scar stays shut), and
rebased_offsets reproduces the deleted inline loop for every input while
moving the sum to u64 so overflow errors instead of wrapping. Also
verified: the refs-order contract on every path, the RESULT_STATUS caps
gate (each of reset/begin/end individually gated, no pool created when
unsupported — recording one on RADV hangs its VCN), pNext lifetimes, and
that no panic is reachable on stream input.

Its 10 findings are fixed. The two that mattered:

- A failed decode stranded a DPB slot. Once plan_to_vk_h265 had mutated
  the slot map, five later failure paths returned without restoring it,
  so planner and slot map both believed a picture was resident while no
  image held it — and every later AU referencing it failed, where H.264
  soft-degrades and keeps delivering. Fail-closed is kept (substituting
  a reference silently is the corruption-hiding this program exists to
  end) but made RECOVERABLE: a latch flushes the planner to AwaitingIdr
  and resets the bindings on the next decode, which composes with the
  client already requesting a keyframe on every decode error. The fix
  deliberately covers pre-mutation failures too — those strand the
  picture the other way round and wedge identically.
- DecodedVkFrame carried no picture format, so a Main 10 frame would
  decode correctly and be rendered with 8-bit transfer/range math. It
  now carries one, stamped from the pool so it is truthful for both
  decoders by construction. The presenter comment says depth 8 is
  because only H.264 is WIRED, not a decoder limit.

Plus: bind_session_memory freed allocations before the session that may
hold them was destroyed (an ordering regression from the extraction,
with a SAFETY comment asserting the opposite) — the bind-stage exit now
hands them back so Drop destroys first; max_level_idc is codec-tagged
rather than an H.264 type carrying H.265 code points; and the decode
family's videoCodecOperations is now checked, turning 'create an H.265
session on a device without the extension' from UB into a clean ladder
demote.

Deferred by design: no HEVC gpu_smoke/gpu_parity yet (its goldens are
already in tests/data/test-25fps-h265.nv12.sha256), and no codec
dispatch in the client — both later legs.

Gates: fmt clean; mac clippy zero warnings, pf-vkdecode 106 +
pf-bitstream 69 green; container clippy -D warnings zero for
pf-client-core + pf-presenter + pf-vkdecode, tests 69/121/106 green.

HARDWARE (.173, after the refactor — review saying the proven path is
safe is not the GPU saying it): gpu_parity '250 frames bit-identical to
libavcodec software decode' on BOTH the NVIDIA 4090 (610.88, coincide
mode) and the AMD iGPU (Adrenalin 25.10.30.02, distinct mode), gpu_smoke
green on both. Two independent drivers, both DPB modes, still bit-exact.
The smoke trace also shows the new videoCodecOperations capture reading
DECODE_H264 | DECODE_H265 | DECODE_AV1 off the real decode family.
2026-08-06 01:59:39 +02:00
enricobuehler c985438db1 test(pf-bitstream): replay real host captures through the planners + HEVC goldens
M0's capture hook has been in since 119ec0dd with nothing consuming its
output. corpus_replay.rs is that consumer: point PF_CORPUS at an
au-<stamp>.<codec> capture and every AU walks back through the H.264 or
H.265 planner, asserting no errors and no warnings — a clean capture of
a healthy session must plan whole. Ignored by default (captures are
hundreds of MB and live outside the repo).

It earns its keep immediately. Captured on .173 against the live host
(NVENC, 2800x1260, ~30 s each, client-side codec pin only — no host
config touched):

  h265  1133/1133 AUs planned, 0 errors, 0 warnings
  h264  1514/1514 AUs planned, 0 errors, 0 warnings

The HEVC number is the point: it is the FIRST validation of the WP-1
h265 planner against real host output rather than the vendored
conformance vectors, and it lands before the client's HEVC rung exists
to produce on-glass evidence.

Two real-capture facts the harness had to learn, both from this run:
ending a capture means killing the client, so the final .idx line is
routinely half-written and the final AU's bytes may not all have landed.
Both are tolerated at the TAIL only — a malformed line anywhere else, or
a gap the data cannot cover mid-file, still fails loudly rather than
silently replaying a subset.

Also adds tests/data/test-25fps-h265.nv12.sha256: 250 per-frame NV12
hashes of the vendored HEVC vector from libavcodec's software decoder,
cross-checked frame-for-frame between two independent FFmpeg builds
(8.0.1 in pf-lxcheck2, 8.1.1 from Homebrew) — the sibling of the H.264
goldens, ready for WP-2's parity leg.

Gates: fmt clean; pf-bitstream clippy clean, 69 tests green (the replay
stays ignored in normal runs).
2026-08-06 01:08:50 +02:00
enricobuehler 55bc664eca docs(pf-bitstream): the two upstream cros-codecs bugs are now reported
PROVENANCE deviations #6 and #7 carried 'report upstream' — done:
- chromeos/cros-codecs#99: h264 PictureData display_resolution
  double-counts the left/top crop and underflow-panics on
  parser-valid crop offsets (answers their open #81).
- chromeos/cros-codecs#100: h265 parse_slice_header index-OOB panic
  when num_long_term_sps + num_long_term_pics > 16 — a hostile-input
  panic on the LTR path (an instance of their #78).

Both reports offer the downstream patch for the AOSP tree.
2026-08-06 00:44:18 +02:00
enricobuehler a34f4051fc feat(pf-vkdecode): the CPU half of HEVC decode — StdVideo H265 conversion + slot map
M3 WP-2, first half. params_h265.rs: VPS/SPS/PPS -> StdVideoH265* with
owned pointer-backing (the params.rs contract), scaling lists incl. the
32x32 two-matrix quirk and +8 DC convention, short-term RPS re-encoded
from the parser's RESOLVED DeltaPoc arrays back into delta_poc_sX_minus1
syntax under monotonicity checks, fallback_vps_from_sps for streams
whose VPS NALU was lost. pic_h265.rs: plan_to_vk_h265 — h265 AuPlan ->
StdVideoDecodeH265PictureInfo + per-reference infos; the binding set is
the union of the three current RPS sets with the Std index arrays
indexing into refs (0xFF unused; the GPU half must lay pReferenceSlots
out in refs order); NumDeltaPocsOfRefRpsIdx from the predicted-from
candidate; transactional SlotMap lifecycle identical to pic.rs. SlotMap
reused unmodified — HEVC's ceiling equals H.264's 16+1.

Envelope fails closed: Main/Main10/MainStill/RExt only, 4:2:0-8/10 +
4:4:4 only (separate_colour_plane_flag rejected — ChromaArrayType 0 in
disguise), SCC palette predictors out, >64 ST RPS sets / >16 per side /
>32 LT SPS candidates out, checked narrowing on every narrower Std
field. No panics on untrusted input.

Review round 9 (adversarial): RPS re-encode math, Std field-by-field
conformance, transactionality and slot ceiling verified clean; 6
findings fixed pre-commit. Headline (BLOCKING): long_term_ref_pics_
present_flag=1 with num=0 left pLongTermRefPicsSps NULL — the header
demands a valid pointer whenever the flag is set, and flag=1/num=0 is
exactly the punktfunk LTR/RFI recovery stream shape; the all-zero
backing now rides whenever the flag is set. Also: the slice_offsets doc
in BOTH pic modules claimed submit-as-planned while decoder.rs packs
slices-only and rebases (non-VCL NALUs in the decode range hang VCN
firmware) — reworded so the HEVC GPU half cannot implement the hang; a
concealment-produced ST/LT duplicate now ORs the long-term flag across
occurrences; NumDeltaPocs clamps became a typed error; dead
UnmappableLevelIdc variant dropped.

Deferred to the GPU half: HEVC caps/profile chain, session parameters
(VPS leg in the ledger), P010/4:4:4 pool selection, recording, and the
pReferenceSlots-in-refs-order contract consumption.

Gates: fmt clean; mac pf-vkdecode 80 + pf-bitstream 69 green, clippy
clean; container clippy -D warnings zero (pf-client-core, pf-presenter,
pf-vkdecode) + tests green (69/121/80).
2026-08-06 00:41:23 +02:00
enricobuehler 370b0ab494 feat(client): native Vulkan decode joins the automatic ladder, above FFmpeg-Vulkan
Closes M2. The 2026-08-05 ladder decision: WP-D shut with bit-exact
parity vs libavcodec (250/250 AUs on RADV, AMD-proprietary and NVIDIA)
and a 92-minute clean soak, and the program's goal is dropping FFmpeg
from the client — so on H.264 sessions where caps pass, auto now tries
pf-vkdecode FIRST, exactly where the ladder would reach FFmpeg-Vulkan.
No bake period.

native_vulkan_gate widens from by-name-only to the auto family
(auto/""/hardware); the vendor-first rungs are untouched (Linux
Intel/unknown still VAAPI-first, Windows Intel/unknown still
D3D11VA-first — NVIDIA and ALL AMD go native first). A native INIT
failure or caps refusal logs and falls through to FFmpeg-Vulkan, so
admission can't cost a session its decoder at start; runtime error
streaks ride the existing demotion machinery unchanged (past
FFmpeg-Vulkan to VAAPI/D3D11VA/software — a native→FFmpeg-Vulkan
runtime rung is deliberately absent, FFmpeg is on its way out).
PUNKTFUNK_DECODER=native-vulkan stays as the explicit pin; vulkan
keeps naming the FFmpeg backend specifically. A native_tried guard
keeps a failed pin init from re-attempting construction in auto.

Review round 8 (adversarial): no blocking code defect — no demote
bounce-back (Decoder::new is session-start-only; demotion mutates in
place), no double attempt, no cfg imbalance. 5 findings fixed: two doc
overclaims ("nothing regresses" now scoped to init; the ladder
enumerations no longer claim desktop-AMD Linux is VAAPI-first —
prefer_vulkan_first is vendor-wide), stale opt-in claims in Cargo.toml,
stale user-facing ladder text (console-ui row, trust.rs decoder field,
session README incl. the env-knob list), and the gate test now pins the
H264 codec-op bit to the literal 0x1 so a typo'd constant can't make
native silently never engage.

Gates: fmt clean; container clippy -D warnings zero for pf-client-core +
pf-presenter + pf-vkdecode; container tests green (pf-client-core lib +
pf-vkdecode + pf-bitstream); pf-console-ui check clean; mac
pf-vkdecode/pf-bitstream/cros-codecs 167 tests green.

On-glass sanity CLOSED 2026-08-05 ~22:10 UTC on .173 (4090, coincide
mode), decoder=auto and NO env var: the ladder picked native on its
own ("pf-vkdecode auto rung" log line), 525/526 stats windows on
native-vulkan over ~8m46s / 31550 frames, fps 0/59.4/61 with 6
windows <55 incl. startup zeros, bad-signature grep over the whole
log EMPTY, zero TDR events, host service Running after teardown.
2026-08-06 00:12:35 +02:00
enricobuehler cf5db2d485 feat(pf-bitstream): H.265 DecodePlan layer — M3's AU-to-hardware contract
H265Planner mirrors the H.264 layer's contract exactly: plan_au -> AuPlan
{ picture, slices with ref lists by stable PicId, DpbUpdate, warnings },
same concealment posture (warnings never abort, in-place reference
substitution preserving ref_idx positions, outputs survive failed AUs,
flush gates on AwaitingIdr, any IRAP resumes). Ported logic: RPS 8.3.2
(short-term AND long-term incl. PocLsbLt/MSB-cycle - the hosts' RFI
recovery rides long-term refs), ref lists 8.3.3/8.3.4, DPB C.5.2.2/C.5.2.3
via the vendored dpb; POC 8.3.1 from the vendored PictureData. Written
fresh: the plan surface, AU walk, envelope gates (multilayer, interlaced,
SCC self-reference, DPB>16, conf-window overflow - checked at EVERY
activation, not just parse), HEVC recovery-point SEI (prefix NALU 39,
se(v) recovery_poc_cnt), VUI colour with E.3.1 inference, and a test-only
HEVC bitstream synthesizer (upstream has none).

Upstream deviations worth naming (all in-code with spec anchors): the
empty-RPS inter slice cannot infinite-loop (upstream bug); RASL behind a
joined CRA refuses BEFORE any state change (PlanError::RaslSkipped - the
WP-2 wiring must map it to skip, not reanchor; module docs carry the
contract note); MaxPicOrderCntLsb reads from the ACTIVATING SPS (upstream
latches at parse - a latent multi-SPS bug); C.5.2.2's exemption is
picture 0 of the BITSTREAM (EobNut), never first-after-EOS.

Vendored parser gained PROVENANCE deviation 7 (report upstream): hostile
slice headers with num_long_term_sps+num_long_term_pics > 16 indexed out
of bounds of SliceHeader's [_;16] arrays - a production panic on exactly
the long-term-reference path, now a parse error.

Port review round 7: 10 findings (3 blocking: the vendor panic, an
EOS-boundary output interleave, an envelope bypass through PPS-only SPS
rebind reaching wrapping crop arithmetic) - 9 fixed with a regression
test each, 1 documented as the WP-2 contract note. Known follow-up: the
h264 AU-tail truncation detector shares h265's dead-arm shape (its arm
also cuts reserved NALU types, so the fix is not identical - deferred).

Tests: 29 h265 planner + 2 HEVC SEI + full test-25fps.h265/bear/bbb clip
walks with real invariants (every stored id output exactly once,
ascending POC per IRAP period). Gates: fmt clean; clippy -D warnings zero
(mac + pf-lxcheck2 incl. pf-client-core/pf-presenter); tests 45+69 mac,
69+121+53 container.
2026-08-05 23:29:45 +02:00
enricobuehler dc0766b2f3 fix(client): the native rung now follows the stream's colour and reports true decode latency
The round-4 residuals, closed after the WP-D hardware verdict:

- VUI colour plumbing (the one silent-wrong): the picture's ACTIVE SPS's
  colour signalling (H.273 code points + range, with E.2.1's 'unspecified'
  inference where the VUI is silent — the vendored parser's defaults ARE the
  inferred values, verified) rides PicturePlan -> DecodedVkFrame ->
  NativeVkFrame per frame, never latched: the Windows host switches an HDR
  desktop to PQ/BT.2020 IN-BAND while the Welcome still says SDR. Before
  this, the native path would have painted PQ washed out, silently.
- Native decode-latency stat: the deliberately-deferred NativeVk arm of the
  pump's sampled once-per-stats-window decode measurement now feeds - the
  frame's (semaphore, semaphore_value) is the decode-done signal, resolved
  through the shipped ledger before a bounded, pure-measurement
  vkWaitSemaphores (VkH264Decoder::wait_decoded).
- The renegotiation-teardown window is settled as NO HOLE: rebuild_state now
  documents the full safety argument (graveyarded pools stay intact under
  presenter holds, tokens route strictly by generation, session objects die
  only post-drain with the generation gate INSIDE read_status), and the two
  backend comments that wrongly claimed stale pools were 'gone' are fixed.
- VK_KHR_unified_image_layouts stays deferred (fleet drivers lack it).

Adversarial review round 6: 3 minor findings (2 doc fixes applied; the
SPS-replaced-without-PPS-resend divergence stays a documented envelope
assumption - hosts re-send both at every keyframe, and a hardening
PlanWarning could cost real frames on a false positive).

Gates: fmt clean; clippy -D warnings zero (mac + pf-lxcheck2 container,
incl. pf-client-core/pf-presenter); tests 45+30+53 mac, 30+121+53 container.
2026-08-05 22:22:03 +02:00
enricobuehler e6d6498a49 test(pf-vkdecode): frame-hash parity vs libavcodec — bit-exact on the whole fleet
WP-D parity A/B. gpu_parity (ignored) decodes the conformance vector,
reads every frame back through the presenter's exact contract (wait,
layout round-trip, signal-back, release), crops at the copy so pitch
can never leak, and compares SHA-256s in display order against goldens
from ffmpeg software decode — cross-checked bit-identical between
ffmpeg 8.0.1 (linux) and 8.1.1 (macOS), so the reference is the spec,
not one build. PF_VKD_TEST_READBACK=1 is the one test-only hook (ORs
TRANSFER_SRC into pool usage; production pools stay zero-copy-tight).

Fleet verdict: 250/250 frames bit-identical to libavcodec on RADV
(Mesa 26.0.3, distinct), AMD proprietary Windows (25.10.30.02,
distinct) and NVIDIA Windows (610.88, coincide) — H.264 decode is
exactly specified, and the native path meets the spec on every driver
and both DPB arrangements.
2026-08-05 20:23:18 +02:00
enricobuehler ca92dab6fd fix(pf-vkdecode): prefer DEVICE_LOCAL, never require it — NVIDIA runs; both DPB modes hardware-green
Session-memory and image allocation now honor each binding's
memoryTypeBits with DEVICE_LOCAL preferred, not required: NVIDIA 610.88
legally places a video-session binding in host-visible-only memory and
the hard requirement refused the whole device. The bitstream ring keeps
its hard HOST_VISIBLE|COHERENT need. Smoke test gains
PF_VKD_SMOKE_VENDOR device pinning + attribution and a final-state
print (DPB mode now observed, not inferred).

On-glass matrix after this fix (.173, vendor-pinned): NVIDIA 4090
PASSES in COINCIDE mode — the first end-to-end run of the RESULT_STATUS
query path, ~44 per-frame driver verdicts on the recording pattern that
hangs RADV's VCN — and Adrenalin re-passes in distinct mode unchanged.
With RADV's distinct pass, both DPB arrangements and three of four
desktop drivers are now hardware-validated; Intel remains a clean caps
refusal (no SAMPLED on decode outputs — its rung stays D3D11VA).

Gates: fmt clean, clippy -D warnings zero, 45+27+53 green both
platforms.
2026-08-05 20:03:25 +02:00
enricobuehler 6331ae7fd9 fix(pf-vkdecode): zero-copy pool model + the two faults the first hardware run found
WP-D leg 1 (.25 RADV, distinct mode) root causes, both real:
1. Output starvation: the fixed 4-deep ring lost to a stream that keeps
   max_dpb_frames+1 = 8 pictures pending. Zero-copy fix (user
   requirement, no copies): one picture pool of required_slots +
   HOLD_HEADROOM(8) images decoupled from DPB slots — a re-activated
   slot binds a fresh free image, so a delivered picture is never a
   decode target; the WP-B pin layer became dead and is deleted.
   Per-image timeline semaphores carry the AVVkFrame contract: decode
   signals value+1, the presenter waits and signals back, later decodes
   wait the image's latest value — layout traffic ordered against
   reference reads with no copy anywhere.
2. RESULT_STATUS queries HANG RADV's VCN firmware (ring timeout,
   DEVICE_LOST): queryResultStatusSupport=false on the decode family.
   Queries are now caps-gated; without them poll/wait degrade to
   timeline-completion verdicts (FFmpeg parity — and the likely reason
   upstream never wired nb_queries). The Ally-X-class detection runs
   where drivers advertise the query; .173 probes NVIDIA/Windows-AMD.

Also: slice-only bitstream feeding (the field-proven consumer shape),
graveyarded pool retirement keyed by release tokens + generation,
decode-current-AU-before-status attribution, take_ready drained,
H264-bit gating, teardown short-circuit on disconnected channel.

On-glass: 48 AUs green on .25 holding 4 frames like the real client.
Gates: fmt clean, container clippy -D warnings zero, 27+121+52 green
both platforms.
2026-08-05 19:12:20 +02:00
enricobuehler d0659d2b61 feat(client): wire the native Vulkan decoder in behind PUNKTFUNK_DECODER=native-vulkan
M2 WP-C. video_vk_native.rs adapts the presenter's VulkanDecodeDevice to
pf-vkdecode (queue lock shared only when the families actually collide —
the one case the 2026-07-09 DEVICE_LOST race proved matters), and the
presenter consumes DecodedImage::NativeVk on its own device: no handle
import, no AVVkFrame co-authoring — wait the timeline, barrier to
sampled, existing crop-aware CSC, barrier back, release after the fence.

Frame lifetime is a token: presented, retired, displaced or dropped
mid-demotion, the guard's drop sends it exactly once; the backend
releases the decoder slot only after the status query resolves, so a
recycled slot can never report a false Failed. Driver-reported decode
failures and plan warnings ride the existing streak/reanchor machinery —
the Ally X corruption class is now a visible error, not a silent frame.

Opt-in only until WP-D's on-glass parity verdict; H.264 sessions only;
failures demote to the existing ladder. Known WP-D items recorded in
code: coincide-mode cross-queue reference overlap, renegotiation
teardown window, VUI colour plumbing.

Gates: fmt clean; container clippy -D warnings zero for pf-client-core +
pf-presenter + pf-vkdecode; 121+53+27 tests green.
2026-08-05 17:57:12 +02:00
enricobuehler 540c0d3027 feat(pf-vkdecode): the GPU half — session, DPB pools, decode recording, status queries
M2 WP-B. VkVideoSessionKHR lifecycle with drain-before-destroy on
parameters recreation, DPB pools in both coincide and distinct modes
(caps-derived, usage/flags validated against the driver's format
properties), an aligned bitstream ring, vkCmdDecodeVideoKHR recording
with one-shot RESET re-armed on failed submits, timeline-semaphore
completion, and the per-op RESULT_STATUS query ring — the signal
FFmpeg's hwaccel never reads and the reason this program exists.

Frame lifetime is two-phase by construction: release_frame pins a
delivered frame's slot against reuse, closing the coincide-mode
overwrite the adversarial review round proved (a full DPB handed a
just-returned frame's image back as the same call's decode target).
Nine review findings fixed pre-commit; a counterfactual test pins the
collision. Generation-stamped frames, memory-type misses as errors,
granularity-aligned extents, level gate.

AuPlan now carries its activated SPS/PPS (Rc) so backends never
re-parse. GPU smoke test (ignored) decodes 48 AUs past DPB-full with
releases — the fleet runs it in WP-D.

Gates: fmt clean, clippy -D warnings zero, 45+27+53 tests green on
macOS and the linux/amd64 container.
2026-08-05 16:44:44 +02:00
enricobuehler d24f7fc6ac feat(pf-vkdecode): the CPU half of native Vulkan decode — StdVideo conversion + slot map
M2 WP-A (design/client-native-decode.md §3.2). AuPlan -> StdVideo
parameter sets (owned pointer backings), per-AU decode info with slice
start-code offsets, and a PicId->slot map that never evicts on its own.
Deliberate rejections over silent claims: FMO, separate colour planes,
DPBs deeper than 16 frames (unbounded VUI ue(v)) all fail closed.

pf-bitstream API grew what the review proved necessary: RefPic carries
the true top/bottom field order counts (a single poc fabricated
BottomFieldOrderCnt whenever the PPS signals pic-order deltas), the
>16-frame DPB envelope gate, and an MMCO5 rebase warning.

Adversarial review round two: 8 findings fixed pre-commit, including
transactional slot mutation (an error path could permanently desync
the map) and count/pointer coherence on type-1 POC offsets.

Gates: fmt clean, clippy -D warnings zero, 45+26+21 tests green on
macOS and the linux/amd64 container.
2026-08-05 15:03:39 +02:00
enricobuehler 0a359525a7 feat(pf-bitstream): H.264 DecodePlan layer — the AU-to-hardware contract
Adapted from cros-codecs decoder/stateless/h264.rs (POC 8.2.1, ref lists
8.2.4 incl. modification, sliding-window + MMCO/LTR marking 8.2.5,
frame_num-gap handling 8.2.5.2), minus the fd-coupled backend trait.
H264Planner::plan_au maps one wire AU to picture params, per-slice byte
ranges + ref lists keyed by stable PicIds, and a DPB update; recovery-
point SEI parsing is new code (upstream reads no SEI payloads).

Concealment posture, deliberately different from upstream's aborts:
frame_num gaps, failed RPLM/MMCO and mis-split AUs degrade to warnings
the session turns into recovery asks, gap placeholders substitute
in-place so ref_idx mapping never shifts, and DPB outputs queued during
a failed AU survive to the next plan.

An adversarial review round fixed 11 findings before this commit;
one was an upstream cros-codecs bug our conformance-window test exposed
(display_resolution double-subtracts the crop offset and underflow-
panics — PROVENANCE deviation #6, worth reporting upstream).

Gates: fmt clean, clippy -D warnings zero, 45+24 tests green on macOS
and the linux/amd64 container.
2026-08-05 13:13:00 +02:00
enricobuehler 896bb47235 refactor(pf-bitstream): the parser layer is now compiler-enforced unsafe-free
#![forbid(unsafe_code)] on both crates. Upstream's codec module was one
production unsafe away: build_ref_pic_lists turned DPB borrows into
indices via pointer offset_from — same pointer-identity mapping now
expressed as position(ptr::eq) over the <=16-entry DPB (PROVENANCE #5).
Three test-only mem::zeroed() asserts became Default::default(), an
identical value for the all-integer PredWeightTable.

Honest coverage note: build_ref_pic_lists has no callers inside the
vendored subset (its consumer was the non-vendored stateless layer), so
the rewrite is equivalence-by-construction until the DecodePlan layer
exercises it against goldens.
2026-08-05 11:25:41 +02:00
enricobuehler b5e54aea6e feat(client): vendor the cros-codecs parser layer + pf-bitstream skeleton
M1 of design/client-native-decode.md. The vendored snapshot (AOSP mirror
main @ 5ff6d693ffae, BSD-3, PROVENANCE.md) is the codec module only —
H.264/H.265/AV1/VP9 parsers, DPBs and their test vectors, which now run
as 45 conformance tests in our CI. pf-bitstream sits where upstream's
Linux-only decoder::stateless half would and starts with vendor-pinning
smoke tests: a re-sync that shifts parser behavior trips in-tree, not in
a decode session.
2026-08-05 11:19:51 +02:00
enricobuehler 119ec0dd83 feat(client): PUNKTFUNK_DUMP_VIDEO captures the exact decoder input
Fixture-corpus enabler for the native-decode program (M0,
design/client-native-decode.md): every AU exactly as the pump hands it
to decode_frame — the raw concatenation plus a sidecar .idx carrying
the AU boundaries and wire flags a byte stream cannot. Best-effort by
design: any I/O error disables the capture, never the stream.
2026-08-05 11:02:37 +02:00
338 changed files with 124264 additions and 16837 deletions
+5
View File
@@ -252,6 +252,11 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
# that only a browser rejects, which nothing else here would catch.
- name: Test
run: bun run test
docs-site:
runs-on: ubuntu-24.04
+8 -3
View File
@@ -119,10 +119,15 @@ jobs:
run: |
apt-get update
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
# (FFmpeg's hwcontext_vulkan.h includes it).
# No libvulkan-dev: nothing here compiles or links against Vulkan (ash dlopens
# libvulkan and pf-vkdecode binds nothing at build time), so neither the compile nor
# dpkg-shlibdeps — which resolves DT_NEEDED sonames only — ever asks for it. The
# client's `Depends: libvulkan1` is added by hand in packaging/debian/build-client-deb.sh
# precisely because a dlopen is invisible to shlibdeps.
# No libav*-dev: the client links no FFmpeg since M10 (§6 of
# design/client-native-decode.md).
apt-get install -y --no-install-recommends dpkg-dev python3 \
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
libgtk-4-dev libadwaita-1-dev libsdl3-dev
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
- name: Cache keys
+7
View File
@@ -148,7 +148,12 @@ jobs:
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
ci
# Gated like Build/Push: only the docker CLI needs this login (Reconcile and Tag-for-release
# authenticate via curl -u), so a cache-hit job with nothing to push must not be able to fail
# on a login it never uses — proven on run 16013, where a host with a misconfigured daemon
# failed exactly here on a hit=true leg.
- name: Log in to the LAN registry
if: steps.exists.outputs.hit == 'false'
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
@@ -236,7 +241,9 @@ jobs:
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
.
# Same gate as the builders job above: the login only serves Push.
- name: Log in to the LAN registry
if: steps.exists.outputs.hit == 'false'
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
+11 -4
View File
@@ -34,7 +34,10 @@ on:
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
# binary's dependency closure must be listed here.
# binary's dependency closure must be listed here — including the native decode rungs, or a
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
paths:
- 'clients/linux/**'
- 'clients/session/**'
@@ -42,6 +45,9 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-vaadec/**'
- 'packaging/flatpak/**'
- 'Cargo.lock'
- '.gitea/workflows/flatpak.yml'
@@ -128,7 +134,7 @@ jobs:
# authselect trigger fires — so this line alone was never the fix for the failures
# below. See the retry.sh bump for the real cause.
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
# Flathub provides the GNOME runtime/SDK + the rust-stable and llvm20 extensions.
#
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
@@ -147,7 +153,7 @@ jobs:
git config --global --add safe.directory "$PWD"
# This job was the fleet's single heaviest network consumer: every run re-downloaded
# the GNOME runtime + SDK + llvm/rust/ffmpeg extensions (multi-GB from Flathub) and
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and
# every crate source. Both live in well-defined directories, both are idempotently
# verified/extended by the steps below, and the central cache server restores them
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
@@ -251,7 +257,8 @@ jobs:
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
# extensions. (No codec extension: the client links no FFmpeg — see the
# manifest header.)
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
+6 -3
View File
@@ -96,9 +96,12 @@ jobs:
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers.
# The host's Vulkan encode hand-rolls its structs, pyrowave-sys bindgens its own vendored
# copy, and both host and client reach Vulkan through ash, which dlopens the loader. (The
# HDR gamescope leg further down does need them, and pulls them itself via `dnf builddep
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
+4 -2
View File
@@ -141,8 +141,10 @@ jobs:
# observed on a clean build on this very runner (2026-07-17). No-op for compliant
# projects (libvpl-sys pins 3.13+).
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
# by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
if (-not $env:FFMPEG_DIR) {
+13 -12
View File
@@ -1,13 +1,17 @@
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, FFmpeg
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
#
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
# windows.yml for the cross-build rationale + the BOM/MAX_PATH runner gotchas.
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
# BOM/MAX_PATH runner gotchas.
#
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
# (windows-host.yml) is unchanged.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
@@ -49,7 +53,9 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-msix.yml'
@@ -80,12 +86,10 @@ jobs:
include:
- arch: x64
target: x86_64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg
td: C:\t
session_flags: ''
- arch: arm64
target: aarch64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg-arm64
td: C:\t-a64
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
@@ -94,7 +98,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
@@ -102,12 +106,9 @@ jobs:
shell: pwsh
run: |
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
# crates (see windows.yml). FFMPEG_DIR selects the arch's import libs + is read by
# pack-msix.ps1 for the runtime DLLs. All via GITHUB_ENV.
# crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10),
# and pack-msix.ps1 no longer copies runtime DLLs from one.
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
+32 -31
View File
@@ -1,26 +1,29 @@
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg,
# Vulkan-Headers, WDK, Inno Setup, the ARM64 rustup target - self-provision via the "Ensure
# Windows toolchain" step below, a fast no-op once already present, so any runner with that label
# works with no manual dispatch step first). Build + clippy + fmt + test BOTH client binaries:
# the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui/pf-ffvk — every stream runs in it, spawned by the
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
# no-op once already present, so any runner with that label works with no manual dispatch step
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
# flip when skia-binaries adds the target.
#
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
# `amf-qsv` leg link-imports them.
#
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
# so fmt + test run only for x64.
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
#
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# / per-arch vars are set in a step:
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
@@ -29,7 +32,6 @@
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
# root keeps every nested path well under the limit (per-arch so the two
# matrix legs don't share a target dir).
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
#
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
@@ -55,7 +57,9 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
@@ -67,7 +71,9 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-ffvk/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
@@ -110,7 +116,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
@@ -120,21 +126,13 @@ jobs:
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
# starts before this job's "Ensure Windows toolchain" step ever writes that file, so its
# PATH doesn't include this yet on a first run (confirmed live: STATUS_DLL_NOT_FOUND).
"$ff\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
rustup target add ${{ matrix.target }}
rustc --version
cargo --version
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
Write-Output "target ${{ matrix.target }} target-dir $td"
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
@@ -152,7 +150,10 @@ jobs:
- name: Clippy (-D warnings)
shell: pwsh
run: |
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
# dependency but only LINTS the packages it is given, so a decode crate that starts the
# run but is missing from this list would be gated by nothing.
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
$sf = @()
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
@@ -160,9 +161,9 @@ jobs:
- name: Rustfmt check
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
- name: Test
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
+5 -3
View File
@@ -46,9 +46,11 @@ sudo apt install build-essential clang libclang-dev pkg-config cmake \
libvulkan-dev
```
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
PipeWire — and is not a substitute for the list above.)
(The last two groups are the Linux client shell and the Vulkan session presenter; skip them only
if you never build those crates. `libvulkan-dev` is for the LOADER's pkg-config/soname — ash
dlopens it, and the client links no FFmpeg at all, so no libav*-dev appears here.
`scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway, PipeWire —
and is not a substitute for the list above.)
## Before you push
Generated
+307 -41
View File
@@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826"
dependencies = [
"android_log-sys",
"env_filter",
"env_filter 0.1.4",
"log",
]
@@ -204,6 +204,12 @@ dependencies = [
"syn",
]
[[package]]
name = "assert_matches"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -341,6 +347,26 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atomig"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0f41f4bb89f5c6450325e283fb78c4a3d042181b54f3855ee2f872919f9863"
dependencies = [
"atomig-macro",
]
[[package]]
name = "atomig-macro"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49c98dba06b920588de7d63f6acc23f1e6a9fade5fd6198e564506334fb5a4f5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "audiopus_sys"
version = "0.2.2"
@@ -446,7 +472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"annotate-snippets",
"bitflags",
"bitflags 2.13.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
@@ -475,6 +501,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
@@ -538,6 +570,12 @@ dependencies = [
"syn",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -556,7 +594,7 @@ version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"cairo-sys-rs",
"glib",
"libc",
@@ -885,6 +923,15 @@ dependencies = [
"itertools 0.10.5",
]
[[package]]
name = "cros-codecs"
version = "0.0.5"
dependencies = [
"env_logger",
"log",
"serde_json",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -991,6 +1038,37 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -1101,6 +1179,29 @@ dependencies = [
"regex",
]
[[package]]
name = "env_filter"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
"env_filter 2.0.0",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1193,7 +1294,7 @@ version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"ffmpeg-sys-next",
"libc",
]
@@ -1584,7 +1685,7 @@ version = "0.22.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1777,7 +1878,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
"zerocopy 0.8.52",
]
[[package]]
@@ -2127,6 +2228,42 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2291,7 +2428,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"cc",
"convert_case",
"cookie-factory",
@@ -2486,6 +2623,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149"
dependencies = [
"jobserver",
"log",
]
@@ -2493,7 +2631,7 @@ dependencies = [
name = "ndk"
version = "0.9.0"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2517,7 +2655,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -2530,7 +2668,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -2848,6 +2986,14 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.24.0"
dependencies = [
"cros-codecs",
"tracing",
]
[[package]]
name = "pf-capture"
version = "0.24.0"
@@ -2876,19 +3022,26 @@ dependencies = [
"anyhow",
"ash",
"async-channel",
"ffmpeg-next",
"libc",
"libloading",
"mdns-sd",
"openh264",
"opus",
"pf-ffvk",
"pf-bitstream",
"pf-dxvadec",
"pf-update-check",
"pf-vaadec",
"pf-vkdecode",
"pipewire",
"punktfunk-core",
"pyrowave-sys",
"rand 0.9.4",
"rav1d",
"rustls",
"sdl3",
"serde",
"serde_json",
"sha2",
"tracing",
"ureq",
"wasapi",
@@ -2935,6 +3088,16 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "pf-dxvadec"
version = "0.24.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
"pf-vkdecode",
"tracing",
]
[[package]]
name = "pf-encode"
version = "0.24.0"
@@ -2959,15 +3122,6 @@ dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "pf-ffvk"
version = "0.24.0"
dependencies = [
"ash",
"bindgen",
"pkg-config",
]
[[package]]
name = "pf-frame"
version = "0.24.0"
@@ -3042,7 +3196,6 @@ dependencies = [
"ash",
"async-channel",
"pf-client-core",
"pf-ffvk",
"punktfunk-core",
"sdl3",
"tracing",
@@ -3069,13 +3222,22 @@ dependencies = [
"ureq",
]
[[package]]
name = "pf-vaadec"
version = "0.24.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
"pf-vkdecode",
]
[[package]]
name = "pf-vdisplay"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
"bitflags",
"bitflags 2.13.0",
"bytemuck",
"futures-util",
"hex",
@@ -3102,6 +3264,17 @@ dependencies = [
"x11rb",
]
[[package]]
name = "pf-vkdecode"
version = "0.24.0"
dependencies = [
"ash",
"cros-codecs",
"pf-bitstream",
"sha2",
"tracing",
]
[[package]]
name = "pf-win-display"
version = "0.24.0"
@@ -3153,7 +3326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.13.0",
"libc",
"libspa",
"libspa-sys",
@@ -3207,7 +3380,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
@@ -3251,6 +3424,21 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3272,7 +3460,7 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
"zerocopy 0.8.52",
]
[[package]]
@@ -3311,7 +3499,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"bitflags 2.13.0",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
@@ -3388,7 +3576,6 @@ name = "punktfunk-client-windows"
version = "0.24.0"
dependencies = [
"async-channel",
"ffmpeg-next",
"mdns-sd",
"pf-client-core",
"punktfunk-core",
@@ -3431,7 +3618,7 @@ dependencies = [
"tokio",
"tracing",
"windows-sys 0.59.0",
"zerocopy",
"zerocopy 0.8.52",
"zeroize",
]
@@ -3726,6 +3913,36 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rav1d"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1932f060d5e7bd49dc9f8b272c1dc5e9ce0ffe141c28be900265d3989b36c9ed"
dependencies = [
"assert_matches",
"atomig",
"bitflags 2.13.0",
"cc",
"cfg-if",
"libc",
"nasm-rs",
"parking_lot",
"paste",
"raw-cpuid",
"strum",
"to_method",
"zerocopy 0.7.35",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3777,7 +3994,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.13.0",
]
[[package]]
@@ -3934,7 +4151,7 @@ version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -3973,7 +4190,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
@@ -4127,7 +4344,7 @@ version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"libc",
"sdl3-image-sys",
"sdl3-mixer-sys",
@@ -4222,7 +4439,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -4428,7 +4645,7 @@ version = "0.87.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"lazy_static",
"skia-bindings",
]
@@ -4521,6 +4738,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4724,6 +4963,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "to_method"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8"
[[package]]
name = "tokio"
version = "1.52.3"
@@ -5301,7 +5546,7 @@ version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"rustix",
"wayland-backend",
"wayland-scanner",
@@ -5313,7 +5558,7 @@ version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
@@ -5325,7 +5570,7 @@ version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -5338,7 +5583,7 @@ version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -5690,7 +5935,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
dependencies = [
"bitflags",
"bitflags 2.13.0",
"widestring",
"windows-sys 0.52.0",
]
@@ -6182,13 +6427,34 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive 0.7.35",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
"zerocopy-derive 0.8.52",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
+5 -1
View File
@@ -5,11 +5,12 @@ members = [
"crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"crates/pf-bitstream",
"crates/pf-bitstream/vendor/cros-codecs",
"crates/pf-client-core",
"crates/pf-clipboard",
"crates/pf-presenter",
"crates/pf-console-ui",
"crates/pf-ffvk",
"crates/pf-driver-proto",
"crates/pf-paths",
"crates/pf-update",
@@ -23,6 +24,9 @@ members = [
"crates/pf-capture",
"crates/pf-inject",
"crates/pf-vdisplay",
"crates/pf-vkdecode",
"crates/pf-dxvadec",
"crates/pf-vaadec",
"crates/pyrowave-sys",
"crates/libvpl-sys",
"clients/probe",
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 unom
Copyright 2026 unom - Enrico Bühler
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 unom
Copyright (c) 2026 unom - Enrico Bühler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+12 -6
View File
@@ -84,7 +84,9 @@ mid-stream mode renegotiation and a wall-clock skew handshake so latency stays v
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
weaknesses). The host is managed through a REST API and web console. The **host** builds against
FFmpeg 7 or 8; the **clients** link no FFmpeg at all — they decode natively (Vulkan Video, DXVA,
VAAPI, VideoToolbox, MediaCodec, openh264 + rav1d).
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
@@ -187,10 +189,13 @@ and the [docs site](https://docs.punktfunk.unom.io).
crates/
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
pf-client-core/ shared client plumbing (Linux + Windows): session pump · native decode ladder · audio · SDL3 gamepads · trust · discovery
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
pf-bitstream/ H.264 / H.265 / AV1 bitstream parsing + per-AU decode plans — the one parser every native rung submits from
pf-vkdecode/ native Vulkan Video decode (H.264 / H.265 / AV1) on the presenter's own device
pf-dxvadec/ native DXVA buffer layouts + AuPlan → picparams conversion (the Windows D3D11VA rung)
pf-vaadec/ native libva buffer layouts + AuPlan → picparams conversion (the Linux VAAPI rung)
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
clients/
@@ -245,9 +250,10 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
notice ship in the installed `licenses/` folder).
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows **host** build also
bundles FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
notice ship in the installed `licenses/` folder). The **clients** bundle no FFmpeg — they link
none.
### Trademarks
+566 -223
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
THIRD-PARTY SOFTWARE NOTICES
============================================================================
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
The binaries it ships statically/dynamically link the third-party Rust crates below.
Each is distributed under its own permissive license; full texts follow.
Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand.
+14 -2
View File
@@ -4,10 +4,22 @@
# cargo about generate about.hbs > THIRD-PARTY-NOTICES.txt # (or use scripts/gen-third-party-notices.sh)
#
# `accepted` is the allow-list of SPDX licenses permitted in the dependency tree. CI fails if a crate
# carries anything not listed here — which is exactly the regression guard we want against a copyleft
# dependency silently entering the linked set. All entries
# carries anything not listed here — the regression guard against a copyleft dependency silently
# entering the linked set. All entries
# below are permissive / attribution-only; deliberately NO GPL/LGPL/AGPL/MPL-link/SSPL/EPL.
#
# ⚠ KNOW THE LIMIT OF THIS GATE. cargo-about walks the CARGO graph, so it sees CRATES. A native
# library linked through a permissively-licensed `-sys` crate is INVISIBLE to it, licence and all.
# FFmpeg is precisely that shape: `ffmpeg-sys-next` is WTFPL and passes cleanly, while the LGPL
# libavcodec/libavutil/swscale it link-imports — and which the Windows host installer bundles as
# DLLs — never appear in the harvest at all. This gate did not catch FFmpeg entering the tree and
# would not catch the next such library. Copyleft arriving as C behind a -sys crate is a REVIEW
# question, not a CI one; the LGPL obligations we do carry are discharged by hand (the notice files
# and the replaceable-DLL linkage, see packaging/windows/punktfunk-host.iss).
#
# Since M10 this is a HOST-only concern: the client links no FFmpeg, so for every client artifact
# the crate graph and the linked set finally coincide and the gate means what it appears to mean.
#
# The dependency-free fallback is scripts/gen-third-party-notices.py (reads the cargo registry cache),
# which is what produced the committed baseline when cargo-about is unavailable offline.
+5
View File
@@ -15,6 +15,11 @@ FROM docker.io/library/archlinux:base-devel
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
# deps (second list) — both copied verbatim from what arch.yml installed in-job, where
# they now no-op as `--needed` guards.
# vulkan-headers rides the first list only because arch.yml's copy does; the package it actually
# serves is the gamescope companion (packaging/gamescope/PKGBUILD makedepends). punktfunk itself
# needs no system Vulkan headers — pyrowave-sys bindgens its own vendored copy and ash dlopens the
# loader — but arch.yml builds gamescope with `makepkg -d`, so an absent makedepend would not be
# reported as a missing dependency, only as a compile failure. Keep it.
RUN pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
+4 -2
View File
@@ -27,8 +27,10 @@ RUN dnf -y install \
mesa-libGL-devel mesa-libgbm-devel \
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
gtk4-devel libadwaita-devel SDL3-devel \
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
vulkan-headers \
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers
# (pyrowave-sys bindgens its own vendored copy; host and client both reach Vulkan through
# ash, which dlopens the loader), and packaging/rpm/punktfunk.spec BuildRequires none.
# rpm.yml's HDR gamescope leg needs them and pulls them with `dnf builddep gamescope`.
&& dnf clean all
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
+4 -3
View File
@@ -29,15 +29,16 @@ RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.l
&& dpkg --add-architecture arm64
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon,
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon). No
# Vulkan dev package: nothing compiles or links against Vulkan — ash dlopens the loader, and
# pyrowave-sys bindgens its own vendored headers.
RUN apt-get update && apt-get install -y --no-install-recommends \
crossbuild-essential-arm64 \
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
libavfilter-dev:arm64 libavdevice-dev:arm64 \
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
libwayland-dev:arm64 libxkbcommon-dev:arm64 libvulkan-dev:arm64 \
libwayland-dev:arm64 libxkbcommon-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
+3 -2
View File
@@ -22,8 +22,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgl-dev libegl-dev libgbm-dev \
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
libvulkan-dev \
# No libvulkan-dev: nothing in the workspace compiles or links against Vulkan (pyrowave-sys
# bindgens its own vendored headers, and both host and client reach Vulkan through ash, which
# dlopens the loader), so neither the build nor deb.yml's dpkg-shlibdeps ever asks for it.
&& rm -rf /var/lib/apt/lists/*
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
File diff suppressed because it is too large Load Diff
@@ -168,9 +168,9 @@ internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onD
title = { Text("Allow local network access") },
text = {
Text(
"Android blocks punktfunk from talking to devices on your network, so it can't " +
"Android blocks Punktfunk from talking to devices on your network, so it can't " +
"find or reach any host until you allow it. If no prompt appears when you tap " +
"Allow, enable “Nearby devices” for punktfunk in system settings.",
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
)
},
confirmButton = {
@@ -1,11 +1,14 @@
package io.unom.punktfunk
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
@@ -622,6 +625,32 @@ fun ConnectScreen(
savedHosts = knownHostStore.all()
}
// "Copy link" — the self-emitted form every other client already hands out
// (design/client-deep-links.md §4): the host's STABLE id first, with `host=` and `fp=` alongside,
// so a link written today still lands on the right box after the host changes address or this
// client is reinstalled. A PINNED card copies its own profile with it, because that combination
// is the thing being copied; a host card copies no profile at all and so keeps honouring the
// host's binding, exactly like a tap on it does.
fun copyLink(kh: KnownHost, pin: StreamProfile?) {
val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl()
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val copied = clipboard != null && runCatching {
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
}.isSuccess
// Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is
// the platform's own documented anti-pattern. Below it nothing visible happens at all unless
// we say so — a silent menu item reads as a broken one.
if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard."
// The console home renders neither the notice nor the status banner, so there it has to be a
// toast; the touch grid has both, and a success dressed as an error banner is a small lie.
when {
gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
copied -> notice = message
else -> status = message
}
}
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
@@ -630,6 +659,7 @@ fun ConnectScreen(
if (pin == null) {
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
}
add(HostMenuItem("Copy link") { copyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
@@ -911,7 +941,7 @@ fun ConnectScreen(
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Android blocks punktfunk from finding or reaching hosts until you allow it.",
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
@@ -1162,6 +1192,7 @@ fun ConnectScreen(
} else {
null
},
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
onEdit = { optionsTarget = null; editTarget = kh },
onForget = {
knownHostStore.remove(kh)
@@ -191,7 +191,7 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
dsUsb?.let { DsRow(it) }
if (pads.isEmpty() && !sc2Present) {
Text(
"No controller detected. punktfunk can only forward devices Android " +
"No controller detected. Punktfunk can only forward devices Android " +
"classifies as a gamepad or joystick — a pad connected through an adapter " +
"or hub may show up under \"Other input devices\" below with the adapter's " +
"identity, or not at all.",
@@ -205,9 +205,9 @@ private fun DialogText(text: String) {
}
/**
* Console host options for a saved tile Wake (offered only when offline + a MAC is known), Edit,
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
* the touch host card's overflow menu.
* Console host options for a saved tile Wake (offered only when offline + a MAC is known), Copy
* link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console
* counterpart of the touch host card's overflow menu.
*/
@Composable
fun GamepadHostOptionsDialog(
@@ -217,6 +217,12 @@ fun GamepadHostOptionsDialog(
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
onEdit: () -> Unit,
onForget: () -> Unit,
/**
* Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other
* actions it says nothing about the host, it hands out the shortcut this very tile already is
* (profile included), which is exactly what a pin is for.
*/
onCopyLink: () -> Unit,
onDismiss: () -> Unit,
onSpeedTest: (() -> Unit)? = null,
/**
@@ -233,12 +239,14 @@ fun GamepadHostOptionsDialog(
actions = buildList {
if (onUnpin != null) {
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Cancel", onClick = onDismiss))
return@buildList
}
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
if (canWake) add(DialogAction("Wake host", onClick = onWake))
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
add(DialogAction("Forget", onClick = onForget))
add(DialogAction("Cancel", onClick = onDismiss))
@@ -455,11 +463,11 @@ fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDis
),
) {
DialogText(
"Android blocks punktfunk from talking to devices on your network, so it can't find " +
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
)
DialogText(
"If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " +
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
"system settings.",
)
}
@@ -344,7 +344,7 @@ struct HomeView: View {
ContentUnavailableView {
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
} description: {
Text("Add your punktfunk host with the + button, or scan the network again.")
Text("Add your Punktfunk host with the + button, or scan the network again.")
} actions: {
Button("Add Host") { showAddHost = true }
.glassProminentButtonStyle()
@@ -58,8 +58,8 @@ struct AcknowledgementsView: View {
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text(
"Punktfunk uses the open-source components below, each under its own license. "
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
+ "(dynamically linked, replaceable)."
+ "Video decoding uses the system's own VideoToolbox framework, so nothing "
+ "is bundled for it — and no Punktfunk client bundles FFmpeg on any platform."
)
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
@@ -15,10 +15,16 @@ import os
/// audible blip". It is now the same two-stage scheme the Rust clients share
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
///
/// **Adaptive depth.** The target is a floor, not a constant: repeated genuine underruns grow it
/// a step at a time (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`, and a
/// long quiet spell relaxes it back toward the base so a session on Wi-Fi that bunches arrivals
/// deepens until it stops crackling, while a clean LAN keeps the tight base latency. Keep the
/// constants here in step with `JitterTuning.COREAUDIO`.
final class AudioRing: @unchecked Sendable {
/// Mirrors `JitterTuning::COREAUDIO` see that type for the rationale.
private static let targetMS = 20
private static let maxTargetMS = 70
private static let headroomMS = 30
private static let hardCapMS = 90
private static let deprimeAfter = 4
@@ -33,6 +39,15 @@ final class AudioRing: @unchecked Sendable {
private static let crossfadeMS = 2
/// Time constant of the depth average.
private static let ewmaTauMS = 1_000
/// Adaptive target floor, mirroring `JitterPolicy::note_read`: this many genuine underruns
/// inside one window grow the live target a step (up to `maxTargetMS`), and a long quiet
/// spell relaxes it a step back toward the base so only the sessions that actually starve
/// (Wi-Fi power-save bunching is the classic) pay for extra depth, and only while they need
/// it. All spans are measured in consumed samples, like the Rust policy.
private static let growUnderruns = 3
private static let growWindowMS = 5_000
private static let growStepMS = 10
private static let shrinkQuietMS = 30_000
private var buf: [Float]
private var readIdx = 0
@@ -42,6 +57,14 @@ final class AudioRing: @unchecked Sendable {
private var emptyReads = 0
private var depthAvg: Double = 0
private var overRun = 0
/// The live target in interleaved samples `targetMS` grown by underrun pressure
/// (`noteRead`), never below the base. Set in `init` (needs `perMS`).
private var targetLive = 0
/// Underruns seen in the current growth window, and the window's consumed-sample count.
private var underrunsInWindow = 0
private var windowRun = 0
/// Consumed samples since the last underrun (drives the relax-back-down step).
private var quietRun = 0
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
/// which is a different problem from the depth being wrong.
@@ -57,12 +80,14 @@ final class AudioRing: @unchecked Sendable {
buf = [Float](repeating: 0, count: capacity)
self.channels = channels
perMS = 48 * channels
targetLive = Self.targetMS * perMS
}
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
/// Effective target depth in interleaved samples: the (adaptively grown) live target, lifted
/// so it can always serve one device quantum plus a packet (a large-buffer device cannot
/// sustain a target below its own quantum).
private var target: Int {
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
max(targetLive, renderQuantum + Self.frameMS * perMS)
}
func write(_ samples: UnsafePointer<Float>, count: Int) {
@@ -80,8 +105,13 @@ final class AudioRing: @unchecked Sendable {
buf[(writeIdx + i) % capacity] = samples[i]
}
writeIdx += count
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
// Backstop only: the smooth shed in `read` is what normally holds the depth down. The
// hard cap must always leave room for one device quantum past the target (mirrors the
// Rust policy's `.max(target + want)`) or a large-quantum device would trim itself into
// a permanent underrun.
let cap = max(
min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS),
target + renderQuantum)
if writeIdx - readIdx > cap {
readIdx = writeIdx - cap
depthAvg = Double(cap)
@@ -133,13 +163,43 @@ final class AudioRing: @unchecked Sendable {
readIdx += n
if n < count {
for i in n..<count { out[i] = 0 }
// De-prime only after a RUN of short reads: a single transient drain must not
// manufacture a whole target's worth of fresh silence.
}
noteRead(ranShort: n < count, count: count)
}
/// The outcome accounting of one primed read the Swift mirror of
/// `JitterPolicy::note_read`. A short read drives both the de-prime hysteresis (a single
/// transient drain must not manufacture a whole target's worth of fresh silence) and the
/// adaptive target floor: a device that genuinely keeps starving gets more slack, one step
/// per window, capped and gives it back after a long quiet spell, so one bad minute
/// doesn't cost latency for the rest of the session. Caller holds the lock.
private func noteRead(ranShort: Bool, count: Int) {
windowRun += count
if windowRun >= Self.growWindowMS * perMS {
windowRun = 0
underrunsInWindow = 0
}
if ranShort {
quietRun = 0
emptyReads += 1
underrunCount += 1
if emptyReads >= Self.deprimeAfter { primed = false }
if emptyReads >= Self.deprimeAfter {
primed = false
emptyReads = 0
}
underrunsInWindow += 1
if underrunsInWindow >= Self.growUnderruns {
underrunsInWindow = 0
windowRun = 0
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
}
} else {
emptyReads = 0
quietRun += count
if quietRun >= Self.shrinkQuietMS * perMS {
quietRun = 0
targetLive = max(targetLive - Self.growStepMS * perMS, Self.targetMS * perMS)
}
}
}
@@ -453,7 +453,7 @@ public final class HostDiscovery: ObservableObject {
private static func instanceName(_ endpoint: NWEndpoint) -> String {
if case let .service(name, _, _, _) = endpoint { return name }
return "punktfunk host"
return "Punktfunk host"
}
private static func entry(_ txt: NWTXTRecord, _ field: String) -> String? {
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 unom
Copyright 2026 unom - Enrico Bühler
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 unom
Copyright (c) 2026 unom - Enrico Bühler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
File diff suppressed because it is too large Load Diff
@@ -91,5 +91,112 @@ final class AudioRingDriftTests: XCTestCase {
scratch.contains { $0 != 0 },
"a single short read must not force a full re-prime")
}
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`: clustered genuine
/// underruns raise the target floor (that session needs the slack), a long quiet spell gives
/// it back and the floor never dips below the base.
func testTargetGrowsOnUnderrunsAndRelaxesWhenQuiet() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 25 * perMS)
func write(ms: Int) {
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
}
func read() {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
}
XCTAssertEqual(ring.stats.targetMS, 20, "base target must match JitterTuning.COREAUDIO")
// Prime, drain dry, then alternate starve/refill: each dry read is a genuine underrun,
// each full read in between keeps the de-prime hysteresis from tripping.
write(ms: 25)
for _ in 0..<5 { read() } // drains to zero
read() // short underrun 1
write(ms: 5); read() // full hysteresis reset
read() // short underrun 2
write(ms: 5); read() // full
read() // short underrun 3 the floor grows one step
XCTAssertEqual(ring.stats.targetMS, 30, "3 clustered underruns must grow the target 10 ms")
XCTAssertEqual(ring.stats.underruns, 3)
// A long clean run (30 s of consumed audio) relaxes the growth back to the base
for _ in 0..<(30_000 / 5 + 10) {
write(ms: 5)
read()
}
XCTAssertEqual(ring.stats.targetMS, 20, "a quiet spell must give the growth back")
// and stays there: quiet forever never dips below the base.
for _ in 0..<(30_000 / 5 + 10) {
write(ms: 5)
read()
}
XCTAssertEqual(ring.stats.targetMS, 20, "the floor must never go below the base target")
}
/// Growth is capped at `maxTargetMS`, exactly like `JitterPolicy` respects
/// `JitterTuning.max_target_ms`.
func testTargetGrowthRespectsTheCap() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 25 * perMS)
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 25 * perMS) }
// Starve it far past what six growth steps (20 70) would need.
for _ in 0..<40 {
for _ in 0..<5 {
scratch.withUnsafeMutableBufferPointer {
ring.read(into: $0.baseAddress!, count: want)
}
}
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 25 * perMS) }
}
XCTAssertLessThanOrEqual(ring.stats.targetMS, 70, "growth must respect maxTargetMS")
}
/// THE field scenario: Wi-Fi power-save bunches arrivals audio is produced steadily but
/// delivered in bursts, some of them late. A fixed 20 ms target crackles on every late burst
/// forever; the adaptive floor must deepen until the bunching rides through, and the tail of
/// the session must be silence-free.
func testWifiBunchingConvergesToSilenceFree() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
var pending = 0 // ms produced by the host but still "in flight"
var burst = 0
var silentTail = 0
let steps = 4000 // 20 s in 5 ms callbacks
let feed = [Float](repeating: 0.5, count: 200 * perMS)
for step in 0..<steps {
pending += 5 // the host encodes 5 ms per 5 ms of wall clock, stall or not
// Delivery bunches into ~60 ms bursts; every 4th burst arrives a further 30 ms late.
if step % 12 == 11 {
if burst % 4 == 3 {
// Hold this burst 30 ms: it is flushed 6 callbacks later instead.
burst += 1
} else {
feed.withUnsafeBufferPointer {
ring.write($0.baseAddress!, count: pending * perMS)
}
pending = 0
burst += 1
}
} else if step % 12 == 5, pending >= 60 {
// The held burst lands, together with everything produced since.
feed.withUnsafeBufferPointer {
ring.write($0.baseAddress!, count: pending * perMS)
}
pending = 0
}
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
if step >= steps - 600, scratch.allSatisfy({ $0 == 0 }) { silentTail += 1 }
}
XCTAssertGreaterThanOrEqual(
ring.stats.targetMS, 30,
"bunched delivery must have grown the target floor")
XCTAssertEqual(
silentTail, 0,
"after adapting, the last 3 s must play through the bunching without a dropout")
}
}
#endif
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 unom
Copyright (c) 2026 unom - Enrico Bühler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "punktfunk-client-linux"
description = "Native Linux punktfunk/1 client — GTK4/libadwaita shell, FFmpeg decode, PipeWire audio, SDL3 gamepads"
description = "Native Linux punktfunk/1 client — GTK4/libadwaita shell, PipeWire audio, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
+13 -8
View File
@@ -12,9 +12,11 @@ Built in Rust end to end (no C ABI): the shell shares its plumbing with the sess
## Features
- **Zero-copy hardware decode** — the session presenter decodes via **Vulkan Video** on every GPU
vendor (including NVIDIA), falling back to FFmpeg VAAPI → DRM-PRIME dmabuf and then software when
Vulkan Video is unavailable.
- **Zero-copy hardware decode, and it's ours** — the session presenter decodes with Punktfunk's own
decoders; no FFmpeg is linked or bundled. **Vulkan Video** (`pf-vkdecode`, decoding onto the
presenter's own device) leads on NVIDIA and AMD, **VAAPI** (`pf-vaadec` driving a dlopen'd libva,
exporting DRM-PRIME dmabufs) leads on Intel, whichever isn't first is the fallback, and an
OpenH264/rav1d CPU rung is last.
- **Your display's native mode** — the host builds a virtual output at exactly your WxH@Hz; no
scaling, no letterboxing. Steady 60 fps at 1080p60, ~6 ms capture→decoded on the LAN.
- **Audio both ways** — PipeWire playback with a jitter ring, plus mic uplink to the host.
@@ -50,8 +52,11 @@ Per-device install steps and pairing walkthrough:
## Build & run from source
Requires GTK ≥ 4.16, libadwaita ≥ 1.5, FFmpeg 7 or 8 (with VAAPI for hardware decode), PipeWire,
and SDL3 (with hidapi) development packages.
Requires GTK ≥ 4.16, libadwaita ≥ 1.5, PipeWire, and SDL3 (with hidapi) development packages,
plus a C compiler (the CPU rung builds OpenH264 from source). No *decoder* development package
is needed: libva and the Vulkan loader are both opened at runtime rather than linked, so
hardware decode is a fact about the box you **run** on — a Vulkan loader and your GPU's driver,
and libva for the VAAPI rung — not about the one you build on.
```sh
# from the repo root
@@ -85,9 +90,9 @@ src/
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
```
The UI-agnostic plumbing — session pump, FFmpeg decode, PipeWire audio, SDL3 gamepads +
keymap, trust store, mDNS discovery, library client, Wake-on-LAN — lives in
`crates/pf-client-core`, shared with the Vulkan session binary.
The UI-agnostic plumbing — session pump, the native decode ladder (Vulkan Video · VAAPI ·
OpenH264/rav1d), PipeWire audio, SDL3 gamepads + keymap, trust store, mDNS discovery, library
client, Wake-on-LAN — lives in `crates/pf-client-core`, shared with the Vulkan session binary.
## Related
File diff suppressed because it is too large Load Diff
+26 -8
View File
@@ -728,7 +728,11 @@ const CODEC_LABELS: &[&str] = &[
"AV1",
"PyroWave (wired LAN)",
];
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
// Stored decoder-preference values. `native-*` since M10 — the bare "vulkan"/"vaapi"
// named libavcodec's rungs, which are deleted; a store still holding them is migrated on
// read (`pf_client_core::video::migrate_decoder_pref`) and simply matches no entry here
// until the user re-picks. The labels below are unchanged and still true.
const DECODERS: &[&str] = &["auto", "native-vulkan", "native-vaapi", "software"];
/// Touch-input model values (persisted) paired with their display labels below — the
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
@@ -773,16 +777,26 @@ const APP_LICENSE: &str = concat!(
"\n\n=============================== Apache-2.0 ===============================\n\n",
include_str!("../../../LICENSE-APACHE"),
);
/// Third-party software notices for the linked Rust crates (generated by
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
/// Third-party software notices for the Rust crates THIS CLIENT links — the shell, the
/// session streamer, the headless CLI and the update helper (generated by
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog, and
/// shipped as /usr/share/doc/punktfunk-client/THIRD-PARTY-NOTICES.txt by the packages).
///
/// Deliberately the client-scoped file and not the workspace-wide one at the repo root:
/// that root file is the HOST's, it still carries `ffmpeg-next` and the full FFmpeg licence
/// text — and after M10 this app links no FFmpeg at all, which is exactly what the section
/// below it claims.
const THIRD_PARTY_NOTICES: &str = include_str!("../THIRD-PARTY-NOTICES.txt");
/// The dynamically linked system libraries — not in the crate notices, since they aren't
/// crates. Their full texts ship with each project rather than being vendored here.
const SYSTEM_LIBRARY_NOTICES: &str =
"This application dynamically links system libraries under their own licenses, including \
FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), and SDL 3 (Zlib). \
Their full license texts are available from each project.";
GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), and SDL 3 (Zlib). \
Their full license texts are available from each project. Video decoding uses the \
system's own Vulkan Video and VAAPI drivers (loaded at runtime, never linked), with \
OpenH264 and rav1d both BSD-2-Clause, and both in the Rust crate notices as the \
CPU fallback; no FFmpeg is linked or bundled.";
/// Show the About dialog (app license + the third-party-software Legal section) — reached
/// from the primary menu (app.rs `win.about`).
@@ -806,7 +820,7 @@ pub fn show_about(parent: &impl IsA<gtk::Widget>) {
.license_type(gtk::License::Custom)
.license(license.as_str())
.build();
// The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
// The native (GTK/PipeWire/SDL3) components are dynamically linked under their own
// (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
about.add_legal_section(
"Third-party software (Rust crates)",
@@ -1628,7 +1642,11 @@ pub fn show_scoped(
mouse_row.set_selected(mouse_i);
set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i as usize]);
compositor_row.set_selected(index::compositor(s));
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
// Migrated for the LOOKUP only (the store is left alone): a pre-M10 settings file
// holds `vulkan`/`vaapi`, which match no entry — the combo would show Automatic and
// a save would silently rewrite the user's hardware preference to `auto`.
let dec_stored = pf_client_core::video::migrate_decoder_pref(&s.decoder);
let dec_i = DECODERS.iter().position(|&d| d == dec_stored).unwrap_or(0);
decoder_row.set_selected(dec_i as u32);
stats_row.set_selected(index::stats(s));
fullscreen_row.set_active(s.fullscreen_on_stream);
-1
View File
@@ -24,7 +24,6 @@ pyrowave = ["pf-client-core/pyrowave", "pf-presenter/pyrowave"]
# (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming,
# stats on stdout only.
ui = ["dep:pf-console-ui", "dep:serde_json"]
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
# binary.
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
+55 -7
View File
@@ -49,19 +49,67 @@ path + per-stage latency equation); any tier but Off also emits the stdout mirro
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
only, no Skia anywhere in the dependency tree.
Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software on Linux,
Vulkan Video → D3D11VA → software on Windows): FFmpeg's Vulkan Video decoder runs on the
presenter's own device where the stack supports it (every vendor, zero copy); VAAPI
dmabufs import per-plane elsewhere (D3D11VA textures on Windows); software is the
universal fallback. 10-bit Main10 and HDR10 are advertised
(`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present
Decode follows the Settings preference (auto is vendor-ordered: Vulkan Video → VAAPI →
software on Linux, Vulkan Video → D3D11VA → software on Windows, with VAAPI/D3D11VA first
on Intel — every rung native since M10; see "Decode rungs" below): the Vulkan decoder runs
on the presenter's own device where the stack supports it (every vendor, zero copy); VAAPI
dmabufs import per-plane elsewhere (D3D11VA textures on Windows); software is the universal
fallback. 10-bit Main10 and HDR10 are advertised (`VIDEO_CAP_10BIT|HDR`): P010 decodes
through the Vulkan and VAAPI/D3D11VA paths (the CPU rung is 8-bit by contract and refuses
10-bit rather than mis-scaling it), and PQ streams present
on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or
tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff,
default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT`
policy.
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
## Decode rungs (M10: native only)
**This binary contains no FFmpeg.** `auto` walks native rungs — pf-vkdecode over Vulkan
Video, then the platform's own (pf-dxvadec on Windows, pf-vaadec on Linux), then the CPU
rung (openh264/rav1d). The libavcodec rungs that used to sit under each of them are
deleted, along with `pf-ffvk` and the `ffmpeg-next` dependency.
Two of the native rungs have never decoded a frame on real hardware (native VAAPI at all;
native D3D11VA's AV1 leg). They run anyway — with the libavcodec twins gone, the only
thing below them is the CPU, so barring them would cost the session hardware decode
outright rather than move it one rung down. What replaces the safety net is the log: every
session names the rung it landed on with its evidence state,
decode rung active rung=native-vulkan codec=HEVC hardware_verified=true evidence=...
…and that line is a **WARNING** when nothing has ever decoded a frame through the
rung/codec pair the session chose. `pf-client-core`'s `video.rs` module docs carry the full
table; read any field report about M10 against it.
Debug/bisect knobs: `PUNKTFUNK_DECODER=native-vulkan|native-vaapi|native-d3d11va|software`
(a pin skips the vendor order, which is how a lab run reaches a rung `auto` will not pick
on this device; a pinned rung that cannot open still falls through to the standard ladder,
loudly; `native-vaapi` also takes `PUNKTFUNK_VAAPI_DEVICE=/dev/dri/renderDNNN` to choose
the GPU). The pre-M10 spellings `vulkan`/`vaapi`/`d3d11va` named the libavcodec rungs
specifically; they are MIGRATED onto the native rung for the same hardware family, with a
`warn` line saying so — every desktop Settings UI offered those values, so refusing them
would end a session over a dropdown someone picked long ago.
`PUNKTFUNK_PRESENT_MODE=
mailbox|fifo|immediate|fifo_relaxed` (default MAILBOX, FIFO where the surface offers no
MAILBOX — AMD on Windows), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
demotion to software on healthy hardware).
`PUNKTFUNK_AU_FAULT=drop|truncate|flip[:period]` deliberately corrupts decoder input on the
native Vulkan lane (default period 60 — one AU a second at 60 fps; inert everywhere else, and
inert entirely if the value doesn't parse). `drop` swallows the AU, so the next one references a
picture that was never decoded — the bitstream planner catches it immediately. `truncate` delivers
a picture whose slice data stops mid-frame and `flip` alters one byte deep in the payload: both
parse perfectly, so only the driver's per-frame decode-status query can see them, and neither is
visible at all on a driver without `queryResultStatusSupport`. Watch the
result on the Detailed stats line's `integrity:` term (`damaged` = concealment the planner caught,
`refused` = AUs the decoder rejected outright, `driver-failed` = the hardware's own verdict, `run`
= consecutive frames with no picture, `worst run` = the longest such stretch of the session — the
once-a-second `run` sample misses the bad moment almost every time — and `no driver status` = this
device cannot answer the driver question at all). A session that lands on any other lane says so
in the log rather than faulting silently.
Note that `PUNKTFUNK_AU_DUMP` records the AU as it arrived from the HOST, while the fault injector
runs later, at the native decoder's own entry. On a faulted run the dump is therefore the clean
bitstream — reconstruct the damaged bytes from the spec if you need them (the injector is pure and
deterministic).
+89 -8
View File
@@ -346,9 +346,13 @@ mod session_main {
bitrate_kbps: settings.bitrate_kbps,
audio_channels: settings.audio_channels,
preferred_codec: settings.preferred_codec(),
// Nothing excluded on a fresh dial. Only the run loop's codec-fallback retry
// sets this, and it does so on a CLONE of these params — a Settings-level
// "never use HEVC" would be `preferred_codec`, not this.
exclude_codecs: 0,
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
// MULTI_SLICE is decoder truth for THIS embedder: every desktop decode stack
// (FFmpeg software, VAAPI, D3D11VA, Vulkan Video) handles AUs carrying several
// (Vulkan Video, D3D11VA, VAAPI, openh264/rav1d) handles AUs carrying several
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
@@ -357,12 +361,14 @@ mod session_main {
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
// Welcome BEFORE we build a decoder. Advertised whenever the user asks because
// every path can DISPLAY it: the Vulkan presenter samples the 2-plane 4:4:4 pool
// formats (hardware RExt decode where the driver offers it — NVIDIA today) and
// swscale converts anything else for the software rung, with the decoder ladder
// demoting on its own. No capability probe gates the bit — software decode is the
// guaranteed floor — but the cost is VISIBLE, not silent: the Detailed stats
// overlay prints the resolved chroma ("4:4:4→4:2:0" when the host declined) and
// the decode path frames actually took.
// formats (hardware RExt decode where the driver offers it — NVIDIA today),
// with the decoder ladder demoting on its own. No capability probe gates the
// bit — but note (M8) that the software rung below it is 4:2:0 8-bit ONLY and
// refuses anything else rather than mis-scaling it, so on a box whose hardware
// 4:4:4 decode fails the floor is a codec fallback, not a converted picture.
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
// frames actually took.
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
| if settings.hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
@@ -488,7 +494,8 @@ mod session_main {
///
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
/// decode is already the default just no-ops. Append rather than clobber so a user's own
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=native-vaapi` still overrides the decoder
/// choice (the pre-M10 `vaapi` spelling reaches the same rung — it migrates, loudly).
#[cfg(target_os = "linux")]
fn enable_radv_video_decode() {
const TOKEN: &str = "video_decode";
@@ -530,6 +537,80 @@ mod session_main {
};
}
// `--probe-decode`: per-adapter Vulkan Video decode capability, then exit. Human
// output on purpose — this is a triage tool, not a picker source, which is also
// why it is a separate flag: `--list-adapters` is parsed line-by-line by the
// desktop shells' GPU picker and must keep printing bare names.
if arg_flag("--probe-decode") {
return match pf_presenter::vk::probe_decode() {
Ok(adapters) => {
if adapters.is_empty() {
println!("no Vulkan physical devices");
}
for (i, a) in adapters.iter().enumerate() {
// The index IS the PUNKTFUNK_VK_DEVICE value, and entry 0 is what
// a default run presents on — the decoder shares that device, so
// on a hybrid box this line is usually the answer.
let kind = if a.discrete { "discrete" } else { "integrated" };
println!(
"[{i}] {} ({kind}){}",
a.name,
if i == 0 { " <- default presenter" } else { "" }
);
println!(
" vulkan video decode: {}",
if a.usable { "YES" } else { "no" }
);
let codecs: Vec<&str> = [(0x1u32, "H.264"), (0x2, "H.265"), (0x4, "AV1")]
.iter()
.filter(|(bit, _)| a.codec_ops & bit != 0)
.map(|(_, n)| *n)
.collect();
println!(
" driver decode ops: {}",
if codecs.is_empty() {
format!("none (0x{:X})", a.codec_ops)
} else {
format!("{} (0x{:X})", codecs.join(", "), a.codec_ops)
}
);
if !a.usable {
// Say which conjunct failed. "no" with no reason is the thing
// this whole flag exists to stop.
let mut why: Vec<String> = Vec::new();
if !a.api_1_3 {
why.push("device is not Vulkan 1.3".into());
}
if !a.features_ok {
why.push(
"missing samplerYcbcrConversion / timelineSemaphore / \
synchronization2"
.into(),
);
}
if a.decode_family.is_none() {
why.push("no queue family advertises VIDEO_DECODE".into());
}
if !a.base_missing.is_empty() {
why.push(format!("missing {}", a.base_missing.join(", ")));
}
if a.codec_exts.is_empty() {
why.push("no VK_KHR_video_decode_{h264,h265,av1} extension".into());
}
println!(" why not: {}", why.join("; "));
} else {
println!(" extensions: {}", a.codec_exts.join(", "));
}
}
0
}
Err(e) => {
eprintln!("probe-decode: {e:#}");
EXIT_PRESENTER_FAILED
}
};
}
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
// same enumeration the GTK shell probes.
-5
View File
@@ -78,11 +78,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
"winuser",
] }
# FFmpeg — used only to enumerate which codecs this client can decode (probe::decodable_codecs),
# advertised to the host on the speed-test connect. Same pin as the host/Linux client. (Real
# decode + present live in the spawned punktfunk-session binary.)
ffmpeg-next = "8"
# Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service
# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3
# itself (built from source via the bundled CMake on Windows) is pulled transitively by
+9 -7
View File
@@ -2,7 +2,7 @@
The native **Windows** app for streaming a punktfunk host to your PC. A modern WinUI 3 app that
discovers hosts on your network, pairs with a PIN, and streams at your display's own resolution and
refresh rate — with a hardware-accelerated D3D11 video path and HDR.
refresh rate — with hardware-accelerated video decode and HDR.
It's **pure Rust**: the UI is WinUI 3 driven through [windows-reactor](https://github.com/microsoft/windows-rs)
(a declarative, React-like framework), and it links the shared **`punktfunk-core`** directly to speak
@@ -10,9 +10,11 @@ the fast **`punktfunk/1`** protocol.
## Features
- **Hardware decode, GPU present**FFmpeg HEVC with a **D3D11VA zero-copy path** (decoder and
presenter share one D3D11 device; NV12/P010 textures sampled straight into a `SwapChainPanel`
composition swapchain), with a robust software-decode fallback.
- **Hardware decode, GPU present**Punktfunk's own decoders, no FFmpeg anywhere in the client:
**Vulkan Video** (`pf-vkdecode`) leads on NVIDIA and AMD, **D3D11VA** (`pf-dxvadec` driving
`ID3D11VideoDecoder`) leads on Intel, whichever isn't first is the fallback, and an
OpenH264/rav1d CPU rung is last. Either hardware rung hands its surface to the Vulkan presenter
without a CPU copy.
- **HDR10** — advertise 10-bit/HDR, detect PQ in-band, and flip the swapchain to `R10G10B10A2` +
ST.2084 with HDR10 metadata.
- **Your display's native mode** — the host builds a virtual display at exactly your WxH@Hz.
@@ -42,9 +44,9 @@ A stock [Moonlight](https://moonlight-stream.org/) client also works over GameSt
## Build from source
Windows-only (the crate builds as a stub on other platforms so the workspace stays green). You need
the MSVC toolchain, an `FFMPEG_DIR` FFmpeg tree, and CMake (SDL3 builds from source). The Windows
App SDK runtime bootstrap is staged next to the exe by `windows-reactor-setup` from this crate's
own `build.rs` — no extra environment needed.
the MSVC toolchain and CMake (SDL3 builds from source) — nothing else: decode is native since M10,
so there is no `FFMPEG_DIR` to point anywhere, and the Windows App SDK runtime bootstrap is staged
next to the exe by `windows-reactor-setup` from this crate's own `build.rs`.
```sh
cargo build -p punktfunk-client-windows --target x86_64-pc-windows-msvc
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -9,8 +9,9 @@ touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; the matrix points `FFMPEG_DIR`
at the runner's ARM64 FFmpeg tree, `C:\Users\Public\ffmpeg-arm64`). Artifacts are arch-suffixed
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; since M10 nothing in the
package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged on the runner —
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
stamps the manifest `ProcessorArchitecture` and names the output. See
[`windows.yml`](../../../.gitea/workflows/windows.yml) for the cross-build rationale.
@@ -25,10 +26,17 @@ stamps the manifest `ProcessorArchitecture` and names the output. See
| `punktfunk-session.exe` | the release build — the Vulkan session client the shell spawns for every stream (sibling resolution, `src/spawn.rs`). Skia links statically; `vulkan-1.dll` is a GPU-driver component, never bundled. ARM64 builds it `--no-default-features` (no Skia console UI) until rust-skia ships aarch64-pc-windows-msvc prebuilts |
| `Microsoft.WindowsAppRuntime.Bootstrap.dll`, `resources.pri` | staged by the client's `build.rs` via `windows-reactor-setup::as_framework_dependent()` |
| `SDL3.dll` | auto-staged by the `sdl3` crate |
| `avcodec/avformat/avutil/swscale/swresample/...-*.dll` | `FFMPEG_DIR\bin` |
| `licenses\*` | the project's MIT/Apache texts + the generated `THIRD-PARTY-NOTICES.txt` (MSIX has no installer EULA page, so attribution ships as files) |
| `Assets\*.png` | checked-in tile/store logos (rasterized from `packaging/flatpak/io.unom.Punktfunk.svg`) |
| `AppxManifest.xml` | the template here, with `{VERSION}`/`{PUBLISHER}` substituted |
**No FFmpeg DLLs.** The client decodes natively since M10 (`pf-vkdecode` / `pf-dxvadec` /
OpenH264+rav1d — punktfunk-planning `design/client-native-decode.md` §6), so nothing here
link-imports `libav*` and the wildcard `avcodec/avformat/avutil/swscale/swresample-*.dll` copy is
gone, along with the FFmpeg LGPL notice that accompanied it — shipping that notice now would claim
a dependency the package doesn't have. The **host** installer is unchanged:
`packaging/windows/pack-host-installer.ps1` still ships those DLLs for its AMF/QSV encode path.
### Why an "unpackaged" WinUI app packages cleanly
`main` calls `windows_reactor::bootstrap()`, which runs `MddBootstrapInitialize2` with
@@ -87,8 +95,7 @@ cargo build --release -p punktfunk-client-windows --target x86_64-pc-windows-msv
pwsh -File clients/windows/packaging/pack-msix.ps1 `
-Version 0.2.0.0 -TargetDir C:\t\x86_64-pc-windows-msvc\release -OutDir C:\t\msix
# arm64 (cross-compiled; point FFMPEG_DIR at the ARM64 tree)
$env:FFMPEG_DIR = 'C:\Users\Public\ffmpeg-arm64'
# arm64 (cross-compiled; no extra environment — the client links no FFmpeg)
cargo build --release -p punktfunk-client-windows --target aarch64-pc-windows-msvc
pwsh -File clients/windows/packaging/pack-msix.ps1 `
-Version 0.2.0.0 -Arch arm64 -TargetDir C:\t\aarch64-pc-windows-msvc\release -OutDir C:\t\msix
+26 -19
View File
@@ -4,9 +4,14 @@
.DESCRIPTION
Builds a packaging layout from a release `cargo build` output (exe + the reactor/SDL3 auto-staged
DLLs + resources.pri + FFmpeg DLLs + the checked-in Assets + the manifest), runs makeappx, and
DLLs + resources.pri + the checked-in Assets + the manifest), runs makeappx, and
signs with signtool. Idempotent; safe to re-run.
NO FFmpeg DLLs since M10 (design/client-native-decode.md §6): the client decodes natively
(pf-vkdecode / pf-dxvadec / openh264+rav1d) and link-imports no libav* at all, so the
wildcard copy and its LGPL notice are gone with it. The HOST installer is unchanged
packaging/windows/pack-host-installer.ps1 still ships them for its amf-qsv encode path.
Signing cert precedence:
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) the
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
@@ -22,8 +27,7 @@
.EXAMPLE
# x64 (default arch):
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -TargetDir C:\t\x86_64-pc-windows-msvc\release -OutDir C:\t\msix
# arm64 (point -TargetDir + FFMPEG_DIR at the ARM64 build/tree):
$env:FFMPEG_DIR='C:\Users\Public\ffmpeg-arm64'
# arm64 (point -TargetDir at the ARM64 build):
pwsh -File pack-msix.ps1 -Version 0.2.137.0 -Arch arm64 -TargetDir C:\t-a64\aarch64-pc-windows-msvc\release -OutDir C:\t-a64\msix
#>
[CmdletBinding()]
@@ -31,7 +35,6 @@ param(
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, e.g. 0.2.137.0
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
[string]$FfmpegBin = $(if ($env:FFMPEG_DIR) { Join-Path $env:FFMPEG_DIR 'bin' } else { 'C:\Users\Public\ffmpeg\bin' }),
[string]$OutDir = (Join-Path $TargetDir 'msix'),
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
@@ -83,28 +86,32 @@ foreach ($f in $required) {
Copy-Item $src (Join-Path $layout $f) -Force
}
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct).
# These are unmodified BtbN *lgpl-shared* builds, linked dynamically (replaceable DLLs) — FFmpeg is
# used under the LGPL v2.1+; the license text + notice ship in licenses\ below.
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
# license/attribution payload (MSIX has no installer EULA page, so ship them as files): FFmpeg's LGPL
# notice + license text, the project's own MIT/Apache texts, and the generated third-party notices.
# license/attribution payload (MSIX has no installer EULA page, so ship them as files): the
# project's own MIT/Apache texts plus the generated third-party notices, which is where every
# vendored/statically-linked dependency's attribution lives (openh264 BSD-2, rav1d BSD-2, …).
#
# The FFmpeg LGPL notice + license texts that used to be copied here went with the DLLs at M10:
# nothing in this package links libav* any more, so shipping an LGPL notice would be claiming a
# dependency that is not there.
#
# For the same reason the notices come from clients/windows/ and NOT from the repo root: the root
# file is workspace-wide, it is what the HOST ships out of, and it still lists ffmpeg-next plus the
# full FFmpeg licence text. The client-scoped file (same generator, `--packages
# punktfunk-client-windows,punktfunk-client-session,punktfunk-cli`) is the one that describes what
# is actually inside this .msix — and it is the same file the app's Licenses page shows.
$licDir = Join-Path $layout 'licenses'
New-Item -ItemType Directory -Force -Path $licDir | Out-Null
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
Copy-Item (Join-Path $repoRoot 'packaging\windows\licenses\FFmpeg-LGPL-NOTICE.txt') $licDir -Force -ErrorAction SilentlyContinue
foreach ($n in @('THIRD-PARTY-NOTICES.txt', 'LICENSE-MIT', 'LICENSE-APACHE')) {
$clientRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
foreach ($n in @('LICENSE-MIT', 'LICENSE-APACHE')) {
$p = Join-Path $repoRoot $n
if (Test-Path $p) { Copy-Item $p $licDir -Force }
}
$ffRoot = Split-Path $FfmpegBin -Parent
foreach ($lic in @('LICENSE.txt', 'LICENSE', 'COPYING.LGPLv2.1', 'COPYING.LGPLv3', 'COPYING.txt')) {
$p = Join-Path $ffRoot $lic
if (Test-Path $p) { Copy-Item $p $licDir -Force }
$notices = Join-Path $clientRoot 'THIRD-PARTY-NOTICES.txt'
if (-not (Test-Path $notices)) {
throw "missing $notices — run scripts/gen-third-party-notices.sh (it generates the per-client copies)"
}
Copy-Item $notices $licDir -Force
# tile/store assets
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
+13 -6
View File
@@ -12,9 +12,15 @@ const APP_LICENSE: &str = concat!(
"\n\n================================ Apache-2.0 ================================\n\n",
include_str!("../../../../LICENSE-APACHE"),
);
/// Third-party software notices for the linked Rust crates (generated by
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt");
/// Third-party software notices for the Rust crates THIS CLIENT links — the shell, the
/// session streamer and the headless CLI (generated by scripts/gen-third-party-notices.sh;
/// the MSIX ships the same file under licenses/).
///
/// Deliberately the client-scoped file and not the workspace-wide one at the repo root:
/// that root file is the HOST's, it still carries `ffmpeg-next` and the full FFmpeg licence
/// text — and after M10 this app bundles no FFmpeg at all, so printing that attribution
/// three lines under a card saying so would be a false statement to the user's face.
const THIRD_PARTY_NOTICES: &str = include_str!("../../THIRD-PARTY-NOTICES.txt");
pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
@@ -46,9 +52,10 @@ pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen
vstack((
text_block("Bundled components").font_size(15.0).semibold(),
text_block(
"FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \
license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \
Windows App SDK (Microsoft) are also linked.",
"SDL 3 (Zlib) and the Windows App SDK (Microsoft) are linked; their notices ship \
in the installed licenses\\ folder. Video decoding uses Windows' own DXVA and \
Vulkan Video, with OpenH264 and rav1d (both BSD-2-Clause) as the CPU fallback \
no FFmpeg is bundled.",
)
.font_size(12.0)
.wrap()
+11 -5
View File
@@ -45,12 +45,14 @@ fn render_scale_label(scale: f64) -> String {
}
}
/// Decode backend presets: `(stored value, display label)`.
// A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows
// Automatic — which is exactly how the session's decoder chain reads that value.
// A stored legacy value that matches no preset (the D3D11VA-era "hardware", and since M10
// the bare "vulkan"/"d3d11va" that named libavcodec's rungs) shows as Automatic — which is
// how the session's ladder reads "hardware", and near enough for the other two, which
// `pf_client_core::video::migrate_decoder_pref` maps onto the entries below anyway.
const DECODERS: &[(&str, &str)] = &[
("auto", "Automatic (GPU, fall back to CPU)"),
("vulkan", "Hardware (Vulkan Video)"),
("d3d11va", "Hardware (Direct3D 11 / DXVA)"),
("native-vulkan", "Hardware (Vulkan Video)"),
("native-d3d11va", "Hardware (Direct3D 11 / DXVA)"),
("software", "Software (CPU)"),
];
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
@@ -862,7 +864,11 @@ pub(crate) fn settings_page(
);
// --- Video -----------------------------------------------------------------------------
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
// Migrated for the LOOKUP only (the store is left alone): a pre-M10 settings file
// holds `vulkan`/`d3d11va`, which match no preset — the combo would show Automatic and
// a save would silently rewrite the user's hardware preference to `auto`.
let stored_decoder = pf_client_core::video::migrate_decoder_pref(&s.decoder);
let (dec_names, dec_i) = presets(DECODERS, |v| *v == stored_decoder);
let decoder_combo = setting_combo(ctx, scope, (rev, set_rev), dec_names, dec_i, |s, i| {
s.decoder = DECODERS[i].0.to_string();
});
+6 -1
View File
@@ -81,8 +81,13 @@ pub(crate) fn session_page(ctx: &Arc<super::AppCtx>, hud: &HudSample) -> Element
.map(str::trim)
.filter(|c| !c.is_empty())
.map(|c| {
// The `stats:` decode-path tags (see pf-client-core's session
// pump). M10 removed the `vulkan`/`vaapi`/`d3d11va` tags with their
// rungs; a hardware rung is now always a `native-*` one.
let kind = match c {
"vulkan" | "vaapi" => Pill::Good,
"native-vulkan" | "native-vaapi" | "native-d3d11va" | "pyrowave" => {
Pill::Good
}
"software" => Pill::Info,
_ => Pill::Neutral,
};
+1 -1
View File
@@ -244,7 +244,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
#[cfg(windows)]
fn discover_and_print() {
use std::time::{Duration, Instant};
println!("Browsing the LAN for punktfunk hosts (~5 s)…");
println!("Browsing the LAN for Punktfunk hosts (~5 s)…");
let (rx, _rescan) = discovery::browse();
let deadline = Instant::now() + Duration::from_secs(5);
let mut seen = std::collections::HashSet::new();
+16 -15
View File
@@ -6,26 +6,27 @@
//! over the real data plane, so it stays here. [`decodable_codecs`] rode along for the same
//! reason — the probe connect still advertises which codecs this client can decode.
use ffmpeg_next as ffmpeg;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::time::{Duration, Instant};
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
/// The `quic` codec bitfield this client can decode. Advertised to the host so it never emits
/// a codec we can't decode.
///
/// It is pf-client-core's [`decodable_codecs`](pf_client_core::video::decodable_codecs) —
/// the codecs the SESSION BINARY's rungs speak, which is the process that actually decodes.
/// This shell used to walk libavcodec's registry (`ffmpeg::decoder::find` per id) for the
/// same answer; that was wrong in two ways even before M10 deleted the dependency. It
/// described the decoders in THIS process, which decodes nothing, and it answered "a
/// decoder exists" where the question is "a rung can keep up" — the AV1-on-CPU promise
/// `decodable_codecs_for` exists to refuse.
///
/// ⚠ Deliberately the DEVICE-FREE answer ([`decodable_codecs`], not
/// `decodable_codecs_for`): this connect creates no presenter and has no `VulkanDecodeDevice`
/// to gate AV1 on, and it decodes nothing — the codec it advertises is never exercised. A
/// real session's Hello is built in the session binary, with the device in hand.
pub fn decodable_codecs() -> u8 {
let _ = ffmpeg::init();
let mut bits = 0u8;
for (id, bit) in [
(ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC),
(ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264),
(ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1),
] {
if ffmpeg::decoder::find(id).is_some() {
bits |= bit;
}
}
bits
pf_client_core::video::decodable_codecs()
}
/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI):
+2 -2
View File
@@ -46,8 +46,8 @@
{
"type": "library",
"name": "FFmpeg",
"version": "7.x/8.x (system-provided on Linux; replaceable DLLs bundled with the Windows packages)",
"description": "Dynamically linked libav* decode/encode; LGPL notice at packaging/windows/licenses/FFmpeg-LGPL-NOTICE.txt",
"version": "7.x/8.x (HOST only \u2014 system-provided on Linux; replaceable DLLs bundled with the Windows host installer)",
"description": "Dynamically linked libav* ENCODE for the host (pf-encode: NVENC-libav, VAAPI, AMF/QSV); LGPL notice at packaging/windows/licenses/FFmpeg-LGPL-NOTICE.txt. No punktfunk CLIENT links FFmpeg since M10 \u2014 client decode is Vulkan Video / DXVA / VAAPI / VideoToolbox / MediaCodec with openh264 + rav1d as the CPU floor.",
"licenses": [{ "license": { "id": "LGPL-2.1-or-later" } }],
"externalReferences": [{ "type": "website", "url": "https://ffmpeg.org" }]
},
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "pf-bitstream"
description = "Client-side bitstream layer for native decode: AU parsing, POC/DPB/reference derivation and per-AU DecodePlans (H.264/HEVC/AV1) on the vendored cros-codecs parsers — the layer libavcodec used to be (design/client-native-decode.md §3.1)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[dependencies]
cros-codecs = { path = "vendor/cros-codecs" }
tracing = "0.1"
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
//! The client's bitstream layer for native decode (design/client-native-decode.md §3.1):
//! everything a stateless hardware decoder needs to know about an AU before submission —
//! parsed headers, POC, DPB state, reference lists (including MMCO/LTR, which the hosts'
//! RFI recovery actively uses), recovery-point SEI — derived once here and consumed by
//! every backend (Vulkan `StdVideo*`, DXVA picparams, libva buffers).
//!
//! Parsing primitives come from the vendored cros-codecs parser layer
//! (`vendor/cros-codecs`, see its PROVENANCE.md); this crate owns what upstream keeps in
//! its Linux-only `decoder::stateless` half — the per-AU orchestration — plus the pieces
//! upstream lacks (SEI payload parsing: their parsers classify SEI NALUs but never read
//! them).
//!
//! Scope discipline: punktfunk clients decode punktfunk hosts — zero-reorder, no
//! B-frames, progressive, parameter sets from encoders we control. Implement to spec
//! where cheap; reject-with-log outside that envelope rather than half-decode.
//!
//! Nothing in this crate may touch a GPU API, an OS handle, or the network: CPU-only by
//! construction, so its tests run on every CI leg including macOS. And no `unsafe`,
//! compiler-enforced — this layer exists to replace C parsers; it does not get to
//! reintroduce their failure mode.
#![forbid(unsafe_code)]
pub mod av1;
pub mod h264;
pub mod h265;
pub mod sei;
// The vendor-pinning smoke tests below assert against byte counts and golden values from
// the vendored snapshot's own test vectors; a cros-codecs re-sync that shifts parser
// behavior must trip HERE, in our tree, not in a decode session.
#[cfg(test)]
mod vendor_smoke {
use std::io::Cursor;
use cros_codecs::bitstream_utils::IvfIterator;
use cros_codecs::codec::av1::parser::ObuAction;
use cros_codecs::codec::av1::parser::ParsedObu;
use cros_codecs::codec::h264::parser::Nalu as H264Nalu;
use cros_codecs::codec::h264::parser::Parser as H264Parser;
use cros_codecs::codec::h265::parser::Nalu as H265Nalu;
use cros_codecs::codec::h265::parser::Parser as H265Parser;
const H264_25FPS: &[u8] =
include_bytes!("../vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264");
const H265_25FPS: &[u8] =
include_bytes!("../vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265");
const AV1_25FPS: &[u8] =
include_bytes!("../vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1");
const VP9_25FPS: &[u8] =
include_bytes!("../vendor/cros-codecs/src/codec/vp9/test_data/test-25fps.vp9");
#[test]
fn h264_parses_the_vendored_vector_to_its_goldens() {
let mut cursor = Cursor::new(H264_25FPS);
let mut parser = H264Parser::default();
let (mut nalus, mut sps, mut slices) = (0u32, 0u32, 0u32);
let mut coded = (0u32, 0u32);
while let Ok(nalu) = H264Nalu::next(&mut cursor) {
nalus += 1;
if let Ok(s) = parser.parse_sps(&nalu) {
sps += 1;
coded = (
(s.pic_width_in_mbs_minus1 as u32 + 1) * 16,
(s.pic_height_in_map_units_minus1 as u32 + 1) * 16,
);
continue;
}
if parser.parse_pps(&nalu).is_ok() {
continue;
}
if parser.parse_slice_header(nalu).is_ok() {
slices += 1;
}
}
// 759 is upstream's own golden for this stream (chromium h264_parser_unittest lineage).
assert_eq!(nalus, 759);
assert_eq!(sps, 4);
assert_eq!(slices, 500);
assert_eq!(coded, (320, 240));
}
#[test]
fn h265_parses_the_vendored_vector() {
let mut cursor = Cursor::new(H265_25FPS);
let mut parser = H265Parser::default();
let (mut nalus, mut sps, mut slices) = (0u32, 0u32, 0u32);
while let Ok(nalu) = H265Nalu::next(&mut cursor) {
nalus += 1;
if parser.parse_sps(&nalu).is_ok() {
sps += 1;
continue;
}
if parser.parse_pps(&nalu).is_ok() {
continue;
}
if parser.parse_slice_header(nalu).is_ok() {
slices += 1;
}
}
assert_eq!(nalus, 254);
assert_eq!(sps, 1);
assert_eq!(slices, 250);
}
#[test]
fn av1_walks_obus_and_maintains_ref_slots_across_the_stream() {
let mut parser = cros_codecs::codec::av1::parser::Parser::default();
let (mut obus, mut frames) = (0u32, 0u32);
for packet in IvfIterator::new(AV1_25FPS) {
let mut consumed = 0;
while let Ok(action) = parser.read_obu(&packet[consumed..]) {
let obu = match action {
ObuAction::Process(obu) => obu,
ObuAction::Drop(n) => {
consumed += n as usize;
continue;
}
};
consumed += obu.bytes_used;
obus += 1;
// `ref_frame_update` is the parser's ref-slot bookkeeping; without it,
// inter frames fail with "Reference is invalid" — the parser validates
// reference integrity rather than trusting the stream.
match parser.parse_obu(obu).expect("parse_obu") {
ParsedObu::FrameHeader(fh) => {
frames += 1;
parser.ref_frame_update(&fh).expect("ref slot update");
}
ParsedObu::Frame(f) => {
frames += 1;
parser.ref_frame_update(&f.header).expect("ref slot update");
}
_ => {}
}
}
}
// 525 is upstream's own golden (cross-checked against GStreamer's OBU walk).
assert_eq!(obus, 525);
assert_eq!(frames, 274);
}
#[test]
fn vp9_splits_superframes_and_parses_headers() {
let mut parser = cros_codecs::codec::vp9::parser::Parser::default();
let (mut chunks, mut frames) = (0u32, 0u32);
for packet in IvfIterator::new(VP9_25FPS) {
chunks += 1;
frames += parser
.parse_chunk(packet.as_ref())
.expect("vp9 chunk")
.len() as u32;
}
assert_eq!(chunks, 250);
// > chunks proves superframe splitting engaged.
assert_eq!(frames, 269);
}
}
+346
View File
@@ -0,0 +1,346 @@
//! SEI payload parsing — the piece the vendored parser layer lacks: upstream classifies
//! SEI NALUs but never reads a payload. punktfunk needs exactly one payload type per
//! codec: the recovery point SEI, which hosts emit on RFI recovery so the client knows
//! where a decode-from-here point lands. Every other payload type is skipped by its
//! declared size.
//!
//! Both codecs put the recovery point at payload type 6 with the same D.1 message
//! framing, but the payload syntax differs: H.264 (D.1.8/D.2.8) counts recovery in
//! `frame_num` increments (`recovery_frame_cnt`, ue(v)) and carries a slice-group bit
//! pair; H.265 (D.2.8/D.3.8) counts in picture order (`recovery_poc_cnt`, se(v) — it
//! can be negative) and has no slice-group field. Hence two parsers over one shared
//! message walk.
/// Recovery point SEI (D.2.8).
///
/// `recovery_frame_cnt` counts in `frame_num` increments from the AU carrying the SEI to
/// the picture at which output is exact (`exact_match`) or approximate. `broken_link` set
/// means pictures before the recovery point may be visually broken and must not be shown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecoveryPoint {
pub recovery_frame_cnt: u32,
pub exact_match: bool,
pub broken_link: bool,
}
/// Recovery point SEI, H.265 flavour (D.3.8).
///
/// `recovery_poc_cnt` is the POC delta from the picture carrying the SEI to the
/// recovery-point picture — se(v)-coded, so unlike H.264's `recovery_frame_cnt` it can
/// be NEGATIVE (a recovery point among leading pictures). `exact_match`/`broken_link`
/// keep their H.264 semantics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecoveryPointHevc {
pub recovery_poc_cnt: i32,
pub exact_match: bool,
pub broken_link: bool,
}
/// Parse the first recovery point SEI message out of an H.264 SEI NALU.
///
/// `sei_payload` are the bytes of the NALU after its one-byte NAL header, emulation
/// prevention bytes still in place (they are removed here — 7.4.1 RBSP extraction).
/// `Ok(None)` means the NALU parsed cleanly but carries no recovery point.
pub fn parse_recovery_point(sei_payload: &[u8]) -> Result<Option<RecoveryPoint>, String> {
let rbsp = strip_emulation_prevention(sei_payload);
let Some(payload) = first_recovery_point_payload(&rbsp)? else {
return Ok(None);
};
let mut r = BitCursor::new(payload);
let recovery_frame_cnt = r.read_ue()?;
let exact_match = r.read_bit()? != 0;
let broken_link = r.read_bit()? != 0;
// changing_slice_group_idc u(2): parsed to keep the reader honest, unused —
// slice groups are outside every profile punktfunk hosts emit.
let _changing_slice_group_idc = r.read_bits(2)?;
Ok(Some(RecoveryPoint {
recovery_frame_cnt,
exact_match,
broken_link,
}))
}
/// Parse the first recovery point SEI message out of an H.265 prefix SEI NALU.
///
/// `sei_payload` are the bytes of the NALU after its TWO-byte NAL header (H.265 NALU
/// headers are 16 bits), emulation prevention still in place. Only prefix SEI NALUs
/// (type 39) can carry a recovery point — D.2.1 lists it as prefix-only, so suffix SEI
/// NALUs (type 40) need never reach here.
pub fn parse_recovery_point_hevc(sei_payload: &[u8]) -> Result<Option<RecoveryPointHevc>, String> {
let rbsp = strip_emulation_prevention(sei_payload);
let Some(payload) = first_recovery_point_payload(&rbsp)? else {
return Ok(None);
};
let mut r = BitCursor::new(payload);
let recovery_poc_cnt = r.read_se()?;
let exact_match = r.read_bit()? != 0;
let broken_link = r.read_bit()? != 0;
Ok(Some(RecoveryPointHevc {
recovery_poc_cnt,
exact_match,
broken_link,
}))
}
/// Walk the D.1 SEI message framing (shared verbatim between H.264 and H.265) and
/// return the payload bytes of the first recovery point message (payload type 6 in
/// both codecs), if any. `rbsp` is already emulation-prevention-stripped.
fn first_recovery_point_payload(rbsp: &[u8]) -> Result<Option<&[u8]>, String> {
let mut i = 0usize;
while i < rbsp.len() && !is_rbsp_trailing(rbsp, i) {
// D.1: payload type and size are ff-coded — 0xFF bytes each add 255 until a
// non-0xFF byte terminates the value. The run length is unbounded, so the type
// accumulates saturating: an adversarial ~16M-byte 0xFF run must not overflow
// (a saturated type simply never matches 6). The size accumulator is a usize
// whose use is bounds-checked below.
let mut payload_type = 0u32;
while i < rbsp.len() && rbsp[i] == 0xFF {
payload_type = payload_type.saturating_add(255);
i += 1;
}
if i >= rbsp.len() {
return Err("truncated SEI payload type".into());
}
payload_type = payload_type.saturating_add(u32::from(rbsp[i]));
i += 1;
let mut payload_size = 0usize;
while i < rbsp.len() && rbsp[i] == 0xFF {
payload_size += 255;
i += 1;
}
if i >= rbsp.len() {
return Err("truncated SEI payload size".into());
}
payload_size += usize::from(rbsp[i]);
i += 1;
let end = i
.checked_add(payload_size)
.filter(|&end| end <= rbsp.len())
.ok_or_else(|| "SEI payload overruns the NALU".to_string())?;
if payload_type == 6 {
return Ok(Some(&rbsp[i..end]));
}
i = end;
}
Ok(None)
}
/// 7.4.1: within the RBSP, `00 00 03` encodes two zero bytes; the `03` is the emulation
/// prevention byte and is dropped.
fn strip_emulation_prevention(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
let mut zeros = 0usize;
for &byte in data {
if zeros >= 2 && byte == 0x03 {
zeros = 0;
continue;
}
zeros = if byte == 0 { zeros + 1 } else { 0 };
out.push(byte);
}
out
}
/// `more_rbsp_data()` at a byte-aligned message boundary: the remainder is trailing bits
/// iff it is the stop bit (0x80) followed by nothing but zero bytes.
fn is_rbsp_trailing(rbsp: &[u8], i: usize) -> bool {
rbsp[i] == 0x80 && rbsp[i + 1..].iter().all(|&b| b == 0)
}
/// Minimal MSB-first bit reader over an already-unescaped RBSP slice. The vendored
/// `BitReader` is `pub(crate)` to the vendored crate, so this crate carries its own.
struct BitCursor<'a> {
data: &'a [u8],
/// Position in bits from the start of `data`.
pos: usize,
}
impl<'a> BitCursor<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn read_bit(&mut self) -> Result<u32, String> {
let byte = *self
.data
.get(self.pos / 8)
.ok_or("SEI payload out of bits")?;
let bit = (byte >> (7 - self.pos % 8)) & 1;
self.pos += 1;
Ok(u32::from(bit))
}
fn read_bits(&mut self, count: usize) -> Result<u32, String> {
debug_assert!(count <= 31);
let mut out = 0u32;
for _ in 0..count {
out = (out << 1) | self.read_bit()?;
}
Ok(out)
}
/// ue(v), spec 9.1.
fn read_ue(&mut self) -> Result<u32, String> {
let mut leading_zeros = 0usize;
while self.read_bit()? == 0 {
leading_zeros += 1;
if leading_zeros > 31 {
return Err("invalid exp-Golomb code in SEI payload".into());
}
}
let suffix = self.read_bits(leading_zeros)?;
((1u32 << leading_zeros) - 1)
.checked_add(suffix)
.ok_or_else(|| "exp-Golomb value overflows u32".to_string())
}
/// se(v), spec 9.1.1: the ue(v) code point k maps to (1)^(k+1) · ⌈k/2⌉.
fn read_se(&mut self) -> Result<i32, String> {
let k = self.read_ue()?;
let magnitude = k.div_ceil(2);
let magnitude =
i32::try_from(magnitude).map_err(|_| "exp-Golomb value overflows i32".to_string())?;
Ok(if k % 2 == 1 { magnitude } else { -magnitude })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_minimal_recovery_point_message_parses_to_its_field_values() {
// Message: type 6, size 1. Payload bits: ue(0)='1', exact=0, broken=0, csg=00,
// then payload alignment '1' + zeros -> 0b1000_0100. NALU trailing 0x80.
let sei = [0x06, 0x01, 0x84, 0x80];
assert_eq!(
parse_recovery_point(&sei).unwrap(),
Some(RecoveryPoint {
recovery_frame_cnt: 0,
exact_match: false,
broken_link: false
})
);
}
#[test]
fn recovery_frame_cnt_and_both_flags_round_trip_through_the_bit_reader() {
// ue(5)='00110', exact=1, broken=1, csg=00, alignment -> 0b0011_0110 0b0100_0000.
let sei = [0x06, 0x02, 0x36, 0x40, 0x80];
assert_eq!(
parse_recovery_point(&sei).unwrap(),
Some(RecoveryPoint {
recovery_frame_cnt: 5,
exact_match: true,
broken_link: true
})
);
}
#[test]
fn earlier_messages_and_ff_coded_types_are_skipped_to_reach_the_recovery_point() {
// First message: ff-coded payload type 255 (0xFF 0x00), size 1, payload 0x55.
// Second message: type 5 (user data), size 3. Third: the recovery point.
let sei = [
0xFF, 0x00, 0x01, 0x55, // type 255
0x05, 0x03, 0xAA, 0xBB, 0xCC, // type 5
0x06, 0x01, 0x84, // recovery point
0x80,
];
assert_eq!(
parse_recovery_point(&sei).unwrap(),
Some(RecoveryPoint {
recovery_frame_cnt: 0,
exact_match: false,
broken_link: false
})
);
}
#[test]
fn emulation_prevention_bytes_inside_the_payload_are_removed_before_reading() {
// Unescaped payload (7 bytes): ue with a 22-zero prefix => recovery_frame_cnt
// 2^22-1 = 4194303, exact=1, broken=0, csg=00, alignment. Its first bytes are
// 00 00 02, which the escaper must have written as 00 00 03 02 on the wire.
let sei = [
0x06, 0x07, 0x00, 0x00, 0x03, 0x02, 0x00, 0x00, 0x04, 0x40, 0x80,
];
assert!(sei.windows(3).any(|w| w == [0x00, 0x00, 0x03]));
assert_eq!(
parse_recovery_point(&sei).unwrap(),
Some(RecoveryPoint {
recovery_frame_cnt: 4194303,
exact_match: true,
broken_link: false
})
);
}
#[test]
fn a_sei_nalu_without_a_recovery_point_yields_none_not_an_error() {
let sei = [0x05, 0x01, 0x00, 0x80];
assert_eq!(parse_recovery_point(&sei).unwrap(), None);
}
#[test]
fn a_payload_size_overrunning_the_nalu_is_a_parse_error() {
let sei = [0x06, 0x0A, 0x00];
assert!(parse_recovery_point(&sei).is_err());
}
#[test]
fn the_hevc_recovery_point_parses_its_se_coded_poc_count() {
// recovery_poc_cnt se(0) = '1', exact = 0, broken = 0, payload alignment:
// 0b1001_0000.
let sei = [0x06, 0x01, 0x90, 0x80];
assert_eq!(
parse_recovery_point_hevc(&sei).unwrap(),
Some(RecoveryPointHevc {
recovery_poc_cnt: 0,
exact_match: false,
broken_link: false
})
);
// se(-1) = '011' (ue code point 2), exact = 1, broken = 0, alignment:
// 0b0111_0100 — the negative range H.264's ue(v) syntax cannot express.
let sei = [0x06, 0x01, 0x74, 0x80];
assert_eq!(
parse_recovery_point_hevc(&sei).unwrap(),
Some(RecoveryPointHevc {
recovery_poc_cnt: -1,
exact_match: true,
broken_link: false
})
);
}
#[test]
fn the_hevc_parser_skips_earlier_messages_and_reports_absence_as_none() {
// User-data message first, then the recovery point (poc_cnt se(3): ue code
// point 5 = '00110', exact = 1, broken = 1, alignment: 0b0011_0111).
let sei = [
0x05, 0x02, 0xAA, 0xBB, // type 5
0x06, 0x01, 0x37, // recovery point
0x80,
];
assert_eq!(
parse_recovery_point_hevc(&sei).unwrap(),
Some(RecoveryPointHevc {
recovery_poc_cnt: 3,
exact_match: true,
broken_link: true
})
);
let sei = [0x05, 0x01, 0x00, 0x80];
assert_eq!(parse_recovery_point_hevc(&sei).unwrap(), None);
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Corpus replay: walk a captured real-host stream through the planners.
//!
//! The M0 capture hook (`PUNKTFUNK_DUMP_VIDEO=<dir>` on any desktop client) writes
//! the exact decoder input of a live session — `au-<stamp>.<codec>` plus an `.idx`
//! sidecar carrying `offset len flags complete` per AU. This harness feeds those AUs
//! back through [`pf_bitstream::h264::H264Planner`] / [`pf_bitstream::h265::H265Planner`]
//! and asserts the planner survives a REAL host stream: every AU plans (bar the
//! deliberate skips), no panic, and the warnings are only the ones a clean capture may
//! legitimately produce.
//!
//! Why this exists separately from the vendored conformance vectors: those prove we
//! match the spec's own test streams, and the on-glass sessions prove the whole pipe —
//! but between the two sits "does the planner handle what OUR five host encoder
//! families actually emit", which is the question the corpus was captured to answer.
//! For HEVC this is the ONLY pre-wiring validation against real host output (the
//! client's HEVC rung is still being built), so it runs long before M3 finishes.
//!
//! Ignored by default: captures are hundreds of megabytes and live outside the repo.
//! Run one explicitly —
//!
//! ```text
//! PF_CORPUS=/path/to/au-1785970273.h265 \
//! cargo test -p pf-bitstream --test corpus_replay -- --ignored --nocapture
//! ```
//!
//! The `.idx` sidecar is found next to the data file (`<data>.idx`); the codec comes
//! from the extension, matching the capture hook's own naming convention.
use std::path::Path;
use std::path::PathBuf;
/// One captured access unit: its byte range in the data file, plus the wire bits the
/// byte stream itself cannot carry.
struct CapturedAu {
offset: usize,
len: usize,
/// The wire `flags` byte (`USER_FLAG_*`) — kept for the RFI/intra-refresh legs,
/// which discriminate on it.
_flags: u32,
complete: bool,
}
/// Parse the `.idx` sidecar: one `offset len flags complete` line per AU, `#` comments
/// and blank lines skipped (the hook writes none today, but a hand-trimmed corpus file
/// is a thing a human will produce).
///
/// A malformed FINAL line is dropped with a note instead of failing: ending a capture
/// means killing the client, so the last buffered line is routinely half-written (the
/// hook's own docs call a truncated last AU acceptable). Anywhere else a malformed line
/// means the sidecar is corrupt and the run must not quietly replay a subset.
fn read_index(path: &Path) -> Vec<CapturedAu> {
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read the index sidecar {}: {e}", path.display()));
let lines: Vec<&str> = text
.lines()
.filter(|line| !line.trim().is_empty() && !line.trim_start().starts_with('#'))
.collect();
let last = lines.len().saturating_sub(1);
let mut out = Vec::with_capacity(lines.len());
for (n, line) in lines.iter().enumerate() {
match parse_index_line(line) {
Some(au) => out.push(au),
None if n == last => {
println!("note: dropping a truncated final index line ({line:?})");
}
None => panic!("index line {n} is malformed: {line:?}"),
}
}
out
}
/// One `offset len flags complete` line, or `None` when it is not four parsable fields.
fn parse_index_line(line: &str) -> Option<CapturedAu> {
let mut it = line.split_whitespace();
let num = |raw: &str| -> Option<u64> {
match raw.strip_prefix("0x") {
Some(hex) => u64::from_str_radix(hex, 16).ok(),
None => raw.parse().ok(),
}
};
let offset = num(it.next()?)?;
let len = num(it.next()?)?;
let flags = num(it.next()?)?;
let complete = num(it.next()?)?;
Some(CapturedAu {
offset: offset as usize,
len: len as usize,
_flags: flags as u32,
complete: complete != 0,
})
}
/// The capture named by `PF_CORPUS`, or `None` when the variable is unset.
fn corpus_from_env() -> Option<(PathBuf, Vec<u8>, Vec<CapturedAu>)> {
let path = PathBuf::from(std::env::var_os("PF_CORPUS")?);
let data = std::fs::read(&path)
.unwrap_or_else(|e| panic!("cannot read the capture {}: {e}", path.display()));
let mut idx = path.clone().into_os_string();
idx.push(".idx");
let mut index = read_index(Path::new(&idx));
// Same truncation story on the data side: the final AU's bytes may not all have
// reached the file before the client died. Drop AUs the data cannot cover — but
// only from the tail, so a short file can never silently hide a middle gap.
let covered = index
.iter()
.take_while(|au| au.offset.saturating_add(au.len) <= data.len())
.count();
if covered < index.len() {
println!(
"note: dropping {} index entr{} past the end of the data file (truncated capture)",
index.len() - covered,
if index.len() - covered == 1 {
"y"
} else {
"ies"
},
);
index.truncate(covered);
}
assert!(!index.is_empty(), "the capture's index is empty");
Some((path, data, index))
}
/// Per-AU outcome tally — what the run reports and asserts on.
#[derive(Default)]
struct Tally {
planned: usize,
skipped: usize,
errors: Vec<String>,
warnings: Vec<String>,
partial: usize,
}
impl Tally {
/// A clean capture of a healthy session must plan every complete AU. Errors are
/// hard failures; warnings are printed and capped — `MissingReference` on a stream
/// that never lost a packet would mean the planner invented a gap.
fn assert_clean(&self, total: usize) {
println!(
"planned {} / skipped {} / partial-AUs-ignored {} / errors {} / warnings {} \
(of {total} captured AUs)",
self.planned,
self.skipped,
self.partial,
self.errors.len(),
self.warnings.len(),
);
for w in self.warnings.iter().take(20) {
println!(" warning: {w}");
}
for e in self.errors.iter().take(20) {
println!(" ERROR: {e}");
}
assert!(
self.errors.is_empty(),
"{} AUs failed to plan — first: {}",
self.errors.len(),
self.errors[0],
);
assert!(
self.warnings.is_empty(),
"{} planner warnings on a clean capture — first: {}",
self.warnings.len(),
self.warnings[0],
);
assert!(self.planned > 0, "no AU planned at all");
}
}
#[test]
#[ignore = "needs a capture: PF_CORPUS=<au-file> (see the module docs)"]
fn a_captured_host_stream_replays_through_the_planner() {
let Some((path, data, index)) = corpus_from_env() else {
panic!("PF_CORPUS is unset — see the module docs for the invocation");
};
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_owned();
println!(
"replaying {} ({} bytes, {} AUs, codec {ext})",
path.display(),
data.len(),
index.len(),
);
let mut tally = Tally::default();
// The planners take one COMPLETE AU. A partial AU (the wire's shard split) is the
// pump's business, not the planner's — count and skip rather than feed a fragment.
let complete: Vec<&CapturedAu> = index.iter().filter(|au| au.complete).collect();
tally.partial = index.len() - complete.len();
match ext.as_str() {
"h265" => {
let mut planner = pf_bitstream::h265::H265Planner::new();
for (i, au) in complete.iter().enumerate() {
let bytes = &data[au.offset..au.offset + au.len];
match planner.plan_au(bytes) {
Ok(plan) => {
tally.planned += 1;
for w in &plan.warnings {
tally.warnings.push(format!("AU {i}: {w:?}"));
}
}
// The spec's own skip (8.1.3): decode nothing, show nothing, the
// stream is healthy — never an error (the WP-2 contract note).
Err(pf_bitstream::h265::PlanError::RaslSkipped { .. }) => tally.skipped += 1,
Err(e) => tally.errors.push(format!("AU {i}: {e}")),
}
}
}
"h264" => {
let mut planner = pf_bitstream::h264::H264Planner::new();
for (i, au) in complete.iter().enumerate() {
let bytes = &data[au.offset..au.offset + au.len];
match planner.plan_au(bytes) {
Ok(plan) => {
tally.planned += 1;
for w in &plan.warnings {
tally.warnings.push(format!("AU {i}: {w:?}"));
}
}
Err(e) => tally.errors.push(format!("AU {i}: {e}")),
}
}
}
other => panic!("no planner for a .{other} capture (h264/h265 only today)"),
}
tally.assert_clean(index.len());
}
+17
View File
@@ -0,0 +1,17 @@
# Vendored snapshot — see PROVENANCE.md. Deliberately NOT opted into workspace lints
# or workspace package inheritance: upstream code stays as close to pristine as the
# trim allows, so re-syncing against the AOSP tree stays a diff, not an archaeology dig.
[package]
name = "cros-codecs"
version = "0.0.5"
license = "BSD-3-Clause"
description = "Vendored cros-codecs parser layer (codec module only) for pf-bitstream"
edition = "2021"
[dependencies]
log = "0.4"
# Upstream's in-tree unit tests (kept — they are the conformance goldens) want these.
[dev-dependencies]
env_logger = "0.11"
serde_json = "1"
+26
View File
@@ -0,0 +1,26 @@
Copyright 2022 The ChromiumOS Authors
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+72
View File
@@ -0,0 +1,72 @@
# Vendored: cros-codecs (parser layer only)
- **Upstream:** <https://android.googlesource.com/platform/system/cros-codecs/> (the
authoritative AOSP tree). Snapshot taken from the read-only GitHub mirror
<https://github.com/chromeos/cros-codecs>, branch `main`,
commit **`5ff6d693ffae0b36935b8fc13092c733b4c2646f`**, fetched 2026-08-05.
- **License:** BSD-3-Clause (`LICENSE`, copied verbatim). Attribution headers retained
in every source file.
- **Why vendored, not a crates.io dependency:** the GitHub repo is a read-only mirror
and the crates.io release lags it; a pinned, reviewed snapshot is the supply-chain
posture punktfunk already uses elsewhere (`clients/android/native/vendor/ndk`,
`punktfunk-host/vendor/usbip-sim`). Decision of record:
punktfunk-planning `design/client-native-decode.md` §8.1.
## What was taken
`src/codec/{h264,h265,av1,vp9}` (parsers, DPBs, picture types, NALU/OBU machinery,
their `test_data` vectors — they double as punktfunk's conformance corpus),
`src/bitstream_utils.rs`, `LICENSE`. Upstream designed the `codec` module for exactly
this extraction — its module doc: "There shall be no dependencies from other modules of
this crate to this module, so that it can be turned into a crate of its own if needed
in the future."
## What was left behind
- `decoder/`, `encoder/`, `backend/`, `c2_wrapper/`, `video_frame`, `image_processing`,
`utils` — the Linux-only halves (libva/v4l2/gbm/nix). punktfunk's `pf-bitstream` +
`pf-vkdecode` occupy that layer.
- `codec/vp8` — VP9 has no dependency on it (verified) and no punktfunk host will ever
emit VP8.
## Deviations from pristine upstream
1. `src/lib.rs` — rewritten: keeps only the module decls and `Resolution` /
`ResolutionRoundMode` (the sole root items `codec` references), both copied verbatim;
adds crate-level `#![allow(clippy::all, mismatched_lifetime_syntaxes)]` — vendored
code is not held to the workspace lint bar (CI's `-D warnings` legs would fail on
upstream style otherwise).
2. `src/codec.rs` — one line removed (`pub mod vp8;`).
3. `Cargo.toml` — rewritten: `log` is the only dependency the vendored subset needs,
plus `env_logger`/`serde_json` dev-dependencies for upstream's in-tree tests.
4. `cargo fmt` normalization under the workspace's rustfmt config (mechanical only).
5. **Zero-unsafe, enforced**: `#![forbid(unsafe_code)]` added to lib.rs. Upstream's codec
module had exactly one production `unsafe` (h264/dpb.rs `build_ref_pic_lists`: ref→index
via pointer `offset_from`) — replaced with a safe `position(ptr::eq)` over the ≤16-entry
DPB — and three test-only `mem::zeroed()` asserts, replaced with `Default::default()`
(`PredWeightTable` derives `Default`; all-integer struct, identical value). The layer
facing untrusted bytes is now compiler-verified free of unsafe — the property that
motivates replacing libavcodec's C parsers in the first place.
6. `src/codec/h264/picture.rs``PictureData::new_from_slice`: `display_resolution`
computed as `visible_rect.max` instead of `max - min`. `Sps::visible_rectangle()`
returns the crop offset in `min` and the visible *size* in `max` (see its
definition: `max.x = width - crop_left - crop_right`); upstream's subtraction
double-counts the left/top crop and, worse, panics on u32 underflow for a
large-but-parser-valid `frame_crop_left_offset` (e.g. 100 crop units on a 320-wide
SPS). Found by pf-bitstream's conformance-window tests; upstream never hits it
because real encoders crop right/bottom only. **Reported upstream 2026-08-06:
<https://github.com/chromeos/cros-codecs/issues/99>.**
7. `src/codec/h265/parser.rs``parse_slice_header`: reject
`num_long_term_sps + num_long_term_pics > 16` before the long-term RPS loop.
Upstream bounds the pair only by `MAX_LONG_TERM_REF_PIC_SETS` (32) combined, while
every long-term array in `SliceHeader` (`poc_lsb_lt`, `used_by_curr_pic_lt`,
`delta_poc_msb_present_flag`, `delta_poc_msb_cycle_lt`, `lt_idx_sps`) is `[_; 16]`
— a hostile slice header with 17+ entries panics the parser with an
index-out-of-bounds (bounds checks stay on in release). Found by pf-bitstream's
H.265 planner review; regression-tested there
(`a_hostile_long_term_count_is_a_parse_error_not_a_panic`). **Reported upstream
2026-08-06: <https://github.com/chromeos/cros-codecs/issues/100>.**
Re-sync procedure: fetch the AOSP tree, re-apply this trim, diff `codec/` +
`bitstream_utils.rs` (expect near-zero conflicts), update the commit pin above.
@@ -0,0 +1,788 @@
// Copyright 2024 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use std::borrow::Cow;
use std::fmt;
use std::io::Cursor;
use std::io::Read;
use std::io::Seek;
use std::io::SeekFrom;
use std::io::Write;
use std::marker::PhantomData;
use crate::codec::h264::parser::Nalu as H264Nalu;
use crate::codec::h265::parser::Nalu as H265Nalu;
/// A bit reader for codec bitstreams. It properly handles emulation-prevention
/// bytes and stop bits for H264.
#[derive(Clone)]
pub(crate) struct BitReader<'a> {
/// A reference into the next unread byte in the stream.
data: Cursor<&'a [u8]>,
/// Contents of the current byte. First unread bit starting at position 8 -
/// num_remaining_bits_in_curr_bytes.
curr_byte: u8,
/// Number of bits remaining in `curr_byte`
num_remaining_bits_in_curr_byte: usize,
/// Used in emulation prevention byte detection.
prev_two_bytes: u16,
/// Number of emulation prevention bytes (i.e. 0x000003) we found.
num_epb: usize,
/// Whether or not we need emulation prevention logic.
needs_epb: bool,
/// How many bits have been read so far.
position: u64,
}
#[derive(Debug)]
pub(crate) enum GetByteError {
OutOfBits,
}
impl fmt::Display for GetByteError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "reader ran out of bits")
}
}
#[derive(Debug)]
pub(crate) enum ReadBitsError {
TooManyBitsRequested(usize),
GetByte(GetByteError),
ConversionFailed,
}
impl fmt::Display for ReadBitsError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ReadBitsError::TooManyBitsRequested(bits) => {
write!(f, "more than 31 ({}) bits were requested", bits)
}
ReadBitsError::GetByte(_) => write!(f, "failed to advance the current byte"),
ReadBitsError::ConversionFailed => {
write!(f, "failed to convert read input to target type")
}
}
}
}
impl From<GetByteError> for ReadBitsError {
fn from(err: GetByteError) -> Self {
ReadBitsError::GetByte(err)
}
}
impl<'a> BitReader<'a> {
pub fn new(data: &'a [u8], needs_epb: bool) -> Self {
Self {
data: Cursor::new(data),
curr_byte: Default::default(),
num_remaining_bits_in_curr_byte: Default::default(),
prev_two_bytes: 0xffff,
num_epb: Default::default(),
needs_epb: needs_epb,
position: 0,
}
}
/// Read a single bit from the stream.
pub fn read_bit(&mut self) -> Result<bool, String> {
let bit = self.read_bits::<u32>(1)?;
match bit {
1 => Ok(true),
0 => Ok(false),
_ => panic!("Unexpected value {}", bit),
}
}
/// Read up to 31 bits from the stream. Note that we don't want to read 32
/// bits even though we're returning a u32 because that would break the
/// read_bits_signed() function. 31 bits should be overkill for compressed
/// header parsing anyway.
pub fn read_bits<U: TryFrom<u32>>(&mut self, num_bits: usize) -> Result<U, String> {
if num_bits > 31 {
return Err(ReadBitsError::TooManyBitsRequested(num_bits).to_string());
}
let mut bits_left = num_bits;
let mut out = 0u32;
while self.num_remaining_bits_in_curr_byte < bits_left {
out |= (self.curr_byte as u32) << (bits_left - self.num_remaining_bits_in_curr_byte);
bits_left -= self.num_remaining_bits_in_curr_byte;
self.move_to_next_byte().map_err(|err| err.to_string())?;
}
out |= (self.curr_byte >> (self.num_remaining_bits_in_curr_byte - bits_left)) as u32;
out &= (1 << num_bits) - 1;
self.num_remaining_bits_in_curr_byte -= bits_left;
self.position += num_bits as u64;
U::try_from(out).map_err(|_| ReadBitsError::ConversionFailed.to_string())
}
/// Reads a two's complement signed integer of length |num_bits|.
pub fn read_bits_signed<U: TryFrom<i32>>(&mut self, num_bits: usize) -> Result<U, String> {
let mut out: i32 = self
.read_bits::<u32>(num_bits)?
.try_into()
.map_err(|_| ReadBitsError::ConversionFailed.to_string())?;
if out >> (num_bits - 1) != 0 {
out |= -1i32 ^ ((1 << num_bits) - 1);
}
U::try_from(out).map_err(|_| ReadBitsError::ConversionFailed.to_string())
}
/// Reads an unsigned integer from the stream and checks if the stream is byte aligned.
pub fn read_bits_aligned<U: TryFrom<u32>>(&mut self, num_bits: usize) -> Result<U, String> {
if self.num_remaining_bits_in_curr_byte % 8 != 0 {
return Err("Attempted unaligned read_le()".into());
}
Ok(self.read_bits(num_bits).map_err(|err| err.to_string())?)
}
/// Skip `num_bits` bits from the stream.
pub fn skip_bits(&mut self, mut num_bits: usize) -> Result<(), String> {
while num_bits > 0 {
let n = std::cmp::min(num_bits, 31);
self.read_bits::<u32>(n)?;
num_bits -= n;
}
Ok(())
}
/// Returns the amount of bits left in the stream
pub fn num_bits_left(&mut self) -> usize {
let cur_pos = self.data.position();
// This should always be safe to unwrap.
let end_pos = self.data.seek(SeekFrom::End(0)).unwrap();
let _ = self.data.seek(SeekFrom::Start(cur_pos));
((end_pos - cur_pos) as usize) * 8 + self.num_remaining_bits_in_curr_byte
}
/// Returns the number of emulation-prevention bytes read so far.
pub fn num_epb(&self) -> usize {
self.num_epb
}
/// Whether the stream still has RBSP data. Implements more_rbsp_data(). See
/// the spec for more details.
pub fn has_more_rsbp_data(&mut self) -> bool {
if self.num_remaining_bits_in_curr_byte == 0 && self.move_to_next_byte().is_err() {
// no more data at all in the rbsp
return false;
}
// If the next bit is the stop bit, then we should only see unset bits
// until the end of the data.
if (self.curr_byte & ((1 << (self.num_remaining_bits_in_curr_byte - 1)) - 1)) != 0 {
return true;
}
let mut buf = [0u8; 1];
let orig_pos = self.data.position();
while let Ok(_) = self.data.read_exact(&mut buf) {
if buf[0] != 0 {
self.data.set_position(orig_pos);
return true;
}
}
false
}
/// Reads an Unsigned Exponential golomb coding number from the next bytes in the
/// bitstream. This may advance the state of position within the bitstream even if the
/// read operation is unsuccessful. See H264 Annex B specification 9.1 for details.
pub fn read_ue<U: TryFrom<u32>>(&mut self) -> Result<U, String> {
let mut num_bits = 0;
while self.read_bits::<u32>(1)? == 0 {
num_bits += 1;
if num_bits > 31 {
return Err("invalid stream".into());
}
}
let value = ((1u32 << num_bits) - 1)
.checked_add(self.read_bits::<u32>(num_bits)?)
.ok_or::<String>("read number cannot fit in 32 bits".into())?;
U::try_from(value).map_err(|_| "conversion error".into())
}
pub fn read_ue_bounded<U: TryFrom<u32>>(&mut self, min: u32, max: u32) -> Result<U, String> {
let ue = self.read_ue()?;
if ue > max || ue < min {
Err(format!(
"Value out of bounds: expected {} - {}, got {}",
min, max, ue
))
} else {
Ok(U::try_from(ue).map_err(|_| String::from("Conversion error"))?)
}
}
pub fn read_ue_max<U: TryFrom<u32>>(&mut self, max: u32) -> Result<U, String> {
self.read_ue_bounded(0, max)
}
/// Reads a signed exponential golomb coding number. Instead of using two's
/// complement, this scheme maps even integers to positive numbers and odd
/// integers to negative numbers. The least significant bit indicates the
/// sign. See H264 Annex B specification 9.1.1 for details.
pub fn read_se<U: TryFrom<i32>>(&mut self) -> Result<U, String> {
let ue = self.read_ue::<u32>()? as i32;
if ue % 2 == 0 {
Ok(U::try_from(-(ue / 2)).map_err(|_| String::from("Conversion error"))?)
} else {
Ok(U::try_from(ue / 2 + 1).map_err(|_| String::from("Conversion error"))?)
}
}
pub fn read_se_bounded<U: TryFrom<i32>>(&mut self, min: i32, max: i32) -> Result<U, String> {
let se = self.read_se()?;
if se < min || se > max {
Err(format!(
"Value out of bounds, expected between {}-{}, got {}",
min, max, se
))
} else {
Ok(U::try_from(se).map_err(|_| String::from("Conversion error"))?)
}
}
/// Read little endian multi-byte integer.
pub fn read_le<U: TryFrom<u32>>(&mut self, num_bits: u8) -> Result<U, String> {
let mut t = 0;
for i in 0..num_bits {
let byte = self.read_bits_aligned::<u32>(8)?;
t += byte << (i * 8)
}
Ok(U::try_from(t).map_err(|_| String::from("Conversion error"))?)
}
/// Return the position of this bitstream in bits.
pub fn position(&self) -> u64 {
self.position
}
fn get_byte(&mut self) -> Result<u8, GetByteError> {
let mut buf = [0u8; 1];
self.data
.read_exact(&mut buf)
.map_err(|_| GetByteError::OutOfBits)?;
Ok(buf[0])
}
fn move_to_next_byte(&mut self) -> Result<(), GetByteError> {
let mut byte = self.get_byte()?;
if self.needs_epb {
if self.prev_two_bytes == 0 && byte == 0x03 {
// We found an epb
self.num_epb += 1;
// Read another byte
byte = self.get_byte()?;
// We need another 3 bytes before another epb can happen.
self.prev_two_bytes = 0xffff;
}
self.prev_two_bytes = (self.prev_two_bytes << 8) | u16::from(byte);
}
self.num_remaining_bits_in_curr_byte = 8;
self.curr_byte = byte;
Ok(())
}
}
/// Iterator over IVF packets.
pub struct IvfIterator<'a> {
cursor: Cursor<&'a [u8]>,
}
impl<'a> IvfIterator<'a> {
pub fn new(data: &'a [u8]) -> Self {
let mut cursor = Cursor::new(data);
// Skip the IVH header entirely.
cursor.seek(std::io::SeekFrom::Start(32)).unwrap();
Self { cursor }
}
}
impl<'a> Iterator for IvfIterator<'a> {
type Item = &'a [u8];
fn next(&mut self) -> Option<Self::Item> {
// Make sure we have a header.
let mut len_buf = [0u8; 4];
self.cursor.read_exact(&mut len_buf).ok()?;
let len = ((len_buf[3] as usize) << 24)
| ((len_buf[2] as usize) << 16)
| ((len_buf[1] as usize) << 8)
| (len_buf[0] as usize);
// Skip PTS.
self.cursor.seek(std::io::SeekFrom::Current(8)).ok()?;
let start = self.cursor.position() as usize;
let _ = self
.cursor
.seek(std::io::SeekFrom::Current(len as i64))
.ok()?;
let end = self.cursor.position() as usize;
Some(&self.cursor.get_ref()[start..end])
}
}
/// Helper struct for synthesizing IVF file header
pub struct IvfFileHeader {
pub magic: [u8; 4],
pub version: u16,
pub header_size: u16,
pub codec: [u8; 4],
pub width: u16,
pub height: u16,
pub framerate: u32,
pub timescale: u32,
pub frame_count: u32,
pub unused: u32,
}
impl Default for IvfFileHeader {
fn default() -> Self {
Self {
magic: Self::MAGIC,
version: 0,
header_size: 32,
codec: Self::CODEC_VP9,
width: 320,
height: 240,
framerate: 1,
timescale: 1000,
frame_count: 1,
unused: Default::default(),
}
}
}
impl IvfFileHeader {
pub const MAGIC: [u8; 4] = *b"DKIF";
pub const CODEC_VP8: [u8; 4] = *b"VP80";
pub const CODEC_VP9: [u8; 4] = *b"VP90";
pub const CODEC_AV1: [u8; 4] = *b"AV01";
pub fn new(codec: [u8; 4], width: u16, height: u16, framerate: u32, frame_count: u32) -> Self {
let default = Self::default();
Self {
codec,
width,
height,
framerate: framerate * default.timescale,
frame_count,
..default
}
}
}
impl IvfFileHeader {
/// Writes header into writer
pub fn writo_into(&self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
writer.write_all(&self.magic)?;
writer.write_all(&self.version.to_le_bytes())?;
writer.write_all(&self.header_size.to_le_bytes())?;
writer.write_all(&self.codec)?;
writer.write_all(&self.width.to_le_bytes())?;
writer.write_all(&self.height.to_le_bytes())?;
writer.write_all(&self.framerate.to_le_bytes())?;
writer.write_all(&self.timescale.to_le_bytes())?;
writer.write_all(&self.frame_count.to_le_bytes())?;
writer.write_all(&self.unused.to_le_bytes())?;
Ok(())
}
}
/// Helper struct for synthesizing IVF frame header
pub struct IvfFrameHeader {
pub frame_size: u32,
pub timestamp: u64,
}
impl IvfFrameHeader {
/// Writes header into writer
pub fn writo_into(&self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
writer.write_all(&self.frame_size.to_le_bytes())?;
writer.write_all(&self.timestamp.to_le_bytes())?;
Ok(())
}
}
/// Iterator NALUs in a bitstream.
pub struct NalIterator<'a, Nalu>(Cursor<&'a [u8]>, PhantomData<Nalu>);
impl<'a, Nalu> NalIterator<'a, Nalu> {
pub fn new(stream: &'a [u8]) -> Self {
Self(Cursor::new(stream), PhantomData)
}
}
impl<'a> Iterator for NalIterator<'a, H264Nalu<'a>> {
type Item = Cow<'a, [u8]>;
fn next(&mut self) -> Option<Self::Item> {
H264Nalu::next(&mut self.0).map(|n| n.data).ok()
}
}
impl<'a> Iterator for NalIterator<'a, H265Nalu<'a>> {
type Item = Cow<'a, [u8]>;
fn next(&mut self) -> Option<Self::Item> {
H265Nalu::next(&mut self.0).map(|n| n.data).ok()
}
}
#[derive(Debug)]
pub enum BitWriterError {
InvalidBitCount,
Io(std::io::Error),
}
impl fmt::Display for BitWriterError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BitWriterError::InvalidBitCount => write!(f, "invalid bit count"),
BitWriterError::Io(x) => write!(f, "{}", x.to_string()),
}
}
}
impl From<std::io::Error> for BitWriterError {
fn from(err: std::io::Error) -> Self {
BitWriterError::Io(err)
}
}
pub type BitWriterResult<T> = std::result::Result<T, BitWriterError>;
pub struct BitWriter<W: Write> {
out: W,
nth_bit: u8,
curr_byte: u8,
}
impl<W: Write> BitWriter<W> {
pub fn new(writer: W) -> Self {
Self {
out: writer,
curr_byte: 0,
nth_bit: 0,
}
}
/// Writes fixed bit size integer (up to 32 bit)
pub fn write_f<T: Into<u32>>(&mut self, bits: usize, value: T) -> BitWriterResult<usize> {
let value = value.into();
if bits > 32 {
return Err(BitWriterError::InvalidBitCount);
}
let mut written = 0;
for bit in (0..bits).rev() {
let bit = (1 << bit) as u32;
self.write_bit((value & bit) == bit)?;
written += 1;
}
Ok(written)
}
/// Takes a single bit that will be outputed to [`std::io::Write`]
pub fn write_bit(&mut self, bit: bool) -> BitWriterResult<()> {
self.curr_byte |= (bit as u8) << (7u8 - self.nth_bit);
self.nth_bit += 1;
if self.nth_bit == 8 {
self.out.write_all(&[self.curr_byte])?;
self.nth_bit = 0;
self.curr_byte = 0;
}
Ok(())
}
/// Immediately outputs any cached bits to [`std::io::Write`]
pub fn flush(&mut self) -> BitWriterResult<()> {
if self.nth_bit != 0 {
self.out.write_all(&[self.curr_byte])?;
self.nth_bit = 0;
self.curr_byte = 0;
}
self.out.flush()?;
Ok(())
}
/// Returns `true` if ['Self`] hold data that wasn't written to [`std::io::Write`]
pub fn has_data_pending(&self) -> bool {
self.nth_bit != 0
}
pub(crate) fn inner(&self) -> &W {
&self.out
}
pub(crate) fn inner_mut(&mut self) -> &mut W {
&mut self.out
}
}
impl<W: Write> Drop for BitWriter<W> {
fn drop(&mut self) {
if let Err(e) = self.flush() {
log::error!("Unable to flush bits {e:?}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ivf_file_header() {
let mut hdr = IvfFileHeader {
version: 0,
codec: IvfFileHeader::CODEC_VP9,
width: 256,
height: 256,
framerate: 30_000,
timescale: 1_000,
frame_count: 1,
..Default::default()
};
let mut buf = Vec::new();
hdr.writo_into(&mut buf).unwrap();
const EXPECTED: [u8; 32] = [
0x44, 0x4b, 0x49, 0x46, 0x00, 0x00, 0x20, 0x00, 0x56, 0x50, 0x39, 0x30, 0x00, 0x01,
0x00, 0x01, 0x30, 0x75, 0x00, 0x00, 0xe8, 0x03, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
];
assert_eq!(&buf, &EXPECTED);
hdr.width = 1920;
hdr.height = 800;
hdr.framerate = 24;
hdr.timescale = 1;
hdr.frame_count = 100;
buf.clear();
hdr.writo_into(&mut buf).unwrap();
const EXPECTED2: [u8; 32] = [
0x44, 0x4b, 0x49, 0x46, 0x00, 0x00, 0x20, 0x00, 0x56, 0x50, 0x39, 0x30, 0x80, 0x07,
0x20, 0x03, 0x18, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
];
assert_eq!(&buf, &EXPECTED2);
}
#[test]
fn test_ivf_frame_header() {
let mut hdr = IvfFrameHeader {
frame_size: 199249,
timestamp: 0,
};
let mut buf = Vec::new();
hdr.writo_into(&mut buf).unwrap();
const EXPECTED: [u8; 12] = [
0x51, 0x0a, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
assert_eq!(&buf, &EXPECTED);
hdr.timestamp = 1;
hdr.frame_size = 52;
buf.clear();
hdr.writo_into(&mut buf).unwrap();
const EXPECTED2: [u8; 12] = [
0x34, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
assert_eq!(&buf, &EXPECTED2);
}
#[test]
fn test_bitwriter_f1() {
let mut buf = Vec::<u8>::new();
{
let mut writer = BitWriter::new(&mut buf);
writer.write_f(1, true).unwrap();
writer.write_f(1, false).unwrap();
writer.write_f(1, false).unwrap();
writer.write_f(1, false).unwrap();
writer.write_f(1, true).unwrap();
writer.write_f(1, true).unwrap();
writer.write_f(1, true).unwrap();
writer.write_f(1, true).unwrap();
}
assert_eq!(buf, vec![0b10001111u8]);
}
#[test]
fn test_bitwriter_f3() {
let mut buf = Vec::<u8>::new();
{
let mut writer = BitWriter::new(&mut buf);
writer.write_f(3, 0b100u8).unwrap();
writer.write_f(3, 0b101u8).unwrap();
writer.write_f(3, 0b011u8).unwrap();
}
assert_eq!(buf, vec![0b10010101u8, 0b10000000u8]);
}
#[test]
fn test_bitwriter_f4() {
let mut buf = Vec::<u8>::new();
{
let mut writer = BitWriter::new(&mut buf);
writer.write_f(4, 0b1000u8).unwrap();
writer.write_f(4, 0b1011u8).unwrap();
}
assert_eq!(buf, vec![0b10001011u8]);
}
// These tests are adapted from the chromium tests at media/video/h264_bit_reader_unitttest.cc
#[test]
fn read_stream_without_escape_and_trailing_zero_bytes() {
const RBSP: [u8; 6] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xa0];
let mut reader = BitReader::new(&RBSP, true);
assert_eq!(reader.read_bits::<u32>(1).unwrap(), 0);
assert_eq!(reader.num_bits_left(), 47);
assert!(reader.has_more_rsbp_data());
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x02);
assert_eq!(reader.num_bits_left(), 39);
assert!(reader.has_more_rsbp_data());
assert_eq!(reader.read_bits::<u32>(31).unwrap(), 0x23456789);
assert_eq!(reader.num_bits_left(), 8);
assert!(reader.has_more_rsbp_data());
assert_eq!(reader.read_bits::<u32>(1).unwrap(), 1);
assert_eq!(reader.num_bits_left(), 7);
assert!(reader.has_more_rsbp_data());
assert_eq!(reader.read_bits::<u32>(1).unwrap(), 0);
assert_eq!(reader.num_bits_left(), 6);
assert!(!reader.has_more_rsbp_data());
}
#[test]
fn single_byte_stream() {
const RBSP: [u8; 1] = [0x18];
let mut reader = BitReader::new(&RBSP, true);
assert_eq!(reader.num_bits_left(), 8);
assert!(reader.has_more_rsbp_data());
assert_eq!(reader.read_bits::<u32>(4).unwrap(), 1);
assert!(!reader.has_more_rsbp_data());
}
#[test]
fn stop_bit_occupy_full_byte() {
const RBSP: [u8; 2] = [0xab, 0x80];
let mut reader = BitReader::new(&RBSP, true);
assert_eq!(reader.num_bits_left(), 16);
assert!(reader.has_more_rsbp_data());
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0xab);
assert_eq!(reader.num_bits_left(), 8);
assert!(!reader.has_more_rsbp_data());
}
// Check that read_ue behaves properly with input at the limits.
#[test]
fn read_ue() {
// Regular value.
let mut reader = BitReader::new(&[0b0001_1010], true);
assert_eq!(reader.read_ue::<u32>().unwrap(), 12);
assert_eq!(reader.data.position(), 1);
assert_eq!(reader.num_remaining_bits_in_curr_byte, 1);
// 0 value.
let mut reader = BitReader::new(&[0b1000_0000], true);
assert_eq!(reader.read_ue::<u32>().unwrap(), 0);
assert_eq!(reader.data.position(), 1);
assert_eq!(reader.num_remaining_bits_in_curr_byte, 7);
// No prefix stop bit.
let mut reader = BitReader::new(&[0b0000_0000], true);
reader.read_ue::<u32>().unwrap_err();
// u32 max value: 31 0-bits, 1 bit marker, 31 bits 1-bits.
let mut reader = BitReader::new(
&[
0b0000_0000,
0b0000_0000,
0b0000_0000,
0b0000_0001,
0b1111_1111,
0b1111_1111,
0b1111_1111,
0b1111_1110,
],
true,
);
assert_eq!(reader.read_ue::<u32>().unwrap(), 0xffff_fffe);
assert_eq!(reader.data.position(), 8);
assert_eq!(reader.num_remaining_bits_in_curr_byte, 1);
}
// Check that emulation prevention is being handled correctly.
#[test]
fn skip_epb_when_enabled() {
let mut reader = BitReader::new(&[0x00, 0x00, 0x03, 0x01], false);
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x00);
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x00);
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x03);
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x01);
let mut reader = BitReader::new(&[0x00, 0x00, 0x03, 0x01], true);
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x00);
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x00);
assert_eq!(reader.read_bits::<u32>(8).unwrap(), 0x01);
}
#[test]
fn read_signed_bits() {
let mut reader = BitReader::new(&[0b1111_0000], false);
assert_eq!(reader.read_bits_signed::<i32>(4).unwrap(), -1);
}
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2023 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//! Parsers for various kinds of encoded streams.
//!
//! This module does not provide any actual decoding tools - that's the job of the
//! [crate::decoder] module. However the parsers of this module are heavily used in order to
//! implement stateless decoding.
//!
//! There shall be no dependencies from other modules of this crate to this module, so that it
//! can be turned into a crate of its own if needed in the future.
pub mod av1;
pub mod h264;
pub mod h265;
pub mod vp9;
@@ -0,0 +1,9 @@
// Copyright 2023 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
mod helpers;
pub mod parser;
pub mod reader;
pub mod synthesizer;
pub mod writer;
@@ -0,0 +1,186 @@
// Copyright 2023 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use crate::codec::av1::parser::NUM_REF_FRAMES;
const DIV_LUT: [i32; 257] = [
16384, 16320, 16257, 16194, 16132, 16070, 16009, 15948, 15888, 15828, 15768, 15709, 15650,
15592, 15534, 15477, 15420, 15364, 15308, 15252, 15197, 15142, 15087, 15033, 14980, 14926,
14873, 14821, 14769, 14717, 14665, 14614, 14564, 14513, 14463, 14413, 14364, 14315, 14266,
14218, 14170, 14122, 14075, 14028, 13981, 13935, 13888, 13843, 13797, 13752, 13707, 13662,
13618, 13574, 13530, 13487, 13443, 13400, 13358, 13315, 13273, 13231, 13190, 13148, 13107,
13066, 13026, 12985, 12945, 12906, 12866, 12827, 12788, 12749, 12710, 12672, 12633, 12596,
12558, 12520, 12483, 12446, 12409, 12373, 12336, 12300, 12264, 12228, 12193, 12157, 12122,
12087, 12053, 12018, 11984, 11950, 11916, 11882, 11848, 11815, 11782, 11749, 11716, 11683,
11651, 11619, 11586, 11555, 11523, 11491, 11460, 11429, 11398, 11367, 11336, 11305, 11275,
11245, 11215, 11185, 11155, 11125, 11096, 11067, 11038, 11009, 10980, 10951, 10923, 10894,
10866, 10838, 10810, 10782, 10755, 10727, 10700, 10673, 10645, 10618, 10592, 10565, 10538,
10512, 10486, 10460, 10434, 10408, 10382, 10356, 10331, 10305, 10280, 10255, 10230, 10205,
10180, 10156, 10131, 10107, 10082, 10058, 10034, 10010, 9986, 9963, 9939, 9916, 9892, 9869,
9846, 9823, 9800, 9777, 9754, 9732, 9709, 9687, 9664, 9642, 9620, 9598, 9576, 9554, 9533, 9511,
9489, 9468, 9447, 9425, 9404, 9383, 9362, 9341, 9321, 9300, 9279, 9259, 9239, 9218, 9198, 9178,
9158, 9138, 9118, 9098, 9079, 9059, 9039, 9020, 9001, 8981, 8962, 8943, 8924, 8905, 8886, 8867,
8849, 8830, 8812, 8793, 8775, 8756, 8738, 8720, 8702, 8684, 8666, 8648, 8630, 8613, 8595, 8577,
8560, 8542, 8525, 8508, 8490, 8473, 8456, 8439, 8422, 8405, 8389, 8372, 8355, 8339, 8322, 8306,
8289, 8273, 8257, 8240, 8224, 8208, 8192,
];
const DIV_LUT_BITS: u32 = 8;
const DIV_LUT_PREC_BITS: u32 = 14;
/// Implements FloorLog2(x), which is defined to be the floor of the base 2
/// logarithm of the input x.
///
/// The input x will always be an integer, and will always be greater than or equal to 1.
/// This function extracts the location of the most significant bit in x.
pub fn floor_log2(mut x: u32) -> u32 {
assert!(x > 0);
let mut s = 0;
while x != 0 {
x >>= 1;
s += 1;
}
s - 1
}
/// Implements 5.9.3. Get relative distance function
pub fn get_relative_dist(enable_order_hint: bool, order_hint_bits: i32, a: i32, b: i32) -> i32 {
if !enable_order_hint {
0
} else {
let diff = a - b;
let m = 1 << (order_hint_bits - 1);
(diff & (m - 1)) - (diff & m)
}
}
/// Implements find_latest_backward from section 7.8.
pub fn find_latest_backward(
shifted_order_hints: &[i32; NUM_REF_FRAMES],
used_frame: &[bool; NUM_REF_FRAMES],
cur_frame_hint: i32,
latest_order_hint: &mut i32,
) -> i32 {
let mut _ref = -1;
for i in 0..NUM_REF_FRAMES {
let hint = shifted_order_hints[i];
if !used_frame[i] && hint >= cur_frame_hint && (_ref < 0 || hint >= *latest_order_hint) {
_ref = i as i32;
*latest_order_hint = hint;
}
}
_ref
}
/// Implements find_earliest_backward from section 7.8.
pub fn find_earliest_backward(
shifted_order_hints: &[i32; NUM_REF_FRAMES],
used_frame: &[bool; NUM_REF_FRAMES],
cur_frame_hint: i32,
earliest_order_hint: &mut i32,
) -> i32 {
let mut _ref = -1;
for i in 0..NUM_REF_FRAMES {
let hint = shifted_order_hints[i];
if !used_frame[i] && hint >= cur_frame_hint && (_ref < 0 || hint < *earliest_order_hint) {
_ref = i as i32;
*earliest_order_hint = hint;
}
}
_ref
}
/// Implements find_latest_forward from section 7.8.
pub fn find_latest_forward(
shifted_order_hints: &[i32; NUM_REF_FRAMES],
used_frame: &[bool; NUM_REF_FRAMES],
cur_frame_hint: i32,
latest_order_hint: &mut i32,
) -> i32 {
let mut _ref = -1;
for i in 0..NUM_REF_FRAMES {
let hint = shifted_order_hints[i];
if !used_frame[i] && hint < cur_frame_hint && (_ref < 0 || hint >= *latest_order_hint) {
_ref = i as i32;
*latest_order_hint = hint;
}
}
_ref
}
pub fn tile_log2(blk_size: u32, target: u32) -> u32 {
let mut k = 0;
while (blk_size << k) < target {
k += 1;
}
k
}
pub fn clip3(x: i32, y: i32, z: i32) -> i32 {
if z < x {
x
} else if z > y {
y
} else {
z
}
}
/// 5.9.29
pub fn inverse_recenter(r: i32, v: i32) -> i32 {
if v > 2 * r {
v
} else if v & 1 != 0 {
r - ((v + 1) >> 1)
} else {
r + (v >> 1)
}
}
/// Implements Round2. See 4.7: mathematical functions.
pub fn round2(x: u32, n: u32) -> u32 {
(x + 2u32.pow(n - 1)) / 2u32.pow(n)
}
/// Implements Round2Signed. See 4.7: mathematical functions.
pub fn round2signed(x: i32, n: u32) -> Result<i32, String> {
if x >= 0 {
i32::try_from(round2(x as u32, n)).map_err(|e| e.to_string())
} else {
let x = x as i64;
let val = i32::try_from(round2(-x as u32, n)).map_err(|e| e.to_string())?;
Ok(-val)
}
}
/// Implements 7.11.3.7. Resolve divisor process
pub fn resolve_divisor(d: i32) -> Result<(u32, i32), String> {
let abs_d = u32::try_from(d.abs()).unwrap(); // abs cannot return a negative
let n = floor_log2(abs_d);
let e = abs_d - (1 << n);
let f = if n > DIV_LUT_BITS {
round2(e, n - DIV_LUT_BITS)
} else {
e << (DIV_LUT_BITS - n)
};
let div_shift = n + DIV_LUT_PREC_BITS;
let div_factor = if d < 0 {
-DIV_LUT[f as usize]
} else {
DIV_LUT[f as usize]
};
Ok((div_shift, div_factor))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,251 @@
// Copyright 2023 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use crate::bitstream_utils::BitReader;
use crate::codec::av1::helpers;
use super::parser::AnnexBState;
pub(crate) struct Reader<'a>(pub BitReader<'a>);
impl<'a> Reader<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self(BitReader::new(data, false))
}
/// Implements uvlc(): Variable length unsigned n-bit number appearing
/// directly in the bitstream. See 4.10.3
pub fn read_uvlc(&mut self) -> Result<u32, String> {
let mut leading_zeroes = 0;
loop {
let done = self.0.read_bit()?;
if done {
break;
}
leading_zeroes += 1;
}
if leading_zeroes >= 32 {
return Ok(u32::MAX);
}
let value = self.0.read_bits::<u32>(leading_zeroes)?;
Ok(value + (1 << leading_zeroes) - 1)
}
/// Implements leb128(): Unsigned integer represented by a variable number
/// of little-endian bytes. See 4.10.5
pub fn read_leb128(&mut self) -> Result<u32, String> {
let mut value = 0u64;
for i in 0..8 {
let byte = u64::from(self.0.read_bits_aligned::<u32>(8)?);
value |= (byte & 0x7f) << (i * 7);
if byte & 0x80 == 0 {
break;
}
}
Ok(value as u32)
}
/// Implements su(n): Signed integer converted from an n bits unsigned
/// integer in the bitstream. (The unsigned integer corresponds to the
/// bottom n bits of the signed integer.). See 4.10.6
pub fn read_su(&mut self, num_bits: usize) -> Result<i32, String> {
let mut value: i32 = self
.0
.read_bits::<u32>(num_bits)?
.try_into()
.map_err(|_| String::from("Read more than 31 signed bits!"))?;
let sign_mask = 1 << (num_bits - 1);
if (value & sign_mask) != 0 {
value -= 2 * sign_mask;
}
Ok(value)
}
/// Implements ns(n): Unsigned encoded integer with maximum number of values
/// n (i.e. output in range 0..n-1). See 4.10.7
pub fn read_ns(&mut self, num_bits: usize) -> Result<u32, String> {
let w = helpers::floor_log2(num_bits as u32) + 1;
let m = (1 << w) - num_bits as u32;
let v = self.0.read_bits::<u32>(
usize::try_from(w).map_err(|_| String::from("Invalid num_bits"))? - 1,
)?;
if v < m.into() {
return Ok(v);
}
let extra_bit = self.0.read_bit()?;
Ok((v << 1) - u32::from(m) + u32::from(extra_bit))
}
/// Implements 5.9.13: Delta quantizer syntax.
pub fn read_delta_q(&mut self) -> Result<i32, String> {
let delta_coded = self.0.read_bit()?;
if delta_coded {
self.read_su(7)
} else {
Ok(0)
}
}
pub fn more_data_in_bitstream(&mut self) -> bool {
self.0.num_bits_left() > 0
}
pub(crate) fn consumed(&self, start_pos: u32) -> u32 {
(self.0.position() / 8) as u32 - start_pos
}
/// Get the length of the current OBU in AnnexB format.
pub fn current_annexb_obu_length(
&mut self,
annexb_state: &mut AnnexBState,
) -> Result<Option<usize>, String> {
if !self.more_data_in_bitstream() {
return Ok(None);
}
#[allow(clippy::comparison_chain)]
if annexb_state.temporal_unit_consumed == annexb_state.temporal_unit_size {
annexb_state.temporal_unit_size = 0;
} else if annexb_state.temporal_unit_consumed > annexb_state.temporal_unit_size {
return Err(format!(
"temporal_unit_size is {} but we consumed {} bytes",
annexb_state.temporal_unit_size, annexb_state.temporal_unit_consumed,
));
}
if annexb_state.temporal_unit_size == 0 {
annexb_state.temporal_unit_size = self.read_leb128()?;
if annexb_state.temporal_unit_size == 0 {
return Ok(None);
}
}
let start_pos = self.consumed(0);
#[allow(clippy::comparison_chain)]
if annexb_state.frame_unit_consumed == annexb_state.frame_unit_size {
annexb_state.frame_unit_size = 0;
} else if annexb_state.frame_unit_consumed > annexb_state.frame_unit_size {
return Err(format!(
"frame_unit_size is {} but we consumed {} bytes",
annexb_state.frame_unit_size, annexb_state.frame_unit_consumed,
));
}
if annexb_state.frame_unit_size == 0 {
annexb_state.frame_unit_size = self.read_leb128()?;
if annexb_state.frame_unit_size == 0 {
return Ok(None);
}
annexb_state.temporal_unit_consumed += self.consumed(start_pos);
}
let start_pos = self.consumed(0);
let obu_length = self.read_leb128()?;
let consumed = self.consumed(start_pos);
annexb_state.temporal_unit_consumed += consumed;
annexb_state.frame_unit_consumed += consumed;
Ok(Some(obu_length.try_into().unwrap()))
}
/// Implements 5.3.4.
pub fn read_trailing_bits(&mut self, mut num_bits: u64) -> Result<(), String> {
let trailing_one_bit = self.0.read_bit()?;
num_bits -= 1;
if !trailing_one_bit {
return Err("bad padding: trailing_one_bit is not set".into());
}
while num_bits > 0 {
let trailing_zero_bit = self.0.read_bit()?;
if trailing_zero_bit {
return Err("bad padding: trailing_zero_bit is set".into());
}
num_bits -= 1;
}
Ok(())
}
fn decode_subexp(&mut self, num_syms: i32) -> Result<u32, String> {
let mut i = 0;
let mut mk = 0;
let k = 3;
loop {
let b2 = if i != 0 { k + i - 1 } else { k };
let a = 1 << b2;
if num_syms <= mk + 3 * a {
let num_bits = num_syms - mk;
let subexp_final_bits = self.read_ns(num_bits as usize)?;
return Ok(subexp_final_bits);
} else {
let subexp_more_bits = self.0.read_bit()?;
if subexp_more_bits {
i += 1;
mk += a;
} else {
let num_bits = b2 as usize;
let subexp_bits = self.0.read_bits::<u32>(num_bits)?;
return Ok(subexp_bits + mk as u32);
}
}
}
}
/// Implements 5.9.27.
pub fn decode_unsigned_subexp_with_ref(&mut self, mx: i32, r: i32) -> Result<u32, String> {
let v = self.decode_subexp(mx)?;
if (r << 1) <= mx {
Ok(helpers::inverse_recenter(r, v.try_into().unwrap())
.try_into()
.unwrap())
} else {
let res = mx - 1 - helpers::inverse_recenter(mx - 1 - r, v.try_into().unwrap());
Ok(res.try_into().unwrap())
}
}
/// Implements 5.9.26.
pub fn decode_signed_subexp_with_ref(
&mut self,
low: i32,
high: i32,
r: i32,
) -> Result<i32, String> {
let x = self.decode_unsigned_subexp_with_ref(high - low, r - low)?;
Ok(i32::try_from(x).unwrap() + low)
}
/// Implements 5.3.5 Byte alignment syntax
pub fn byte_alignment(&mut self) -> Result<(), String> {
while (self.0.position() & 7) != 0 {
self.0.read_bit()?;
}
Ok(())
}
}
impl<'a> Clone for Reader<'a> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
#!/bin/bash
# Generates the CRCs for all .av1 files in the current directory using ffmpeg.
for f in `ls *.av1`; do
ffmpeg -i $f -pix_fmt nv12 -f framehash -hash crc32 - |grep -v '^#' |awk '{print $6}' >$f.crc
ffmpeg -i $f -pix_fmt nv12 -f framehash -hash md5 - |grep -v '^#' |awk '{print $6}' >$f.md5
done
@@ -0,0 +1,260 @@
{
"profile": "AV1PROFILE_PROFILE_MAIN",
"width": 320,
"height": 240,
"frame_rate": 25,
"num_frames": 250,
"num_fragments": 250,
"md5_checksums": [
"83dab175e49c33a6e3ece5c3758d1bf6",
"cedb4e25453dba430cb6ee830c1643f3",
"ef14d142df162819eee800f3930a9e95",
"11849ccb72cfdabc8f70e33271cd916f",
"7c9856ed61566f399a1eda2243e199ca",
"9394ed10354e986ecdfa2b753b921d2b",
"573005df5ba8f982980c1f427dfb472e",
"a96d9a913b1712f37f73b6c47b577cdf",
"91fc52b076badd1e6cbddb9bca5c5cc3",
"6253dde984bc5282f01005d7ebb55fe9",
"ad88146fe374423e7c597a922049c76c",
"ca7d1ddea7269e476b43a805bd5dd50b",
"e3d1eeec3cbfa363a2222d274890be84",
"fd7902a1b7352e04dc3940a010482a67",
"cada3ee9e33c11c99f89c3f439e2723e",
"1723ee290ae8be930f2904cb5aaf9de4",
"bf48d536e70a6a0a6549e221a833ebe4",
"f13b2e454420b1e0e5ab0adbbf2ce72f",
"31c799b4bb0971b798970e23b906cd25",
"3d925424bf645a225caf64757944a6e5",
"ac55ba724f54dd068350fd2818064dd5",
"a7125b8b0dc1e464e76ee64956897d22",
"f2a00db38d83cc778fa9fbdb3f4515e3",
"c804823f3401558a0283c4e77888af20",
"662d71a06319faef70a0981b664481d2",
"ad3a221a5f1f9d1a615733155a194385",
"8b071335d6cee4b227782415b1cb8a13",
"f3d98bfac8e5083233b88386b539b790",
"739c73e71590e64db0629911039962b4",
"0944990b0ada4012cc686b06833264ef",
"77e306874ec1b0c91668f8df0953831b",
"ca376820c5248cccb221ec8cb4b0eb9e",
"86a72cec3aaf50e393880ac8d4139921",
"b892ef1ff0c169b683b35cbaa9462ee3",
"b5feafe6c294d29adb1b05138803be36",
"3c3b150801f2dc48d300380c9a932890",
"16b06dfb6e426a1b6d88e17de83f5e2c",
"b1633b1c661a645bd1b04317b30fdbfa",
"56d08d66ad8042ba295c3f86025d44cf",
"78f6697fbb3af79dbd614f495c372031",
"afb700399aacde4c8c895def06fd8594",
"e7ae993d18af5c58047196d8369eee3e",
"65d5d77181388229606972ea99f5191a",
"bd336774b22d502d8fdca07d881d737e",
"5cabd4479d94c040f86892fa41860ca9",
"9ab949d5ede2f25889a9612b39f6f158",
"bb54bb8d7782c4b2d63f201a8d338e7d",
"9bf73fc10b4bb19021fd07586a0401d1",
"a37621cda632ac5f58e03272d226e53b",
"5c3ca20aa646d72b69f6e56592fd2f0b",
"b8ff47ec622ce73e70cfc913a3f8116e",
"1d020f74b9d3adf17b3f3da6992fcb52",
"0236c4d4a9aa68ba59eeaeb6b010db26",
"78a8e16aebbe5fc3d1e6051153031a51",
"78a54c1182b8ce007a2c10d17915272d",
"c7ac4f87168bbddd01155f54c79bc132",
"e074be8ece629e08d6d93029b8d34ce2",
"edfba91c624f4cdc558c693b33165542",
"f3d005151909922bfed7a905693de7c0",
"80118fa45af8b3e486928aae49d85b94",
"e7663b97fc9b26348876d5fa64a54d5d",
"69200a2a34bd4beccf4d473b4a4976f3",
"82f6c63a1d87037c08fba3bf16ef9bf8",
"6e3033f25eb56670a3c45c12822f340c",
"6085ac2c582fd3048c240d3dc46e3455",
"b4bbf1a7ae3f57b1d8f0ed65070e010b",
"e7f0aad67af5c7719eb89e457cefe6b4",
"6d5fdc1d68e136ce9095696be59b617b",
"bf15759321de7b21ec3c1bd2cb98ca69",
"fc4d67815a94c2e18e7d8b0b7d866641",
"0d96425a800252d500fd25da7dccb94d",
"5438c9e83cc90f030ee9b1876ec28bdc",
"a88915fd11ad765299cef49c8e65e8ab",
"4995a3442fa74849f3c45888037f9b42",
"82693761e531127d2a98cdc5615cdb92",
"07b9601bebc32dc33fd806f90cee586f",
"d68df0d6b1dae0e4428a17717723dbe0",
"2851422da5bcceebf57ae37a02a1fb57",
"ae9ff5d20ea6ba6105fb5ed3e0109834",
"a0f956663545e54acbaecf684d0841ff",
"23589649e474e155583e6742fa79098c",
"9031fbd94bd7a0508b48f8081018d447",
"b72920bce007e8f0584d615529dda39f",
"0638d4c62dbf43087b4831f2e39314fe",
"7d34a23c9b78a61da7a60c520784e204",
"0a3cf1af9095a7cb2787cf79ed257c59",
"1c05ab5abe793c14ee53ea80e170d6cc",
"02d0b03fe10b1b6675b86390fe317778",
"7639df2f62c4f958a90337b19a591530",
"6080ee3519bd5dd88fbb4050459c126b",
"75ce7282839cda34f7ca6111d6353cdb",
"2214093ad2bb512a223808ed78fe3369",
"eb9bfad6ef08a5f28b531203db3fcb43",
"cf4fd68dd57df907f35cc55fe5930fb8",
"0aaf799fc8509acd1168cd667f789c9c",
"e36ebe2332eb5a9a0ddd03469493d3cb",
"49ec5c818b0fcc02fbe3f5636417a2da",
"8430b81f1a90c325d116cec424d3c32c",
"c67e1e1f7fec9f9ab688f1d54cf2f4d4",
"fd98fabb3db5dc2d841dc50af7395130",
"8af0c8f486057cbae86939fa248c8238",
"e1888b1f32d2991682261f28f860cb27",
"9c0455744df622675a6d5232d790f5b1",
"acc73e6665f54bdaa98acead49be5f65",
"5c6e6167e6c05411a22c348a2c807fc5",
"89d09cb95dc2e212efed266b4b687b35",
"9d35702693b699ac0ccb4bffc559e2e8",
"afbeb04e4e00a1b0a4407b4a34815a63",
"6f3515d4331d83abfd1dbdfb2de78189",
"be6cb0da9149ad5d68269d0ed657cd38",
"9e9f498bc71df3097caa266089a77fba",
"40d9198beea17a254ee6b74544317a7a",
"e2f04358338ae115635e621cf229df56",
"5da22d2d7c5c304fc6198f809206de1d",
"4951c1ad5ea945a098c6a4d7fad163fa",
"5a25300d1ef28b60f9e137b47b2e4364",
"5954dd49f459ea4c0984dba30076db89",
"820a838b57e43271a55f2f17c3e0676f",
"b486477fe004ed8ee213afe8ecdb35c6",
"0715a96ada76fe2d713cdfc2b9abf11d",
"c30544fc39df1c7876043115ceed355f",
"1f7ce18debf0e339db20c68c1f0bf8b8",
"e10229f1f8a95d2e59a086e5d6ac4faa",
"8ad6c73b38d32fd1712a1fd67750d364",
"30fdf5b06ae3fd2c0df9e631ea1e1048",
"0026b1e7db5e25651ecaaf19685b2dc2",
"0b53f3f2ca17d00138b4678580d63ab6",
"cb08de2030a42826ef573082188d3614",
"e14242c776b3a32da936d1f6484e29ba",
"41a43d3ee9021bcda09efa1b218cc65a",
"9b884d243e8b330edb8f569e416960f1",
"5737c4f611cdc9871df094ba51a6a6d5",
"9d47768c1a15c0b20d28545ad5e6ca53",
"8c981af4e6c3432a330b6d8e330bc03a",
"195910783821f51cb3dad54f651c1a75",
"659833dc03dc5f929eb140e188f2b1f6",
"f2f30eaa72c5a093d67cebfda15fac73",
"3c521e3025340933cf826027a994114b",
"469b72a8db2ac77c70a792c8035b0288",
"e2cf1d7930d44341c5c363b0ceaa6c84",
"469d4283cca04e78ac2cb9407531db67",
"02416a6c77174796ecbd61adab5177b9",
"f89077b3dfb54119157e04dff24b79bd",
"2d1a8fc14b649e343288b5972abe1fdf",
"3d059186dab49cd8d13584dc365862db",
"765ef42f457dee16234df6992ec74cd2",
"957589c357d82631477177e6250c713c",
"2473af8395d08e9396216c082f854d6f",
"6a117d16f8e8ffe87aebec05844d6a0a",
"8e467cfcbd66ed80fca648248af56b3f",
"71e39c4304e09688750aa85e9d040f4a",
"3071ee85d87ef8029619d4777c799c2b",
"cc86f09e3f228001e806fd3afe5e1271",
"3828673cf67828240005f7bf9fe37412",
"12700b04cc22fba7688ae1709e48b886",
"a1045aa13b0007d23f22ad8e83c4bdb0",
"a12e6c2d2805336c407417b7503670fe",
"11dd1907c6785eccbedeba6a5708c516",
"3fb8be1276b83db1ccd8206cc69ec736",
"980ce03ae4e6f2eceb314f37290e0dd2",
"c39395d9b6108f1bd4ab1f004c44a5f0",
"a7244c89efd3b00611c92ebaf98793e8",
"fbede5a2957c023216e79cc31ab32946",
"bb40544a3adfe992e07a8eb3e5bf6966",
"0f71f57100699e95fff51dabde86cb42",
"6ba085efefacf385728423ca99bd4629",
"4df2d956234cb2129ebefdd90f28185a",
"d0a0d4263ef4f32d59b004752a5b94af",
"a9deed8bf550f4ee370730e1d993d4dc",
"eca65ba94930bf78854ce991c05e3d9c",
"4eef9fa5bca7ee74e97a0ac6e12e5f53",
"84e97d688ba1922f9ed71d72cea67259",
"407dc974664f93be14c53a2b291cc422",
"31eb9e589470cfbe4e72e1c441d56e3f",
"ea4f696ad2ae150a1f0e622874f67fca",
"7552fea18e053b4771c0490015488291",
"6863cd478581244fff72f6a0021a9fb8",
"9118deacdd49db27c317eeec868cd870",
"50290c87c5ee1c558bd304fc0f4a15ea",
"5aef6d78538151648450fd391c9deaec",
"a7e3feb23e01e0b4556446e01b2d81eb",
"6918645d2b0bd511247c6ed372bf213e",
"2f9661df4d114ab9f3a98a255a8177f2",
"baaea6f9f05a7a2bc10721f1afe6f57b",
"366ab99c236a8e84e658bad69d82a1ba",
"f950a6508c051ef32e4c04be56fcc719",
"9e586c73c3b9daebc608600efbee33b0",
"e9fb7690077e8cbac15d5564130a8d7c",
"ef3c8d2b5376753c26a795bf403cdcc1",
"be46eec37b67dfc8077705ce89588a7c",
"dba1b08cfeddbcf4e8d79621ee343bd4",
"a4a612bcb6c33c7799433c998c5f12d1",
"5f45fa214bc860825510d034f1ff26de",
"1766dd6fe6080c408a4baca135609f1c",
"34dc16d50d7afeb93a874563f1f5e4fd",
"0a5314f217972a9271c4792921f61153",
"7c228f1f5ccc6f7307526531f79a307d",
"188544479f5a44b6a90b6be62e33611a",
"9cb9be5574c6a2c06862a3ed605c68b5",
"845b033cc82472d8e647f2a3bb3eb653",
"2c6b66c68107fd13395a1ed5f0639355",
"0436424e24e562c52ab7e7063b04c129",
"21248608746bb252faa347028f5343d7",
"81607476ae05bbf7386dea9d8eac352b",
"fadc83171e49e779ebe5d81769049cef",
"e768c96238a8cf14caf8d03420981265",
"c57aa4d8e2f7c9101868246de4de1c13",
"b405a01f8eb1d4ffade0e49f593e5a8f",
"76f02bab5b932bf4bcf4ce06bdf0e42c",
"51b09ce7117e15cf654ad76e13263f0c",
"45f650b845e31b87b522574cc7afde80",
"858605e70124ef7130742b713b079ad9",
"5d955329a007742ecab7153d2e69b262",
"c4139450cb6ffcac62b764d62083bc31",
"8b13de43cffa8d12dd17b8e749a375b2",
"b000fbdb48be876302bc336aeb6deba1",
"88e2b7abbc33f70231889113582f8f8c",
"d9f31d4cfeee31da719b3567d5d11a19",
"7c2a6cc9be9ce8cb96b5a1616909941b",
"954c2be78496f0235ab137a98a5ae11a",
"9efd13fe15e8900f67a6c76103c9ce78",
"b04aedbee4993ff955ebba1dcb8a04e1",
"83b045a2a43445f97d82c1faaf33b332",
"1cf416bf7bf4a2124932d4b1bb0aca94",
"ac17d329b587b065512269fa3def8279",
"fcf2b7ab8b7d53741c737f75c3587a6f",
"0aaaaca0aa165a6786e2161043aa1e72",
"8d19a0848fad7b32a15522cc0b24b4d1",
"be0a1be3f90ff47b80be31be021c28f1",
"c9e423c59186d93100c56ad8f14c0fb4",
"052dbb80693750c716acce06f3ad7266",
"6a2c8002df49d3832b7e1abd70af542f",
"92bec3aa91edfd2cc5dee7d1f66ac189",
"579d29110e7a7a71152aedb9a462c79f",
"b0bb435df9dd211dd5da3c547c8887b8",
"fb7a3cf05afd06668287f5dcc9c20a55",
"bbd27b9b555f361478f9516e839077a5",
"a74dc95fe982709b25434bcc53a91973",
"27f82418568440caea8df7bcdf56eae7",
"d5ff54f75c8fa6d60428816945ee5e89",
"4b6677c4cc9866f477ecda5618411c8d",
"d56046f25113a82b090bf9a91291587c",
"cb56aa0f4343c7da821c04e55e8af21a",
"c633ea8a4c20ea4d70c8c241d0293e9b",
"dd2b157ce8a010a61908170ecb3e31b0",
"3c3509c92b03a702ac1f4a28b5abd3d5",
"d1400e54b8b61241cabad5291d6e3a43",
"c29470cd6afb0aee4ae4a8d0622a468d",
"e35764f7bd48746d37478b2d6f3f2755",
"3dcf1fd38fbcc1d45f98574b17ac5710"
]
}
@@ -0,0 +1,250 @@
6aea6152
cb4a90b3
fd83e35f
074bd081
216fcc04
c73ca1e4
fbfb2a30
cd587935
e8bc2912
051517b7
f3e9831d
812e7bba
3e2054e6
7446385e
8b75d043
f930d9a7
7bf6b591
253c5389
11a25f1f
c5101d08
ee1c0aae
ce055a9f
0ed4a046
aa0a72c3
87f7a598
aa3a422e
0ee0e533
5ce3d683
cc7e88e5
3e1c7774
bd6708d3
0ba6a0ee
ac00d2da
f1222e65
3c6aadc8
a28e7327
fd2d0d8c
e0a791bf
90a33d7e
63ae28ce
26230e31
215e9021
6ca1295b
01604116
15f8d3c9
6f28a1d4
8d54633d
08cccd41
0b082923
0aaae5a1
98310e98
33f12578
9fd072e0
36a1220e
a278c894
828dcf3f
3573505c
861874b7
dd78b1fd
310ca9a0
74d670f3
93c48c50
814991b1
fca836a7
9299e121
a8d3b267
661a1c05
50038462
bc866fe1
46e2d826
7035f78c
00c2ed17
6b64a0c6
315f4761
df4c3ef7
7ad4996a
3185972f
19af5f17
01c4e3a3
3728bd7e
16adc384
5798878c
d0ac34ee
56f76f8a
2b6f3f47
4a5ec1a9
75f0027c
7a1dd1f6
50c0f14f
978d00e7
a1c430b8
7c67432a
4615b4cb
c53c7308
876d9f98
b0015dd1
73288a1e
d843f40a
4127f309
3adbe013
9e84104c
9f98680d
380a36e2
2202f414
ca931e02
182b20e1
815a6be2
59cc5d53
0a05d420
d53b5f2d
98c613aa
e4de4fc1
2b8f6d29
48ce9586
d6e822dd
c8674577
75dfa0e9
7ad6ed39
f7725767
a44e0f11
bcd743cd
1ccf1835
8ceb6153
0a43af09
79bab263
855dd65a
a37d34fe
be063b4d
aec5df0c
7b12399b
c5ad7294
2f609050
8bb2dcba
c19aa763
700eb99b
11bf5174
e74d5eda
f54e54ef
0173e328
162372d6
3c8f18a3
ce8579c7
91bdbf86
745edb27
df783664
e7462cfd
30d8d761
f15474e6
4c5bb874
27c36dd2
c40981c0
1cc4b7c0
ebd96a40
a5f4d31e
cd1a957a
0b0d8e1d
c5d30b99
d7d6b75e
0ea512b8
dd12fced
29eab68d
e4bb47d1
e3060770
0483e6ca
296b084f
d11abff6
c7589394
b187d62e
2e217b3c
b516603b
287c8b71
2f24a994
7ea617d0
7ba0024e
7067ae3e
95650cbd
7b2e7deb
d1b6da6d
76f22a5a
9ce6fac7
1e5cf6c7
bbe20b47
2f2cc450
3f0297f3
a2084503
06487daa
5d06ae48
02cbba1b
b386b4f4
47623370
1acb6e9d
5b55fd00
70d43ae3
8a0703c0
4d62a79b
e358a27d
b89fcec0
59508160
f2c9f57d
2b57ec09
9427c163
91169e76
eda7cda1
289c7bc0
821dc9d5
b003d6ce
3d8dbc53
d6c3324a
76a7b9c9
a4d4b21b
51ce1f2c
49cc8861
b4fc2309
c435862e
3bceba0f
7ee0c219
cb04facd
b2fad23b
2b8cf4f1
fcdcbad2
f3c038c6
71e639e9
282325bf
52c64dc8
9ebe8705
f8679dc3
906116a2
9f740960
3cad2af8
c7765bb4
c8f38650
1e5d7d36
3374dc4c
e83476e5
6a9c0d00
08b8cb58
bd74ea9e
399bce4c
1577335d
cdbbea48
d1373c35
cc62212a
8a5e59e2
75f5f297
8c5f3776
fce09105
92b2d17e
416e8530
968d1450
d20688a8
@@ -0,0 +1,250 @@
ce5386b1beaa89f73a668d3720717fb4
8f5d21bf3d78d9c242609bdd8df6cfd6
7d75a95c82171c8cf4b09d694d4cbef9
20d4bebdc51702ee7f40e187747696c7
9c55dc575db2a189c5576537afca2c54
68a15a8cdbc0aeaf10dff65db4d1a810
dfd03584b10852eb16901d52578d8f8e
a9a0cffed57bba344c9366ce91408e26
9fdf7ac0fc07f2391da56462faf3e2e0
b83d2da8f82e1a06d26bbb514b471f4b
17c92a58fb68d0b3b02cb5b9105dbb37
1e5bd3c05fb67f2aefc01611ed43d117
eb287667a027f3e5325bfb27652021a6
67e388816af98b99e4b9134516205c52
35cba13cd8a6f23d6dfd95803af0886b
45966e4a1b83dce6a2b0c259fea10cae
416a95236e46a7bb0f11cf1147631979
f23ab59d3c4efbb8eb2d80d67a2e5136
ab7bce374a3468c03483418ccffb52de
bdaaf22c6c4e58d393d7c051d4d0ae8a
6f7de2de3e8d79c726f0f2c2fe52ee58
d2fc7067797bbffe3765a238ff803b85
769dbc781b43759287da0b2888e8338b
2abd608af57978290e24c4cd62e4904f
4fb54d7b0a71a4d3ca95b56d7b3d9a81
262ba91321a0edac67c01e6ddcc2e4f7
4c9c2d967fb853ecdc6042f223a7d138
f21ccd6a4eb5f561b0a0eff9cb1956ec
3c206ed277864249e7eaade75e71c815
7167fed00621472662b19a0215dd00c8
76e6f142e710ff5d7f6797d3fc1b8dfd
4d814bb1fec0a2bca9d92140d5734ae0
65b576bf469b182c8954591fd15dd21c
abb7e8f11617aa0c9bf6870e537b008d
02b86ea6b2f8699097d6899ab1f073c4
610786c84b9fc118679afb09404c15ae
7feb8fae65767f848ebbefa730f15d68
12677768a4266571e85b750b51ef9fc8
5b6541b178f6a885d0439b381e501967
352cc90ecc1f3e26c19970f106c475fa
9c5215a9fce00f0ec30615649171f8e4
27e593a4b30c0b64939c62e5e0488e5a
d8a5fd54958527ecefc642f6730e4164
754dfeb4337108e534ba70bab429777a
821ad11c37ffda3ad5438b57a9523e9f
add12a889a5e08a5358e2e0e086fe522
fa100278f49cbb7f9c9d7dc5e12fc0e1
ef46a151df454084d0f0bc644adeb47d
c02f8023a4be8a3249bcf6e1b223dffa
41a11da85806e85fa86a9de41e1cd93f
e792d89b5df357d1b03b1812a762832c
90f72aead8124bc6ce51495b4b99da75
af25c00b1990b778b50f529ae81efa30
900f66f0e222a8d253153711a23a5ee9
0850cad30d59e1b5e5e2943e6b916d0a
03ca961e9876ee96b14711c44a9f2a69
fa6bf9c30dd8618fdab499e9dd217582
4e09e08e1f99bf210d4757281ab4c836
dbf2bc5355a6846a028a7b3140fdef34
b38e1973cabab58f8137cf66fc7fa3aa
0ee4998cda155ca2823f5c9336a62cc2
4f9918b4782a59a0361619389bd22d54
c5c82a59505a9888b95e012a068b3254
5eabc71dca6f57407ba306db416c5031
41b8b1155bf6144aeb822acbff051e1a
12e7d40ccd4298400cf3b5577370bff8
2f22b7979183c9c3b26838c7e84a49a8
68af2e17a487001d9374f6b38ed950af
3a55bc482844ecc366e10544c38cc890
6a08d19bbecb554837c9922b9818455a
b2f95f48c1214a5dfb8f4528afcbf901
c3c4fc391a1a040c71cb65a8c654a5b1
86786c55d138bb9a4a4165522cd15253
b329ece66a9dab6bebab9e3687629e7c
7d6d818e7fe9a9b335c72ee6aedf9980
317956c14cb119eda584de03dbf089cc
d095660e8dd16899be9e916e0ff7946c
12571880bd56bed1bbbd034094fabbaa
7f702375e8c43036e36ddc07b543afb2
e254d4c9854b28db694fb5477144551e
24a4b250bc0461ad4894334838fbb645
16b3dca0bfcad463498a62bf8a3f06a8
77dbe27a373d87a6fc1012caa166a0a5
1b304ea04baf90e1bf11d6296f64f933
42c94efb8ab8ceb6ff98497ef0981c66
1074398b6c210e833b4135c7d2caeb91
3bce8712a7ba7b037a2cc85e4f72aea4
42c1bc84350e82cbead374b41732d8d5
76f8fc8e26d1b64e8afb8f263ec3f795
ef9f8b042eb888f5408ccb500c9a157f
d46505a331887bf49e157f8de4c17a98
0be7df49394337bb277b63a6b9147520
deb977415048b0b7fa1fd02878c1b10e
a0395e32b4cc279f8545b791cee5d394
c7dcd8741840799886a1f3b5208c0b36
02b1297f58b9e699f232b9ed7e8bbcd0
48852b209e405d7aaab50c5810448579
d8a5598b2e7183d72cd3a81e54f301ce
dfe2e4af7518f0d1f04fd825069d575b
e1a70679c95173e6a1504b7c52dd0d3c
16ae515dfb1268052bfe330a1cbbbffa
f17c090f2140cfd3060558ceefe4d1bd
58b1ba1fbbd76670c9218a846a8f6baf
f543d8380c2d095f9b62d4ffaaee7467
1b16d3e1d688a5a905ff22680ad803cb
12e16d58422d5a5899379f11c6ca5b9a
f1986c3e4a4545356656f76c27db0ae7
324a3d45f631aed6c758888254ae07ad
49532c06798f685e2a41dc683b081f79
95934c5a6e2cc8de08c8b8342936cf38
67d89790392f18908c24cc36bbe50362
c4f963cd490f9241f4959cca6a6d35ce
b21db693ba10898ec3205cb0cb343a02
72e63227e74f2473a44da365fa9bc02f
220d247b6c0f9512256dfcdf95582148
86c290279ffa61ca615f1f8f8877e807
6651d56c548fd4c5e8b2c86354c28ca3
d863321ec53cd582d34a8ee5e88a5576
f262bda775cf67026d8d226620c9d4aa
15816d939647890021ac31aa1c3b2f75
229b295a4bc44d8522580dd4383812d0
833afe87641e34e60f2d127f44a7cbeb
62cc76ec3a853878ebfff04ebbc8addc
40fca93e2d6a976de641cdbfb56257b5
65d654e9145527f8fcd24608d64f68f1
1b2b0c68b6d9305f46642109b92a234b
85aa859b29f2caeb60343cf53718e451
3aa590c6c8a4dee128f1e7fcb2b82a2a
699cb26687934746b722e2dfbc1d2c82
9cc9b7f51cbdbaf15bc8eec9c1a1cf7c
b1be900fe6a6eb21a563754420c5f38e
47976686aff65b42661ae0fb2685f462
6b3f96a1be97c4c2a371d5935672096a
ac9b60c754c95ccbaf56f68544712dd6
7dab91b68788d2ed61cd40d3073091a8
35b367c591e71f11ddcd9dcc75be79e7
d3321d793265630e81e6d52b20e58f44
9d97047182efd2ba0594a569f6f2ce04
14c772a342a28bd6785bb722e67fc767
288802e107c373c9d28eeb3f8726214e
1c9d8ff04c346a59ea4f3a349dc4b891
2c857a65df4619d06f424ddcccbd2221
10a0b3e4e7825d21fc22bcff34a3b26c
78c8a5ee6e6880a3af222bfc27de62a1
9e16c4346a69ff9127bb6c9287d98859
1f1d95fa30c9c3a58f41d9657aa0e6ec
5e8c6b3b4690da7f4175abe288d0c509
0bf315da2ec1e135963cc4c9cf73ea1a
2992fdf295438ec9f8dc7979b6d4e5ff
a3d43afab0119aa3b6e38a81d244c388
889d049dc64457637ad089e18e718a23
46016c611b9323fe8f4206bd393a6741
f555b79be75ba2a1a741f467192b693e
242d4b4c323c7c1e9648de52ec8d7c2c
383d2ee4d28919f193f28448ba28ddc6
2c7cd221bd1025547fc654ca6717553d
e16de72e05b548758832102ffb39d242
5cecf3fc2823225f0f1748a1fedadf35
4e709cb904317a43ffd94739281d32bc
b6dfd4657668ed2c1ebaff9fa92702ab
3c618cd95706cc25a0565fc0ed00e352
dca8dbc91708a94525f16c064454a2ce
fe3fbff45e30dcc3fa4f57ae78114f7f
0f7bc1eab5cf9faeaa127c0243675253
19b6fca6e3f0d039074753265436d2c5
fce950fc2ec2526c0b29b6869ff58dee
4c9c0b19668dc20a9fac84e13c590deb
8fe06eb38ee870ffd7cb9f065476ac78
515ce9eaa9a6970f809475e416325123
6d58868e827dd9dca698c5b24380fea7
072951a0e1d06aa829d716a86ae498fe
07f23add152c44f754eac930093e6b59
e98798eb29c162ddfff7ec72aa7b65dc
62f4e1ca0294a3944224fb4a4280ed56
28fc910d3b7a926f63e7bfaf18878cc1
40065fe5c0e32a16622988bc1485a038
b2623ec75309e28c6c0a4b179e4acd6d
3ea6b991014bde2185be7a6c92745c28
5fde25c7fe79a9c0e2b86eb1878de02c
5bf42dc3006f4c601d01ee4820e44d38
44500b37f18b2eb499894bc1ff1efdcc
f5bfe3352c4281083083e51ef1fe0a72
abf9c6ba639f68d9b8f8c0d9d97a19d3
75f6dc9ccbc6a1381785bf9440a95744
4db0fd27cac20f59718fe00c5dc13202
9e0d611017698f2d1716f8d9d81a8bf7
13c7015f10b37b3dbe1618cde5edc2ee
70e2a0182471290a46961a0d62611053
47ba942fc3396104b3731ea40eb97339
bf10238458969233c0062b09a68f22a7
115b7375a9aa156b8203dd88acdc5672
1b39aa22702c993aa1e7606e9afad9c5
c85e67c08e02538f151c6276662ba69b
89dcf41c5adb944bcd46ca3f67666a75
90fd81f9b572ede873313f4faa58da4b
00badd56e7d5b2dddb10ef7de05be515
a13c3404ac033c9fce8a6435f2a4a416
af83e8f7af9785c96ff61c354417aab4
5176b27945cc7686520b1425144dc4d3
e5f248be2c0e83ad0a6bfd2eff759f6e
cd693cce87898dafa5c68dbaeeb151fa
2a47245fc3da1229e3dc288f216c4e20
c6f8e88fbf5566000f8f86b0d4bf9020
3e88c5f7b571a292c998cac33b41215b
2a2e8271ecba2cec040ef343317e2ae9
8e693282602bde3452061affde3ce514
992acab07cf4d6aa5b95b4c4cc33cf0c
6db1ad3494be9c3c9758b00d956bd2cb
824d130a16d7bc2c6d6b26c31c2a0e05
d286bd8f5cdfd65b695815ff68bb191f
4c5bbeecda37bf4fb6dd79f266aac3d6
c4f64e991e13dfc3913246f730c31e87
459d8fd53975479aa977c61dc3ef1b9e
ece59558539b52684433b54bf2c7e214
4362122d7ed40dd5ad0886cdf901539e
66ffe4fb31a5e4fa9462a3aa57224476
86586a04e75ebde21903f407e6037e50
93b2e3af91cb24ffafa6418c76cc1ce7
41ca4598aac764fa944d8f578b2ab5ab
6ef7ce05836d63520925817c220a5274
c4e902503f3d5602ba2454907d7809f7
8f3cb447682bebd3a230740e11d35dff
4340ec4c3e62118b2749264393fc78ee
8178fbeab83126f2111ab74c23d241c9
db298bcb2a999b7ac2037991ef078d32
f586895d6dca606e2d62dfdc92cc2021
b5aaf4b91dd7b6cda3a2635c603c4500
0f024b59afa957b59f5ebb9b63ff1b1a
41acc378b08b5d9b7da2f644b79aebdf
cd173062829e6e86eb094b8d40d16579
ef55ddf0fa94dcb4f275033bb55e8466
48a3efa1f9d8291d99ce434fc5474fb6
731a649c764fa964d6e2ce640b34c25a
5f9ac7b1dca56c8dab9ce1f92341094b
e9b0ee7355299aaf55cce1e240050125
8a285fc7c728cdfa1f4b651c64eab7e5
05823b86e463ad514a4cd7eacd9c01bb
0ce34318119eb0e8921b5e798789bea0
e46f4c793b42136b1eefbece7b867052
1331c7fcae627f290299b978299a9b48
285c5783029a7b23374e91cce19df0de
670fba4fad181308c15778f38e619646
46a3350a39c21f45bafed75d339ed101
b1d6f3230e58d2647d1aa4a5446061ed
f6eb5936ec9bb3212b14716da8329839
1ecb00343900a550ad33ba10777df23d
4b9f70244adda8e0a42f4da7341b8d84
6caafeb26793a011ebef352ef1d1aa34
723ab0797281e7ee36109b53140a324d
7a5e9be8c7c8307aa22c8b48708424a2
@@ -0,0 +1,203 @@
// Copyright 2024 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use std::fmt;
use std::io::Write;
use crate::bitstream_utils::BitWriter;
use crate::bitstream_utils::BitWriterError;
#[derive(Debug)]
pub enum ObuWriterError {
BitWriterError(BitWriterError),
UnalignedLeb128,
}
impl fmt::Display for ObuWriterError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ObuWriterError::BitWriterError(x) => write!(f, "{}", x.to_string()),
ObuWriterError::UnalignedLeb128 => {
write!(f, "attempted to write leb128 on unaligned position")
}
}
}
}
impl From<BitWriterError> for ObuWriterError {
fn from(err: BitWriterError) -> Self {
ObuWriterError::BitWriterError(err)
}
}
pub type ObuWriterResult<T> = std::result::Result<T, ObuWriterError>;
pub struct ObuWriter<W: Write>(BitWriter<W>);
impl<W: Write> ObuWriter<W> {
pub fn new(writer: W) -> Self {
Self(BitWriter::new(writer))
}
/// Writes fixed bit size integer. Corresponds to `f(n)` in AV1 spec defined in 4.10.2.
pub fn write_f<T: Into<u32>>(&mut self, bits: usize, value: T) -> ObuWriterResult<usize> {
self.0
.write_f(bits, value)
.map_err(ObuWriterError::BitWriterError)
}
/// Writes variable length unsigned n-bit number. Corresponds to `uvlc()` in AV1 spec
/// defined in 4.10.3.
pub fn write_uvlc<T: Into<u32>>(&mut self, value: T) -> ObuWriterResult<usize> {
let value: u32 = value.into();
if value == u32::MAX {
return self.write_f(32, 0u32);
}
let value = value + 1;
let leading_zeros = (32 - value.leading_zeros()) as usize;
Ok(self.write_f(leading_zeros - 1, 0u32)? + self.write_f(leading_zeros, value)?)
}
/// Writes unsigned little-endian n-byte integer. Corresponds to `le(n)` in AV1 spec
/// defined in 4.10.4.
pub fn write_le<T: Into<u32>>(&mut self, n: usize, value: T) -> ObuWriterResult<usize> {
let value: u32 = value.into();
let mut value = value.to_le();
for _ in 0..n {
self.write_f(4, value & 0xff)?;
value >>= 8;
}
Ok(n)
}
/// Writes unsigned integer represented by a variable number of little-endian bytes.
/// Corresponds to `leb128()` in AV1 spec defined in 4.10.4.
///
/// Note: Despite the name, the AV1 4.10.4 limits the value to [`u32::MAX`] = (1 << 32) - 1.
pub fn write_leb128<T: Into<u32>>(
&mut self,
value: T,
min_bytes: usize,
) -> ObuWriterResult<usize> {
if !self.aligned() {
return Err(ObuWriterError::UnalignedLeb128);
}
let value: u32 = value.into();
let mut value: u32 = value.to_le();
let mut bytes = 0;
for _ in 0..8 {
bytes += 1;
if value >= 0x7f || bytes < min_bytes {
self.write_f(8, 0x80 | (value & 0x7f))?;
value >>= 7;
} else {
self.write_f(8, value & 0x7f)?;
break;
}
}
assert!(value < 0x7f);
Ok(bytes)
}
pub fn write_su<T: Into<i32>>(&mut self, bits: usize, value: T) -> ObuWriterResult<usize> {
let mut value: i32 = value.into();
if value < 0 {
value += 1 << bits;
}
assert!(value >= 0);
self.write_f(bits, value.unsigned_abs())
}
pub fn aligned(&self) -> bool {
!self.0.has_data_pending()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::av1::reader::Reader;
const TEST_VECTOR: &[u32] = &[
// some random test values
u32::MAX,
1,
2,
3,
4,
10,
20,
7312,
8832,
10123,
47457,
21390213,
u32::MIN,
u32::MAX - 1,
];
#[test]
fn test_uvlc() {
for &value in TEST_VECTOR {
let mut buf = Vec::<u8>::new();
ObuWriter::new(&mut buf).write_uvlc(value).unwrap();
if value == u32::MAX {
// force stop uvlc
buf.push(0x80);
}
let read = Reader::new(&buf).read_uvlc().unwrap();
assert_eq!(read, value, "failed testing {}", value);
}
}
#[test]
fn test_leb128() {
for &value in TEST_VECTOR {
let mut buf = Vec::<u8>::new();
ObuWriter::new(&mut buf).write_leb128(value, 0).unwrap();
let read = Reader::new(&buf).read_leb128().unwrap();
assert_eq!(read, value, "failed testing {}", value);
}
}
#[test]
fn test_su() {
let vector = TEST_VECTOR
.iter()
.map(|e| *e as i32)
.chain(TEST_VECTOR.iter().map(|e| -(*e as i32)));
for value in vector {
let bits = 32 - value.abs().leading_zeros() as usize + 1; // For sign
if bits >= 32 {
// Skip too big nubmers
continue;
}
let mut buf = Vec::<u8>::new();
ObuWriter::new(&mut buf).write_su(bits, value).unwrap();
let read = Reader::new(&buf).read_su(bits as usize).unwrap();
assert_eq!(read, value, "failed testing {}", value);
}
}
}
@@ -0,0 +1,10 @@
// Copyright 2023 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
pub mod dpb;
pub mod nalu;
pub mod nalu_writer;
pub mod parser;
pub mod picture;
pub mod synthesizer;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
// Copyright 2023 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use std::borrow::Cow;
use std::fmt::Debug;
use std::io::Cursor;
use std::io::Seek;
use std::io::SeekFrom;
#[allow(clippy::len_without_is_empty)]
pub trait Header: Sized {
/// Parse the NALU header, returning it.
fn parse<T: AsRef<[u8]>>(cursor: &mut Cursor<T>) -> Result<Self, String>;
/// Whether this header type indicates EOS.
fn is_end(&self) -> bool;
/// The length of the header.
fn len(&self) -> usize;
}
#[derive(Debug)]
pub struct Nalu<'a, U> {
pub header: U,
/// The mapping that backs this NALU. Possibly shared with the other NALUs
/// in the Access Unit.
pub data: Cow<'a, [u8]>,
pub size: usize,
pub offset: usize,
}
impl<'a, U> Nalu<'a, U>
where
U: Debug + Header,
{
/// Find the next Annex B encoded NAL unit.
pub fn next(cursor: &mut Cursor<&'a [u8]>) -> Result<Nalu<'a, U>, String> {
let bitstream = cursor.clone().into_inner();
let pos = usize::try_from(cursor.position()).map_err(|err| err.to_string())?;
// Find the start code for this NALU
let current_nalu_offset = match Nalu::<'a, U>::find_start_code(cursor, pos) {
Some(offset) => offset,
None => return Err("No NAL found".into()),
};
let mut start_code_offset = pos + current_nalu_offset;
// If the preceding byte is 00, then we actually have a four byte SC,
// i.e. 00 00 00 01 Where the first 00 is the "zero_byte()"
if start_code_offset > 0 && cursor.get_ref()[start_code_offset - 1] == 00 {
start_code_offset -= 1;
}
// The NALU offset is its offset + 3 bytes to skip the start code.
let nalu_offset = pos + current_nalu_offset + 3;
// Set the bitstream position to the start of the current NALU
cursor.set_position(u64::try_from(nalu_offset).map_err(|err| err.to_string())?);
let hdr = U::parse(cursor)?;
// Find the start of the subsequent NALU.
let mut next_nalu_offset = match Nalu::<'a, U>::find_start_code(cursor, nalu_offset) {
Some(offset) => offset,
None => {
let cur_pos = cursor.position();
let end_pos = cursor
.seek(SeekFrom::End(0))
.map_err(|err| err.to_string())?;
let _ = cursor
.seek(SeekFrom::Start(cur_pos))
.map_err(|err| err.to_string())?;
(end_pos - cur_pos) as usize
} // Whatever data is left must be part of the current NALU
};
while next_nalu_offset > 0 && cursor.get_ref()[nalu_offset + next_nalu_offset - 1] == 00 {
// Discard trailing_zero_8bits
next_nalu_offset -= 1;
}
let nal_size = if hdr.is_end() {
// the NALU is comprised of only the header
hdr.len()
} else {
next_nalu_offset
};
Ok(Nalu {
header: hdr,
data: Cow::from(&bitstream[start_code_offset..nalu_offset + nal_size]),
size: nal_size,
offset: nalu_offset - start_code_offset,
})
}
}
impl<'a, U> Nalu<'a, U>
where
U: Debug,
{
fn find_start_code(data: &mut Cursor<&'a [u8]>, offset: usize) -> Option<usize> {
// discard all zeroes until the start code pattern is found
data.get_ref()[offset..]
.windows(3)
.position(|window| window == [0x00, 0x00, 0x01])
}
pub fn into_owned(self) -> Nalu<'static, U> {
Nalu {
header: self.header,
size: self.size,
offset: self.offset,
data: Cow::Owned(self.data.into_owned()),
}
}
}
impl<'a, U> AsRef<[u8]> for Nalu<'a, U> {
fn as_ref(&self) -> &[u8] {
&self.data[self.offset..self.offset + self.size]
}
}
@@ -0,0 +1,311 @@
// Copyright 2024 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use std::fmt;
use std::io::Write;
use crate::bitstream_utils::BitWriter;
use crate::bitstream_utils::BitWriterError;
/// Internal wrapper over [`std::io::Write`] for possible emulation prevention
struct EmulationPrevention<W: Write> {
out: W,
prev_bytes: [Option<u8>; 2],
/// Emulation prevention enabled.
ep_enabled: bool,
}
impl<W: Write> EmulationPrevention<W> {
fn new(writer: W, ep_enabled: bool) -> Self {
Self {
out: writer,
prev_bytes: [None; 2],
ep_enabled,
}
}
fn write_byte(&mut self, curr_byte: u8) -> std::io::Result<()> {
if self.prev_bytes[1] == Some(0x00) && self.prev_bytes[0] == Some(0x00) && curr_byte <= 0x03
{
self.out.write_all(&[0x00, 0x00, 0x03, curr_byte])?;
self.prev_bytes = [None; 2];
} else {
if let Some(byte) = self.prev_bytes[1] {
self.out.write_all(&[byte])?;
}
self.prev_bytes[1] = self.prev_bytes[0];
self.prev_bytes[0] = Some(curr_byte);
}
Ok(())
}
/// Writes a H.264 NALU header.
fn write_header(&mut self, idc: u8, type_: u8) -> NaluWriterResult<()> {
self.out.write_all(&[
0x00,
0x00,
0x00,
0x01,
(idc & 0b11) << 5 | (type_ & 0b11111),
])?;
Ok(())
}
fn has_data_pending(&self) -> bool {
self.prev_bytes[0].is_some() || self.prev_bytes[1].is_some()
}
}
impl<W: Write> Write for EmulationPrevention<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if !self.ep_enabled {
self.out.write_all(buf)?;
return Ok(buf.len());
}
for byte in buf {
self.write_byte(*byte)?;
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if let Some(byte) = self.prev_bytes[1].take() {
self.out.write_all(&[byte])?;
}
if let Some(byte) = self.prev_bytes[0].take() {
self.out.write_all(&[byte])?;
}
self.out.flush()
}
}
impl<W: Write> Drop for EmulationPrevention<W> {
fn drop(&mut self) {
if let Err(e) = self.flush() {
log::error!("Unable to flush pending bytes {e:?}");
}
}
}
#[derive(Debug)]
pub enum NaluWriterError {
Overflow,
Io(std::io::Error),
BitWriterError(BitWriterError),
}
impl fmt::Display for NaluWriterError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
NaluWriterError::Overflow => write!(f, "value increment caused value overflow"),
NaluWriterError::Io(x) => write!(f, "{}", x.to_string()),
NaluWriterError::BitWriterError(x) => write!(f, "{}", x.to_string()),
}
}
}
impl From<std::io::Error> for NaluWriterError {
fn from(err: std::io::Error) -> Self {
NaluWriterError::Io(err)
}
}
impl From<BitWriterError> for NaluWriterError {
fn from(err: BitWriterError) -> Self {
NaluWriterError::BitWriterError(err)
}
}
pub type NaluWriterResult<T> = std::result::Result<T, NaluWriterError>;
/// A writer for H.264 bitstream. It is capable of outputing bitstream with
/// emulation-prevention.
pub struct NaluWriter<W: Write>(BitWriter<EmulationPrevention<W>>);
impl<W: Write> NaluWriter<W> {
pub fn new(writer: W, ep_enabled: bool) -> Self {
Self(BitWriter::new(EmulationPrevention::new(writer, ep_enabled)))
}
/// Writes fixed bit size integer (up to 32 bit) output with emulation
/// prevention if enabled. Corresponds to `f(n)` in H.264 spec.
pub fn write_f<T: Into<u32>>(&mut self, bits: usize, value: T) -> NaluWriterResult<usize> {
self.0
.write_f(bits, value)
.map_err(NaluWriterError::BitWriterError)
}
/// An alias to [`Self::write_f`] Corresponds to `n(n)` in H.264 spec.
pub fn write_u<T: Into<u32>>(&mut self, bits: usize, value: T) -> NaluWriterResult<usize> {
self.write_f(bits, value)
}
/// Writes a number in exponential golumb format.
pub fn write_exp_golumb(&mut self, value: u32) -> NaluWriterResult<()> {
let value = value.checked_add(1).ok_or(NaluWriterError::Overflow)?;
let bits = 32 - value.leading_zeros() as usize;
let zeros = bits - 1;
self.write_f(zeros, 0u32)?;
self.write_f(bits, value)?;
Ok(())
}
/// Writes a unsigned integer in exponential golumb format.
/// Coresponds to `ue(v)` in H.264 spec.
pub fn write_ue<T: Into<u32>>(&mut self, value: T) -> NaluWriterResult<()> {
let value = value.into();
self.write_exp_golumb(value)
}
/// Writes a signed integer in exponential golumb format.
/// Coresponds to `se(v)` in H.264 spec.
pub fn write_se<T: Into<i32>>(&mut self, value: T) -> NaluWriterResult<()> {
let value: i32 = value.into();
let abs_value: u32 = value.unsigned_abs();
if value <= 0 {
self.write_ue(2 * abs_value)
} else {
self.write_ue(2 * abs_value - 1)
}
}
/// Returns `true` if ['Self`] hold data that wasn't written to [`std::io::Write`]
pub fn has_data_pending(&self) -> bool {
self.0.has_data_pending() || self.0.inner().has_data_pending()
}
/// Writes a H.264 NALU header.
pub fn write_header(&mut self, idc: u8, _type: u8) -> NaluWriterResult<()> {
self.0.flush()?;
self.0.inner_mut().write_header(idc, _type)?;
Ok(())
}
/// Returns `true` if next bits will be aligned to 8
pub fn aligned(&self) -> bool {
!self.0.has_data_pending()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bitstream_utils::BitReader;
#[test]
fn simple_bits() {
let mut buf = Vec::<u8>::new();
{
let mut writer = NaluWriter::new(&mut buf, false);
writer.write_f(1, true).unwrap();
writer.write_f(1, false).unwrap();
writer.write_f(1, false).unwrap();
writer.write_f(1, false).unwrap();
writer.write_f(1, true).unwrap();
writer.write_f(1, true).unwrap();
writer.write_f(1, true).unwrap();
writer.write_f(1, true).unwrap();
}
assert_eq!(buf, vec![0b10001111u8]);
}
#[test]
fn simple_first_few_ue() {
fn single_ue(value: u32) -> Vec<u8> {
let mut buf = Vec::<u8>::new();
{
let mut writer = NaluWriter::new(&mut buf, false);
writer.write_ue(value).unwrap();
}
buf
}
assert_eq!(single_ue(0), vec![0b10000000u8]);
assert_eq!(single_ue(1), vec![0b01000000u8]);
assert_eq!(single_ue(2), vec![0b01100000u8]);
assert_eq!(single_ue(3), vec![0b00100000u8]);
assert_eq!(single_ue(4), vec![0b00101000u8]);
assert_eq!(single_ue(5), vec![0b00110000u8]);
assert_eq!(single_ue(6), vec![0b00111000u8]);
assert_eq!(single_ue(7), vec![0b00010000u8]);
assert_eq!(single_ue(8), vec![0b00010010u8]);
assert_eq!(single_ue(9), vec![0b00010100u8]);
}
#[test]
fn writer_reader() {
let mut buf = Vec::<u8>::new();
{
let mut writer = NaluWriter::new(&mut buf, false);
writer.write_ue(10u32).unwrap();
writer.write_se(-42).unwrap();
writer.write_se(3).unwrap();
writer.write_ue(5u32).unwrap();
}
let mut reader = BitReader::new(&buf, true);
assert_eq!(reader.read_ue::<u32>().unwrap(), 10);
assert_eq!(reader.read_se::<i32>().unwrap(), -42);
assert_eq!(reader.read_se::<i32>().unwrap(), 3);
assert_eq!(reader.read_ue::<u32>().unwrap(), 5);
let mut buf = Vec::<u8>::new();
{
let mut writer = NaluWriter::new(&mut buf, false);
writer.write_se(30).unwrap();
writer.write_ue(100u32).unwrap();
writer.write_se(-402).unwrap();
writer.write_ue(50u32).unwrap();
}
let mut reader = BitReader::new(&buf, true);
assert_eq!(reader.read_se::<i32>().unwrap(), 30);
assert_eq!(reader.read_ue::<u32>().unwrap(), 100);
assert_eq!(reader.read_se::<i32>().unwrap(), -402);
assert_eq!(reader.read_ue::<u32>().unwrap(), 50);
}
#[test]
fn writer_emulation_prevention() {
fn test(input: &[u8], bitstream: &[u8]) {
let mut buf = Vec::<u8>::new();
{
let mut writer = NaluWriter::new(&mut buf, true);
for byte in input {
writer.write_f(8, *byte).unwrap();
}
}
assert_eq!(buf, bitstream);
{
let mut reader = BitReader::new(&buf, true);
for byte in input {
assert_eq!(*byte, reader.read_bits::<u8>(8).unwrap());
}
}
}
test(&[0x00, 0x00, 0x00], &[0x00, 0x00, 0x03, 0x00]);
test(&[0x00, 0x00, 0x01], &[0x00, 0x00, 0x03, 0x01]);
test(&[0x00, 0x00, 0x02], &[0x00, 0x00, 0x03, 0x02]);
test(&[0x00, 0x00, 0x03], &[0x00, 0x00, 0x03, 0x03]);
test(&[0x00, 0x00, 0x00, 0x00], &[0x00, 0x00, 0x03, 0x00, 0x00]);
test(&[0x00, 0x00, 0x00, 0x01], &[0x00, 0x00, 0x03, 0x00, 0x01]);
test(&[0x00, 0x00, 0x00, 0x02], &[0x00, 0x00, 0x03, 0x00, 0x02]);
test(&[0x00, 0x00, 0x00, 0x03], &[0x00, 0x00, 0x03, 0x00, 0x03]);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,443 @@
// Copyright 2022 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use std::cell::RefCell;
use std::ops::Deref;
use std::rc::Rc;
use std::rc::Weak;
use log::debug;
use crate::codec::h264::parser::MaxLongTermFrameIdx;
use crate::codec::h264::parser::RefPicMarking;
use crate::codec::h264::parser::Slice;
use crate::codec::h264::parser::SliceType;
use crate::codec::h264::parser::Sps;
use crate::Resolution;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum Field {
#[default]
Frame,
Top,
Bottom,
}
impl Field {
/// Returns the field of opposite parity.
pub fn opposite(&self) -> Self {
match *self {
Field::Frame => Field::Frame,
Field::Top => Field::Bottom,
Field::Bottom => Field::Top,
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum Reference {
#[default]
None,
ShortTerm,
LongTerm,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum IsIdr {
#[default]
No,
Yes {
idr_pic_id: u16,
},
}
/// The rank of a field, i.e. whether it is the first or second one to be parsed from the stream.
/// This is unrelated to the `Field` type, as the first field can be either `Top` or `Bottom`.
#[derive(Default, Debug)]
pub enum FieldRank {
/// Frame has a single field.
#[default]
Single,
/// Frame is interlaced, and this is the first field (with a reference to the second one).
First(Weak<RefCell<PictureData>>),
/// Frame is interlaced, and this is the second field (with a reference to the first one).
Second(Rc<RefCell<PictureData>>),
}
#[derive(Default)]
pub struct PictureData {
pub pic_order_cnt_type: u8,
pub top_field_order_cnt: i32,
pub bottom_field_order_cnt: i32,
pub pic_order_cnt: i32,
pub pic_order_cnt_msb: i32,
pub pic_order_cnt_lsb: i32,
pub delta_pic_order_cnt_bottom: i32,
pub delta_pic_order_cnt0: i32,
pub delta_pic_order_cnt1: i32,
pub pic_num: i32,
pub long_term_pic_num: u32,
pub frame_num: u32,
pub frame_num_offset: u32,
pub frame_num_wrap: i32,
pub long_term_frame_idx: u32,
pub coded_resolution: Resolution,
pub display_resolution: Resolution,
pub type_: SliceType,
pub nal_ref_idc: u8,
pub is_idr: IsIdr,
reference: Reference,
pub ref_pic_list_modification_flag_l0: i32,
pub abs_diff_pic_num_minus1: i32,
// Does memory management op 5 needs to be executed after this
// picture has finished decoding?
pub has_mmco_5: bool,
// Created by the decoding process for gaps in frame_num.
// Not for decode or output.
pub nonexisting: bool,
pub field: Field,
// Values from slice_hdr to be used during reference marking and
// memory management after finishing this picture.
pub ref_pic_marking: RefPicMarking,
field_rank: FieldRank,
pub timestamp: u64,
}
/// A `PictureData` within a `Rc<RefCell>` which field rank is guaranteed to be correct.
///
/// The field rank of `PictureData` is only final after both fields have been constructed - namely,
/// the first field can only point to the second one after the latter is available as a Rc. Methods
/// [`PictureData::into_rc`] and [`PictureData::split_frame`] take care of this, and is this only
/// producer of this type, ensuring all instances are correct.
#[derive(Default, Debug, Clone)]
pub struct RcPictureData {
pic: Rc<RefCell<PictureData>>,
}
impl Deref for RcPictureData {
type Target = Rc<RefCell<PictureData>>;
fn deref(&self) -> &Self::Target {
&self.pic
}
}
impl PictureData {
pub fn new_non_existing(frame_num: u32, timestamp: u64) -> Self {
PictureData {
frame_num,
nonexisting: true,
nal_ref_idc: 1,
field: Field::Frame,
pic_num: frame_num as i32,
reference: Reference::ShortTerm,
timestamp,
..Default::default()
}
}
/// Create a new picture from a `slice`, `sps`, and `timestamp`.
///
/// `first_field` is set if this picture is the second field of a frame.
pub fn new_from_slice(
slice: &Slice,
sps: &Sps,
timestamp: u64,
first_field: Option<&RcPictureData>,
) -> Self {
let hdr = &slice.header;
let nalu_hdr = &slice.nalu.header;
let is_idr = if nalu_hdr.idr_pic_flag {
IsIdr::Yes {
idr_pic_id: hdr.idr_pic_id,
}
} else {
IsIdr::No
};
let field = if hdr.field_pic_flag {
if hdr.bottom_field_flag {
Field::Bottom
} else {
Field::Top
}
} else {
Field::Frame
};
let reference = if nalu_hdr.ref_idc != 0 {
Reference::ShortTerm
} else {
Reference::None
};
let pic_num = if !hdr.field_pic_flag {
hdr.frame_num
} else {
2 * hdr.frame_num + 1
};
let (
pic_order_cnt_lsb,
delta_pic_order_cnt_bottom,
delta_pic_order_cnt0,
delta_pic_order_cnt1,
) = match sps.pic_order_cnt_type {
0 => (
hdr.pic_order_cnt_lsb,
hdr.delta_pic_order_cnt_bottom,
Default::default(),
Default::default(),
),
1 => (
Default::default(),
Default::default(),
hdr.delta_pic_order_cnt[0],
hdr.delta_pic_order_cnt[1],
),
_ => (
Default::default(),
Default::default(),
Default::default(),
Default::default(),
),
};
let coded_resolution = Resolution::from((sps.width(), sps.height()));
let visible_rect = sps.visible_rectangle();
// punktfunk deviation (PROVENANCE.md #6): `Sps::visible_rectangle()` returns
// the crop offset in `min` and the visible SIZE in `max` (not a corner);
// upstream's `max - min` double-counts the left/top crop and panics on a u32
// underflow for large-but-parser-valid left/top offsets.
let display_resolution = Resolution {
width: visible_rect.max.x,
height: visible_rect.max.y,
};
let mut pic = PictureData {
pic_order_cnt_type: sps.pic_order_cnt_type,
pic_order_cnt_lsb: i32::from(pic_order_cnt_lsb),
delta_pic_order_cnt_bottom,
delta_pic_order_cnt0,
delta_pic_order_cnt1,
pic_num: i32::from(pic_num),
frame_num: u32::from(hdr.frame_num),
nal_ref_idc: nalu_hdr.ref_idc,
is_idr,
reference,
field,
ref_pic_marking: hdr.dec_ref_pic_marking.clone(),
coded_resolution,
display_resolution,
timestamp,
..Default::default()
};
if let Some(first_field) = first_field {
pic.set_first_field_to(first_field);
}
pic
}
/// Whether the current picture is a reference, either ShortTerm or LongTerm.
pub fn is_ref(&self) -> bool {
!matches!(self.reference, Reference::None)
}
/// Whether this picture is a second field.
pub fn is_second_field(&self) -> bool {
matches!(self.field_rank, FieldRank::Second(..))
}
/// Returns the field rank of this picture, including a reference to its other field.
pub fn field_rank(&self) -> &FieldRank {
&self.field_rank
}
/// Returns a reference to the picture's Reference
pub fn reference(&self) -> &Reference {
&self.reference
}
/// Mark the picture as a reference picture.
pub fn set_reference(&mut self, reference: Reference, apply_to_other_field: bool) {
log::debug!("Set reference of {:#?} to {:?}", self, reference);
self.reference = reference;
if apply_to_other_field {
if let Some(other_field) = self.other_field() {
log::debug!(
"other_field: Set reference of {:#?} to {:?}",
&other_field.borrow(),
reference
);
other_field.borrow_mut().reference = reference;
}
}
}
/// Get a reference to the picture's other field, if there is any
/// and its reference is still valid.
pub fn other_field(&self) -> Option<Rc<RefCell<PictureData>>> {
match &self.field_rank {
FieldRank::Single => None,
FieldRank::First(other_field) => other_field.upgrade(),
FieldRank::Second(other_field) => Some(other_field.clone()),
}
}
/// Set this picture's second field.
fn set_second_field_to(&mut self, other_field: &Rc<RefCell<Self>>) {
self.field_rank = FieldRank::First(Rc::downgrade(other_field));
}
/// Whether the current picture is the second field of a complementary ref pair.
pub fn is_second_field_of_complementary_ref_pair(&self) -> bool {
self.is_ref()
&& matches!(self.field_rank(), FieldRank::Second(first_field) if first_field.borrow().is_ref())
}
/// Set this picture's first field.
fn set_first_field_to(&mut self, other_field: &Rc<RefCell<Self>>) {
self.field_rank = FieldRank::Second(other_field.clone());
}
pub fn pic_num_f(&self, max_pic_num: i32) -> i32 {
if !matches!(self.reference(), Reference::LongTerm) {
self.pic_num
} else {
max_pic_num
}
}
pub fn long_term_pic_num_f(&self, max_long_term_frame_idx: MaxLongTermFrameIdx) -> u32 {
if matches!(self.reference(), Reference::LongTerm) {
self.long_term_pic_num
} else {
2 * max_long_term_frame_idx.to_value_plus1()
}
}
/// Consume this picture and return a Rc'd version.
///
/// If the picture was a second field, adjust the field of the first field to point to this
/// one.
pub fn into_rc(self) -> RcPictureData {
let self_rc = Rc::new(RefCell::new(self));
if let FieldRank::Second(first_field) = self_rc.borrow().field_rank() {
first_field.borrow_mut().set_second_field_to(&self_rc);
}
RcPictureData { pic: self_rc }
}
/// Split a frame into two complementary fields that reference one another.
pub fn split_frame(mut self) -> (RcPictureData, RcPictureData) {
assert!(matches!(self.field, Field::Frame));
assert!(matches!(self.field_rank, FieldRank::Single));
debug!(
"Splitting picture (frame_num, POC) ({:?}, {:?})",
self.frame_num, self.pic_order_cnt
);
let second_pic_order_cnt = if self.top_field_order_cnt < self.bottom_field_order_cnt {
self.field = Field::Top;
self.pic_order_cnt = self.top_field_order_cnt;
self.bottom_field_order_cnt
} else {
self.field = Field::Bottom;
self.pic_order_cnt = self.bottom_field_order_cnt;
self.top_field_order_cnt
};
let second_field = PictureData {
top_field_order_cnt: self.top_field_order_cnt,
bottom_field_order_cnt: self.bottom_field_order_cnt,
frame_num: self.frame_num,
reference: self.reference,
nonexisting: self.nonexisting,
pic_order_cnt: second_pic_order_cnt,
field: self.field.opposite(),
..Default::default()
};
debug!(
"Split into picture (frame_num, POC) ({:?}, {:?}), field: {:?}",
self.frame_num, self.pic_order_cnt, self.field
);
debug!(
"Split into picture (frame_num, POC) ({:?}, {:?}), field {:?}",
second_field.frame_num, second_field.pic_order_cnt, second_field.field
);
let first_field = Rc::new(RefCell::new(self));
let second_field = Rc::new(RefCell::new(second_field));
first_field.borrow_mut().set_second_field_to(&second_field);
second_field.borrow_mut().set_first_field_to(&first_field);
(
RcPictureData { pic: first_field },
RcPictureData { pic: second_field },
)
}
}
impl std::fmt::Debug for PictureData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PictureData")
.field("pic_order_cnt_type", &self.pic_order_cnt_type)
.field("top_field_order_cnt", &self.top_field_order_cnt)
.field("bottom_field_order_cnt", &self.bottom_field_order_cnt)
.field("pic_order_cnt", &self.pic_order_cnt)
.field("pic_order_cnt_msb", &self.pic_order_cnt_msb)
.field("pic_order_cnt_lsb", &self.pic_order_cnt_lsb)
.field(
"delta_pic_order_cnt_bottom",
&self.delta_pic_order_cnt_bottom,
)
.field("delta_pic_order_cnt0", &self.delta_pic_order_cnt0)
.field("delta_pic_order_cnt1", &self.delta_pic_order_cnt1)
.field("pic_num", &self.pic_num)
.field("long_term_pic_num", &self.long_term_pic_num)
.field("frame_num", &self.frame_num)
.field("frame_num_offset", &self.frame_num_offset)
.field("frame_num_wrap", &self.frame_num_wrap)
.field("long_term_frame_idx", &self.long_term_frame_idx)
.field("coded_resolution", &self.coded_resolution)
.field("display_resolution", &self.display_resolution)
.field("type_", &self.type_)
.field("nal_ref_idc", &self.nal_ref_idc)
.field("is_idr", &self.is_idr)
.field("reference", &self.reference)
.field(
"ref_pic_list_modification_flag_l0",
&self.ref_pic_list_modification_flag_l0,
)
.field("abs_diff_pic_num_minus1", &self.abs_diff_pic_num_minus1)
.field("has_mmco_5", &self.has_mmco_5)
.field("nonexisting", &self.nonexisting)
.field("field", &self.field)
.field("ref_pic_marking", &self.ref_pic_marking)
.field("field_rank", &self.field_rank)
.finish()
}
}
@@ -0,0 +1,593 @@
// Copyright 2024 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use std::fmt;
use std::io::Write;
use crate::codec::h264::nalu_writer::NaluWriter;
use crate::codec::h264::nalu_writer::NaluWriterError;
use crate::codec::h264::parser::HrdParams;
use crate::codec::h264::parser::NaluType;
use crate::codec::h264::parser::Pps;
use crate::codec::h264::parser::Sps;
use crate::codec::h264::parser::DEFAULT_4X4_INTER;
use crate::codec::h264::parser::DEFAULT_4X4_INTRA;
use crate::codec::h264::parser::DEFAULT_8X8_INTER;
use crate::codec::h264::parser::DEFAULT_8X8_INTRA;
mod private {
pub trait NaluStruct {}
}
impl private::NaluStruct for Sps {}
impl private::NaluStruct for Pps {}
#[derive(Debug)]
pub enum SynthesizerError {
Unsupported,
NaluWriter(NaluWriterError),
}
impl fmt::Display for SynthesizerError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
SynthesizerError::Unsupported => write!(f, "tried to synthesize unsupported settings"),
SynthesizerError::NaluWriter(x) => write!(f, "{}", x.to_string()),
}
}
}
impl From<NaluWriterError> for SynthesizerError {
fn from(err: NaluWriterError) -> Self {
SynthesizerError::NaluWriter(err)
}
}
pub type SynthesizerResult<T> = Result<T, SynthesizerError>;
/// A helper to output typed NALUs to [`std::io::Write`] using [`NaluWriter`].
pub struct Synthesizer<'n, N: private::NaluStruct, W: Write> {
writer: NaluWriter<W>,
nalu: &'n N,
}
/// Extended Sample Aspect Ratio - H.264 Table E-1
const EXTENDED_SAR: u8 = 255;
impl<N: private::NaluStruct, W: Write> Synthesizer<'_, N, W> {
fn u<T: Into<u32>>(&mut self, bits: usize, value: T) -> SynthesizerResult<()> {
self.writer.write_u(bits, value)?;
Ok(())
}
fn f<T: Into<u32>>(&mut self, bits: usize, value: T) -> SynthesizerResult<()> {
self.writer.write_f(bits, value)?;
Ok(())
}
fn ue<T: Into<u32>>(&mut self, value: T) -> SynthesizerResult<()> {
self.writer.write_ue(value)?;
Ok(())
}
fn se<T: Into<i32>>(&mut self, value: T) -> SynthesizerResult<()> {
self.writer.write_se(value)?;
Ok(())
}
fn scaling_list(&mut self, list: &[u8], default: &[u8]) -> SynthesizerResult<()> {
// H.264 7.3.2.1.1.1
if list == default {
self.se(-8)?;
return Ok(());
}
// The number of list values we want to encode.
let mut run = list.len();
// Check how many values at the end of the matrix are the same,
// so we can save on encoding those.
for j in (1..list.len()).rev() {
if list[j - 1] != list[j] {
break;
}
run -= 1;
}
// Encode deltas.
let mut last_scale = 8;
for scale in &list[0..run] {
let delta_scale = *scale as i32 - last_scale;
self.se(delta_scale)?;
last_scale = *scale as i32;
}
// Didn't encode all values, encode -|last_scale| to set decoder's
// |next_scale| (H.264 7.3.2.1.1.1) to zero, i.e. decoder should repeat
// last values in matrix.
if run < list.len() {
self.se(-last_scale)?;
}
Ok(())
}
fn default_scaling_list(i: usize) -> &'static [u8] {
// H.264 Table 7-2
match i {
0 => &DEFAULT_4X4_INTRA[..],
1 => &DEFAULT_4X4_INTRA[..],
2 => &DEFAULT_4X4_INTRA[..],
3 => &DEFAULT_4X4_INTER[..],
4 => &DEFAULT_4X4_INTER[..],
5 => &DEFAULT_4X4_INTER[..],
6 => &DEFAULT_8X8_INTRA[..],
7 => &DEFAULT_8X8_INTER[..],
8 => &DEFAULT_8X8_INTRA[..],
9 => &DEFAULT_8X8_INTER[..],
10 => &DEFAULT_8X8_INTRA[..],
11 => &DEFAULT_8X8_INTER[..],
_ => unreachable!(),
}
}
fn rbsp_trailing_bits(&mut self) -> SynthesizerResult<()> {
self.f(1, 1u32)?;
while !self.writer.aligned() {
self.f(1, 0u32)?;
}
Ok(())
}
}
impl<'n, W: Write> Synthesizer<'n, Sps, W> {
pub fn synthesize(
ref_idc: u8,
sps: &'n Sps,
writer: W,
ep_enabled: bool,
) -> SynthesizerResult<()> {
let mut s = Self {
writer: NaluWriter::<W>::new(writer, ep_enabled),
nalu: sps,
};
s.writer.write_header(ref_idc, NaluType::Sps as u8)?;
s.seq_parameter_set_data()?;
s.rbsp_trailing_bits()
}
fn hrd_parameters(&mut self, hrd_params: &HrdParams) -> SynthesizerResult<()> {
self.ue(hrd_params.cpb_cnt_minus1)?;
self.u(4, hrd_params.bit_rate_scale)?;
self.u(4, hrd_params.cpb_size_scale)?;
for i in 0..=(hrd_params.cpb_cnt_minus1 as usize) {
self.ue(hrd_params.bit_rate_value_minus1[i])?;
self.ue(hrd_params.cpb_size_value_minus1[i])?;
self.u(1, hrd_params.cbr_flag[i])?;
}
self.u(5, hrd_params.initial_cpb_removal_delay_length_minus1)?;
self.u(5, hrd_params.cpb_removal_delay_length_minus1)?;
self.u(5, hrd_params.dpb_output_delay_length_minus1)?;
self.u(5, hrd_params.time_offset_length)?;
Ok(())
}
fn vui_parameters(&mut self) -> SynthesizerResult<()> {
// H.264 E.1.1
let vui_params = &self.nalu.vui_parameters;
self.u(1, vui_params.aspect_ratio_info_present_flag)?;
if vui_params.aspect_ratio_info_present_flag {
self.u(8, vui_params.aspect_ratio_idc)?;
if vui_params.aspect_ratio_idc == EXTENDED_SAR {
self.u(16, vui_params.sar_width)?;
self.u(16, vui_params.sar_height)?;
}
}
self.u(1, vui_params.overscan_info_present_flag)?;
if vui_params.overscan_info_present_flag {
self.u(1, vui_params.overscan_appropriate_flag)?;
}
self.u(1, vui_params.video_signal_type_present_flag)?;
if vui_params.video_signal_type_present_flag {
self.u(3, vui_params.video_format)?;
self.u(1, vui_params.video_full_range_flag)?;
self.u(1, vui_params.colour_description_present_flag)?;
if vui_params.colour_description_present_flag {
self.u(8, vui_params.colour_primaries)?;
self.u(8, vui_params.transfer_characteristics)?;
self.u(8, vui_params.matrix_coefficients)?;
}
}
self.u(1, vui_params.chroma_loc_info_present_flag)?;
if vui_params.chroma_loc_info_present_flag {
self.ue(vui_params.chroma_sample_loc_type_top_field)?;
self.ue(self.nalu.vui_parameters.chroma_sample_loc_type_bottom_field)?;
}
self.u(1, vui_params.timing_info_present_flag)?;
if vui_params.timing_info_present_flag {
self.u(32, vui_params.num_units_in_tick)?;
self.u(32, vui_params.time_scale)?;
self.u(1, vui_params.fixed_frame_rate_flag)?;
}
self.u(1, vui_params.nal_hrd_parameters_present_flag)?;
if vui_params.nal_hrd_parameters_present_flag {
self.hrd_parameters(&vui_params.nal_hrd_parameters)?;
}
self.u(1, vui_params.vcl_hrd_parameters_present_flag)?;
if vui_params.vcl_hrd_parameters_present_flag {
self.hrd_parameters(&vui_params.vcl_hrd_parameters)?;
}
if vui_params.nal_hrd_parameters_present_flag || vui_params.vcl_hrd_parameters_present_flag
{
self.u(1, vui_params.low_delay_hrd_flag)?;
}
self.u(1, vui_params.pic_struct_present_flag)?;
self.u(1, vui_params.bitstream_restriction_flag)?;
if vui_params.bitstream_restriction_flag {
self.u(1, vui_params.motion_vectors_over_pic_boundaries_flag)?;
self.ue(vui_params.max_bytes_per_pic_denom)?;
self.ue(vui_params.max_bits_per_mb_denom)?;
self.ue(vui_params.log2_max_mv_length_horizontal)?;
self.ue(vui_params.log2_max_mv_length_vertical)?;
self.ue(vui_params.max_num_reorder_frames)?;
self.ue(vui_params.max_dec_frame_buffering)?;
}
Ok(())
}
fn seq_parameter_set_data(&mut self) -> SynthesizerResult<()> {
// H.264 7.3.2.1.1
self.u(8, self.nalu.profile_idc)?;
self.u(1, self.nalu.constraint_set0_flag)?;
self.u(1, self.nalu.constraint_set1_flag)?;
self.u(1, self.nalu.constraint_set2_flag)?;
self.u(1, self.nalu.constraint_set3_flag)?;
self.u(1, self.nalu.constraint_set4_flag)?;
self.u(1, self.nalu.constraint_set5_flag)?;
self.u(2, /* reserved_zero_2bits */ 0u32)?;
self.u(8, self.nalu.level_idc as u32)?;
self.ue(self.nalu.seq_parameter_set_id)?;
if self.nalu.profile_idc == 100
|| self.nalu.profile_idc == 110
|| self.nalu.profile_idc == 122
|| self.nalu.profile_idc == 244
|| self.nalu.profile_idc == 44
|| self.nalu.profile_idc == 83
|| self.nalu.profile_idc == 86
|| self.nalu.profile_idc == 118
|| self.nalu.profile_idc == 128
|| self.nalu.profile_idc == 138
|| self.nalu.profile_idc == 139
|| self.nalu.profile_idc == 134
|| self.nalu.profile_idc == 135
{
self.ue(self.nalu.chroma_format_idc)?;
if self.nalu.chroma_format_idc == 3 {
self.u(1, self.nalu.separate_colour_plane_flag)?;
}
self.ue(self.nalu.bit_depth_luma_minus8)?;
self.ue(self.nalu.bit_depth_chroma_minus8)?;
self.u(1, self.nalu.qpprime_y_zero_transform_bypass_flag)?;
self.u(1, self.nalu.seq_scaling_matrix_present_flag)?;
if self.nalu.seq_scaling_matrix_present_flag {
let scaling_list_count = if self.nalu.chroma_format_idc != 3 {
8
} else {
12
};
for i in 0..scaling_list_count {
// Assume if scaling lists are zeroed that they are not present.
if i < 6 {
if self.nalu.scaling_lists_4x4[i] == [0; 16] {
self.u(1, /* seq_scaling_list_present_flag */ false)?;
} else {
self.u(1, /* seq_scaling_list_present_flag */ true)?;
self.scaling_list(
&self.nalu.scaling_lists_4x4[i],
Self::default_scaling_list(i),
)?;
}
} else if self.nalu.scaling_lists_8x8[i - 6] == [0; 64] {
self.u(1, /* seq_scaling_list_present_flag */ false)?;
} else {
self.u(1, /* seq_scaling_list_present_flag */ true)?;
self.scaling_list(
&self.nalu.scaling_lists_8x8[i - 6],
Self::default_scaling_list(i),
)?;
}
}
}
}
self.ue(self.nalu.log2_max_frame_num_minus4)?;
self.ue(self.nalu.pic_order_cnt_type)?;
if self.nalu.pic_order_cnt_type == 0 {
self.ue(self.nalu.log2_max_pic_order_cnt_lsb_minus4)?;
} else if self.nalu.pic_order_cnt_type == 1 {
self.u(1, self.nalu.delta_pic_order_always_zero_flag)?;
self.se(self.nalu.offset_for_non_ref_pic)?;
self.se(self.nalu.offset_for_top_to_bottom_field)?;
self.ue(self.nalu.num_ref_frames_in_pic_order_cnt_cycle)?;
for offset_for_ref_frame in &self.nalu.offset_for_ref_frame {
self.se(*offset_for_ref_frame)?;
}
}
self.ue(self.nalu.max_num_ref_frames)?;
self.u(1, self.nalu.gaps_in_frame_num_value_allowed_flag)?;
self.ue(self.nalu.pic_width_in_mbs_minus1)?;
self.ue(self.nalu.pic_height_in_map_units_minus1)?;
self.u(1, self.nalu.frame_mbs_only_flag)?;
if !self.nalu.frame_mbs_only_flag {
self.u(1, self.nalu.mb_adaptive_frame_field_flag)?;
}
self.u(1, self.nalu.direct_8x8_inference_flag)?;
self.u(1, self.nalu.frame_cropping_flag)?;
if self.nalu.frame_cropping_flag {
self.ue(self.nalu.frame_crop_left_offset)?;
self.ue(self.nalu.frame_crop_right_offset)?;
self.ue(self.nalu.frame_crop_top_offset)?;
self.ue(self.nalu.frame_crop_bottom_offset)?;
}
self.u(1, self.nalu.vui_parameters_present_flag)?;
if self.nalu.vui_parameters_present_flag {
self.vui_parameters()?;
}
Ok(())
}
}
impl<'n, W: Write> Synthesizer<'n, Pps, W> {
pub fn synthesize(
ref_idc: u8,
pps: &'n Pps,
writer: W,
ep_enabled: bool,
) -> SynthesizerResult<()> {
let mut s = Self {
writer: NaluWriter::<W>::new(writer, ep_enabled),
nalu: pps,
};
s.writer.write_header(ref_idc, NaluType::Pps as u8)?;
s.pic_parameter_set_rbsp()?;
s.rbsp_trailing_bits()
}
fn pic_parameter_set_rbsp(&mut self) -> SynthesizerResult<()> {
self.ue(self.nalu.pic_parameter_set_id)?;
self.ue(self.nalu.seq_parameter_set_id)?;
self.u(1, self.nalu.entropy_coding_mode_flag)?;
self.u(1, self.nalu.bottom_field_pic_order_in_frame_present_flag)?;
self.ue(self.nalu.num_slice_groups_minus1)?;
if self.nalu.num_slice_groups_minus1 > 0 {
return Err(SynthesizerError::Unsupported);
}
self.ue(self.nalu.num_ref_idx_l0_default_active_minus1)?;
self.ue(self.nalu.num_ref_idx_l1_default_active_minus1)?;
self.u(1, self.nalu.weighted_pred_flag)?;
self.u(2, self.nalu.weighted_bipred_idc)?;
self.se(self.nalu.pic_init_qp_minus26)?;
self.se(self.nalu.pic_init_qs_minus26)?;
self.se(self.nalu.chroma_qp_index_offset)?;
self.u(1, self.nalu.deblocking_filter_control_present_flag)?;
self.u(1, self.nalu.constrained_intra_pred_flag)?;
self.u(1, self.nalu.redundant_pic_cnt_present_flag)?;
if !(self.nalu.transform_8x8_mode_flag
|| self.nalu.pic_scaling_matrix_present_flag
|| self.nalu.second_chroma_qp_index_offset != 0)
{
return Ok(());
}
self.u(1, self.nalu.transform_8x8_mode_flag)?;
self.u(1, self.nalu.pic_scaling_matrix_present_flag)?;
if self.nalu.pic_scaling_matrix_present_flag {
let mut scaling_list_count = 6;
if self.nalu.transform_8x8_mode_flag {
if self.nalu.sps.chroma_format_idc != 3 {
scaling_list_count += 2;
} else {
scaling_list_count += 6;
}
}
for i in 0..scaling_list_count {
// Assume if scaling lists are zeroed that they are not present.
if i < 6 {
if self.nalu.scaling_lists_4x4[i] == [0; 16] {
self.u(1, /* seq_scaling_list_present_flag */ false)?;
} else {
self.u(1, /* seq_scaling_list_present_flag */ true)?;
self.scaling_list(
&self.nalu.scaling_lists_4x4[i],
Self::default_scaling_list(i),
)?;
}
} else if self.nalu.scaling_lists_8x8[i - 6] == [0; 64] {
self.u(1, /* seq_scaling_list_present_flag */ false)?;
} else {
self.u(1, /* seq_scaling_list_present_flag */ true)?;
self.scaling_list(
&self.nalu.scaling_lists_8x8[i - 6],
Self::default_scaling_list(i),
)?;
}
}
}
self.se(self.nalu.second_chroma_qp_index_offset)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::*;
use crate::codec::h264::parser::Nalu;
use crate::codec::h264::parser::NaluType;
use crate::codec::h264::parser::Parser;
use crate::codec::h264::parser::Profile;
#[test]
fn synthesize_sps() {
let raw_sps_buf = [0x00, 0x00, 0x00, 0x01, 0x07, 0x00, 0x00, 0x0a, 0xfb, 0x88];
let mut raw_sps = Cursor::new(&raw_sps_buf[..]);
let nalu = Nalu::next(&mut raw_sps).unwrap();
assert_eq!(nalu.header.type_, NaluType::Sps);
let mut parser = Parser::default();
let sps = parser.parse_sps(&nalu).unwrap();
let mut buf = Vec::<u8>::new();
Synthesizer::<'_, Sps, _>::synthesize(0, sps, &mut buf, false).unwrap();
assert_eq!(buf, raw_sps_buf);
let write_to_file = std::option_env!("CROS_CODECS_TEST_WRITE_TO_FILE") == Some("true");
if write_to_file {
let mut out = std::fs::File::create("sps.h264").unwrap();
out.write_all(&buf).unwrap();
out.flush().unwrap();
}
let mut cursor = Cursor::new(&buf[..]);
let nalu = Nalu::next(&mut cursor).unwrap();
let mut parser = Parser::default();
let sps2 = parser.parse_sps(&nalu).unwrap();
assert_eq!(sps, sps2);
}
#[test]
fn synthesize_sps_scaling_lists() {
let sps = Sps {
profile_idc: Profile::High as u8,
seq_scaling_matrix_present_flag: true,
scaling_lists_4x4: [[
11, 20, 10, 20, 10, 22, 10, 20, 10, 20, 13, 20, 10, 20, 10, 24,
]; 6],
scaling_lists_8x8: [
[
33, 20, 10, 21, 33, 20, 12, 20, 33, 23, 10, 20, 33, 20, 10, 20, 33, 24, 10, 20,
33, 20, 15, 20, 33, 20, 10, 26, 33, 20, 17, 20, 33, 28, 10, 20, 33, 20, 10, 20,
33, 29, 10, 20, 33, 20, 11, 20, 33, 20, 10, 20, 33, 20, 10, 20, 33, 20, 10, 20,
33, 20, 10, 20,
],
[
10, 77, 11, 20, 10, 77, 12, 20, 10, 77, 13, 20, 10, 77, 14, 20, 10, 77, 15, 20,
10, 77, 16, 20, 10, 77, 17, 20, 10, 77, 18, 20, 10, 77, 19, 20, 10, 77, 10, 20,
10, 77, 10, 21, 10, 77, 10, 22, 10, 77, 10, 23, 10, 77, 10, 24, 10, 77, 10, 26,
10, 77, 10, 28,
],
[0; 64],
[0; 64],
[0; 64],
[0; 64],
],
frame_mbs_only_flag: true,
..Default::default()
};
let mut buf = Vec::<u8>::new();
Synthesizer::<'_, Sps, _>::synthesize(0, &sps, &mut buf, false).unwrap();
let write_to_file = std::option_env!("CROS_CODECS_TEST_WRITE_TO_FILE") == Some("true");
if write_to_file {
let mut out = std::fs::File::create("sps.h264").unwrap();
out.write_all(&buf).unwrap();
out.flush().unwrap();
}
let mut cursor = Cursor::new(&buf[..]);
let nalu = Nalu::next(&mut cursor).unwrap();
let mut parser = Parser::default();
let sps2 = parser.parse_sps(&nalu).unwrap();
assert_eq!(sps.scaling_lists_4x4, sps2.scaling_lists_4x4);
assert_eq!(sps.scaling_lists_8x8, sps2.scaling_lists_8x8);
}
#[test]
fn synthesize_pps() {
let raw_sps_pps = [
0x00, 0x00, 0x00, 0x01, 0x07, 0x4d, 0x40, 0x0d, 0xa9, 0x18, 0x28, 0x3e, 0x60, 0x0d,
0x41, 0x80, 0x41, 0xad, 0xb0, 0xad, 0x7b, 0xdf, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08,
0xde, 0x09, 0x88,
];
let mut buf = Vec::<u8>::new();
let mut out = Cursor::new(&mut buf);
let mut cursor = Cursor::new(&raw_sps_pps[..]);
let mut parser: Parser = Default::default();
while let Ok(nalu) = Nalu::next(&mut cursor) {
match nalu.header.type_ {
NaluType::Sps => {
let sps = parser.parse_sps(&nalu).unwrap();
Synthesizer::<'_, Sps, _>::synthesize(0, sps, &mut out, false).unwrap();
}
NaluType::Pps => {
let pps = parser.parse_pps(&nalu).unwrap();
Synthesizer::<'_, Pps, _>::synthesize(0, pps, &mut out, false).unwrap();
}
_ => panic!(),
}
}
let write_to_file = std::option_env!("CROS_CODECS_TEST_WRITE_TO_FILE") == Some("true");
if write_to_file {
let mut out = std::fs::File::create("sps_pps.h264").unwrap();
out.write_all(&buf).unwrap();
out.flush().unwrap();
let mut out = std::fs::File::create("sps_pps_ref.h264").unwrap();
out.write_all(&raw_sps_pps).unwrap();
out.flush().unwrap();
}
assert_eq!(buf, raw_sps_pps);
}
}
@@ -0,0 +1,3 @@
43656b2f
c9dd1361
62d34555
@@ -0,0 +1,3 @@
45ba0c1a27d0ff82fd7a969631adffe6
cee875ded4998c9810a14f9497a11f72
d1e0b9347134ba7a07cbcf249066f022
@@ -0,0 +1,3 @@
ee936370
0e5e577c
bfe430af
@@ -0,0 +1,3 @@
fe6701d54768bc37c76e630435e8fe02
3298d47365ac1f9dc0942ece9e104246
7cb9209603f53ba995f7eee20b2bbf4b
@@ -0,0 +1,2 @@
9fc67012
7f0b441e
@@ -0,0 +1,2 @@
115a8e6899b71c04e32a0254ba62b30a
1fa3f5a930e08943e6d6bfbd5d43004e
@@ -0,0 +1 @@
7dd66ef1
@@ -0,0 +1 @@
d2304abbf0349ec63324741bf723960d
@@ -0,0 +1,70 @@
# H.264 Test Data
This document lists the test data used by the H.264 decoder.
Unless otherwise noted, the CRCs were computed using GStreamer's VA-API decoder in
`gst-plugins-bad`.
## 16x16-I.h264
A 16x16 progressive byte-stream encoded I-frame to make it easier to spot errors on the libva trace.
Encoded with the following GStreamer pipeline:
```
gst-launch-1.0 videotestsrc num-buffers=1 ! video/x-raw,format=I420,width=16,height=16 ! \
x264enc ! video/x-h264,profile=constrained-baseline,stream-format=byte-stream ! \
filesink location="/tmp/16x16-I.h264"
```
## 16x16-I-P.h264
A 16x16 progressive byte-stream encoded I-frame and P-frame to make it easier to spot errors on the
libva trace. Encoded with the following GStreamer pipeline:
```
gst-launch-1.0 videotestsrc num-buffers=2 ! video/x-raw,format=I420,width=16,height=16 ! \
x264enc b-adapt=false ! video/x-h264,profile=constrained-baseline,stream-format=byte-stream ! \
filesink location="/tmp/16x16-I-P.h264"
```
## 16x16-I-P-B-P.h264
A 16x16 progressive byte-stream encoded I-P-B-P sequence to make it easier to it easier to spot
errors on the libva trace. Encoded with the following GStreamer pipeline:
```
gst-launch-1.0 videotestsrc num-buffers=3 ! video/x-raw,format=I420,width=16,height=16 ! \
x264enc b-adapt=false bframes=1 ! video/x-h264,profile=constrained-baseline,stream-format=byte-stream ! \
filesink location="/tmp/16x16-I-B-P.h264"
```
## 16x16-I-P-B-P-high.h264
A 16x16 progressive byte-stream encoded I-P-B-P sequence to make it easier to it easier to spot
errors on the libva trace. Also tests whether the decoder supports the high profile. Encoded with
the following GStreamer pipeline:
```
gst-launch-1.0 videotestsrc num-buffers=3 ! video/x-raw,format=I420,width=16,height=16 ! \
x264enc b-adapt=false bframes=1 ! video/x-h264,profile=high,stream-format=byte-stream ! \
filesink location="/tmp/16x16-I-B-P-high.h264"
```
## test-25fps.h264
Same as Chromium's `test-25fps.h264`. The slice data in `test-25fps-h264-slice-data-*.bin` was
manually extracted from GStreamer using GDB.
## test-25fps-interlaced.h264
Adapted from Chromium's `test-25fps.h264`. Same file as above, but encoded as interlaced instead
using the following ffmpeg command:
```
ffmpeg -i \
src/third_party/blink/web_tests/media/content/test-25fps.mp4 \
-flags +ilme+ildct -vbsf h264_mp4toannexb -an test-25fps.h264
```
This test makes sure that the interlaced logic in the decoder actually works, specially that "frame
splitting" works, as the fields here were encoded as frames.
@@ -0,0 +1,8 @@
#!/bin/bash
# Generates the CRCs for all .h264 files in the current directory using ffmpeg.
for f in `ls *.h264`; do
ffmpeg -i $f -pix_fmt nv12 -f framehash -hash crc32 - |grep -v '^#' |awk '{print $6}' >$f.crc
ffmpeg -i $f -pix_fmt nv12 -f framehash -hash md5 - |grep -v '^#' |awk '{print $6}' >$f.md5
done

Some files were not shown because too many files have changed in this diff Show More