From f7caa3836305a66b987e8fd7b1d0da2b49791a65 Mon Sep 17 00:00:00 2001 From: Dmitry Samoylenko Date: Thu, 27 Aug 2026 13:58:59 +0200 Subject: [PATCH 1/4] Carry the camera turn as data, publish the lens list, add a desktop backend The frame's turn is metadata every backend reports: Android computes it per lens facing and follows display turns while the session runs, iOS stops pre-rotating and reports 90, and upright_rgba8 applies the turn inside the one conversion pass a consumer already pays. The lens list is published state (CameraLenses, LensFacing) with front lenses included, so a lens control observes instead of paying a blocking platform call per recomposition. A lens switch keeps the last frame on screen instead of blanking. A nokhwa backend behind the camera-native feature brings the camera to desktop. Co-Authored-By: Claude Fable 5 --- crates/cranpose-services/Cargo.toml | 3 + crates/cranpose-services/src/camera.rs | 355 +++++++++++++++++- crates/cranpose-services/src/camera/native.rs | 270 +++++++++++++ crates/cranpose-services/src/lib.rs | 189 +++++----- .../cranpose/android/CranposeActivity.java | 9 + .../dev/cranpose/android/CranposeCamera.java | 123 +++++- crates/cranpose/src/android_camera.rs | 65 +++- crates/cranpose/src/ios_camera.rs | 143 ++++--- docs/capability_parity.md | 2 +- 9 files changed, 984 insertions(+), 175 deletions(-) create mode 100644 crates/cranpose-services/src/camera/native.rs diff --git a/crates/cranpose-services/Cargo.toml b/crates/cranpose-services/Cargo.toml index f103328ef..d7f95b31d 100644 --- a/crates/cranpose-services/Cargo.toml +++ b/crates/cranpose-services/Cargo.toml @@ -49,6 +49,8 @@ system-theme = [] system-theme-web = ["dep:web-sys"] # Native desktop file/folder picker (rfd; xdg-portal surfaces GVFS/WebDAV mounts). file-picker-native = ["dep:rfd"] +# Native desktop camera capture (nokhwa: AVFoundation / MSMF / V4L2). +camera-native = ["dep:nokhwa"] # Web file/folder picker (rfd + File System Access). file-picker-web = [ "dep:rfd", @@ -67,6 +69,7 @@ open = { version = "5.3.5", optional = true } # locations (GVFS/WebDAV) on Linux. iOS uses its own backend, so exclude it. [target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "android"), not(target_os = "ios")))'.dependencies] rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"], optional = true } +nokhwa = { version = "0.10", features = ["input-native"], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] futures-util = { version = "0.3.32", optional = true } diff --git a/crates/cranpose-services/src/camera.rs b/crates/cranpose-services/src/camera.rs index 78e87da5f..a6f4ef8e2 100644 --- a/crates/cranpose-services/src/camera.rs +++ b/crates/cranpose-services/src/camera.rs @@ -124,6 +124,109 @@ impl CameraFrame { FrameFormat::Nv12 => nv12_to_rgba8(self.width, self.height, &self.bytes), } } + + /// The frame as tightly packed RGBA8 with + /// [`rotation_degrees`](Self::rotation_degrees) applied, so the pixels are + /// the right way up whatever the sensor's mounting was. + /// + /// The turn happens in the same pass as the format conversion, because this + /// runs on every previewed frame: converting and then turning would walk + /// the pixels twice. A rotation that is not a quarter turn is left alone — + /// no camera produces one. + pub fn upright_rgba8(&self) -> UprightRgba { + let rotation = self.rotation_degrees; + if !matches!(rotation, 90 | 180 | 270) { + return UprightRgba { + width: self.width, + height: self.height, + rgba: self.to_rgba8(), + }; + } + let (width, height) = (self.width as usize, self.height as usize); + let (out_width, out_height) = match rotation { + 90 | 270 => (self.height, self.width), + _ => (self.width, self.height), + }; + let mut rgba = vec![0u8; width * height * 4]; + match self.format { + FrameFormat::Rgba8 => { + for y in 0..height { + let row = &self.bytes[y * width * 4..(y + 1) * width * 4]; + for x in 0..width { + let src = &row[x * 4..x * 4 + 4]; + let dst = turned_index(rotation, width, height, x, y) * 4; + rgba[dst..dst + 4].copy_from_slice(src); + } + } + } + FrameFormat::Rgb8 => { + for y in 0..height { + let row = &self.bytes[y * width * 3..(y + 1) * width * 3]; + for x in 0..width { + let src = &row[x * 3..x * 3 + 3]; + let dst = turned_index(rotation, width, height, x, y) * 4; + rgba[dst..dst + 3].copy_from_slice(src); + rgba[dst + 3] = 255; + } + } + } + FrameFormat::Nv12 => { + let pixels = width * height; + if self.bytes.len() < pixels + pixels / 2 || width == 0 || height == 0 { + return UprightRgba { + width: out_width, + height: out_height, + rgba, + }; + } + let (luma, chroma) = self.bytes.split_at(pixels); + for y in 0..height { + let luma_row = &luma[y * width..(y + 1) * width]; + let chroma_row = &chroma[(y / 2) * width..(y / 2 + 1) * width]; + for x in 0..width { + let luminance = luma_row[x] as i32; + let blue_difference = chroma_row[x & !1] as i32 - 128; + let red_difference = chroma_row[(x & !1) + 1] as i32 - 128; + let dst = turned_index(rotation, width, height, x, y) * 4; + rgba[dst] = clamp_byte(luminance + ((91881 * red_difference) >> 16)); + rgba[dst + 1] = clamp_byte( + luminance - ((22554 * blue_difference + 46802 * red_difference) >> 16), + ); + rgba[dst + 2] = clamp_byte(luminance + ((116130 * blue_difference) >> 16)); + rgba[dst + 3] = 255; + } + } + } + } + UprightRgba { + width: out_width, + height: out_height, + rgba, + } + } +} + +/// A frame's pixels as tightly packed RGBA8, already the right way up. +/// +/// `width` and `height` describe the turned image, so a 90° or 270° turn swaps +/// them relative to the frame that produced this. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UprightRgba { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// Where source pixel `(x, y)` lands in the image turned clockwise by +/// `rotation`, as a pixel index into the turned image. +#[inline] +fn turned_index(rotation: u16, width: usize, height: usize, x: usize, y: usize) -> usize { + match rotation { + 90 => x * height + (height - 1 - y), + 180 => (height - 1 - y) * width + (width - 1 - x), + 270 => (width - 1 - x) * height + y, + _ => y * width + x, + } } /// Widens tightly packed RGB8 to RGBA8, opaque throughout. @@ -189,13 +292,44 @@ pub struct CameraStill { /// One capture device the application may pick. /// /// `id` is the platform's own handle for the device (an `AVCaptureDevice` -/// uniqueID on iOS, a camera2 id on Android); pass it back to -/// [`Camera::use_lens`]. `name` is for a button label: "Ultra wide", "Wide", -/// "Tele". +/// uniqueID on iOS, a camera2 id on Android, a device index on desktop); pass +/// it back to [`Camera::use_lens`]. `name` is for a button label: "Ultra +/// wide", "Wide", "Tele". #[derive(Clone, Debug, PartialEq, Eq)] pub struct CameraLens { pub id: String, pub name: String, + /// Which way the device points, so an application can offer back lenses + /// and the front lens as different controls. + pub facing: LensFacing, +} + +/// Which way a capture device points. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum LensFacing { + /// Away from the screen: the main photography cameras on a phone. + #[default] + Back, + /// At the person holding the device. + Front, + /// Not fixed to a screen at all: a webcam or another attached device. + External, +} + +/// The devices the application may pick between, and the one in use. +/// +/// Published by the backend when a session opens and when the device changes. +/// A screen showing a lens control observes this instead of asking the +/// platform, because both phone lens lists are blocking platform calls — a +/// JNI round trip on Android, a fresh discovery session on iOS — and a +/// recomposition must not pay that. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CameraLenses { + /// Back lenses first in field-of-view order, widest first, then the rest. + pub lenses: Vec, + /// The id of the device the open session uses, or `None` while nothing + /// runs. + pub active: Option, } /// What the light does when a still is captured. @@ -303,10 +437,13 @@ pub trait Camera: Send + Sync { } /// The devices the application may pick between, back cameras first and in - /// field-of-view order, widest first. + /// field-of-view order, widest first, then the rest. /// /// An empty list means the application shows no lens control: either the - /// platform has one camera or the backend does not list them. + /// platform has one camera or the backend does not list them. Backends + /// also publish this through [`publish_camera_lenses`] when a session + /// opens, so a screen observes [`rememberCameraLenses`] rather than paying + /// this blocking platform call per recomposition. fn lenses(&self) -> Vec { Vec::new() } @@ -358,12 +495,18 @@ pub fn clear_platform_camera() { if let Ok(mut observers) = still_observers().lock() { observers.clear(); } + if let Ok(mut observers) = lens_observers().lock() { + observers.clear(); + } if let Ok(mut latest) = latest_frame_slot().lock() { *latest = None; } if let Ok(mut state) = state_slot().lock() { *state = CameraState::Idle; } + if let Ok(mut lenses) = lenses_slot().lock() { + *lenses = CameraLenses::default(); + } DROPPED_FRAMES.store(0, Ordering::Release); } @@ -388,6 +531,11 @@ fn latest_frame_slot() -> &'static Mutex> { SLOT.get_or_init(|| Mutex::new(None)) } +fn lenses_slot() -> &'static Mutex { + static SLOT: OnceLock> = OnceLock::new(); + SLOT.get_or_init(|| Mutex::new(CameraLenses::default())) +} + /// Frames produced while every observer was still busy with an earlier one. /// /// Counted rather than queued: a detector that falls behind should see the @@ -398,6 +546,7 @@ static DROPPED_FRAMES: AtomicU64 = AtomicU64::new(0); type FrameObserver = Arc; type StateObserver = Arc; type StillObserver = Arc) + Send + Sync>; +type LensObserver = Arc; fn frame_observers() -> &'static Mutex> { static SLOT: OnceLock>> = OnceLock::new(); @@ -414,6 +563,11 @@ fn still_observers() -> &'static Mutex> { SLOT.get_or_init(|| Mutex::new(Vec::new())) } +fn lens_observers() -> &'static Mutex> { + static SLOT: OnceLock>> = OnceLock::new(); + SLOT.get_or_init(|| Mutex::new(Vec::new())) +} + static NEXT_OBSERVER: AtomicU64 = AtomicU64::new(1); /// Keeps a camera observer registered until it is dropped. @@ -427,6 +581,7 @@ enum ObserverKind { Frame, State, Still, + Lenses, } impl Drop for CameraObserver { @@ -435,6 +590,7 @@ impl Drop for CameraObserver { ObserverKind::Frame => retain_without(frame_observers(), self.id), ObserverKind::State => retain_without(state_observers(), self.id), ObserverKind::Still => retain_without(still_observers(), self.id), + ObserverKind::Lenses => retain_without(lens_observers(), self.id), } } } @@ -536,6 +692,32 @@ pub fn publish_camera_still(still: Result) { } } +/// Publishes the lens list and the device in use. Backends call this when a +/// session opens and when the device changes. +pub fn publish_camera_lenses(lenses: CameraLenses) { + { + let Ok(mut current) = lenses_slot().lock() else { + return; + }; + if *current == lenses { + return; + } + *current = lenses.clone(); + } + for observer in snapshot(lens_observers()) { + observer(lenses.clone()); + } +} + +/// The devices the application may pick between, as the backend last published +/// them. +pub fn camera_lenses() -> CameraLenses { + lenses_slot() + .lock() + .map(|lenses| lenses.clone()) + .unwrap_or_default() +} + /// Registers `observer` for frames. Applications collect /// [`rememberCameraFrames`] instead of calling this. pub fn observe_camera_frames( @@ -583,6 +765,24 @@ pub fn observe_camera_stills( } } +/// Registers `observer` for the lens list. The current list is delivered at +/// once, so a screen composed mid-session shows the devices rather than +/// waiting for the next change. +pub fn observe_camera_lenses( + observer: impl Fn(CameraLenses) + Send + Sync + 'static, +) -> CameraObserver { + let id = NEXT_OBSERVER.fetch_add(1, Ordering::Relaxed); + let observer: LensObserver = Arc::new(observer); + if let Ok(mut observers) = lens_observers().lock() { + observers.push((id, Arc::clone(&observer))); + } + observer(camera_lenses()); + CameraObserver { + id, + kind: ObserverKind::Lenses, + } +} + /// What the camera session is doing, observed for as long as this call stays in /// the composition. #[allow(non_snake_case)] @@ -617,6 +817,16 @@ pub fn rememberCameraStills() -> EventStream> { }) } +/// The lens list and the device in use, observed for as long as this call +/// stays in the composition. +#[allow(non_snake_case)] +pub fn rememberCameraLenses() -> State { + let updates = rememberEventStream((), |sender| { + observe_camera_lenses(move |lenses| sender.send(lenses)) + }); + cranpose_core::collectAsState(updates, (), camera_lenses()) +} + /// Starts the camera, publishing [`CameraState::Starting`] before the backend /// is asked so a screen shows the wait rather than a gap. pub fn start_camera() -> Result<(), CameraError> { @@ -671,6 +881,21 @@ pub async fn capture_camera_still() -> Result { arrived.unwrap_or(Err(CameraError::NotRunning)) } +#[cfg(all( + not(target_arch = "wasm32"), + not(target_os = "android"), + not(target_os = "ios"), + feature = "camera-native" +))] +mod native; +#[cfg(all( + not(target_arch = "wasm32"), + not(target_os = "android"), + not(target_os = "ios"), + feature = "camera-native" +))] +pub use native::install_native_camera; + #[cfg(test)] mod tests { use super::*; @@ -1002,10 +1227,12 @@ mod tests { CameraLens { id: "u".into(), name: "Ultra wide".into(), + facing: LensFacing::Back, }, CameraLens { id: "w".into(), name: "Wide".into(), + facing: LensFacing::Back, }, ] } @@ -1026,4 +1253,122 @@ mod tests { assert!(!backend.use_lens("tele")); clear_platform_camera(); } + + fn two_pixel_frame(rotation: u16) -> CameraFrame { + let mut bytes = vec![10u8, 10, 10, 255]; + bytes.extend_from_slice(&[20, 20, 20, 255]); + CameraFrame::new(2, 1, FrameFormat::Rgba8, rotation, 0, bytes).expect("a well-formed frame") + } + + fn pixel_values(image: &UprightRgba) -> Vec { + image.rgba.iter().step_by(4).copied().collect() + } + + /// A quarter turn must land every pixel where a clockwise turn of the + /// picture puts it, and swap the sides for the odd quarters. + #[test] + fn a_frame_turns_upright_by_its_rotation() { + let unturned = two_pixel_frame(0).upright_rgba8(); + assert_eq!((unturned.width, unturned.height), (2, 1)); + assert_eq!(pixel_values(&unturned), vec![10, 20]); + + let quarter = two_pixel_frame(90).upright_rgba8(); + assert_eq!((quarter.width, quarter.height), (1, 2)); + assert_eq!(pixel_values(&quarter), vec![10, 20]); + + let half = two_pixel_frame(180).upright_rgba8(); + assert_eq!((half.width, half.height), (2, 1)); + assert_eq!(pixel_values(&half), vec![20, 10]); + + let three_quarters = two_pixel_frame(270).upright_rgba8(); + assert_eq!((three_quarters.width, three_quarters.height), (1, 2)); + assert_eq!(pixel_values(&three_quarters), vec![20, 10]); + } + + /// The turn happens in the same pass as the NV12 conversion, so the two + /// paths must agree pixel for pixel. + #[test] + fn an_nv12_frame_turns_and_converts_in_one_pass() { + let bytes = vec![0, 255, 0, 0, 128, 128]; + let frame = + CameraFrame::new(2, 2, FrameFormat::Nv12, 90, 0, bytes).expect("a well-formed frame"); + let upright = frame.upright_rgba8(); + assert_eq!((upright.width, upright.height), (2, 2)); + let values = pixel_values(&upright); + assert_eq!(values[0], 0, "top-left stays dark"); + assert_eq!( + values[3], 255, + "the bright top-right pixel lands bottom-right" + ); + } + + #[test] + fn an_rgb8_frame_turns_upright_with_a_full_alpha() { + let frame = CameraFrame::new( + 2, + 1, + FrameFormat::Rgb8, + 180, + 0, + vec![10, 10, 10, 20, 20, 20], + ) + .expect("a well-formed frame"); + let upright = frame.upright_rgba8(); + assert_eq!(pixel_values(&upright), vec![20, 10]); + assert!(upright.rgba.iter().skip(3).step_by(4).all(|a| *a == 255)); + } + + /// No camera produces a turn that is not a quarter, so anything else is + /// left alone rather than guessed at. + #[test] + fn a_turn_that_is_not_a_quarter_is_left_alone() { + let frame = CameraFrame::new(2, 1, FrameFormat::Rgba8, 45, 0, vec![7; 8]) + .expect("a well-formed frame"); + let upright = frame.upright_rgba8(); + assert_eq!((upright.width, upright.height), (2, 1)); + assert_eq!(upright.rgba, frame.to_rgba8()); + } + + #[test] + fn the_lens_list_is_published_and_observed() { + let _guard = crate::registry::test_service_guard(); + clear_platform_camera(); + assert_eq!(camera_lenses(), CameraLenses::default()); + + let seen = Arc::new(Mutex::new(Vec::new())); + let recorder = Arc::clone(&seen); + let observer = observe_camera_lenses(move |lenses| { + recorder + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(lenses) + }); + + let published = CameraLenses { + lenses: vec![CameraLens { + id: "0".into(), + name: "Back".into(), + facing: LensFacing::Back, + }], + active: Some("0".into()), + }; + publish_camera_lenses(published.clone()); + publish_camera_lenses(published.clone()); + assert_eq!(camera_lenses(), published); + assert_eq!( + *seen.lock().unwrap_or_else(|error| error.into_inner()), + vec![CameraLenses::default(), published.clone()], + "the current list arrives at once, and a repeat is not re-delivered" + ); + + drop(observer); + publish_camera_lenses(CameraLenses::default()); + assert_eq!( + seen.lock().unwrap_or_else(|error| error.into_inner()).len(), + 2, + "a dropped observer hears nothing more" + ); + clear_platform_camera(); + assert_eq!(camera_lenses(), CameraLenses::default()); + } } diff --git a/crates/cranpose-services/src/camera/native.rs b/crates/cranpose-services/src/camera/native.rs new file mode 100644 index 000000000..cd1381b89 --- /dev/null +++ b/crates/cranpose-services/src/camera/native.rs @@ -0,0 +1,270 @@ +//! Desktop live camera via `nokhwa` (AVFoundation on macOS, Media Foundation +//! on Windows, V4L2 on Linux). +//! +//! nokhwa hands frames over on request, and the framework contract is that +//! frames are published; a capture thread bridges the two. The thread opens +//! the device, publishes what happened, and pushes every decoded frame until +//! the session stops. Nothing here blocks the caller for the length of a +//! capture: `start` returns once the thread exists, and the session's +//! progress arrives as [`CameraState`]. + +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread::JoinHandle, +}; + +use nokhwa::{ + pixel_format::RgbFormat, + utils::{ApiBackend, CameraIndex, RequestedFormat, RequestedFormatType}, + Camera as CaptureDevice, +}; + +use super::{ + publish_camera_frame, publish_camera_lenses, publish_camera_state, record_dropped_camera_frame, + set_platform_camera, Camera, CameraError, CameraFrame, CameraLens, CameraLenses, CameraState, + FrameFormat, LensFacing, +}; + +/// Installs the built-in desktop camera as the platform camera. +pub fn install_native_camera() { + set_platform_camera(Arc::new(NativeCamera::default())); +} + +/// The devices nokhwa can open, as lenses an application can pick between. +/// +/// A webcam does not face a screen the way a phone lens does, so every device +/// reports [`LensFacing::External`]. +fn list_lenses() -> Vec { + match nokhwa::query(ApiBackend::Auto) { + Ok(devices) => devices + .into_iter() + .filter_map(|info| match info.index() { + CameraIndex::Index(index) => Some(CameraLens { + id: index.to_string(), + name: info.human_name(), + facing: LensFacing::External, + }), + CameraIndex::String(_) => None, + }) + .collect(), + Err(error) => { + log::warn!("camera device list failed: {error}"); + Vec::new() + } + } +} + +struct Session { + running: Arc, + thread: Option>, +} + +#[derive(Default)] +struct NativeCamera { + session: Mutex>, + /// The device the application picked, kept across stop and start. + chosen: Mutex>, + /// The device the open session uses, written by the capture thread. + active: Arc>>, +} + +impl NativeCamera { + fn open(&self) -> Result<(), CameraError> { + let mut session = self + .session + .lock() + .map_err(|_| CameraError::Failed("the camera session lock is poisoned".into()))?; + if session.is_some() { + return Ok(()); + } + let chosen = self.chosen.lock().ok().and_then(|chosen| *chosen); + let running = Arc::new(AtomicBool::new(true)); + let thread_running = Arc::clone(&running); + let active = Arc::clone(&self.active); + let thread = std::thread::Builder::new() + .name("cranpose-camera".into()) + .spawn(move || capture_loop(chosen, thread_running, active)) + .map_err(|error| CameraError::Failed(error.to_string()))?; + *session = Some(Session { + running, + thread: Some(thread), + }); + Ok(()) + } + + /// Ends the capture thread and waits for it to release the device, so a + /// restart does not race the platform over who holds the camera. + fn close(&self) { + let taken = self + .session + .lock() + .ok() + .and_then(|mut session| session.take()); + if let Some(mut session) = taken { + session.running.store(false, Ordering::Relaxed); + if let Some(thread) = session.thread.take() { + let _ = thread.join(); + } + } + if let Ok(mut active) = self.active.lock() { + *active = None; + } + } +} + +impl Camera for NativeCamera { + fn start(&self) -> Result<(), CameraError> { + self.open() + } + + fn stop(&self) { + self.close(); + } + + fn lenses(&self) -> Vec { + list_lenses() + } + + fn lens(&self) -> Option { + let active = self.active.lock().ok().and_then(|active| *active); + active + .or_else(|| self.chosen.lock().ok().and_then(|chosen| *chosen)) + .map(|index| index.to_string()) + } + + fn use_lens(&self, id: &str) -> bool { + let Ok(index) = id.parse::() else { + return false; + }; + if !list_lenses().iter().any(|lens| lens.id == id) { + return false; + } + match self.chosen.lock() { + Ok(mut chosen) => *chosen = Some(index), + Err(_) => return false, + } + let running = self + .session + .lock() + .map(|session| session.is_some()) + .unwrap_or(false); + if !running { + return true; + } + // The device changes without a Stopped in between, so the viewfinder + // keeps the last frame instead of blanking while the new one opens. + self.close(); + self.open().is_ok() + } +} + +/// Opens the device and pushes what it produces until `running` clears. +/// +/// Runs on its own thread because nokhwa blocks for the length of every +/// frame. What happens is published rather than returned: the caller has +/// already moved on, exactly as with the phone backends. +fn capture_loop(chosen: Option, running: Arc, active: Arc>>) { + let index = match chosen.or_else(|| { + list_lenses() + .first() + .and_then(|lens| lens.id.parse::().ok()) + }) { + Some(index) => index, + None => { + publish_camera_state(CameraState::Failed(CameraError::Failed( + "this machine has no camera".into(), + ))); + return; + } + }; + let requested = + RequestedFormat::new::(RequestedFormatType::AbsoluteHighestResolution); + let mut device = match CaptureDevice::new(CameraIndex::Index(index), requested) { + Ok(device) => device, + Err(error) => { + publish_camera_state(CameraState::Failed(CameraError::Failed(format!( + "opening camera {index}: {error}" + )))); + return; + } + }; + let name = device.info().human_name(); + if let Err(error) = device.open_stream() { + publish_camera_state(CameraState::Failed(CameraError::Failed(format!( + "starting the camera stream: {error}" + )))); + return; + } + if let Ok(mut slot) = active.lock() { + *slot = Some(index); + } + publish_camera_state(CameraState::Running { device: name }); + publish_camera_lenses(CameraLenses { + lenses: list_lenses(), + active: Some(index.to_string()), + }); + + let mut sequence = 0u64; + while running.load(Ordering::Relaxed) { + match device.frame() { + Ok(buffer) => match buffer.decode_image::() { + Ok(decoded) => { + let (width, height) = (decoded.width(), decoded.height()); + match CameraFrame::new( + width, + height, + FrameFormat::Rgb8, + 0, + sequence, + decoded.into_raw(), + ) { + Some(frame) => { + sequence += 1; + publish_camera_frame(frame); + } + None => record_dropped_camera_frame(), + } + } + Err(error) => { + log::warn!("camera frame decode failed: {error}"); + record_dropped_camera_frame(); + } + }, + Err(error) => { + // The device is briefly out of frames or was yanked; back off + // rather than spinning a core against the error. + log::warn!("camera frame read failed: {error}"); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } + } + let _ = device.stop_stream(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::camera::{camera_supported, clear_platform_camera}; + + /// Installing registers the backend; nothing touches the hardware until a + /// session is asked for. + #[test] + fn installing_the_native_camera_registers_it() { + let _guard = crate::registry::test_service_guard(); + clear_platform_camera(); + assert!(!camera_supported()); + install_native_camera(); + assert!(camera_supported()); + clear_platform_camera(); + } + + #[test] + fn a_lens_id_that_is_not_a_device_index_is_refused() { + let camera = NativeCamera::default(); + assert!(!camera.use_lens("front")); + assert!(!camera.use_lens("")); + } +} diff --git a/crates/cranpose-services/src/lib.rs b/crates/cranpose-services/src/lib.rs index 783ee6cad..1799f6e74 100644 --- a/crates/cranpose-services/src/lib.rs +++ b/crates/cranpose-services/src/lib.rs @@ -1,7 +1,7 @@ //! Multiplatform service abstractions used by Cranpose applications. #[cfg(test)] -use cranpose_core::{Composition, MemoryApplier, location_key}; +use cranpose_core::{location_key, Composition, MemoryApplier}; pub mod app_info; pub mod app_update; @@ -39,122 +39,125 @@ pub mod uri_handler; pub mod writable_folder; pub use app_info::{ - AppInfo, AppInfoRef, app_info, build_version, clear_platform_app_info, set_platform_app_info, - version_name, + app_info, build_version, clear_platform_app_info, set_platform_app_info, version_name, AppInfo, + AppInfoRef, }; pub use app_update::{ - AppUpdateCapabilities, AppUpdateError, AppUpdateObserver, AppUpdateStatus, AppUpdater, - AppUpdaterRef, DigestAlgorithm, DigestVerifier, GitHubReleaseUpdate, PackageDigest, - UpdatePackage, app_update_capabilities, app_update_checks_supported, app_update_status, - app_updates_supported, check_for_app_update, clear_platform_app_updater, install_app_update, + app_update_capabilities, app_update_checks_supported, app_update_status, app_updates_supported, + check_for_app_update, clear_platform_app_updater, install_app_update, observe_app_update_status, set_app_update_status, set_platform_app_updater, sha256_hex, - verify_package, + verify_package, AppUpdateCapabilities, AppUpdateError, AppUpdateObserver, AppUpdateStatus, + AppUpdater, AppUpdaterRef, DigestAlgorithm, DigestVerifier, GitHubReleaseUpdate, PackageDigest, + UpdatePackage, }; -pub use async_io::{ChunkChannel, ChunkNext, ChunkStream, MAX_PENDING_CHUNKS, Signal, SignalWait}; +pub use async_io::{ChunkChannel, ChunkNext, ChunkStream, Signal, SignalWait, MAX_PENDING_CHUNKS}; pub use audio::{ + clear_platform_audio, default_audio, local_audio, rememberSoundBank, set_platform_audio, AudioBus, AudioClip, AudioError, AudioPlayer, AudioPlayerRef, NoopAudioPlayer, PlaybackParams, ProvideAudio, SoundBank, SoundBankEntry, SoundBankFailure, SoundId, SoundSpec, VoiceId, - clear_platform_audio, default_audio, local_audio, rememberSoundBank, set_platform_audio, }; pub use background::{ - BackgroundActivity, BackgroundActivityRef, BackgroundWorkLease, acquire_background_work, - background_active, background_activity, clear_platform_background_activity, - set_platform_background_activity, + acquire_background_work, background_active, background_activity, + clear_platform_background_activity, set_platform_background_activity, BackgroundActivity, + BackgroundActivityRef, BackgroundWorkLease, }; -#[cfg(not(target_arch = "wasm32"))] pub use bundled_assets::{ - BundledAssetEntry, BundledAssetInstallOutcome, BundledAssetInstallSpec, - install_bundled_asset_set, + bundled_assets, clear_platform_bundled_assets, set_platform_bundled_assets, BundledAssetError, + BundledAssetReader, BundledAssets, BundledAssetsRef, StreamingAssetReader, }; +#[cfg(not(target_arch = "wasm32"))] pub use bundled_assets::{ - BundledAssetError, BundledAssetReader, BundledAssets, BundledAssetsRef, StreamingAssetReader, - bundled_assets, clear_platform_bundled_assets, set_platform_bundled_assets, + install_bundled_asset_set, BundledAssetEntry, BundledAssetInstallOutcome, + BundledAssetInstallSpec, }; pub use camera::{ - Camera, CameraError, CameraFrame, CameraLens, CameraObserver, CameraRef, CameraState, - CameraStill, FlashMode, FrameFormat, camera, camera_state, camera_supported, - capture_camera_still, clear_platform_camera, dropped_camera_frames, latest_camera_frame, - observe_camera_frames, observe_camera_state, observe_camera_stills, publish_camera_frame, - publish_camera_state, publish_camera_still, record_dropped_camera_frame, rememberCameraFrames, - rememberCameraState, rememberCameraStills, request_camera_still, set_platform_camera, - start_camera, stop_camera, -}; + camera, camera_lenses, camera_state, camera_supported, capture_camera_still, + clear_platform_camera, dropped_camera_frames, latest_camera_frame, observe_camera_frames, + observe_camera_lenses, observe_camera_state, observe_camera_stills, publish_camera_frame, + publish_camera_lenses, publish_camera_state, publish_camera_still, + record_dropped_camera_frame, rememberCameraFrames, rememberCameraLenses, rememberCameraState, + rememberCameraStills, request_camera_still, set_platform_camera, start_camera, stop_camera, + Camera, CameraError, CameraFrame, CameraLens, CameraLenses, CameraObserver, CameraRef, + CameraState, CameraStill, FlashMode, FrameFormat, LensFacing, UprightRgba, +}; +#[cfg(all( + not(target_arch = "wasm32"), + not(target_os = "android"), + not(target_os = "ios"), + feature = "camera-native" +))] +pub use camera::install_native_camera; pub use content::{ - BytesContent, Content, ContentChannel, ContentEntry, ContentError, ContentFolder, - ContentFolderRef, ContentFuture, ContentHandle, ContentMetadata, ContentReader, - ContentReaderRef, ContentResolver, ContentResolverRef, ContentSink, ContentSinkRef, - ContentStream, ContentStreamRef, DEFAULT_CHUNK_LEN, ReadyFolder, clear_platform_content_resolver, collect_stream, drain_reader, folder_files, percent_decode, - percent_decode_lossy, resolve_content, set_platform_content_resolver, write_all, + percent_decode_lossy, resolve_content, set_platform_content_resolver, write_all, BytesContent, + Content, ContentChannel, ContentEntry, ContentError, ContentFolder, ContentFolderRef, + ContentFuture, ContentHandle, ContentMetadata, ContentReader, ContentReaderRef, + ContentResolver, ContentResolverRef, ContentSink, ContentSinkRef, ContentStream, + ContentStreamRef, ReadyFolder, DEFAULT_CHUNK_LEN, }; #[cfg(not(target_arch = "wasm32"))] -pub use content::{FileContent, FileFolder, FileSink, file_content, file_folder}; +pub use content::{file_content, file_folder, FileContent, FileFolder, FileSink}; pub use device_info::{ - DeviceInfo, DeviceInfoRef, clear_platform_device_info, device_info, release_free_memory, - set_platform_device_info, + clear_platform_device_info, device_info, release_free_memory, set_platform_device_info, + DeviceInfo, DeviceInfoRef, }; pub use file_picker::{ + clear_platform_file_picker, default_file_picker, local_file_picker, set_platform_file_picker, FileFilter, FilePicker, FilePickerError, FilePickerOptions, FilePickerRef, PickerFuture, - ProvideFilePicker, RecoveredPick, SaveDocumentRequest, clear_platform_file_picker, - default_file_picker, local_file_picker, set_platform_file_picker, + ProvideFilePicker, RecoveredPick, SaveDocumentRequest, }; #[cfg(not(target_arch = "wasm32"))] pub use github_release_updater::GitHubAppUpdater; pub use haptics::{ - HapticEffect, HapticError, HapticFeedback, HapticPattern, Haptics, HapticsRef, ProvideHaptics, - clear_platform_haptics, default_haptics, local_haptics, set_platform_haptics, + clear_platform_haptics, default_haptics, local_haptics, set_platform_haptics, HapticEffect, + HapticError, HapticFeedback, HapticPattern, Haptics, HapticsRef, ProvideHaptics, }; pub use host::{ - DEFAULT_DURABLE_SAVE_DEADLINE, DurableSaveEffect, DurableSaveOutcome, DurableSaveRegistration, - HostController, HostControllerRef, LifecycleEvent, LifecycleObserver, LifecycleState, - PlatformDirectories, PlatformDirectoryError, ProvideLifecycle, application_directories, - application_id, background_app, clear_application_id, clear_host_controller, - current_lifecycle_state, dispatch_lifecycle, dispatch_lifecycle_state, exit_app, - host_controller, local_lifecycle_state, observe_lifecycle, register_durable_save, + application_directories, application_id, background_app, clear_application_id, + clear_host_controller, current_lifecycle_state, dispatch_lifecycle, dispatch_lifecycle_state, + exit_app, host_controller, local_lifecycle_state, observe_lifecycle, register_durable_save, rememberLifecycleEvents, rememberLifecycleState, set_application_id, set_host_controller, - set_keep_screen_on, + set_keep_screen_on, DurableSaveEffect, DurableSaveOutcome, DurableSaveRegistration, + HostController, HostControllerRef, LifecycleEvent, LifecycleObserver, LifecycleState, + PlatformDirectories, PlatformDirectoryError, ProvideLifecycle, DEFAULT_DURABLE_SAVE_DEADLINE, }; #[cfg(not(target_arch = "wasm32"))] pub use host::{durable_save_deadline, run_durable_saves}; pub use host_surface::{ - HostSurface, HostSurfaceObserver, HostSurfaceRef, HostSurfaceSize, ResizeRefused, clear_platform_host_surface, host_surface, host_surface_size, observe_host_surface_size, publish_host_surface_size, rememberHostSurfaceSize, request_host_surface_size, - set_platform_host_surface, + set_platform_host_surface, HostSurface, HostSurfaceObserver, HostSurfaceRef, HostSurfaceSize, + ResizeRefused, }; pub use http::{ - BytesBody, HttpBody, HttpBodyRef, HttpClient, HttpClientRef, HttpControl, HttpError, - HttpFuture, HttpMethod, HttpProgress, HttpRequest, HttpResponse, ProgressHandler, StubAnswer, - StubHttpClient, default_http_client, local_http_client, map_ordered_concurrent, + default_http_client, local_http_client, map_ordered_concurrent, BytesBody, HttpBody, + HttpBodyRef, HttpClient, HttpClientRef, HttpControl, HttpError, HttpFuture, HttpMethod, + HttpProgress, HttpRequest, HttpResponse, ProgressHandler, StubAnswer, StubHttpClient, }; pub use image_picker::{ - IMAGE_EXTENSIONS, ImagePicker, ImagePickerError, ImagePickerRef, ImageSource, - ProvideImagePicker, clear_platform_image_picker, default_image_picker, local_image_picker, - set_platform_image_picker, + clear_platform_image_picker, default_image_picker, local_image_picker, + set_platform_image_picker, ImagePicker, ImagePickerError, ImagePickerRef, ImageSource, + ProvideImagePicker, IMAGE_EXTENSIONS, }; pub use incoming_share::{ - IncomingContent, IncomingContentObserver, IncomingSource, clear_incoming_content, - observe_incoming_content, publish_incoming_content, rememberIncomingContent, + clear_incoming_content, observe_incoming_content, publish_incoming_content, + rememberIncomingContent, IncomingContent, IncomingContentObserver, IncomingSource, }; pub use launch_args::{ - LaunchArgValue, LaunchArgs, LaunchArgsRef, ProvideLaunchArgs, clear_platform_launch_args, - is_debuggable, isDebuggable, launch_args, launch_args_from_command_line, local_launch_args, - set_platform_launch_args, + clear_platform_launch_args, isDebuggable, is_debuggable, launch_args, + launch_args_from_command_line, local_launch_args, set_platform_launch_args, LaunchArgValue, + LaunchArgs, LaunchArgsRef, ProvideLaunchArgs, }; pub use launcher::{ + clear_launcher_state, rememberOpenFileLauncher, rememberOpenFilesLauncher, + rememberOpenFolderLauncher, rememberSaveDocumentLauncher, rememberWritableFolderLauncher, LauncherResult, OpenFileLauncher, OpenFilesLauncher, OpenFolderLauncher, SaveDocumentLauncher, - WritableFolderLauncher, clear_launcher_state, rememberOpenFileLauncher, - rememberOpenFilesLauncher, rememberOpenFolderLauncher, rememberSaveDocumentLauncher, - rememberWritableFolderLauncher, + WritableFolderLauncher, }; pub use media::{ - AudioFocus, DUCKED_GAIN, EqualizerBand, EqualizerSettings, MediaArtwork, MediaCapabilities, - MediaCommand, MediaError, MediaItem, MediaMetadata, MediaObserver, MediaPlayer, MediaPlayerRef, - MediaSamples, MediaSourceHandle, MediaSourceOpener, MediaSourceOpenerRef, - OCTAVE_BAND_CENTERS_HZ, PlaybackProgress, PlaybackState, audio_focus, - clear_platform_media_player, clear_platform_media_source_opener, current_media_item, - dropped_media_samples, latest_media_samples, media_capabilities, media_equalizer, - media_equalizer_bands, media_playback_supported, media_player, media_volume, + audio_focus, clear_platform_media_player, clear_platform_media_source_opener, + current_media_item, dropped_media_samples, latest_media_samples, media_capabilities, + media_equalizer, media_equalizer_bands, media_playback_supported, media_player, media_volume, observe_audio_focus, observe_media_commands, observe_media_samples, observe_playback_progress, observe_playback_state, octave_equalizer_bands, open_media, open_media_source, path_from_uri, pause_media, play_media, playback_progress, playback_state, probe_media_duration, @@ -164,61 +167,65 @@ pub use media::{ seek_media, seek_media_fraction, set_media_analysis_enabled, set_media_equalizer, set_media_looping, set_media_metadata, set_media_speed, set_media_volume, set_platform_media_player, set_platform_media_source_opener, stop_media, toggle_media, - uri_for_path, + uri_for_path, AudioFocus, EqualizerBand, EqualizerSettings, MediaArtwork, MediaCapabilities, + MediaCommand, MediaError, MediaItem, MediaMetadata, MediaObserver, MediaPlayer, MediaPlayerRef, + MediaSamples, MediaSourceHandle, MediaSourceOpener, MediaSourceOpenerRef, PlaybackProgress, + PlaybackState, DUCKED_GAIN, OCTAVE_BAND_CENTERS_HZ, }; pub use navigation::{ - BackRequestObserver, back_interception_enabled, exit_requested, observe_back_requests, - push_back_request, request_exit, set_back_interception, take_back_requests, take_exit_request, + back_interception_enabled, exit_requested, observe_back_requests, push_back_request, + request_exit, set_back_interception, take_back_requests, take_exit_request, + BackRequestObserver, }; pub use network_status::{ - NetworkMonitor, NetworkMonitorRef, NetworkStatus, clear_platform_network_monitor, - network_monitor, network_status, set_platform_network_monitor, + clear_platform_network_monitor, network_monitor, network_status, set_platform_network_monitor, + NetworkMonitor, NetworkMonitorRef, NetworkStatus, }; pub use notifier::{ - Notifier, NotifierRef, NotifyRequest, ProvideNotifier, clear_platform_notifier, - default_notifier, local_notifier, push_notification_deeplink, set_platform_notifier, - take_notification_deeplink, + clear_platform_notifier, default_notifier, local_notifier, push_notification_deeplink, + set_platform_notifier, take_notification_deeplink, Notifier, NotifierRef, NotifyRequest, + ProvideNotifier, }; #[cfg(not(target_arch = "wasm32"))] pub use peer::{ - ByteSource, BytesSource, FetchResult, PeerError, PeerServer, SourceResolver, content_length, - fetch_range, fetch_to_writer, + content_length, fetch_range, fetch_to_writer, ByteSource, BytesSource, FetchResult, PeerError, + PeerServer, SourceResolver, }; pub use power::{ + clear_platform_power_monitor, observe_power_state, power_capabilities, power_monitor, + power_state, publish_power_state, rememberPowerState, set_platform_power_monitor, BatteryStatus, PowerCapabilities, PowerMonitor, PowerMonitorRef, PowerObserverRegistration, - PowerReading, PowerState, ThermalState, clear_platform_power_monitor, observe_power_state, - power_capabilities, power_monitor, power_state, publish_power_state, rememberPowerState, - set_platform_power_monitor, + PowerReading, PowerState, ThermalState, }; #[cfg(all(target_arch = "wasm32", feature = "preferences-web"))] pub use preferences::BrowserPreferences; #[cfg(not(target_arch = "wasm32"))] pub use preferences::FilePreferences; pub use preferences::{ - MemoryPreferences, PreferencesError, PreferencesRef, PreferencesStore, Saver, clear_platform_preferences, preferences, rememberSaveable, set_platform_preferences, + MemoryPreferences, PreferencesError, PreferencesRef, PreferencesStore, Saver, }; pub use purchases::{ - Product, PurchaseEvent, Purchases, PurchasesRef, StoreObserver, StorePhase, StoreState, clear_platform_purchases, note_store_news, observe_store_news, purchases, rememberPurchaseEvents, rememberStoreState, set_platform_purchases, store_available, - store_state, + store_state, Product, PurchaseEvent, Purchases, PurchasesRef, StoreObserver, StorePhase, + StoreState, }; pub use share_sheet::{ - ProvideShareSheet, ShareContent, ShareError, ShareSheet, ShareSheetRef, clear_platform_share_sheet, default_share_sheet, local_share_sheet, set_platform_share_sheet, + ProvideShareSheet, ShareContent, ShareError, ShareSheet, ShareSheetRef, }; pub use theme::{ - ProvideSystemTheme, SystemTheme, clear_platform_system_theme, default_system_theme, - isSystemInDarkTheme, local_system_theme, set_platform_system_theme, + clear_platform_system_theme, default_system_theme, isSystemInDarkTheme, local_system_theme, + set_platform_system_theme, ProvideSystemTheme, SystemTheme, }; pub use uri_handler::{ - ProvideUriHandler, UriHandler, UriHandlerError, UriHandlerRef, clear_platform_uri_handler, - default_uri_handler, local_uri_handler, set_platform_uri_handler, + clear_platform_uri_handler, default_uri_handler, local_uri_handler, set_platform_uri_handler, + ProvideUriHandler, UriHandler, UriHandlerError, UriHandlerRef, }; pub use writable_folder::{ - FolderEntry, FolderError, FolderReader, FolderWriter, WritableFolderStore, - WritableFolderStoreRef, open_writable_folder, set_writable_folder_store_factory, + open_writable_folder, set_writable_folder_store_factory, FolderEntry, FolderError, + FolderReader, FolderWriter, WritableFolderStore, WritableFolderStoreRef, }; /// Convenience alias used in unit tests. diff --git a/crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java b/crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java index af8d73d1f..223ef37cb 100644 --- a/crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java +++ b/crates/cranpose/android/java/dev/cranpose/android/CranposeActivity.java @@ -87,6 +87,7 @@ private static native void nativeOnCameraFrame(byte[] nv12, int width, int heigh private static native void nativeOnCameraFrameDropped(); private static native void nativeOnCameraState(int kind, String detail); private static native void nativeOnCameraStill(byte[] jpeg, String error); + private static native void nativeOnCameraLenses(String list, String active); /** One preview frame, in the format the sensor produced. */ static void onCameraFrame( @@ -119,6 +120,14 @@ static void onCameraStill(byte[] jpeg, String error) { nativeOnCameraStill(jpeg, error == null ? "" : error); } + /** + * The devices the application may pick between, one {@code id|facing|name} + * per line, and the id of the one in use. + */ + static void onCameraLenses(String list, String active) { + nativeOnCameraLenses(list == null ? "" : list, active == null ? "" : active); + } + private static final int CAMERA_RUNNING = 1; private static final int CAMERA_STOPPED = 2; private static final int CAMERA_FAILED = 3; diff --git a/crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java b/crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java index 6bff882ef..b9b06c3f4 100644 --- a/crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java +++ b/crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java @@ -62,6 +62,7 @@ final class CranposeCamera { private volatile String openId = null; private volatile int flash = 0; private volatile int rotationDegrees = 0; + private android.hardware.display.DisplayManager.DisplayListener displayListener; CranposeCamera(Activity activity) { this.activity = activity; @@ -96,14 +97,13 @@ private static boolean takesPictures(CameraCharacteristics chars) { return false; } - private java.util.List backIds() { + private java.util.List facingIds(int wanted) { java.util.List ids = new java.util.ArrayList<>(); try { for (String id : manager().getCameraIdList()) { CameraCharacteristics chars = manager().getCameraCharacteristics(id); Integer facing = chars.get(CameraCharacteristics.LENS_FACING); - if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK - && takesPictures(chars)) { + if (facing != null && facing == wanted && takesPictures(chars)) { ids.add(id); } } @@ -122,22 +122,38 @@ && takesPictures(chars)) { return ids; } + private java.util.List backIds() { + return facingIds(CameraCharacteristics.LENS_FACING_BACK); + } + + private java.util.List frontIds() { + return facingIds(CameraCharacteristics.LENS_FACING_FRONT); + } + + /** + * One device per line as {@code id|facing|name}. Back lenses first, + * widest first, then the front ones, matching the order the framework + * documents for {@code Camera::lenses}. + */ String lensList() { - java.util.List ids = backIds(); + java.util.List backs = backIds(); + java.util.List fronts = frontIds(); String[] wideNames = {"Ultra wide", "Wide", "Tele"}; StringBuilder out = new StringBuilder(); - for (int i = 0; i < ids.size(); i++) { + for (int i = 0; i < backs.size(); i++) { String name; - if (ids.size() == 1) { + if (backs.size() == 1) { name = "Back"; - } else if (ids.size() <= wideNames.length) { - name = wideNames[wideNames.length - ids.size() + i]; } else if (i < wideNames.length) { name = wideNames[i]; } else { name = "Lens " + (i + 1); } - out.append(ids.get(i)).append('|').append(name).append('\n'); + out.append(backs.get(i)).append("|back|").append(name).append('\n'); + } + for (int i = 0; i < fronts.size(); i++) { + String name = fronts.size() == 1 ? "Front" : "Front " + (i + 1); + out.append(fronts.get(i)).append("|front|").append(name).append('\n'); } return out.toString(); } @@ -146,6 +162,26 @@ int rotationDegrees() { return rotationDegrees; } + /** + * How far a frame from device {@code id} must be turned clockwise to be + * upright: the sensor's mounting against the display's rotation, with the + * sign flipped for the mirrored front sensor. + */ + private int rotationFor(String id) { + try { + CameraCharacteristics chars = manager().getCameraCharacteristics(id); + Integer sensorOrientation = chars.get(CameraCharacteristics.SENSOR_ORIENTATION); + Integer facing = chars.get(CameraCharacteristics.LENS_FACING); + int sensor = sensorOrientation == null ? 0 : sensorOrientation; + if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) { + return (sensor + displayRotationDegrees()) % 360; + } + return (sensor - displayRotationDegrees() + 360) % 360; + } catch (Exception e) { + return 0; + } + } + @SuppressWarnings("deprecation") private int displayRotationDegrees() { android.view.Display display = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R @@ -183,10 +219,14 @@ synchronized void useLens(String id) { return; } chosenId = id; - boolean wasOpen = open; - stop(); - if (wasOpen) { + if (open) { + // The device changes without a Stopped in between, so the + // viewfinder keeps the last frame instead of blanking while the + // new device opens. + closeSession(); start(); + } else { + CranposeActivity.onCameraLenses(lensList(), currentLens()); } } @@ -264,7 +304,7 @@ synchronized void start() { CameraManager manager = manager(); java.util.List ids = backIds(); String backId = null; - if (chosenId != null && ids.contains(chosenId)) { + if (chosenId != null && (ids.contains(chosenId) || frontIds().contains(chosenId))) { backId = chosenId; } else if (ids.size() > 1) { backId = ids.get(1); @@ -288,9 +328,8 @@ synchronized void start() { } openId = backId; CameraCharacteristics chars = manager.getCameraCharacteristics(backId); - Integer sensorOrientation = chars.get(CameraCharacteristics.SENSOR_ORIENTATION); - rotationDegrees = ((sensorOrientation == null ? 0 : sensorOrientation) - - displayRotationDegrees() + 360) % 360; + rotationDegrees = rotationFor(backId); + watchDisplay(); StreamConfigurationMap streamMap = chars.get( CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); if (streamMap == null) { @@ -349,6 +388,8 @@ public void onConfigured(CameraCaptureSession s) { applyFlash(request, false); s.setRepeatingRequest(request.build(), null, cameraHandler); CranposeActivity.onCameraRunning(openId == null ? "" : openId); + CranposeActivity.onCameraLenses( + lensList(), openId == null ? "" : openId); } catch (Exception error) { CranposeActivity.onCameraFailed(String.valueOf(error.getMessage())); stop(); @@ -440,8 +481,14 @@ synchronized void takeStill() { } synchronized void stop() { + closeSession(); + CranposeActivity.onCameraStopped(); + } + + private synchronized void closeSession() { open = false; openId = null; + unwatchDisplay(); try { if (session != null) { session.close(); @@ -472,7 +519,49 @@ synchronized void stop() { } catch (Exception ignored) { } deliveringFrame.set(false); - CranposeActivity.onCameraStopped(); + } + + /** + * Keeps {@link #rotationDegrees} matching the display while the session + * runs. The activity survives a device turn (its manifest handles + * orientation changes), so nothing else recomputes the value. + */ + private void watchDisplay() { + android.hardware.display.DisplayManager displays = + (android.hardware.display.DisplayManager) + activity.getSystemService(Context.DISPLAY_SERVICE); + if (displays == null || displayListener != null) { + return; + } + displayListener = new android.hardware.display.DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) {} + + @Override + public void onDisplayRemoved(int displayId) {} + + @Override + public void onDisplayChanged(int displayId) { + String id = openId; + if (id != null) { + rotationDegrees = rotationFor(id); + } + } + }; + displays.registerDisplayListener(displayListener, cameraHandler); + } + + private void unwatchDisplay() { + if (displayListener == null) { + return; + } + android.hardware.display.DisplayManager displays = + (android.hardware.display.DisplayManager) + activity.getSystemService(Context.DISPLAY_SERVICE); + if (displays != null) { + displays.unregisterDisplayListener(displayListener); + } + displayListener = null; } private static Size choosePreviewSize(Size[] sizes) { diff --git a/crates/cranpose/src/android_camera.rs b/crates/cranpose/src/android_camera.rs index 0805eaeb2..1818102f8 100644 --- a/crates/cranpose/src/android_camera.rs +++ b/crates/cranpose/src/android_camera.rs @@ -14,14 +14,15 @@ use std::sync::Arc; use cranpose_services::{ - Camera, CameraError, CameraFrame, CameraLens, CameraState, CameraStill, FlashMode, FrameFormat, - publish_camera_frame, publish_camera_state, publish_camera_still, record_dropped_camera_frame, - set_platform_camera, + publish_camera_frame, publish_camera_lenses, publish_camera_state, publish_camera_still, + record_dropped_camera_frame, set_platform_camera, Camera, CameraError, CameraFrame, CameraLens, + CameraLenses, CameraState, CameraStill, FlashMode, FrameFormat, LensFacing, }; use jni::{ - EnvUnowned, Outcome, jni_sig, jni_str, + jni_sig, jni_str, objects::{JByteArray, JClass, JObject, JString, JValue}, sys::{jint, jlong}, + EnvUnowned, Outcome, }; use crate::android_jni::{clear_pending_android_jni_exception, with_android_activity_env}; @@ -95,15 +96,11 @@ impl Camera for AndroidCamera { } fn lenses(&self) -> Vec { - self.call_string(jni_str!("cranposeCameraLenses")) - .unwrap_or_default() - .lines() - .filter_map(|line| line.split_once('|')) - .map(|(id, name)| CameraLens { - id: id.to_string(), - name: name.to_string(), - }) - .collect() + parse_lenses( + &self + .call_string(jni_str!("cranposeCameraLenses")) + .unwrap_or_default(), + ) } fn lens(&self) -> Option { @@ -150,6 +147,27 @@ impl Camera for AndroidCamera { } } +/// The Java side's lens lines, one `id|facing|name` per line. +fn parse_lenses(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let mut parts = line.splitn(3, '|'); + let id = parts.next()?; + let facing = parts.next()?; + let name = parts.next()?; + Some(CameraLens { + id: id.to_string(), + name: name.to_string(), + facing: match facing { + "front" => LensFacing::Front, + "external" => LensFacing::External, + _ => LensFacing::Back, + }, + }) + }) + .collect() +} + /// One preview frame, in the format the sensor produced. #[doc(hidden)] #[unsafe(no_mangle)] @@ -254,3 +272,24 @@ pub extern "system" fn Java_dev_cranpose_android_CranposeActivity_nativeOnCamera })), }); } + +/// The devices the application may pick between, and the one in use. +#[doc(hidden)] +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_cranpose_android_CranposeActivity_nativeOnCameraLenses<'local>( + mut env: EnvUnowned<'local>, + _class: JClass<'local>, + list: JString<'local>, + active: JString<'local>, +) { + let decoded = env.with_env(|env| -> jni::errors::Result<(String, String)> { + Ok((list.try_to_string(env)?, active.try_to_string(env)?)) + }); + let Outcome::Ok((list, active)) = decoded.into_outcome() else { + return; + }; + publish_camera_lenses(CameraLenses { + lenses: parse_lenses(&list), + active: (!active.is_empty()).then_some(active), + }); +} diff --git a/crates/cranpose/src/ios_camera.rs b/crates/cranpose/src/ios_camera.rs index 73f9774f1..cd89d089d 100644 --- a/crates/cranpose/src/ios_camera.rs +++ b/crates/cranpose/src/ios_camera.rs @@ -8,20 +8,21 @@ #![allow(unsafe_code)] use std::{ - sync::{Arc, Mutex, OnceLock, mpsc}, + sync::{mpsc, Arc, Mutex, OnceLock}, time::Duration, }; use block2::RcBlock; use cranpose_services::{ - Camera, CameraError, CameraFrame, CameraLens, CameraState, CameraStill, FlashMode, FrameFormat, - set_platform_camera, + publish_camera_lenses, set_platform_camera, Camera, CameraError, CameraFrame, CameraLens, + CameraLenses, CameraState, CameraStill, FlashMode, FrameFormat, LensFacing, }; use dispatch2::{DispatchQueue, DispatchRetained}; use objc2::{ - AllocAnyThread, define_class, msg_send, + define_class, msg_send, rc::Retained, runtime::{AnyObject, Bool, ProtocolObject}, + AllocAnyThread, }; use objc2_av_foundation::{ AVCaptureAutoFocusRangeRestriction, AVCaptureConnection, AVCaptureDevice, @@ -39,9 +40,9 @@ use objc2_av_foundation::{ }; use objc2_core_media::CMSampleBuffer; use objc2_core_video::{ - CVBuffer, CVPixelBuffer, CVPixelBufferGetBaseAddress, CVPixelBufferGetBytesPerRow, - CVPixelBufferGetHeight, CVPixelBufferGetWidth, CVPixelBufferLockBaseAddress, - CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, kCVPixelBufferPixelFormatTypeKey, + kCVPixelBufferPixelFormatTypeKey, CVBuffer, CVPixelBuffer, CVPixelBufferGetBaseAddress, + CVPixelBufferGetBytesPerRow, CVPixelBufferGetHeight, CVPixelBufferGetWidth, + CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, }; use objc2_foundation::{ NSArray, NSDictionary, NSError, NSNumber, NSObject, NSObjectProtocol, NSString, @@ -114,6 +115,7 @@ impl Camera for IosCamera { } let device = start_session()?; cranpose_services::publish_camera_state(CameraState::Running { device }); + publish_lenses(self.lens()); Ok(()) } @@ -162,13 +164,7 @@ impl Camera for IosCamera { } fn lenses(&self) -> Vec { - back_lenses() - .into_iter() - .map(|device| CameraLens { - id: unsafe { device.uniqueID() }.to_string(), - name: lens_name(&device), - }) - .collect() + all_lenses() } fn lens(&self) -> Option { @@ -181,10 +177,7 @@ impl Camera for IosCamera { } fn use_lens(&self, id: &str) -> bool { - if !back_lenses() - .iter() - .any(|device| unsafe { device.uniqueID() }.to_string() == id) - { + if !all_lenses().iter().any(|lens| lens.id == id) { return false; } let running = session_slot().lock().map(|s| s.is_some()).unwrap_or(false); @@ -193,10 +186,20 @@ impl Camera for IosCamera { Err(_) => return false, } if !running { + publish_lenses(Some(id.to_string())); return true; } + // The device changes without a Stopped in between, so the viewfinder + // keeps the last frame instead of blanking while the new one opens. self.stop(); - start_session().is_ok() + match start_session() { + Ok(device) => { + cranpose_services::publish_camera_state(CameraState::Running { device }); + publish_lenses(Some(id.to_string())); + true + } + Err(_) => false, + } } fn has_flash(&self) -> bool { @@ -235,9 +238,6 @@ impl Camera for IosCamera { /// [`select_camera_device`] opens when the app picks no lens, and listing them /// beside their own constituents would offer the same picture twice. fn back_lenses() -> Vec> { - let Some(media_type) = (unsafe { AVMediaTypeVideo }) else { - return Vec::new(); - }; let types: [&AVCaptureDeviceType; 3] = unsafe { [ AVCaptureDeviceTypeBuiltInUltraWideCamera, @@ -245,17 +245,67 @@ fn back_lenses() -> Vec> { AVCaptureDeviceTypeBuiltInTelephotoCamera, ] }; - let wanted = NSArray::from_slice(&types); + let mut found = discover_devices(&types, AVCaptureDevicePosition::Back); + found.sort_by_key(|device| lens_order(device)); + found +} + +/// The front camera, when the device has one. +fn front_lenses() -> Vec> { + let types: [&AVCaptureDeviceType; 1] = unsafe { [AVCaptureDeviceTypeBuiltInWideAngleCamera] }; + discover_devices(&types, AVCaptureDevicePosition::Front) +} + +fn discover_devices( + types: &[&AVCaptureDeviceType], + position: AVCaptureDevicePosition, +) -> Vec> { + let Some(media_type) = (unsafe { AVMediaTypeVideo }) else { + return Vec::new(); + }; + let wanted = NSArray::from_slice(types); let session = unsafe { AVCaptureDeviceDiscoverySession::discoverySessionWithDeviceTypes_mediaType_position( &wanted, Some(media_type), - AVCaptureDevicePosition::Back, + position, ) }; - let mut found: Vec> = unsafe { session.devices() }.to_vec(); - found.sort_by_key(|device| lens_order(device)); - found + unsafe { session.devices() }.to_vec() +} + +/// Every device the application may pick, back lenses first, widest first, +/// then the front one. +fn all_lenses() -> Vec { + let mut lenses: Vec = back_lenses() + .into_iter() + .map(|device| CameraLens { + id: unsafe { device.uniqueID() }.to_string(), + name: lens_name(&device), + facing: LensFacing::Back, + }) + .collect(); + for (index, device) in front_lenses().into_iter().enumerate() { + lenses.push(CameraLens { + id: unsafe { device.uniqueID() }.to_string(), + name: if index == 0 { + "Front".to_string() + } else { + format!("Front {}", index + 1) + }, + facing: LensFacing::Front, + }); + } + lenses +} + +/// Publishes the lens list and the device in use, so a lens control observes +/// instead of paying a discovery session per recomposition. +fn publish_lenses(active: Option) { + publish_camera_lenses(CameraLenses { + lenses: all_lenses(), + active, + }); } fn lens_order(device: &AVCaptureDevice) -> u8 { @@ -440,10 +490,6 @@ fn frame_from_sample(sample: &CMSampleBuffer) -> Option { let bytes_per_row = CVPixelBufferGetBytesPerRow(pixel_buffer); let base = CVPixelBufferGetBaseAddress(pixel_buffer) as *const u8; - // The sensor delivers landscape buffers; rotate 90° clockwise so the - // in-app viewfinder is upright in portrait. Output is `height` x `width`. - let out_w = height; - let out_h = width; // Recycle a parked buffer when one fits (clear keeps capacity, so after // the first few frames this allocates nothing at all). let mut rgba = buffer_pool() @@ -452,33 +498,34 @@ fn frame_from_sample(sample: &CMSampleBuffer) -> Option { .and_then(|mut pool| pool.pop()) .unwrap_or_default(); rgba.clear(); - rgba.resize(out_w * out_h * 4, 0); + rgba.resize(width * height * 4, 0); if !base.is_null() && bytes_per_row >= width * 4 { - for sy in 0..height { - let src_row = unsafe { base.add(sy * bytes_per_row) }; - let dx = height - 1 - sy; - for sx in 0..width { - let src = unsafe { src_row.add(sx * 4) }; + for y in 0..height { + let src_row = unsafe { base.add(y * bytes_per_row) }; + let out_row = &mut rgba[y * width * 4..(y + 1) * width * 4]; + for x in 0..width { + let src = unsafe { src_row.add(x * 4) }; let (b, g, r, a) = unsafe { (*src, *src.add(1), *src.add(2), *src.add(3)) }; - // 90° clockwise: src(sx, sy) -> dst(height-1-sy, sx). - let dst = (sx * out_w + dx) * 4; - rgba[dst] = r; - rgba[dst + 1] = g; - rgba[dst + 2] = b; - rgba[dst + 3] = a; + let dst = x * 4; + out_row[dst] = r; + out_row[dst + 1] = g; + out_row[dst + 2] = b; + out_row[dst + 3] = a; } } } unsafe { CVPixelBufferUnlockBaseAddress(pixel_buffer, flags) }; - // Already upright and already RGBA: the rotation happened in the same pass - // as the colour conversion above, so the frame needs no further turning. + // The sensor delivers landscape buffers; the app runs in portrait, so the + // frame carries a 90° clockwise turn. The turn is metadata rather than a + // pixel pass here: `CameraFrame::upright_rgba8` fuses it with whatever + // conversion the consumer does anyway, the same as on Android. CameraFrame::new( - out_w as u32, - out_h as u32, + width as u32, + height as u32, FrameFormat::Rgba8, - 0, + 90, FRAME_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::AcqRel), rgba, ) diff --git a/docs/capability_parity.md b/docs/capability_parity.md index 9ef29338e..bc33bc248 100644 --- a/docs/capability_parity.md +++ b/docs/capability_parity.md @@ -30,7 +30,7 @@ never silently pretend. | safe-area insets | ■ | ■ WindowInsets listener → `local_safe_area_insets` | ● zero | ● zero | replaced cranscan's marker-file bridge | | system theme | ■ `window.theme()` polled | ■ uiMode + ConfigChanged | ■ winit `ThemeChanged` (+ cached env probe) | ■ `prefers-color-scheme` listener | drives LiquidTheme Auto | | image picker | ■ | ● file-picker fallback | ● | ● | camera source stays iOS-only for now | -| camera | ■ `AVCaptureSession` | ■ Camera2 (`CranposeCamera`) | □ | □ | frames pushed as `CameraFrame`, NV12 on Android and RGBA on iOS; observable state, bounded latest-wins analysis stream, stills asked for rather than waited on | +| camera | ■ `AVCaptureSession` | ■ Camera2 (`CranposeCamera`) | ■ nokhwa (`camera-native`) | □ | frames pushed as `CameraFrame` with the turn carried as `rotation_degrees` (`upright_rgba8` applies it in the conversion pass); observable state and lens list (`CameraLenses`, `LensFacing`); bounded latest-wins analysis stream; stills asked for rather than waited on | | background activity | ■ | □ (FGS is app policy) | □ | □ | documented | | file save dialog | □ (export picker still open) | ■ ACTION_CREATE_DOCUMENT | ■ rfd save | ■ browser download | `FilePicker::save_file`; killed cranscan's direct rfd | | launch arguments | ● argv (`simctl launch`, `launchArguments`) | ■ intent extras + `onNewIntent` | ● argv | □ (query string still open) | `launch_args()`; `is_debuggable()` = `FLAG_DEBUGGABLE` on Android, `debug_assertions` elsewhere | From e209308032ecee03eac3023a95a935560fb8aadb Mon Sep 17 00:00:00 2001 From: Dmitry Samoylenko Date: Thu, 27 Aug 2026 14:20:35 +0200 Subject: [PATCH 2/4] Let a stopped desktop session release its device without holding up the caller The capture thread sits inside a platform read for as long as that read takes, so joining it where the session is stopped holds up the screen being left. The thread is told to end and parked; the next session opening waits for it, which is also what keeps two sessions from holding one device. A lens switch that cannot reopen now says so instead of leaving a Running nobody serves. Co-Authored-By: Claude Fable 5 --- crates/cranpose-services/src/camera/native.rs | 55 ++++-- crates/cranpose-services/src/lib.rs | 185 +++++++++--------- crates/cranpose/src/android_camera.rs | 9 +- crates/cranpose/src/ios_camera.rs | 22 ++- .../tests/platform_scheduling_static.rs | 30 +++ 5 files changed, 179 insertions(+), 122 deletions(-) diff --git a/crates/cranpose-services/src/camera/native.rs b/crates/cranpose-services/src/camera/native.rs index cd1381b89..fab6156ca 100644 --- a/crates/cranpose-services/src/camera/native.rs +++ b/crates/cranpose-services/src/camera/native.rs @@ -10,22 +10,22 @@ use std::{ sync::{ - atomic::{AtomicBool, Ordering}, Arc, Mutex, + atomic::{AtomicBool, Ordering}, }, thread::JoinHandle, }; use nokhwa::{ + Camera as CaptureDevice, pixel_format::RgbFormat, utils::{ApiBackend, CameraIndex, RequestedFormat, RequestedFormatType}, - Camera as CaptureDevice, }; use super::{ - publish_camera_frame, publish_camera_lenses, publish_camera_state, record_dropped_camera_frame, - set_platform_camera, Camera, CameraError, CameraFrame, CameraLens, CameraLenses, CameraState, - FrameFormat, LensFacing, + Camera, CameraError, CameraFrame, CameraLens, CameraLenses, CameraState, FrameFormat, + LensFacing, publish_camera_frame, publish_camera_lenses, publish_camera_state, + record_dropped_camera_frame, set_platform_camera, }; /// Installs the built-in desktop camera as the platform camera. @@ -59,12 +59,15 @@ fn list_lenses() -> Vec { struct Session { running: Arc, - thread: Option>, + thread: JoinHandle<()>, } #[derive(Default)] struct NativeCamera { session: Mutex>, + /// Capture threads told to end, waited for by the next [`NativeCamera::open`] + /// rather than by whoever stopped the session. + retiring: Mutex>>, /// The device the application picked, kept across stop and start. chosen: Mutex>, /// The device the open session uses, written by the capture thread. @@ -80,6 +83,13 @@ impl NativeCamera { if session.is_some() { return Ok(()); } + // The previous thread holds the device until it returns, so a new one + // waits for it here rather than racing the platform for the camera. + if let Ok(mut retiring) = self.retiring.lock() { + for thread in retiring.drain(..) { + let _ = thread.join(); + } + } let chosen = self.chosen.lock().ok().and_then(|chosen| *chosen); let running = Arc::new(AtomicBool::new(true)); let thread_running = Arc::clone(&running); @@ -88,25 +98,26 @@ impl NativeCamera { .name("cranpose-camera".into()) .spawn(move || capture_loop(chosen, thread_running, active)) .map_err(|error| CameraError::Failed(error.to_string()))?; - *session = Some(Session { - running, - thread: Some(thread), - }); + *session = Some(Session { running, thread }); Ok(()) } - /// Ends the capture thread and waits for it to release the device, so a - /// restart does not race the platform over who holds the camera. + /// Tells the capture thread to end and returns. + /// + /// The thread is left to finish its frame and release the device on its + /// own: it is blocked inside a platform read for as long as that read + /// takes, and a stop that waited for it would hold up whoever asked — + /// which is the screen being left. fn close(&self) { let taken = self .session .lock() .ok() .and_then(|mut session| session.take()); - if let Some(mut session) = taken { + if let Some(session) = taken { session.running.store(false, Ordering::Relaxed); - if let Some(thread) = session.thread.take() { - let _ = thread.join(); + if let Ok(mut retiring) = self.retiring.lock() { + retiring.push(session.thread); } } if let Ok(mut active) = self.active.lock() { @@ -152,12 +163,24 @@ impl Camera for NativeCamera { .map(|session| session.is_some()) .unwrap_or(false); if !running { + publish_camera_lenses(CameraLenses { + lenses: list_lenses(), + active: Some(id.to_string()), + }); return true; } // The device changes without a Stopped in between, so the viewfinder // keeps the last frame instead of blanking while the new one opens. self.close(); - self.open().is_ok() + match self.open() { + Ok(()) => true, + Err(error) => { + // The old device is already released, so a screen that heard + // Running must hear that there is nothing running now. + publish_camera_state(CameraState::Failed(error)); + false + } + } } } diff --git a/crates/cranpose-services/src/lib.rs b/crates/cranpose-services/src/lib.rs index 1799f6e74..e47f3f519 100644 --- a/crates/cranpose-services/src/lib.rs +++ b/crates/cranpose-services/src/lib.rs @@ -1,7 +1,7 @@ //! Multiplatform service abstractions used by Cranpose applications. #[cfg(test)] -use cranpose_core::{location_key, Composition, MemoryApplier}; +use cranpose_core::{Composition, MemoryApplier, location_key}; pub mod app_info; pub mod app_update; @@ -39,46 +39,36 @@ pub mod uri_handler; pub mod writable_folder; pub use app_info::{ - app_info, build_version, clear_platform_app_info, set_platform_app_info, version_name, AppInfo, - AppInfoRef, + AppInfo, AppInfoRef, app_info, build_version, clear_platform_app_info, set_platform_app_info, + version_name, }; pub use app_update::{ - app_update_capabilities, app_update_checks_supported, app_update_status, app_updates_supported, - check_for_app_update, clear_platform_app_updater, install_app_update, + AppUpdateCapabilities, AppUpdateError, AppUpdateObserver, AppUpdateStatus, AppUpdater, + AppUpdaterRef, DigestAlgorithm, DigestVerifier, GitHubReleaseUpdate, PackageDigest, + UpdatePackage, app_update_capabilities, app_update_checks_supported, app_update_status, + app_updates_supported, check_for_app_update, clear_platform_app_updater, install_app_update, observe_app_update_status, set_app_update_status, set_platform_app_updater, sha256_hex, - verify_package, AppUpdateCapabilities, AppUpdateError, AppUpdateObserver, AppUpdateStatus, - AppUpdater, AppUpdaterRef, DigestAlgorithm, DigestVerifier, GitHubReleaseUpdate, PackageDigest, - UpdatePackage, + verify_package, }; -pub use async_io::{ChunkChannel, ChunkNext, ChunkStream, Signal, SignalWait, MAX_PENDING_CHUNKS}; +pub use async_io::{ChunkChannel, ChunkNext, ChunkStream, MAX_PENDING_CHUNKS, Signal, SignalWait}; pub use audio::{ - clear_platform_audio, default_audio, local_audio, rememberSoundBank, set_platform_audio, AudioBus, AudioClip, AudioError, AudioPlayer, AudioPlayerRef, NoopAudioPlayer, PlaybackParams, ProvideAudio, SoundBank, SoundBankEntry, SoundBankFailure, SoundId, SoundSpec, VoiceId, + clear_platform_audio, default_audio, local_audio, rememberSoundBank, set_platform_audio, }; pub use background::{ - acquire_background_work, background_active, background_activity, - clear_platform_background_activity, set_platform_background_activity, BackgroundActivity, - BackgroundActivityRef, BackgroundWorkLease, -}; -pub use bundled_assets::{ - bundled_assets, clear_platform_bundled_assets, set_platform_bundled_assets, BundledAssetError, - BundledAssetReader, BundledAssets, BundledAssetsRef, StreamingAssetReader, + BackgroundActivity, BackgroundActivityRef, BackgroundWorkLease, acquire_background_work, + background_active, background_activity, clear_platform_background_activity, + set_platform_background_activity, }; #[cfg(not(target_arch = "wasm32"))] pub use bundled_assets::{ - install_bundled_asset_set, BundledAssetEntry, BundledAssetInstallOutcome, - BundledAssetInstallSpec, + BundledAssetEntry, BundledAssetInstallOutcome, BundledAssetInstallSpec, + install_bundled_asset_set, }; -pub use camera::{ - camera, camera_lenses, camera_state, camera_supported, capture_camera_still, - clear_platform_camera, dropped_camera_frames, latest_camera_frame, observe_camera_frames, - observe_camera_lenses, observe_camera_state, observe_camera_stills, publish_camera_frame, - publish_camera_lenses, publish_camera_state, publish_camera_still, - record_dropped_camera_frame, rememberCameraFrames, rememberCameraLenses, rememberCameraState, - rememberCameraStills, request_camera_still, set_platform_camera, start_camera, stop_camera, - Camera, CameraError, CameraFrame, CameraLens, CameraLenses, CameraObserver, CameraRef, - CameraState, CameraStill, FlashMode, FrameFormat, LensFacing, UprightRgba, +pub use bundled_assets::{ + BundledAssetError, BundledAssetReader, BundledAssets, BundledAssetsRef, StreamingAssetReader, + bundled_assets, clear_platform_bundled_assets, set_platform_bundled_assets, }; #[cfg(all( not(target_arch = "wasm32"), @@ -87,77 +77,92 @@ pub use camera::{ feature = "camera-native" ))] pub use camera::install_native_camera; +pub use camera::{ + Camera, CameraError, CameraFrame, CameraLens, CameraLenses, CameraObserver, CameraRef, + CameraState, CameraStill, FlashMode, FrameFormat, LensFacing, UprightRgba, camera, + camera_lenses, camera_state, camera_supported, capture_camera_still, clear_platform_camera, + dropped_camera_frames, latest_camera_frame, observe_camera_frames, observe_camera_lenses, + observe_camera_state, observe_camera_stills, publish_camera_frame, publish_camera_lenses, + publish_camera_state, publish_camera_still, record_dropped_camera_frame, rememberCameraFrames, + rememberCameraLenses, rememberCameraState, rememberCameraStills, request_camera_still, + set_platform_camera, start_camera, stop_camera, +}; pub use content::{ + BytesContent, Content, ContentChannel, ContentEntry, ContentError, ContentFolder, + ContentFolderRef, ContentFuture, ContentHandle, ContentMetadata, ContentReader, + ContentReaderRef, ContentResolver, ContentResolverRef, ContentSink, ContentSinkRef, + ContentStream, ContentStreamRef, DEFAULT_CHUNK_LEN, ReadyFolder, clear_platform_content_resolver, collect_stream, drain_reader, folder_files, percent_decode, - percent_decode_lossy, resolve_content, set_platform_content_resolver, write_all, BytesContent, - Content, ContentChannel, ContentEntry, ContentError, ContentFolder, ContentFolderRef, - ContentFuture, ContentHandle, ContentMetadata, ContentReader, ContentReaderRef, - ContentResolver, ContentResolverRef, ContentSink, ContentSinkRef, ContentStream, - ContentStreamRef, ReadyFolder, DEFAULT_CHUNK_LEN, + percent_decode_lossy, resolve_content, set_platform_content_resolver, write_all, }; #[cfg(not(target_arch = "wasm32"))] -pub use content::{file_content, file_folder, FileContent, FileFolder, FileSink}; +pub use content::{FileContent, FileFolder, FileSink, file_content, file_folder}; pub use device_info::{ - clear_platform_device_info, device_info, release_free_memory, set_platform_device_info, - DeviceInfo, DeviceInfoRef, + DeviceInfo, DeviceInfoRef, clear_platform_device_info, device_info, release_free_memory, + set_platform_device_info, }; pub use file_picker::{ - clear_platform_file_picker, default_file_picker, local_file_picker, set_platform_file_picker, FileFilter, FilePicker, FilePickerError, FilePickerOptions, FilePickerRef, PickerFuture, - ProvideFilePicker, RecoveredPick, SaveDocumentRequest, + ProvideFilePicker, RecoveredPick, SaveDocumentRequest, clear_platform_file_picker, + default_file_picker, local_file_picker, set_platform_file_picker, }; #[cfg(not(target_arch = "wasm32"))] pub use github_release_updater::GitHubAppUpdater; pub use haptics::{ - clear_platform_haptics, default_haptics, local_haptics, set_platform_haptics, HapticEffect, - HapticError, HapticFeedback, HapticPattern, Haptics, HapticsRef, ProvideHaptics, + HapticEffect, HapticError, HapticFeedback, HapticPattern, Haptics, HapticsRef, ProvideHaptics, + clear_platform_haptics, default_haptics, local_haptics, set_platform_haptics, }; pub use host::{ - application_directories, application_id, background_app, clear_application_id, - clear_host_controller, current_lifecycle_state, dispatch_lifecycle, dispatch_lifecycle_state, - exit_app, host_controller, local_lifecycle_state, observe_lifecycle, register_durable_save, - rememberLifecycleEvents, rememberLifecycleState, set_application_id, set_host_controller, - set_keep_screen_on, DurableSaveEffect, DurableSaveOutcome, DurableSaveRegistration, + DEFAULT_DURABLE_SAVE_DEADLINE, DurableSaveEffect, DurableSaveOutcome, DurableSaveRegistration, HostController, HostControllerRef, LifecycleEvent, LifecycleObserver, LifecycleState, - PlatformDirectories, PlatformDirectoryError, ProvideLifecycle, DEFAULT_DURABLE_SAVE_DEADLINE, + PlatformDirectories, PlatformDirectoryError, ProvideLifecycle, application_directories, + application_id, background_app, clear_application_id, clear_host_controller, + current_lifecycle_state, dispatch_lifecycle, dispatch_lifecycle_state, exit_app, + host_controller, local_lifecycle_state, observe_lifecycle, register_durable_save, + rememberLifecycleEvents, rememberLifecycleState, set_application_id, set_host_controller, + set_keep_screen_on, }; #[cfg(not(target_arch = "wasm32"))] pub use host::{durable_save_deadline, run_durable_saves}; pub use host_surface::{ + HostSurface, HostSurfaceObserver, HostSurfaceRef, HostSurfaceSize, ResizeRefused, clear_platform_host_surface, host_surface, host_surface_size, observe_host_surface_size, publish_host_surface_size, rememberHostSurfaceSize, request_host_surface_size, - set_platform_host_surface, HostSurface, HostSurfaceObserver, HostSurfaceRef, HostSurfaceSize, - ResizeRefused, + set_platform_host_surface, }; pub use http::{ - default_http_client, local_http_client, map_ordered_concurrent, BytesBody, HttpBody, - HttpBodyRef, HttpClient, HttpClientRef, HttpControl, HttpError, HttpFuture, HttpMethod, - HttpProgress, HttpRequest, HttpResponse, ProgressHandler, StubAnswer, StubHttpClient, + BytesBody, HttpBody, HttpBodyRef, HttpClient, HttpClientRef, HttpControl, HttpError, + HttpFuture, HttpMethod, HttpProgress, HttpRequest, HttpResponse, ProgressHandler, StubAnswer, + StubHttpClient, default_http_client, local_http_client, map_ordered_concurrent, }; pub use image_picker::{ - clear_platform_image_picker, default_image_picker, local_image_picker, - set_platform_image_picker, ImagePicker, ImagePickerError, ImagePickerRef, ImageSource, - ProvideImagePicker, IMAGE_EXTENSIONS, + IMAGE_EXTENSIONS, ImagePicker, ImagePickerError, ImagePickerRef, ImageSource, + ProvideImagePicker, clear_platform_image_picker, default_image_picker, local_image_picker, + set_platform_image_picker, }; pub use incoming_share::{ - clear_incoming_content, observe_incoming_content, publish_incoming_content, - rememberIncomingContent, IncomingContent, IncomingContentObserver, IncomingSource, + IncomingContent, IncomingContentObserver, IncomingSource, clear_incoming_content, + observe_incoming_content, publish_incoming_content, rememberIncomingContent, }; pub use launch_args::{ - clear_platform_launch_args, isDebuggable, is_debuggable, launch_args, - launch_args_from_command_line, local_launch_args, set_platform_launch_args, LaunchArgValue, - LaunchArgs, LaunchArgsRef, ProvideLaunchArgs, + LaunchArgValue, LaunchArgs, LaunchArgsRef, ProvideLaunchArgs, clear_platform_launch_args, + is_debuggable, isDebuggable, launch_args, launch_args_from_command_line, local_launch_args, + set_platform_launch_args, }; pub use launcher::{ - clear_launcher_state, rememberOpenFileLauncher, rememberOpenFilesLauncher, - rememberOpenFolderLauncher, rememberSaveDocumentLauncher, rememberWritableFolderLauncher, LauncherResult, OpenFileLauncher, OpenFilesLauncher, OpenFolderLauncher, SaveDocumentLauncher, - WritableFolderLauncher, + WritableFolderLauncher, clear_launcher_state, rememberOpenFileLauncher, + rememberOpenFilesLauncher, rememberOpenFolderLauncher, rememberSaveDocumentLauncher, + rememberWritableFolderLauncher, }; pub use media::{ - audio_focus, clear_platform_media_player, clear_platform_media_source_opener, - current_media_item, dropped_media_samples, latest_media_samples, media_capabilities, - media_equalizer, media_equalizer_bands, media_playback_supported, media_player, media_volume, + AudioFocus, DUCKED_GAIN, EqualizerBand, EqualizerSettings, MediaArtwork, MediaCapabilities, + MediaCommand, MediaError, MediaItem, MediaMetadata, MediaObserver, MediaPlayer, MediaPlayerRef, + MediaSamples, MediaSourceHandle, MediaSourceOpener, MediaSourceOpenerRef, + OCTAVE_BAND_CENTERS_HZ, PlaybackProgress, PlaybackState, audio_focus, + clear_platform_media_player, clear_platform_media_source_opener, current_media_item, + dropped_media_samples, latest_media_samples, media_capabilities, media_equalizer, + media_equalizer_bands, media_playback_supported, media_player, media_volume, observe_audio_focus, observe_media_commands, observe_media_samples, observe_playback_progress, observe_playback_state, octave_equalizer_bands, open_media, open_media_source, path_from_uri, pause_media, play_media, playback_progress, playback_state, probe_media_duration, @@ -167,65 +172,61 @@ pub use media::{ seek_media, seek_media_fraction, set_media_analysis_enabled, set_media_equalizer, set_media_looping, set_media_metadata, set_media_speed, set_media_volume, set_platform_media_player, set_platform_media_source_opener, stop_media, toggle_media, - uri_for_path, AudioFocus, EqualizerBand, EqualizerSettings, MediaArtwork, MediaCapabilities, - MediaCommand, MediaError, MediaItem, MediaMetadata, MediaObserver, MediaPlayer, MediaPlayerRef, - MediaSamples, MediaSourceHandle, MediaSourceOpener, MediaSourceOpenerRef, PlaybackProgress, - PlaybackState, DUCKED_GAIN, OCTAVE_BAND_CENTERS_HZ, + uri_for_path, }; pub use navigation::{ - back_interception_enabled, exit_requested, observe_back_requests, push_back_request, - request_exit, set_back_interception, take_back_requests, take_exit_request, - BackRequestObserver, + BackRequestObserver, back_interception_enabled, exit_requested, observe_back_requests, + push_back_request, request_exit, set_back_interception, take_back_requests, take_exit_request, }; pub use network_status::{ - clear_platform_network_monitor, network_monitor, network_status, set_platform_network_monitor, - NetworkMonitor, NetworkMonitorRef, NetworkStatus, + NetworkMonitor, NetworkMonitorRef, NetworkStatus, clear_platform_network_monitor, + network_monitor, network_status, set_platform_network_monitor, }; pub use notifier::{ - clear_platform_notifier, default_notifier, local_notifier, push_notification_deeplink, - set_platform_notifier, take_notification_deeplink, Notifier, NotifierRef, NotifyRequest, - ProvideNotifier, + Notifier, NotifierRef, NotifyRequest, ProvideNotifier, clear_platform_notifier, + default_notifier, local_notifier, push_notification_deeplink, set_platform_notifier, + take_notification_deeplink, }; #[cfg(not(target_arch = "wasm32"))] pub use peer::{ - content_length, fetch_range, fetch_to_writer, ByteSource, BytesSource, FetchResult, PeerError, - PeerServer, SourceResolver, + ByteSource, BytesSource, FetchResult, PeerError, PeerServer, SourceResolver, content_length, + fetch_range, fetch_to_writer, }; pub use power::{ - clear_platform_power_monitor, observe_power_state, power_capabilities, power_monitor, - power_state, publish_power_state, rememberPowerState, set_platform_power_monitor, BatteryStatus, PowerCapabilities, PowerMonitor, PowerMonitorRef, PowerObserverRegistration, - PowerReading, PowerState, ThermalState, + PowerReading, PowerState, ThermalState, clear_platform_power_monitor, observe_power_state, + power_capabilities, power_monitor, power_state, publish_power_state, rememberPowerState, + set_platform_power_monitor, }; #[cfg(all(target_arch = "wasm32", feature = "preferences-web"))] pub use preferences::BrowserPreferences; #[cfg(not(target_arch = "wasm32"))] pub use preferences::FilePreferences; pub use preferences::{ - clear_platform_preferences, preferences, rememberSaveable, set_platform_preferences, MemoryPreferences, PreferencesError, PreferencesRef, PreferencesStore, Saver, + clear_platform_preferences, preferences, rememberSaveable, set_platform_preferences, }; pub use purchases::{ + Product, PurchaseEvent, Purchases, PurchasesRef, StoreObserver, StorePhase, StoreState, clear_platform_purchases, note_store_news, observe_store_news, purchases, rememberPurchaseEvents, rememberStoreState, set_platform_purchases, store_available, - store_state, Product, PurchaseEvent, Purchases, PurchasesRef, StoreObserver, StorePhase, - StoreState, + store_state, }; pub use share_sheet::{ - clear_platform_share_sheet, default_share_sheet, local_share_sheet, set_platform_share_sheet, ProvideShareSheet, ShareContent, ShareError, ShareSheet, ShareSheetRef, + clear_platform_share_sheet, default_share_sheet, local_share_sheet, set_platform_share_sheet, }; pub use theme::{ - clear_platform_system_theme, default_system_theme, isSystemInDarkTheme, local_system_theme, - set_platform_system_theme, ProvideSystemTheme, SystemTheme, + ProvideSystemTheme, SystemTheme, clear_platform_system_theme, default_system_theme, + isSystemInDarkTheme, local_system_theme, set_platform_system_theme, }; pub use uri_handler::{ - clear_platform_uri_handler, default_uri_handler, local_uri_handler, set_platform_uri_handler, - ProvideUriHandler, UriHandler, UriHandlerError, UriHandlerRef, + ProvideUriHandler, UriHandler, UriHandlerError, UriHandlerRef, clear_platform_uri_handler, + default_uri_handler, local_uri_handler, set_platform_uri_handler, }; pub use writable_folder::{ - open_writable_folder, set_writable_folder_store_factory, FolderEntry, FolderError, - FolderReader, FolderWriter, WritableFolderStore, WritableFolderStoreRef, + FolderEntry, FolderError, FolderReader, FolderWriter, WritableFolderStore, + WritableFolderStoreRef, open_writable_folder, set_writable_folder_store_factory, }; /// Convenience alias used in unit tests. diff --git a/crates/cranpose/src/android_camera.rs b/crates/cranpose/src/android_camera.rs index 1818102f8..c923a660a 100644 --- a/crates/cranpose/src/android_camera.rs +++ b/crates/cranpose/src/android_camera.rs @@ -14,15 +14,14 @@ use std::sync::Arc; use cranpose_services::{ - publish_camera_frame, publish_camera_lenses, publish_camera_state, publish_camera_still, - record_dropped_camera_frame, set_platform_camera, Camera, CameraError, CameraFrame, CameraLens, - CameraLenses, CameraState, CameraStill, FlashMode, FrameFormat, LensFacing, + Camera, CameraError, CameraFrame, CameraLens, CameraLenses, CameraState, CameraStill, + FlashMode, FrameFormat, LensFacing, publish_camera_frame, publish_camera_lenses, + publish_camera_state, publish_camera_still, record_dropped_camera_frame, set_platform_camera, }; use jni::{ - jni_sig, jni_str, + EnvUnowned, Outcome, jni_sig, jni_str, objects::{JByteArray, JClass, JObject, JString, JValue}, sys::{jint, jlong}, - EnvUnowned, Outcome, }; use crate::android_jni::{clear_pending_android_jni_exception, with_android_activity_env}; diff --git a/crates/cranpose/src/ios_camera.rs b/crates/cranpose/src/ios_camera.rs index cd89d089d..ee78edf1d 100644 --- a/crates/cranpose/src/ios_camera.rs +++ b/crates/cranpose/src/ios_camera.rs @@ -8,21 +8,20 @@ #![allow(unsafe_code)] use std::{ - sync::{mpsc, Arc, Mutex, OnceLock}, + sync::{Arc, Mutex, OnceLock, mpsc}, time::Duration, }; use block2::RcBlock; use cranpose_services::{ - publish_camera_lenses, set_platform_camera, Camera, CameraError, CameraFrame, CameraLens, - CameraLenses, CameraState, CameraStill, FlashMode, FrameFormat, LensFacing, + Camera, CameraError, CameraFrame, CameraLens, CameraLenses, CameraState, CameraStill, + FlashMode, FrameFormat, LensFacing, publish_camera_lenses, set_platform_camera, }; use dispatch2::{DispatchQueue, DispatchRetained}; use objc2::{ - define_class, msg_send, + AllocAnyThread, define_class, msg_send, rc::Retained, runtime::{AnyObject, Bool, ProtocolObject}, - AllocAnyThread, }; use objc2_av_foundation::{ AVCaptureAutoFocusRangeRestriction, AVCaptureConnection, AVCaptureDevice, @@ -40,9 +39,9 @@ use objc2_av_foundation::{ }; use objc2_core_media::CMSampleBuffer; use objc2_core_video::{ - kCVPixelBufferPixelFormatTypeKey, CVBuffer, CVPixelBuffer, CVPixelBufferGetBaseAddress, - CVPixelBufferGetBytesPerRow, CVPixelBufferGetHeight, CVPixelBufferGetWidth, - CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, + CVBuffer, CVPixelBuffer, CVPixelBufferGetBaseAddress, CVPixelBufferGetBytesPerRow, + CVPixelBufferGetHeight, CVPixelBufferGetWidth, CVPixelBufferLockBaseAddress, + CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, kCVPixelBufferPixelFormatTypeKey, }; use objc2_foundation::{ NSArray, NSDictionary, NSError, NSNumber, NSObject, NSObjectProtocol, NSString, @@ -198,7 +197,12 @@ impl Camera for IosCamera { publish_lenses(Some(id.to_string())); true } - Err(_) => false, + Err(error) => { + // The old device is already closed, so a screen that heard + // Running must hear that there is nothing running now. + cranpose_services::publish_camera_state(CameraState::Failed(error)); + false + } } } diff --git a/crates/cranpose/tests/platform_scheduling_static.rs b/crates/cranpose/tests/platform_scheduling_static.rs index 483cdae75..d3abf6549 100644 --- a/crates/cranpose/tests/platform_scheduling_static.rs +++ b/crates/cranpose/tests/platform_scheduling_static.rs @@ -2736,6 +2736,36 @@ fn the_camera_service_is_published_to_rather_than_polled() { ); } +/// Stopping the desktop camera must return rather than wait. +/// +/// Its capture thread sits inside a platform read for as long as that read +/// takes, so joining it where the session is stopped holds up whoever asked — +/// which is the screen being left, on the thread that draws. The thread is +/// told to end and the next session opening waits for it instead, which is +/// also what keeps two sessions from holding one device. +#[test] +fn stopping_the_desktop_camera_does_not_wait_for_its_capture_thread() { + let backend = workspace_source("crates/cranpose-services/src/camera/native.rs"); + let close = backend + .split_once("fn close(&self)") + .map(|(_, tail)| tail.split_once("\n }").map(|(body, _)| body)) + .expect("the desktop camera has a close") + .expect("close has a body"); + assert!( + !close.contains("join()"), + "stopping must not wait for the capture thread: {close}" + ); + let open = backend + .split_once("fn open(&self)") + .map(|(_, tail)| tail.split_once("\n }").map(|(body, _)| body)) + .expect("the desktop camera has an open") + .expect("open has a body"); + assert!( + open.contains("join()"), + "opening must wait for the previous thread to release the device: {open}" + ); +} + /// Every media backend the framework ships, so a contract test covers all of /// them rather than whichever one was written last. const MEDIA_BACKENDS: [&str; 4] = [ From 6ad4b48b2f5eafe1ddea7f65b46cc7c2064ac3a8 Mon Sep 17 00:00:00 2001 From: Dmitry Samoylenko Date: Thu, 27 Aug 2026 16:08:01 +0200 Subject: [PATCH 3/4] Offer the lenses a phone keeps behind its one back camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Pixel 9 Pro lists one back camera and one front camera, so a lens control had nothing to switch between: its ultra wide and tele sit behind the back one and never appear in getCameraIdList. Camera2 reaches them by opening the listed camera and pointing the outputs at the lens behind it, which is what a logical:physical lens id names here. A session that a lens refuses falls back to the camera itself rather than leaving the screen with no picture. The same lens is exposed more than once — that phone carries six cameras behind its back camera, which are its three lenses twice over — so one camera per focal length is kept. On the phone the control now reads Ultra wide, Wide, Tele, and each one opens. Co-Authored-By: Claude Fable 5 --- .../dev/cranpose/android/CranposeCamera.java | 126 ++++++++++++++++-- 1 file changed, 115 insertions(+), 11 deletions(-) diff --git a/crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java b/crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java index b9b06c3f4..47bebbda3 100644 --- a/crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java +++ b/crates/cranpose/android/java/dev/cranpose/android/CranposeCamera.java @@ -62,6 +62,7 @@ final class CranposeCamera { private volatile String openId = null; private volatile int flash = 0; private volatile int rotationDegrees = 0; + private volatile String openPhysicalId = null; private android.hardware.display.DisplayManager.DisplayListener displayListener; CranposeCamera(Activity activity) { @@ -97,31 +98,117 @@ private static boolean takesPictures(CameraCharacteristics chars) { return false; } + /** + * The characteristics of a lens id, which is either a camera the device + * lists or one of the cameras behind it, written {@code logical:physical}. + */ + private CameraCharacteristics charsFor(String id) throws Exception { + int mark = id.indexOf(':'); + return manager().getCameraCharacteristics(mark < 0 ? id : id.substring(mark + 1)); + } + + private static String openId(String id) { + int mark = id.indexOf(':'); + return mark < 0 ? id : id.substring(0, mark); + } + + private static String physicalId(String id) { + int mark = id.indexOf(':'); + return mark < 0 ? null : id.substring(mark + 1); + } + + /** + * The lenses behind a device that carries several, widest first. + * + *

A modern phone lists one back camera and one front camera; the ultra + * wide and the tele sit behind the back one and never appear in + * {@code getCameraIdList}. Camera2 reaches them by opening the listed + * camera and pointing an output at the lens behind it, which is what + * {@code logical:physical} names here. A device whose lenses are listed + * separately reports none of these and keeps its own ids. + */ + private java.util.List lensesBehind(String id) { + java.util.List found = new java.util.ArrayList<>(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return found; + } + try { + java.util.Set behind = + manager().getCameraCharacteristics(id).getPhysicalCameraIds(); + if (behind == null || behind.size() < 2) { + return found; + } + for (String one : behind) { + if (takesPictures(manager().getCameraCharacteristics(one))) { + found.add(id + ":" + one); + } + } + } catch (Exception e) { + android.util.Log.w("cranpose", "camera list behind " + id + " failed", e); + } + return found; + } + private java.util.List facingIds(int wanted) { java.util.List ids = new java.util.ArrayList<>(); try { for (String id : manager().getCameraIdList()) { CameraCharacteristics chars = manager().getCameraCharacteristics(id); Integer facing = chars.get(CameraCharacteristics.LENS_FACING); - if (facing != null && facing == wanted && takesPictures(chars)) { + if (facing == null || facing != wanted || !takesPictures(chars)) { + continue; + } + java.util.List behind = lensesBehind(id); + if (behind.isEmpty()) { ids.add(id); + } else { + ids.addAll(behind); } } ids.sort((a, b) -> { try { - return Float.compare( - shortestFocalLength(manager().getCameraCharacteristics(a)), - shortestFocalLength(manager().getCameraCharacteristics(b))); + return Float.compare(shortestFocalLength(charsFor(a)), + shortestFocalLength(charsFor(b))); } catch (Exception e) { return 0; } }); + return oneCameraPerLens(ids); } catch (Exception e) { android.util.Log.w("cranpose", "camera list failed", e); } return ids; } + /** + * Keeps one camera per focal length, the list already being in focal + * length order. + * + *

A phone exposes the same lens more than once — a Pixel 9 Pro carries + * six cameras behind its back camera, which are its three lenses twice + * over. Offering the same picture under two names asks a person to pick + * between them with nothing to go on. + */ + private java.util.List oneCameraPerLens(java.util.List ids) { + java.util.List kept = new java.util.ArrayList<>(); + float last = Float.NaN; + for (String id : ids) { + float focal; + try { + focal = shortestFocalLength(charsFor(id)); + } catch (Exception e) { + kept.add(id); + continue; + } + if (!Float.isNaN(last) && Math.abs(focal - last) < 0.01f) { + continue; + } + last = focal; + kept.add(id); + } + return kept; + } + private java.util.List backIds() { return facingIds(CameraCharacteristics.LENS_FACING_BACK); } @@ -169,7 +256,7 @@ int rotationDegrees() { */ private int rotationFor(String id) { try { - CameraCharacteristics chars = manager().getCameraCharacteristics(id); + CameraCharacteristics chars = charsFor(id); Integer sensorOrientation = chars.get(CameraCharacteristics.SENSOR_ORIENTATION); Integer facing = chars.get(CameraCharacteristics.LENS_FACING); int sensor = sensorOrientation == null ? 0 : sensorOrientation; @@ -240,7 +327,9 @@ boolean hasFlash() { } id = ids.get(ids.size() > 1 ? 1 : 0); } - Boolean available = manager().getCameraCharacteristics(id) + // The flash belongs to the camera the device lists, not to the + // lens behind it, which reports none of its own. + Boolean available = manager().getCameraCharacteristics(openId(id)) .get(CameraCharacteristics.FLASH_INFO_AVAILABLE); return available != null && available; } catch (Exception e) { @@ -327,7 +416,8 @@ synchronized void start() { return; } openId = backId; - CameraCharacteristics chars = manager.getCameraCharacteristics(backId); + openPhysicalId = physicalId(backId); + CameraCharacteristics chars = charsFor(backId); rotationDegrees = rotationFor(backId); watchDisplay(); StreamConfigurationMap streamMap = chars.get( @@ -348,7 +438,7 @@ synchronized void start() { still.getWidth(), still.getHeight(), ImageFormat.JPEG, 1); stillReader.setOnImageAvailableListener(this::onStill, previewHandler); - manager.openCamera(backId, new CameraDevice.StateCallback() { + manager.openCamera(openId(backId), new CameraDevice.StateCallback() { @Override public void onOpened(CameraDevice device) { camera = device; @@ -398,16 +488,30 @@ public void onConfigured(CameraCaptureSession s) { @Override public void onConfigureFailed(CameraCaptureSession s) { + // A lens behind the listed camera can refuse this pair of + // streams; the camera itself takes them, so fall back to it + // rather than leaving the screen with no picture. + if (openPhysicalId != null) { + openPhysicalId = null; + chosenId = openId(openId == null ? "" : openId); + createSession(); + return; + } CranposeActivity.onCameraFailed("the camera session could not be configured"); stop(); } }; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + OutputConfiguration previewOutput = + new OutputConfiguration(previewReader.getSurface()); + OutputConfiguration stillOutput = new OutputConfiguration(stillReader.getSurface()); + if (openPhysicalId != null) { + previewOutput.setPhysicalCameraId(openPhysicalId); + stillOutput.setPhysicalCameraId(openPhysicalId); + } SessionConfiguration configuration = new SessionConfiguration( SessionConfiguration.SESSION_REGULAR, - Arrays.asList( - new OutputConfiguration(previewReader.getSurface()), - new OutputConfiguration(stillReader.getSurface())), + Arrays.asList(previewOutput, stillOutput), command -> cameraHandler.post(command), callback); camera.createCaptureSession(configuration); From 3ad6ecd9e09989c78ba5670d3cfa3b1a7b801bb1 Mon Sep 17 00:00:00 2001 From: Dmitry Samoylenko Date: Fri, 28 Aug 2026 01:45:43 +0200 Subject: [PATCH 4/4] Serve the desktop camera where its capture stack does not split a dependency The camera-native feature carried no lockfile entry, so every build resolved nokhwa afresh and the all-features tree grew a second rustix family: nokhwa reaches v4l2 on Linux through a bindgen old enough to bring its own, and a second family of a crate this workspace already builds is what the duplicate dependency budget exists to refuse. macOS and Windows are clean. So the backend serves those two and the lock now records what it pulls. Linux desktop capture waits for that v4l2 chain to carry a bindgen of this decade; the alternative was the first entry in an allowlist this workspace has kept empty, which is a wider promise than one backend is worth. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 676 ++++++++++++++++++++----- crates/cranpose-services/Cargo.toml | 9 +- crates/cranpose-services/src/camera.rs | 8 +- crates/cranpose-services/src/lib.rs | 4 +- docs/capability_parity.md | 2 +- 5 files changed, 574 insertions(+), 125 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c200f6bc5..8ec02d48a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", "version_check", @@ -147,8 +147,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags", - "cfg-if", + "bitflags 2.11.1", + "cfg-if 1.0.4", "libc", ] @@ -169,7 +169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags", + "bitflags 2.11.1", "cc", "jni", "libc", @@ -364,13 +364,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ "autocfg", - "cfg-if", + "cfg-if 1.0.4", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -398,10 +398,10 @@ dependencies = [ "async-signal", "async-task", "blocking", - "cfg-if", + "cfg-if 1.0.4", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -424,10 +424,10 @@ dependencies = [ "async-io", "async-lock", "atomic-waker", - "cfg-if", + "cfg-if 1.0.4", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -536,6 +536,29 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", + "which", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -551,12 +574,24 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.12.1" @@ -651,9 +686,9 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags", + "bitflags 2.11.1", "polling", - "rustix", + "rustix 1.1.4", "slab", "tracing", ] @@ -665,7 +700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop", - "rustix", + "rustix 1.1.4", "wayland-backend", "wayland-client", ] @@ -688,6 +723,21 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -727,6 +777,17 @@ dependencies = [ "half", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.1" @@ -770,6 +831,34 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c49e86fc36d5704151f5996b7b3795385f50ce09e3be0f47a0cfde869681cf8" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.7.0", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-foundation 0.10.1", + "core-graphics-types", + "objc", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -818,7 +907,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "wasm-bindgen", ] @@ -828,29 +917,93 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-media-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273bf3fc5bf51fd06a7766a84788c1540b6527130a0bce39e00567d6ab9f31f1" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-video-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ecad23610ad9757664d644e369246edde1803fcb43ed72876565098a5d3828" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "core-graphics", + "libc", + "metal", + "objc", +] + [[package]] name = "coreaudio-rs" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -1152,6 +1305,7 @@ dependencies = [ "futures-util", "js-sys", "log", + "nokhwa", "open", "parking_lot", "pollster", @@ -1230,7 +1384,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1405,7 +1559,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1605,6 +1759,18 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -1727,7 +1893,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link", ] @@ -1737,7 +1903,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "wasi", @@ -1750,7 +1916,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "r-efi", @@ -1779,6 +1945,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.17.0" @@ -1819,7 +1991,7 @@ name = "gpu-descriptor" version = "0.3.2" source = "git+https://github.com/zakarumych/gpu-descriptor?rev=79804e422186805f1ff5ab3d8310c07c145a6731#79804e422186805f1ff5ab3d8310c07c145a6731" dependencies = [ - "bitflags", + "bitflags 2.11.1", "gpu-descriptor-types", "hashbrown", ] @@ -1829,7 +2001,7 @@ name = "gpu-descriptor-types" version = "0.2.0" source = "git+https://github.com/zakarumych/gpu-descriptor?rev=79804e422186805f1ff5ab3d8310c07c145a6731#79804e422186805f1ff5ab3d8310c07c145a6731" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -1838,7 +2010,7 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "crunchy", "num-traits", "zerocopy", @@ -1873,6 +2045,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -2165,7 +2346,7 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "wasm-bindgen", "web-sys", @@ -2247,7 +2428,7 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "combine", "jni-macros", "jni-sys 0.4.1", @@ -2315,7 +2496,7 @@ version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "futures-util", "once_cell", "wasm-bindgen", @@ -2327,7 +2508,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "serde", ] @@ -2354,6 +2535,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.186" @@ -2366,7 +2553,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "windows-link", ] @@ -2382,12 +2569,18 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.5", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2433,6 +2626,15 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2457,6 +2659,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metal" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e198a0ee42bdbe9ef2c09d0b9426f3b2b47d90d93a4a9b0395c4cea605e92dc0" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa", + "core-graphics", + "foreign-types", + "log", + "objc", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2488,6 +2711,31 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mozjpeg" +version = "0.10.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7891b80aaa86097d38d276eb98b3805d6280708c4e0a1e6f6aed9380c51fec9" +dependencies = [ + "arrayvec", + "bytemuck", + "libc", + "mozjpeg-sys", + "rgb", +] + +[[package]] +name = "mozjpeg-sys" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0dc668bf9bf888c88e2fb1ab16a406d2c380f1d082b20d51dd540ab2aa70c1" +dependencies = [ + "cc", + "dunce", + "libc", + "nasm-rs", +] + [[package]] name = "naga" version = "29.0.3" @@ -2496,8 +2744,8 @@ checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" dependencies = [ "arrayvec", "bit-set", - "bitflags", - "cfg-if", + "bitflags 2.11.1", + "cfg-if 1.0.4", "cfg_aliases", "codespan-reporting", "half", @@ -2514,6 +2762,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "jobserver", + "log", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2537,7 +2804,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags", + "bitflags 2.11.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2561,6 +2828,83 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nokhwa" +version = "0.10.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d63f10b450319a0ace7aa8e0e25477d1fdb345313a97e220e886175539a1dbb" +dependencies = [ + "flume", + "image", + "nokhwa-bindings-linux", + "nokhwa-bindings-macos", + "nokhwa-bindings-windows", + "nokhwa-core", + "paste", + "thiserror 2.0.18", +] + +[[package]] +name = "nokhwa-bindings-linux" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb67e22201a53322291740ca064b20eaaade7222ef0349f312d9b37b004e1984" +dependencies = [ + "libc", + "nokhwa-core", + "v4l", +] + +[[package]] +name = "nokhwa-bindings-macos" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70d3908ea68324e44a6b3a0f885aa59e433fb1f6678839d09e0df7d226fb42d" +dependencies = [ + "block", + "cocoa-foundation", + "core-foundation 0.10.1", + "core-media-sys", + "core-video-sys", + "flume", + "nokhwa-core", + "objc", + "once_cell", +] + +[[package]] +name = "nokhwa-bindings-windows" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be28886bad8abcec3655c1f24b965b4cb596a72b23164c910c54439ce55d2a4" +dependencies = [ + "nokhwa-core", + "once_cell", + "windows", +] + +[[package]] +name = "nokhwa-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1cba20bebd3bd9ae22f9273ade5bbe49da3e047c8512b53fbaf8b4b9c80d496" +dependencies = [ + "bytes", + "image", + "mozjpeg", + "thiserror 2.0.18", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2622,6 +2966,16 @@ dependencies = [ "syn", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -2653,7 +3007,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2669,7 +3023,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -2683,7 +3037,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "objc2 0.6.4", "objc2-core-audio", @@ -2698,7 +3052,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "dispatch2", "objc2 0.6.4", @@ -2712,7 +3066,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -2736,7 +3090,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2 0.6.4", ] @@ -2746,7 +3100,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2758,7 +3112,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "dispatch2", "libc", @@ -2771,7 +3125,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.11.1", "dispatch2", "libc", "objc2 0.6.4", @@ -2797,7 +3151,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "dispatch2", "objc2 0.6.4", "objc2-core-audio", @@ -2812,7 +3166,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -2831,7 +3185,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2843,7 +3197,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -2856,7 +3210,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -2867,7 +3221,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85f2a98483f6e76313cb85a5a185eaa3cda86a177b13a8852d56cbd0085bf635" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "objc2 0.6.4", "objc2-foundation 0.3.2", @@ -2879,7 +3233,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2891,7 +3245,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "objc2 0.6.4", "objc2-foundation 0.3.2", @@ -2903,7 +3257,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2916,7 +3270,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2929,7 +3283,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -2953,12 +3307,21 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "objc2 0.6.4", "objc2-foundation 0.3.2", ] +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2994,8 +3357,8 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags", - "cfg-if", + "bitflags 2.11.1", + "cfg-if 1.0.4", "foreign-types", "libc", "openssl-macros", @@ -3107,19 +3470,31 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall 0.5.18", "smallvec", "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3252,7 +3627,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", @@ -3271,11 +3646,11 @@ version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3324,6 +3699,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primal-check" version = "0.3.4" @@ -3363,7 +3748,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "memchr", "unicase", ] @@ -3539,7 +3924,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -3548,7 +3933,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -3667,6 +4052,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -3674,7 +4068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted", @@ -3716,16 +4110,29 @@ dependencies = [ "transpose", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3771,8 +4178,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", - "core-foundation-sys", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", "jni", "log", "once_cell", @@ -3859,9 +4266,9 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", "libc", "security-framework-sys", ] @@ -3872,7 +4279,7 @@ version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -3942,7 +4349,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cpufeatures", "digest", ] @@ -4018,14 +4425,14 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags", + "bitflags 2.11.1", "calloop", "calloop-wayland-source", "cursor-icon", "libc", "log", "memmap2", - "rustix", + "rustix 1.1.4", "thiserror 2.0.18", "wayland-backend", "wayland-client", @@ -4059,13 +4466,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -4215,7 +4631,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytemuck", "lazy_static", "log", @@ -4336,7 +4752,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4398,7 +4814,7 @@ dependencies = [ "arrayref", "arrayvec", "bytemuck", - "cfg-if", + "cfg-if 1.0.4", "log", "tiny-skia-path", ] @@ -4534,7 +4950,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -4700,6 +5116,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v4l" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" +dependencies = [ + "bitflags 1.3.2", + "libc", + "v4l2-sys-mit", +] + +[[package]] +name = "v4l2-sys-mit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" +dependencies = [ + "bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -4752,7 +5188,7 @@ version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -4820,7 +5256,7 @@ checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -4832,8 +5268,8 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags", - "rustix", + "bitflags 2.11.1", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -4844,7 +5280,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cursor-icon", "wayland-backend", ] @@ -4855,7 +5291,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix", + "rustix 1.1.4", "wayland-client", "xcursor", ] @@ -4866,7 +5302,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -4878,7 +5314,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4891,7 +5327,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4904,7 +5340,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4917,7 +5353,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4973,7 +5409,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "jni", "log", "ndk-context", @@ -5005,9 +5441,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" dependencies = [ "arrayvec", - "bitflags", + "bitflags 2.11.1", "bytemuck", - "cfg-if", + "cfg-if 1.0.4", "cfg_aliases", "document-features", "hashbrown", @@ -5037,7 +5473,7 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.1", "bytemuck", "cfg_aliases", "document-features", @@ -5108,10 +5544,10 @@ dependencies = [ "arrayvec", "ash", "bit-set", - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "bytemuck", - "cfg-if", + "cfg-if 1.0.4", "cfg_aliases", "glow", "glutin_wgl_sys", @@ -5168,7 +5604,7 @@ version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytemuck", "js-sys", "log", @@ -5176,6 +5612,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5488,13 +5936,13 @@ version = "0.31.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2879d2854d1a43e48f67322d4bd097afcb6eb8f8f775c8de0260a71aea1df1aa" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg_aliases", "cursor-icon", "dpi", "libc", "raw-window-handle", - "rustix", + "rustix 1.1.4", "smol_str", "tracing", "winit-android", @@ -5516,7 +5964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d9c0d2cd93efec3a9f9ad819cfaf0834782403af7c0d248c784ec0c61761df" dependencies = [ "android-activity", - "bitflags", + "bitflags 2.11.1", "dpi", "ndk", "raw-window-handle", @@ -5531,7 +5979,7 @@ version = "0.31.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21310ca07851a49c348e0c2cc768e36b52ca65afda2c2354d78ed4b90074d8aa" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "dispatch2", "dpi", @@ -5570,7 +6018,7 @@ version = "0.31.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4f0ccd7abb43740e2c6124ac7cae7d865ecec74eec63783e8922577ac232583" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cursor-icon", "dpi", "keyboard-types", @@ -5585,7 +6033,7 @@ version = "0.31.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51ea1fb262e7209f265f12bd0cc792c399b14355675e65531e9c8a87db287d46" dependencies = [ - "bitflags", + "bitflags 2.11.1", "dpi", "orbclient", "raw-window-handle", @@ -5601,7 +6049,7 @@ version = "0.31.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680a356e798837d8eb274d4556e83bceaf81698194e31aafc5cfb8a9f2fab643" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2 0.6.2", "dispatch2", "dpi", @@ -5623,14 +6071,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce5afb2ba07da603f84b722c95f9f9396d2cedae3944fb6c0cda4a6f88de545" dependencies = [ "ahash", - "bitflags", + "bitflags 2.11.1", "calloop", "cursor-icon", "dpi", "libc", "memmap2", "raw-window-handle", - "rustix", + "rustix 1.1.4", "sctk-adwaita", "smithay-client-toolkit", "smol_str", @@ -5650,7 +6098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c2490a953fb776fbbd5e295d54f1c3847f4f15b6c3929ec53c09acda6487a92" dependencies = [ "atomic-waker", - "bitflags", + "bitflags 2.11.1", "concurrent-queue", "cursor-icon", "dpi", @@ -5672,7 +6120,7 @@ version = "0.31.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "644ea78af0e858aa3b092e5d1c67c41995a98220c81813f1353b28bc8bb91eaa" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cursor-icon", "dpi", "raw-window-handle", @@ -5689,7 +6137,7 @@ version = "0.31.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa5b600756534c7041aa93cd0d244d44b09fca1b89e202bd1cd80dd9f3636c46" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytemuck", "calloop", "cursor-icon", @@ -5697,7 +6145,7 @@ dependencies = [ "libc", "percent-encoding", "raw-window-handle", - "rustix", + "rustix 1.1.4", "smol_str", "tracing", "winit-common", @@ -5750,7 +6198,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix", + "rustix 1.1.4", "x11rb-protocol", "xcursor", ] @@ -5773,7 +6221,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags", + "bitflags 2.11.1", "dlib", "log", "once_cell", @@ -5841,7 +6289,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", diff --git a/crates/cranpose-services/Cargo.toml b/crates/cranpose-services/Cargo.toml index d7f95b31d..486d43d65 100644 --- a/crates/cranpose-services/Cargo.toml +++ b/crates/cranpose-services/Cargo.toml @@ -49,7 +49,8 @@ system-theme = [] system-theme-web = ["dep:web-sys"] # Native desktop file/folder picker (rfd; xdg-portal surfaces GVFS/WebDAV mounts). file-picker-native = ["dep:rfd"] -# Native desktop camera capture (nokhwa: AVFoundation / MSMF / V4L2). +# Native desktop camera capture (nokhwa: AVFoundation on macOS, Media +# Foundation on Windows). camera-native = ["dep:nokhwa"] # Web file/folder picker (rfd + File System Access). file-picker-web = [ @@ -69,6 +70,12 @@ open = { version = "5.3.5", optional = true } # locations (GVFS/WebDAV) on Linux. iOS uses its own backend, so exclude it. [target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "android"), not(target_os = "ios")))'.dependencies] rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"], optional = true } + +# Desktop camera capture. macOS and Windows only: the Linux backend reaches +# v4l2 through a bindgen old enough to carry its own rustix family, and a +# second family of a crate this workspace already builds is what the +# duplicate-dependency budget exists to refuse. +[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] nokhwa = { version = "0.10", features = ["input-native"], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/cranpose-services/src/camera.rs b/crates/cranpose-services/src/camera.rs index a6f4ef8e2..133d05f11 100644 --- a/crates/cranpose-services/src/camera.rs +++ b/crates/cranpose-services/src/camera.rs @@ -882,16 +882,12 @@ pub async fn capture_camera_still() -> Result { } #[cfg(all( - not(target_arch = "wasm32"), - not(target_os = "android"), - not(target_os = "ios"), + any(target_os = "macos", target_os = "windows"), feature = "camera-native" ))] mod native; #[cfg(all( - not(target_arch = "wasm32"), - not(target_os = "android"), - not(target_os = "ios"), + any(target_os = "macos", target_os = "windows"), feature = "camera-native" ))] pub use native::install_native_camera; diff --git a/crates/cranpose-services/src/lib.rs b/crates/cranpose-services/src/lib.rs index e47f3f519..9f9229bd4 100644 --- a/crates/cranpose-services/src/lib.rs +++ b/crates/cranpose-services/src/lib.rs @@ -71,9 +71,7 @@ pub use bundled_assets::{ bundled_assets, clear_platform_bundled_assets, set_platform_bundled_assets, }; #[cfg(all( - not(target_arch = "wasm32"), - not(target_os = "android"), - not(target_os = "ios"), + any(target_os = "macos", target_os = "windows"), feature = "camera-native" ))] pub use camera::install_native_camera; diff --git a/docs/capability_parity.md b/docs/capability_parity.md index bc33bc248..c5877f902 100644 --- a/docs/capability_parity.md +++ b/docs/capability_parity.md @@ -30,7 +30,7 @@ never silently pretend. | safe-area insets | ■ | ■ WindowInsets listener → `local_safe_area_insets` | ● zero | ● zero | replaced cranscan's marker-file bridge | | system theme | ■ `window.theme()` polled | ■ uiMode + ConfigChanged | ■ winit `ThemeChanged` (+ cached env probe) | ■ `prefers-color-scheme` listener | drives LiquidTheme Auto | | image picker | ■ | ● file-picker fallback | ● | ● | camera source stays iOS-only for now | -| camera | ■ `AVCaptureSession` | ■ Camera2 (`CranposeCamera`) | ■ nokhwa (`camera-native`) | □ | frames pushed as `CameraFrame` with the turn carried as `rotation_degrees` (`upright_rgba8` applies it in the conversion pass); observable state and lens list (`CameraLenses`, `LensFacing`); bounded latest-wins analysis stream; stills asked for rather than waited on | +| camera | ■ `AVCaptureSession` | ■ Camera2 (`CranposeCamera`) | ● nokhwa on macOS and Windows (`camera-native`); Linux open | □ | frames pushed as `CameraFrame` with the turn carried as `rotation_degrees` (`upright_rgba8` applies it in the conversion pass); observable state and lens list (`CameraLenses`, `LensFacing`); bounded latest-wins analysis stream; stills asked for rather than waited on | | background activity | ■ | □ (FGS is app policy) | □ | □ | documented | | file save dialog | □ (export picker still open) | ■ ACTION_CREATE_DOCUMENT | ■ rfd save | ■ browser download | `FilePicker::save_file`; killed cranscan's direct rfd | | launch arguments | ● argv (`simctl launch`, `launchArguments`) | ■ intent extras + `onNewIntent` | ● argv | □ (query string still open) | `launch_args()`; `is_debuggable()` = `FLAG_DEBUGGABLE` on Android, `debug_assertions` elsewhere |