camera core add

This commit is contained in:
Brad Stein 2025-07-03 08:19:59 -05:00
parent f96bb0f8c2
commit 1c095a95d1
9 changed files with 150 additions and 16 deletions

View File

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

View File

@ -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<Self> {
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 downcast with `?`
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?
.downcast::<gst::Pipeline>()
.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::<gst_app::AppSink>()
.expect("appsink downcast");
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<VideoPacket> {
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() })
}
/// Fuzzymatch devices under `/dev/v4l/by-id`
fn find_device(substr: &str) -> Option<String> {
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 webcam 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::<gst_app::AppSink>()
.expect("appsink downcast");
Self { pipeline, sink }
}
}

View File

@ -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());
// Autoselect 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(())
}

View File

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

View File

@ -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);
}

View File

@ -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 (usbvideo) ------------------
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 altsetting
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

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_server"
version = "0.5.0"
version = "0.6.0"
edition = "2024"
[dependencies]

View File

@ -133,6 +133,7 @@ impl Relay for Handler {
type CaptureVideoStream = Pin<Box<dyn Stream<Item=Result<VideoPacket,Status>> + Send>>;
type CaptureAudioStream = Pin<Box<dyn Stream<Item=Result<AudioPacket,Status>> + Send>>;
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
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<tonic::Streaming<VideoPacket>>,
) -> Result<Response<Self::StreamCameraStream>, 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<MonitorRequest>,

View File

@ -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<Self> {
gst::init()?;
let pipeline = gst::Pipeline::new();
let src = gst::ElementFactory::make("appsrc")
.build()?
.downcast::<gst_app::AppSrc>()
.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()?;
// Upcast 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);
}
}