diff --git a/addon/src/main/NativeCamera.gd b/addon/src/main/NativeCamera.gd
index 84bf42e..81447d0 100644
--- a/addon/src/main/NativeCamera.gd
+++ b/addon/src/main/NativeCamera.gd
@@ -34,6 +34,16 @@ const PLUGIN_SINGLETON_NAME: String = "@pluginName@"
## Whether the emitted frames should be flipped vertically (top-bottom mirror).
@export var mirror_vertical: bool = false
+## Target width (pixels) to scale the frame buffer to after rotation and mirroring.
+## Set to 0 (default) to disable scaling. Both scale_width and scale_height must be
+## non-zero for scaling to take effect.
+@export var scale_width: int = FeedRequest.DEFAULT_SCALE_WIDTH
+
+## Target height (pixels) to scale the frame buffer to after rotation and mirroring.
+## Set to 0 (default) to disable scaling. Both scale_width and scale_height must be
+## non-zero for scaling to take effect.
+@export var scale_height: int = FeedRequest.DEFAULT_SCALE_HEIGHT
+
var _plugin_singleton: Object
@@ -104,6 +114,8 @@ func create_feed_request() -> FeedRequest:
. set_grayscale(is_grayscale)
. set_mirror_horizontal(mirror_horizontal)
. set_mirror_vertical(mirror_vertical)
+ . set_scale_width(scale_width)
+ . set_scale_height(scale_height)
)
diff --git a/addon/src/main/model/FeedRequest.gd b/addon/src/main/model/FeedRequest.gd
index 9f1dd29..c96940a 100644
--- a/addon/src/main/model/FeedRequest.gd
+++ b/addon/src/main/model/FeedRequest.gd
@@ -12,11 +12,17 @@ const DATA_ROTATION_PROPERTY := &"rotation"
const DATA_IS_GRAYSCALE_PROPERTY := &"is_grayscale"
const DATA_MIRROR_HORIZONTAL_PROPERTY := &"mirror_horizontal"
const DATA_MIRROR_VERTICAL_PROPERTY := &"mirror_vertical"
+const DATA_SCALE_WIDTH_PROPERTY := &"scale_width"
+const DATA_SCALE_HEIGHT_PROPERTY := &"scale_height"
const DEFAULT_WIDTH: int = 1280
const DEFAULT_HEIGHT: int = 720
const DEFAULT_FRAMES_TO_SKIP: int = 40
const DEFAULT_ROTATION: int = 90
+## A value of 0 disables post-capture scaling on that axis.
+## Both scale_width and scale_height must be non-zero for scaling to take effect.
+const DEFAULT_SCALE_WIDTH: int = 0
+const DEFAULT_SCALE_HEIGHT: int = 0
const DEFAULT_DATA: Dictionary = {
DATA_WIDTH_PROPERTY: DEFAULT_WIDTH,
@@ -25,7 +31,9 @@ const DEFAULT_DATA: Dictionary = {
DATA_ROTATION_PROPERTY: DEFAULT_ROTATION,
DATA_IS_GRAYSCALE_PROPERTY: false,
DATA_MIRROR_HORIZONTAL_PROPERTY: false,
- DATA_MIRROR_VERTICAL_PROPERTY: false
+ DATA_MIRROR_VERTICAL_PROPERTY: false,
+ DATA_SCALE_WIDTH_PROPERTY: DEFAULT_SCALE_WIDTH,
+ DATA_SCALE_HEIGHT_PROPERTY: DEFAULT_SCALE_HEIGHT
}
var _data: Dictionary
@@ -75,5 +83,21 @@ func set_mirror_vertical(a_value: bool) -> FeedRequest:
return self
+## Sets the target width (in pixels) to which the frame buffer is scaled after
+## rotation and mirroring. Set to 0 (default) to disable scaling.
+## Both scale_width and scale_height must be non-zero for scaling to take effect.
+func set_scale_width(a_value: int) -> FeedRequest:
+ _data[DATA_SCALE_WIDTH_PROPERTY] = a_value
+ return self
+
+
+## Sets the target height (in pixels) to which the frame buffer is scaled after
+## rotation and mirroring. Set to 0 (default) to disable scaling.
+## Both scale_width and scale_height must be non-zero for scaling to take effect.
+func set_scale_height(a_value: int) -> FeedRequest:
+ _data[DATA_SCALE_HEIGHT_PROPERTY] = a_value
+ return self
+
+
func get_raw_data() -> Dictionary:
return _data
diff --git a/android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java b/android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java
index 8febb1e..29a5924 100644
--- a/android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java
+++ b/android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java
@@ -67,6 +67,10 @@ public class NativeCameraPlugin extends GodotPlugin {
private volatile boolean isGrayscale;
private volatile boolean mirrorHorizontal;
private volatile boolean mirrorVertical;
+ /** Target width for post-capture scaling; 0 means disabled. */
+ private volatile int scaleWidth;
+ /** Target height for post-capture scaling; 0 means disabled. */
+ private volatile int scaleHeight;
private int frameCounter = 0;
private volatile boolean running = false;
@@ -168,6 +172,8 @@ public void start(Dictionary requestDict) {
isGrayscale = feedRequest.isGrayscale();
mirrorHorizontal = feedRequest.isMirrorHorizontal();
mirrorVertical = feedRequest.isMirrorVertical();
+ scaleWidth = feedRequest.getScaleWidth();
+ scaleHeight = feedRequest.getScaleHeight();
openCamera(feedRequest);
}
@@ -425,6 +431,18 @@ private void onImageAvailable(ImageReader reader) {
}
}
+ // Scaling is applied last — after rotation and mirroring — so that
+ // scale_width and scale_height always describe the final emitted dimensions.
+ if (scaleWidth > 0 && scaleHeight > 0 && (scaleWidth != width || scaleHeight != height)) {
+ if (isGrayscale) {
+ output = scaleGray(output, width, height, scaleWidth, scaleHeight);
+ } else {
+ output = scaleRGBA(output, width, height, scaleWidth, scaleHeight);
+ }
+ width = scaleWidth;
+ height = scaleHeight;
+ }
+
if (running) {
emitFrame(output, width, height, rotation, isGrayscale);
}
@@ -611,4 +629,41 @@ private static byte[] mirrorGray(byte[] src, int width, int height,
}
return dst;
}
+
+ /**
+ * Scales an RGBA (4 bytes/pixel) frame buffer to {@code dstW × dstH} using
+ * nearest-neighbour interpolation. Applied after rotation and mirroring.
+ */
+ static byte[] scaleRGBA(byte[] src, int srcW, int srcH, int dstW, int dstH) {
+ byte[] dst = new byte[dstW * dstH * 4];
+ for (int dy = 0; dy < dstH; dy++) {
+ int sy = dy * srcH / dstH;
+ for (int dx = 0; dx < dstW; dx++) {
+ int sx = dx * srcW / dstW;
+ int srcIdx = (sy * srcW + sx) * 4;
+ int dstIdx = (dy * dstW + dx) * 4;
+ dst[dstIdx] = src[srcIdx];
+ dst[dstIdx + 1] = src[srcIdx + 1];
+ dst[dstIdx + 2] = src[srcIdx + 2];
+ dst[dstIdx + 3] = src[srcIdx + 3];
+ }
+ }
+ return dst;
+ }
+
+ /**
+ * Scales a grayscale (1 byte/pixel) frame buffer to {@code dstW × dstH} using
+ * nearest-neighbour interpolation. Applied after rotation and mirroring.
+ */
+ static byte[] scaleGray(byte[] src, int srcW, int srcH, int dstW, int dstH) {
+ byte[] dst = new byte[dstW * dstH];
+ for (int dy = 0; dy < dstH; dy++) {
+ int sy = dy * srcH / dstH;
+ for (int dx = 0; dx < dstW; dx++) {
+ int sx = dx * srcW / dstW;
+ dst[dy * dstW + dx] = src[sy * srcW + sx];
+ }
+ }
+ return dst;
+ }
}
diff --git a/android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java b/android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java
index 3257bf0..791bc76 100644
--- a/android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java
+++ b/android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java
@@ -19,6 +19,8 @@ public class FeedRequest {
private static final String DATA_IS_GRAYSCALE_PROPERTY = "is_grayscale";
private static final String DATA_MIRROR_HORIZONTAL_PROPERTY = "mirror_horizontal";
private static final String DATA_MIRROR_VERTICAL_PROPERTY = "mirror_vertical";
+ private static final String DATA_SCALE_WIDTH_PROPERTY = "scale_width";
+ private static final String DATA_SCALE_HEIGHT_PROPERTY = "scale_height";
private Dictionary data;
@@ -70,6 +72,26 @@ public boolean isMirrorVertical() {
}
+ /**
+ * Returns the target width (pixels) to scale the post-processed frame to.
+ * A value of 0 (the default) means no scaling on this axis.
+ * Scaling is only applied when both scale_width and scale_height are non-zero.
+ */
+ public int getScaleWidth() {
+ return data.containsKey(DATA_SCALE_WIDTH_PROPERTY) ? toInt(data.get(DATA_SCALE_WIDTH_PROPERTY)) : 0;
+ }
+
+
+ /**
+ * Returns the target height (pixels) to scale the post-processed frame to.
+ * A value of 0 (the default) means no scaling on this axis.
+ * Scaling is only applied when both scale_width and scale_height are non-zero.
+ */
+ public int getScaleHeight() {
+ return data.containsKey(DATA_SCALE_HEIGHT_PROPERTY) ? toInt(data.get(DATA_SCALE_HEIGHT_PROPERTY)) : 0;
+ }
+
+
public Dictionary getRawData() {
return data;
}
diff --git a/android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java b/android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java
index 4c5ff45..8d0c402 100644
--- a/android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java
+++ b/android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java
@@ -88,6 +88,7 @@ public void getFramesToSkip_missingKey_returnsDefaultOne() {
@Test
public void getFramesToSkip_minimalDict_returnsDefaultOne() {
+ // minimalDict has no frames_to_skip key → default 1
FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict());
assertEquals(1, req.getFramesToSkip());
}
@@ -222,6 +223,101 @@ public void mirrorVerticalDict_onlyVerticalTrue() {
assertTrue(req.isMirrorVertical());
}
+ // ── scaleWidth ────────────────────────────────────────────────────────
+
+ @Test
+ public void getScaleWidth_returnsValueFromDict() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.scaledDict());
+ assertEquals(640, req.getScaleWidth());
+ }
+
+ @Test
+ public void getScaleWidth_missingKey_returnsDefaultZero() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.emptyDict());
+ assertEquals(0, req.getScaleWidth());
+ }
+
+ @Test
+ public void getScaleWidth_zeroInFullDict_returnsZero() {
+ // fullDict() stores scale_width = 0 (disabled)
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.fullDict());
+ assertEquals(0, req.getScaleWidth());
+ }
+
+ @Test
+ public void getScaleWidth_minimalDict_returnsDefaultZero() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict());
+ assertEquals(0, req.getScaleWidth());
+ }
+
+ @Test
+ public void getScaleWidth_scaleWidthOnlyDict_returnsValue() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleWidthOnlyDict());
+ assertEquals(640, req.getScaleWidth());
+ }
+
+ // ── scaleHeight ───────────────────────────────────────────────────────
+
+ @Test
+ public void getScaleHeight_returnsValueFromDict() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.scaledDict());
+ assertEquals(360, req.getScaleHeight());
+ }
+
+ @Test
+ public void getScaleHeight_missingKey_returnsDefaultZero() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.emptyDict());
+ assertEquals(0, req.getScaleHeight());
+ }
+
+ @Test
+ public void getScaleHeight_zeroInFullDict_returnsZero() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.fullDict());
+ assertEquals(0, req.getScaleHeight());
+ }
+
+ @Test
+ public void getScaleHeight_minimalDict_returnsDefaultZero() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict());
+ assertEquals(0, req.getScaleHeight());
+ }
+
+ @Test
+ public void getScaleHeight_scaleHeightOnlyDict_returnsValue() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleHeightOnlyDict());
+ assertEquals(360, req.getScaleHeight());
+ }
+
+ // ── scale combined ────────────────────────────────────────────────────
+
+ @Test
+ public void scaledDict_bothDimensionsPopulated() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.scaledDict());
+ assertEquals(640, req.getScaleWidth());
+ assertEquals(360, req.getScaleHeight());
+ }
+
+ @Test
+ public void scaleIdentityDict_dimensionsEqualCaptureSize() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleIdentityDict());
+ assertEquals(1280, req.getScaleWidth());
+ assertEquals(720, req.getScaleHeight());
+ }
+
+ @Test
+ public void scaleWidthOnlyDict_heightRemainsZero() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleWidthOnlyDict());
+ assertEquals(640, req.getScaleWidth());
+ assertEquals(0, req.getScaleHeight());
+ }
+
+ @Test
+ public void scaleHeightOnlyDict_widthRemainsZero() {
+ FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleHeightOnlyDict());
+ assertEquals(0, req.getScaleWidth());
+ assertEquals(360, req.getScaleHeight());
+ }
+
// ── type coercion (Long -> int) ───────────────────────────────────────
@Test
@@ -232,11 +328,15 @@ public void intFields_neverThrowClassCastException_forLongValues() {
d.put("height", Long.MAX_VALUE);
d.put("frames_to_skip", Long.MAX_VALUE);
d.put("rotation", Long.MAX_VALUE);
+ d.put("scale_width", Long.MAX_VALUE);
+ d.put("scale_height", Long.MAX_VALUE);
FeedRequest req = new FeedRequest(d);
// Just calling the getters must not throw
req.getWidth();
req.getHeight();
req.getFramesToSkip();
req.getRotation();
+ req.getScaleWidth();
+ req.getScaleHeight();
}
}
diff --git a/android/src/test/java/org/godotengine/plugin/nativecamera/FrameProcessingLogicTest.java b/android/src/test/java/org/godotengine/plugin/nativecamera/FrameProcessingLogicTest.java
index 8dae0c1..d7d2da2 100644
--- a/android/src/test/java/org/godotengine/plugin/nativecamera/FrameProcessingLogicTest.java
+++ b/android/src/test/java/org/godotengine/plugin/nativecamera/FrameProcessingLogicTest.java
@@ -13,7 +13,7 @@
/**
- * Tests for the frame-skip and buffer-sizing logic embedded in
+ * Tests for the frame-skip, buffer-sizing, and scaling logic embedded in
* {@code NativeCameraPlugin.onImageAvailable()}.
*
*
These rules come directly from the plugin source:
@@ -22,6 +22,8 @@
*
A frame is processed when {@code ++frameCounter % divisor == 0}
* RGBA buffer size = {@code width * height * 4}
* Gray buffer size = {@code width * height}
+ * Scaling uses nearest-neighbour and is applied only when both
+ * {@code scaleWidth > 0} and {@code scaleHeight > 0}
*
*
* The logic is pure arithmetic so no Android environment is required.
@@ -174,6 +176,173 @@ public void bufferReuse_switchFromColorToGray_reallocates() {
assertEquals(4, result.length);
}
+ // ─────────────────────────────────────────────────────────────────────
+ // Scale guard: enabled only when both dimensions are > 0
+ // ─────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void scaleGuard_bothZero_scalingDisabled() {
+ // scaleWidth=0, scaleHeight=0 → no scaling
+ assertScalingEnabled(0, 0, 640, 480, false);
+ }
+
+ @Test
+ public void scaleGuard_widthZeroHeightNonZero_scalingDisabled() {
+ assertScalingEnabled(0, 360, 640, 480, false);
+ }
+
+ @Test
+ public void scaleGuard_widthNonZeroHeightZero_scalingDisabled() {
+ assertScalingEnabled(320, 0, 640, 480, false);
+ }
+
+ @Test
+ public void scaleGuard_bothNonZero_scalingEnabled() {
+ assertScalingEnabled(320, 240, 640, 480, true);
+ }
+
+ @Test
+ public void scaleGuard_dimensionsEqualSource_scalingStillApplied() {
+ // Even when target == source the guard passes; the caller decides whether to no-op.
+ assertScalingEnabled(640, 480, 640, 480, true);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // scaleRGBA – buffer length and pixel-position correctness
+ // ─────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void scaleRGBA_outputLengthIsCorrect() {
+ byte[] src = new byte[4 * 4 * 4]; // 4×4 RGBA
+ byte[] dst = NativeCameraPlugin.scaleRGBA(src, 4, 4, 2, 2);
+ assertEquals(2 * 2 * 4, dst.length);
+ }
+
+ @Test
+ public void scaleRGBA_upscale_outputLengthIsCorrect() {
+ byte[] src = new byte[2 * 2 * 4]; // 2×2 RGBA
+ byte[] dst = NativeCameraPlugin.scaleRGBA(src, 2, 2, 4, 4);
+ assertEquals(4 * 4 * 4, dst.length);
+ }
+
+ @Test
+ public void scaleRGBA_topLeftPixelIsPreserved() {
+ // Nearest-neighbour: dst(0,0) must map to src(0,0).
+ byte[] src = new byte[4 * 4 * 4];
+ src[0] = 10;
+ src[1] = 20;
+ src[2] = 30;
+ src[3] = (byte) 255; // TL pixel
+ byte[] dst = NativeCameraPlugin.scaleRGBA(src, 4, 4, 2, 2);
+ assertEquals(10, dst[0]);
+ assertEquals(20, dst[1]);
+ assertEquals(30, dst[2]);
+ assertEquals((byte) 255, dst[3]);
+ }
+
+ @Test
+ public void scaleRGBA_identityScale_preservesAllPixels() {
+ byte[] src = new byte[2 * 2 * 4];
+ for (int i = 0; i < src.length; i++) {
+ src[i] = (byte) (i % 256);
+ }
+ byte[] dst = NativeCameraPlugin.scaleRGBA(src, 2, 2, 2, 2);
+ for (int i = 0; i < src.length; i++) {
+ assertEquals(src[i], dst[i], "Mismatch at byte " + i);
+ }
+ }
+
+ @Test
+ public void scaleRGBA_halveResolution_bufferSizeIsQuarterOfSource() {
+ // 8×8 → 4×4: pixels = 1/4, bytes = 1/4.
+ byte[] src = new byte[8 * 8 * 4];
+ byte[] dst = NativeCameraPlugin.scaleRGBA(src, 8, 8, 4, 4);
+ assertEquals(src.length / 4, dst.length);
+ }
+
+ @Test
+ public void scaleRGBA_1x1Source_outputHasCorrectSize() {
+ byte[] src = new byte[]{10, 20, 30, (byte) 255};
+ byte[] dst = NativeCameraPlugin.scaleRGBA(src, 1, 1, 3, 3);
+ assertEquals(3 * 3 * 4, dst.length);
+ // Every pixel must be the single source pixel
+ for (int i = 0; i < dst.length; i += 4) {
+ assertEquals(10, dst[i], "R at pixel " + i / 4);
+ assertEquals(20, dst[i + 1], "G at pixel " + i / 4);
+ assertEquals(30, dst[i + 2], "B at pixel " + i / 4);
+ assertEquals((byte) 255, dst[i + 3], "A at pixel " + i / 4);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // scaleGray – buffer length and pixel-position correctness
+ // ─────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void scaleGray_outputLengthIsCorrect() {
+ byte[] src = new byte[4 * 4]; // 4×4 grayscale
+ byte[] dst = NativeCameraPlugin.scaleGray(src, 4, 4, 2, 2);
+ assertEquals(2 * 2, dst.length);
+ }
+
+ @Test
+ public void scaleGray_upscale_outputLengthIsCorrect() {
+ byte[] src = new byte[2 * 2];
+ byte[] dst = NativeCameraPlugin.scaleGray(src, 2, 2, 4, 4);
+ assertEquals(4 * 4, dst.length);
+ }
+
+ @Test
+ public void scaleGray_topLeftPixelIsPreserved() {
+ byte[] src = new byte[4 * 4];
+ src[0] = 42;
+ byte[] dst = NativeCameraPlugin.scaleGray(src, 4, 4, 2, 2);
+ assertEquals(42, dst[0]);
+ }
+
+ @Test
+ public void scaleGray_identityScale_preservesAllPixels() {
+ byte[] src = new byte[]{10, 20, 30, 40};
+ byte[] dst = NativeCameraPlugin.scaleGray(src, 2, 2, 2, 2);
+ for (int i = 0; i < src.length; i++) {
+ assertEquals(src[i], dst[i], "Mismatch at byte " + i);
+ }
+ }
+
+ @Test
+ public void scaleGray_halveResolution_bufferSizeIsQuarterOfSource() {
+ byte[] src = new byte[8 * 8];
+ byte[] dst = NativeCameraPlugin.scaleGray(src, 8, 8, 4, 4);
+ assertEquals(src.length / 4, dst.length);
+ }
+
+ @Test
+ public void scaleGray_1x1Source_allOutputPixelsMatchSource() {
+ byte[] src = new byte[]{(byte) 99};
+ byte[] dst = NativeCameraPlugin.scaleGray(src, 1, 1, 3, 3);
+ assertEquals(9, dst.length);
+ for (byte b : dst) {
+ assertEquals((byte) 99, b);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Scale + grayscale buffer size relationship
+ // ─────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void scaledGrayBuffer_isFourTimesSmaller_thanScaledRgbaBuffer() {
+ int w = 320;
+ int h = 240;
+ byte[] srcRgba = new byte[w * h * 4];
+ byte[] srcGray = new byte[w * h];
+
+ byte[] dstRgba = NativeCameraPlugin.scaleRGBA(srcRgba, w, h, 160, 120);
+ byte[] dstGray = NativeCameraPlugin.scaleGray(srcGray, w, h, 160, 120);
+
+ assertEquals(dstRgba.length, dstGray.length * 4);
+ }
+
// ─────────────────────────────────────────────────────────────────────
// Pure-Java helpers that mirror the plugin's inline formulas
// ─────────────────────────────────────────────────────────────────────
@@ -220,4 +389,16 @@ private static byte[] getOrAllocBuffer(byte[] existing, int width, int height, b
}
return existing;
}
+
+ /**
+ * Mirrors the scale-guard condition in {@code onImageAvailable}:
+ * scaling is enabled when both {@code scaleWidth} and {@code scaleHeight} are > 0.
+ */
+ private static void assertScalingEnabled(int scaleWidth, int scaleHeight,
+ int currentWidth, int currentHeight,
+ boolean expectEnabled) {
+ boolean enabled = scaleWidth > 0 && scaleHeight > 0;
+ assertEquals(expectEnabled, enabled,
+ String.format("scaleWidth=%d scaleHeight=%d", scaleWidth, scaleHeight));
+ }
}
diff --git a/android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java b/android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java
index 38c7fc1..fc59406 100644
--- a/android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java
+++ b/android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java
@@ -16,7 +16,7 @@ public final class FeedRequestFixtures {
private FeedRequestFixtures() {
}
- /** All fields populated with non-default values. */
+ /** All fields populated with non-default values. scale_width and scale_height are 0 (disabled). */
public static Dictionary fullDict() {
Dictionary d = new Dictionary();
d.put("camera_id", "0");
@@ -27,6 +27,8 @@ public static Dictionary fullDict() {
d.put("is_grayscale", false);
d.put("mirror_horizontal", false);
d.put("mirror_vertical", false);
+ d.put("scale_width", 0L);
+ d.put("scale_height", 0L);
return d;
}
@@ -96,4 +98,50 @@ public static Dictionary mirrorBothDict() {
d.put("mirror_vertical", true);
return d;
}
+
+ // ── Scale variants ─────────────────────────────────────────────────────
+
+ /**
+ * Both scale dimensions set to 640×360 — half the default 1280×720 capture
+ * size. Scaling should be applied because both values are non-zero.
+ */
+ public static Dictionary scaledDict() {
+ Dictionary d = fullDict();
+ d.put("scale_width", 640L);
+ d.put("scale_height", 360L);
+ return d;
+ }
+
+ /**
+ * scale_width populated, scale_height remains 0 (from fullDict).
+ * Scaling must NOT be applied when either dimension is zero.
+ */
+ public static Dictionary scaleWidthOnlyDict() {
+ Dictionary d = fullDict();
+ d.put("scale_width", 640L);
+ // scale_height stays 0L from fullDict
+ return d;
+ }
+
+ /**
+ * scale_height populated, scale_width remains 0 (from fullDict).
+ * Scaling must NOT be applied when either dimension is zero.
+ */
+ public static Dictionary scaleHeightOnlyDict() {
+ Dictionary d = fullDict();
+ d.put("scale_height", 360L);
+ // scale_width stays 0L from fullDict
+ return d;
+ }
+
+ /**
+ * Scale dimensions that equal the capture resolution (1280×720).
+ * The guard passes; the plugin skips the copy as src == dst size.
+ */
+ public static Dictionary scaleIdentityDict() {
+ Dictionary d = fullDict();
+ d.put("scale_width", 1280L);
+ d.put("scale_height", 720L);
+ return d;
+ }
}
diff --git a/common/config/godot.properties b/common/config/godot.properties
index 5e9f223..344713d 100644
--- a/common/config/godot.properties
+++ b/common/config/godot.properties
@@ -3,4 +3,4 @@
#
godotVersion=4.7
-godotReleaseType=dev3
+godotReleaseType=dev5
diff --git a/docs/README.md b/docs/README.md
index 20da959..72bcd74 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -23,7 +23,7 @@ A Godot plugin that provides a **unified camera capture interface** for **Androi
* Enumerate available cameras and their supported output sizes
* Start and stop native camera frame streaming
* Receive raw frame buffers or ready‑to‑use `Image` objects
-* Configurable resolution, rotation, frame skipping, horizontal/vertical mirroring, and grayscale capture
+* Configurable resolution, rotation, frame skipping, horizontal/vertical mirroring, grayscale capture, and post-capture scaling
* Designed for real‑time use cases (CV, AR preprocessing, custom rendering)
##
Table of Contents
@@ -117,6 +117,8 @@ func _on_camera_permission_granted() -> void:
.set_grayscale(false)
.set_mirror_horizontal(true) # flip left-right (e.g. selfie camera preview)
.set_mirror_vertical(false)
+ .set_scale_width(640) # downscale to 640×360 before emitting
+ .set_scale_height(360)
camera.start(request)
@@ -164,7 +166,7 @@ Register listeners on the `NativeCamera` node:
* `create_feed_request() -> FeedRequest`
- * Creates a `FeedRequest` pre-populated with the node's exported property values (`frame_width`, `frame_height`, `frames_to_skip`, `frame_rotation`, `is_grayscale`, `mirror_horizontal`, `mirror_vertical`)
+ * Creates a `FeedRequest` pre-populated with the node's exported property values (`frame_width`, `frame_height`, `frames_to_skip`, `frame_rotation`, `is_grayscale`, `mirror_horizontal`, `mirror_vertical`, `scale_width`, `scale_height`)
* `start(request: FeedRequest)`
@@ -203,9 +205,12 @@ Defines configuration parameters for starting a camera feed.
* Grayscale capture
* Horizontal mirror (`mirror_horizontal`) — flips the frame left-to-right after rotation
* Vertical mirror (`mirror_vertical`) — flips the frame top-to-bottom after rotation
+* Scale width and height (`scale_width`, `scale_height`) — resizes the pixel buffer to the given dimensions as the final post-processing step (after rotation and mirroring); both must be non-zero for scaling to take effect; defaults to 0 (disabled)
Both mirror flags default to `false` and can be combined independently with any rotation value. Mirroring is applied as a post-processing step after rotation on both Android and iOS, so the axis labels always refer to the final upright image.
+Scaling is applied after mirroring and uses nearest-neighbour interpolation, making it suitable for real-time use cases such as downscaling before CV inference. Setting either `scale_width` or `scale_height` to 0 disables scaling entirely.
+
Supports fluent chaining via setter methods.
**Setter methods:**
@@ -218,6 +223,8 @@ Supports fluent chaining via setter methods.
* `set_grayscale(value: bool) -> FeedRequest`
* `set_mirror_horizontal(value: bool) -> FeedRequest`
* `set_mirror_vertical(value: bool) -> FeedRequest`
+* `set_scale_width(value: int) -> FeedRequest`
+* `set_scale_height(value: int) -> FeedRequest`
###
FrameInfo
diff --git a/ios/src/NativeCamera.swift b/ios/src/NativeCamera.swift
index 75e39da..a979f22 100644
--- a/ios/src/NativeCamera.swift
+++ b/ios/src/NativeCamera.swift
@@ -25,14 +25,8 @@ import UIKit
private var videoOutput: AVCaptureVideoDataOutput?
private let sessionQueue = DispatchQueue(label: "camera_session_queue")
- private var framesToSkip: Int = 0
+ private var frameRequest: FrameRequest?
private var frameCounter: Int = 0
- private var rotation: Int = 0
- private var isGrayscale: Bool = false
- private var mirrorHorizontal: Bool = false
- private var mirrorVertical: Bool = false
- private var targetWidth: Int = 0
- private var targetHeight: Int = 0
@objc public static func hasPermission() -> Bool {
return AVCaptureDevice.authorizationStatus(for: .video) == .authorized
@@ -46,7 +40,7 @@ import UIKit
}
}
- @objc public func getCameras() -> [CameraInfo] { // Changed return type
+ @objc public func getCameras() -> [CameraInfo] {
let discoverySession = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInWideAngleCamera],
mediaType: .video,
@@ -62,8 +56,7 @@ import UIKit
}
}
- @objc public func start(cameraId: String, width: Int, height: Int, skip: Int, rot: Int, gray: Bool,
- mirrorHorizontal: Bool, mirrorVertical: Bool) {
+ @objc public func start(request: FrameRequest) {
// Dispatch everything onto sessionQueue so stop() fully completes
// before we reconfigure, AND so variable writes are on the same
// queue that captureOutput reads them from — no data race.
@@ -72,21 +65,14 @@ import UIKit
self.captureSession = nil
self.videoOutput = nil
- // Set instance vars inside sessionQueue, so captureOutput
- // (also on sessionQueue) always sees a consistent, written value.
- self.targetWidth = width
- self.targetHeight = height
- self.framesToSkip = skip
- self.rotation = rot
- self.isGrayscale = gray
- self.mirrorHorizontal = mirrorHorizontal
- self.mirrorVertical = mirrorVertical
+ // Store the request and reset counter
+ self.frameRequest = request
self.frameCounter = 0
let session = AVCaptureSession()
session.beginConfiguration()
- guard let device = AVCaptureDevice(uniqueID: cameraId),
+ guard let device = AVCaptureDevice(uniqueID: request.cameraId()),
let input = try? AVCaptureDeviceInput(device: device) else { return }
if session.canAddInput(input) { session.addInput(input) }
@@ -118,8 +104,10 @@ import UIKit
public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
+ guard let req = frameRequest else { return }
+
frameCounter += 1
- if frameCounter % (framesToSkip + 1) != 0 { return }
+ if frameCounter % (req.framesToSkip() + 1) != 0 { return }
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
@@ -133,7 +121,7 @@ import UIKit
let totalPixels = width * height
var outputData: Data
- if isGrayscale {
+ if req.isGrayscale() {
// Extract Luminance from BGRA (Approximation: Blue channel or average)
// For true YUV-Y extraction, we'd change videoSettings to 420v
var grayBytes = [UInt8](repeating: 0, count: totalPixels)
@@ -157,19 +145,28 @@ import UIKit
}
// Handle Rotation
- let rotated = rotateData(outputData, w: width, h: height, degrees: rotation, gray: isGrayscale)
+ let rotated = rotateData(outputData, w: width, h: height, degrees: req.rotation(), gray: req.isGrayscale())
// Handle Mirror (applied after rotation, same as Android)
- let final = mirrorData(rotated.data, w: rotated.w, h: rotated.h,
- gray: isGrayscale, horizontal: mirrorHorizontal, vertical: mirrorVertical)
+ let mirrored = mirrorData(rotated.data, w: rotated.w, h: rotated.h,
+ gray: req.isGrayscale(), horizontal: req.isMirrorHorizontal(), vertical: req.isMirrorVertical())
+
+ // Handle Scaling (applied last — after rotation and mirroring)
+ let final: (data: Data, w: Int, h: Int)
+ if req.scaleWidth() > 0 && req.scaleHeight() > 0 {
+ final = scaleData(mirrored.data, srcW: mirrored.w, srcH: mirrored.h,
+ dstW: req.scaleWidth(), dstH: req.scaleHeight(), gray: req.isGrayscale())
+ } else {
+ final = mirrored
+ }
DispatchQueue.main.async {
let info = FrameInfo(
buffer: final.data,
width: final.w,
height: final.h,
- rotation: self.rotation,
- isGrayscale: self.isGrayscale
+ rotation: req.rotation(),
+ isGrayscale: req.isGrayscale()
)
self.onFrameAvailable?(info)
}
@@ -235,4 +232,27 @@ import UIKit
}
return (Data(dst), w, h)
}
+
+ /// Scales a pixel buffer to `dstW × dstH` using nearest-neighbour interpolation.
+ /// Applied after rotation and mirroring — the last post-processing step before emit.
+ /// Supports both RGBA (4 bytes/pixel) and grayscale (1 byte/pixel) buffers.
+ internal func scaleData(_ src: Data, srcW: Int, srcH: Int,
+ dstW: Int, dstH: Int, gray: Bool) -> (data: Data, w: Int, h: Int) {
+ let bytesPerPixel = gray ? 1 : 4
+ var dst = [UInt8](repeating: 0, count: dstW * dstH * bytesPerPixel)
+ let srcArray = [UInt8](src)
+
+ for dy in 0..
-#include "core/object/class_db.h"
-
@interface FrameRequest : NSObject
-@property(nonatomic, assign) Dictionary rawData;
-
-- (instancetype)initWithDictionary:(Dictionary)data;
+- (instancetype)initWithRawData:(void *)data;
- (NSString *)cameraId;
@@ -31,6 +27,20 @@
- (BOOL)isMirrorVertical;
+/**
+ * Target width (pixels) to scale the post-processed frame to.
+ * Returns 0 when the key is absent, meaning scaling is disabled on this axis.
+ * Scaling is only performed when both scaleWidth and scaleHeight are non-zero.
+ */
+- (NSInteger)scaleWidth;
+
+/**
+ * Target height (pixels) to scale the post-processed frame to.
+ * Returns 0 when the key is absent, meaning scaling is disabled on this axis.
+ * Scaling is only performed when both scaleWidth and scaleHeight are non-zero.
+ */
+- (NSInteger)scaleHeight;
+
@end
#endif /* frame_request_h */
diff --git a/ios/src/model/frame_request.mm b/ios/src/model/frame_request.mm
index 5d773c7..b2a2cf4 100644
--- a/ios/src/model/frame_request.mm
+++ b/ios/src/model/frame_request.mm
@@ -3,6 +3,11 @@
//
#import "frame_request.h"
+#include "core/object/class_db.h"
+
+@interface FrameRequest ()
+@property(nonatomic, assign) Dictionary rawData;
+@end
@implementation FrameRequest
@@ -14,10 +19,13 @@ @implementation FrameRequest
static String const kIsGrayscaleProperty = "is_grayscale";
static String const kMirrorHorizontalProperty = "mirror_horizontal";
static String const kMirrorVerticalProperty = "mirror_vertical";
+static String const kScaleWidthProperty = "scale_width";
+static String const kScaleHeightProperty = "scale_height";
-- (instancetype)initWithDictionary:(Dictionary)data {
- if ((self = [super init])) {
- self.rawData = data;
+- (instancetype)initWithRawData:(void *)data {
+ self = [super init];
+ if (self) {
+ _rawData = *(Dictionary *)data;
}
return self;
}
@@ -57,4 +65,13 @@ - (BOOL)isMirrorVertical {
return self.rawData.has(kMirrorVerticalProperty) ? (BOOL)self.rawData[kMirrorVerticalProperty] : NO;
}
+- (NSInteger)scaleWidth {
+ return self.rawData.has(kScaleWidthProperty) ? (NSInteger)self.rawData[kScaleWidthProperty].operator int64_t() : 0;
+}
+
+- (NSInteger)scaleHeight {
+ return self.rawData.has(kScaleHeightProperty) ? (NSInteger)self.rawData[kScaleHeightProperty].operator int64_t()
+ : 0;
+}
+
@end
diff --git a/ios/src/native_camera_plugin-Bridging-Header.h b/ios/src/native_camera_plugin-Bridging-Header.h
index 1cadbec..954def5 100644
--- a/ios/src/native_camera_plugin-Bridging-Header.h
+++ b/ios/src/native_camera_plugin-Bridging-Header.h
@@ -2,5 +2,4 @@
// © 2026-present https://github.com/cengiz-pz
//
-// TODO
-// #import "shared_stuff.h"
+#import "frame_request.h"
diff --git a/ios/src/native_camera_plugin.mm b/ios/src/native_camera_plugin.mm
index 0524a6d..af7b60d 100644
--- a/ios/src/native_camera_plugin.mm
+++ b/ios/src/native_camera_plugin.mm
@@ -49,15 +49,11 @@
}
void NativeCameraPlugin::start(Dictionary requestDict) {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:requestDict];
- [swiftCamera startWithCameraId:req.cameraId
- width:req.width
- height:req.height
- skip:req.framesToSkip
- rot:req.rotation
- gray:req.isGrayscale
- mirrorHorizontal:req.isMirrorHorizontal
- mirrorVertical:req.isMirrorVertical];
+ // Pass the memory address of the C++ Dictionary to the wrapper
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&requestDict];
+
+ // Start the Swift camera by passing the FrameRequest object
+ [swiftCamera startWithRequest:req];
}
void NativeCameraPlugin::stop() {
diff --git a/ios/test/fixtures/TestFixtures.swift b/ios/test/fixtures/TestFixtures.swift
index 50efeb4..3925081 100644
--- a/ios/test/fixtures/TestFixtures.swift
+++ b/ios/test/fixtures/TestFixtures.swift
@@ -137,6 +137,23 @@ enum PixelBufferFixture {
]
return Data(bytes)
}()
+
+ // MARK: 4×4 source for downscale / upscale tests (nearest-neighbour)
+
+ /// 4×4 grayscale ramp: pixel(x,y) = y*4 + x (values 0…15)
+ static let gray4x4: Data = Data((0..<16).map { UInt8($0) })
+
+ /// 4×4 RGBA: R channel = pixel index (0…15), G=B=0, A=255
+ static let rgba4x4: Data = {
+ var bytes = [UInt8](repeating: 0, count: 16 * 4)
+ for i in 0..<16 {
+ bytes[i * 4] = UInt8(i) // R
+ bytes[i * 4 + 1] = 0 // G
+ bytes[i * 4 + 2] = 0 // B
+ bytes[i * 4 + 3] = 255 // A
+ }
+ return Data(bytes)
+ }()
}
// MARK: - FrameRequest Dictionary Fixtures (mirrors ObjC frame_request keys)
@@ -148,17 +165,21 @@ enum FrameRequestFixture {
static let framesToSkipKey = "frames_to_skip"
static let rotationKey = "rotation"
static let isGrayscaleKey = "is_grayscale"
+ static let scaleWidthKey = "scale_width"
+ static let scaleHeightKey = "scale_height"
static let sampleCameraId = "com.apple.avfoundation.avcapturedevice.built-in_video:0"
- /// Fully-populated request parameters
+ /// Fully-populated request parameters (scaling disabled)
static let fullParams: [String: Any] = [
cameraIdKey: sampleCameraId,
widthKey: 1280,
heightKey: 720,
framesToSkipKey: 2,
rotationKey: 90,
- isGrayscaleKey: false
+ isGrayscaleKey: false,
+ scaleWidthKey: 0,
+ scaleHeightKey: 0
]
/// Minimal request – only camera_id; all other fields should fall back to defaults
@@ -166,6 +187,30 @@ enum FrameRequestFixture {
cameraIdKey: sampleCameraId
]
+ /// Request with scaling to half the capture resolution (640×360 from 1280×720)
+ static let scaledParams: [String: Any] = [
+ cameraIdKey: sampleCameraId,
+ widthKey: 1280,
+ heightKey: 720,
+ framesToSkipKey: 0,
+ rotationKey: 0,
+ isGrayscaleKey: false,
+ scaleWidthKey: 640,
+ scaleHeightKey: 360
+ ]
+
+ /// scale_width set, scale_height absent — scaling must not be applied.
+ static let scaleWidthOnlyParams: [String: Any] = [
+ cameraIdKey: sampleCameraId,
+ scaleWidthKey: 640
+ ]
+
+ /// scale_height set, scale_width absent — scaling must not be applied.
+ static let scaleHeightOnlyParams: [String: Any] = [
+ cameraIdKey: sampleCameraId,
+ scaleHeightKey: 360
+ ]
+
/// Default values expected when keys are absent
enum Defaults {
static let width = 640
@@ -173,5 +218,7 @@ enum FrameRequestFixture {
static let framesToSkip = 0
static let rotation = 0
static let isGrayscale = false
+ static let scaleWidth = 0
+ static let scaleHeight = 0
}
}
diff --git a/ios/test/unit/FrameRequestTests.mm b/ios/test/unit/FrameRequestTests.mm
index 838cafd..c45d875 100644
--- a/ios/test/unit/FrameRequestTests.mm
+++ b/ios/test/unit/FrameRequestTests.mm
@@ -22,31 +22,40 @@
// ---------------------------------------------------------------------------
static Dictionary make_full_request_dict() {
- Dictionary d;
- d[String("camera_id")] = String("test-camera-id-42");
- d[String("width")] = (int64_t)1280;
- d[String("height")] = (int64_t)720;
- d[String("frames_to_skip")] = (int64_t)3;
- d[String("rotation")] = (int64_t)270;
- d[String("is_grayscale")] = true;
- return d;
+ Dictionary d;
+ d[String("camera_id")] = String("test-camera-id-42");
+ d[String("width")] = (int64_t)1280;
+ d[String("height")] = (int64_t)720;
+ d[String("frames_to_skip")] = (int64_t)3;
+ d[String("rotation")] = (int64_t)270;
+ d[String("is_grayscale")] = true;
+ d[String("scale_width")] = (int64_t)0;
+ d[String("scale_height")] = (int64_t)0;
+ return d;
}
static Dictionary make_empty_dict() {
- return Dictionary();
+ return Dictionary();
}
static Dictionary make_partial_dict_camera_only() {
- Dictionary d;
- d[String("camera_id")] = String("only-camera");
- return d;
+ Dictionary d;
+ d[String("camera_id")] = String("only-camera");
+ return d;
}
static Dictionary make_mirror_dict(bool mirrorH, bool mirrorV) {
- Dictionary d = make_full_request_dict();
- d[String("mirror_horizontal")] = mirrorH;
- d[String("mirror_vertical")] = mirrorV;
- return d;
+ Dictionary d = make_full_request_dict();
+ d[String("mirror_horizontal")] = mirrorH;
+ d[String("mirror_vertical")] = mirrorV;
+ return d;
+}
+
+static Dictionary make_scaled_dict(int64_t sw, int64_t sh) {
+ Dictionary d = make_full_request_dict();
+ d[String("scale_width")] = sw;
+ d[String("scale_height")] = sh;
+ return d;
}
// ---------------------------------------------------------------------------
@@ -61,199 +70,326 @@ @implementation FrameRequestTests
// MARK: - Full dictionary
- (void)test_cameraId_fullDict_returnsCorrectString {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()];
- XCTAssertEqualObjects(req.cameraId, @"test-camera-id-42");
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqualObjects(req.cameraId, @"test-camera-id-42");
}
- (void)test_width_fullDict_returnsCorrectValue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()];
- XCTAssertEqual(req.width, 1280);
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.width, 1280);
}
- (void)test_height_fullDict_returnsCorrectValue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()];
- XCTAssertEqual(req.height, 720);
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.height, 720);
}
- (void)test_framesToSkip_fullDict_returnsCorrectValue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()];
- XCTAssertEqual(req.framesToSkip, 3);
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.framesToSkip, 3);
}
- (void)test_rotation_fullDict_returnsCorrectValue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()];
- XCTAssertEqual(req.rotation, 270);
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.rotation, 270);
}
- (void)test_isGrayscale_fullDict_returnsTrue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()];
- XCTAssertTrue(req.isGrayscale);
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertTrue(req.isGrayscale);
}
// MARK: - Empty dictionary → defaults
- (void)test_cameraId_emptyDict_returnsEmptyString {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()];
- XCTAssertEqualObjects(req.cameraId, @"");
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqualObjects(req.cameraId, @"");
}
- (void)test_width_emptyDict_returns640 {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()];
- XCTAssertEqual(req.width, 640);
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.width, 640);
}
- (void)test_height_emptyDict_returns480 {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()];
- XCTAssertEqual(req.height, 480);
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.height, 480);
}
- (void)test_framesToSkip_emptyDict_returnsZero {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()];
- XCTAssertEqual(req.framesToSkip, 0);
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.framesToSkip, 0);
}
- (void)test_rotation_emptyDict_returnsZero {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()];
- XCTAssertEqual(req.rotation, 0);
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.rotation, 0);
}
- (void)test_isGrayscale_emptyDict_returnsFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()];
- XCTAssertFalse(req.isGrayscale);
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isGrayscale);
}
// MARK: - Partial dictionary
- (void)test_cameraId_partialDict_returnsValue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_partial_dict_camera_only()];
- XCTAssertEqualObjects(req.cameraId, @"only-camera");
+ Dictionary d = make_partial_dict_camera_only();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqualObjects(req.cameraId, @"only-camera");
}
- (void)test_missingNumericKeys_fallBackToDefaults {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_partial_dict_camera_only()];
- XCTAssertEqual(req.width, 640);
- XCTAssertEqual(req.height, 480);
- XCTAssertEqual(req.framesToSkip, 0);
- XCTAssertEqual(req.rotation, 0);
- XCTAssertFalse(req.isGrayscale);
+ Dictionary d = make_partial_dict_camera_only();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.width, 640);
+ XCTAssertEqual(req.height, 480);
+ XCTAssertEqual(req.framesToSkip, 0);
+ XCTAssertEqual(req.rotation, 0);
+ XCTAssertFalse(req.isGrayscale);
}
// MARK: - Boolean variants
- (void)test_isGrayscale_falseExplicit_returnsFalse {
- Dictionary d;
- d[String("is_grayscale")] = false;
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d];
- XCTAssertFalse(req.isGrayscale);
+ Dictionary d;
+ d[String("is_grayscale")] = false;
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isGrayscale);
}
- (void)test_isGrayscale_trueExplicit_returnsTrue {
- Dictionary d;
- d[String("is_grayscale")] = true;
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d];
- XCTAssertTrue(req.isGrayscale);
+ Dictionary d;
+ d[String("is_grayscale")] = true;
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertTrue(req.isGrayscale);
}
// MARK: - Boundary / edge values
- (void)test_width_zeroValue_isPreserved {
- Dictionary d;
- d[String("width")] = (int64_t)0;
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d];
- XCTAssertEqual(req.width, 0);
+ Dictionary d;
+ d[String("width")] = (int64_t)0;
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.width, 0);
}
- (void)test_framesToSkip_largeValue_isPreserved {
- Dictionary d;
- d[String("frames_to_skip")] = (int64_t)120;
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d];
- XCTAssertEqual(req.framesToSkip, 120);
+ Dictionary d;
+ d[String("frames_to_skip")] = (int64_t)120;
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.framesToSkip, 120);
}
- (void)test_rotation_allCardinalAngles_arePreserved {
- for (int angle : {0, 90, 180, 270}) {
- Dictionary d;
- d[String("rotation")] = (int64_t)angle;
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d];
- XCTAssertEqual(req.rotation, angle,
- @"Rotation angle %d not preserved", angle);
- }
+ for (int angle : {0, 90, 180, 270}) {
+ Dictionary d;
+ d[String("rotation")] = (int64_t)angle;
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.rotation, angle,
+ @"Rotation angle %d not preserved", angle);
+ }
}
// MARK: - Unicode camera ID
- (void)test_cameraId_unicodeCharacters_roundTrip {
- Dictionary d;
- // Use String::utf8() to explicitly parse the C-string literal as UTF-8
- d[String("camera_id")] = String::utf8("カメラ:0");
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d];
- XCTAssertEqualObjects(req.cameraId, @"カメラ:0");
+ Dictionary d;
+ // Use String::utf8() to explicitly parse the C-string literal as UTF-8
+ d[String("camera_id")] = String::utf8("カメラ:0");
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqualObjects(req.cameraId, @"カメラ:0");
}
// MARK: - Mirror flags
- (void)test_isMirrorHorizontal_fullDict_returnsFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()];
- XCTAssertFalse(req.isMirrorHorizontal);
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isMirrorHorizontal);
}
- (void)test_isMirrorHorizontal_trueExplicit_returnsTrue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(true, false)];
- XCTAssertTrue(req.isMirrorHorizontal);
+ Dictionary d = make_mirror_dict(true, false);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertTrue(req.isMirrorHorizontal);
}
- (void)test_isMirrorHorizontal_emptyDict_returnsFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()];
- XCTAssertFalse(req.isMirrorHorizontal);
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isMirrorHorizontal);
}
- (void)test_isMirrorHorizontal_falseExplicit_returnsFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(false, false)];
- XCTAssertFalse(req.isMirrorHorizontal);
+ Dictionary d = make_mirror_dict(false, false);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isMirrorHorizontal);
}
- (void)test_isMirrorVertical_fullDict_returnsFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()];
- XCTAssertFalse(req.isMirrorVertical);
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isMirrorVertical);
}
- (void)test_isMirrorVertical_trueExplicit_returnsTrue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(false, true)];
- XCTAssertTrue(req.isMirrorVertical);
+ Dictionary d = make_mirror_dict(false, true);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertTrue(req.isMirrorVertical);
}
- (void)test_isMirrorVertical_emptyDict_returnsFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()];
- XCTAssertFalse(req.isMirrorVertical);
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isMirrorVertical);
}
- (void)test_isMirrorVertical_falseExplicit_returnsFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(false, false)];
- XCTAssertFalse(req.isMirrorVertical);
+ Dictionary d = make_mirror_dict(false, false);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isMirrorVertical);
}
- (void)test_mirrorBoth_bothTrue {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(true, true)];
- XCTAssertTrue(req.isMirrorHorizontal);
- XCTAssertTrue(req.isMirrorVertical);
+ Dictionary d = make_mirror_dict(true, true);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertTrue(req.isMirrorHorizontal);
+ XCTAssertTrue(req.isMirrorVertical);
}
- (void)test_mirrorHorizontalOnly_verticalFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(true, false)];
- XCTAssertTrue(req.isMirrorHorizontal);
- XCTAssertFalse(req.isMirrorVertical);
+ Dictionary d = make_mirror_dict(true, false);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertTrue(req.isMirrorHorizontal);
+ XCTAssertFalse(req.isMirrorVertical);
}
- (void)test_mirrorVerticalOnly_horizontalFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(false, true)];
- XCTAssertFalse(req.isMirrorHorizontal);
- XCTAssertTrue(req.isMirrorVertical);
+ Dictionary d = make_mirror_dict(false, true);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isMirrorHorizontal);
+ XCTAssertTrue(req.isMirrorVertical);
}
- (void)test_missingMirrorKeys_partialDict_defaultToFalse {
- FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_partial_dict_camera_only()];
- XCTAssertFalse(req.isMirrorHorizontal);
- XCTAssertFalse(req.isMirrorVertical);
+ Dictionary d = make_partial_dict_camera_only();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertFalse(req.isMirrorHorizontal);
+ XCTAssertFalse(req.isMirrorVertical);
+}
+
+// MARK: - scaleWidth
+
+- (void)test_scaleWidth_fullDict_returnsZero {
+ // fullDict stores scale_width = 0 (disabled)
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 0);
+}
+
+- (void)test_scaleWidth_emptyDict_returnsZero {
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 0);
+}
+
+- (void)test_scaleWidth_partialDict_returnsZero {
+ Dictionary d = make_partial_dict_camera_only();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 0);
+}
+
+- (void)test_scaleWidth_explicitValue_returnsValue {
+ Dictionary d = make_scaled_dict(640, 360);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 640);
+}
+
+- (void)test_scaleWidth_largeValue_isPreserved {
+ Dictionary d;
+ d[String("scale_width")] = (int64_t)3840;
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 3840);
+}
+
+// MARK: - scaleHeight
+
+- (void)test_scaleHeight_fullDict_returnsZero {
+ Dictionary d = make_full_request_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleHeight, 0);
+}
+
+- (void)test_scaleHeight_emptyDict_returnsZero {
+ Dictionary d = make_empty_dict();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleHeight, 0);
+}
+
+- (void)test_scaleHeight_partialDict_returnsZero {
+ Dictionary d = make_partial_dict_camera_only();
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleHeight, 0);
+}
+
+- (void)test_scaleHeight_explicitValue_returnsValue {
+ Dictionary d = make_scaled_dict(640, 360);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleHeight, 360);
+}
+
+- (void)test_scaleHeight_largeValue_isPreserved {
+ Dictionary d;
+ d[String("scale_height")] = (int64_t)2160;
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleHeight, 2160);
+}
+
+// MARK: - Scale combined
+
+- (void)test_scaleBoth_bothDimensionsPopulated {
+ Dictionary d = make_scaled_dict(640, 360);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 640);
+ XCTAssertEqual(req.scaleHeight, 360);
+}
+
+- (void)test_scaleIdentity_dimensionsEqualCaptureSize {
+ Dictionary d = make_scaled_dict(1280, 720);
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 1280);
+ XCTAssertEqual(req.scaleHeight, 720);
+}
+
+- (void)test_scaleWidthOnly_heightRemainsZero {
+ Dictionary d = make_full_request_dict();
+ d[String("scale_width")] = (int64_t)640;
+ // scale_height is 0 from fullDict
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 640);
+ XCTAssertEqual(req.scaleHeight, 0);
+}
+
+- (void)test_scaleHeightOnly_widthRemainsZero {
+ Dictionary d = make_full_request_dict();
+ d[String("scale_height")] = (int64_t)360;
+ // scale_width is 0 from fullDict
+ FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d];
+ XCTAssertEqual(req.scaleWidth, 0);
+ XCTAssertEqual(req.scaleHeight, 360);
}
@end
diff --git a/ios/test/unit/NativeCameraTests.swift b/ios/test/unit/NativeCameraTests.swift
index 8f3a96b..74ec3fd 100644
--- a/ios/test/unit/NativeCameraTests.swift
+++ b/ios/test/unit/NativeCameraTests.swift
@@ -366,6 +366,162 @@ final class NativeCameraMirrorTests: XCTestCase {
}
}
+// MARK: - NativeCamera Scale Tests
+
+/// Tests NativeCamera.scaleData(_:srcW:srcH:dstW:dstH:gray:) in isolation.
+///
+/// The method is `internal`, accessible via `@testable import NativeCameraPlugin`.
+final class NativeCameraScaleTests: XCTestCase {
+
+ private let camera = NativeCamera()
+
+ // MARK: Guard – disabled when either dimension is zero
+
+ func test_scale_guard_bothZero_noScaling() {
+ // guard is checked by the caller; here we just verify the helper is callable
+ // with equal src/dst (identity) when both are non-zero.
+ let src = PixelBufferFixture.gray2x2
+ let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 2, dstH: 2, gray: true)
+ XCTAssertEqual([UInt8](result.data), [UInt8](src))
+ }
+
+ // MARK: Buffer length
+
+ func test_scale_gray_downscale_outputLengthCorrect() {
+ let src = PixelBufferFixture.gray(width: 4, height: 4)
+ let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: true)
+ XCTAssertEqual(result.data.count, 2 * 2)
+ }
+
+ func test_scale_gray_upscale_outputLengthCorrect() {
+ let src = PixelBufferFixture.gray2x2
+ let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 4, dstH: 4, gray: true)
+ XCTAssertEqual(result.data.count, 4 * 4)
+ }
+
+ func test_scale_rgba_downscale_outputLengthCorrect() {
+ let src = PixelBufferFixture.rgba(width: 4, height: 4)
+ let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: false)
+ XCTAssertEqual(result.data.count, 2 * 2 * 4)
+ }
+
+ func test_scale_rgba_upscale_outputLengthCorrect() {
+ let src = PixelBufferFixture.rgba2x2
+ let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 4, dstH: 4, gray: false)
+ XCTAssertEqual(result.data.count, 4 * 4 * 4)
+ }
+
+ // MARK: Reported dimensions
+
+ func test_scale_gray_reportedDimensions_matchDst() {
+ let src = PixelBufferFixture.gray(width: 4, height: 4)
+ let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 2, dstH: 3, gray: true)
+ XCTAssertEqual(result.w, 2)
+ XCTAssertEqual(result.h, 3)
+ }
+
+ func test_scale_rgba_reportedDimensions_matchDst() {
+ let src = PixelBufferFixture.rgba(width: 4, height: 4)
+ let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 3, dstH: 2, gray: false)
+ XCTAssertEqual(result.w, 3)
+ XCTAssertEqual(result.h, 2)
+ }
+
+ // MARK: Identity scale
+
+ func test_scale_gray_identityDimensions_preservesPixels() {
+ let src = PixelBufferFixture.gray2x2
+ let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 2, dstH: 2, gray: true)
+ XCTAssertEqual([UInt8](result.data), [UInt8](src))
+ }
+
+ func test_scale_rgba_identityDimensions_preservesPixels() {
+ let src = PixelBufferFixture.rgba2x2
+ let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 2, dstH: 2, gray: false)
+ XCTAssertEqual([UInt8](result.data), [UInt8](src))
+ }
+
+ // MARK: Top-left pixel preserved (nearest-neighbour property)
+
+ func test_scale_gray_topLeftPixelPreserved_onDownscale() {
+ let src = PixelBufferFixture.gray(width: 4, height: 4)
+ // Manually set TL pixel to a distinct value
+ var bytes = [UInt8](src)
+ bytes[0] = 42
+ let result = camera.scaleData(Data(bytes), srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: true)
+ XCTAssertEqual([UInt8](result.data)[0], 42, "Top-left pixel not preserved after downscale")
+ }
+
+ func test_scale_rgba_topLeftPixelPreserved_onDownscale() {
+ var bytes = [UInt8](PixelBufferFixture.rgba(width: 4, height: 4))
+ bytes[0] = 10; bytes[1] = 20; bytes[2] = 30; bytes[3] = 255
+ let result = camera.scaleData(Data(bytes), srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: false)
+ let out = [UInt8](result.data)
+ XCTAssertEqual(out[0], 10, "R of TL pixel not preserved")
+ XCTAssertEqual(out[1], 20, "G of TL pixel not preserved")
+ XCTAssertEqual(out[2], 30, "B of TL pixel not preserved")
+ XCTAssertEqual(out[3], 255, "A of TL pixel not preserved")
+ }
+
+ // MARK: 1×1 source → all destination pixels equal source pixel
+
+ func test_scale_gray_1x1Source_allOutputPixelsMatchSource() {
+ let src = Data([UInt8(77)])
+ let result = camera.scaleData(src, srcW: 1, srcH: 1, dstW: 3, dstH: 3, gray: true)
+ XCTAssertEqual(result.data.count, 9)
+ for byte in result.data { XCTAssertEqual(byte, 77) }
+ }
+
+ func test_scale_rgba_1x1Source_allOutputPixelsMatchSource() {
+ let src = Data([UInt8(11), UInt8(22), UInt8(33), UInt8(255)])
+ let result = camera.scaleData(src, srcW: 1, srcH: 1, dstW: 2, dstH: 2, gray: false)
+ XCTAssertEqual(result.data.count, 2 * 2 * 4)
+ let out = [UInt8](result.data)
+ for i in stride(from: 0, to: out.count, by: 4) {
+ XCTAssertEqual(out[i], 11, "R mismatch at pixel \(i/4)")
+ XCTAssertEqual(out[i + 1], 22, "G mismatch at pixel \(i/4)")
+ XCTAssertEqual(out[i + 2], 33, "B mismatch at pixel \(i/4)")
+ XCTAssertEqual(out[i + 3], 255, "A mismatch at pixel \(i/4)")
+ }
+ }
+
+ // MARK: Alpha channel preserved through scaling
+
+ func test_scale_rgba_alphaPreserved_afterDownscale() {
+ let src = PixelBufferFixture.rgba(width: 4, height: 4) // alpha = 255 for all
+ let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: false)
+ let out = [UInt8](result.data)
+ for i in stride(from: 3, to: out.count, by: 4) {
+ XCTAssertEqual(out[i], 255, "Alpha corrupted at pixel \(i/4)")
+ }
+ }
+
+ // MARK: Half-size buffer relationship (gray vs RGBA)
+
+ func test_scale_scaledGrayBuffer_isFourTimesSmaller_thanScaledRgba() {
+ let grayResult = camera.scaleData(
+ PixelBufferFixture.gray(width: 8, height: 8),
+ srcW: 8, srcH: 8, dstW: 4, dstH: 4, gray: true
+ )
+ let rgbaResult = camera.scaleData(
+ PixelBufferFixture.rgba(width: 8, height: 8),
+ srcW: 8, srcH: 8, dstW: 4, dstH: 4, gray: false
+ )
+ XCTAssertEqual(rgbaResult.data.count, grayResult.data.count * 4)
+ }
+
+ // MARK: Rectangular (non-square) scale
+
+ func test_scale_gray_rectangularDownscale_bufferLengthCorrect() {
+ // 6×4 → 3×2: 6 pixels
+ let src = PixelBufferFixture.gray(width: 6, height: 4)
+ let result = camera.scaleData(src, srcW: 6, srcH: 4, dstW: 3, dstH: 2, gray: true)
+ XCTAssertEqual(result.data.count, 3 * 2)
+ XCTAssertEqual(result.w, 3)
+ XCTAssertEqual(result.h, 2)
+ }
+}
+
final class NativeCameraPermissionTests: XCTestCase {
/// Verifies `hasPermission()` returns a Bool without crashing.