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
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:
@@ -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_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_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_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
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| 1280×800 @ 60 (Deck) | ≈ 100 Mbps |
|
||||
| 1920×1080 @ 60 | ≈ 200 Mbps |
|
||||
| 1920×1080 @ 120 | ≈ 400 Mbps |
|
||||
| 2560×1440 @ 60 | ≈ 355 Mbps |
|
||||
| 2560×1440 @ 120 | ≈ 710 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 %.
|
||||
Gigabit Ethernet tops out around 940 Mbps of payload, so 4K60 wants 2.5GbE (or a lower
|
||||
rate). **Do not run this over Wi-Fi** — that's what HEVC/AV1 are for.
|
||||
Frame rate scales the rate linearly, [4:4:4](#444-and-hdr) multiplies it by ~1.6, and an
|
||||
[HDR](#444-and-hdr) (10-bit) session adds ~15 %. Estimate any combination:
|
||||
|
||||
PyroWave follows the same 4:4:4 and HDR settings as HEVC/AV1: a session negotiates
|
||||
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
|
||||
(the Linux host's capture path has no HDR source yet, so Linux-hosted sessions are SDR).
|
||||
<BitrateCalculator />
|
||||
|
||||
Gigabit Ethernet tops out around 940 Mbps of payload, so 4K60 wants 2.5GbE and the big
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx'
|
||||
import type { MDXComponents } from 'mdx/types'
|
||||
import BitrateCalculator from '@/components/BitrateCalculator'
|
||||
|
||||
export function getMDXComponents(components?: MDXComponents) {
|
||||
return {
|
||||
...defaultMdxComponents,
|
||||
// Custom components usable in any .md/.mdx without a per-file import.
|
||||
BitrateCalculator,
|
||||
...components,
|
||||
} satisfies MDXComponents
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user