Skip to content

Carry the camera turn as data, publish the lens list, add a desktop backend - #501

Open
samoylenkodmitry wants to merge 4 commits into
mainfrom
feat/camera-api
Open

Carry the camera turn as data, publish the lens list, add a desktop backend#501
samoylenkodmitry wants to merge 4 commits into
mainfrom
feat/camera-api

Conversation

@samoylenkodmitry

Copy link
Copy Markdown
Owner

What changes

The turn is data, and one pass applies it. Every backend now reports how far a frame must be turned clockwise, and CameraFrame::upright_rgba8() applies that turn inside the same pass as the format conversion. Before this, Android reported the turn and no consumer read it, iOS turned the pixels itself and reported zero, and desktop reported zero: three different contracts for one field. This is what rendered the Pixel 9 viewfinder 90 degrees off.

  • Android computes the turn per lens facing (back: sensor − display; front: sensor + display) and a display listener keeps it fresh while the session runs — the activity survives device turns, so nothing else recomputes it.
  • iOS stops pre-rotating in the BGRA conversion and reports 90, same contract as Android. The conversion is now a plain row copy.
  • The still path keeps EXIF (JPEG_ORIENTATION on Android, the photo pipeline on iOS).

The lens list is published state. CameraLenses { lenses, active } with LensFacing (Back/Front/External) arrives through publish_camera_lenses when a session opens and when the device changes; rememberCameraLenses() observes it. Both phone lens lists are blocking platform calls, so a lens control must not poll them per recomposition. Front lenses are listed now; the Android line protocol is id|facing|name.

A lens switch keeps the picture. Both phone backends restart the session without publishing Stopped in between, so the viewfinder holds the last frame instead of blanking; the new device arrives as a second Running.

Desktop backend. A nokhwa capture thread (AVFoundation/MSMF/V4L2) behind the camera-native feature, installed with install_native_camera(), publishing Rgb8 frames. Lifted from cranscan, which deletes its own copy.

Verification

  • cargo test -p cranpose-services --features camera-native: green on Linux (samarch-1) and macOS (macm3), 246 tests. New tests pin the quarter-turn mapping per format, the non-quarter fallback, and the lens-list publish/observe/clear path.
  • cargo fmt --all --check on the pinned nightly: clean.
  • Compiles inside the cranscan app for all four targets: Linux desktop, macOS desktop, aarch64-apple-ios, aarch64-linux-android (via cargo ndk).
  • docs/capability_parity.md camera row updated (desktop ■, web still □).

🤖 Generated with Claude Code

@samoylenkodmitry

Copy link
Copy Markdown
Owner Author

Checked on a Pixel 9 Pro (caiman), which is the device the turn was wrong on.

The turn. The app's own log, same phone, before and after:

13:10:31  camera started: Back
13:10:31  live preview: 640x480 rgba direct
15:42:12  camera started: Back
15:42:12  live preview: 480x640 rgba direct

A landscape sensor buffer was reaching a portrait viewfinder untouched; it is turned upright now, before both the screen and the scan pipeline. On screen the monitor and its dock read the right way up.

The lenses. The first device run showed no lens control at all, which is what sent me after the last commit here. That phone lists exactly two cameras — one back, one front — and keeps its ultra wide and tele behind the back one as physical cameras 2 to 6. getCameraIdList never mentions them, so there was nothing to switch between.

Reaching them needs OutputConfiguration.setPhysicalCameraId, which is what the logical:physical lens id carries now. Two things showed up on the device and are handled:

  • The six cameras behind the back one are three lenses twice over — 2 and 5 at 6.9 mm, 4 and 6 at 17.9 mm, 3 and 9 at 2.02 mm. Listing all six offered the same picture under two names, so one camera per focal length is kept.
  • A lens can refuse the preview-plus-still stream pair, so a failed configuration falls back to the listed camera instead of leaving the screen blank.

The control now reads Ultra wide · Wide · Tele, and all three open: ultra wide widens the field of view, tele is visibly magnified, and the viewfinder keeps its picture across a switch rather than blanking.

Still green after the change: cargo fmt --all --check, cargo clippy --workspace --all-targets (zero warnings), 246 service tests, and the 86-test static contract suite on macm3.

samoylenkodmitry and others added 3 commits August 27, 2026 18:08
…ackend

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 <noreply@anthropic.com>
…he 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…endency

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant