@camstack/addon-pipeline 1.2.288 → 1.2.291

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-DTvsx_iX.mjs → default-detection-model-DcTy-nJl.mjs} +1 -1
  4. package/dist/{default-detection-model-D5kMByfZ.js → default-detection-model-jjdmKKr3.js} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-mEtwupOP.mjs → dist-Bn6BQW8q.mjs} +1692 -920
  8. package/dist/{dist-CegiJ2Pw.js → dist-D7FonEUB.js} +1709 -919
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-B8p-rV9H.mjs → node-BN0Na3HN.mjs} +1 -1
  12. package/dist/{node-C8jhiPW2.js → node-D3C1krEi.js} +1 -1
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-C5bRH8-t.mjs → process-memory-CeZ8ms80.mjs} +1 -1
  16. package/dist/{process-memory-lPbJ1ta2.js → process-memory-DI4jrCtX.js} +1 -1
  17. package/dist/recorder/index.js +7432 -7264
  18. package/dist/recorder/index.mjs +7432 -7264
  19. package/dist/{segment-demux-js-BP5vqRPY.mjs → segment-demux-js-CNPzlcyO.mjs} +1 -1
  20. package/dist/{segment-demux-js-Dv1EPPdH.js → segment-demux-js-COooJ-t0.js} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BqjkSRCu.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BrD7TK3W.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-pSFdAaR0.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-DhImOdbA.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-C4EuFQ_w.mjs → hostInit-CbSGtrCj.mjs} +2 -2
  28. package/dist/stream-broker/index.js +1465 -133
  29. package/dist/stream-broker/index.mjs +1465 -133
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +5 -3
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqcT4fRG.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-DCbVs3IC.mjs +0 -26
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $t as buildFfmpegArgs$1, B as addonWidgetsSourceCapability, Cn as array, Dn as literal, G as cameraStreamsCapability, Gt as streamBrokerCapability, I as RingBuffer, J as createHwAccelCache, M as RATE_CONTROL_TIGHT, Mn as string, Mt as rankStreams, Nn as union, On as number, Pn as EventCategory, Q as deriveBatteryPresence, Rt as resolveEgressDecodeHwAccel, Sn as _enum, Tn as discriminatedUnion, W as batteryCapability, X as declareLogChannel, Xt as errMsg, Y as createLogChannelsProvider, Yt as webrtcSessionCapability, Zt as AUDIO_PRESETS, _n as nodePin, a as CLIP_BROKER_PACED_RATES, an as CamProfileSchema, en as invocationFromEncodeProfile, fn as expandAudioChunkToF32le, gn as makeSourceBrokerId, gt as logChannelsCapability, hn as makeProfileBrokerId, ht as loadContributionCapability, i as BatteryStatusSchema, in as CAM_PROFILE_ORDER, j as RATE_CONTROL_RELAXED, jn as record, kn as object, ln as createEvent, mn as isEvent, nn as BOOT_RECOVERY_BACKOFF_MS, nt as egressTranscodeSharingKey, on as DeviceFeature, q as clipExportFailureOf, rn as BaseAddon, rt as egressTransportFromRequest, sn as DeviceType, tn as isSoftwareDecode, vt as maskUrlCredentials, w as G711_SCALE_CORRECTION_DB, wn as boolean, x as EncodeProfileSchema, yn as parseProfileBrokerId } from "../dist-mEtwupOP.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-B8p-rV9H.mjs";
2
+ import { $t as errMsg, An as literal, Dn as boolean, En as array, Fn as string, H as addonWidgetsSourceCapability, In as union, Jt as streamBrokerCapability, K as batteryCapability, Ln as EventCategory, M as RATE_CONTROL_TIGHT, Mn as object, On as discriminatedUnion, Pn as record, Pt as rankStreams, Q as declareLogChannel, Qt as webrtcSessionCapability, R as RingBuffer, Sn as parseProfileBrokerId, Tn as _enum, Vt as resolveEgressDecodeHwAccel, X as createHwAccelCache, Y as clipExportFailureOf, Z as createLogChannelsProvider, _n as isEvent, _t as loadContributionCapability, a as CLIP_BROKER_PACED_RATES, an as BOOT_RECOVERY_BACKOFF_MS, at as egressTransportFromRequest, bn as nodePin, bt as maskUrlCredentials, cn as CamProfileSchema, en as AUDIO_PRESETS, et as deriveBatteryPresence, fn as createEvent, hn as expandAudioChunkToF32le, i as BatteryStatusSchema, in as isSoftwareDecode, it as egressTranscodeSharingKey, j as RATE_CONTROL_RELAXED, jn as number, ln as DeviceFeature, nn as buildFfmpegArgs$1, o as CLIP_STILL_REASON_CODE_HEADER, on as BaseAddon, q as cameraStreamsCapability, rn as invocationFromEncodeProfile, sn as CAM_PROFILE_ORDER, un as DeviceType, vn as makeProfileBrokerId, vt as logChannelsCapability, w as G711_SCALE_CORRECTION_DB, x as EncodeProfileSchema, yn as makeSourceBrokerId } from "../dist-Bn6BQW8q.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-BN0Na3HN.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-BP5vqRPY.mjs";
7
+ import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-CNPzlcyO.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -23177,6 +23177,246 @@ var H264Repacketizer = class {
23177
23177
  }
23178
23178
  };
23179
23179
  //#endregion
23180
+ //#region src/stream-broker/webrtc/delivery-meter.ts
23181
+ /** The window the ladder judges delivery over: at least one GOP. */
23182
+ function deliveryWindowMs(idrIntervalMs, config) {
23183
+ const gop = idrIntervalMs !== null && Number.isFinite(idrIntervalMs) && idrIntervalMs > 0 ? idrIntervalMs : 0;
23184
+ return Math.min(config.maxWindowMs, Math.max(config.minWindowMs, gop));
23185
+ }
23186
+ /**
23187
+ * When a session's meter may start counting client reports. A session created
23188
+ * by a tier change starts after the change blackout (review I1): until the
23189
+ * client swaps its player onto the new PC (after the new stream's first
23190
+ * decode, 2–5 s), its reporter still reads the OLD PC — 0 kbps — and the
23191
+ * first report after the swap is 0 too; both fall inside the new session's
23192
+ * sends and read as a failed rung.
23193
+ */
23194
+ function deliveryMeterStart(now, afterTierChange, waitAfterChangeMs) {
23195
+ return afterTierChange ? now + waitAfterChangeMs : now;
23196
+ }
23197
+ /**
23198
+ * Bucket width. 100 ms, and edges round to the NEAREST boundary, so a frame is
23199
+ * put on the wrong side of a window edge only when it was sent within 50 ms of
23200
+ * it — about one frame at 25 fps. Rounding edges inward instead dropped a whole
23201
+ * IDR that sat 160 ms inside a window and read a whole rung as 1.5× delivered.
23202
+ * 32 s of history is 320 slots.
23203
+ */
23204
+ var SEND_BUCKET_MS = 100;
23205
+ /**
23206
+ * Bytes this session sent, bucketed by time. A fixed ring: `record` is on the
23207
+ * RTP send path, so it allocates nothing.
23208
+ */
23209
+ var SendHistory = class {
23210
+ bytes;
23211
+ /** The bucket index each slot currently holds; -1 = empty. */
23212
+ ids;
23213
+ constructor(spanMs) {
23214
+ const slots = Math.max(1, Math.ceil(spanMs / SEND_BUCKET_MS));
23215
+ this.bytes = new Float64Array(slots);
23216
+ this.ids = new Float64Array(slots).fill(-1);
23217
+ }
23218
+ record(byteCount, now) {
23219
+ const id = Math.floor(now / SEND_BUCKET_MS);
23220
+ const slot = id % this.ids.length;
23221
+ if (this.ids[slot] !== id) {
23222
+ this.ids[slot] = id;
23223
+ this.bytes[slot] = 0;
23224
+ }
23225
+ this.bytes[slot] = (this.bytes[slot] ?? 0) + byteCount;
23226
+ }
23227
+ bitsBetween(fromMs, toMs) {
23228
+ const first = Math.round(fromMs / SEND_BUCKET_MS);
23229
+ const last = Math.round(toMs / SEND_BUCKET_MS) - 1;
23230
+ let bytes = 0;
23231
+ for (let id = first; id <= last; id += 1) {
23232
+ const slot = id % this.ids.length;
23233
+ if (this.ids[slot] === id) bytes += this.bytes[slot] ?? 0;
23234
+ }
23235
+ return bytes * 8;
23236
+ }
23237
+ };
23238
+ /**
23239
+ * The client's recent reports, and the ratio they imply against a
23240
+ * {@link SendHistoryReader}. One meter per adaptive session: `notBefore` is
23241
+ * the session's start, because the reports are DEVICE-scoped and a report
23242
+ * about time before this session describes the previous one (a re-offer).
23243
+ */
23244
+ var DeliveryMeter = class {
23245
+ config;
23246
+ notBefore;
23247
+ reports = [];
23248
+ constructor(config, notBefore) {
23249
+ this.config = config;
23250
+ this.notBefore = notBefore;
23251
+ }
23252
+ /** Fold one client report. The same report read on several ticks counts once. */
23253
+ observe(kbps, endAt) {
23254
+ if (!Number.isFinite(kbps) || kbps < 0 || !Number.isFinite(endAt)) return;
23255
+ if (endAt - this.config.clientSampleSpanMs < this.notBefore) return;
23256
+ if (this.reports.some((r) => r.endAt === endAt)) return;
23257
+ const horizon = endAt - this.config.historySpanMs;
23258
+ this.reports = [...this.reports.filter((r) => r.endAt > horizon), {
23259
+ kbps,
23260
+ endAt
23261
+ }].sort((a, b) => a.endAt - b.endAt);
23262
+ }
23263
+ /**
23264
+ * Is the client reporting at all? A fresh report that does not yet cover a
23265
+ * whole window is "not measured YET" — different from a client that sends
23266
+ * no telemetry, and it must not be climbed on (a 10 %-delivery link would
23267
+ * otherwise promote in the seconds before its first window closes).
23268
+ */
23269
+ isReporting(now) {
23270
+ const newest = this.reports.at(-1);
23271
+ return newest !== void 0 && now - newest.endAt <= this.config.maxClientSampleAgeMs;
23272
+ }
23273
+ /**
23274
+ * The newest report alone against the same 2 s of sends — the short window
23275
+ * a total freeze is judged on. Null when there is none, or it is stale.
23276
+ */
23277
+ evaluateNewest(history, now) {
23278
+ const newest = this.reports.at(-1);
23279
+ if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
23280
+ const span = this.config.clientSampleSpanMs;
23281
+ return {
23282
+ receivedKbps: newest.kbps,
23283
+ sentKbps: history.bitsBetween(newest.endAt - span, newest.endAt) / span,
23284
+ windowMs: span
23285
+ };
23286
+ }
23287
+ /**
23288
+ * Received vs sent over the newest reports that together cover `windowMs`,
23289
+ * or `null` when they do not (yet) — or the newest one is stale.
23290
+ */
23291
+ evaluate(history, windowMs, now) {
23292
+ const newest = this.reports.at(-1);
23293
+ if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
23294
+ const span = this.config.clientSampleSpanMs;
23295
+ let covered = 0;
23296
+ let receivedBits = 0;
23297
+ let sentBits = 0;
23298
+ for (let i = this.reports.length - 1; i >= 0 && covered < windowMs; i -= 1) {
23299
+ const r = this.reports[i];
23300
+ if (r === void 0 || now - (r.endAt - span) > this.config.historySpanMs) break;
23301
+ covered += span;
23302
+ receivedBits += r.kbps * span;
23303
+ sentBits += history.bitsBetween(r.endAt - span, r.endAt);
23304
+ }
23305
+ if (covered < windowMs) return null;
23306
+ return {
23307
+ receivedKbps: receivedBits / covered,
23308
+ sentKbps: sentBits / covered,
23309
+ windowMs: covered
23310
+ };
23311
+ }
23312
+ };
23313
+ //#endregion
23314
+ //#region src/stream-broker/webrtc/adaptive-config.ts
23315
+ /** How often the adaptive controller re-evaluates the ladder (ms). */
23316
+ var ADAPTIVE_TICK_MS = 2e3;
23317
+ /** Ladder tuning shared by every adaptive session. */
23318
+ var ADAPTIVE_CONFIG = {
23319
+ analyzeWindowMs: 15e3,
23320
+ minFailPercent: .05,
23321
+ waitAfterResetMs: 8e3,
23322
+ upgradeHealthyMs: 4e3,
23323
+ upgradeHealthyGrowthFactor: 3,
23324
+ maxProgressiveUpgradeHealthyMs: 3e4,
23325
+ minTimeBetweenChangesMs: 4e3,
23326
+ maxUpgradeBackoffMs: 9e5,
23327
+ stallDeliveryRatio: .25,
23328
+ recoverDeliveryRatio: .5,
23329
+ freezeDeliveryRatio: .1,
23330
+ freezeMinSentKbps: 16,
23331
+ climbDeliveryRatio: .9,
23332
+ stallConfirmMs: 6e3,
23333
+ waitAfterChangeMs: 8e3,
23334
+ climbCapacityMargin: 1.2,
23335
+ tryClimbDeliveryRatio: .95,
23336
+ tryClimbMaxLossRatio: .01,
23337
+ tryBackoffBaseMs: 3e4,
23338
+ tryBackoffMaxMs: 6e5,
23339
+ trialMs: 2e4,
23340
+ trialMaxMs: 4e4,
23341
+ trialRevertRatio: .9,
23342
+ trialRevertConsecutive: 2
23343
+ };
23344
+ /** How the delivery window is built from the client's reports. */
23345
+ var ADAPTIVE_DELIVERY_CONFIG = {
23346
+ clientSampleSpanMs: 2e3,
23347
+ minWindowMs: 2e3,
23348
+ maxWindowMs: 16e3,
23349
+ maxClientSampleAgeMs: 1e4,
23350
+ historySpanMs: 32e3
23351
+ };
23352
+ //#endregion
23353
+ //#region src/stream-broker/webrtc/climb-capacity.ts
23354
+ /** The client ladder's thresholds (`DOWNLINK_MID_MBPS`, `DOWNLINK_HIGH_MBPS`), kbps. */
23355
+ var UNMEASURED_RUNG_BAR_KBPS = {
23356
+ low: 0,
23357
+ mid: 2500,
23358
+ high: 8e3
23359
+ };
23360
+ /** The kbps the next rung needs; `(1800 × 1.2)` or the client threshold. */
23361
+ function capacityBarKbps(tier, rungKbps, margin) {
23362
+ return rungKbps !== null && rungKbps > 0 ? rungKbps * margin : UNMEASURED_RUNG_BAR_KBPS[tier];
23363
+ }
23364
+ function known(v) {
23365
+ return v !== null && Number.isFinite(v) && v > 0;
23366
+ }
23367
+ function judgeCapacity(evidence, nextTier, nextRungKbps, margin) {
23368
+ const neededKbps = capacityBarKbps(nextTier, nextRungKbps, margin);
23369
+ const { rembKbps, probeKbps, probeFresh } = evidence;
23370
+ if (known(probeKbps) && probeFresh && probeKbps >= neededKbps) return {
23371
+ kind: "sufficient",
23372
+ source: "probe",
23373
+ kbps: probeKbps,
23374
+ neededKbps
23375
+ };
23376
+ if (known(rembKbps) && rembKbps >= neededKbps) return {
23377
+ kind: "sufficient",
23378
+ source: "remb",
23379
+ kbps: rembKbps,
23380
+ neededKbps
23381
+ };
23382
+ if (known(probeKbps) && probeKbps < neededKbps) return {
23383
+ kind: "probe-short",
23384
+ kbps: probeKbps,
23385
+ neededKbps
23386
+ };
23387
+ return {
23388
+ kind: "none",
23389
+ bestKbps: known(rembKbps) ? rembKbps : null,
23390
+ neededKbps
23391
+ };
23392
+ }
23393
+ /**
23394
+ * REMB as it can be trusted. werift stores the LAST received estimate on the
23395
+ * sender and never ages it, so a peer that stopped sending REMB (backgrounded,
23396
+ * gone) would vouch for room indefinitely (review m5). Chrome re-estimates
23397
+ * about once a second and the value moves; an estimate unchanged for
23398
+ * `staleMs` (30 000 ms — fifteen ticks) is treated as no estimate at all.
23399
+ */
23400
+ var RembWatch = class {
23401
+ staleMs;
23402
+ last = null;
23403
+ changedAt = 0;
23404
+ constructor(staleMs) {
23405
+ this.staleMs = staleMs;
23406
+ }
23407
+ read(kbps, now) {
23408
+ if (kbps === null) {
23409
+ this.last = null;
23410
+ return null;
23411
+ }
23412
+ if (kbps !== this.last) {
23413
+ this.last = kbps;
23414
+ this.changedAt = now;
23415
+ }
23416
+ return now - this.changedAt > this.staleMs ? null : kbps;
23417
+ }
23418
+ };
23419
+ //#endregion
23180
23420
  //#region src/stream-broker/webrtc/transcode-egress-leg.ts
23181
23421
  /**
23182
23422
  * The request a browser session makes. Structured only: the primitive's schema
@@ -23291,6 +23531,8 @@ function selectAudioClock(mimeType) {
23291
23531
  *
23292
23532
  * Uses werift (optional peer dependency) for server-side WebRTC.
23293
23533
  */
23534
+ /** A REMB unchanged this long is no estimate (`RembWatch`). */
23535
+ var REMB_STALE_MS = 3e4;
23294
23536
  var _werift;
23295
23537
  async function loadWerift() {
23296
23538
  if (_werift) return _werift;
@@ -24812,6 +25054,7 @@ var AdaptiveSession = class AdaptiveSession {
24812
25054
  this.maybeRebaseRtp(pkt.header.sequenceNumber, pkt.header.timestamp);
24813
25055
  try {
24814
25056
  this.videoSender.sendRtp(pkt);
25057
+ this.videoSendHistory.record(pkt.payload.length, Date.now());
24815
25058
  this.lastVideoRtpTs = pkt.header.timestamp >>> 0;
24816
25059
  if (this.firstVideoRtpTs === null) {
24817
25060
  this.firstVideoRtpTs = this.lastVideoRtpTs;
@@ -25383,6 +25626,26 @@ var AdaptiveSession = class AdaptiveSession {
25383
25626
  get rtpPacketsSentTotal() {
25384
25627
  return this.rtpPacketsSent;
25385
25628
  }
25629
+ /**
25630
+ * Video RTP PAYLOAD bytes this session sent, bucketed by time — the "sent"
25631
+ * half of the adaptive ladder's delivery ratio (D635). Payload, not wire
25632
+ * size, because the client's `bytesReceived` excludes RTP headers.
25633
+ */
25634
+ videoSendHistory = new SendHistory(ADAPTIVE_DELIVERY_CONFIG.historySpanMs);
25635
+ get sentVideoHistory() {
25636
+ return this.videoSendHistory;
25637
+ }
25638
+ /**
25639
+ * The receiver's bandwidth estimate (REMB) in kbps, or `null` when the peer
25640
+ * has sent none. werift stores the last REMB on the sender; `-1`/absent in
25641
+ * the loss line means the same "no estimate", which is not "zero".
25642
+ */
25643
+ receiverEstimateKbps(now) {
25644
+ const raw = this.videoSender?.receiverEstimatedMaxBitrate;
25645
+ return this.rembWatch.read(typeof raw === "bigint" && raw > 0n ? Number(raw) / 1e3 : null, now);
25646
+ }
25647
+ /** Ages the REMB werift keeps forever — see `RembWatch`. */
25648
+ rembWatch = new RembWatch(REMB_STALE_MS);
25386
25649
  rtpLogCounter = 0;
25387
25650
  /** One-shot guards for the "first video/audio RTP" debug markers. */
25388
25651
  firstVideoRtpLogged = false;
@@ -25705,6 +25968,7 @@ var AdaptiveSession = class AdaptiveSession {
25705
25968
  } });
25706
25969
  }
25707
25970
  this.videoSender.sendRtp(pkt);
25971
+ this.videoSendHistory.record(payload.length, Date.now());
25708
25972
  this.rtpPacketsSent++;
25709
25973
  this.logFirstVideoRtp("writeVideoNals", codec);
25710
25974
  if (this.debug && (this.rtpPacketsSent === 1 || this.rtpPacketsSent % 500 === 0)) this.logger.info("RTP packets sent", { meta: {
@@ -26172,6 +26436,42 @@ var LossTracker = class {
26172
26436
  //#endregion
26173
26437
  //#region src/stream-broker/webrtc/stall-tracker.ts
26174
26438
  /**
26439
+ * The one place the device-scoped telemetry is judged ownable. With more than
26440
+ * one adaptive session of the device, the record may be another viewer's:
26441
+ * delivery is unknown ("no telemetry" semantics — the loss path judges) and
26442
+ * the sample says so, so the ladder refuses a try-climb on it.
26443
+ */
26444
+ function buildStallSample(input) {
26445
+ if (input.adaptiveSessionsOnDevice > 1) return {
26446
+ delivery: null,
26447
+ recent: null,
26448
+ clientReporting: false,
26449
+ telemetryShared: true,
26450
+ serverSending: input.serverSending
26451
+ };
26452
+ return {
26453
+ delivery: input.delivery,
26454
+ recent: input.recent,
26455
+ clientReporting: input.clientReporting,
26456
+ telemetryShared: false,
26457
+ serverSending: input.serverSending
26458
+ };
26459
+ }
26460
+ var UNKNOWN_EVIDENCE = {
26461
+ clientKbps: null,
26462
+ sentKbps: null,
26463
+ deliveryRatio: null,
26464
+ deliveryWindowMs: null,
26465
+ stalledForMs: 0,
26466
+ serverSending: false
26467
+ };
26468
+ function ratioOf(delivery) {
26469
+ if (delivery === null) return null;
26470
+ const { receivedKbps, sentKbps } = delivery;
26471
+ if (!Number.isFinite(receivedKbps) || !Number.isFinite(sentKbps) || sentKbps <= 0) return null;
26472
+ return receivedKbps / sentKbps;
26473
+ }
26474
+ /**
26175
26475
  * Windowed "the media stopped arriving" detector. Pure state machine: no
26176
26476
  * clock of its own, no side effects — `observe` is the only mutator and every
26177
26477
  * decision is a function of the samples it was given.
@@ -26181,11 +26481,9 @@ var StallTracker = class {
26181
26481
  /** When the current uninterrupted stall began; null when not stalling. */
26182
26482
  stallStartedAt = null;
26183
26483
  blackoutUntil = 0;
26184
- lastEvidence = {
26185
- clientKbps: null,
26186
- stalledForMs: 0,
26187
- serverSending: false
26188
- };
26484
+ lastEvidence = UNKNOWN_EVIDENCE;
26485
+ clientReporting = false;
26486
+ telemetryShared = false;
26189
26487
  constructor(config) {
26190
26488
  this.config = config;
26191
26489
  }
@@ -26198,38 +26496,36 @@ var StallTracker = class {
26198
26496
  * Fold one evaluation into the window. Returns nothing; ask `isStalled`.
26199
26497
  *
26200
26498
  * Three outcomes, and the middle one is the reason this is not a simple
26201
- * boolean: a rate at or above `recoverKbpsFloor` CLEARS the stall, a rate
26202
- * below `stallKbpsFloor` starts or extends it, and anything between the two
26203
- * (or unknown, or with the server idle) HOLDS whatever the window already
26204
- * believed. Holding is what stops a borderline link from oscillating.
26499
+ * boolean: a ratio at or above `recoverDeliveryRatio` CLEARS the stall, a
26500
+ * ratio below `stallDeliveryRatio` with the server sending starts or extends
26501
+ * it, and anything between the two (or unknown, or nothing sent) HOLDS
26502
+ * whatever the window already believed.
26205
26503
  */
26206
26504
  observe(sample, now) {
26505
+ const ratio = ratioOf(sample.delivery);
26506
+ const measured = (stalledForMs) => ({
26507
+ clientKbps: sample.delivery?.receivedKbps ?? null,
26508
+ sentKbps: sample.delivery?.sentKbps ?? null,
26509
+ deliveryRatio: ratio,
26510
+ deliveryWindowMs: sample.delivery?.windowMs ?? null,
26511
+ stalledForMs,
26512
+ serverSending: sample.serverSending
26513
+ });
26514
+ this.clientReporting = sample.clientReporting;
26515
+ this.telemetryShared = sample.telemetryShared;
26207
26516
  if (now < this.blackoutUntil) {
26208
26517
  this.stallStartedAt = null;
26209
- this.lastEvidence = {
26210
- clientKbps: sample.clientKbps,
26211
- stalledForMs: 0,
26212
- serverSending: sample.serverSending
26213
- };
26518
+ this.lastEvidence = measured(0);
26214
26519
  return;
26215
26520
  }
26216
- const kbps = sample.clientKbps;
26217
- const age = sample.clientSampleAgeMs;
26218
- if (!(kbps !== null && Number.isFinite(kbps) && age !== null && age <= this.config.maxClientSampleAgeMs) || kbps === null) {
26219
- this.lastEvidence = {
26220
- clientKbps: null,
26221
- stalledForMs: this.stalledForMs(now),
26222
- serverSending: sample.serverSending
26223
- };
26224
- return;
26225
- }
26226
- if (kbps >= this.config.recoverKbpsFloor) this.stallStartedAt = null;
26227
- else if (kbps < this.config.stallKbpsFloor && sample.serverSending) this.stallStartedAt ??= now;
26228
- this.lastEvidence = {
26229
- clientKbps: kbps,
26230
- stalledForMs: this.stalledForMs(now),
26231
- serverSending: sample.serverSending
26232
- };
26521
+ const recent = sample.recent !== null && sample.recent.sentKbps >= this.config.freezeMinSentKbps ? ratioOf(sample.recent) : null;
26522
+ const frozen = recent !== null && recent < this.config.freezeDeliveryRatio;
26523
+ const recovered = recent !== null && recent >= this.config.recoverDeliveryRatio;
26524
+ const starved = ratio !== null && ratio < this.config.stallDeliveryRatio && !recovered;
26525
+ const current = recent ?? ratio;
26526
+ if ((frozen || starved) && sample.serverSending) this.stallStartedAt ??= now;
26527
+ else if (current !== null && current >= this.config.recoverDeliveryRatio) this.stallStartedAt = null;
26528
+ this.lastEvidence = measured(this.stalledForMs(now));
26233
26529
  }
26234
26530
  stalledForMs(now) {
26235
26531
  return this.stallStartedAt === null ? 0 : now - this.stallStartedAt;
@@ -26240,25 +26536,42 @@ var StallTracker = class {
26240
26536
  return this.stalledForMs(now) >= this.config.stallConfirmMs;
26241
26537
  }
26242
26538
  /**
26243
- * May the ladder PROMOTE on this evaluation?
26539
+ * May the ladder PROMOTE on this evaluation, as far as the transport knows?
26244
26540
  *
26245
- * False when the last fresh measurement was below `recoverKbpsFloor` —
26246
- * either a stall building, or a link crawling along in the dead band.
26247
- * Promoting there spends bandwidth the session has just been shown not to
26248
- * have, and the loss path cannot object: a stream delivering almost nothing
26249
- * reports almost no loss. Same doctrine as `LossTracker.hasEvidence` — a
26250
- * climb needs POSITIVE evidence, not the absence of a complaint.
26541
+ * No while a stall is building, and no while the rung we are on is not
26542
+ * landing (nearly) whole — promoting there spends bandwidth the session has
26543
+ * just been shown not to have, and the loss path cannot object: a stream
26544
+ * delivering almost nothing reports almost no loss.
26251
26545
  *
26252
- * Unknown does not block. A deployment with no client telemetry at all must
26546
+ * Unknown refuses only while the client IS reporting — the window is simply
26547
+ * not covered yet, and climbing then would promote a link measured at 10 %
26548
+ * a few seconds later. A deployment with no client telemetry at all must
26253
26549
  * behave exactly as it did before this tracker existed; the loss path's own
26254
26550
  * `hasEvidence` is the gate there.
26255
26551
  */
26256
- isClimbSafe(now) {
26257
- if (now < this.blackoutUntil) return true;
26258
- if (this.stallStartedAt !== null) return false;
26259
- const last = this.lastEvidence.clientKbps;
26260
- if (last === null) return true;
26261
- return last >= this.config.recoverKbpsFloor;
26552
+ climbSafety(now) {
26553
+ if (now < this.blackoutUntil) return { safe: true };
26554
+ if (this.stallStartedAt !== null) return {
26555
+ safe: false,
26556
+ reason: "stall-building"
26557
+ };
26558
+ const ratio = this.lastEvidence.deliveryRatio;
26559
+ if (ratio === null) return this.clientReporting ? {
26560
+ safe: false,
26561
+ reason: "delivery"
26562
+ } : { safe: true };
26563
+ return ratio >= this.config.climbDeliveryRatio ? { safe: true } : {
26564
+ safe: false,
26565
+ reason: "delivery"
26566
+ };
26567
+ }
26568
+ /** Was the device's client record possibly another viewer's at the last evaluation? */
26569
+ get isTelemetryShared() {
26570
+ return this.telemetryShared;
26571
+ }
26572
+ /** Was the client reporting at the last evaluation (even if not yet covering a window)? */
26573
+ get isClientReporting() {
26574
+ return this.clientReporting;
26262
26575
  }
26263
26576
  /** What the last `observe` measured — for the switch's log line. */
26264
26577
  evidence(now) {
@@ -26269,9 +26582,142 @@ var StallTracker = class {
26269
26582
  }
26270
26583
  };
26271
26584
  //#endregion
26585
+ //#region src/stream-broker/webrtc/rtt-stability.ts
26586
+ /** 16 s — the longest delivery window, so both halves of a try look at the same span. */
26587
+ var RTT_WINDOW_MS = 16e3;
26588
+ var MIN_SAMPLES = 3;
26589
+ var MIN_SPREAD_MS = 20;
26590
+ var SPREAD_FRACTION = .5;
26591
+ var RttWindow = class {
26592
+ samples = [];
26593
+ observe(rttMs, now) {
26594
+ if (rttMs === null || !Number.isFinite(rttMs) || rttMs <= 0) return;
26595
+ this.samples = [...this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS), {
26596
+ rttMs,
26597
+ at: now
26598
+ }];
26599
+ }
26600
+ stability(now) {
26601
+ const recent = this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS);
26602
+ if (recent.length < MIN_SAMPLES) return "unknown";
26603
+ const values = recent.map((s) => s.rttMs);
26604
+ const min = Math.min(...values);
26605
+ return Math.max(...values) - min <= Math.max(MIN_SPREAD_MS, min * SPREAD_FRACTION) ? "stable" : "unstable";
26606
+ }
26607
+ };
26608
+ //#endregion
26609
+ //#region src/stream-broker/webrtc/try-climb.ts
26610
+ var NO_TRY_HISTORY = {
26611
+ failures: 0,
26612
+ notBefore: 0,
26613
+ lastRevert: null,
26614
+ trialFrom: null
26615
+ };
26616
+ /** Backoff after the Nth consecutive failed try: base × 2^(N−1), capped. */
26617
+ function tryBackoffMs(failures, baseMs, maxMs) {
26618
+ if (failures <= 0) return 0;
26619
+ return Math.min(baseMs * 2 ** (failures - 1), maxMs);
26620
+ }
26621
+ /** Watches one tried rung through its first window. Pure: fed, never reads a clock. */
26622
+ var TrialWatch = class {
26623
+ from;
26624
+ config;
26625
+ startedAt = null;
26626
+ lowStreak = 0;
26627
+ measured = 0;
26628
+ constructor(from, config) {
26629
+ this.from = from;
26630
+ this.config = config;
26631
+ }
26632
+ /**
26633
+ * Start the trial over: it must be judged afresh on live media. Called when
26634
+ * recorded playback takes the track (review m14) — time off live must never
26635
+ * count toward keeping a rung that was never measured.
26636
+ */
26637
+ restart() {
26638
+ this.startedAt = null;
26639
+ this.lowStreak = 0;
26640
+ this.measured = 0;
26641
+ }
26642
+ judge(now, input) {
26643
+ this.startedAt ??= now;
26644
+ if (input.stalled || input.lossFailing) return {
26645
+ kind: "revert",
26646
+ deliveryRatio: input.deliveryRatio,
26647
+ stalled: input.stalled
26648
+ };
26649
+ if (input.deliveryRatio !== null) {
26650
+ this.measured += 1;
26651
+ this.lowStreak = input.deliveryRatio < this.config.trialRevertRatio ? this.lowStreak + 1 : 0;
26652
+ if (this.lowStreak >= this.config.trialRevertConsecutive) return {
26653
+ kind: "revert",
26654
+ deliveryRatio: input.deliveryRatio,
26655
+ stalled: false
26656
+ };
26657
+ }
26658
+ const forMs = now - this.startedAt;
26659
+ const judged = this.measured >= this.config.trialRevertConsecutive || !input.clientReporting;
26660
+ if (forMs >= this.config.trialMs && judged || forMs >= this.config.trialMaxMs) return { kind: "kept" };
26661
+ return {
26662
+ kind: "running",
26663
+ forMs
26664
+ };
26665
+ }
26666
+ };
26667
+ /**
26668
+ * The try-climb memory per DEVICE on this broker, with a TTL (review I4).
26669
+ * The per-session intent survives only a re-offer; without this, every new
26670
+ * viewer session of a camera whose next rung keeps failing started again at
26671
+ * try #1. The trial itself (`trialFrom`) never carries to a new session.
26672
+ */
26673
+ var DeviceTryMemory = class {
26674
+ ttlMs;
26675
+ entries = /* @__PURE__ */ new Map();
26676
+ /** @param ttlMs forget a device not refreshed for this long — 600 000 ms, the backoff cap. */
26677
+ constructor(ttlMs) {
26678
+ this.ttlMs = ttlMs;
26679
+ }
26680
+ set(deviceId, carry, now) {
26681
+ this.entries.set(deviceId, {
26682
+ carry,
26683
+ at: now
26684
+ });
26685
+ }
26686
+ get(deviceId, now) {
26687
+ const entry = this.entries.get(deviceId);
26688
+ if (entry === void 0) return null;
26689
+ if (now - entry.at > this.ttlMs) {
26690
+ this.entries.delete(deviceId);
26691
+ return null;
26692
+ }
26693
+ return {
26694
+ ...entry.carry,
26695
+ trialFrom: null
26696
+ };
26697
+ }
26698
+ };
26699
+ //#endregion
26272
26700
  //#region src/stream-broker/webrtc/adaptive-controller.ts
26273
26701
  var TIER_ORDER = CAM_PROFILE_ORDER;
26274
26702
  /**
26703
+ * The cause as the wire (`WebrtcLadderDecision.cause`) can carry it. The wire
26704
+ * enum predates the try-climb, and widening it would make a server built
26705
+ * before it reject this broker's debug answer (the router validates output).
26706
+ * A trial revert is reported as what undelivered bytes are — `stall` when the
26707
+ * stall confirmed, else `loss` — and the climb hold carries the exact account
26708
+ * ("tried mid, reverted: delivery 0.60").
26709
+ */
26710
+ function wireLadderCause(decision, stallConfirmMs) {
26711
+ if (decision.cause !== "trial-revert") return { cause: decision.cause };
26712
+ return {
26713
+ cause: decision.stall.stalledForMs >= stallConfirmMs ? "stall" : "loss",
26714
+ causeDetail: "trial-revert"
26715
+ };
26716
+ }
26717
+ function positiveOrNull(v) {
26718
+ return v !== null && Number.isFinite(v) && v > 0 ? v : null;
26719
+ }
26720
+ /**
26275
26721
  * Ladder state machine for an adaptive WebRTC session. Fed loss samples
26276
26722
  * (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
26277
26723
  * Downgrade order: tier high→mid→low, then MTU reduction at low, then
@@ -26285,7 +26731,16 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
26285
26731
  * a session that has produced no RTCP at all is not "healthy", it is
26286
26732
  * silent, and climbing on silence spends bandwidth never shown to exist;
26287
26733
  * 3. the target tier's re-attempt backoff, which doubles per measured
26288
- * failure of that tier in this session.
26734
+ * failure of that tier in this session;
26735
+ * 4. the transport showing the CURRENT rung landing whole
26736
+ * (`StallTracker.climbSafety`, a delivery ratio over one GOP — D635);
26737
+ * 5. capacity: evidence (REMB or the viewer's probe) clearing the next
26738
+ * rung's bar confirms the climb; a MEASURED probe below it holds; with no
26739
+ * evidence either way the ladder TRIES the rung on a clean path (delivery
26740
+ * ≥ 0.95, loss < 1 %, RTT stable) and reverts within the rung's first
26741
+ * window if it does not land, backing off 30 s → 10 min (D635, round 2).
26742
+ *
26743
+ * Every tick that does not promote leaves a {@link ClimbHold} naming why.
26289
26744
  */
26290
26745
  var AdaptiveController = class {
26291
26746
  opts;
@@ -26306,6 +26761,16 @@ var AdaptiveController = class {
26306
26761
  armed = false;
26307
26762
  failures;
26308
26763
  upgradeCount;
26764
+ capacity = {
26765
+ rembKbps: null,
26766
+ probeKbps: null,
26767
+ probeFresh: false
26768
+ };
26769
+ rtt = new RttWindow();
26770
+ tryCarry;
26771
+ /** Non-null while the current rung is a trial. */
26772
+ trial;
26773
+ hold = null;
26309
26774
  constructor(opts) {
26310
26775
  this.opts = opts;
26311
26776
  this.tracker = new LossTracker(opts.config);
@@ -26313,6 +26778,16 @@ var AdaptiveController = class {
26313
26778
  this.tier = opts.initialTier;
26314
26779
  this.failures = new Map(opts.priorFailures ?? []);
26315
26780
  this.upgradeCount = Math.max(0, opts.priorUpgradeCount ?? 0);
26781
+ const carried = opts.tryClimb ?? NO_TRY_HISTORY;
26782
+ this.tryCarry = carried.trialFrom === opts.initialTier ? {
26783
+ ...carried,
26784
+ trialFrom: null
26785
+ } : carried;
26786
+ this.trial = this.tryCarry.trialFrom !== null ? new TrialWatch(this.tryCarry.trialFrom, opts.config) : null;
26787
+ }
26788
+ /** What the NEXT controller of this viewer session must inherit about tries. */
26789
+ get tryClimbCarry() {
26790
+ return this.tryCarry;
26316
26791
  }
26317
26792
  get currentTier() {
26318
26793
  return this.tier;
@@ -26345,6 +26820,27 @@ var AdaptiveController = class {
26345
26820
  this.stalls.observe(sample, now);
26346
26821
  }
26347
26822
  /**
26823
+ * The capacity witnesses, kbps — `null` when unknown: the receiver's REMB
26824
+ * (capped by what is sent, so evidence only FOR room) and the viewer's
26825
+ * downlink probe for this session (a path measurement, evidence both ways).
26826
+ * See `climb-capacity.ts`.
26827
+ */
26828
+ onCapacity(evidence) {
26829
+ this.capacity = {
26830
+ rembKbps: positiveOrNull(evidence.rembKbps),
26831
+ probeKbps: positiveOrNull(evidence.probeKbps),
26832
+ probeFresh: evidence.probeFresh
26833
+ };
26834
+ }
26835
+ /** The client's round-trip time, ms; null/0 = unknown. One of a try's conditions. */
26836
+ onRtt(rttMs, now) {
26837
+ this.rtt.observe(rttMs, now);
26838
+ }
26839
+ /** Why the ladder did not climb on the last evaluation; null = at the top, or it just moved. */
26840
+ get climbHold() {
26841
+ return this.hold;
26842
+ }
26843
+ /**
26348
26844
  * Freeze the ladder for this evaluation instead of ticking it.
26349
26845
  *
26350
26846
  * Called while the session's track is NOT carrying the live source — D42
@@ -26365,6 +26861,8 @@ var AdaptiveController = class {
26365
26861
  this.lastChangeAt = now;
26366
26862
  }
26367
26863
  this.healthySince = now;
26864
+ this.trial?.restart();
26865
+ this.setHold("suspended", now);
26368
26866
  this.stalls.markChanged(now);
26369
26867
  }
26370
26868
  tick(now) {
@@ -26373,26 +26871,132 @@ var AdaptiveController = class {
26373
26871
  this.healthySince = now;
26374
26872
  this.lastChangeAt = now;
26375
26873
  }
26874
+ if (this.trial !== null && this.judgeTrial(now)) return;
26376
26875
  if (this.stalls.isStalled(now)) {
26377
26876
  this.healthySince = now;
26877
+ this.hold = null;
26378
26878
  this.handleFailing(now, "stall");
26379
26879
  return;
26380
26880
  }
26381
26881
  if (this.tracker.isFailing(now)) {
26382
26882
  this.healthySince = now;
26883
+ this.hold = null;
26383
26884
  this.handleFailing(now, "loss");
26384
26885
  return;
26385
26886
  }
26386
- if (!this.stalls.isClimbSafe(now)) {
26887
+ const safety = this.stalls.climbSafety(now);
26888
+ if (!safety.safe) {
26387
26889
  this.healthySince = now;
26890
+ this.setHold(safety.reason, now);
26388
26891
  return;
26389
26892
  }
26390
26893
  if (!this.tracker.hasEvidence(now)) {
26391
26894
  this.healthySince = now;
26895
+ this.setHold("no-evidence", now);
26392
26896
  return;
26393
26897
  }
26394
26898
  this.handleHealthy(now);
26395
26899
  }
26900
+ nextTier() {
26901
+ const idx = TIER_ORDER.indexOf(this.tier);
26902
+ return idx <= 0 ? null : TIER_ORDER[idx - 1] ?? null;
26903
+ }
26904
+ /**
26905
+ * Record why this evaluation did not climb. At the top rung there is nothing
26906
+ * to hold — except a `trial`, which names the rung being tried.
26907
+ */
26908
+ setHold(code, now, extra = {}) {
26909
+ const next = code === "trial" ? this.tier : this.nextTier();
26910
+ if (next === null) {
26911
+ this.hold = null;
26912
+ return;
26913
+ }
26914
+ const ev = this.stalls.evidence(now);
26915
+ const clockRunning = code === "warming" || code === "cooldown" || code === "capacity" || code === "try-unclean" || code === "try-backoff";
26916
+ const nextTierKbps = this.opts.expectedTierKbps?.(next) ?? null;
26917
+ this.hold = {
26918
+ code,
26919
+ nextTier: next,
26920
+ deliveryRatio: ev.deliveryRatio,
26921
+ deliveryWindowMs: ev.deliveryWindowMs,
26922
+ climbDeliveryRatio: this.opts.config.climbDeliveryRatio,
26923
+ capacityKbps: this.capacity.rembKbps,
26924
+ probeKbps: this.capacity.probeKbps,
26925
+ nextTierKbps,
26926
+ capacityMargin: this.opts.config.climbCapacityMargin,
26927
+ neededKbps: capacityBarKbps(next, nextTierKbps, this.opts.config.climbCapacityMargin),
26928
+ tryBlockedBy: extra.tryBlockedBy ?? null,
26929
+ tryDeliveryRatio: this.opts.config.tryClimbDeliveryRatio,
26930
+ tryMaxLossRatio: this.opts.config.tryClimbMaxLossRatio,
26931
+ retryInMs: code === "try-backoff" ? Math.max(0, this.tryCarry.notBefore - now) : null,
26932
+ lastRevert: this.tryCarry.lastRevert,
26933
+ trialForMs: code === "trial" ? extra.trialForMs ?? 0 : null,
26934
+ trialMs: code === "trial" ? this.opts.config.trialMs : null,
26935
+ healthyForMs: clockRunning ? now - this.healthySince : null,
26936
+ requiredHealthyMs: this.requiredHealthyMs(next),
26937
+ since: this.hold?.code === code ? this.hold.since : now
26938
+ };
26939
+ }
26940
+ /**
26941
+ * Judge the rung on trial. Returns true when this evaluation is consumed
26942
+ * (still running, or reverted); false when the trial was just kept and the
26943
+ * ordinary ladder takes over.
26944
+ */
26945
+ judgeTrial(now) {
26946
+ const watch = this.trial;
26947
+ if (watch === null) return false;
26948
+ const verdict = watch.judge(now, {
26949
+ deliveryRatio: this.stalls.evidence(now).deliveryRatio,
26950
+ clientReporting: this.stalls.isClientReporting,
26951
+ stalled: this.stalls.isStalled(now),
26952
+ lossFailing: this.tracker.isFailing(now)
26953
+ });
26954
+ if (verdict.kind === "running") {
26955
+ this.healthySince = now;
26956
+ this.setHold("trial", now, { trialForMs: verdict.forMs });
26957
+ return true;
26958
+ }
26959
+ this.trial = null;
26960
+ if (verdict.kind === "kept") {
26961
+ this.tryCarry = NO_TRY_HISTORY;
26962
+ this.healthySince = now;
26963
+ return false;
26964
+ }
26965
+ const failures = this.tryCarry.failures + 1;
26966
+ const tried = this.tier;
26967
+ this.tryCarry = {
26968
+ failures,
26969
+ notBefore: now + tryBackoffMs(failures, this.opts.config.tryBackoffBaseMs, this.opts.config.tryBackoffMaxMs),
26970
+ lastRevert: {
26971
+ tier: tried,
26972
+ deliveryRatio: verdict.deliveryRatio,
26973
+ stalled: verdict.stalled,
26974
+ at: now
26975
+ },
26976
+ trialFrom: null
26977
+ };
26978
+ this.hold = null;
26979
+ this.tier = watch.from;
26980
+ this.mtuReduced = false;
26981
+ this.transcodeEngaged = false;
26982
+ this.opts.onSwitchTier(watch.from, this.decisionAt(now, "trial-revert"));
26983
+ this.commitChange(now);
26984
+ this.healthySince = now;
26985
+ return true;
26986
+ }
26987
+ /**
26988
+ * Which clean-path condition refuses a TRY, or null when the path is clean.
26989
+ * A client that sends no telemetry at all cannot measure delivery; there the
26990
+ * loss path is the judge, as it was before delivery existed.
26991
+ */
26992
+ tryBlocker(now) {
26993
+ if (this.stalls.isTelemetryShared) return "shared-telemetry";
26994
+ const ratio = this.stalls.evidence(now).deliveryRatio;
26995
+ if (ratio === null ? this.stalls.isClientReporting : ratio < this.opts.config.tryClimbDeliveryRatio) return "delivery";
26996
+ if (this.tracker.lossRatio() >= this.opts.config.tryClimbMaxLossRatio) return "loss";
26997
+ if (this.rtt.stability(now) === "unstable") return "rtt";
26998
+ return null;
26999
+ }
26396
27000
  canUpgrade(now) {
26397
27001
  return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
26398
27002
  }
@@ -26413,11 +27017,12 @@ var AdaptiveController = class {
26413
27017
  this.stalls.markChanged(now);
26414
27018
  }
26415
27019
  /** The measurement this decision rests on, snapshotted for the log line. */
26416
- decisionAt(now, cause) {
27020
+ decisionAt(now, cause, trial = false) {
26417
27021
  return {
26418
27022
  cause,
26419
27023
  lossRatio: this.tracker.lossRatio(),
26420
- stall: this.stalls.evidence(now)
27024
+ stall: this.stalls.evidence(now),
27025
+ trial
26421
27026
  };
26422
27027
  }
26423
27028
  handleFailing(now, cause) {
@@ -26444,21 +27049,223 @@ var AdaptiveController = class {
26444
27049
  }
26445
27050
  }
26446
27051
  handleHealthy(now) {
26447
- const idx = TIER_ORDER.indexOf(this.tier);
26448
- if (idx <= 0) return;
26449
- const next = TIER_ORDER[idx - 1];
26450
- if (now - this.healthySince < this.requiredHealthyMs(next)) return;
26451
- if (!this.canUpgrade(now)) return;
27052
+ const next = this.nextTier();
27053
+ if (next === null) {
27054
+ this.hold = null;
27055
+ return;
27056
+ }
27057
+ const capacity = judgeCapacity(this.capacity, next, this.opts.expectedTierKbps?.(next) ?? null, this.opts.config.climbCapacityMargin);
27058
+ if (capacity.kind === "probe-short") {
27059
+ this.setHold("capacity", now);
27060
+ return;
27061
+ }
27062
+ if (capacity.kind === "none" && now < this.tryCarry.notBefore) {
27063
+ this.setHold("try-backoff", now);
27064
+ return;
27065
+ }
27066
+ if (now - this.healthySince < this.requiredHealthyMs(next)) {
27067
+ this.setHold("warming", now);
27068
+ return;
27069
+ }
27070
+ if (!this.canUpgrade(now)) {
27071
+ this.setHold("cooldown", now);
27072
+ return;
27073
+ }
27074
+ if (capacity.kind === "sufficient") {
27075
+ this.promote(now, next, false);
27076
+ return;
27077
+ }
27078
+ const blocker = this.tryBlocker(now);
27079
+ if (blocker !== null) {
27080
+ this.setHold("try-unclean", now, { tryBlockedBy: blocker });
27081
+ return;
27082
+ }
27083
+ this.promote(now, next, true);
27084
+ }
27085
+ promote(now, next, trial) {
27086
+ const from = this.tier;
27087
+ this.hold = null;
26452
27088
  this.tier = next;
26453
27089
  this.mtuReduced = false;
26454
27090
  this.transcodeEngaged = false;
26455
27091
  this.upgradeCount += 1;
26456
- this.opts.onSwitchTier(next, this.decisionAt(now, "healthy"));
27092
+ this.tryCarry = {
27093
+ ...this.tryCarry,
27094
+ trialFrom: trial ? from : null
27095
+ };
27096
+ this.trial = trial ? new TrialWatch(from, this.opts.config) : null;
27097
+ this.opts.onSwitchTier(next, this.decisionAt(now, "healthy", trial));
26457
27098
  this.commitChange(now);
26458
27099
  this.healthySince = now;
26459
27100
  }
26460
27101
  };
26461
27102
  //#endregion
27103
+ //#region src/stream-broker/webrtc/climb-hold.ts
27104
+ /** The wire shape: the hold plus the line the broker logs. */
27105
+ function toWireClimbHold(h) {
27106
+ return {
27107
+ ...h,
27108
+ summary: describeClimbHold(h)
27109
+ };
27110
+ }
27111
+ function seconds(ms) {
27112
+ return `${(ms / 1e3).toFixed(1)}s`;
27113
+ }
27114
+ /** A rate, or "unknown" — never a fabricated 0 kbps (D393). */
27115
+ function kbps(v) {
27116
+ return v === null ? "unknown" : `${String(Math.round(v))} kbps`;
27117
+ }
27118
+ function deliveryPart(h) {
27119
+ if (h.deliveryRatio === null || h.deliveryWindowMs === null) return null;
27120
+ return `delivery ${h.deliveryRatio.toFixed(2)} over ${seconds(h.deliveryWindowMs)}`;
27121
+ }
27122
+ /** `mid needs 2160 kbps (1800 × 1.2)`, or `(client threshold)` for an unmeasured rung. */
27123
+ function barPart(h) {
27124
+ const basis = h.nextTierKbps !== null && h.nextTierKbps > 0 ? `(${String(Math.round(h.nextTierKbps))} × ${String(h.capacityMargin)})` : "(client threshold)";
27125
+ return `${h.nextTier} needs ${kbps(h.neededKbps)} ${basis}`;
27126
+ }
27127
+ function witnesses(h) {
27128
+ return `${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`}, ${h.probeKbps === null ? "no probe" : `probe ${kbps(h.probeKbps)}`}`;
27129
+ }
27130
+ function blockerPart(h) {
27131
+ switch (h.tryBlockedBy) {
27132
+ case "delivery": return h.deliveryRatio === null ? "delivery not measured" : `delivery ${h.deliveryRatio.toFixed(2)} < ${h.tryDeliveryRatio.toFixed(2)}`;
27133
+ case "loss": return `loss ≥ ${String(Math.round(h.tryMaxLossRatio * 1e3) / 10)}%`;
27134
+ case "rtt": return "RTT unstable";
27135
+ case "shared-telemetry": return "another viewer shares this camera's telemetry";
27136
+ case null: return "unknown";
27137
+ }
27138
+ }
27139
+ function revertPart(h) {
27140
+ const r = h.lastRevert;
27141
+ if (r === null) return "a try reverted";
27142
+ const why = r.stalled ? "stall" : r.deliveryRatio === null ? "loss" : `delivery ${r.deliveryRatio.toFixed(2)}`;
27143
+ return `tried ${r.tier}, reverted: ${why}`;
27144
+ }
27145
+ /**
27146
+ * One line, English, numbers included — the same text the broker logs and the
27147
+ * viewer shows under its localized hold label. Every number is one the
27148
+ * controller actually used; an unknown one is said to be unknown.
27149
+ */
27150
+ function describeClimbHold(h) {
27151
+ const delivery = deliveryPart(h);
27152
+ switch (h.code) {
27153
+ case "stall-building": return `held: stall building (${delivery ?? "delivery unknown"})`;
27154
+ case "delivery": return delivery === null ? "held: delivery not yet measured over one GOP" : `held: ${delivery} < ${h.climbDeliveryRatio.toFixed(2)}`;
27155
+ case "no-evidence": return "held: no receiver feedback in the loss window";
27156
+ case "warming": return `climbing: healthy ${seconds(h.healthyForMs ?? 0)} of ${seconds(h.requiredHealthyMs ?? 0)} before ${h.nextTier}`;
27157
+ case "cooldown": return "held: too soon after the last change";
27158
+ case "capacity": return `held: probe ${kbps(h.probeKbps)}, ${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`} < ${barPart(h)}`;
27159
+ case "try-unclean": return `held: no capacity evidence for ${h.nextTier} (${witnesses(h)}); not clean enough to try: ${blockerPart(h)}`;
27160
+ case "try-backoff": return `held: ${revertPart(h)}; next try in ${seconds(h.retryInMs ?? 0)}`;
27161
+ case "trial": return `trying ${h.nextTier}: ${seconds(h.trialForMs ?? 0)} of ${seconds(h.trialMs ?? 0)} (reverts if it does not land)`;
27162
+ case "suspended": return "held: recorded playback on the track (ladder suspended)";
27163
+ }
27164
+ }
27165
+ /**
27166
+ * Which hold changes deserve a log line (review m3). A code logs when it
27167
+ * differs from the last one logged AND has not itself been logged within
27168
+ * `quietMs` — a link hovering at 0.90–0.95 delivery cycles delivery →
27169
+ * warming → try-unclean and used to write a line every 4–8 s for ever.
27170
+ * Progress (`warming`, `trial`) never logs; the switch line covers moves.
27171
+ */
27172
+ var HoldLogGate = class {
27173
+ quietMs;
27174
+ last = null;
27175
+ loggedAt = /* @__PURE__ */ new Map();
27176
+ constructor(quietMs) {
27177
+ this.quietMs = quietMs;
27178
+ }
27179
+ shouldLog(code, now) {
27180
+ if (code === "warming" || code === "trial") return false;
27181
+ if (code === this.last) return false;
27182
+ const at = this.loggedAt.get(code);
27183
+ if (at !== void 0 && now - at < this.quietMs) return false;
27184
+ this.last = code;
27185
+ this.loggedAt.set(code, now);
27186
+ return true;
27187
+ }
27188
+ };
27189
+ //#endregion
27190
+ //#region src/stream-broker/webrtc/rung-bitrate.ts
27191
+ /**
27192
+ * Shortest lifetime whose average means anything: 4 000 ms ≈ one GOP on the
27193
+ * cameras this hub serves; less than that is one IDR divided by nothing.
27194
+ */
27195
+ var MIN_AVERAGE_UPTIME_MS = 4e3;
27196
+ function expectedRungKbps(stats) {
27197
+ const lifetime = stats.totalBytes > 0 && stats.uptimeMs >= MIN_AVERAGE_UPTIME_MS ? Math.round(stats.totalBytes * 8 / stats.uptimeMs) : null;
27198
+ const windowKbps = stats.status === "streaming" && stats.bitrateKbps > 0 ? stats.bitrateKbps : null;
27199
+ if (windowKbps === null) return lifetime;
27200
+ return lifetime === null ? windowKbps : Math.max(windowKbps, lifetime);
27201
+ }
27202
+ /** The client's probe cache TTL (`DOWNLINK_TTL_MS`): the oldest a hinted probe can be. */
27203
+ var HINT_PROBE_MAX_AGE_MS = 3e4;
27204
+ function mbpsToKbps(mbps) {
27205
+ return mbps !== void 0 && Number.isFinite(mbps) && mbps > 0 ? mbps * 1e3 : null;
27206
+ }
27207
+ /** The probe an offer carried, dated conservatively; null when it carried none. */
27208
+ function probeFromHints(downlinkMbps, now) {
27209
+ const kbps = mbpsToKbps(downlinkMbps);
27210
+ return kbps === null ? null : {
27211
+ kbps,
27212
+ at: now - HINT_PROBE_MAX_AGE_MS
27213
+ };
27214
+ }
27215
+ /** The probe a telemetry report carried; null when it carried none. */
27216
+ function probeFromReport(report, now) {
27217
+ const kbps = mbpsToKbps(report.downlinkMbps);
27218
+ if (kbps === null) return null;
27219
+ return {
27220
+ kbps,
27221
+ at: now - Math.max(0, report.reportAgeMs) - Math.max(0, report.downlinkAgeMs)
27222
+ };
27223
+ }
27224
+ /** The more recent of two probes (either may be absent). */
27225
+ function newerProbe(a, b) {
27226
+ if (a === null) return b;
27227
+ if (b === null) return a;
27228
+ return b.at >= a.at ? b : a;
27229
+ }
27230
+ function isProbeFresh(probe, now) {
27231
+ return probe !== null && now - probe.at <= 12e4;
27232
+ }
27233
+ /**
27234
+ * How long the broker keeps a device's probe at all. 600 000 ms (10 min, the
27235
+ * try-backoff cap). A SHORT probe holds a climb until a newer probe replaces
27236
+ * it — and a viewer that cannot probe (no token yet, a share link, whose token
27237
+ * is not session-grade) never supplies one, so without an end one viewer's
27238
+ * 1.2 Mbps reading would hold every such viewer of the camera on `capacity`
27239
+ * until the broker restarted (review N1).
27240
+ */
27241
+ var PROBE_MEMORY_TTL_MS = 6e5;
27242
+ /** The newest probe per device, bounded in time and forgotten with the device's last session. */
27243
+ var DeviceProbeMemory = class {
27244
+ ttlMs;
27245
+ probes = /* @__PURE__ */ new Map();
27246
+ constructor(ttlMs) {
27247
+ this.ttlMs = ttlMs;
27248
+ }
27249
+ /** Keep `probe` if it is newer than what is held; an absent one changes nothing. */
27250
+ record(deviceId, probe) {
27251
+ const kept = newerProbe(this.probes.get(deviceId) ?? null, probe);
27252
+ if (kept !== null) this.probes.set(deviceId, kept);
27253
+ }
27254
+ get(deviceId, now) {
27255
+ const probe = this.probes.get(deviceId);
27256
+ if (probe === void 0) return null;
27257
+ if (now - probe.at > this.ttlMs) {
27258
+ this.probes.delete(deviceId);
27259
+ return null;
27260
+ }
27261
+ return probe;
27262
+ }
27263
+ /** The device's last adaptive session ended: nobody's probe speaks for the next viewer. */
27264
+ forget(deviceId) {
27265
+ this.probes.delete(deviceId);
27266
+ }
27267
+ };
27268
+ //#endregion
26462
27269
  //#region src/stream-broker/webrtc/session-debug.ts
26463
27270
  /** `<deviceId>/<camStreamId>` → the camStreamId, or null when it is not one. */
26464
27271
  function camStreamIdOf(brokerId) {
@@ -26497,7 +27304,8 @@ var DEAD = {
26497
27304
  trackMode: null,
26498
27305
  lastDecision: null,
26499
27306
  tierFailures: [],
26500
- upgradeCount: null
27307
+ upgradeCount: null,
27308
+ climbHold: null
26501
27309
  };
26502
27310
  function projectSessionDebug(input) {
26503
27311
  const { session } = input;
@@ -26534,7 +27342,8 @@ function projectSessionDebug(input) {
26534
27342
  tier,
26535
27343
  failures
26536
27344
  })),
26537
- upgradeCount: input.ladder?.upgradeCount ?? null
27345
+ upgradeCount: input.ladder?.upgradeCount ?? null,
27346
+ climbHold: input.ladder?.climbHold ?? null
26538
27347
  };
26539
27348
  }
26540
27349
  var PLAYBACK_PROFILE_FALLBACK_ORDER = [
@@ -26560,6 +27369,22 @@ function gapReachable(gap, epochMs) {
26560
27369
  }
26561
27370
  //#endregion
26562
27371
  //#region src/stream-broker/recorded/control-protocol.ts
27372
+ /**
27373
+ * The rates a clip may be OFFERED at — re-exported from the capability, which
27374
+ * is where the one list lives (D612). The broker owns the clamp; the cap owns
27375
+ * the ladder; a second list here would be the second authority D62 forbids.
27376
+ *
27377
+ * Declared at the top of the file because the clamp and the `setRate` bound
27378
+ * are DERIVED from its ends (D621). They were three literals — this array, a
27379
+ * `Math.min(4, …)` and a `.max(4)` — and that is precisely how 8 and 16 came
27380
+ * to be offered while the clamp served 4 with no line anywhere saying so. One
27381
+ * decision now has one place to be written down.
27382
+ */
27383
+ var BROKER_PACED_RATES = CLIP_BROKER_PACED_RATES;
27384
+ /** The slowest offered rate. Anything slower is clamped up to it. */
27385
+ var MIN_PACED_RATE = Math.min(...BROKER_PACED_RATES);
27386
+ /** The fastest offered rate. Anything faster is refused at the wire. */
27387
+ var MAX_PACED_RATE = Math.max(...BROKER_PACED_RATES);
26563
27388
  var ControlMessageSchema = discriminatedUnion("t", [
26564
27389
  object({
26565
27390
  t: literal("playRecorded"),
@@ -26626,7 +27451,7 @@ var ControlMessageSchema = discriminatedUnion("t", [
26626
27451
  }),
26627
27452
  object({
26628
27453
  t: literal("setRate"),
26629
- rate: number().min(0).max(4)
27454
+ rate: number().min(0).max(MAX_PACED_RATE)
26630
27455
  }),
26631
27456
  object({
26632
27457
  t: literal("stepFrame"),
@@ -26639,22 +27464,41 @@ var ControlMessageSchema = discriminatedUnion("t", [
26639
27464
  })
26640
27465
  ]);
26641
27466
  /**
26642
- * Clamp a requested playback rate onto the supported domain: {0} ∪ [0.25, 4].
26643
- * 0 (and any non-finite / non-positive input) pauses; anything else is a
26644
- * continuous rate clamped into [0.25, 4]. Single server-side authority — the
26645
- * feeder applies it to every inbound `setRate`.
27467
+ * Clamp a requested playback rate onto the supported domain:
27468
+ * {0} ∪ [{@link MIN_PACED_RATE}, {@link MAX_PACED_RATE}] — the ends of
27469
+ * {@link BROKER_PACED_RATES} itself. 0 (and any non-finite / non-positive
27470
+ * input) pauses; anything else is a continuous rate clamped into that band.
27471
+ * Single server-side authority — the feeder applies it to every inbound
27472
+ * `setRate`.
27473
+ *
27474
+ * That every ladder entry survives this unchanged is now structural rather
27475
+ * than a discipline. What is NOT structural, and is therefore the gate that
27476
+ * matters, is whether the pacer really empties a clip `rate` times faster:
27477
+ * `clip-stream-feeder.spec` drives every entry and measures it.
26646
27478
  */
26647
27479
  function clampPlaybackRate(rate) {
26648
27480
  if (!Number.isFinite(rate) || rate <= 0) return 0;
26649
- return Math.min(4, Math.max(.25, rate));
27481
+ return Math.min(MAX_PACED_RATE, Math.max(MIN_PACED_RATE, rate));
26650
27482
  }
26651
27483
  /**
26652
- * The rates a clip may be OFFERED at — re-exported from the capability, which
26653
- * is where the one list lives (D612). The broker owns the clamp; the cap owns
26654
- * the ladder; a second list here would be the second authority D62 forbids.
26655
- * `control-protocol.spec` pins that every entry survives {@link clampPlaybackRate}.
27484
+ * Does a clip carry SOUND at this rate?
27485
+ *
27486
+ * ONE predicate, read by the branch that DROPS the audio
27487
+ * (`ClipStreamFeeder.pushNextAudio`) and by the branch that TELLS the viewer
27488
+ * why it is silent — because a second copy of `rate !== 1` is exactly the
27489
+ * disagreeing second switch D62 forbids, and the telling would then be able
27490
+ * to describe a drop that did not happen (or miss one that did).
27491
+ *
27492
+ * *"L'audio non serve in velocità non 1x!!!"* — the operator, 2026-09-23.
27493
+ * Resampling would need a decoder this path has not got, and pitch-shifted
27494
+ * sound is worse than none.
27495
+ *
27496
+ * Rate 0 is PAUSE, not a rate: nothing plays at all there, so it is not this
27497
+ * predicate's business and callers hold their last non-zero rate instead.
26656
27498
  */
26657
- var BROKER_PACED_RATES = CLIP_BROKER_PACED_RATES;
27499
+ function audioPlaysAtRate(rate) {
27500
+ return rate === 1;
27501
+ }
26658
27502
  var ServerMessageSchema = discriminatedUnion("t", [
26659
27503
  object({
26660
27504
  t: literal("state"),
@@ -26695,6 +27539,14 @@ var ServerMessageSchema = discriminatedUnion("t", [
26695
27539
  clipId: string().min(1),
26696
27540
  durationMs: number().positive()
26697
27541
  }),
27542
+ object({
27543
+ t: literal("clipIncomplete"),
27544
+ clipId: string().min(1),
27545
+ code: _enum(["stream-short", "stream-empty"]),
27546
+ /** The provider's own sentence, already Latin-1 safe. Shown where the
27547
+ * stream error is shown. */
27548
+ reason: string().min(1)
27549
+ }),
26698
27550
  object({
26699
27551
  t: literal("clipAudio"),
26700
27552
  clipId: string().min(1),
@@ -26707,8 +27559,23 @@ var ServerMessageSchema = discriminatedUnion("t", [
26707
27559
  "no-audio-track",
26708
27560
  "unsupported-codec",
26709
27561
  "codec-mismatch",
26710
- "stream-failed"
26711
- ]).optional()
27562
+ "stream-failed",
27563
+ "rate"
27564
+ ]).optional(),
27565
+ /**
27566
+ * The rate the sound is suppressed AT. Present iff `reason` is `rate`,
27567
+ * and refused otherwise — see the check below.
27568
+ */
27569
+ rate: number().positive().optional()
27570
+ }).check((ctx) => {
27571
+ const value = ctx.value;
27572
+ if (value.reason === "rate" === (value.rate !== void 0)) return;
27573
+ ctx.issues.push({
27574
+ code: "custom",
27575
+ message: "`rate` is present exactly when `reason` is `rate`",
27576
+ input: value,
27577
+ path: ["rate"]
27578
+ });
26712
27579
  })
26713
27580
  ]);
26714
27581
  function parseControlMessage(raw) {
@@ -27680,7 +28547,7 @@ var RecordedFeeder = class {
27680
28547
  /** Audio clock (PCMU 8 kHz / Opus 48 kHz); re-anchored together with video. */
27681
28548
  audioClock;
27682
28549
  clockHasAdvanced = false;
27683
- /** Playback rate: 0 (paused) or a continuous factor in [0.25, 4]. */
28550
+ /** Playback rate: 0 (paused) or a continuous factor in [0.25, 8]. */
27684
28551
  rate = 1;
27685
28552
  disposed = false;
27686
28553
  seekToken = 0;
@@ -28261,7 +29128,7 @@ var RecordedFeeder = class {
28261
29128
  }
28262
29129
  /**
28263
29130
  * Set playback rate. 0 pauses in place (the pacing loop aborts on its next
28264
- * guard check); any other value is clamped to [0.25, 4].
29131
+ * guard check); any other value is clamped to [0.25, 8].
28265
29132
  *
28266
29133
  * Rate-change strategy (documented choice): every transition TO a non-zero
28267
29134
  * rate — resume from pause AND a playing→playing rate change — re-enters via
@@ -30126,17 +30993,25 @@ var TimelineSession = class {
30126
30993
  this.lastPositionMs = null;
30127
30994
  const feeder = this.enterRecorded();
30128
30995
  await feeder.seek(offsetMs);
30129
- await Promise.all([this.reportClipDuration(clipId, feeder), this.reportClipAudio(clipId, feeder)]);
30996
+ await Promise.all([
30997
+ this.reportClipDuration(clipId, feeder),
30998
+ this.reportClipAudio(clipId, feeder),
30999
+ this.reportClipIncomplete(clipId, feeder)
31000
+ ]);
30130
31001
  }
30131
31002
  /**
30132
31003
  * Tell the viewer whether this clip has sound.
30133
31004
  *
30134
- * Silence has three causes the operator cannot tell apart by listening —
30135
- * a camera with no microphone, a transport that carried no audio, and a
30136
- * session that negotiated none — so the viewer is told which. Every branch
30137
- * that sends nothing says so (D391): a feeder that cannot answer, an answer
30138
- * that rejected, and an answer that landed after the pointer had left the
30139
- * clip, which must not be pinned on whatever came next.
31005
+ * Silence has causes the operator cannot tell apart by listening — a camera
31006
+ * with no microphone, a transport that carried no audio, a session that
31007
+ * negotiated none, and (D621) a clip playing at anything but 1×, where the
31008
+ * broker drops every frame on purpose — so the viewer is told which. Every
31009
+ * branch that sends nothing says so (D391): a feeder that cannot answer, an
31010
+ * answer that rejected, and an answer that landed after the pointer had left
31011
+ * the clip, which must not be pinned on whatever came next.
31012
+ *
31013
+ * The first answer SETTLES; the rate does not, so this also subscribes for
31014
+ * as long as the clip is on the pointer.
30140
31015
  */
30141
31016
  async reportClipAudio(clipId, feeder) {
30142
31017
  if (feeder.audio === void 0) {
@@ -30157,11 +31032,25 @@ var TimelineSession = class {
30157
31032
  });
30158
31033
  return;
30159
31034
  }
31035
+ this.sendClipAudio(clipId, report);
31036
+ feeder.watchAudio?.((later) => this.sendClipAudio(clipId, later));
31037
+ }
31038
+ /**
31039
+ * Send one `clipAudio`, or say why nothing was sent.
31040
+ *
31041
+ * ONE place, for the settle and for every later change: they are the same
31042
+ * statement about the same clip, and a second builder is how the two would
31043
+ * come to disagree about what `reason` means. The clip-identity guard lives
31044
+ * here too — a report that lands after the pointer has left names a clip
31045
+ * nobody is playing, and the viewer would mute whatever came next.
31046
+ */
31047
+ sendClipAudio(clipId, report) {
30160
31048
  if (this.clip?.clipId !== clipId) {
30161
31049
  this.deps.onLog?.("clip playback: audio dropped — the pointer has left this clip", {
30162
31050
  deviceId: this.deps.deviceId,
30163
31051
  clipId,
30164
- carried: report.carried
31052
+ carried: report.carried,
31053
+ reason: report.reason
30165
31054
  });
30166
31055
  return;
30167
31056
  }
@@ -30171,6 +31060,7 @@ var TimelineSession = class {
30171
31060
  carried: report.carried,
30172
31061
  codec: report.codec,
30173
31062
  reason: report.reason,
31063
+ rate: report.rate,
30174
31064
  detail: report.detail
30175
31065
  });
30176
31066
  this.deps.session.sendControl(serializeServerMessage({
@@ -30178,7 +31068,63 @@ var TimelineSession = class {
30178
31068
  clipId,
30179
31069
  carried: report.carried,
30180
31070
  ...report.codec !== null ? { codec: report.codec } : {},
30181
- ...report.reason !== null ? { reason: report.reason } : {}
31071
+ ...report.reason !== null ? { reason: report.reason } : {},
31072
+ ...report.reason === "rate" && report.rate !== void 0 ? { rate: report.rate } : {}
31073
+ }));
31074
+ }
31075
+ /**
31076
+ * Tell the operator that the clip he is watching did not arrive whole
31077
+ * (D623), in the surface that already shows him a stream error.
31078
+ *
31079
+ * Awaited AFTER the stream ends, which is when the provider gets to speak,
31080
+ * and guarded by the clip identity for the same reason the audio report is:
31081
+ * a late answer names a clip nobody is playing and the viewer would put the
31082
+ * complaint on whatever came next.
31083
+ *
31084
+ * There is no retry here and there must not be one. *"Se cade la sessione
31085
+ * cade non fa niente"* — the operator, 2026-09-23. A second dial would
31086
+ * contend for the camera's single replay lane exactly while that lane is
31087
+ * already failing.
31088
+ */
31089
+ async reportClipIncomplete(clipId, feeder) {
31090
+ if (feeder.incomplete === void 0) {
31091
+ this.deps.onLog?.("clip playback: completeness not reported — this feeder cannot ask the provider", {
31092
+ deviceId: this.deps.deviceId,
31093
+ clipId
31094
+ });
31095
+ return;
31096
+ }
31097
+ let report;
31098
+ try {
31099
+ report = await feeder.incomplete();
31100
+ } catch (err) {
31101
+ this.deps.onLog?.("clip playback: completeness not reported — the answer failed", {
31102
+ deviceId: this.deps.deviceId,
31103
+ clipId,
31104
+ error: err instanceof Error ? err.message : String(err)
31105
+ });
31106
+ return;
31107
+ }
31108
+ if (report === null) return;
31109
+ if (this.clip?.clipId !== clipId) {
31110
+ this.deps.onLog?.("clip playback: completeness dropped — the pointer has left this clip", {
31111
+ deviceId: this.deps.deviceId,
31112
+ clipId,
31113
+ code: report.code
31114
+ });
31115
+ return;
31116
+ }
31117
+ this.deps.onLog?.("clip playback: the clip did not arrive whole", {
31118
+ deviceId: this.deps.deviceId,
31119
+ clipId,
31120
+ code: report.code,
31121
+ reason: report.reason
31122
+ });
31123
+ this.deps.session.sendControl(serializeServerMessage({
31124
+ t: "clipIncomplete",
31125
+ clipId,
31126
+ code: report.code,
31127
+ reason: report.reason
30182
31128
  }));
30183
31129
  }
30184
31130
  /**
@@ -31580,7 +32526,7 @@ function createFmp4StreamDemuxer(options = {}) {
31580
32526
  * faster than the camera sends.
31581
32527
  * - `stepFrame(-1)` and the scrub verbs are refused by name; `stepFrame(+1)`
31582
32528
  * pushes the next buffered unit.
31583
- * - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 4],
32529
+ * - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 8],
31584
32530
  * the recorded domain). The camera's own `playSpeed` (8, 16 — real, the app
31585
32531
  * reopens the replay at 8) is a different mechanism and is NOT wired: it
31586
32532
  * would go on the dial (`videoclips.dialClipStream` → the cmd-5 replay's
@@ -31646,6 +32592,11 @@ function createFmp4StreamDemuxer(options = {}) {
31646
32592
  * `data`, which the broker's `onLog` puts in `tags`.
31647
32593
  */
31648
32594
  /**
32595
+ * The reason an in-flight open is abandoned, carried on the abort so the
32596
+ * opener's refusal and the provider's log line say the same thing.
32597
+ */
32598
+ var CLIP_STREAM_ABANDONED = "clip-stream-abandoned";
32599
+ /**
31649
32600
  * The same ceiling as the file path (`CLIP_SOURCE_MAX_BYTES`): a clip the
31650
32601
  * file path would refuse `too-large` is one this path stops taking. Frames
31651
32602
  * behind the head are released as they are pushed, so the backlog is the
@@ -31668,6 +32619,8 @@ var ClipStreamFeeder = class {
31668
32619
  deps;
31669
32620
  phase = "idle";
31670
32621
  connection = null;
32622
+ /** Armed for the length of one `open()`; aborted by {@link teardown}. */
32623
+ opening = null;
31671
32624
  demuxer;
31672
32625
  clock = new MonotonicClock();
31673
32626
  /** The RTP rate the audio sender negotiated — a CONVERSION, not a clock. */
@@ -31680,10 +32633,28 @@ var ClipStreamFeeder = class {
31680
32633
  audioPushed = 0;
31681
32634
  audioDroppedLate = 0;
31682
32635
  audioDroppedRate = 0;
32636
+ /**
32637
+ * The last report handed to a caller — the settle promise's value, or the
32638
+ * newest one {@link watchAudio}'s listener was given. What a change is
32639
+ * measured against, so the same answer is never announced twice.
32640
+ */
32641
+ audioEmitted = null;
32642
+ /**
32643
+ * The last NON-ZERO rate. Pause is the absence of a rate, not a rate: a
32644
+ * `clipAudio` blaming the speed while the clip is stopped would be false,
32645
+ * and un-pausing would have to take it back.
32646
+ */
32647
+ audioRate = 1;
32648
+ audioListener = null;
31683
32649
  audioDroppedSeek = 0;
31684
32650
  firstAudioLogged = false;
31685
32651
  audioReport = null;
31686
32652
  audioWaiters = [];
32653
+ /** The provider's last word, and whether it has been given. Settled is not
32654
+ * the same as non-null: a whole stream settles `null` (D623). */
32655
+ incompleteSettled = false;
32656
+ incompleteReport = null;
32657
+ incompleteWaiters = [];
31687
32658
  clockHasAdvanced = false;
31688
32659
  rate = 1;
31689
32660
  queue = [];
@@ -31715,12 +32686,16 @@ var ClipStreamFeeder = class {
31715
32686
  firstFrameLogged = false;
31716
32687
  maxBufferedBytes;
31717
32688
  stallTimeoutMs;
32689
+ prerollMs;
32690
+ /** True while the pacer is waiting for the cushion to refill after a starve. */
32691
+ rebuffering = false;
31718
32692
  now;
31719
32693
  tags;
31720
32694
  constructor(deps) {
31721
32695
  this.deps = deps;
31722
32696
  this.maxBufferedBytes = deps.maxBufferedBytes ?? CLIP_STREAM_BUFFER_MAX_BYTES;
31723
32697
  this.stallTimeoutMs = deps.stallTimeoutMs ?? 15e3;
32698
+ this.prerollMs = deps.prerollMs ?? 600;
31724
32699
  this.now = deps.now ?? (() => Date.now());
31725
32700
  this.tags = {
31726
32701
  deviceId: deps.handle.deviceId,
@@ -31818,6 +32793,8 @@ var ClipStreamFeeder = class {
31818
32793
  this.clock.setRate(next);
31819
32794
  const wasPaused = this.rate === 0;
31820
32795
  this.rate = next;
32796
+ this.audioRate = next;
32797
+ this.refreshAudioReport();
31821
32798
  if (this.phase !== "streaming" || this.pendingSeekMs !== null) return;
31822
32799
  if (wasPaused) this.emitState("playing", "set-rate-resume", this.seekToken);
31823
32800
  this.startPacing();
@@ -31852,6 +32829,19 @@ var ClipStreamFeeder = class {
31852
32829
  return new Promise((resolve) => this.durationWaiters.push(resolve));
31853
32830
  }
31854
32831
  /**
32832
+ * Did the provider deliver this clip whole? (D623)
32833
+ *
32834
+ * Resolves `null` for a whole stream AND for one that never got to say —
32835
+ * cut, disposed, or opened against a provider that adds no trailer. Both are
32836
+ * "nothing to tell the operator", and neither may be reported as a defect:
32837
+ * a cut already announces itself as `state: error`, and inventing a second
32838
+ * complaint for it would file one fault twice.
32839
+ */
32840
+ incomplete() {
32841
+ if (this.incompleteSettled) return Promise.resolve(this.incompleteReport);
32842
+ return new Promise((resolve) => this.incompleteWaiters.push(resolve));
32843
+ }
32844
+ /**
31855
32845
  * What this clip turned out to carry as sound — the answer the viewer needs
31856
32846
  * to say "this clip has no audio" rather than leave the operator wondering.
31857
32847
  *
@@ -31861,15 +32851,31 @@ var ClipStreamFeeder = class {
31861
32851
  */
31862
32852
  audio() {
31863
32853
  const settled = this.audioReport;
31864
- if (settled !== null) return Promise.resolve(settled);
32854
+ if (settled !== null) return Promise.resolve(this.audioNow(settled));
31865
32855
  return new Promise((resolve) => this.audioWaiters.push(resolve));
31866
32856
  }
32857
+ /**
32858
+ * Follow the audio answer for as long as this clip is on the pointer.
32859
+ *
32860
+ * {@link audio} settles ONCE, because what the stream carries is decided
32861
+ * once — at the `moov`. The RATE is not: it moves under the operator's
32862
+ * finger, and a one-shot report cannot say that the clip which had sound a
32863
+ * second ago is silent now because he pressed 2×. The listener is told only
32864
+ * when the answer really changes, never the settle it already awaited.
32865
+ *
32866
+ * One listener, replaced rather than added to: there is one session behind
32867
+ * one clip, and a list would outlive the pointer.
32868
+ */
32869
+ watchAudio(listener) {
32870
+ this.audioListener = listener;
32871
+ }
31867
32872
  dispose() {
31868
32873
  if (this.phase === "disposed") return;
31869
32874
  const before = this.phase;
31870
32875
  this.phase = "disposed";
31871
32876
  this.teardown();
31872
32877
  this.settleDuration(null);
32878
+ this.settleIncomplete(null);
31873
32879
  this.settleAudio({
31874
32880
  carried: false,
31875
32881
  codec: null,
@@ -31940,13 +32946,15 @@ var ClipStreamFeeder = class {
31940
32946
  audioReason: answer.audioReason ?? null
31941
32947
  });
31942
32948
  }
32949
+ const abandon = new AbortController();
32950
+ this.opening = abandon;
31943
32951
  let opened;
31944
32952
  try {
31945
32953
  opened = await this.deps.open(answer.url, {
31946
32954
  onData: (chunk) => this.onData(chunk),
31947
- onEnd: () => this.onEnd(),
32955
+ onEnd: (report) => this.onEnd(report),
31948
32956
  onError: (err) => this.cut("stream-failed", err.message)
31949
- });
32957
+ }, abandon.signal);
31950
32958
  } catch (err) {
31951
32959
  opened = {
31952
32960
  kind: "refused",
@@ -31955,8 +32963,15 @@ var ClipStreamFeeder = class {
31955
32963
  detail: describe(err)
31956
32964
  };
31957
32965
  }
32966
+ if (this.opening === abandon) this.opening = null;
31958
32967
  if (this.phase !== "opening") {
31959
32968
  if (opened.kind === "open") opened.connection.destroy();
32969
+ this.log("clip-stream:open-abandoned", {
32970
+ phase: this.phase,
32971
+ opened: opened.kind,
32972
+ sinceDialMs: this.now() - (this.dialedAt ?? this.now()),
32973
+ ...opened.kind === "refused" ? { code: opened.code } : {}
32974
+ });
31960
32975
  return;
31961
32976
  }
31962
32977
  if (opened.kind === "refused") {
@@ -31976,6 +32991,7 @@ var ClipStreamFeeder = class {
31976
32991
  this.phase = "refused";
31977
32992
  this.teardown();
31978
32993
  this.settleDuration(null);
32994
+ this.settleIncomplete(null);
31979
32995
  this.settleAudio({
31980
32996
  carried: false,
31981
32997
  codec: null,
@@ -32097,10 +33113,24 @@ var ClipStreamFeeder = class {
32097
33113
  return;
32098
33114
  }
32099
33115
  while (this.rate === 0 && this.pendingSteps > 0 && this.pushNext()) this.pendingSteps -= 1;
32100
- if (this.rate > 0 && this.paceTimer === null) this.tick();
33116
+ if (this.rate === 0) return;
33117
+ if (this.rebuffering) {
33118
+ if (this.cushionShortfallMs() > 0) return;
33119
+ this.rebuffering = false;
33120
+ this.log("clip-stream:refilled", {
33121
+ headMs: this.headMs,
33122
+ arrivedMs: this.arrivedMs,
33123
+ queued: this.queue.length,
33124
+ starved: this.starved
33125
+ });
33126
+ this.startPacing();
33127
+ return;
33128
+ }
33129
+ if (this.paceTimer === null) this.tick();
32101
33130
  }
32102
- onEnd() {
33131
+ onEnd(report) {
32103
33132
  if (!this.takesBytes()) return;
33133
+ this.settleIncomplete(report);
32104
33134
  this.streamEnded = true;
32105
33135
  this.clearStall();
32106
33136
  this.streamEnd = this.demuxer.end();
@@ -32118,13 +33148,20 @@ var ClipStreamFeeder = class {
32118
33148
  this.resolvePendingSeek(this.seekToken);
32119
33149
  return;
32120
33150
  }
32121
- if (this.rate > 0 && this.paceTimer === null) this.tick();
33151
+ if (this.rate === 0) return;
33152
+ if (this.rebuffering || this.anchorWall > this.now()) {
33153
+ this.rebuffering = false;
33154
+ this.startPacing();
33155
+ return;
33156
+ }
33157
+ if (this.paceTimer === null) this.tick();
32122
33158
  }
32123
33159
  cut(reason, detail) {
32124
33160
  if (this.phase !== "streaming" && this.phase !== "opening") return;
32125
33161
  this.phase = "cut";
32126
33162
  this.teardown();
32127
33163
  this.settleDuration(null);
33164
+ this.settleIncomplete(null);
32128
33165
  this.settleAudio({
32129
33166
  carried: false,
32130
33167
  codec: null,
@@ -32258,9 +33295,24 @@ var ClipStreamFeeder = class {
32258
33295
  this.audioQueue = this.audioQueue.slice(drop);
32259
33296
  this.audioDroppedSeek += drop;
32260
33297
  }
33298
+ /**
33299
+ * How much of the cushion is MISSING, in milliseconds of media.
33300
+ *
33301
+ * Zero once the stream has ended — there is nothing more coming, so waiting
33302
+ * for it would hang the clip — and zero for any source that already holds
33303
+ * more than the cushion, which is every fast one.
33304
+ */
33305
+ cushionShortfallMs() {
33306
+ if (this.streamEnded) return 0;
33307
+ const head = this.headMs;
33308
+ const arrived = this.arrivedMs;
33309
+ if (head === null || arrived === null) return this.prerollMs;
33310
+ return Math.max(0, this.prerollMs - (arrived - head));
33311
+ }
32261
33312
  startPacing() {
32262
33313
  this.stopPacing();
32263
- this.anchorWall = this.now();
33314
+ const shortfall = this.cushionShortfallMs();
33315
+ this.anchorWall = this.now() + shortfall;
32264
33316
  this.anchorPts = this.headMs ?? this.queue[0]?.ptsMs ?? 0;
32265
33317
  this.startPositionReports();
32266
33318
  this.tick();
@@ -32285,7 +33337,10 @@ var ClipStreamFeeder = class {
32285
33337
  const audio = this.audioQueue[0];
32286
33338
  if (video === void 0 && audio === void 0) {
32287
33339
  if (this.streamEnded) this.finish();
32288
- else this.noteStarved();
33340
+ else {
33341
+ this.rebuffering = true;
33342
+ this.noteStarved();
33343
+ }
32289
33344
  return;
32290
33345
  }
32291
33346
  const takeVideo = video !== void 0 && (audio === void 0 || video.ptsMs <= audio.ptsMs);
@@ -32321,7 +33376,7 @@ var ClipStreamFeeder = class {
32321
33376
  if (frame === void 0) return false;
32322
33377
  this.audioQueue = this.audioQueue.slice(1);
32323
33378
  this.audioQueuedBytes -= frame.data.byteLength;
32324
- if (this.rate !== 1) {
33379
+ if (!audioPlaysAtRate(this.rate)) {
32325
33380
  this.audioDroppedRate += 1;
32326
33381
  return true;
32327
33382
  }
@@ -32419,11 +33474,53 @@ var ClipStreamFeeder = class {
32419
33474
  audioQueued: this.audioQueue.length
32420
33475
  };
32421
33476
  }
33477
+ /** One-shot, like the duration beside it: the provider speaks once, at the
33478
+ * end of the body, and a cut settles `null` so nobody waits for ever. */
33479
+ settleIncomplete(report) {
33480
+ if (this.incompleteSettled) return;
33481
+ this.incompleteSettled = true;
33482
+ this.incompleteReport = report;
33483
+ if (report !== null) this.log("clip-stream:incomplete", {
33484
+ code: report.code,
33485
+ reason: report.reason
33486
+ });
33487
+ for (const resolve of this.incompleteWaiters.splice(0)) resolve(report);
33488
+ }
32422
33489
  settleAudio(report) {
32423
33490
  if (this.audioReport !== null) return;
32424
33491
  this.audioReport = report;
33492
+ const now = this.audioNow(report);
33493
+ this.audioEmitted = now;
32425
33494
  const waiters = this.audioWaiters.splice(0);
32426
- for (const resolve of waiters) resolve(report);
33495
+ for (const resolve of waiters) resolve(now);
33496
+ }
33497
+ /**
33498
+ * The audio answer AS IT STANDS — the stream's own settled fact, with the
33499
+ * rate laid over it.
33500
+ *
33501
+ * The overlay only ever applies to a clip that really is carrying sound. A
33502
+ * clip whose camera has no microphone is silent at 1× too, and replacing
33503
+ * `no-audio-track` with `rate` would swap the true cause for a transient
33504
+ * one and send the operator to the speed chip.
33505
+ */
33506
+ audioNow(settled) {
33507
+ if (!settled.carried || audioPlaysAtRate(this.audioRate)) return settled;
33508
+ return {
33509
+ carried: false,
33510
+ codec: null,
33511
+ reason: "rate",
33512
+ rate: this.audioRate
33513
+ };
33514
+ }
33515
+ /** Tell the listener when — and only when — the answer has actually moved. */
33516
+ refreshAudioReport() {
33517
+ const settled = this.audioReport;
33518
+ if (settled === null) return;
33519
+ const next = this.audioNow(settled);
33520
+ const last = this.audioEmitted;
33521
+ if (last !== null && last.reason === next.reason && last.rate === next.rate) return;
33522
+ this.audioEmitted = next;
33523
+ this.audioListener?.(next);
32427
33524
  }
32428
33525
  settleDuration(value) {
32429
33526
  if (this.duration !== null) return;
@@ -32438,6 +33535,9 @@ var ClipStreamFeeder = class {
32438
33535
  this.stopPacing();
32439
33536
  this.stopPositionReports();
32440
33537
  this.clearStall();
33538
+ const opening = this.opening;
33539
+ this.opening = null;
33540
+ opening?.abort(/* @__PURE__ */ new Error(CLIP_STREAM_ABANDONED));
32441
33541
  const connection = this.connection;
32442
33542
  this.connection = null;
32443
33543
  connection?.destroy();
@@ -32479,6 +33579,62 @@ var ClipStreamFeeder = class {
32479
33579
  function describe(err) {
32480
33580
  return err instanceof Error ? err.message : String(err);
32481
33581
  }
33582
+ //#endregion
33583
+ //#region src/stream-broker/recorded/clip-stream-http.ts
33584
+ /**
33585
+ * clip-stream-http — the `node:http` half of {@link ClipStreamOpener}.
33586
+ *
33587
+ * Dials the URL `videoclips.dialClipStream` handed back — plain HTTP on the
33588
+ * provider's own token-gated listener, never the hub's proxy (an addon holds
33589
+ * no user credential, and the hub proxy takes only those) — and turns the
33590
+ * response into what the feeder drives: `pause`/`resume`/`destroy` on the
33591
+ * socket and the three events.
33592
+ *
33593
+ * A status other than 200 is a REFUSAL, named by the provider's own headers
33594
+ * (`x-camstack-reason-code`, `x-camstack-reason`), never a stream of nothing.
33595
+ * A 200 that does not declare `x-camstack-clip-transport: forward-only` is
33596
+ * refused too: it means the URL is not the stream route, and playing a
33597
+ * progressive file through a forward-only feeder would look like it worked
33598
+ * until the first seek.
33599
+ */
33600
+ /**
33601
+ * The codes a provider may add AFTER its body, and the only two this hop
33602
+ * carries onward.
33603
+ *
33604
+ * A stream's status line is spent on its first fragment, so a serve the camera
33605
+ * cut short has nothing left to say it with but a trailer — and a trailer this
33606
+ * reader did not know about would be read as a whole clip, which is the defect
33607
+ * D623 is about, one hop further out. Anything else a provider writes there is
33608
+ * ITS diagnostic and stays in ITS log: a viewer is shown what it can act on.
33609
+ */
33610
+ var CARRIED_TRAILER_CODES = ["stream-short", "stream-empty"];
33611
+ function trailerText(trailers, name) {
33612
+ const value = trailers[name];
33613
+ return typeof value === "string" && value.length > 0 ? value : void 0;
33614
+ }
33615
+ /**
33616
+ * What the provider said about its own stream once the last byte was out.
33617
+ *
33618
+ * `null` for a stream that added nothing, which is a whole one — the absence
33619
+ * is the statement, and it is never read as "unknown". A code with no sentence
33620
+ * is still reported, with the code standing in for one: a consumer that
33621
+ * branches on the code must not lose the branch because the prose was dropped
33622
+ * by a proxy.
33623
+ */
33624
+ function readClipStreamTrailers(trailers) {
33625
+ const code = trailerText(trailers, CLIP_STILL_REASON_CODE_HEADER);
33626
+ if (code === void 0 || !CARRIED_TRAILER_CODES.includes(code)) return null;
33627
+ return {
33628
+ code: code === "stream-empty" ? "stream-empty" : "stream-short",
33629
+ reason: trailerText(trailers, "x-camstack-reason") ?? code
33630
+ };
33631
+ }
33632
+ /** The abort's reason, as a sentence the provider's log can be matched against. */
33633
+ function describeReason(reason) {
33634
+ if (reason instanceof Error) return reason.message;
33635
+ if (typeof reason === "string") return reason;
33636
+ return "the clip stream was abandoned before it opened";
33637
+ }
32482
33638
  function headerText(res, name) {
32483
33639
  const value = res.headers[name];
32484
33640
  if (Array.isArray(value)) return value[0];
@@ -32486,14 +33642,26 @@ function headerText(res, name) {
32486
33642
  }
32487
33643
  function createHttpClipStreamOpener(options = {}) {
32488
33644
  const headersTimeoutMs = options.headersTimeoutMs ?? 3e4;
32489
- return (url, handlers) => new Promise((resolve) => {
33645
+ return (url, handlers, signal) => new Promise((resolve) => {
32490
33646
  let settled = false;
33647
+ let onAbort = null;
32491
33648
  const settle = (value) => {
32492
33649
  if (settled) return;
32493
33650
  settled = true;
32494
33651
  clearTimeout(timer);
33652
+ if (onAbort !== null) signal.removeEventListener("abort", onAbort);
32495
33653
  resolve(value);
32496
33654
  };
33655
+ const abandoned = () => ({
33656
+ kind: "refused",
33657
+ status: 0,
33658
+ code: "abandoned",
33659
+ detail: describeReason(signal.reason)
33660
+ });
33661
+ if (signal.aborted) {
33662
+ resolve(abandoned());
33663
+ return;
33664
+ }
32497
33665
  let target;
32498
33666
  try {
32499
33667
  target = new URL(url);
@@ -32522,7 +33690,16 @@ function createHttpClipStreamOpener(options = {}) {
32522
33690
  const timer = setTimeout(() => {
32523
33691
  req.destroy(/* @__PURE__ */ new Error(`no status line in ${headersTimeoutMs} ms`));
32524
33692
  }, headersTimeoutMs);
33693
+ onAbort = () => {
33694
+ req.destroy(new Error(describeReason(signal.reason)));
33695
+ settle(abandoned());
33696
+ };
33697
+ signal.addEventListener("abort", onAbort, { once: true });
32525
33698
  req.on("error", (err) => {
33699
+ if (signal.aborted) {
33700
+ settle(abandoned());
33701
+ return;
33702
+ }
32526
33703
  settle({
32527
33704
  kind: "refused",
32528
33705
  status: 0,
@@ -32553,7 +33730,7 @@ function createHttpClipStreamOpener(options = {}) {
32553
33730
  return;
32554
33731
  }
32555
33732
  res.on("data", (chunk) => handlers.onData(chunk));
32556
- res.on("end", () => handlers.onEnd());
33733
+ res.on("end", () => handlers.onEnd(readClipStreamTrailers(res.trailers)));
32557
33734
  res.on("error", (err) => handlers.onError(err));
32558
33735
  settle({
32559
33736
  kind: "open",
@@ -33143,24 +34320,13 @@ function pickAdaptiveTier(input) {
33143
34320
  *
33144
34321
  * Flow: Camera → RTSP → Broker → EncodedPacket → RTP packetization → WebRTC
33145
34322
  */
33146
- /** How often the adaptive controller re-evaluates the ladder (ms). */
33147
- var ADAPTIVE_TICK_MS = 2e3;
33148
- /** Ladder tuning shared by every adaptive session. */
33149
- var ADAPTIVE_CONFIG = {
33150
- analyzeWindowMs: 15e3,
33151
- minFailPercent: .05,
33152
- waitAfterResetMs: 8e3,
33153
- upgradeHealthyMs: 4e3,
33154
- upgradeHealthyGrowthFactor: 3,
33155
- maxProgressiveUpgradeHealthyMs: 3e4,
33156
- minTimeBetweenChangesMs: 4e3,
33157
- maxUpgradeBackoffMs: 9e5,
33158
- stallKbpsFloor: 64,
33159
- stallConfirmMs: 6e3,
33160
- recoverKbpsFloor: 128,
33161
- waitAfterChangeMs: 8e3,
33162
- maxClientSampleAgeMs: 1e4
33163
- };
34323
+ /**
34324
+ * A hold code that has been logged stays quiet this long before it may log
34325
+ * again (`HoldLogGate`). 60 000 ms: a hovering link changes code every 4–8 s.
34326
+ */
34327
+ var HOLD_LOG_QUIET_MS = 6e4;
34328
+ /** A failing adaptive tick logs at most once per this long, per session. */
34329
+ var TICK_FAILURE_LOG_QUIET_MS = 6e4;
33164
34330
  /**
33165
34331
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
33166
34332
  * controls. Uses `in` narrowing + `typeof` checks (no cast) so the returned
@@ -33397,6 +34563,14 @@ var BrokerWebrtcServer = class {
33397
34563
  */
33398
34564
  lastLadderDecision = /* @__PURE__ */ new Map();
33399
34565
  /**
34566
+ * Try-climb memory per DEVICE (review I4): a new viewer session of a camera
34567
+ * whose next rung keeps failing inherits the backoff instead of starting at
34568
+ * try #1. Forgotten after the backoff cap without a refresh.
34569
+ */
34570
+ deviceTryMemory = new DeviceTryMemory(ADAPTIVE_CONFIG.tryBackoffMaxMs);
34571
+ /** The newest viewer probe per device, from offers and telemetry (`probe-evidence.ts`). */
34572
+ deviceProbe = new DeviceProbeMemory(PROBE_MEMORY_TTL_MS);
34573
+ /**
33400
34574
  * brokerId → where its media is ingested. Injected by the manager, which
33401
34575
  * owns the ownership map; unset (or a null answer) means UNRESOLVED, and
33402
34576
  * the overlay says so rather than naming the local node.
@@ -34394,7 +35568,8 @@ var BrokerWebrtcServer = class {
34394
35568
  };
34395
35569
  if (streamId.endsWith("/adaptive")) {
34396
35570
  const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
34397
- this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures, intent?.upgradeCount);
35571
+ this.recordDeviceProbe(deviceId, probeFromHints(hints.downlinkMbps, Date.now()));
35572
+ this.startAdaptive(entry, deviceId, initialTier, sessionId, intent);
34398
35573
  }
34399
35574
  return {
34400
35575
  sessionId,
@@ -34512,11 +35687,13 @@ var BrokerWebrtcServer = class {
34512
35687
  const ladder = controller !== void 0 ? {
34513
35688
  lastDecision: decision,
34514
35689
  failures: [...controller.failureCounts],
34515
- upgradeCount: controller.successfulUpgradeCount
35690
+ upgradeCount: controller.successfulUpgradeCount,
35691
+ climbHold: controller.climbHold === null ? null : toWireClimbHold(controller.climbHold)
34516
35692
  } : decision !== null ? {
34517
35693
  lastDecision: decision,
34518
35694
  failures: [],
34519
- upgradeCount: 0
35695
+ upgradeCount: 0,
35696
+ climbHold: null
34520
35697
  } : null;
34521
35698
  let brokerStats = null;
34522
35699
  try {
@@ -34559,7 +35736,7 @@ var BrokerWebrtcServer = class {
34559
35736
  /** Snapshot the ladder's move so the overlay can name it later. */
34560
35737
  recordLadderDecision(sessionId, decision, tier, forcedTranscodeDown) {
34561
35738
  this.lastLadderDecision.set(sessionId, {
34562
- cause: decision.cause,
35739
+ ...wireLadderCause(decision, ADAPTIVE_CONFIG.stallConfirmMs),
34563
35740
  tier,
34564
35741
  forcedTranscodeDown,
34565
35742
  lossRatio: decision.lossRatio,
@@ -34570,6 +35747,39 @@ var BrokerWebrtcServer = class {
34570
35747
  });
34571
35748
  }
34572
35749
  /**
35750
+ * The IDR interval the broker feeding a session measured, or null when it
35751
+ * has measured none (a fresh broker reports 0). Sizes the delivery window.
35752
+ */
35753
+ brokerIdrIntervalMs(brokerId) {
35754
+ try {
35755
+ const idr = this.brokers.get(brokerId)?.getStats().idrIntervalMs ?? null;
35756
+ return idr !== null && idr > 0 ? idr : null;
35757
+ } catch {
35758
+ return null;
35759
+ }
35760
+ }
35761
+ /**
35762
+ * The expected bitrate of `tier` for this device, from the broker serving
35763
+ * that rung — null when no such broker is registered here or it has
35764
+ * measured nothing. Unknown never vetoes a climb (D315/D393).
35765
+ */
35766
+ expectedTierKbps(deviceId, tier) {
35767
+ const prefix = `${String(deviceId)}/`;
35768
+ const candidates = [];
35769
+ for (const [bid, broker] of this.brokers) if (bid.startsWith(prefix)) candidates.push({
35770
+ brokerId: bid,
35771
+ tier: this.profileTierResolver?.(bid) ?? null,
35772
+ broker
35773
+ });
35774
+ const match = preferLiveAdaptiveCandidates(candidates).find((c) => c.tier === tier);
35775
+ if (match === void 0) return null;
35776
+ try {
35777
+ return expectedRungKbps(match.broker.getStats());
35778
+ } catch {
35779
+ return null;
35780
+ }
35781
+ }
35782
+ /**
34573
35783
  * Wire an {@link AdaptiveController} to a freshly-created adaptive session.
34574
35784
  * The controller is fed loss from two sources — RTCP Receiver Reports
34575
35785
  * (`onReceiverReport`) and, when injected, the device's client-reported
@@ -34586,12 +35796,20 @@ var BrokerWebrtcServer = class {
34586
35796
  * whichever produces the higher loss ratio across its window. The async
34587
35797
  * poll is guarded against overlapping ticks via `tickInFlight`.
34588
35798
  */
34589
- startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures, priorUpgradeCount) {
35799
+ startAdaptive(entry, deviceId, initialTier, sessionId, intent) {
35800
+ const startedAt = Date.now();
35801
+ /** Rung bitrates, looked up at most once per tier per tick (review m12). */
35802
+ const rungKbps = /* @__PURE__ */ new Map();
34590
35803
  const controller = new AdaptiveController({
34591
35804
  initialTier,
34592
- priorFailures,
34593
- priorUpgradeCount,
35805
+ priorFailures: intent?.failures,
35806
+ priorUpgradeCount: intent?.upgradeCount,
35807
+ tryClimb: intent?.tryClimb ?? this.deviceTryMemory.get(deviceId, startedAt) ?? void 0,
34594
35808
  config: ADAPTIVE_CONFIG,
35809
+ expectedTierKbps: (tier) => {
35810
+ if (!rungKbps.has(tier)) rungKbps.set(tier, this.expectedTierKbps(deviceId, tier));
35811
+ return rungKbps.get(tier) ?? null;
35812
+ },
34595
35813
  onSwitchTier: (tier, decision) => {
34596
35814
  this.recordLadderDecision(sessionId, decision, tier, false);
34597
35815
  this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
@@ -34608,6 +35826,9 @@ var BrokerWebrtcServer = class {
34608
35826
  reason: decision.cause,
34609
35827
  lossRatio: Number(decision.lossRatio.toFixed(4)),
34610
35828
  clientKbps: decision.stall.clientKbps,
35829
+ sentKbps: decision.stall.sentKbps,
35830
+ deliveryRatio: decision.stall.deliveryRatio,
35831
+ deliveryWindowMs: decision.stall.deliveryWindowMs,
34611
35832
  stalledForMs: decision.stall.stalledForMs,
34612
35833
  serverSending: decision.stall.serverSending
34613
35834
  }
@@ -34627,14 +35848,39 @@ var BrokerWebrtcServer = class {
34627
35848
  * sending?" half of the stall verdict. */
34628
35849
  let lastRtpSent = entry.session.rtpPacketsSentTotal;
34629
35850
  const CLIENT_LOSS_SPAN = 1e3;
35851
+ const meter = new DeliveryMeter(ADAPTIVE_DELIVERY_CONFIG, deliveryMeterStart(startedAt, intent !== void 0, ADAPTIVE_CONFIG.waitAfterChangeMs));
35852
+ const holdLog = new HoldLogGate(HOLD_LOG_QUIET_MS);
35853
+ let lastTickFailureLogAt = Number.NEGATIVE_INFINITY;
35854
+ const logHoldChange = (now) => {
35855
+ const hold = controller.climbHold;
35856
+ if (hold === null || !holdLog.shouldLog(hold.code, now)) return;
35857
+ this.logger.info("adaptive: climb held", {
35858
+ tags: { deviceId },
35859
+ meta: {
35860
+ sessionId,
35861
+ deviceId,
35862
+ tier: controller.currentTier,
35863
+ nextTier: hold.nextTier,
35864
+ code: hold.code,
35865
+ summary: describeClimbHold(hold),
35866
+ deliveryRatio: hold.deliveryRatio,
35867
+ deliveryWindowMs: hold.deliveryWindowMs,
35868
+ capacityKbps: hold.capacityKbps,
35869
+ probeKbps: hold.probeKbps,
35870
+ nextTierKbps: hold.nextTierKbps
35871
+ }
35872
+ });
35873
+ };
34630
35874
  entry.adaptiveTimer = setInterval(() => {
34631
35875
  if (tickInFlight) return;
34632
35876
  tickInFlight = true;
34633
35877
  (async () => {
34634
35878
  try {
35879
+ rungKbps.clear();
34635
35880
  if (entry.timeline?.currentMode === "recorded") {
34636
35881
  controller.suspend(Date.now());
34637
35882
  lastRtpSent = entry.session.rtpPacketsSentTotal;
35883
+ logHoldChange(Date.now());
34638
35884
  return;
34639
35885
  }
34640
35886
  const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
@@ -34642,11 +35888,15 @@ var BrokerWebrtcServer = class {
34642
35888
  const rtpSent = entry.session.rtpPacketsSentTotal;
34643
35889
  const serverSending = rtpSent > lastRtpSent;
34644
35890
  lastRtpSent = rtpSent;
34645
- controller.onThroughput({
34646
- clientKbps: client ? client.estimatedBandwidthKbps : null,
34647
- clientSampleAgeMs: client ? Math.max(0, now - client.lastUpdated) : null,
34648
- serverSending
34649
- }, now);
35891
+ this.feedAdaptive({
35892
+ entry,
35893
+ deviceId,
35894
+ controller,
35895
+ meter,
35896
+ client,
35897
+ serverSending,
35898
+ now
35899
+ });
34650
35900
  const pct = client?.packetLossPercent ?? null;
34651
35901
  if (pct !== null && pct !== void 0) {
34652
35902
  clientHighestSequence += CLIENT_LOSS_SPAN;
@@ -34657,6 +35907,21 @@ var BrokerWebrtcServer = class {
34657
35907
  }, now);
34658
35908
  }
34659
35909
  controller.tick(now);
35910
+ this.deviceTryMemory.set(deviceId, controller.tryClimbCarry, now);
35911
+ logHoldChange(now);
35912
+ } catch (err) {
35913
+ const now = Date.now();
35914
+ if (now - lastTickFailureLogAt >= TICK_FAILURE_LOG_QUIET_MS) {
35915
+ lastTickFailureLogAt = now;
35916
+ this.logger.warn("adaptive: tick failed", {
35917
+ tags: { deviceId },
35918
+ meta: {
35919
+ sessionId,
35920
+ deviceId,
35921
+ error: errMsg(err)
35922
+ }
35923
+ });
35924
+ }
34660
35925
  } finally {
34661
35926
  tickInFlight = false;
34662
35927
  }
@@ -34664,6 +35929,55 @@ var BrokerWebrtcServer = class {
34664
35929
  }, ADAPTIVE_TICK_MS);
34665
35930
  }
34666
35931
  /**
35932
+ * One evaluation's transport, capacity and RTT evidence into the controller.
35933
+ * The device-scoped client record is used as this session's own only when
35934
+ * this is the device's ONLY adaptive session here (review I2).
35935
+ */
35936
+ feedAdaptive(feed) {
35937
+ const { entry, deviceId, controller, meter, client, now } = feed;
35938
+ const sessionsOnDevice = this.adaptiveSessionCount(deviceId);
35939
+ const shared = sessionsOnDevice > 1;
35940
+ let rttMs = null;
35941
+ if (client) {
35942
+ const reportAgeMs = Math.max(0, now - client.lastUpdated);
35943
+ meter.observe(client.estimatedBandwidthKbps, client.lastUpdated);
35944
+ if (!shared) {
35945
+ this.recordDeviceProbe(deviceId, probeFromReport({
35946
+ downlinkMbps: client.downlinkMbps,
35947
+ downlinkAgeMs: client.downlinkAgeMs ?? 0,
35948
+ reportAgeMs
35949
+ }, now));
35950
+ if (reportAgeMs <= ADAPTIVE_DELIVERY_CONFIG.maxClientSampleAgeMs) rttMs = client.rttMs ?? null;
35951
+ }
35952
+ }
35953
+ const history = entry.session.sentVideoHistory;
35954
+ controller.onThroughput(buildStallSample({
35955
+ delivery: meter.evaluate(history, deliveryWindowMs(this.brokerIdrIntervalMs(entry.brokerId), ADAPTIVE_DELIVERY_CONFIG), now),
35956
+ recent: meter.evaluateNewest(history, now),
35957
+ clientReporting: meter.isReporting(now),
35958
+ serverSending: feed.serverSending,
35959
+ adaptiveSessionsOnDevice: sessionsOnDevice
35960
+ }), now);
35961
+ const probe = this.deviceProbe.get(deviceId, now);
35962
+ controller.onCapacity({
35963
+ rembKbps: entry.session.receiverEstimateKbps(now),
35964
+ probeKbps: probe?.kbps ?? null,
35965
+ probeFresh: isProbeFresh(probe, now)
35966
+ });
35967
+ controller.onRtt(rttMs, now);
35968
+ }
35969
+ /** Adaptive sessions of `deviceId` currently on this broker server. */
35970
+ adaptiveSessionCount(deviceId) {
35971
+ const prefix = `${String(deviceId)}/`;
35972
+ let count = 0;
35973
+ for (const s of this.sessions.values()) if (s.adaptiveController !== void 0 && s.brokerId.startsWith(prefix)) count += 1;
35974
+ return count;
35975
+ }
35976
+ /** Keep the newest probe per device; an absent one changes nothing. */
35977
+ recordDeviceProbe(deviceId, probe) {
35978
+ this.deviceProbe.record(deviceId, probe);
35979
+ }
35980
+ /**
34667
35981
  * Schedule an adaptive tier switch via a close-and-recreate re-offer.
34668
35982
  *
34669
35983
  * Records the controller's desired tier (+ forced transcode-down) in
@@ -34680,11 +35994,18 @@ var BrokerWebrtcServer = class {
34680
35994
  const prev = this.adaptiveIntent.get(sessionId);
34681
35995
  const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
34682
35996
  const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
35997
+ const tryClimb = entry.adaptiveController?.tryClimbCarry ?? prev?.tryClimb;
34683
35998
  this.adaptiveIntent.set(sessionId, {
34684
35999
  prefersTier: tier,
34685
36000
  forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
34686
36001
  failures,
34687
- upgradeCount
36002
+ upgradeCount,
36003
+ tryClimb: tryClimb ?? {
36004
+ failures: 0,
36005
+ notBefore: 0,
36006
+ lastRevert: null,
36007
+ trialFrom: null
36008
+ }
34688
36009
  });
34689
36010
  const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
34690
36011
  entry.session.pendingRenegotiation = {
@@ -34702,8 +36023,12 @@ var BrokerWebrtcServer = class {
34702
36023
  failures: Object.fromEntries(failures),
34703
36024
  upgradeCount,
34704
36025
  reason: decision?.cause ?? "unknown",
36026
+ trial: decision?.trial ?? false,
34705
36027
  lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
34706
36028
  clientKbps: decision?.stall.clientKbps ?? null,
36029
+ sentKbps: decision?.stall.sentKbps ?? null,
36030
+ deliveryRatio: decision?.stall.deliveryRatio ?? null,
36031
+ deliveryWindowMs: decision?.stall.deliveryWindowMs ?? null,
34707
36032
  stalledForMs: decision?.stall.stalledForMs ?? null,
34708
36033
  serverSending: decision?.stall.serverSending ?? null
34709
36034
  }
@@ -34720,6 +36045,10 @@ var BrokerWebrtcServer = class {
34720
36045
  }
34721
36046
  if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
34722
36047
  this.adaptiveIntent.delete(sessionId);
36048
+ if (entry.adaptiveController !== void 0) {
36049
+ const deviceId = deviceIdFromBrokerId(entry.brokerId);
36050
+ if (this.adaptiveSessionCount(deviceId) === 0) this.deviceProbe.forget(deviceId);
36051
+ }
34723
36052
  this.pruneLadderDecisions();
34724
36053
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
34725
36054
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
@@ -35880,7 +37209,10 @@ var StreamBrokerAddon = class extends BaseAddon {
35880
37209
  return {
35881
37210
  packetLossPercent: client.packetLossPercent,
35882
37211
  estimatedBandwidthKbps: client.estimatedBandwidthKbps,
35883
- lastUpdated: client.lastUpdated
37212
+ lastUpdated: client.lastUpdated,
37213
+ rttMs: client.rttMs,
37214
+ ...client.downlinkMbps !== void 0 ? { downlinkMbps: client.downlinkMbps } : {},
37215
+ ...client.downlinkAgeMs !== void 0 ? { downlinkAgeMs: client.downlinkAgeMs } : {}
35884
37216
  };
35885
37217
  } catch {
35886
37218
  return null;
@@ -35888,8 +37220,8 @@ var StreamBrokerAddon = class extends BaseAddon {
35888
37220
  clearTimeout(timer);
35889
37221
  }
35890
37222
  },
35891
- locateRecordedSegment: (args) => this.ctx.api.recording.locateSegment.query(args),
35892
- readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data,
37223
+ locateRecordedSegment: (args) => this.ctx.api.recordingArchive.locateSegment.query(args),
37224
+ readRecordedSegmentBytes: async (args) => (await this.ctx.api.recordingArchive.readSegmentBytes.query(args)).data,
35893
37225
  readClipBytes: async (args) => {
35894
37226
  const res = await this.ctx.api.videoclips.readClipBytes.query({
35895
37227
  deviceId: args.deviceId,
@@ -35922,7 +37254,7 @@ var StreamBrokerAddon = class extends BaseAddon {
35922
37254
  },
35923
37255
  readRecordedGopBytes: async (args) => {
35924
37256
  try {
35925
- return await this.ctx.api.recording.readGopBytes.query({
37257
+ return await this.ctx.api.recordingArchive.readGopBytes.query({
35926
37258
  deviceId: args.deviceId,
35927
37259
  profile: args.profile,
35928
37260
  startMs: args.startMs,
@@ -35937,7 +37269,7 @@ var StreamBrokerAddon = class extends BaseAddon {
35937
37269
  });
35938
37270
  }
35939
37271
  return {
35940
- data: (await this.ctx.api.recording.readSegmentBytes.query({
37272
+ data: (await this.ctx.api.recordingArchive.readSegmentBytes.query({
35941
37273
  deviceId: args.deviceId,
35942
37274
  profile: args.profile,
35943
37275
  startMs: args.startMs