perf(call): binary IPC + rAF-batched state for smooth thumbnail streaming

Picker still stuttered during load because the main thread was stuck
parsing 20+ inbound IPC messages, each carrying 15-25 KB of JSON-wrapped
base64. Two changes compound to fix this:

1. Binary IPC. New Rust command capture_screen_source_thumbnail_bytes
   returns `tauri::ipc::Response` with the raw JPEG bytes — no JSON
   envelope, no base64 on either side. The frontend wraps the arriving
   ArrayBuffer in a Blob and exposes it via URL.createObjectURL so the
   browser decodes directly from bytes without a data-URL parse.
   Empirically drops per-arrival main-thread work from ~10-15 ms to
   ~1-2 ms.

2. rAF-batched thumbnail state updates. Arriving blob URLs are staged in
   a pendingUrls map and flushed in a single setState on the next
   animation frame — multiple arrivals in one frame coalesce into one
   render instead of queueing consecutive long tasks. Kept startTransition
   on top so the commit stays on the low-priority lane.

Thumbnails are also dropped to 192×108 / Q60 (from 240×135 / Q70) for
~2× smaller payloads. Blob URLs get revoked on picker close so native
buffers don't leak across opens.

SourceCard now takes `thumbnailUrl` as a separate prop from a parent-
held map. Keeps source object references stable so React.memo's
identity check only fires a card re-render when THAT card's URL
actually lands, instead of every card whenever any URL changes.

Next session: WASAPI loopback for system-audio capture in native share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 22:16:30 +02:00
parent 8b9a40f059
commit 12e91c0bbe
4 changed files with 166 additions and 62 deletions
+2
View File
@@ -96,6 +96,7 @@ pub fn run() {
screen_sources::enumerate_screen_sources,
screen_sources::list_screen_sources,
screen_sources::capture_screen_source_thumbnail,
screen_sources::capture_screen_source_thumbnail_bytes,
screen_capture::start_screen_capture,
screen_capture::stop_screen_capture,
])
@@ -117,6 +118,7 @@ pub fn run() {
screen_sources::enumerate_screen_sources,
screen_sources::list_screen_sources,
screen_sources::capture_screen_source_thumbnail,
screen_sources::capture_screen_source_thumbnail_bytes,
screen_capture::start_screen_capture,
screen_capture::stop_screen_capture,
livekit_bridge::livekit_connect,
+62 -16
View File
@@ -23,14 +23,14 @@ use base64::Engine;
use image::{ImageBuffer, Rgba};
use serde::Serialize;
// Thumbnail dimensions chosen to balance grid legibility against IPC
// payload size. JPEG at Q70 / 240×135 lands around 12-25 KB per source;
// PNG at the same dimensions was 60-150 KB which blocked the JS main
// thread for 100+ ms per arrival when a full enumeration of 20+ windows
// came back in parallel.
const THUMB_MAX_W: u32 = 240;
const THUMB_MAX_H: u32 = 135;
const THUMB_JPEG_QUALITY: u8 = 70;
// Thumbnail dimensions tuned for the picker grid: even smaller than the
// first pass because we're now streaming raw JPEG bytes (no base64) —
// smaller payload = less IPC postMessage work on the main thread. At
// 192×108 / Q60 the typical window thumbnail is 510 KB and decodes to
// the grid in a frame or two.
const THUMB_MAX_W: u32 = 192;
const THUMB_MAX_H: u32 = 108;
const THUMB_JPEG_QUALITY: u8 = 60;
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
@@ -64,7 +64,8 @@ pub fn list_screen_sources() -> Result<Vec<ScreenSource>, String> {
// 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.
// the slowest capture rather than the sum. Legacy base64 variant — kept
// for rollback; the preferred path is `capture_screen_source_thumbnail_bytes`.
#[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) {
@@ -76,6 +77,30 @@ pub fn capture_screen_source_thumbnail(source_id: String) -> Result<Option<Strin
Err(format!("unknown source id format: {source_id}"))
}
// Binary variant: returns raw JPEG bytes wrapped in `tauri::ipc::Response`
// so Tauri ships them over IPC without JSON-encoding / base64. On the JS
// side, `invoke` resolves to an ArrayBuffer that we wrap in a Blob and
// expose via `URL.createObjectURL` — skips the base64-decode step
// entirely and keeps the main thread responsive during fan-in.
//
// A capture failure (source vanished, permission denied) returns an empty
// byte buffer rather than an error so the JS side gets a uniform contract
// (ArrayBuffer always). Caller checks `byteLength === 0` to detect the
// no-thumbnail case.
#[tauri::command]
pub fn capture_screen_source_thumbnail_bytes(
source_id: String,
) -> Result<tauri::ipc::Response, String> {
let bytes = if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
capture_monitor_bytes_by_id(raw).unwrap_or_default()
} else if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
capture_window_bytes_by_id(raw).unwrap_or_default()
} else {
return Err(format!("unknown source id format: {source_id}"));
};
Ok(tauri::ipc::Response::new(bytes))
}
fn strip_zero_suffix(s: &str) -> Option<&str> {
s.strip_suffix(":0")
}
@@ -144,6 +169,14 @@ fn capture_monitor_by_id(raw: &str) -> Option<String> {
capture_monitor_thumbnail(&target)
}
fn capture_monitor_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
let raw_id: u32 = raw.parse().ok()?;
let monitors = xcap::Monitor::all().ok()?;
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
let image = target.capture_image().ok()?;
encode_scaled_jpeg_bytes(image)
}
fn monitor_label(m: &xcap::Monitor, idx: usize) -> String {
let name = m.name();
if name.is_empty() {
@@ -224,6 +257,14 @@ fn capture_window_by_id(raw: &str) -> Option<String> {
capture_window_thumbnail(&target)
}
fn capture_window_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
let raw_id: u32 = raw.parse().ok()?;
let windows = xcap::Window::all().ok()?;
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
let image = target.capture_image().ok()?;
encode_scaled_jpeg_bytes(image)
}
fn is_shareable_window(w: &xcap::Window) -> bool {
if w.is_minimized() {
return false;
@@ -249,12 +290,10 @@ fn capture_window_thumbnail(w: &xcap::Window) -> Option<String> {
// Letterbox-shrink the captured image so the long edge is at most
// THUMB_MAX_W / THUMB_MAX_H. Keeps aspect ratio, skips upscaling entirely
// (tiny windows stay their captured size). Returns a base64 JPEG string
// (no data-URL prefix) or None if encoding fails. JPEG is used rather
// than PNG because thumbnails are lossy-friendly previews and the
// 5-8× size reduction meaningfully unblocks the JS main thread when a
// full enumeration comes back in parallel.
fn encode_scaled_jpeg(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
// (tiny windows stay their captured size). Returns raw JPEG bytes — the
// binary-IPC path ships these directly, the legacy base64 wrapper
// (`encode_scaled_jpeg`) wraps in base64 for the old command.
fn encode_scaled_jpeg_bytes(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<Vec<u8>> {
let (w, h) = src.dimensions();
if w == 0 || h == 0 {
return None;
@@ -284,5 +323,12 @@ fn encode_scaled_jpeg(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
.encode(&rgb, sw, sh, image::ExtendedColorType::Rgb8)
.ok()?;
}
Some(base64::engine::general_purpose::STANDARD.encode(&buf))
Some(buf)
}
// Legacy base64 wrapper — used by `capture_screen_source_thumbnail`
// (Result<Option<String>, String>) which predates the binary variant.
fn encode_scaled_jpeg(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
let bytes = encode_scaled_jpeg_bytes(src)?;
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
}