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