Skip to content

Commit 5e45caf

Browse files
compot-devclaude
andcommitted
feat: http:// MJPEG slots — multipart/x-mixed-replace over HTTP, in the single view and in multi-view (#121)
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fa348af commit 5e45caf

5 files changed

Lines changed: 558 additions & 1 deletion

File tree

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/*
2+
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
3+
*
4+
* HttpMjpeg.java — MJPEG over HTTP: multipart/x-mixed-replace, one JPEG per part (#121)
5+
*
6+
*/
7+
8+
package com.openipc.decoder;
9+
10+
import java.io.ByteArrayOutputStream;
11+
import java.io.IOException;
12+
import java.io.InputStream;
13+
import java.nio.charset.StandardCharsets;
14+
import java.util.ArrayList;
15+
import java.util.List;
16+
import java.util.Locale;
17+
18+
/**
19+
* The HTTP side of an http:// slot such as Majestic's {@code http://<camera>/mjpeg}: the URL,
20+
* the response head and the body, a {@code multipart/x-mixed-replace} stream whose parts are
21+
* whole JPEG frames, chunked or not. The connection itself is RtspSession's, which also
22+
* handles authentication (Basic, Digest) and reconnects. Pure Java, no Android types.
23+
*/
24+
final class HttpMjpeg {
25+
/** Larger parts are not JPEG frames of a camera: the stream is given up. */
26+
static final int MAX_PART = 8 * 1024 * 1024;
27+
private static final int MAX_LINE = 8192;
28+
29+
private HttpMjpeg() {}
30+
31+
static boolean matches(String url) {
32+
return url != null && url.trim().toLowerCase(Locale.ROOT).startsWith("http://");
33+
}
34+
35+
static final class Url {
36+
String host;
37+
int port = 80;
38+
/** Path and query, "/" at least. */
39+
String path = "/";
40+
/** Decoded "user:password", or null. */
41+
String userInfo;
42+
43+
static Url parse(String url) throws IOException {
44+
if (!matches(url)) throw new IOException("Not an http:// URL");
45+
String rest = url.trim().substring("http://".length());
46+
int slash = rest.indexOf('/');
47+
String authority = slash >= 0 ? rest.substring(0, slash) : rest;
48+
Url u = new Url();
49+
if (slash >= 0) u.path = rest.substring(slash);
50+
int at = authority.lastIndexOf('@');
51+
if (at >= 0) {
52+
u.userInfo = RtspSession.Url.percentDecode(authority.substring(0, at));
53+
authority = authority.substring(at + 1);
54+
}
55+
String host = authority;
56+
int colon = authority.lastIndexOf(':');
57+
if (authority.startsWith("[")) {
58+
int close = authority.indexOf(']');
59+
if (close < 0) throw new IOException("Bad host in URL");
60+
host = authority.substring(1, close);
61+
colon = authority.indexOf(':', close);
62+
} else if (colon >= 0) {
63+
host = authority.substring(0, colon);
64+
}
65+
if (colon >= 0) {
66+
try {
67+
u.port = Integer.parseInt(authority.substring(colon + 1));
68+
} catch (NumberFormatException e) {
69+
throw new IOException("Bad port in URL");
70+
}
71+
if (u.port <= 0 || u.port > 65535) throw new IOException("Bad port in URL");
72+
}
73+
if (host.isEmpty()) throw new IOException("No host in URL");
74+
u.host = host;
75+
return u;
76+
}
77+
}
78+
79+
/** Status and the headers that matter here. */
80+
static final class Response {
81+
int status;
82+
String contentType;
83+
boolean chunked;
84+
final List<String> challenges = new ArrayList<>();
85+
86+
/** The multipart boundary, without the leading "--", or null when not multipart. */
87+
String boundary() {
88+
if (contentType == null) return null;
89+
String lower = contentType.toLowerCase(Locale.ROOT);
90+
if (!lower.startsWith("multipart/")) return null;
91+
int i = lower.indexOf("boundary=");
92+
if (i < 0) return null;
93+
String b = contentType.substring(i + "boundary=".length()).trim();
94+
int semi = b.indexOf(';');
95+
if (semi >= 0) b = b.substring(0, semi).trim();
96+
if (b.length() >= 2 && b.startsWith("\"") && b.endsWith("\"")) {
97+
b = b.substring(1, b.length() - 1);
98+
}
99+
// Some servers put the dashes into the parameter too
100+
if (b.startsWith("--")) b = b.substring(2);
101+
return b.isEmpty() ? null : b;
102+
}
103+
}
104+
105+
/** The status line and headers, up to the empty line. */
106+
static Response readResponse(InputStream in) throws IOException {
107+
String line = readLine(in);
108+
if (line == null) throw new IOException("No HTTP response");
109+
String[] parts = line.split(" ", 3);
110+
if (parts.length < 2 || !parts[0].startsWith("HTTP/")) {
111+
throw new IOException("Not an HTTP response");
112+
}
113+
Response r = new Response();
114+
try {
115+
r.status = Integer.parseInt(parts[1]);
116+
} catch (NumberFormatException e) {
117+
throw new IOException("Bad HTTP status");
118+
}
119+
while ((line = readLine(in)) != null && !line.isEmpty()) {
120+
int colon = line.indexOf(':');
121+
if (colon <= 0) continue;
122+
String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT);
123+
String value = line.substring(colon + 1).trim();
124+
if (name.equals("content-type")) r.contentType = value;
125+
else if (name.equals("transfer-encoding")) {
126+
r.chunked = value.toLowerCase(Locale.ROOT).contains("chunked");
127+
} else if (name.equals("www-authenticate")) r.challenges.add(value);
128+
}
129+
return r;
130+
}
131+
132+
interface FrameSink {
133+
/** One JPEG frame; false to stop reading. */
134+
boolean onFrame(byte[] buf, int offset, int length);
135+
}
136+
137+
/**
138+
* Reads parts until the stream ends, a part is malformed or the sink says stop. Each part
139+
* goes to the sink whole: by its Content-Length, or else up to the next boundary.
140+
*/
141+
static void readParts(InputStream body, String boundary, FrameSink sink) throws IOException {
142+
String delimiter = "--" + boundary;
143+
byte[] buf = new byte[256 * 1024];
144+
String line;
145+
// Up to the first delimiter (a preamble is allowed)
146+
while ((line = readLine(body)) != null && !line.startsWith(delimiter)) {
147+
// skip
148+
}
149+
while (line != null) {
150+
if (line.startsWith(delimiter + "--")) return; // the closing delimiter
151+
int length = -1;
152+
while ((line = readLine(body)) != null && !line.isEmpty()) {
153+
int colon = line.indexOf(':');
154+
if (colon > 0 && line.substring(0, colon).trim()
155+
.equalsIgnoreCase("content-length")) {
156+
try {
157+
length = Integer.parseInt(line.substring(colon + 1).trim());
158+
} catch (NumberFormatException e) {
159+
length = -1;
160+
}
161+
}
162+
}
163+
if (line == null) return;
164+
if (length > MAX_PART) throw new IOException("MJPEG part of " + length + " bytes");
165+
int n;
166+
if (length >= 0) {
167+
if (buf.length < length) buf = new byte[length];
168+
readFully(body, buf, length);
169+
n = length;
170+
// The rest of the line after the data, then the next delimiter
171+
while ((line = readLine(body)) != null && !line.startsWith(delimiter)) {
172+
// skip
173+
}
174+
} else {
175+
// No length: the data runs up to CRLF + delimiter
176+
ByteArrayOutputStream part = new ByteArrayOutputStream();
177+
byte[] end = ("\r\n" + delimiter).getBytes(StandardCharsets.US_ASCII);
178+
if (!readUntil(body, end, part)) return;
179+
n = part.size();
180+
if (n > MAX_PART) throw new IOException("MJPEG part of " + n + " bytes");
181+
if (buf.length < n) buf = new byte[n];
182+
System.arraycopy(part.toByteArray(), 0, buf, 0, n);
183+
// What follows the delimiter on its line: "--" closes the stream
184+
String tail = readLine(body);
185+
line = tail == null ? null : delimiter + tail;
186+
}
187+
if (n > 0 && !sink.onFrame(buf, 0, n)) return;
188+
}
189+
}
190+
191+
/** Copies bytes into {@code out} until {@code end} has been read (not copied). */
192+
private static boolean readUntil(InputStream in, byte[] end, ByteArrayOutputStream out)
193+
throws IOException {
194+
int matched = 0;
195+
int b;
196+
while ((b = in.read()) >= 0) {
197+
if (b == (end[matched] & 0xFF)) {
198+
matched++;
199+
if (matched == end.length) return true;
200+
continue;
201+
}
202+
if (matched > 0) {
203+
// Not the delimiter after all: what matched so far is data
204+
out.write(end, 0, matched);
205+
matched = b == (end[0] & 0xFF) ? 1 : 0;
206+
if (matched == 0) out.write(b);
207+
} else {
208+
out.write(b);
209+
}
210+
if (out.size() > MAX_PART) throw new IOException("MJPEG part too large");
211+
}
212+
return false;
213+
}
214+
215+
private static void readFully(InputStream in, byte[] buf, int length) throws IOException {
216+
int off = 0;
217+
while (off < length) {
218+
int r = in.read(buf, off, length - off);
219+
if (r < 0) throw new IOException("Stream ended inside a part");
220+
off += r;
221+
}
222+
}
223+
224+
/** One CRLF (or LF) terminated ASCII line without the terminator; null at the end. */
225+
static String readLine(InputStream in) throws IOException {
226+
StringBuilder sb = new StringBuilder();
227+
int b;
228+
while ((b = in.read()) >= 0) {
229+
if (b == '\n') {
230+
int len = sb.length();
231+
if (len > 0 && sb.charAt(len - 1) == '\r') sb.setLength(len - 1);
232+
return sb.toString();
233+
}
234+
if (sb.length() >= MAX_LINE) throw new IOException("HTTP line too long");
235+
sb.append((char) b);
236+
}
237+
return sb.length() > 0 ? sb.toString() : null;
238+
}
239+
240+
/** The body of a chunked response as a plain stream (RFC 9112 7.1). */
241+
static final class Chunked extends InputStream {
242+
private final InputStream in;
243+
private int left;
244+
private boolean done;
245+
246+
Chunked(InputStream in) {
247+
this.in = in;
248+
}
249+
250+
private boolean next() throws IOException {
251+
if (done) return false;
252+
if (left > 0) return true;
253+
String line = readLine(in);
254+
// The CRLF that ends the previous chunk's data
255+
if (line != null && line.isEmpty()) line = readLine(in);
256+
if (line == null) {
257+
done = true;
258+
return false;
259+
}
260+
int semi = line.indexOf(';');
261+
String hex = (semi >= 0 ? line.substring(0, semi) : line).trim();
262+
try {
263+
left = Integer.parseInt(hex, 16);
264+
} catch (NumberFormatException e) {
265+
throw new IOException("Bad chunk size");
266+
}
267+
if (left < 0) throw new IOException("Bad chunk size");
268+
if (left == 0) {
269+
done = true;
270+
return false;
271+
}
272+
return true;
273+
}
274+
275+
@Override
276+
public int read() throws IOException {
277+
if (!next()) return -1;
278+
int b = in.read();
279+
if (b < 0) {
280+
done = true;
281+
return -1;
282+
}
283+
left--;
284+
return b;
285+
}
286+
287+
@Override
288+
public int read(byte[] b, int off, int len) throws IOException {
289+
if (len == 0) return 0;
290+
if (!next()) return -1;
291+
int r = in.read(b, off, Math.min(len, left));
292+
if (r < 0) {
293+
done = true;
294+
return -1;
295+
}
296+
left -= r;
297+
return r;
298+
}
299+
}
300+
}

‎android/app/src/main/java/com/openipc/decoder/QuadCell.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,19 @@ public void onSdp(Sdp sdp) {
549549
if (sink != null) sink.configure(sdp.audio, audioRoute);
550550
}
551551

552+
@Override
553+
public boolean onJpegFrame(byte[] buf, int offset, int length) {
554+
// An http:// MJPEG slot (#121): the frame is whole already, no RTP to assemble
555+
if (!jpeg || length < 4) return false;
556+
videoPackets++;
557+
framesIn++;
558+
Frame frame = cellFramePool.obtain(length);
559+
System.arraycopy(buf, offset, frame.data(), 0, length);
560+
frame.setLength(length);
561+
jpegSink.onNal(frame);
562+
return true;
563+
}
564+
552565
@Override
553566
public boolean onPacket(int channel, byte[] buf, int offset, int length) {
554567
if (length < 12) return false;

‎android/app/src/main/java/com/openipc/decoder/RtspClient.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ public void onSdp(Sdp sdp) {
137137
private final NalAssembler.Sink nalSink;
138138
private int queueFullDrops;
139139

140+
@Override
141+
public boolean onJpegFrame(byte[] buf, int offset, int length) {
142+
// An http:// MJPEG slot (#121): the frame is whole already, no RTP to assemble
143+
if (!videoJpeg || length < 4) return false;
144+
Frame frame = framePool.obtain(length);
145+
System.arraycopy(buf, offset, frame.data(), 0, length);
146+
frame.setLength(length);
147+
nalSink.onNal(frame);
148+
return true;
149+
}
150+
140151
@Override
141152
public boolean onPacket(int channel, byte[] buf, int offset, int length) {
142153
if (length < 12) return false;

0 commit comments

Comments
 (0)