remoterigs-raspberrypi 0.1.16 → 0.1.17
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/package.json +1 -1
- package/src/common/native/capture-source.js +136 -24
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
2
3
|
import { EventEmitter } from 'events';
|
|
3
4
|
import dgram from 'dgram';
|
|
4
5
|
import { performance } from 'perf_hooks';
|
|
@@ -42,9 +43,14 @@ export default class CaptureSource extends EventEmitter {
|
|
|
42
43
|
// audio timestamps: ffmpeg RTP timestamp + offset = wall clock in 48 kHz
|
|
43
44
|
this.audioTimestampOffset = null;
|
|
44
45
|
this.resolvedAudioDevice = null;
|
|
46
|
+
this.resolvedVideoDevice = null;
|
|
47
|
+
// restart delay grows while a process keeps failing (camera unplugged), back to the minimum after a success
|
|
48
|
+
this.videoFailures = 0;
|
|
49
|
+
this.audioFailures = 0;
|
|
45
50
|
// keep the camera open a bit after the last viewer left, a reconnect (page reload) is then instant
|
|
46
51
|
this.STOP_DELAY_MS = 15000;
|
|
47
52
|
this.RESTART_DELAY_MS = 2000;
|
|
53
|
+
this.MAX_RESTART_DELAY_MS = 30000;
|
|
48
54
|
this.setMaxListeners(100);
|
|
49
55
|
}
|
|
50
56
|
get HasAudio() {
|
|
@@ -110,17 +116,18 @@ export default class CaptureSource extends EventEmitter {
|
|
|
110
116
|
this.currentAccessUnit = [];
|
|
111
117
|
this.audioTimestampOffset = null;
|
|
112
118
|
}
|
|
113
|
-
ScheduleRestart(what, start) {
|
|
119
|
+
ScheduleRestart(what, failures, start) {
|
|
114
120
|
if (!this.running) {
|
|
115
121
|
return;
|
|
116
122
|
}
|
|
117
|
-
this.
|
|
123
|
+
const delay = Math.min(this.MAX_RESTART_DELAY_MS, this.RESTART_DELAY_MS * Math.pow(2, Math.max(0, failures - 1)));
|
|
124
|
+
this.log(`${what} stopped, restarting in ${delay} ms`);
|
|
118
125
|
const timer = setTimeout(() => {
|
|
119
126
|
this.restartTimers.delete(timer);
|
|
120
127
|
if (this.running) {
|
|
121
128
|
start();
|
|
122
129
|
}
|
|
123
|
-
},
|
|
130
|
+
}, delay);
|
|
124
131
|
this.restartTimers.add(timer);
|
|
125
132
|
}
|
|
126
133
|
// ---------------------------------------------------------------- video
|
|
@@ -148,7 +155,7 @@ export default class CaptureSource extends EventEmitter {
|
|
|
148
155
|
if (s.fps > 0) {
|
|
149
156
|
args.push("-framerate", String(s.fps));
|
|
150
157
|
}
|
|
151
|
-
args.push("-i", s.devicePath);
|
|
158
|
+
args.push("-i", this.resolvedVideoDevice ?? s.devicePath);
|
|
152
159
|
}
|
|
153
160
|
args.push("-an");
|
|
154
161
|
if (inputFormat == "h264") {
|
|
@@ -173,6 +180,10 @@ export default class CaptureSource extends EventEmitter {
|
|
|
173
180
|
return args;
|
|
174
181
|
}
|
|
175
182
|
StartVideo() {
|
|
183
|
+
if (!this.settings.devicePath.startsWith("lavfi:") && (this.resolvedVideoDevice == null || this.videoFailures > 0)) {
|
|
184
|
+
// (again) after a failure: a USB camera that was unplugged can come back under another /dev/videoN
|
|
185
|
+
this.resolvedVideoDevice = this.ResolveVideoDevice(this.settings.devicePath);
|
|
186
|
+
}
|
|
176
187
|
const args = this.BuildVideoArgs();
|
|
177
188
|
const startedAt = Date.now();
|
|
178
189
|
let lastStderr = "";
|
|
@@ -201,7 +212,8 @@ export default class CaptureSource extends EventEmitter {
|
|
|
201
212
|
this.log("no frames with input format mjpeg, falling back to the camera default format");
|
|
202
213
|
this.useDefaultInputFormat = true;
|
|
203
214
|
}
|
|
204
|
-
this.
|
|
215
|
+
this.videoFailures = this.framesEmitted > 0 ? 1 : this.videoFailures + 1;
|
|
216
|
+
this.ScheduleRestart("video", this.videoFailures, () => this.StartVideo());
|
|
205
217
|
});
|
|
206
218
|
}
|
|
207
219
|
OnVideoData(chunk) {
|
|
@@ -274,16 +286,110 @@ export default class CaptureSource extends EventEmitter {
|
|
|
274
286
|
keyframe: keyframe,
|
|
275
287
|
};
|
|
276
288
|
this.framesEmitted++;
|
|
289
|
+
if (this.framesEmitted == 1) {
|
|
290
|
+
this.log(`first frame from ${this.resolvedVideoDevice ?? this.settings.devicePath} (${frame.data.length} bytes, keyframe: ${frame.keyframe})`);
|
|
291
|
+
this.videoFailures = 0;
|
|
292
|
+
}
|
|
277
293
|
this.emit("video", frame);
|
|
278
294
|
}
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
295
|
+
// the video capture devices in the same order as webrtc-streamer (libwebrtc) numbers them:
|
|
296
|
+
// /dev/video0..63 that have the VIDEO_CAPTURE capability (so no metadata nodes, no codec devices)
|
|
297
|
+
ListVideoCaptureDevices() {
|
|
298
|
+
const devices = [];
|
|
299
|
+
for (let n = 0; n < 64; n++) {
|
|
300
|
+
const device = `/dev/video${n}`;
|
|
301
|
+
if (!fs.existsSync(device)) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const result = spawnSync("v4l2-ctl", ["-d", device, "--info"], { encoding: "utf8", timeout: 2000 });
|
|
305
|
+
if (result.error != null) {
|
|
306
|
+
// no v4l2-ctl (package v4l-utils): use the name from sysfs, skip the Pi's own codec / ISP devices
|
|
307
|
+
const name = this.ReadSysfs(`/sys/class/video4linux/video${n}/name`);
|
|
308
|
+
const index = this.ReadSysfs(`/sys/class/video4linux/video${n}/index`);
|
|
309
|
+
if (index == "0" && !/^(bcm2835|rpi-|pispbe|rp1-)/.test(name)) {
|
|
310
|
+
devices.push({ device, name, driver: "?" });
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const output = result.stdout ?? "";
|
|
315
|
+
const deviceCaps = /Device Caps\s*:\s*0x([0-9a-fA-F]+)/.exec(output);
|
|
316
|
+
const capabilities = /Capabilities\s*:\s*0x([0-9a-fA-F]+)/.exec(output);
|
|
317
|
+
const caps = parseInt((deviceCaps ?? capabilities)?.[1] ?? "0", 16);
|
|
318
|
+
// V4L2_CAP_VIDEO_CAPTURE = 0x00000001
|
|
319
|
+
if ((caps & 0x1) != 0) {
|
|
320
|
+
devices.push({
|
|
321
|
+
device,
|
|
322
|
+
name: /Card type\s*:\s*(.*)/.exec(output)?.[1]?.trim() ?? "",
|
|
323
|
+
driver: /Driver name\s*:\s*(.*)/.exec(output)?.[1]?.trim() ?? "",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return devices;
|
|
328
|
+
}
|
|
329
|
+
ReadSysfs(path) {
|
|
330
|
+
try {
|
|
331
|
+
return fs.readFileSync(path, "utf8").trim();
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return "";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
ResolveVideoDevice(devicePath) {
|
|
338
|
+
const path = devicePath.trim();
|
|
339
|
+
// a real device path: /dev/video0, /dev/v4l/by-id/usb-...-video-index0
|
|
340
|
+
if (path.startsWith("/dev/")) {
|
|
341
|
+
return path;
|
|
342
|
+
}
|
|
343
|
+
const devices = this.ListVideoCaptureDevices();
|
|
344
|
+
this.log("video capture devices: " + (devices.length == 0 ? "none" : devices.map((d, i) => `[${i}] ${d.device} '${d.name}' (${d.driver})`).join(" | ")));
|
|
345
|
+
// webrtc-streamer style: videocap://N = the N-th capture device
|
|
346
|
+
const capMatch = /^videocap:\/\/(\d+)$/.exec(path);
|
|
284
347
|
if (capMatch) {
|
|
285
|
-
|
|
348
|
+
const index = parseInt(capMatch[1]);
|
|
349
|
+
// a Pi internal device (ISP / codec) is never the camera that was meant: then take the USB webcam
|
|
350
|
+
const isPiInternal = (d) => /^(bcm2835|rpi-|pispbe|rp1-)/.test(d.driver) || /^(bcm2835|rpi-|pispbe|rp1-)/.test(d.name);
|
|
351
|
+
if (index < devices.length && !isPiInternal(devices[index])) {
|
|
352
|
+
this.log(`video device '${devicePath}' -> ${devices[index].device} '${devices[index].name}'`);
|
|
353
|
+
return devices[index].device;
|
|
354
|
+
}
|
|
355
|
+
// fewer devices than webrtc-streamer saw: the USB webcam (uvcvideo) is the best guess
|
|
356
|
+
const uvc = devices.find(d => d.driver == "uvcvideo") ?? devices.find(d => !isPiInternal(d));
|
|
357
|
+
if (uvc != null) {
|
|
358
|
+
this.log(`video device '${devicePath}': capture device ${index} is not a camera, using ${uvc.device} '${uvc.name}'`);
|
|
359
|
+
return uvc.device;
|
|
360
|
+
}
|
|
361
|
+
return "/dev/video0";
|
|
362
|
+
}
|
|
363
|
+
// a (part of a) camera name, e.g. "C920"
|
|
364
|
+
const match = devices.find(d => d.name.toLowerCase().includes(path.toLowerCase()));
|
|
365
|
+
if (match) {
|
|
366
|
+
this.log(`video device '${devicePath}' -> ${match.device} '${match.name}'`);
|
|
367
|
+
return match.device;
|
|
368
|
+
}
|
|
369
|
+
this.log(`video device '${devicePath}' not found, using it as it is`);
|
|
370
|
+
return path;
|
|
371
|
+
}
|
|
372
|
+
// ---------------------------------------------------------------- audio
|
|
373
|
+
// capture devices from 'arecord -l', in that order:
|
|
374
|
+
// card 1: C920 [HD Pro Webcam C920], device 0: USB Audio [USB Audio]
|
|
375
|
+
ListAudioCaptureDevices() {
|
|
376
|
+
const devices = [];
|
|
377
|
+
try {
|
|
378
|
+
const result = spawnSync("arecord", ["-l"], { encoding: "utf8", timeout: 5000 });
|
|
379
|
+
for (const line of (result.stdout ?? "").split("\n")) {
|
|
380
|
+
const m = /^card (\d+): (\S+) \[([^\]]*)\], device (\d+)/.exec(line);
|
|
381
|
+
if (m) {
|
|
382
|
+
devices.push({ device: `plughw:CARD=${m[2]},DEV=${m[4]}`, description: line.trim() });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
286
385
|
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
this.log("arecord -l failed: " + err);
|
|
388
|
+
}
|
|
389
|
+
return devices;
|
|
390
|
+
}
|
|
391
|
+
ResolveAudioDevice(audioPath) {
|
|
392
|
+
const path = audioPath.trim();
|
|
287
393
|
// ALSA device names (and the lavfi test source) can be used as they are
|
|
288
394
|
if (/^(lavfi|hw|plughw|default|sysdefault|dsnoop|front):?/.test(path)) {
|
|
289
395
|
return path;
|
|
@@ -292,20 +398,22 @@ export default class CaptureSource extends EventEmitter {
|
|
|
292
398
|
if (/^\d+$/.test(path)) {
|
|
293
399
|
return `plughw:${path},0`;
|
|
294
400
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return `plughw:CARD=${m[2]},DEV=${m[4]}`;
|
|
304
|
-
}
|
|
401
|
+
const devices = this.ListAudioCaptureDevices();
|
|
402
|
+
this.log("audio capture devices: " + (devices.length == 0 ? "none" : devices.map((d, i) => `[${i}] ${d.description}`).join(" | ")));
|
|
403
|
+
// webrtc-streamer style: audiocap://N = the N-th capture device (not card N: card 0 of a Pi is HDMI, without input)
|
|
404
|
+
const capMatch = /^audiocap:\/\/(\d+)$/.exec(path);
|
|
405
|
+
if (capMatch) {
|
|
406
|
+
const index = parseInt(capMatch[1]);
|
|
407
|
+
if (index < devices.length) {
|
|
408
|
+
return devices[index].device;
|
|
305
409
|
}
|
|
410
|
+
this.log(`audio device '${audioPath}': there is no capture device ${index}`);
|
|
411
|
+
return devices.length > 0 ? devices[0].device : "default";
|
|
306
412
|
}
|
|
307
|
-
|
|
308
|
-
|
|
413
|
+
// a (part of a) card name, e.g. "C920" or "USB Camera"
|
|
414
|
+
const match = devices.find(d => d.description.toLowerCase().includes(path.toLowerCase()));
|
|
415
|
+
if (match) {
|
|
416
|
+
return match.device;
|
|
309
417
|
}
|
|
310
418
|
this.log(`audio device '${audioPath}' not found in 'arecord -l', using it as ALSA device name`);
|
|
311
419
|
return path;
|
|
@@ -343,6 +451,7 @@ export default class CaptureSource extends EventEmitter {
|
|
|
343
451
|
`rtp://127.0.0.1:${port}?rtcpport=${port}&pkt_size=1200`,
|
|
344
452
|
];
|
|
345
453
|
this.log("starting audio: ffmpeg " + args.join(" "));
|
|
454
|
+
const audioStartedAt = Date.now();
|
|
346
455
|
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
347
456
|
this.audioProcess = proc;
|
|
348
457
|
this.audioTimestampOffset = null;
|
|
@@ -358,7 +467,10 @@ export default class CaptureSource extends EventEmitter {
|
|
|
358
467
|
}
|
|
359
468
|
this.audioProcess = null;
|
|
360
469
|
this.log(`ffmpeg audio exited (code ${code}, signal ${signal})`);
|
|
361
|
-
this.
|
|
470
|
+
this.audioFailures = Date.now() - audioStartedAt > 10000 ? 1 : this.audioFailures + 1;
|
|
471
|
+
// a device that did not work: look it up again on the next start
|
|
472
|
+
this.resolvedAudioDevice = null;
|
|
473
|
+
this.ScheduleRestart("audio", this.audioFailures, () => { this.StartAudio().catch(err => this.log("audio restart failed: " + err)); });
|
|
362
474
|
});
|
|
363
475
|
}
|
|
364
476
|
OnAudioRtp(packet) {
|