Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to OpenLens are documented here. The format follows

## [Unreleased]

### Added

- A **Rotate 180°** toggle for cameras mounted upside down, saved per scene.

## [0.4.6] - 2026-09-24

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ want the product rather than the source. See
with real pixels — no upscaling. The inspector shows how far that reaches
("Stays sharp up to"), and the badge on the picture says `soft` once you pass
it.
- **Scenes are the presets.** Camera, zoom, mirror and overlay are saved into
- **Scenes are the presets.** Camera, zoom, mirror, rotation and overlay are saved into
the selected scene as you change them — there is no save button. Duplicate
snapshots the current look, and **⌃⌥1…⌃⌥9** switch between scenes system-wide,
so it works while you are in a call.
Expand Down
7 changes: 7 additions & 0 deletions Sources/OpenLens/Model/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ final class AppModel: ObservableObject {
pipeline?.update {
$0.target = scene.crop
$0.mirror = scene.mirrored
$0.rotate180 = scene.rotated180
$0.overlayEnabled = scene.overlayEnabled
$0.overlayRect = scene.overlayRect
$0.overlayOpacity = scene.overlayOpacity
Expand Down Expand Up @@ -346,6 +347,12 @@ final class AppModel: ObservableObject {
pipeline?.update { $0.mirror = mirrored }
}

func setRotated180(_ rotated: Bool) {
scenes.mutateSelected { $0.rotated180 = rotated }
scenes.save()
pipeline?.update { $0.rotate180 = rotated }
}

// MARK: - Zoom

func zoom(to zoom: CGFloat, anchor: CGPoint) {
Expand Down
10 changes: 10 additions & 0 deletions Sources/OpenLens/Model/CameraScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ struct CameraScene: Identifiable, Codable, Equatable {
/// read as "does not drive the lights", not as "wants them all off".
private var storedLighting: SceneLighting?

private var storedRotated180: Bool?

var adjustments: ImageAdjustments {
get { storedAdjustments ?? .neutral }
set { storedAdjustments = newValue }
Expand All @@ -82,11 +84,17 @@ struct CameraScene: Identifiable, Codable, Equatable {
set { storedLighting = newValue }
}

var rotated180: Bool {
get { storedRotated180 ?? false }
set { storedRotated180 = newValue }
}

private enum CodingKeys: String, CodingKey {
case id, name, deviceID, deviceName, crop, mirrored, quality
case overlayEnabled, overlayRect, overlayOpacity
case storedAdjustments = "adjustments"
case storedLighting = "lighting"
case storedRotated180 = "rotated180"
}

/// Spelled out because the private stored property above would otherwise
Expand All @@ -98,6 +106,7 @@ struct CameraScene: Identifiable, Codable, Equatable {
deviceName: String,
crop: CropState = .identity,
mirrored: Bool = false,
rotated180: Bool = false,
quality: CaptureQuality = .losslessZoom,
overlayEnabled: Bool = false,
overlayRect: CGRect = CGRect(x: 0.72, y: 0.72, width: 0.24, height: 0.24),
Expand All @@ -117,6 +126,7 @@ struct CameraScene: Identifiable, Codable, Equatable {
self.overlayOpacity = overlayOpacity
self.storedAdjustments = adjustments
self.storedLighting = lighting
self.storedRotated180 = rotated180
}
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/OpenLens/Model/FramePipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import QuartzCore
struct FrameSettings {
var target: CropState = .identity
var mirror = false
var rotate180 = false
var overlayEnabled = false
var overlayRect = CGRect(x: 0.72, y: 0.72, width: 0.24, height: 0.24)
var overlayOpacity: Double = 1
Expand Down Expand Up @@ -152,6 +153,7 @@ final class FramePipeline: NSObject, @unchecked Sendable {
outputAspect: CGFloat(OpenLensOutput.aspectRatio)
),
mirror: snapshot.mirror,
rotate180: snapshot.rotate180,
overlay: frameOverlay,
adjustments: snapshot.adjustments
)
Expand Down
4 changes: 4 additions & 0 deletions Sources/OpenLens/Rendering/Shaders.metal
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct RenderUniforms {
float overlayOpacity;
// 1 when the source should be mirrored horizontally.
float mirror;
float rotate180;
// Luma offset/scale for video-range vs full-range YCbCr.
float lumaOffset;
float lumaScale;
Expand Down Expand Up @@ -58,6 +59,9 @@ vertex VertexOut openlens_vertex(uint vertexID [[vertex_id]],
// The crop is applied here, in the vertex stage: zooming costs nothing at
// runtime because it is only a remap of texture coordinates.
out.sourceCoord = u.cropRect.xy + cropUV * u.cropRect.zw;
if (u.rotate180 > 0.5) {
out.sourceCoord = 1.0 - out.sourceCoord;
}
return out;
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/OpenLens/Rendering/VideoRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct RenderUniforms {
var overlayRect: SIMD4<Float>
var overlayOpacity: Float
var mirror: Float
var rotate180: Float
var lumaOffset: Float
var lumaScale: Float
var chromaOffset: Float
Expand Down Expand Up @@ -147,6 +148,7 @@ final class VideoRenderer {
var pixelBuffer: CVPixelBuffer
var crop: CGRect
var mirror: Bool
var rotate180 = false
var overlay: OverlayTexture?
var adjustments: ImageAdjustments = .neutral
}
Expand Down Expand Up @@ -330,6 +332,7 @@ final class VideoRenderer {
} ?? SIMD4<Float>(0, 0, 0, 0),
overlayOpacity: Float(frame.overlay?.opacity ?? 0),
mirror: frame.mirror ? 1 : 0,
rotate180: frame.rotate180 ? 1 : 0,
lumaOffset: isFullRange ? 0.0 : 16.0 / 255.0,
lumaScale: isFullRange ? 1.0 : 255.0 / 219.0,
chromaOffset: 128.0 / 255.0,
Expand Down
8 changes: 8 additions & 0 deletions Sources/OpenLens/UI/InspectorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ struct InspectorView: View {
}

Toggle("Mirror", isOn: mirrorBinding)
Toggle("Rotate 180°", isOn: rotated180Binding)

HStack {
Button("Duplicate") { model.duplicateSelectedScene() }
Expand Down Expand Up @@ -475,6 +476,13 @@ struct InspectorView: View {
)
}

private var rotated180Binding: Binding<Bool> {
Binding(
get: { scenes.selectedScene?.rotated180 ?? false },
set: { model.setRotated180($0) }
)
}

private var overlayEnabledBinding: Binding<Bool> {
Binding(
get: { scenes.selectedScene?.overlayEnabled ?? false },
Expand Down
4 changes: 3 additions & 1 deletion Tests/OpenLensTests/CameraSceneCodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ final class CameraSceneCodingTests: XCTestCase {
XCTAssertEqual(scene.name, "Desk")
XCTAssertEqual(scene.crop.zoom, 1.4, accuracy: 1e-9)
XCTAssertTrue(scene.mirrored)
XCTAssertFalse(scene.rotated180)
// The missing key must read back as neutral rather than throwing.
XCTAssertEqual(scene.adjustments, .neutral)
// And the scene must read as "leaves the lights alone" rather than as
Expand Down Expand Up @@ -103,6 +104,7 @@ final class CameraSceneCodingTests: XCTestCase {
deviceName: "Cam Link 4K",
crop: CropState(center: CGPoint(x: 0.4, y: 0.6), zoom: 1.8),
mirrored: true,
rotated180: true,
quality: .matchOutput,
overlayEnabled: true,
overlayRect: CGRect(x: 0.1, y: 0.2, width: 0.3, height: 0.4),
Expand Down Expand Up @@ -135,7 +137,7 @@ final class CameraSceneCodingTests: XCTestCase {
[
"id", "name", "deviceID", "deviceName", "crop", "mirrored",
"quality", "overlayEnabled", "overlayRect", "overlayOpacity",
"adjustments", "lighting",
"adjustments", "lighting", "rotated180",
],
"A scene property was added or removed. Check that it is listed in "
+ "CodingKeys and in the hand-written initialiser, and that "
Expand Down
40 changes: 36 additions & 4 deletions Tests/OpenLensTests/VideoRendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ final class VideoRendererTests: XCTestCase {
// MARK: - Fixtures

/// A 1280x720 BGRA frame: red on the left half, blue on the right half.
private func makeSourceBuffer() throws -> CVPixelBuffer {
private func makeSourceBuffer(greenBottomHalf: Bool = false) throws -> CVPixelBuffer {
let width = 1280
let height = 720
var buffer: CVPixelBuffer?
Expand Down Expand Up @@ -49,9 +49,10 @@ final class VideoRendererTests: XCTestCase {
for x in 0..<width {
let pixel = row.advanced(by: x * 4)
let isLeft = x < width / 2
pixel[0] = isLeft ? 0 : 255 // B
pixel[1] = 0 // G
pixel[2] = isLeft ? 255 : 0 // R
let isGreen = greenBottomHalf && y >= height / 2
pixel[0] = !isGreen && !isLeft ? 255 : 0 // B
pixel[1] = isGreen ? 255 : 0 // G
pixel[2] = !isGreen && isLeft ? 255 : 0 // R
pixel[3] = 255
}
}
Expand Down Expand Up @@ -207,6 +208,37 @@ final class VideoRendererTests: XCTestCase {
assertDominant(\.x, try sample(output, atX: 0.9, y: 0.5))
}

func testRotate180TurnsTheImageUpsideDown() throws {
let output = try render(
VideoRenderer.Frame(
pixelBuffer: try makeSourceBuffer(greenBottomHalf: true),
crop: CGRect(x: 0, y: 0, width: 1, height: 1),
mirror: false,
rotate180: true,
overlay: nil
)
)
assertDominant(\.y, try sample(output, atX: 0.1, y: 0.25))
assertDominant(\.y, try sample(output, atX: 0.9, y: 0.25))
assertDominant(\.z, try sample(output, atX: 0.1, y: 0.75))
assertDominant(\.x, try sample(output, atX: 0.9, y: 0.75))
}

func testCropAppliesToTheRotatedImage() throws {
let output = try render(
VideoRenderer.Frame(
pixelBuffer: try makeSourceBuffer(),
crop: CGRect(x: 0, y: 0, width: 0.5, height: 1),
mirror: false,
rotate180: true,
overlay: nil
)
)
for x in [0.02, 0.5, 0.98] {
assertDominant(\.z, try sample(output, atX: x, y: 0.5))
}
}

func testOverlayIsCompositedOverTheLeftHalfOfTheOutput() throws {
let output = try render(
VideoRenderer.Frame(
Expand Down