-
-
Notifications
You must be signed in to change notification settings - Fork 231
feat: Isolate MJPEG helpers in the driver #1028
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
04776ac
c2c7ca4
cacf11d
62bda99
4e74a89
0e057a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| export * from './app.js'; | ||
| export * from './lang.js'; | ||
| export * from './memoize.js'; | ||
| export * from './object.js'; | ||
| export {signApp} from './app.js'; | ||
| export {escapeRegExp, isEmpty} from './lang.js'; | ||
| export {memoize} from './memoize.js'; | ||
| export {MJpegStream} from './mjpeg.js'; | ||
| export {assignDefaults} from './object.js'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,299 @@ | ||
| import { | ||
| Transform, | ||
| Writable, | ||
| type Readable, | ||
| type TransformCallback, | ||
| type WritableOptions, | ||
| } from 'node:stream'; | ||
|
|
||
| import {logger} from 'appium/support.js'; | ||
| import axios from 'axios'; | ||
| import type sharp from 'sharp'; | ||
|
|
||
| const log = logger.getLogger('MJPEG'); | ||
|
|
||
| const DEFAULT_SERVER_TIMEOUT_MS = 10000; | ||
| const JPEG_SOI = Buffer.from([0xff, 0xd8]); | ||
| const JPEG_EOI = Buffer.from([0xff, 0xd9]); | ||
| const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i; | ||
|
|
||
| /** | ||
| * Extracts individual JPEG frames out of a multipart MJPEG-over-HTTP byte stream by | ||
| * scanning for JPEG start/end-of-image markers and the multipart `Content-Length` header. | ||
| */ | ||
| export class MjpegFrameParser extends Transform { | ||
| private buffer: Buffer | null = null; | ||
| private expectedLength = 0; | ||
| private bytesWritten = 0; | ||
| private isReading = false; | ||
|
|
||
| /* eslint-disable promise/prefer-await-to-callbacks -- Transform._transform is callback-based */ | ||
| override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void { | ||
| const startIdx = chunk.indexOf(JPEG_SOI); | ||
| const endIdx = chunk.indexOf(JPEG_EOI); | ||
| const lengthMatch = CONTENT_LENGTH_RE.exec(chunk.toString('latin1')); | ||
|
|
||
| if (this.buffer && (this.isReading || startIdx > -1)) { | ||
| this.appendChunk(chunk, startIdx, endIdx); | ||
| } | ||
| if (lengthMatch) { | ||
| this.startFrame(Number(lengthMatch[1]), chunk, startIdx, endIdx); | ||
| } | ||
| callback(); | ||
| } | ||
| /* eslint-enable promise/prefer-await-to-callbacks */ | ||
|
|
||
| private startFrame(length: number, chunk: Buffer, start: number, end: number): void { | ||
| this.expectedLength = length; | ||
| this.buffer = Buffer.alloc(length); | ||
| this.bytesWritten = 0; | ||
| this.isReading = false; | ||
|
|
||
| if (start < 0) { | ||
| return; | ||
| } | ||
| const hasEnd = end > start; | ||
| const copyEnd = hasEnd ? end + JPEG_EOI.length : chunk.length; | ||
| // Buffer.copy() silently truncates if the destination has less room than requested, | ||
| // so bytesWritten must track what was actually copied, not the requested range size. | ||
| this.bytesWritten = chunk.copy(this.buffer, 0, start, copyEnd); | ||
|
|
||
| if (hasEnd) { | ||
| this.emitFrame(); | ||
| } else { | ||
| this.isReading = true; | ||
| } | ||
| } | ||
|
|
||
| private appendChunk(chunk: Buffer, start: number, end: number): void { | ||
| if (!this.buffer) { | ||
| return; | ||
| } | ||
| const copyStart = start > -1 ? start : 0; | ||
| const copyEnd = end > -1 ? end + JPEG_EOI.length : chunk.length; | ||
| // Buffer.copy() silently truncates if the destination has less room than requested, | ||
| // so bytesWritten must track what was actually copied, not the requested range size. | ||
| this.bytesWritten += chunk.copy(this.buffer, this.bytesWritten, copyStart, copyEnd); | ||
|
|
||
| if (end > -1 || this.bytesWritten === this.expectedLength) { | ||
| this.emitFrame(); | ||
| } else { | ||
| this.isReading = true; | ||
| } | ||
| } | ||
|
|
||
| private emitFrame(): void { | ||
| this.isReading = false; | ||
| if (this.buffer) { | ||
| // Only push what was actually copied: the buffer is allocated to `expectedLength` | ||
| // up front, so pushing it as-is would leak trailing zero bytes whenever fewer | ||
| // bytes were actually written (e.g. a mismatched/truncated Content-Length). | ||
| this.push(this.buffer.subarray(0, this.bytesWritten)); | ||
| } | ||
| // Clear the frame state so an unrelated later chunk (e.g. one with a JPEG SOI | ||
| // marker but no Content-Length header) cannot be appended onto a buffer that | ||
| // has already been pushed downstream. | ||
| this.buffer = null; | ||
| this.expectedLength = 0; | ||
| this.bytesWritten = 0; | ||
| } | ||
|
mykola-mokhnach marked this conversation as resolved.
|
||
| } | ||
|
|
||
| let sharpModule: typeof sharp | null = null; | ||
|
|
||
| async function requireSharp(): Promise<typeof sharp> { | ||
| if (sharpModule) { | ||
| return sharpModule; | ||
| } | ||
| try { | ||
| sharpModule = (await import('sharp')).default; | ||
| return sharpModule; | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| throw new Error( | ||
| `Cannot load the 'sharp' module needed for MJPEG frame processing. ` + | ||
| `Consider visiting https://sharp.pixelplumbing.com/install for troubleshooting. ` + | ||
| `Original error: ${message}`, | ||
| {cause: err}, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const noop = () => {}; | ||
|
|
||
| /** | ||
| * Connects to an MJPEG-over-HTTP stream and keeps track of the last JPEG frame received, | ||
| * so that it can be used as a cheap, low-latency screenshot source. | ||
| */ | ||
| export class MJpegStream extends Writable { | ||
| readonly errorHandler: (err: Error) => void; | ||
| readonly url: string; | ||
| private updateCount = 0; | ||
| private lastChunk: Buffer | null = null; | ||
| private registerStartSuccess: (() => void) | null = null; | ||
| private registerStartFailure: ((err: Error) => void) | null = null; | ||
| private responseStream: Readable | null = null; | ||
| private consumer: MjpegFrameParser | null = null; | ||
|
|
||
| /** | ||
| * @param mJpegUrl - URL of the MJPEG-over-HTTP stream | ||
| * @param errorHandler - additional function that will be called in the case of any errors | ||
| * @param options - Options to pass to the Writable constructor | ||
| */ | ||
| constructor( | ||
| mJpegUrl: string, | ||
| errorHandler: (err: Error) => void = noop, | ||
| options: WritableOptions = {}, | ||
| ) { | ||
| super(options); | ||
| this.errorHandler = errorHandler; | ||
| this.url = mJpegUrl; | ||
| this.clear(); | ||
| } | ||
|
|
||
| get lastChunkBase64(): string | null { | ||
| const lastChunk = this.lastChunk; | ||
| return lastChunk && lastChunk.length > 0 ? lastChunk.toString('base64') : null; | ||
| } | ||
|
|
||
| async lastChunkPNG(): Promise<Buffer | null> { | ||
| const chunk = this.lastChunk; | ||
| if (!chunk || chunk.length === 0) { | ||
| return null; | ||
| } | ||
| try { | ||
| const sharp = await requireSharp(); | ||
| return await sharp(chunk).png().toBuffer(); | ||
| } catch (err: any) { | ||
| log.warn(`Cannot convert MJPEG chunk to PNG: ${err.message}`); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| async lastChunkPNGBase64(): Promise<string | null> { | ||
| const png = await this.lastChunkPNG(); | ||
| return png ? png.toString('base64') : null; | ||
| } | ||
|
|
||
| clear(): void { | ||
| this.registerStartSuccess = null; | ||
| this.registerStartFailure = null; | ||
| this.responseStream = null; | ||
| this.consumer = null; | ||
| this.lastChunk = null; | ||
| this.updateCount = 0; | ||
| } | ||
|
|
||
| async start(serverTimeout = DEFAULT_SERVER_TIMEOUT_MS): Promise<void> { | ||
| this.stop(); | ||
|
|
||
| this.consumer = new MjpegFrameParser(); | ||
| const url = this.url; | ||
| // Bound only the connect phase with an abort signal; axios's own `timeout` option would | ||
| // otherwise keep ticking for the whole request lifetime and race with the "first frame" | ||
| // watchdog below, since both would share the same deadline. | ||
| const connectController = new AbortController(); | ||
| const connectTimeoutId = setTimeout(() => connectController.abort(), serverTimeout); | ||
| try { | ||
| try { | ||
| this.responseStream = ( | ||
| await axios({ | ||
| url, | ||
| responseType: 'stream', | ||
| signal: connectController.signal, | ||
| }) | ||
| ).data as Readable; | ||
| } catch (e) { | ||
| let message: string; | ||
| if (e && typeof e === 'object' && 'response' in e) { | ||
| message = JSON.stringify((e as {response: unknown}).response); | ||
| } else if (e instanceof Error) { | ||
| message = e.message; | ||
| } else { | ||
| message = String(e); | ||
| } | ||
| throw new Error( | ||
| `Cannot connect to the MJPEG stream at ${url}. Original error: ${message}`, | ||
| { | ||
| cause: e, | ||
| }, | ||
| ); | ||
| } | ||
| } finally { | ||
| clearTimeout(connectTimeoutId); | ||
| } | ||
|
|
||
| const onErr = (err: Error) => { | ||
| this.lastChunk = null; | ||
| log.error(`Error getting MJPEG screenshot chunk: ${err.message}`); | ||
| this.errorHandler(err); | ||
| this.registerStartFailure?.(err); | ||
| }; | ||
| const onClose = () => { | ||
| log.debug(`The connection to MJPEG server at ${url} has been closed`); | ||
| this.lastChunk = null; | ||
| // No-op if start() has already resolved; only rejects a still-pending start(). | ||
| this.registerStartFailure?.( | ||
| new Error( | ||
| `The connection to the MJPEG stream at ${url} has been closed before any frame was received`, | ||
| ), | ||
| ); | ||
| }; | ||
|
Comment on lines
+236
to
+245
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 62bda99. |
||
|
|
||
| let timeoutId: NodeJS.Timeout | undefined; | ||
| const startPromise = new Promise<void>((resolve, reject) => { | ||
| this.registerStartSuccess = resolve; | ||
| this.registerStartFailure = reject; | ||
| timeoutId = setTimeout( | ||
| () => | ||
| reject(new Error(`Waited ${serverTimeout}ms but the MJPEG server never sent any images`)), | ||
| serverTimeout, | ||
| ); | ||
| }); | ||
|
|
||
| (this.responseStream as Readable & {pipe<T extends Writable>(dest: T): T}) | ||
| .once('close', onClose) | ||
| .on('error', onErr) | ||
| .pipe(this.consumer) | ||
| .pipe(this); | ||
|
|
||
| try { | ||
| await startPromise; | ||
| } catch (err) { | ||
| // Do not leak the underlying HTTP connection/pipes if we never reached a usable state. | ||
| this.stop(); | ||
| throw err; | ||
| } finally { | ||
| clearTimeout(timeoutId); | ||
| } | ||
| } | ||
|
|
||
| stop(): void { | ||
| if (this.consumer) { | ||
| this.consumer.unpipe(this); | ||
| } | ||
| if (this.responseStream) { | ||
| if (this.consumer) { | ||
| this.responseStream.unpipe(this.consumer); | ||
| } | ||
| this.responseStream.destroy(); | ||
| } | ||
| this.clear(); | ||
| } | ||
|
|
||
| /* eslint-disable promise/prefer-await-to-callbacks -- Writable._write is callback-based */ | ||
| override _write( | ||
| chunk: Buffer | string, | ||
| _encoding: BufferEncoding, | ||
| callback: (error?: Error | null) => void, | ||
| ): void { | ||
| this.lastChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); | ||
| this.updateCount++; | ||
| if (this.registerStartSuccess) { | ||
| this.registerStartSuccess(); | ||
| this.registerStartSuccess = null; | ||
| } | ||
| callback(); | ||
| } | ||
| /* eslint-enable promise/prefer-await-to-callbacks */ | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.