diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index fc8067b1b3..d7ddafcc65 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -946,6 +946,11 @@ fn park_idle_camera_preview(cx: &mut App) { view.suspend_device_restore(); }) .ok(); + if let Some(handle) = cx.global::().camera { + handle + .update(cx, |view, _, cx| view.retain_preview(cx)) + .ok(); + } close_camera_window(cx); crate::feeds::Feeds::global(cx).update(cx, |feeds, cx| feeds.park_camera_preview(cx)); } @@ -3753,7 +3758,8 @@ pub fn open_camera_window(cx: &mut App) { } let state = crate::store::load().camera_window.unwrap_or_default(); - let (width, height) = camera_window::window_size(&state, None); + let (width, height) = + camera_window::window_size(&state, camera_window::retained_preview_aspect(cx)); let main = cx.global::().main; let display = main @@ -4501,6 +4507,12 @@ pub fn deliver_camera_frame( #[cfg(not(target_os = "macos"))] frame: crate::camera_window::CameraPreviewFrame, cx: &mut App, ) -> bool { + if !crate::feeds::Feeds::global(cx) + .read(cx) + .accepts_camera_preview(frame.timestamp) + { + return false; + } let Some(handle) = cx.global::().camera else { return false; }; diff --git a/apps/desktop-gpui/src/camera_window.rs b/apps/desktop-gpui/src/camera_window.rs index 9e448a4ef6..90219560df 100644 --- a/apps/desktop-gpui/src/camera_window.rs +++ b/apps/desktop-gpui/src/camera_window.rs @@ -48,8 +48,7 @@ use std::sync::{ }; use std::time::{Duration, Instant}; -#[cfg(not(target_os = "macos"))] -use gpui::StyledImage as _; +use gpui::{Animation, AnimationExt as _, StyledImage as _}; use gpui::{ AppContext as _, Context, Entity, FontWeight, InteractiveElement as _, IntoElement, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, @@ -828,6 +827,86 @@ mod preview_effect_failure_tests { } } +#[derive(Default)] +struct ParkedCameraPreview(Option); + +impl gpui::Global for ParkedCameraPreview {} + +struct RetainedCameraPreview { + image: Arc, + camera: crate::feeds::SelectedCamera, + state: CameraWindowState, + captured_at: Instant, + frame_dims: (usize, usize), +} + +pub(crate) fn retained_preview_aspect(cx: &gpui::App) -> Option { + let retained = cx.try_global::()?.0.as_ref()?; + let feeds = Feeds::global(cx); + let feeds = feeds.read(cx); + if feeds.camera.as_ref() != Some(&retained.camera) + || feeds.camera_error.is_some() + || store::load().camera_window.unwrap_or_default() != retained.state + || retained.captured_at.elapsed() >= Duration::from_secs(60) + { + return None; + } + Some(retained.frame_dims.0 as f32 / retained.frame_dims.1.max(1) as f32) +} + +pub(crate) fn clear_parked_camera_preview(cx: &mut gpui::App) { + cx.default_global::().0 = None; +} + +#[cfg(target_os = "macos")] +fn snapshot_preview( + buffer: &core_video::pixel_buffer::CVPixelBuffer, +) -> Option> { + use core_video::pixel_buffer::{kCVPixelBufferLock_ReadOnly, kCVPixelFormatType_32BGRA}; + + let width = buffer.get_width(); + let height = buffer.get_height(); + let stride = buffer.get_bytes_per_row(); + let row_bytes = width.checked_mul(4)?; + let required_bytes = height + .checked_sub(1)? + .checked_mul(stride)? + .checked_add(row_bytes)?; + if buffer.get_pixel_format() != kCVPixelFormatType_32BGRA + || width == 0 + || height == 0 + || stride < row_bytes + || required_bytes > buffer.get_data_size() + || buffer.lock_base_address(kCVPixelBufferLock_ReadOnly) != 0 + { + return None; + } + let image = (|| { + let base = unsafe { buffer.get_base_address() }.cast::(); + if base.is_null() { + return None; + } + let scale = (960. / width as f64).min(540. / height as f64).min(1.); + let target_width = (width as f64 * scale).round().max(1.) as u32; + let target_height = (height as f64 * scale).round().max(1.) as u32; + let image = image::RgbaImage::from_fn(target_width, target_height, |x, y| { + let source_x = x as usize * width / target_width as usize; + let source_y = y as usize * height / target_height as usize; + let offset = source_y * stride + source_x * 4; + let pixel = unsafe { std::slice::from_raw_parts(base.add(offset), 4) }; + // RenderImage consumes BGRA bytes even though image::Frame wraps RgbaImage. + image::Rgba([pixel[0], pixel[1], pixel[2], 255]) + }); + Some(Arc::new(gpui::RenderImage::new(smallvec::smallvec![ + image::Frame::new(image) + ]))) + })(); + if buffer.unlock_base_address(kCVPixelBufferLock_ReadOnly) != 0 { + return None; + } + image +} + /// The per-frame half of the window: owns the latest converted (or blurred) /// frame and is the only entity notified at camera rate. Chrome invalidation /// goes through the parent [`CameraWindow`] instead, so a frame draw reuses @@ -842,6 +921,14 @@ struct CameraPreviewView { #[cfg(not(target_os = "macos"))] latest_frame: Option>, frame_dims: Option<(usize, usize)>, + retained: Option>, + retained_dims: Option<(usize, usize)>, + retained_captured_at: Option, + reveal_started: Option, + retained_invalidated: bool, + selection_revision: u64, + frame_revision: Option, + camera: Option, /// Bumped by the canvas paint callback; the parent's cadence log reads it /// to prove notify-driven repaints actually present. paints: Arc, @@ -864,10 +951,48 @@ impl CameraPreviewView { ) -> Self { let feeds = Feeds::global(cx); let camera_error = feeds.read(cx).camera_error.clone(); + let camera = feeds.read(cx).camera.clone(); + let state = store::load().camera_window.unwrap_or_default(); + let retained = cx + .default_global::() + .0 + .take() + .filter(|retained| { + camera.as_ref() == Some(&retained.camera) + && state == retained.state + && camera_error.is_none() + && retained.captured_at.elapsed() < Duration::from_secs(60) + }); + let retained_dims = retained.as_ref().map(|retained| retained.frame_dims); + let retained_captured_at = retained.as_ref().map(|retained| retained.captured_at); + if let Some(retained) = &retained { + let expiry = cx + .background_executor() + .timer(Duration::from_secs(60).saturating_sub(retained.captured_at.elapsed())); + cx.spawn(async move |this, cx| { + expiry.await; + this.update(cx, |this: &mut Self, cx| { + if this.retained.is_some() { + this.retained_invalidated = true; + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + let retained = retained.map(|retained| retained.image); let feeds_subscription = cx.observe(&feeds, |this: &mut Self, feeds, cx| { - let error = feeds.read(cx).camera_error.clone(); - if this.camera_error != error { + let feeds = feeds.read(cx); + let error = feeds.camera_error.clone(); + let camera = feeds.camera.clone(); + if this.camera_error != error || this.camera != camera { + if error.is_some() || this.camera != camera { + this.retained_invalidated = true; + this.selection_revision = this.selection_revision.wrapping_add(1); + } this.camera_error = error; + this.camera = camera; cx.notify(); } }); @@ -880,6 +1005,14 @@ impl CameraPreviewView { #[cfg(not(target_os = "macos"))] latest_frame: None, frame_dims: None, + retained, + retained_dims, + retained_captured_at, + reveal_started: None, + retained_invalidated: false, + selection_revision: 0, + frame_revision: None, + camera, paints, camera_error, effect_issue: None, @@ -896,6 +1029,10 @@ impl CameraPreviewView { ) { self.latest_frame = Some(frame); self.frame_dims = Some(dims); + self.frame_revision = Some(self.selection_revision); + if self.retained.is_some() && self.reveal_started.is_none() { + self.reveal_started = Some(Instant::now()); + } cx.notify(); } @@ -908,6 +1045,10 @@ impl CameraPreviewView { ) -> Option> { let previous = self.latest_frame.replace(frame); self.frame_dims = Some(dims); + self.frame_revision = Some(self.selection_revision); + if self.retained.is_some() && self.reveal_started.is_none() { + self.reveal_started = Some(Instant::now()); + } cx.notify(); previous } @@ -922,7 +1063,31 @@ impl CameraPreviewView { } impl Render for CameraPreviewView { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, _cx: &mut Context) -> impl IntoElement { + if self + .frame_revision + .is_some_and(|revision| revision != self.selection_revision) + { + #[cfg(target_os = "macos")] + { + self.latest_frame = None; + } + #[cfg(not(target_os = "macos"))] + if let Some(image) = self.latest_frame.take() { + let _ = window.drop_image(image); + } + self.frame_dims = None; + self.frame_revision = None; + } + if (self.retained_invalidated + || self + .reveal_started + .is_some_and(|started| started.elapsed() >= Duration::from_millis(180))) + && let Some(image) = self.retained.take() + { + let _ = window.drop_image(image); + } + self.retained_invalidated = false; let theme = self.theme; let radius = self.radius; @@ -978,7 +1143,25 @@ impl Render for CameraPreviewView { ); } - let showing_frame = self.latest_frame.is_some(); + if let Some(image) = self.retained.clone() { + let overlay = div().absolute().inset_0().child( + gpui::img(image) + .size_full() + .rounded(px(radius)) + .object_fit(gpui::ObjectFit::Cover), + ); + container = if self.reveal_started.is_some() { + container.child(overlay.with_animation( + "camera-preview-reveal", + Animation::new(Duration::from_millis(180)), + |element, progress| element.opacity(1. - progress), + )) + } else { + container.child(overlay) + }; + } + + let showing_frame = self.latest_frame.is_some() || self.retained.is_some(); if !showing_frame { container = container.child( @@ -1047,6 +1230,7 @@ impl Render for CameraPreviewView { #[cfg(not(target_os = "macos"))] pub struct CameraPreviewFrame { + pub timestamp: cap_timestamp::Timestamp, pub image: Arc, pub dims: (usize, usize), } @@ -1085,6 +1269,7 @@ impl Render for CameraToolbarView { /// `release_blur_resources` behaviour (`camera.rs:1477-1484`). #[cfg(target_os = "macos")] struct BlurBridge { + epoch: u64, tx: flume::Sender, /// The first blurred output may land while the window is inactive, where /// a notify alone may not present (the unit-2 first-frame finding); the @@ -1111,6 +1296,7 @@ pub struct CameraWindow { preview: Entity, toolbar: Entity, frame_dims: Option<(usize, usize)>, + retained_aspect: Option, // Cadence instrumentation: proves the preview stays live (delivered) and // actually presents (painted) while the window is inactive. frames_in_window: u32, @@ -1165,6 +1351,62 @@ impl CameraWindow { ) } + pub(crate) fn retain_preview(&self, cx: &mut Context) { + let feeds = Feeds::global(cx); + let Some(camera) = feeds.read(cx).camera.clone() else { + return; + }; + if feeds.read(cx).camera_error.is_some() { + return; + } + let preview = self.preview.read(cx); + if preview.retained_invalidated || preview.camera.as_ref() != Some(&camera) { + return; + } + #[cfg(target_os = "macos")] + let image = (preview.frame_revision == Some(preview.selection_revision)) + .then(|| preview.latest_frame.as_ref().and_then(snapshot_preview)) + .flatten(); + #[cfg(not(target_os = "macos"))] + let image = (preview.frame_revision == Some(preview.selection_revision)) + .then(|| preview.latest_frame.clone()) + .flatten(); + let Some((image, captured_at)) = image + .map(|image| (image, Instant::now())) + .or_else(|| preview.retained.clone().zip(preview.retained_captured_at)) + else { + return; + }; + let remaining = Duration::from_secs(60).saturating_sub(captured_at.elapsed()); + if remaining.is_zero() { + return; + } + let Some(frame_dims) = preview.frame_dims.or(preview.retained_dims) else { + return; + }; + cx.default_global::().0 = Some(RetainedCameraPreview { + image, + camera, + state: self.state, + captured_at, + frame_dims, + }); + let expiry = cx.background_executor().timer(remaining); + cx.spawn(async move |_, cx| { + expiry.await; + cx.update(|cx| { + if cx + .try_global::() + .and_then(|cache| cache.0.as_ref()) + .is_some_and(|cached| cached.captured_at == captured_at) + { + clear_parked_camera_preview(cx); + } + }); + }) + .detach(); + } + pub fn new(window: &mut Window, cx: &mut Context) -> Self { // `document.documentElement.classList.toggle("dark", true)` // (`camera.tsx:128`): the bubble is always dark, whatever the app @@ -1189,6 +1431,7 @@ impl CameraWindow { Feeds::global(cx).update(cx, |feeds, _| { feeds.set_camera_preview_state(state.mirrored, state.background_blur) }); + let retained_aspect = retained_preview_aspect(cx); let paints = Arc::new(AtomicU32::new(0)); let preview = cx.new({ let paints = paints.clone(); @@ -1225,6 +1468,7 @@ impl CameraWindow { preview, toolbar, frame_dims: None, + retained_aspect, frames_in_window: 0, cadence_window_start: Instant::now(), paints, @@ -1297,6 +1541,9 @@ impl CameraWindow { ) { #[cfg(target_os = "macos")] { + let Some(epoch) = Feeds::global(cx).read(cx).camera_preview_epoch() else { + return; + }; use core_foundation::base::TCFType as _; use core_video::pixel_buffer::{CVPixelBuffer, CVPixelBufferRef}; @@ -1327,7 +1574,7 @@ impl CameraWindow { ring_generation: converted.generation, mode, }; - match self.ensure_blur_bridge(window, cx).tx.try_send(job) { + match self.ensure_blur_bridge(epoch, window, cx).tx.try_send(job) { Ok(()) => {} // Worker busy: drop this frame and keep the last // painted one -- the bounded(1) latest-wins shape of @@ -1435,7 +1682,19 @@ impl CameraWindow { } #[cfg(target_os = "macos")] - fn ensure_blur_bridge(&mut self, window: &Window, cx: &mut Context) -> &BlurBridge { + fn ensure_blur_bridge( + &mut self, + epoch: u64, + window: &Window, + cx: &mut Context, + ) -> &BlurBridge { + if self + .blur + .as_ref() + .is_some_and(|bridge| bridge.epoch != epoch) + { + self.blur = None; + } if self.blur.is_none() { let (job_tx, job_rx) = flume::bounded::(1); let (out_tx, out_rx) = flume::bounded::(2); @@ -1451,7 +1710,7 @@ impl CameraWindow { let pump = cx.spawn(async move |this, cx| { while let Ok(output) = out_rx.recv_async().await { let first = match this.update(cx, |this: &mut CameraWindow, cx| { - this.blurred_frame_arrived(output, cx) + this.blurred_frame_arrived(output, epoch, cx) }) { Ok(first) => first, Err(_) => break, @@ -1468,6 +1727,7 @@ impl CameraWindow { } }); self.blur = Some(BlurBridge { + epoch, tx: job_tx, first_output_pending: true, _pump: pump, @@ -1483,11 +1743,14 @@ impl CameraWindow { fn blurred_frame_arrived( &mut self, output: camera_blur::BlurOutput, + epoch: u64, cx: &mut Context, ) -> bool { // A stale output can land after the mode flips back to Off; the raw // path is already painting again, so drop it. - if self.state.background_blur == BlurMode::Off { + if self.state.background_blur == BlurMode::Off + || Feeds::global(cx).read(cx).camera_preview_epoch() != Some(epoch) + { return false; } let first = self @@ -1508,6 +1771,7 @@ impl CameraWindow { fn frame_aspect(&self) -> Option { self.frame_dims .map(|(width, height)| width as f32 / height.max(1) as f32) + .or(self.retained_aspect) } fn toolbar_scale(&self) -> f32 { @@ -2537,3 +2801,56 @@ mod recording_snapshot_tests { assert_eq!(snapshot.content_rect.y, client.y + 75); } } + +#[cfg(all(test, target_os = "macos"))] +mod retained_preview_tests { + use super::*; + use core_video::pixel_buffer::{ + CVPixelBuffer, kCVPixelFormatType_32ARGB, kCVPixelFormatType_32BGRA, + }; + + fn buffer(width: usize, height: usize) -> CVPixelBuffer { + let buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, width, height, None).unwrap(); + assert_eq!(buffer.lock_base_address(0), 0); + let stride = buffer.get_bytes_per_row(); + let pixels = unsafe { + std::slice::from_raw_parts_mut(buffer.get_base_address().cast::(), stride * height) + }; + for row in pixels.chunks_exact_mut(stride) { + for pixel in row[..width * 4].as_chunks_mut::<4>().0 { + pixel.copy_from_slice(&[19, 71, 143, 255]); + } + } + assert_eq!(buffer.unlock_base_address(0), 0); + buffer + } + + #[test] + fn retained_preview_owns_pixels_after_native_surface_changes() { + let buffer = buffer(17, 9); + let image = snapshot_preview(&buffer).unwrap(); + assert_eq!( + image.as_bytes(0).unwrap(), + [19, 71, 143, 255].repeat(17 * 9) + ); + assert_eq!(buffer.lock_base_address(0), 0); + unsafe { buffer.get_base_address().cast::().write(200) }; + assert_eq!(buffer.unlock_base_address(0), 0); + drop(buffer); + assert_eq!(image.as_bytes(0).unwrap()[0], 19); + } + + #[test] + fn retained_preview_bounds_memory_for_large_camera_frames() { + let buffer = buffer(3840, 2160); + let image = snapshot_preview(&buffer).unwrap(); + assert_eq!(image.as_bytes(0).unwrap().len(), 960 * 540 * 4); + assert_eq!(&image.as_bytes(0).unwrap()[..4], &[19, 71, 143, 255]); + } + + #[test] + fn retained_preview_rejects_non_bgra_surfaces() { + let buffer = CVPixelBuffer::new(kCVPixelFormatType_32ARGB, 32, 16, None).unwrap(); + assert!(snapshot_preview(&buffer).is_none()); + } +} diff --git a/apps/desktop-gpui/src/feeds.rs b/apps/desktop-gpui/src/feeds.rs index 661b6445a6..05570c5dac 100644 --- a/apps/desktop-gpui/src/feeds.rs +++ b/apps/desktop-gpui/src/feeds.rs @@ -117,6 +117,13 @@ async fn camera_input_operation( operation.await.map(Some) } +fn camera_preview_frame_is_fresh( + timestamp: cap_timestamp::Timestamp, + ready_at: Option, +) -> bool { + ready_at.is_some_and(|ready_at| timestamp.checked_duration_since(ready_at).is_some()) +} + fn configuration_result( current_epoch: u64, epoch: u64, @@ -642,7 +649,11 @@ fn run_camera_preview_worker(config: CameraPreviewWorkerConfig) { recording.publish(&image, dims, frame.timestamp, applied_mask); } if active.load(Ordering::Acquire) { - match preview_tx.try_send(crate::camera_window::CameraPreviewFrame { image, dims }) { + match preview_tx.try_send(crate::camera_window::CameraPreviewFrame { + image, + dims, + timestamp: frame.timestamp, + }) { Ok(()) | Err(flume::TrySendError::Full(_)) => {} Err(flume::TrySendError::Disconnected(_)) => { #[cfg(target_os = "linux")] @@ -667,6 +678,7 @@ pub struct Feeds { microphone_settings: Option, applied_settings: crate::store::RecordingDeviceSettings, camera_input_pending: bool, + camera_preview_not_before: Option, mic_input_pending: bool, mic_input_released: bool, microphone_error: Option, @@ -728,6 +740,7 @@ impl Feeds { microphone_settings: None, applied_settings: crate::store::RecordingDeviceSettings::default(), camera_input_pending: false, + camera_preview_not_before: None, mic_input_pending: false, mic_input_released: false, microphone_error: None, @@ -861,6 +874,7 @@ impl Feeds { { return self.camera_epoch; } + crate::camera_window::clear_parked_camera_preview(cx); self.camera_epoch += 1; self.camera_input_epoch .store(self.camera_epoch, Ordering::Release); @@ -869,6 +883,7 @@ impl Feeds { self.camera_settings = settings; self.applied_settings.camera = None; self.camera_input_pending = false; + self.camera_preview_not_before = None; self.camera_error = None; cx.notify(); @@ -885,6 +900,20 @@ impl Feeds { self.camera_epoch } + pub(crate) fn camera_preview_epoch(&self) -> Option { + (self.camera.is_some() + && !self.camera_preview_parked + && !self.camera_input_pending + && self.camera_error.is_none() + && self.camera_preview_not_before.is_some()) + .then_some(self.camera_epoch) + } + + pub(crate) fn accepts_camera_preview(&self, timestamp: cap_timestamp::Timestamp) -> bool { + self.camera_preview_epoch().is_some() + && camera_preview_frame_is_fresh(timestamp, self.camera_preview_not_before) + } + pub fn camera_configuration_result(&self, epoch: u64) -> Option> { configuration_result( self.camera_epoch, @@ -909,6 +938,7 @@ impl Feeds { } self.camera_preview_parked = true; + self.camera_preview_not_before = None; self.applied_settings.camera = None; self.camera_input_pending = false; self.camera_epoch += 1; @@ -948,6 +978,7 @@ impl Feeds { fn start_camera_preview(&mut self, selection: SelectedCamera, cx: &mut Context) { self.camera_error = None; self.camera_input_pending = true; + self.camera_preview_not_before = None; self.applied_settings.camera = None; let epoch = self.camera_epoch; let settings = self.camera_settings; @@ -1009,7 +1040,10 @@ impl Feeds { } this.camera_input_pending = false; match result { - Ok(settings) => this.applied_settings.camera = settings.camera, + Ok(settings) => { + this.applied_settings.camera = settings.camera; + this.camera_preview_not_before = Some(cap_timestamp::Timestamps::now()); + } Err(error) => { tracing::error!("camera input failed: {error}"); this.camera_error = Some(error); @@ -1469,6 +1503,43 @@ fn db_fs(samples: &MicrophoneSamples) -> f64 { mod tests { use super::*; + #[test] + fn preview_rejects_frames_queued_before_input_readiness() { + let ready = cap_timestamp::Timestamps::now(); + let queued = ready + .instant() + .checked_sub(Duration::from_millis(1)) + .unwrap(); + assert!(!camera_preview_frame_is_fresh( + cap_timestamp::Timestamp::Instant(queued), + Some(ready) + )); + assert!(!camera_preview_frame_is_fresh( + cap_timestamp::Timestamp::Instant(ready.instant()), + None + )); + assert!(camera_preview_frame_is_fresh( + cap_timestamp::Timestamp::Instant(ready.instant()), + Some(ready) + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn preview_checks_native_camera_capture_clock() { + let ready = cap_timestamp::Timestamps::now(); + assert!(!camera_preview_frame_is_fresh( + cap_timestamp::Timestamp::MachAbsoluteTime(cap_timestamp::MachAbsoluteTimestamp::new( + 0 + )), + Some(ready) + )); + assert!(camera_preview_frame_is_fresh( + cap_timestamp::Timestamp::MachAbsoluteTime(cap_timestamp::MachAbsoluteTimestamp::now()), + Some(ready) + )); + } + #[tokio::test] async fn same_device_format_change_discards_queued_previous_configuration() { let gate = tokio::sync::Mutex::new(()); diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 99b5191920..61905af678 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -51,7 +51,10 @@ import { type FrameData, } from "~/utils/socket"; import { commands, events } from "~/utils/tauri"; -import { RecordingOptionsProvider } from "./(window-chrome)/OptionsContext"; +import { + RecordingOptionsProvider, + useRecordingOptions, +} from "./(window-chrome)/OptionsContext"; type CameraPreviewIssue = { title: string; @@ -425,6 +428,28 @@ function LegacyCameraPreviewPage(props: { const [hasPositioned, setHasPositioned] = createSignal(isCameraOnlyMode()); const [hasFrame, setHasFrame] = createSignal(false); + const [hasRetainedFrame, setHasRetainedFrame] = createSignal(false); + const retainedCanvas = document.createElement("canvas"); + const { rawOptions } = useRecordingOptions(); + let retainedFrameTimeout: ReturnType | undefined; + let retainedFrameCapturedAt: number | undefined; + + const clearRetainedFrame = () => { + clearTimeout(retainedFrameTimeout); + retainedFrameCapturedAt = undefined; + setHasRetainedFrame(false); + retainedCanvas.width = 0; + retainedCanvas.height = 0; + }; + const scheduleRetainedFrameExpiry = () => { + clearTimeout(retainedFrameTimeout); + if (retainedFrameCapturedAt === undefined) return; + const remaining = 60_000 - (performance.now() - retainedFrameCapturedAt); + retainedFrameTimeout = setTimeout( + clearRetainedFrame, + Math.max(0, remaining), + ); + }; const [frameDimensions, setFrameDimensions] = createSignal<{ width: number; height: number; @@ -479,12 +504,33 @@ function LegacyCameraPreviewPage(props: { let lastFrameTime = 0; let cameraCanvasRef: HTMLCanvasElement | undefined; + const retainCurrentFrame = (controls: CanvasControls | undefined) => { + if (!rawOptions.cameraID || props.issue()) { + clearRetainedFrame(); + return; + } + try { + if ( + controls?.hasRenderedFrame() && + controls.drawLatestFrameToCanvas(retainedCanvas) + ) { + retainedFrameCapturedAt = performance.now(); + setHasRetainedFrame(true); + } + } catch { + clearRetainedFrame(); + } + }; + const closeSocket = () => { const socket = ws; const controls = canvasControls; ws = undefined; canvasControls = undefined; + retainCurrentFrame(controls); controls?.dispose(); + setHasFrame(false); + scheduleRetainedFrameExpiry(); if ( socket && socket.readyState !== WebSocket.CLOSING && @@ -511,8 +557,10 @@ function LegacyCameraPreviewPage(props: { ) { setFrameDimensions({ width: frame.width, height: frame.height }); } - if (canvasControls?.hasRenderedFrame()) { + if (canvasControls?.hasRenderedFrame() && !hasFrame()) { setHasFrame(true); + clearTimeout(retainedFrameTimeout); + retainedFrameTimeout = setTimeout(clearRetainedFrame, 180); } }; @@ -545,7 +593,9 @@ function LegacyCameraPreviewPage(props: { const instantQuery = isInstantRecording() ? "?instant=true" : ""; const [socket, _isConnected, _isWorkerReady, controls] = createImageDataWS( `ws://localhost:${cameraWsPort}${instantQuery}`, - updateFrameState, + (frame) => { + if (canvasControls === controls) updateFrameState(frame); + }, () => commands.refreshCameraFeed().catch(() => {}), { powerPreference: "low-power" }, ); @@ -553,16 +603,29 @@ function LegacyCameraPreviewPage(props: { initCanvasControls(); socket.addEventListener("open", () => { + if (ws !== socket) return; lastFrameTime = Date.now(); setHasFrame(false); - setFrameDimensions(null); }); + const captureBeforeCleanup = () => { + if (ws !== socket || canvasControls !== controls) return; + retainCurrentFrame(controls); + setHasFrame(false); + scheduleRetainedFrameExpiry(); + }; + // Capture listeners run before the transport discards its frame buffers. + socket.addEventListener("close", captureBeforeCleanup, { capture: true }); + socket.addEventListener("error", captureBeforeCleanup, { capture: true }); + socket.addEventListener("close", () => { if (canvasControls === controls) { canvasControls = undefined; } - if (ws === socket) ws = undefined; + if (ws !== socket) return; + ws = undefined; + setHasFrame(false); + scheduleRetainedFrameExpiry(); scheduleReconnect(); }); @@ -654,9 +717,27 @@ function LegacyCameraPreviewPage(props: { ), ); + createEffect( + on( + () => JSON.stringify(rawOptions.cameraID), + () => { + stopSocket(); + clearRetainedFrame(); + setFrameDimensions(null); + if (rawOptions.cameraID) startSocket(); + }, + { defer: true }, + ), + ); + + createEffect(() => { + if (props.issue()) clearRetainedFrame(); + }); + onCleanup(() => { isCleanedUp = true; stopSocket(); + clearRetainedFrame(); }); const scale = () => cameraToolbarScale(state.size); @@ -933,7 +1014,15 @@ function LegacyCameraPreviewPage(props: { }} containerSize={externalContainerSize() ?? undefined} /> - + {}} + containerSize={externalContainerSize() ?? undefined} + opacity={hasRetainedFrame() && !hasFrame() ? 1 : 0} + /> + @@ -952,6 +1041,8 @@ function Canvas(props: { state: CameraWindowState; onCanvas: (canvas: HTMLCanvasElement) => void; containerSize?: { width: number; height: number }; + canvas?: HTMLCanvasElement; + opacity?: number; }) { const style = () => { const dimensions = props.frameDimensions(); @@ -978,6 +1069,7 @@ function Canvas(props: { const top = (size.height - targetSize.height) / 2; return { + opacity: props.opacity, width: `${size.width}px`, height: `${size.height}px`, left: `-${left}px`, @@ -986,6 +1078,21 @@ function Canvas(props: { }; }; + if (props.canvas) { + const canvas = props.canvas; + canvas.className = + "absolute pointer-events-none motion-reduce:transition-none"; + createEffect(() => { + canvas.style.transition = + props.opacity === 0 && + !matchMedia("(prefers-reduced-motion: reduce)").matches + ? "opacity 180ms ease-out" + : "none"; + Object.assign(canvas.style, style()); + }); + return canvas; + } + return (