@camstack/addon-pipeline 1.2.274 → 1.2.275

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 (32) 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-CAWhHQo2.js → default-detection-model-6lCvep7n.js} +1 -1
  4. package/dist/{default-detection-model-BXwE-Dvo.mjs → default-detection-model-dQSwHHfx.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-g5MfpiBh.mjs → dist-C1pCVJk7.mjs} +440 -32
  8. package/dist/{dist-k6Vu-AhZ.js → dist-NYjn7hkE.js} +469 -31
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-BXsHQH5h.mjs → node--UY0iepi.mjs} +1 -1
  12. package/dist/{node--u_jUObS.js → node-CVYY8a4y.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-Gl3EowL-.mjs → process-memory-BQOfL3Ig.mjs} +1 -1
  16. package/dist/{process-memory-DN7opum2.js → process-memory-DDvuSEMJ.js} +1 -1
  17. package/dist/recorder/index.js +495 -35
  18. package/dist/recorder/index.mjs +495 -35
  19. package/dist/{segment-demux-js-sYP_jndp.mjs → segment-demux-js-B0_aAzN0.mjs} +1 -1
  20. package/dist/{segment-demux-js-DHX8o8pI.js → segment-demux-js-DVupBBc4.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-CEy8IryH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BrNGbvkl.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-BV2tQ1w3.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-MU3_q1MX.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BnLVuoDg.mjs} +1 -1
  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-UKNN4qes.mjs → hostInit-CSJt3rp0.mjs} +2 -2
  28. package/dist/stream-broker/index.js +526 -69
  29. package/dist/stream-broker/index.mjs +526 -69
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BlXyfeXU.mjs +0 -26
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $t as createEvent, B as cameraStreamsCapability, Bt as webrtcSessionCapability, Cn as union, D as RATE_CONTROL_RELAXED, F as addonWidgetsSourceCapability, G as deriveBatteryPresence, Gt as invocationFromEncodeProfile, H as createLogChannelsProvider, Ht as AUDIO_PRESETS, It as streamBrokerCapability, Jt as BaseAddon, Kt as isSoftwareDecode, M as RingBuffer, O as RATE_CONTROL_TIGHT, Sn as string, U as declareLogChannel, V as createHwAccelCache, Vt as errMsg, Wt as buildFfmpegArgs$1, X as egressTransportFromRequest, Xt as DeviceFeature, Y as egressTranscodeSharingKey, Yt as CAM_PROFILE_ORDER, Zt as DeviceType, _ as EncodeProfileSchema, _n as literal, an as makeProfileBrokerId, b as G711_SCALE_CORRECTION_DB, ct as logChannelsCapability, fn as _enum, hn as discriminatedUnion, i as BatteryStatusSchema, in as isEvent, kt as resolveEgressDecodeHwAccel, ln as parseProfileBrokerId, mn as boolean, nn as expandAudioChunkToF32le, on as makeSourceBrokerId, pn as array, qt as BOOT_RECOVERY_BACKOFF_MS, sn as nodePin, st as loadContributionCapability, ut as maskUrlCredentials, vn as number, wn as EventCategory, wt as rankStreams, xn as record, yn as object, z as batteryCapability } from "../dist-g5MfpiBh.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-BXsHQH5h.mjs";
2
+ import { $t as CAM_PROFILE_ORDER, B as cameraStreamsCapability, Bt as streamBrokerCapability, Cn as number, D as RATE_CONTROL_RELAXED, Dn as string, Dt as rankStreams, En as record, F as addonWidgetsSourceCapability, G as declareLogChannel, Gt as errMsg, Jt as buildFfmpegArgs$1, Kt as AUDIO_PRESETS, M as RingBuffer, Nt as resolveEgressDecodeHwAccel, O as RATE_CONTROL_TIGHT, On as union, Q as egressTransportFromRequest, Qt as BaseAddon, Sn as literal, U as createHwAccelCache, W as createLogChannelsProvider, Wt as webrtcSessionCapability, Xt as isSoftwareDecode, Yt as invocationFromEncodeProfile, Z as egressTranscodeSharingKey, Zt as BOOT_RECOVERY_BACKOFF_MS, _ as EncodeProfileSchema, _n as _enum, b as G711_SCALE_CORRECTION_DB, bn as discriminatedUnion, dn as makeSourceBrokerId, dt as logChannelsCapability, en as CamProfileSchema, fn as nodePin, i as BatteryStatusSchema, in as createEvent, kn as EventCategory, ln as isEvent, mn as parseProfileBrokerId, nn as DeviceType, pt as maskUrlCredentials, q as deriveBatteryPresence, sn as expandAudioChunkToF32le, tn as DeviceFeature, un as makeProfileBrokerId, ut as loadContributionCapability, vn as array, wn as object, yn as boolean, z as batteryCapability } from "../dist-C1pCVJk7.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node--UY0iepi.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 { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-sYP_jndp.mjs";
7
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-B0_aAzN0.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -26566,6 +26566,21 @@ var ControlMessageSchema = discriminatedUnion("t", [
26566
26566
  epoch: number(),
26567
26567
  profile: string()
26568
26568
  }),
26569
+ object({
26570
+ t: literal("playClip"),
26571
+ deviceId: number(),
26572
+ clipId: string().min(1),
26573
+ offsetMs: number().min(0),
26574
+ /**
26575
+ * Which TWIN of the clip — the vendor's own quality pair, on the one
26576
+ * quality scale the system has. `auto` is deliberately not in
26577
+ * `CamProfileSchema`'s domain here for the same reason
26578
+ * `videoclips.getClipPlayback` refuses it: a stored file has no broker
26579
+ * session to resolve an adaptive tier against. Absent ⇒ the provider's own
26580
+ * default; never invented here.
26581
+ */
26582
+ profile: CamProfileSchema.optional()
26583
+ }),
26569
26584
  object({
26570
26585
  t: literal("seek"),
26571
26586
  epoch: number()
@@ -29885,6 +29900,18 @@ var TimelineSession = class {
29885
29900
  * old recorded position is not where the viewer is coming from.
29886
29901
  */
29887
29902
  lastPositionMs = null;
29903
+ /**
29904
+ * The clip the pointer is on, or `null` when the recorded source is the
29905
+ * recorder's own footage.
29906
+ *
29907
+ * It is deliberately NOT a third `mode`. Clip playback IS recorded mode with
29908
+ * a different byte source, so `currentMode` still reads `recorded` and every
29909
+ * invariant built on it holds unchanged — the adaptive ladder still suspends
29910
+ * itself while the pointer is off live, and `goLive` still leaves in one
29911
+ * dispatch (D570's go-live terminator: a mode `GO_LIVE` cannot leave is a
29912
+ * loop, and the cheapest way not to have one is not to add the mode).
29913
+ */
29914
+ clip = null;
29888
29915
  constructor(deps) {
29889
29916
  this.deps = deps;
29890
29917
  deps.session.setLiveFeedGate(false);
@@ -29903,13 +29930,26 @@ var TimelineSession = class {
29903
29930
  this.emitLiveProfile();
29904
29931
  switch (msg.t) {
29905
29932
  case "playRecorded":
29933
+ this.leaveClip();
29906
29934
  this.requestedProfile = msg.profile;
29907
29935
  await this.playAt(msg.epoch, msg.profile, { travelFromMs: this.lastPositionMs });
29908
29936
  break;
29937
+ case "playClip":
29938
+ await this.playClip(msg.deviceId, msg.clipId, msg.profile ?? null, msg.offsetMs);
29939
+ break;
29909
29940
  case "seek":
29941
+ if (this.clip) {
29942
+ await this.feeder?.seek(msg.epoch);
29943
+ break;
29944
+ }
29910
29945
  await this.playAt(msg.epoch, this.requestedProfile, { travelFromMs: this.lastPositionMs });
29911
29946
  break;
29912
29947
  case "scrubCommit":
29948
+ if (this.clip) {
29949
+ if (msg.dragId !== void 0) await this.feeder?.seek(msg.epoch, { dragId: msg.dragId });
29950
+ else await this.feeder?.seek(msg.epoch);
29951
+ break;
29952
+ }
29913
29953
  if (msg.dragId !== void 0) await this.playAt(msg.epoch, this.requestedProfile, { dragId: msg.dragId });
29914
29954
  else await this.playAt(msg.epoch, this.requestedProfile);
29915
29955
  break;
@@ -29935,6 +29975,14 @@ var TimelineSession = class {
29935
29975
  if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
29936
29976
  break;
29937
29977
  case "setProfile":
29978
+ if (this.clip) {
29979
+ this.deps.onLog?.("clip playback: setProfile ignored — a clip file has no recorded tier", {
29980
+ deviceId: this.clip.deviceId,
29981
+ clipId: this.clip.clipId,
29982
+ requested: msg.profile
29983
+ });
29984
+ break;
29985
+ }
29938
29986
  this.requestedProfile = msg.profile;
29939
29987
  if (msg.profile === this.profile) break;
29940
29988
  this.profile = msg.profile;
@@ -29953,12 +30001,52 @@ var TimelineSession = class {
29953
30001
  this.feeder?.dispose();
29954
30002
  this.deps.session.setLiveFeedGate(true);
29955
30003
  this.mode = "recorded";
29956
- const feeder = this.deps.makeFeeder(this.profile);
30004
+ const clip = this.clip;
30005
+ const makeClip = this.deps.makeClipFeeder;
30006
+ const feeder = clip && makeClip ? makeClip({ handle: clip }) : this.deps.makeFeeder(this.profile);
29957
30007
  if (this.rate !== 1) feeder.setRate(this.rate);
29958
30008
  this.feeder = feeder;
29959
30009
  return feeder;
29960
30010
  }
29961
30011
  /**
30012
+ * Put the pointer on a clip file: same track, same feeder, same control
30013
+ * protocol, on the clip's own axis.
30014
+ *
30015
+ * A session with no clip byte source REFUSES by name — it never gates the
30016
+ * live feed, never builds a feeder and never leaves the operator on a frozen
30017
+ * frame with no explanation (D391: the branch that accepts work and produces
30018
+ * nothing is the same branch as the one that drops it).
30019
+ */
30020
+ async playClip(deviceId, clipId, profile, offsetMs) {
30021
+ if (!this.deps.makeClipFeeder) {
30022
+ this.deps.onLog?.("clip playback: refused — no clip byte source wired on this session (videoclips.readClipBytes absent)", {
30023
+ deviceId,
30024
+ clipId,
30025
+ profile,
30026
+ offsetMs
30027
+ });
30028
+ this._emitState("error");
30029
+ return;
30030
+ }
30031
+ this.clip = {
30032
+ deviceId,
30033
+ clipId,
30034
+ profile
30035
+ };
30036
+ this.lastPositionMs = null;
30037
+ await this.enterRecorded().seek(offsetMs);
30038
+ }
30039
+ /** Drop the clip pointer. Every path back to the recorder's own footage — a
30040
+ * `playRecorded` and `goLive` — goes through here, so there is exactly one
30041
+ * place that can forget to. */
30042
+ leaveClip() {
30043
+ if (!this.clip) return;
30044
+ this.clip = null;
30045
+ this.feeder?.dispose();
30046
+ this.feeder = null;
30047
+ this.mode = "live";
30048
+ }
30049
+ /**
29962
30050
  * Play recorded footage at `epochMs`. If `locate` is wired and the requested
29963
30051
  * profile has no segment, try high → mid → low so events-mode cameras that
29964
30052
  * only kept one tier still play.
@@ -30049,6 +30137,7 @@ var TimelineSession = class {
30049
30137
  }));
30050
30138
  }
30051
30139
  goLive() {
30140
+ this.clip = null;
30052
30141
  this.feeder?.dispose();
30053
30142
  this.feeder = null;
30054
30143
  this.lastPositionMs = null;
@@ -30092,6 +30181,331 @@ var TimelineSession = class {
30092
30181
  }
30093
30182
  };
30094
30183
  //#endregion
30184
+ //#region src/stream-broker/recorded/clip-mp4-index.ts
30185
+ /**
30186
+ * The sample index for a CLIP FILE, so a clip can be another position of the
30187
+ * recorded source pointer (D575).
30188
+ *
30189
+ * `RecordedFeeder` asks its byte source two questions — "is there a segment at
30190
+ * `epochMs`?" and "give me its bytes" — and the recorder answers both from the
30191
+ * segment's own `mfra`. A clip is a progressive `+faststart` MP4: it has a
30192
+ * `moov` at the FRONT and no `mfra` at all, so the time→sample map has to be
30193
+ * read out of `stbl` instead. That is what this module does, and it is the only
30194
+ * place in the clip path that parses MP4 structure.
30195
+ *
30196
+ * **It is a TIME index, not a byte index, and that is deliberate.** The
30197
+ * offsets in a progressive `moov` (`stco`/`stsc`) are absolute to the WHOLE
30198
+ * file, so a GOP-ranged slice of one is not a standalone-demuxable MP4 — making
30199
+ * it one means rewriting `stbl`, i.e. a remux. A clip arrives whole and
30200
+ * already compressed (one bounded by-handle fetch, D9/D18), so the file IS its
30201
+ * byte range, exactly as `RecordedFeederDeps.readGop` says of the playback
30202
+ * path. Parsing offsets nobody reads would be a leftover describing a design
30203
+ * this slice does not have.
30204
+ *
30205
+ * What the index IS for:
30206
+ * 1. **The gate.** A file with no usable sample table cannot join the recorded
30207
+ * path, and is refused BY NAME rather than served as a zero-length
30208
+ * timeline. Measured 2026-09-20: an HKSV clip has `mvhd.duration = 0`,
30209
+ * `mdhd.duration = 0`, no `sidx` and no `mfra` — ffmpeg writes the trailer
30210
+ * only on close and the tee cuts like a pipe.
30211
+ * 2. **The file-relative timeline.** `durationMs` is the segment span the
30212
+ * feeder plays and the bound `ended` is decided on; `firstSyncMs` is where a
30213
+ * seek before the first keyframe snaps to.
30214
+ */
30215
+ var FOURCC_LENGTH = 4;
30216
+ var BOX_HEADER_LENGTH = 8;
30217
+ var LARGE_SIZE_HEADER_LENGTH = 16;
30218
+ /**
30219
+ * Walk the direct children of `[start, end)`. Returns `null` when the region is
30220
+ * structurally impossible (a size that does not fit), which the caller turns
30221
+ * into `malformed` — never a throw, because these bytes come off a camera.
30222
+ */
30223
+ function childBoxes(bytes, start, end) {
30224
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30225
+ const out = [];
30226
+ let off = start;
30227
+ while (off + BOX_HEADER_LENGTH <= end) {
30228
+ const declared = view.getUint32(off);
30229
+ let type = "";
30230
+ for (let i = 0; i < FOURCC_LENGTH; i++) type += String.fromCharCode(bytes[off + 4 + i] ?? 0);
30231
+ let size = declared;
30232
+ let contentStart = off + BOX_HEADER_LENGTH;
30233
+ if (declared === 1) {
30234
+ if (off + LARGE_SIZE_HEADER_LENGTH > end) return null;
30235
+ const hi = view.getUint32(off + 8);
30236
+ const lo = view.getUint32(off + 12);
30237
+ size = hi * 4294967296 + lo;
30238
+ contentStart = off + LARGE_SIZE_HEADER_LENGTH;
30239
+ } else if (declared === 0) size = end - off;
30240
+ if (size < contentStart - off) return null;
30241
+ const boxEnd = off + size;
30242
+ if (boxEnd > end) return null;
30243
+ out.push({
30244
+ type,
30245
+ start: off,
30246
+ end: boxEnd,
30247
+ contentStart
30248
+ });
30249
+ off = boxEnd;
30250
+ }
30251
+ return out;
30252
+ }
30253
+ function findBox(boxes, type) {
30254
+ for (const b of boxes) if (b.type === type) return b;
30255
+ return null;
30256
+ }
30257
+ function readSttsRuns(bytes, box) {
30258
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30259
+ const base = box.contentStart + 4;
30260
+ if (base + 4 > box.end) return null;
30261
+ const entries = view.getUint32(base);
30262
+ if (base + 4 + entries * 8 > box.end) return null;
30263
+ const runs = [];
30264
+ for (let i = 0; i < entries; i++) {
30265
+ const at = base + 4 + i * 8;
30266
+ runs.push({
30267
+ count: view.getUint32(at),
30268
+ delta: view.getUint32(at + 4)
30269
+ });
30270
+ }
30271
+ return runs;
30272
+ }
30273
+ function readSyncSampleNumbers(bytes, box) {
30274
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30275
+ const base = box.contentStart + 4;
30276
+ if (base + 4 > box.end) return null;
30277
+ const entries = view.getUint32(base);
30278
+ if (base + 4 + entries * 4 > box.end) return null;
30279
+ const out = /* @__PURE__ */ new Set();
30280
+ for (let i = 0; i < entries; i++) out.add(view.getUint32(base + 4 + i * 4));
30281
+ return out;
30282
+ }
30283
+ function mediaTimescale(bytes, mdhd) {
30284
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
30285
+ const version = bytes[mdhd.contentStart] ?? 0;
30286
+ const at = mdhd.contentStart + 4 + (version === 1 ? 16 : 8);
30287
+ if (at + 4 > mdhd.end) return null;
30288
+ const ts = view.getUint32(at);
30289
+ return ts > 0 ? ts : null;
30290
+ }
30291
+ function handlerType(bytes, hdlr) {
30292
+ const at = hdlr.contentStart + 8;
30293
+ if (at + FOURCC_LENGTH > hdlr.end) return "";
30294
+ let out = "";
30295
+ for (let i = 0; i < FOURCC_LENGTH; i++) out += String.fromCharCode(bytes[at + i] ?? 0);
30296
+ return out;
30297
+ }
30298
+ /** The video `trak` of a `moov`, or `null` when it has none. */
30299
+ function videoTrack(bytes, moov) {
30300
+ const children = childBoxes(bytes, moov.contentStart, moov.end);
30301
+ if (!children) return null;
30302
+ for (const trak of children) {
30303
+ if (trak.type !== "trak") continue;
30304
+ const trakKids = childBoxes(bytes, trak.contentStart, trak.end);
30305
+ const mdia = trakKids ? findBox(trakKids, "mdia") : null;
30306
+ if (!mdia) continue;
30307
+ const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
30308
+ const hdlr = mdiaKids ? findBox(mdiaKids, "hdlr") : null;
30309
+ if (hdlr && handlerType(bytes, hdlr) === "vide") return mdia;
30310
+ }
30311
+ return null;
30312
+ }
30313
+ /**
30314
+ * Read a clip file's time→sample map. Never throws: every structural failure
30315
+ * comes back as a named refusal so the caller can say WHY a clip will not play.
30316
+ */
30317
+ function indexClipMp4(bytes) {
30318
+ const top = childBoxes(bytes, 0, bytes.length);
30319
+ if (!top) return {
30320
+ kind: "refused",
30321
+ reason: "malformed"
30322
+ };
30323
+ const moov = findBox(top, "moov");
30324
+ if (!moov) return {
30325
+ kind: "refused",
30326
+ reason: "no-moov"
30327
+ };
30328
+ const mdia = videoTrack(bytes, moov);
30329
+ if (!mdia) return {
30330
+ kind: "refused",
30331
+ reason: "no-video-track"
30332
+ };
30333
+ const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
30334
+ if (!mdiaKids) return {
30335
+ kind: "refused",
30336
+ reason: "malformed"
30337
+ };
30338
+ const mdhd = findBox(mdiaKids, "mdhd");
30339
+ const minf = findBox(mdiaKids, "minf");
30340
+ if (!mdhd || !minf) return {
30341
+ kind: "refused",
30342
+ reason: "malformed"
30343
+ };
30344
+ const timescale = mediaTimescale(bytes, mdhd);
30345
+ if (timescale === null) return {
30346
+ kind: "refused",
30347
+ reason: "malformed"
30348
+ };
30349
+ const minfKids = childBoxes(bytes, minf.contentStart, minf.end);
30350
+ const stbl = minfKids ? findBox(minfKids, "stbl") : null;
30351
+ if (!stbl) return {
30352
+ kind: "refused",
30353
+ reason: "no-samples"
30354
+ };
30355
+ const stblKids = childBoxes(bytes, stbl.contentStart, stbl.end);
30356
+ if (!stblKids) return {
30357
+ kind: "refused",
30358
+ reason: "malformed"
30359
+ };
30360
+ const sttsBox = findBox(stblKids, "stts");
30361
+ if (!sttsBox) return {
30362
+ kind: "refused",
30363
+ reason: "no-samples"
30364
+ };
30365
+ const runs = readSttsRuns(bytes, sttsBox);
30366
+ if (!runs) return {
30367
+ kind: "refused",
30368
+ reason: "malformed"
30369
+ };
30370
+ const stssBox = findBox(stblKids, "stss");
30371
+ const syncNumbers = stssBox === null ? null : readSyncSampleNumbers(bytes, stssBox);
30372
+ if (stssBox !== null && syncNumbers === null) return {
30373
+ kind: "refused",
30374
+ reason: "malformed"
30375
+ };
30376
+ const samples = [];
30377
+ let ticks = 0;
30378
+ let syncSampleCount = 0;
30379
+ let firstSyncMs = null;
30380
+ for (const run of runs) for (let i = 0; i < run.count; i++) {
30381
+ const sampleNumber = samples.length + 1;
30382
+ const sync = syncNumbers === null ? true : syncNumbers.has(sampleNumber);
30383
+ const startMs = ticks * 1e3 / timescale;
30384
+ const durMs = run.delta * 1e3 / timescale;
30385
+ if (sync) {
30386
+ syncSampleCount++;
30387
+ if (firstSyncMs === null) firstSyncMs = startMs;
30388
+ }
30389
+ samples.push({
30390
+ startMs,
30391
+ durMs,
30392
+ sync
30393
+ });
30394
+ ticks += run.delta;
30395
+ }
30396
+ if (samples.length === 0 || ticks === 0) return {
30397
+ kind: "refused",
30398
+ reason: "no-samples"
30399
+ };
30400
+ return {
30401
+ kind: "index",
30402
+ durationMs: ticks * 1e3 / timescale,
30403
+ firstSyncMs: firstSyncMs ?? 0,
30404
+ syncSampleCount,
30405
+ samples
30406
+ };
30407
+ }
30408
+ //#endregion
30409
+ //#region src/stream-broker/recorded/clip-file-source.ts
30410
+ var ClipSourceError = class extends Error {
30411
+ reason;
30412
+ constructor(reason, message) {
30413
+ super(message);
30414
+ this.reason = reason;
30415
+ this.name = "ClipSourceError";
30416
+ }
30417
+ };
30418
+ function createClipFileSource(deps) {
30419
+ const maxBytes = deps.maxBytes ?? 67108864;
30420
+ const { deviceId, clipId, profile } = deps.handle;
30421
+ let resolved = null;
30422
+ let inFlight = null;
30423
+ /**
30424
+ * A refusal that can never change for this file — it is unindexable, or it
30425
+ * is over the bound. Cached so a drag does not re-fetch a 35 MB file per
30426
+ * pointer move. A FETCH failure is deliberately NOT cached: a sleeping
30427
+ * battery camera is a transient no, and the operator's retry must reach it.
30428
+ */
30429
+ let terminal = null;
30430
+ const refuse = (reason, message, data) => {
30431
+ const err = new ClipSourceError(reason, message);
30432
+ deps.onLog?.(message, {
30433
+ deviceId,
30434
+ clipId,
30435
+ profile,
30436
+ reason,
30437
+ ...data
30438
+ });
30439
+ return err;
30440
+ };
30441
+ const load = async () => {
30442
+ let bytes;
30443
+ try {
30444
+ bytes = await deps.read({
30445
+ deviceId,
30446
+ clipId,
30447
+ profile
30448
+ });
30449
+ } catch (err) {
30450
+ throw refuse("fetch-failed", "clip playback: byte fetch failed", { error: err instanceof Error ? err.message : String(err) });
30451
+ }
30452
+ if (bytes.length > maxBytes) {
30453
+ terminal = refuse("too-large", "clip playback: clip exceeds the broker byte bound", {
30454
+ bytes: bytes.length,
30455
+ maxBytes
30456
+ });
30457
+ throw terminal;
30458
+ }
30459
+ const index = indexClipMp4(bytes);
30460
+ if (index.kind === "refused") {
30461
+ terminal = refuse(index.reason, "clip playback: clip cannot be indexed — it will not play in the recorded path", { bytes: bytes.length });
30462
+ throw terminal;
30463
+ }
30464
+ return {
30465
+ bytes,
30466
+ index
30467
+ };
30468
+ };
30469
+ const ensure = async () => {
30470
+ if (terminal) throw terminal;
30471
+ if (resolved) return resolved;
30472
+ if (!inFlight) inFlight = load().then((r) => {
30473
+ resolved = r;
30474
+ return r;
30475
+ }).finally(() => {
30476
+ inFlight = null;
30477
+ });
30478
+ return inFlight;
30479
+ };
30480
+ return {
30481
+ async locate(args) {
30482
+ const { index, bytes } = await ensure();
30483
+ if (args.epochMs >= index.durationMs) return {
30484
+ kind: "gap",
30485
+ nearestEdgeMs: null,
30486
+ prevEndMs: index.durationMs
30487
+ };
30488
+ if (args.epochMs < index.firstSyncMs) return {
30489
+ kind: "gap",
30490
+ nearestEdgeMs: index.firstSyncMs,
30491
+ prevEndMs: null
30492
+ };
30493
+ return {
30494
+ kind: "segment",
30495
+ startMs: 0,
30496
+ durMs: index.durationMs,
30497
+ bytes: bytes.length
30498
+ };
30499
+ },
30500
+ async readBytes() {
30501
+ return (await ensure()).bytes;
30502
+ },
30503
+ async durationMs() {
30504
+ return (await ensure()).index.durationMs;
30505
+ }
30506
+ };
30507
+ }
30508
+ //#endregion
30095
30509
  //#region src/stream-broker/recorded/demux-off-thread.ts
30096
30510
  /**
30097
30511
  * Off-thread JS demux client — mp4box parse on a `worker_threads` Worker
@@ -30837,6 +31251,7 @@ var BrokerWebrtcServer = class {
30837
31251
  locateRecordedSegment;
30838
31252
  readRecordedSegmentBytes;
30839
31253
  readRecordedGopBytes;
31254
+ readClipBytes;
30840
31255
  resolveFfmpegPath;
30841
31256
  /**
30842
31257
  * THE ffmpeg primitive, for the transcode leg. `null` until the manager wires
@@ -30929,6 +31344,7 @@ var BrokerWebrtcServer = class {
30929
31344
  this.locateRecordedSegment = options.locateRecordedSegment;
30930
31345
  this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
30931
31346
  this.readRecordedGopBytes = options.readRecordedGopBytes;
31347
+ this.readClipBytes = options.readClipBytes;
30932
31348
  this.resolveFfmpegPath = options.resolveFfmpegPath;
30933
31349
  }
30934
31350
  registerBroker(brokerId, broker) {
@@ -31648,7 +32064,96 @@ var BrokerWebrtcServer = class {
31648
32064
  bytes: res.data
31649
32065
  };
31650
32066
  } : void 0;
32067
+ const readClip = this.readClipBytes;
31651
32068
  let timeline;
32069
+ /**
32070
+ * ONE feeder builder for BOTH sources (D575). `clip === null` is the
32071
+ * recorder's own footage; a clip swaps only the two byte closures —
32072
+ * everything else (demux, sink, audio codec branch, state/position/log
32073
+ * seams) is identical, which is the whole claim the clip path makes.
32074
+ */
32075
+ const buildFeeder = (profile, clip) => {
32076
+ const opusSession = session.isOpusAudio;
32077
+ const audioSink = opusSession ? {
32078
+ pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
32079
+ pushAudioOpus: (opus, ts) => session.writeRecordedAudioOpus(Buffer.from(opus), ts)
32080
+ } : {
32081
+ pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
32082
+ pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
32083
+ };
32084
+ const demuxAudio = opusSession ? async (b) => {
32085
+ return (await demuxSegmentAudioOpusPreferJs(b, {
32086
+ ffmpegPath: resolveFfmpeg?.(),
32087
+ onFfmpegFallback: (reason) => {
32088
+ if (audioOpusFallbackLogged) return;
32089
+ audioOpusFallbackLogged = true;
32090
+ sessionLogger.warn("recorded audio: JS Opus demux missed — ffmpeg AAC→Opus fallback (once/session)", { meta: {
32091
+ sessionId,
32092
+ deviceId,
32093
+ profile,
32094
+ reason
32095
+ } });
32096
+ }
32097
+ })).map((au) => ({
32098
+ data: au.data,
32099
+ ptsMs: au.ptsMs
32100
+ }));
32101
+ } : (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.());
32102
+ const clipSource = clip && readClip ? createClipFileSource({
32103
+ handle: clip,
32104
+ read: readClip,
32105
+ onLog: (msg, data) => sessionLogger.warn(msg, {
32106
+ tags: { deviceId: clip.deviceId },
32107
+ meta: {
32108
+ sessionId,
32109
+ ...data
32110
+ }
32111
+ })
32112
+ }) : null;
32113
+ return new RecordedFeeder({
32114
+ deviceId: clip ? clip.deviceId : deviceId,
32115
+ profile,
32116
+ locate: clipSource ? clipSource.locate : locateRecorded,
32117
+ readBytes: clipSource ? clipSource.readBytes : readRecorded,
32118
+ ...clipSource ? {} : {
32119
+ readGop,
32120
+ fragmentCache: scrubCache
32121
+ },
32122
+ demux: demuxVideo,
32123
+ demuxTally,
32124
+ demuxAudio,
32125
+ audioClockRateHz: opusSession ? 48e3 : 8e3,
32126
+ sink: audioSink,
32127
+ onState: (t) => {
32128
+ sessionLogger.info("recorded: feeder state", {
32129
+ tags: { deviceId: clip ? clip.deviceId : deviceId },
32130
+ meta: {
32131
+ sessionId,
32132
+ deviceId,
32133
+ profile,
32134
+ clipId: clip?.clipId,
32135
+ from: t.from,
32136
+ to: t.to,
32137
+ reason: t.reason,
32138
+ seekToken: t.seekToken,
32139
+ dragId: t.dragId
32140
+ }
32141
+ });
32142
+ timeline._emitState(t.to);
32143
+ },
32144
+ onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
32145
+ onLog: (msg, data) => sessionLogger.info(msg, {
32146
+ tags: { deviceId: clip ? clip.deviceId : deviceId },
32147
+ meta: {
32148
+ sessionId,
32149
+ deviceId,
32150
+ profile,
32151
+ clipId: clip?.clipId,
32152
+ ...data
32153
+ }
32154
+ })
32155
+ });
32156
+ };
31652
32157
  timeline = new TimelineSession({
31653
32158
  session,
31654
32159
  deviceId,
@@ -31679,71 +32184,15 @@ var BrokerWebrtcServer = class {
31679
32184
  } });
31680
32185
  });
31681
32186
  } : void 0,
31682
- makeFeeder: (profile) => {
31683
- const opusSession = session.isOpusAudio;
31684
- return new RecordedFeeder({
31685
- deviceId,
31686
- profile,
31687
- locate: locateRecorded,
31688
- readBytes: readRecorded,
31689
- readGop,
31690
- fragmentCache: scrubCache,
31691
- demux: demuxVideo,
31692
- demuxTally,
31693
- demuxAudio: opusSession ? async (b) => {
31694
- return (await demuxSegmentAudioOpusPreferJs(b, {
31695
- ffmpegPath: resolveFfmpeg?.(),
31696
- onFfmpegFallback: (reason) => {
31697
- if (audioOpusFallbackLogged) return;
31698
- audioOpusFallbackLogged = true;
31699
- sessionLogger.warn("recorded audio: JS Opus demux missed — ffmpeg AAC→Opus fallback (once/session)", { meta: {
31700
- sessionId,
31701
- deviceId,
31702
- profile,
31703
- reason
31704
- } });
31705
- }
31706
- })).map((au) => ({
31707
- data: au.data,
31708
- ptsMs: au.ptsMs
31709
- }));
31710
- } : (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.()),
31711
- audioClockRateHz: opusSession ? 48e3 : 8e3,
31712
- sink: opusSession ? {
31713
- pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
31714
- pushAudioOpus: (opus, ts) => session.writeRecordedAudioOpus(Buffer.from(opus), ts)
31715
- } : {
31716
- pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
31717
- pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
31718
- },
31719
- onState: (t) => {
31720
- sessionLogger.info("recorded: feeder state", {
31721
- tags: { deviceId },
31722
- meta: {
31723
- sessionId,
31724
- deviceId,
31725
- profile,
31726
- from: t.from,
31727
- to: t.to,
31728
- reason: t.reason,
31729
- seekToken: t.seekToken,
31730
- dragId: t.dragId
31731
- }
31732
- });
31733
- timeline._emitState(t.to);
31734
- },
31735
- onPosition: (epochMs, pushSeq) => timeline._emitPosition(epochMs, pushSeq),
31736
- onLog: (msg, data) => sessionLogger.info(msg, {
31737
- tags: { deviceId },
31738
- meta: {
31739
- sessionId,
31740
- deviceId,
31741
- profile,
31742
- ...data
31743
- }
31744
- })
31745
- });
31746
- }
32187
+ makeFeeder: (profile) => buildFeeder(profile, null),
32188
+ ...readClip ? { makeClipFeeder: ({ handle }) => buildFeeder(handle.profile ?? "clip", handle) } : {},
32189
+ onLog: (msg, data) => sessionLogger.warn(msg, {
32190
+ tags: { deviceId },
32191
+ meta: {
32192
+ sessionId,
32193
+ ...data
32194
+ }
32195
+ })
31747
32196
  });
31748
32197
  entry.timeline = timeline;
31749
32198
  sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
@@ -33246,6 +33695,14 @@ var StreamBrokerAddon = class extends BaseAddon {
33246
33695
  },
33247
33696
  locateRecordedSegment: (args) => this.ctx.api.recording.locateSegment.query(args),
33248
33697
  readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data,
33698
+ readClipBytes: async (args) => {
33699
+ const res = await this.ctx.api.videoclips.readClipBytes.query({
33700
+ deviceId: args.deviceId,
33701
+ clipId: args.clipId,
33702
+ ...args.profile !== null ? { profile: args.profile } : {}
33703
+ });
33704
+ return new Uint8Array(Buffer.from(res.base64, "base64"));
33705
+ },
33249
33706
  readRecordedGopBytes: async (args) => {
33250
33707
  try {
33251
33708
  return await this.ctx.api.recording.readGopBytes.query({