Skip to content

feat: hardware MJPEG decoding through MediaCodec where available (#132, single view) - #135

Merged
compot-dev merged 4 commits into
v2from
feat/mjpeg-hw
Oct 13, 2026
Merged

compot-dev merged 4 commits into
v2from
feat/mjpeg-hw

Conversation

@compot-dev

Copy link
Copy Markdown
Collaborator

Part of #132 (the single-view half; MJPEG in multi-view follows).

  • JpegCodec: looks up a decoder for video/jpeg / video/mjpeg / video/x-motion-jpeg once (the AG-216 lists OMX.allwinner.video.decoder.mjpeg), creates it for the frame size (JpegAssembler.size() from SOF0) with the video surface as output, queues each whole JPEG and renders the output buffers.
  • drawJpeg tries it first; on any failure it logs Hardware MJPEG decoder failed, decoding in software and uses the BitmapFactory path of feat: MJPEG over RTSP (RTP/JPEG, RFC 2435) in the single view (#119) #131 for the rest of the run.
  • No lockCanvas() on the hardware path, so switching to an H.264/H.265 stream needs no new surface; the JPEG decoder is released on a codec change, a surface change and when the streams stop.
  • Test for JpegAssembler.size().

Device check: Aceline-1, .133 /stream=2 — log MJPEG decoder: OMX.allwinner.video.decoder.mjpeg / Start MJPEG decoder … 1280x720, picture, lower CPU than the software path; back to H.265 works.

@compot-dev

Copy link
Copy Markdown
Collaborator Author

/review

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Large JPEG frames disappear silently ✓ Resolved
Description
JpegCodec.decode queues a zero-length buffer instead of reporting failure when the codec input
buffer is null or too small for the complete JPEG frame. If a later assembled frame exceeds the
capacity requested from the first frame, drawJpeg returns after the hardware attempt without
passing that frame to BitmapFactory, leaving it undisplayed.
Code

android/app/src/main/java/com/openipc/decoder/JpegCodec.java[R91-98]

+            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);
+            }
Evidence
The codec requests input capacity based on the first frame, while the assembler permits subsequent
frames up to 4 MiB. The insufficient-capacity branch queues zero bytes and returns normally, so
drawJpeg takes neither its exception-based BitmapFactory fallback nor any other path that renders
the frame.

Fall back to bitmap decoding
android/app/src/main/java/com/openipc/decoder/JpegCodec.java[89-110]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1613-1628]
android/app/src/main/java/com/openipc/decoder/JpegCodec.java[70-98]
android/app/src/main/java/com/openipc/decoder/JpegAssembler.java[183-191]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1617-1629]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hardware decoder queues an empty buffer when it cannot accept a complete JPEG frame, causing `drawJpeg` to skip software decoding of that frame.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/JpegCodec.java[89-99]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1613-1628]
## Recommended Fix
Do not queue an empty buffer in place of the JPEG. Treat a null or undersized input buffer as a hardware-decoder failure by throwing or returning an explicit failure result, then have `drawJpeg` release the hardware codec, mark hardware decoding unavailable for the run, and decode the same frame through BitmapFactory.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Some JPEG streams use the wrong dimensions ✓ Resolved
Description
JpegAssembler.size() searches every byte for FF C0 instead of skipping length-delimited JPEG
segments, so it can read dimensions from quantization-table data. When sender-supplied tables
contain those bytes before the actual frame header, drawJpeg() configures the hardware decoder
with unrelated values.
Code

android/app/src/main/java/com/openipc/decoder/JpegAssembler.java[R225-228]

+        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);
Evidence
The assembler accepts sender-provided tables and writes their bytes into DQT segments before writing
its SOF0 header; the new scan can therefore encounter table bytes first. The result is passed
directly to the hardware decoder.

android/app/src/main/java/com/openipc/decoder/JpegAssembler.java[177-201]
android/app/src/main/java/com/openipc/decoder/JpegAssembler.java[223-233]
android/app/src/main/java/com/openipc/decoder/JpegAssembler.java[261-282]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1614-1621]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The dimension scan can mistake bytes inside a quantization table for the frame header and configure the decoder with incorrect dimensions.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/JpegAssembler.java[223-233]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1614-1619]
## Recommended Fix
Walk JPEG markers using each segment's declared length, and read dimensions only from a validated SOF0 segment. Add a test whose DQT payload contains `FF C0` before the real SOF0.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. A non-rendering decoder leaves video blank ✓ Resolved
Description
drawJpeg() returns after every non-throwing JpegCodec.decode() call, even when it returns
false because no output buffer arrived. If a selected decoder accepts input but never produces a
frame, subsequent JPEGs continue through that decoder indefinitely, and neither software decoding
nor the on-screen frame count advances.
Code

android/app/src/main/java/com/openipc/decoder/Decoder.java[R1618-1621]

+                    if (jpegCodec.decode(frame, s, size[0], size[1])) {
+                        jpegShown(size[0], size[1]);
+                    }
+                    return;
Evidence
The output dequeue has a timeout and decode() returns false without throwing when nothing renders.
The caller skips jpegShown() but returns before BitmapFactory, and the decode worker then recycles
the frame.

android/app/src/main/java/com/openipc/decoder/JpegCodec.java[89-110]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1614-1631]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1700-1717]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A codec that accepts frames but produces no output never triggers the software fallback.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1614-1627]
- android/app/src/main/java/com/openipc/decoder/JpegCodec.java[89-110]
## Recommended Fix
Allow normal decoder startup latency, but track sustained lack of rendered output despite queued frames. Release that codec and switch to BitmapFactory once a bounded failure threshold is reached.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. A stopped stream can restart its decoder ✓ Resolved
Description
stopStreams() releases jpegCodec before invalidating the decode worker, and drawJpeg() has no
stop check before starting hardware decoding. A worker that has already polled a JPEG can enter
JpegCodec.decode() after the release, create another codec on the surface, and retain it after the
stream has stopped.
Code

android/app/src/main/java/com/openipc/decoder/Decoder.java[1784]

+        jpegCodec.release();
Evidence
The release precedes the generation increment and non-blocking executor shutdown. A polled frame can
still enter drawJpeg(), where decode() creates a codec whenever the release has left its codec
field null.

android/app/src/main/java/com/openipc/decoder/Decoder.java[1698-1717]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1781-1817]
android/app/src/main/java/com/openipc/decoder/JpegCodec.java[67-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A queued JPEG can recreate the hardware codec after stop cleanup has released it.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1698-1717]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1781-1808]
- android/app/src/main/java/com/openipc/decoder/JpegCodec.java[67-87]
## Recommended Fix
Invalidate the decode worker before hardware-codec cleanup and make decoding honor the stopped generation. Coordinate the final release with in-flight decoding so a frame cannot recreate the codec after stop.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (2)
5. A surface change can disable hardware JPEG ✓ Resolved
Description
replaceSurface() releases the JPEG codec before changing mVideoSurface, but drawJpeg()
captures that surface before entering the codec's synchronized decode() method. If the view
changes surface after that capture, the worker can attempt to configure against the released
surface, and the resulting exception sets hwJpegFailed for the rest of the run.
Code

android/app/src/main/java/com/openipc/decoder/Decoder.java[R1614-1618]

+        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])) {
Evidence
Surface validity is checked before decode() acquires its lock. Replacement releases the codec and
old surface independently of that initial read; any exception in the attempted decode permanently
enables the software-only flag.

android/app/src/main/java/com/openipc/decoder/Decoder.java[1572-1581]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1591-1596]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1610-1629]
android/app/src/main/java/com/openipc/decoder/JpegCodec.java[67-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A concurrent surface replacement can make an otherwise working hardware decoder fail permanently.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1572-1580]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1611-1627]
## Recommended Fix
Coordinate surface selection and replacement with hardware decoding, or recognize a stale-surface exception and retry on the current surface without permanently setting `hwJpegFailed`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Hardware decode failures lose their traceback ✓ Resolved
Description
drawJpeg() logs only e.getMessage() when a hardware JPEG attempt throws, discarding the
exception and its stack trace. Configuration, input queuing, output draining, and surface failures
all reach this handler, so a permanent switch to software decoding lacks the diagnostic context
needed to distinguish them.
Code

android/app/src/main/java/com/openipc/decoder/Decoder.java[R1622-1624]

+                } catch (Exception e) {
+                    Log.w(TAG, "Hardware MJPEG decoder failed, decoding in software: "
+                            + e.getMessage());
Evidence
The catch handles the entire hardware decode call, logs only the stringified message, and then sets
the flag that prevents future hardware attempts.

android/app/src/main/java/com/openipc/decoder/Decoder.java[1614-1627]
android/app/src/main/java/com/openipc/decoder/JpegCodec.java[67-110]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The permanent hardware-to-software fallback logs an exception message without its traceback.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1622-1626]
## Recommended Fix
Pass the caught exception to `Log.w` alongside the fallback message so the failing operation and its stack trace remain available in logs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn these tips off under Display > Comment parts

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread android/app/src/main/java/com/openipc/decoder/JpegCodec.java
Comment thread android/app/src/main/java/com/openipc/decoder/JpegAssembler.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/Decoder.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/Decoder.java Outdated
Comment on lines +1614 to +1618
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])) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. A surface change can disable hardware jpeg 🐞 Bug ☼ Reliability

replaceSurface() releases the JPEG codec before changing mVideoSurface, but drawJpeg()
captures that surface before entering the codec's synchronized decode() method. If the view
changes surface after that capture, the worker can attempt to configure against the released
surface, and the resulting exception sets hwJpegFailed for the rest of the run.
Agent Prompt
## Issue description
A concurrent surface replacement can make an otherwise working hardware decoder fail permanently.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1572-1580]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1611-1627]
## Recommended Fix
Coordinate surface selection and replacement with hardware decoding, or recognize a stale-surface exception and retry on the current surface without permanently setting `hwJpegFailed`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread android/app/src/main/java/com/openipc/decoder/Decoder.java Outdated
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Qodo review — all six accepted and fixed:

  1. Large JPEG frames disappear silently — an undersized/null input buffer now throws; drawJpeg falls back to BitmapFactory for that frame and the rest of the run.
  2. Some JPEG streams use the wrong dimensions — JpegAssembler.size() walks the marker segments by their lengths and reads only SOF0; test with FF C0 inside a table.
  3. A non-rendering decoder leaves video blank — after 15 frames in a row without output the decoder counts as failed → software path.
  4. A stopped stream can restart its decoder — the decode call carries a check (worker generation and surface still current) evaluated under the codec lock; stopStreams() releases the JPEG decoder after the worker generation is gone.
  5. A surface change can disable hardware JPEG — a failure while the surface or generation changed releases the decoder without disabling the hardware path.
  6. Tracebacks — the fallback warning logs the exception.

Device result (Aceline-1): the AG-216 declares OMX.allwinner.video.decoder.mjpeg (video/jpeg) in /vendor/etc/media_codecs.xml, but Android 10 lists no video/jpeg decoder (No MJPEG decoder; video decoders: video/avc … vp9), and creating it by name fails (Failed to initialize …, error 0xfffffffe). The app falls back to the software path as designed; the hardware path stays for devices that expose a JPEG decoder.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Qodo review — all six accepted and fixed:

  1. Large JPEG frames disappear silently — an undersized/null input buffer now throws; drawJpeg falls back to BitmapFactory for that frame and the rest of the run.
  2. Some JPEG streams use the wrong dimensions — JpegAssembler.size() walks the marker segments by their lengths and reads only SOF0; test with FF C0 inside a table.
  3. A non-rendering decoder leaves video blank — after 15 frames in a row without output the decoder counts as failed → software path.
  4. A stopped stream can restart its decoder — the decode call carries a check (worker generation and surface still current) evaluated under the codec lock; stopStreams() releases the JPEG decoder after the worker generation is gone.
  5. A surface change can disable hardware JPEG — a failure while the surface or generation changed releases the decoder without disabling the hardware path.
  6. Tracebacks — the fallback warning logs the exception.

Device result (Aceline-1): the AG-216 declares OMX.allwinner.video.decoder.mjpeg (video/jpeg) in /vendor/etc/media_codecs.xml, but Android 10 lists no video/jpeg decoder (No MJPEG decoder; video decoders: video/avc … vp9), and creating it by name fails (Failed to initialize …, error 0xfffffffe). The app falls back to the software path as designed; the hardware path stays for devices that expose a JPEG decoder.

Thanks for the detailed validation. The current diff addresses all six review findings, including the JPEG size parsing, bounded no-output fallback, stop/surface-generation checks, and traceback logging. The Aceline-1 result also confirms the intended behavior: when the vendor decoder cannot be created, the app falls back to software decoding while retaining hardware support for compatible devices.

@compot-dev

Copy link
Copy Markdown
Collaborator Author

Device check of the final build (Aceline-1, cb04ca0): no listed JPEG decoder, the by-name attempt fails once, the app logs the fallback with the stack trace and plays .133 /stream=2 in software (codec MJPEG, video true); switching the slot back to .126 H.265 plays with the hardware decoder. The hardware JPEG path is in place for devices that expose video/jpeg.

@compot-dev
compot-dev merged commit 7df5617 into v2 Oct 13, 2026
4 checks passed
@compot-dev
compot-dev deleted the feat/mjpeg-hw branch October 13, 2026 14:13
compot-dev added a commit that referenced this pull request Oct 15, 2026
… software, size walks the markers, no restart after stop, surface swaps not fatal, stack trace logged
compot-dev added a commit that referenced this pull request Oct 15, 2026
feat: hardware MJPEG decoding through MediaCodec where available (#132, single view)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant