Full project rename, decided 2026-06-10: - Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs. - C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h, PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl. PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants). - Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1. WIRE BREAK: clients must be rebuilt from this revision. - Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / …. - Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the persistent identity is unchanged, pinned fingerprints stay valid). - Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated. - scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated. Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of "desktop but no apps/settings" over the stream: plasmashell launched without XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and rendered an empty menu. The script sets the complete KDE session env (menu prefix, KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell. Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS, zero lumen references left outside .git. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
//! Hardware video encode (plan §7). Binds FFmpeg (NVENC); never rewrites codecs.
|
||||
//! Low-latency preset, B-frames off. M0 feeds BGRx CPU frames directly — `*_nvenc`
|
||||
//! accepts `bgr0` input and converts to YUV on the GPU, so no host-side swscale is
|
||||
//! needed (dmabuf zero-copy import is deferred; plan §9).
|
||||
|
||||
use crate::capture::{CapturedFrame, PixelFormat};
|
||||
use anyhow::Result;
|
||||
|
||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
||||
/// stream and a self-contained AU for the wire.
|
||||
pub struct EncodedFrame {
|
||||
pub data: Vec<u8>,
|
||||
pub pts_ns: u64,
|
||||
/// True for IDR/keyframes (sets the SOF/keyframe wire flags).
|
||||
pub keyframe: bool,
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
Av1,
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||
/// pick the software encoder).
|
||||
pub fn nvenc_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_nvenc",
|
||||
Codec::H265 => "hevc_nvenc",
|
||||
Codec::Av1 => "av1_nvenc",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
/// Pull the next encoded AU if one is ready.
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// Maximum encodable dimension (px) per side for this codec on NVENC. H.264 tops out at
|
||||
/// 4096 (level constraint); HEVC and AV1 allow 8192. Used to reject out-of-range client
|
||||
/// modes up front (see [`validate_dimensions`]).
|
||||
pub fn max_dimension(self) -> u32 {
|
||||
match self {
|
||||
Codec::H264 => 4096,
|
||||
Codec::H265 | Codec::Av1 => 8192,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a requested encode resolution before we allocate buffers or open NVENC. Rejects
|
||||
/// zero/odd-sized and out-of-range modes with a clear error instead of letting buffer math
|
||||
/// overflow or the encoder open fail with an opaque NVENC code. A client can request any
|
||||
/// `mode=WxHxFPS`, so this is the gate on attacker/typo-controlled dimensions.
|
||||
pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()> {
|
||||
if width == 0 || height == 0 {
|
||||
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be non-zero");
|
||||
}
|
||||
// NVENC requires even dimensions for the chroma subsampling it does internally.
|
||||
if width % 2 != 0 || height % 2 != 0 {
|
||||
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be even");
|
||||
}
|
||||
let max = codec.max_dimension();
|
||||
if width > max || height > max {
|
||||
anyhow::bail!(
|
||||
"{codec:?} max dimension is {max}px; requested {width}x{height} \
|
||||
(use HEVC/AV1 above 4096, or lower the client resolution)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open an NVENC encoder for frames of the given `format` and mode. When `cuda` is true the
|
||||
/// encoder takes GPU frames (`AV_PIX_FMT_CUDA`) from the zero-copy path; otherwise it takes
|
||||
/// packed RGB/BGR CPU frames. `format`/`bitrate_bps`/`codec`/mode come from session
|
||||
/// negotiation; the caller derives `cuda` from the first captured frame's payload.
|
||||
pub fn open_video(
|
||||
codec: Codec,
|
||||
format: PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
cuda: bool,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
validate_dimensions(codec, width, height)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let enc = linux::NvencEncoder::open(codec, format, width, height, fps, bitrate_bps, cuda)?;
|
||||
Ok(Box::new(enc) as Box<dyn Encoder>)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (codec, format, width, height, fps, bitrate_bps, cuda);
|
||||
anyhow::bail!("NVENC encode requires Linux (FFmpeg + NVIDIA driver)")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_odd_dimensions() {
|
||||
assert!(validate_dimensions(Codec::H265, 0, 1080).is_err());
|
||||
assert!(validate_dimensions(Codec::H265, 1920, 0).is_err());
|
||||
assert!(validate_dimensions(Codec::H265, 1921, 1080).is_err()); // odd width
|
||||
assert!(validate_dimensions(Codec::H265, 1920, 1081).is_err()); // odd height
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_capped_at_4096() {
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 2160).is_ok()); // 4K fits (width < 4096)
|
||||
assert!(validate_dimensions(Codec::H264, 4096, 4096).is_ok()); // exactly at the limit
|
||||
assert!(validate_dimensions(Codec::H264, 4098, 2160).is_err());
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_and_av1_allow_up_to_8192() {
|
||||
for c in [Codec::H265, Codec::Av1] {
|
||||
assert!(validate_dimensions(c, 3840, 2160).is_ok());
|
||||
assert!(validate_dimensions(c, 7680, 4320).is_ok()); // 8K fits
|
||||
assert!(validate_dimensions(c, 8192, 8192).is_ok());
|
||||
assert!(validate_dimensions(c, 8194, 4320).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_modes_accepted() {
|
||||
for c in [Codec::H264, Codec::H265, Codec::Av1] {
|
||||
for (w, h) in [(1280, 720), (1920, 1080), (2560, 1440)] {
|
||||
assert!(validate_dimensions(c, w, h).is_ok(), "{c:?} {w}x{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user