Skip to content

Commit f32c5b7

Browse files
feat: Isolate MJPEG helpers in the driver (#1028)
1 parent d293dd9 commit f32c5b7

5 files changed

Lines changed: 517 additions & 8 deletions

File tree

lib/driver.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import type {
1111
import {DEFAULT_ADB_PORT, type ADB} from 'appium-adb';
1212
import {AndroidDriver, utils} from 'appium-android-driver';
1313
import {BaseDriver, DeviceSettings} from 'appium/driver.js';
14-
import {mjpeg, util} from 'appium/support.js';
14+
import {util} from 'appium/support.js';
1515
import UIAUTOMATOR2_CONSTRAINTS, {type Uiautomator2Constraints} from './constraints.js';
1616
import {newMethodMap} from './method-map.js';
17-
import {assignDefaults, memoize} from './utils/index.js';
17+
import {assignDefaults, memoize, MJpegStream} from './utils/index.js';
1818
import type {
1919
Uiautomator2Settings,
2020
Uiautomator2DeviceDetails,
@@ -233,7 +233,7 @@ class AndroidUiautomator2Driver
233233

234234
_originalIme: string | null;
235235

236-
mjpegStream?: mjpeg.MJpegStream;
236+
mjpegStream?: MJpegStream;
237237

238238
override caps: Uiautomator2DriverCaps;
239239

@@ -457,7 +457,7 @@ class AndroidUiautomator2Driver
457457

458458
if (this.opts.mjpegScreenshotUrl) {
459459
this.log.info(`Starting MJPEG stream reading URL: '${this.opts.mjpegScreenshotUrl}'`);
460-
this.mjpegStream = new mjpeg.MJpegStream(this.opts.mjpegScreenshotUrl);
460+
this.mjpegStream = new MJpegStream(this.opts.mjpegScreenshotUrl);
461461
await this.mjpegStream.start();
462462
}
463463
return [sessionId, result];

lib/utils/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
export * from './app.js';
2-
export * from './lang.js';
3-
export * from './memoize.js';
4-
export * from './object.js';
1+
export {signApp} from './app.js';
2+
export {escapeRegExp, isEmpty} from './lang.js';
3+
export {memoize} from './memoize.js';
4+
export {MJpegStream} from './mjpeg.js';
5+
export {assignDefaults} from './object.js';

lib/utils/mjpeg.ts

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import {
2+
Transform,
3+
Writable,
4+
type Readable,
5+
type TransformCallback,
6+
type WritableOptions,
7+
} from 'node:stream';
8+
9+
import {logger} from 'appium/support.js';
10+
import axios from 'axios';
11+
import type sharp from 'sharp';
12+
13+
const log = logger.getLogger('MJPEG');
14+
15+
const DEFAULT_SERVER_TIMEOUT_MS = 10000;
16+
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
17+
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
18+
const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i;
19+
20+
/**
21+
* Extracts individual JPEG frames out of a multipart MJPEG-over-HTTP byte stream by
22+
* scanning for JPEG start/end-of-image markers and the multipart `Content-Length` header.
23+
*/
24+
export class MjpegFrameParser extends Transform {
25+
private buffer: Buffer | null = null;
26+
private expectedLength = 0;
27+
private bytesWritten = 0;
28+
private isReading = false;
29+
30+
/* eslint-disable promise/prefer-await-to-callbacks -- Transform._transform is callback-based */
31+
override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
32+
const startIdx = chunk.indexOf(JPEG_SOI);
33+
const endIdx = chunk.indexOf(JPEG_EOI);
34+
const lengthMatch = CONTENT_LENGTH_RE.exec(chunk.toString('latin1'));
35+
36+
if (this.buffer && (this.isReading || startIdx > -1)) {
37+
this.appendChunk(chunk, endIdx);
38+
}
39+
if (lengthMatch) {
40+
this.startFrame(Number(lengthMatch[1]), chunk, startIdx, endIdx);
41+
}
42+
callback();
43+
}
44+
/* eslint-enable promise/prefer-await-to-callbacks */
45+
46+
private startFrame(length: number, chunk: Buffer, start: number, end: number): void {
47+
this.expectedLength = length;
48+
this.buffer = Buffer.alloc(length);
49+
this.bytesWritten = 0;
50+
this.isReading = false;
51+
52+
if (start < 0) {
53+
return;
54+
}
55+
const hasEnd = end > start;
56+
const copyEnd = hasEnd ? end + JPEG_EOI.length : chunk.length;
57+
// Buffer.copy() silently truncates if the destination has less room than requested,
58+
// so bytesWritten must track what was actually copied, not the requested range size.
59+
this.bytesWritten = chunk.copy(this.buffer, 0, start, copyEnd);
60+
61+
if (hasEnd) {
62+
this.emitFrame();
63+
} else {
64+
this.isReading = true;
65+
}
66+
}
67+
68+
private appendChunk(chunk: Buffer, end: number): void {
69+
if (!this.buffer) {
70+
return;
71+
}
72+
// We are continuing a frame that is already open, so the whole chunk belongs to it
73+
// and must be copied starting at offset 0. A JPEG SOI marker found anywhere in this
74+
// chunk can only belong to the *next* frame (it necessarily comes after our own EOI),
75+
// so it must never be used as the copy start here, or the current frame's tail bytes
76+
// between offset 0 and that marker would be skipped, truncating it.
77+
const copyEnd = end > -1 ? end + JPEG_EOI.length : chunk.length;
78+
// Buffer.copy() silently truncates if the destination has less room than requested,
79+
// so bytesWritten must track what was actually copied, not the requested range size.
80+
this.bytesWritten += chunk.copy(this.buffer, this.bytesWritten, 0, copyEnd);
81+
82+
if (end > -1 || this.bytesWritten === this.expectedLength) {
83+
this.emitFrame();
84+
} else {
85+
this.isReading = true;
86+
}
87+
}
88+
89+
private emitFrame(): void {
90+
this.isReading = false;
91+
if (this.buffer) {
92+
// Only push what was actually copied: the buffer is allocated to `expectedLength`
93+
// up front, so pushing it as-is would leak trailing zero bytes whenever fewer
94+
// bytes were actually written (e.g. a mismatched/truncated Content-Length).
95+
this.push(this.buffer.subarray(0, this.bytesWritten));
96+
}
97+
// Clear the frame state so an unrelated later chunk (e.g. one with a JPEG SOI
98+
// marker but no Content-Length header) cannot be appended onto a buffer that
99+
// has already been pushed downstream.
100+
this.buffer = null;
101+
this.expectedLength = 0;
102+
this.bytesWritten = 0;
103+
}
104+
}
105+
106+
let sharpModule: typeof sharp | null = null;
107+
108+
async function requireSharp(): Promise<typeof sharp> {
109+
if (sharpModule) {
110+
return sharpModule;
111+
}
112+
try {
113+
sharpModule = (await import('sharp')).default;
114+
return sharpModule;
115+
} catch (err) {
116+
const message = err instanceof Error ? err.message : String(err);
117+
throw new Error(
118+
`Cannot load the 'sharp' module needed for MJPEG frame processing. ` +
119+
`Consider visiting https://sharp.pixelplumbing.com/install for troubleshooting. ` +
120+
`Original error: ${message}`,
121+
{cause: err},
122+
);
123+
}
124+
}
125+
126+
const noop = () => {};
127+
128+
/**
129+
* Connects to an MJPEG-over-HTTP stream and keeps track of the last JPEG frame received,
130+
* so that it can be used as a cheap, low-latency screenshot source.
131+
*/
132+
export class MJpegStream extends Writable {
133+
readonly errorHandler: (err: Error) => void;
134+
readonly url: string;
135+
private updateCount = 0;
136+
private lastChunk: Buffer | null = null;
137+
private registerStartSuccess: (() => void) | null = null;
138+
private registerStartFailure: ((err: Error) => void) | null = null;
139+
private responseStream: Readable | null = null;
140+
private consumer: MjpegFrameParser | null = null;
141+
142+
/**
143+
* @param mJpegUrl - URL of the MJPEG-over-HTTP stream
144+
* @param errorHandler - additional function that will be called in the case of any errors
145+
* @param options - Options to pass to the Writable constructor
146+
*/
147+
constructor(
148+
mJpegUrl: string,
149+
errorHandler: (err: Error) => void = noop,
150+
options: WritableOptions = {},
151+
) {
152+
super(options);
153+
this.errorHandler = errorHandler;
154+
this.url = mJpegUrl;
155+
this.clear();
156+
}
157+
158+
get lastChunkBase64(): string | null {
159+
const lastChunk = this.lastChunk;
160+
return lastChunk && lastChunk.length > 0 ? lastChunk.toString('base64') : null;
161+
}
162+
163+
async lastChunkPNG(): Promise<Buffer | null> {
164+
const chunk = this.lastChunk;
165+
if (!chunk || chunk.length === 0) {
166+
return null;
167+
}
168+
try {
169+
const sharp = await requireSharp();
170+
return await sharp(chunk).png().toBuffer();
171+
} catch (err: any) {
172+
log.warn(`Cannot convert MJPEG chunk to PNG: ${err.message}`);
173+
return null;
174+
}
175+
}
176+
177+
async lastChunkPNGBase64(): Promise<string | null> {
178+
const png = await this.lastChunkPNG();
179+
return png ? png.toString('base64') : null;
180+
}
181+
182+
clear(): void {
183+
this.registerStartSuccess = null;
184+
this.registerStartFailure = null;
185+
this.responseStream = null;
186+
this.consumer = null;
187+
this.lastChunk = null;
188+
this.updateCount = 0;
189+
}
190+
191+
async start(serverTimeout = DEFAULT_SERVER_TIMEOUT_MS): Promise<void> {
192+
this.stop();
193+
194+
this.consumer = new MjpegFrameParser();
195+
const url = this.url;
196+
// Bound only the connect phase with an abort signal; axios's own `timeout` option would
197+
// otherwise keep ticking for the whole request lifetime and race with the "first frame"
198+
// watchdog below, since both would share the same deadline.
199+
const connectController = new AbortController();
200+
const connectTimeoutId = setTimeout(() => connectController.abort(), serverTimeout);
201+
try {
202+
try {
203+
this.responseStream = (
204+
await axios({
205+
url,
206+
responseType: 'stream',
207+
signal: connectController.signal,
208+
})
209+
).data as Readable;
210+
} catch (e) {
211+
let message: string;
212+
if (e && typeof e === 'object' && 'response' in e) {
213+
message = JSON.stringify((e as {response: unknown}).response);
214+
} else if (e instanceof Error) {
215+
message = e.message;
216+
} else {
217+
message = String(e);
218+
}
219+
throw new Error(
220+
`Cannot connect to the MJPEG stream at ${url}. Original error: ${message}`,
221+
{
222+
cause: e,
223+
},
224+
);
225+
}
226+
} finally {
227+
clearTimeout(connectTimeoutId);
228+
}
229+
230+
const onErr = (err: Error) => {
231+
this.lastChunk = null;
232+
log.error(`Error getting MJPEG screenshot chunk: ${err.message}`);
233+
this.errorHandler(err);
234+
this.registerStartFailure?.(err);
235+
};
236+
const onClose = () => {
237+
log.debug(`The connection to MJPEG server at ${url} has been closed`);
238+
this.lastChunk = null;
239+
// No-op if start() has already resolved; only rejects a still-pending start().
240+
this.registerStartFailure?.(
241+
new Error(
242+
`The connection to the MJPEG stream at ${url} has been closed before any frame was received`,
243+
),
244+
);
245+
};
246+
247+
let timeoutId: NodeJS.Timeout | undefined;
248+
const startPromise = new Promise<void>((resolve, reject) => {
249+
this.registerStartSuccess = resolve;
250+
this.registerStartFailure = reject;
251+
timeoutId = setTimeout(
252+
() =>
253+
reject(new Error(`Waited ${serverTimeout}ms but the MJPEG server never sent any images`)),
254+
serverTimeout,
255+
);
256+
});
257+
258+
(this.responseStream as Readable & {pipe<T extends Writable>(dest: T): T})
259+
.once('close', onClose)
260+
.on('error', onErr)
261+
.pipe(this.consumer)
262+
.pipe(this);
263+
264+
try {
265+
await startPromise;
266+
} catch (err) {
267+
// Do not leak the underlying HTTP connection/pipes if we never reached a usable state.
268+
this.stop();
269+
throw err;
270+
} finally {
271+
clearTimeout(timeoutId);
272+
}
273+
}
274+
275+
stop(): void {
276+
if (this.consumer) {
277+
this.consumer.unpipe(this);
278+
}
279+
if (this.responseStream) {
280+
if (this.consumer) {
281+
this.responseStream.unpipe(this.consumer);
282+
}
283+
this.responseStream.destroy();
284+
}
285+
this.clear();
286+
}
287+
288+
/* eslint-disable promise/prefer-await-to-callbacks -- Writable._write is callback-based */
289+
override _write(
290+
chunk: Buffer | string,
291+
_encoding: BufferEncoding,
292+
callback: (error?: Error | null) => void,
293+
): void {
294+
this.lastChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
295+
this.updateCount++;
296+
if (this.registerStartSuccess) {
297+
this.registerStartSuccess();
298+
this.registerStartSuccess = null;
299+
}
300+
callback();
301+
}
302+
/* eslint-enable promise/prefer-await-to-callbacks */
303+
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@
9999
"peerDependencies": {
100100
"appium": "^3.0.0-rc.2"
101101
},
102+
"optionalDependencies": {
103+
"sharp": "^0.x"
104+
},
102105
"engines": {
103106
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
104107
"npm": ">=10"

0 commit comments

Comments
 (0)