@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.
Files changed (67) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/autotrack/index.js +1 -1
  8. package/dist/builtins/autotrack/index.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/camera-grid/addon.d.ts +20 -0
  12. package/dist/builtins/camera-grid/grid-activity-mirror.d.ts +32 -0
  13. package/dist/builtins/camera-grid/grid-activity.d.ts +50 -0
  14. package/dist/builtins/camera-grid/grid-camera-device.d.ts +11 -1
  15. package/dist/builtins/camera-grid/grid-canvas.d.ts +83 -0
  16. package/dist/builtins/camera-grid/grid-compositor-child.d.ts +3 -0
  17. package/dist/builtins/camera-grid/grid-compositor-invocation.d.ts +19 -0
  18. package/dist/builtins/camera-grid/grid-compositor.d.ts +36 -0
  19. package/dist/builtins/camera-grid/grid-detection-mapping.d.ts +31 -0
  20. package/dist/builtins/camera-grid/grid-tile-feed.d.ts +38 -0
  21. package/dist/builtins/camera-grid/grid-tile-plan.d.ts +31 -0
  22. package/dist/builtins/camera-grid/index.js +1199 -232
  23. package/dist/builtins/camera-grid/index.mjs +1199 -232
  24. package/dist/builtins/camera-grid/silence-analysis.d.ts +7 -0
  25. package/dist/builtins/console-logging/index.js +1 -1
  26. package/dist/builtins/console-logging/index.mjs +1 -1
  27. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  28. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  29. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  30. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  31. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  32. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  33. package/dist/builtins/hub-forwarder/index.js +1 -1
  34. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  35. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  36. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  37. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  38. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  39. package/dist/builtins/local-network/local-network.addon.js +1 -1
  40. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  41. package/dist/builtins/loki-logging/index.js +1 -1
  42. package/dist/builtins/loki-logging/index.mjs +1 -1
  43. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  44. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  45. package/dist/builtins/platform-probe/index.js +1 -1
  46. package/dist/builtins/platform-probe/index.mjs +1 -1
  47. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  48. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  49. package/dist/builtins/snapshot/index.js +1 -1
  50. package/dist/builtins/snapshot/index.mjs +1 -1
  51. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  52. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  53. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  54. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  55. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  56. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  57. package/dist/builtins/system-config/system-config.addon.js +1 -1
  58. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  59. package/dist/builtins/winston-logging/index.js +1 -1
  60. package/dist/builtins/winston-logging/index.mjs +1 -1
  61. package/dist/{dist-6hiXbrNY.js → dist-BbPssgI6.js} +85 -15
  62. package/dist/{dist-CQ7ItBIG.mjs → dist-Ck0rjYOQ.mjs} +72 -8
  63. package/dist/index.js +1 -1
  64. package/dist/index.mjs +1 -1
  65. package/dist/{retired-settings-keys-CCXmaFTK.mjs → retired-settings-keys-BZpDuJVh.mjs} +1 -1
  66. package/dist/{retired-settings-keys-AmmGrCp1.js → retired-settings-keys-Dg_69cOY.js} +1 -1
  67. 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-6hiXbrNY.js");
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
- var GRID_SILENCED_CAP_NAMES = [
1406
- "motion-detection",
1407
- require_dist.DETECTION_PIPELINE_CAP_NAME,
1408
- require_dist.AUDIO_ANALYSIS_CAP_NAME
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-filter-graph.ts
1473
- var CANVAS_LABEL = "canvas";
1474
- var OUT_LABEL = "grid";
1475
- /** Full-frame within a tolerance, i.e. nothing to crop. */
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
- * yuv420p subsamples chroma 2x2, so an odd width or height is rejected by every
1482
- * encoder we use. Rounding here lets the reason be stated; rounding inside the
1483
- * child is a start-up failure with no context.
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 toEven(value) {
1486
- const rounded = Math.round(value);
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
- function assertWithin(rect, what, cellIndex) {
1491
- 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`);
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. That is what makes the stored rectangle
1496
- * survive a source that changes resolution.
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 buildGridFilterGraph(layout) {
1502
- if (layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell to emit anything");
1503
- const steps = [`color=c=black:s=${layout.width}x${layout.height}:d=1[${CANVAS_LABEL}]`];
1504
- const cellLabels = [];
1505
- layout.cells.forEach((cell, index) => {
1506
- assertWithin(cell.source, "outside its source", index);
1507
- assertWithin(cell.cell, "outside the canvas", index);
1508
- const targetWidth = toEven(cell.cell.width * layout.width);
1509
- const targetHeight = toEven(cell.cell.height * layout.height);
1510
- const label = `c${index}`;
1511
- const filters = [...isFullFrame(cell.source) ? [] : [cropExpression(cell.source)], `scale=${targetWidth}:${targetHeight}`];
1512
- steps.push(`[${cell.inputIndex}:v]${filters.join(",")}[${label}]`);
1513
- cellLabels.push(label);
1514
- });
1515
- let base = CANVAS_LABEL;
1516
- layout.cells.forEach((cell, index) => {
1517
- const x = Math.round(cell.cell.x * layout.width);
1518
- const y = Math.round(cell.cell.y * layout.height);
1519
- const out = index === layout.cells.length - 1 ? OUT_LABEL : `s${index}`;
1520
- steps.push(`[${base}][${cellLabels[index]}]overlay=${x}:${y}[${out}]`);
1521
- base = out;
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
- graph: steps.join(";"),
1525
- videoOutLabel: OUT_LABEL
2105
+ canvas: input.canvas,
2106
+ tiles
1526
2107
  };
1527
2108
  }
1528
2109
  //#endregion
1529
- //#region src/builtins/camera-grid/grid-stream-invocation.ts
1530
- /**
1531
- * The encoder preset. `veryfast` because a composite is the one encode in the
1532
- * cluster whose input cost already scales with the number of cameras in it —
1533
- * the decode side of a 2x2 is four decodes — so the encode side is where a
1534
- * cheap default matters most.
1535
- */
1536
- var PRESET = "veryfast";
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
- * `-tune zerolatency`, and this one is worth several SECONDS.
2183
+ * The composition as processes: one decoder per tile, one encoder, one tick.
1539
2184
  *
1540
- * x264's defaults hold frames before emitting any: a rate-control lookahead of
1541
- * ~40 frames plus B-frames. At a composite's 10 fps that lookahead alone is
1542
- * about four seconds of picture sitting inside the encoder — the operator saw
1543
- * it as "ritardi di secondi" and it was not the network, the relay or the
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
- * It is not a preset. `veryfast` says how hard the encoder searches; this says
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
- * Two things ride on this and they pull the same way. A recorder cuts its
1559
- * segments on a key frame, so a long GOP produces long segments and a scrub
1560
- * that lands far from where the operator clicked. And every consumer — the
1561
- * broker's reader included — starts at a key frame, so the GOP is the WORST
1562
- * CASE wait before a grid appears at all: at two seconds a viewer could stare
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
- * One second halves that wait. It costs bitrate (an IDR is expensive and there
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
- * ## The probes are SEQUENTIAL, and this is what the tile skew was
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
- * The sentence that used to be here said the probes are "PARALLEL inputs of one
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
- * Reproduced in one command on 2026-09-18 — two sources hstacked, one frame,
1585
- * reading the cameras' own burnt-in clocks out of the composed picture:
1586
- *
1587
- * ```
1588
- * 13:11:21 | 13:11:23 two seconds, and the earlier input is the late one
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
- * @throws when the layout addresses an input the sources do not contain, or
1635
- * when there is nothing to compose. Both are plan errors: ffmpeg would refuse
1636
- * them at start-up with a message about a filter pad, which names the graph
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
- function gridStreamInvocation(input) {
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
- * Spawning the composition, through the ONE primitive.
1676
- *
1677
- * `FfmpegProcess` (`@camstack/types`) owns spawn, the first-data deadline, the
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
- /** No first-data deadline retry loop: one attempt, and the session hears about it. */
1688
- var FIRST_DATA_TIMEOUT_MS = 15e3;
1689
- async function startGridChild(ctx, plan, sources, onEnded) {
1690
- const invocation = (decodeHwAccel) => gridStreamInvocation({
1691
- layout: plan.layout,
1692
- sources: plan.sourceDeviceIds.map((deviceId, index) => ({
1693
- deviceId,
1694
- url: sources[index]?.url ?? ""
1695
- })),
1696
- decodeHwAccel,
1697
- encoder: plan.encoder,
1698
- fps: plan.fps,
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
- let stdout = null;
1702
- const process = new _camstack_types_node.FfmpegProcess({
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: (decodeHwAccel) => require_dist.buildFfmpegArgs(invocation(decodeHwAccel)),
1705
- decodeHwAccel: plan.decodeHwAccel,
1706
- logger: ctx.logger,
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
- cells: plan.layout.cells.length,
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
- if (child.stdout) stdout = child.stdout;
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
- onEnded(exit.classification);
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
- await process.start();
1724
- if (stdout === null) {
1725
- await process.stop();
1726
- throw new Error("camera-grid: the composition child produced no stdout to read");
1727
- }
1728
- return {
1729
- stdout,
1730
- stop: async () => {
1731
- await process.stop();
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) => startGridChild(childContext, plan, sources, (reason) => {
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;