feat(media): add HDMI mirror webcam output

This commit is contained in:
Brad Stein 2026-07-19 05:03:33 -03:00
parent 0d5284c9a2
commit 432cb96164
36 changed files with 731 additions and 164 deletions

6
Cargo.lock generated
View File

@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.26.6"
version = "0.27.0"
dependencies = [
"anyhow",
"async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.26.6"
version = "0.27.0"
dependencies = [
"anyhow",
"base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.26.6"
version = "0.27.0"
dependencies = [
"anyhow",
"base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.26.6"
version = "0.27.0"
edition = "2024"
[dependencies]

View File

@ -46,7 +46,6 @@ impl MouseEventState<'_> {
*self.last_abs_x = None;
*self.last_abs_y = None;
}
self.set_btn(0, event.value());
}
_ => {}
},

View File

@ -132,11 +132,20 @@ fn key_events_update_button_bits_and_touch_release_clears_origins() {
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 1));
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_RIGHT.0, 1));
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_MIDDLE.0, 1));
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 1));
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::KEY_A.0, 1));
}
assert_eq!(harness.buttons & 0b111, 0b111);
{
let mut state = harness.state();
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 0));
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_RIGHT.0, 0));
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_MIDDLE.0, 0));
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 1));
}
assert_eq!(harness.buttons & 0b111, 0);
assert!(harness.touch_guarded);
assert!(harness.touch_active);
@ -148,7 +157,43 @@ fn key_events_update_button_bits_and_touch_release_clears_origins() {
assert!(!harness.touch_active);
assert!(harness.last_abs_x.is_none());
assert!(harness.last_abs_y.is_none());
assert_eq!(harness.buttons & 0b111, 0);
}
#[test]
fn touch_contact_tracks_motion_without_pressing_left_button() {
let mut harness = StateHarness::with_touch_state();
{
let mut state = harness.state();
state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 1));
state.apply_event(&InputEvent::new(
EventType::ABSOLUTE.0,
AbsoluteAxisCode::ABS_MT_POSITION_X.0,
1000,
));
state.apply_event(&InputEvent::new(
EventType::ABSOLUTE.0,
AbsoluteAxisCode::ABS_MT_POSITION_Y.0,
1000,
));
state.apply_event(&InputEvent::new(
EventType::ABSOLUTE.0,
AbsoluteAxisCode::ABS_MT_POSITION_X.0,
1040,
));
state.apply_event(&InputEvent::new(
EventType::ABSOLUTE.0,
AbsoluteAxisCode::ABS_MT_POSITION_Y.0,
984,
));
}
assert!(harness.touch_guarded);
assert!(harness.touch_active);
assert_eq!(harness.buttons & 0b001, 0);
assert_eq!(harness.dx, 5);
assert_eq!(harness.dy, -2);
}
#[test]

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.26.6"
version = "0.27.0"
edition = "2024"
build = "build.rs"

View File

@ -458,7 +458,7 @@ These entries are intentionally concise because most are manual lab or CI harnes
| `LESAVKA_HEVC_REENTRY_SYNC` | manual HEVC re-entry helper toggle; when `1`, rsyncs the local workspace to Theia before optional build/deploy |
| `LESAVKA_HEVC_REENTRY_WAIT_INTERVAL_SECONDS` | manual HEVC re-entry helper retry interval while waiting for SSH after a lab host outage, defaults to `15` |
| `LESAVKA_HEVC_REENTRY_WAIT_SECONDS` | manual HEVC re-entry helper reachability wait budget; when greater than `0`, polls SSH before status/build/deploy/reconfigure instead of failing immediately |
| `LESAVKA_INSTALL_CAM_CODEC` | server installer camera ingress codec default; persists `LESAVKA_CAM_CODEC` for installed services, defaults to `hevc`; HEVC installs probe the hardware decoder and warn with a smoke log when the synthetic probe fails, while runtime still refuses software video fallback in production |
| `LESAVKA_INSTALL_CAM_CODEC` | server installer camera ingress codec override; persists `LESAVKA_CAM_CODEC` for installed services, defaults to `mjpeg`; explicit HEVC installs probe the hardware decoder and refuse the install when hardware decode cannot be proven |
| `LESAVKA_INSTALL_ALLOW_SOFTWARE_VIDEO` | installer-only video acceleration override; persists the software fallback allowance for lab/debug installs without changing normal runtime defaults |
| `LESAVKA_INSTALL_HEVC_DECODER` | installer-only HEVC decoder override; persists the selected hardware decoder when repairing or pinning a known-good UVC bridge path |
| `LESAVKA_INSTALL_SOURCE` | install script source selector; use `ref` to fetch the requested git ref instead of building the existing local checkout |

View File

@ -171,7 +171,8 @@ UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-1}
UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-}
UVC_WIDTH=${LESAVKA_UVC_WIDTH:-1280}
UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-720}
UVC_FPS=${LESAVKA_UVC_FPS:-30}
UVC_FPS=${LESAVKA_UVC_FPS:-20}
UVC_ADVERTISE_EXTRA_MODES=${LESAVKA_UVC_ADVERTISE_EXTRA_MODES:-0}
UVC_DISABLE_IRQ=${LESAVKA_UVC_DISABLE_IRQ:-}
flag_enabled() {
case "${1,,}" in
@ -371,6 +372,10 @@ if [[ -z ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
fi
uvc_selected_frame_index() {
if ! flag_enabled "$UVC_ADVERTISE_EXTRA_MODES"; then
echo 1
return
fi
case "${UVC_WIDTH}x${UVC_HEIGHT}" in
1920x1080) echo 1 ;;
1280x720) echo 2 ;;
@ -378,6 +383,21 @@ uvc_selected_frame_index() {
esac
}
write_active_mjpeg_frame_descriptor() {
case "${UVC_WIDTH}x${UVC_HEIGHT}" in
1920x1080)
write_mjpeg_frame_descriptor 1080p 1920 1080
;;
1280x720)
write_mjpeg_frame_descriptor 720p 1280 720
;;
*)
log "unsupported MJPEG UVC profile ${UVC_WIDTH}x${UVC_HEIGHT}; advertising safe 720p"
write_mjpeg_frame_descriptor 720p 1280 720
;;
esac
}
uvc_frame_size_for() {
local width=$1 height=$2
if [[ $width == "$UVC_WIDTH" && $height == "$UVC_HEIGHT" && -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
@ -399,16 +419,22 @@ uvc_default_interval_for() {
write_mjpeg_frame_descriptor() {
local name=$1 width=$2 height=$3
local frame="$F/streaming/mjpeg/m/$name"
local default_interval
default_interval="$(uvc_default_interval_for "$width" "$height")"
mkdir -p "$frame"
echo 0 >"$frame/bmCapabilities"
echo "$width" >"$frame/wWidth"
echo "$height" >"$frame/wHeight"
echo "$(uvc_frame_size_for "$width" "$height")" >"$frame/dwMaxVideoFrameBufferSize"
echo "$(uvc_default_interval_for "$width" "$height")" >"$frame/dwDefaultFrameInterval"
cat <<EOF >"$frame/dwFrameInterval"
echo "$default_interval" >"$frame/dwDefaultFrameInterval"
if flag_enabled "$UVC_ADVERTISE_EXTRA_MODES"; then
cat <<EOF >"$frame/dwFrameInterval"
${UVC_INTERVAL_30}
${UVC_INTERVAL_20}
EOF
else
echo "$default_interval" >"$frame/dwFrameInterval"
fi
}
wait_for_enum() {
@ -638,8 +664,12 @@ if [[ -z $DISABLE_UVC ]]; then
echo "$(uvc_selected_frame_index)" >"$F/streaming/mjpeg/m/bDefaultFrameIndex" 2>/dev/null || true
echo 0 >"$F/streaming/mjpeg/m/bmaControls" 2>/dev/null || true
write_mjpeg_frame_descriptor 1080p 1920 1080
write_mjpeg_frame_descriptor 720p 1280 720
if flag_enabled "$UVC_ADVERTISE_EXTRA_MODES"; then
write_mjpeg_frame_descriptor 1080p 1920 1080
write_mjpeg_frame_descriptor 720p 1280 720
else
write_active_mjpeg_frame_descriptor
fi
else
# uncompressed YUY2, 16 bpp
mkdir -p "$F/streaming/uncompressed/yuyv"

View File

@ -21,6 +21,21 @@ load_uvc_env_defaults() {
load_uvc_env_defaults
migrate_unsafe_uvc_defaults() {
if [[ ${LESAVKA_UVC_BUFFER_COUNT:-} == "1" && -z ${LESAVKA_UVC_ALLOW_SINGLE_BUFFER:-} ]]; then
echo "[lesavka-uvc] migrating legacy LESAVKA_UVC_BUFFER_COUNT=1 to 4; set LESAVKA_UVC_ALLOW_SINGLE_BUFFER=1 to keep the old behavior" >&2
export LESAVKA_UVC_BUFFER_COUNT=4
fi
if [[ ${LESAVKA_UVC_QUEUE_PACING:-} == "1" && -z ${LESAVKA_UVC_ALLOW_QUEUE_PACING:-} ]]; then
echo "[lesavka-uvc] migrating legacy LESAVKA_UVC_QUEUE_PACING=1 to 0; set LESAVKA_UVC_ALLOW_QUEUE_PACING=1 to keep the old behavior" >&2
export LESAVKA_UVC_QUEUE_PACING=0
fi
export LESAVKA_UVC_BUFFER_COUNT=${LESAVKA_UVC_BUFFER_COUNT:-4}
export LESAVKA_UVC_QUEUE_PACING=${LESAVKA_UVC_QUEUE_PACING:-0}
}
migrate_unsafe_uvc_defaults
resolve_default_uvc_dev() {
local ctrl=""
ctrl=$(ls /sys/class/udc 2>/dev/null | head -n1 || true)

View File

@ -108,7 +108,7 @@ INSTALL_CAM_CODEC_EXPLICIT=0
if [[ -n "${LESAVKA_INSTALL_CAM_CODEC+x}" || -n "${LESAVKA_CAM_CODEC+x}" ]]; then
INSTALL_CAM_CODEC_EXPLICIT=1
fi
REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}
REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}}
INSTALL_CAM_CODEC=$(normalize_cam_codec "${REQUESTED_CAM_CODEC}")
INSTALL_HEVC_DECODER=${LESAVKA_INSTALL_HEVC_DECODER:-${LESAVKA_HEVC_DECODER:-}}
INSTALL_ALLOW_SOFTWARE_VIDEO=${LESAVKA_INSTALL_ALLOW_SOFTWARE_VIDEO:-${LESAVKA_ALLOW_SOFTWARE_VIDEO:-0}}
@ -172,7 +172,7 @@ lookup_mode_offset_us() {
default_uvc_mode() {
local width=${LESAVKA_UVC_WIDTH:-1280}
local height=${LESAVKA_UVC_HEIGHT:-720}
local fps=${LESAVKA_UVC_FPS:-30}
local fps=${LESAVKA_UVC_FPS:-20}
printf '%sx%s@%s\n' "${width}" "${height}" "${fps}"
}
@ -302,17 +302,6 @@ ensure_hevc_decode_support() {
fi
done
if [[ -n "$software_hevc_decoder" && "$INSTALL_CAM_CODEC_EXPLICIT" == "0" ]]; then
echo "⚠️ hardware HEVC decoder is exposed but the synthetic 1280x720 decode smoke failed: $hevc_decoder" >&2
echo " smoke log: $hevc_smoke_log" >&2
sed -n '1,120p' "$hevc_smoke_log" >&2 || true
echo " Software HEVC decoder passed the same 1280x720 smoke: $software_hevc_decoder" >&2
echo " Keeping default HEVC upstream to avoid direct-MJPEG UVC artifacts; monitor CPU/RSS during field runs." >&2
INSTALL_HEVC_DECODER=$software_hevc_decoder
INSTALL_ALLOW_SOFTWARE_VIDEO=1
return 0
fi
if [[ "$INSTALL_CAM_CODEC_EXPLICIT" == "0" ]]; then
echo "⚠️ hardware HEVC decoder is exposed but the synthetic 1280x720 decode smoke failed: $hevc_decoder" >&2
echo " smoke log: $hevc_smoke_log" >&2
@ -423,14 +412,15 @@ render_uvc_env_file() {
LESAVKA_UVC_DEBUG=$(uvc_env_value LESAVKA_UVC_DEBUG 1)
LESAVKA_UVC_MAXPACKET=$(uvc_env_value LESAVKA_UVC_MAXPACKET 1024)
LESAVKA_UVC_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_LIMIT_PCT 100)
LESAVKA_UVC_FPS=$(uvc_env_value LESAVKA_UVC_FPS 30)
LESAVKA_UVC_INTERVAL=$(uvc_env_value LESAVKA_UVC_INTERVAL 333333)
LESAVKA_UVC_FPS=$(uvc_env_value LESAVKA_UVC_FPS 20)
LESAVKA_UVC_INTERVAL=$(uvc_env_value LESAVKA_UVC_INTERVAL 500000)
LESAVKA_UVC_WIDTH=$(uvc_env_value LESAVKA_UVC_WIDTH 1280)
LESAVKA_UVC_HEIGHT=$(uvc_env_value LESAVKA_UVC_HEIGHT 720)
LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}
LESAVKA_UVC_BUFFER_COUNT=$(uvc_env_value LESAVKA_UVC_BUFFER_COUNT 4)
LESAVKA_UVC_BLOCKING=$(uvc_env_value LESAVKA_UVC_BLOCKING 1)
LESAVKA_UVC_CONTROL_READ_ONLY=$(uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0)
LESAVKA_UVC_QUEUE_PACING=$(uvc_env_value LESAVKA_UVC_QUEUE_PACING 1)
LESAVKA_UVC_QUEUE_PACING=$(uvc_env_value LESAVKA_UVC_QUEUE_PACING 0)
LESAVKA_UVC_MAXBURST=$(uvc_env_value LESAVKA_UVC_MAXBURST 0)
LESAVKA_UVC_BULK=$(uvc_env_value LESAVKA_UVC_BULK 1)
LESAVKA_UVC_FRAME_SIZE_GUARD=$(uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1)
@ -694,8 +684,8 @@ live_uvc_descriptor_matches_request() {
[[ -r "$frame_root/wWidth" && -r "$frame_root/wHeight" && -r "$frame_root/dwDefaultFrameInterval" ]] || return 1
[[ "$(cat "$frame_root/wWidth" 2>/dev/null || true)" == "${LESAVKA_UVC_WIDTH:-1280}" ]] || return 1
[[ "$(cat "$frame_root/wHeight" 2>/dev/null || true)" == "${LESAVKA_UVC_HEIGHT:-720}" ]] || return 1
grep -qx "${LESAVKA_UVC_INTERVAL:-333333}" "$frame_root/dwFrameInterval" 2>/dev/null \
|| [[ "$(cat "$frame_root/dwDefaultFrameInterval" 2>/dev/null || true)" == "${LESAVKA_UVC_INTERVAL:-333333}" ]]
grep -qx "${LESAVKA_UVC_INTERVAL:-500000}" "$frame_root/dwFrameInterval" 2>/dev/null \
|| [[ "$(cat "$frame_root/dwDefaultFrameInterval" 2>/dev/null || true)" == "${LESAVKA_UVC_INTERVAL:-500000}" ]]
}
live_uac_descriptor_matches_request() {
@ -1631,6 +1621,7 @@ SERVER_ENV_TMP=$(mktemp)
printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR"
fi
printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}"
printf 'LESAVKA_CAM_HDMI_MIRROR=%s\n' "${LESAVKA_INSTALL_CAM_HDMI_MIRROR:-${LESAVKA_CAM_HDMI_MIRROR:-0}}"
printf 'LESAVKA_CAM_CODEC=%s\n' "${INSTALL_CAM_CODEC}"
printf 'LESAVKA_UPLINK_CAMERA_CODEC=%s\n' "${INSTALL_CAM_CODEC}"
if [[ -n "$INSTALL_HEVC_DECODER" ]]; then
@ -1708,8 +1699,8 @@ SERVER_ENV_TMP=$(mktemp)
printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}"
printf 'LESAVKA_UVC_WIDTH=%s\n' "${LESAVKA_UVC_WIDTH:-1280}"
printf 'LESAVKA_UVC_HEIGHT=%s\n' "${LESAVKA_UVC_HEIGHT:-720}"
printf 'LESAVKA_UVC_FPS=%s\n' "${LESAVKA_UVC_FPS:-30}"
printf 'LESAVKA_UVC_INTERVAL=%s\n' "${LESAVKA_UVC_INTERVAL:-333333}"
printf 'LESAVKA_UVC_FPS=%s\n' "${LESAVKA_UVC_FPS:-20}"
printf 'LESAVKA_UVC_INTERVAL=%s\n' "${LESAVKA_UVC_INTERVAL:-500000}"
printf 'LESAVKA_UVC_FRAME_META=%s\n' "${INSTALL_UVC_FRAME_META}"
printf 'LESAVKA_UVC_FRAME_META_LOG_PATH=%s\n' "${INSTALL_UVC_FRAME_META_LOG_PATH}"
printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}"
@ -2008,6 +1999,7 @@ sudo systemctl start lesavka-recovery-ladder.timer
INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true)
INSTALLED_SHA=$(git -C "$SCRIPT_REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true)
PERSISTED_CAM_OUTPUT=$(grep '^LESAVKA_CAM_OUTPUT=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true)
PERSISTED_CAM_HDMI_MIRROR=$(grep '^LESAVKA_CAM_HDMI_MIRROR=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true)
PERSISTED_SERVER_BIND_ADDR=$(grep '^LESAVKA_SERVER_BIND_ADDR=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true)
PERSISTED_UVC_CODEC=$(grep '^LESAVKA_UVC_CODEC=' /etc/lesavka/uvc.env 2>/dev/null | tail -n1 | cut -d= -f2- || true)
echo "✅ lesavka-server installed and restarted..."
@ -2020,6 +2012,9 @@ fi
if [[ -n $PERSISTED_CAM_OUTPUT ]]; then
echo "➡️ Camera output: ${PERSISTED_CAM_OUTPUT}"
fi
if [[ -n $PERSISTED_CAM_HDMI_MIRROR ]]; then
echo "➡️ HDMI mirror: ${PERSISTED_CAM_HDMI_MIRROR}"
fi
if [[ -n $PERSISTED_UVC_CODEC ]]; then
echo "➡️ UVC codec: ${PERSISTED_UVC_CODEC}"
fi

View File

@ -16,7 +16,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.26.6"
version = "0.27.0"
edition = "2024"
autobins = false

View File

@ -48,12 +48,12 @@ const V4L2_PIX_FMT_MJPEG: u32 = u32::from_le_bytes(*b"MJPG");
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
const IDLE_MJPEG_FRAME: &[u8] = include_bytes!("lesavka_uvc/idle_1280x720_black.jpg");
// Keep the kernel from holding several future frames at once; if USB falls
// behind, a short stutter is safer than mixing payload from different frames.
const DEFAULT_UVC_BUFFER_COUNT: u32 = 1;
// Keep enough output buffers queued that the USB gadget is not starved while a
// browser is consuming high-speed isochronous MJPEG.
const DEFAULT_UVC_BUFFER_COUNT: u32 = 4;
const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2;
const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
const DEFAULT_UVC_QUEUE_PACING: bool = true;
const DEFAULT_UVC_QUEUE_PACING: bool = false;
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
@ -504,32 +504,41 @@ impl UvcVideoStream {
fn refresh_latest_frame(&mut self) {
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
if stale && looks_like_mjpeg_frame(&self.latest_frame) {
if stale {
self.stats.replayed_stale += 1;
self.replace_latest_with_idle();
return;
}
let max_frame_bytes = self.frame_payload_limit();
match std::fs::read(&self.frame_path) {
Ok(frame) if !looks_like_mjpeg_frame(&frame) => {
self.stats.rejected_invalid += 1;
self.replace_latest_with_idle();
}
Ok(frame) if frame.len() > max_frame_bytes => {
self.stats.rejected_oversize += 1;
self.stats.last_rejected_oversize_bytes = frame.len();
self.stats.last_rejected_oversize_cap = max_frame_bytes;
self.replace_latest_with_idle();
}
Ok(frame) => {
self.stats.reloaded += 1;
self.latest_frame = frame;
}
Err(_) => {}
Err(_) => self.replace_latest_with_idle(),
}
if !looks_like_mjpeg_frame(&self.latest_frame) {
self.stats.fallback_idle += 1;
self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
self.replace_latest_with_idle();
}
}
fn replace_latest_with_idle(&mut self) {
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
self.stats.fallback_idle += 1;
}
self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
}
fn frame_payload_limit(&self) -> usize {
self.buffers
.iter()
@ -1542,9 +1551,8 @@ fn sanitize_streaming_control(data: &[u8], state: &UvcState) -> [u8; STREAM_CTRL
&mut out[18..22],
uvc_frame_size_for_index(frame_index, state.cfg.frame_size),
);
if interval != 0 {
write_le32(&mut out[4..8], interval);
}
let _host_requested_interval = interval;
write_le32(&mut out[4..8], state.cfg.interval);
if host_payload > 0 {
let payload = host_payload.min(state.cfg.max_packet);
write_le32(&mut out[22..26], payload);
@ -1610,6 +1618,9 @@ fn build_streaming_control(cfg: &UvcConfig, ctrl_len: usize) -> [u8; STREAM_CTRL
}
fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 {
if !uvc_advertises_multiple_mjpeg_frames() {
return 1;
}
match (width, height) {
(1920, 1080) => 1,
(1280, 720) => 2,
@ -1617,11 +1628,13 @@ fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 {
}
}
fn uvc_frame_index_for_request(requested: u8, cfg: &UvcConfig) -> u8 {
match requested {
1 | 2 => requested,
_ => uvc_frame_index_for_mode(cfg.width, cfg.height),
}
fn uvc_frame_index_for_request(_requested: u8, cfg: &UvcConfig) -> u8 {
uvc_frame_index_for_mode(cfg.width, cfg.height)
}
fn uvc_advertises_multiple_mjpeg_frames() -> bool {
let base = std::path::Path::new(CONFIGFS_UVC_BASE).join("streaming/mjpeg/m");
base.join("1080p").exists() && base.join("720p").exists()
}
fn uvc_frame_size_for_index(frame_index: u8, fallback: u32) -> u32 {

View File

@ -36,6 +36,9 @@ fn build_streaming_control(cfg: &UvcConfig, ctrl_len: usize) -> [u8; STREAM_CTRL
}
fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 {
if !uvc_advertises_multiple_mjpeg_frames() {
return 1;
}
match (width, height) {
(1920, 1080) => 1,
(1280, 720) => 2,
@ -43,11 +46,13 @@ fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 {
}
}
fn uvc_frame_index_for_request(requested: u8, cfg: &UvcConfig) -> u8 {
match requested {
1 | 2 => requested,
_ => uvc_frame_index_for_mode(cfg.width, cfg.height),
}
fn uvc_frame_index_for_request(_requested: u8, cfg: &UvcConfig) -> u8 {
uvc_frame_index_for_mode(cfg.width, cfg.height)
}
fn uvc_advertises_multiple_mjpeg_frames() -> bool {
let base = std::path::Path::new(CONFIGFS_UVC_BASE).join("streaming/mjpeg/m");
base.join("1080p").exists() && base.join("720p").exists()
}
fn uvc_frame_size_for_index(frame_index: u8, fallback: u32) -> u32 {

View File

@ -152,10 +152,8 @@ fn sanitize_streaming_control(data: &[u8], state: &UvcState) -> [u8; STREAM_CTRL
&mut out[18..22],
uvc_frame_size_for_index(frame_index, state.cfg.frame_size),
);
let interval = read_le32(data, 4);
if interval != 0 {
write_le32(&mut out[4..8], interval);
}
let _host_requested_interval = read_le32(data, 4);
write_le32(&mut out[4..8], state.cfg.interval);
let host_payload = read_le32(data, 22);
if host_payload > 0 {
write_le32(&mut out[22..26], host_payload.min(state.cfg.max_packet));

View File

@ -25,8 +25,8 @@ fn uvc_idle_pump_sleep() -> std::time::Duration {
/// Returns the frame queue pacing period for the negotiated UVC frame rate.
///
/// Inputs: `LESAVKA_UVC_QUEUE_PACING` plus the active frame rate. Output:
/// `None` when pacing is explicitly disabled. Why: the RCT-facing host must
/// not be overfed faster than the advertised descriptor cadence.
/// `None` when pacing is disabled. Why: the RCT-facing host paces UVC
/// consumption, and user-space sleeps before QBUF can starve isochronous MJPEG.
fn uvc_queue_period(fps: u32) -> Option<std::time::Duration> {
if !env_flag_enabled("LESAVKA_UVC_QUEUE_PACING", DEFAULT_UVC_QUEUE_PACING) {
return None;

View File

@ -51,13 +51,13 @@ const UVC_VC_REQUEST_ERROR_CODE_CONTROL: u8 = 0x02;
#[cfg(coverage)]
// Keep coverage-mode defaults aligned with the real UVC helper.
const DEFAULT_UVC_BUFFER_COUNT: u32 = 1;
const DEFAULT_UVC_BUFFER_COUNT: u32 = 4;
#[cfg(coverage)]
const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2;
#[cfg(coverage)]
const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
#[cfg(coverage)]
const DEFAULT_UVC_QUEUE_PACING: bool = true;
const DEFAULT_UVC_QUEUE_PACING: bool = false;
#[cfg(coverage)]
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
#[cfg(coverage)]
@ -222,7 +222,9 @@ impl UvcVideoStream {
/// frames should degrade to known-safe idle content instead of freezing RCT.
fn refresh_latest_frame(&mut self) {
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
if stale && looks_like_mjpeg_frame(&self.latest_frame) {
if stale {
self.stats.replayed_stale += 1;
self.replace_latest_with_idle();
return;
}
let max_frame_bytes = self.frame_payload_limit();
@ -239,15 +241,21 @@ impl UvcVideoStream {
} else {
self.stats.rejected_invalid += 1;
}
if !looks_like_mjpeg_frame(&self.latest_frame) {
self.stats.fallback_idle += 1;
self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
}
self.replace_latest_with_idle();
}
} else if !looks_like_mjpeg_frame(&self.latest_frame) {
self.stats.fallback_idle += 1;
self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
} else {
self.replace_latest_with_idle();
}
if !looks_like_mjpeg_frame(&self.latest_frame) {
self.replace_latest_with_idle();
}
}
fn replace_latest_with_idle(&mut self) {
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
self.stats.fallback_idle += 1;
}
self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
}
fn frame_payload_limit(&self) -> usize {

View File

@ -136,17 +136,17 @@ fn uvc_frame_budget_caps_isochronous_transport() {
}
#[test]
fn uvc_queue_pacing_defaults_on_but_can_be_disabled() {
fn uvc_queue_pacing_defaults_off_but_can_be_enabled() {
with_var("LESAVKA_UVC_QUEUE_PACING", None::<&str>, || {
assert_eq!(uvc_queue_period(30), None);
});
with_var("LESAVKA_UVC_QUEUE_PACING", Some("1"), || {
assert_eq!(
uvc_queue_period(30),
Some(std::time::Duration::from_nanos(33_333_333))
);
});
with_var("LESAVKA_UVC_QUEUE_PACING", Some("0"), || {
assert_eq!(uvc_queue_period(30), None);
});
}
#[test]

View File

@ -6,7 +6,7 @@ mod selection;
use std::sync::{OnceLock, RwLock};
use selection::select_camera_config;
use selection::{select_camera_config, select_hdmi_mirror_config};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CameraOutput {
@ -129,6 +129,16 @@ pub fn current_camera_config() -> CameraConfig {
update_camera_config()
}
/// Build an HDMI display config that mirrors an already-selected camera profile.
///
/// Inputs: the primary camera configuration.
/// Outputs: an HDMI config with the same uplink codec, size, and rate.
/// Why: the experimental HDMI capture-card path must be testable beside the
/// normal UVC gadget without negotiating a second client capture profile.
pub fn hdmi_mirror_config(primary: &CameraConfig) -> CameraConfig {
select_hdmi_mirror_config(primary)
}
#[cfg(test)]
#[path = "tests/camera.rs"]
mod tests;

View File

@ -165,6 +165,17 @@ fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig {
}
}
pub(super) fn select_hdmi_mirror_config(primary: &CameraConfig) -> CameraConfig {
CameraConfig {
output: CameraOutput::Hdmi,
codec: primary.codec,
width: primary.width,
height: primary.height,
fps: primary.fps,
hdmi: detect_hdmi_connector(false),
}
}
#[cfg(coverage)]
/// Keeps `select_uvc_config` explicit because it sits on camera selection, where negotiated profiles must match the server output contract.
/// Inputs are the typed parameters; output is the return value or side effect.

View File

@ -11,10 +11,16 @@ use tracing::info;
use crate::{camera, uvc_runtime, video};
struct CameraRelaySlot {
cfg: camera::CameraConfig,
profile: CameraRelayProfile,
relay: Arc<video::CameraRelay>,
}
#[derive(Clone, Debug)]
struct CameraRelayProfile {
primary: camera::CameraConfig,
hdmi_mirror: Option<camera::CameraConfig>,
}
/// Manage the currently active camera relay instance.
///
/// Inputs: camera configurations requested by incoming RPC streams.
@ -69,32 +75,34 @@ impl CameraRuntime {
cfg: &camera::CameraConfig,
) -> Result<(u64, Arc<video::CameraRelay>, bool), Status> {
let session_id = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
let requested_profile = camera_relay_profile(cfg);
let mut slot = self.slot.lock().await;
let mut reused = false;
let relay = if let Some(existing) = slot.as_ref() {
if camera_cfg_eq(&existing.cfg, cfg) {
if camera_relay_profile_eq(&existing.profile, &requested_profile) {
reused = true;
existing.relay.clone()
} else {
self.make_relay(cfg)?
self.make_relay(&requested_profile)?
}
} else {
self.make_relay(cfg)?
self.make_relay(&requested_profile)?
};
if !reused {
*slot = Some(CameraRelaySlot {
cfg: cfg.clone(),
profile: requested_profile.clone(),
relay: relay.clone(),
});
info!(
session_id,
output = cfg.output.as_str(),
codec = cfg.codec.as_str(),
width = cfg.width,
height = cfg.height,
fps = cfg.fps,
output = requested_profile.primary.output.as_str(),
codec = requested_profile.primary.codec.as_str(),
width = requested_profile.primary.width,
height = requested_profile.primary.height,
fps = requested_profile.primary.fps,
hdmi_mirror = requested_profile.hdmi_mirror.is_some(),
"🎥 camera relay (re)created"
);
} else {
@ -167,7 +175,8 @@ impl CameraRuntime {
#[allow(clippy::result_large_err)]
#[cfg(not(coverage))]
fn make_relay(&self, cfg: &camera::CameraConfig) -> Result<Arc<video::CameraRelay>, Status> {
fn make_relay(&self, profile: &CameraRelayProfile) -> Result<Arc<video::CameraRelay>, Status> {
let cfg = &profile.primary;
let relay = match cfg.output {
camera::CameraOutput::Uvc => {
if std::env::var("LESAVKA_DISABLE_UVC").is_ok() {
@ -177,9 +186,26 @@ impl CameraRuntime {
}
let uvc = uvc_runtime::pick_uvc_device()
.map_err(|e| Status::internal(format!("{e:#}")))?;
info!(%uvc, "🎥 stream_camera using UVC sink");
video::CameraRelay::new_uvc(0, &uvc, cfg)
.map_err(|e| Status::internal(format!("{e:#}")))?
if let Some(hdmi_cfg) = profile.hdmi_mirror.as_ref() {
let display_size = hdmi_cfg.hdmi_display_size();
info!(
%uvc,
hdmi = hdmi_cfg
.hdmi
.as_ref()
.map(|h| h.name.as_str())
.unwrap_or("none"),
display_width = display_size.0,
display_height = display_size.1,
"🎥 stream_camera using UVC sink with HDMI mirror"
);
video::CameraRelay::new_uvc_with_hdmi_mirror(0, &uvc, cfg, hdmi_cfg)
.map_err(|e| Status::internal(format!("{e:#}")))?
} else {
info!(%uvc, "🎥 stream_camera using UVC sink");
video::CameraRelay::new_uvc(0, &uvc, cfg)
.map_err(|e| Status::internal(format!("{e:#}")))?
}
}
camera::CameraOutput::Hdmi => video::CameraRelay::new_hdmi(0, cfg)
.map_err(|e| Status::internal(format!("{e:#}")))?,
@ -222,10 +248,42 @@ pub fn camera_cfg_eq(a: &camera::CameraConfig, b: &camera::CameraConfig) -> bool
}
}
fn camera_relay_profile(cfg: &camera::CameraConfig) -> CameraRelayProfile {
let hdmi_mirror = (cfg.output == camera::CameraOutput::Uvc && hdmi_mirror_enabled())
.then(|| camera::hdmi_mirror_config(cfg));
CameraRelayProfile {
primary: cfg.clone(),
hdmi_mirror,
}
}
fn camera_relay_profile_eq(a: &CameraRelayProfile, b: &CameraRelayProfile) -> bool {
camera_cfg_eq(&a.primary, &b.primary)
&& match (&a.hdmi_mirror, &b.hdmi_mirror) {
(Some(left), Some(right)) => camera_cfg_eq(left, right),
(None, None) => true,
_ => false,
}
}
fn hdmi_mirror_enabled() -> bool {
std::env::var("LESAVKA_CAM_HDMI_MIRROR")
.ok()
.map(|value| {
let trimmed = value.trim();
trimmed.eq_ignore_ascii_case("1")
|| trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
|| trimmed.eq_ignore_ascii_case("on")
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::camera_cfg_eq;
use super::{camera_cfg_eq, camera_relay_profile, camera_relay_profile_eq};
use crate::camera::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector};
use serial_test::serial;
#[test]
fn camera_cfg_eq_requires_matching_sink_profile() {
@ -257,4 +315,73 @@ mod tests {
});
assert!(!camera_cfg_eq(&base, &changed));
}
#[test]
#[serial]
fn camera_relay_profile_tracks_hdmi_mirror_flag() {
let base = CameraConfig {
output: CameraOutput::Uvc,
codec: CameraCodec::Mjpeg,
width: 1280,
height: 720,
fps: 20,
hdmi: None,
};
temp_env::with_var("LESAVKA_CAM_HDMI_MIRROR", None::<&str>, || {
let profile = camera_relay_profile(&base);
assert!(profile.hdmi_mirror.is_none());
});
temp_env::with_var("LESAVKA_CAM_HDMI_MIRROR", Some("1"), || {
let profile = camera_relay_profile(&base);
let mirror = profile.hdmi_mirror.as_ref().expect("HDMI mirror config");
assert_eq!(mirror.output, CameraOutput::Hdmi);
assert_eq!(mirror.codec, CameraCodec::Mjpeg);
assert_eq!((mirror.width, mirror.height, mirror.fps), (1280, 720, 20));
});
}
#[test]
#[serial]
fn camera_relay_profile_reuse_requires_same_mirror_connector() {
let base = CameraConfig {
output: CameraOutput::Uvc,
codec: CameraCodec::Mjpeg,
width: 1280,
height: 720,
fps: 20,
hdmi: None,
};
let left = CameraConfig {
output: CameraOutput::Hdmi,
hdmi: Some(HdmiConnector {
name: String::from("HDMI-A-1"),
id: Some(41),
modes: Vec::new(),
}),
..base.clone()
};
let right = CameraConfig {
output: CameraOutput::Hdmi,
hdmi: Some(HdmiConnector {
name: String::from("HDMI-A-2"),
id: Some(42),
modes: Vec::new(),
}),
..base.clone()
};
assert!(!camera_relay_profile_eq(
&super::CameraRelayProfile {
primary: base.clone(),
hdmi_mirror: Some(left),
},
&super::CameraRelayProfile {
primary: base,
hdmi_mirror: Some(right),
},
));
}
}

View File

@ -1,3 +1,5 @@
#[cfg(coverage)]
use super::hdmi_mirror_config;
use super::selection::{parse_hdmi_mode, parse_hdmi_modes, preferred_hdmi_mode};
use super::{
CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, current_camera_config,
@ -265,6 +267,36 @@ fn non_hdmi_display_size_uses_camera_profile() {
assert_eq!(cfg.hdmi_display_size(), (800, 600));
}
#[test]
#[cfg(coverage)]
#[serial]
fn hdmi_mirror_config_keeps_primary_uplink_profile_and_detects_display() {
let primary = CameraConfig {
output: CameraOutput::Uvc,
codec: CameraCodec::Mjpeg,
width: 1280,
height: 720,
fps: 20,
hdmi: None,
};
temp_env::with_vars(
[
("LESAVKA_HDMI_CONNECTOR", Some("card1-HDMI-A-2")),
("LESAVKA_HDMI_MODES", Some("1920x1080,1280x720")),
("LESAVKA_HDMI_WIDTH", None),
("LESAVKA_HDMI_HEIGHT", None),
],
|| {
let mirror = hdmi_mirror_config(&primary);
assert_eq!(mirror.output, CameraOutput::Hdmi);
assert_eq!(mirror.codec, primary.codec);
assert_eq!((mirror.width, mirror.height, mirror.fps), (1280, 720, 20));
assert_eq!(mirror.hdmi_display_size(), (1920, 1080));
},
);
}
#[test]
#[cfg(coverage)]
#[serial]

View File

@ -361,6 +361,9 @@ pub(crate) fn build_streaming_control(
}
pub(crate) fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 {
if !uvc_advertises_multiple_mjpeg_frames() {
return 1;
}
match (width, height) {
(1920, 1080) => 1,
(1280, 720) => 2,
@ -368,21 +371,22 @@ pub(crate) fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 {
}
}
pub(crate) fn uvc_frame_index_for_request(requested: u8, cfg: &UvcConfig) -> u8 {
match requested {
1 | 2 => requested,
_ => uvc_frame_index_for_mode(cfg.width, cfg.height),
}
pub(crate) fn uvc_frame_index_for_request(_requested: u8, cfg: &UvcConfig) -> u8 {
uvc_frame_index_for_mode(cfg.width, cfg.height)
}
pub(crate) fn uvc_frame_size_for_index(frame_index: u8, fallback: u32) -> u32 {
match frame_index {
1 => 1920 * 1080 * 2,
2 => 1280 * 720 * 2,
1 | 2 => fallback,
_ => fallback,
}
}
fn uvc_advertises_multiple_mjpeg_frames() -> bool {
let base = std::path::Path::new(CONFIGFS_UVC_BASE).join("streaming/mjpeg/m");
base.join("1080p").exists() && base.join("720p").exists()
}
pub(crate) fn compute_payload_cap(bulk: bool) -> Option<PayloadCap> {
if let Some(limit) = env_u32_opt("LESAVKA_UVC_MAXPAYLOAD_LIMIT") {
return Some(PayloadCap {

View File

@ -285,9 +285,8 @@ pub(crate) fn sanitize_streaming_control(
&mut out[18..22],
uvc_frame_size_for_index(frame_index, state.cfg.frame_size),
);
if interval != 0 {
write_le32(&mut out[4..8], interval);
}
let _host_requested_interval = interval;
write_le32(&mut out[4..8], state.cfg.interval);
if host_payload > 0 {
let payload = host_payload.min(state.cfg.max_packet);
write_le32(&mut out[22..26], payload);

View File

@ -1,6 +1,10 @@
enum CameraSink {
Uvc(WebcamSink),
Hdmi(HdmiSink),
UvcWithHdmiMirror {
uvc: WebcamSink,
hdmi: HdmiSink,
},
#[cfg(coverage)]
Noop,
}
@ -10,6 +14,10 @@ impl CameraSink {
match self {
CameraSink::Uvc(sink) => sink.push(pkt),
CameraSink::Hdmi(sink) => sink.push(pkt),
CameraSink::UvcWithHdmiMirror { uvc, hdmi } => {
uvc.push(pkt.clone());
hdmi.push(pkt);
}
#[cfg(coverage)]
CameraSink::Noop => {
let _ = pkt;
@ -44,6 +52,29 @@ impl CameraRelay {
})
}
/// Build a relay that writes the same camera stream to UVC and HDMI.
///
/// Inputs: the logical camera id, UVC device node, UVC profile, and HDMI
/// mirror display profile.
/// Outputs: a relay that fans out each client frame to both physical sinks.
/// Why: this lets the capture-card bypass be evaluated without removing the
/// current UVC gadget path from Tethys.
pub fn new_uvc_with_hdmi_mirror(
id: u32,
uvc_dev: &str,
uvc_cfg: &CameraConfig,
hdmi_cfg: &CameraConfig,
) -> anyhow::Result<Self> {
Ok(Self {
sink: CameraSink::UvcWithHdmiMirror {
uvc: WebcamSink::new(uvc_dev, uvc_cfg)?,
hdmi: HdmiSink::new(hdmi_cfg)?,
},
id,
frames: AtomicU64::new(0),
})
}
/// Build a relay that targets the HDMI output pipeline.
///
/// Inputs: the logical camera id plus the camera config.

View File

@ -15,7 +15,7 @@ const DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES: u32 = 12;
const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92;
const DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT: u32 = 18;
const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024;
const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false;
const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = true;
const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = true;
const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 60;
const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 50;
@ -185,18 +185,18 @@ pub(super) fn direct_mjpeg_min_reference_bytes() -> u32 {
/// Decide whether direct MJPEG frames must match the active UVC dimensions.
///
/// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH`.
/// Output: false unless explicitly enabled. Why: current field debugging needs
/// profile-mismatch telemetry first; rejecting mismatches by default could
/// accidentally freeze every frame on an attached gadget with stale descriptors.
/// Output: true unless explicitly disabled. Why: the live UVC descriptor is the
/// browser-visible contract; spooling a mismatched JPEG can leave the host
/// showing stale or smeared frames instead of an honest unsupported-mode freeze.
pub(super) fn direct_mjpeg_reject_profile_mismatch_enabled() -> bool {
std::env::var("LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH")
.ok()
.map(|value| {
let trimmed = value.trim();
trimmed.eq_ignore_ascii_case("1")
|| trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
|| trimmed.eq_ignore_ascii_case("on")
!(trimmed.eq_ignore_ascii_case("0")
|| trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off"))
})
.unwrap_or(DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT)
}

View File

@ -9,6 +9,8 @@ use super::mjpeg_visual_seams::seam_reason;
const DEFAULT_PIXEL_GUARD_SAMPLE_WIDTH: u32 = 160;
const DEFAULT_PIXEL_GUARD_FLAT_STDDEV: u32 = 6;
const DEFAULT_PIXEL_GUARD_FLAT_RUN_PCT: u32 = 16;
const DEFAULT_PIXEL_GUARD_FLAT_MIN_START_PCT: u32 = 45;
const DEFAULT_PIXEL_GUARD_FLAT_MIN_END_PCT: u32 = 72;
const DEFAULT_PIXEL_GUARD_MIN_PIXELS: u32 = 160 * 90;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -93,6 +95,22 @@ fn flat_run_pct_threshold() -> usize {
.clamp(5, 60) as usize
}
fn flat_min_start_pct_threshold() -> usize {
env_u32(
"LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_MIN_START_PCT",
DEFAULT_PIXEL_GUARD_FLAT_MIN_START_PCT,
)
.min(100) as usize
}
fn flat_min_end_pct_threshold() -> usize {
env_u32(
"LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_MIN_END_PCT",
DEFAULT_PIXEL_GUARD_FLAT_MIN_END_PCT,
)
.min(100) as usize
}
fn min_pixels_for_guard() -> usize {
env_u32(
"LESAVKA_UVC_MJPEG_PIXEL_GUARD_MIN_PIXELS",
@ -234,6 +252,8 @@ fn row_stats(frame: &SampledFrame, row: usize) -> (f64, f64) {
fn flat_block_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
let threshold = flat_stddev_threshold();
let min_run = (frame.height.saturating_mul(flat_run_pct_threshold()) / 100).max(2);
let min_start_pct = flat_min_start_pct_threshold();
let min_end_pct = flat_min_end_pct_threshold();
let mut best_start = 0usize;
let mut best_len = 0usize;
let mut current_start = 0usize;
@ -255,9 +275,14 @@ fn flat_block_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason>
}
}
if best_len >= min_run {
let start_row_pct = pct(best_start, frame.height);
let end_row_pct = pct(best_start + best_len, frame.height);
if usize::from(start_row_pct) < min_start_pct || usize::from(end_row_pct) < min_end_pct {
return None;
}
return Some(MjpegVisualArtifactReason::FlatBlock {
start_row_pct: pct(best_start, frame.height),
end_row_pct: pct(best_start + best_len, frame.height),
start_row_pct,
end_row_pct,
run_pct: pct(best_len, frame.height),
});
}

View File

@ -389,6 +389,48 @@ fn pixel_guard_accepts_wall_over_couch_scene_edges() {
);
}
#[test]
/// Verifies close-up webcam regions are not frozen as damaged gray slabs.
fn pixel_guard_accepts_midframe_flat_camera_regions() {
temp_env::with_vars(
[
("LESAVKA_UVC_MJPEG_PIXEL_GUARD", Some("1")),
("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_STDDEV", Some("6")),
("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_RUN_PCT", Some("16")),
("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_MIN_START_PCT", Some("45")),
("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_MIN_END_PCT", Some("72")),
],
|| {
let width = 320;
let height = 180;
let mut frame = vec![0u8; width * height * 3];
let band_start = height.saturating_mul(43) / 100;
let band_end = height.saturating_mul(63) / 100;
for y in 0..height {
for x in 0..width {
let offset = (y * width + x) * 3;
if (band_start..band_end).contains(&y) {
frame[offset] = 132;
frame[offset + 1] = 132;
frame[offset + 2] = 132;
} else {
let shade = 122u8.saturating_add(((x * 13 + y * 7) % 24) as u8);
frame[offset] = shade;
frame[offset + 1] = shade.saturating_add((x % 3) as u8);
frame[offset + 2] = shade.saturating_sub((y % 3) as u8);
}
}
}
assert_eq!(
super::mjpeg_visual_guard::sampled_rgb_frame_for_test(width, height, &frame),
None
);
},
);
}
#[test]
/// Verifies low-detail gray slabs freeze out before UVC handoff.
fn pixel_guard_rejects_flat_lower_block_frames() {
@ -556,18 +598,24 @@ fn direct_mjpeg_guard_reports_oversize_and_profile_mismatch_when_configured() {
assert_eq!(inspection.height, Some(1080));
assert!(inspection.entropy_distinct_bytes > 64);
temp_env::with_var_unset("LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH", || {
assert_eq!(
super::direct_mjpeg_reject_reason(0, Some(frame.len() + 1), Some((1280, 720)), &frame),
Some(super::DirectMjpegRejectReason::ProfileMismatch {
expected_width: 1280,
expected_height: 720,
actual_width: 1920,
actual_height: 1080,
})
);
});
temp_env::with_var(
"LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH",
Some("1"),
Some("0"),
|| {
assert_eq!(
super::direct_mjpeg_reject_reason(0, Some(frame.len() + 1), Some((1280, 720)), &frame),
Some(super::DirectMjpegRejectReason::ProfileMismatch {
expected_width: 1280,
expected_height: 720,
actual_width: 1920,
actual_height: 1080,
})
None
);
},
);

View File

@ -335,6 +335,14 @@ fn add_hevc_mjpeg_spool_branch(
configure_hevc_decoder(&decoder);
let decoded_queue = build_hevc_freshness_queue("dynamic_hevc_mjpeg_decoded_queue")?;
let convert = gst::ElementFactory::make("videoconvert").build()?;
let scale = gst::ElementFactory::make("videoscale").build()?;
let raw_caps = gst::Caps::builder("video/x-raw")
.field("width", width)
.field("height", height)
.build();
let raw_capsfilter = gst::ElementFactory::make("capsfilter")
.property("caps", &raw_caps)
.build()?;
let encoder = gst::ElementFactory::make("jpegenc")
.property("quality", hevc_mjpeg_guard::hevc_jpeg_quality() as i32)
.build()?;
@ -362,6 +370,8 @@ fn add_hevc_mjpeg_spool_branch(
&decoder,
&decoded_queue,
&convert,
&scale,
&raw_capsfilter,
&encoder,
&jpegparse,
&caps,
@ -374,6 +384,8 @@ fn add_hevc_mjpeg_spool_branch(
&decoder,
&decoded_queue,
&convert,
&scale,
&raw_capsfilter,
&encoder,
&jpegparse,
&caps,

View File

@ -213,6 +213,14 @@ impl WebcamSink {
configure_hevc_decoder(&decoder);
let decoded_queue = build_hevc_freshness_queue("hevc_mjpeg_decoded_queue")?;
let convert = gst::ElementFactory::make("videoconvert").build()?;
let scale = gst::ElementFactory::make("videoscale").build()?;
let raw_caps = gst::Caps::builder("video/x-raw")
.field("width", width)
.field("height", height)
.build();
let raw_capsfilter = gst::ElementFactory::make("capsfilter")
.property("caps", &raw_caps)
.build()?;
let encoder = gst::ElementFactory::make("jpegenc")
.property("quality", hevc_mjpeg_guard::hevc_jpeg_quality() as i32)
.build()?;
@ -231,6 +239,27 @@ impl WebcamSink {
);
if mjpeg_spool_enabled() {
if hevc_mjpeg_guard::direct_mjpeg_normalize_enabled() {
match build_direct_mjpeg_normalize_branch(&pipeline, width, height, fps) {
Ok((normalize_src, normalize_sink)) => {
direct_mjpeg_appsrc = Some(normalize_src);
normalized_mjpeg_sink = Some(normalize_sink);
tracing::info!(
target: "lesavka_server::video",
quality = hevc_mjpeg_guard::direct_mjpeg_jpeg_quality(),
pull_timeout_ms = hevc_mjpeg_guard::direct_mjpeg_normalize_pull_timeout_ms(),
"HEVC UVC spool will normalize unexpected direct MJPEG packets"
);
}
Err(err) => {
tracing::warn!(
target: "lesavka_server::video",
%err,
"direct MJPEG normalization unavailable for HEVC UVC spool fallback"
);
}
}
}
let sink = gst::ElementFactory::make("appsink")
.name("hevc_mjpeg_spool_sink")
.property("sync", false)
@ -247,6 +276,8 @@ impl WebcamSink {
&decoder,
&decoded_queue,
&convert,
&scale,
&raw_capsfilter,
&encoder,
&jpegparse,
&caps,
@ -259,6 +290,8 @@ impl WebcamSink {
&decoder,
&decoded_queue,
&convert,
&scale,
&raw_capsfilter,
&encoder,
&jpegparse,
&caps,
@ -283,6 +316,8 @@ impl WebcamSink {
&h265parse,
&decoder,
&convert,
&scale,
&raw_capsfilter,
&encoder,
&jpegparse,
&caps,
@ -293,6 +328,8 @@ impl WebcamSink {
&h265parse,
&decoder,
&convert,
&scale,
&raw_capsfilter,
&encoder,
&jpegparse,
&caps,

View File

@ -167,7 +167,6 @@ impl WebcamSink {
#[cfg(not(coverage))]
fn spool_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) {
let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed);
let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(&pkt.data);
if let (Some(width), Some(height)) = (inspection.width, inspection.height)
&& (width, height) != (self.uvc_width, self.uvc_height)
@ -184,6 +183,26 @@ impl WebcamSink {
"direct MJPEG frame dimensions differ from the live UVC profile; this can make browser output unstable"
);
}
if !hevc_mjpeg_guard::looks_like_complete_jpeg(&pkt.data) {
self.reject_direct_mjpeg_frame(pkt, "Incomplete");
return;
}
if self.direct_mjpeg_appsrc.is_some()
&& self.normalized_mjpeg_sink.is_some()
&& !self.direct_mjpeg_normalize_bypassed.load(Ordering::Relaxed)
{
self.spool_normalized_direct_mjpeg_frame(path, pkt);
return;
}
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
}
#[cfg(not(coverage))]
fn spool_guarded_passthrough_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) {
let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed);
let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(&pkt.data);
if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason(
previous_bytes,
Some(self.direct_mjpeg_max_bytes),
@ -204,20 +223,21 @@ impl WebcamSink {
entropy_max_run = inspection.entropy_max_run,
"freezing suspicious direct MJPEG frame before UVC spool"
);
return;
}
if self.direct_mjpeg_appsrc.is_some()
&& self.normalized_mjpeg_sink.is_some()
&& !self.direct_mjpeg_normalize_bypassed.load(Ordering::Relaxed)
{
self.spool_normalized_direct_mjpeg_frame(path, pkt);
let reason_text = format!("{reason:?}");
self.reject_direct_mjpeg_frame(pkt, &reason_text);
return;
}
self.spool_passthrough_direct_mjpeg_frame(path, pkt);
}
#[cfg(not(coverage))]
fn reject_direct_mjpeg_frame(&self, pkt: &VideoPacket, reason: &str) {
let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts)
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
super::mjpeg_spool::audit_rejected_mjpeg_frame(&pkt.data, timing, reason);
}
#[cfg(not(coverage))]
fn spool_passthrough_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) {
let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts)
@ -233,11 +253,11 @@ impl WebcamSink {
#[cfg(not(coverage))]
fn spool_normalized_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) {
let Some(src) = self.direct_mjpeg_appsrc.as_ref() else {
self.spool_passthrough_direct_mjpeg_frame(path, pkt);
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
return;
};
let Some(sink) = self.normalized_mjpeg_sink.as_ref() else {
self.spool_passthrough_direct_mjpeg_frame(path, pkt);
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
return;
};
@ -257,7 +277,7 @@ impl WebcamSink {
limit_kb,
"direct MJPEG normalization disabled because server RSS exceeded its safety limit"
);
self.spool_passthrough_direct_mjpeg_frame(path, pkt);
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
return;
}
@ -276,7 +296,7 @@ impl WebcamSink {
);
self.direct_mjpeg_normalize_bypassed
.store(true, Ordering::Relaxed);
self.spool_passthrough_direct_mjpeg_frame(path, pkt);
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
return;
}
@ -300,7 +320,7 @@ impl WebcamSink {
limit,
"direct MJPEG normalization starved; falling back to guarded passthrough for this webcam session"
);
self.spool_passthrough_direct_mjpeg_frame(path, pkt);
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
}
return;
};
@ -311,7 +331,7 @@ impl WebcamSink {
target:"lesavka_server::video",
"direct MJPEG normalization returned an empty sample; falling back to guarded passthrough"
);
self.spool_passthrough_direct_mjpeg_frame(path, pkt);
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
return;
};
let Ok(map) = buffer.map_readable() else {
@ -321,7 +341,7 @@ impl WebcamSink {
target:"lesavka_server::video",
"direct MJPEG normalization returned an unreadable sample; falling back to guarded passthrough"
);
self.spool_passthrough_direct_mjpeg_frame(path, pkt);
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
return;
};
let normalized = map.as_slice();
@ -333,6 +353,14 @@ impl WebcamSink {
normalized,
) {
let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(normalized);
let rejected_timing = MjpegSpoolTiming::mjpeg_normalized(pkt.pts)
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
let reason_text = format!("{reason:?}");
super::mjpeg_spool::audit_rejected_mjpeg_frame(
normalized,
rejected_timing,
&reason_text,
);
warn!(
target:"lesavka_server::video",
?reason,

View File

@ -184,11 +184,60 @@ fn direct_mjpeg_normalizer_branch_reencodes_a_valid_frame() {
use gstreamer as gst;
use gstreamer::prelude::ElementExt;
fn jpeg_dimensions(bytes: &[u8]) -> Option<(u16, u16)> {
let mut idx = 2;
while idx + 4 <= bytes.len() {
if bytes[idx] != 0xff {
idx += 1;
continue;
}
while idx < bytes.len() && bytes[idx] == 0xff {
idx += 1;
}
let marker = *bytes.get(idx)?;
idx += 1;
if marker == 0xda || marker == 0xd9 {
return None;
}
if matches!(marker, 0x01 | 0xd0..=0xd9) {
continue;
}
let segment_len = bytes
.get(idx..idx + 2)
.map(|value| u16::from_be_bytes([value[0], value[1]]))?
as usize;
if segment_len < 7 || idx + segment_len > bytes.len() {
return None;
}
if matches!(
marker,
0xc0 | 0xc1
| 0xc2
| 0xc3
| 0xc5
| 0xc6
| 0xc7
| 0xc9
| 0xca
| 0xcb
| 0xcd
| 0xce
| 0xcf
) {
let height = u16::from_be_bytes([bytes[idx + 3], bytes[idx + 4]]);
let width = u16::from_be_bytes([bytes[idx + 5], bytes[idx + 6]]);
return Some((width, height));
}
idx += segment_len;
}
None
}
const FIXTURE: &[u8] = include_bytes!("../../bin/lesavka_uvc/idle_1280x720_black.jpg");
gst::init().expect("gstreamer init");
let pipeline = gst::Pipeline::new();
let (src, sink) = super::build_direct_mjpeg_normalize_branch(&pipeline, 1280, 720, 30)
let (src, sink) = super::build_direct_mjpeg_normalize_branch(&pipeline, 640, 360, 30)
.expect("normalizer branch");
pipeline
.set_state(gst::State::Playing)
@ -202,5 +251,6 @@ fn direct_mjpeg_normalizer_branch_reencodes_a_valid_frame() {
assert!(map.as_slice().starts_with(&[0xff, 0xd8, 0xff]));
assert!(map.as_slice().ends_with(&[0xff, 0xd9]));
assert_eq!(jpeg_dimensions(map.as_slice()), Some((640, 360)));
pipeline.set_state(gst::State::Null).ok();
}

View File

@ -9,6 +9,10 @@ const CORE_SCRIPT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/daemon/lesavka-core.sh"
));
const UVC_SCRIPT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/daemon/lesavka-uvc.sh"
));
#[test]
fn core_script_rebuilds_incomplete_bound_gadgets() {
@ -100,15 +104,20 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() {
"UVC bulk requested but this kernel lacks streaming_bulk; using isochronous descriptors",
"apply_uvc_payload_limits",
"UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}",
"UVC_ADVERTISE_EXTRA_MODES=${LESAVKA_UVC_ADVERTISE_EXTRA_MODES:-0}",
"uvc_mjpeg_frame_size_for_fps()",
"UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}",
"UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}",
"uvc_isochronous_budget_bytes_per_sec()",
"isoch_budget=\"$(uvc_isochronous_budget_bytes_per_sec)\"",
"UVC_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"",
"write_active_mjpeg_frame_descriptor()",
"if flag_enabled \"$UVC_ADVERTISE_EXTRA_MODES\"; then",
"write_mjpeg_frame_descriptor 1080p 1920 1080",
"write_mjpeg_frame_descriptor 720p 1280 720",
"write_active_mjpeg_frame_descriptor",
"echo \"$(uvc_frame_size_for \"$width\" \"$height\")\" >\"$frame/dwMaxVideoFrameBufferSize\"",
"echo \"$default_interval\" >\"$frame/dwFrameInterval\"",
"UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333}",
"UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000}",
] {
@ -137,3 +146,21 @@ fn core_script_hides_uac_hardware_mixer_controls_by_default() {
);
}
}
#[test]
fn uvc_script_migrates_starvation_prone_legacy_defaults() {
for expected in [
"migrate_unsafe_uvc_defaults()",
"LESAVKA_UVC_BUFFER_COUNT:-",
"LESAVKA_UVC_ALLOW_SINGLE_BUFFER",
"export LESAVKA_UVC_BUFFER_COUNT=4",
"LESAVKA_UVC_QUEUE_PACING:-",
"LESAVKA_UVC_ALLOW_QUEUE_PACING",
"export LESAVKA_UVC_QUEUE_PACING=0",
] {
assert!(
UVC_SCRIPT.contains(expected),
"lesavka-uvc should migrate unsafe old defaults: {expected}"
);
}
}

View File

@ -29,6 +29,7 @@ fn server_install_infers_checkout_owner_when_root_wrapper_hides_sudo_user() {
fn server_install_pins_hdmi_camera_and_display_defaults() {
for expected in [
"LESAVKA_CAM_OUTPUT=%s",
"LESAVKA_CAM_HDMI_MIRROR=%s",
"LESAVKA_CAM_CODEC=%s",
"LESAVKA_CAM_WIDTH=%s",
"LESAVKA_CAM_HEIGHT=%s",
@ -83,10 +84,14 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_HEIGHT:-1080}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_FPS:-30}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}"));
assert!(
SERVER_INSTALL
.contains("${LESAVKA_INSTALL_CAM_HDMI_MIRROR:-${LESAVKA_CAM_HDMI_MIRROR:-0}}")
);
assert!(SERVER_INSTALL.contains("normalize_cam_codec()"));
assert!(
SERVER_INSTALL.contains(
"REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}"
"REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}}"
)
);
assert!(
@ -181,6 +186,8 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
SERVER_INSTALL.contains("LESAVKA_INSTALL_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US"),
"install-specific video offset override should bypass stale ambient runtime env"
);
assert!(SERVER_INSTALL.contains("PERSISTED_CAM_HDMI_MIRROR="));
assert!(SERVER_INSTALL.contains("HDMI mirror:"));
assert!(
SERVER_INSTALL.contains(
"migrating stale upstream audio playout offset to the per-mode MJPEG/UAC default"
@ -225,11 +232,13 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
assert!(SERVER_INSTALL.contains("paste-key"));
assert!(SERVER_INSTALL.contains("ca.crt client.crt client.key paste-key"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_MAXPACKET 1024"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_INTERVAL 333333"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FPS 20"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_INTERVAL 500000"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_WIDTH 1280"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_HEIGHT 720"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_QUEUE_PACING 1"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_BUFFER_COUNT 4"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_QUEUE_PACING 0"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_BULK 1"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0"));
@ -263,10 +272,10 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
"install script should try the Raspberry Pi HEVC decoder before requiring another hardware decoder"
);
assert!(
SERVER_INSTALL.contains("Software HEVC decoder passed the same 1280x720 smoke")
SERVER_INSTALL.contains("Software HEVC decoder passed:")
&& SERVER_INSTALL.contains("LESAVKA_HEVC_DECODER")
&& SERVER_INSTALL.contains("LESAVKA_ALLOW_SOFTWARE_VIDEO"),
"install script should make software HEVC fallback explicit and smoke-proven"
"install script should document software HEVC fallback only as an explicit follow-up"
);
assert!(
SERVER_INSTALL.contains("hardware HEVC decoder passed a real 1280x720 decode smoke")
@ -290,7 +299,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
SERVER_INSTALL.contains("Refusing HEVC upstream install because production video decode must be hardware-accelerated and proven")
&& SERVER_INSTALL.contains("Use LESAVKA_INSTALL_CAM_CODEC=mjpeg while the HEVC decoder stack is repaired")
&& SERVER_INSTALL.contains("Default HEVC upstream cannot be proven on this host; falling back to MJPEG ingress."),
"explicit HEVC installs should fail loud while default HEVC installs can use proven fallback paths"
"explicit HEVC installs should fail loud while defensive default paths fall back to MJPEG"
);
assert!(
!SERVER_INSTALL
@ -436,7 +445,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
&& SERVER_INSTALL
.contains("1280x720) frame_root=\"$function_root/streaming/mjpeg/m/720p\"")
&& SERVER_INSTALL.contains(
"grep -qx \"${LESAVKA_UVC_INTERVAL:-333333}\" \"$frame_root/dwFrameInterval\""
"grep -qx \"${LESAVKA_UVC_INTERVAL:-500000}\" \"$frame_root/dwFrameInterval\""
),
"live descriptor matching should recognize all supported MJPEG UVC profiles instead of collapsing to one 720p frame"
);

View File

@ -120,7 +120,7 @@ mod uvc_binary {
maybe_update_ctrl_len(&mut state, STREAM_CTRL_SIZE_11 as u16, false);
assert_eq!(state.ctrl_len, STREAM_CTRL_SIZE_11);
assert_eq!(state.probe[2], 1);
assert_eq!(state.commit[3], 2);
assert_eq!(state.commit[3], 1);
}
#[test]
@ -128,13 +128,13 @@ mod uvc_binary {
let state = UvcState::new(sample_cfg());
let mut data = [0u8; STREAM_CTRL_SIZE_MAX];
data[2] = 1;
data[3] = 1;
data[3] = 2;
write_le32(&mut data[4..8], 333_333);
write_le32(&mut data[22..26], 4096);
let out = sanitize_streaming_control(&data, &state);
assert_eq!(out[2], 1);
assert_eq!(out[3], 1);
assert_eq!(read_le32(&out, 4), 333_333);
assert_eq!(read_le32(&out, 4), state.cfg.interval);
assert_eq!(read_le32(&out, 18), state.cfg.frame_size);
assert_eq!(read_le32(&out, 22), state.cfg.max_packet);
}
@ -166,7 +166,7 @@ mod uvc_binary {
false,
);
assert!(pending.is_none());
assert_eq!(read_le32(&state.probe, 4), 250_000);
assert_eq!(read_le32(&state.probe, 4), state.cfg.interval);
assert_eq!(read_le32(&state.probe, 22), state.cfg.max_packet);
let mut pending = Some(PendingRequest {
@ -186,7 +186,7 @@ mod uvc_binary {
},
false,
);
assert_eq!(read_le32(&state.commit, 4), 250_000);
assert_eq!(read_le32(&state.commit, 4), state.cfg.interval);
}
#[test]

View File

@ -243,14 +243,14 @@ mod uvc_binary_extra {
let state = UvcState::new(sample_cfg());
let mut payload = [0u8; UVC_DATA_SIZE];
payload[2] = 1;
payload[3] = 1;
payload[3] = 2;
payload[4..8].copy_from_slice(&333_333u32.to_le_bytes());
payload[22..26].copy_from_slice(&(state.cfg.max_packet + 500).to_le_bytes());
let out = sanitize_streaming_control(&payload, &state);
assert_eq!(out[2], 1);
assert_eq!(out[3], 1);
assert_eq!(read_le32(&out, 4), 333_333);
assert_eq!(read_le32(&out, 4), state.cfg.interval);
assert_eq!(read_le32(&out, 18), state.cfg.frame_size);
assert_eq!(read_le32(&out, 22), state.cfg.max_packet);
}
@ -290,7 +290,7 @@ mod uvc_binary_extra {
}
#[test]
fn handle_data_updates_commit_and_ignores_unknown_streaming_selector() {
fn handle_data_clamps_commit_interval_and_ignores_unknown_streaming_selector() {
let interfaces = sample_interfaces();
let mut state = UvcState::new(sample_cfg());
let original_commit = state.commit;
@ -315,7 +315,8 @@ mod uvc_binary_extra {
true,
);
assert!(pending.is_none());
assert_ne!(state.commit, original_commit);
assert_eq!(state.commit, original_commit);
assert_eq!(read_le32(&state.commit, 4), state.cfg.interval);
let after_commit = state.commit;
let mut pending = Some(PendingRequest {
@ -359,12 +360,9 @@ mod uvc_binary_extra {
with_var("LESAVKA_UVC_BUFFER_COUNT", None::<&str>, || {
with_var("LESAVKA_UVC_IDLE_PUMP_MS", None::<&str>, || {
with_var("LESAVKA_UVC_FRAME_MAX_AGE_MS", None::<&str>, || {
assert_eq!(uvc_buffer_count(), 1);
assert_eq!(uvc_buffer_count(), 4);
assert_eq!(uvc_idle_pump_sleep(), std::time::Duration::from_millis(2));
assert_eq!(
uvc_queue_period(30),
Some(std::time::Duration::from_nanos(33_333_333))
);
assert_eq!(uvc_queue_period(30), None);
assert_eq!(
frame_spool_max_age(),
Some(std::time::Duration::from_millis(1_000))
@ -430,8 +428,9 @@ mod uvc_binary_extra {
.expect("write oversize frame");
stream.refresh_latest_frame();
assert_eq!(stream.latest_frame, vec![0xff, 0xd8, 0x11, 0xff, 0xd9]);
assert_eq!(stream.latest_frame, IDLE_MJPEG_FRAME);
assert_eq!(stream.stats.rejected_oversize, 1);
assert_eq!(stream.stats.fallback_idle, 1);
assert_eq!(stream.stats.last_rejected_oversize_bytes, 12);
assert_eq!(stream.stats.last_rejected_oversize_cap, 8);
assert_eq!(stream.frame_payload_limit(), 8);

View File

@ -238,6 +238,18 @@ mod video_sinks {
}
}
#[test]
fn camera_sink_dispatch_mirrors_packets_to_uvc_and_hdmi() {
let source = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/video_sinks/camera_relay.rs"
));
assert!(source.contains("UvcWithHdmiMirror"));
assert!(source.contains("uvc.push(pkt.clone())"));
assert!(source.contains("hdmi.push(pkt)"));
assert!(source.contains("new_uvc_with_hdmi_mirror"));
}
#[test]
#[serial]
fn camera_relay_feed_covers_dev_mode_dump_branch_without_panicking() {

View File

@ -4,8 +4,8 @@
// explicit, while still allowing operator-provided install overrides.
// Targets: server/client install scripts and client camera capture defaults.
// Why: Lesavka now supports both MJPEG and HEVC upstream media, and installer
// reruns may prefer HEVC when decode is proven, falling back to either a
// smoke-tested software HEVC decoder or MJPEG instead of producing black frames.
// reruns should prefer MJPEG unless HEVC is explicitly requested and hardware
// decode is proven, instead of producing black frames.
const SERVER_INSTALL: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
@ -17,19 +17,18 @@ const CLIENT_CAMERA: &str = include_str!(concat!(
));
#[test]
fn server_install_defaults_to_hevc_ingress_with_mjpeg_fallback_and_mjpeg_uvc_output() {
fn server_install_defaults_to_mjpeg_ingress_and_mjpeg_uvc_output() {
for marker in [
"PERSISTED_UVC_CODEC=$(persisted_uvc_value LESAVKA_UVC_CODEC || true)",
"normalize_uvc_codec()",
"normalize_cam_codec()",
"REQUESTED_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}}",
"INSTALL_UVC_CODEC=$(normalize_uvc_codec \"$REQUESTED_UVC_CODEC\")",
"REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}",
"REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}}",
"INSTALL_CAM_CODEC=$(normalize_cam_codec \"${REQUESTED_CAM_CODEC}\")",
"INSTALL_CAM_CODEC_EXPLICIT=0",
"INSTALL_HEVC_DECODER=${LESAVKA_INSTALL_HEVC_DECODER:-${LESAVKA_HEVC_DECODER:-}}",
"INSTALL_ALLOW_SOFTWARE_VIDEO=${LESAVKA_INSTALL_ALLOW_SOFTWARE_VIDEO:-${LESAVKA_ALLOW_SOFTWARE_VIDEO:-0}}",
"Software HEVC decoder passed the same 1280x720 smoke",
"Default HEVC upstream cannot be proven on this host; falling back to MJPEG ingress.",
"printf 'LESAVKA_CAM_CODEC=%s\\n' \"${INSTALL_CAM_CODEC}\"",
"printf 'LESAVKA_HEVC_DECODER=%s\\n' \"$INSTALL_HEVC_DECODER\"",
@ -89,7 +88,6 @@ fn hevc_prerequisites_are_rechecked_idempotently() {
"/etc/modules-load.d/lesavka-hevc.conf",
"gst-inspect-1.0 v4l2slh265dec",
"avdec_h265 libde265dec",
"Keeping default HEVC upstream to avoid direct-MJPEG UVC artifacts",
"Refusing HEVC upstream install because production video decode must be hardware-accelerated and proven",
] {
assert!(