Skip to content

Commit 04776ac

Browse files
feat: Isolate MJPEG helpers in the driver
1 parent 88e7871 commit 04776ac

5 files changed

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

package.json

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

0 commit comments

Comments
 (0)