@camstack/system 1.2.263 → 1.2.265
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/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/camera-grid/addon.d.ts +20 -0
- package/dist/builtins/camera-grid/grid-activity-mirror.d.ts +32 -0
- package/dist/builtins/camera-grid/grid-activity.d.ts +50 -0
- package/dist/builtins/camera-grid/grid-camera-device.d.ts +11 -1
- package/dist/builtins/camera-grid/grid-canvas.d.ts +83 -0
- package/dist/builtins/camera-grid/grid-compositor-child.d.ts +3 -0
- package/dist/builtins/camera-grid/grid-compositor-invocation.d.ts +19 -0
- package/dist/builtins/camera-grid/grid-compositor.d.ts +36 -0
- package/dist/builtins/camera-grid/grid-detection-mapping.d.ts +31 -0
- package/dist/builtins/camera-grid/grid-tile-feed.d.ts +38 -0
- package/dist/builtins/camera-grid/grid-tile-plan.d.ts +31 -0
- package/dist/builtins/camera-grid/index.js +1199 -232
- package/dist/builtins/camera-grid/index.mjs +1199 -232
- package/dist/builtins/camera-grid/silence-analysis.d.ts +7 -0
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-6hiXbrNY.js → dist-BbPssgI6.js} +85 -15
- package/dist/{dist-CQ7ItBIG.mjs → dist-Ck0rjYOQ.mjs} +72 -8
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-CCXmaFTK.mjs → retired-settings-keys-BZpDuJVh.mjs} +1 -1
- package/dist/{retired-settings-keys-AmmGrCp1.js → retired-settings-keys-Dg_69cOY.js} +1 -1
- package/package.json +4 -1
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-BbPssgI6.js");
|
|
7
7
|
let zod = require("zod");
|
|
8
8
|
let _camstack_types_node = require("@camstack/types/node");
|
|
9
9
|
let node_path = require("node:path");
|
|
@@ -197,6 +197,10 @@ var GridCameraDevice = class extends require_dist.BaseDevice {
|
|
|
197
197
|
getSnapshot: async ({ deviceId }) => activeRuntime().getSnapshot(deviceId),
|
|
198
198
|
invalidateCache: async () => {}
|
|
199
199
|
});
|
|
200
|
+
this.ctx.registerNativeCap(require_dist.motionCapability, {
|
|
201
|
+
getStatus: async ({ deviceId }) => activeRuntime().motionStatus(deviceId),
|
|
202
|
+
isDetected: async ({ deviceId }) => activeRuntime().motionStatus(deviceId).detected
|
|
203
|
+
});
|
|
200
204
|
this.markOnline(true);
|
|
201
205
|
}
|
|
202
206
|
catalog() {
|
|
@@ -400,6 +404,330 @@ function normalizeGridRows(raw, mintId) {
|
|
|
400
404
|
};
|
|
401
405
|
}
|
|
402
406
|
//#endregion
|
|
407
|
+
//#region src/builtins/camera-grid/grid-activity-mirror.ts
|
|
408
|
+
/**
|
|
409
|
+
* What a grid says about ITSELF, kept from what its sources said.
|
|
410
|
+
*
|
|
411
|
+
* ## Why the grid answers at all
|
|
412
|
+
*
|
|
413
|
+
* A grid is a camera, and the viewer already knows how to light a camera: the
|
|
414
|
+
* motion ring keys off `motion.on-motion-changed` filtered by `deviceId`, the
|
|
415
|
+
* audio one off `pipeline.audio-inference-result`. So the grid does not need a
|
|
416
|
+
* new channel, a new cap or a line of app code — it needs to answer those
|
|
417
|
+
* questions about its own device id. The operator set both rules: motion is on
|
|
418
|
+
* if AT LEAST ONE source has it, and the audio level is the LOUDEST of them.
|
|
419
|
+
*
|
|
420
|
+
* It is derived from the SOURCES' events and never from the composed picture.
|
|
421
|
+
* Analysing the composite would be a second motion authority for cameras that
|
|
422
|
+
* already have one, and a decode this addon does not pay today.
|
|
423
|
+
*
|
|
424
|
+
* ## The falling edge is the whole difficulty
|
|
425
|
+
*
|
|
426
|
+
* Bus events are telemetry and may be dropped (D8/D11). A lost `detected:
|
|
427
|
+
* false` would leave a grid lit for ever, and the grid has no phase machine to
|
|
428
|
+
* produce a falling edge of its own — a real camera's comes from the pipeline
|
|
429
|
+
* runner, which a grid deliberately does not run.
|
|
430
|
+
*
|
|
431
|
+
* So a source's motion is remembered WITH ITS TIME and expires on its own. That
|
|
432
|
+
* is D49's shape: in-memory state refreshed off the event path, never a gate
|
|
433
|
+
* that re-reads something fallible in order to decide. A dropped clear costs a
|
|
434
|
+
* ring that stays lit until the expiry instead of for ever, and a subject that
|
|
435
|
+
* is genuinely still there keeps renewing it.
|
|
436
|
+
*
|
|
437
|
+
* The expiry is `MOTION_CLOSE_AFTER_MS`, imported and not re-spelled: it is the
|
|
438
|
+
* system's one answer to "how long after the last push is a camera still
|
|
439
|
+
* moving", and a second number here would be a second authority disagreeing
|
|
440
|
+
* with the first on exactly the cameras that make up the grid.
|
|
441
|
+
*/
|
|
442
|
+
/**
|
|
443
|
+
* How long an audio reading counts as current.
|
|
444
|
+
*
|
|
445
|
+
* The analyzer emits one per window per camera, so a source that has gone quiet
|
|
446
|
+
* simply stops arriving — there is no "silence" event to wait for. Without a
|
|
447
|
+
* window the loudest reading of the day would be the grid's level for ever.
|
|
448
|
+
* Chosen to span several analysis windows so an ordinary gap does not blink the
|
|
449
|
+
* level away, and to be far shorter than a person notices a stale ring.
|
|
450
|
+
*/
|
|
451
|
+
var AUDIO_FRESH_MS = 5e3;
|
|
452
|
+
var GridActivityMirror = class {
|
|
453
|
+
motion = /* @__PURE__ */ new Map();
|
|
454
|
+
audio = /* @__PURE__ */ new Map();
|
|
455
|
+
lastDetectedAt = null;
|
|
456
|
+
/** One source's motion push. `detected: false` is as important as `true`. */
|
|
457
|
+
noteMotion(sourceDeviceId, detected, atMs) {
|
|
458
|
+
this.motion.set(sourceDeviceId, {
|
|
459
|
+
detected,
|
|
460
|
+
atMs
|
|
461
|
+
});
|
|
462
|
+
if (detected) this.lastDetectedAt = Math.max(this.lastDetectedAt ?? 0, atMs);
|
|
463
|
+
}
|
|
464
|
+
/** One source's audio window. `dbfs` is the level, on the analyzer's scale. */
|
|
465
|
+
noteAudio(sourceDeviceId, dbfs, atMs) {
|
|
466
|
+
this.audio.set(sourceDeviceId, {
|
|
467
|
+
dbfs,
|
|
468
|
+
atMs
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Drop every source this grid no longer composes.
|
|
473
|
+
*
|
|
474
|
+
* A cell removed while its camera was moving would otherwise hold the grid
|
|
475
|
+
* lit until the expiry, for a picture the source is not even in.
|
|
476
|
+
*/
|
|
477
|
+
keepOnly(sourceDeviceIds) {
|
|
478
|
+
const keep = new Set(sourceDeviceIds);
|
|
479
|
+
const motionIds = Array.from(this.motion.keys());
|
|
480
|
+
const audioIds = Array.from(this.audio.keys());
|
|
481
|
+
for (const id of motionIds) if (!keep.has(id)) this.motion.delete(id);
|
|
482
|
+
for (const id of audioIds) if (!keep.has(id)) this.audio.delete(id);
|
|
483
|
+
}
|
|
484
|
+
/** At least one source moving, as of `nowMs`. */
|
|
485
|
+
motionAt(nowMs) {
|
|
486
|
+
let detected = false;
|
|
487
|
+
for (const state of this.motion.values()) {
|
|
488
|
+
if (!state.detected) continue;
|
|
489
|
+
if (nowMs - state.atMs > 15e3) continue;
|
|
490
|
+
detected = true;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
detected,
|
|
495
|
+
lastDetectedAt: this.lastDetectedAt
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The loudest CURRENT reading, or `null` when nothing fresh has been heard.
|
|
500
|
+
*
|
|
501
|
+
* Null and not `-Infinity`: "nobody has reported a level" is a different
|
|
502
|
+
* answer from "it is silent", and a number here would have the viewer draw a
|
|
503
|
+
* level for a grid whose sources are not being analysed at all.
|
|
504
|
+
*/
|
|
505
|
+
loudestAt(nowMs) {
|
|
506
|
+
let loudest = null;
|
|
507
|
+
for (const state of this.audio.values()) {
|
|
508
|
+
if (nowMs - state.atMs > AUDIO_FRESH_MS) continue;
|
|
509
|
+
if (loudest === null || state.dbfs > loudest) loudest = state.dbfs;
|
|
510
|
+
}
|
|
511
|
+
return loudest;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/builtins/camera-grid/grid-detection-mapping.ts
|
|
516
|
+
/**
|
|
517
|
+
* Where this detection lands on the canvas — once per cell that shows it.
|
|
518
|
+
*
|
|
519
|
+
* Empty is a real answer: the subject is in a part of the source this grid does
|
|
520
|
+
* not display, or the frame arrived with no size to measure against. Neither is
|
|
521
|
+
* a reason to guess.
|
|
522
|
+
*/
|
|
523
|
+
function mapDetectionToCanvas(input) {
|
|
524
|
+
const { width: frameW, height: frameH } = input.sourceFrame;
|
|
525
|
+
if (frameW <= 0 || frameH <= 0) return [];
|
|
526
|
+
const left = input.bbox.x / frameW;
|
|
527
|
+
const top = input.bbox.y / frameH;
|
|
528
|
+
const right = (input.bbox.x + input.bbox.width) / frameW;
|
|
529
|
+
const bottom = (input.bbox.y + input.bbox.height) / frameH;
|
|
530
|
+
const out = [];
|
|
531
|
+
for (const { source, cell } of input.cells) {
|
|
532
|
+
if (source.width <= 0 || source.height <= 0) continue;
|
|
533
|
+
const l = Math.max(left, source.x);
|
|
534
|
+
const t = Math.max(top, source.y);
|
|
535
|
+
const r = Math.min(right, source.x + source.width);
|
|
536
|
+
const b = Math.min(bottom, source.y + source.height);
|
|
537
|
+
if (r <= l || b <= t) continue;
|
|
538
|
+
const cx = (l - source.x) / source.width;
|
|
539
|
+
const cy = (t - source.y) / source.height;
|
|
540
|
+
const cw = (r - l) / source.width;
|
|
541
|
+
const ch = (b - t) / source.height;
|
|
542
|
+
out.push({
|
|
543
|
+
x: (cell.x + cx * cell.width) * input.canvas.width,
|
|
544
|
+
y: (cell.y + cy * cell.height) * input.canvas.height,
|
|
545
|
+
width: cw * cell.width * input.canvas.width,
|
|
546
|
+
height: ch * cell.height * input.canvas.height
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
552
|
+
//#region src/builtins/camera-grid/grid-activity.ts
|
|
553
|
+
/**
|
|
554
|
+
* The grid's own motion / audio / detection, forwarded from its sources.
|
|
555
|
+
*
|
|
556
|
+
* ## Why the grid speaks a camera's language instead of a new one
|
|
557
|
+
*
|
|
558
|
+
* The operator asked for the grid's tile to light up in the viewer exactly as a
|
|
559
|
+
* camera's card does, with NO app change. The app already knows how: it filters
|
|
560
|
+
* `motion.on-motion-changed` by `deviceId` for the motion ring,
|
|
561
|
+
* `pipeline.audio-inference-result` for the audio one, and `detection.result`
|
|
562
|
+
* for the detection one. So the grid answers those three questions about its
|
|
563
|
+
* own device id, in the same shapes. Nothing new is invented, and nothing in
|
|
564
|
+
* the viewer is touched.
|
|
565
|
+
*
|
|
566
|
+
* The rules are the operator's: motion if AT LEAST ONE source has it, and the
|
|
567
|
+
* LOUDEST source's level for audio.
|
|
568
|
+
*
|
|
569
|
+
* ## What speaking a camera's language costs, and why it is safe here
|
|
570
|
+
*
|
|
571
|
+
* Those categories have other listeners, and emitting on them makes the grid a
|
|
572
|
+
* motion source for the whole system. Checked, one by one, before shipping:
|
|
573
|
+
* the recorder's event capture, the HomeKit and Alexa exports and the Home
|
|
574
|
+
* Assistant export are all per-device and do nothing until an operator enables
|
|
575
|
+
* them for this camera — and if an operator DOES export a grid to HomeKit, a
|
|
576
|
+
* grid reporting motion is correct, not a surprise.
|
|
577
|
+
*
|
|
578
|
+
* The one listener with no gate of its own was `pipeline-analytics`, which
|
|
579
|
+
* would have opened a motion EPISODE per grid, duplicating the rows its sources
|
|
580
|
+
* already write. Its gate turned out to be the `pipeline-analytics` WRAPPER
|
|
581
|
+
* BINDING, so the answer is the one the operator gave: a derived camera is
|
|
582
|
+
* created with the analytics wrappers OFF (`grid-analytics-defaults.ts`).
|
|
583
|
+
* That is a binding, not a new flag, so an operator who wants a grid analysed
|
|
584
|
+
* can simply turn it back on.
|
|
585
|
+
*
|
|
586
|
+
* ## Detection carries boxes whether or not anyone wanted them
|
|
587
|
+
*
|
|
588
|
+
* `detection.result` is the same event the viewer draws BOXES from, so a grid
|
|
589
|
+
* that reports detections reports boxes. Sending the source's own pixel box
|
|
590
|
+
* would draw it against the composed canvas at confidently wrong coordinates,
|
|
591
|
+
* so every box is carried through the cell geometry first
|
|
592
|
+
* (`grid-detection-mapping.ts`) and lands where the subject actually is.
|
|
593
|
+
*/
|
|
594
|
+
/**
|
|
595
|
+
* One mirror per grid, and the emission that follows an update.
|
|
596
|
+
*
|
|
597
|
+
* Deliberately NOT one mirror per source: the question is always "is THIS grid
|
|
598
|
+
* lit", and a source shared by two grids answers it twice, differently, because
|
|
599
|
+
* the other cells differ.
|
|
600
|
+
*/
|
|
601
|
+
var GridActivityForwarder = class {
|
|
602
|
+
deps;
|
|
603
|
+
mirrors = /* @__PURE__ */ new Map();
|
|
604
|
+
constructor(deps) {
|
|
605
|
+
this.deps = deps;
|
|
606
|
+
}
|
|
607
|
+
/** A source camera's motion push. */
|
|
608
|
+
onSourceMotion(sourceDeviceId, detected, atMs) {
|
|
609
|
+
for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
|
|
610
|
+
const mirror = this.mirrorFor(grid);
|
|
611
|
+
const before = mirror.motionAt(atMs).detected;
|
|
612
|
+
mirror.noteMotion(sourceDeviceId, detected, atMs);
|
|
613
|
+
const after = mirror.motionAt(atMs);
|
|
614
|
+
if (after.detected === before) continue;
|
|
615
|
+
this.emitMotion(grid.deviceId, after, atMs);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/** A source camera's audio window. */
|
|
619
|
+
onSourceAudio(sourceDeviceId, dbfs, atMs) {
|
|
620
|
+
for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
|
|
621
|
+
const mirror = this.mirrorFor(grid);
|
|
622
|
+
mirror.noteAudio(sourceDeviceId, dbfs, atMs);
|
|
623
|
+
const loudest = mirror.loudestAt(atMs);
|
|
624
|
+
if (loudest === null) continue;
|
|
625
|
+
this.emitAudio(grid.deviceId, loudest, atMs);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** A source camera's detection frame, with its boxes in SOURCE pixels. */
|
|
629
|
+
onSourceDetections(frame) {
|
|
630
|
+
for (const grid of this.deps.gridsForSource(frame.deviceId)) {
|
|
631
|
+
const cells = grid.cellsForSource(frame.deviceId);
|
|
632
|
+
if (cells.length === 0) continue;
|
|
633
|
+
const detections = [];
|
|
634
|
+
for (const detection of frame.detections) for (const bbox of mapDetectionToCanvas({
|
|
635
|
+
bbox: detection.bbox,
|
|
636
|
+
sourceFrame: {
|
|
637
|
+
width: frame.width,
|
|
638
|
+
height: frame.height
|
|
639
|
+
},
|
|
640
|
+
canvas: grid.canvas,
|
|
641
|
+
cells
|
|
642
|
+
})) {
|
|
643
|
+
const { refinedBbox: _refined, mask: _mask, ...rest } = detection;
|
|
644
|
+
detections.push({
|
|
645
|
+
...rest,
|
|
646
|
+
bbox
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (detections.length === 0) continue;
|
|
650
|
+
this.emitDetections(grid, frame, detections);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/** The grid's own motion answer, for `motion.getStatus`. */
|
|
654
|
+
motionStatusFor(deviceId, nowMs) {
|
|
655
|
+
return this.mirrors.get(deviceId)?.motionAt(nowMs) ?? {
|
|
656
|
+
detected: false,
|
|
657
|
+
lastDetectedAt: null
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/** Drop the mirror of a grid that no longer exists. */
|
|
661
|
+
forget(deviceId) {
|
|
662
|
+
this.mirrors.delete(deviceId);
|
|
663
|
+
}
|
|
664
|
+
mirrorFor(grid) {
|
|
665
|
+
let mirror = this.mirrors.get(grid.deviceId);
|
|
666
|
+
if (mirror === void 0) {
|
|
667
|
+
mirror = new GridActivityMirror();
|
|
668
|
+
this.mirrors.set(grid.deviceId, mirror);
|
|
669
|
+
}
|
|
670
|
+
mirror.keepOnly(grid.sourceDeviceIds);
|
|
671
|
+
return mirror;
|
|
672
|
+
}
|
|
673
|
+
source(deviceId) {
|
|
674
|
+
return {
|
|
675
|
+
type: "device",
|
|
676
|
+
id: deviceId,
|
|
677
|
+
addonId: this.deps.addonId,
|
|
678
|
+
deviceId
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
emitMotion(deviceId, state, atMs) {
|
|
682
|
+
const payload = {
|
|
683
|
+
deviceId,
|
|
684
|
+
detected: state.detected,
|
|
685
|
+
timestamp: atMs,
|
|
686
|
+
source: "onboard"
|
|
687
|
+
};
|
|
688
|
+
this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.MotionOnMotionChanged, this.source(deviceId), payload));
|
|
689
|
+
this.deps.logger.debug("camera grid: motion forwarded", {
|
|
690
|
+
tags: { deviceId },
|
|
691
|
+
meta: { detected: state.detected }
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
emitAudio(deviceId, dbfs, atMs) {
|
|
695
|
+
const payload = {
|
|
696
|
+
deviceId,
|
|
697
|
+
frame: {
|
|
698
|
+
kind: "audio-window",
|
|
699
|
+
windowId: `grid-${String(deviceId)}-${String(atMs)}`,
|
|
700
|
+
deviceId,
|
|
701
|
+
timestamp: atMs,
|
|
702
|
+
startMs: atMs,
|
|
703
|
+
endMs: atMs,
|
|
704
|
+
level: {
|
|
705
|
+
rms: 0,
|
|
706
|
+
dbfs
|
|
707
|
+
},
|
|
708
|
+
detections: []
|
|
709
|
+
},
|
|
710
|
+
nodeId: "hub"
|
|
711
|
+
};
|
|
712
|
+
this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.PipelineAudioInferenceResult, this.source(deviceId), payload));
|
|
713
|
+
}
|
|
714
|
+
emitDetections(grid, sourceFrame, detections) {
|
|
715
|
+
const payload = {
|
|
716
|
+
frame: {
|
|
717
|
+
kind: "frame",
|
|
718
|
+
frameId: `grid-${String(grid.deviceId)}-${sourceFrame.frameId}`,
|
|
719
|
+
deviceId: grid.deviceId,
|
|
720
|
+
timestamp: sourceFrame.timestamp,
|
|
721
|
+
width: grid.canvas.width,
|
|
722
|
+
height: grid.canvas.height,
|
|
723
|
+
detections
|
|
724
|
+
},
|
|
725
|
+
analysisResults: []
|
|
726
|
+
};
|
|
727
|
+
this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.DetectionResult, this.source(grid.deviceId), payload));
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
//#endregion
|
|
403
731
|
//#region src/builtins/camera-grid/grid-wire-format.ts
|
|
404
732
|
/** What the relay answers with, and what the sampler must demux. */
|
|
405
733
|
var GRID_WIRE_CONTENT_TYPE = "video/x-flv";
|
|
@@ -1402,11 +1730,14 @@ var GridStreamSession = class {
|
|
|
1402
1730
|
* for a grid must win, and a reconcile that re-asserted every pass would silently
|
|
1403
1731
|
* overrule them once a minute.
|
|
1404
1732
|
*/
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1733
|
+
/**
|
|
1734
|
+
* The list lives in `@camstack/types` now, and this is an alias rather than a
|
|
1735
|
+
* second copy. There WERE two — this one and the Terminal addon's — and they
|
|
1736
|
+
* had drifted: neither named `pipeline-analytics`, the one cap with no gate of
|
|
1737
|
+
* its own, which is what a grid forwarding its sources' motion would have used
|
|
1738
|
+
* to write a duplicate motion episode per event.
|
|
1739
|
+
*/
|
|
1740
|
+
var GRID_SILENCED_CAP_NAMES = require_dist.DERIVED_CAMERA_SILENCED_CAP_NAMES;
|
|
1410
1741
|
/**
|
|
1411
1742
|
* Resolve the addon currently providing `capName` for this device, from the
|
|
1412
1743
|
* device's OWN bindings. `listBindableCapsForDeviceType` answers for the device
|
|
@@ -1469,267 +1800,561 @@ async function silenceAnalysisFor(deps, deviceId) {
|
|
|
1469
1800
|
if (failures.length > 0) throw new Error(`camera grid ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
|
|
1470
1801
|
}
|
|
1471
1802
|
//#endregion
|
|
1472
|
-
//#region src/builtins/camera-grid/grid-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
var FULL_FRAME_EPSILON = 1e-6;
|
|
1477
|
-
function isFullFrame(rect) {
|
|
1478
|
-
return Math.abs(rect.x) < FULL_FRAME_EPSILON && Math.abs(rect.y) < FULL_FRAME_EPSILON && Math.abs(rect.width - 1) < FULL_FRAME_EPSILON && Math.abs(rect.height - 1) < FULL_FRAME_EPSILON;
|
|
1803
|
+
//#region src/builtins/camera-grid/grid-canvas.ts
|
|
1804
|
+
/** Bytes one yuv420p frame of this size occupies. */
|
|
1805
|
+
function yuv420pSize(width, height) {
|
|
1806
|
+
return width * height + 2 * (width / 2) * (height / 2);
|
|
1479
1807
|
}
|
|
1480
1808
|
/**
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1809
|
+
* Round DOWN to even.
|
|
1810
|
+
*
|
|
1811
|
+
* Down and not nearest: a tile that rounded UP would claim a row of samples the
|
|
1812
|
+
* decoder was never asked to produce, and the copy would either overrun or be
|
|
1813
|
+
* refused at the moment the picture is wanted.
|
|
1484
1814
|
*/
|
|
1485
|
-
function
|
|
1486
|
-
|
|
1487
|
-
const even = rounded % 2 === 0 ? rounded : rounded - 1;
|
|
1488
|
-
return Math.max(2, even);
|
|
1815
|
+
function evenDown(value) {
|
|
1816
|
+
return Math.max(0, Math.floor(value / 2) * 2);
|
|
1489
1817
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1818
|
+
var GridCanvas = class {
|
|
1819
|
+
width;
|
|
1820
|
+
height;
|
|
1821
|
+
buffer;
|
|
1822
|
+
lumaBytes;
|
|
1823
|
+
chromaBytes;
|
|
1824
|
+
constructor(width, height) {
|
|
1825
|
+
this.width = width;
|
|
1826
|
+
this.height = height;
|
|
1827
|
+
this.lumaBytes = width * height;
|
|
1828
|
+
this.chromaBytes = width / 2 * (height / 2);
|
|
1829
|
+
this.buffer = Buffer.alloc(yuv420pSize(width, height));
|
|
1830
|
+
this.clear();
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Legible black — Y 0, chroma 128 (neutral).
|
|
1834
|
+
*
|
|
1835
|
+
* `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
|
|
1836
|
+
* strong green. A cell nobody has filled yet has to look like an empty cell,
|
|
1837
|
+
* not like a fault.
|
|
1838
|
+
*/
|
|
1839
|
+
clear() {
|
|
1840
|
+
this.buffer.fill(0, 0, this.lumaBytes);
|
|
1841
|
+
this.buffer.fill(128, this.lumaBytes);
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Copy one decoded tile in at `origin`.
|
|
1845
|
+
*
|
|
1846
|
+
* Returns false — never throws, never writes a partial tile — when the tile
|
|
1847
|
+
* does not fit, is the wrong size for the dimensions it claims, or sits on an
|
|
1848
|
+
* odd boundary. This runs on the output tick, so a bad frame from one source
|
|
1849
|
+
* must cost that source's rectangle and nothing else.
|
|
1850
|
+
*/
|
|
1851
|
+
blit(tile, size, origin) {
|
|
1852
|
+
if (!isEven(size.width) || !isEven(size.height)) return false;
|
|
1853
|
+
if (!isEven(origin.x) || !isEven(origin.y)) return false;
|
|
1854
|
+
if (size.width <= 0 || size.height <= 0) return false;
|
|
1855
|
+
if (origin.x < 0 || origin.y < 0) return false;
|
|
1856
|
+
if (origin.x + size.width > this.width) return false;
|
|
1857
|
+
if (origin.y + size.height > this.height) return false;
|
|
1858
|
+
if (tile.length !== yuv420pSize(size.width, size.height)) return false;
|
|
1859
|
+
for (let row = 0; row < size.height; row += 1) {
|
|
1860
|
+
const from = row * size.width;
|
|
1861
|
+
const to = (origin.y + row) * this.width + origin.x;
|
|
1862
|
+
tile.copy(this.buffer, to, from, from + size.width);
|
|
1863
|
+
}
|
|
1864
|
+
const tileLuma = size.width * size.height;
|
|
1865
|
+
const tileChroma = size.width / 2 * (size.height / 2);
|
|
1866
|
+
const halfW = size.width / 2;
|
|
1867
|
+
const halfH = size.height / 2;
|
|
1868
|
+
const canvasHalfW = this.width / 2;
|
|
1869
|
+
for (let plane = 0; plane < 2; plane += 1) {
|
|
1870
|
+
const tileBase = tileLuma + plane * tileChroma;
|
|
1871
|
+
const canvasBase = this.lumaBytes + plane * this.chromaBytes;
|
|
1872
|
+
for (let row = 0; row < halfH; row += 1) {
|
|
1873
|
+
const from = tileBase + row * halfW;
|
|
1874
|
+
const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
|
|
1875
|
+
tile.copy(this.buffer, to, from, from + halfW);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
return true;
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
function isEven(value) {
|
|
1882
|
+
return Number.isInteger(value) && value % 2 === 0;
|
|
1883
|
+
}
|
|
1884
|
+
//#endregion
|
|
1885
|
+
//#region src/builtins/camera-grid/grid-tile-feed.ts
|
|
1886
|
+
/**
|
|
1887
|
+
* One source's decoded tiles, of which only the LATEST is ever wanted.
|
|
1888
|
+
*
|
|
1889
|
+
* ## Why dropping is the point, not a compromise
|
|
1890
|
+
*
|
|
1891
|
+
* ffmpeg's `overlay` consumes one frame per input per output frame, so an input
|
|
1892
|
+
* that got ahead of another stays ahead for the life of the child — that is the
|
|
1893
|
+
* tile gap. A canvas asks a different question: "what does this camera look
|
|
1894
|
+
* like NOW". Everything behind the newest frame is not a backlog to work
|
|
1895
|
+
* through, it is history, and dropping it is what keeps every tile at the same
|
|
1896
|
+
* moment however unevenly their frames arrive.
|
|
1897
|
+
*
|
|
1898
|
+
* It is also the only bound on memory here. A decoder child that outruns the
|
|
1899
|
+
* output tick — or a tick that stalls — would otherwise grow an unbounded queue
|
|
1900
|
+
* of raw frames, and a raw frame is 345 KB at 640x360.
|
|
1901
|
+
*
|
|
1902
|
+
* ## The last frame is HELD
|
|
1903
|
+
*
|
|
1904
|
+
* A tick that finds nothing new redraws what was there. A tile must not blink
|
|
1905
|
+
* because a source was 40 ms late, and a source that has genuinely stopped is a
|
|
1906
|
+
* still picture — which is legible, and which the snapshot path already treats
|
|
1907
|
+
* as the honest answer for a camera that is not sending.
|
|
1908
|
+
*/
|
|
1909
|
+
var GridTileFeed = class {
|
|
1910
|
+
frameBytes;
|
|
1911
|
+
partial = Buffer.alloc(0);
|
|
1912
|
+
newest = null;
|
|
1913
|
+
fresh = false;
|
|
1914
|
+
constructor(frameBytes) {
|
|
1915
|
+
this.frameBytes = frameBytes;
|
|
1916
|
+
}
|
|
1917
|
+
/** Feed bytes from the decoder. Any whole frames inside replace the newest. */
|
|
1918
|
+
push(chunk) {
|
|
1919
|
+
let buf = this.partial.length === 0 ? chunk : Buffer.concat([this.partial, chunk]);
|
|
1920
|
+
while (buf.length >= this.frameBytes) {
|
|
1921
|
+
this.newest = Buffer.from(buf.subarray(0, this.frameBytes));
|
|
1922
|
+
this.fresh = true;
|
|
1923
|
+
buf = buf.subarray(this.frameBytes);
|
|
1924
|
+
}
|
|
1925
|
+
this.partial = buf.length === 0 ? Buffer.alloc(0) : Buffer.from(buf);
|
|
1926
|
+
}
|
|
1927
|
+
/** The newest whole frame, or `null` while none has arrived. */
|
|
1928
|
+
latest() {
|
|
1929
|
+
this.fresh = false;
|
|
1930
|
+
return this.newest;
|
|
1931
|
+
}
|
|
1932
|
+
/** Has a new frame arrived since the last {@link latest}? */
|
|
1933
|
+
hasFresh() {
|
|
1934
|
+
return this.fresh;
|
|
1935
|
+
}
|
|
1936
|
+
/** For the bound in the spec: one frame plus at most a partial one. */
|
|
1937
|
+
bufferedBytes() {
|
|
1938
|
+
return (this.newest?.length ?? 0) + this.partial.length;
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
//#endregion
|
|
1942
|
+
//#region src/builtins/camera-grid/grid-compositor.ts
|
|
1943
|
+
/**
|
|
1944
|
+
* The composition, driven by the OUTPUT's clock.
|
|
1945
|
+
*
|
|
1946
|
+
* ## What this replaces, and why it is the only thing that works
|
|
1947
|
+
*
|
|
1948
|
+
* `overlay` pairs its inputs BY PTS and ffmpeg gives each input pts 0 at its
|
|
1949
|
+
* own first frame, so two sources that joined at different moments compose
|
|
1950
|
+
* different moments for the life of the child. Measured at 4-5 s on this hub,
|
|
1951
|
+
* drifting over hours as the sources' key-frame phases slide through each other.
|
|
1952
|
+
* Every cheaper fix was tried and measured: `-use_wallclock_as_timestamps`
|
|
1953
|
+
* re-times by arrival and wrecked the pacing; `-copyts` changes nothing because
|
|
1954
|
+
* the broker re-bases each session (both restreams report `pts_time:0`), so
|
|
1955
|
+
* there is no shared timeline to align against; and serving each dial from the
|
|
1956
|
+
* newest key frame it already holds only moves the offsets into the past,
|
|
1957
|
+
* because those key frames are of different ages.
|
|
1958
|
+
*
|
|
1959
|
+
* Here there is nothing to pair. Every tick asks each source "what do you look
|
|
1960
|
+
* like NOW" and the answer is whatever arrived last. The gap is zero by
|
|
1961
|
+
* construction rather than by tuning.
|
|
1962
|
+
*
|
|
1963
|
+
* ## It also removes the wait
|
|
1964
|
+
*
|
|
1965
|
+
* A cell with no frame yet is BLACK, not a reason to hold the picture back. The
|
|
1966
|
+
* output exists from the first tick and cells fill in as their sources arrive —
|
|
1967
|
+
* instead of the whole grid waiting for the slowest source's first key frame,
|
|
1968
|
+
* which is what the ~9 s cold start was.
|
|
1969
|
+
*
|
|
1970
|
+
* ## What it costs
|
|
1971
|
+
*
|
|
1972
|
+
* The same N decodes ffmpeg already paid, in N children instead of one, plus
|
|
1973
|
+
* one strided memcpy per tile per tick (`grid-canvas.ts`). It does NOT reuse the
|
|
1974
|
+
* frames the detection pipeline decodes: those live in per-session decode
|
|
1975
|
+
* workers behind two process boundaries, the shm plane that once joined them was
|
|
1976
|
+
* removed on 2026-07-16, and what the pipeline retains is a model-sized view
|
|
1977
|
+
* (320x180 JPEG, on demand), not a tile.
|
|
1978
|
+
*/
|
|
1979
|
+
var GridCompositor = class {
|
|
1980
|
+
deps;
|
|
1981
|
+
canvas;
|
|
1982
|
+
/** Keyed by tile id — unique per CELL, because one camera can hold two. */
|
|
1983
|
+
placed = /* @__PURE__ */ new Map();
|
|
1984
|
+
closed = false;
|
|
1985
|
+
constructor(deps) {
|
|
1986
|
+
this.deps = deps;
|
|
1987
|
+
this.canvas = new GridCanvas(deps.plan.canvas.width, deps.plan.canvas.height);
|
|
1988
|
+
for (const tile of deps.plan.tiles) this.placed.set(tile.id, {
|
|
1989
|
+
tile,
|
|
1990
|
+
feed: new GridTileFeed(tile.frameBytes)
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Raw bytes from one decoder output.
|
|
1995
|
+
*
|
|
1996
|
+
* A tile this composition has no rectangle for is IGNORED, never thrown on: a
|
|
1997
|
+
* child that outlives a layout change would otherwise take the tick down with
|
|
1998
|
+
* it, and the tick is what every other cell depends on.
|
|
1999
|
+
*/
|
|
2000
|
+
onTile(tileId, chunk) {
|
|
2001
|
+
this.placed.get(tileId)?.feed.push(chunk);
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Compose and emit one frame. Returns what the sink said about back-pressure.
|
|
2005
|
+
*
|
|
2006
|
+
* Every tile is redrawn, fresh or not: a tile that produced nothing this tick
|
|
2007
|
+
* is a still picture, not a hole, and clearing it would make a late source
|
|
2008
|
+
* flicker. The canvas is never cleared between ticks for the same reason.
|
|
2009
|
+
*/
|
|
2010
|
+
tick() {
|
|
2011
|
+
if (this.closed) return true;
|
|
2012
|
+
for (const { tile, feed } of this.placed.values()) {
|
|
2013
|
+
const frame = feed.latest();
|
|
2014
|
+
if (frame === null) continue;
|
|
2015
|
+
this.canvas.blit(frame, tile.size, tile.origin);
|
|
2016
|
+
}
|
|
2017
|
+
return this.deps.write(this.canvas.buffer);
|
|
2018
|
+
}
|
|
2019
|
+
/** After this, a tick emits nothing — a dead child must not keep writing. */
|
|
2020
|
+
close() {
|
|
2021
|
+
this.closed = true;
|
|
2022
|
+
}
|
|
2023
|
+
};
|
|
2024
|
+
//#endregion
|
|
2025
|
+
//#region src/builtins/camera-grid/grid-tile-plan.ts
|
|
2026
|
+
/**
|
|
2027
|
+
* The operator's layout, as one decoder child per CELL.
|
|
2028
|
+
*
|
|
2029
|
+
* ## Why per cell, when `grid-plan.ts` dedupes per source
|
|
2030
|
+
*
|
|
2031
|
+
* The single ffmpeg that this replaces made a camera used by two cells ONE
|
|
2032
|
+
* input, because decoding it twice doubles the expensive half of the job. A
|
|
2033
|
+
* compositor cannot do that with the same shape: this repo has exactly one
|
|
2034
|
+
* ffmpeg argv builder (`scripts/check-ffmpeg-primitive.ts`, Rule 1) and it
|
|
2035
|
+
* expresses one output per process, so a source feeding two differently cropped
|
|
2036
|
+
* and differently scaled tiles needs two children.
|
|
2037
|
+
*
|
|
2038
|
+
* The trade is small and worth stating: since D528 every source is read at its
|
|
2039
|
+
* CHEAPEST stream, so the duplicate is a second 640x360 sub-stream decode, not a
|
|
2040
|
+
* second 4 MP one. Teaching the builder several outputs would buy that back, and
|
|
2041
|
+
* is the right change to make if a grid ever shows one camera many times — it is
|
|
2042
|
+
* deliberately not made for a case that costs this little.
|
|
2043
|
+
*
|
|
2044
|
+
* ## Why the tile is scaled in the DECODER
|
|
2045
|
+
*
|
|
2046
|
+
* ffmpeg's scaler is SIMD and already there. Scaling in this process would be a
|
|
2047
|
+
* per-pixel loop in JavaScript, on the event loop that also answers the grid's
|
|
2048
|
+
* RPCs. Handing each child the exact pixel size its rectangle needs makes the
|
|
2049
|
+
* composition a strided memcpy (`grid-canvas.ts`) and keeps the only per-pixel
|
|
2050
|
+
* work where it belongs.
|
|
2051
|
+
*
|
|
2052
|
+
* ## Even, and snapped DOWN
|
|
2053
|
+
*
|
|
2054
|
+
* yuv420p subsamples chroma 2x2, so an odd origin lands a tile's chroma half a
|
|
2055
|
+
* sample off its luma — which TINTS the tile instead of breaking it, and
|
|
2056
|
+
* survives review. Sizes round DOWN so a tile never claims a row the decoder
|
|
2057
|
+
* was not asked to produce; a cell that rounds away to nothing is dropped
|
|
2058
|
+
* rather than emitted at zero size, because a zero-size output is an ffmpeg
|
|
2059
|
+
* start-up failure that would take the whole grid with it.
|
|
2060
|
+
*/
|
|
2061
|
+
/** Smallest tile worth decoding. Below this it is not a cheaper picture, it is none. */
|
|
2062
|
+
var MIN_TILE_PX = 2;
|
|
2063
|
+
/** The one pixel format the canvas is in, so a blit is a copy and not a convert. */
|
|
2064
|
+
var TILE_PIXEL_FORMAT = "yuv420p";
|
|
2065
|
+
var FULL_FRAME_EPSILON$1 = 1e-6;
|
|
2066
|
+
function isFullFrame$1(rect) {
|
|
2067
|
+
return Math.abs(rect.x) < FULL_FRAME_EPSILON$1 && Math.abs(rect.y) < FULL_FRAME_EPSILON$1 && Math.abs(rect.width - 1) < FULL_FRAME_EPSILON$1 && Math.abs(rect.height - 1) < FULL_FRAME_EPSILON$1;
|
|
1492
2068
|
}
|
|
1493
2069
|
/**
|
|
1494
2070
|
* `crop` resolved against the source's OWN size at runtime (`iw`/`ih`), never
|
|
1495
|
-
* against a resolution guessed here
|
|
1496
|
-
* survive a
|
|
2071
|
+
* against a resolution guessed here — which is what lets a stored rectangle
|
|
2072
|
+
* survive a camera that changes resolution under us.
|
|
1497
2073
|
*/
|
|
1498
|
-
function cropExpression(source) {
|
|
2074
|
+
function cropExpression$1(source) {
|
|
1499
2075
|
return `crop=iw*${source.width}:ih*${source.height}:iw*${source.x}:ih*${source.y}`;
|
|
1500
2076
|
}
|
|
1501
|
-
function
|
|
1502
|
-
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
const
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
2077
|
+
function buildGridTilePlan(input) {
|
|
2078
|
+
const tiles = [];
|
|
2079
|
+
for (const [index, cell] of input.cells.entries()) {
|
|
2080
|
+
const width = evenDown(cell.cell.width * input.canvas.width);
|
|
2081
|
+
const height = evenDown(cell.cell.height * input.canvas.height);
|
|
2082
|
+
if (width < MIN_TILE_PX || height < MIN_TILE_PX) continue;
|
|
2083
|
+
const x = Math.min(evenDown(cell.cell.x * input.canvas.width), evenDown(input.canvas.width - width));
|
|
2084
|
+
const y = Math.min(evenDown(cell.cell.y * input.canvas.height), evenDown(input.canvas.height - height));
|
|
2085
|
+
tiles.push({
|
|
2086
|
+
id: `cell-${String(index)}`,
|
|
2087
|
+
deviceId: cell.deviceId,
|
|
2088
|
+
size: {
|
|
2089
|
+
width,
|
|
2090
|
+
height
|
|
2091
|
+
},
|
|
2092
|
+
origin: {
|
|
2093
|
+
x,
|
|
2094
|
+
y
|
|
2095
|
+
},
|
|
2096
|
+
frameBytes: yuv420pSize(width, height),
|
|
2097
|
+
filter: [
|
|
2098
|
+
...isFullFrame$1(cell.source) ? [] : [cropExpression$1(cell.source)],
|
|
2099
|
+
`scale=${String(width)}:${String(height)}`,
|
|
2100
|
+
`format=${TILE_PIXEL_FORMAT}`
|
|
2101
|
+
].join(",")
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
1523
2104
|
return {
|
|
1524
|
-
|
|
1525
|
-
|
|
2105
|
+
canvas: input.canvas,
|
|
2106
|
+
tiles
|
|
1526
2107
|
};
|
|
1527
2108
|
}
|
|
1528
2109
|
//#endregion
|
|
1529
|
-
//#region src/builtins/camera-grid/grid-
|
|
1530
|
-
/**
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
2110
|
+
//#region src/builtins/camera-grid/grid-compositor-invocation.ts
|
|
2111
|
+
/** See D527: the probe returns from the SDP instead of reading media. */
|
|
2112
|
+
var ANALYZE_DURATION_US$1 = 0;
|
|
2113
|
+
var PROBE_SIZE_BYTES$1 = 32;
|
|
2114
|
+
var PRESET$1 = "veryfast";
|
|
2115
|
+
var TUNE$1 = "zerolatency";
|
|
2116
|
+
var GOP_SECONDS$1 = 1;
|
|
2117
|
+
/** One tile's decoder: dial in, raw yuv420p frames out. */
|
|
2118
|
+
function gridTileDecoderInvocation(input) {
|
|
2119
|
+
return {
|
|
2120
|
+
logLevel: "error",
|
|
2121
|
+
decodeHwAccel: null,
|
|
2122
|
+
input: {
|
|
2123
|
+
url: input.url,
|
|
2124
|
+
rtspTransport: "tcp",
|
|
2125
|
+
fflags: ["+discardcorrupt"],
|
|
2126
|
+
lowDelay: true,
|
|
2127
|
+
analyzeDurationUs: ANALYZE_DURATION_US$1,
|
|
2128
|
+
probeSizeBytes: PROBE_SIZE_BYTES$1
|
|
2129
|
+
},
|
|
2130
|
+
video: {
|
|
2131
|
+
kind: "raw",
|
|
2132
|
+
pixelFormat: TILE_PIXEL_FORMAT,
|
|
2133
|
+
filter: input.tile.filter,
|
|
2134
|
+
fps: input.fps
|
|
2135
|
+
},
|
|
2136
|
+
audio: { kind: "none" },
|
|
2137
|
+
threadCount: 0,
|
|
2138
|
+
outputArgs: [],
|
|
2139
|
+
sink: {
|
|
2140
|
+
kind: "stdout",
|
|
2141
|
+
container: "rawvideo"
|
|
2142
|
+
}
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
/** The canvas encoder: raw frames on stdin, one compressed stream out. */
|
|
2146
|
+
function gridCanvasEncoderInvocation(input) {
|
|
2147
|
+
const video = {
|
|
2148
|
+
kind: "encode",
|
|
2149
|
+
encoder: input.encoder,
|
|
2150
|
+
preset: PRESET$1,
|
|
2151
|
+
tune: TUNE$1,
|
|
2152
|
+
pixelFormat: TILE_PIXEL_FORMAT,
|
|
2153
|
+
fps: input.fps,
|
|
2154
|
+
gopFrames: input.fps * GOP_SECONDS$1,
|
|
2155
|
+
bitrateKbps: input.bitrateKbps,
|
|
2156
|
+
scale: null
|
|
2157
|
+
};
|
|
2158
|
+
return {
|
|
2159
|
+
logLevel: "error",
|
|
2160
|
+
decodeHwAccel: null,
|
|
2161
|
+
input: {
|
|
2162
|
+
url: "pipe:0",
|
|
2163
|
+
rawVideo: {
|
|
2164
|
+
pixelFormat: TILE_PIXEL_FORMAT,
|
|
2165
|
+
width: input.canvas.width,
|
|
2166
|
+
height: input.canvas.height,
|
|
2167
|
+
framerate: input.fps
|
|
2168
|
+
}
|
|
2169
|
+
},
|
|
2170
|
+
video,
|
|
2171
|
+
audio: { kind: "none" },
|
|
2172
|
+
threadCount: 0,
|
|
2173
|
+
outputArgs: [],
|
|
2174
|
+
sink: {
|
|
2175
|
+
kind: "stdout",
|
|
2176
|
+
container: "flv"
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
//#endregion
|
|
2181
|
+
//#region src/builtins/camera-grid/grid-compositor-child.ts
|
|
1537
2182
|
/**
|
|
1538
|
-
*
|
|
2183
|
+
* The composition as processes: one decoder per tile, one encoder, one tick.
|
|
1539
2184
|
*
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
* broker. `zerolatency` sets `rc-lookahead=0`, `sync-lookahead=0` and
|
|
1545
|
-
* `bframes=0`, which is exactly the trade a live composite wants: a few percent
|
|
1546
|
-
* more bitrate for the same picture, in exchange for the encoder emitting each
|
|
1547
|
-
* frame as it arrives.
|
|
2185
|
+
* It presents the SAME shape the single ffmpeg did — `{ stdout, stop }` — so
|
|
2186
|
+
* `GridStreamSession` is untouched. The seam is deliberate: the session owns
|
|
2187
|
+
* demand, acquisition and teardown, and none of that changes because the
|
|
2188
|
+
* picture is assembled differently.
|
|
1548
2189
|
*
|
|
1549
|
-
*
|
|
1550
|
-
* how long it is allowed to WAIT, and the two are independent.
|
|
1551
|
-
*/
|
|
1552
|
-
var TUNE = "zerolatency";
|
|
1553
|
-
/** yuv420p: the only pixel format every consumer of a CamStack profile reads. */
|
|
1554
|
-
var PIXEL_FORMAT = "yuv420p";
|
|
1555
|
-
/**
|
|
1556
|
-
* Key-frame cadence, in seconds.
|
|
2190
|
+
* ## The order things start in, and why it is the order
|
|
1557
2191
|
*
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1560
|
-
*
|
|
1561
|
-
*
|
|
1562
|
-
*
|
|
1563
|
-
* at nothing for two seconds after pressing play.
|
|
2192
|
+
* The encoder first, then the tick, then the decoders. The tick writes a black
|
|
2193
|
+
* canvas from its very first beat, so the encoder produces a stream — and the
|
|
2194
|
+
* consumer sees a picture — before any camera has delivered anything. That is
|
|
2195
|
+
* the cold start the old shape spent waiting for the slowest source's first key
|
|
2196
|
+
* frame, and it is now spent watching cells fill in.
|
|
1564
2197
|
*
|
|
1565
|
-
*
|
|
1566
|
-
* are now twice as many), which is why it is not lower: below a second the
|
|
1567
|
-
* bitrate paid buys a wait nobody can feel.
|
|
1568
|
-
*/
|
|
1569
|
-
var GOP_SECONDS = 1;
|
|
1570
|
-
/**
|
|
1571
|
-
* `-analyzeduration` / `-probesize`, as small as ffmpeg allows.
|
|
2198
|
+
* ## Back-pressure is a SKIPPED tick, never a queue
|
|
1572
2199
|
*
|
|
1573
|
-
*
|
|
2200
|
+
* A raw canvas frame is 3.1 MB at 1080p. If the encoder stops draining, the one
|
|
2201
|
+
* safe thing is to stop composing until it drains again: a queue of raw frames
|
|
2202
|
+
* is how a stalled encoder becomes an OOM, and a dropped frame in a live
|
|
2203
|
+
* composite costs nothing anybody can see.
|
|
1574
2204
|
*
|
|
1575
|
-
*
|
|
1576
|
-
* child". They are not. ffmpeg opens its inputs one after another and each open
|
|
1577
|
-
* BLOCKS in `avformat_find_stream_info` until its budget is spent — so input 0
|
|
1578
|
-
* is already connected and receiving while ffmpeg is still opening input 1, its
|
|
1579
|
-
* frames pile up, and `overlay` (which pairs inputs BY PTS) marries input 0's
|
|
1580
|
-
* oldest frame to input 1's newest. The lag is fixed for the life of the child:
|
|
1581
|
-
* both tiles then advance one frame per output frame, so nothing ever closes it.
|
|
1582
|
-
* The FIRST cell is the most behind; the last is live.
|
|
2205
|
+
* ## Any child exiting ends the composition
|
|
1583
2206
|
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
*
|
|
1589
|
-
* ```
|
|
1590
|
-
*
|
|
1591
|
-
* ## Why the old numbers cost seconds
|
|
1592
|
-
*
|
|
1593
|
-
* `-probesize` is a BYTE budget, and 1 MB of a 640x360 sub-stream is tens of
|
|
1594
|
-
* seconds of video — so `-analyzeduration 1s` never got the chance to stop it
|
|
1595
|
-
* and each open ran until the byte budget filled. Zero and 32 make the probe
|
|
1596
|
-
* return from the SDP, which already declares the codec, without reading media.
|
|
1597
|
-
*
|
|
1598
|
-
* Measured on this hub, the two sources of the live grid, three runs each,
|
|
1599
|
-
* production input args otherwise identical — time to the first composed frame,
|
|
1600
|
-
* and the skew read off the two burnt-in clocks in that frame:
|
|
1601
|
-
*
|
|
1602
|
-
* ```
|
|
1603
|
-
* 1 s / 1 MB 8986, 9728, 9712 ms skew 5 s, 5 s, 4 s
|
|
1604
|
-
* 0 / 32 2281, 2270, 2304 ms skew 1 s, 1 s, 0 s
|
|
1605
|
-
* ```
|
|
1606
|
-
*
|
|
1607
|
-
* The same numbers are the ~9 s cold start the operator had accepted: it was a
|
|
1608
|
-
* SUM of the opens, never the wait for the slowest source.
|
|
1609
|
-
*
|
|
1610
|
-
* Checked before shipping, because a probe that reads nothing has to survive
|
|
1611
|
-
* what it is not told: h264, h265 and a derived (`mid-to-low`) source all open
|
|
1612
|
-
* with `rc=0`, a 20 s run produces 200 frames of 200 either way (so the pacing
|
|
1613
|
-
* this file already lost once is untouched), and the skew is still 0 at 25 s
|
|
1614
|
-
* into a run — it is set at start-up and does not drift.
|
|
1615
|
-
*
|
|
1616
|
-
* What remains is the spread in when each source's first key frame arrives, and
|
|
1617
|
-
* a `/muted` dial waits for the next IDR. That spread is small here; if it ever
|
|
1618
|
-
* is not, the answer is to start the inputs from a join point we hold, NOT to
|
|
1619
|
-
* re-time the inputs (see `useWallclockTimestamps` below).
|
|
2207
|
+
* Same policy as the single child (`maxRestarts: 0`): the session tells its
|
|
2208
|
+
* consumers why and tears down, and the next read starts a cold composition
|
|
2209
|
+
* with freshly acquired sources. Leaving a dead tile frozen while the others
|
|
2210
|
+
* run would be a nicer picture and a worse fact — the operator would be looking
|
|
2211
|
+
* at a camera that stopped minutes ago with nothing saying so.
|
|
1620
2212
|
*/
|
|
1621
|
-
var ANALYZE_DURATION_US = 0;
|
|
1622
|
-
var PROBE_SIZE_BYTES = 32;
|
|
1623
|
-
function inputPlanFor(source) {
|
|
1624
|
-
return {
|
|
1625
|
-
url: source.url,
|
|
1626
|
-
rtspTransport: "tcp",
|
|
1627
|
-
fflags: ["+discardcorrupt"],
|
|
1628
|
-
lowDelay: true,
|
|
1629
|
-
analyzeDurationUs: ANALYZE_DURATION_US,
|
|
1630
|
-
probeSizeBytes: PROBE_SIZE_BYTES
|
|
1631
|
-
};
|
|
1632
|
-
}
|
|
1633
2213
|
/**
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
* rather than the configuration that produced it.
|
|
2214
|
+
* The encoder must produce its first bytes quickly, because the tick feeds it a
|
|
2215
|
+
* black canvas immediately — a silence here is the encoder failing to start,
|
|
2216
|
+
* not a camera being slow.
|
|
1638
2217
|
*/
|
|
1639
|
-
|
|
1640
|
-
if (input.sources.length === 0 || input.layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell with a source before it can emit anything");
|
|
1641
|
-
for (const cell of input.layout.cells) if (cell.inputIndex < 0 || cell.inputIndex >= input.sources.length) throw new Error(`camera-grid: cell addresses input ordinal ${cell.inputIndex}, but only ${input.sources.length} input(s) were acquired`);
|
|
1642
|
-
const graph = buildGridFilterGraph(input.layout);
|
|
1643
|
-
const [first, ...rest] = input.sources;
|
|
1644
|
-
if (first === void 0) throw new Error("camera-grid: a composite needs at least one cell with a source");
|
|
1645
|
-
const video = {
|
|
1646
|
-
kind: "encode",
|
|
1647
|
-
encoder: input.encoder,
|
|
1648
|
-
preset: PRESET,
|
|
1649
|
-
tune: TUNE,
|
|
1650
|
-
pixelFormat: PIXEL_FORMAT,
|
|
1651
|
-
fps: input.fps,
|
|
1652
|
-
gopFrames: input.fps * GOP_SECONDS,
|
|
1653
|
-
bitrateKbps: input.bitrateKbps,
|
|
1654
|
-
scale: null
|
|
1655
|
-
};
|
|
1656
|
-
return {
|
|
1657
|
-
logLevel: "error",
|
|
1658
|
-
decodeHwAccel: input.decodeHwAccel,
|
|
1659
|
-
input: inputPlanFor(first),
|
|
1660
|
-
extraInputs: rest.map(inputPlanFor),
|
|
1661
|
-
filterGraph: graph,
|
|
1662
|
-
video,
|
|
1663
|
-
audio: { kind: "none" },
|
|
1664
|
-
threadCount: 0,
|
|
1665
|
-
outputArgs: [],
|
|
1666
|
-
sink: {
|
|
1667
|
-
kind: "stdout",
|
|
1668
|
-
container: "flv"
|
|
1669
|
-
}
|
|
1670
|
-
};
|
|
1671
|
-
}
|
|
1672
|
-
//#endregion
|
|
1673
|
-
//#region src/builtins/camera-grid/grid-child.ts
|
|
2218
|
+
var ENCODER_FIRST_DATA_MS = 5e3;
|
|
1674
2219
|
/**
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1677
|
-
*
|
|
1678
|
-
* hardware→software retry, exit classification and the bounded restart. This
|
|
1679
|
-
* file owns only the PLUMBING — which bytes go where — because that is the one
|
|
1680
|
-
* thing the primitive deliberately does not own.
|
|
1681
|
-
*
|
|
1682
|
-
* `maxRestarts: 0`. A composite is demand-driven: if the child dies, the
|
|
1683
|
-
* session tells every consumer why and tears down, and the consumer's next read
|
|
1684
|
-
* starts a cold one with freshly acquired sources. A restart inside the process
|
|
1685
|
-
* would reuse broker handles that may already have been released.
|
|
2220
|
+
* A decoder's first frame waits for its source's next key frame, which on this
|
|
2221
|
+
* fleet is up to a GOP. Generous, and it costs only that tile: the picture is
|
|
2222
|
+
* already on screen.
|
|
1686
2223
|
*/
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
bitrateKbps: plan.bitrateKbps
|
|
2224
|
+
var DECODER_FIRST_DATA_MS = 15e3;
|
|
2225
|
+
async function startGridCompositorChild(ctx, plan, sources, onEnded) {
|
|
2226
|
+
const tilePlan = buildGridTilePlan({
|
|
2227
|
+
canvas: {
|
|
2228
|
+
width: plan.layout.width,
|
|
2229
|
+
height: plan.layout.height
|
|
2230
|
+
},
|
|
2231
|
+
cells: plan.layout.cells.map((cell) => ({
|
|
2232
|
+
deviceId: plan.sourceDeviceIds[cell.inputIndex] ?? 0,
|
|
2233
|
+
source: cell.source,
|
|
2234
|
+
cell: cell.cell
|
|
2235
|
+
}))
|
|
1700
2236
|
});
|
|
1701
|
-
|
|
1702
|
-
|
|
2237
|
+
if (tilePlan.tiles.length === 0) throw new Error("camera-grid: every cell rounded away to nothing — there is no picture to make");
|
|
2238
|
+
let ended = false;
|
|
2239
|
+
const end = (reason) => {
|
|
2240
|
+
if (ended) return;
|
|
2241
|
+
ended = true;
|
|
2242
|
+
onEnded(reason);
|
|
2243
|
+
};
|
|
2244
|
+
let encoderIn = null;
|
|
2245
|
+
let encoderOut = null;
|
|
2246
|
+
const encoder = new _camstack_types_node.FfmpegProcess({
|
|
1703
2247
|
binaryPath: ctx.binaryPath,
|
|
1704
|
-
buildArgs: (
|
|
1705
|
-
|
|
1706
|
-
|
|
2248
|
+
buildArgs: () => require_dist.buildFfmpegArgs(gridCanvasEncoderInvocation({
|
|
2249
|
+
canvas: tilePlan.canvas,
|
|
2250
|
+
fps: plan.fps,
|
|
2251
|
+
encoder: plan.encoder,
|
|
2252
|
+
bitrateKbps: plan.bitrateKbps
|
|
2253
|
+
})),
|
|
2254
|
+
decodeHwAccel: null,
|
|
2255
|
+
logger: ctx.logger.child("encode"),
|
|
1707
2256
|
deviceId: ctx.deviceId,
|
|
1708
|
-
role: "camera-grid",
|
|
1709
|
-
tags: {
|
|
1710
|
-
|
|
1711
|
-
inputs: plan.sourceDeviceIds.length
|
|
1712
|
-
},
|
|
1713
|
-
firstDataTimeoutMs: FIRST_DATA_TIMEOUT_MS,
|
|
2257
|
+
role: "camera-grid-encode",
|
|
2258
|
+
tags: { tiles: tilePlan.tiles.length },
|
|
2259
|
+
firstDataTimeoutMs: ENCODER_FIRST_DATA_MS,
|
|
1714
2260
|
maxRestarts: 0,
|
|
2261
|
+
stdio: [
|
|
2262
|
+
"pipe",
|
|
2263
|
+
"pipe",
|
|
2264
|
+
"pipe"
|
|
2265
|
+
],
|
|
1715
2266
|
onChild: (child) => {
|
|
1716
|
-
|
|
2267
|
+
encoderIn = child.stdin;
|
|
2268
|
+
if (child.stdout) encoderOut = child.stdout;
|
|
1717
2269
|
child.stderr?.on("data", () => {});
|
|
2270
|
+
child.stdin?.on("error", () => {});
|
|
1718
2271
|
},
|
|
1719
|
-
onExit: (exit) => {
|
|
1720
|
-
|
|
2272
|
+
onExit: (exit) => end(`encoder:${exit.classification}`)
|
|
2273
|
+
});
|
|
2274
|
+
await encoder.start();
|
|
2275
|
+
if (encoderIn === null || encoderOut === null) {
|
|
2276
|
+
await encoder.stop();
|
|
2277
|
+
throw new Error("camera-grid: the canvas encoder exposed no stdin/stdout to compose into");
|
|
2278
|
+
}
|
|
2279
|
+
let draining = false;
|
|
2280
|
+
const sink = encoderIn;
|
|
2281
|
+
sink.on("drain", () => {
|
|
2282
|
+
draining = false;
|
|
2283
|
+
});
|
|
2284
|
+
const compositor = new GridCompositor({
|
|
2285
|
+
plan: tilePlan,
|
|
2286
|
+
write: (frame) => {
|
|
2287
|
+
if (draining || sink.destroyed) return false;
|
|
2288
|
+
const accepted = sink.write(frame);
|
|
2289
|
+
if (!accepted) draining = true;
|
|
2290
|
+
return accepted;
|
|
1721
2291
|
}
|
|
1722
2292
|
});
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2293
|
+
const tick = setInterval(() => {
|
|
2294
|
+
compositor.tick();
|
|
2295
|
+
}, Math.max(1, Math.round(1e3 / plan.fps)));
|
|
2296
|
+
tick.unref();
|
|
2297
|
+
const urlByDeviceId = /* @__PURE__ */ new Map();
|
|
2298
|
+
for (const [index, deviceId] of plan.sourceDeviceIds.entries()) {
|
|
2299
|
+
const url = sources[index]?.url;
|
|
2300
|
+
if (url !== void 0) urlByDeviceId.set(deviceId, url);
|
|
2301
|
+
}
|
|
2302
|
+
const decoders = [];
|
|
2303
|
+
const startDecoder = async (tile) => {
|
|
2304
|
+
const url = urlByDeviceId.get(tile.deviceId);
|
|
2305
|
+
if (url === void 0) throw new Error(`camera-grid: no acquired source for device ${String(tile.deviceId)}`);
|
|
2306
|
+
const decoder = new _camstack_types_node.FfmpegProcess({
|
|
2307
|
+
binaryPath: ctx.binaryPath,
|
|
2308
|
+
buildArgs: () => require_dist.buildFfmpegArgs(gridTileDecoderInvocation({
|
|
2309
|
+
tile,
|
|
2310
|
+
url,
|
|
2311
|
+
fps: plan.fps
|
|
2312
|
+
})),
|
|
2313
|
+
decodeHwAccel: null,
|
|
2314
|
+
logger: ctx.logger.child("tile"),
|
|
2315
|
+
deviceId: tile.deviceId,
|
|
2316
|
+
role: "camera-grid-tile",
|
|
2317
|
+
tags: {
|
|
2318
|
+
tile: tile.id,
|
|
2319
|
+
width: tile.size.width,
|
|
2320
|
+
height: tile.size.height
|
|
2321
|
+
},
|
|
2322
|
+
firstDataTimeoutMs: DECODER_FIRST_DATA_MS,
|
|
2323
|
+
maxRestarts: 0,
|
|
2324
|
+
onChild: (child) => {
|
|
2325
|
+
child.stdout?.on("data", (chunk) => {
|
|
2326
|
+
compositor.onTile(tile.id, chunk);
|
|
2327
|
+
});
|
|
2328
|
+
child.stderr?.on("data", () => {});
|
|
2329
|
+
},
|
|
2330
|
+
onExit: (exit) => end(`tile ${tile.id}:${exit.classification}`)
|
|
2331
|
+
});
|
|
2332
|
+
decoders.push(decoder);
|
|
2333
|
+
await decoder.start();
|
|
2334
|
+
};
|
|
2335
|
+
const stopAll = async () => {
|
|
2336
|
+
clearInterval(tick);
|
|
2337
|
+
compositor.close();
|
|
2338
|
+
await Promise.allSettled(decoders.map((d) => d.stop()));
|
|
2339
|
+
await encoder.stop();
|
|
2340
|
+
};
|
|
2341
|
+
try {
|
|
2342
|
+
for (const tile of tilePlan.tiles) await startDecoder(tile);
|
|
2343
|
+
} catch (error) {
|
|
2344
|
+
await stopAll();
|
|
2345
|
+
throw error;
|
|
2346
|
+
}
|
|
2347
|
+
ctx.logger.info("camera grid: composing on an output clock", {
|
|
2348
|
+
tags: { deviceId: ctx.deviceId },
|
|
2349
|
+
meta: {
|
|
2350
|
+
tiles: tilePlan.tiles.length,
|
|
2351
|
+
canvas: `${String(tilePlan.canvas.width)}x${String(tilePlan.canvas.height)}`,
|
|
2352
|
+
fps: plan.fps
|
|
1732
2353
|
}
|
|
2354
|
+
});
|
|
2355
|
+
return {
|
|
2356
|
+
stdout: encoderOut,
|
|
2357
|
+
stop: stopAll
|
|
1733
2358
|
};
|
|
1734
2359
|
}
|
|
1735
2360
|
//#endregion
|
|
@@ -1783,6 +2408,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
1783
2408
|
* — that only changes when an operator changes it.
|
|
1784
2409
|
*/
|
|
1785
2410
|
planByInstanceId = /* @__PURE__ */ new Map();
|
|
2411
|
+
activity = null;
|
|
1786
2412
|
snapshots = null;
|
|
1787
2413
|
frameSampler = null;
|
|
1788
2414
|
ffmpegBinaryPath = "ffmpeg";
|
|
@@ -1811,11 +2437,29 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
1811
2437
|
sampleFrame: async (session) => this.sampleFrame(session),
|
|
1812
2438
|
store: new GridLastFrameFileStore({ dataDir: this.ctx.dataDir })
|
|
1813
2439
|
});
|
|
2440
|
+
this.activity = new GridActivityForwarder({
|
|
2441
|
+
eventBus: this.ctx.eventBus,
|
|
2442
|
+
logger: this.ctx.logger.child("activity"),
|
|
2443
|
+
addonId: this.ctx.id,
|
|
2444
|
+
gridsForSource: (sourceDeviceId) => this.gridsForSource(sourceDeviceId),
|
|
2445
|
+
now: () => Date.now()
|
|
2446
|
+
});
|
|
2447
|
+
this.subscribeSourceActivity();
|
|
1814
2448
|
installGridCameraRuntime({
|
|
1815
2449
|
descriptorsFor: (instanceId) => this.descriptorsFor(instanceId),
|
|
1816
2450
|
getLayout: (deviceId) => this.gridViewFor(deviceId),
|
|
1817
2451
|
saveLayout: async (patch) => this.saveGridLayout(patch),
|
|
1818
|
-
getSnapshot: async (deviceId) => this.getSnapshot(deviceId)
|
|
2452
|
+
getSnapshot: async (deviceId) => this.getSnapshot(deviceId),
|
|
2453
|
+
motionStatus: (deviceId) => {
|
|
2454
|
+
const state = this.activity?.motionStatusFor(deviceId, Date.now()) ?? {
|
|
2455
|
+
detected: false,
|
|
2456
|
+
lastDetectedAt: null
|
|
2457
|
+
};
|
|
2458
|
+
return {
|
|
2459
|
+
...state,
|
|
2460
|
+
autoClearAfterMs: state.detected ? require_dist.MOTION_CLOSE_AFTER_MS : null
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
1819
2463
|
});
|
|
1820
2464
|
await this.reconcile().catch((error) => {
|
|
1821
2465
|
this.ctx.logger.warn("camera grid: initial reconciliation failed — will retry", { meta: { error: require_dist.errMsg(error) } });
|
|
@@ -1952,6 +2596,64 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
1952
2596
|
}
|
|
1953
2597
|
}
|
|
1954
2598
|
/**
|
|
2599
|
+
* Listen to the SOURCES, so the grid can say the same things about itself.
|
|
2600
|
+
*
|
|
2601
|
+
* Filtered by source id inside the handler rather than by subscribing per
|
|
2602
|
+
* camera: the bus filter is per category, the set of sources changes whenever
|
|
2603
|
+
* an operator drags a cell, and a per-camera subscription would have to be
|
|
2604
|
+
* torn down and rebuilt on every layout save.
|
|
2605
|
+
*
|
|
2606
|
+
* Every handler is TOTAL — a malformed payload is ignored, never thrown out
|
|
2607
|
+
* of. These are telemetry events on a shared bus (D8/D11), and a throw here
|
|
2608
|
+
* would take the grid's forwarding down for every grid because one camera
|
|
2609
|
+
* emitted something unexpected.
|
|
2610
|
+
*/
|
|
2611
|
+
subscribeSourceActivity() {
|
|
2612
|
+
this.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
|
|
2613
|
+
const data = event.data;
|
|
2614
|
+
if (typeof data.deviceId !== "number" || typeof data.detected !== "boolean") return;
|
|
2615
|
+
const at = typeof data.timestamp === "number" ? data.timestamp : Date.now();
|
|
2616
|
+
this.activity?.onSourceMotion(data.deviceId, data.detected, at);
|
|
2617
|
+
});
|
|
2618
|
+
this.subscribe({ category: require_dist.EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
2619
|
+
const data = event.data;
|
|
2620
|
+
const dbfs = data.frame?.level?.dbfs;
|
|
2621
|
+
if (typeof data.deviceId !== "number" || typeof dbfs !== "number") return;
|
|
2622
|
+
this.activity?.onSourceAudio(data.deviceId, dbfs, Date.now());
|
|
2623
|
+
});
|
|
2624
|
+
this.subscribe({ category: require_dist.EventCategory.DetectionResult }, (event) => {
|
|
2625
|
+
const frame = event.data.frame;
|
|
2626
|
+
if (frame === void 0 || frame.kind !== "frame") return;
|
|
2627
|
+
this.activity?.onSourceDetections(frame);
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Which grids show this camera. Empty for the overwhelming majority of
|
|
2632
|
+
* events, which is why it is the FIRST thing every handler asks.
|
|
2633
|
+
*/
|
|
2634
|
+
gridsForSource(sourceDeviceId) {
|
|
2635
|
+
const targets = [];
|
|
2636
|
+
for (const [instanceId, deviceId] of this.deviceIdByInstanceId) {
|
|
2637
|
+
const instance = this.instanceById(instanceId);
|
|
2638
|
+
if (!instance || !instance.enabled) continue;
|
|
2639
|
+
if (sourceDeviceId === deviceId) continue;
|
|
2640
|
+
if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
|
|
2641
|
+
targets.push({
|
|
2642
|
+
deviceId,
|
|
2643
|
+
canvas: {
|
|
2644
|
+
width: instance.width,
|
|
2645
|
+
height: instance.height
|
|
2646
|
+
},
|
|
2647
|
+
cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
|
|
2648
|
+
source: cell.source,
|
|
2649
|
+
cell: cell.cell
|
|
2650
|
+
})),
|
|
2651
|
+
sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))]
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
return targets;
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
1955
2657
|
* Which stream each grid reads each of its sources at, asked of the BROKER.
|
|
1956
2658
|
*
|
|
1957
2659
|
* `listAllProfileSlots` is the one authority on "does 615 have a low
|
|
@@ -2095,7 +2797,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2095
2797
|
releaseSource: async (pipelineKey) => {
|
|
2096
2798
|
await this.ctx.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey });
|
|
2097
2799
|
},
|
|
2098
|
-
startChild: async (plan, sources) =>
|
|
2800
|
+
startChild: async (plan, sources) => startGridCompositorChild(childContext, plan, sources, (reason) => {
|
|
2099
2801
|
session.onChildEnded(reason);
|
|
2100
2802
|
})
|
|
2101
2803
|
},
|
|
@@ -2135,6 +2837,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2135
2837
|
}
|
|
2136
2838
|
for (const instanceId of [...this.deviceIdByInstanceId.keys()]) {
|
|
2137
2839
|
if (live.has(instanceId)) continue;
|
|
2840
|
+
this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
|
|
2138
2841
|
this.deviceIdByInstanceId.delete(instanceId);
|
|
2139
2842
|
this.planByInstanceId.delete(instanceId);
|
|
2140
2843
|
}
|
|
@@ -2317,6 +3020,270 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2317
3020
|
}
|
|
2318
3021
|
};
|
|
2319
3022
|
//#endregion
|
|
3023
|
+
//#region src/builtins/camera-grid/grid-filter-graph.ts
|
|
3024
|
+
var CANVAS_LABEL = "canvas";
|
|
3025
|
+
var OUT_LABEL = "grid";
|
|
3026
|
+
/** Full-frame within a tolerance, i.e. nothing to crop. */
|
|
3027
|
+
var FULL_FRAME_EPSILON = 1e-6;
|
|
3028
|
+
function isFullFrame(rect) {
|
|
3029
|
+
return Math.abs(rect.x) < FULL_FRAME_EPSILON && Math.abs(rect.y) < FULL_FRAME_EPSILON && Math.abs(rect.width - 1) < FULL_FRAME_EPSILON && Math.abs(rect.height - 1) < FULL_FRAME_EPSILON;
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* yuv420p subsamples chroma 2x2, so an odd width or height is rejected by every
|
|
3033
|
+
* encoder we use. Rounding here lets the reason be stated; rounding inside the
|
|
3034
|
+
* child is a start-up failure with no context.
|
|
3035
|
+
*/
|
|
3036
|
+
function toEven(value) {
|
|
3037
|
+
const rounded = Math.round(value);
|
|
3038
|
+
const even = rounded % 2 === 0 ? rounded : rounded - 1;
|
|
3039
|
+
return Math.max(2, even);
|
|
3040
|
+
}
|
|
3041
|
+
function assertWithin(rect, what, cellIndex) {
|
|
3042
|
+
if (rect.x < 0 || rect.y < 0 || rect.width <= 0 || rect.height <= 0 || rect.x + rect.width > 1.000001 || rect.y + rect.height > 1.000001) throw new Error(`camera-grid: cell ${cellIndex} falls ${what} (x=${rect.x} y=${rect.y} w=${rect.width} h=${rect.height}) — ffmpeg would render this as a silently clipped picture`);
|
|
3043
|
+
}
|
|
3044
|
+
/**
|
|
3045
|
+
* `crop` resolved against the source's OWN size at runtime (`iw`/`ih`), never
|
|
3046
|
+
* against a resolution guessed here. That is what makes the stored rectangle
|
|
3047
|
+
* survive a source that changes resolution.
|
|
3048
|
+
*/
|
|
3049
|
+
function cropExpression(source) {
|
|
3050
|
+
return `crop=iw*${source.width}:ih*${source.height}:iw*${source.x}:ih*${source.y}`;
|
|
3051
|
+
}
|
|
3052
|
+
function buildGridFilterGraph(layout) {
|
|
3053
|
+
if (layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell to emit anything");
|
|
3054
|
+
const steps = [`color=c=black:s=${layout.width}x${layout.height}:d=1[${CANVAS_LABEL}]`];
|
|
3055
|
+
const cellLabels = [];
|
|
3056
|
+
layout.cells.forEach((cell, index) => {
|
|
3057
|
+
assertWithin(cell.source, "outside its source", index);
|
|
3058
|
+
assertWithin(cell.cell, "outside the canvas", index);
|
|
3059
|
+
const targetWidth = toEven(cell.cell.width * layout.width);
|
|
3060
|
+
const targetHeight = toEven(cell.cell.height * layout.height);
|
|
3061
|
+
const label = `c${index}`;
|
|
3062
|
+
const filters = [...isFullFrame(cell.source) ? [] : [cropExpression(cell.source)], `scale=${targetWidth}:${targetHeight}`];
|
|
3063
|
+
steps.push(`[${cell.inputIndex}:v]${filters.join(",")}[${label}]`);
|
|
3064
|
+
cellLabels.push(label);
|
|
3065
|
+
});
|
|
3066
|
+
let base = CANVAS_LABEL;
|
|
3067
|
+
layout.cells.forEach((cell, index) => {
|
|
3068
|
+
const x = Math.round(cell.cell.x * layout.width);
|
|
3069
|
+
const y = Math.round(cell.cell.y * layout.height);
|
|
3070
|
+
const out = index === layout.cells.length - 1 ? OUT_LABEL : `s${index}`;
|
|
3071
|
+
steps.push(`[${base}][${cellLabels[index]}]overlay=${x}:${y}[${out}]`);
|
|
3072
|
+
base = out;
|
|
3073
|
+
});
|
|
3074
|
+
return {
|
|
3075
|
+
graph: steps.join(";"),
|
|
3076
|
+
videoOutLabel: OUT_LABEL
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
//#endregion
|
|
3080
|
+
//#region src/builtins/camera-grid/grid-stream-invocation.ts
|
|
3081
|
+
/**
|
|
3082
|
+
* The encoder preset. `veryfast` because a composite is the one encode in the
|
|
3083
|
+
* cluster whose input cost already scales with the number of cameras in it —
|
|
3084
|
+
* the decode side of a 2x2 is four decodes — so the encode side is where a
|
|
3085
|
+
* cheap default matters most.
|
|
3086
|
+
*/
|
|
3087
|
+
var PRESET = "veryfast";
|
|
3088
|
+
/**
|
|
3089
|
+
* `-tune zerolatency`, and this one is worth several SECONDS.
|
|
3090
|
+
*
|
|
3091
|
+
* x264's defaults hold frames before emitting any: a rate-control lookahead of
|
|
3092
|
+
* ~40 frames plus B-frames. At a composite's 10 fps that lookahead alone is
|
|
3093
|
+
* about four seconds of picture sitting inside the encoder — the operator saw
|
|
3094
|
+
* it as "ritardi di secondi" and it was not the network, the relay or the
|
|
3095
|
+
* broker. `zerolatency` sets `rc-lookahead=0`, `sync-lookahead=0` and
|
|
3096
|
+
* `bframes=0`, which is exactly the trade a live composite wants: a few percent
|
|
3097
|
+
* more bitrate for the same picture, in exchange for the encoder emitting each
|
|
3098
|
+
* frame as it arrives.
|
|
3099
|
+
*
|
|
3100
|
+
* It is not a preset. `veryfast` says how hard the encoder searches; this says
|
|
3101
|
+
* how long it is allowed to WAIT, and the two are independent.
|
|
3102
|
+
*/
|
|
3103
|
+
var TUNE = "zerolatency";
|
|
3104
|
+
/** yuv420p: the only pixel format every consumer of a CamStack profile reads. */
|
|
3105
|
+
var PIXEL_FORMAT = "yuv420p";
|
|
3106
|
+
/**
|
|
3107
|
+
* Key-frame cadence, in seconds.
|
|
3108
|
+
*
|
|
3109
|
+
* Two things ride on this and they pull the same way. A recorder cuts its
|
|
3110
|
+
* segments on a key frame, so a long GOP produces long segments and a scrub
|
|
3111
|
+
* that lands far from where the operator clicked. And every consumer — the
|
|
3112
|
+
* broker's reader included — starts at a key frame, so the GOP is the WORST
|
|
3113
|
+
* CASE wait before a grid appears at all: at two seconds a viewer could stare
|
|
3114
|
+
* at nothing for two seconds after pressing play.
|
|
3115
|
+
*
|
|
3116
|
+
* One second halves that wait. It costs bitrate (an IDR is expensive and there
|
|
3117
|
+
* are now twice as many), which is why it is not lower: below a second the
|
|
3118
|
+
* bitrate paid buys a wait nobody can feel.
|
|
3119
|
+
*/
|
|
3120
|
+
var GOP_SECONDS = 1;
|
|
3121
|
+
/**
|
|
3122
|
+
* `-analyzeduration` / `-probesize`, as small as ffmpeg allows.
|
|
3123
|
+
*
|
|
3124
|
+
* ## The probes are SEQUENTIAL, and this is what the tile skew was
|
|
3125
|
+
*
|
|
3126
|
+
* The sentence that used to be here said the probes are "PARALLEL inputs of one
|
|
3127
|
+
* child". They are not. ffmpeg opens its inputs one after another and each open
|
|
3128
|
+
* BLOCKS in `avformat_find_stream_info` until its budget is spent — so input 0
|
|
3129
|
+
* is already connected and receiving while ffmpeg is still opening input 1, its
|
|
3130
|
+
* frames pile up, and `overlay` (which pairs inputs BY PTS) marries input 0's
|
|
3131
|
+
* oldest frame to input 1's newest. The lag is fixed for the life of the child:
|
|
3132
|
+
* both tiles then advance one frame per output frame, so nothing ever closes it.
|
|
3133
|
+
* The FIRST cell is the most behind; the last is live.
|
|
3134
|
+
*
|
|
3135
|
+
* Reproduced in one command on 2026-09-18 — two sources hstacked, one frame,
|
|
3136
|
+
* reading the cameras' own burnt-in clocks out of the composed picture:
|
|
3137
|
+
*
|
|
3138
|
+
* ```
|
|
3139
|
+
* 13:11:21 | 13:11:23 two seconds, and the earlier input is the late one
|
|
3140
|
+
* ```
|
|
3141
|
+
*
|
|
3142
|
+
* ## Why the old numbers cost seconds
|
|
3143
|
+
*
|
|
3144
|
+
* `-probesize` is a BYTE budget, and 1 MB of a 640x360 sub-stream is tens of
|
|
3145
|
+
* seconds of video — so `-analyzeduration 1s` never got the chance to stop it
|
|
3146
|
+
* and each open ran until the byte budget filled. Zero and 32 make the probe
|
|
3147
|
+
* return from the SDP, which already declares the codec, without reading media.
|
|
3148
|
+
*
|
|
3149
|
+
* Measured on this hub, the two sources of the live grid, three runs each,
|
|
3150
|
+
* production input args otherwise identical — time to the first composed frame,
|
|
3151
|
+
* and the skew read off the two burnt-in clocks in that frame:
|
|
3152
|
+
*
|
|
3153
|
+
* ```
|
|
3154
|
+
* 1 s / 1 MB 8986, 9728, 9712 ms skew 5 s, 5 s, 4 s
|
|
3155
|
+
* 0 / 32 2281, 2270, 2304 ms skew 1 s, 1 s, 0 s
|
|
3156
|
+
* ```
|
|
3157
|
+
*
|
|
3158
|
+
* The same numbers are the ~9 s cold start the operator had accepted: it was a
|
|
3159
|
+
* SUM of the opens, never the wait for the slowest source.
|
|
3160
|
+
*
|
|
3161
|
+
* Checked before shipping, because a probe that reads nothing has to survive
|
|
3162
|
+
* what it is not told: h264, h265 and a derived (`mid-to-low`) source all open
|
|
3163
|
+
* with `rc=0`, a 20 s run produces 200 frames of 200 either way (so the pacing
|
|
3164
|
+
* this file already lost once is untouched), and the skew is still 0 at 25 s
|
|
3165
|
+
* into a run — it is set at start-up and does not drift.
|
|
3166
|
+
*
|
|
3167
|
+
* What remains is the spread in when each source's first key frame arrives, and
|
|
3168
|
+
* a `/muted` dial waits for the next IDR. That spread is small here; if it ever
|
|
3169
|
+
* is not, the answer is to start the inputs from a join point we hold, NOT to
|
|
3170
|
+
* re-time the inputs (see `useWallclockTimestamps` below).
|
|
3171
|
+
*/
|
|
3172
|
+
var ANALYZE_DURATION_US = 0;
|
|
3173
|
+
var PROBE_SIZE_BYTES = 32;
|
|
3174
|
+
function inputPlanFor(source) {
|
|
3175
|
+
return {
|
|
3176
|
+
url: source.url,
|
|
3177
|
+
rtspTransport: "tcp",
|
|
3178
|
+
fflags: ["+discardcorrupt"],
|
|
3179
|
+
lowDelay: true,
|
|
3180
|
+
analyzeDurationUs: ANALYZE_DURATION_US,
|
|
3181
|
+
probeSizeBytes: PROBE_SIZE_BYTES
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
/**
|
|
3185
|
+
* @throws when the layout addresses an input the sources do not contain, or
|
|
3186
|
+
* when there is nothing to compose. Both are plan errors: ffmpeg would refuse
|
|
3187
|
+
* them at start-up with a message about a filter pad, which names the graph
|
|
3188
|
+
* rather than the configuration that produced it.
|
|
3189
|
+
*/
|
|
3190
|
+
function gridStreamInvocation(input) {
|
|
3191
|
+
if (input.sources.length === 0 || input.layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell with a source before it can emit anything");
|
|
3192
|
+
for (const cell of input.layout.cells) if (cell.inputIndex < 0 || cell.inputIndex >= input.sources.length) throw new Error(`camera-grid: cell addresses input ordinal ${cell.inputIndex}, but only ${input.sources.length} input(s) were acquired`);
|
|
3193
|
+
const graph = buildGridFilterGraph(input.layout);
|
|
3194
|
+
const [first, ...rest] = input.sources;
|
|
3195
|
+
if (first === void 0) throw new Error("camera-grid: a composite needs at least one cell with a source");
|
|
3196
|
+
const video = {
|
|
3197
|
+
kind: "encode",
|
|
3198
|
+
encoder: input.encoder,
|
|
3199
|
+
preset: PRESET,
|
|
3200
|
+
tune: TUNE,
|
|
3201
|
+
pixelFormat: PIXEL_FORMAT,
|
|
3202
|
+
fps: input.fps,
|
|
3203
|
+
gopFrames: input.fps * GOP_SECONDS,
|
|
3204
|
+
bitrateKbps: input.bitrateKbps,
|
|
3205
|
+
scale: null
|
|
3206
|
+
};
|
|
3207
|
+
return {
|
|
3208
|
+
logLevel: "error",
|
|
3209
|
+
decodeHwAccel: input.decodeHwAccel,
|
|
3210
|
+
input: inputPlanFor(first),
|
|
3211
|
+
extraInputs: rest.map(inputPlanFor),
|
|
3212
|
+
filterGraph: graph,
|
|
3213
|
+
video,
|
|
3214
|
+
audio: { kind: "none" },
|
|
3215
|
+
threadCount: 0,
|
|
3216
|
+
outputArgs: [],
|
|
3217
|
+
sink: {
|
|
3218
|
+
kind: "stdout",
|
|
3219
|
+
container: "flv"
|
|
3220
|
+
}
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
//#endregion
|
|
3224
|
+
//#region src/builtins/camera-grid/grid-child.ts
|
|
3225
|
+
/**
|
|
3226
|
+
* Spawning the composition, through the ONE primitive.
|
|
3227
|
+
*
|
|
3228
|
+
* `FfmpegProcess` (`@camstack/types`) owns spawn, the first-data deadline, the
|
|
3229
|
+
* hardware→software retry, exit classification and the bounded restart. This
|
|
3230
|
+
* file owns only the PLUMBING — which bytes go where — because that is the one
|
|
3231
|
+
* thing the primitive deliberately does not own.
|
|
3232
|
+
*
|
|
3233
|
+
* `maxRestarts: 0`. A composite is demand-driven: if the child dies, the
|
|
3234
|
+
* session tells every consumer why and tears down, and the consumer's next read
|
|
3235
|
+
* starts a cold one with freshly acquired sources. A restart inside the process
|
|
3236
|
+
* would reuse broker handles that may already have been released.
|
|
3237
|
+
*/
|
|
3238
|
+
/** No first-data deadline retry loop: one attempt, and the session hears about it. */
|
|
3239
|
+
var FIRST_DATA_TIMEOUT_MS = 15e3;
|
|
3240
|
+
async function startGridChild(ctx, plan, sources, onEnded) {
|
|
3241
|
+
const invocation = (decodeHwAccel) => gridStreamInvocation({
|
|
3242
|
+
layout: plan.layout,
|
|
3243
|
+
sources: plan.sourceDeviceIds.map((deviceId, index) => ({
|
|
3244
|
+
deviceId,
|
|
3245
|
+
url: sources[index]?.url ?? ""
|
|
3246
|
+
})),
|
|
3247
|
+
decodeHwAccel,
|
|
3248
|
+
encoder: plan.encoder,
|
|
3249
|
+
fps: plan.fps,
|
|
3250
|
+
bitrateKbps: plan.bitrateKbps
|
|
3251
|
+
});
|
|
3252
|
+
let stdout = null;
|
|
3253
|
+
const process = new _camstack_types_node.FfmpegProcess({
|
|
3254
|
+
binaryPath: ctx.binaryPath,
|
|
3255
|
+
buildArgs: (decodeHwAccel) => require_dist.buildFfmpegArgs(invocation(decodeHwAccel)),
|
|
3256
|
+
decodeHwAccel: plan.decodeHwAccel,
|
|
3257
|
+
logger: ctx.logger,
|
|
3258
|
+
deviceId: ctx.deviceId,
|
|
3259
|
+
role: "camera-grid",
|
|
3260
|
+
tags: {
|
|
3261
|
+
cells: plan.layout.cells.length,
|
|
3262
|
+
inputs: plan.sourceDeviceIds.length
|
|
3263
|
+
},
|
|
3264
|
+
firstDataTimeoutMs: FIRST_DATA_TIMEOUT_MS,
|
|
3265
|
+
maxRestarts: 0,
|
|
3266
|
+
onChild: (child) => {
|
|
3267
|
+
if (child.stdout) stdout = child.stdout;
|
|
3268
|
+
child.stderr?.on("data", () => {});
|
|
3269
|
+
},
|
|
3270
|
+
onExit: (exit) => {
|
|
3271
|
+
onEnded(exit.classification);
|
|
3272
|
+
}
|
|
3273
|
+
});
|
|
3274
|
+
await process.start();
|
|
3275
|
+
if (stdout === null) {
|
|
3276
|
+
await process.stop();
|
|
3277
|
+
throw new Error("camera-grid: the composition child produced no stdout to read");
|
|
3278
|
+
}
|
|
3279
|
+
return {
|
|
3280
|
+
stdout,
|
|
3281
|
+
stop: async () => {
|
|
3282
|
+
await process.stop();
|
|
3283
|
+
}
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
//#endregion
|
|
2320
3287
|
exports.CameraGridAddon = CameraGridAddon;
|
|
2321
3288
|
exports.GRID_CAM_STREAM_ID = GRID_CAM_STREAM_ID;
|
|
2322
3289
|
exports.GRID_OUTPUT_PROFILE = GRID_OUTPUT_PROFILE;
|