From edede7dfb22cfa46aca3b172246be4849ead1934 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 20 Oct 2026 09:19:37 +0000 Subject: [PATCH 1/2] fix: the snapshot keeps the single view's aspect ratio in its cell (#248) A turned (portrait) picture was copied straight into the 16:9 cell and stretched; it is now fitted, centred, with black around it, as the screen shows it. --- .../java/com/openipc/decoder/Decoder.java | 4 ++ .../java/com/openipc/decoder/Snapshots.java | 33 ++++++++++++++++- .../com/openipc/decoder/SnapshotsTest.java | 37 +++++++++++++++++++ docs/debug.md | 2 +- 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 android/app/src/test/java/com/openipc/decoder/SnapshotsTest.java diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 07a1a91d..521cdf34 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1498,6 +1498,10 @@ private List snapshotCells(int[] side) { ? statusText.getText().toString() : c.live ? "Playing" : "No new frames"; // Copied with the grid cells, off the UI thread (#76) if (c.live) c.view = mSurface; + // As on the screen: a turned picture is not stretched to the cell (#248) + if (mVideoWidth > 0 && mVideoHeight > 0) { + c.aspect = (float) mVideoWidth / mVideoHeight; + } } c.onScreen = onScreen(i); cells.add(c); diff --git a/android/app/src/main/java/com/openipc/decoder/Snapshots.java b/android/app/src/main/java/com/openipc/decoder/Snapshots.java index e26068a1..1b89ef04 100644 --- a/android/app/src/main/java/com/openipc/decoder/Snapshots.java +++ b/android/app/src/main/java/com/openipc/decoder/Snapshots.java @@ -60,6 +60,27 @@ static final class Cell { boolean ok; /** What the TV shows, in a grid drawn for the single view (#100): framed. */ boolean onScreen; + /** + * Width / height of the picture as the screen shows it, fitted into the cell with + * black around it (the single view, e.g. a turned slot, #248); 0 fills the cell. + */ + float aspect; + } + + /** + * The largest rectangle of the given aspect ratio (width / height) centred in the cell, + * as {left, top, right, bottom}; the whole cell for 0 or a ratio within 1 % of its own. + */ + static int[] fit(int left, int top, int right, int bottom, float aspect) { + int w = right - left, h = bottom - top; + if (aspect <= 0 || w <= 0 || h <= 0 || Math.abs(aspect * h / w - 1f) < 0.01f) { + return new int[] {left, top, right, bottom}; + } + int fw = w, fh = h; + if (aspect * h > w) fh = Math.max(1, Math.round(w / aspect)); + else fw = Math.max(1, Math.round(h * aspect)); + int l = left + (w - fw) / 2, t = top + (h - fh) / 2; + return new int[] {l, t, l + fw, t + fh}; } private static HandlerThread copyThread; @@ -100,12 +121,20 @@ static byte[] render(List cells, int side, int width, boolean labels) { rects[i] = new Rect(col * width / side, row * height / side, (col + 1) * width / side, (row + 1) * height / side); } + // Where each frame goes: its own aspect ratio inside the cell (#248) + Rect[] dst = new Rect[n]; + for (int i = 0; i < n; i++) { + Cell c = cells.get(i); + Rect r = rects[i]; + int[] f = fit(r.left, r.top, r.right, r.bottom, c != null ? c.aspect : 0); + dst[i] = new Rect(f[0], f[1], f[2], f[3]); + } // All copies are requested at once and run side by side, then awaited together Copy[] copies = new Copy[n]; for (int i = 0; i < n; i++) { Cell c = cells.get(i); if (c != null && c.live && c.view != null) { - copies[i] = Copy.start(c.view, rects[i].width(), rects[i].height()); + copies[i] = Copy.start(c.view, dst[i].width(), dst[i].height()); } } long deadline = SystemClock.elapsedRealtime() + COPY_TIMEOUT_MS; @@ -137,7 +166,7 @@ static byte[] render(List cells, int side, int width, boolean labels) { if (copies[i] != null && frame != null) copied++; boolean shown = c.live && frame != null; if (shown) { - canvas.drawBitmap(frame, null, r, frames); + canvas.drawBitmap(frame, null, dst[i], frames); } if (frame != null) frame.recycle(); if (labels) { diff --git a/android/app/src/test/java/com/openipc/decoder/SnapshotsTest.java b/android/app/src/test/java/com/openipc/decoder/SnapshotsTest.java new file mode 100644 index 00000000..196f6806 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/SnapshotsTest.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * SnapshotsTest.java — where a frame goes in its snapshot cell (#248) + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertArrayEquals; + +import org.junit.Test; + +public class SnapshotsTest { + @Test + public void fitKeepsTheCellForNoOrTheSameRatio() { + assertArrayEquals(new int[] {0, 0, 640, 360}, Snapshots.fit(0, 0, 640, 360, 0f)); + assertArrayEquals(new int[] {0, 0, 640, 360}, Snapshots.fit(0, 0, 640, 360, 16f / 9)); + // 1280×720 into a cell of 213×120: within 1 %, no one-pixel bars + assertArrayEquals(new int[] {213, 120, 426, 240}, Snapshots.fit(213, 120, 426, 240, 16f / 9)); + } + + @Test + public void fitCentresATurnedPictureWithBarsAtTheSides() { + // 1920×2560 (2560×1920 turned 90°) in a 16:9 cell: as tall as the cell, centred + assertArrayEquals(new int[] {185, 0, 455, 360}, Snapshots.fit(0, 0, 640, 360, 1920f / 2560)); + // The same in the bottom right cell of a 3×3 grid of 1280×720 + assertArrayEquals(new int[] {976, 480, 1156, 720}, + Snapshots.fit(853, 480, 1280, 720, 1920f / 2560)); + } + + @Test + public void fitPutsBarsAboveAndBelowAWiderPicture() { + // 32:9 in a 16:9 cell + assertArrayEquals(new int[] {0, 90, 640, 270}, Snapshots.fit(0, 0, 640, 360, 32f / 9)); + } +} diff --git a/docs/debug.md b/docs/debug.md index 0a6df3ef..58de7449 100644 --- a/docs/debug.md +++ b/docs/debug.md @@ -63,7 +63,7 @@ A JPEG laid out like the screen, at the screen's resolution (at most 1920 wide, ## Stream checks in the single view -In the single view (and the U view) the page and the snapshot still show the whole grid ([#100](https://github.com/OpenIPC/decoder/issues/100)): the camera on screen is framed in blue, and every other configured slot gets a short stream check while the page is open. Its cell says what was found, for example `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`, or the error. +In the single view (and the U view) the page and the snapshot still show the whole grid ([#100](https://github.com/OpenIPC/decoder/issues/100)): the camera on screen is framed in blue, its picture fitted into the cell with its own aspect ratio, as on the screen (a turned slot is not stretched, [#248](https://github.com/OpenIPC/decoder/issues/248)), and every other configured slot gets a short stream check while the page is open. Its cell says what was found, for example `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`, or the error. - A check connects, receives packets for about 3 s and does not decode. - Each slot is checked again about every 45 s, at most two checks run at a time, one new check starts every 2 s. From c17ebb86f169214119dc7cf8d95b4638132c8e81 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 20 Oct 2026 09:19:37 +0000 Subject: [PATCH 2/2] chore: version 2.18.1 --- CHANGELOG.md | 8 +++++++- android/app/build.gradle | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ecd0cdc..dce8d1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,13 @@ Versions after 2.17 are `MAJOR.MINOR.PATCH` (2.17 is followed by 2.17.1 or 2.18. `docs/testing.md`, "Versions and releases"); the headings below keep the numbers as they were released. -## 2.18.0 — 2026-10-19 +## 2.18.1 — 2026-10-20 + +- Snapshot (`/snapshot.jpg`, the debug page): the single view's picture keeps its aspect ratio + in its cell, as on the screen, so a turned (portrait) slot is no longer stretched to 16:9 + (#248). + +## 2.18.0 — 2026-10-20 - Mirror in the camera settings, next to Turn: flips the picture left to right in the single view and the grid, for H.264, H.265 and MJPEG; a mirrored H.264/H.265 slot is drawn with diff --git a/android/app/build.gradle b/android/app/build.gradle index ab89f643..caca9eb8 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.openipc.decoder" minSdk 21 targetSdk 36 - versionCode 43 - versionName "2.18.0" + versionCode 44 + versionName "2.18.1" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" }