diff --git a/README.md b/README.md
index 2a87e26a..65774e22 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,7 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern
| **Q** | Turn multi-view (Quad) on or off |
| **1**–**8** | Switch to a camera slot. The active slot's number shows the connection quality: green under 100 ms of jitter, yellow 100–300 ms, red above 300 ms. A grey number is an empty slot. |
| **U** | Show the reserved UDP cell of the 3×3 grid full-screen |
+| **D** | Debug text in every multi-view cell: resolution, codec, decoded/received fps. Off: only cells that need attention show text |
| **Settings** | Edit the URL and the transport of the active slot (single-camera mode only) |
| **WebUI** | Open the camera's web interface; credentials come from the RTSP URL (single-camera mode only) |
| **Exit** | Close the app. The small text next to it is the app version and build hash. |
@@ -88,7 +89,7 @@ adb shell am start -a android.intent.action.SEND -t text/plain \
### Multi-view (Quad)
-Shows the configured slots in a 2×2 grid, or in a 3×3 grid once any of slots 5–8 is used. The ninth cell of the 3×3 grid is reserved for a camera that pushes RTP over UDP; for now it only shows the address and port to send to, and **U** in the menu opens it full-screen. To watch such a camera today, put a [`udp://` URL](#udp-slots-majestic-outgoing) into any slot. Each cell has its own connection and decoder, so the device's video decoder limits how many high-resolution streams play smoothly: with five or more cameras, their substreams are a good choice. Some cameras accept only one RTSP client at a time. Long-press a cell to play its audio (long-press it again to mute); the selected cell shows an AUDIO badge and is remembered. Tap any cell to open the menu.
+Shows the configured slots in a 2×2 grid, or in a 3×3 grid once any of slots 5–8 is used. The ninth cell of the 3×3 grid is reserved for a camera that pushes RTP over UDP; for now it only shows the address and port to send to, and **U** in the menu opens it full-screen. To watch such a camera today, put a [`udp://` URL](#udp-slots-majestic-outgoing) into any slot. Each cell has its own connection and decoder, and the device's video decoder has a fixed budget: the AG-216 test box, for example, decodes about two 1080p30 streams at full rate. So the grid adapts: cells start on key frames only, then decode every frame in priority order (the cell whose sound plays, then slot order) while the decoder keeps up. Cells beyond that stay on key frames, with the picture updated once per key frame interval and `key frames` in the corner. The limit is learnt on each device by measurement. Substreams of the cameras, where available, let more cells play at full rate. A cell over the decoder's instance limit shows `Decoder limit: N × H.265`. Some cameras accept only one RTSP client at a time. Long-press a cell to play its audio (long-press it again to mute); the selected cell shows an AUDIO badge and is remembered. Tap any cell to open the menu.
## Transports
@@ -239,7 +240,7 @@ Most devices below were reported with the legacy 1.0x version (`basic` branch);
| Tablet | Samsung Tab A7 (SM-T505) | 12 | 4.19.157-perf- | 1.0x | |
| Set-top box | A95X F3 AIR | 9 (SlimBOXtv AOSP 9.14, no GAPPS) | 4.9.113 | 1.0x | |
| Set-top box | IE X3 Air | 9 (SlimBOXtv AOSP 9.14, no GAPPS) | 4.9.113 | 1.0x | |
-| Set-top box | AG-216 | 10 | 4.9.170 | v2 1.28 | v2 test device: 8-camera multi-view, H.264/H.265, Opus/AAC/L16 |
+| Set-top box | AG-216 | 10 | 4.9.170 | v2 1.28 | v2 test device; decoder budget measured at ≈ 1080p60 in total (two 1080p30 at full rate) |
| Set-top box | SberBox SBDV-00001 | custom | 4.9.228 | 1.0x | |
| Set-top box | X96Q (Allwinner H313) | 10 | 4.9.170 | 1.0x | |
| TV | Haier Candy Android TV 2K | 11 | 4.9.243+ | 1.0x | No icon in the device's app list |
diff --git a/README.ru.md b/README.ru.md
index cf794070..00b2b20f 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -62,6 +62,7 @@
| **Q** | Включить или выключить мультивид (Quad) |
| **1**–**8** | Переключиться на слот камеры. Цвет номера активного слота показывает качество связи: зелёный — джиттер меньше 100 мс, жёлтый — 100–300 мс, красный — больше 300 мс. Серый номер — пустой слот. |
| **U** | Показать на весь экран зарезервированную ячейку UDP сетки 3×3 |
+| **D** | Отладочный текст в каждой ячейке мультивида: разрешение, кодек, декодировано/получено кадр/с. Выключено — текст только у ячеек, требующих внимания |
| **Settings** | Изменить адрес и транспорт активного слота (только в режиме одной камеры) |
| **WebUI** | Открыть веб-интерфейс камеры; логин и пароль берутся из RTSP-адреса (только в режиме одной камеры) |
| **Exit** | Закрыть приложение. Мелкий текст рядом — версия приложения и хеш сборки. |
@@ -88,7 +89,7 @@ adb shell am start -a android.intent.action.SEND -t text/plain \
### Мультивид (Quad)
-Показывает настроенные слоты сеткой 2×2, а если занят хотя бы один из слотов 5–8 — сеткой 3×3. Девятая ячейка сетки 3×3 зарезервирована под камеру, которая сама отправляет RTP по UDP; пока в ней показаны только адрес и порт, куда слать поток, а кнопка **U** в меню открывает её на весь экран. Чтобы смотреть такую камеру уже сейчас, укажите в любом слоте [адрес `udp://`](#слоты-udp-majestic-outgoing). У каждой ячейки своё подключение и свой декодер, поэтому число плавно идущих потоков высокого разрешения ограничено видеодекодером устройства: для пяти и более камер лучше брать их дополнительные потоки (substream). Некоторые камеры допускают только одно RTSP-подключение одновременно. Долгое нажатие на ячейку включает её звук (повторное — выключает); у выбранной ячейки виден значок AUDIO, выбор запоминается. Коснитесь любой ячейки, чтобы открыть меню.
+Показывает настроенные слоты сеткой 2×2, а если занят хотя бы один из слотов 5–8 — сеткой 3×3. Девятая ячейка сетки 3×3 зарезервирована под камеру, которая сама отправляет RTP по UDP; пока в ней показаны только адрес и порт, куда слать поток, а кнопка **U** в меню открывает её на весь экран. Чтобы смотреть такую камеру уже сейчас, укажите в любом слоте [адрес `udp://`](#слоты-udp-majestic-outgoing). У каждой ячейки своё подключение и свой декодер, а у видеодекодера устройства постоянный бюджет: тестовая приставка AG-216, например, декодирует в полном темпе примерно два потока 1080p30. Поэтому сетка подстраивается: ячейки стартуют на ключевых кадрах, затем по приоритету (ячейка со звуком, потом по номеру слота) переходят на все кадры, пока декодер успевает. Остальные остаются на ключевых кадрах — картинка обновляется раз в интервал между ключевыми кадрами, в углу пометка `key frames`. Предел определяется замером на каждом устройстве. Дополнительные потоки камер (substream), если они есть, позволяют показать в полном темпе больше ячеек. Ячейка сверх лимита экземпляров декодера показывает `Decoder limit: N × H.265`. Некоторые камеры допускают только одно RTSP-подключение одновременно. Долгое нажатие на ячейку включает её звук (повторное — выключает); у выбранной ячейки виден значок AUDIO, выбор запоминается. Коснитесь любой ячейки, чтобы открыть меню.
## Транспорты
@@ -239,7 +240,7 @@ export KEY_PASS=your_key_password
| Планшет | Samsung Tab A7 (SM-T505) | 12 | 4.19.157-perf- | 1.0x | |
| ТВ-приставка | A95X F3 AIR | 9 (SlimBOXtv AOSP 9.14, без GAPPS) | 4.9.113 | 1.0x | |
| ТВ-приставка | IE X3 Air | 9 (SlimBOXtv AOSP 9.14, без GAPPS) | 4.9.113 | 1.0x | |
-| ТВ-приставка | AG-216 | 10 | 4.9.170 | v2 1.28 | Тестовое устройство v2: мультивид на 8 камер, H.264/H.265, Opus/AAC/L16 |
+| ТВ-приставка | AG-216 | 10 | 4.9.170 | v2 1.28 | Тестовое устройство v2; бюджет декодера по замеру ≈ 1080p60 суммарно (два 1080p30 в полном темпе) |
| ТВ-приставка | SberBox SBDV-00001 | своя | 4.9.228 | 1.0x | |
| ТВ-приставка | X96Q (Allwinner H313) | 10 | 4.9.170 | 1.0x | |
| Телевизор | Haier Candy Android TV 2K | 11 | 4.9.243+ | 1.0x | Нет значка в списке приложений |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c1eab06f..3da5eb21 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -62,6 +62,7 @@
| **Q** | 开启或关闭多画面模式(Quad) |
| **1**–**8** | 切换摄像机槽位。当前槽位编号的颜色表示连接质量:绿色为抖动低于 100 毫秒,黄色为 100–300 毫秒,红色为高于 300 毫秒。灰色编号表示该槽位未配置。 |
| **U** | 全屏显示 3×3 网格中预留的 UDP 画面 |
+| **D** | 在每个多画面格子中显示调试信息:分辨率、编码、已解码/已接收帧率。关闭时仅需关注的格子显示文字 |
| **Settings** | 编辑当前槽位的地址和传输方式(仅单路模式) |
| **WebUI** | 打开摄像机网页界面,用户名和密码取自 RTSP 地址(仅单路模式) |
| **Exit** | 退出应用。旁边的小字是应用版本号和构建哈希。 |
@@ -88,7 +89,7 @@ adb shell am start -a android.intent.action.SEND -t text/plain \
### 多画面模式(Quad)
-以 2×2 网格显示已配置的槽位;只要使用了第 5–8 个槽位中的任意一个,就改为 3×3 网格。3×3 网格的第九格预留给通过 UDP 主动推送 RTP 的摄像机;目前其中仅显示推流目标地址和端口,菜单中的 **U** 可将其全屏显示。如需立即观看此类摄像机,请在任意槽位中填写 [`udp://` 地址](#udp-槽位-majestic-outgoing)。每个画面都有独立的连接和解码器,因此能流畅播放多少路高分辨率码流取决于设备的视频解码器:五路及以上时建议使用摄像机的子码流。部分摄像机同一时间只允许一个 RTSP 客户端。长按某个画面即可播放其声音(再次长按则静音);被选中的画面显示 AUDIO 标记,选择会被记住。点击任意画面即可打开菜单。
+以 2×2 网格显示已配置的槽位;只要使用了第 5–8 个槽位中的任意一个,就改为 3×3 网格。3×3 网格的第九格预留给通过 UDP 主动推送 RTP 的摄像机;目前其中仅显示推流目标地址和端口,菜单中的 **U** 可将其全屏显示。如需立即观看此类摄像机,请在任意槽位中填写 [`udp://` 地址](#udp-槽位-majestic-outgoing)。每个画面都有独立的连接和解码器,而设备视频解码器的处理能力是固定的:例如测试用的 AG-216 盒子以全帧率大约只能解码两路 1080p30。因此网格会自适应:各画面先只解码关键帧,然后按优先级(正在播放声音的画面,其次按槽位顺序)在解码器跟得上时切换为全帧率。其余画面保持只解码关键帧,画面按关键帧间隔更新,角落显示 `key frames`。上限在每台设备上通过实测确定。如摄像机提供子码流,可让更多画面以全帧率播放。超出解码器实例上限的画面显示 `Decoder limit: N × H.265`。部分摄像机同一时间只允许一个 RTSP 客户端。长按某个画面即可播放其声音(再次长按则静音);被选中的画面显示 AUDIO 标记,选择会被记住。点击任意画面即可打开菜单。
## 传输方式
@@ -239,7 +240,7 @@ export KEY_PASS=your_key_password
| 平板 | Samsung Tab A7 (SM-T505) | 12 | 4.19.157-perf- | 1.0x | |
| 电视盒子 | A95X F3 AIR | 9(SlimBOXtv AOSP 9.14,无 GAPPS) | 4.9.113 | 1.0x | |
| 电视盒子 | IE X3 Air | 9(SlimBOXtv AOSP 9.14,无 GAPPS) | 4.9.113 | 1.0x | |
-| 电视盒子 | AG-216 | 10 | 4.9.170 | v2 1.28 | v2 测试设备:8 路多画面,H.264/H.265,Opus/AAC/L16 |
+| 电视盒子 | AG-216 | 10 | 4.9.170 | v2 1.28 | v2 测试设备;实测解码能力约为总计 1080p60(全帧率两路 1080p30) |
| 电视盒子 | SberBox SBDV-00001 | 定制系统 | 4.9.228 | 1.0x | |
| 电视盒子 | X96Q(全志 H313) | 10 | 4.9.170 | 1.0x | |
| 电视 | Haier Candy Android TV 2K | 11 | 4.9.243+ | 1.0x | 应用列表中没有图标 |
diff --git a/android/app/src/main/java/com/openipc/decoder/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java
index 011b5c28..1a89e57b 100644
--- a/android/app/src/main/java/com/openipc/decoder/DebugServer.java
+++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java
@@ -489,8 +489,7 @@ private static void closeQuietly(Socket s) {
+ "if(r&&+r[1]<0.9*r[2])t=t.replace(r[0],''+r[0]+'');\n"
+ "if(c.lost_fps>0)t+=' \u00b7 lost '+num(c.lost_fps)"
+ "+'/s';\n"
- + "if(c.behind_s>0)t+=' \u00b7 behind '+c.behind_s"
- + "+' s';return t}\n"
+ + "return t}\n"
// One cell: slot, camera and transport on top; size, fps and losses below
+ "function cell(c){var top=''+esc(c.slot)+''+where(c)+'';\n"
diff --git a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java
new file mode 100644
index 00000000..e58f4c44
--- /dev/null
+++ b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * DecodePlanner.java — which multi-view cells decode every frame and which only key frames (#85)
+ *
+ */
+
+package com.openipc.decoder;
+
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Decides, once per second, which cells decode at full rate and which only key frames, so
+ * that every camera stays on screen while the hardware decoder keeps up.
+ *
+ *
Load is counted in macroblocks per second (16x16 blocks x frames per second), the unit
+ * decoders declare their throughput in. The capacity starts as the decoder's declared budget,
+ * or the value learnt on this device before, and is then learnt by measurement: decoder losses
+ * at some load lower it, a load that plays without losses for a while raises it.
+ *
+ *
Cells come in priority order, highest first. Pure logic, no Android types.
+ */
+final class DecodePlanner {
+ /** Lossy samples within LOSS_WINDOW_MS before a cell is demoted or paused. */
+ static final int LOSS_SAMPLES = 3;
+ /**
+ * Key-frame cells lose a frame only now and then (one key frame per GOP), so lossy samples
+ * are counted over a window rather than in a row.
+ */
+ static final long LOSS_WINDOW_MS = 10_000;
+ /** How long a playing cell with an unknown load keeps the lower cells waiting. */
+ static final long UNKNOWN_WAIT_MS = 10_000;
+ /** Time after a change before the next one, so the decoder load can settle. */
+ static final long SETTLE_MS = 3000;
+ /** Quiet time after which a load counts as proven and a promotion beyond capacity is tried. */
+ static final long PROBE_QUIET_MS = 30_000;
+ /** Longest wait between probes: it doubles after every failure. */
+ static final long PROBE_MAX_WAIT_MS = 10 * 60_000;
+ /** Promotions stay this far below a load that failed: the load estimates are noisy. */
+ static final double FAILED_MARGIN = 0.9;
+ /** Quiet time after which a load that failed is tried once more: it may have been a fluke. */
+ static final long FAILED_RETRY_MS = 60 * 60_000;
+
+ static final class Cell {
+ int index;
+ boolean full;
+ boolean paused;
+ /** Resolution and frame rate known, so fullLoad means something. */
+ boolean known;
+ /** Macroblocks per second when decoding every frame. */
+ long fullLoad;
+ /** Macroblocks per second it costs on key frames only (counts in the load too). */
+ long keyLoad;
+ /** The decoder lost frames of this cell in the last sample. */
+ boolean lossy;
+ /** Connected and decoding: only such cells are demoted or paused (Qodo on #84). */
+ boolean playing = true;
+
+ Cell(int index, boolean full, boolean known, long fullLoad, boolean lossy) {
+ this.index = index;
+ this.full = full;
+ this.known = known;
+ this.fullLoad = fullLoad;
+ this.lossy = lossy;
+ }
+ }
+
+ enum Action { NONE, PROMOTE, DEMOTE, PAUSE }
+
+ static final class Decision {
+ final Action action;
+ final int index;
+
+ Decision(Action action, int index) {
+ this.action = action;
+ this.index = index;
+ }
+
+ static final Decision NONE = new Decision(Action.NONE, -1);
+ }
+
+ private long capacity;
+ private boolean learnt;
+ /** Largest full-rate load that played without losses for PROBE_QUIET_MS. */
+ private long proven;
+ /** Smallest load at which the decoder lost frames: promotions stay below it. */
+ private long failedLoad;
+ /** Quiet time before the next probe; grows after failures, resets after a success. */
+ private long probeWaitMs = PROBE_QUIET_MS;
+ /** Times of the recent lossy samples. */
+ private final ArrayDeque lossyTimes = new ArrayDeque<>();
+ /** Since when a playing cell's load is unknown, by cell index. */
+ private final Map unknownSince = new HashMap<>();
+ private long quietSinceMs = -1;
+ private long lastChangeMs = -1;
+
+ DecodePlanner(long capacity, boolean learnt) {
+ this(capacity, learnt, 0);
+ }
+
+ /** With the failed load stored with the learnt capacity (0: none), not tried again. */
+ DecodePlanner(long capacity, boolean learnt, long failedLoad) {
+ this.capacity = capacity;
+ this.learnt = learnt;
+ this.failedLoad = failedLoad > 0 ? failedLoad : Long.MAX_VALUE;
+ }
+
+ long capacity() { return capacity; }
+ boolean isLearnt() { return learnt; }
+ /** Smallest load that made the decoder lose frames, or 0 if none. */
+ long failedLoad() { return failedLoad == Long.MAX_VALUE ? 0 : failedLoad; }
+
+ /** The decoder's declared budget, once known; ignored when a learnt value exists. */
+ void offerDeclared(long declared) {
+ if (!learnt && declared > 0) capacity = declared;
+ }
+
+ static long fullLoad(int width, int height, float fps) {
+ long blocks = (long) ((width + 15) / 16) * ((height + 15) / 16);
+ return Math.round(blocks * (double) fps);
+ }
+
+ Decision step(List cells, long nowMs) {
+ if (quietSinceMs < 0) quietSinceMs = nowMs;
+ long load = 0;
+ boolean anyLossy = false;
+ for (Cell c : cells) {
+ if (c.paused) continue;
+ if (c.full && c.known) load += c.fullLoad;
+ else if (!c.full) load += c.keyLoad;
+ anyLossy |= c.lossy;
+ }
+ while (!lossyTimes.isEmpty() && nowMs - lossyTimes.peekFirst() >= LOSS_WINDOW_MS) {
+ lossyTimes.pollFirst();
+ }
+ if (anyLossy) {
+ lossyTimes.addLast(nowMs);
+ quietSinceMs = nowMs;
+ } else if (nowMs - quietSinceMs >= PROBE_QUIET_MS && load > proven) {
+ // A load that played without losses long enough is within this device's means
+ proven = load;
+ probeWaitMs = PROBE_QUIET_MS;
+ if (load > capacity) {
+ capacity = load;
+ learnt = true;
+ }
+ // A failure at about this load was a fluke: do not hold promotions back by it
+ if (load >= failedLoad * FAILED_MARGIN) failedLoad = Long.MAX_VALUE;
+ }
+ if (lastChangeMs >= 0 && nowMs - lastChangeMs < SETTLE_MS) return Decision.NONE;
+
+ if (lossyTimes.size() >= LOSS_SAMPLES) {
+ lossyTimes.clear();
+ for (int i = cells.size() - 1; i >= 0; i--) {
+ Cell c = cells.get(i);
+ if (c.full && !c.paused && c.playing && c.known) {
+ // Losses at this load: the capacity is below it, but not below a proven load
+ failedLoad = Math.min(failedLoad, load);
+ // A "proven" load at or above a failing one was not proven after all
+ if (proven >= load) proven = Math.round(load * FAILED_MARGIN);
+ probeWaitMs = Math.min(probeWaitMs * 2, PROBE_MAX_WAIT_MS);
+ // Never raised by a failure; not below a proven load
+ capacity = Math.max(proven, Math.min(capacity, Math.round(load * 0.9)));
+ learnt = true;
+ return change(nowMs, Action.DEMOTE, c.index);
+ }
+ }
+ // Even key frames are too much: pause the lowest-priority cell as a last resort, but
+ // never the last one that plays
+ if (countPlaying(cells) < 2) return Decision.NONE;
+ for (int i = cells.size() - 1; i >= 0; i--) {
+ Cell c = cells.get(i);
+ if (!c.paused && c.playing) return change(nowMs, Action.PAUSE, c.index);
+ }
+ return Decision.NONE;
+ }
+ if (anyLossy) return Decision.NONE;
+
+ // Promote the highest-priority key-frame cell whose load is known
+ for (Cell c : cells) {
+ if (c.full || c.paused || !c.playing) {
+ // Not connected (an offline camera) or paused: nothing to wait for
+ unknownSince.remove(c.index);
+ continue;
+ }
+ if (!c.known) {
+ // Keep the priority order: wait for its load, but not for ever
+ Long since = unknownSince.get(c.index);
+ if (since == null) unknownSince.put(c.index, since = nowMs);
+ if (nowMs - since < UNKNOWN_WAIT_MS) return Decision.NONE;
+ continue;
+ }
+ unknownSince.remove(c.index);
+ long after = load - c.keyLoad + c.fullLoad;
+ // Never up to a load that failed, not even within the capacity, unless it has been
+ // quiet for very long
+ if (after >= failedLoad * FAILED_MARGIN
+ && nowMs - quietSinceMs < FAILED_RETRY_MS) return Decision.NONE;
+ boolean fits = after <= capacity;
+ // Beyond the capacity only after a quiet period
+ boolean probe = nowMs - quietSinceMs >= probeWaitMs;
+ if (fits || probe) {
+ // Every promotion starts a new quiet period: a load counts as proven only
+ // after it has itself played for PROBE_QUIET_MS without losses
+ quietSinceMs = nowMs;
+ return change(nowMs, Action.PROMOTE, c.index);
+ }
+ return Decision.NONE;
+ }
+ return Decision.NONE;
+ }
+
+ private static int countPlaying(List| cells) {
+ int n = 0;
+ for (Cell c : cells) if (!c.paused && c.playing) n++;
+ return n;
+ }
+
+ private Decision change(long nowMs, Action action, int index) {
+ lastChangeMs = nowMs;
+ return new Decision(action, index);
+ }
+}
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 fb31c8be..afa08cbb 100755
--- a/android/app/src/main/java/com/openipc/decoder/Decoder.java
+++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java
@@ -35,6 +35,7 @@
import android.view.SurfaceView;
import android.view.TextureView;
import android.view.View;
+import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
@@ -103,11 +104,10 @@ public class Decoder extends Activity
private final TextView[] quadAudioBadges = new TextView[CAM_COUNT];
/** Small text in each cell's corner: stats while playing, otherwise the status (#80, #82). */
private final TextView[] quadStats = new TextView[CAM_COUNT];
- /** Samples (seconds) a cell may fall behind before the grid is relieved (#83). */
- private static final int OVERLOAD_SAMPLES = 5;
- /** After pausing a cell, time for the load to settle before the next decision. */
- private static final long RELIEF_COOLDOWN_MS = 10_000;
- private long lastReliefMs;
+ /** Debug text in every cell (menu D); without it only cells needing attention show text. */
+ private boolean showStats;
+ /** With the debug text on: where the debug page is (#96). */
+ private TextView debugUrls;
private volatile int quadAudioGen;
private ExecutorService quadAudioExecutor;
@@ -205,6 +205,7 @@ public void onCodecChanged(boolean h265, AudioConfig audio) {
mSurface = findViewById(R.id.video_surface);
mSurface.setKeepScreenOn(true);
statusText = findViewById(R.id.status_text);
+ debugUrls = findViewById(R.id.debug_urls);
mSurface.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
@Override public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture st, int w, int h) {
@@ -325,6 +326,44 @@ public void onToggleQuad() {
}
}
+ @Override
+ public void onToggleStats() {
+ showStats = !showStats;
+ getSharedPreferences("settings", MODE_PRIVATE).edit()
+ .putBoolean("show_stats", showStats).apply();
+ menuManager.setStatsShown(showStats);
+ updateDebugUrls();
+ // Apply at once instead of at the next second
+ QuadCell[] cells = quadCells;
+ for (int i = 0; i < quadStats.length; i++) {
+ QuadCell c = cells != null ? cells[i] : null;
+ if (quadStats[i] == null || c == null) continue;
+ quadStats[i].setVisibility(showStats || c.needsAttention() ? View.VISIBLE : View.GONE);
+ }
+ Toast.makeText(this, showStats ? "Debug text on" : "Debug text off",
+ Toast.LENGTH_SHORT).show();
+ }
+
+ /** One line http://:8085/ per device address while the debug text is on. */
+ private void updateDebugUrls() {
+ if (debugUrls == null) return;
+ if (!showStats) {
+ debugUrls.setVisibility(View.GONE);
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (String ip : NetInfo.addresses()) {
+ if (sb.length() > 0) sb.append('\n');
+ sb.append("http://").append(ip).append(':').append(DebugServer.PORT).append('/');
+ }
+ if (sb.length() == 0) sb.append("Debug page: no network");
+ if (!sb.toString().contentEquals(debugUrls.getText())) debugUrls.setText(sb);
+ debugUrls.setVisibility(View.VISIBLE);
+ // Above the grid and its overlays, which are added later
+ ViewGroup parent = (ViewGroup) debugUrls.getParent();
+ if (parent.getChildAt(parent.getChildCount() - 1) != debugUrls) debugUrls.bringToFront();
+ }
+
@Override
public void onShowUdp() {
if (udpViewShown) return;
@@ -408,6 +447,8 @@ private void loadSettings() {
// Restore the last camera and mode (#22); both are saved but were never read back
mActive = clampSlot(pref.getInt("active", 0));
quadEnabled = pref.getBoolean("quad_enabled", false);
+ showStats = pref.getBoolean("show_stats", false);
+ if (menuManager != null) menuManager.setStatsShown(showStats);
SharedPreferences.Editor migrate = null;
for (int i = 0; i < CAM_COUNT; i++) {
String stored = pref.getString("host_" + i, null);
@@ -880,55 +921,115 @@ public void run() {
if (c == null || v == null) continue;
String line = c.sample(now);
v.setText(line);
- v.setVisibility(View.VISIBLE);
+ // Without the debug text (D) only what needs attention is shown
+ v.setVisibility(showStats || c.needsAttention() ? View.VISIBLE : View.GONE);
if (log) Log.i(TAG, "Quad-" + i + ": " + line);
}
- relieveDecoder(cells, now);
+ planDecoding(cells, now);
+ updateDebugUrls();
quadContainer.postDelayed(this, 1000);
}
};
private void startStatsTick() {
quadContainer.removeCallbacks(quadStatsTick);
+ decodePlanner = newPlanner();
quadContainer.postDelayed(quadStatsTick, 1000);
}
/**
- * Load is judged by measurement, not by the declared budget: a cell falls behind when the
- * decoder makes it lose frames (its queue overflows, or the decoder has no free input
- * buffer). Frames lost on the network do not count: they lower decoded/received in the
- * corner text but say nothing about the decoder. When a cell keeps falling behind, the
- * highest-numbered playing cell is paused, then the load is given time to settle (#83).
+ * Learnt decoder capacity of this device in macroblocks/s, or 0 (#85). "_v2": values
+ * learnt before #89 counted NAL units dropped after a 5 ms wait as overload; not reused.
*/
- private void relieveDecoder(QuadCell[] cells, long now) {
- boolean overloaded = false;
- int active = 0;
- int last = -1;
+ private static final String PREF_DECODER_CAPACITY = "decoder_capacity_v2";
+ /** Smallest load that made the decoder lose frames, or 0: not retried after a restart. */
+ private static final String PREF_DECODER_FAILED = "decoder_failed_load_v2";
+ /** 1080p at 60 fps: the usual declared budget, until the decoder declares its own. */
+ private static final long DEFAULT_CAPACITY = DecodePlanner.fullLoad(1920, 1080, 60);
+ private DecodePlanner decodePlanner;
+
+ private DecodePlanner newPlanner() {
+ SharedPreferences pref = getSharedPreferences("settings", MODE_PRIVATE);
+ if (pref.contains("decoder_capacity")) {
+ pref.edit().remove("decoder_capacity").remove("decoder_failed_load").apply();
+ }
+ long learnt = pref.getLong(PREF_DECODER_CAPACITY, 0);
+ long failed = pref.getLong(PREF_DECODER_FAILED, 0);
+ if (learnt > 0) return new DecodePlanner(learnt, true, failed);
+ long declared = MediaCodecManager.declaredBlocksPerSec;
+ return new DecodePlanner(declared > 0 ? declared : DEFAULT_CAPACITY, false, failed);
+ }
+
+ /**
+ * Every camera stays on screen: cells decode every frame while the decoder keeps up and
+ * only key frames otherwise. Decoder losses (queue overflow, no free input buffer) are the
+ * signal; frames lost on the network do not count. The capacity learnt this way is kept
+ * for this device (#83, #85).
+ */
+ private void planDecoding(QuadCell[] cells, long now) {
+ DecodePlanner planner = decodePlanner;
+ if (planner == null) return;
+ planner.offerDeclared(MediaCodecManager.declaredBlocksPerSec);
+ long before = planner.capacity();
+ long failedBefore = planner.failedLoad();
+ boolean learntBefore = planner.isLearnt();
+
+ java.util.List order = new java.util.ArrayList<>();
+ // Priority: the cell whose sound plays, then slot order
+ if (quadAudio >= 0 && quadAudio < cells.length && cells[quadAudio] != null) {
+ order.add(plannerCell(quadAudio, cells[quadAudio], now));
+ }
for (int i = 0; i < cells.length; i++) {
- QuadCell c = cells[i];
- if (c == null || c.isPaused()) continue;
- active++;
- last = i;
- if (c.isSettled(now) && c.inFps() >= 5) {
- boolean behind = c.lostFps() >= Math.max(2f, 0.1f * c.inFps());
- c.behindSamples = behind ? c.behindSamples + 1 : 0;
- if (c.behindSamples >= OVERLOAD_SAMPLES) overloaded = true;
- } else {
- c.behindSamples = 0;
+ if (cells[i] != null && i != quadAudio) order.add(plannerCell(i, cells[i], now));
+ }
+ DecodePlanner.Decision d = planner.step(order, now);
+ if (d.action != DecodePlanner.Action.NONE) {
+ QuadCell c = cells[d.index];
+ switch (d.action) {
+ case PROMOTE:
+ c.setKeyFramesOnly(false);
+ Log.i(TAG, "Quad-" + d.index + ": full rate");
+ break;
+ case DEMOTE:
+ c.setKeyFramesOnly(true);
+ Log.w(TAG, "Decoder losses: Quad-" + d.index + " to key frames, capacity "
+ + planner.capacity() + " blocks/s");
+ break;
+ case PAUSE:
+ String reason = "Paused: decoder overloaded";
+ Log.w(TAG, "Decoder losses even on key frames: pausing Quad-" + d.index);
+ c.pause(reason);
+ showQuadOverlay(d.index);
+ if (quadStats[d.index] != null) quadStats[d.index].setText(reason);
+ break;
+ default:
+ break;
}
}
- if (!overloaded || active < 2 || now - lastReliefMs < RELIEF_COOLDOWN_MS) return;
- lastReliefMs = now;
- String reason = "Paused: decoder overloaded";
- Log.w(TAG, "Decoder overloaded with " + active + " streams: pausing Quad-" + last);
- cells[last].pause(reason);
- showQuadOverlay(last);
- if (quadStats[last] != null) quadStats[last].setText(reason);
- for (QuadCell c : cells) {
- if (c != null) c.behindSamples = 0;
+ boolean capacityChanged = planner.isLearnt()
+ && (!learntBefore || planner.capacity() != before);
+ if (capacityChanged || planner.failedLoad() != failedBefore) {
+ SharedPreferences.Editor e = getSharedPreferences("settings", MODE_PRIVATE).edit();
+ if (planner.isLearnt()) e.putLong(PREF_DECODER_CAPACITY, planner.capacity());
+ e.putLong(PREF_DECODER_FAILED, planner.failedLoad()).apply();
+ Log.i(TAG, "Decoder capacity of this device: " + planner.capacity()
+ + " blocks/s, failed at " + planner.failedLoad());
}
}
+ private static DecodePlanner.Cell plannerCell(int index, QuadCell c, long now) {
+ // At full rate a few lost frames per second are noise; on key frames, a cell decodes
+ // one frame per GOP, so every decoder loss counts, whatever the frame rate
+ boolean lossy = c.isSettled(now) && (c.isKeyFramesOnly() ? c.lostFps() > 0
+ : c.inFps() >= 5 && c.lostFps() >= Math.max(2f, 0.1f * c.inFps()));
+ DecodePlanner.Cell pc = new DecodePlanner.Cell(index, !c.isKeyFramesOnly(),
+ c.knowsLoad(), c.knowsLoad() ? c.fullLoad() : 0, lossy);
+ pc.paused = c.isPaused();
+ pc.playing = c.isPlaying();
+ if (c.knowsLoad()) pc.keyLoad = c.keyLoad();
+ return pc;
+ }
+
private void removeQuadOverlay(int index) {
if (index < 0 || index >= quadViews.length || quadViews[index] == null) return;
SurfaceView t = quadViews[index];
@@ -1103,7 +1204,15 @@ private Map debugState() {
m.put("mode", !mResumed ? "paused" : udpViewShown ? "udp"
: quadEnabled ? "multi-view" : "single");
m.put("active_slot", mActive + 1);
- if (quadEnabled) m.put("overload_after_s", OVERLOAD_SAMPLES);
+ DecodePlanner planner = decodePlanner;
+ if (quadEnabled && planner != null) {
+ // Decoder capacity in macroblocks/s: declared, or learnt on this device (#85)
+ Map dec = new LinkedHashMap<>();
+ dec.put("capacity", planner.capacity());
+ dec.put("learnt", planner.isLearnt());
+ dec.put("failed_load", planner.failedLoad());
+ m.put("decoder", dec);
+ }
if (udpViewShown) {
Map udp = new LinkedHashMap<>();
udp.put("port", UDP_RTP_PORT);
@@ -1142,9 +1251,10 @@ private Map debugState() {
c.put("out_fps", cell.outFps());
c.put("in_fps", cell.inFps());
c.put("lost_fps", cell.lostFps());
- // Seconds in a row the decoder made this cell lose frames; at
- // OVERLOAD_SAMPLES the grid is relieved (#83)
- c.put("behind_s", cell.behindSamples);
+ // Every frame, or key frames only while the decoder has no room (#85)
+ c.put("decode", cell.isKeyFramesOnly() ? "key frames" : "full");
+ if (cell.knowsLoad()) c.put("load", cell.isKeyFramesOnly()
+ ? cell.keyLoad() : cell.fullLoad());
c.put("paused", cell.isPaused());
c.put("audio", i == quadAudio);
}
@@ -1442,6 +1552,7 @@ protected void onResume() {
super.onResume();
mResumed = true;
startStreams();
+ updateDebugUrls();
}
private boolean mResumed;
diff --git a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java
index 96c12238..cab2808b 100644
--- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java
+++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java
@@ -102,7 +102,8 @@ static final class InstanceBudget {
static synchronized boolean tryAcquire(String mime) {
int u = used.containsKey(mime) ? used.get(mime) : 0;
Integer max = limit.get(mime);
- if (max != null && u >= max) return false;
+ // Until the first decoder of a type has told its limit, start one at a time
+ if (max == null ? u > 0 : u >= max) return false;
used.put(mime, u + 1);
return true;
}
@@ -128,28 +129,52 @@ static synchronized void reset() {
}
}
- /** Give the instance slot back; called wherever the decoder is detached (decoderLock held). */
- private void releaseSlot() {
- if (heldMime != null) {
- InstanceBudget.release(heldMime);
- heldMime = null;
- }
+ /**
+ * The decoder's declared throughput in 16x16 macroblocks per second, from the first decoder
+ * created (0 while unknown). The starting capacity for multi-view planning (#85).
+ */
+ static volatile long declaredBlocksPerSec;
+
+ /**
+ * Detach the instance slot from this manager (decoderLock held). The caller gives it back
+ * with {@link InstanceBudget#release} only after the codec itself is released, so another
+ * cell cannot start a decoder while the device still holds the maximum.
+ */
+ private String takeSlot() {
+ String m = heldMime;
+ heldMime = null;
+ return m;
+ }
+
+ private static void releaseSlot(String mime) {
+ if (mime != null) InstanceBudget.release(mime);
}
/** Learn the codec's instance limit and log its declared throughput, once per type. */
private static void learnLimits(MediaCodec codec, String mime) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return;
if (InstanceBudget.limit(mime) >= 0) return;
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ InstanceBudget.learn(mime, Integer.MAX_VALUE); // not declared before Android 6
+ return;
+ }
try {
MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime);
int max = caps.getMaxSupportedInstances();
InstanceBudget.learn(mime, max);
MediaCodecInfo.VideoCapabilities video = caps.getVideoCapabilities();
- String fps1080 = video != null && video.isSizeSupported(1920, 1080)
- ? String.valueOf(video.getSupportedFrameRatesFor(1920, 1080).getUpper()) : "?";
+ String fps1080 = "?";
+ if (video != null && video.isSizeSupported(1920, 1080)) {
+ double fps = video.getSupportedFrameRatesFor(1920, 1080).getUpper();
+ fps1080 = String.valueOf(fps);
+ long blocks = DecodePlanner.fullLoad(1920, 1080, (float) fps);
+ if (declaredBlocksPerSec == 0 || blocks < declaredBlocksPerSec) {
+ declaredBlocksPerSec = blocks;
+ }
+ }
Log.i(TAG, "Decoder " + codec.getName() + " (" + mime + "): up to " + max
+ " instances, declared 1080p frame rate in total " + fps1080);
} catch (Exception e) {
+ InstanceBudget.learn(mime, Integer.MAX_VALUE);
Log.w(TAG, "Decoder limits unknown: " + e.getMessage());
}
}
@@ -177,13 +202,15 @@ void setEnabled(boolean enabled) {
final MediaCodec target = decoder;
if (target == null) return;
new Thread(() -> {
+ String slot;
synchronized (decoderLock) {
if (decoder != target) return; // already replaced or released
decoder = null;
- releaseSlot();
+ slot = takeSlot();
retry.onSuccess();
}
releaseNow(target);
+ releaseSlot(slot);
}, "decoder-release").start();
}
@@ -214,7 +241,7 @@ private void closeDecoderLocked() {
lastFrameMs = 0;
if (codec == null) return;
decoder = null;
- releaseSlot();
+ String slot = takeSlot();
Log.i(TAG, "Close video decoder");
try {
codec.stop();
@@ -222,6 +249,7 @@ private void closeDecoderLocked() {
} catch (Exception e) {
Log.e(TAG, "Decoder close exception", e);
}
+ releaseSlot(slot);
}
}
@@ -253,6 +281,8 @@ void createDecoder(boolean codecH265) {
synchronized (decoderLock) {
retry.onFailure(nowMs());
}
+ // Limit still unknown: another cell is creating the first decoder, just retry
+ if (limit < 0) return;
Log.w(TAG, "Decoder limit reached: " + limit + " × " + type);
if (listener != null) listener.onDecoderLimit(type, limit);
return;
@@ -279,6 +309,7 @@ void createDecoder(boolean codecH265) {
retry.onFailure(nowMs());
}
Log.e(TAG, "Cannot setup decoder (attempt " + retry.failures() + "): " + e.getMessage());
+ if (listener != null) listener.onDecoderFailed();
return;
}
@@ -361,12 +392,13 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) {
drainOutput(codec);
} catch (Exception e) {
decoder = null;
- releaseSlot();
+ String slot = takeSlot();
retry.onFailure(nowMs());
Log.e(TAG, "Codec exception, retry in "
+ DecoderRetry.delayMs(retry.failures()) + " ms: " + e.getMessage());
try { codec.stop(); } catch (Exception ignored) {}
try { codec.release(); } catch (Exception ignored) {}
+ releaseSlot(slot);
if (listener != null) listener.onDecoderFailed();
}
}
diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java
index b3bd7d8a..df5ae8f6 100644
--- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java
+++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java
@@ -50,6 +50,8 @@ class MenuManager {
private TextView[] mCachedCamButtons;
private TextView mCachedQuadBtn;
private TextView mCachedUdpBtn;
+ private TextView mCachedStatsBtn;
+ private boolean statsShown;
/** The UDP RTP view is shown: neither a slot nor multi-view is active. */
private boolean udpShown;
private boolean mMenuIsCached;
@@ -66,6 +68,8 @@ interface Listener {
void onToggleQuad();
/** U: the reserved UDP RTP cell full-screen (#68). */
void onShowUdp();
+ /** D: debug text (resolution, codec, fps) in every multi-view cell on or off. */
+ void onToggleStats();
void onExit();
void onShowSettings();
void onSaveSettings();
@@ -96,6 +100,8 @@ void updateQuality(int latency) {
void setUdpShown(boolean shown) { udpShown = shown; }
+ void setStatsShown(boolean shown) { statsShown = shown; }
+
void applyQualityToActive(boolean quadEnabled, int mActive) {
if (camButtons != null && !quadEnabled && !udpShown) {
camButtons[mActive].setTextColor(mActiveQualityColor);
@@ -236,6 +242,21 @@ public boolean dispatchKeyEvent(KeyEvent event) {
if (listener != null) listener.onShowUdp();
});
+ // D after U: the debug text in the multi-view cells, highlighted while on
+ final TextView statsBtn = createItem("D");
+ statsBtn.setGravity(Gravity.CENTER);
+ statsBtn.setPadding(dp(12), dp(8), dp(12), dp(8));
+ camRow.addView(statsBtn, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT));
+ if (statsShown) highlightItem(statsBtn);
+ mCachedStatsBtn = statsBtn;
+ statsBtn.setOnClickListener(v -> {
+ PopupWindow p = mMenuPopup;
+ if (p != null) p.dismiss();
+ if (listener != null) listener.onToggleStats();
+ });
+
String code = "Exit [v" + version + ", " + gitHash + "]";
SpannableString s = new SpannableString(code);
s.setSpan(new SuperscriptSpan(), 5, s.length(), 0);
@@ -287,6 +308,10 @@ private void updateCachedMenu(boolean quadEnabled, int mActive, String[] mHosts)
if (udpShown) highlightItem(mCachedUdpBtn);
else resetItem(mCachedUdpBtn);
}
+ if (mCachedStatsBtn != null) {
+ if (statsShown) highlightItem(mCachedStatsBtn);
+ else resetItem(mCachedStatsBtn);
+ }
if (mCachedCamButtons != null && camButtons != null) {
for (int i = 0; i < CAM_COUNT; i++) {
if (mCachedCamButtons[i] == null || camButtons[i] == null) continue;
diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java
index bbabd715..bf7a8e0c 100644
--- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java
+++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java
@@ -67,8 +67,13 @@ class QuadCell implements RtspSession.Callback {
private float outFps;
/** Frames per second lost because the decoder did not keep up (queue full, no input buffer). */
private float lostFps;
- /** Consecutive samples in which the decoder fell behind (UI thread, #83). */
- int behindSamples;
+ /** Smoothed rates (about 3 s): the per-second ones jump too much to plan with (#85). */
+ private float inAvg;
+ private float outAvg;
+ /** Decode only key frames and parameter sets (#85): a fraction of the load. */
+ private volatile boolean keyFramesOnly = true;
+ /** Just promoted to full rate: skip until the next key frame, the references are missing. */
+ private volatile boolean waitingKeyFrame;
interface Listener {
void onRemoveOverlay(int index);
@@ -107,7 +112,10 @@ void setAudioSink(AudioManager sink, int route) {
decoderStartedMs = System.nanoTime() / 1_000_000;
if (listener != null) listener.onRemoveOverlay(index);
}
- @Override public void onDecoderFailed() {}
+ @Override public void onDecoderFailed() {
+ // A decoder limit message says more; keep it
+ if (problem == null || !problem.startsWith("Decoder limit")) problem = "Decoder error";
+ }
@Override public void onDecoderLimit(String mime, int limit) {
problem = "Decoder limit: " + (limit > 0 ? limit + " × " : "")
+ (mime.contains("hevc") ? "H.265" : "H.264");
@@ -227,7 +235,9 @@ public void onStatus(String status) {
@Override
public void onFailure(String message) {
- problem = shortError(message);
+ // A decoder problem stays visible; the connection retries on its own
+ String p = problem;
+ if (p == null || !p.startsWith("Decoder")) problem = shortError(message);
}
/** A connection error short enough for a cell corner. */
@@ -261,6 +271,8 @@ String sample(long nowMs) {
inFps = Math.max(0, in - lastIn) / sec;
outFps = Math.max(0, out - lastOut) / sec;
lostFps = Math.max(0, lost - lastLost) / sec;
+ inAvg = inAvg == 0 ? inFps : 0.7f * inAvg + 0.3f * inFps;
+ outAvg = outAvg == 0 ? outFps : 0.7f * outAvg + 0.3f * outFps;
}
lastIn = in;
lastOut = out;
@@ -278,18 +290,74 @@ String sample(long nowMs) {
int w = decoder.outWidth();
int h = decoder.outHeight();
String size = w > 0 ? w + "×" + h + " " : "";
- return size + (codecH265 ? "H.265" : "H.264") + " "
- + Math.round(outFps) + "/" + Math.round(inFps) + " fps";
+ String codec = codecH265 ? "H.265" : "H.264";
+ if (keyFramesOnly) {
+ String rate = outAvg < 10 ? String.format(java.util.Locale.ROOT, "%.1f", outAvg)
+ : String.valueOf(Math.round(outAvg));
+ return size + codec + " key frames · " + rate + " fps";
+ }
+ return size + codec + " " + Math.round(outFps) + "/" + Math.round(inFps) + " fps";
+ }
+
+ boolean isKeyFramesOnly() { return keyFramesOnly; }
+
+ /** Full rate or key frames only (#85). Going to full rate waits for the next key frame. */
+ void setKeyFramesOnly(boolean on) {
+ if (on == keyFramesOnly) return;
+ if (!on) waitingKeyFrame = true;
+ keyFramesOnly = on;
+ }
+
+ /** Resolution and frame rate are known, so the full-rate load can be estimated. */
+ boolean knowsLoad() {
+ return decoder.outWidth() > 0 && inAvg > 0;
+ }
+
+ /** Macroblocks per second at full rate (#85). */
+ long fullLoad() {
+ return DecodePlanner.fullLoad(decoder.outWidth(), decoder.outHeight(), inAvg);
+ }
+
+ /** Macroblocks per second it costs now on key frames only (#85). */
+ long keyLoad() {
+ return DecodePlanner.fullLoad(decoder.outWidth(), decoder.outHeight(), outAvg);
+ }
+
+ /** A parameter set or a key frame (H.264 IDR; H.265 IRAP). */
+ static boolean isKeyNal(int type, boolean h265) {
+ if (h265) return (type >= 16 && type <= 21) || (type >= 32 && type <= 34);
+ return type == 5 || type == 7 || type == 8;
+ }
+
+ static boolean isKeyFrameNal(int type, boolean h265) {
+ return h265 ? type >= 16 && type <= 21 : type == 5;
}
float inFps() { return inFps; }
float outFps() { return outFps; }
float lostFps() { return lostFps; }
+
+ /** Something worth showing even without the debug text (D): not playing at full rate. */
+ boolean needsAttention() {
+ return pausedReason != null || problem != null || !"connected".equals(state) || keyFramesOnly;
+ }
boolean isPaused() { return pausedReason != null; }
- /** Connected, not paused and a frame shown within the last {@code ms} milliseconds (#98). */
+ /** On key frames only, a frame comes once per GOP, which can be several seconds (#85). */
+ static final long KEY_FRAMES_LIVE_MS = 10_000;
+
+ /**
+ * Connected, not paused and a frame shown within the last {@code ms} milliseconds (#98);
+ * on key frames within KEY_FRAMES_LIVE_MS, or the cell would blink between key frames.
+ */
boolean showsVideo(long ms) {
- return pausedReason == null && "connected".equals(state) && decoder.renderedWithin(ms);
+ long window = keyFramesOnly ? Math.max(ms, KEY_FRAMES_LIVE_MS) : ms;
+ return pausedReason == null && "connected".equals(state) && decoder.renderedWithin(window);
+ }
+
+ /** Connected and decoding: its load is real, so demoting or pausing it helps (#85). */
+ boolean isPlaying() {
+ return pausedReason == null && "connected".equals(state) && decoder.hasDecoder();
}
/** Playing long enough for its decode rate to mean something (#83). */
@@ -349,6 +417,11 @@ public boolean onPacket(int channel, byte[] buf, int offset, int length) {
private void decode(Frame buffer) {
if (buffer.length() < 5) return;
+ if (keyFramesOnly || waitingKeyFrame) {
+ int type = NalAssembler.fragment(buffer.data()[4], codecH265);
+ if (!isKeyNal(type, codecH265)) return;
+ if (!keyFramesOnly && isKeyFrameNal(type, codecH265)) waitingKeyFrame = false;
+ }
if (decoder.decodeFrame(buffer, codecH265, 0)) {
Surface s = surface;
if (s == null || !s.isValid()) return;
diff --git a/android/app/src/main/res/layout/decoder.xml b/android/app/src/main/res/layout/decoder.xml
index d18931e4..1a0236d3 100755
--- a/android/app/src/main/res/layout/decoder.xml
+++ b/android/app/src/main/res/layout/decoder.xml
@@ -22,4 +22,17 @@
android:textSize="14sp"
android:visibility="gone" />
+
+
+
diff --git a/android/app/src/test/java/com/openipc/decoder/CellStatsTest.java b/android/app/src/test/java/com/openipc/decoder/CellStatsTest.java
index d2f73cd6..c476896c 100644
--- a/android/app/src/test/java/com/openipc/decoder/CellStatsTest.java
+++ b/android/app/src/test/java/com/openipc/decoder/CellStatsTest.java
@@ -54,11 +54,33 @@ public void playingShowsCodecAndRates() {
QuadCell c = cell();
c.onFailure("ECONNREFUSED");
c.onStatus("connected");
- // A working connection clears the connection error; no frames yet
- assertEquals("H.264 0/0 fps", c.sample(1000));
+ // A working connection clears the connection error; a new cell starts on key frames (#85)
+ assertEquals("H.264 key frames · 0.0 fps", c.sample(1000));
+ assertTrue(c.needsAttention());
+ c.setKeyFramesOnly(false);
+ assertEquals("H.264 0/0 fps", c.sample(2000));
+ assertFalse("playing at full rate: no text without D", c.needsAttention());
assertFalse(c.isPaused());
}
+ @Test
+ public void keyNalUnits() {
+ // H.264: IDR, SPS, PPS pass; non-IDR slice and SEI do not
+ assertTrue(QuadCell.isKeyNal(5, false));
+ assertTrue(QuadCell.isKeyNal(7, false));
+ assertTrue(QuadCell.isKeyNal(8, false));
+ assertFalse(QuadCell.isKeyNal(1, false));
+ assertFalse(QuadCell.isKeyNal(6, false));
+ // H.265: IRAP (16-21), VPS/SPS/PPS pass; TRAIL_R and SEI do not
+ assertTrue(QuadCell.isKeyNal(19, true));
+ assertTrue(QuadCell.isKeyNal(32, true));
+ assertTrue(QuadCell.isKeyNal(34, true));
+ assertFalse(QuadCell.isKeyNal(1, true));
+ assertFalse(QuadCell.isKeyNal(39, true));
+ assertTrue(QuadCell.isKeyFrameNal(5, false));
+ assertFalse(QuadCell.isKeyFrameNal(7, false));
+ }
+
@Test
public void pausedReasonWins() {
QuadCell c = cell();
@@ -80,8 +102,23 @@ public void instanceBudgetHoldsTheLearntLimit() {
MediaCodecManager.InstanceBudget.tryAcquire(hevc));
MediaCodecManager.InstanceBudget.release(hevc);
assertTrue(MediaCodecManager.InstanceBudget.tryAcquire(hevc));
- // Other codecs are counted separately
+ // Other codecs are counted separately; while a limit is unknown, one at a time
assertTrue(MediaCodecManager.InstanceBudget.tryAcquire("video/avc"));
assertEquals(-1, MediaCodecManager.InstanceBudget.limit("video/avc"));
+ assertFalse("second decoder waits until the first has told its limit",
+ MediaCodecManager.InstanceBudget.tryAcquire("video/avc"));
+ MediaCodecManager.InstanceBudget.learn("video/avc", 16);
+ assertTrue(MediaCodecManager.InstanceBudget.tryAcquire("video/avc"));
+ }
+
+ @Test
+ public void decoderLimitIsNotHiddenByAConnectionError() {
+ QuadCell c = cell();
+ c.onStatus("connected");
+ c.setKeyFramesOnly(false);
+ c.onFailure("ECONNREFUSED");
+ assertEquals("Connection refused", c.sample(1000));
+ c.onStatus("connected");
+ assertEquals("H.264 0/0 fps", c.sample(2000));
}
}
diff --git a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java
new file mode 100644
index 00000000..ba9f84f4
--- /dev/null
+++ b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java
@@ -0,0 +1,297 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * DecodePlannerTest.java — full rate or key frames per multi-view cell (#85)
+ *
+ */
+
+package com.openipc.decoder;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class DecodePlannerTest {
+ /** 1080p at 30 fps: 120 x 68 blocks x 30. */
+ private static final long P1080_30 = DecodePlanner.fullLoad(1920, 1080, 30);
+ /** The AG-216's declared budget: 1080p at 60 fps. */
+ private static final long BUDGET = DecodePlanner.fullLoad(1920, 1080, 60);
+
+ private static List keyCells(int n, long load) {
+ List cells = new ArrayList<>();
+ for (int i = 0; i < n; i++) cells.add(new DecodePlanner.Cell(i, false, true, load, false));
+ return cells;
+ }
+
+ private static void apply(List cells, DecodePlanner.Decision d) {
+ for (DecodePlanner.Cell c : cells) {
+ if (c.index != d.index) continue;
+ if (d.action == DecodePlanner.Action.PROMOTE) c.full = true;
+ if (d.action == DecodePlanner.Action.DEMOTE) c.full = false;
+ if (d.action == DecodePlanner.Action.PAUSE) c.paused = true;
+ }
+ }
+
+ private static int full(List cells) {
+ int n = 0;
+ for (DecodePlanner.Cell c : cells) if (c.full) n++;
+ return n;
+ }
+
+ @Test
+ public void loadIsBlocksTimesFps() {
+ assertEquals(120L * 68 * 30, P1080_30);
+ }
+
+ @Test
+ public void promotesInPriorityOrderUpToTheBudget() {
+ DecodePlanner p = new DecodePlanner(BUDGET, false);
+ List cells = keyCells(8, P1080_30);
+ long t = 0;
+ for (int i = 0; i < 20; i++, t += 1000) apply(cells, p.step(cells, t));
+ assertEquals("two 1080p30 cameras fit 1080p60", 2, full(cells));
+ assertTrue(cells.get(0).full && cells.get(1).full);
+ }
+
+ @Test
+ public void lossesDemoteTheLowestPriorityFullCellAndLowerTheCapacity() {
+ DecodePlanner p = new DecodePlanner(BUDGET * 2, false);
+ List cells = keyCells(4, P1080_30);
+ long t = 0;
+ for (int i = 0; i < 20; i++, t += 1000) apply(cells, p.step(cells, t));
+ assertEquals(4, full(cells));
+ // The decoder starts losing frames
+ cells.get(0).lossy = true;
+ DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
+ for (int i = 0; i < DecodePlanner.LOSS_SAMPLES; i++, t += 1000) d = p.step(cells, t);
+ assertEquals(DecodePlanner.Action.DEMOTE, d.action);
+ assertEquals(3, d.index);
+ assertTrue(p.isLearnt());
+ assertEquals(Math.round(4 * P1080_30 * 0.9), p.capacity());
+ }
+
+ @Test
+ public void keyFrameLossesWithNothingToDemotePauseAsLastResort() {
+ DecodePlanner p = new DecodePlanner(1, false); // nothing fits: every cell stays on key frames
+ List cells = keyCells(3, P1080_30);
+ for (DecodePlanner.Cell c : cells) c.lossy = true;
+ DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
+ long t = 0;
+ for (int i = 0; i < DecodePlanner.LOSS_SAMPLES; i++, t += 1000) d = p.step(cells, t);
+ assertEquals(DecodePlanner.Action.PAUSE, d.action);
+ assertEquals(2, d.index);
+ }
+
+ @Test
+ public void aQuietLoadIsProvenAndAProbeMayExceedTheCapacity() {
+ DecodePlanner p = new DecodePlanner(P1080_30, false); // one camera fits
+ List cells = keyCells(2, P1080_30);
+ long t = 0;
+ apply(cells, p.step(cells, t));
+ assertEquals(1, full(cells));
+ DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
+ for (t = 1000; t <= DecodePlanner.PROBE_QUIET_MS + 1000 && d.action == DecodePlanner.Action.NONE; t += 1000) {
+ d = p.step(cells, t);
+ }
+ assertEquals("after a quiet period the second camera is tried", DecodePlanner.Action.PROMOTE, d.action);
+ apply(cells, d);
+ // It plays without losses for another quiet period: the capacity is raised to that load
+ for (long end = t + DecodePlanner.PROBE_QUIET_MS + 1000; t <= end; t += 1000) p.step(cells, t);
+ assertEquals(2 * P1080_30, p.capacity());
+ assertTrue(p.isLearnt());
+ }
+
+ @Test
+ public void aLoadThatFailedIsNotProbedAgain() {
+ DecodePlanner p = new DecodePlanner(P1080_30, false);
+ List cells = keyCells(2, P1080_30);
+ long t = 0;
+ apply(cells, p.step(cells, t));
+ DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
+ for (t = 1000; d.action == DecodePlanner.Action.NONE && t < 40_000; t += 1000) d = p.step(cells, t);
+ apply(cells, d);
+ assertEquals(2, full(cells));
+ // The probe makes the decoder lose frames: the second camera goes back to key frames
+ cells.get(0).lossy = true;
+ d = DecodePlanner.Decision.NONE;
+ for (; d.action != DecodePlanner.Action.DEMOTE && t < 60_000; t += 1000) {
+ d = p.step(cells, t);
+ apply(cells, d);
+ }
+ assertEquals(1, d.index);
+ assertEquals(1, full(cells));
+ // Demoting it was enough: the losses stop
+ cells.get(0).lossy = false;
+ // A long quiet period later the same load is not tried again
+ for (long end = t + 5 * DecodePlanner.PROBE_QUIET_MS; t < end; t += 1000) {
+ assertEquals(DecodePlanner.Action.NONE, p.step(cells, t).action);
+ }
+ }
+
+ @Test
+ public void keyFrameCellsCountInTheLoad() {
+ DecodePlanner p = new DecodePlanner(BUDGET, false);
+ List cells = keyCells(3, P1080_30);
+ // Each key-frame cell costs a quarter of a 1080p30 stream: two full ones no longer fit
+ for (DecodePlanner.Cell c : cells) c.keyLoad = P1080_30 / 4;
+ long t = 0;
+ for (int i = 0; i < 10; i++, t += 1000) apply(cells, p.step(cells, t));
+ assertEquals(1, full(cells));
+ }
+
+ @Test
+ public void aFailureNeverRaisesTheCapacity() {
+ DecodePlanner p = new DecodePlanner(P1080_30, false);
+ List cells = keyCells(1, P1080_30);
+ long t = 0;
+ apply(cells, p.step(cells, t));
+ // Losses at a load estimated higher than the capacity: capacity stays
+ cells.get(0).fullLoad = 3 * P1080_30;
+ cells.get(0).lossy = true;
+ for (int i = 0; i < 6; i++, t += 1000) apply(cells, p.step(cells, t));
+ assertTrue(p.capacity() <= P1080_30);
+ }
+
+ @Test
+ public void aSlightlyLowerEstimateOfAFailedLoadIsNotProbed() {
+ DecodePlanner p = new DecodePlanner(P1080_30, false);
+ List cells = keyCells(2, P1080_30);
+ long t = 0;
+ apply(cells, p.step(cells, t));
+ DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
+ for (t = 1000; d.action == DecodePlanner.Action.NONE && t < 40_000; t += 1000) d = p.step(cells, t);
+ apply(cells, d);
+ cells.get(0).lossy = true;
+ for (d = DecodePlanner.Decision.NONE; d.action != DecodePlanner.Action.DEMOTE; t += 1000) {
+ d = p.step(cells, t);
+ apply(cells, d);
+ }
+ cells.get(0).lossy = false;
+ // The estimate of the same configuration comes out 5 % lower: still not tried
+ cells.get(1).fullLoad = Math.round(P1080_30 * 0.95);
+ for (long end = t + 5 * DecodePlanner.PROBE_QUIET_MS; t < end; t += 1000) {
+ assertEquals(DecodePlanner.Action.NONE, p.step(cells, t).action);
+ }
+ }
+
+ @Test
+ public void waitsForTheLoadOfAHigherPriorityCell() {
+ DecodePlanner p = new DecodePlanner(BUDGET, false);
+ List cells = keyCells(2, P1080_30);
+ cells.get(0).known = false;
+ assertEquals(DecodePlanner.Action.NONE, p.step(cells, 0).action);
+ }
+
+ @Test
+ public void declaredBudgetIsIgnoredOnceLearnt() {
+ DecodePlanner p = new DecodePlanner(1000, true);
+ p.offerDeclared(5000);
+ assertEquals(1000, p.capacity());
+ DecodePlanner q = new DecodePlanner(1000, false);
+ q.offerDeclared(5000);
+ assertEquals(5000, q.capacity());
+ }
+
+ @Test
+ public void anOfflineCameraDoesNotHoldTheLowerCellsBack() {
+ DecodePlanner p = new DecodePlanner(BUDGET, false);
+ List cells = keyCells(3, P1080_30);
+ cells.get(0).playing = false;
+ cells.get(0).known = false;
+ long t = 0;
+ for (int i = 0; i < 10; i++, t += 1000) apply(cells, p.step(cells, t));
+ assertTrue(cells.get(1).full && cells.get(2).full);
+ }
+
+ @Test
+ public void aPlayingCellWithAnUnknownLoadIsWaitedForOnlyAWhile() {
+ DecodePlanner p = new DecodePlanner(BUDGET, false);
+ List cells = keyCells(2, P1080_30);
+ cells.get(0).known = false;
+ long t = 0;
+ for (; t < DecodePlanner.UNKNOWN_WAIT_MS; t += 1000) {
+ assertEquals(DecodePlanner.Action.NONE, p.step(cells, t).action);
+ }
+ DecodePlanner.Decision d = p.step(cells, t);
+ assertEquals(DecodePlanner.Action.PROMOTE, d.action);
+ assertEquals(1, d.index);
+ }
+
+ @Test
+ public void aPromotionWithinTheCapacityStaysBelowAFailedLoad() {
+ // Learnt capacity of three cameras, but two already failed (stored with it)
+ DecodePlanner p = new DecodePlanner(3 * P1080_30, true, 2 * P1080_30);
+ List cells = keyCells(3, P1080_30);
+ long t = 0;
+ for (int i = 0; i < 20; i++, t += 1000) apply(cells, p.step(cells, t));
+ assertEquals(1, full(cells));
+ assertEquals(2 * P1080_30, p.failedLoad());
+ }
+
+ @Test
+ public void aDemotionStoresTheFailedLoad() {
+ DecodePlanner p = new DecodePlanner(BUDGET, false);
+ List cells = keyCells(2, P1080_30);
+ long t = 0;
+ for (int i = 0; i < 10; i++, t += 1000) apply(cells, p.step(cells, t));
+ assertEquals(0, p.failedLoad());
+ cells.get(0).lossy = true;
+ for (int i = 0; i < DecodePlanner.LOSS_SAMPLES; i++, t += 1000) apply(cells, p.step(cells, t));
+ assertEquals(2 * P1080_30, p.failedLoad());
+ assertEquals(1, full(cells));
+ }
+
+ @Test
+ public void theLastPlayingCellIsNeverPaused() {
+ DecodePlanner p = new DecodePlanner(1, false);
+ List cells = keyCells(2, P1080_30);
+ cells.get(1).playing = false; // offline
+ cells.get(0).lossy = true;
+ long t = 0;
+ for (int i = 0; i < 10; i++, t += 1000) {
+ assertEquals(DecodePlanner.Action.NONE, p.step(cells, t).action);
+ }
+ }
+
+ @Test
+ public void sparseLossesOfKeyFrameCellsAreDetected() {
+ DecodePlanner p = new DecodePlanner(1, false);
+ List cells = keyCells(2, P1080_30);
+ DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
+ // One lost key frame every three seconds: never two lossy samples in a row
+ for (long t = 0; t < DecodePlanner.LOSS_WINDOW_MS && d.action == DecodePlanner.Action.NONE;
+ t += 1000) {
+ cells.get(0).lossy = t % 3000 == 0;
+ d = p.step(cells, t);
+ }
+ assertEquals(DecodePlanner.Action.PAUSE, d.action);
+ assertEquals(1, d.index);
+ }
+
+ @Test
+ public void aLoadJustReachedIsNotProvenAndTheCellIsNotPromotedAgain() {
+ // Qodo on #86: a fitting promotion after a long quiet period, then losses at that load
+ DecodePlanner p = new DecodePlanner(2 * P1080_30, false);
+ List cells = keyCells(2, P1080_30);
+ long t = 0;
+ apply(cells, p.step(cells, t));
+ for (t = 1000; t <= DecodePlanner.PROBE_QUIET_MS + 1000; t += 1000) apply(cells, p.step(cells, t));
+ assertEquals(2, full(cells));
+ // The second camera makes the decoder lose frames
+ cells.get(0).lossy = true;
+ DecodePlanner.Decision d = DecodePlanner.Decision.NONE;
+ for (; d.action != DecodePlanner.Action.DEMOTE; t += 1000) {
+ d = p.step(cells, t);
+ apply(cells, d);
+ }
+ cells.get(0).lossy = false;
+ assertTrue("capacity below the failing load", p.capacity() < 2 * P1080_30);
+ for (long end = t + 3 * DecodePlanner.PROBE_QUIET_MS; t < end; t += 1000) {
+ assertEquals(DecodePlanner.Action.NONE, p.step(cells, t).action);
+ }
+ }
+}
| |