diff --git a/client/Cargo.toml b/client/Cargo.toml index b63f8a6..5141e67 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.5.0" +version = "0.6.0" edition = "2024" [dependencies] diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 29814db..49476e8 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -1,21 +1,85 @@ // client/src/input/camera.rs +#![forbid(unsafe_code)] use anyhow::Result; +use gstreamer as gst; +use gstreamer_app as gst_app; +use gst::prelude::*; +use lesavka_common::lesavka::VideoPacket; -/// A stub camera aggregator or capture pub struct CameraCapture { - // no real fields yet + pipeline: gst::Pipeline, + sink: gst_app::AppSink, } impl CameraCapture { - pub fn new_stub() -> Self { - // in real code: open /dev/video0, set formats, etc - CameraCapture {} + pub fn new(device_fragment: Option<&str>) -> anyhow::Result { + gst::init().ok(); + + // Pick device + let dev = device_fragment + .and_then(Self::find_device) + .unwrap_or_else(|| "/dev/video0".into()); + + let desc = format!( + "v4l2src device={dev} io-mode=dmabuf-do-timestamp=true ! \ + videoconvert ! videoscale ! video/x-raw,width=1280,height=720 ! \ + v4l2h264enc key-int-max=30 \ + extra-controls=\"encode,frame_level_rate_control_enable=1,h264_profile=4\" ! \ + h264parse config-interval=-1 ! \ + appsink name=asink emit-signals=true max-buffers=60 drop=true" + ); + + // --- NEW: propagate the Result and down‑cast with `?` + let pipeline: gst::Pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("not a pipeline"); + + // --- NEW: the lookup already returns the concrete AppSink → just unwrap with `?` + let sink: gst_app::AppSink = pipeline + .by_name("asink") + .expect("appsink element not found") + .downcast::() + .expect("appsink down‑cast"); + + pipeline.set_state(gst::State::Playing)?; + + Ok(Self { pipeline, sink }) } - /// Called regularly to capture frames or do nothing - pub fn process_frames(&mut self) -> Result<()> { - // no-op - Ok(()) + pub fn pull(&self) -> Option { + let sample = self.sink.pull_sample().ok()?; + let buf = sample.buffer()?; + let map = buf.map_readable().ok()?; + let pts = buf.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000; + Some(VideoPacket { id: 100, pts, data: map.as_slice().to_vec() }) + } + + /// Fuzzy‑match devices under `/dev/v4l/by-id` + fn find_device(substr: &str) -> Option { + let dir = std::fs::read_dir("/dev/v4l/by-id").ok()?; + for e in dir.flatten() { + let p = e.path(); + if p.file_name()?.to_string_lossy().contains(substr) { + if let Ok(target) = std::fs::read_link(&p) { + return Some(format!("/dev/{}", target.file_name()?.to_string_lossy())); + } + } + } + None + } + + /// Cheap stub used when the web‑cam is disabled + pub fn new_stub() -> Self { + let pipeline = gst::Pipeline::new(); + + // --- NEW: AppSink factory helper (AppSink::new() does not exist) + let sink: gst_app::AppSink = gst::ElementFactory::make("appsink") + .build() + .expect("make appsink") + .downcast::() + .expect("appsink down‑cast"); + + Self { pipeline, sink } } } diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index f471f43..861e596 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -92,8 +92,11 @@ impl InputAggregator { bail!("No suitable keyboard/mouse devices found or none grabbed."); } - // Stubs for camera / mic: - self.camera = Some(CameraCapture::new_stub()); + // Auto‑select webcam (may be toggled later through magic chord) + self.camera = match CameraCapture::new(std::env::var("LESAVKA_CAM_SOURCE").ok().as_deref()) { + Ok(cam) => { info!("📸 webcam enabled"); Some(cam) } + Err(e) => { warn!("📸 webcam disabled: {e}"); None } + }; Ok(()) } diff --git a/common/Cargo.toml b/common/Cargo.toml index 4628718..24781aa 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.5.0" +version = "0.6.0" edition = "2024" build = "build.rs" diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index 4ee8de2..8e97acc 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -17,6 +17,8 @@ service Relay { rpc StreamMouse (stream MouseReport) returns (stream MouseReport); rpc CaptureVideo (MonitorRequest) returns (stream VideoPacket); rpc CaptureAudio (MonitorRequest) returns (stream AudioPacket); - rpc StreamMicrophone (stream AudioPacket) returns (stream Empty) {} + rpc StreamMicrophone (stream AudioPacket) returns (stream Empty); + rpc StreamCamera (stream VideoPacket) returns (stream Empty); + rpc ResetUsb (Empty) returns (ResetUsbReply); } diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index d0f07e1..e4c1ece 100644 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -112,6 +112,14 @@ echo 2 >"$U/c_ssize" # Optional: allocate a few extra request buffers echo 32 >"$U/req_number" 2>/dev/null || true +# ----------------------- UVC function (usb‑video) ------------------ +mkdir -p "$G/functions/uvc.usb0" +# Mandatory control interface strings +mkdir -p "$G/functions/uvc.usb0/control/strings/0x409" +echo "Lesavka UVC" >"$G/functions/uvc.usb0/control/strings/0x409/label" +# Simple 720p MJPEG + 720p H.264 alt‑setting +printf '\x50\x00\x00\x00' >"$G/functions/uvc.usb0/control/header/h_video" + # ----------------------- configuration ----------------------------- mkdir -p "$G/configs/c.1/strings/0x409" echo 500 > "$G/configs/c.1/MaxPower" @@ -120,7 +128,8 @@ echo "Config 1: HID + UAC2" >"$G/configs/c.1/strings/0x409/configuration" ln -s $G/functions/hid.usb0 $G/configs/c.1/ ln -s $G/functions/hid.usb1 $G/configs/c.1/ -ln -s "$U" "$G/configs/c.1/" +ln -s $U $G/configs/c.1/ +ln -s $G/functions/uvc.usb0 $G/configs/c.1/ # mkdir -p $G/functions/hid.usb0/os_desc # mkdir -p $G/functions/hid.usb1/os_desc diff --git a/server/Cargo.toml b/server/Cargo.toml index eba7ff2..7eb3c4f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_server" -version = "0.5.0" +version = "0.6.0" edition = "2024" [dependencies] diff --git a/server/src/main.rs b/server/src/main.rs index 1104715..3b56188 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -133,6 +133,7 @@ impl Relay for Handler { type CaptureVideoStream = Pin> + Send>>; type CaptureAudioStream = Pin> + Send>>; type StreamMicrophoneStream = ReceiverStream>; + type StreamCameraStream = ReceiverStream>; async fn stream_keyboard( &self, @@ -209,6 +210,16 @@ impl Relay for Handler { Ok(Response::new(ReceiverStream::new(rx))) } + async fn stream_camera( + &self, + _req: Request>, + ) -> Result, Status> { + // Simply consume the inbound stream and discard; echo back a single Empty. + let (tx, rx) = tokio::sync::mpsc::channel(1); + tx.send(Ok(Empty {})).await.ok(); + Ok(Response::new(ReceiverStream::new(rx))) + } + async fn capture_video( &self, req: Request, diff --git a/server/src/video.rs b/server/src/video.rs index 1604376..78884a5 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -214,3 +214,48 @@ pub async fn eye_ball( } Ok(VideoStream { _pipeline: pipeline, inner: ReceiverStream::new(rx) }) } + +pub struct WebcamSink { + appsrc: gst_app::AppSrc, + _pipe: gst::Pipeline, +} + +impl WebcamSink { + pub fn new(uvc_dev: &str) -> anyhow::Result { + gst::init()?; + + let pipeline = gst::Pipeline::new(); + let src = gst::ElementFactory::make("appsrc") + .build()? + .downcast::() + .expect("appsrc"); + src.set_is_live(true); + src.set_format(gst::Format::Time); + + let h264parse = gst::ElementFactory::make("h264parse").build()?; + let decoder = gst::ElementFactory::make("v4l2h264dec").build()?; + let convert = gst::ElementFactory::make("videoconvert").build()?; + let sink = gst::ElementFactory::make("v4l2sink") + .property("device", &uvc_dev) + .property("sync", &false) + .build()?; + + // Up‑cast to &gst::Element for the collection macros + pipeline.add_many(&[ + src.upcast_ref(), &h264parse, &decoder, &convert, &sink + ])?; + gst::Element::link_many(&[ + src.upcast_ref(), &h264parse, &decoder, &convert, &sink + ])?; + pipeline.set_state(gst::State::Playing)?; + + Ok(Self { appsrc: src, _pipe: pipeline }) + } + + pub fn push(&self, pkt: VideoPacket) { + let mut buf = gst::Buffer::from_slice(pkt.data); + buf.get_mut().unwrap() + .set_pts(Some(gst::ClockTime::from_useconds(pkt.pts))); + let _ = self.appsrc.push_buffer(buf); + } +}