feat(media): add HDMI mirror webcam output
This commit is contained in:
parent
0d5284c9a2
commit
432cb96164
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.26.6"
|
||||
version = "0.27.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -46,7 +46,6 @@ impl MouseEventState<'_> {
|
||||
*self.last_abs_x = None;
|
||||
*self.last_abs_y = None;
|
||||
}
|
||||
self.set_btn(0, event.value());
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.26.6"
|
||||
version = "0.27.0"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -16,7 +16,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.26.6"
|
||||
version = "0.27.0"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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!(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user