perf(call): JPEG thumbnails + memoized picker cards unfreeze the grid

The picker still felt frozen while thumbnails were streaming in because
each result was both (a) large — PNG @ 320×180 landed at 60-150 KB
base64 — and (b) triggering a high-priority React re-render of the whole
grid. Three fixes together restore interactivity:

- Thumbnails encoded as JPEG @ Q70 at 240×135 instead of PNG @ 320×180.
  Drops the typical payload from ~100 KB to ~20 KB, so IPC JSON-parsing
  on arrival is 5× faster.
- SourceCard wrapped in React.memo so only the card whose thumbnail just
  landed re-renders. Previously one new thumbnail caused all ~20 cards
  to re-evaluate their props.
- setSources updates run inside startTransition so scroll / click events
  stay on the high-priority lane while the grid backfills.

Also: when the user enables "Sound mit übertragen" AND has a source
picked, the picker now surfaces an inline amber note explaining that
the OS picker will appear for the audio capture path. Matches the
existing console info log but is visible pre-click so users don't
experience it as a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 21:58:05 +02:00
parent a5e930ac17
commit eac19823ea
3 changed files with 68 additions and 35 deletions
+31 -22
View File
@@ -23,8 +23,14 @@ use base64::Engine;
use image::{ImageBuffer, Rgba};
use serde::Serialize;
const THUMB_MAX_W: u32 = 320;
const THUMB_MAX_H: u32 = 180;
// 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;
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
@@ -149,7 +155,7 @@ fn monitor_label(m: &xcap::Monitor, idx: usize) -> String {
fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option<String> {
let image = m.capture_image().ok()?;
encode_scaled_png(image)
encode_scaled_jpeg(image)
}
// ---------------------------------------------------------------------------
@@ -234,7 +240,7 @@ fn is_shareable_window(w: &xcap::Window) -> bool {
fn capture_window_thumbnail(w: &xcap::Window) -> Option<String> {
let image = w.capture_image().ok()?;
encode_scaled_png(image)
encode_scaled_jpeg(image)
}
// ---------------------------------------------------------------------------
@@ -243,9 +249,12 @@ 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 PNG string or
// None if encoding fails.
fn encode_scaled_png(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
// (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> {
let (w, h) = src.dimensions();
if w == 0 || h == 0 {
return None;
@@ -260,20 +269,20 @@ fn encode_scaled_png(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
} else {
src
};
let mut buf: Vec<u8> = Vec::new();
let encoder = image::codecs::png::PngEncoder::new_with_quality(
&mut buf,
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::Adaptive,
);
use image::ImageEncoder;
encoder
.write_image(
scaled.as_raw(),
scaled.width(),
scaled.height(),
image::ExtendedColorType::Rgba8,
)
.ok()?;
// JPEG encoder doesn't accept RGBA — strip alpha into a packed RGB
// buffer first. Alpha carries no info for a visible thumbnail anyway.
let (sw, sh) = (scaled.width(), scaled.height());
let mut rgb: Vec<u8> = Vec::with_capacity((sw * sh * 3) as usize);
for p in scaled.pixels() {
rgb.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
}
let mut buf: Vec<u8> = Vec::with_capacity((sw * sh / 8) as usize);
{
use image::codecs::jpeg::JpegEncoder;
let mut encoder = JpegEncoder::new_with_quality(&mut buf, THUMB_JPEG_QUALITY);
encoder
.encode(&rgb, sw, sh, image::ExtendedColorType::Rgb8)
.ok()?;
}
Some(base64::engine::general_purpose::STANDARD.encode(&buf))
}