Skip to content

Commit cadef4a

Browse files
committed
perf(desktop-gpui): convert the camera preview at bubble size, newest frame only, while shown
1 parent aa3c30f commit cadef4a

5 files changed

Lines changed: 381 additions & 48 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const STEPS: [f32; 10] = [
2+
270., 360., 480., 540., 720., 960., 1080., 1440., 1920., 2160.,
3+
];
4+
5+
pub fn quantized_target((width, height): (f32, f32)) -> Option<(usize, usize)> {
6+
let step = |extent: f32| {
7+
STEPS
8+
.iter()
9+
.find(|step| extent <= **step)
10+
.map(|step| *step as usize)
11+
};
12+
Some((step(width)?, step(height)?))
13+
}
14+
15+
pub fn cover_dimensions(source: (usize, usize), target: (usize, usize)) -> (usize, usize) {
16+
let (width, height) = source;
17+
if width == 0 || height == 0 {
18+
return source;
19+
}
20+
let scale = (target.0 as f64 / width as f64).max(target.1 as f64 / height as f64);
21+
if scale >= 1.0 {
22+
return source;
23+
}
24+
(
25+
((width as f64 * scale).round() as usize).clamp(1, width),
26+
((height as f64 * scale).round() as usize).clamp(1, height),
27+
)
28+
}
29+
30+
#[cfg(test)]
31+
mod tests {
32+
use super::*;
33+
34+
#[test]
35+
fn targets_round_up_to_a_fixed_step() {
36+
assert_eq!(quantized_target((460., 460.)), Some((480, 480)));
37+
assert_eq!(quantized_target((480., 480.)), Some((480, 480)));
38+
assert_eq!(quantized_target((481., 300.)), Some((540, 360)));
39+
assert_eq!(quantized_target((0., -56.)), Some((270, 270)));
40+
assert_eq!(quantized_target((2133.3, 1200.)), Some((2160, 1440)));
41+
}
42+
43+
#[test]
44+
fn targets_beyond_the_largest_step_or_not_finite_are_uncapped() {
45+
assert_eq!(quantized_target((2161., 400.)), None);
46+
assert_eq!(quantized_target((400., f32::NAN)), None);
47+
assert_eq!(quantized_target((f32::INFINITY, 400.)), None);
48+
}
49+
50+
#[test]
51+
fn nearby_sizes_share_a_target() {
52+
let targets = (461..=480)
53+
.map(|extent| quantized_target((extent as f32, extent as f32)))
54+
.collect::<Vec<_>>();
55+
assert!(targets.iter().all(|target| *target == Some((480, 480))));
56+
}
57+
58+
#[test]
59+
fn cover_dimensions_fill_the_target_and_keep_the_source_aspect() {
60+
assert_eq!(cover_dimensions((1920, 1080), (480, 480)), (853, 480));
61+
assert_eq!(cover_dimensions((3840, 2160), (480, 480)), (853, 480));
62+
assert_eq!(cover_dimensions((1920, 1080), (960, 540)), (960, 540));
63+
assert_eq!(cover_dimensions((1440, 1080), (960, 540)), (960, 720));
64+
assert_eq!(cover_dimensions((1080, 1920), (480, 480)), (480, 853));
65+
for source in [
66+
(1280, 720),
67+
(1920, 1080),
68+
(3840, 2160),
69+
(1440, 1080),
70+
(1080, 1920),
71+
] {
72+
for target in [(270, 270), (480, 480), (960, 540), (1080, 1080)] {
73+
let (width, height) = cover_dimensions(source, target);
74+
assert!(width >= target.0.min(source.0));
75+
assert!(height >= target.1.min(source.1));
76+
let aspect = source.0 as f64 / source.1 as f64;
77+
assert!((width as f64 / height as f64 - aspect).abs() / aspect < 0.005);
78+
}
79+
}
80+
}
81+
82+
#[test]
83+
fn cover_dimensions_never_upscale() {
84+
assert_eq!(cover_dimensions((1280, 720), (1440, 1440)), (1280, 720));
85+
assert_eq!(cover_dimensions((1920, 1080), (1920, 1080)), (1920, 1080));
86+
assert_eq!(cover_dimensions((1920, 1080), (2160, 1080)), (1920, 1080));
87+
assert_eq!(cover_dimensions((0, 1080), (480, 480)), (0, 1080));
88+
}
89+
}

‎apps/desktop-gpui/kernels/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod camera_preview;
12
pub mod codec;
23
pub mod frame;
34
pub mod mask_cache;

‎apps/desktop-gpui/src/camera_window.rs‎

Lines changed: 139 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,21 @@ mod frame {
384384
pub struct Converted {
385385
pub buffer: arc::R<cv::PixelBuf>,
386386
pub dims: (usize, usize),
387+
/// What the window lays itself out for: the camera's own size when
388+
/// the frame was only shrunk to the bubble, so the rounding of a
389+
/// cover target never nudges the window's aspect.
390+
pub layout_dims: (usize, usize),
387391
pub generation: u64,
388392
pub mirrored: bool,
389393
}
390394

395+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
396+
pub enum Target {
397+
Source,
398+
Fit((usize, usize)),
399+
Cover((usize, usize)),
400+
}
401+
391402
// Handed from the conversion thread to the main thread once written; the
392403
// buffer is only read after that, and CF refcounts are atomic.
393404
unsafe impl Send for Converted {}
@@ -518,21 +529,32 @@ mod frame {
518529
}
519530

520531
/// The frame as a BGRA IOSurface-backed pixel buffer, downscaled to
521-
/// fit `max_dims` when given and horizontally flipped when
522-
/// `mirrored`. Recreates the rings when the camera's frame size, the
523-
/// target size or the mirror toggle changes, or when `force_rebuild`
524-
/// asks for a fresh rotation session (keeping the generation count
525-
/// rising so no earlier ring can alias a new one).
532+
/// fit inside a maximum size or to just cover the bubble per
533+
/// `target`, and horizontally flipped when `mirrored`. Recreates the
534+
/// rings when the camera's frame size, the target size or the mirror
535+
/// toggle changes, or when `force_rebuild` asks for a fresh rotation
536+
/// session (keeping the generation count rising so no earlier ring
537+
/// can alias a new one).
526538
pub fn convert(
527539
this: &mut Option<Self>,
528540
frame: &cap_recording::NativeCameraFrame,
529-
max_dims: Option<(usize, usize)>,
541+
target: Target,
530542
mirrored: bool,
531543
force_rebuild: bool,
532544
) -> Option<Converted> {
533545
let src = frame.sample_buf.image_buf()?;
534546
let src_dims = (src.width(), src.height());
535-
let dst_dims = max_dims.map_or(src_dims, |max| fit_within(src_dims, max));
547+
let dst_dims = match target {
548+
Target::Source => src_dims,
549+
Target::Fit(max) => fit_within(src_dims, max),
550+
Target::Cover(visible) => {
551+
cap_gpui_kernels::camera_preview::cover_dimensions(src_dims, visible)
552+
}
553+
};
554+
let layout_dims = match target {
555+
Target::Cover(_) => src_dims,
556+
Target::Source | Target::Fit(_) => dst_dims,
557+
};
536558
if force_rebuild
537559
|| this.as_ref().is_none_or(|converter| {
538560
converter.src_dims != src_dims
@@ -566,6 +588,7 @@ mod frame {
566588
Some(Converted {
567589
buffer: out,
568590
dims: converter.dst_dims,
591+
layout_dims,
569592
generation: converter.generation,
570593
mirrored: converter.flip_session.is_some(),
571594
})
@@ -1460,16 +1483,32 @@ struct ConvertJob {
14601483
struct ConvertSettings {
14611484
epoch: u64,
14621485
blur_mode: Option<cap_camera_effects::BlurMode>,
1486+
preview_target: Option<(usize, usize)>,
14631487
mirrored: bool,
14641488
rebuilds: u64,
14651489
}
14661490

1491+
#[cfg(target_os = "macos")]
1492+
impl ConvertSettings {
1493+
fn target(&self) -> frame::Target {
1494+
match (self.blur_mode, self.preview_target) {
1495+
(Some(_), _) => frame::Target::Fit(camera_blur::BLUR_MAX_DIMS),
1496+
(None, Some(visible)) => frame::Target::Cover(visible),
1497+
(None, None) => frame::Target::Source,
1498+
}
1499+
}
1500+
}
1501+
14671502
/// The `camera-preview-convert` thread. The VideoToolbox transfer (and the
14681503
/// mirror pass) blocks until the hardware finishes, ~4-5 ms per frame, which
14691504
/// stalled every main-thread frame while it ran on the main thread.
14701505
#[cfg(target_os = "macos")]
14711506
struct ConvertBridge {
14721507
tx: flume::Sender<ConvertJob>,
1508+
/// The thread's own queue, so a newer frame can evict one still waiting
1509+
/// there. `None` when the thread could not be spawned: holding a receiver
1510+
/// would keep the channel connected and hide the inline fallback.
1511+
queued: Option<flume::Receiver<ConvertJob>>,
14731512
_pump: gpui::Task<()>,
14741513
}
14751514

@@ -1488,11 +1527,10 @@ fn run_preview_conversion(
14881527
let settings = job.settings;
14891528
let force_rebuild = rebuilds.is_some_and(|rebuilds| rebuilds != settings.rebuilds);
14901529
rebuilds = Some(settings.rebuilds);
1491-
let max_dims = settings.blur_mode.map(|_| camera_blur::BLUR_MAX_DIMS);
14921530
let result = frame::FrameConverter::convert(
14931531
&mut converter,
14941532
&job.frame,
1495-
max_dims,
1533+
settings.target(),
14961534
settings.mirrored,
14971535
force_rebuild,
14981536
);
@@ -1852,34 +1890,18 @@ impl CameraWindow {
18521890
if failures_before != self.effect_failures {
18531891
self.sync_effect_feedback(cx);
18541892
}
1855-
let job = ConvertJob {
1856-
frame,
1857-
settings: self.convert_settings(epoch),
1858-
};
1859-
match self.ensure_convert_bridge(window, cx).tx.try_send(job) {
1860-
// A full queue means the thread is mid-conversion; it picks up
1861-
// the next frame.
1862-
Ok(()) | Err(flume::TrySendError::Full(_)) => {}
1863-
Err(flume::TrySendError::Disconnected(job)) => {
1864-
let settings = job.settings;
1865-
let max_dims = settings.blur_mode.map(|_| camera_blur::BLUR_MAX_DIMS);
1866-
let force_rebuild = self
1867-
.fallback_rebuilds
1868-
.is_some_and(|rebuilds| rebuilds != settings.rebuilds);
1869-
self.fallback_rebuilds = Some(settings.rebuilds);
1870-
let converted = frame::FrameConverter::convert(
1871-
&mut self.fallback_converter,
1872-
&job.frame,
1873-
max_dims,
1874-
settings.mirrored,
1875-
force_rebuild,
1876-
);
1877-
self.converted_arrived(converted, settings, window, cx);
1878-
}
1893+
// The same occlusion test gpui's display link runs on: a window
1894+
// failing it presents nothing, so converting (or blurring) for it
1895+
// is wasted. The next frame after it is shown converts again.
1896+
if self.inline || platform::window_is_presenting(window) {
1897+
self.queue_conversion(frame, epoch, window, cx);
18791898
}
18801899
}
18811900
#[cfg(not(target_os = "macos"))]
18821901
{
1902+
Feeds::global(cx)
1903+
.read(cx)
1904+
.set_camera_preview_target(self.preview_target(window));
18831905
let first_frame = self.frame_dims.is_none();
18841906
let dims_changed = self.frame_dims != Some(frame.dims);
18851907
self.frame_dims = Some(frame.dims);
@@ -1945,10 +1967,57 @@ impl CameraWindow {
19451967
}
19461968

19471969
#[cfg(target_os = "macos")]
1948-
fn convert_settings(&self, epoch: u64) -> ConvertSettings {
1970+
fn queue_conversion(
1971+
&mut self,
1972+
frame: cap_recording::NativeCameraFrame,
1973+
epoch: u64,
1974+
window: &mut Window,
1975+
cx: &mut Context<Self>,
1976+
) {
1977+
let job = ConvertJob {
1978+
frame,
1979+
settings: self.convert_settings(epoch, window),
1980+
};
1981+
let bridge = self.ensure_convert_bridge(window, cx);
1982+
let sent = match bridge.tx.try_send(job) {
1983+
Err(flume::TrySendError::Full(job)) => {
1984+
if let Some(queued) = &bridge.queued {
1985+
let _ = queued.try_recv();
1986+
}
1987+
bridge.tx.try_send(job)
1988+
}
1989+
sent => sent,
1990+
};
1991+
match sent {
1992+
Ok(()) | Err(flume::TrySendError::Full(_)) => {}
1993+
Err(flume::TrySendError::Disconnected(job)) => {
1994+
let settings = job.settings;
1995+
let force_rebuild = self
1996+
.fallback_rebuilds
1997+
.is_some_and(|rebuilds| rebuilds != settings.rebuilds);
1998+
self.fallback_rebuilds = Some(settings.rebuilds);
1999+
let converted = frame::FrameConverter::convert(
2000+
&mut self.fallback_converter,
2001+
&job.frame,
2002+
settings.target(),
2003+
settings.mirrored,
2004+
force_rebuild,
2005+
);
2006+
self.converted_arrived(converted, settings, window, cx);
2007+
}
2008+
}
2009+
}
2010+
2011+
#[cfg(target_os = "macos")]
2012+
fn convert_settings(&self, epoch: u64, window: &Window) -> ConvertSettings {
2013+
let blur_mode = self.active_blur_mode();
19492014
ConvertSettings {
19502015
epoch,
1951-
blur_mode: self.active_blur_mode(),
2016+
blur_mode,
2017+
preview_target: blur_mode
2018+
.is_none()
2019+
.then(|| self.preview_target(window))
2020+
.flatten(),
19522021
mirrored: self.state.mirrored,
19532022
rebuilds: self.convert_rebuilds,
19542023
}
@@ -1965,7 +2034,7 @@ impl CameraWindow {
19652034
let Some(epoch) = Feeds::global(cx).read(cx).camera_preview_epoch() else {
19662035
return;
19672036
};
1968-
if settings != self.convert_settings(epoch) {
2037+
if settings != self.convert_settings(epoch, window) {
19692038
return;
19702039
}
19712040
let blur_mode = settings.blur_mode;
@@ -1977,8 +2046,8 @@ impl CameraWindow {
19772046
self.effect_failures.mirror = self.state.mirrored && !converted.mirrored;
19782047
let first_frame = self.frame_dims.is_none();
19792048
let dims = converted.dims;
1980-
let dims_changed = self.frame_dims != Some(dims);
1981-
self.frame_dims = Some(dims);
2049+
let dims_changed = self.frame_dims != Some(converted.layout_dims);
2050+
self.frame_dims = Some(converted.layout_dims);
19822051

19832052
let mut paint_raw = blur_mode.is_none();
19842053
if let Some(mode) = blur_mode {
@@ -2036,16 +2105,21 @@ impl CameraWindow {
20362105
fn ensure_convert_bridge(&mut self, window: &Window, cx: &mut Context<Self>) -> &ConvertBridge {
20372106
if self.convert.is_none() {
20382107
let (job_tx, job_rx) = flume::bounded::<ConvertJob>(1);
2108+
let queued = job_rx.clone();
20392109
let (out_tx, out_rx) = flume::bounded(1);
2040-
if let Err(error) = std::thread::Builder::new()
2110+
let queued = match std::thread::Builder::new()
20412111
.name("camera-preview-convert".into())
20422112
.stack_size(crate::MEDIA_THREAD_STACK_SIZE)
20432113
.spawn(move || run_preview_conversion(job_rx, out_tx))
20442114
{
2045-
// job_rx is gone; the caller's try_send sees Disconnected and
2046-
// converts inline.
2047-
tracing::warn!("camera preview conversion thread failed to spawn: {error}");
2048-
}
2115+
Ok(_) => Some(queued),
2116+
Err(error) => {
2117+
// job_rx is gone; the caller's try_send sees Disconnected
2118+
// and converts inline.
2119+
tracing::warn!("camera preview conversion thread failed to spawn: {error}");
2120+
None
2121+
}
2122+
};
20492123
let pump = cx.spawn_in(window, async move |this, cx| {
20502124
while let Ok((converted, settings)) = out_rx.recv_async().await {
20512125
if this
@@ -2060,6 +2134,7 @@ impl CameraWindow {
20602134
});
20612135
self.convert = Some(ConvertBridge {
20622136
tx: job_tx,
2137+
queued,
20632138
_pump: pump,
20642139
});
20652140
}
@@ -2156,6 +2231,27 @@ impl CameraWindow {
21562231
first
21572232
}
21582233

2234+
/// The bubble in physical pixels, rounded up to a fixed step so resizing
2235+
/// rebuilds the conversion rings only at step boundaries: the size camera
2236+
/// frames are shrunk to cover. The larger of the requested and the
2237+
/// current window size, so a frame converted mid-resize still covers
2238+
/// either. `None` (full size) while the target overlays draw the preview
2239+
/// inline, possibly on displays of another scale.
2240+
fn preview_target(&self, window: &Window) -> Option<(usize, usize)> {
2241+
if self.inline {
2242+
return None;
2243+
}
2244+
let (width, height) = self
2245+
.picker_size
2246+
.unwrap_or_else(|| window_size(&self.state, self.frame_aspect()));
2247+
let viewport = window.viewport_size();
2248+
let scale = window.scale_factor().max(1.);
2249+
cap_gpui_kernels::camera_preview::quantized_target((
2250+
width.max(f32::from(viewport.width)) * scale,
2251+
(height.max(f32::from(viewport.height)) - CAMERA_TOOLBAR_HEIGHT) * scale,
2252+
))
2253+
}
2254+
21592255
fn frame_aspect(&self) -> Option<f32> {
21602256
self.frame_dims
21612257
.map(|(width, height)| width as f32 / height.max(1) as f32)

0 commit comments

Comments
 (0)