Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ Versions after 2.17 are `MAJOR.MINOR.PATCH` (2.17 is followed by 2.17.1 or 2.18.
`docs/testing.md`, "Versions and releases"); the headings below keep the numbers as they
were released.

## 2.18.2 — 2026-10-20

- Single view: the decoder is set up for the stream's size from its SPS, so streams above
1080p (e.g. 2304×1296) play on hardware decoders that cannot find the size themselves; a
hardware decoder that gives no frame at a size it does not declare hands the stream to a
software one after 5 s. `/stats.json` shows `size` only while a picture is shown, and adds
`stream_size` and `decoder` (PR #220, #166).

## 2.18.1 — 2026-10-20

- Snapshot (`/snapshot.jpg`, the debug page): the single view's picture keeps its aspect ratio
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ android {
applicationId "com.openipc.decoder"
minSdk 21
targetSdk 36
versionCode 44
versionName "2.18.1"
versionCode 45
versionName "2.18.2"

buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
}
Expand Down
16 changes: 13 additions & 3 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ protected void onCreate(Bundle savedInstanceState) {
@Override public void onDecoderStarted() { removeClearOverlay(); }
@Override public void onDecoderFailed() {}
});
// Single view: streams above 1080p on decoders that need to know it up front (#166)
videoDecoder.allowLargeFrames(true);
audioManager = new AudioManager();
rtspClient = new RtspClient(framePool, nalAssembler, nalQueue);
rtspClient.setListener(new RtspClient.Listener() {
Expand Down Expand Up @@ -1618,11 +1620,19 @@ private Map<String, Object> debugState() {
rtspClient != null ? rtspClient.transportInUse() : null, mTransports[mActive]));
single.put("transport_mode", mTransports[mActive] != null
? mTransports[mActive].name() : null);
single.put("video", mResumed && "connected".equals(singleState)
&& singleRenderedWithin(SNAPSHOT_LIVE_MS));
boolean live = mResumed && "connected".equals(singleState)
&& singleRenderedWithin(SNAPSHOT_LIVE_MS);
single.put("video", live);
single.put("status", statusText != null && statusText.getVisibility() == View.VISIBLE
? statusText.getText().toString() : singleState);
if (mVideoWidth > 0) single.put("size", mVideoWidth + "x" + mVideoHeight);
// The decoded picture's size, only while it is shown: otherwise it is the size of
// an earlier stream (#166)
if (live && mVideoWidth > 0) single.put("size", mVideoWidth + "x" + mVideoHeight);
// What the camera sends, from the SPS, and the decoder taking it (#166)
int[] streamSize = videoDecoder.streamSize();
if (streamSize != null) single.put("stream_size", streamSize[0] + "x" + streamSize[1]);
Comment thread
compot-dev marked this conversation as resolved.
String decoderName = videoDecoder.decoderName();
if (decoderName != null) single.put("decoder", decoderName);
single.put("codec", rtspClient != null && rtspClient.isJpeg() ? "MJPEG"
: codecH265 ? "H.265" : "H.264");
single.put("audio", audioCodec);
Expand Down
341 changes: 297 additions & 44 deletions android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java

Large diffs are not rendered by default.

196 changes: 196 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/SpsParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
*
* SpsParser.java — picture size from an H.264 / H.265 sequence parameter set
*
*/

package com.openipc.decoder;

/**
* Reads the visible picture size (after the cropping / conformance window) from an SPS, so a
* decoder can be configured for the stream's real size before the first frame (#166).
*/
final class SpsParser {
private SpsParser() {}

/**
* Size {width, height} from one NAL unit (no start code, header included), or null if it
* is not an SPS or cannot be read.
*/
static int[] size(byte[] nal, int offset, int length, boolean h265) {
if (nal == null || length < (h265 ? 3 : 2) || offset < 0
|| offset + length > nal.length) return null;
try {
Bits b = new Bits(unescape(nal, offset, length));
return h265 ? h265(b) : h264(b);
} catch (RuntimeException e) {
// Truncated or malformed: the size stays unknown
return null;
}
}

private static int[] h264(Bits b) {
if ((b.u(8) & 0x1f) != MediaCodecManager.H264_NAL_SPS) return null;
int profile = b.u(8);
b.u(8); // constraint flags
b.u(8); // level
b.ue(); // seq_parameter_set_id
int chroma = 1;
boolean separatePlanes = false;
if (profile == 100 || profile == 110 || profile == 122 || profile == 244
|| profile == 44 || profile == 83 || profile == 86 || profile == 118
|| profile == 128 || profile == 138 || profile == 139 || profile == 134
|| profile == 135) {
chroma = b.ue();
if (chroma == 3) separatePlanes = b.u(1) == 1;
b.ue(); // bit_depth_luma_minus8
b.ue(); // bit_depth_chroma_minus8
b.u(1); // qpprime_y_zero_transform_bypass_flag
if (b.u(1) == 1) {
for (int i = 0; i < (chroma != 3 ? 8 : 12); i++) {
if (b.u(1) == 1) skipScalingList(b, i < 6 ? 16 : 64);
}
}
}
b.ue(); // log2_max_frame_num_minus4
int pocType = b.ue();
if (pocType == 0) {
b.ue(); // log2_max_pic_order_cnt_lsb_minus4
} else if (pocType == 1) {
b.u(1); // delta_pic_order_always_zero_flag
b.se(); // offset_for_non_ref_pic
b.se(); // offset_for_top_to_bottom_field
int n = b.ue();
for (int i = 0; i < n; i++) b.se();
}
b.ue(); // max_num_ref_frames
b.u(1); // gaps_in_frame_num_value_allowed_flag
// In long: a malformed code must not wrap round to a small size (Qodo on #220)
long widthMbs = b.ueLong() + 1;
long heightUnits = b.ueLong() + 1;
int frameMbsOnly = b.u(1);
if (frameMbsOnly == 0) b.u(1); // mb_adaptive_frame_field_flag
b.u(1); // direct_8x8_inference_flag
long width = widthMbs * 16;
long height = (2 - frameMbsOnly) * heightUnits * 16;
if (b.u(1) == 1) {
long left = b.ueLong(), right = b.ueLong(), top = b.ueLong(), bottom = b.ueLong();
int arrayType = separatePlanes ? 0 : chroma;
int cropX = arrayType == 1 || arrayType == 2 ? 2 : 1;
int cropY = (arrayType == 1 ? 2 : 1) * (2 - frameMbsOnly);
width -= cropX * (left + right);
height -= cropY * (top + bottom);
}
return valid(width, height);
}

private static void skipScalingList(Bits b, int size) {
int last = 8, next = 8;
for (int j = 0; j < size; j++) {
if (next != 0) next = (last + b.se() + 256) % 256;
last = next == 0 ? last : next;
}
}

private static int[] h265(Bits b) {
if (((b.u(8) >> 1) & 0x3f) != MediaCodecManager.H265_NAL_SPS) return null;
b.u(8); // rest of the NAL header
b.u(4); // sps_video_parameter_set_id
int subLayers = b.u(3); // sps_max_sub_layers_minus1
b.u(1); // sps_temporal_id_nesting_flag
// profile_tier_level(1, subLayers): general part, 96 bits
b.skip(96);
boolean[] profile = new boolean[subLayers];
boolean[] level = new boolean[subLayers];
for (int i = 0; i < subLayers; i++) {
profile[i] = b.u(1) == 1;
level[i] = b.u(1) == 1;
}
if (subLayers > 0) b.skip(2 * (8 - subLayers));
for (int i = 0; i < subLayers; i++) {
if (profile[i]) b.skip(88);
if (level[i]) b.skip(8);
}
b.ue(); // sps_seq_parameter_set_id
int chroma = b.ue();
boolean separatePlanes = chroma == 3 && b.u(1) == 1;
long width = b.ueLong();
long height = b.ueLong();
if (b.u(1) == 1) {
long left = b.ueLong(), right = b.ueLong(), top = b.ueLong(), bottom = b.ueLong();
int arrayType = separatePlanes ? 0 : chroma;
int subW = arrayType == 1 || arrayType == 2 ? 2 : 1;
int subH = arrayType == 1 ? 2 : 1;
width -= subW * (left + right);
height -= subH * (top + bottom);
}
return valid(width, height);
}

private static int[] valid(long width, long height) {
// Beyond 8K, empty or cropped below nothing: not a size worth configuring a decoder for
return width > 0 && height > 0 && width <= 8192 && height <= 8192
? new int[]{(int) width, (int) height} : null;
}

/** The NAL unit without its emulation prevention bytes (00 00 03 → 00 00). */
static byte[] unescape(byte[] data, int offset, int length) {
byte[] out = new byte[length];
int n = 0, zeros = 0;
for (int i = offset; i < offset + length; i++) {
int v = data[i] & 0xff;
if (zeros >= 2 && v == 3) {
zeros = 0;
continue;
}
zeros = v == 0 ? zeros + 1 : 0;
out[n++] = (byte) v;
}
return java.util.Arrays.copyOf(out, n);
}

/** MSB-first bit reader with Exp-Golomb codes; reading past the end throws. */
private static final class Bits {
private final byte[] data;
private int pos;

Bits(byte[] data) { this.data = data; }

int u(int n) {
int v = 0;
for (int i = 0; i < n; i++) {
if (pos >= data.length * 8) throw new IllegalStateException("SPS truncated");
v = (v << 1) | ((data[pos >> 3] >> (7 - (pos & 7))) & 1);
pos++;
}
return v;
}

void skip(int n) {
if (pos + n > data.length * 8) throw new IllegalStateException("SPS truncated");
pos += n;
}

/** Unsigned Exp-Golomb code, 0 … 2^32 − 2. */
long ueLong() {
int zeros = 0;
while (u(1) == 0) {
if (++zeros > 31) throw new IllegalStateException("bad Exp-Golomb code");
}
return zeros == 0 ? 0 : (1L << zeros) - 1 + (u(zeros) & 0xffffffffL);
}

/** {@link #ueLong()} where the syntax needs an int; larger values are malformed. */
int ue() {
long v = ueLong();
if (v > Integer.MAX_VALUE) throw new IllegalStateException("Exp-Golomb code too large");
return (int) v;
}

int se() {
int k = ue();
return (k & 1) == 1 ? (k + 1) / 2 : -(k / 2);
}
}
}
137 changes: 137 additions & 0 deletions android/app/src/test/java/com/openipc/decoder/SpsParserTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
*
* SpsParserTest.java — picture size from H.264 / H.265 SPS (#166)
*
*/

package com.openipc.decoder;

import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNull;

import org.junit.Test;

public class SpsParserTest {
// SPS from ffmpeg 6.1 (libx264 / libx265), testsrc, emulation prevention bytes included
private static final String H264_2304 = "67640032acd9402400a3b011000003000100000300320f183196";
private static final String H264_1080 = "674d4028eca03c0113f2e022000003000200000300641e30632c";
private static final String H264_720 = "6742c01fd9005005bb011000000300100000030320f1832480";
private static final String H264_444 = "67f40028919b280f0044fc4e0220000003002000000641e30632c0";
private static final String H264_INTERLACED =
"67640028acd94078044fde0220000003002000000643e2c5b2c0";
private static final String H265_2304 = "420101016000000300900000030000030096a0012020051165959a"
+ "4932bc05a02000000300200000030321";
private static final String H265_1080 = "420101016000000300900000030000030078a003c08010e5965669"
+ "24caf0168080000003008000000c84";

private static int[] size(String hex, boolean h265) {
byte[] b = hex(hex);
return SpsParser.size(b, 0, b.length, h265);
}

private static byte[] hex(String s) {
byte[] b = new byte[s.length() / 2];
for (int i = 0; i < b.length; i++) {
b[i] = (byte) Integer.parseInt(s.substring(2 * i, 2 * i + 2), 16);
}
return b;
}

@Test
public void h264Sizes() {
// High, 2304×1296: 144×81 macroblocks, no cropping
assertArrayEquals(new int[]{2304, 1296}, size(H264_2304, false));
// Main, 1080p: 1088 coded, cropped to 1080
assertArrayEquals(new int[]{1920, 1080}, size(H264_1080, false));
assertArrayEquals(new int[]{1280, 720}, size(H264_720, false));
// High 4:4:4: crop units of one line
assertArrayEquals(new int[]{1920, 1080}, size(H264_444, false));
// Interlaced: map units are field macroblock pairs
assertArrayEquals(new int[]{1920, 1080}, size(H264_INTERLACED, false));
}

@Test
public void h265Sizes() {
assertArrayEquals(new int[]{2304, 1296}, size(H265_2304, true));
// Coded 1920×1088 with a conformance window
assertArrayEquals(new int[]{1920, 1080}, size(H265_1080, true));
}

@Test
public void notAnSpsOrBroken() {
// PPS
assertNull(size("68ebe3cb22c0", false));
// H.265 VPS
assertNull(size("40010c01ffff016000000300900000030000030078959809", true));
// Truncated
assertNull(size(H264_2304.substring(0, 16), false));
assertNull(size(H265_2304.substring(0, 40), true));
// Codec mixed up
assertNull(size(H264_1080, true));
assertNull(SpsParser.size(null, 0, 0, false));
byte[] b = hex(H264_720);
assertNull(SpsParser.size(b, 4, b.length, false));
}

/** A baseline H.264 SPS with the given size fields (Exp-Golomb values as written). */
private static int[] h264Sps(long widthMbsMinus1, long heightMinus1, long[] crop) {
StringBuilder bits = new StringBuilder();
bits.append(bin(0x67, 8)).append(bin(66, 8)).append(bin(0, 8)).append(bin(31, 8));
ue(bits, 0); // seq_parameter_set_id
ue(bits, 0); // log2_max_frame_num_minus4
ue(bits, 2); // pic_order_cnt_type
ue(bits, 1); // max_num_ref_frames
bits.append('0'); // gaps_in_frame_num_value_allowed_flag
ue(bits, widthMbsMinus1);
ue(bits, heightMinus1);
bits.append("11"); // frame_mbs_only_flag, direct_8x8_inference_flag
if (crop != null) {
bits.append('1');
for (long c : crop) ue(bits, c);
} else {
bits.append('0');
}
bits.append("01"); // no VUI, then the stop bit
while (bits.length() % 8 != 0) bits.append('0');
byte[] b = new byte[bits.length() / 8];
for (int i = 0; i < b.length; i++) {
b[i] = (byte) Integer.parseInt(bits.substring(8 * i, 8 * i + 8), 2);
}
return SpsParser.size(b, 0, b.length, false);
}

private static String bin(long v, int n) {
StringBuilder s = new StringBuilder(Long.toBinaryString(v));
while (s.length() < n) s.insert(0, '0');
return s.toString();
}

private static void ue(StringBuilder bits, long v) {
String code = Long.toBinaryString(v + 1);
for (int i = 1; i < code.length(); i++) bits.append('0');
bits.append(code);
}

@Test
public void malformedSizesDoNotWrapRound() {
// The writer itself: 1280×720
assertArrayEquals(new int[]{1280, 720}, h264Sps(79, 44, null));
// 2^28 + 1 macroblocks wide is 2^32 + 16 pixels: 16 in 32-bit arithmetic (Qodo on #220)
assertNull(h264Sps(1L << 28, 44, null));
// Crops larger than the picture, and one that overflows an int when doubled
assertNull(h264Sps(79, 44, new long[]{700, 0, 0, 0}));
assertNull(h264Sps(79, 44, new long[]{1L << 31, 0, 0, 0}));
// A sane crop still works: 1920×1088 coded, 1080 shown
assertArrayEquals(new int[]{1920, 1080}, h264Sps(119, 67, new long[]{0, 0, 0, 4}));
}

@Test
public void emulationPreventionBytesAreRemoved() {
assertArrayEquals(new byte[]{0, 0, 1, 0, 0, 0},
SpsParser.unescape(new byte[]{0, 0, 3, 1, 0, 0, 3, 0}, 0, 8));
// 00 00 03 03: only the first 03 goes
assertArrayEquals(new byte[]{0, 0, 3},
SpsParser.unescape(new byte[]{0, 0, 3, 3}, 0, 4));
}
}
Loading
Loading