feat(agent,server): v2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m57s
Build and Test / Build Server (Linux) (push) Successful in 10m23s
Build and Test / Security Audit (push) Successful in 4m15s
Build and Test / Build Summary (push) Successful in 9s

SPEC-002 Phase 1 Task 7 (the last), code-reviewed APPROVED, locally verified
(cargo fmt + clippy -D warnings exit 0 + cargo test --workspace 89 pass + build).

- Encoder trait + factory: RawEncoder (salvaged, UNCHANGED) and H264Encoder,
  selected by negotiation; factory falls back to raw on H.264 init failure.
- Negotiation: agent advertises supports_h264 (MFTEnumEx HW probe, cached) in
  AgentStatus; server picks the codec via select_video_codec(supports, prefer)
  and stamps StartStream.video_codec; agent re-guards on local HW. Policy
  constant DEFAULT_PREFER_H264 = false, so RAW is negotiated for every session
  today - H.264 stays dormant until live hardware validation (Task 8).
- MF H.264 encoder (h264.rs, FIRST-CUT / compile-verified-only): HW encoder MFT,
  BGRA->NV12 (color.rs, unit-tested), sync drain, fall-back-to-raw on any failure.
- Viewer H.264 decoder (decoder.rs, FIRST-CUT): MF decoder on a dedicated COM
  thread; drops+logs on failure, raw render path untouched.
- proto additive: VideoCodec enum, StartStream.video_codec=3,
  SessionResponse.video_codec=5, AgentStatus.supports_h264=11.
- Raw+Zstd path byte-for-byte unchanged; remains the guaranteed default/fallback.

Review confirmed unsafe impl Send for H264Encoder is sound (single-owned &mut on
the block_on thread; session future never spawned) and every MF failure degrades
to raw. H.264 is NOT claimed functional - compile/clippy/build-verified only;
live validation + force-IDR + the no-spawn-invariant doc are Task 8 go-live gates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 10:35:04 -07:00
parent bb73ba667f
commit f9bdecbfdb
12 changed files with 1885 additions and 23 deletions

View File

@@ -1,16 +1,27 @@
//! Frame encoding module
//!
//! Encodes captured frames for transmission. Supports:
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode)
//! - VP9 software encoding (universal fallback)
//! - H264 hardware encoding (when GPU available)
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode; the guaranteed
//! fallback and the current default).
//! - H.264 hardware encoding via Windows Media Foundation (Task 7) — the
//! negotiated upgrade. Compile-verified; validated on real hardware in plan
//! Task 8. On any init/feed failure the factory or encoder falls back to raw.
//!
//! Codec selection is driven by the negotiated `VideoCodec` the server sends on
//! `StartStream` (see `select_codec` / `create_encoder_for`). The capability the
//! agent advertises to the server is detected by `capability::supports_hardware_h264`.
mod capability;
pub(crate) mod color;
#[cfg(windows)]
mod h264;
mod raw;
pub use capability::supports_hardware_h264;
pub use raw::RawEncoder;
use crate::capture::CapturedFrame;
use crate::proto::VideoFrame;
use crate::proto::{video_frame, VideoCodec, VideoFrame};
use anyhow::Result;
/// Encoded frame ready for transmission
@@ -28,7 +39,12 @@ pub struct EncodedFrame {
pub is_keyframe: bool,
}
/// Frame encoder trait
/// Frame encoder trait.
///
/// Every implementor turns a `CapturedFrame` (BGRA) into a wire `VideoFrame`
/// using one `video_frame::Encoding` variant. `RawEncoder` emits the `Raw`
/// variant; the H.264 encoder emits the `H264` variant. The factory
/// (`create_encoder_for`) selects the implementor from the negotiated codec.
pub trait Encoder: Send {
/// Encode a captured frame
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame>;
@@ -42,13 +58,167 @@ pub trait Encoder: Send {
fn name(&self) -> &str;
}
/// Create an encoder based on configuration
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> {
/// Map a configured/negotiated codec string to a `VideoCodec`.
///
/// Used when constructing an encoder from the agent's own `EncodingConfig`
/// (before any server negotiation). Unknown / "auto" / "raw" all resolve to raw
/// — the safe default. "h264" resolves to H.264 (which itself falls back to raw
/// if MF init fails).
///
/// Retained as the config-string entry point (used by `create_encoder` and the
/// unit tests); the live session negotiates via `select_codec` on a `VideoCodec`.
#[allow(dead_code)]
pub fn codec_from_str(codec: &str) -> VideoCodec {
match codec.to_lowercase().as_str() {
"raw" | "zstd" => Ok(Box::new(RawEncoder::new(quality)?)),
// "vp9" => Ok(Box::new(Vp9Encoder::new(quality)?)),
// "h264" => Ok(Box::new(H264Encoder::new(quality)?)),
// "auto" and any unknown codec default to raw for now (best for LAN)
_ => Ok(Box::new(RawEncoder::new(quality)?)),
"h264" => VideoCodec::H264,
// "h265"/"hevc" are future opt-in (TODO) — treat as raw for now so we
// never select an unimplemented codec.
_ => VideoCodec::Raw,
}
}
/// Choose the codec the agent will actually use for a stream, given the codec
/// the server negotiated and the agent's own hardware capability.
///
/// This is the agent-side guard that keeps the raw fallback authoritative:
/// - The server only negotiates H.264 when the agent advertised support, but we
/// re-check `supports_hardware_h264()` here so a stale/misconfigured server
/// selection can never force an unsupported codec.
/// - H.265 is not implemented; it degrades to raw.
/// - Anything else is raw.
pub fn select_codec(negotiated: VideoCodec, hardware_h264_available: bool) -> VideoCodec {
match negotiated {
VideoCodec::H264 if hardware_h264_available => VideoCodec::H264,
// Server asked for H.264 but we have no HW encoder -> raw.
VideoCodec::H264 => VideoCodec::Raw,
// HEVC not implemented yet (TODO: Task 7 opt-in / future).
VideoCodec::H265 => VideoCodec::Raw,
VideoCodec::Raw => VideoCodec::Raw,
}
}
/// Create an encoder for an explicit `VideoCodec`, with a transparent fallback
/// to raw if a hardware encoder cannot be constructed.
///
/// `quality` is the 1-100 quality knob (mapped per-codec). On H.264 init failure
/// this logs and returns a raw encoder so the session keeps working.
pub fn create_encoder_for(codec: VideoCodec, quality: u32) -> Result<Box<dyn Encoder>> {
match codec {
VideoCodec::H264 => {
#[cfg(windows)]
{
match h264::H264Encoder::new(quality) {
Ok(enc) => {
tracing::info!("Using hardware H.264 encoder (Media Foundation)");
Ok(Box::new(enc))
}
Err(e) => {
tracing::warn!(
"H.264 encoder init failed ({e:#}); falling back to raw+Zstd"
);
Ok(Box::new(RawEncoder::new(quality)?))
}
}
}
#[cfg(not(windows))]
{
tracing::warn!("H.264 unsupported on this platform; using raw+Zstd");
Ok(Box::new(RawEncoder::new(quality)?))
}
}
// Raw (and anything that resolved to raw) uses the salvaged encoder.
VideoCodec::Raw | VideoCodec::H265 => Ok(Box::new(RawEncoder::new(quality)?)),
}
}
/// Create an encoder based on a codec string (agent config path).
///
/// Backwards-compatible entry point that builds an encoder from a codec STRING
/// (e.g. `EncodingConfig.codec`). Resolves the string to a `VideoCodec`, applies
/// the hardware-availability guard, then builds the encoder. The live session
/// uses `select_codec` + `create_encoder_for` (negotiated `VideoCodec`) instead;
/// this remains for the config path and is covered by unit tests.
#[allow(dead_code)]
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> {
let requested = codec_from_str(codec);
let chosen = select_codec(requested, supports_hardware_h264());
create_encoder_for(chosen, quality)
}
/// Build an `EncodedFrame` carrying a single `video_frame::Encoding` payload.
/// Shared helper so encoders don't each repeat the `VideoFrame` wrapper.
#[allow(dead_code)]
pub(crate) fn wrap_video_frame(
timestamp_ms: i64,
display_id: i32,
sequence: i32,
encoding: video_frame::Encoding,
size: usize,
is_keyframe: bool,
) -> EncodedFrame {
EncodedFrame {
frame: VideoFrame {
timestamp: timestamp_ms,
display_id,
sequence,
encoding: Some(encoding),
},
size,
is_keyframe,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codec_from_str_maps_known_and_unknown() {
assert_eq!(codec_from_str("h264"), VideoCodec::H264);
assert_eq!(codec_from_str("H264"), VideoCodec::H264);
assert_eq!(codec_from_str("raw"), VideoCodec::Raw);
assert_eq!(codec_from_str("zstd"), VideoCodec::Raw);
assert_eq!(codec_from_str("auto"), VideoCodec::Raw);
assert_eq!(codec_from_str("vp9"), VideoCodec::Raw);
// HEVC not implemented -> raw, never H265.
assert_eq!(codec_from_str("h265"), VideoCodec::Raw);
assert_eq!(codec_from_str("hevc"), VideoCodec::Raw);
assert_eq!(codec_from_str(""), VideoCodec::Raw);
}
#[test]
fn select_codec_honors_hardware_guard() {
// Server negotiated H.264 and HW is present -> H.264.
assert_eq!(select_codec(VideoCodec::H264, true), VideoCodec::H264);
// Server negotiated H.264 but no HW -> raw (never forced).
assert_eq!(select_codec(VideoCodec::H264, false), VideoCodec::Raw);
// Raw stays raw regardless of HW.
assert_eq!(select_codec(VideoCodec::Raw, true), VideoCodec::Raw);
assert_eq!(select_codec(VideoCodec::Raw, false), VideoCodec::Raw);
// HEVC always degrades to raw (unimplemented).
assert_eq!(select_codec(VideoCodec::H265, true), VideoCodec::Raw);
}
#[test]
fn raw_factory_always_succeeds() {
// Raw must always construct (the guaranteed fallback).
let enc = create_encoder_for(VideoCodec::Raw, 75).unwrap();
assert_eq!(enc.name(), "raw+zstd");
}
#[test]
fn create_encoder_string_path_resolves_to_raw_without_hw() {
// On a machine without a HW encoder (CI / non-Windows), "h264" must
// resolve to a working raw encoder, not an error.
let enc = create_encoder("h264", 75).unwrap();
// Without HW it is raw; with HW it would be the H.264 encoder. We only
// assert it constructed.
let _ = enc.name();
}
#[test]
fn create_encoder_auto_is_raw() {
let enc = create_encoder("auto", 75).unwrap();
assert_eq!(enc.name(), "raw+zstd");
}
}