@camstack/addon-provider-reolink 1.2.161 → 1.2.163

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 (3) hide show
  1. package/dist/addon.js +583 -17
  2. package/dist/addon.mjs +583 -17
  3. package/package.json +2 -2
package/dist/addon.js CHANGED
@@ -5391,7 +5391,7 @@ var ZodIssueCode = {
5391
5391
  var ZodFirstPartyTypeKind;
5392
5392
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5393
5393
  //#endregion
5394
- //#region ../types/dist/sleep-i3eUVc-d.mjs
5394
+ //#region ../types/dist/sleep-COWaSCAi.mjs
5395
5395
  /**
5396
5396
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5397
5397
  * window to float samples (D455).
@@ -7607,6 +7607,24 @@ object({
7607
7607
  unreachable: number()
7608
7608
  })
7609
7609
  });
7610
+ /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
7611
+ var PeerBytesTicketSchema = object({
7612
+ /** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
7613
+ url: string().min(1),
7614
+ /**
7615
+ * The HOST node this URL means something on — the hub or a named agent,
7616
+ * never a runner. {@link AddonPeerBytes.open} compares it to its own and
7617
+ * refuses `cross-node` by name when they differ, without dialling.
7618
+ */
7619
+ hostNodeId: string().min(1),
7620
+ expiresAtMs: number().int().nonnegative(),
7621
+ /**
7622
+ * What the producer DECLARED the body to be, when it knows — `null` when it
7623
+ * does not. Never `0` for unknown (D393): a consumer sizing a bound off this
7624
+ * must be able to tell "the producer did not say" from "the body is empty".
7625
+ */
7626
+ declaredBytes: number().int().nonnegative().nullable()
7627
+ });
7610
7628
  /**
7611
7629
  * Adoption job — the background form of `device-adoption.adopt`.
7612
7630
  *
@@ -25352,6 +25370,7 @@ _enum([
25352
25370
  "sleeping",
25353
25371
  "camera-refused",
25354
25372
  "no-keyframe",
25373
+ "decode-failed",
25355
25374
  "no-catalog-row",
25356
25375
  "unsupported",
25357
25376
  "unknown-device",
@@ -25360,6 +25379,39 @@ _enum([
25360
25379
  _enum(["deferred", "final"]);
25361
25380
  /** The camera's / provider's own words. Kept verbatim, never parsed. */
25362
25381
  var CLIP_STILL_REASON_HEADER = "x-camstack-reason";
25382
+ /**
25383
+ * Make a refusal sentence safe to put in an HTTP header.
25384
+ *
25385
+ * **An HTTP header value is Latin-1.** Node throws `Invalid character in
25386
+ * header content` on anything above U+00FF, the handler dies mid-response,
25387
+ * and the client gets a socket hang up — no status, no reason, nothing. The
25388
+ * mechanism that exists to EXPLAIN a refusal is then the thing that silences
25389
+ * it, and the surface shows "nothing happened".
25390
+ *
25391
+ * That is not hypothetical: measured 2026-09-23 on 1436, a Hikvision replay
25392
+ * refusal read `SETUP of the video track answered 500 — the camera refused
25393
+ * this replay`, and the EM DASH in it threw on every clip the operator
25394
+ * opened. This repository writes its refusals in prose, and its prose uses
25395
+ * `—`, `’` and `…` everywhere, so every reason string is a candidate.
25396
+ *
25397
+ * Substitutes rather than strips: an operator reading a log or a response
25398
+ * should get a readable sentence, not one with holes in it. The typographic
25399
+ * characters this repo actually writes have exact ASCII equivalents; anything
25400
+ * else non-Latin-1 becomes `?`, which is visible rather than silent. Control
25401
+ * characters go too — a newline in a header value is a response-splitting
25402
+ * bug, not a formatting one.
25403
+ */
25404
+ function clipReasonHeaderValue(reason) {
25405
+ const folded = reason.replace(/[\u2013\u2014\u2212]/g, "-").replace(/[\u2018\u2019\u201b]/g, "'").replace(/[\u201c\u201d]/g, "\"").replace(/\u2026/g, "...").replace(/[\u00a0\u2007\u202f]/g, " ");
25406
+ let out = "";
25407
+ for (const ch of folded) {
25408
+ const code = ch.codePointAt(0) ?? 0;
25409
+ if (code < 32 || code === 127) out += " ";
25410
+ else if (code > 255) out += "?";
25411
+ else out += ch;
25412
+ }
25413
+ return out.slice(0, 500).trim();
25414
+ }
25363
25415
  /** One {@link ClipStillRefusalCodeSchema} token, alone. */
25364
25416
  var CLIP_STILL_REASON_CODE_HEADER = "x-camstack-reason-code";
25365
25417
  /** One {@link ClipStillDispositionSchema} token, alone. */
@@ -25622,12 +25674,37 @@ var ClipWakeSchema = _enum(["authorised"]);
25622
25674
  * the size in the message, never truncates. Half a video is worse than an
25623
25675
  * honest refusal.
25624
25676
  *
25625
- * The clean follow-on is a CHUNKED read so a `high` twin of a long clip stops
25626
- * being refusable at all. That is a later slice, named here so the bound is
25627
- * not mistaken for a design ceiling.
25677
+ * **Superseded as the way a clip export gets its bytes** (D613). The bound is
25678
+ * a property of the ENVELOPE, not of clips, so the cure was a transport with
25679
+ * no envelope: {@link videoclipsCapability.methods.offerClipBytes} hands back
25680
+ * a one-shot `peerBytes` ticket and the consumer streams it straight to disk,
25681
+ * holding nothing. This method stays for a consumer that genuinely wants the
25682
+ * bytes in hand, and for a provider not yet redeployed — with this bound,
25683
+ * which for an inline read is not going to move.
25628
25684
  */
25629
25685
  var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
25630
25686
  /**
25687
+ * Hard ceiling on ONE {@link videoclipsCapability.methods.offerClipBytes}
25688
+ * transfer — ten times {@link VIDEOCLIPS_MAX_READ_BYTES}, and the reasoning is
25689
+ * not "ten times more comfortable".
25690
+ *
25691
+ * The inline bound is set by what is HELD: a base64 envelope is the payload at
25692
+ * ~1.33× in the provider AND the same again in the caller, so 50 MiB of clip
25693
+ * is ~133 MiB of heap across two processes. Over `peerBytes` the consumer
25694
+ * holds one socket chunk at a time and writes straight through to the export
25695
+ * file, so its contribution is flat whatever the clip weighs. What is left is
25696
+ * the PRODUCER's own copy, and that is a property of how each provider makes a
25697
+ * clip rather than of this transport: Hikvision's fetch writes to a scratch
25698
+ * FILE and offers it (nothing is held), Reolink's cmd-5 transfer is collected
25699
+ * in memory and offered from there (one copy, the one it already had).
25700
+ *
25701
+ * So this number bounds the worst provider, not the transport, and it is named
25702
+ * separately so that lifting it is a statement about a provider rather than
25703
+ * about clips. Above it the provider REFUSES with the size in the message and
25704
+ * never truncates: half a video is worse than an honest refusal.
25705
+ */
25706
+ var VIDEOCLIPS_MAX_OFFER_BYTES = 500 * 1024 * 1024;
25707
+ /**
25631
25708
  * A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
25632
25709
  *
25633
25710
  * `bytes` is the DECODED length, so nobody infers it from the base64 length,
@@ -25659,6 +25736,32 @@ var ClipBytesSchema = object({
25659
25736
  durationMs: number().positive().optional()
25660
25737
  });
25661
25738
  /**
25739
+ * Where a clip's finished bytes can be TAKEN (D613) — the answer to
25740
+ * {@link videoclipsCapability.methods.offerClipBytes}.
25741
+ *
25742
+ * Everything {@link ClipBytesSchema} carries except the bytes themselves, plus
25743
+ * the one-shot ticket that leads to them. The metadata is answered BEFORE the
25744
+ * transfer on purpose: a consumer learns which twin it got, what to call the
25745
+ * file and how long the clip runs without having to read a byte, so a decision
25746
+ * it would make on that metadata (a wrong twin, an implausible duration) costs
25747
+ * no transfer at all.
25748
+ */
25749
+ var ClipBytesOfferSchema = object({
25750
+ /**
25751
+ * One shot, seconds-long, loopback, on the PROVIDER's own host. Open it with
25752
+ * `ctx.peerBytes.open(...)`, which refuses a ticket from another node by
25753
+ * name rather than dialling a port that means something else here.
25754
+ */
25755
+ ticket: PeerBytesTicketSchema,
25756
+ contentType: string(),
25757
+ /** Suggested filename, extension included. */
25758
+ name: string(),
25759
+ /** Which twin was actually served — see {@link ClipBytesSchema.served}. */
25760
+ served: CamProfileSchema,
25761
+ /** See {@link ClipBytesSchema.durationMs}. Absent when nothing measured it. */
25762
+ durationMs: number().positive().optional()
25763
+ });
25764
+ /**
25662
25765
  * Where a clip's STREAM can be dialled (D597) — the answer to
25663
25766
  * {@link videoclipsCapability.methods.dialClipStream}.
25664
25767
  *
@@ -25734,6 +25837,105 @@ var ClipStreamDialSchema = object({
25734
25837
  /** Why `servedAudio` is `none` although sound was asked for. */
25735
25838
  audioReason: ClipStreamAudioReasonSchema.optional()
25736
25839
  });
25840
+ /**
25841
+ * The playback rates a clip can be DELIVERED at, ascending, always with `1`.
25842
+ *
25843
+ * These are the BROKER's: it re-paces frames it has already demuxed — the
25844
+ * `MonotonicClock` divides source elapsed time by the factor and the pacer
25845
+ * pushes that much faster — so the domain is the recorded one, {0} ∪ [0.25, 4],
25846
+ * and this is the discrete ladder drawn from it. `1.5` is in it because a
25847
+ * re-pacer has no reason to refuse it.
25848
+ *
25849
+ * `8` and `16` are deliberately NOT here. They are the CAMERA's own
25850
+ * `<playSpeed>` (Reolink cmd-5 replay), a different mechanism, still unwired
25851
+ * (D597, D600) — a rate change there costs a new dial and a new stream, and
25852
+ * the camera's 8x would need a resample the clip audio path has no decoder
25853
+ * for. Offering them today accepts a rate and delivers 1x, which is the whole
25854
+ * defect this list exists to end (D612). When `playSpeed` IS wired its rates
25855
+ * join THIS array — a second list elsewhere is the second authority D62
25856
+ * forbids.
25857
+ *
25858
+ * Every entry must survive the broker's `clampPlaybackRate` unchanged: a set
25859
+ * that offers what the clamp then moves is the same lie one step later.
25860
+ */
25861
+ var CLIP_BROKER_PACED_RATES = [
25862
+ .25,
25863
+ .5,
25864
+ 1,
25865
+ 1.5,
25866
+ 2,
25867
+ 4
25868
+ ];
25869
+ /**
25870
+ * The rates a REALTIME-BOUND stream can be delivered at.
25871
+ *
25872
+ * **Measured, and it is the whole reason this second ladder exists.** A
25873
+ * Hikvision RTSP replay is not a fast fetch: it delivers the recording at the
25874
+ * speed it was recorded. On 1436, 2026-09-23, one stream carried 176 access
25875
+ * units in 14 274 ms of wall clock at a measured 12.5 fps — 14.08 s of media
25876
+ * in 14.27 s, **1.01× realtime** — and a whole 18 s clip took 22.3 s to fetch
25877
+ * end to end. Reolink's cmd-5 transfer, which {@link CLIP_BROKER_PACED_RATES}
25878
+ * was written for, lands the same bytes at 49–62× and therefore has the entire
25879
+ * clip in hand within the first second.
25880
+ *
25881
+ * The broker's pacer consumes `rate` seconds of media per second of wall
25882
+ * clock while the socket supplies one. So at any rate above 1 the buffer
25883
+ * drains at `(rate − 1)×` and a stream that is only seconds old runs dry
25884
+ * almost at once — the viewer stops, the queue empties, and the provider's
25885
+ * own drain gate eventually reports `peer-stalled`. Nothing about that is a
25886
+ * bug to be fixed with a bigger buffer: there is no buffer, because the bytes
25887
+ * do not exist yet.
25888
+ *
25889
+ * So the honest answer is the DECLARATION (D612's own rule, applied to the
25890
+ * case it did not yet have): a rate this transport cannot deliver is never
25891
+ * offered. Below 1 is free — a slower pacer only lets the buffer grow.
25892
+ *
25893
+ * When a clip is served from a FILE the constraint is gone with the transport,
25894
+ * which is why `file` keeps the full ladder.
25895
+ */
25896
+ var CLIP_REALTIME_SOURCE_RATES = [
25897
+ .25,
25898
+ .5,
25899
+ 1
25900
+ ];
25901
+ /**
25902
+ * What a surface may DRAW for this provider's clips — the answer to
25903
+ * {@link videoclipsCapability.methods.getPlaybackOptions} (D612).
25904
+ *
25905
+ * The envelope is a PROVIDER fact, not a clip fact, and that is measured, not
25906
+ * assumed: the broker's `chooseClipPath` reads exactly two inputs — whether
25907
+ * `dialClipStream` and `readClipBytes` are wired — and both are constants of
25908
+ * the broker's own closure over the provider's methods. The `profile` it is
25909
+ * handed is explicitly not read. So every clip of a provider is served the
25910
+ * same way, and a per-clip channel carried a value that could not vary. The
25911
+ * per-clip `clipTransport` server message was removed for exactly that reason.
25912
+ *
25913
+ * Queried per camera, before a clip is picked, so a control is rendered or
25914
+ * DISABLED rather than offered and refused at play time (D62: a disabled
25915
+ * control reads as unavailable, one that undoes the gesture reads as broken).
25916
+ */
25917
+ var ClipPlaybackOptionsSchema = object({
25918
+ /**
25919
+ * How this provider's clips reach the player. `stream` is the provider's
25920
+ * forward-only fMP4 (D597); `file` is one bounded by-handle fetch of the
25921
+ * whole clip, `stbl` indexed (D575).
25922
+ */
25923
+ transport: _enum(["stream", "file"]),
25924
+ /** `forward` = only ahead of the playhead. `free` = anywhere. */
25925
+ seek: _enum(["forward", "free"]),
25926
+ /** Frame-step BACKWARD is meaningful. Forward always is. */
25927
+ stepBack: boolean(),
25928
+ /** Whether the scrub gesture is served, as opposed to refused by name. */
25929
+ scrub: boolean(),
25930
+ /**
25931
+ * The rates that can be delivered, ascending, always containing `1`. The
25932
+ * viewer draws its picker from this and from nothing else — a constant it
25933
+ * keeps instead is the second authority that produced the defect: `8` and
25934
+ * `16` were offered, the broker clamped them to `4`, and no line anywhere
25935
+ * said so. `0` is not a member: pause is the absence of a rate.
25936
+ */
25937
+ rates: array(number().positive()).min(1).readonly()
25938
+ });
25737
25939
  var ClipSourceAvailabilitySchema = object({
25738
25940
  state: _enum([
25739
25941
  "ok",
@@ -25912,14 +26114,18 @@ var videoclipsCapability = {
25912
26114
  *
25913
26115
  * `getClipPlayback` is the right answer for a player: it hands back a URL
25914
26116
  * on a plane the hub serves `access:'authenticated'`, which a browser and a
25915
- * viewer session satisfy. It is the wrong answer for another ADDON. There
25916
- * is no addon→addon byte transport in this framework — `AddonDataPlane`
25917
- * only lets an addon SERVE, on `127.0.0.1` behind a per-listener secret
25918
- * only the hub may present — so a recorder that wants a camera's clip
25919
- * cannot fetch that URL. This method is the one seam that exists for it,
25920
- * and it is deliberately the same shape (and the same bound) as
25921
- * `recordingExport.readExportBytes`, which exists for the mirror-image
25922
- * reason.
26117
+ * viewer session satisfy. It is the wrong answer for another ADDON: the
26118
+ * hub's proxy in front of that plane takes only a user credential, which
26119
+ * an addon does not hold, so a recorder that wants a camera's clip cannot
26120
+ * fetch that URL. This method is the shape that answer forced — the same
26121
+ * one (and the same bound) as `recordingExport.readExportBytes`.
26122
+ *
26123
+ * **It is no longer the only seam.** Until D613 there was no addon→addon
26124
+ * byte transport at all; there is now
26125
+ * ({@link offerClipBytes}, over `ctx.peerBytes`), it holds nothing on
26126
+ * either side, and it is what a clip EXPORT uses. This method remains for
26127
+ * a consumer that genuinely wants the bytes in hand, and as the named
26128
+ * fallback for a provider not yet redeployed.
25923
26129
  *
25924
26130
  * Routing needs no `provider` pin: the id is source-prefixed and
25925
26131
  * self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
@@ -25986,6 +26192,65 @@ var videoclipsCapability = {
25986
26192
  auth: "protected"
25987
26193
  }),
25988
26194
  /**
26195
+ * Where this clip's finished bytes can be TAKEN — the by-handle read a
26196
+ * clip EXPORT pulls, over the addon→addon byte transport (D613).
26197
+ *
26198
+ * This is {@link readClipBytes} with the envelope removed. Same gates,
26199
+ * same vocabulary, same completion rules, same `served` contract — the
26200
+ * only difference is that the bytes travel over a one-shot loopback
26201
+ * socket instead of inside a base64 field, so neither side holds the
26202
+ * payload whole and the 50 MiB refusal on a long `high` twin stops
26203
+ * existing. The bound that remains is
26204
+ * {@link VIDEOCLIPS_MAX_OFFER_BYTES}, and it bounds the PRODUCER's own
26205
+ * copy rather than the transport.
26206
+ *
26207
+ * **The ticket is loopback and same-host.** A provider on an agent mints a
26208
+ * URL that means nothing on the hub, and `ctx.peerBytes.open` refuses it
26209
+ * `cross-node` by name rather than dialling whatever else holds that port
26210
+ * here. A consumer that can be on the other side of a node boundary from
26211
+ * its provider must be able to read that refusal and say so; it must not
26212
+ * treat it as "no bytes".
26213
+ *
26214
+ * **A ticket is a one-shot bearer credential with a seconds-long life.**
26215
+ * Take it immediately, never persist it, never log its `url`. An untaken
26216
+ * ticket costs the provider one map entry until its TTL, and outstanding
26217
+ * tickets are bounded — which is also what makes a per-frame misuse of
26218
+ * this method refuse by name rather than work slowly (D9/D18: this is a
26219
+ * by-handle fetch of finished media, not a frame pipe).
26220
+ *
26221
+ * Optional on the provider for the same reason `readClipBytes` is: a
26222
+ * source with no camera socket behind it has no bytes. A provider that
26223
+ * predates this method answers `NOT_IMPLEMENTED`, and a consumer may fall
26224
+ * back to `readClipBytes` — but it says so in the log, with the deploy
26225
+ * hint, because that fallback re-imposes the 50 MiB refusal and an
26226
+ * operator who sees `too-large-to-transfer` after this shipped is looking
26227
+ * at a stale addon, not at a clip that cannot be exported.
26228
+ */
26229
+ offerClipBytes: optionalMethod(object({
26230
+ deviceId: number(),
26231
+ clipId: string().min(1),
26232
+ /** WHICH provider holds the bytes — see `readClipBytes.provider`. */
26233
+ provider: string().min(1),
26234
+ /** Which twin — `low | mid` → the sub file, `high` → the main twin. */
26235
+ profile: CamProfileSchema.optional(),
26236
+ /**
26237
+ * The CALLER's byte bound, so an over-size clip is refused before the
26238
+ * camera is touched rather than after. Capped by
26239
+ * {@link VIDEOCLIPS_MAX_OFFER_BYTES} whatever is passed; absent means
26240
+ * that ceiling.
26241
+ */
26242
+ maxBytes: number().int().positive().optional(),
26243
+ /**
26244
+ * The operator's authorisation to wake a sleeping camera for this
26245
+ * read. Absent — the default — means a sleeping standalone battery
26246
+ * camera is REFUSED by name, before any session is opened.
26247
+ */
26248
+ wake: ClipWakeSchema.optional()
26249
+ }), ClipBytesOfferSchema, {
26250
+ kind: "query",
26251
+ auth: "protected"
26252
+ }),
26253
+ /**
25989
26254
  * Where this clip's STREAM can be dialled (D597) — the forward-only
25990
26255
  * fMP4 the provider writes from the first muxed byte, for the broker to
25991
26256
  * play through the same WebRTC session as recorded footage, with the
@@ -26022,10 +26287,88 @@ var videoclipsCapability = {
26022
26287
  }), ClipStreamDialSchema, {
26023
26288
  kind: "query",
26024
26289
  auth: "protected"
26290
+ }),
26291
+ /**
26292
+ * What a surface may DRAW for this provider's clips: the rates it can be
26293
+ * played at, whether scrub is served, how far a position may be moved,
26294
+ * and whether a backward frame-step means anything (D612).
26295
+ *
26296
+ * **The only authority.** The per-clip `clipTransport` server message
26297
+ * that used to carry the same answer was removed: the broker's transport
26298
+ * choice reads nothing that varies per clip, so the clip level had no
26299
+ * information the provider does not already have, and two channels that
26300
+ * can disagree are worse than one (D62).
26301
+ *
26302
+ * Asked per camera and per provider, so it must stay CHEAP — it is a
26303
+ * statement about wiring, answered from a constant, never a call to the
26304
+ * camera. A provider answers with one of {@link CLIP_PLAYBACK_OPTIONS}
26305
+ * and never composes an envelope of its own.
26306
+ *
26307
+ * Optional, and absence is load-bearing: a provider that has not answered
26308
+ * has not restricted anything, and a viewer reads it as the freedom it
26309
+ * always had. See D612 on the rollout order that absence implies.
26310
+ */
26311
+ getPlaybackOptions: optionalMethod(object({
26312
+ deviceId: number(),
26313
+ /** WHICH provider to ask — the `addonId` a {@link ClipSourceSchema}
26314
+ * row carries. Required for the same reason `listClips` requires it:
26315
+ * a collection cap has no "the bound one" to resolve to (D554). */
26316
+ provider: string().min(1)
26317
+ }), ClipPlaybackOptionsSchema, {
26318
+ kind: "query",
26319
+ auth: "protected"
26025
26320
  })
26026
26321
  }
26027
26322
  };
26028
26323
  /**
26324
+ * NOTE ON PLACEMENT: this block lives AFTER the capability definition on
26325
+ * purpose. `scripts/lib/parse-cap.ts` finds a cap by the FIRST
26326
+ * `export const <X> = {` in the file, so an object literal declared above
26327
+ * `videoclipsCapability` is silently taken for the capability itself and
26328
+ * codegen emits a `DeviceProxy` entry that does not type-check. Found the
26329
+ * hard way, 2026-09-23.
26330
+ */
26331
+ /**
26332
+ * The two envelopes, spelled ONCE.
26333
+ *
26334
+ * A provider answers with the entry for the transport its own wiring buys —
26335
+ * `stream` if it implements `dialClipStream`, `file` if it implements only
26336
+ * `readClipBytes` — and never composes one of its own. One table is what
26337
+ * makes "the provider may not promise what no clip can be given" structural
26338
+ * instead of a discipline: there is nothing else to promise.
26339
+ */
26340
+ var CLIP_PLAYBACK_OPTIONS = {
26341
+ stream: {
26342
+ transport: "stream",
26343
+ seek: "forward",
26344
+ stepBack: false,
26345
+ scrub: false,
26346
+ rates: CLIP_BROKER_PACED_RATES
26347
+ },
26348
+ /**
26349
+ * A `stream` whose SOURCE runs at realtime — a Hikvision RTSP replay.
26350
+ *
26351
+ * Same verbs as `stream`, a shorter rate ladder, and the difference is a
26352
+ * measurement rather than a preference: see
26353
+ * {@link CLIP_REALTIME_SOURCE_RATES}. A third entry rather than a parameter,
26354
+ * because a provider must still be able to do nothing but NAME one of these.
26355
+ */
26356
+ realtimeStream: {
26357
+ transport: "stream",
26358
+ seek: "forward",
26359
+ stepBack: false,
26360
+ scrub: false,
26361
+ rates: CLIP_REALTIME_SOURCE_RATES
26362
+ },
26363
+ file: {
26364
+ transport: "file",
26365
+ seek: "free",
26366
+ stepBack: true,
26367
+ scrub: true,
26368
+ rates: CLIP_BROKER_PACED_RATES
26369
+ }
26370
+ };
26371
+ /**
26029
26372
  * Optional client-side hints sent at session creation to help the provider
26030
26373
  * pick the best native source. All fields optional — a viewer that knows
26031
26374
  * nothing still gets a sane default. (Relocated from the retired `webrtc`
@@ -47170,6 +47513,12 @@ Object.freeze({
47170
47513
  addonId: null,
47171
47514
  access: "view"
47172
47515
  },
47516
+ "videoclips.getPlaybackOptions": {
47517
+ capName: "videoclips",
47518
+ capScope: "device",
47519
+ addonId: null,
47520
+ access: "view"
47521
+ },
47173
47522
  "videoclips.listClips": {
47174
47523
  capName: "videoclips",
47175
47524
  capScope: "device",
@@ -47182,6 +47531,12 @@ Object.freeze({
47182
47531
  addonId: null,
47183
47532
  access: "view"
47184
47533
  },
47534
+ "videoclips.offerClipBytes": {
47535
+ capName: "videoclips",
47536
+ capScope: "device",
47537
+ addonId: null,
47538
+ access: "view"
47539
+ },
47185
47540
  "videoclips.readClipBytes": {
47186
47541
  capName: "videoclips",
47187
47542
  capScope: "device",
@@ -49228,6 +49583,11 @@ Object.freeze({
49228
49583
  form: "single",
49229
49584
  optional: false
49230
49585
  }],
49586
+ "videoclips.getPlaybackOptions": [{
49587
+ name: "deviceId",
49588
+ form: "single",
49589
+ optional: false
49590
+ }],
49231
49591
  "videoclips.listClips": [{
49232
49592
  name: "deviceId",
49233
49593
  form: "single",
@@ -49238,6 +49598,11 @@ Object.freeze({
49238
49598
  form: "single",
49239
49599
  optional: false
49240
49600
  }],
49601
+ "videoclips.offerClipBytes": [{
49602
+ name: "deviceId",
49603
+ form: "single",
49604
+ optional: false
49605
+ }],
49241
49606
  "videoclips.readClipBytes": [{
49242
49607
  name: "deviceId",
49243
49608
  form: "single",
@@ -79037,7 +79402,7 @@ var require_lz4 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
79037
79402
  };
79038
79403
  }));
79039
79404
  //#endregion
79040
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-IPKY5AWA.js
79405
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-7B2Z5FIA.js
79041
79406
  var import_fxp = require_fxp();
79042
79407
  var import_lz4 = /* @__PURE__ */ require_chunk.__toESM(require_lz4(), 1);
79043
79408
  function encodeHeader(h) {
@@ -82546,6 +82911,7 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
82546
82911
  if (prev.superseded) continue;
82547
82912
  prev.superseded = true;
82548
82913
  this.logger?.warn?.(`[BaichuanClient] cmd 5 replay session #${prev.id} (channelId=${prev.discriminator.channelId}, ${prev.discriminator.kind}) superseded by #${this.replaySessionSeq + 1} after ${prev.acceptedFrames} accepted frame(s). Frames still in flight for #${prev.id} will be dropped, not handed to its successor.`);
82914
+ prev.onSuperseded?.(/* @__PURE__ */ new Error(`cmd 5 replay session #${prev.id} was superseded by a later transfer on this socket after ${prev.acceptedFrames} accepted frame(s)`));
82549
82915
  }
82550
82916
  const session = {
82551
82917
  id: ++this.replaySessionSeq,
@@ -83268,6 +83634,10 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
83268
83634
  cleanup();
83269
83635
  reject(e instanceof Error ? e : new Error(String(e)));
83270
83636
  };
83637
+ session.onSuperseded = (reason) => {
83638
+ if (chunks.length > 0) finish(Buffer.concat(chunks));
83639
+ else fail(reason);
83640
+ };
83271
83641
  const armIdleFinish = () => {
83272
83642
  if (idleTimer) clearTimeout(idleTimer);
83273
83643
  idleTimer = setTimeout(() => {
@@ -151601,7 +151971,7 @@ function writeRefusal(res, refusal, deviceId, logger, clipKey) {
151601
151971
  else logger.debug(line, extras);
151602
151972
  const retryAfterMs = refusal.disposition === "deferred" ? refusal.retryAfterMs : void 0;
151603
151973
  res.writeHead(204, {
151604
- [CLIP_STILL_REASON_HEADER]: refusal.reason.slice(0, 500),
151974
+ [CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(refusal.reason),
151605
151975
  [CLIP_STILL_REASON_CODE_HEADER]: refusal.code,
151606
151976
  [CLIP_STILL_DISPOSITION_HEADER]: refusal.disposition,
151607
151977
  ...retryAfterMs === void 0 ? {} : { "retry-after": String(Math.max(1, Math.ceil(retryAfterMs / 1e3))) }
@@ -151798,7 +152168,7 @@ function createClipStreamHandler(deps) {
151798
152168
  status: resolved.status
151799
152169
  } });
151800
152170
  res.writeHead(resolved.status, {
151801
- [CLIP_STILL_REASON_HEADER]: resolved.detail.slice(0, 500),
152171
+ [CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(resolved.detail),
151802
152172
  [CLIP_STILL_REASON_CODE_HEADER]: resolved.code
151803
152173
  });
151804
152174
  res.end();
@@ -151871,7 +152241,7 @@ function createClipStreamHandler(deps) {
151871
152241
  }
151872
152242
  });
151873
152243
  res.writeHead(streamRefusalStatus(outcome.code), {
151874
- [CLIP_STILL_REASON_HEADER]: outcome.detail.slice(0, 500),
152244
+ [CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(outcome.detail),
151875
152245
  [CLIP_STILL_REASON_CODE_HEADER]: outcome.code
151876
152246
  });
151877
152247
  res.end();
@@ -152313,7 +152683,33 @@ function createClipStreamProducer(deps) {
152313
152683
  }
152314
152684
  });
152315
152685
  activeByHost.set(hostKey, ahead + 1);
152316
- const run = (lanes.get(hostKey) ?? Promise.resolve()).then(() => runAttempts(input)).finally(() => {
152686
+ let abandonedAt = null;
152687
+ input.sink.onceClose(() => {
152688
+ abandonedAt ??= Date.now();
152689
+ });
152690
+ const queuedAt = Date.now();
152691
+ const run = (lanes.get(hostKey) ?? Promise.resolve()).then(async () => {
152692
+ if (abandonedAt !== null) {
152693
+ const waitedMs = abandonedAt - queuedAt;
152694
+ deps.logger.info("videoclips: a queued clip stream was dropped, its peer had gone", {
152695
+ tags: { deviceId },
152696
+ meta: {
152697
+ clipKey: input.row.clipKey,
152698
+ twin: input.twin,
152699
+ branch: "queued-peer-gone",
152700
+ waitedMs,
152701
+ aheadWhenQueued: ahead
152702
+ }
152703
+ });
152704
+ return {
152705
+ kind: "refused",
152706
+ code: "fetch-failed",
152707
+ detail: `the peer left after ${String(waitedMs)} ms in the queue, so this clip was never fetched`,
152708
+ attempts: 0
152709
+ };
152710
+ }
152711
+ return await runAttempts(input);
152712
+ }).finally(() => {
152317
152713
  const left = (activeByHost.get(hostKey) ?? 1) - 1;
152318
152714
  if (left <= 0) activeByHost.delete(hostKey);
152319
152715
  else activeByHost.set(hostKey, left);
@@ -154543,6 +154939,118 @@ function createReolinkVideoclipsProvider(deps) {
154543
154939
  };
154544
154940
  },
154545
154941
  /**
154942
+ * The same clip, over the addon→addon byte transport (D613).
154943
+ *
154944
+ * Every gate {@link readClipBytes} runs, in the same order and with the
154945
+ * same vocabulary — the id, the row, a clip still being written, the twin,
154946
+ * the size the ROW declares, and the sleep gate, which the producer
154947
+ * decides before `getApi()` because on UDP the login IS the wake. The one
154948
+ * difference is the envelope: the bytes leave over a one-shot loopback
154949
+ * socket instead of inside a base64 field, so the caller holds nothing and
154950
+ * this process holds the one copy the cmd-5 transfer already produced.
154951
+ *
154952
+ * The bound is {@link VIDEOCLIPS_MAX_OFFER_BYTES}: ten times the inline
154953
+ * one, because the inline one was paying for two inflated copies and this
154954
+ * pays for one plain one.
154955
+ */
154956
+ offerClipBytes: async ({ deviceId, clipId, profile, maxBytes, wake }) => {
154957
+ const tags = { deviceId };
154958
+ const refuse = (code, detail, meta) => {
154959
+ deps.logger.warn("videoclips: refusing a clip byte offer", {
154960
+ tags,
154961
+ meta: {
154962
+ clipId,
154963
+ branch: code,
154964
+ ...meta
154965
+ }
154966
+ });
154967
+ throw new Error(clipExportFailure(code, detail));
154968
+ };
154969
+ const parsed = parseClipId(clipId);
154970
+ if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Reolink provider`, {});
154971
+ const clipKey = clipKeyFor(parsed.channel, parsed.fileName);
154972
+ const row = rowsByKey.get(`${deviceId}|${clipKey}`);
154973
+ if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${deviceId} — list the day that holds it first`, { clipKey });
154974
+ if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written, so it has no end to export`, { clipKey });
154975
+ const chosen = chooseClipTwin(profile, row);
154976
+ if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — serving the sub copy", {
154977
+ tags,
154978
+ meta: {
154979
+ clipId,
154980
+ clipKey,
154981
+ branch: "no-main-twin",
154982
+ asked: profile
154983
+ }
154984
+ });
154985
+ const twin = chosen.twin;
154986
+ const fileName = chosen.fileName;
154987
+ if (fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
154988
+ clipKey,
154989
+ twin
154990
+ });
154991
+ const served = chosen.served;
154992
+ const bound = Math.min(maxBytes ?? 524288e3, VIDEOCLIPS_MAX_OFFER_BYTES);
154993
+ const declaredBytes = twin === "main" ? row.mainBytes : row.subBytes;
154994
+ if (declaredBytes !== void 0 && declaredBytes > bound) return refuse("too-large-to-transfer", `the camera lists the ${twin} file of clip "${clipId}" as ${declaredBytes} bytes, over the ${bound}-byte bound for one transfer`, {
154995
+ clipKey,
154996
+ twin,
154997
+ declaredBytes,
154998
+ bound
154999
+ });
155000
+ const outcome = await deps.prepareOffer({
155001
+ deviceId,
155002
+ row,
155003
+ fileName,
155004
+ twin,
155005
+ ...wake !== void 0 ? { wake } : {}
155006
+ });
155007
+ if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
155008
+ if (outcome.bytes > bound) return refuse("too-large-to-transfer", `clip "${clipId}" muxed to ${outcome.bytes} bytes, over the ${bound}-byte bound for one transfer`, {
155009
+ clipKey,
155010
+ twin,
155011
+ bytes: outcome.bytes,
155012
+ bound
155013
+ });
155014
+ deps.logger.info("videoclips: offered a Reolink clip by handle", {
155015
+ tags,
155016
+ meta: {
155017
+ clipId,
155018
+ clipKey,
155019
+ twin,
155020
+ served,
155021
+ asked: profile ?? null,
155022
+ bytes: outcome.bytes,
155023
+ durationMs: outcome.durationMs,
155024
+ wake: wake ?? "none"
155025
+ }
155026
+ });
155027
+ return {
155028
+ ticket: outcome.ticket,
155029
+ contentType: "video/mp4",
155030
+ name: clipFileLabel(fileName),
155031
+ served,
155032
+ ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
155033
+ };
155034
+ },
155035
+ /**
155036
+ * What a surface may DRAW for this camera's clips (D612).
155037
+ *
155038
+ * A CONSTANT, and it has to be: this method is asked per camera, and the
155039
+ * answer is a fact about which accessors this provider wires, not about
155040
+ * the camera. Every clip here reaches the player over
155041
+ * {@link dialClipStream}'s forward-only stream, so every clip gets the
155042
+ * `stream` envelope — measured, not assumed: the broker's `chooseClipPath`
155043
+ * reads only whether the dial and the file read are wired, and never the
155044
+ * clip or the profile.
155045
+ *
155046
+ * The rates are the BROKER's re-pacing ladder. The camera's own
155047
+ * `<playSpeed>` (8, 16) is real and still unwired (D597, D600): it would
155048
+ * ride on the dial and cost a new stream per change, and its audio has no
155049
+ * resampler on this path. When it is wired its rates join
155050
+ * `CLIP_BROKER_PACED_RATES` — not a second list here.
155051
+ */
155052
+ getPlaybackOptions: async () => CLIP_PLAYBACK_OPTIONS.stream,
155053
+ /**
154546
155054
  * Where this clip's STREAM is dialled (D597) — the same questions
154547
155055
  * `readClipBytes` asks before the camera is touched (the id, the row, a
154548
155056
  * file still being written), the same vocabulary, and NO fetch: the URL
@@ -155513,6 +156021,62 @@ function createClipService(deps) {
155513
156021
  return outcome;
155514
156022
  };
155515
156023
  /**
156024
+ * The same clip, offered over the addon→addon byte transport (D613).
156025
+ *
156026
+ * It reuses {@link prepareBytes} whole — one fetch path, one set of
156027
+ * completion rules, one scratch. A second producer here would be the second
156028
+ * authority D558 refuses to create. What changes is only how the result
156029
+ * LEAVES: `offerBuffer` writes it to a one-shot loopback socket in bounded
156030
+ * chunks, honouring `write()`'s return (D447), instead of base64-ing it into
156031
+ * a cap envelope that the caller then copies again.
156032
+ */
156033
+ const prepareOffer = async (input) => {
156034
+ const peerBytes = deps.peerBytes;
156035
+ if (peerBytes === void 0) {
156036
+ deps.logger.warn("videoclips: no peer-bytes transport on this node", {
156037
+ tags: { deviceId: input.deviceId },
156038
+ meta: {
156039
+ clipKey: input.row.clipKey,
156040
+ branch: "no-peer-bytes"
156041
+ }
156042
+ });
156043
+ return {
156044
+ kind: "refused",
156045
+ code: "fetch-failed",
156046
+ detail: "this node has no addon-to-addon byte transport wired"
156047
+ };
156048
+ }
156049
+ const outcome = await prepareBytes(input);
156050
+ if (outcome.kind === "refused") return outcome;
156051
+ const offered = await peerBytes.offerBuffer({
156052
+ bytes: outcome.bytes,
156053
+ contentType: "video/mp4",
156054
+ label: "reolink-clip",
156055
+ deviceId: input.deviceId
156056
+ });
156057
+ if (offered.kind === "refused") {
156058
+ deps.logger.warn("videoclips: the clip byte offer could not be minted", {
156059
+ tags: { deviceId: input.deviceId },
156060
+ meta: {
156061
+ clipKey: input.row.clipKey,
156062
+ branch: offered.code,
156063
+ detail: offered.detail
156064
+ }
156065
+ });
156066
+ return {
156067
+ kind: "refused",
156068
+ code: "fetch-failed",
156069
+ detail: `${offered.code}: ${offered.detail}`
156070
+ };
156071
+ }
156072
+ return {
156073
+ kind: "ok",
156074
+ ticket: offered.ticket,
156075
+ durationMs: outcome.durationMs,
156076
+ bytes: outcome.bytes.byteLength
156077
+ };
156078
+ };
156079
+ /**
155516
156080
  * The STREAM's path (`clip-stream-route.ts`): the same gates, the same row,
155517
156081
  * nothing written. Beside the file path, not in its place.
155518
156082
  */
@@ -155552,6 +156116,7 @@ function createClipService(deps) {
155552
156116
  served: profile === "high" ? "high" : "low"
155553
156117
  }),
155554
156118
  prepareBytes,
156119
+ prepareOffer,
155555
156120
  prepareStreamDial: async ({ deviceId, row, twin, audio }) => {
155556
156121
  const gate = await gateClipDevice({
155557
156122
  deviceId,
@@ -170230,6 +170795,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
170230
170795
  this.clipService = createClipService({
170231
170796
  dataDir: this.ctx.dataDir,
170232
170797
  logger: this.ctx.logger,
170798
+ ...this.ctx.peerBytes !== void 0 ? { peerBytes: this.ctx.peerBytes } : {},
170233
170799
  resolveDevice: async (deviceId) => {
170234
170800
  const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
170235
170801
  return device instanceof ReolinkCamera ? device.getClipDeviceRef() : null;