lesavka/client/src/input/camera.rs

250 lines
7.6 KiB
Rust

// Webcam capture pipeline, quality selection, and launcher preview tap support.
use anyhow::Context;
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use lesavka_common::lesavka::VideoPacket;
use std::{
io::{Read, Write},
os::fd::IntoRawFd,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
const CAMERA_PREVIEW_TAP_ENV: &str = "LESAVKA_UPLINK_CAMERA_PREVIEW";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CameraSourceProfile {
Raw,
Mjpeg,
AutoDecode,
}
fn env_u32(name: &str, default: u32) -> u32 {
std::env::var(name)
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(default)
}
#[derive(Clone, Copy, Debug)]
pub enum CameraCodec {
H264,
Hevc,
Mjpeg,
}
#[derive(Clone, Copy, Debug)]
pub struct CameraConfig {
pub codec: CameraCodec,
pub width: u32,
pub height: u32,
pub fps: u32,
}
pub struct CameraCapture {
#[allow(dead_code)] // kept alive to hold PLAYING state
pipeline: gst::Pipeline,
sink: gst_app::AppSink,
ffmpeg_child: Option<Child>,
preview_tap_running: Option<Arc<AtomicBool>>,
pts_rebaser: crate::live_capture_clock::DurationPacedSourcePtsRebaser,
frame_duration_us: u64,
}
include!("camera/capture_pipeline.rs");
include!("camera/device_selection.rs");
include!("camera/encoder_selection.rs");
include!("camera/source_description.rs");
include!("camera/preview_tap.rs");
include!("camera/bus_and_encoder.rs");
#[cfg(test)]
mod tests {
use super::{
CameraCapture, CameraCodec, CameraConfig, hevc_keyframe_interval, resolved_capture_profile,
resolved_output_profile,
};
use serial_test::serial;
#[test]
#[serial]
/// Keeps the selected local webcam mode independent from the UVC gadget mode.
fn local_capture_profile_keeps_launcher_quality_env_by_default() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
("LESAVKA_CAM_EMIT_UI_PROFILE", None),
],
|| assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)),
);
}
#[test]
#[serial]
/// UVC output must match the gadget profile that browsers negotiate.
fn negotiated_output_profile_matches_server_uvc_contract_by_default() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
("LESAVKA_CAM_EMIT_UI_PROFILE", None),
],
|| {
let capture_profile = resolved_capture_profile(Some(cfg));
assert_eq!(capture_profile, (1280, 720, 30));
assert_eq!(
resolved_output_profile(Some(cfg), capture_profile),
(640, 480, 20)
);
},
);
}
#[test]
#[serial]
/// Keeps UI-profile emission explicit until the server can reconfigure UVC.
fn explicit_ui_profile_emission_keeps_lab_mode_available() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", None),
("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")),
],
|| {
let capture_profile = resolved_capture_profile(Some(cfg));
assert_eq!(capture_profile, (1280, 720, 30));
assert_eq!(
resolved_output_profile(Some(cfg), capture_profile),
(1280, 720, 30)
);
},
);
}
#[test]
#[serial]
/// The safety lock wins if both experimental flags are set.
fn explicit_server_profile_lock_wins_over_ui_emission() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_LOCK_TO_SERVER_PROFILE", Some("1")),
("LESAVKA_CAM_EMIT_UI_PROFILE", Some("1")),
],
|| {
let capture_profile = resolved_capture_profile(Some(cfg));
assert_eq!(capture_profile, (1280, 720, 30));
assert_eq!(
resolved_output_profile(Some(cfg), capture_profile),
(640, 480, 20)
);
},
);
}
#[test]
#[serial]
/// HEVC lab fallback options must stay shaped for live transport.
fn hevc_lab_fallback_options_keep_low_latency_and_keyframes() {
temp_env::with_var("LESAVKA_CAM_HEVC_KBIT", Some("2400"), || {
let options = CameraCapture::encoder_options("x265enc", Some("key-int-max"), 30);
assert!(options.starts_with("x265enc "));
assert!(options.contains("tune=zerolatency"));
assert!(options.contains("speed-preset=ultrafast"));
assert!(options.contains("bitrate=2400"));
assert!(options.contains("key-int-max=30"));
});
}
#[test]
#[serial]
/// Vulkan H.264 hardware encode should stay live-call shaped when available.
fn vulkan_h264_encoder_options_keep_cbr_and_keyframes() {
temp_env::with_var("LESAVKA_CAM_H264_KBIT", Some("6000"), || {
let options = CameraCapture::encoder_options("vulkanh264enc", Some("idr-period"), 30);
assert_eq!(
options,
"vulkanh264enc bitrate=6000 rate-control=cbr idr-period=30"
);
});
}
#[test]
#[serial]
/// HEVC should recover quickly after freshness drops without changing H.264 knobs.
fn hevc_keyframe_interval_defaults_short_and_honors_overrides() {
temp_env::with_vars(
[
("LESAVKA_CAM_KEYFRAME_INTERVAL", None::<&str>),
("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", None::<&str>),
],
|| {
assert_eq!(hevc_keyframe_interval(30), 1);
assert_eq!(hevc_keyframe_interval(2), 1);
},
);
temp_env::with_vars(
[
("LESAVKA_CAM_KEYFRAME_INTERVAL", Some("5")),
("LESAVKA_CAM_HEVC_KEYFRAME_INTERVAL", Some("2")),
],
|| {
assert_eq!(hevc_keyframe_interval(30), 2);
},
);
}
#[cfg(coverage)]
#[test]
/// Coverage builds use a deterministic HEVC encoder choice.
fn coverage_hevc_encoder_choice_is_stable() {
assert_eq!(
CameraCapture::choose_hevc_encoder().unwrap(),
("x265enc", Some("key-int-max"))
);
}
}