Skip to content

fix(iris): orient injected frames like a real sensor - #20

Merged
annurdien merged 2 commits into
annurdien:mainfrom
meatpaste:feat/frame-orientation
Jul 29, 2026
Merged

fix(iris): orient injected frames like a real sensor#20
annurdien merged 2 commits into
annurdien:mainfrom
meatpaste:feat/frame-orientation

Conversation

@meatpaste

Copy link
Copy Markdown
Contributor

The gap

A hardware camera is bolted into the device. Its buffers always arrive in the sensor's own orientation — landscape-right for a back-facing sensor — and the scene inside them turns as the device turns. Apps depend on that: they read the interface orientation and rotate by the matching quarter turns to get an upright picture.

The host webcam is bolted to nothing, so its frames arrive already upright and the app's correction becomes the error. Rotate the simulator with sim cam running and the picture is only ever right in one orientation:

Interface orientation App's correction Result today
landscapeRight none correct
portrait 90° clockwise 90° out
portraitUpsideDown 90° anticlockwise 90° out the other way
landscapeLeft 180° upside down

This counter-rotates by whatever the app is about to apply, so the two cancel and the injected device behaves like hardware from the outside.

Why here and not in the app

A device published by an injector is still an AVCaptureDevice. An app that special-cased where its pixels came from would be working around a bug rather than consuming a camera — and every app would have to do it separately. The injector is also the only layer that can: it sees both the host frame and the simulated device's orientation.

I tried it the other way first, in the app's camera plugin, and its maintainer pushed back for exactly this reason. He was right.

Two properties preserved

Fixed dimensions. The synthesised device advertises one activeFormat, and a frame whose width and height swapped underneath it would contradict that format. A rotated frame is therefore scaled to cover the sensor rectangle and centre-cropped — which is what a real sensor shows in portrait anyway: the same optics over a narrower slice of the world.

Zero-copy in landscape-right. No turn is needed there, so that path is untouched and the IOSurface still reaches the app unmodified. Only the rotated orientations pay for a render, and those recycle a CVPixelBufferPool rather than allocating per frame. Any failure falls through to the unrotated frame — a picture the wrong way up beats no picture.

UIKit is read only on the main thread, with the quarter-turn count published to the delivery queue as an atomic. An orientation UIKit doesn't resolve (face up, unknown) leaves the last good value in place rather than snapping to a default.

One build change: -framework UIKit added to the IrisInject link step, which is new for the dylib.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How this has been tested

  • Unit tests pass — go build ./..., go vet ./..., go test ./... all clean
  • Manual testing performed

End to end against a real Flutter app — the multicamera plugin driving an iPad (A16) simulator, with sim cam start --camera on the built-in webcam:

[IrisInject] ✅ injector ready (fps=30, shm=/tmp/iris.<udid>.frames)

Test configuration

  • OS: macOS 26.5.2, Xcode 26.5
  • Go: as vendored by the repo (go test ./... green)
  • Device types tested: iOS Simulator (iPad A16, iOS 26.5), both architectures built via Iris/Scripts/build.sh

No automated test accompanies this: the behaviour needs a live UIWindowScene and a running capture session inside the Simulator, which the Swift test targets here don't stand up. Happy to add one if you have a pattern in mind.

Docs updated: a new Frame orientation section in docs/SIM_CAM_ARCHITECTURE.md, alongside the frame-delivery flow it modifies.

A camera is bolted into the device, so its buffers always arrive in the
sensor's own orientation and the scene inside them turns as the device does.
Consumers depend on that: they read the interface orientation and rotate by the
matching quarter turns to get an upright picture.

The host webcam is bolted to nothing, so its frames arrive already upright and
the consumer's correction becomes the error — 90 degrees out in portrait, the
other way upside down, 180 out in the opposite landscape. Only one orientation
ever looked right, and which one depended on the sensor the consumer assumed.

Counter-rotate by the correction the consumer is about to apply so the two
cancel, leaving the injected device behaving like hardware from the outside.
This belongs here rather than in any individual app: a device published by an
injector is still an AVCaptureDevice, and an app that special-cased where its
pixels came from would be working around a bug rather than consuming a camera.

The buffer keeps the sensor's fixed dimensions. The fake device advertises one
format, and a frame whose width and height swapped underneath it would
contradict its own activeFormat — so a rotated frame is scaled to cover the
sensor rectangle and centre-cropped, which is what a real sensor shows in
portrait anyway: same optics, narrower slice of the world.

Landscape-right needs no turn, and that path is untouched: the IOSurface still
reaches the app zero-copy. Rotation goes through a recycled buffer pool rather
than allocating per frame, and any failure falls through to the unrotated
frame — a picture the wrong way up beats no picture.

UIKit is read only on the main thread, with the orientation published to the
delivery queue as an atomic; an orientation UIKit doesn't resolve (face up,
unknown) leaves the last good value alone instead of snapping to a default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@annurdien

Copy link
Copy Markdown
Owner

Cool! @meatpaste, a screen recording would be appreciated 😄

Time to use your legendary Claude screen recording skill

@meatpaste

Copy link
Copy Markdown
Contributor Author
iris-frame-orientation.mp4

UIKit's landscape constants are the mirror of the device orientations that
produce them, so pairing them up by name gets the two landscape cases
backwards. The sensor's native landscape — the case needing no turn — is
UIInterfaceOrientationLandscapeLeft, not LandscapeRight.

The effect was that landscapeLeft kept its 180-degree error and
landscapeRight, correct before this branch, regressed to 180 out: it was
computing 2 turns rather than taking the zero-turn path at all.

Verified on an iPad (A16) against the multicamera plugin, filming all four
orientations under this build and under 961abaf, with a test card as the
frame source. All four are now upright; portrait and portraitUpsideDown are
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@meatpaste

Copy link
Copy Markdown
Contributor Author

Follow-up in 2ff4f31: the two landscape cases were mapped backwards, and the video above is filmed against that fix rather than the original commit.

UIInterfaceOrientation's landscape constants are the mirror of the device orientations that produce them, so pairing them up by name — which is what I did — gets both landscape rows wrong. The sensor's native landscape, the case that needs no turn, is UIInterfaceOrientationLandscapeLeft.

The symptom was asymmetric, which is what made it worth chasing:

  • landscapeLeft kept the full 180° error, since it was taking the zero-turn path meant for the other orientation.
  • landscapeRight, which was correct before this branch, regressed to 180° out — it was computing 2 turns instead of skipping rotation, so it never reached the zero-copy path at all.

Portrait and portraitUpsideDown were unaffected, which is why a portrait-only check passed.

Corrected, this is the table — the two landscape rows swap against what the description above says:

Interface orientation App's correction Result without counter-rotation
landscapeLeft none correct
portrait 90° clockwise 90° out
portraitUpsideDown 90° anticlockwise 90° out the other way
landscapeRight 180° upside down

So the "Zero-copy in landscape-right" property in the description is really zero-copy in landscapeLeft. docs/SIM_CAM_ARCHITECTURE.md is corrected in the same commit, along with the code comments, and the switch now carries a note about the mirroring so it doesn't get "fixed" back.

Found it by filming all four orientations twice — once under this branch, once under 961abaf — with the two builds side by side and only the dylib swapped, using a test card as the frame source so any quarter turn is unambiguous. The frame source is mirror-invariant by design, since the app I tested with previews the front camera and iOS mirrors that. go build, go vet and go test stay clean.

@annurdien
annurdien self-requested a review July 29, 2026 11:17

@annurdien annurdien left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks @meatpaste

@annurdien
annurdien merged commit 67adb63 into annurdien:main Jul 29, 2026
2 checks passed
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.

2 participants