@vextrix3d/machine-agent 2.0.0 → 2.1.0
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.
- package/LICENSE +3 -3
- package/README.md +37 -47
- package/dist/bootstrap.js +40 -18
- package/dist/camera-bambu.js +92 -0
- package/dist/camera-frame-bridge.js +102 -0
- package/dist/camera-jpeg-bridge.js +8 -79
- package/dist/camera-jpeg-connection.js +2 -5
- package/dist/camera-relay.js +112 -126
- package/dist/camera-webcam-connection.js +186 -0
- package/dist/camera-webcam.js +156 -0
- package/dist/clients/MoonrakerClient.js +41 -1
- package/dist/moonraker-camera-reporter.js +21 -0
- package/dist/utils.js +5 -0
- package/package.json +4 -3
package/dist/camera-relay.js
CHANGED
|
@@ -5,46 +5,8 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
6
|
import { ensureCameraBinaries, findCameraBinaryPins } from './camera-binaries.js';
|
|
7
7
|
import { CAMERA_GATEWAY_PORT, CAMERA_REGISTRY_DIR, createCameraGateway, createCameraRegistry, } from './camera-gateway.js';
|
|
8
|
-
import { createCameraJpegBridge, } from './camera-jpeg-bridge.js';
|
|
9
|
-
import { createCameraStreamBridge, } from './camera-stream-bridge.js';
|
|
10
8
|
import { resolveCameraTunnel } from './camera-tunnel.js';
|
|
11
9
|
import { backoffDelayMs, isExecutable, isPortFree, readArmVersion, stopChildProcess, } from './utils.js';
|
|
12
|
-
export const readCameraStream = (ipcam) => ipcam === undefined ? undefined : (ipcam.rtsp_url ?? null);
|
|
13
|
-
const parseCameraStreamUrl = (rtspUrl) => {
|
|
14
|
-
if (!rtspUrl) {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
let url;
|
|
18
|
-
try {
|
|
19
|
-
url = new URL(rtspUrl);
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
return url.protocol === 'rtsps:' || url.protocol === 'rtsp:' ? url : null;
|
|
25
|
-
};
|
|
26
|
-
export const hasCameraStream = (rtspUrl, jpegCamera = null) => (jpegCamera ? rtspUrl !== undefined : parseCameraStreamUrl(rtspUrl) !== null);
|
|
27
|
-
export const readCameraStreamTarget = (rtspUrl) => {
|
|
28
|
-
const url = parseCameraStreamUrl(rtspUrl);
|
|
29
|
-
return url?.protocol === 'rtsps:'
|
|
30
|
-
? {
|
|
31
|
-
host: url.hostname,
|
|
32
|
-
port: Number(url.port) || 322,
|
|
33
|
-
}
|
|
34
|
-
: null;
|
|
35
|
-
};
|
|
36
|
-
export const buildCameraStreamUrl = (rtspUrl, accessCode, bridgePort) => {
|
|
37
|
-
const url = parseCameraStreamUrl(rtspUrl);
|
|
38
|
-
if (!url) {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
const streamUrl = url.protocol === 'rtsps:'
|
|
42
|
-
? new URL(`rtsp://127.0.0.1:${bridgePort}${url.pathname}${url.search}`)
|
|
43
|
-
: url;
|
|
44
|
-
streamUrl.username = 'bblp';
|
|
45
|
-
streamUrl.password = accessCode;
|
|
46
|
-
return streamUrl.toString();
|
|
47
|
-
};
|
|
48
10
|
const quoteFfconcat = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
49
11
|
export const buildCameraFile = ({ streamUrl, kind, readsOptions }) => [
|
|
50
12
|
'ffconcat version 1.0',
|
|
@@ -74,22 +36,57 @@ export const findFreeCameraRelayPorts = async (isPortFree) => {
|
|
|
74
36
|
}
|
|
75
37
|
return null;
|
|
76
38
|
};
|
|
77
|
-
const
|
|
39
|
+
const buildH264Encoding = ({ ffmpegFpsModeFlag, fps, filters, maxrateKbps, }) => [
|
|
40
|
+
...(filters ? [`-vf ${filters}`] : []),
|
|
78
41
|
'-c:v libx264 -preset ultrafast -tune zerolatency -pix_fmt yuv420p',
|
|
42
|
+
...(maxrateKbps ? [`-maxrate ${maxrateKbps}k -bufsize ${maxrateKbps}k`] : []),
|
|
79
43
|
'-force_key_frames expr:gte(t,n_forced*2)',
|
|
80
|
-
`${ffmpegFpsModeFlag} cfr -r
|
|
44
|
+
`${ffmpegFpsModeFlag} cfr -r ${fps}`,
|
|
81
45
|
].join(' ');
|
|
82
|
-
export const
|
|
46
|
+
export const WEBCAM_MAX_FPS = 10;
|
|
47
|
+
const WEBCAM_MAX_HEIGHT = 720;
|
|
48
|
+
export const buildWebcamFilters = (orientation) => [
|
|
49
|
+
`scale=-2:'min(ih,${WEBCAM_MAX_HEIGHT})'`,
|
|
50
|
+
...(orientation?.rotation === 90 ? ['transpose=clock'] : []),
|
|
51
|
+
...(orientation?.rotation === 180 ? ['hflip,vflip'] : []),
|
|
52
|
+
...(orientation?.rotation === 270 ? ['transpose=cclock'] : []),
|
|
53
|
+
...(orientation?.flipHorizontal ? ['hflip'] : []),
|
|
54
|
+
...(orientation?.flipVertical ? ['vflip'] : []),
|
|
55
|
+
].join(',');
|
|
56
|
+
const PROTOCOL_WHITELISTS = {
|
|
57
|
+
rtsp: 'file,rtsp,tcp,udp,rtp',
|
|
58
|
+
jpeg: 'file,http,tcp',
|
|
59
|
+
mjpeg: 'file,http,tcp',
|
|
60
|
+
h264: 'file,http,https,tcp,tls',
|
|
61
|
+
};
|
|
62
|
+
export const buildFfmpegCommand = ({ cameraFile, kind, orientation, ffmpegFpsModeFlag, }) => {
|
|
63
|
+
const encodings = {
|
|
64
|
+
rtsp: '-c copy',
|
|
65
|
+
jpeg: buildH264Encoding({
|
|
66
|
+
ffmpegFpsModeFlag,
|
|
67
|
+
fps: 2,
|
|
68
|
+
}),
|
|
69
|
+
mjpeg: buildH264Encoding({
|
|
70
|
+
ffmpegFpsModeFlag,
|
|
71
|
+
fps: WEBCAM_MAX_FPS,
|
|
72
|
+
filters: buildWebcamFilters(orientation),
|
|
73
|
+
maxrateKbps: 1500,
|
|
74
|
+
}),
|
|
75
|
+
h264: '-c copy',
|
|
76
|
+
};
|
|
77
|
+
return [
|
|
78
|
+
'exec:ffmpeg -hide_banner -loglevel error',
|
|
79
|
+
'-fflags nobuffer -analyzeduration 1000000 -probesize 1000000',
|
|
80
|
+
'-use_wallclock_as_timestamps 1',
|
|
81
|
+
`-f concat -safe 0 -protocol_whitelist ${PROTOCOL_WHITELISTS[kind]}`,
|
|
82
|
+
`-i ${cameraFile}`,
|
|
83
|
+
`-map 0:v ${encodings[kind]}`,
|
|
84
|
+
'-rtsp_transport tcp -f rtsp {output}',
|
|
85
|
+
].join(' ');
|
|
86
|
+
};
|
|
87
|
+
export const buildGo2rtcConfig = ({ printerId, ports, ffmpegCommand, }) => `${JSON.stringify({
|
|
83
88
|
streams: {
|
|
84
|
-
[printerId]:
|
|
85
|
-
'exec:ffmpeg -hide_banner -loglevel error',
|
|
86
|
-
'-fflags nobuffer -analyzeduration 1000000 -probesize 1000000',
|
|
87
|
-
'-use_wallclock_as_timestamps 1',
|
|
88
|
-
`-f concat -safe 0 -protocol_whitelist ${kind === 'jpeg' ? 'file,http,tcp' : 'file,rtsp,tcp,udp,rtp'}`,
|
|
89
|
-
`-i ${cameraFile}`,
|
|
90
|
-
`-map 0:v ${kind === 'jpeg' ? buildJpegEncoding(ffmpegFpsModeFlag) : '-c copy'}`,
|
|
91
|
-
'-rtsp_transport tcp -f rtsp {output}',
|
|
92
|
-
].join(' '),
|
|
89
|
+
[printerId]: ffmpegCommand,
|
|
93
90
|
},
|
|
94
91
|
api: {
|
|
95
92
|
listen: `127.0.0.1:${ports.apiPort}`,
|
|
@@ -126,54 +123,28 @@ const defaultDependencies = {
|
|
|
126
123
|
chmodSync(path, 0o600);
|
|
127
124
|
},
|
|
128
125
|
findFreePorts: () => findFreeCameraRelayPorts(isPortFree),
|
|
129
|
-
createStreamBridge: options => createCameraStreamBridge(options),
|
|
130
|
-
createJpegBridge: options => createCameraJpegBridge(options),
|
|
131
126
|
};
|
|
132
|
-
export const createCameraRelay = ({ printerId, printerLabel,
|
|
127
|
+
export const createCameraRelay = ({ printerId, printerLabel, go2rtcPath, runtimeDir, ffmpegReadsOptions, ffmpegFpsModeFlag, source, relayKey, registry, }, dependencies = defaultDependencies) => {
|
|
133
128
|
const configPath = join(runtimeDir, 'go2rtc.json');
|
|
134
129
|
const cameraFilePath = join(runtimeDir, 'stream.ffconcat');
|
|
135
|
-
const
|
|
136
|
-
const createInput = () => {
|
|
137
|
-
if (jpegCamera) {
|
|
138
|
-
const bridge = dependencies.createJpegBridge({
|
|
139
|
-
host: jpegCamera.host,
|
|
140
|
-
port: jpegCamera.port,
|
|
141
|
-
serialNumber,
|
|
142
|
-
accessCode,
|
|
143
|
-
caFile,
|
|
144
|
-
printerLabel,
|
|
145
|
-
});
|
|
146
|
-
return {
|
|
147
|
-
resolveStreamUrl: () => bridge.streamUrl,
|
|
148
|
-
close: bridge.close,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
const bridge = dependencies.createStreamBridge({
|
|
152
|
-
serialNumber,
|
|
153
|
-
caFile,
|
|
154
|
-
printerLabel,
|
|
155
|
-
});
|
|
156
|
-
return {
|
|
157
|
-
resolveStreamUrl: async (rtspUrl) => {
|
|
158
|
-
const target = readCameraStreamTarget(rtspUrl);
|
|
159
|
-
if (target) {
|
|
160
|
-
bridge.setTarget(target.host, target.port);
|
|
161
|
-
}
|
|
162
|
-
return buildCameraStreamUrl(rtspUrl, accessCode, await bridge.port);
|
|
163
|
-
},
|
|
164
|
-
close: bridge.close,
|
|
165
|
-
};
|
|
166
|
-
};
|
|
167
|
-
const input = createInput();
|
|
130
|
+
const input = source.openInput();
|
|
168
131
|
let currentRelayKey = relayKey;
|
|
169
|
-
let
|
|
132
|
+
let reported;
|
|
133
|
+
let target = null;
|
|
134
|
+
let startedCommand = null;
|
|
170
135
|
let go2rtc = null;
|
|
171
136
|
let registeredPort = null;
|
|
172
137
|
let restartTimeoutId;
|
|
173
138
|
let failures = 0;
|
|
174
139
|
let startedAt = 0;
|
|
175
|
-
let
|
|
140
|
+
let isRestarting = false;
|
|
176
141
|
let isStopping = false;
|
|
142
|
+
const buildCommand = ({ kind, orientation }) => buildFfmpegCommand({
|
|
143
|
+
cameraFile: cameraFilePath,
|
|
144
|
+
kind,
|
|
145
|
+
orientation,
|
|
146
|
+
ffmpegFpsModeFlag,
|
|
147
|
+
});
|
|
177
148
|
const forward = (output, log) => {
|
|
178
149
|
if (!output) {
|
|
179
150
|
return;
|
|
@@ -191,7 +162,7 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
|
|
|
191
162
|
const start = async () => {
|
|
192
163
|
startedAt = Date.now();
|
|
193
164
|
const ports = await dependencies.findFreePorts();
|
|
194
|
-
if (isStopping) {
|
|
165
|
+
if (isStopping || !target) {
|
|
195
166
|
return;
|
|
196
167
|
}
|
|
197
168
|
if (!ports) {
|
|
@@ -199,12 +170,11 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
|
|
|
199
170
|
scheduleStart(`No pair of free ports for go2rtc between ${firstApiPort} and ${lastApiPort}`);
|
|
200
171
|
return;
|
|
201
172
|
}
|
|
173
|
+
startedCommand = buildCommand(target);
|
|
202
174
|
dependencies.writePrivateFile(configPath, buildGo2rtcConfig({
|
|
203
175
|
printerId,
|
|
204
|
-
cameraFile: cameraFilePath,
|
|
205
|
-
kind,
|
|
206
|
-
ffmpegFpsModeFlag,
|
|
207
176
|
ports,
|
|
177
|
+
ffmpegCommand: startedCommand,
|
|
208
178
|
}));
|
|
209
179
|
const child = dependencies.spawn(go2rtcPath, ['-config', configPath]);
|
|
210
180
|
go2rtc = child;
|
|
@@ -218,35 +188,48 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
|
|
|
218
188
|
});
|
|
219
189
|
child.once('exit', (code, signal) => {
|
|
220
190
|
go2rtc = null;
|
|
221
|
-
if (
|
|
222
|
-
|
|
191
|
+
if (isStopping) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (isRestarting) {
|
|
195
|
+
isRestarting = false;
|
|
196
|
+
void start();
|
|
197
|
+
return;
|
|
223
198
|
}
|
|
199
|
+
scheduleStart(`go2rtc stopped (${signal ?? `exit code ${code}`})`);
|
|
224
200
|
});
|
|
225
201
|
};
|
|
226
|
-
const writeCameraFile = async (
|
|
227
|
-
const
|
|
228
|
-
if (isStopping || !
|
|
202
|
+
const writeCameraFile = async (report) => {
|
|
203
|
+
const nextTarget = await input.resolveTarget(report);
|
|
204
|
+
if (isStopping || !nextTarget || report !== reported) {
|
|
229
205
|
return;
|
|
230
206
|
}
|
|
231
207
|
dependencies.ensurePrivateDir(runtimeDir);
|
|
232
208
|
dependencies.writePrivateFile(cameraFilePath, buildCameraFile({
|
|
233
|
-
streamUrl,
|
|
234
|
-
kind,
|
|
209
|
+
streamUrl: nextTarget.streamUrl,
|
|
210
|
+
kind: nextTarget.kind,
|
|
235
211
|
readsOptions: ffmpegReadsOptions,
|
|
236
212
|
}));
|
|
237
|
-
|
|
213
|
+
const isFirst = target === null;
|
|
214
|
+
target = nextTarget;
|
|
215
|
+
if (isFirst) {
|
|
216
|
+
void start();
|
|
238
217
|
return;
|
|
239
218
|
}
|
|
240
|
-
|
|
241
|
-
|
|
219
|
+
if (!go2rtc || isRestarting || buildCommand(nextTarget) === startedCommand) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
console.log(`[camera] The camera of "${printerLabel}" is to be read another way; starting go2rtc again for it`);
|
|
223
|
+
isRestarting = true;
|
|
224
|
+
void stopChildProcess(go2rtc);
|
|
242
225
|
};
|
|
243
226
|
return {
|
|
244
|
-
update:
|
|
245
|
-
if (isStopping || !
|
|
227
|
+
update: report => {
|
|
228
|
+
if (isStopping || !source.hasStream(report) || report === reported) {
|
|
246
229
|
return;
|
|
247
230
|
}
|
|
248
|
-
|
|
249
|
-
void writeCameraFile(
|
|
231
|
+
reported = report;
|
|
232
|
+
void writeCameraFile(report).catch(error => {
|
|
250
233
|
console.error('[camera] Could not write the camera file:', error);
|
|
251
234
|
});
|
|
252
235
|
},
|
|
@@ -259,7 +242,7 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
|
|
|
259
242
|
stop: async () => {
|
|
260
243
|
isStopping = true;
|
|
261
244
|
clearTimeout(restartTimeoutId);
|
|
262
|
-
if (
|
|
245
|
+
if (target) {
|
|
263
246
|
registry.unregister(printerId);
|
|
264
247
|
}
|
|
265
248
|
await input.close();
|
|
@@ -270,7 +253,7 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
|
|
|
270
253
|
};
|
|
271
254
|
};
|
|
272
255
|
export const fingerprintRelayKey = (relayKey) => createHash('sha256').update(relayKey).digest('hex').slice(0, 16);
|
|
273
|
-
const
|
|
256
|
+
const createDefaultResolveDependencies = () => ({
|
|
274
257
|
readFfmpegVersion: () => {
|
|
275
258
|
try {
|
|
276
259
|
return execFileSync('ffmpeg', ['-version'], {
|
|
@@ -292,17 +275,16 @@ const defaultResolveDependencies = {
|
|
|
292
275
|
}),
|
|
293
276
|
createRelay: options => createCameraRelay(options),
|
|
294
277
|
resolveTunnel: options => resolveCameraTunnel(options),
|
|
295
|
-
};
|
|
278
|
+
});
|
|
296
279
|
const CAMERA_ACCESS_REFRESH_MS = 24 * 60 * 60_000;
|
|
297
|
-
export const resolveCameraRelay = ({
|
|
280
|
+
export const resolveCameraRelay = ({ printerId, printerLabel, source, workDir, fetchesBinaries, getCameraAccess, printerGone, }, dependencies = createDefaultResolveDependencies()) => {
|
|
298
281
|
const ffmpegVersion = dependencies.readFfmpegVersion();
|
|
299
282
|
if (ffmpegVersion === null) {
|
|
300
283
|
console.log('[camera] Relay off: ffmpeg is not installed');
|
|
301
284
|
return null;
|
|
302
285
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
console.log(`[camera] Relay off: ffmpeg has no libx264, which a Bambu ${jpegCamera.model}'s camera is encoded to H.264 with`);
|
|
286
|
+
if (source.encodedCamera && !/--enable-libx264\b/.test(ffmpegVersion)) {
|
|
287
|
+
console.log(`[camera] Relay off: ffmpeg has no libx264, which ${source.encodedCamera} is encoded to H.264 with`);
|
|
306
288
|
return null;
|
|
307
289
|
}
|
|
308
290
|
const ffmpegMajorVersion = parseFfmpegMajorVersion(ffmpegVersion);
|
|
@@ -317,16 +299,17 @@ export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCamera
|
|
|
317
299
|
console.log(`[camera] No go2rtc or cloudflared build is pinned for ${process.platform} ${process.arch}: running camera/go2rtc and camera/cloudflared as they are`);
|
|
318
300
|
}
|
|
319
301
|
const relayOptions = {
|
|
320
|
-
|
|
302
|
+
printerId,
|
|
303
|
+
printerLabel,
|
|
304
|
+
source,
|
|
321
305
|
go2rtcPath,
|
|
322
306
|
runtimeDir: join(cameraDir, 'runtime'),
|
|
323
|
-
caFile,
|
|
324
307
|
ffmpegReadsOptions: ffmpegMajorVersion === null || ffmpegMajorVersion >= 5,
|
|
325
308
|
ffmpegFpsModeFlag: ffmpegMajorVersion === null || ffmpegMajorVersion >= 6 ? '-fps_mode' : '-vsync',
|
|
326
309
|
};
|
|
327
310
|
let camera = null;
|
|
328
311
|
let lastAccess = null;
|
|
329
|
-
let
|
|
312
|
+
let report;
|
|
330
313
|
let isWaitingForStream = true;
|
|
331
314
|
let hasReportedNoStream = false;
|
|
332
315
|
let timeoutId;
|
|
@@ -367,7 +350,7 @@ export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCamera
|
|
|
367
350
|
const startTunnel = (tunnelToken) => dependencies.resolveTunnel({
|
|
368
351
|
workDir,
|
|
369
352
|
tunnelToken,
|
|
370
|
-
printerLabel
|
|
353
|
+
printerLabel,
|
|
371
354
|
});
|
|
372
355
|
const startCamera = (relayKey, tunnelToken) => {
|
|
373
356
|
const registry = dependencies.createRegistry();
|
|
@@ -383,7 +366,9 @@ export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCamera
|
|
|
383
366
|
relay,
|
|
384
367
|
tunnel: startTunnel(tunnelToken),
|
|
385
368
|
};
|
|
386
|
-
|
|
369
|
+
if (report !== undefined) {
|
|
370
|
+
relay.update(report);
|
|
371
|
+
}
|
|
387
372
|
};
|
|
388
373
|
const stopCamera = async () => {
|
|
389
374
|
const running = camera;
|
|
@@ -529,26 +514,27 @@ export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCamera
|
|
|
529
514
|
};
|
|
530
515
|
return {
|
|
531
516
|
noteRelayKeyFingerprint,
|
|
532
|
-
update:
|
|
533
|
-
|
|
517
|
+
update: nextReport => {
|
|
518
|
+
report = nextReport;
|
|
534
519
|
if (isStopping) {
|
|
535
520
|
return;
|
|
536
521
|
}
|
|
537
522
|
if (!isWaitingForStream) {
|
|
538
|
-
camera?.relay.update(
|
|
523
|
+
camera?.relay.update(nextReport);
|
|
539
524
|
return;
|
|
540
525
|
}
|
|
541
|
-
if (
|
|
526
|
+
if (source.hasStream(nextReport)) {
|
|
542
527
|
isWaitingForStream = false;
|
|
543
|
-
|
|
544
|
-
|
|
528
|
+
const description = source.describeStream(nextReport);
|
|
529
|
+
if (description) {
|
|
530
|
+
console.log(`[camera] ${description}`);
|
|
545
531
|
}
|
|
546
532
|
run(start);
|
|
547
533
|
return;
|
|
548
534
|
}
|
|
549
|
-
if (
|
|
535
|
+
if (nextReport !== undefined && !hasReportedNoStream) {
|
|
550
536
|
hasReportedNoStream = true;
|
|
551
|
-
console.log(`[camera]
|
|
537
|
+
console.log(`[camera] ${source.describeNoStream(nextReport)}`);
|
|
552
538
|
}
|
|
553
539
|
},
|
|
554
540
|
stop,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { request as requestHttp } from 'node:http';
|
|
2
|
+
import { request as requestHttps } from 'node:https';
|
|
3
|
+
import { isJpeg } from './utils.js';
|
|
4
|
+
export const readMjpegBoundary = (contentType) => {
|
|
5
|
+
const match = contentType?.match(/^\s*multipart\/x-mixed-replace\s*;(?:.*;)?\s*boundary=([^;]+)/i);
|
|
6
|
+
const boundary = match?.[1].trim().replace(/^"(.*)"$/, '$1');
|
|
7
|
+
return boundary || null;
|
|
8
|
+
};
|
|
9
|
+
const MAX_FRAME_SIZE = 16 * 1024 * 1024;
|
|
10
|
+
const JPEG_END = Buffer.from([0xff, 0xd9]);
|
|
11
|
+
export const createMjpegFrameReader = (boundary, onFrame) => {
|
|
12
|
+
const delimiter = Buffer.from(boundary, 'latin1');
|
|
13
|
+
const pieces = [];
|
|
14
|
+
let buffer = Buffer.alloc(0);
|
|
15
|
+
let piecesSize = 0;
|
|
16
|
+
let state = 'boundary';
|
|
17
|
+
let contentLength = null;
|
|
18
|
+
const takeLine = () => {
|
|
19
|
+
const end = buffer.indexOf(0x0a);
|
|
20
|
+
if (end < 0) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const line = buffer.toString('latin1', 0, end).replace(/\r$/, '');
|
|
24
|
+
buffer = buffer.subarray(end + 1);
|
|
25
|
+
return line;
|
|
26
|
+
};
|
|
27
|
+
const emit = (frame) => {
|
|
28
|
+
if (isJpeg(frame)) {
|
|
29
|
+
onFrame(frame);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const step = () => {
|
|
33
|
+
switch (state) {
|
|
34
|
+
case 'boundary': {
|
|
35
|
+
const at = buffer.indexOf(delimiter);
|
|
36
|
+
if (at < 0) {
|
|
37
|
+
buffer = buffer.subarray(Math.max(0, buffer.length - delimiter.length));
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const lineEnd = buffer.indexOf(0x0a, at + delimiter.length);
|
|
41
|
+
if (lineEnd < 0) {
|
|
42
|
+
buffer = buffer.subarray(at);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
buffer = buffer.subarray(lineEnd + 1);
|
|
46
|
+
state = 'headers';
|
|
47
|
+
contentLength = null;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
case 'headers': {
|
|
51
|
+
const line = takeLine();
|
|
52
|
+
if (line === null) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (line) {
|
|
56
|
+
const length = /^content-length\s*:\s*(\d+)\s*$/i.exec(line);
|
|
57
|
+
if (length) {
|
|
58
|
+
contentLength = Number(length[1]);
|
|
59
|
+
if (contentLength > MAX_FRAME_SIZE) {
|
|
60
|
+
throw new Error(`it sent a part of ${contentLength} bytes`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
state = 'body';
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
case 'body': {
|
|
69
|
+
if (contentLength !== null) {
|
|
70
|
+
if (buffer.length < contentLength) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
emit(buffer.subarray(0, contentLength));
|
|
74
|
+
buffer = buffer.subarray(contentLength);
|
|
75
|
+
state = 'boundary';
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
const next = buffer.indexOf(delimiter);
|
|
79
|
+
if (next < 0) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const body = buffer.subarray(0, next);
|
|
83
|
+
const end = body.lastIndexOf(JPEG_END);
|
|
84
|
+
emit(end < 0 ? body : body.subarray(0, end + 2));
|
|
85
|
+
buffer = buffer.subarray(next);
|
|
86
|
+
state = 'boundary';
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return (chunk) => {
|
|
92
|
+
pieces.push(chunk);
|
|
93
|
+
piecesSize += chunk.length;
|
|
94
|
+
const isBodyComing = state === 'body' && contentLength !== null && buffer.length + piecesSize < contentLength;
|
|
95
|
+
if (isBodyComing) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
buffer =
|
|
99
|
+
buffer.length || pieces.length > 1
|
|
100
|
+
? Buffer.concat([buffer, ...pieces], buffer.length + piecesSize)
|
|
101
|
+
: chunk;
|
|
102
|
+
pieces.length = 0;
|
|
103
|
+
piecesSize = 0;
|
|
104
|
+
while (step()) {
|
|
105
|
+
}
|
|
106
|
+
if (buffer.length > MAX_FRAME_SIZE) {
|
|
107
|
+
throw new Error(`it sent ${MAX_FRAME_SIZE / 1024 / 1024} MB with no frame in it`);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
const FRAME_TIMEOUT_MS = 10_000;
|
|
112
|
+
export const describeWebcamUrl = (url) => {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = new URL(url);
|
|
115
|
+
parsed.username = '';
|
|
116
|
+
parsed.password = '';
|
|
117
|
+
return parsed.toString();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return url;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
export class CameraWebcamConnection {
|
|
124
|
+
request;
|
|
125
|
+
frameTimeoutId;
|
|
126
|
+
isClosed = false;
|
|
127
|
+
constructor({ url, onFrame, onFailure, onClose }) {
|
|
128
|
+
const target = new URL(url);
|
|
129
|
+
const fail = (error) => {
|
|
130
|
+
if (!this.isClosed) {
|
|
131
|
+
onFailure(error);
|
|
132
|
+
this.close();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const awaitFrame = () => {
|
|
136
|
+
clearTimeout(this.frameTimeoutId);
|
|
137
|
+
this.frameTimeoutId = setTimeout(() => fail(new Error(`it sent no frame in ${FRAME_TIMEOUT_MS / 1000}s`)), FRAME_TIMEOUT_MS);
|
|
138
|
+
};
|
|
139
|
+
const read = (response) => {
|
|
140
|
+
const boundary = readMjpegBoundary(response.headers['content-type']);
|
|
141
|
+
response.on('error', fail);
|
|
142
|
+
if (response.statusCode !== 200) {
|
|
143
|
+
fail(new Error(`it answered ${response.statusCode}`));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!boundary) {
|
|
147
|
+
fail(new Error(`it answered with ${response.headers['content-type'] ?? 'no content type'}, not an MJPEG stream`));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const readFrames = createMjpegFrameReader(boundary, frame => {
|
|
151
|
+
awaitFrame();
|
|
152
|
+
onFrame(frame);
|
|
153
|
+
});
|
|
154
|
+
response.on('data', (chunk) => {
|
|
155
|
+
try {
|
|
156
|
+
readFrames(chunk);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
fail(error);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
response.once('end', () => fail(new Error('it ended the stream')));
|
|
163
|
+
};
|
|
164
|
+
this.request = (target.protocol === 'https:' ? requestHttps : requestHttp)(target, {
|
|
165
|
+
headers: {
|
|
166
|
+
Accept: 'multipart/x-mixed-replace, */*',
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
this.request.once('response', read);
|
|
170
|
+
this.request.on('error', fail);
|
|
171
|
+
this.request.once('close', () => {
|
|
172
|
+
this.close();
|
|
173
|
+
onClose();
|
|
174
|
+
});
|
|
175
|
+
this.request.end();
|
|
176
|
+
awaitFrame();
|
|
177
|
+
}
|
|
178
|
+
close() {
|
|
179
|
+
if (this.isClosed) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.isClosed = true;
|
|
183
|
+
clearTimeout(this.frameTimeoutId);
|
|
184
|
+
this.request.destroy();
|
|
185
|
+
}
|
|
186
|
+
}
|