Skip to content

Commit 7b2e0cd

Browse files
committed
fix: bound retained camera frames to their age and input
1 parent 6e51d6c commit 7b2e0cd

4 files changed

Lines changed: 146 additions & 13 deletions

File tree

apps/desktop-gpui/src/app_windows.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4507,6 +4507,12 @@ pub fn deliver_camera_frame(
45074507
#[cfg(not(target_os = "macos"))] frame: crate::camera_window::CameraPreviewFrame,
45084508
cx: &mut App,
45094509
) -> bool {
4510+
if !crate::feeds::Feeds::global(cx)
4511+
.read(cx)
4512+
.accepts_camera_preview(frame.timestamp)
4513+
{
4514+
return false;
4515+
}
45104516
let Some(handle) = cx.global::<AppWindows>().camera else {
45114517
return false;
45124518
};

apps/desktop-gpui/src/camera_window.rs

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,7 @@ struct CameraPreviewView {
923923
frame_dims: Option<(usize, usize)>,
924924
retained: Option<Arc<gpui::RenderImage>>,
925925
retained_dims: Option<(usize, usize)>,
926+
retained_captured_at: Option<Instant>,
926927
reveal_started: Option<Instant>,
927928
retained_invalidated: bool,
928929
selection_revision: u64,
@@ -963,6 +964,7 @@ impl CameraPreviewView {
963964
&& retained.captured_at.elapsed() < Duration::from_secs(60)
964965
});
965966
let retained_dims = retained.as_ref().map(|retained| retained.frame_dims);
967+
let retained_captured_at = retained.as_ref().map(|retained| retained.captured_at);
966968
if let Some(retained) = &retained {
967969
let expiry = cx
968970
.background_executor()
@@ -1005,6 +1007,7 @@ impl CameraPreviewView {
10051007
frame_dims: None,
10061008
retained,
10071009
retained_dims,
1010+
retained_captured_at,
10081011
reveal_started: None,
10091012
retained_invalidated: false,
10101013
selection_revision: 0,
@@ -1061,6 +1064,21 @@ impl CameraPreviewView {
10611064

10621065
impl Render for CameraPreviewView {
10631066
fn render(&mut self, window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1067+
if self
1068+
.frame_revision
1069+
.is_some_and(|revision| revision != self.selection_revision)
1070+
{
1071+
#[cfg(target_os = "macos")]
1072+
{
1073+
self.latest_frame = None;
1074+
}
1075+
#[cfg(not(target_os = "macos"))]
1076+
if let Some(image) = self.latest_frame.take() {
1077+
let _ = window.drop_image(image);
1078+
}
1079+
self.frame_dims = None;
1080+
self.frame_revision = None;
1081+
}
10641082
if (self.retained_invalidated
10651083
|| self
10661084
.reveal_started
@@ -1129,6 +1147,7 @@ impl Render for CameraPreviewView {
11291147
let overlay = div().absolute().inset_0().child(
11301148
gpui::img(image)
11311149
.size_full()
1150+
.rounded(px(radius))
11321151
.object_fit(gpui::ObjectFit::Cover),
11331152
);
11341153
container = if self.reveal_started.is_some() {
@@ -1211,6 +1230,7 @@ impl Render for CameraPreviewView {
12111230

12121231
#[cfg(not(target_os = "macos"))]
12131232
pub struct CameraPreviewFrame {
1233+
pub timestamp: cap_timestamp::Timestamp,
12141234
pub image: Arc<gpui::RenderImage>,
12151235
pub dims: (usize, usize),
12161236
}
@@ -1249,6 +1269,7 @@ impl Render for CameraToolbarView {
12491269
/// `release_blur_resources` behaviour (`camera.rs:1477-1484`).
12501270
#[cfg(target_os = "macos")]
12511271
struct BlurBridge {
1272+
epoch: u64,
12521273
tx: flume::Sender<camera_blur::BlurJob>,
12531274
/// The first blurred output may land while the window is inactive, where
12541275
/// a notify alone may not present (the unit-2 first-frame finding); the
@@ -1350,21 +1371,27 @@ impl CameraWindow {
13501371
let image = (preview.frame_revision == Some(preview.selection_revision))
13511372
.then(|| preview.latest_frame.clone())
13521373
.flatten();
1353-
let Some(image) = image.or_else(|| preview.retained.clone()) else {
1374+
let Some((image, captured_at)) = image
1375+
.map(|image| (image, Instant::now()))
1376+
.or_else(|| preview.retained.clone().zip(preview.retained_captured_at))
1377+
else {
13541378
return;
13551379
};
1380+
let remaining = Duration::from_secs(60).saturating_sub(captured_at.elapsed());
1381+
if remaining.is_zero() {
1382+
return;
1383+
}
13561384
let Some(frame_dims) = preview.frame_dims.or(preview.retained_dims) else {
13571385
return;
13581386
};
1359-
let captured_at = Instant::now();
13601387
cx.default_global::<ParkedCameraPreview>().0 = Some(RetainedCameraPreview {
13611388
image,
13621389
camera,
13631390
state: self.state,
13641391
captured_at,
13651392
frame_dims,
13661393
});
1367-
let expiry = cx.background_executor().timer(Duration::from_secs(60));
1394+
let expiry = cx.background_executor().timer(remaining);
13681395
cx.spawn(async move |_, cx| {
13691396
expiry.await;
13701397
cx.update(|cx| {
@@ -1514,6 +1541,9 @@ impl CameraWindow {
15141541
) {
15151542
#[cfg(target_os = "macos")]
15161543
{
1544+
let Some(epoch) = Feeds::global(cx).read(cx).camera_preview_epoch() else {
1545+
return;
1546+
};
15171547
use core_foundation::base::TCFType as _;
15181548
use core_video::pixel_buffer::{CVPixelBuffer, CVPixelBufferRef};
15191549

@@ -1544,7 +1574,7 @@ impl CameraWindow {
15441574
ring_generation: converted.generation,
15451575
mode,
15461576
};
1547-
match self.ensure_blur_bridge(window, cx).tx.try_send(job) {
1577+
match self.ensure_blur_bridge(epoch, window, cx).tx.try_send(job) {
15481578
Ok(()) => {}
15491579
// Worker busy: drop this frame and keep the last
15501580
// painted one -- the bounded(1) latest-wins shape of
@@ -1652,7 +1682,19 @@ impl CameraWindow {
16521682
}
16531683

16541684
#[cfg(target_os = "macos")]
1655-
fn ensure_blur_bridge(&mut self, window: &Window, cx: &mut Context<Self>) -> &BlurBridge {
1685+
fn ensure_blur_bridge(
1686+
&mut self,
1687+
epoch: u64,
1688+
window: &Window,
1689+
cx: &mut Context<Self>,
1690+
) -> &BlurBridge {
1691+
if self
1692+
.blur
1693+
.as_ref()
1694+
.is_some_and(|bridge| bridge.epoch != epoch)
1695+
{
1696+
self.blur = None;
1697+
}
16561698
if self.blur.is_none() {
16571699
let (job_tx, job_rx) = flume::bounded::<camera_blur::BlurJob>(1);
16581700
let (out_tx, out_rx) = flume::bounded::<camera_blur::BlurOutput>(2);
@@ -1668,7 +1710,7 @@ impl CameraWindow {
16681710
let pump = cx.spawn(async move |this, cx| {
16691711
while let Ok(output) = out_rx.recv_async().await {
16701712
let first = match this.update(cx, |this: &mut CameraWindow, cx| {
1671-
this.blurred_frame_arrived(output, cx)
1713+
this.blurred_frame_arrived(output, epoch, cx)
16721714
}) {
16731715
Ok(first) => first,
16741716
Err(_) => break,
@@ -1685,6 +1727,7 @@ impl CameraWindow {
16851727
}
16861728
});
16871729
self.blur = Some(BlurBridge {
1730+
epoch,
16881731
tx: job_tx,
16891732
first_output_pending: true,
16901733
_pump: pump,
@@ -1700,11 +1743,14 @@ impl CameraWindow {
17001743
fn blurred_frame_arrived(
17011744
&mut self,
17021745
output: camera_blur::BlurOutput,
1746+
epoch: u64,
17031747
cx: &mut Context<Self>,
17041748
) -> bool {
17051749
// A stale output can land after the mode flips back to Off; the raw
17061750
// path is already painting again, so drop it.
1707-
if self.state.background_blur == BlurMode::Off {
1751+
if self.state.background_blur == BlurMode::Off
1752+
|| Feeds::global(cx).read(cx).camera_preview_epoch() != Some(epoch)
1753+
{
17081754
return false;
17091755
}
17101756
let first = self

apps/desktop-gpui/src/feeds.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ async fn camera_input_operation<T>(
117117
operation.await.map(Some)
118118
}
119119

120+
fn camera_preview_frame_is_fresh(
121+
timestamp: cap_timestamp::Timestamp,
122+
ready_at: Option<cap_timestamp::Timestamps>,
123+
) -> bool {
124+
ready_at.is_some_and(|ready_at| timestamp.checked_duration_since(ready_at).is_some())
125+
}
126+
120127
fn configuration_result(
121128
current_epoch: u64,
122129
epoch: u64,
@@ -642,7 +649,11 @@ fn run_camera_preview_worker(config: CameraPreviewWorkerConfig) {
642649
recording.publish(&image, dims, frame.timestamp, applied_mask);
643650
}
644651
if active.load(Ordering::Acquire) {
645-
match preview_tx.try_send(crate::camera_window::CameraPreviewFrame { image, dims }) {
652+
match preview_tx.try_send(crate::camera_window::CameraPreviewFrame {
653+
image,
654+
dims,
655+
timestamp: frame.timestamp,
656+
}) {
646657
Ok(()) | Err(flume::TrySendError::Full(_)) => {}
647658
Err(flume::TrySendError::Disconnected(_)) => {
648659
#[cfg(target_os = "linux")]
@@ -667,6 +678,7 @@ pub struct Feeds {
667678
microphone_settings: Option<microphone::MicrophoneDeviceSettings>,
668679
applied_settings: crate::store::RecordingDeviceSettings,
669680
camera_input_pending: bool,
681+
camera_preview_not_before: Option<cap_timestamp::Timestamps>,
670682
mic_input_pending: bool,
671683
mic_input_released: bool,
672684
microphone_error: Option<String>,
@@ -728,6 +740,7 @@ impl Feeds {
728740
microphone_settings: None,
729741
applied_settings: crate::store::RecordingDeviceSettings::default(),
730742
camera_input_pending: false,
743+
camera_preview_not_before: None,
731744
mic_input_pending: false,
732745
mic_input_released: false,
733746
microphone_error: None,
@@ -870,6 +883,7 @@ impl Feeds {
870883
self.camera_settings = settings;
871884
self.applied_settings.camera = None;
872885
self.camera_input_pending = false;
886+
self.camera_preview_not_before = None;
873887
self.camera_error = None;
874888
cx.notify();
875889

@@ -886,6 +900,20 @@ impl Feeds {
886900
self.camera_epoch
887901
}
888902

903+
pub(crate) fn camera_preview_epoch(&self) -> Option<u64> {
904+
(self.camera.is_some()
905+
&& !self.camera_preview_parked
906+
&& !self.camera_input_pending
907+
&& self.camera_error.is_none()
908+
&& self.camera_preview_not_before.is_some())
909+
.then_some(self.camera_epoch)
910+
}
911+
912+
pub(crate) fn accepts_camera_preview(&self, timestamp: cap_timestamp::Timestamp) -> bool {
913+
self.camera_preview_epoch().is_some()
914+
&& camera_preview_frame_is_fresh(timestamp, self.camera_preview_not_before)
915+
}
916+
889917
pub fn camera_configuration_result(&self, epoch: u64) -> Option<Result<(), String>> {
890918
configuration_result(
891919
self.camera_epoch,
@@ -910,6 +938,7 @@ impl Feeds {
910938
}
911939

912940
self.camera_preview_parked = true;
941+
self.camera_preview_not_before = None;
913942
self.applied_settings.camera = None;
914943
self.camera_input_pending = false;
915944
self.camera_epoch += 1;
@@ -949,6 +978,7 @@ impl Feeds {
949978
fn start_camera_preview(&mut self, selection: SelectedCamera, cx: &mut Context<Self>) {
950979
self.camera_error = None;
951980
self.camera_input_pending = true;
981+
self.camera_preview_not_before = None;
952982
self.applied_settings.camera = None;
953983
let epoch = self.camera_epoch;
954984
let settings = self.camera_settings;
@@ -1010,7 +1040,10 @@ impl Feeds {
10101040
}
10111041
this.camera_input_pending = false;
10121042
match result {
1013-
Ok(settings) => this.applied_settings.camera = settings.camera,
1043+
Ok(settings) => {
1044+
this.applied_settings.camera = settings.camera;
1045+
this.camera_preview_not_before = Some(cap_timestamp::Timestamps::now());
1046+
}
10141047
Err(error) => {
10151048
tracing::error!("camera input failed: {error}");
10161049
this.camera_error = Some(error);
@@ -1470,6 +1503,43 @@ fn db_fs(samples: &MicrophoneSamples) -> f64 {
14701503
mod tests {
14711504
use super::*;
14721505

1506+
#[test]
1507+
fn preview_rejects_frames_queued_before_input_readiness() {
1508+
let ready = cap_timestamp::Timestamps::now();
1509+
let queued = ready
1510+
.instant()
1511+
.checked_sub(Duration::from_millis(1))
1512+
.unwrap();
1513+
assert!(!camera_preview_frame_is_fresh(
1514+
cap_timestamp::Timestamp::Instant(queued),
1515+
Some(ready)
1516+
));
1517+
assert!(!camera_preview_frame_is_fresh(
1518+
cap_timestamp::Timestamp::Instant(ready.instant()),
1519+
None
1520+
));
1521+
assert!(camera_preview_frame_is_fresh(
1522+
cap_timestamp::Timestamp::Instant(ready.instant()),
1523+
Some(ready)
1524+
));
1525+
}
1526+
1527+
#[cfg(target_os = "macos")]
1528+
#[test]
1529+
fn preview_checks_native_camera_capture_clock() {
1530+
let ready = cap_timestamp::Timestamps::now();
1531+
assert!(!camera_preview_frame_is_fresh(
1532+
cap_timestamp::Timestamp::MachAbsoluteTime(cap_timestamp::MachAbsoluteTimestamp::new(
1533+
0
1534+
)),
1535+
Some(ready)
1536+
));
1537+
assert!(camera_preview_frame_is_fresh(
1538+
cap_timestamp::Timestamp::MachAbsoluteTime(cap_timestamp::MachAbsoluteTimestamp::now()),
1539+
Some(ready)
1540+
));
1541+
}
1542+
14731543
#[tokio::test]
14741544
async fn same_device_format_change_discards_queued_previous_configuration() {
14751545
let gate = tokio::sync::Mutex::new(());

apps/desktop/src/routes/camera.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -432,13 +432,24 @@ function LegacyCameraPreviewPage(props: {
432432
const retainedCanvas = document.createElement("canvas");
433433
const { rawOptions } = useRecordingOptions();
434434
let retainedFrameTimeout: ReturnType<typeof setTimeout> | undefined;
435+
let retainedFrameCapturedAt: number | undefined;
435436

436437
const clearRetainedFrame = () => {
437438
clearTimeout(retainedFrameTimeout);
439+
retainedFrameCapturedAt = undefined;
438440
setHasRetainedFrame(false);
439441
retainedCanvas.width = 0;
440442
retainedCanvas.height = 0;
441443
};
444+
const scheduleRetainedFrameExpiry = () => {
445+
clearTimeout(retainedFrameTimeout);
446+
if (retainedFrameCapturedAt === undefined) return;
447+
const remaining = 60_000 - (performance.now() - retainedFrameCapturedAt);
448+
retainedFrameTimeout = setTimeout(
449+
clearRetainedFrame,
450+
Math.max(0, remaining),
451+
);
452+
};
442453
const [frameDimensions, setFrameDimensions] = createSignal<{
443454
width: number;
444455
height: number;
@@ -502,13 +513,13 @@ function LegacyCameraPreviewPage(props: {
502513
controls?.hasRenderedFrame() && rawOptions.cameraID && !props.issue();
503514
controls?.dispose();
504515
if (canRetain && retainedCanvas.width > 0) {
516+
retainedFrameCapturedAt = performance.now();
505517
setHasRetainedFrame(true);
506518
} else if (!rawOptions.cameraID || props.issue()) {
507519
clearRetainedFrame();
508520
}
509521
setHasFrame(false);
510-
clearTimeout(retainedFrameTimeout);
511-
retainedFrameTimeout = setTimeout(clearRetainedFrame, 60_000);
522+
scheduleRetainedFrameExpiry();
512523
if (
513524
socket &&
514525
socket.readyState !== WebSocket.CLOSING &&
@@ -601,11 +612,11 @@ function LegacyCameraPreviewPage(props: {
601612
rawOptions.cameraID &&
602613
!props.issue()
603614
) {
615+
retainedFrameCapturedAt = performance.now();
604616
setHasRetainedFrame(true);
605617
}
606618
setHasFrame(false);
607-
clearTimeout(retainedFrameTimeout);
608-
retainedFrameTimeout = setTimeout(clearRetainedFrame, 60_000);
619+
scheduleRetainedFrameExpiry();
609620
scheduleReconnect();
610621
});
611622

0 commit comments

Comments
 (0)