neoagent 2.4.1-beta.7 → 2.4.1-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const { requireAuth } = require('../middleware/auth');
5
+ const { sanitizeError } = require('../utils/security');
6
+ const { AndroidStream } = require('../services/streaming/android-stream');
7
+ const { BrowserStream } = require('../services/streaming/browser-stream');
8
+
9
+ const router = express.Router();
10
+ router.use(requireAuth);
11
+
12
+ function boundedInt(value, fallback, min, max) {
13
+ const parsed = Number(value);
14
+ if (!Number.isFinite(parsed)) return fallback;
15
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
16
+ }
17
+
18
+ function streamHub(req) {
19
+ const hub = req.app?.locals?.streamHub;
20
+ if (!hub) throw new Error('Stream hub is unavailable.');
21
+ return hub;
22
+ }
23
+
24
+ async function desktopProvider(req) {
25
+ const factory = req.app?.locals?.getDesktopProviderForUser;
26
+ if (typeof factory !== 'function') {
27
+ throw new Error('Desktop provider is unavailable.');
28
+ }
29
+ return factory(req.session?.userId);
30
+ }
31
+
32
+ async function androidController(req) {
33
+ const runtimeManager = req.app?.locals?.runtimeManager;
34
+ if (runtimeManager && typeof runtimeManager.getAndroidProviderForUser === 'function') {
35
+ return runtimeManager.getAndroidProviderForUser(req.session?.userId);
36
+ }
37
+ throw new Error('Android controller is unavailable.');
38
+ }
39
+
40
+ async function browserController(req) {
41
+ const runtimeManager = req.app?.locals?.runtimeManager;
42
+ if (runtimeManager && typeof runtimeManager.getBrowserProviderForUser === 'function') {
43
+ return runtimeManager.getBrowserProviderForUser(req.session?.userId);
44
+ }
45
+ throw new Error('Browser controller is unavailable.');
46
+ }
47
+
48
+ function normalizePlatform(value) {
49
+ const platform = String(value || '').trim().toLowerCase();
50
+ if (platform === 'desktop' || platform === 'android' || platform === 'browser') return platform;
51
+ const error = new Error('platform must be desktop, android, or browser.');
52
+ error.status = 400;
53
+ throw error;
54
+ }
55
+
56
+ function normalizeDeviceId(platform, value) {
57
+ const deviceId = String(value || '').trim();
58
+ if (deviceId) return deviceId;
59
+ if (platform === 'browser') return 'browser';
60
+ const error = new Error('deviceId is required.');
61
+ error.status = 400;
62
+ throw error;
63
+ }
64
+
65
+ async function resolveStartDeviceId(platform, req) {
66
+ const rawDeviceId = String(req.body?.deviceId || '').trim();
67
+ if (platform === 'browser') return { deviceId: 'browser', controller: null };
68
+ if (platform === 'android') {
69
+ const controller = await androidController(req);
70
+ const status = typeof controller.getStatus === 'function'
71
+ ? await controller.getStatus().catch(() => ({}))
72
+ : {};
73
+ const adbSerial = String(status?.adbSerial || controller.adbSerial || '').trim();
74
+ if (rawDeviceId && adbSerial && rawDeviceId !== adbSerial) {
75
+ const error = new Error('Android deviceId does not match the active emulator serial.');
76
+ error.status = 400;
77
+ throw error;
78
+ }
79
+ if (rawDeviceId) return { deviceId: rawDeviceId, controller };
80
+ if (!adbSerial) throw new Error('Android deviceId is required when no emulator serial is available.');
81
+ return { deviceId: adbSerial, controller };
82
+ }
83
+ if (rawDeviceId) return { deviceId: rawDeviceId, controller: null };
84
+ return { deviceId: normalizeDeviceId(platform, rawDeviceId), controller: null };
85
+ }
86
+
87
+ async function resolveStopDeviceId(platform, req) {
88
+ const rawDeviceId = String(req.body?.deviceId || '').trim();
89
+ if (rawDeviceId) return rawDeviceId;
90
+ if (platform === 'browser') return 'browser';
91
+ if (platform === 'android') {
92
+ const controller = await androidController(req);
93
+ const status = typeof controller.getStatus === 'function'
94
+ ? await controller.getStatus().catch(() => ({}))
95
+ : {};
96
+ const adbSerial = String(status?.adbSerial || controller.adbSerial || '').trim();
97
+ if (adbSerial) return adbSerial;
98
+ }
99
+ const error = new Error('deviceId is required.');
100
+ error.status = 400;
101
+ throw error;
102
+ }
103
+
104
+ function sendError(res, err) {
105
+ res.status(Number(err?.status || 500)).json({ error: sanitizeError(err) });
106
+ }
107
+
108
+ router.post('/start', async (req, res) => {
109
+ try {
110
+ const userId = req.session?.userId;
111
+ const platform = normalizePlatform(req.body?.platform);
112
+ const resolved = await resolveStartDeviceId(platform, req);
113
+ const deviceId = resolved.deviceId;
114
+ const fps = boundedInt(req.body?.fps, platform === 'android' ? 10 : 15, 1, platform === 'android' ? 15 : 20);
115
+ const quality = boundedInt(req.body?.quality, platform === 'android' ? 75 : 80, 30, 95);
116
+ const hub = streamHub(req);
117
+ await hub.stopStream(userId, platform, deviceId, 'restart');
118
+
119
+ if (platform === 'desktop') {
120
+ const provider = await desktopProvider(req);
121
+ const result = await provider.startStream({
122
+ deviceId,
123
+ fps,
124
+ quality,
125
+ displayId: req.body?.displayId || null,
126
+ });
127
+ const resolvedDeviceId = result?.deviceId || result?.device?.deviceId || deviceId;
128
+ hub.markStarted(userId, resolvedDeviceId, platform, { fps, quality }, () =>
129
+ provider.stopStream({ deviceId: resolvedDeviceId }));
130
+ return res.json({ ok: true, platform, deviceId: resolvedDeviceId, fps, quality });
131
+ }
132
+
133
+ if (platform === 'android') {
134
+ const controller = resolved.controller || await androidController(req);
135
+ const stream = new AndroidStream({ userId, deviceId, controller, streamHub: hub, fps, quality });
136
+ stream.start();
137
+ hub.markStarted(userId, deviceId, platform, { fps, quality }, () => stream.stop());
138
+ return res.json({ ok: true, platform, deviceId, fps, quality });
139
+ }
140
+
141
+ const controller = await browserController(req);
142
+ const stream = new BrowserStream({ userId, deviceId, controller, streamHub: hub, fps, quality });
143
+ stream.start();
144
+ hub.markStarted(userId, deviceId, platform, { fps, quality }, () => stream.stop());
145
+ return res.json({ ok: true, platform, deviceId, fps, quality });
146
+ } catch (err) {
147
+ sendError(res, err);
148
+ }
149
+ });
150
+
151
+ router.post('/stop', async (req, res) => {
152
+ try {
153
+ const platform = normalizePlatform(req.body?.platform);
154
+ const deviceId = await resolveStopDeviceId(platform, req);
155
+ const stopped = await streamHub(req).stopStream(req.session?.userId, platform, deviceId, 'api_stop');
156
+ res.json({ ok: true, stopped, platform, deviceId });
157
+ } catch (err) {
158
+ sendError(res, err);
159
+ }
160
+ });
161
+
162
+ router.get('/status', async (req, res) => {
163
+ try {
164
+ const platform = req.query?.platform ? normalizePlatform(req.query.platform) : null;
165
+ const deviceId = req.query?.deviceId
166
+ ? normalizeDeviceId(platform || 'desktop', req.query.deviceId)
167
+ : null;
168
+ const hub = streamHub(req);
169
+ if (deviceId) {
170
+ return res.json(hub.status(req.session?.userId, platform || 'desktop', deviceId));
171
+ }
172
+ return res.json({ streams: hub.listStatus(req.session?.userId), platform });
173
+ } catch (err) {
174
+ sendError(res, err);
175
+ }
176
+ });
177
+
178
+ module.exports = router;
@@ -347,12 +347,16 @@ class AndroidController {
347
347
  // ── Actions ───────────────────────────────────────────────────────────────
348
348
 
349
349
  async screenshot(_opts = {}) {
350
- const serial = this.#requireSerial();
351
- const r = this.#adbCapture(serial, ['exec-out', 'screencap', '-p']);
350
+ const r = await this.capturePng();
352
351
  if (!r?.length) throw new Error('screencap returned no data');
353
352
  return { screenshotPath: this.#saveArtifact(r) };
354
353
  }
355
354
 
355
+ async capturePng(_opts = {}) {
356
+ const serial = this.#requireSerial();
357
+ return this.#adbCapture(serial, ['exec-out', 'screencap', '-p']);
358
+ }
359
+
356
360
  async observe(_opts = {}) { return this.screenshot(); }
357
361
 
358
362
  async tap({ x, y } = {}) {
@@ -453,6 +453,22 @@ class BrowserController {
453
453
  };
454
454
  }
455
455
 
456
+ async screenshotJpeg(quality = 80, options = {}) {
457
+ const page = await this.ensurePage();
458
+ const screenshotOptions = {
459
+ type: 'jpeg',
460
+ quality: Math.min(95, Math.max(30, Math.floor(Number(quality) || 80))),
461
+ fullPage: options.fullPage === true,
462
+ };
463
+ if (options.selector) {
464
+ const element = await page.$(options.selector);
465
+ if (element) {
466
+ return element.screenshot(screenshotOptions);
467
+ }
468
+ }
469
+ return page.screenshot(screenshotOptions);
470
+ }
471
+
456
472
  async navigate(url, options = {}) {
457
473
  const page = await this.ensurePage();
458
474
 
@@ -1,5 +1,11 @@
1
1
  const { WebSocketServer } = require('ws');
2
- const { DESKTOP_COMPANION_WS_PATH, parseDesktopMessage } = require('./protocol');
2
+ const {
3
+ DESKTOP_COMPANION_WS_PATH,
4
+ FRAME_TYPE_VIDEO,
5
+ MAX_DESKTOP_STREAM_FRAME_BYTES,
6
+ parseBinaryFrame,
7
+ parseDesktopMessage,
8
+ } = require('./protocol');
3
9
  const {
4
10
  assertDesktopHelloAuth,
5
11
  isDesktopCompanionHello,
@@ -72,8 +78,11 @@ function createUpgradeThrottleObserver() {
72
78
  return { record, snapshot };
73
79
  }
74
80
 
75
- function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware) {
76
- const wss = new WebSocketServer({ noServer: true });
81
+ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware, streamHub = null) {
82
+ const wss = new WebSocketServer({
83
+ noServer: true,
84
+ maxPayload: MAX_DESKTOP_STREAM_FRAME_BYTES,
85
+ });
77
86
  const upgradeAttempts = new Map();
78
87
  const upgradeThrottleObserver = createUpgradeThrottleObserver();
79
88
 
@@ -173,6 +182,22 @@ function bindDesktopCompanionGateway(httpServer, app, sessionMiddleware) {
173
182
  device,
174
183
  }));
175
184
  ws.on('message', (nextData) => {
185
+ const activeStreamHub = streamHub || app?.locals?.streamHub || null;
186
+ if (
187
+ activeStreamHub
188
+ && Buffer.isBuffer(nextData)
189
+ && nextData.length > 10
190
+ && nextData[0] === FRAME_TYPE_VIDEO
191
+ ) {
192
+ const frame = parseBinaryFrame(nextData);
193
+ if (frame) {
194
+ activeStreamHub.handleFrame(req.session.userId, device.deviceId, {
195
+ ...frame,
196
+ platform: 'desktop',
197
+ });
198
+ }
199
+ return;
200
+ }
176
201
  let parsed;
177
202
  try {
178
203
  parsed = parseDesktopMessage(nextData);
@@ -3,6 +3,8 @@ const DESKTOP_COMPANION_WS_PATH = '/api/desktop/ws';
3
3
  const DESKTOP_COMMANDS = Object.freeze({
4
4
  GET_STATUS: 'getStatus',
5
5
  CAPTURE_FRAME: 'captureFrame',
6
+ STREAM_START: 'startStream',
7
+ STREAM_STOP: 'stopStream',
6
8
  OBSERVE: 'observe',
7
9
  CLICK: 'click',
8
10
  DRAG: 'drag',
@@ -18,6 +20,9 @@ const DESKTOP_COMMANDS = Object.freeze({
18
20
  PING: 'ping',
19
21
  });
20
22
 
23
+ const FRAME_TYPE_VIDEO = 0x01;
24
+ const MAX_DESKTOP_STREAM_FRAME_BYTES = 8 * 1024 * 1024;
25
+
21
26
  class DesktopCompanionUnavailableError extends Error {
22
27
  constructor(message = 'Desktop companion is not connected.') {
23
28
  super(message);
@@ -55,11 +60,38 @@ function parseDesktopMessage(data) {
55
60
  }
56
61
  }
57
62
 
63
+ function parseBinaryFrame(buffer) {
64
+ if (
65
+ !Buffer.isBuffer(buffer)
66
+ || buffer.length <= 10
67
+ || buffer.length > MAX_DESKTOP_STREAM_FRAME_BYTES
68
+ || buffer[0] !== FRAME_TYPE_VIDEO
69
+ ) {
70
+ return null;
71
+ }
72
+ const jpeg = buffer.subarray(10);
73
+ const hasJpegMarkers = jpeg.length >= 4
74
+ && jpeg[0] === 0xff
75
+ && jpeg[1] === 0xd8
76
+ && jpeg[jpeg.length - 2] === 0xff
77
+ && jpeg[jpeg.length - 1] === 0xd9;
78
+ if (!hasJpegMarkers) return null;
79
+ return {
80
+ seq: buffer.readUInt32BE(1),
81
+ ts: buffer.readUInt32BE(5),
82
+ flags: buffer.readUInt8(9),
83
+ jpeg,
84
+ };
85
+ }
86
+
58
87
  module.exports = {
59
88
  DESKTOP_COMPANION_WS_PATH,
60
89
  DESKTOP_COMMANDS,
90
+ FRAME_TYPE_VIDEO,
91
+ MAX_DESKTOP_STREAM_FRAME_BYTES,
61
92
  DesktopCompanionUnavailableError,
62
93
  DesktopCompanionSelectionError,
63
94
  createDesktopCommandMessage,
95
+ parseBinaryFrame,
64
96
  parseDesktopMessage,
65
97
  };
@@ -130,6 +130,16 @@ class DesktopProvider {
130
130
  return this._dispatch(DESKTOP_COMMANDS.CAPTURE_FRAME, options);
131
131
  }
132
132
 
133
+ startStream(options = {}) {
134
+ this._assertReady();
135
+ return this.registry.startStream(this.userId, options.deviceId || null, options);
136
+ }
137
+
138
+ stopStream(options = {}) {
139
+ this._assertReady();
140
+ return this.registry.stopStream(this.userId, options.deviceId || null);
141
+ }
142
+
133
143
  observe(options = {}) {
134
144
  return this._dispatch(DESKTOP_COMMANDS.OBSERVE, options);
135
145
  }
@@ -1,6 +1,8 @@
1
1
  const crypto = require('crypto');
2
2
  const db = require('../../db/database');
3
3
  const {
4
+ DESKTOP_COMMANDS,
5
+ FRAME_TYPE_VIDEO,
4
6
  DesktopCompanionSelectionError,
5
7
  DesktopCompanionUnavailableError,
6
8
  createDesktopCommandMessage,
@@ -373,6 +375,42 @@ class DesktopCompanionRegistry {
373
375
  };
374
376
  }
375
377
 
378
+ async startStream(userId, deviceId, options = {}) {
379
+ const device = this.resolveDevice(userId, deviceId);
380
+ const connection = this.getConnection(userId, device.deviceId);
381
+ if (!connection || !connection.isOpen()) {
382
+ throw new DesktopCompanionUnavailableError();
383
+ }
384
+ const result = await connection.sendCommand(DESKTOP_COMMANDS.STREAM_START, {
385
+ fps: options.fps,
386
+ quality: options.quality,
387
+ displayId: options.displayId || device.activeDisplayId || null,
388
+ }, options);
389
+ connection._streaming = true;
390
+ return {
391
+ ...result,
392
+ success: result?.success !== false,
393
+ deviceId: device.deviceId,
394
+ device: this.getDeviceRecordByDeviceId(userId, device.deviceId),
395
+ };
396
+ }
397
+
398
+ async stopStream(userId, deviceId) {
399
+ const device = this.resolveDevice(userId, deviceId);
400
+ const connection = this.getConnection(userId, device.deviceId);
401
+ if (!connection || !connection.isOpen()) {
402
+ throw new DesktopCompanionUnavailableError();
403
+ }
404
+ const result = await connection.sendCommand(DESKTOP_COMMANDS.STREAM_STOP, {});
405
+ connection._streaming = false;
406
+ return {
407
+ ...result,
408
+ success: result?.success !== false,
409
+ deviceId: device.deviceId,
410
+ device: this.getDeviceRecordByDeviceId(userId, device.deviceId),
411
+ };
412
+ }
413
+
376
414
  getStatus(userId) {
377
415
  const devices = this.listDevices(userId);
378
416
  const onlineDevices = devices.filter((device) => device.online);
@@ -511,6 +549,9 @@ class DesktopCompanionConnection {
511
549
  }
512
550
 
513
551
  _handleMessage(data) {
552
+ if (Buffer.isBuffer(data) && data.length > 0 && data[0] === FRAME_TYPE_VIDEO) {
553
+ return;
554
+ }
514
555
  let message;
515
556
  try {
516
557
  message = parseDesktopMessage(data);
@@ -438,6 +438,7 @@ function configureRealtime(app, io, services) {
438
438
  recordingManager: services.recordingManager,
439
439
  memoryManager: services.memoryManager,
440
440
  voiceRuntimeManager: services.voiceRuntimeManager,
441
+ streamHub: app.locals.streamHub || services.streamHub || null,
441
442
  app,
442
443
  });
443
444
  app.locals.io = io;
@@ -516,6 +517,7 @@ async function startServices(app, io) {
516
517
  recordingManager,
517
518
  memoryManager,
518
519
  voiceRuntimeManager,
520
+ streamHub: app.locals.streamHub || null,
519
521
  });
520
522
 
521
523
  resumePendingRecordingSessions(recordingManager);
@@ -541,6 +543,15 @@ async function stopServices(app) {
541
543
  }
542
544
  }
543
545
 
546
+ if (app.locals.streamHub) {
547
+ try {
548
+ await app.locals.streamHub.shutdown();
549
+ logServiceReady('Stream hub stopped');
550
+ } catch (err) {
551
+ console.error('[StreamHub] Shutdown error:', getErrorMessage(err));
552
+ }
553
+ }
554
+
544
555
  if (app.locals.memoryIngestionService) {
545
556
  try {
546
557
  app.locals.memoryIngestionService.stop();
@@ -253,6 +253,12 @@ class VmBrowserProvider {
253
253
  extract(selector, attribute, all = false) { return this.client.request('POST', '/browser/extract', { selector, attribute, all }); }
254
254
  evaluate(script) { return this.client.request('POST', '/browser/execute', { code: script }); }
255
255
  async screenshot(options = {}) { return this.#materialize(await this.client.request('POST', '/browser/screenshot', options)); }
256
+ async screenshotJpeg(quality = 80, options = {}) {
257
+ const result = await this.client.request('POST', '/browser/screenshot-jpeg', { ...options, quality });
258
+ const content = String(result?.contentBase64 || '');
259
+ if (!content) throw new Error('VM browser screenshot-jpeg returned no data.');
260
+ return Buffer.from(content, 'base64');
261
+ }
256
262
  launch(options = {}) { return this.client.request('POST', '/browser/launch', options); }
257
263
  closeBrowser() { return this.client.request('POST', '/browser/close'); }
258
264
  fill(selector, value) { return this.type(selector, value); }
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const sharp = require('sharp');
4
+
5
+ function clampInt(value, fallback, min, max) {
6
+ const parsed = Number(value);
7
+ if (!Number.isFinite(parsed)) return fallback;
8
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
9
+ }
10
+
11
+ class AndroidStream {
12
+ constructor({ userId, deviceId, controller, streamHub, fps = 10, quality = 75 }) {
13
+ this.userId = String(userId || '');
14
+ this.deviceId = String(deviceId || '');
15
+ this.controller = controller;
16
+ this.streamHub = streamHub;
17
+ this.fps = clampInt(fps, 10, 1, 15);
18
+ this.quality = clampInt(quality, 75, 30, 95);
19
+ this._timer = null;
20
+ this._capturing = false;
21
+ this._seq = 0;
22
+ }
23
+
24
+ start() {
25
+ if (this._timer) return;
26
+ const interval = Math.max(1, Math.floor(1000 / this.fps));
27
+ this._timer = setInterval(() => {
28
+ void this._captureOnce();
29
+ }, interval);
30
+ this._timer.unref?.();
31
+ void this._captureOnce();
32
+ }
33
+
34
+ stop() {
35
+ if (this._timer) {
36
+ clearInterval(this._timer);
37
+ this._timer = null;
38
+ }
39
+ }
40
+
41
+ async _captureOnce() {
42
+ if (this._capturing) return;
43
+ this._capturing = true;
44
+ try {
45
+ if (!this.controller || typeof this.controller.capturePng !== 'function') {
46
+ throw new Error('Android streaming requires a controller with capturePng().');
47
+ }
48
+ const png = await this.controller.capturePng({ deviceId: this.deviceId });
49
+ if (!png?.length) return;
50
+ const jpeg = await sharp(png).jpeg({ quality: this.quality }).toBuffer();
51
+ this.streamHub.handleFrame(this.userId, this.deviceId, {
52
+ jpeg,
53
+ platform: 'android',
54
+ seq: this._seq++ >>> 0,
55
+ ts: Date.now() >>> 0,
56
+ flags: 1,
57
+ });
58
+ } catch (error) {
59
+ console.warn('[AndroidStream] frame capture failed', {
60
+ userId: this.userId,
61
+ deviceId: this.deviceId,
62
+ error: String(error?.message || error),
63
+ });
64
+ } finally {
65
+ this._capturing = false;
66
+ }
67
+ }
68
+ }
69
+
70
+ module.exports = {
71
+ AndroidStream,
72
+ };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const sharp = require('sharp');
5
+
6
+ function clampInt(value, fallback, min, max) {
7
+ const parsed = Number(value);
8
+ if (!Number.isFinite(parsed)) return fallback;
9
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
10
+ }
11
+
12
+ class BrowserStream {
13
+ constructor({ userId, deviceId = 'browser', controller, streamHub, fps = 15, quality = 80 }) {
14
+ this.userId = String(userId || '');
15
+ this.deviceId = String(deviceId || 'browser');
16
+ this.controller = controller;
17
+ this.streamHub = streamHub;
18
+ this.fps = clampInt(fps, 15, 1, 20);
19
+ this.quality = clampInt(quality, 80, 30, 95);
20
+ this._timer = null;
21
+ this._capturing = false;
22
+ this._seq = 0;
23
+ }
24
+
25
+ start() {
26
+ if (this._timer) return;
27
+ const interval = Math.max(1, Math.floor(1000 / this.fps));
28
+ this._timer = setInterval(() => {
29
+ void this._captureOnce();
30
+ }, interval);
31
+ this._timer.unref?.();
32
+ void this._captureOnce();
33
+ }
34
+
35
+ stop() {
36
+ if (this._timer) {
37
+ clearInterval(this._timer);
38
+ this._timer = null;
39
+ }
40
+ }
41
+
42
+ async _captureOnce() {
43
+ if (this._capturing) return;
44
+ this._capturing = true;
45
+ try {
46
+ const jpeg = await this._captureJpeg();
47
+ if (!jpeg?.length) return;
48
+ this.streamHub.handleFrame(this.userId, this.deviceId, {
49
+ jpeg,
50
+ platform: 'browser',
51
+ seq: this._seq++ >>> 0,
52
+ ts: Date.now() >>> 0,
53
+ flags: 1,
54
+ });
55
+ } catch (error) {
56
+ console.warn('[BrowserStream] frame capture failed', {
57
+ userId: this.userId,
58
+ deviceId: this.deviceId,
59
+ error: String(error?.message || error),
60
+ });
61
+ } finally {
62
+ this._capturing = false;
63
+ }
64
+ }
65
+
66
+ async _captureJpeg() {
67
+ if (!this.controller) {
68
+ throw new Error('Browser controller is unavailable.');
69
+ }
70
+ if (typeof this.controller.screenshotJpeg === 'function') {
71
+ return this.controller.screenshotJpeg(this.quality);
72
+ }
73
+ if (typeof this.controller.screenshot !== 'function') {
74
+ throw new Error('Browser streaming requires a screenshot-capable controller.');
75
+ }
76
+ const result = await this.controller.screenshot({ fullPage: false });
77
+ if (!result?.fullPath || !fs.existsSync(result.fullPath)) {
78
+ throw new Error('Browser screenshot did not produce a readable file.');
79
+ }
80
+ const png = fs.readFileSync(result.fullPath);
81
+ return sharp(png).jpeg({ quality: this.quality }).toBuffer();
82
+ }
83
+ }
84
+
85
+ module.exports = {
86
+ BrowserStream,
87
+ };