@camstack/addon-pipeline 1.2.329 → 1.2.331

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 (33) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-ChH7Y219.js → default-detection-model-D3SnO2hN.js} +39 -67
  4. package/dist/{default-detection-model-j0nBpRoB.mjs → default-detection-model-DELTCQgv.mjs} +39 -67
  5. package/dist/detection-pipeline/index.js +197 -31
  6. package/dist/detection-pipeline/index.mjs +197 -31
  7. package/dist/{dist-0HoA3wAi.mjs → dist-CNXhBzn6.mjs} +847 -439
  8. package/dist/{dist-BlIWhjnu.js → dist-DrUx3EGR.js} +892 -466
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-BPpVMnZy.js → node-B4qy3lnV.js} +1 -1
  12. package/dist/{node-HOMWRPzW.mjs → node-BmxmlYLx.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +355 -104
  14. package/dist/pipeline-runner/index.mjs +355 -104
  15. package/dist/{process-memory-DuF8pa2w.mjs → process-memory-BGc5PzZI.mjs} +1 -1
  16. package/dist/{process-memory-BW06vOIQ.js → process-memory-CRtWJbjV.js} +1 -1
  17. package/dist/recorder/index.js +7 -7
  18. package/dist/recorder/index.mjs +7 -7
  19. package/dist/{segment-demux-js-Dl52Zz8w.js → segment-demux-js-CpSgZmSR.js} +2 -2
  20. package/dist/{segment-demux-js-BU2MXpln.mjs → segment-demux-js-VqsLAgQ0.mjs} +2 -2
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-vNfS65In.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BnncdmtJ.mjs} +2 -2
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-2jlYV34n.mjs +26 -0
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bt8aa0wk.mjs +26 -0
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-Ciacp783.mjs → hostInit-CF_2Rlu0.mjs} +2 -2
  28. package/dist/stream-broker/index.js +713 -80
  29. package/dist/stream-broker/index.mjs +713 -80
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DfeHvW4k.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BpXPNINd.mjs +0 -26
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-BlIWhjnu.js");
7
- const require_node = require("../node-BPpVMnZy.js");
6
+ const require_dist = require("../dist-DrUx3EGR.js");
7
+ const require_node = require("../node-B4qy3lnV.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-Dl52Zz8w.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-CpSgZmSR.js");
12
12
  let node_os = require("node:os");
13
13
  node_os = require_chunk.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
@@ -24422,7 +24422,9 @@ var AdaptiveSession = class AdaptiveSession {
24422
24422
  * tier when loss persists — smaller packets survive lossy links better.
24423
24423
  * Must stay below H264_REPACKETIZER_MTU (1100). */
24424
24424
  static ADAPTIVE_LOW_MTU = 900;
24425
- /** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
24425
+ /** Broker source-RTP pre-buffer accessor + one-shot replay guard. Replaced
24426
+ * by `switchLiveSource` when an adaptive tier change moves the track onto
24427
+ * another broker in place. */
24426
24428
  rtpBootstrap;
24427
24429
  rtpBootstrapDone = false;
24428
24430
  /**
@@ -24517,6 +24519,46 @@ var AdaptiveSession = class AdaptiveSession {
24517
24519
  reason
24518
24520
  } });
24519
24521
  }
24522
+ /**
24523
+ * Move the live track onto ANOTHER source of the same codec, in place — an
24524
+ * adaptive tier change on the session the viewer already has (D733).
24525
+ *
24526
+ * Everything `onSourceRestarted` does for a re-dial, because a different
24527
+ * rung IS a different RTP stream (new SSRC, new sequence/timestamp base, new
24528
+ * parameter sets), plus the two things only a different broker changes:
24529
+ * - the bootstrap accessor now reads the NEW broker's pre-buffer, so the
24530
+ * next forwarded packet replays its current GOP from the keyframe — the
24531
+ * decoder switches on an IDR instead of waiting for the camera's next one;
24532
+ * - the repacketizer is re-seeded with the new rung's SDP parameter sets,
24533
+ * for a camera that publishes them only in SDP.
24534
+ * The MTU returns to the default a freshly built session would have: the
24535
+ * reduced MTU belongs to the lowest rung's loss recovery, not to the session.
24536
+ */
24537
+ switchLiveSource(next) {
24538
+ if (this.closed) return;
24539
+ this.rtpBootstrap = next.rtpBootstrap;
24540
+ this.activeRtpReplay?.abort();
24541
+ this.activeRtpReplay = null;
24542
+ this.h264Repacketizer = null;
24543
+ this.h265Repacketizer = null;
24544
+ this.sourceVideoSsrc = null;
24545
+ this.rtpRebasePending = true;
24546
+ this.rtpBootstrapDone = false;
24547
+ this.repacketizerMtu = AdaptiveSession.H264_REPACKETIZER_MTU;
24548
+ const ps = next.parameterSets ?? [];
24549
+ if (ps.length > 0) if (this.sourceCodec === "H265") this.seedH265CodecInfoFromSdp(ps);
24550
+ else this.seedH264CodecInfoFromSdp(ps);
24551
+ this.logger.info("live source switched in place — replaying the new source from its keyframe", {
24552
+ tags: { deviceId: this.deviceId },
24553
+ meta: {
24554
+ phase: "session",
24555
+ sessionId: this.sessionId,
24556
+ deviceId: this.deviceId,
24557
+ reason: next.reason,
24558
+ seededParamSets: ps.length
24559
+ }
24560
+ });
24561
+ }
24520
24562
  /** Drop the repacketizer + bootstrap/sync state so a newly bound source
24521
24563
  * stream re-seeds its SPS/PPS and replays instant-start cleanly. */
24522
24564
  resetRepacketizer() {
@@ -27209,6 +27251,32 @@ var AdaptiveController = class {
27209
27251
  onRtt(rttMs, now) {
27210
27252
  this.rtt.observe(rttMs, now);
27211
27253
  }
27254
+ /**
27255
+ * The session now serves `tier`, decided OUTSIDE this ladder's own tick
27256
+ * (D733): the viewer's downlink probe placed it there, or the rung the ladder
27257
+ * asked for is not served and the switch landed on the one that is.
27258
+ *
27259
+ * Before D733 both were a close-and-recreate re-offer, which built a NEW
27260
+ * controller on the rung the session landed on. The ladder ends up where that
27261
+ * new controller started: on `tier`, not on trial (a probe IS capacity
27262
+ * evidence; a fallback rung was not tried), with its healthy clock and change
27263
+ * blackout starting now, and with no promotion counted. The measured-failure
27264
+ * memory is kept: a rung this session already proved unusable stays backed off.
27265
+ */
27266
+ settleOn(tier, now) {
27267
+ this.armed = true;
27268
+ this.hold = null;
27269
+ this.tier = tier;
27270
+ this.mtuReduced = false;
27271
+ this.transcodeEngaged = false;
27272
+ this.trial = null;
27273
+ this.tryCarry = {
27274
+ ...this.tryCarry,
27275
+ trialFrom: null
27276
+ };
27277
+ this.commitChange(now);
27278
+ this.healthySince = now;
27279
+ }
27212
27280
  /** Why the ladder did not climb on the last evaluation; null = at the top, or it just moved. */
27213
27281
  get climbHold() {
27214
27282
  return this.hold;
@@ -31231,6 +31299,8 @@ var TimelineSession = class {
31231
31299
  * paused across that required quality transition. */
31232
31300
  rate = 1;
31233
31301
  liveProfileReported = false;
31302
+ /** The live rung after an in-place tier switch; undefined = `initialLiveProfile`. */
31303
+ liveProfile = void 0;
31234
31304
  /**
31235
31305
  * The last position the viewer was SHOWN (every `_emitPosition`). Handed to
31236
31306
  * `seek` as `travelFromMs` so the seek travel animation works across feeder
@@ -31705,11 +31775,29 @@ var TimelineSession = class {
31705
31775
  }));
31706
31776
  }
31707
31777
  emitLiveProfile() {
31708
- if (this.liveProfileReported || this.deps.initialLiveProfile === void 0) return;
31778
+ const profile = this.liveProfile ?? this.deps.initialLiveProfile;
31779
+ if (this.liveProfileReported || profile === void 0) return;
31709
31780
  this.liveProfileReported = true;
31710
31781
  this.deps.session.sendControl(serializeServerMessage({
31711
31782
  t: "liveProfile",
31712
- profile: this.deps.initialLiveProfile
31783
+ profile
31784
+ }));
31785
+ }
31786
+ /**
31787
+ * The live rung moved under this coordinator — an adaptive tier switch that
31788
+ * stayed on the session (D733). Before D733 a tier change built a new
31789
+ * coordinator, which reported its own `initialLiveProfile`; now the same one
31790
+ * outlives the switch and must say what the viewer is watching. Reported at
31791
+ * once when the viewer has already been told a profile, otherwise on its
31792
+ * first control message as before.
31793
+ */
31794
+ setLiveProfile(profile) {
31795
+ if (this.liveProfile === profile) return;
31796
+ this.liveProfile = profile;
31797
+ if (!this.liveProfileReported) return;
31798
+ this.deps.session.sendControl(serializeServerMessage({
31799
+ t: "liveProfile",
31800
+ profile
31713
31801
  }));
31714
31802
  }
31715
31803
  dispose() {
@@ -34590,7 +34678,7 @@ async function demuxSegmentAudioOpusPreferJs(bytes, opts) {
34590
34678
  if (direct) return direct;
34591
34679
  } catch {}
34592
34680
  if (opts.ffmpegPath === null) {
34593
- opts.onFfmpegFallback?.("audio track is not Opus (legacy fragment) and the ffmpeg fallback is refused — no pinned ffmpeg on this node (D540)");
34681
+ opts.onFfmpegFallback?.("audio track is not Opus (legacy fragment) and the ffmpeg fallback is refused — no pinned ffmpeg on this node");
34594
34682
  return [];
34595
34683
  }
34596
34684
  opts.onFfmpegFallback?.("audio track is not Opus (legacy fragment)");
@@ -34632,6 +34720,98 @@ function preferLiveAdaptiveCandidates(candidates) {
34632
34720
  return natives.length > 0 ? natives : [...candidates];
34633
34721
  }
34634
34722
  //#endregion
34723
+ //#region src/stream-broker/webrtc/in-place-tier-switch.ts
34724
+ /**
34725
+ * How long a switch waits for the new rendition's first switchable keyframe.
34726
+ * A warm broker commits on its next packet; a cold one needs its dial (2–3 s on
34727
+ * this hub) plus the camera's next IDR (≤ 4 s at the GOPs measured). 8 s covers
34728
+ * both; past it the old source has been shown the whole time, so nothing froze.
34729
+ */
34730
+ var IN_PLACE_SWITCH_KEYFRAME_WAIT_MS = 8e3;
34731
+ /** Map a broker's codec string to the session codec it would negotiate; null = unknown. */
34732
+ function sessionCodecOf(brokerCodec) {
34733
+ if (brokerCodec === void 0 || brokerCodec === "") return null;
34734
+ const c = brokerCodec.toLowerCase();
34735
+ return c === "h265" || c === "hevc" ? "H265" : "H264";
34736
+ }
34737
+ /**
34738
+ * Whether the switch may stay on the session; the refusal names why not.
34739
+ * Facts not known yet (a cold target's codec or audio) do not refuse — the
34740
+ * judgement is repeated at commit time, when they are.
34741
+ */
34742
+ function judgeInPlaceSwitch(session, target) {
34743
+ if (session.recorded) return "recorded";
34744
+ if (session.forceTranscodeDown) return "transcode-down";
34745
+ if (!session.useRtpRepacketizer) return "not-rtp-passthrough";
34746
+ if (session.needsTranscode) return "transcode";
34747
+ if (!target.tierMatches) return "tier-unavailable";
34748
+ if (!target.isRtp) return "target-not-rtp";
34749
+ if (target.codec !== null && target.codec !== session.sessionCodec) return "codec-differs";
34750
+ if (target.needsBaselineTranscode) return "target-needs-transcode";
34751
+ if (target.audioCodec !== void 0 && !sameAudioCodec(session.audioCodec, target.audioCodec)) return "audio-codec-differs";
34752
+ if (session.usesOpusEgress && !target.supportsOpusEgress) return "opus-egress-unsupported";
34753
+ return null;
34754
+ }
34755
+ function sameAudioCodec(a, b) {
34756
+ return (a ?? "").toLowerCase() === (b ?? "").toLowerCase();
34757
+ }
34758
+ /**
34759
+ * Subscribe `broker`'s RTP while the session's current source keeps feeding,
34760
+ * and commit when the new rendition can start on a keyframe.
34761
+ *
34762
+ * ONE subscription goes from pending to forwarding — never a second one added
34763
+ * during the delivery of the committing packet, which a broker iterating its
34764
+ * subscriber set would hand the same packet again (a duplicate behind the
34765
+ * replay). A warm-up consumer keeps a cold broker's dial up while pending and
34766
+ * is released only after the commit has attached the session's own consumers.
34767
+ */
34768
+ function bindPendingSource(opts) {
34769
+ let state = "pending";
34770
+ let warm = opts.broker.onEncodedData(() => {}, {
34771
+ kind: "warmup",
34772
+ media: "both"
34773
+ });
34774
+ const releaseWarm = () => {
34775
+ warm?.();
34776
+ warm = null;
34777
+ };
34778
+ let unsubRtp = null;
34779
+ const timer = setTimeout(() => {
34780
+ if (state !== "pending") return;
34781
+ state = "gone";
34782
+ unsubRtp?.();
34783
+ releaseWarm();
34784
+ opts.onTimeout();
34785
+ }, opts.timeoutMs);
34786
+ unsubRtp = opts.broker.onVideoRtp((rtp) => {
34787
+ if (state === "gone") return;
34788
+ if (state === "committed") {
34789
+ opts.forward(rtp);
34790
+ return;
34791
+ }
34792
+ if (opts.broker.getRtpPreBuffer().length === 0) return;
34793
+ clearTimeout(timer);
34794
+ if (!opts.commit()) {
34795
+ state = "gone";
34796
+ unsubRtp?.();
34797
+ releaseWarm();
34798
+ return;
34799
+ }
34800
+ state = "committed";
34801
+ releaseWarm();
34802
+ opts.forward(rtp);
34803
+ }, opts.attribution);
34804
+ return {
34805
+ unsubscribe: () => {
34806
+ if (state === "pending") clearTimeout(timer);
34807
+ state = "gone";
34808
+ unsubRtp?.();
34809
+ releaseWarm();
34810
+ },
34811
+ committed: () => state === "committed"
34812
+ };
34813
+ }
34814
+ //#endregion
34635
34815
  //#region src/stream-broker/webrtc/adaptive-tier.ts
34636
34816
  var RANK = {
34637
34817
  low: 1,
@@ -34811,6 +34991,107 @@ function adaptivePickLogLine(reason) {
34811
34991
  if (reason === "default-low") return "Adaptive source selected (no client hints — smallest available)";
34812
34992
  return "Adaptive source selected";
34813
34993
  }
34994
+ /**
34995
+ * One broker audio packet onto a session's push source — the per-session codec
34996
+ * branch (mirrors the live/recorded split). The broker fans out BOTH a
34997
+ * PCMU/PCMA stream (always) and, when an Opus session is attached, a parallel
34998
+ * `codec:'opus'` stream. Forward only the stream matching THIS session's
34999
+ * negotiated codec; drop the other so a PCMU-fallback session never sees Opus
35000
+ * and vice-versa. Shared by the session's first broker and by every broker an
35001
+ * in-place tier switch moves it to (D733).
35002
+ */
35003
+ function forwardBrokerAudio(packet, session, pushFrame) {
35004
+ const pktCodec = packet.codec?.toLowerCase();
35005
+ if (session?.isOpusAudio === true) {
35006
+ if (pktCodec !== "opus") return;
35007
+ pushFrame({
35008
+ type: "audio",
35009
+ frame: {
35010
+ data: packet.data,
35011
+ codec: "Opus",
35012
+ sampleRate: 48e3,
35013
+ channels: 2,
35014
+ timestampMicros: packet.pts * 1e3
35015
+ }
35016
+ });
35017
+ return;
35018
+ }
35019
+ if (pktCodec === "opus") return;
35020
+ pushFrame({
35021
+ type: "audio",
35022
+ frame: {
35023
+ data: packet.data,
35024
+ codec: pktCodec === "pcma" ? "Pcma" : "Pcmu",
35025
+ sampleRate: 8e3,
35026
+ channels: 1,
35027
+ timestampMicros: packet.pts * 1e3
35028
+ }
35029
+ });
35030
+ }
35031
+ /**
35032
+ * The `onVideoRtp` callback of an RTP-passthrough session: source RTP through
35033
+ * the codec repacketizer, unless a transcode owns the wire or recorded playback
35034
+ * owns the track. One implementation for the session's first broker and for
35035
+ * every broker an in-place tier switch moves it to (D733).
35036
+ */
35037
+ function makeSourceRtpForwarder(input) {
35038
+ const { session, sessionLogger, sessionId, deviceId, brokerId, sessionCodec } = input;
35039
+ let firstRtpForwarded = false;
35040
+ let gateDropLogged = false;
35041
+ let forwardAfterGateLogged = false;
35042
+ return (rtpData) => {
35043
+ if (session.needsTranscode) return;
35044
+ if (session.isLiveFeedGated) {
35045
+ if (!gateDropLogged) {
35046
+ gateDropLogged = true;
35047
+ forwardAfterGateLogged = false;
35048
+ sessionLogger.info("recorded: live RTP GATED (dropping live video)", {
35049
+ tags: { deviceId },
35050
+ meta: {
35051
+ sessionId,
35052
+ deviceId
35053
+ }
35054
+ });
35055
+ }
35056
+ return;
35057
+ }
35058
+ if (gateDropLogged && !forwardAfterGateLogged) {
35059
+ forwardAfterGateLogged = true;
35060
+ gateDropLogged = false;
35061
+ sessionLogger.info("recorded: live RTP UNGATED (forwarding live video again)", {
35062
+ tags: { deviceId },
35063
+ meta: {
35064
+ sessionId,
35065
+ deviceId
35066
+ }
35067
+ });
35068
+ }
35069
+ if (!firstRtpForwarded) {
35070
+ firstRtpForwarded = true;
35071
+ sessionLogger.info("RTP session: first source RTP forwarded to repacketizer", {
35072
+ tags: { deviceId },
35073
+ meta: {
35074
+ sessionId,
35075
+ brokerId,
35076
+ codec: sessionCodec,
35077
+ bytes: rtpData.length
35078
+ }
35079
+ });
35080
+ }
35081
+ try {
35082
+ session.forwardSourceRtpVideo(rtpData);
35083
+ } catch (err) {
35084
+ sessionLogger.warn("forwardSourceRtpVideo threw", {
35085
+ tags: { deviceId },
35086
+ meta: {
35087
+ sessionId,
35088
+ codec: sessionCodec,
35089
+ error: require_dist.errMsg(err)
35090
+ }
35091
+ });
35092
+ }
35093
+ };
35094
+ }
34814
35095
  /** Push-queue overflow purges are aggregated and reported at most this often. */
34815
35096
  var FRAME_DROP_LOG_INTERVAL_MS = 3e4;
34816
35097
  /**
@@ -35436,34 +35717,7 @@ var BrokerWebrtcServer = class {
35436
35717
  timestampMicros: packet.pts * 1e3
35437
35718
  }
35438
35719
  });
35439
- } else if (packet.type === "audio") {
35440
- const pktCodec = packet.codec?.toLowerCase();
35441
- if (session?.isOpusAudio === true) {
35442
- if (pktCodec !== "opus") return;
35443
- pushFrame({
35444
- type: "audio",
35445
- frame: {
35446
- data: packet.data,
35447
- codec: "Opus",
35448
- sampleRate: 48e3,
35449
- channels: 2,
35450
- timestampMicros: packet.pts * 1e3
35451
- }
35452
- });
35453
- } else {
35454
- if (pktCodec === "opus") return;
35455
- pushFrame({
35456
- type: "audio",
35457
- frame: {
35458
- data: packet.data,
35459
- codec: pktCodec === "pcma" ? "Pcma" : "Pcmu",
35460
- sampleRate: 8e3,
35461
- channels: 1,
35462
- timestampMicros: packet.pts * 1e3
35463
- }
35464
- });
35465
- }
35466
- }
35720
+ } else if (packet.type === "audio") forwardBrokerAudio(packet, session, pushFrame);
35467
35721
  }, annexbAttribution);
35468
35722
  warmUpUnsubscribe?.();
35469
35723
  this.brokerLinger.cancel(brokerId);
@@ -35584,49 +35838,14 @@ var BrokerWebrtcServer = class {
35584
35838
  }
35585
35839
  });
35586
35840
  }
35587
- let firstRtpForwarded = false;
35588
- let gateDropLogged = false;
35589
- let forwardAfterGateLogged = false;
35590
- unsubRtp = broker.onVideoRtp((rtpData) => {
35591
- if (session.needsTranscode) return;
35592
- if (session.isLiveFeedGated) {
35593
- if (!gateDropLogged) {
35594
- gateDropLogged = true;
35595
- forwardAfterGateLogged = false;
35596
- sessionLogger.info("recorded: live RTP GATED (dropping live video)", { meta: {
35597
- sessionId,
35598
- deviceId
35599
- } });
35600
- }
35601
- return;
35602
- }
35603
- if (gateDropLogged && !forwardAfterGateLogged) {
35604
- forwardAfterGateLogged = true;
35605
- gateDropLogged = false;
35606
- sessionLogger.info("recorded: live RTP UNGATED (forwarding live video again)", { meta: {
35607
- sessionId,
35608
- deviceId
35609
- } });
35610
- }
35611
- if (!firstRtpForwarded) {
35612
- firstRtpForwarded = true;
35613
- sessionLogger.info("RTP session: first source RTP forwarded to repacketizer", { meta: {
35614
- sessionId,
35615
- brokerId,
35616
- codec: sessionCodec,
35617
- bytes: rtpData.length
35618
- } });
35619
- }
35620
- try {
35621
- session.forwardSourceRtpVideo(rtpData);
35622
- } catch (err) {
35623
- sessionLogger.warn("forwardSourceRtpVideo threw", { meta: {
35624
- sessionId,
35625
- codec: sessionCodec,
35626
- error: require_dist.errMsg(err)
35627
- } });
35628
- }
35629
- }, rtpAttribution);
35841
+ unsubRtp = broker.onVideoRtp(makeSourceRtpForwarder({
35842
+ session,
35843
+ sessionLogger,
35844
+ sessionId,
35845
+ deviceId,
35846
+ brokerId,
35847
+ sessionCodec
35848
+ }), rtpAttribution);
35630
35849
  }
35631
35850
  const unsubSourceRestart = broker.onSourceRestart((reason) => {
35632
35851
  sessionLogger.info("broker re-dialed the source — re-syncing session", { meta: {
@@ -35652,7 +35871,17 @@ var BrokerWebrtcServer = class {
35652
35871
  unsubRtp,
35653
35872
  unsubSdpParams,
35654
35873
  unsubSourceRestart,
35655
- closeSource
35874
+ closeSource,
35875
+ rebind: {
35876
+ deviceId,
35877
+ sessionCodec,
35878
+ useRtpRepacketizer,
35879
+ offeredProfileLevelIds,
35880
+ hints,
35881
+ sessionLogger,
35882
+ pushFrame,
35883
+ baseAttribution
35884
+ }
35656
35885
  };
35657
35886
  this.sessions.set(sessionId, entry);
35658
35887
  session.onClosed = () => this.cleanupSession(sessionId);
@@ -36379,6 +36608,88 @@ var BrokerWebrtcServer = class {
36379
36608
  });
36380
36609
  controller.onRtt(rttMs, now);
36381
36610
  }
36611
+ /**
36612
+ * The viewer's downlink probe for ONE session (D733) — the server half of the
36613
+ * probe climb. The player used to answer its own probe with a second session
36614
+ * (a make-before-break reconnect carrying `downlinkMbps`); it now hands the
36615
+ * measurement to the session it already has, and the broker places that
36616
+ * session on the rung the probe picks, in place when the codec allows.
36617
+ *
36618
+ * The client sends a MEASUREMENT, never a tier: the pick is the same
36619
+ * `pickAdaptiveTier` an offer's hints get, against the same viewport the
36620
+ * offer carried, so a tile still ceilings to `low`. A probe only ever
36621
+ * raises the rung — lowering it is the ladder's job, on delivery evidence.
36622
+ */
36623
+ applyDownlinkProbe(sessionId, downlinkMbps) {
36624
+ const entry = this.sessions.get(sessionId);
36625
+ if (entry === void 0) return {
36626
+ outcome: "held",
36627
+ tier: null,
36628
+ reason: "no-session"
36629
+ };
36630
+ const deviceId = deviceIdFromBrokerId(entry.brokerId);
36631
+ const served = this.profileTierResolver?.(entry.brokerId) ?? null;
36632
+ const controller = entry.adaptiveController;
36633
+ const rebind = entry.rebind;
36634
+ const now = Date.now();
36635
+ const held = (reason) => {
36636
+ this.logger.info("adaptive: downlink probe holds the session where it is", {
36637
+ tags: { deviceId },
36638
+ meta: {
36639
+ sessionId,
36640
+ deviceId,
36641
+ downlinkMbps,
36642
+ tier: served,
36643
+ reason
36644
+ }
36645
+ });
36646
+ return {
36647
+ outcome: "held",
36648
+ tier: served,
36649
+ reason
36650
+ };
36651
+ };
36652
+ if (controller === void 0 || rebind === void 0) return held("not-adaptive");
36653
+ this.recordDeviceProbe(deviceId, probeFromHints(downlinkMbps, now));
36654
+ if (rebind.hints.prefersTier !== void 0) return held("pinned");
36655
+ if (entry.timeline?.currentMode === "recorded") return held("recorded");
36656
+ const pick = pickAdaptiveTier({
36657
+ available: preferLiveAdaptiveCandidates(this.deviceBrokerCandidates(deviceId)).map((c) => c.tier),
36658
+ downlinkMbps,
36659
+ viewportWidth: rebind.hints.viewportWidth,
36660
+ viewportHeight: rebind.hints.viewportHeight
36661
+ });
36662
+ const current = entry.pendingSwitch?.tier ?? controller.currentTier;
36663
+ if (require_dist.CAM_PROFILE_ORDER.indexOf(pick.tier) >= require_dist.CAM_PROFILE_ORDER.indexOf(current)) return held(pick.tier === current ? "already-there" : "probe-below-current");
36664
+ controller.settleOn(pick.tier, now);
36665
+ this.logger.info("adaptive: downlink probe places the session on a higher rung", {
36666
+ tags: { deviceId },
36667
+ meta: {
36668
+ sessionId,
36669
+ deviceId,
36670
+ downlinkMbps,
36671
+ from: current,
36672
+ tier: pick.tier,
36673
+ pick: pick.reason
36674
+ }
36675
+ });
36676
+ this.switchSessionTier(entry, deviceId, pick.tier, sessionId);
36677
+ return {
36678
+ outcome: "switching",
36679
+ tier: pick.tier,
36680
+ reason: pick.reason
36681
+ };
36682
+ }
36683
+ /** Every registered broker of `deviceId`, with the tier it currently serves. */
36684
+ deviceBrokerCandidates(deviceId) {
36685
+ const prefix = `${String(deviceId)}/`;
36686
+ const out = [];
36687
+ for (const bid of this.brokers.keys()) if (bid.startsWith(prefix)) out.push({
36688
+ brokerId: bid,
36689
+ tier: this.profileTierResolver?.(bid) ?? null
36690
+ });
36691
+ return out;
36692
+ }
36382
36693
  /** Adaptive sessions of `deviceId` currently on this broker server. */
36383
36694
  adaptiveSessionCount(deviceId) {
36384
36695
  const prefix = `${String(deviceId)}/`;
@@ -36391,6 +36702,322 @@ var BrokerWebrtcServer = class {
36391
36702
  this.deviceProbe.record(deviceId, probe);
36392
36703
  }
36393
36704
  /**
36705
+ * Move an adaptive session to `tier` — ON THE SESSION when it can (D733),
36706
+ * by the close-and-recreate re-offer when it cannot, and the refusal that
36707
+ * forced the re-offer is logged by name.
36708
+ *
36709
+ * A newer switch replaces one still waiting for its keyframe: the ladder's
36710
+ * latest verdict is the one that must land, and an abandoned wait leaves the
36711
+ * old source on the track (nothing froze).
36712
+ */
36713
+ switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false, decision) {
36714
+ entry.pendingSwitch?.cancel();
36715
+ entry.pendingSwitch = void 0;
36716
+ const why = decision?.cause ?? "probe";
36717
+ if (forceTranscodeDown) {
36718
+ this.logInPlaceFallback(entry, deviceId, tier, sessionId, "transcode-down", why);
36719
+ this.scheduleTierReoffer(entry, deviceId, tier, sessionId, true, decision);
36720
+ return;
36721
+ }
36722
+ this.switchTierInPlace(entry, deviceId, tier, sessionId, why).then((outcome) => {
36723
+ if (outcome.kind !== "refused") return;
36724
+ if (this.sessions.get(sessionId) !== entry) return;
36725
+ this.logInPlaceFallback(entry, deviceId, tier, sessionId, outcome.reason, why);
36726
+ this.scheduleTierReoffer(entry, deviceId, tier, sessionId, false, decision);
36727
+ }).catch((err) => {
36728
+ this.logger.warn("adaptive: in-place tier switch threw — falling back to re-offer", {
36729
+ tags: { deviceId },
36730
+ meta: {
36731
+ sessionId,
36732
+ deviceId,
36733
+ tier,
36734
+ error: require_dist.errMsg(err)
36735
+ }
36736
+ });
36737
+ if (this.sessions.get(sessionId) !== entry) return;
36738
+ this.scheduleTierReoffer(entry, deviceId, tier, sessionId, false, decision);
36739
+ });
36740
+ }
36741
+ logInPlaceFallback(entry, deviceId, tier, sessionId, reason, why) {
36742
+ this.logger.info("adaptive: tier switch needs a re-offer — cannot stay on the session", {
36743
+ tags: { deviceId },
36744
+ meta: {
36745
+ sessionId,
36746
+ deviceId,
36747
+ tier,
36748
+ fromBrokerId: entry.brokerId,
36749
+ reason,
36750
+ cause: why
36751
+ }
36752
+ });
36753
+ }
36754
+ /** What the session is now, for `judgeInPlaceSwitch`. */
36755
+ inPlaceSessionShape(entry, rebind) {
36756
+ return {
36757
+ recorded: entry.timeline?.currentMode === "recorded",
36758
+ forceTranscodeDown: false,
36759
+ useRtpRepacketizer: rebind.useRtpRepacketizer,
36760
+ needsTranscode: entry.session.needsTranscode,
36761
+ sessionCodec: rebind.sessionCodec,
36762
+ audioCodec: this.brokers.get(entry.brokerId)?.getStats().audio?.codec ?? null,
36763
+ usesOpusEgress: entry.releaseOpusEgress !== void 0
36764
+ };
36765
+ }
36766
+ /** What the target rendition is, as far as it is known now. */
36767
+ inPlaceTargetShape(rebind, landed, target) {
36768
+ if (target === void 0) return {
36769
+ tierMatches: false,
36770
+ isRtp: false,
36771
+ codec: null,
36772
+ needsBaselineTranscode: false,
36773
+ audioCodec: void 0,
36774
+ supportsOpusEgress: false
36775
+ };
36776
+ const stats = target.getStats();
36777
+ const codec = sessionCodecOf(stats.codec);
36778
+ let needsBaselineTranscode = false;
36779
+ if (codec === "H264") {
36780
+ const profile = deriveH264ProfileLevelId(target.getSdpParameterSets(), target.getPreBuffer());
36781
+ needsBaselineTranscode = rebind.offeredProfileLevelIds !== void 0 ? resolveClientOfferTranscodeToBaseline("H264", profile, rebind.offeredProfileLevelIds) : resolveBrokerTranscodeToBaseline("H264", profile);
36782
+ }
36783
+ return {
36784
+ tierMatches: landed !== null,
36785
+ isRtp: target.isRtpSource(),
36786
+ codec,
36787
+ needsBaselineTranscode,
36788
+ audioCodec: codec === null ? void 0 : stats.audio?.codec ?? null,
36789
+ supportsOpusEgress: supportsOpusEgress(target)
36790
+ };
36791
+ }
36792
+ /**
36793
+ * The in-place half of a tier switch (D733): resolve the tier's broker,
36794
+ * judge whether the negotiated session carries it as is, subscribe it
36795
+ * make-before-break and commit on its first switchable keyframe.
36796
+ */
36797
+ async switchTierInPlace(entry, deviceId, tier, sessionId, why) {
36798
+ const rebind = entry.rebind;
36799
+ if (rebind === void 0) return {
36800
+ kind: "refused",
36801
+ reason: "not-rtp-passthrough"
36802
+ };
36803
+ const early = judgeInPlaceSwitch(this.inPlaceSessionShape(entry, rebind), {
36804
+ tierMatches: true,
36805
+ isRtp: true,
36806
+ codec: null,
36807
+ needsBaselineTranscode: false,
36808
+ audioCodec: void 0,
36809
+ supportsOpusEgress: true
36810
+ });
36811
+ if (early !== null) return {
36812
+ kind: "refused",
36813
+ reason: early
36814
+ };
36815
+ try {
36816
+ await this.ensureAdaptiveTierBroker?.(deviceId, tier);
36817
+ } catch (err) {
36818
+ this.logger.warn("adaptive: could not prepare the tier broker for an in-place switch", {
36819
+ tags: { deviceId },
36820
+ meta: {
36821
+ sessionId,
36822
+ deviceId,
36823
+ tier,
36824
+ error: require_dist.errMsg(err)
36825
+ }
36826
+ });
36827
+ return {
36828
+ kind: "refused",
36829
+ reason: "tier-unavailable"
36830
+ };
36831
+ }
36832
+ if (this.sessions.get(sessionId) !== entry) return { kind: "abandoned" };
36833
+ const targetId = this.selectBestBroker(String(deviceId), { prefersTier: tier });
36834
+ const target = this.brokers.get(targetId);
36835
+ const landed = this.profileTierResolver?.(targetId) ?? null;
36836
+ if (target !== void 0 && targetId === entry.brokerId) {
36837
+ if (landed !== null) entry.adaptiveController?.settleOn(landed, Date.now());
36838
+ rebind.sessionLogger.info("adaptive: wanted tier is not served — the session stays on its rung", {
36839
+ tags: { deviceId },
36840
+ meta: {
36841
+ sessionId,
36842
+ deviceId,
36843
+ tier,
36844
+ landed,
36845
+ brokerId: targetId,
36846
+ cause: why
36847
+ }
36848
+ });
36849
+ return { kind: "committed" };
36850
+ }
36851
+ const judge = () => judgeInPlaceSwitch(this.inPlaceSessionShape(entry, rebind), this.inPlaceTargetShape(rebind, landed, target));
36852
+ const refusal = judge();
36853
+ if (refusal !== null || target === void 0) return {
36854
+ kind: "refused",
36855
+ reason: refusal ?? "tier-unavailable"
36856
+ };
36857
+ const fromBrokerId = entry.brokerId;
36858
+ const startedAt = Date.now();
36859
+ rebind.sessionLogger.info("adaptive: tier switch staying on the session — waiting for a keyframe", {
36860
+ tags: { deviceId },
36861
+ meta: {
36862
+ sessionId,
36863
+ deviceId,
36864
+ tier,
36865
+ fromBrokerId,
36866
+ toBrokerId: targetId,
36867
+ cause: why
36868
+ }
36869
+ });
36870
+ return new Promise((resolve) => {
36871
+ const forward = makeSourceRtpForwarder({
36872
+ session: entry.session,
36873
+ sessionLogger: rebind.sessionLogger,
36874
+ sessionId,
36875
+ deviceId,
36876
+ brokerId: targetId,
36877
+ sessionCodec: rebind.sessionCodec
36878
+ });
36879
+ const pending = bindPendingSource({
36880
+ broker: target,
36881
+ attribution: {
36882
+ ...rebind.baseAttribution,
36883
+ media: "video",
36884
+ targetCodec: rebind.sessionCodec,
36885
+ transport: "repacketize"
36886
+ },
36887
+ timeoutMs: IN_PLACE_SWITCH_KEYFRAME_WAIT_MS,
36888
+ commit: () => {
36889
+ if (this.sessions.get(sessionId) !== entry || entry.pendingSwitch !== handle) {
36890
+ resolve({ kind: "abandoned" });
36891
+ return false;
36892
+ }
36893
+ entry.pendingSwitch = void 0;
36894
+ const late = judge();
36895
+ if (late !== null) {
36896
+ resolve({
36897
+ kind: "refused",
36898
+ reason: late
36899
+ });
36900
+ return false;
36901
+ }
36902
+ if (landed !== null && landed !== tier) entry.adaptiveController?.settleOn(landed, Date.now());
36903
+ this.commitInPlaceSwitch(entry, rebind, {
36904
+ sessionId,
36905
+ tier: landed ?? tier,
36906
+ targetId,
36907
+ target,
36908
+ unsubRtp: pending.unsubscribe,
36909
+ waitedMs: Date.now() - startedAt,
36910
+ cause: why
36911
+ });
36912
+ resolve({ kind: "committed" });
36913
+ return true;
36914
+ },
36915
+ forward,
36916
+ onTimeout: () => {
36917
+ if (entry.pendingSwitch === handle) entry.pendingSwitch = void 0;
36918
+ resolve({
36919
+ kind: "refused",
36920
+ reason: "target-no-keyframe"
36921
+ });
36922
+ }
36923
+ });
36924
+ const handle = {
36925
+ tier,
36926
+ cancel: () => {
36927
+ pending.unsubscribe();
36928
+ resolve({ kind: "abandoned" });
36929
+ }
36930
+ };
36931
+ entry.pendingSwitch = handle;
36932
+ });
36933
+ }
36934
+ /**
36935
+ * Swap every subscription of `entry` onto `target` in one synchronous step,
36936
+ * inside the delivery of the target's committing packet: nothing from the old
36937
+ * broker can reach the track after this returns, and the target's next
36938
+ * forward replays its GOP from the keyframe onto a rebased RTP stream.
36939
+ */
36940
+ commitInPlaceSwitch(entry, rebind, sw) {
36941
+ const { session } = entry;
36942
+ const { deviceId, sessionLogger } = rebind;
36943
+ const old = {
36944
+ brokerId: entry.brokerId,
36945
+ unsubBroker: entry.unsubBroker,
36946
+ unsubRtp: entry.unsubRtp,
36947
+ unsubSdpParams: entry.unsubSdpParams,
36948
+ unsubSourceRestart: entry.unsubSourceRestart,
36949
+ releaseOpusEgress: entry.releaseOpusEgress
36950
+ };
36951
+ const target = sw.target;
36952
+ entry.unsubBroker = target.onEncodedData((packet) => {
36953
+ if (packet.type === "audio") forwardBrokerAudio(packet, session, rebind.pushFrame);
36954
+ }, {
36955
+ ...rebind.baseAttribution,
36956
+ media: "audio",
36957
+ transport: "passthrough"
36958
+ });
36959
+ entry.unsubRtp = sw.unsubRtp;
36960
+ if (old.releaseOpusEgress !== void 0 && supportsOpusEgress(target)) {
36961
+ target.acquireOpusEgress();
36962
+ entry.releaseOpusEgress = () => target.releaseOpusEgress();
36963
+ } else entry.releaseOpusEgress = void 0;
36964
+ const parameterSets = target.getSdpParameterSets();
36965
+ session.switchLiveSource({
36966
+ rtpBootstrap: () => target.getRtpPreBuffer(),
36967
+ parameterSets,
36968
+ reason: `tier ${sw.tier} (${sw.cause})`
36969
+ });
36970
+ entry.unsubSdpParams = parameterSets !== null && parameterSets.length > 0 ? null : target.onSdpParameterSets((ps) => {
36971
+ if (rebind.sessionCodec === "H265") session.seedH265CodecInfoFromSdp(ps);
36972
+ else session.seedH264CodecInfoFromSdp(ps);
36973
+ });
36974
+ entry.unsubSourceRestart = target.onSourceRestart((reason) => {
36975
+ sessionLogger.info("broker re-dialed the source — re-syncing session", {
36976
+ tags: { deviceId },
36977
+ meta: {
36978
+ sessionId: sw.sessionId,
36979
+ brokerId: sw.targetId,
36980
+ deviceId,
36981
+ reason
36982
+ }
36983
+ });
36984
+ session.onSourceRestarted(reason);
36985
+ });
36986
+ entry.brokerId = sw.targetId;
36987
+ old.unsubRtp?.();
36988
+ old.unsubBroker();
36989
+ old.unsubSdpParams?.();
36990
+ old.unsubSourceRestart();
36991
+ old.releaseOpusEgress?.();
36992
+ this.brokerLinger.cancel(sw.targetId);
36993
+ let oldStillServed = false;
36994
+ for (const other of this.sessions.values()) if (other.brokerId === old.brokerId) {
36995
+ oldStillServed = true;
36996
+ break;
36997
+ }
36998
+ if (!oldStillServed && !this.stopped) this.brokerLinger.hold(old.brokerId);
36999
+ const pair = session.readNominatedPair();
37000
+ if (pair !== null) {
37001
+ const remoteAddr = pair.remoteType === "host" ? pair.remoteAddr : `${pair.remoteAddr} (${pair.remoteType})`;
37002
+ target.updateConsumerAttributionBySession(sw.sessionId, { remoteAddr });
37003
+ }
37004
+ entry.timeline?.setLiveProfile(sw.tier);
37005
+ sessionLogger.info("adaptive: tier switched in place — same session, same connection", {
37006
+ tags: { deviceId },
37007
+ meta: {
37008
+ sessionId: sw.sessionId,
37009
+ deviceId,
37010
+ tier: sw.tier,
37011
+ fromBrokerId: old.brokerId,
37012
+ toBrokerId: sw.targetId,
37013
+ codec: rebind.sessionCodec,
37014
+ waitedMs: sw.waitedMs,
37015
+ cause: sw.cause,
37016
+ replayPackets: target.getRtpPreBuffer().length
37017
+ }
37018
+ });
37019
+ }
37020
+ /**
36394
37021
  * Schedule an adaptive tier switch via a close-and-recreate re-offer.
36395
37022
  *
36396
37023
  * Records the controller's desired tier (+ forced transcode-down) in
@@ -36403,7 +37030,7 @@ var BrokerWebrtcServer = class {
36403
37030
  * consumes the recorded intent to pin the recreated session to `tier` and
36404
37031
  * apply the reduced transcode profile when `forceTranscodeDown` is set.
36405
37032
  */
36406
- switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false, decision) {
37033
+ scheduleTierReoffer(entry, deviceId, tier, sessionId, forceTranscodeDown = false, decision) {
36407
37034
  const prev = this.adaptiveIntent.get(sessionId);
36408
37035
  const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
36409
37036
  const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
@@ -36451,6 +37078,8 @@ var BrokerWebrtcServer = class {
36451
37078
  const entry = this.sessions.get(sessionId);
36452
37079
  if (!entry) return;
36453
37080
  this.sessions.delete(sessionId);
37081
+ entry.pendingSwitch?.cancel();
37082
+ entry.pendingSwitch = void 0;
36454
37083
  let brokerStillServed = false;
36455
37084
  for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
36456
37085
  brokerStillServed = true;
@@ -37110,6 +37739,10 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
37110
37739
  async getSessionState(input) {
37111
37740
  return this.webrtcServer.getSessionRenegotiationState(input.sessionId);
37112
37741
  }
37742
+ /** The viewer's downlink probe for one session (D733) — see the cap's docblock. */
37743
+ async applyDownlinkProbe(input) {
37744
+ return this.webrtcServer.applyDownlinkProbe(input.sessionId, input.downlinkMbps);
37745
+ }
37113
37746
  /**
37114
37747
  * The SERVER half of the stream-debug overlay. Thin facade — the gathering
37115
37748
  * lives in the WebRTC server and the shaping in the pure