@camstack/addon-pipeline 1.1.43 → 1.1.44

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 (36) hide show
  1. package/dist/audio-analyzer/index.js +5 -4
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/chunk-D6vf50IK.js +28 -0
  4. package/dist/detection-pipeline/index.js +526 -109
  5. package/dist/detection-pipeline/index.mjs +520 -104
  6. package/dist/{dist-GFd8M6KO.mjs → dist-CgBGV988.mjs} +214 -186
  7. package/dist/{dist-NvwN60Fq.js → dist-CySHUkXY.js} +219 -185
  8. package/dist/{model-download-service-C-IHWnXx-DxM2DSns.js → model-download-service-C-IHWnXx-BLXzxpRB.js} +3 -29
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +122 -287
  12. package/dist/pipeline-runner/index.mjs +118 -285
  13. package/dist/recorder/index.js +60 -20
  14. package/dist/recorder/index.mjs +57 -18
  15. package/dist/{frame-handle-plane-E_AsR77T.mjs → remote-source-plane-B4hRwchu.mjs} +219 -2
  16. package/dist/{frame-handle-plane-B3Fxww8H.js → remote-source-plane-D9m0z1N9.js} +236 -1
  17. package/dist/session-decode/decode-worker-child.js +86 -2
  18. package/dist/session-decode/decode-worker-child.mjs +83 -2
  19. package/dist/stream-broker/_stub.js +550 -523
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CeielP5P.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-G6wtUb3G.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Dlagq9X_.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BUpF966l.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-DmjqskOR.mjs → hostInit-BSzP3BIj.mjs} +2 -2
  24. package/dist/stream-broker/index.js +746 -135
  25. package/dist/stream-broker/index.mjs +737 -127
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CEsPjwzT.js → MaskShapeCanvas-DI4BY7W2-D2ML5TPi.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-D43fIRgB.js → MotionZonesSettings-NcxxQN8r-TTWGGaqv.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-C3wJxG8V.js → PrivacyMaskSettings-APgPLF7p-CJqHU2nU.js} +1 -1
  30. package/embed-dist/assets/index-C1oaL9s7.js +81 -0
  31. package/embed-dist/assets/{index-CSFtK41z.css → index-CA4kv43T.css} +1 -1
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +2 -2
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqJvWBKS.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-dGka8yZY.mjs +0 -26
  36. package/embed-dist/assets/index-CX-rILhw.js +0 -81
@@ -1,5 +1,5 @@
1
- import { D as nodePin, G as makeSourceBrokerId, H as createEvent, I as errMsg, L as BaseAddon, Q as boolean, V as EventCategory, X as _enum, Z as array, _ as customAction, at as string, et as lazy, k as pipelineRunnerCapability, nt as number, rt as object, y as defineCustomActions } from "../dist-GFd8M6KO.mjs";
2
- import { t as FrameHandlePlane } from "../frame-handle-plane-E_AsR77T.mjs";
1
+ import { $ as boolean, D as nodePin, H as createEvent, I as errMsg, K as makeSourceBrokerId, L as BaseAddon, Q as array, V as EventCategory, Z as _enum, _ as customAction, it as object, k as pipelineRunnerCapability, ot as string, rt as number, tt as lazy, y as defineCustomActions } from "../dist-CgBGV988.mjs";
2
+ import { n as isRemoteRestream, r as profileForStreamId, t as RemoteSourcePlane } from "../remote-source-plane-B4hRwchu.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { t as isWorkerReply } from "../worker-protocol-pk7qdYXt.mjs";
5
5
  import { FrameRingReaderCache } from "@camstack/shm-ring";
@@ -422,221 +422,6 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
422
422
  };
423
423
  }
424
424
  //#endregion
425
- //#region src/pipeline-runner/remote-source-plane.ts
426
- /**
427
- * Runner-side mode selection (pure): whether an attach payload routes this
428
- * camera through the remote-source leg. Absent `frameSource` (a pre-P2c
429
- * payload) and `local-broker` both take today's co-located broker path —
430
- * the rollout-OFF safety invariant.
431
- */
432
- function isRemoteRestream(frameSource) {
433
- return frameSource !== void 0 && frameSource.kind === "remote-restream";
434
- }
435
- /**
436
- * Resolve WHICH broker profile a camStream id feeds for a device (pure).
437
- *
438
- * `getStreamWithCodec` targets a PROFILE (its assigned camStream), while the
439
- * runner is dispatched with camStream ids (`motionStreamId` /
440
- * `detectionStreamId`). The broker's profile slots carry the mapping
441
- * (`sourceCamStreamId`); a stream no slot feeds returns `null` — the acquire
442
- * fails fast and the poller's backoff retries (the assignment may still be
443
- * propagating).
444
- */
445
- function profileForStreamId(slots, deviceId, streamId) {
446
- for (const slot of slots) if (slot.deviceId === deviceId && slot.sourceCamStreamId === streamId) return slot.profile;
447
- return null;
448
- }
449
- var RemoteSourcePlane = class {
450
- api;
451
- logger;
452
- deviceId;
453
- ownNodeId;
454
- hostname;
455
- /** Single-flight entry creation per camStream id. */
456
- entries = /* @__PURE__ */ new Map();
457
- disposed = false;
458
- constructor(options) {
459
- this.api = options.api;
460
- this.logger = options.logger.withTags({ deviceId: options.deviceId });
461
- this.deviceId = options.deviceId;
462
- this.ownNodeId = options.ownNodeId;
463
- this.hostname = options.hostname;
464
- }
465
- /**
466
- * A {@link FrameHandleSource} for one camStream id, pluggable into
467
- * `startFrameHandlePoller`. Motion (`gray`) and detection (`rgb`)
468
- * subscriptions of the same stream share ONE acquired restream and ONE
469
- * satellite plane (per-format decoder sessions inside it — hub parity).
470
- */
471
- makeSource(streamId) {
472
- return {
473
- subscribe: async (input) => {
474
- if (this.disposed) throw new Error("RemoteSourcePlane: subscribe after dispose");
475
- const entry = await this.ensureEntry(streamId);
476
- const result = await entry.plane.subscribe({
477
- format: input.format,
478
- maxFps: input.maxFps,
479
- tag: input.tag
480
- });
481
- entry.subscriptionIds.add(result.subscriptionId);
482
- return result;
483
- },
484
- pullHandles: async (input) => {
485
- const entry = await this.peekEntry(streamId);
486
- if (!entry) return [];
487
- return entry.plane.pullHandles(input.subscriptionId, input.maxCount);
488
- },
489
- unsubscribe: async (input) => {
490
- const entry = await this.peekEntry(streamId);
491
- if (!entry) return;
492
- entry.subscriptionIds.delete(input.subscriptionId);
493
- await entry.plane.unsubscribe(input.subscriptionId).catch(() => {});
494
- if (entry.subscriptionIds.size === 0) {
495
- this.entries.delete(streamId);
496
- await this.teardownEntry(entry, streamId);
497
- }
498
- }
499
- };
500
- }
501
- /** Dispose every entry: satellite planes destroyed, restreams released. Idempotent. */
502
- async dispose() {
503
- if (this.disposed) return;
504
- this.disposed = true;
505
- const pending = [...this.entries.entries()];
506
- this.entries.clear();
507
- for (const [streamId, entryPromise] of pending) try {
508
- const entry = await entryPromise;
509
- await this.teardownEntry(entry, streamId);
510
- } catch {}
511
- }
512
- /** The live entry for a stream, or `null` (never creates one). */
513
- async peekEntry(streamId) {
514
- const pending = this.entries.get(streamId);
515
- if (!pending) return null;
516
- try {
517
- return await pending;
518
- } catch {
519
- return null;
520
- }
521
- }
522
- /** Single-flight: acquire the restream + build the satellite plane once per stream. */
523
- ensureEntry(streamId) {
524
- const existing = this.entries.get(streamId);
525
- if (existing) return existing;
526
- const creation = this.createEntry(streamId);
527
- this.entries.set(streamId, creation);
528
- creation.catch(() => {
529
- if (this.entries.get(streamId) === creation) this.entries.delete(streamId);
530
- });
531
- return creation;
532
- }
533
- async createEntry(streamId) {
534
- const { api, deviceId, ownNodeId } = this;
535
- if (this.hostname === void 0) throw new Error("RemoteSourcePlane: owner hostname unresolvable — set frameSource.hubHostnameOverride or CAMSTACK_HUB_URL (a 127.0.0.1 restream URL would dial the wrong box)");
536
- const profile = profileForStreamId(await api.streamBroker.listAllProfileSlots.query(), deviceId, streamId);
537
- if (profile === null) throw new Error(`RemoteSourcePlane: no broker profile is fed by camStream '${streamId}' of device ${deviceId} — cannot acquire the owner restream`);
538
- const rtpSource = await api.streamBroker.getStreamWithCodec.mutate({
539
- deviceId,
540
- video: "copy",
541
- audio: "none",
542
- profile,
543
- hostname: this.hostname,
544
- tag: `runner:${ownNodeId}:remote-src:${deviceId}/${streamId}`
545
- });
546
- const codec = rtpSource.videoCodec === "H265" ? "h265" : "h264";
547
- const plane = new FrameHandlePlane({
548
- decoderApi: this.buildPinnedDecoderCapApi(),
549
- logger: this.logger,
550
- localNodeId: ownNodeId,
551
- resolveStreamInfo: () => ({
552
- codec,
553
- numericDeviceId: deviceId,
554
- tag: `runner-remote:${deviceId}/${streamId}`,
555
- pullModeUrl: rtpSource.url
556
- })
557
- });
558
- this.logger.info("remote-source plane: owner restream acquired", { meta: {
559
- streamId,
560
- profile,
561
- codec,
562
- pipelineKey: rtpSource.pipelineKey,
563
- hostname: this.hostname
564
- } });
565
- return {
566
- plane,
567
- pipelineKey: rtpSource.pipelineKey,
568
- subscriptionIds: /* @__PURE__ */ new Set(),
569
- released: false
570
- };
571
- }
572
- /** Destroy an entry's satellite plane and release its restream refcount once. */
573
- async teardownEntry(entry, streamId) {
574
- await entry.plane.dispose().catch((err) => {
575
- this.logger.warn("remote-source plane: satellite plane dispose failed", { meta: {
576
- streamId,
577
- error: errMsg(err)
578
- } });
579
- });
580
- if (entry.released) return;
581
- entry.released = true;
582
- await this.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: entry.pipelineKey }).catch((err) => {
583
- this.logger.debug("remote-source plane: releaseStreamWithCodec failed (benign on hub restart)", { meta: {
584
- streamId,
585
- pipelineKey: entry.pipelineKey,
586
- error: errMsg(err)
587
- } });
588
- });
589
- }
590
- /**
591
- * Every decoder call pinned to THIS runner's node — mirror of the hub
592
- * broker's wrapper (`stream-broker-manager.buildDecoderCapApi`): the
593
- * `decoder` cap is a singleton whose UNPINNED calls resolve to the
594
- * cluster-elected node, which is generally NOT this agent. Pinning create
595
- * AND every session op to the same local node keeps the session + its shm
596
- * ring here, where this plane's readers live.
597
- */
598
- buildPinnedDecoderCapApi() {
599
- const api = this.api;
600
- const pin = nodePin(this.ownNodeId);
601
- const toWireBytes = (bytes) => {
602
- const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength));
603
- copy.set(bytes);
604
- return copy;
605
- };
606
- return {
607
- supportsCodec: async (input) => api.decoder.supportsCodec.query({ ...input }, pin),
608
- getInfo: async () => api.decoder.getInfo.query(void 0, pin),
609
- createSession: async (config) => api.decoder.createSession.query({ ...config }, pin),
610
- pushPacket: async (input) => {
611
- await api.decoder.pushPacket.query({
612
- sessionId: input.sessionId,
613
- packet: {
614
- ...input.packet,
615
- data: toWireBytes(input.packet.data)
616
- }
617
- }, pin);
618
- },
619
- pullFrames: async (input) => {
620
- return (await api.decoder.pullFrames.query({ ...input }, pin)).map((frame) => ({
621
- ...frame,
622
- data: Buffer.from(frame.data)
623
- }));
624
- },
625
- pullHandles: async (input) => api.decoder.pullHandles.query({ ...input }, pin),
626
- destroySession: async (input) => {
627
- await api.decoder.destroySession.query({ ...input }, pin);
628
- },
629
- openStream: async (input) => {
630
- await api.decoder.openStream.query({ ...input }, pin);
631
- },
632
- updateConfig: async (input) => {
633
- await api.decoder.updateConfig.query({ ...input }, pin);
634
- },
635
- getStats: async (input) => api.decoder.getStats.query({ ...input }, pin)
636
- };
637
- }
638
- };
639
- //#endregion
640
425
  //#region src/pipeline-runner/frame-queue.ts
641
426
  /**
642
427
  * Latest-frame-only buffer. Keeps only the most recent item, dropping all
@@ -1924,21 +1709,25 @@ function startSessionDecodePump(deps) {
1924
1709
  const decoded = await toDecodedFrame(frame, format);
1925
1710
  deps.onDecodedFrame(decoded);
1926
1711
  } catch (err) {
1927
- deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", { meta: {
1928
- deviceId: deps.source.deviceId,
1929
- format,
1930
- error: errMsg(err)
1931
- } });
1712
+ deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
1713
+ tags: { deviceId: deps.source.deviceId },
1714
+ meta: {
1715
+ format,
1716
+ error: errMsg(err)
1717
+ }
1718
+ });
1932
1719
  } finally {
1933
1720
  frame.image.close();
1934
1721
  }
1935
1722
  }
1936
1723
  } catch (err) {
1937
- deps.logger.warn("session-decode pump: frame loop ended with error", { meta: {
1938
- deviceId: deps.source.deviceId,
1939
- format,
1940
- error: errMsg(err)
1941
- } });
1724
+ deps.logger.warn("session-decode pump: frame loop ended with error", {
1725
+ tags: { deviceId: deps.source.deviceId },
1726
+ meta: {
1727
+ format,
1728
+ error: errMsg(err)
1729
+ }
1730
+ });
1942
1731
  } finally {
1943
1732
  releaseSpawnSlotOnce();
1944
1733
  }
@@ -2061,9 +1850,95 @@ function resolveMotionFrameSource(config, sharedWithDetection) {
2061
1850
  function resolveSessionDecodeHostname(config, hubUrl) {
2062
1851
  const frameSource = config.frameSource;
2063
1852
  if (!isRemoteRestream(frameSource)) return void 0;
1853
+ return resolveOwnerRestreamHost(frameSource, hubUrl);
1854
+ }
1855
+ /**
1856
+ * The camera-source OWNER node for a session-decode acquire, or `undefined`
1857
+ * when the source is node-local (co-located, pre-cross-node). Mirrors
1858
+ * `resolveSessionDecodeHostname`: only a `remote-restream` frame source names an
1859
+ * owner. Used to `nodePin` the broker calls at the actual owner.
1860
+ */
1861
+ function resolveSessionDecodeOwnerNodeId(config) {
1862
+ const frameSource = config.frameSource;
1863
+ if (!isRemoteRestream(frameSource)) return void 0;
1864
+ return frameSource.ownerNodeId;
1865
+ }
1866
+ /**
1867
+ * The host a cross-node consumer dials for the owner's compressed restream.
1868
+ * Precedence: the orchestrator-resolved `ownerReachableHost` (the per-node
1869
+ * Cluster-UI override) → the operator `hubHostnameOverride` escape hatch →
1870
+ * the `CAMSTACK_HUB_URL`-derived host (the auto-detected Moleculer address).
1871
+ * `undefined` only when none resolve (the acquire then fails loudly rather
1872
+ * than silently dialing `127.0.0.1` on a remote node).
1873
+ */
1874
+ function resolveOwnerRestreamHost(frameSource, hubUrl) {
1875
+ const reachable = frameSource.ownerReachableHost?.trim();
1876
+ if (reachable !== void 0 && reachable.length > 0) return reachable;
2064
1877
  return resolveHubHostname(frameSource.hubHostnameOverride, hubUrl);
2065
1878
  }
2066
1879
  /**
1880
+ * Acquire the broker's COMPRESSED passthrough restream for one camStream and
1881
+ * wrap it as a {@link VideoFrameSource} the decode worker can dial. Mirrors
1882
+ * {@link RemoteSourcePlane}'s acquire: resolve the profile the camStream
1883
+ * feeds, then `getStreamWithCodec({ video: 'copy' })` (refcounted — rides the
1884
+ * broker's single source pull, never a second camera dial). No `hostname`:
1885
+ * the P1 flagged camera is HUB-LOCAL, so the returned `127.0.0.1` URL is
1886
+ * dialable from this hub-resident runner. Returns `null` (loudly logged) when
1887
+ * no profile is bound yet — the caller opens no subscription.
1888
+ *
1889
+ * Extracted as a standalone exported function (rather than a private method)
1890
+ * so the owner-node pin threading through the three `streamBroker` calls
1891
+ * (`listAllProfileSlots`, `getStreamWithCodec`, `releaseStreamWithCodec`) is
1892
+ * directly unit-testable without instantiating the addon class. The private
1893
+ * `acquireSessionDecodeRestream` method on {@link PipelineRunnerAddon} is a
1894
+ * thin delegator to this function — its two call sites are unchanged.
1895
+ */
1896
+ async function acquireSessionDecodeRestreamFrom(api, logger, params) {
1897
+ const { deviceId, streamId, tag, hostname, ownerNodeId } = params;
1898
+ const pin = ownerNodeId !== void 0 ? nodePin(ownerNodeId) : void 0;
1899
+ const profile = profileForStreamId(await api.streamBroker.listAllProfileSlots.query(void 0, pin), deviceId, streamId);
1900
+ if (profile === null) {
1901
+ logger.error("session-decode: no broker profile feeds camStream — cannot acquire restream", {
1902
+ tags: { deviceId },
1903
+ meta: { streamId }
1904
+ });
1905
+ return null;
1906
+ }
1907
+ const rtpSource = await api.streamBroker.getStreamWithCodec.mutate({
1908
+ deviceId,
1909
+ video: "copy",
1910
+ audio: "none",
1911
+ profile,
1912
+ tag,
1913
+ ...hostname !== void 0 ? { hostname } : {}
1914
+ }, pin);
1915
+ const codec = rtpSource.videoCodec === "H265" ? "h265" : "h264";
1916
+ const source = {
1917
+ restreamUrl: rtpSource.url,
1918
+ deviceId,
1919
+ codec
1920
+ };
1921
+ const pipelineKey = rtpSource.pipelineKey;
1922
+ let released = false;
1923
+ const release = async () => {
1924
+ if (released) return;
1925
+ released = true;
1926
+ await api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey }, pin).catch((err) => {
1927
+ logger.debug("session-decode: releaseStreamWithCodec failed (benign on hub restart)", {
1928
+ tags: { deviceId },
1929
+ meta: {
1930
+ pipelineKey,
1931
+ error: errMsg(err)
1932
+ }
1933
+ });
1934
+ });
1935
+ };
1936
+ return {
1937
+ source,
1938
+ release
1939
+ };
1940
+ }
1941
+ /**
2067
1942
  * Interval for periodic metric snapshot emission. ~1 Hz strikes the
2068
1943
  * balance between UI freshness (overlay phase / fps numbers feel live)
2069
1944
  * and bus traffic (one snapshot per attached camera + one per node
@@ -2624,7 +2499,6 @@ var PipelineRunnerAddon = class extends BaseAddon {
2624
2499
  if (config.motionSources.includes("analyzer")) attachment.motionUnsubscribe = await this.subscribeMotionFrames(config);
2625
2500
  const stepsCount = config.steps?.length ?? 0;
2626
2501
  const dispatch = stepsCount > 0 ? `runPipeline(${stepsCount}step${stepsCount === 1 ? "" : "s"})` : config.steps !== void 0 ? "skip(0steps)" : "runFrame(legacy)";
2627
- const engineLabel = config.engine ? `${config.engine.runtime}+${config.engine.backend}/${config.engine.format}` : "default";
2628
2502
  this.ctx.logger.info("attachCamera", {
2629
2503
  tags: { deviceId: config.deviceId },
2630
2504
  meta: {
@@ -2634,7 +2508,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
2634
2508
  detectionFps: config.detectionFps,
2635
2509
  motionSources: config.motionSources,
2636
2510
  dispatch,
2637
- engine: engineLabel
2511
+ engine: "node-local"
2638
2512
  }
2639
2513
  });
2640
2514
  return { success: true };
@@ -2823,12 +2697,13 @@ var PipelineRunnerAddon = class extends BaseAddon {
2823
2697
  if (existing) return existing;
2824
2698
  const api = this.ctx.api;
2825
2699
  if (!api) return null;
2826
- const hostname = resolveHubHostname(frameSource.hubHostnameOverride, process.env["CAMSTACK_HUB_URL"]);
2700
+ const hostname = resolveOwnerRestreamHost(frameSource, process.env["CAMSTACK_HUB_URL"]);
2827
2701
  const plane = new RemoteSourcePlane({
2828
2702
  api,
2829
2703
  logger: this.ctx.logger,
2830
2704
  deviceId: config.deviceId,
2831
2705
  ownNodeId: this.nodeId,
2706
+ ownerNodeId: frameSource.ownerNodeId,
2832
2707
  hostname
2833
2708
  });
2834
2709
  this.ctx.logger.info("remote-source leg armed for camera", {
@@ -2842,57 +2717,18 @@ var PipelineRunnerAddon = class extends BaseAddon {
2842
2717
  return plane;
2843
2718
  }
2844
2719
  /**
2845
- * Acquire the broker's COMPRESSED passthrough restream for one camStream and
2846
- * wrap it as a {@link VideoFrameSource} the decode worker can dial. Mirrors
2847
- * {@link RemoteSourcePlane}'s acquire: resolve the profile the camStream
2848
- * feeds, then `getStreamWithCodec({ video: 'copy' })` (refcounted — rides the
2849
- * broker's single source pull, never a second camera dial). No `hostname`:
2850
- * the P1 flagged camera is HUB-LOCAL, so the returned `127.0.0.1` URL is
2851
- * dialable from this hub-resident runner. Returns `null` (loudly logged) when
2852
- * no profile is bound yet — the caller opens no subscription.
2720
+ * Thin delegator to the module-level {@link acquireSessionDecodeRestreamFrom}
2721
+ * (see its doc for the acquire behavior) supplies this addon's `ctx.logger`.
2722
+ * Kept as a method so the two call sites need no change.
2853
2723
  */
2854
- async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname) {
2855
- const profile = profileForStreamId(await api.streamBroker.listAllProfileSlots.query(), deviceId, streamId);
2856
- if (profile === null) {
2857
- this.ctx.logger.error("session-decode: no broker profile feeds camStream — cannot acquire restream", {
2858
- tags: { deviceId },
2859
- meta: { streamId }
2860
- });
2861
- return null;
2862
- }
2863
- const rtpSource = await api.streamBroker.getStreamWithCodec.mutate({
2724
+ async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname, ownerNodeId) {
2725
+ return acquireSessionDecodeRestreamFrom(api, this.ctx.logger, {
2864
2726
  deviceId,
2865
- video: "copy",
2866
- audio: "none",
2867
- profile,
2727
+ streamId,
2868
2728
  tag,
2869
- ...hostname !== void 0 ? { hostname } : {}
2729
+ hostname,
2730
+ ownerNodeId
2870
2731
  });
2871
- const codec = rtpSource.videoCodec === "H265" ? "h265" : "h264";
2872
- const source = {
2873
- restreamUrl: rtpSource.url,
2874
- deviceId,
2875
- codec
2876
- };
2877
- const pipelineKey = rtpSource.pipelineKey;
2878
- let released = false;
2879
- const release = async () => {
2880
- if (released) return;
2881
- released = true;
2882
- await api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey }).catch((err) => {
2883
- this.ctx.logger.debug("session-decode: releaseStreamWithCodec failed (benign on hub restart)", {
2884
- tags: { deviceId },
2885
- meta: {
2886
- pipelineKey,
2887
- error: errMsg(err)
2888
- }
2889
- });
2890
- });
2891
- };
2892
- return {
2893
- source,
2894
- release
2895
- };
2896
2732
  }
2897
2733
  /**
2898
2734
  * Register this device's motion to ride the detection session-decode worker
@@ -2970,7 +2806,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
2970
2806
  if (!runner) return () => {};
2971
2807
  return startGuardedSessionDecode({
2972
2808
  logger: log,
2973
- acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"])),
2809
+ acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
2974
2810
  startPump: (source) => {
2975
2811
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
2976
2812
  streamId: config.detectionStreamId,
@@ -3009,7 +2845,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3009
2845
  if (!runner) return () => {};
3010
2846
  return startGuardedSessionDecode({
3011
2847
  logger: log,
3012
- acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.motionStreamId, `session-decode:motion:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"])),
2848
+ acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.motionStreamId, `session-decode:motion:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
3013
2849
  startPump: (source) => {
3014
2850
  log.info("session-decode: motion routed through per-session decode worker (gray)", { meta: {
3015
2851
  streamId: config.motionStreamId,
@@ -3191,9 +3027,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3191
3027
  log.error("runInference: this.ctx.api not available");
3192
3028
  return null;
3193
3029
  }
3194
- const camConfig = this.attached.get(deviceId)?.config;
3195
- const steps = camConfig?.steps;
3196
- const engine = camConfig?.engine;
3030
+ const steps = (this.attached.get(deviceId)?.config)?.steps;
3197
3031
  if (!steps) {
3198
3032
  log.warn("runInference: no steps in attach config — skipping frame (legacy attach?)");
3199
3033
  return null;
@@ -3204,8 +3038,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3204
3038
  return await api.pipelineExecutor.runPipeline.mutate({
3205
3039
  steps: [...steps],
3206
3040
  ...useHandle ? { frameHandle: handle } : { frame },
3207
- deviceId,
3208
- ...engine ? { engine } : {}
3041
+ deviceId
3209
3042
  });
3210
3043
  } catch (err) {
3211
3044
  const msg = errMsg(err);
@@ -3563,4 +3396,4 @@ var PipelineRunnerAddon = class extends BaseAddon {
3563
3396
  }
3564
3397
  };
3565
3398
  //#endregion
3566
- export { DEFAULT_CONFIG, FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, resolveMotionFrameSource, resolveSessionDecodeHostname, rgbFrameToGrayFrame, shouldStartOnboardAnalyzer };
3399
+ export { DEFAULT_CONFIG, FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, acquireSessionDecodeRestreamFrom, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, resolveMotionFrameSource, resolveSessionDecodeHostname, resolveSessionDecodeOwnerNodeId, rgbFrameToGrayFrame, shouldStartOnboardAnalyzer };
@@ -1,10 +1,11 @@
1
- const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-DxM2DSns.js");
2
- const require_dist = require("../dist-NvwN60Fq.js");
1
+ const require_chunk = require("../chunk-D6vf50IK.js");
2
+ const require_dist = require("../dist-CySHUkXY.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
+ const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-BLXzxpRB.js");
4
5
  let node_child_process = require("node:child_process");
5
6
  let node_fs = require("node:fs");
6
7
  let node_path = require("node:path");
7
- node_path = require_model_download_service_C_IHWnXx.__toESM(node_path);
8
+ node_path = require_chunk.__toESM(node_path);
8
9
  //#region src/recorder/segment-path.ts
9
10
  function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
10
11
  if (profile.includes("/")) throw new Error(`segmentRelPath: profile must not contain '/': ${profile}`);
@@ -224,7 +225,10 @@ var SegmentStore = class SegmentStore {
224
225
  try {
225
226
  bytes = await this.deps.statBytes(input.flatAbsPath);
226
227
  } catch (err) {
227
- this.deps.logger.warn("segment stat failed", err);
228
+ this.deps.logger.warn("segment stat failed", {
229
+ tags: { deviceId: input.deviceId },
230
+ error: err
231
+ });
228
232
  return null;
229
233
  }
230
234
  if (bytes <= 0) return null;
@@ -233,7 +237,10 @@ var SegmentStore = class SegmentStore {
233
237
  try {
234
238
  await this.deps.moveFile(input.flatAbsPath, toAbs);
235
239
  } catch (err) {
236
- this.deps.logger.warn("segment relocate failed", err);
240
+ this.deps.logger.warn("segment relocate failed", {
241
+ tags: { deviceId: input.deviceId },
242
+ error: err
243
+ });
237
244
  return null;
238
245
  }
239
246
  const row = {
@@ -283,7 +290,10 @@ var SegmentStore = class SegmentStore {
283
290
  }
284
291
  continue;
285
292
  } catch (err) {
286
- this.deps.logger.warn("bucket removeDir failed — falling back to per-file deletes", err);
293
+ this.deps.logger.warn("bucket removeDir failed — falling back to per-file deletes", {
294
+ tags: { deviceId: bucketRows[0]?.deviceId },
295
+ error: err
296
+ });
287
297
  }
288
298
  for (const r of bucketRows) try {
289
299
  await this.deps.deletePath({
@@ -293,7 +303,10 @@ var SegmentStore = class SegmentStore {
293
303
  removed.push(r.path);
294
304
  reclaimed += r.bytes;
295
305
  } catch (err) {
296
- this.deps.logger.warn("segment delete failed", err);
306
+ this.deps.logger.warn("segment delete failed", {
307
+ tags: { deviceId: r.deviceId },
308
+ error: err
309
+ });
297
310
  }
298
311
  }
299
312
  if (removed.length > 0) this.deps.index.removeSegments(removed);
@@ -1863,7 +1876,7 @@ var RecordingController = class {
1863
1876
  * assigned sources is ignored (record every assigned source — minimum of 1).
1864
1877
  */
1865
1878
  async resolveProfiles(deviceId, override) {
1866
- const assigned = require_dist.selectAssignedProfileSlots(await this.deps.brokerCall(() => this.deps.api.streamBroker.listAllProfileSlots.query()), deviceId).map((slot) => slot.profile);
1879
+ const assigned = require_dist.selectAssignedProfileSlots(await this.deps.brokerCall(() => this.deps.api.streamBroker.listAllProfileSlots.query(void 0, require_dist.nodePin(this.deps.ownerNodeId))), deviceId).map((slot) => slot.profile);
1867
1880
  if (!override || override.length === 0) return assigned;
1868
1881
  const selected = assigned.filter((p) => override.includes(p));
1869
1882
  return selected.length > 0 ? selected : assigned;
@@ -1883,7 +1896,7 @@ var RecordingController = class {
1883
1896
  profile,
1884
1897
  ...this.deps.consumerHostname !== void 0 ? { hostname: this.deps.consumerHostname } : {},
1885
1898
  tag: `recorder:${deviceId}/${profile}`
1886
- }));
1899
+ }, require_dist.nodePin(this.deps.ownerNodeId)));
1887
1900
  const outDir = node_path.default.join(placement.root, STAGING_DIR_NAME, String(deviceId), profile);
1888
1901
  await node_fs.promises.mkdir(outDir, { recursive: true });
1889
1902
  const deviceLog = this.deps.logger.withTags({ deviceId });
@@ -1969,7 +1982,7 @@ var RecordingController = class {
1969
1982
  r.writer.stop();
1970
1983
  } catch {}
1971
1984
  try {
1972
- await this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey });
1985
+ await this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey }, require_dist.nodePin(this.deps.ownerNodeId));
1973
1986
  } catch (err) {
1974
1987
  this.deps.logger.warn("recorder: releaseStreamWithCodec failed", {
1975
1988
  tags: { deviceId },
@@ -2080,9 +2093,13 @@ var MARKER_PAD_POST_MS = require_dist.EVENT_PAD_MS.postMs;
2080
2093
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
2081
2094
  var RECORDER_ADDON_ID = "recorder";
2082
2095
  var PLAYBACK_PREFIX = "playback";
2083
- /** Node the hub-only `stream-broker` runs on. Every recorder on the hub or on
2084
- * a designated agent gates on, and pulls from, THIS node's broker. */
2085
- var BROKER_HOST_NODE_ID = "hub";
2096
+ /** Narrows the `unknown` extras `SegmentStore`'s logger contract accepts down
2097
+ * to the structured `{ tags, error }` shape the device-scoped call sites
2098
+ * (stat/relocate failures) pass — as opposed to the raw caught error most
2099
+ * other call sites still pass directly. */
2100
+ function isSegmentStoreLogExtras(extras) {
2101
+ return typeof extras === "object" && extras !== null && ("tags" in extras || "error" in extras);
2102
+ }
2086
2103
  var RecorderV2Addon = class extends require_dist.BaseAddon {
2087
2104
  nodeId = "hub";
2088
2105
  /** True when THIS node is the designated recording node (`recordingNodeId`).
@@ -2117,14 +2134,15 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2117
2134
  } });
2118
2135
  return { providers: [] };
2119
2136
  }
2120
- const consumerHostname = this.nodeId === BROKER_HOST_NODE_ID ? void 0 : resolveRecordingHubHostname(this.config.recordingHubHostname, process.env["CAMSTACK_HUB_URL"]);
2121
- if (this.nodeId !== BROKER_HOST_NODE_ID) {
2137
+ const ingestOwner = await this.resolveIngestOwner();
2138
+ const consumerHostname = this.nodeId === ingestOwner.ownerNodeId ? void 0 : ingestOwner.reachableHost ?? resolveRecordingHubHostname(this.config.recordingHubHostname, process.env["CAMSTACK_HUB_URL"]);
2139
+ if (this.nodeId !== ingestOwner.ownerNodeId) {
2122
2140
  this.ctx.logger.info("recorder is a REMOTE recording node — pulling broker streams cross-node", { meta: {
2123
2141
  ownNodeId: this.nodeId,
2124
- brokerHost: BROKER_HOST_NODE_ID,
2142
+ brokerHost: ingestOwner.ownerNodeId,
2125
2143
  consumerHostname: consumerHostname ?? "(unresolved)"
2126
2144
  } });
2127
- if (consumerHostname === void 0) this.ctx.logger.warn("recorder: could not resolve the hub host for cross-node pull — set the `recordingHubHostname` global setting or CAMSTACK_HUB_URL; recording will not reach this node", { meta: { ownNodeId: this.nodeId } });
2145
+ if (consumerHostname === void 0) this.ctx.logger.warn("recorder: could not resolve the ingest owner host for cross-node pull — set the `recordingHubHostname` global setting or CAMSTACK_HUB_URL; recording will not reach this node", { meta: { ownNodeId: this.nodeId } });
2128
2146
  }
2129
2147
  const segmentStoreDeps = {
2130
2148
  index: this.index,
@@ -2149,14 +2167,21 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2149
2167
  force: true
2150
2168
  });
2151
2169
  },
2152
- logger: { warn: (message, extras) => this.ctx.logger.warn(message, { meta: { extras } }) }
2170
+ logger: { warn: (message, extras) => {
2171
+ if (isSegmentStoreLogExtras(extras)) this.ctx.logger.warn(message, {
2172
+ tags: extras.tags,
2173
+ meta: { error: extras.error }
2174
+ });
2175
+ else this.ctx.logger.warn(message, { meta: { extras } });
2176
+ } }
2153
2177
  };
2154
2178
  this.segmentStore = new SegmentStore(segmentStoreDeps);
2155
- const brokerHandle = this.ctx.useCapability("stream-broker", brokerScope(BROKER_HOST_NODE_ID));
2179
+ const brokerHandle = this.ctx.useCapability("stream-broker", brokerScope(ingestOwner.ownerNodeId));
2156
2180
  this.controller = new RecordingController({
2157
2181
  api: this.ctx.api,
2158
2182
  logger: this.ctx.logger,
2159
2183
  nodeId: this.nodeId,
2184
+ ownerNodeId: ingestOwner.ownerNodeId,
2160
2185
  segmentStore: this.segmentStore,
2161
2186
  loadConfig: (deviceId) => loadDeviceConfig(this.configStore(), deviceId),
2162
2187
  enabledDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
@@ -2165,7 +2190,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2165
2190
  segmentSeconds: this.config.segmentSeconds,
2166
2191
  watchIntervalMs: this.config.watchIntervalMs,
2167
2192
  ...consumerHostname !== void 0 ? { consumerHostname } : {},
2168
- onBrokerReady: (handler) => this.ctx.onCapabilityStateChange("stream-broker", brokerScope(BROKER_HOST_NODE_ID), (state) => {
2193
+ onBrokerReady: (handler) => this.ctx.onCapabilityStateChange("stream-broker", brokerScope(ingestOwner.ownerNodeId), (state) => {
2169
2194
  if (state === "ready") handler();
2170
2195
  }),
2171
2196
  brokerCall: makeBrokerCall(brokerHandle)
@@ -2291,6 +2316,21 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2291
2316
  return this.resolvedLocations;
2292
2317
  }
2293
2318
  /**
2319
+ * Resolve the cluster's camera-source owner. Graceful degrade: the
2320
+ * `pipeline-orchestrator` cap may not be mounted yet (pre-image-release),
2321
+ * in which case we fall back to `{ ownerNodeId: 'hub' }` — the same default
2322
+ * `getIngestOwner` itself returns absent an explicit `clusterRoles.ingestNode`,
2323
+ * so an unresolved owner is byte-identical to today's hub-only behaviour.
2324
+ */
2325
+ async resolveIngestOwner() {
2326
+ try {
2327
+ return await this.ctx.api.pipelineOrchestrator.getIngestOwner.query();
2328
+ } catch (err) {
2329
+ this.ctx.logger.warn("recorder: getIngestOwner unavailable — defaulting to hub (cap not mounted yet?)", { meta: { error: require_dist.errMsg(err) } });
2330
+ return { ownerNodeId: "hub" };
2331
+ }
2332
+ }
2333
+ /**
2294
2334
  * Roots the playback data-plane handler searches, IN ORDER: `<dataDir>/playback`
2295
2335
  * FIRST (the freshly-written master/variant `.m3u8` playlists), then every
2296
2336
  * recordings location root (the segment `.m4s` files at