From 55e5d7fd20a1050dcc99cf8f29a0794b5e22dc83 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 13:54:27 +0000 Subject: [PATCH 1/4] feat: MJPEG through the device's hardware JPEG decoder (MediaCodec video/jpeg), BitmapFactory as the fallback (#132) --- .../java/com/openipc/decoder/Decoder.java | 37 ++++- .../com/openipc/decoder/JpegAssembler.java | 12 ++ .../java/com/openipc/decoder/JpegCodec.java | 131 ++++++++++++++++++ .../openipc/decoder/JpegAssemblerTest.java | 9 ++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/JpegCodec.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 0373d096..3378faed 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -198,9 +198,13 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { audioCodec = audio.codec; boolean jpeg = rtspClient.isJpeg(); if (jpeg) { - // MJPEG is drawn by the app (#119): no MediaCodec may hold the surface + // MJPEG has its own decoder or is drawn by the app (#119, #132): the + // video decoder must not hold the surface closeDecoder(); - } else if (jpegDrawn) { + } else { + jpegCodec.release(); + } + if (!jpeg && jpegDrawn) { // A surface drawn with lockCanvas() cannot take a decoder any more: no // decoder until the UI thread has put a new one in place (Qodo on #131) jpegDrawn = false; @@ -1566,6 +1570,8 @@ private void resetZoom() { } private void replaceSurface(Surface next) { + // The MJPEG decoder renders into the old surface: let go of it first (#132) + jpegCodec.release(); Surface prev = mVideoSurface; mVideoSurface = next; videoDecoder.setSurface(next); @@ -1582,6 +1588,10 @@ private int dp(float dp) { // ====================== MJPEG (#119) ====================== + /** Hardware MJPEG decoding (#132), unless it failed once in this run. */ + private final JpegCodec jpegCodec = new JpegCodec(); + private volatile boolean hwJpegFailed; + /** Something was drawn into the video surface with lockCanvas(). */ private volatile boolean jpegDrawn; /** A new video surface is being made after MJPEG: no decoder until it is there. */ @@ -1600,6 +1610,23 @@ private int dp(float dp) { private void drawJpeg(Frame frame) { Surface s = mVideoSurface; if (s == null || !s.isValid()) return; + // The hardware JPEG decoder first, where there is one (#132) + if (!hwJpegFailed && !jpegDrawn && JpegCodec.find() != null) { + int[] size = JpegAssembler.size(frame.data(), frame.length()); + if (size != null) { + try { + if (jpegCodec.decode(frame, s, size[0], size[1])) { + jpegShown(size[0], size[1]); + } + return; + } catch (Exception e) { + Log.w(TAG, "Hardware MJPEG decoder failed, decoding in software: " + + e.getMessage()); + jpegCodec.release(); + hwJpegFailed = true; + } + } + } BitmapFactory.Options o = new BitmapFactory.Options(); o.inMutable = true; Bitmap reuse = jpegBitmap; @@ -1629,6 +1656,11 @@ private void drawJpeg(Frame frame) { return; } jpegDrawn = true; + jpegShown(w, h); + } + + /** A JPEG frame is on screen (hardware or software path). */ + private void jpegShown(int w, int h) { jpegFramesOut++; boolean first = lastJpegFrameMs == 0; lastJpegFrameMs = SystemClock.elapsedRealtime(); @@ -1749,6 +1781,7 @@ public void run() { private void stopStreams() { lastJpegFrameMs = 0; + jpegCodec.release(); mSurface.removeCallbacks(singleUdpCheck); // The single view's stream ends here: it is no longer "connected" for the debug page singleState = "disconnected"; diff --git a/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java b/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java index 3318f974..f3133fa7 100644 --- a/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java +++ b/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java @@ -220,6 +220,18 @@ private void drop(String reason) { } } + /** {width, height} from the SOF0 header of a JPEG, or null (#132). */ + static int[] size(byte[] d, int length) { + for (int i = 2; i + 8 < length; i++) { + if ((d[i] & 0xFF) == 0xFF && (d[i + 1] & 0xFF) == 0xC0) { + int h = ((d[i + 5] & 0xFF) << 8) | (d[i + 6] & 0xFF); + int w = ((d[i + 7] & 0xFF) << 8) | (d[i + 8] & 0xFF); + return new int[] {w, h}; + } + } + return null; + } + /** Quantization tables for Q 1..99 (RFC 2435 Appendix A), luminance then chrominance. */ static byte[] tables(int q) { q = Math.max(1, Math.min(99, q)); diff --git a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java new file mode 100644 index 00000000..7e80cb25 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * JpegCodec.java — hardware MJPEG decoding through MediaCodec, where the device has it (#132) + * + */ + +package com.openipc.decoder; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; +import android.util.Log; +import android.view.Surface; + +import java.nio.ByteBuffer; + +/** + * A JPEG decoder from the device's codec list ({@code video/jpeg} on Allwinner boxes such as + * the AG-216, {@code video/mjpeg} elsewhere) fed with the whole frames of JpegAssembler and + * rendering to the video surface like the H.264/H.265 decoder. The caller falls back to + * BitmapFactory when there is none or it fails. Used by one decode thread; release() may come + * from others, hence synchronized. + */ +final class JpegCodec { + private static final String TAG = "OpenIPCDecoder"; + private static final String[] MIMES = {"video/jpeg", "video/mjpeg", "video/x-motion-jpeg"}; + private static volatile String[] found; + + private MediaCodec codec; + private Surface surface; + private int width; + private int height; + private final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + + /** {name, mime} of a hardware-or-vendor JPEG decoder, or null; looked up once. */ + static String[] find() { + String[] f = found; + if (f != null) return f.length == 0 ? null : f; + String[] result = new String[0]; + try { + for (MediaCodecInfo ci : new MediaCodecList(MediaCodecList.REGULAR_CODECS) + .getCodecInfos()) { + if (ci.isEncoder()) continue; + for (String type : ci.getSupportedTypes()) { + for (String m : MIMES) { + if (m.equalsIgnoreCase(type) && result.length == 0) { + result = new String[] {ci.getName(), type}; + } + } + } + } + } catch (RuntimeException e) { + Log.w(TAG, "Codec list: " + e.getMessage()); + } + found = result; + if (result.length > 0) Log.i(TAG, "MJPEG decoder: " + result[0] + " (" + result[1] + ")"); + return result.length == 0 ? null : result; + } + + /** + * Decodes one JPEG of {@code w}×{@code h} to {@code target}; (re)creates the decoder when + * the size or surface changes. Returns true when a frame was rendered. Throws when the + * decoder cannot be used: the caller then switches to the software path. + */ + synchronized boolean decode(Frame frame, Surface target, int w, int h) throws Exception { + String[] dec = find(); + if (dec == null) throw new IllegalStateException("no JPEG decoder"); + if (codec == null || target != surface || w != width || h != height) { + releaseLocked(); + MediaCodec c = MediaCodec.createByCodecName(dec[0]); + try { + MediaFormat format = MediaFormat.createVideoFormat(dec[1], w, h); + format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, Math.max(frame.length() * 2, + 512 * 1024)); + c.configure(format, target, null, 0); + c.start(); + } catch (Exception e) { + c.release(); + throw e; + } + codec = c; + surface = target; + width = w; + height = h; + Log.i(TAG, "Start MJPEG decoder " + dec[0] + " " + w + "x" + h); + } + int in = codec.dequeueInputBuffer(40_000); + if (in >= 0) { + ByteBuffer b = codec.getInputBuffer(in); + if (b != null && b.capacity() >= frame.length()) { + b.clear(); + b.put(frame.data(), 0, frame.length()); + codec.queueInputBuffer(in, 0, frame.length(), System.nanoTime() / 1000, 0); + } else { + codec.queueInputBuffer(in, 0, 0, 0, 0); + } + } + boolean rendered = false; + int out; + while ((out = codec.dequeueOutputBuffer(info, in >= 0 ? 20_000 : 0)) >= 0 + || out == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + if (out >= 0) { + codec.releaseOutputBuffer(out, true); + rendered = true; + } + in = -1; // after the first output, only take what is ready + } + return rendered; + } + + synchronized void release() { + releaseLocked(); + } + + private void releaseLocked() { + MediaCodec c = codec; + codec = null; + surface = null; + if (c == null) return; + try { + c.stop(); + } catch (Exception ignored) { + } + try { + c.release(); + } catch (Exception ignored) { + } + } +} diff --git a/android/app/src/test/java/com/openipc/decoder/JpegAssemblerTest.java b/android/app/src/test/java/com/openipc/decoder/JpegAssemblerTest.java index c465f50c..d7bd3d65 100644 --- a/android/app/src/test/java/com/openipc/decoder/JpegAssemblerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/JpegAssemblerTest.java @@ -170,4 +170,13 @@ public void sixteenBitTables() { assertEquals(3 + 128, ((h[dqt + 2] & 0xFF) << 8) | (h[dqt + 3] & 0xFF)); assertEquals(0x10, h[dqt + 4] & 0xFF); } + + @Test + public void sizeFromTheFrameHeader() throws Exception { + byte[] jpeg = resource("jpeg_q50_64x32.jpg"); + int[] size = JpegAssembler.size(jpeg, jpeg.length); + assertNotNull(size); + assertEquals(64, size[0]); + assertEquals(32, size[1]); + } } From 862f8bd71647de4cd89ede1f7e13c97d7aa66189 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 14:00:34 +0000 Subject: [PATCH 2/4] fix: look for the JPEG decoder in ALL_CODECS and log the video decoders when there is none (#132) --- .../java/com/openipc/decoder/JpegCodec.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java index 7e80cb25..98c6a342 100644 --- a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java +++ b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java @@ -40,7 +40,7 @@ static String[] find() { if (f != null) return f.length == 0 ? null : f; String[] result = new String[0]; try { - for (MediaCodecInfo ci : new MediaCodecList(MediaCodecList.REGULAR_CODECS) + for (MediaCodecInfo ci : new MediaCodecList(MediaCodecList.ALL_CODECS) .getCodecInfos()) { if (ci.isEncoder()) continue; for (String type : ci.getSupportedTypes()) { @@ -55,7 +55,24 @@ static String[] find() { Log.w(TAG, "Codec list: " + e.getMessage()); } found = result; - if (result.length > 0) Log.i(TAG, "MJPEG decoder: " + result[0] + " (" + result[1] + ")"); + if (result.length > 0) { + Log.i(TAG, "MJPEG decoder: " + result[0] + " (" + result[1] + ")"); + } else { + // Which video decoders the codec list offers instead (the vendor list may name a + // JPEG decoder that the platform does not expose) + StringBuilder all = new StringBuilder(); + try { + for (MediaCodecInfo ci : new MediaCodecList(MediaCodecList.ALL_CODECS) + .getCodecInfos()) { + if (ci.isEncoder()) continue; + for (String type : ci.getSupportedTypes()) { + if (type.startsWith("video/")) all.append(' ').append(type); + } + } + } catch (RuntimeException ignored) { + } + Log.i(TAG, "No MJPEG decoder; video decoders:" + all); + } return result.length == 0 ? null : result; } From 5fd5a43642109357f53a71d2c46786e63e019745 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 14:04:02 +0000 Subject: [PATCH 3/4] fix: try the AG-216's JPEG decoder by name when the codec list leaves it out (#132) --- .../java/com/openipc/decoder/Decoder.java | 2 +- .../java/com/openipc/decoder/JpegCodec.java | 26 +++++++++++++++++-- 2 files changed, 25 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 3378faed..0917927e 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1611,7 +1611,7 @@ private void drawJpeg(Frame frame) { Surface s = mVideoSurface; if (s == null || !s.isValid()) return; // The hardware JPEG decoder first, where there is one (#132) - if (!hwJpegFailed && !jpegDrawn && JpegCodec.find() != null) { + if (!hwJpegFailed && !jpegDrawn && JpegCodec.candidate() != null) { int[] size = JpegAssembler.size(frame.data(), frame.length()); if (size != null) { try { diff --git a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java index 98c6a342..2c81a893 100644 --- a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java +++ b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java @@ -27,6 +27,15 @@ final class JpegCodec { private static final String TAG = "OpenIPCDecoder"; private static final String[] MIMES = {"video/jpeg", "video/mjpeg", "video/x-motion-jpeg"}; private static volatile String[] found; + /** + * Vendor JPEG decoders a platform may leave out of its codec list although it can create + * them by name: the AG-216 declares this one in /vendor/etc/media_codecs.xml, but Android + * 10 does not list video/jpeg (#132). Tried once when the list has none. + */ + private static final String[][] BY_NAME = {{"OMX.allwinner.video.decoder.mjpeg", "video/jpeg"}}; + private static volatile boolean byNameTried; + /** The decoder that worked, once one did. */ + private static volatile String[] chosen; private MediaCodec codec; private Surface surface; @@ -81,9 +90,21 @@ static String[] find() { * the size or surface changes. Returns true when a frame was rendered. Throws when the * decoder cannot be used: the caller then switches to the software path. */ - synchronized boolean decode(Frame frame, Surface target, int w, int h) throws Exception { + /** A listed decoder, or one of BY_NAME once; null when there is none at all. */ + static String[] candidate() { + if (chosen != null) return chosen; String[] dec = find(); - if (dec == null) throw new IllegalStateException("no JPEG decoder"); + if (dec != null) return dec; + return byNameTried ? null : BY_NAME[0]; + } + + synchronized boolean decode(Frame frame, Surface target, int w, int h) throws Exception { + String[] dec = chosen != null ? chosen : find(); + if (dec == null) { + if (byNameTried) throw new IllegalStateException("no JPEG decoder"); + byNameTried = true; + dec = BY_NAME[0]; + } if (codec == null || target != surface || w != width || h != height) { releaseLocked(); MediaCodec c = MediaCodec.createByCodecName(dec[0]); @@ -98,6 +119,7 @@ synchronized boolean decode(Frame frame, Surface target, int w, int h) throws Ex throw e; } codec = c; + chosen = dec; surface = target; width = w; height = h; From cb04ca0f3d8ac2916eb8a211eab3b1eb70d2ba95 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 14:08:23 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20Qodo=20on=20#135=20=E2=80=94=20overs?= =?UTF-8?q?ized=20frames=20and=20silent=20decoders=20fall=20back=20to=20so?= =?UTF-8?q?ftware,=20size=20walks=20the=20markers,=20no=20restart=20after?= =?UTF-8?q?=20stop,=20surface=20swaps=20not=20fatal,=20stack=20trace=20log?= =?UTF-8?q?ged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/openipc/decoder/Decoder.java | 18 ++++++----- .../com/openipc/decoder/JpegAssembler.java | 13 ++++++-- .../java/com/openipc/decoder/JpegCodec.java | 30 +++++++++++++++---- .../openipc/decoder/JpegAssemblerTest.java | 13 ++++++++ 4 files changed, 59 insertions(+), 15 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 0917927e..c97230d9 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1607,22 +1607,24 @@ private int dp(float dp) { * with BitmapFactory (into the previous bitmap when the size is the same) and drawn over * the whole surface; the view's fit transform keeps the aspect ratio, as for video. */ - private void drawJpeg(Frame frame) { + private void drawJpeg(Frame frame, int gen) { Surface s = mVideoSurface; - if (s == null || !s.isValid()) return; + if (s == null || !s.isValid() || gen != listenerGen) return; // The hardware JPEG decoder first, where there is one (#132) if (!hwJpegFailed && !jpegDrawn && JpegCodec.candidate() != null) { int[] size = JpegAssembler.size(frame.data(), frame.length()); if (size != null) { try { - if (jpegCodec.decode(frame, s, size[0], size[1])) { + if (jpegCodec.decode(frame, s, size[0], size[1], + () -> gen == listenerGen && s == mVideoSurface)) { jpegShown(size[0], size[1]); } return; } catch (Exception e) { - Log.w(TAG, "Hardware MJPEG decoder failed, decoding in software: " - + e.getMessage()); jpegCodec.release(); + // The surface was replaced meanwhile: not the decoder's fault, try again + if (s != mVideoSurface || gen != listenerGen) return; + Log.w(TAG, "Hardware MJPEG decoder failed, decoding in software", e); hwJpegFailed = true; } } @@ -1703,7 +1705,7 @@ private void startListener() { try { Frame buffer = nalQueue.poll(5, TimeUnit.MILLISECONDS); if (buffer != null && rtspClient.isJpeg()) { - drawJpeg(buffer); + drawJpeg(buffer, gen); framePool.recycle(buffer); } else if (buffer != null && surfacePending) { // Waiting for a fresh surface after MJPEG; the next key frame follows @@ -1781,7 +1783,6 @@ public void run() { private void stopStreams() { lastJpegFrameMs = 0; - jpegCodec.release(); mSurface.removeCallbacks(singleUdpCheck); // The single view's stream ends here: it is no longer "connected" for the debug page singleState = "disconnected"; @@ -1803,6 +1804,9 @@ private void stopStreams() { // for on the UI thread (#32: this used to block it for seconds) audioManager.setEnabled(false); stopExecutor(); + // After the worker's generation is gone: a frame still in flight sees that and can no + // longer create the JPEG decoder again (Qodo on #135) + jpegCodec.release(); nalQueue.clear(); } diff --git a/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java b/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java index f3133fa7..9c3ce706 100644 --- a/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java +++ b/android/app/src/main/java/com/openipc/decoder/JpegAssembler.java @@ -222,12 +222,21 @@ private void drop(String reason) { /** {width, height} from the SOF0 header of a JPEG, or null (#132). */ static int[] size(byte[] d, int length) { - for (int i = 2; i + 8 < length; i++) { - if ((d[i] & 0xFF) == 0xFF && (d[i + 1] & 0xFF) == 0xC0) { + // Walk the marker segments by their lengths: table bytes may contain FF C0 (Qodo on #135) + int i = 2; + while (i + 4 <= length) { + if ((d[i] & 0xFF) != 0xFF) return null; + int marker = d[i + 1] & 0xFF; + if (marker == 0xDA || marker == 0xD9) return null; // scan or end before any SOF + int len = ((d[i + 2] & 0xFF) << 8) | (d[i + 3] & 0xFF); + if (len < 2) return null; + if (marker == 0xC0) { + if (i + 9 > length) return null; int h = ((d[i + 5] & 0xFF) << 8) | (d[i + 6] & 0xFF); int w = ((d[i + 7] & 0xFF) << 8) | (d[i + 8] & 0xFF); return new int[] {w, h}; } + i += 2 + len; } return null; } diff --git a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java index 2c81a893..68e9ee5e 100644 --- a/android/app/src/main/java/com/openipc/decoder/JpegCodec.java +++ b/android/app/src/main/java/com/openipc/decoder/JpegCodec.java @@ -98,7 +98,18 @@ static String[] candidate() { return byNameTried ? null : BY_NAME[0]; } - synchronized boolean decode(Frame frame, Surface target, int w, int h) throws Exception { + /** Frames queued in a row without any output: the decoder is not working (Qodo on #135). */ + static final int MAX_SILENT_FRAMES = 15; + private int silentFrames; + + /** Checked under the lock: false once the stream that called decode() has stopped. */ + interface Current { + boolean holds(); + } + + synchronized boolean decode(Frame frame, Surface target, int w, int h, Current current) + throws Exception { + if (!current.holds()) return false; String[] dec = chosen != null ? chosen : find(); if (dec == null) { if (byNameTried) throw new IllegalStateException("no JPEG decoder"); @@ -120,6 +131,7 @@ synchronized boolean decode(Frame frame, Surface target, int w, int h) throws Ex } codec = c; chosen = dec; + silentFrames = 0; surface = target; width = w; height = h; @@ -128,13 +140,15 @@ synchronized boolean decode(Frame frame, Surface target, int w, int h) throws Ex int in = codec.dequeueInputBuffer(40_000); if (in >= 0) { ByteBuffer b = codec.getInputBuffer(in); - if (b != null && b.capacity() >= frame.length()) { - b.clear(); - b.put(frame.data(), 0, frame.length()); - codec.queueInputBuffer(in, 0, frame.length(), System.nanoTime() / 1000, 0); - } else { + if (b == null || b.capacity() < frame.length()) { + // A frame larger than the decoder's input buffers: let the software path have it codec.queueInputBuffer(in, 0, 0, 0, 0); + throw new IllegalStateException("JPEG frame of " + frame.length() + + " bytes does not fit the input buffer"); } + b.clear(); + b.put(frame.data(), 0, frame.length()); + codec.queueInputBuffer(in, 0, frame.length(), System.nanoTime() / 1000, 0); } boolean rendered = false; int out; @@ -146,6 +160,10 @@ synchronized boolean decode(Frame frame, Surface target, int w, int h) throws Ex } in = -1; // after the first output, only take what is ready } + silentFrames = rendered ? 0 : silentFrames + 1; + if (silentFrames > MAX_SILENT_FRAMES) { + throw new IllegalStateException(MAX_SILENT_FRAMES + " frames without output"); + } return rendered; } diff --git a/android/app/src/test/java/com/openipc/decoder/JpegAssemblerTest.java b/android/app/src/test/java/com/openipc/decoder/JpegAssemblerTest.java index d7bd3d65..5e38053f 100644 --- a/android/app/src/test/java/com/openipc/decoder/JpegAssemblerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/JpegAssemblerTest.java @@ -179,4 +179,17 @@ public void sizeFromTheFrameHeader() throws Exception { assertEquals(64, size[0]); assertEquals(32, size[1]); } + + @Test + public void sizeSkipsTableBytesThatLookLikeSof() { + // A DQT whose table contains FF C0 before the real SOF0 (64x32) + byte[] tables = new byte[128]; + tables[10] = (byte) 0xFF; + tables[11] = (byte) 0xC0; + byte[] h = JpegAssembler.headers(0, 64, 32, tables, 0); + int[] size = JpegAssembler.size(h, h.length); + assertNotNull(size); + assertEquals(64, size[0]); + assertEquals(32, size[1]); + } }