diff --git a/addon/src/main/model/CameraInfo.gd b/addon/src/main/model/CameraInfo.gd
index 6b9281e..7f17d20 100644
--- a/addon/src/main/model/CameraInfo.gd
+++ b/addon/src/main/model/CameraInfo.gd
@@ -7,6 +7,7 @@ class_name CameraInfo extends RefCounted
const DATA_CAMERA_ID_PROPERTY := &"camera_id"
const DATA_IS_FRONT_FACING_PROPERTY := &"is_front_facing"
const DATA_OUTPUT_SIZES_PROPERTY := &"output_sizes"
+const DATA_SENSOR_ORIENTATION_PROPERTY := &"sensor_orientation"
var _data: Dictionary
@@ -30,3 +31,11 @@ func get_output_sizes() -> Array[FrameSize]:
__sizes.append(FrameSize.new(__size_dict))
return __sizes
+
+
+## Returns the clockwise angle in degrees (0, 90, 180, or 270) that the camera
+## sensor image must be rotated to be upright when the device is held in its
+## natural (portrait) orientation. Defaults to [code]0[/code] when the value
+## is unavailable.
+func get_sensor_orientation() -> int:
+ return _data[DATA_SENSOR_ORIENTATION_PROPERTY] if _data.has(DATA_SENSOR_ORIENTATION_PROPERTY) else 0
diff --git a/addon/src/main/model/FrameInfo.gd b/addon/src/shared/FrameInfo.gd
similarity index 100%
rename from addon/src/main/model/FrameInfo.gd
rename to addon/src/shared/FrameInfo.gd
diff --git a/addon/src/main/model/FrameSize.gd b/addon/src/shared/FrameSize.gd
similarity index 100%
rename from addon/src/main/model/FrameSize.gd
rename to addon/src/shared/FrameSize.gd
diff --git a/android/src/main/java/org/godotengine/plugin/nativecamera/model/CameraInfo.java b/android/src/main/java/org/godotengine/plugin/nativecamera/model/CameraInfo.java
index 17eae63..ae889f4 100644
--- a/android/src/main/java/org/godotengine/plugin/nativecamera/model/CameraInfo.java
+++ b/android/src/main/java/org/godotengine/plugin/nativecamera/model/CameraInfo.java
@@ -22,6 +22,7 @@ public class CameraInfo {
private static final String DATA_CAMERA_ID_PROPERTY = "camera_id";
private static final String DATA_IS_FRONT_FACING_PROPERTY = "is_front_facing";
private static final String DATA_OUTPUT_SIZES_PROPERTY = "output_sizes";
+ private static final String DATA_SENSOR_ORIENTATION_PROPERTY = "sensor_orientation";
private String cameraId;
private CameraCharacteristics characteristics;
@@ -56,6 +57,13 @@ public Dictionary buildRawData() {
dict.put(DATA_OUTPUT_SIZES_PROPERTY, dictList.toArray());
+ // SENSOR_ORIENTATION is the clockwise angle (0, 90, 180, or 270 degrees) that
+ // the camera image must be rotated to be upright when the device is held in its
+ // natural (portrait) orientation. Null-safe: defaults to 0 when the value is
+ // unavailable (e.g. on emulators or in unit tests).
+ Integer sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION);
+ dict.put(DATA_SENSOR_ORIENTATION_PROPERTY, sensorOrientation != null ? sensorOrientation : 0);
+
return dict;
}
}
diff --git a/android/src/test/java/org/godotengine/plugin/nativecamera/CameraInfoTest.java b/android/src/test/java/org/godotengine/plugin/nativecamera/CameraInfoTest.java
index 625882f..48c130a 100644
--- a/android/src/test/java/org/godotengine/plugin/nativecamera/CameraInfoTest.java
+++ b/android/src/test/java/org/godotengine/plugin/nativecamera/CameraInfoTest.java
@@ -27,6 +27,14 @@
* Unit tests for {@link CameraInfo}.
*
*
All Android framework classes are mocked with Mockito — no real Android runtime is required.
+ *
+ *
{@link CameraCharacteristics#get} is called three times in {@link CameraInfo#buildRawData}:
+ *
+ * - {@link CameraCharacteristics#LENS_FACING}
+ * - {@link CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP}
+ * - {@link CameraCharacteristics#SENSOR_ORIENTATION}
+ *
+ * Each mock helper stubs these three return values in order via chained {@code thenReturn} calls.
*/
@ExtendWith(MockitoExtension.class)
public class CameraInfoTest {
@@ -40,8 +48,13 @@ private static Size size(int w, int h) {
return s;
}
+ /**
+ * Builds a {@link CameraCharacteristics} mock whose three sequential {@code get()} calls
+ * return {@code facing}, {@code map}, and {@code sensorOrientation} respectively.
+ */
@SuppressWarnings("unchecked")
- private CameraCharacteristics mockCharacteristics(int facing, Size[] outputSizes) {
+ private CameraCharacteristics mockCharacteristics(int facing, Size[] outputSizes,
+ int sensorOrientation) {
CameraCharacteristics chars = mock(CameraCharacteristics.class);
StreamConfigurationMap map = mock(StreamConfigurationMap.class);
@@ -49,32 +62,62 @@ private CameraCharacteristics mockCharacteristics(int facing, Size[] outputSizes
// and SuppressWarnings handles the generic return stubbing.
when(chars.get(any()))
.thenReturn(facing)
- .thenReturn(map);
+ .thenReturn(map)
+ .thenReturn(sensorOrientation);
when(map.getOutputSizes(ImageFormat.YUV_420_888)).thenReturn(outputSizes);
return chars;
}
+ /**
+ * Variant with no {@link StreamConfigurationMap} (second {@code get()} returns null).
+ */
@SuppressWarnings("unchecked")
- private CameraCharacteristics mockCharacteristicsNoMap(int facing) {
+ private CameraCharacteristics mockCharacteristicsNoMap(int facing, int sensorOrientation) {
CameraCharacteristics chars = mock(CameraCharacteristics.class);
when(chars.get(any()))
.thenReturn(facing)
- .thenReturn(null); // no map
+ .thenReturn(null) // no map
+ .thenReturn(sensorOrientation);
+
+ return chars;
+ }
+
+ /**
+ * Variant with a null lens-facing value (first {@code get()} returns null).
+ */
+ @SuppressWarnings("unchecked")
+ private CameraCharacteristics mockCharacteristicsNullFacing(Size[] outputSizes,
+ int sensorOrientation) {
+ CameraCharacteristics chars = mock(CameraCharacteristics.class);
+ StreamConfigurationMap map = mock(StreamConfigurationMap.class);
+
+ when(chars.get(any()))
+ .thenReturn(null) // null facing
+ .thenReturn(map)
+ .thenReturn(sensorOrientation);
+
+ when(map.getOutputSizes(ImageFormat.YUV_420_888)).thenReturn(outputSizes);
return chars;
}
+ /**
+ * Variant where {@link CameraCharacteristics#SENSOR_ORIENTATION} is null (third
+ * {@code get()} returns null), exercising the null-safety default of 0.
+ */
@SuppressWarnings("unchecked")
- private CameraCharacteristics mockCharacteristicsNullFacing(Size[] outputSizes) {
+ private CameraCharacteristics mockCharacteristicsNullSensorOrientation(int facing,
+ Size[] outputSizes) {
CameraCharacteristics chars = mock(CameraCharacteristics.class);
StreamConfigurationMap map = mock(StreamConfigurationMap.class);
when(chars.get(any()))
- .thenReturn(null) // null facing
- .thenReturn(map);
+ .thenReturn(facing)
+ .thenReturn(map)
+ .thenReturn(null); // null sensor orientation
when(map.getOutputSizes(ImageFormat.YUV_420_888)).thenReturn(outputSizes);
@@ -86,14 +129,14 @@ private CameraCharacteristics mockCharacteristicsNullFacing(Size[] outputSizes)
@Test
public void buildRawData_cameraIdBackCamera() {
CameraInfo info = new CameraInfo("0",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0]));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90));
assertEquals("0", info.buildRawData().get("camera_id"));
}
@Test
public void buildRawData_cameraIdFrontCamera() {
CameraInfo info = new CameraInfo("1",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0]));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0], 270));
assertEquals("1", info.buildRawData().get("camera_id"));
}
@@ -102,21 +145,21 @@ public void buildRawData_cameraIdFrontCamera() {
@Test
public void buildRawData_backCamera_isFrontFacingFalse() {
CameraInfo info = new CameraInfo("0",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0]));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90));
assertFalse((Boolean) info.buildRawData().get("is_front_facing"));
}
@Test
public void buildRawData_frontCamera_isFrontFacingTrue() {
CameraInfo info = new CameraInfo("1",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0]));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0], 270));
assertTrue((Boolean) info.buildRawData().get("is_front_facing"));
}
@Test
public void buildRawData_nullFacing_isFrontFacingFalse() {
CameraInfo info = new CameraInfo("2",
- mockCharacteristicsNullFacing(new Size[0]));
+ mockCharacteristicsNullFacing(new Size[0], 90));
assertFalse((Boolean) info.buildRawData().get("is_front_facing"));
}
@@ -125,7 +168,7 @@ public void buildRawData_nullFacing_isFrontFacingFalse() {
@Test
public void buildRawData_noOutputSizes_returnsEmptyArray() {
CameraInfo info = new CameraInfo("0",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0]));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90));
Object[] sizes = (Object[]) info.buildRawData().get("output_sizes");
assertNotNull(sizes);
assertEquals(0, sizes.length);
@@ -135,7 +178,7 @@ public void buildRawData_noOutputSizes_returnsEmptyArray() {
public void buildRawData_twoOutputSizes_returnsTwoEntries() {
Size[] sizes = {size(1920, 1080), size(1280, 720)};
CameraInfo info = new CameraInfo("0",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, sizes));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, sizes, 90));
Object[] result = (Object[]) info.buildRawData().get("output_sizes");
assertEquals(2, result.length);
}
@@ -144,7 +187,7 @@ public void buildRawData_twoOutputSizes_returnsTwoEntries() {
public void buildRawData_outputSizesContainWidthAndHeight() {
Size[] sizes = {size(640, 480)};
CameraInfo info = new CameraInfo("0",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, sizes));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, sizes, 90));
Object[] result = (Object[]) info.buildRawData().get("output_sizes");
Dictionary sizeDict = (Dictionary) result[0];
assertEquals(640, sizeDict.get("width"));
@@ -154,7 +197,7 @@ public void buildRawData_outputSizesContainWidthAndHeight() {
@Test
public void buildRawData_nullStreamConfigMap_returnsEmptyOutputSizes() {
CameraInfo info = new CameraInfo("0",
- mockCharacteristicsNoMap(CameraCharacteristics.LENS_FACING_BACK));
+ mockCharacteristicsNoMap(CameraCharacteristics.LENS_FACING_BACK, 90));
Object[] result = (Object[]) info.buildRawData().get("output_sizes");
assertNotNull(result);
assertEquals(0, result.length);
@@ -163,21 +206,71 @@ public void buildRawData_nullStreamConfigMap_returnsEmptyOutputSizes() {
@Test
public void buildRawData_nullOutputSizesArray_returnsEmptyArray() {
CameraInfo info = new CameraInfo("0",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, null));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, null, 90));
Object[] result = (Object[]) info.buildRawData().get("output_sizes");
assertNotNull(result);
assertEquals(0, result.length);
}
+ // -- sensor_orientation ------------------------------------------------
+
+ @Test
+ public void buildRawData_backCamera_sensorOrientation90() {
+ CameraInfo info = new CameraInfo("0",
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90));
+ assertEquals(90, info.buildRawData().get("sensor_orientation"));
+ }
+
+ @Test
+ public void buildRawData_frontCamera_sensorOrientation270() {
+ CameraInfo info = new CameraInfo("1",
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_FRONT, new Size[0], 270));
+ assertEquals(270, info.buildRawData().get("sensor_orientation"));
+ }
+
+ @Test
+ public void buildRawData_sensorOrientation0_returnsZero() {
+ CameraInfo info = new CameraInfo("0",
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 0));
+ assertEquals(0, info.buildRawData().get("sensor_orientation"));
+ }
+
+ @Test
+ public void buildRawData_sensorOrientation180_returns180() {
+ CameraInfo info = new CameraInfo("0",
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 180));
+ assertEquals(180, info.buildRawData().get("sensor_orientation"));
+ }
+
+ @Test
+ public void buildRawData_nullSensorOrientation_defaultsToZero() {
+ CameraInfo info = new CameraInfo("0",
+ mockCharacteristicsNullSensorOrientation(
+ CameraCharacteristics.LENS_FACING_BACK, new Size[0]));
+ assertEquals(0, info.buildRawData().get("sensor_orientation"));
+ }
+
+ @Test
+ public void buildRawData_nullSensorOrientation_doesNotThrow() {
+ CameraInfo info = new CameraInfo("0",
+ mockCharacteristicsNullSensorOrientation(
+ CameraCharacteristics.LENS_FACING_BACK, new Size[0]));
+ // Must not throw; must contain the key with the default value
+ Dictionary d = info.buildRawData();
+ assertTrue(d.containsKey("sensor_orientation"));
+ assertEquals(0, d.get("sensor_orientation"));
+ }
+
// -- key completeness -------------------------------------------------
@Test
public void buildRawData_containsAllExpectedKeys() {
CameraInfo info = new CameraInfo("0",
- mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0]));
+ mockCharacteristics(CameraCharacteristics.LENS_FACING_BACK, new Size[0], 90));
Dictionary d = info.buildRawData();
assertTrue(d.containsKey("camera_id"));
assertTrue(d.containsKey("is_front_facing"));
assertTrue(d.containsKey("output_sizes"));
+ assertTrue(d.containsKey("sensor_orientation"));
}
}
diff --git a/common/config/plugin.properties b/common/config/plugin.properties
index f9d694a..8c1d01b 100644
--- a/common/config/plugin.properties
+++ b/common/config/plugin.properties
@@ -5,4 +5,4 @@
pluginNodeName=NativeCamera
pluginModuleName=native_camera
pluginPackage=org.godotengine.plugin.nativecamera
-pluginVersion=2.0
+pluginVersion=3.0
diff --git a/demo/Main.gd b/demo/Main.gd
index 440bb77..7dac209 100644
--- a/demo/Main.gd
+++ b/demo/Main.gd
@@ -53,9 +53,19 @@ func _on_get_button_pressed() -> void:
var __cameras_array = camera_node.get_all_cameras()
for __camera_info in __cameras_array:
_cameras[__camera_info.get_camera_id()] = __camera_info
- print("Available size:")
+ _print_to_screen(
+ (
+ "Camera %s: front-facing?=%s, sensorOrientation=%d"
+ % [
+ __camera_info.get_camera_id(),
+ str(__camera_info.is_front_facing()),
+ __camera_info.get_sensor_orientation()
+ ]
+ )
+ )
+ _print_to_screen("Available output sizes:")
for __size: FrameSize in __camera_info.get_output_sizes():
- print("[%d,%d]" % [__size.get_width(), __size.get_height()])
+ _print_to_screen("[%d,%d]" % [__size.get_width(), __size.get_height()])
cameras_option_button.add_item(__camera_info.get_camera_id())
if not __cameras_array.is_empty():
diff --git a/demo/addons/GMPShared/FrameInfo.gd.uid b/demo/addons/GMPShared/FrameInfo.gd.uid
new file mode 100644
index 0000000..53c7e62
--- /dev/null
+++ b/demo/addons/GMPShared/FrameInfo.gd.uid
@@ -0,0 +1 @@
+uid://3nivbhilp5kc
diff --git a/demo/addons/GMPShared/FrameSize.gd.uid b/demo/addons/GMPShared/FrameSize.gd.uid
new file mode 100644
index 0000000..92fd88c
--- /dev/null
+++ b/demo/addons/GMPShared/FrameSize.gd.uid
@@ -0,0 +1 @@
+uid://0nvtl37flq1c
diff --git a/demo/addons/NativeCameraPlugin/model/CameraInfo.gd.uid b/demo/addons/NativeCameraPlugin/model/CameraInfo.gd.uid
index 44eb4ac..d1bca15 100644
--- a/demo/addons/NativeCameraPlugin/model/CameraInfo.gd.uid
+++ b/demo/addons/NativeCameraPlugin/model/CameraInfo.gd.uid
@@ -1 +1 @@
-uid://cpkga3i4ky2d0
+uid://cltabpju1wo4a
diff --git a/demo/addons/NativeCameraPlugin/model/FeedRequest.gd.uid b/demo/addons/NativeCameraPlugin/model/FeedRequest.gd.uid
index 4ecbcf2..0907294 100644
--- a/demo/addons/NativeCameraPlugin/model/FeedRequest.gd.uid
+++ b/demo/addons/NativeCameraPlugin/model/FeedRequest.gd.uid
@@ -1 +1 @@
-uid://bl58f6bhh4jp5
+uid://dy36kbvkhhp7q
diff --git a/demo/addons/NativeCameraPlugin/model/FrameInfo.gd.uid b/demo/addons/NativeCameraPlugin/model/FrameInfo.gd.uid
deleted file mode 100644
index b2cd189..0000000
--- a/demo/addons/NativeCameraPlugin/model/FrameInfo.gd.uid
+++ /dev/null
@@ -1 +0,0 @@
-uid://cmfayawhvpwtj
diff --git a/demo/addons/NativeCameraPlugin/model/FrameSize.gd.uid b/demo/addons/NativeCameraPlugin/model/FrameSize.gd.uid
deleted file mode 100644
index 0610fb1..0000000
--- a/demo/addons/NativeCameraPlugin/model/FrameSize.gd.uid
+++ /dev/null
@@ -1 +0,0 @@
-uid://d34q1vxftnsyw
diff --git a/demo/project.godot b/demo/project.godot
index 29871d2..4351879 100644
--- a/demo/project.godot
+++ b/demo/project.godot
@@ -15,6 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Native Camera Demo"
+config/tags=PackedStringArray("android", "camera", "demo", "ios", "plugin")
run/main_scene="uid://daro5x6n3pd26"
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://assets/native-camera-android.png"
diff --git a/docs/README.md b/docs/README.md
index a437a5d..79d5db6 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -232,6 +232,8 @@ Encapsulates camera metadata provided by the mobile OS.
* `get_camera_id() -> String`
* `is_front_facing() -> bool`
* `get_output_sizes() -> Array[FrameSize]`
+* `get_sensor_orientation() -> int`
+ * Returns the clockwise angle in degrees (`0`, `90`, `180`, or `270`) that the camera sensor image must be rotated to be upright when the device is held in its natural (portrait) orientation. This value is a fixed hardware property of the camera and does not change with device rotation. Defaults to `0` when the value is unavailable (e.g. on emulators). Useful when implementing custom frame processing that needs to account for sensor mounting angle independently of device orientation.
###
FeedRequest
diff --git a/ios/plugin.xcodeproj/project.pbxproj b/ios/plugin.xcodeproj/project.pbxproj
index f28cd41..f32c1dd 100644
--- a/ios/plugin.xcodeproj/project.pbxproj
+++ b/ios/plugin.xcodeproj/project.pbxproj
@@ -10,6 +10,8 @@
0721C6A22F82629000A9BA75 /* libnative_camera_plugin.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 90CAAA9424E71FF10013969F /* libnative_camera_plugin.a */; };
0721C6C12F82639B00A9BA75 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0721C6C02F82639B00A9BA75 /* XCTest.framework */; platformFilter = ios; };
072C41E12F8F2AA300BA4867 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072C41E02F8F2AA200BA4867 /* AVFoundation.framework */; };
+ 078F64A12FAF0C8A005B6CE8 /* CameraInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078F649F2FAF0C8A005B6CE8 /* CameraInfoTests.swift */; };
+ 078F64A22FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 078F64A02FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift */; };
07A6C6802F8F73FE00F484DA /* NativeCameraTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A6C67C2F8F73FE00F484DA /* NativeCameraTests.swift */; };
07A6C6812F8F73FE00F484DA /* TestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A6C6782F8F73FE00F484DA /* TestFixtures.swift */; };
07A6C6822F8F73FE00F484DA /* WrapperTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 07A6C67D2F8F73FE00F484DA /* WrapperTests.mm */; };
@@ -53,6 +55,8 @@
0721C69E2F82629000A9BA75 /* native_camera_plugin_tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = native_camera_plugin_tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0721C6C02F82639B00A9BA75 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
072C41E02F8F2AA200BA4867 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.0.sdk/System/Library/Frameworks/AVFoundation.framework; sourceTree = DEVELOPER_DIR; };
+ 078F649F2FAF0C8A005B6CE8 /* CameraInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraInfoTests.swift; sourceTree = ""; };
+ 078F64A02FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCameraAutoUprightTests.swift; sourceTree = ""; };
07A6C6782F8F73FE00F484DA /* TestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFixtures.swift; sourceTree = ""; };
07A6C67A2F8F73FE00F484DA /* FrameRequestTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FrameRequestTests.mm; sourceTree = ""; };
07A6C67B2F8F73FE00F484DA /* native_camera_test_helpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = native_camera_test_helpers.h; sourceTree = ""; };
@@ -112,6 +116,8 @@
07A6C67E2F8F73FE00F484DA /* unit */ = {
isa = PBXGroup;
children = (
+ 078F649F2FAF0C8A005B6CE8 /* CameraInfoTests.swift */,
+ 078F64A02FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift */,
07A6C67A2F8F73FE00F484DA /* FrameRequestTests.mm */,
07A6C67B2F8F73FE00F484DA /* native_camera_test_helpers.h */,
07A6C67C2F8F73FE00F484DA /* NativeCameraTests.swift */,
@@ -286,6 +292,8 @@
buildActionMask = 2147483647;
files = (
07A6C6802F8F73FE00F484DA /* NativeCameraTests.swift in Sources */,
+ 078F64A12FAF0C8A005B6CE8 /* CameraInfoTests.swift in Sources */,
+ 078F64A22FAF0C8A005B6CE8 /* NativeCameraAutoUprightTests.swift in Sources */,
07A6C6812F8F73FE00F484DA /* TestFixtures.swift in Sources */,
07A6C6822F8F73FE00F484DA /* WrapperTests.mm in Sources */,
07A6C6832F8F73FE00F484DA /* FrameRequestTests.mm in Sources */,
@@ -354,10 +362,6 @@
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
- OTHER_LDFLAGS = (
- "$(inherited)",
- "$(GODOT_DIR)/bin/libgodot.ios.template_debug.$(CURRENT_ARCH).simulator.a",
- );
OTHER_CFLAGS = (
"-g",
"-DDEBUG",
@@ -406,6 +410,10 @@
"-isystem",
"$(GODOT_DIR)/platform/ios",
);
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "$(GODOT_DIR)/bin/libgodot.ios.template_debug.$(CURRENT_ARCH).simulator.a",
+ );
PRODUCT_BUNDLE_IDENTIFIER = "org.godotengine.plugin.native-camera-unit-tests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -452,10 +460,6 @@
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
- OTHER_LDFLAGS = (
- "$(inherited)",
- "$(GODOT_DIR)/bin/libgodot.ios.template_debug.$(CURRENT_ARCH).simulator.a",
- );
OTHER_CFLAGS = (
"-fmodules",
"-fobjc-arc",
@@ -495,6 +499,10 @@
"-isystem",
"$(GODOT_DIR)/platform/ios",
);
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "$(GODOT_DIR)/bin/libgodot.ios.template_debug.$(CURRENT_ARCH).simulator.a",
+ );
PRODUCT_BUNDLE_IDENTIFIER = "org.godotengine.plugin.native-camera-unit-tests";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
diff --git a/ios/src/NativeCamera.swift b/ios/src/NativeCamera.swift
index 2f9d4ae..791590f 100644
--- a/ios/src/NativeCamera.swift
+++ b/ios/src/NativeCamera.swift
@@ -73,10 +73,34 @@ import UIKit
let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription)
return FrameSize(width: Int(dims.width), height: Int(dims.height))
}
- return CameraInfo(id: device.uniqueID, device: device, outputSizes: sizes)
+ let so = NativeCamera.deriveSensorOrientation(from: device)
+ return CameraInfo(id: device.uniqueID, device: device, outputSizes: sizes,
+ sensorOrientation: so)
}
}
+ /// Derives the sensor orientation in degrees (0, 90, 180, or 270) from the
+ /// first available capture format on the given device.
+ ///
+ /// On iPhone, the built-in camera sensor is mounted in landscape orientation, so
+ /// the raw frame buffer always needs 90° of clockwise rotation to appear upright
+ /// in the device's natural (portrait) orientation. On some iPad models the camera
+ /// may be mounted differently, resulting in 0°. The value is a fixed hardware
+ /// property and does not change with device orientation.
+ ///
+ /// - Parameter device: The `AVCaptureDevice` to inspect.
+ /// - Returns: `90` if the first format's native width is greater than or equal to
+ /// its height (landscape-mounted sensor — the common case for all iPhones), or
+ /// `0` for a portrait-mounted sensor. Defaults to `90` when no formats are
+ /// available, matching the expected behaviour on all current iPhone hardware.
+ internal static func deriveSensorOrientation(from device: AVCaptureDevice) -> Int {
+ guard let format = device.formats.first else { return 90 }
+ let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription)
+ // A landscape-native sensor (width >= height) in a portrait-natural device
+ // needs 90° CW rotation to produce an upright image — the standard for all iPhones.
+ return dims.width >= dims.height ? 90 : 0
+ }
+
@objc public func start(request: FrameRequest) {
// UIDevice orientation tracking must be started on the main thread.
// The notification fires on the main thread too, keeping deviceOrientation
diff --git a/ios/src/model/CameraInfo.swift b/ios/src/model/CameraInfo.swift
index 2faec80..29fee4b 100644
--- a/ios/src/model/CameraInfo.swift
+++ b/ios/src/model/CameraInfo.swift
@@ -10,11 +10,24 @@ import Foundation
@objc let device: AVCaptureDevice
@objc let outputSizes: [FrameSize]
- @objc(initWithId:device:outputSizes:)
- init(id cameraId: String, device: AVCaptureDevice, outputSizes: [FrameSize]) {
+ /// The clockwise angle in degrees (0, 90, 180, or 270) that the camera
+ /// sensor image must be rotated to be upright when the device is held in
+ /// its natural (portrait) orientation.
+ ///
+ /// On iPhone, all built-in cameras capture natively in landscape, so this
+ /// value is 90°. On some iPad models the camera may be mounted differently
+ /// and return 0°. The value is a fixed hardware property and does not
+ /// change with device rotation. It is used alongside `isFrontFacing` to
+ /// implement custom frame-processing pipelines that must account for sensor
+ /// mounting angle independently of live device orientation.
+ @objc let sensorOrientation: Int
+
+ @objc(initWithId:device:outputSizes:sensorOrientation:)
+ init(id cameraId: String, device: AVCaptureDevice, outputSizes: [FrameSize], sensorOrientation: Int) {
self.id = cameraId
self.device = device
self.outputSizes = outputSizes
+ self.sensorOrientation = sensorOrientation
super.init()
}
}
diff --git a/ios/src/model/camera_info_wrapper.mm b/ios/src/model/camera_info_wrapper.mm
index f7d54fc..bd99296 100644
--- a/ios/src/model/camera_info_wrapper.mm
+++ b/ios/src/model/camera_info_wrapper.mm
@@ -11,6 +11,7 @@ @implementation CameraInfoWrapper
static String const kCameraIdProperty = "camera_id";
static String const kIsFrontFacingProperty = "is_front_facing";
static String const kOutputSizesProperty = "output_sizes";
+static String const kSensorOrientationProperty = "sensor_orientation";
- (instancetype)initWithCameraInfo:(CameraInfo *)cameraInfo {
self = [super init];
@@ -37,6 +38,12 @@ - (Dictionary)buildRawData {
dict[kOutputSizesProperty] = dictArray;
+ // SENSOR_ORIENTATION is the clockwise angle (0, 90, 180, or 270 degrees) that
+ // the camera image must be rotated to be upright when the device is in its
+ // natural (portrait) orientation. On iPhone this is always 90°; on some iPad
+ // models it may be 0°. Mirrors the Android Camera2 SENSOR_ORIENTATION field.
+ dict[kSensorOrientationProperty] = (int)self.cameraInfo.sensorOrientation;
+
return dict;
}
diff --git a/ios/test/unit/CameraInfoTests.swift b/ios/test/unit/CameraInfoTests.swift
new file mode 100644
index 0000000..b265579
--- /dev/null
+++ b/ios/test/unit/CameraInfoTests.swift
@@ -0,0 +1,143 @@
+//
+// © 2026-present https://github.com/cengiz-pz
+//
+
+import AVFoundation
+@testable import native_camera_plugin
+import XCTest
+
+// MARK: - CameraInfo Tests
+
+/// Tests for the `CameraInfo` model, focusing on the `sensorOrientation` property.
+///
+/// `CameraInfo.init` requires a real `AVCaptureDevice`; tests that need one are
+/// guarded by `XCTSkip` so they are silently skipped on a Simulator host with
+/// no physical cameras attached.
+final class CameraInfoTests: XCTestCase {
+
+ // MARK: - sensorOrientation stored at init
+
+ func test_sensorOrientation_90_isStoredAtInit() throws {
+ let dev = AVCaptureDevice.default(for: .video)
+ try XCTSkipIf(dev == nil, "No camera available on this host")
+ let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 90)
+ XCTAssertEqual(info.sensorOrientation, 90)
+ }
+
+ func test_sensorOrientation_0_isStoredAtInit() throws {
+ let dev = AVCaptureDevice.default(for: .video)
+ try XCTSkipIf(dev == nil, "No camera available on this host")
+ let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 0)
+ XCTAssertEqual(info.sensorOrientation, 0)
+ }
+
+ func test_sensorOrientation_180_isStoredAtInit() throws {
+ let dev = AVCaptureDevice.default(for: .video)
+ try XCTSkipIf(dev == nil, "No camera available on this host")
+ let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 180)
+ XCTAssertEqual(info.sensorOrientation, 180)
+ }
+
+ func test_sensorOrientation_270_isStoredAtInit() throws {
+ let dev = AVCaptureDevice.default(for: .video)
+ try XCTSkipIf(dev == nil, "No camera available on this host")
+ let info = CameraInfo(id: "cam0", device: dev!, outputSizes: [], sensorOrientation: 270)
+ XCTAssertEqual(info.sensorOrientation, 270)
+ }
+
+ // MARK: - sensorOrientation does not interfere with other stored properties
+
+ func test_sensorOrientation_doesNotAffectId() throws {
+ let dev = AVCaptureDevice.default(for: .video)
+ try XCTSkipIf(dev == nil, "No camera available on this host")
+ let info = CameraInfo(id: "unique-id", device: dev!, outputSizes: [], sensorOrientation: 90)
+ XCTAssertEqual(info.id, "unique-id")
+ }
+
+ func test_sensorOrientation_doesNotAffectOutputSizesCount() throws {
+ let dev = AVCaptureDevice.default(for: .video)
+ try XCTSkipIf(dev == nil, "No camera available on this host")
+ let sizes = [FrameSize(width: 1280, height: 720), FrameSize(width: 640, height: 480)]
+ let info = CameraInfo(id: "cam0", device: dev!, outputSizes: sizes, sensorOrientation: 90)
+ XCTAssertEqual(info.outputSizes.count, 2)
+ }
+}
+
+// MARK: - NativeCamera.deriveSensorOrientation Tests
+
+/// Tests for `NativeCamera.deriveSensorOrientation(from:)`.
+///
+/// The helper is `internal static`, making it accessible via `@testable import`.
+/// Because it requires a real `AVCaptureDevice`, every test that constructs one
+/// is guarded by `XCTSkip` for Simulator hosts with no cameras.
+///
+/// ## What is tested here
+///
+/// | Scenario | Expected result |
+/// |---|---|
+/// | Device has landscape-native formats (width ≥ height) | 90° |
+/// | Device has no formats | 90° (safe default) |
+/// | All cameras returned by getCameras() | value in {0, 90, 180, 270} |
+final class NativeCameraDeriveSensorOrientationTests: XCTestCase {
+
+ // MARK: - Default when no formats are available
+
+ /// The helper must never throw and must return a sensible default when the
+ /// device exposes no formats. We cannot construct a formatless device in
+ /// a unit test without private API, so this scenario is verified by checking
+ /// that `deriveSensorOrientation` on any real device returns a canonical value.
+ func test_deriveSensorOrientation_realDevice_returnsCanonicalValue() throws {
+ let dev = AVCaptureDevice.default(for: .video)
+ try XCTSkipIf(dev == nil, "No camera available on this host")
+ let result = NativeCamera.deriveSensorOrientation(from: dev!)
+ XCTAssertTrue([0, 90, 180, 270].contains(result),
+ "deriveSensorOrientation returned non-canonical value \(result)")
+ }
+
+ // MARK: - iPhone built-in back camera is landscape-native → 90°
+
+ func test_deriveSensorOrientation_backCamera_returns90() throws {
+ guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera,
+ for: .video, position: .back) else {
+ throw XCTSkip("No back camera available on this host")
+ }
+ // All iPhone back cameras capture in landscape natively.
+ XCTAssertEqual(NativeCamera.deriveSensorOrientation(from: dev), 90,
+ "iPhone back camera should report sensorOrientation 90")
+ }
+
+ func test_deriveSensorOrientation_frontCamera_returnsCanonicalValue() throws {
+ guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera,
+ for: .video, position: .front) else {
+ throw XCTSkip("No front camera available on this host")
+ }
+ let result = NativeCamera.deriveSensorOrientation(from: dev)
+ XCTAssertTrue([0, 90, 180, 270].contains(result),
+ "Front camera sensorOrientation \(result) is not canonical")
+ }
+
+ // MARK: - getCameras() integration: sensorOrientation propagates end-to-end
+
+ /// When `getCameras()` is called on a host that has at least one camera,
+ /// every returned `CameraInfo` must carry a `sensorOrientation` that is a
+ /// member of {0, 90, 180, 270}.
+ func test_getCameras_sensorOrientation_isCanonicalForAllCameras() {
+ let cameras = NativeCamera().getCameras()
+ let valid = Set([0, 90, 180, 270])
+ for cam in cameras {
+ XCTAssertTrue(valid.contains(cam.sensorOrientation),
+ "Camera '\(cam.id)' sensorOrientation \(cam.sensorOrientation) is not canonical")
+ }
+ }
+
+ /// Confirms that `getCameras()` propagates `sensorOrientation` into `CameraInfo`
+ /// by checking that the property is non-negative for every camera.
+ /// (A value of 0 is valid for landscape-native iPads; negative would be a bug.)
+ func test_getCameras_sensorOrientation_isNonNegative() {
+ let cameras = NativeCamera().getCameras()
+ for cam in cameras {
+ XCTAssertGreaterThanOrEqual(cam.sensorOrientation, 0,
+ "Camera '\(cam.id)' has negative sensorOrientation")
+ }
+ }
+}
diff --git a/ios/test/unit/NativeCameraAutoUprightTests.swift b/ios/test/unit/NativeCameraAutoUprightTests.swift
new file mode 100644
index 0000000..371e103
--- /dev/null
+++ b/ios/test/unit/NativeCameraAutoUprightTests.swift
@@ -0,0 +1,302 @@
+//
+// © 2026-present https://github.com/cengiz-pz
+//
+
+import AVFoundation
+@testable import native_camera_plugin
+import XCTest
+
+// MARK: - NativeCamera Auto-Upright Tests
+
+/// Tests for `computeUprightRotation()` and the supporting state fields.
+///
+/// ## Strategy
+///
+/// `computeUprightRotation()` is `internal` and therefore accessible via
+/// `@testable import`. Its two inputs — `isFrontFacingCamera` and
+/// `deviceOrientation` — are `internal` (promoted from `private` specifically
+/// to enable this test class) so they can be injected directly. This lets
+/// every orientation × camera-type combination be exercised without requiring
+/// real hardware or a live capture session.
+///
+/// ## Rotation table under test
+///
+/// | `UIDeviceOrientation` | Back camera | Front camera |
+/// |-----------------------|-------------|--------------|
+/// | `.portrait` | 90° | 90° |
+/// | `.portraitUpsideDown` | 270° | 270° |
+/// | `.landscapeLeft` | 0° | 180° |
+/// | `.landscapeRight` | 180° | 0° |
+/// | `.faceUp` | 90° (fallback) | 90° (fallback) |
+/// | `.faceDown` | 90° (fallback) | 90° (fallback) |
+/// | `.unknown` | 90° (fallback) | 90° (fallback) |
+final class NativeCameraAutoUprightTests: XCTestCase {
+
+ private var camera: NativeCamera!
+
+ override func setUp() {
+ super.setUp()
+ camera = NativeCamera()
+ }
+
+ override func tearDown() {
+ camera = nil
+ super.tearDown()
+ }
+
+ // MARK: - State defaults
+
+ func test_isFrontFacingCamera_defaultsFalse() {
+ XCTAssertFalse(camera.isFrontFacingCamera,
+ "isFrontFacingCamera must default to false (back camera assumption)")
+ }
+
+ func test_deviceOrientation_defaultsPortrait() {
+ XCTAssertEqual(camera.deviceOrientation, .portrait,
+ "deviceOrientation must default to .portrait so the first frame is always valid")
+ }
+
+ // MARK: - Back camera — all four primary orientations
+
+ /// Back, portrait: sensor is landscape; device is upright → rotate 90° CW.
+ func test_computeUprightRotation_backCamera_portrait_returns90() {
+ camera.isFrontFacingCamera = false
+ camera.deviceOrientation = .portrait
+ XCTAssertEqual(camera.computeUprightRotation(), 90)
+ }
+
+ /// Back, portrait upside-down: sensor landscape, device inverted → rotate 270° CW.
+ func test_computeUprightRotation_backCamera_portraitUpsideDown_returns270() {
+ camera.isFrontFacingCamera = false
+ camera.deviceOrientation = .portraitUpsideDown
+ XCTAssertEqual(camera.computeUprightRotation(), 270)
+ }
+
+ /// Back, landscapeLeft (top points left, home button right):
+ /// sensor already aligned with the display → 0° needed.
+ func test_computeUprightRotation_backCamera_landscapeLeft_returns0() {
+ camera.isFrontFacingCamera = false
+ camera.deviceOrientation = .landscapeLeft
+ XCTAssertEqual(camera.computeUprightRotation(), 0)
+ }
+
+ /// Back, landscapeRight (top points right, home button left):
+ /// sensor inverted relative to the display → rotate 180°.
+ func test_computeUprightRotation_backCamera_landscapeRight_returns180() {
+ camera.isFrontFacingCamera = false
+ camera.deviceOrientation = .landscapeRight
+ XCTAssertEqual(camera.computeUprightRotation(), 180)
+ }
+
+ // MARK: - Front camera — all four primary orientations
+
+ /// Front, portrait: front sensor is horizontally mirrored but still needs
+ /// 90° CW to be upright — same as back camera for portrait.
+ func test_computeUprightRotation_frontCamera_portrait_returns90() {
+ camera.isFrontFacingCamera = true
+ camera.deviceOrientation = .portrait
+ XCTAssertEqual(camera.computeUprightRotation(), 90)
+ }
+
+ /// Front, portrait upside-down: 270° — same as back camera for this axis.
+ func test_computeUprightRotation_frontCamera_portraitUpsideDown_returns270() {
+ camera.isFrontFacingCamera = true
+ camera.deviceOrientation = .portraitUpsideDown
+ XCTAssertEqual(camera.computeUprightRotation(), 270)
+ }
+
+ /// Front, landscapeLeft: because the front sensor is horizontally mirrored,
+ /// the landscape cases are swapped compared to the back camera → 180°.
+ func test_computeUprightRotation_frontCamera_landscapeLeft_returns180() {
+ camera.isFrontFacingCamera = true
+ camera.deviceOrientation = .landscapeLeft
+ XCTAssertEqual(camera.computeUprightRotation(), 180)
+ }
+
+ /// Front, landscapeRight: mirrored from back → 0°.
+ func test_computeUprightRotation_frontCamera_landscapeRight_returns0() {
+ camera.isFrontFacingCamera = true
+ camera.deviceOrientation = .landscapeRight
+ XCTAssertEqual(camera.computeUprightRotation(), 0)
+ }
+
+ // MARK: - Fallback orientations → portrait default (90°)
+
+ func test_computeUprightRotation_backCamera_faceUp_returns90() {
+ camera.isFrontFacingCamera = false
+ camera.deviceOrientation = .faceUp
+ XCTAssertEqual(camera.computeUprightRotation(), 90,
+ ".faceUp must fall through to the portrait default of 90°")
+ }
+
+ func test_computeUprightRotation_backCamera_faceDown_returns90() {
+ camera.isFrontFacingCamera = false
+ camera.deviceOrientation = .faceDown
+ XCTAssertEqual(camera.computeUprightRotation(), 90,
+ ".faceDown must fall through to the portrait default of 90°")
+ }
+
+ func test_computeUprightRotation_backCamera_unknown_returns90() {
+ camera.isFrontFacingCamera = false
+ camera.deviceOrientation = .unknown
+ XCTAssertEqual(camera.computeUprightRotation(), 90,
+ ".unknown must fall through to the portrait default of 90°")
+ }
+
+ func test_computeUprightRotation_frontCamera_faceUp_returns90() {
+ camera.isFrontFacingCamera = true
+ camera.deviceOrientation = .faceUp
+ XCTAssertEqual(camera.computeUprightRotation(), 90)
+ }
+
+ func test_computeUprightRotation_frontCamera_faceDown_returns90() {
+ camera.isFrontFacingCamera = true
+ camera.deviceOrientation = .faceDown
+ XCTAssertEqual(camera.computeUprightRotation(), 90)
+ }
+
+ func test_computeUprightRotation_frontCamera_unknown_returns90() {
+ camera.isFrontFacingCamera = true
+ camera.deviceOrientation = .unknown
+ XCTAssertEqual(camera.computeUprightRotation(), 90)
+ }
+
+ // MARK: - Portrait orientations are the same for both camera types
+
+ /// Portrait cases do not involve the horizontal-mirror difference between
+ /// front and back sensors, so both cameras must return the same value.
+ func test_computeUprightRotation_portrait_sameForBothCameraTypes() {
+ camera.deviceOrientation = .portrait
+ camera.isFrontFacingCamera = false
+ let back = camera.computeUprightRotation()
+ camera.isFrontFacingCamera = true
+ let front = camera.computeUprightRotation()
+ XCTAssertEqual(back, front, "Both cameras must agree on portrait rotation")
+ }
+
+ func test_computeUprightRotation_portraitUpsideDown_sameForBothCameraTypes() {
+ camera.deviceOrientation = .portraitUpsideDown
+ camera.isFrontFacingCamera = false
+ let back = camera.computeUprightRotation()
+ camera.isFrontFacingCamera = true
+ let front = camera.computeUprightRotation()
+ XCTAssertEqual(back, front, "Both cameras must agree on portrait-upside-down rotation")
+ }
+
+ // MARK: - Landscape orientations differ between front and back cameras
+
+ func test_computeUprightRotation_landscapeLeft_frontAndBackDiffer() {
+ camera.deviceOrientation = .landscapeLeft
+ camera.isFrontFacingCamera = false
+ let back = camera.computeUprightRotation() // 0
+ camera.isFrontFacingCamera = true
+ let front = camera.computeUprightRotation() // 180
+ XCTAssertNotEqual(back, front,
+ "landscapeLeft rotation must differ between front and back cameras")
+ }
+
+ func test_computeUprightRotation_landscapeRight_frontAndBackDiffer() {
+ camera.deviceOrientation = .landscapeRight
+ camera.isFrontFacingCamera = false
+ let back = camera.computeUprightRotation() // 180
+ camera.isFrontFacingCamera = true
+ let front = camera.computeUprightRotation() // 0
+ XCTAssertNotEqual(back, front,
+ "landscapeRight rotation must differ between front and back cameras")
+ }
+
+ /// landscapeLeft back == landscapeRight front, and vice-versa,
+ /// confirming the front/back landscape values are exactly swapped.
+ func test_computeUprightRotation_landscapeValues_areExactlySwapped() {
+ camera.deviceOrientation = .landscapeLeft
+ camera.isFrontFacingCamera = false
+ let backLeft = camera.computeUprightRotation()
+
+ camera.deviceOrientation = .landscapeRight
+ camera.isFrontFacingCamera = true
+ let frontRight = camera.computeUprightRotation()
+
+ XCTAssertEqual(backLeft, frontRight,
+ "Back landscapeLeft and front landscapeRight must be equal (both 0°)")
+
+ camera.deviceOrientation = .landscapeRight
+ camera.isFrontFacingCamera = false
+ let backRight = camera.computeUprightRotation()
+
+ camera.deviceOrientation = .landscapeLeft
+ camera.isFrontFacingCamera = true
+ let frontLeft = camera.computeUprightRotation()
+
+ XCTAssertEqual(backRight, frontLeft,
+ "Back landscapeRight and front landscapeLeft must be equal (both 180°)")
+ }
+
+ // MARK: - Result is always a canonical rotation angle
+
+ /// For every orientation × camera-type combination the result must be
+ /// one of the four canonical values {0, 90, 180, 270}.
+ func test_computeUprightRotation_allCombinations_resultIsCanonical() {
+ let orientations: [UIDeviceOrientation] = [
+ .portrait, .portraitUpsideDown,
+ .landscapeLeft, .landscapeRight,
+ .faceUp, .faceDown, .unknown
+ ]
+ let validAngles: Set = [0, 90, 180, 270]
+
+ for isFront in [false, true] {
+ camera.isFrontFacingCamera = isFront
+ for orientation in orientations {
+ camera.deviceOrientation = orientation
+ let result = camera.computeUprightRotation()
+ XCTAssertTrue(validAngles.contains(result),
+ "Non-canonical rotation \(result)° for orientation \(orientation.rawValue), "
+ + "isFront=\(isFront)")
+ }
+ }
+ }
+
+ // MARK: - Idempotency and liveness
+
+ /// Calling the method twice with the same state must return the same value,
+ /// confirming that computeUprightRotation() is read-only and side-effect-free.
+ func test_computeUprightRotation_consecutiveCalls_returnSameValue() {
+ camera.isFrontFacingCamera = false
+ camera.deviceOrientation = .portrait
+ let first = camera.computeUprightRotation()
+ let second = camera.computeUprightRotation()
+ XCTAssertEqual(first, second,
+ "Consecutive calls with identical state must return the same value")
+ }
+
+ /// When deviceOrientation changes, the very next call must reflect the new value,
+ /// confirming the live orientation is read on every invocation.
+ func test_computeUprightRotation_updatesWhenOrientationChanges() {
+ camera.isFrontFacingCamera = false
+
+ camera.deviceOrientation = .portrait
+ let portrait = camera.computeUprightRotation() // 90
+
+ camera.deviceOrientation = .landscapeLeft
+ let landscape = camera.computeUprightRotation() // 0
+
+ XCTAssertNotEqual(portrait, landscape,
+ "Result must update immediately when deviceOrientation changes")
+ XCTAssertEqual(portrait, 90)
+ XCTAssertEqual(landscape, 0)
+ }
+
+ /// Swapping camera type must immediately change the result for landscape
+ /// orientations, confirming isFrontFacingCamera is read on every invocation.
+ func test_computeUprightRotation_updatesWhenCameraTypeChanges() {
+ camera.deviceOrientation = .landscapeLeft
+
+ camera.isFrontFacingCamera = false
+ let back = camera.computeUprightRotation() // 0
+
+ camera.isFrontFacingCamera = true
+ let front = camera.computeUprightRotation() // 180
+
+ XCTAssertNotEqual(back, front,
+ "Result must update immediately when isFrontFacingCamera changes")
+ }
+}
diff --git a/ios/test/unit/NativeCameraTests.swift b/ios/test/unit/NativeCameraTests.swift
index e88add6..5f28d41 100644
--- a/ios/test/unit/NativeCameraTests.swift
+++ b/ios/test/unit/NativeCameraTests.swift
@@ -228,13 +228,13 @@ final class NativeCameraMirrorTests: XCTestCase {
func test_mirror_neitherAxis_returnsOriginalData() {
let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true,
- horizontal: false, vertical: false)
+ horizontal: false, vertical: false)
XCTAssertEqual([UInt8](result.data), [UInt8](PixelBufferFixture.gray2x2))
}
func test_mirror_neitherAxis_preservesDimensions() {
let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true,
- horizontal: false, vertical: false)
+ horizontal: false, vertical: false)
XCTAssertEqual(result.w, 2)
XCTAssertEqual(result.h, 2)
}
@@ -245,22 +245,22 @@ final class NativeCameraMirrorTests: XCTestCase {
// [ 10 | 20 ] → [ 20 | 10 ]
// [ 30 | 40 ] → [ 40 | 30 ]
let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true,
- horizontal: true, vertical: false)
+ horizontal: true, vertical: false)
XCTAssertEqual([UInt8](result.data), [20, 10, 40, 30])
}
func test_mirror_gray_horizontal_preservesDimensions() {
let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true,
- horizontal: true, vertical: false)
+ horizontal: true, vertical: false)
XCTAssertEqual(result.w, 2)
XCTAssertEqual(result.h, 2)
}
func test_mirror_gray_horizontal_appliedTwice_recoversOriginal() {
let once = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true,
- horizontal: true, vertical: false)
+ horizontal: true, vertical: false)
let twice = camera.mirrorData(once.data, w: 2, h: 2, gray: true,
- horizontal: true, vertical: false)
+ horizontal: true, vertical: false)
XCTAssertEqual([UInt8](twice.data), [UInt8](PixelBufferFixture.gray2x2))
}
@@ -270,15 +270,15 @@ final class NativeCameraMirrorTests: XCTestCase {
// [ 10 | 20 ] → [ 30 | 40 ]
// [ 30 | 40 ] → [ 10 | 20 ]
let result = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true,
- horizontal: false, vertical: true)
+ horizontal: false, vertical: true)
XCTAssertEqual([UInt8](result.data), [30, 40, 10, 20])
}
func test_mirror_gray_vertical_appliedTwice_recoversOriginal() {
let once = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true,
- horizontal: false, vertical: true)
+ horizontal: false, vertical: true)
let twice = camera.mirrorData(once.data, w: 2, h: 2, gray: true,
- horizontal: false, vertical: true)
+ horizontal: false, vertical: true)
XCTAssertEqual([UInt8](twice.data), [UInt8](PixelBufferFixture.gray2x2))
}
@@ -287,7 +287,7 @@ final class NativeCameraMirrorTests: XCTestCase {
func test_mirror_gray_bothAxes_equals180Rotation() {
// Mirroring both H and V is equivalent to a 180° rotation.
let mirrored = camera.mirrorData(PixelBufferFixture.gray2x2, w: 2, h: 2, gray: true,
- horizontal: true, vertical: true)
+ horizontal: true, vertical: true)
let rotated = camera.rotateData(PixelBufferFixture.gray2x2, w: 2, h: 2, degrees: 180, gray: true)
XCTAssertEqual([UInt8](mirrored.data), [UInt8](rotated.data))
}
@@ -296,7 +296,7 @@ final class NativeCameraMirrorTests: XCTestCase {
func test_mirror_rgba_horizontal_preservesAlpha() {
let result = camera.mirrorData(PixelBufferFixture.rgba2x2, w: 2, h: 2, gray: false,
- horizontal: true, vertical: false)
+ horizontal: true, vertical: false)
let bytes = [UInt8](result.data)
for i in stride(from: 3, to: bytes.count, by: 4) {
XCTAssertEqual(bytes[i], 255, "Alpha corrupted at pixel \(i/4)")
@@ -523,6 +523,15 @@ final class NativeCameraLifecycleTests: XCTestCase {
}
}
+ func test_getCameras_eachEntry_hasSensorOrientationInValidRange() {
+ let cameras = NativeCamera().getCameras()
+ let valid = Set([0, 90, 180, 270])
+ for cam in cameras {
+ XCTAssertTrue(valid.contains(cam.sensorOrientation),
+ "sensorOrientation \(cam.sensorOrientation) is not a canonical angle (0/90/180/270)")
+ }
+ }
+
func test_onFrameAvailable_callbackIsOptional_atInit() {
let camera = NativeCamera()
XCTAssertNil(camera.onFrameAvailable)
@@ -533,298 +542,3 @@ final class NativeCameraLifecycleTests: XCTestCase {
XCTAssertNil(camera.onPermissionResult)
}
}
-
-// MARK: - NativeCamera Auto-Upright Tests
-
-/// Tests for `computeUprightRotation()` and the supporting state fields.
-///
-/// ## Strategy
-///
-/// `computeUprightRotation()` is `internal` and therefore accessible via
-/// `@testable import`. Its two inputs — `isFrontFacingCamera` and
-/// `deviceOrientation` — are `internal` (promoted from `private` specifically
-/// to enable this test class) so they can be injected directly. This lets
-/// every orientation × camera-type combination be exercised without requiring
-/// real hardware or a live capture session.
-///
-/// ## Rotation table under test
-///
-/// | `UIDeviceOrientation` | Back camera | Front camera |
-/// |-----------------------|-------------|--------------|
-/// | `.portrait` | 90° | 90° |
-/// | `.portraitUpsideDown` | 270° | 270° |
-/// | `.landscapeLeft` | 0° | 180° |
-/// | `.landscapeRight` | 180° | 0° |
-/// | `.faceUp` | 90° (fallback) | 90° (fallback) |
-/// | `.faceDown` | 90° (fallback) | 90° (fallback) |
-/// | `.unknown` | 90° (fallback) | 90° (fallback) |
-final class NativeCameraAutoUprightTests: XCTestCase {
-
- private var camera: NativeCamera!
-
- override func setUp() {
- super.setUp()
- camera = NativeCamera()
- }
-
- override func tearDown() {
- camera = nil
- super.tearDown()
- }
-
- // MARK: - State defaults
-
- func test_isFrontFacingCamera_defaultsFalse() {
- XCTAssertFalse(camera.isFrontFacingCamera,
- "isFrontFacingCamera must default to false (back camera assumption)")
- }
-
- func test_deviceOrientation_defaultsPortrait() {
- XCTAssertEqual(camera.deviceOrientation, .portrait,
- "deviceOrientation must default to .portrait so the first frame is always valid")
- }
-
- // MARK: - Back camera — all four primary orientations
-
- /// Back, portrait: sensor is landscape; device is upright → rotate 90° CW.
- func test_computeUprightRotation_backCamera_portrait_returns90() {
- camera.isFrontFacingCamera = false
- camera.deviceOrientation = .portrait
- XCTAssertEqual(camera.computeUprightRotation(), 90)
- }
-
- /// Back, portrait upside-down: sensor landscape, device inverted → rotate 270° CW.
- func test_computeUprightRotation_backCamera_portraitUpsideDown_returns270() {
- camera.isFrontFacingCamera = false
- camera.deviceOrientation = .portraitUpsideDown
- XCTAssertEqual(camera.computeUprightRotation(), 270)
- }
-
- /// Back, landscapeLeft (top points left, home button right):
- /// sensor already aligned with the display → 0° needed.
- func test_computeUprightRotation_backCamera_landscapeLeft_returns0() {
- camera.isFrontFacingCamera = false
- camera.deviceOrientation = .landscapeLeft
- XCTAssertEqual(camera.computeUprightRotation(), 0)
- }
-
- /// Back, landscapeRight (top points right, home button left):
- /// sensor inverted relative to the display → rotate 180°.
- func test_computeUprightRotation_backCamera_landscapeRight_returns180() {
- camera.isFrontFacingCamera = false
- camera.deviceOrientation = .landscapeRight
- XCTAssertEqual(camera.computeUprightRotation(), 180)
- }
-
- // MARK: - Front camera — all four primary orientations
-
- /// Front, portrait: front sensor is horizontally mirrored but still needs
- /// 90° CW to be upright — same as back camera for portrait.
- func test_computeUprightRotation_frontCamera_portrait_returns90() {
- camera.isFrontFacingCamera = true
- camera.deviceOrientation = .portrait
- XCTAssertEqual(camera.computeUprightRotation(), 90)
- }
-
- /// Front, portrait upside-down: 270° — same as back camera for this axis.
- func test_computeUprightRotation_frontCamera_portraitUpsideDown_returns270() {
- camera.isFrontFacingCamera = true
- camera.deviceOrientation = .portraitUpsideDown
- XCTAssertEqual(camera.computeUprightRotation(), 270)
- }
-
- /// Front, landscapeLeft: because the front sensor is horizontally mirrored,
- /// the landscape cases are swapped compared to the back camera → 180°.
- func test_computeUprightRotation_frontCamera_landscapeLeft_returns180() {
- camera.isFrontFacingCamera = true
- camera.deviceOrientation = .landscapeLeft
- XCTAssertEqual(camera.computeUprightRotation(), 180)
- }
-
- /// Front, landscapeRight: mirrored from back → 0°.
- func test_computeUprightRotation_frontCamera_landscapeRight_returns0() {
- camera.isFrontFacingCamera = true
- camera.deviceOrientation = .landscapeRight
- XCTAssertEqual(camera.computeUprightRotation(), 0)
- }
-
- // MARK: - Fallback orientations → portrait default (90°)
-
- func test_computeUprightRotation_backCamera_faceUp_returns90() {
- camera.isFrontFacingCamera = false
- camera.deviceOrientation = .faceUp
- XCTAssertEqual(camera.computeUprightRotation(), 90,
- ".faceUp must fall through to the portrait default of 90°")
- }
-
- func test_computeUprightRotation_backCamera_faceDown_returns90() {
- camera.isFrontFacingCamera = false
- camera.deviceOrientation = .faceDown
- XCTAssertEqual(camera.computeUprightRotation(), 90,
- ".faceDown must fall through to the portrait default of 90°")
- }
-
- func test_computeUprightRotation_backCamera_unknown_returns90() {
- camera.isFrontFacingCamera = false
- camera.deviceOrientation = .unknown
- XCTAssertEqual(camera.computeUprightRotation(), 90,
- ".unknown must fall through to the portrait default of 90°")
- }
-
- func test_computeUprightRotation_frontCamera_faceUp_returns90() {
- camera.isFrontFacingCamera = true
- camera.deviceOrientation = .faceUp
- XCTAssertEqual(camera.computeUprightRotation(), 90)
- }
-
- func test_computeUprightRotation_frontCamera_faceDown_returns90() {
- camera.isFrontFacingCamera = true
- camera.deviceOrientation = .faceDown
- XCTAssertEqual(camera.computeUprightRotation(), 90)
- }
-
- func test_computeUprightRotation_frontCamera_unknown_returns90() {
- camera.isFrontFacingCamera = true
- camera.deviceOrientation = .unknown
- XCTAssertEqual(camera.computeUprightRotation(), 90)
- }
-
- // MARK: - Portrait orientations are the same for both camera types
-
- /// Portrait cases do not involve the horizontal-mirror difference between
- /// front and back sensors, so both cameras must return the same value.
- func test_computeUprightRotation_portrait_sameForBothCameraTypes() {
- camera.deviceOrientation = .portrait
- camera.isFrontFacingCamera = false
- let back = camera.computeUprightRotation()
- camera.isFrontFacingCamera = true
- let front = camera.computeUprightRotation()
- XCTAssertEqual(back, front, "Both cameras must agree on portrait rotation")
- }
-
- func test_computeUprightRotation_portraitUpsideDown_sameForBothCameraTypes() {
- camera.deviceOrientation = .portraitUpsideDown
- camera.isFrontFacingCamera = false
- let back = camera.computeUprightRotation()
- camera.isFrontFacingCamera = true
- let front = camera.computeUprightRotation()
- XCTAssertEqual(back, front, "Both cameras must agree on portrait-upside-down rotation")
- }
-
- // MARK: - Landscape orientations differ between front and back cameras
-
- func test_computeUprightRotation_landscapeLeft_frontAndBackDiffer() {
- camera.deviceOrientation = .landscapeLeft
- camera.isFrontFacingCamera = false
- let back = camera.computeUprightRotation() // 0
- camera.isFrontFacingCamera = true
- let front = camera.computeUprightRotation() // 180
- XCTAssertNotEqual(back, front,
- "landscapeLeft rotation must differ between front and back cameras")
- }
-
- func test_computeUprightRotation_landscapeRight_frontAndBackDiffer() {
- camera.deviceOrientation = .landscapeRight
- camera.isFrontFacingCamera = false
- let back = camera.computeUprightRotation() // 180
- camera.isFrontFacingCamera = true
- let front = camera.computeUprightRotation() // 0
- XCTAssertNotEqual(back, front,
- "landscapeRight rotation must differ between front and back cameras")
- }
-
- /// landscapeLeft back == landscapeRight front, and vice-versa,
- /// confirming the front/back landscape values are exactly swapped.
- func test_computeUprightRotation_landscapeValues_areExactlySwapped() {
- camera.deviceOrientation = .landscapeLeft
- camera.isFrontFacingCamera = false
- let backLeft = camera.computeUprightRotation()
-
- camera.deviceOrientation = .landscapeRight
- camera.isFrontFacingCamera = true
- let frontRight = camera.computeUprightRotation()
-
- XCTAssertEqual(backLeft, frontRight,
- "Back landscapeLeft and front landscapeRight must be equal (both 0°)")
-
- camera.deviceOrientation = .landscapeRight
- camera.isFrontFacingCamera = false
- let backRight = camera.computeUprightRotation()
-
- camera.deviceOrientation = .landscapeLeft
- camera.isFrontFacingCamera = true
- let frontLeft = camera.computeUprightRotation()
-
- XCTAssertEqual(backRight, frontLeft,
- "Back landscapeRight and front landscapeLeft must be equal (both 180°)")
- }
-
- // MARK: - Result is always a canonical rotation angle
-
- /// For every orientation × camera-type combination the result must be
- /// one of the four canonical values {0, 90, 180, 270}.
- func test_computeUprightRotation_allCombinations_resultIsCanonical() {
- let orientations: [UIDeviceOrientation] = [
- .portrait, .portraitUpsideDown,
- .landscapeLeft, .landscapeRight,
- .faceUp, .faceDown, .unknown
- ]
- let validAngles: Set = [0, 90, 180, 270]
-
- for isFront in [false, true] {
- camera.isFrontFacingCamera = isFront
- for orientation in orientations {
- camera.deviceOrientation = orientation
- let result = camera.computeUprightRotation()
- XCTAssertTrue(validAngles.contains(result),
- "Non-canonical rotation \(result)° for orientation \(orientation.rawValue), "
- + "isFront=\(isFront)")
- }
- }
- }
-
- // MARK: - Idempotency and liveness
-
- /// Calling the method twice with the same state must return the same value,
- /// confirming that computeUprightRotation() is read-only and side-effect-free.
- func test_computeUprightRotation_consecutiveCalls_returnSameValue() {
- camera.isFrontFacingCamera = false
- camera.deviceOrientation = .portrait
- let first = camera.computeUprightRotation()
- let second = camera.computeUprightRotation()
- XCTAssertEqual(first, second,
- "Consecutive calls with identical state must return the same value")
- }
-
- /// When deviceOrientation changes, the very next call must reflect the new value,
- /// confirming the live orientation is read on every invocation.
- func test_computeUprightRotation_updatesWhenOrientationChanges() {
- camera.isFrontFacingCamera = false
-
- camera.deviceOrientation = .portrait
- let portrait = camera.computeUprightRotation() // 90
-
- camera.deviceOrientation = .landscapeLeft
- let landscape = camera.computeUprightRotation() // 0
-
- XCTAssertNotEqual(portrait, landscape,
- "Result must update immediately when deviceOrientation changes")
- XCTAssertEqual(portrait, 90)
- XCTAssertEqual(landscape, 0)
- }
-
- /// Swapping camera type must immediately change the result for landscape
- /// orientations, confirming isFrontFacingCamera is read on every invocation.
- func test_computeUprightRotation_updatesWhenCameraTypeChanges() {
- camera.deviceOrientation = .landscapeLeft
-
- camera.isFrontFacingCamera = false
- let back = camera.computeUprightRotation() // 0
-
- camera.isFrontFacingCamera = true
- let front = camera.computeUprightRotation() // 180
-
- XCTAssertNotEqual(back, front,
- "Result must update immediately when isFrontFacingCamera changes")
- }
-}
diff --git a/ios/test/unit/WrapperTests.mm b/ios/test/unit/WrapperTests.mm
index c85a20a..f15cf57 100644
--- a/ios/test/unit/WrapperTests.mm
+++ b/ios/test/unit/WrapperTests.mm
@@ -39,13 +39,17 @@
}
/// Build a minimal CameraInfo with one output size – does NOT need a real AVCaptureDevice
-/// because CameraInfo stores the device reference but the wrapper only reads `id` and `outputSizes`.
+/// because CameraInfo stores the device reference but the wrapper only reads `id`,
+/// `outputSizes`, and `sensorOrientation` for non-position fields.
/// We pass nil for device; accessing device.position will crash, so CameraInfoWrapper tests
/// using isFrontFacing must provide a real device or be skipped on Simulator.
-static CameraInfo *make_camera_info_without_device(NSString *cameraId, NSArray *sizes) {
+static CameraInfo *make_camera_info_without_device(NSString *cameraId,
+ NSArray *sizes,
+ int sensorOrientation) {
// Construct with nil device – only safe when the test doesn't call buildRawData
- // (which reads device.position). Use make_camera_info_with_back_position for full tests.
- return [[CameraInfo alloc] initWithId:cameraId device:nil outputSizes:sizes];
+ // (which reads device.position). Use a real device for full integration tests.
+ return [[CameraInfo alloc] initWithId:cameraId device:nil outputSizes:sizes
+ sensorOrientation:sensorOrientation];
}
// ---------------------------------------------------------------------------
@@ -224,15 +228,15 @@ @interface CameraInfoWrapperTests : XCTestCase
@implementation CameraInfoWrapperTests
-// MARK: Key presence (device = nil, is_front_facing derivation is skipped)
+// MARK: Key presence (requires a real AVCaptureDevice for device.position access)
- (void)test_buildRawData_containsCameraIdKey {
NSArray *sizes = @[make_frame_size(1280, 720)];
- // Use a real device from AVCaptureDevice when available; otherwise skip isFrontFacing sub-test.
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"test-id" device:dev outputSizes:sizes];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"test-id" device:dev
+ outputSizes:sizes sensorOrientation:90];
CameraInfoWrapper *w = [[CameraInfoWrapper alloc] initWithCameraInfo:info];
XCTAssertDictHasKey([w buildRawData], String("camera_id"));
}
@@ -241,7 +245,8 @@ - (void)test_buildRawData_containsIsFrontFacingKey {
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
NSArray *sizes = @[make_frame_size(640, 480)];
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:90];
XCTAssertDictHasKey([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData,
String("is_front_facing"));
}
@@ -250,29 +255,75 @@ - (void)test_buildRawData_containsOutputSizesKey {
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
NSArray *sizes = @[make_frame_size(640, 480)];
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:90];
XCTAssertDictHasKey([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData,
String("output_sizes"));
}
+- (void)test_buildRawData_containsSensorOrientationKey {
+ AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
+ if (!dev) { XCTSkip(@"No camera available on this host"); }
+ NSArray *sizes = @[make_frame_size(1280, 720)];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:90];
+ XCTAssertDictHasKey([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData,
+ String("sensor_orientation"));
+}
+
// MARK: camera_id value
- (void)test_buildRawData_cameraId_matchesInput {
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
NSArray *sizes = @[make_frame_size(1280, 720)];
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"my-unique-camera-id" device:dev outputSizes:sizes];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"my-unique-camera-id" device:dev
+ outputSizes:sizes sensorOrientation:90];
Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData;
XCTAssertGodotStringEqual(dict_get_string(d, String("camera_id")), String("my-unique-camera-id"));
}
+// MARK: sensor_orientation value
+
+- (void)test_buildRawData_sensorOrientation_90_isPreserved {
+ AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
+ if (!dev) { XCTSkip(@"No camera available on this host"); }
+ NSArray *sizes = @[make_frame_size(1280, 720)];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:90];
+ Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData;
+ XCTAssertEqual(dict_get_int(d, String("sensor_orientation")), 90);
+}
+
+- (void)test_buildRawData_sensorOrientation_0_isPreserved {
+ AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
+ if (!dev) { XCTSkip(@"No camera available on this host"); }
+ NSArray *sizes = @[make_frame_size(1280, 720)];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:0];
+ Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData;
+ XCTAssertEqual(dict_get_int(d, String("sensor_orientation")), 0);
+}
+
+- (void)test_buildRawData_sensorOrientation_isIntType {
+ AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
+ if (!dev) { XCTSkip(@"No camera available on this host"); }
+ NSArray *sizes = @[make_frame_size(1280, 720)];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:90];
+ Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData;
+ Variant v = d[String("sensor_orientation")];
+ XCTAssertEqual(v.get_type(), Variant::INT);
+}
+
// MARK: output_sizes array
- (void)test_buildRawData_outputSizes_isArray {
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
NSArray *sizes = @[make_frame_size(640, 480), make_frame_size(1280, 720)];
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:90];
Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData;
Variant sizesVar = d[String("output_sizes")];
XCTAssertEqual(sizesVar.get_type(), Variant::ARRAY);
@@ -282,7 +333,8 @@ - (void)test_buildRawData_outputSizes_countMatchesInput {
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
NSArray *sizes = @[make_frame_size(640, 480), make_frame_size(1280, 720), make_frame_size(1920, 1080)];
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:90];
Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData;
Array godotSizes = (Array)d[String("output_sizes")];
XCTAssertEqual((int)godotSizes.size(), 3);
@@ -291,7 +343,8 @@ - (void)test_buildRawData_outputSizes_countMatchesInput {
- (void)test_buildRawData_outputSizes_emptyArray_yieldsEmptyGodotArray {
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:@[]];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:@[] sensorOrientation:90];
Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData;
Array godotSizes = (Array)d[String("output_sizes")];
XCTAssertEqual((int)godotSizes.size(), 0);
@@ -301,7 +354,8 @@ - (void)test_buildRawData_outputSizes_firstEntry_hasWidthAndHeight {
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
NSArray *sizes = @[make_frame_size(320, 240)];
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:sizes];
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:sizes sensorOrientation:90];
Dictionary d = [[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData;
Array godotSizes = (Array)d[String("output_sizes")];
Dictionary first = (Dictionary)godotSizes[0];
@@ -311,11 +365,12 @@ - (void)test_buildRawData_outputSizes_firstEntry_hasWidthAndHeight {
XCTAssertEqual(dict_get_int(first, String("height")), 240);
}
-- (void)test_buildRawData_exactlyThreeKeys {
+- (void)test_buildRawData_exactlyFourKeys {
AVCaptureDevice *dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!dev) { XCTSkip(@"No camera available on this host"); }
- CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev outputSizes:@[]];
- XCTAssertEqual([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData.size(), 3);
+ CameraInfo *info = [[CameraInfo alloc] initWithId:@"id" device:dev
+ outputSizes:@[] sensorOrientation:90];
+ XCTAssertEqual([[CameraInfoWrapper alloc] initWithCameraInfo:info].buildRawData.size(), 4);
}
@end