docs(pyrowave): document 4:4:4 + HDR and add an interactive bitrate calculator
decky / build-publish (push) Successful in 20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 56s
arch / build-publish (push) Successful in 12m17s
deb / build-publish (push) Successful in 9m20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m18s
docker / deploy-docs (push) Successful in 26s
android / android (push) Successful in 16m30s
deb / build-publish-host (push) Successful in 12m43s
ci / rust (push) Successful in 24m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m38s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m39s
ci / web (push) Successful in 48s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 54s
ci / bench (push) Successful in 5m55s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
apple / screenshots (push) Successful in 6m31s

- New "4:4:4 and HDR" section: what each mode buys, the ~1.6× / +15% bitrate
  cost, that HDR needs a Windows host today (Linux capture has no HDR source),
  and that the two combine (~1.9× the 4:2:0 SDR rate).
- Interactive <BitrateCalculator> (registered in the MDX components map):
  resolution (presets or custom), frame rate, 4:2:0/4:4:4, SDR/HDR -> the
  estimated Automatic pin, bits/pixel, per-frame size, and which link tier it
  needs. Formula mirrors the host's resolve_bitrate_kbps_for exactly.
- Expanded the bandwidth table with 120 Hz rows; note the big modes want 5/10 GbE.
- Document PUNKTFUNK_PYROWAVE_MAX_MBPS (cap the open-loop pin on a constrained
  link) in configuration.md.
- pyrowave.md -> .mdx so the page can host the component.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 19:28:28 +02:00
parent 9fe9c451dc
commit e5eec51a78
4 changed files with 368 additions and 8 deletions
+1
View File
@@ -90,6 +90,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. | | `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. **Windows host only** (the Linux host stays 8-bit). | | `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. **Windows host only** (the Linux host stays 8-bit). |
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. | | `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. | | `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. | | `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
@@ -27,23 +27,52 @@ either side can't, the session silently falls back to the normal codec ladder.
## What it costs ## What it costs
Bandwidth. At the codec's ~1.6 bits-per-pixel operating point (4:2:0, 60 fps): Bandwidth. At the codec's ~1.6 bits-per-pixel operating point (4:2:0, SDR):
| Mode | Bitrate | | Mode | Bitrate |
|---|---| |---|---|
| 1280×800 @ 60 (Deck) | ≈ 100 Mbps | | 1280×800 @ 60 (Deck) | ≈ 100 Mbps |
| 1920×1080 @ 60 | ≈ 200 Mbps | | 1920×1080 @ 60 | ≈ 200 Mbps |
| 1920×1080 @ 120 | ≈ 400 Mbps |
| 2560×1440 @ 60 | ≈ 355 Mbps | | 2560×1440 @ 60 | ≈ 355 Mbps |
| 2560×1440 @ 120 | ≈ 710 Mbps |
| 3840×2160 @ 60 | ≈ 800 Mbps | | 3840×2160 @ 60 | ≈ 800 Mbps |
| 3840×2160 @ 120 | ≈ 1.6 Gbps |
120 Hz doubles these, 4:4:4 multiplies them by ~1.6, and an HDR (10-bit) session adds ~15 %. Frame rate scales the rate linearly, [4:4:4](#444-and-hdr) multiplies it by ~1.6, and an
Gigabit Ethernet tops out around 940 Mbps of payload, so 4K60 wants 2.5GbE (or a lower [HDR](#444-and-hdr) (10-bit) session adds ~15 %. Estimate any combination:
rate). **Do not run this over Wi-Fi** — that's what HEVC/AV1 are for.
PyroWave follows the same 4:4:4 and HDR settings as HEVC/AV1: a session negotiates <BitrateCalculator />
full-chroma 4:4:4 when your client's 4:4:4 setting is on, and HDR (BT.2020 PQ, carried in
16-bit planes) when the host's display pipeline is HDR — currently the Windows host only Gigabit Ethernet tops out around 940 Mbps of payload, so 4K60 wants 2.5GbE and the big
(the Linux host's capture path has no HDR source yet, so Linux-hosted sessions are SDR). 4:4:4 / HDR / high-refresh modes want 5GbE or 10GbE. **Do not run this over Wi-Fi** — that's
what HEVC/AV1 are for.
## 4:4:4 and HDR
PyroWave carries **full-chroma 4:4:4** and **HDR** the same way it carries everything else —
intra-only, every frame a keyframe — so the low-latency and clean-loss properties above hold
in these modes too. Both are negotiated per session from your client's settings, exactly like
HEVC/AV1; nothing PyroWave-specific to turn on beyond picking the codec.
- **4:4:4 (full chroma).** With your client's **4:4:4** setting on, the session encodes chroma
at full resolution instead of subsampled 4:2:0 — sharp coloured text, thin UI lines, and
red/blue edges that 4:2:0 softens. It costs ~1.6× the bitrate (chroma compresses better than
luma, so it is less than the 2× the extra samples imply). Available on Linux and Windows
hosts.
- **HDR (10-bit, BT.2020 PQ).** With HDR on and an HDR host display pipeline, the session
carries a 10-bit BT.2020 PQ signal in 16-bit planes and adds ~15 % to the bitrate. **HDR
needs a Windows host today** — the Linux host's capture path has no HDR source yet, so
Linux-hosted sessions are SDR (4:4:4 still works).
- The two combine: a 4:4:4 **and** HDR session applies both factors (~1.6 × 1.15 ≈ 1.9× the
4:2:0 SDR rate). The Apple and Rust clients decode whatever the session negotiated — 4:2:0
or 4:4:4, SDR or HDR — with no extra setup.
At the top end this gets demanding: 4:4:4 + HDR at a super-ultrawide 5120×1440@240 pins around
5.3 Gbps, which is more than a 5GbE link carries. On a link that can't keep up, either set an
explicit lower bitrate on the client or cap the host's Automatic pin with
[`PUNKTFUNK_PYROWAVE_MAX_MBPS`](/docs/configuration) — otherwise the overshoot just becomes
dropped packets.
## Turning it on ## Turning it on
@@ -0,0 +1,327 @@
// Interactive PyroWave bitrate estimator for the docs. The formula mirrors the host's
// `resolve_bitrate_kbps_for` (crates/punktfunk-host/src/native.rs): the Automatic pin is
// ~1.6 bits/pixel for 4:2:0, ~2.6 bpp for 4:4:4, +15% for a 10-bit/HDR session, clamped to
// [0.5 Mbps, 8 Gbps]. Self-contained (no design-system imports) so it renders even where the
// full workspace UI packages are unavailable; themed via Fumadocs' `--color-fd-*` variables.
import { useState } from 'react'
type Preset = { label: string; w: number; h: number }
const RES_PRESETS: Preset[] = [
{ label: '1280 × 800 — Steam Deck', w: 1280, h: 800 },
{ label: '1920 × 1080 — 1080p', w: 1920, h: 1080 },
{ label: '2560 × 1440 — 1440p', w: 2560, h: 1440 },
{ label: '3440 × 1440 — ultrawide', w: 3440, h: 1440 },
{ label: '3840 × 2160 — 4K', w: 3840, h: 2160 },
{ label: '5120 × 1440 — super-ultrawide', w: 5120, h: 1440 },
]
const FPS_PRESETS = [30, 60, 90, 120, 144, 240]
// Practical payload ceilings (a bit under line rate — headers, FEC, framing).
const LINKS = [
{ label: 'Gigabit', mbps: 940 },
{ label: '2.5 GbE', mbps: 2350 },
{ label: '5 GbE', mbps: 4700 },
{ label: '10 GbE', mbps: 9400 },
]
const MIN_MBPS = 0.5
const MAX_MBPS = 8000
function pyrowaveMbps(
w: number,
h: number,
fps: number,
chroma444: boolean,
hdr: boolean,
): number {
if (!(w > 0) || !(h > 0) || !(fps > 0)) return 0
const bppX10 = chroma444 ? 26 : 16
let kbps = (w * h * fps * bppX10) / 10 / 1000
if (hdr) kbps = (kbps * 115) / 100
kbps = Math.min(Math.max(kbps, MIN_MBPS * 1000), MAX_MBPS * 1000)
return kbps / 1000
}
const card: React.CSSProperties = {
border: '1px solid var(--color-fd-border, #e5e7eb)',
borderRadius: '0.75rem',
background: 'var(--color-fd-card, transparent)',
padding: '1.25rem',
margin: '1.5rem 0',
}
const label: React.CSSProperties = {
display: 'block',
fontSize: '0.75rem',
fontWeight: 600,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: 'var(--color-fd-muted-foreground, #6b7280)',
marginBottom: '0.35rem',
}
const field: React.CSSProperties = {
width: '100%',
padding: '0.45rem 0.6rem',
borderRadius: '0.5rem',
border: '1px solid var(--color-fd-border, #e5e7eb)',
background: 'var(--color-fd-background, transparent)',
color: 'var(--color-fd-foreground, inherit)',
fontSize: '0.9rem',
}
function Toggle({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
style={{
flex: 1,
padding: '0.45rem 0.6rem',
borderRadius: '0.5rem',
border: '1px solid var(--color-fd-border, #e5e7eb)',
cursor: 'pointer',
fontSize: '0.9rem',
fontWeight: 600,
background: active
? 'var(--color-fd-primary, #6c5bf3)'
: 'var(--color-fd-background, transparent)',
color: active
? 'var(--color-fd-primary-foreground, #fff)'
: 'var(--color-fd-foreground, inherit)',
}}
>
{children}
</button>
)
}
export default function BitrateCalculator() {
const [presetIdx, setPresetIdx] = useState(1) // 1080p
const [custom, setCustom] = useState(false)
const [cw, setCw] = useState(1920)
const [ch, setCh] = useState(1080)
const [fps, setFps] = useState(60)
const [chroma444, setChroma444] = useState(false)
const [hdr, setHdr] = useState(false)
const preset = RES_PRESETS[presetIdx] ?? RES_PRESETS[1]!
const w = custom ? cw : preset.w
const h = custom ? ch : preset.h
const mbps = pyrowaveMbps(w, h, fps, chroma444, hdr)
const gbps = mbps / 1000
const bpp = w > 0 && h > 0 && fps > 0 ? (mbps * 1e6) / (w * h * fps) : 0
const frameKB = fps > 0 ? (mbps * 1e6) / 8 / fps / 1024 : 0
const needed = LINKS.find((l) => l.mbps >= mbps)
const big =
mbps >= 1000 ? `${gbps.toFixed(2)} Gbps` : `${Math.round(mbps)} Mbps`
return (
<div style={card}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '0.9rem',
}}
>
<div style={{ gridColumn: '1 / -1' }}>
<label style={label}>Resolution</label>
<select
style={field}
value={custom ? 'custom' : String(presetIdx)}
onChange={(e) => {
if (e.target.value === 'custom') {
setCustom(true)
} else {
setCustom(false)
setPresetIdx(Number(e.target.value))
}
}}
>
{RES_PRESETS.map((p, i) => (
<option key={p.label} value={i}>
{p.label}
</option>
))}
<option value="custom">Custom</option>
</select>
</div>
{custom && (
<>
<div>
<label style={label}>Width</label>
<input
style={field}
type="number"
min={128}
value={cw}
onChange={(e) => setCw(Math.max(0, Number(e.target.value)))}
/>
</div>
<div>
<label style={label}>Height</label>
<input
style={field}
type="number"
min={128}
value={ch}
onChange={(e) => setCh(Math.max(0, Number(e.target.value)))}
/>
</div>
</>
)}
<div>
<label style={label}>Frame rate</label>
<select
style={field}
value={fps}
onChange={(e) => setFps(Number(e.target.value))}
>
{FPS_PRESETS.map((f) => (
<option key={f} value={f}>
{f} fps
</option>
))}
</select>
</div>
<div>
<label style={label}>Chroma</label>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<Toggle active={!chroma444} onClick={() => setChroma444(false)}>
4:2:0
</Toggle>
<Toggle active={chroma444} onClick={() => setChroma444(true)}>
4:4:4
</Toggle>
</div>
</div>
<div>
<label style={label}>Dynamic range</label>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<Toggle active={!hdr} onClick={() => setHdr(false)}>
SDR (8-bit)
</Toggle>
<Toggle active={hdr} onClick={() => setHdr(true)}>
HDR (10-bit)
</Toggle>
</div>
</div>
</div>
<div
style={{
marginTop: '1.1rem',
paddingTop: '1.1rem',
borderTop: '1px solid var(--color-fd-border, #e5e7eb)',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'baseline',
gap: '0.4rem 1.4rem',
}}
>
<div
style={{
fontSize: '2rem',
fontWeight: 700,
color: 'var(--color-fd-primary, #6c5bf3)',
lineHeight: 1.1,
}}
>
{big}
</div>
<div
style={{
fontSize: '0.85rem',
color: 'var(--color-fd-muted-foreground, #6b7280)',
}}
>
{bpp.toFixed(2)} bits/pixel · {Math.round(frameKB)} KB per frame ·{' '}
{needed ? `needs ${needed.label}` : 'over 10 GbE — lower the mode'}
</div>
</div>
<div style={{ marginTop: '0.9rem', display: 'grid', gap: '0.4rem' }}>
{LINKS.map((l) => {
const fits = l.mbps >= mbps
return (
<div
key={l.label}
style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}
>
<span
style={{
width: '4.5rem',
fontSize: '0.8rem',
fontWeight: 600,
color: 'var(--color-fd-muted-foreground, #6b7280)',
}}
>
{l.label}
</span>
<div
style={{
flex: 1,
height: '0.5rem',
borderRadius: '999px',
background: 'var(--color-fd-muted, #eef0f4)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${Math.min(100, (mbps / l.mbps) * 100)}%`,
height: '100%',
background: fits
? 'var(--color-fd-primary, #6c5bf3)'
: '#e5484d',
}}
/>
</div>
<span
style={{
width: '2.5rem',
textAlign: 'right',
fontSize: '0.75rem',
color: fits
? 'var(--color-fd-muted-foreground, #6b7280)'
: '#e5484d',
}}
>
{Math.round((mbps / l.mbps) * 100)}%
</span>
</div>
)
})}
</div>
<p
style={{
marginTop: '0.9rem',
marginBottom: 0,
fontSize: '0.75rem',
color: 'var(--color-fd-muted-foreground, #6b7280)',
}}
>
Estimate of the Automatic bitrate a PyroWave session pins for this mode. Link bars use a
practical payload ceiling (below line rate). The pin is capped at 8 Gbps; on a constrained
link a host can cap it lower with <code>PUNKTFUNK_PYROWAVE_MAX_MBPS</code>.
</p>
</div>
)
}
+3
View File
@@ -1,9 +1,12 @@
import defaultMdxComponents from 'fumadocs-ui/mdx' import defaultMdxComponents from 'fumadocs-ui/mdx'
import type { MDXComponents } from 'mdx/types' import type { MDXComponents } from 'mdx/types'
import BitrateCalculator from '@/components/BitrateCalculator'
export function getMDXComponents(components?: MDXComponents) { export function getMDXComponents(components?: MDXComponents) {
return { return {
...defaultMdxComponents, ...defaultMdxComponents,
// Custom components usable in any .md/.mdx without a per-file import.
BitrateCalculator,
...components, ...components,
} satisfies MDXComponents } satisfies MDXComponents
} }