From 46b22d0c9ce40c81e7d2c72857e8c62ffce42d27 Mon Sep 17 00:00:00 2001 From: Jonas Danila <10038023+kriakiku@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:26:30 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20180=C2=B0=20rotation=20for=20upsi?= =?UTF-8?q?de-down=20cameras?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++ README.md | 2 +- Sources/OpenLens/Model/AppModel.swift | 7 ++++ Sources/OpenLens/Model/CameraScene.swift | 10 +++++ Sources/OpenLens/Model/FramePipeline.swift | 2 + Sources/OpenLens/Rendering/Shaders.metal | 4 ++ .../OpenLens/Rendering/VideoRenderer.swift | 3 ++ Sources/OpenLens/UI/InspectorView.swift | 8 ++++ .../CameraSceneCodingTests.swift | 4 +- Tests/OpenLensTests/VideoRendererTests.swift | 40 +++++++++++++++++-- 10 files changed, 78 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85528cd..9a3fecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 453d3f7..14fb479 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/OpenLens/Model/AppModel.swift b/Sources/OpenLens/Model/AppModel.swift index 79f35db..e62538d 100644 --- a/Sources/OpenLens/Model/AppModel.swift +++ b/Sources/OpenLens/Model/AppModel.swift @@ -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 @@ -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) { diff --git a/Sources/OpenLens/Model/CameraScene.swift b/Sources/OpenLens/Model/CameraScene.swift index f79dfab..71996db 100644 --- a/Sources/OpenLens/Model/CameraScene.swift +++ b/Sources/OpenLens/Model/CameraScene.swift @@ -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 } @@ -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 @@ -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), @@ -117,6 +126,7 @@ struct CameraScene: Identifiable, Codable, Equatable { self.overlayOpacity = overlayOpacity self.storedAdjustments = adjustments self.storedLighting = lighting + self.storedRotated180 = rotated180 } } diff --git a/Sources/OpenLens/Model/FramePipeline.swift b/Sources/OpenLens/Model/FramePipeline.swift index dcd797c..3d55dad 100644 --- a/Sources/OpenLens/Model/FramePipeline.swift +++ b/Sources/OpenLens/Model/FramePipeline.swift @@ -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 @@ -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 ) diff --git a/Sources/OpenLens/Rendering/Shaders.metal b/Sources/OpenLens/Rendering/Shaders.metal index 51d53e8..16a0419 100644 --- a/Sources/OpenLens/Rendering/Shaders.metal +++ b/Sources/OpenLens/Rendering/Shaders.metal @@ -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; @@ -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; } diff --git a/Sources/OpenLens/Rendering/VideoRenderer.swift b/Sources/OpenLens/Rendering/VideoRenderer.swift index 49c273a..5f26664 100644 --- a/Sources/OpenLens/Rendering/VideoRenderer.swift +++ b/Sources/OpenLens/Rendering/VideoRenderer.swift @@ -10,6 +10,7 @@ struct RenderUniforms { var overlayRect: SIMD4 var overlayOpacity: Float var mirror: Float + var rotate180: Float var lumaOffset: Float var lumaScale: Float var chromaOffset: Float @@ -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 } @@ -330,6 +332,7 @@ final class VideoRenderer { } ?? SIMD4(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, diff --git a/Sources/OpenLens/UI/InspectorView.swift b/Sources/OpenLens/UI/InspectorView.swift index eeeb831..d7b5d3b 100644 --- a/Sources/OpenLens/UI/InspectorView.swift +++ b/Sources/OpenLens/UI/InspectorView.swift @@ -73,6 +73,7 @@ struct InspectorView: View { } Toggle("Mirror", isOn: mirrorBinding) + Toggle("Rotate 180°", isOn: rotated180Binding) HStack { Button("Duplicate") { model.duplicateSelectedScene() } @@ -475,6 +476,13 @@ struct InspectorView: View { ) } + private var rotated180Binding: Binding { + Binding( + get: { scenes.selectedScene?.rotated180 ?? false }, + set: { model.setRotated180($0) } + ) + } + private var overlayEnabledBinding: Binding { Binding( get: { scenes.selectedScene?.overlayEnabled ?? false }, diff --git a/Tests/OpenLensTests/CameraSceneCodingTests.swift b/Tests/OpenLensTests/CameraSceneCodingTests.swift index 76824e3..ed60d46 100644 --- a/Tests/OpenLensTests/CameraSceneCodingTests.swift +++ b/Tests/OpenLensTests/CameraSceneCodingTests.swift @@ -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 @@ -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), @@ -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 " diff --git a/Tests/OpenLensTests/VideoRendererTests.swift b/Tests/OpenLensTests/VideoRendererTests.swift index aed4880..12fb2dc 100644 --- a/Tests/OpenLensTests/VideoRendererTests.swift +++ b/Tests/OpenLensTests/VideoRendererTests.swift @@ -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? @@ -49,9 +49,10 @@ final class VideoRendererTests: XCTestCase { for x in 0..= 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 } } @@ -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(