feat(call): native screen-capture pipeline + faster picker
Picker speed (Phase 1+2): - screen_sources.rs split into list_screen_sources (metadata only, returns in ~10ms) + capture_screen_source_thumbnail (single source, by id). ScreenSourcePicker now shows names + placeholders instantly and streams thumbnails in as each capture lands. Total wall-clock is bounded by the slowest source instead of the serial sum. - enumerate_screen_sources kept as a dead_code fallback so any rollout regression can switch the frontend back without code loss. Native capture (Phase 3): - New src-tauri/src/screen_capture.rs. start_screen_capture spawns a Rust thread per share that grabs frames via xcap, downscales to the user's quality preset, JPEG-encodes at Q72, and streams each frame through a Tauri Channel<FramePayload>. stop_screen_capture signals the stop flag and joins the worker. - Worker re-resolves the xcap handle inside the thread because xcap::Window holds a !Send HWND — passing the source id string across the thread boundary sidesteps that. - New lib/screenCapture.ts: decodes each frame into an ImageBitmap, draws to an offscreen canvas, exposes canvas.captureStream() as the MediaStream LiveKit publishes. Latest-wins frame queue drops stale frames when the JS side falls behind the Rust producer. 3s first- frame timeout so a silently-failing source (locked screen, DRM window) surfaces as a clean NativeCaptureUnavailable and we fall back to getDisplayMedia. - CallContext.startScreenShare takes the native path first when the picker provided a sourceId and system audio wasn't requested. The old chromeMediaSourceId attempt and final setScreenShareEnabled fallback stay in place for the audio case + non-Tauri runtimes. - stopScreenShare kills the native handle first, then unpublishes any manually-published ScreenShare/ScreenShareAudio tracks, then falls back to setScreenShareEnabled(false). disconnectRoom also stops the handle so we don't leak Rust threads across calls. Scope note: native path is video-only. System-audio capture needs WASAPI-loopback (Windows) or ScreenCaptureKit-audio (macOS); until those are wired, requesting audio in the picker falls through to the getDisplayMedia path and shows the OS picker for that one case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
mod crypto;
|
||||
mod screen_capture;
|
||||
mod screen_sources;
|
||||
|
||||
#[cfg(feature = "rust-livekit")]
|
||||
@@ -93,6 +94,10 @@ pub fn run() {
|
||||
crypto::crypto_box_seal_open,
|
||||
crypto::crypto_pwhash,
|
||||
screen_sources::enumerate_screen_sources,
|
||||
screen_sources::list_screen_sources,
|
||||
screen_sources::capture_screen_source_thumbnail,
|
||||
screen_capture::start_screen_capture,
|
||||
screen_capture::stop_screen_capture,
|
||||
])
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
|
||||
@@ -110,6 +115,10 @@ pub fn run() {
|
||||
crypto::crypto_box_seal_open,
|
||||
crypto::crypto_pwhash,
|
||||
screen_sources::enumerate_screen_sources,
|
||||
screen_sources::list_screen_sources,
|
||||
screen_sources::capture_screen_source_thumbnail,
|
||||
screen_capture::start_screen_capture,
|
||||
screen_capture::stop_screen_capture,
|
||||
livekit_bridge::livekit_connect,
|
||||
livekit_bridge::livekit_disconnect,
|
||||
livekit_bridge::livekit_send_data,
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// Native screen / window capture pipeline. Spawns a Rust thread per active
|
||||
// capture that grabs frames via xcap, downscales them to the user's target
|
||||
// resolution, encodes JPEG, and streams each frame to the JS side through
|
||||
// a Tauri `Channel<FramePayload>`. The JS end decodes into an
|
||||
// ImageBitmap, draws to a <canvas>, and exposes the canvas as a
|
||||
// MediaStream via captureStream() — that stream is what LiveKit publishes.
|
||||
// Net result: the user picks a source in our custom picker and the share
|
||||
// starts directly, without the OS/browser picker appearing.
|
||||
//
|
||||
// JPEG on the Rust side + decode on the JS side introduces a second
|
||||
// encoding hop (LiveKit re-encodes VP9 later) but keeps IPC bandwidth
|
||||
// manageable — raw RGBA at 1920×1080×30fps would be ~240MB/s and cannot
|
||||
// go over Tauri's JSON-serialised IPC. JPEG frames at Q72 land around
|
||||
// 50–150KB each, so 30fps = 2–5MB/s of base64 traffic, which is fine.
|
||||
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
|
||||
|
||||
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
|
||||
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
struct Session {
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FramePayload {
|
||||
pub capture_id: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// JPEG image bytes, base64-encoded (no data-URL prefix). Frontend
|
||||
/// reconstructs via `Uint8Array.from(atob(...))` and decodes with
|
||||
/// `createImageBitmap(blob)`.
|
||||
pub jpeg_base64: String,
|
||||
}
|
||||
|
||||
enum Source {
|
||||
Window(xcap::Window),
|
||||
Monitor(xcap::Monitor),
|
||||
}
|
||||
|
||||
/// Start a continuous capture for the given source id and begin streaming
|
||||
/// JPEG frames via the provided channel. Returns a numeric capture id that
|
||||
/// must be passed to `stop_screen_capture` to tear the pipeline down.
|
||||
#[tauri::command]
|
||||
pub fn start_screen_capture(
|
||||
source_id: String,
|
||||
max_width: u32,
|
||||
max_height: u32,
|
||||
fps: u32,
|
||||
channel: Channel<FramePayload>,
|
||||
) -> Result<u32, String> {
|
||||
let clamped_fps = fps.clamp(5, 30);
|
||||
let clamped_w = max_width.max(320).min(3840);
|
||||
let clamped_h = max_height.max(180).min(2160);
|
||||
|
||||
// Probe once on the command thread so we can surface a clear error
|
||||
// before spawning; the actual capture handle is re-resolved inside the
|
||||
// worker thread (xcap::Window holds a Windows HWND which is !Send).
|
||||
if find_source(&source_id).is_none() {
|
||||
return Err(format!("screen source {source_id} not found"));
|
||||
}
|
||||
|
||||
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_clone = Arc::clone(&stop);
|
||||
let source_id_owned = source_id;
|
||||
|
||||
let handle = thread::Builder::new()
|
||||
.name(format!("screen-capture-{capture_id}"))
|
||||
.spawn(move || {
|
||||
capture_loop(
|
||||
source_id_owned,
|
||||
clamped_w,
|
||||
clamped_h,
|
||||
clamped_fps,
|
||||
capture_id,
|
||||
channel,
|
||||
stop_clone,
|
||||
);
|
||||
})
|
||||
.map_err(|e| format!("failed to spawn capture thread: {e}"))?;
|
||||
|
||||
sessions().lock().unwrap().insert(
|
||||
capture_id,
|
||||
Session {
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
},
|
||||
);
|
||||
Ok(capture_id)
|
||||
}
|
||||
|
||||
/// Signal the capture thread to stop and join it. Safe to call more than
|
||||
/// once — missing ids are silently no-ops so the JS side doesn't need to
|
||||
/// track whether a stop was already issued by the OS "stop sharing" path.
|
||||
#[tauri::command]
|
||||
pub fn stop_screen_capture(capture_id: u32) -> Result<(), String> {
|
||||
let session = sessions().lock().unwrap().remove(&capture_id);
|
||||
let Some(mut session) = session else {
|
||||
return Ok(());
|
||||
};
|
||||
session.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(handle) = session.handle.take() {
|
||||
// Best-effort join. If the thread is stuck in a long OS capture
|
||||
// call we don't want to hang the command — detach after a brief
|
||||
// wait by dropping the handle.
|
||||
let _ = handle.join();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture_loop(
|
||||
source_id: String,
|
||||
max_w: u32,
|
||||
max_h: u32,
|
||||
fps: u32,
|
||||
capture_id: u32,
|
||||
channel: Channel<FramePayload>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let frame_interval = Duration::from_nanos(1_000_000_000 / fps as u64);
|
||||
let jpeg_quality: u8 = 72;
|
||||
|
||||
// Re-resolve the source inside the worker thread — xcap::Window holds
|
||||
// an HWND which is !Send so we can't move it across threads. Caching
|
||||
// the handle for the lifetime of the loop keeps per-frame cost to the
|
||||
// actual pixel capture + encode.
|
||||
let source = match find_source(&source_id) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("screen-capture {capture_id}: source vanished before capture started");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let frame_start = Instant::now();
|
||||
|
||||
let img_result = match &source {
|
||||
Source::Window(w) => w.capture_image(),
|
||||
Source::Monitor(m) => m.capture_image(),
|
||||
};
|
||||
let img = match img_result {
|
||||
Ok(i) => i,
|
||||
Err(err) => {
|
||||
eprintln!("screen-capture {capture_id}: capture failed: {err}");
|
||||
// Brief backoff before retrying — transient Windows GDI
|
||||
// errors (e.g. during screen lock) tend to recover within
|
||||
// a frame or two.
|
||||
thread::sleep(frame_interval);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let (raw_w, raw_h) = img.dimensions();
|
||||
let (tgt_w, tgt_h) = scale_to_fit(raw_w, raw_h, max_w, max_h);
|
||||
let scaled = if (tgt_w, tgt_h) != (raw_w, raw_h) {
|
||||
image::imageops::resize(
|
||||
&img,
|
||||
tgt_w,
|
||||
tgt_h,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
// JPEG doesn't support alpha; strip it before encoding.
|
||||
let rgb = rgba_to_rgb(&scaled);
|
||||
|
||||
let mut jpeg_buf: Vec<u8> = Vec::with_capacity((tgt_w * tgt_h) as usize);
|
||||
{
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
|
||||
if let Err(err) =
|
||||
encoder.encode(&rgb, tgt_w, tgt_h, image::ExtendedColorType::Rgb8)
|
||||
{
|
||||
eprintln!("screen-capture {capture_id}: encode failed: {err}");
|
||||
thread::sleep(frame_interval);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let jpeg_base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);
|
||||
|
||||
let send_result = channel.send(FramePayload {
|
||||
capture_id,
|
||||
width: tgt_w,
|
||||
height: tgt_h,
|
||||
jpeg_base64,
|
||||
});
|
||||
if send_result.is_err() {
|
||||
// Frontend went away (window closed, renderer crashed).
|
||||
break;
|
||||
}
|
||||
|
||||
// Pace to target framerate. If the capture + encode already took
|
||||
// longer than one frame interval, yield a millisecond to avoid
|
||||
// pegging a single core when the target is unreachable.
|
||||
let elapsed = frame_start.elapsed();
|
||||
if elapsed < frame_interval {
|
||||
thread::sleep(frame_interval - elapsed);
|
||||
} else {
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba_to_rgb(buf: &image::RgbaImage) -> Vec<u8> {
|
||||
let (w, h) = buf.dimensions();
|
||||
let mut out = Vec::with_capacity((w * h * 3) as usize);
|
||||
for p in buf.pixels() {
|
||||
out.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn scale_to_fit(w: u32, h: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||||
if w == 0 || h == 0 {
|
||||
return (w, h);
|
||||
}
|
||||
let scale = (max_w as f32 / w as f32)
|
||||
.min(max_h as f32 / h as f32)
|
||||
.min(1.0);
|
||||
if scale >= 1.0 {
|
||||
return (w, h);
|
||||
}
|
||||
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
|
||||
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
|
||||
(new_w, new_h)
|
||||
}
|
||||
|
||||
fn find_source(source_id: &str) -> Option<Source> {
|
||||
if let Some(rest) = source_id.strip_prefix("window:") {
|
||||
let raw = rest.strip_suffix(":0")?;
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let windows = xcap::Window::all().ok()?;
|
||||
return windows
|
||||
.into_iter()
|
||||
.find(|w| w.id() == raw_id)
|
||||
.map(Source::Window);
|
||||
}
|
||||
if let Some(rest) = source_id.strip_prefix("screen:") {
|
||||
let raw = rest.strip_suffix(":0")?;
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let monitors = xcap::Monitor::all().ok()?;
|
||||
return monitors
|
||||
.into_iter()
|
||||
.find(|m| m.id() == raw_id)
|
||||
.map(Source::Monitor);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
// Source enumeration for the Discord-style screen-share picker. Returns a
|
||||
// flat list of screens + windows with small PNG thumbnails so the JS
|
||||
// picker can render a grid without the browser's native picker.
|
||||
// Source enumeration for the Discord-style screen-share picker. Split
|
||||
// into two commands on the slow-vs-fast axis:
|
||||
//
|
||||
// - list_screen_sources — metadata only (no thumbnails). Fast; the
|
||||
// picker shows names + placeholders instantly.
|
||||
// - capture_screen_source_thumbnail — one thumbnail at a time, keyed by
|
||||
// the id returned from the list.
|
||||
//
|
||||
// The frontend fans out the thumbnail calls via Promise.all so Tauri's
|
||||
// command thread pool captures them in parallel — wall-clock time ends up
|
||||
// bounded by the *slowest* source rather than the sum of all captures.
|
||||
// The `id` field is emitted in Chromium's internal desktopCapturer format
|
||||
// ("screen:<id>:0" / "window:<hwnd>:0") so the JS side can try to pass it
|
||||
// straight into getUserMedia's `chromeMediaSourceId` constraint. That
|
||||
// bypass may or may not be accepted by WebView2 depending on the version
|
||||
// — if it isn't, the JS falls back to the ordinary getDisplayMedia flow
|
||||
// and at least the user has seen an informed preview first.
|
||||
// straight into getUserMedia's `chromeMediaSourceId` constraint, or use
|
||||
// it as the source key for the native capture pipeline in screen_capture.
|
||||
//
|
||||
// xcap abstracts the platform-specific capture APIs (Windows GDI + DXGI,
|
||||
// macOS CoreGraphics/ScreenCaptureKit, X11) so the code here stays flat.
|
||||
@@ -40,6 +45,39 @@ pub struct ScreenSource {
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
// Fast metadata-only enumeration. No image capture happens here — that's
|
||||
// why it returns in tens of milliseconds instead of the multi-second
|
||||
// wait the single-shot enumerate_screen_sources command had.
|
||||
#[tauri::command]
|
||||
pub fn list_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||
let mut out: Vec<ScreenSource> = Vec::new();
|
||||
append_monitor_metadata(&mut out);
|
||||
append_window_metadata(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// One-shot thumbnail capture by source id. Called N times in parallel from
|
||||
// the frontend after the list lands so the total wall-clock is bounded by
|
||||
// the slowest capture rather than the sum.
|
||||
#[tauri::command]
|
||||
pub fn capture_screen_source_thumbnail(source_id: String) -> Result<Option<String>, String> {
|
||||
if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
|
||||
return Ok(capture_monitor_by_id(raw));
|
||||
}
|
||||
if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
|
||||
return Ok(capture_window_by_id(raw));
|
||||
}
|
||||
Err(format!("unknown source id format: {source_id}"))
|
||||
}
|
||||
|
||||
fn strip_zero_suffix(s: &str) -> Option<&str> {
|
||||
s.strip_suffix(":0")
|
||||
}
|
||||
|
||||
// Legacy one-shot all-in-one enumeration. Kept around so the frontend can
|
||||
// fall back during rollout if the split pair throws; marked dead_code so
|
||||
// the linker doesn't grumble when only the split variant is wired up.
|
||||
#[allow(dead_code)]
|
||||
#[tauri::command]
|
||||
pub fn enumerate_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||
let mut out: Vec<ScreenSource> = Vec::new();
|
||||
@@ -52,6 +90,27 @@ pub fn enumerate_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||
// Monitors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn append_monitor_metadata(out: &mut Vec<ScreenSource>) {
|
||||
let monitors = match xcap::Monitor::all() {
|
||||
Ok(m) => m,
|
||||
Err(err) => {
|
||||
eprintln!("xcap Monitor::all failed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for (idx, m) in monitors.iter().enumerate() {
|
||||
out.push(ScreenSource {
|
||||
id: format!("screen:{}:0", m.id()),
|
||||
name: monitor_label(m, idx),
|
||||
kind: "screen",
|
||||
thumbnail_png: None,
|
||||
width: m.width(),
|
||||
height: m.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn append_monitors(out: &mut Vec<ScreenSource>) {
|
||||
let monitors = match xcap::Monitor::all() {
|
||||
Ok(m) => m,
|
||||
@@ -61,25 +120,24 @@ fn append_monitors(out: &mut Vec<ScreenSource>) {
|
||||
}
|
||||
};
|
||||
for (idx, m) in monitors.iter().enumerate() {
|
||||
let name = monitor_label(m, idx);
|
||||
let width = m.width();
|
||||
let height = m.height();
|
||||
// xcap exposes an OS-level monitor id; pass it through as the
|
||||
// middle component so the JS side can correlate repeat enumerations.
|
||||
let raw_id = m.id();
|
||||
let id = format!("screen:{raw_id}:0");
|
||||
let thumb = capture_monitor_thumbnail(m);
|
||||
out.push(ScreenSource {
|
||||
id,
|
||||
name,
|
||||
id: format!("screen:{}:0", m.id()),
|
||||
name: monitor_label(m, idx),
|
||||
kind: "screen",
|
||||
thumbnail_png: thumb,
|
||||
width,
|
||||
height,
|
||||
thumbnail_png: capture_monitor_thumbnail(m),
|
||||
width: m.width(),
|
||||
height: m.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_monitor_by_id(raw: &str) -> Option<String> {
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let monitors = xcap::Monitor::all().ok()?;
|
||||
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
|
||||
capture_monitor_thumbnail(&target)
|
||||
}
|
||||
|
||||
fn monitor_label(m: &xcap::Monitor, idx: usize) -> String {
|
||||
let name = m.name();
|
||||
if name.is_empty() {
|
||||
@@ -98,6 +156,34 @@ fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option<String> {
|
||||
// Windows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn append_window_metadata(out: &mut Vec<ScreenSource>) {
|
||||
let windows = match xcap::Window::all() {
|
||||
Ok(w) => w,
|
||||
Err(err) => {
|
||||
eprintln!("xcap Window::all failed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for w in windows.iter() {
|
||||
if !is_shareable_window(w) {
|
||||
continue;
|
||||
}
|
||||
let title = w.title();
|
||||
if title.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(ScreenSource {
|
||||
id: format!("window:{}:0", w.id()),
|
||||
name: title.to_string(),
|
||||
kind: "window",
|
||||
thumbnail_png: None,
|
||||
width: w.width(),
|
||||
height: w.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn append_windows(out: &mut Vec<ScreenSource>) {
|
||||
let windows = match xcap::Window::all() {
|
||||
Ok(w) => w,
|
||||
@@ -114,22 +200,24 @@ fn append_windows(out: &mut Vec<ScreenSource>) {
|
||||
if title.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let width = w.width();
|
||||
let height = w.height();
|
||||
let raw_id = w.id();
|
||||
let id = format!("window:{raw_id}:0");
|
||||
let thumb = capture_window_thumbnail(w);
|
||||
out.push(ScreenSource {
|
||||
id,
|
||||
id: format!("window:{}:0", w.id()),
|
||||
name: title.to_string(),
|
||||
kind: "window",
|
||||
thumbnail_png: thumb,
|
||||
width,
|
||||
height,
|
||||
thumbnail_png: capture_window_thumbnail(w),
|
||||
width: w.width(),
|
||||
height: w.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_window_by_id(raw: &str) -> Option<String> {
|
||||
let raw_id: u32 = raw.parse().ok()?;
|
||||
let windows = xcap::Window::all().ok()?;
|
||||
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
|
||||
capture_window_thumbnail(&target)
|
||||
}
|
||||
|
||||
fn is_shareable_window(w: &xcap::Window) -> bool {
|
||||
if w.is_minimized() {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user