From 31d81b9a2fde1b514fe15cd6586f7dd5e6ba7fed Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 14:22:16 +0000 Subject: [PATCH 1/4] feat: key-frame cells beyond the hardware decoder limit use a software decoder instead of 'Decoder limit' (#88) --- .../com/openipc/decoder/DecodePlanner.java | 4 +- .../java/com/openipc/decoder/Decoder.java | 1 + .../openipc/decoder/MediaCodecManager.java | 81 ++++++++++++++++--- .../java/com/openipc/decoder/QuadCell.java | 9 ++- .../openipc/decoder/DecodePlannerTest.java | 11 +++ 5 files changed, 91 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java index 25a7e78b..b6472d7c 100644 --- a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java +++ b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java @@ -58,6 +58,8 @@ static final class Cell { boolean lossy; /** Connected and decoding: only such cells are demoted or paused (Qodo on #84). */ boolean playing = true; + /** On a software decoder beyond the hardware limit: never promoted (#88). */ + boolean pinned; Cell(int index, boolean full, boolean known, long fullLoad, boolean lossy) { this.index = index; @@ -206,7 +208,7 @@ Decision step(List cells, long nowMs) { // Promote the highest-priority key-frame cell whose load is known for (Cell c : cells) { - if (c.full || c.paused || !c.playing) { + if (c.full || c.paused || !c.playing || c.pinned) { // Not connected (an offline camera) or paused: nothing to wait for unknownSince.remove(c.index); continue; 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 caa5836a..ffab4504 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1159,6 +1159,7 @@ private static DecodePlanner.Cell plannerCell(int index, QuadCell c, long now) { c.knowsLoad(), c.knowsLoad() ? c.fullLoad() : 0, lossy); pc.paused = c.isPaused(); pc.playing = c.isPlaying(); + pc.pinned = c.isSoftwareDecoder(); if (c.knowsLoad()) pc.keyLoad = c.keyLoad(); return pc; } 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 cab2808b..0e23f857 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -69,6 +69,14 @@ class MediaCodecManager { private volatile int outHeight; /** MIME type whose instance slot this manager holds while it has a decoder (#83). */ private String heldMime; + /** + * Beyond the hardware instance limit, a software decoder may be used (#88): set while the + * cell decodes key frames only, about one frame per second, which a software decoder + * manages at little CPU cost. + */ + private volatile boolean allowSoftware; + private volatile boolean softwareInUse; + private static final java.util.Map SOFTWARE = new java.util.HashMap<>(); interface Listener { void onResolutionChanged(int width, int height); @@ -95,6 +103,40 @@ boolean renderedWithin(long ms) { * learnt from the first decoder of a type, so it is the codec that createDecoderByType * really picks (#83). */ + /** Allow a software decoder beyond the hardware limit (key-frame cells, #88). */ + void setSoftwareFallback(boolean allow) { allowSoftware = allow; } + + /** The current decoder is a software one (it is never promoted to full rate). */ + boolean isSoftware() { return softwareInUse && decoder != null; } + + /** A software decoder for the mime type (c2.android.* first), or null; looked up once. */ + static String softwareDecoder(String mime) { + synchronized (SOFTWARE) { + if (SOFTWARE.containsKey(mime)) return SOFTWARE.get(mime); + String found = null; + try { + for (MediaCodecInfo ci : new android.media.MediaCodecList( + android.media.MediaCodecList.ALL_CODECS).getCodecInfos()) { + if (ci.isEncoder()) continue; + String n = ci.getName(); + boolean sw = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? ci.isSoftwareOnly() + : n.startsWith("c2.android.") || n.startsWith("OMX.google."); + if (!sw) continue; + for (String t : ci.getSupportedTypes()) { + if (t.equalsIgnoreCase(mime) + && (found == null || n.startsWith("c2.android."))) { + found = n; + } + } + } + } catch (RuntimeException ignored) { + } + SOFTWARE.put(mime, found); + return found; + } + } + static final class InstanceBudget { private static final Map used = new HashMap<>(); private static final Map limit = new HashMap<>(); @@ -276,23 +318,34 @@ void createDecoder(boolean codecH265) { // Must hold the largest NAL unit the assembler can deliver format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_NAL_SIZE); + String softwareName = null; if (!InstanceBudget.tryAcquire(type)) { int limit = InstanceBudget.limit(type); - synchronized (decoderLock) { - retry.onFailure(nowMs()); + softwareName = limit >= 0 && allowSoftware ? softwareDecoder(type) : null; + if (softwareName == null) { + 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; } - // 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; } + final boolean software = softwareName != null; MediaCodec local; try { - Log.i(TAG, "Start video decoder (" + type + ")"); - local = MediaCodec.createDecoderByType(type); - learnLimits(local, type); + if (software) { + Log.i(TAG, "Start software video decoder " + softwareName + + " (hardware limit reached, key frames only)"); + local = MediaCodec.createByCodecName(softwareName); + } else { + Log.i(TAG, "Start video decoder (" + type + ")"); + local = MediaCodec.createDecoderByType(type); + learnLimits(local, type); + } try { local.configure(format, s, null, 0); local.start(); @@ -302,7 +355,7 @@ void createDecoder(boolean codecH265) { throw e; } } catch (Exception e) { - InstanceBudget.release(type); + if (!software) InstanceBudget.release(type); synchronized (decoderLock) { // A creation cancelled by a pause must not delay the next start if (gen != lifecycle || !enabled) return; @@ -316,12 +369,14 @@ void createDecoder(boolean codecH265) { synchronized (decoderLock) { // Disabled meanwhile (pause/stop) or another thread was faster if (decoder != null || !enabled || gen != lifecycle) { - InstanceBudget.release(type); + if (!software) InstanceBudget.release(type); releaseAsync(local); return; } decoder = local; - heldMime = type; + // A software decoder holds no slot of the hardware budget + heldMime = software ? null : type; + softwareInUse = software; awaitingConfig = true; configSeen = 0; } 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 bc610a9e..287ff8d0 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -141,6 +141,7 @@ void setAudioSink(AudioManager sink, int route) { } void start() { + decoder.setSoftwareFallback(keyFramesOnly); if (started) return; started = true; running = true; @@ -300,15 +301,21 @@ String sample(long nowMs) { 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 + " key frames · " + rate + " fps" + + (decoder.isSoftware() ? " · software" : ""); } return size + codec + " " + Math.round(outFps) + "/" + Math.round(inFps) + " fps"; } boolean isKeyFramesOnly() { return keyFramesOnly; } + /** Decoded by a software decoder beyond the hardware limit: stays on key frames (#88). */ + boolean isSoftwareDecoder() { return decoder.isSoftware(); } + /** Full rate or key frames only (#85). Going to full rate waits for the next key frame. */ void setKeyFramesOnly(boolean on) { + // Key frames only may run on a software decoder beyond the hardware limit (#88) + decoder.setSoftwareFallback(on); if (on == keyFramesOnly) return; if (!on) waitingKeyFrame = true; keyFramesOnly = on; diff --git a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java index 389412f1..2cf01d66 100644 --- a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java @@ -329,4 +329,15 @@ public void aDeclaredBudgetBelowTheFloorIsRaised() { p.offerDeclared(P1080_30 / 3); assertEquals(P1080_30, p.capacity()); } + + @Test + public void aCellOnASoftwareDecoderIsNeverPromoted() { + DecodePlanner p = new DecodePlanner(BUDGET, false); + List cells = keyCells(2, P1080_30); + cells.get(0).pinned = true; // over the hardware limit (#88) + long t = 0; + for (int i = 0; i < 10; i++, t += 1000) apply(cells, p.step(cells, t)); + assertTrue(!cells.get(0).full); + assertTrue("the next cell is not held back", cells.get(1).full); + } } From 4c5bc264da7af59ab9cf8326ff9cf32a239baa20 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 14:28:29 +0000 Subject: [PATCH 2/4] fix: a software cell's losses do not pause hardware cells (#88) --- .../app/src/main/java/com/openipc/decoder/Decoder.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 ffab4504..0ef72ce3 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1153,14 +1153,16 @@ private static int countFullRate(QuadCell[] cells) { 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 + // A software decoder's losses and load say nothing of the hardware's means (#88) + boolean software = c.isSoftwareDecoder(); + boolean lossy = !software && 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(); - pc.pinned = c.isSoftwareDecoder(); - if (c.knowsLoad()) pc.keyLoad = c.keyLoad(); + pc.pinned = software; + if (c.knowsLoad() && !software) pc.keyLoad = c.keyLoad(); return pc; } From d709c5d5673bc84918e85bc3181db9eccfff32b4 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 14:29:28 +0000 Subject: [PATCH 3/4] fix: software decoders get their own instance limit, stay out of hardware planning (#88, Qodo) --- .../com/openipc/decoder/DecodePlanner.java | 7 ++- .../java/com/openipc/decoder/Decoder.java | 8 ++- .../openipc/decoder/MediaCodecManager.java | 53 ++++++++++++++----- .../openipc/decoder/DecodePlannerTest.java | 15 ++++++ 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java index b6472d7c..7494f178 100644 --- a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java +++ b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java @@ -152,7 +152,8 @@ Decision step(List cells, long nowMs) { long load = 0; boolean anyLossy = false; for (Cell c : cells) { - if (c.paused) continue; + // A software cell neither loads the hardware decoder nor shows its limits (#88) + if (c.paused || c.pinned) continue; if (c.full && c.known) load += c.fullLoad; else if (!c.full) load += c.keyLoad; anyLossy |= c.lossy; @@ -200,7 +201,9 @@ Decision step(List cells, long nowMs) { 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); + if (!c.paused && c.playing && !c.pinned) { + return change(nowMs, Action.PAUSE, c.index); + } } return Decision.NONE; } 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 0ef72ce3..ffab4504 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1153,16 +1153,14 @@ private static int countFullRate(QuadCell[] cells) { 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 - // A software decoder's losses and load say nothing of the hardware's means (#88) - boolean software = c.isSoftwareDecoder(); - boolean lossy = !software && c.isSettled(now) && (c.isKeyFramesOnly() ? c.lostFps() > 0 + 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(); - pc.pinned = software; - if (c.knowsLoad() && !software) pc.keyLoad = c.keyLoad(); + pc.pinned = c.isSoftwareDecoder(); + if (c.knowsLoad()) pc.keyLoad = c.keyLoad(); return pc; } 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 0e23f857..a19301b0 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -109,14 +109,29 @@ boolean renderedWithin(long ms) { /** The current decoder is a software one (it is never promoted to full rate). */ boolean isSoftware() { return softwareInUse && decoder != null; } - /** A software decoder for the mime type (c2.android.* first), or null; looked up once. */ + /** Budget key of the software decoders of a mime type: they have their own limit. */ + static String softwareSlot(String mime) { return "software:" + mime; } + + /** + * A software decoder for the mime type (c2.android.* first), or null. Looked up once, with + * its instance limit; a failed codec list is not cached, so a later call tries again. + */ static String softwareDecoder(String mime) { synchronized (SOFTWARE) { if (SOFTWARE.containsKey(mime)) return SOFTWARE.get(mime); - String found = null; + MediaCodecInfo[] infos; try { - for (MediaCodecInfo ci : new android.media.MediaCodecList( - android.media.MediaCodecList.ALL_CODECS).getCodecInfos()) { + infos = new android.media.MediaCodecList( + android.media.MediaCodecList.ALL_CODECS).getCodecInfos(); + } catch (RuntimeException e) { + Log.w(TAG, "Codec list unavailable: " + e.getMessage()); + return null; + } + String found = null; + int max = Integer.MAX_VALUE; + for (MediaCodecInfo ci : infos) { + // One broken entry must not hide the others + try { if (ci.isEncoder()) continue; String n = ci.getName(); boolean sw = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q @@ -124,13 +139,20 @@ static String softwareDecoder(String mime) { : n.startsWith("c2.android.") || n.startsWith("OMX.google."); if (!sw) continue; for (String t : ci.getSupportedTypes()) { - if (t.equalsIgnoreCase(mime) - && (found == null || n.startsWith("c2.android."))) { - found = n; - } + if (!t.equalsIgnoreCase(mime) + || (found != null && !n.startsWith("c2.android."))) continue; + found = n; + max = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + ? ci.getCapabilitiesForType(t).getMaxSupportedInstances() + : Integer.MAX_VALUE; } + } catch (RuntimeException ignored) { } - } catch (RuntimeException ignored) { + } + if (found != null) { + InstanceBudget.learn(softwareSlot(mime), max > 0 ? max : Integer.MAX_VALUE); + Log.i(TAG, "Software decoder " + found + " (" + mime + "): up to " + max + + " instances"); } SOFTWARE.put(mime, found); return found; @@ -322,6 +344,10 @@ void createDecoder(boolean codecH265) { if (!InstanceBudget.tryAcquire(type)) { int limit = InstanceBudget.limit(type); softwareName = limit >= 0 && allowSoftware ? softwareDecoder(type) : null; + // Software decoders have their own limit: beyond it, the hardware one is reported + if (softwareName != null && !InstanceBudget.tryAcquire(softwareSlot(type))) { + softwareName = null; + } if (softwareName == null) { synchronized (decoderLock) { retry.onFailure(nowMs()); @@ -334,6 +360,7 @@ void createDecoder(boolean codecH265) { } } final boolean software = softwareName != null; + final String slot = software ? softwareSlot(type) : type; MediaCodec local; try { @@ -355,7 +382,7 @@ void createDecoder(boolean codecH265) { throw e; } } catch (Exception e) { - if (!software) InstanceBudget.release(type); + InstanceBudget.release(slot); synchronized (decoderLock) { // A creation cancelled by a pause must not delay the next start if (gen != lifecycle || !enabled) return; @@ -369,13 +396,13 @@ void createDecoder(boolean codecH265) { synchronized (decoderLock) { // Disabled meanwhile (pause/stop) or another thread was faster if (decoder != null || !enabled || gen != lifecycle) { - if (!software) InstanceBudget.release(type); + InstanceBudget.release(slot); releaseAsync(local); return; } decoder = local; - // A software decoder holds no slot of the hardware budget - heldMime = software ? null : type; + // A software decoder holds a slot of its own budget, not of the hardware one + heldMime = slot; softwareInUse = software; awaitingConfig = true; configSeen = 0; diff --git a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java index 2cf01d66..70eafd98 100644 --- a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java @@ -340,4 +340,19 @@ public void aCellOnASoftwareDecoderIsNeverPromoted() { assertTrue(!cells.get(0).full); assertTrue("the next cell is not held back", cells.get(1).full); } + + @Test + public void aSoftwareCellNeitherLoadsNorPausesTheHardware() { + DecodePlanner p = new DecodePlanner(P1080_30, false, 0, P1080_30); + List cells = keyCells(2, P1080_30); + DecodePlanner.Cell sw = cells.get(0); + sw.pinned = true; + sw.keyLoad = P1080_30 / 2; + sw.lossy = true; // the software decoder drops frames: not a hardware overload + long t = 0; + for (int i = 0; i < 20; i++, t += 1000) apply(cells, p.step(cells, t)); + assertTrue("the hardware cell fits without the software load", cells.get(1).full); + assertTrue(!cells.get(0).paused && !cells.get(1).paused); + assertEquals(P1080_30, p.capacity()); + } } From ed0fde4b1a216a228cfd7ea4d17434d3d4ed88a4 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 14:49:15 +0000 Subject: [PATCH 4/4] fix: read a software codec's limit before choosing it (#88, Qodo) --- .../main/java/com/openipc/decoder/MediaCodecManager.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 a19301b0..1f2026d0 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -141,10 +141,12 @@ static String softwareDecoder(String mime) { for (String t : ci.getSupportedTypes()) { if (!t.equalsIgnoreCase(mime) || (found != null && !n.startsWith("c2.android."))) continue; - found = n; - max = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + // The limit first: a failed query keeps the previous candidate + int m = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? ci.getCapabilitiesForType(t).getMaxSupportedInstances() : Integer.MAX_VALUE; + found = n; + max = m; } } catch (RuntimeException ignored) { }