@camstack/addon-provider-reolink 1.2.162 → 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.
- package/dist/addon.js +520 -13
- package/dist/addon.mjs +520 -13
- 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-
|
|
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",
|
|
@@ -25655,12 +25674,37 @@ var ClipWakeSchema = _enum(["authorised"]);
|
|
|
25655
25674
|
* the size in the message, never truncates. Half a video is worse than an
|
|
25656
25675
|
* honest refusal.
|
|
25657
25676
|
*
|
|
25658
|
-
*
|
|
25659
|
-
*
|
|
25660
|
-
*
|
|
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.
|
|
25661
25684
|
*/
|
|
25662
25685
|
var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
|
|
25663
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
|
+
/**
|
|
25664
25708
|
* A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
|
|
25665
25709
|
*
|
|
25666
25710
|
* `bytes` is the DECODED length, so nobody infers it from the base64 length,
|
|
@@ -25692,6 +25736,32 @@ var ClipBytesSchema = object({
|
|
|
25692
25736
|
durationMs: number().positive().optional()
|
|
25693
25737
|
});
|
|
25694
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
|
+
/**
|
|
25695
25765
|
* Where a clip's STREAM can be dialled (D597) — the answer to
|
|
25696
25766
|
* {@link videoclipsCapability.methods.dialClipStream}.
|
|
25697
25767
|
*
|
|
@@ -25767,6 +25837,105 @@ var ClipStreamDialSchema = object({
|
|
|
25767
25837
|
/** Why `servedAudio` is `none` although sound was asked for. */
|
|
25768
25838
|
audioReason: ClipStreamAudioReasonSchema.optional()
|
|
25769
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
|
+
});
|
|
25770
25939
|
var ClipSourceAvailabilitySchema = object({
|
|
25771
25940
|
state: _enum([
|
|
25772
25941
|
"ok",
|
|
@@ -25945,14 +26114,18 @@ var videoclipsCapability = {
|
|
|
25945
26114
|
*
|
|
25946
26115
|
* `getClipPlayback` is the right answer for a player: it hands back a URL
|
|
25947
26116
|
* on a plane the hub serves `access:'authenticated'`, which a browser and a
|
|
25948
|
-
* viewer session satisfy. It is the wrong answer for another ADDON
|
|
25949
|
-
*
|
|
25950
|
-
*
|
|
25951
|
-
*
|
|
25952
|
-
*
|
|
25953
|
-
*
|
|
25954
|
-
*
|
|
25955
|
-
*
|
|
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.
|
|
25956
26129
|
*
|
|
25957
26130
|
* Routing needs no `provider` pin: the id is source-prefixed and
|
|
25958
26131
|
* self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
|
|
@@ -26019,6 +26192,65 @@ var videoclipsCapability = {
|
|
|
26019
26192
|
auth: "protected"
|
|
26020
26193
|
}),
|
|
26021
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
|
+
/**
|
|
26022
26254
|
* Where this clip's STREAM can be dialled (D597) — the forward-only
|
|
26023
26255
|
* fMP4 the provider writes from the first muxed byte, for the broker to
|
|
26024
26256
|
* play through the same WebRTC session as recorded footage, with the
|
|
@@ -26055,10 +26287,88 @@ var videoclipsCapability = {
|
|
|
26055
26287
|
}), ClipStreamDialSchema, {
|
|
26056
26288
|
kind: "query",
|
|
26057
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"
|
|
26058
26320
|
})
|
|
26059
26321
|
}
|
|
26060
26322
|
};
|
|
26061
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
|
+
/**
|
|
26062
26372
|
* Optional client-side hints sent at session creation to help the provider
|
|
26063
26373
|
* pick the best native source. All fields optional — a viewer that knows
|
|
26064
26374
|
* nothing still gets a sane default. (Relocated from the retired `webrtc`
|
|
@@ -47203,6 +47513,12 @@ Object.freeze({
|
|
|
47203
47513
|
addonId: null,
|
|
47204
47514
|
access: "view"
|
|
47205
47515
|
},
|
|
47516
|
+
"videoclips.getPlaybackOptions": {
|
|
47517
|
+
capName: "videoclips",
|
|
47518
|
+
capScope: "device",
|
|
47519
|
+
addonId: null,
|
|
47520
|
+
access: "view"
|
|
47521
|
+
},
|
|
47206
47522
|
"videoclips.listClips": {
|
|
47207
47523
|
capName: "videoclips",
|
|
47208
47524
|
capScope: "device",
|
|
@@ -47215,6 +47531,12 @@ Object.freeze({
|
|
|
47215
47531
|
addonId: null,
|
|
47216
47532
|
access: "view"
|
|
47217
47533
|
},
|
|
47534
|
+
"videoclips.offerClipBytes": {
|
|
47535
|
+
capName: "videoclips",
|
|
47536
|
+
capScope: "device",
|
|
47537
|
+
addonId: null,
|
|
47538
|
+
access: "view"
|
|
47539
|
+
},
|
|
47218
47540
|
"videoclips.readClipBytes": {
|
|
47219
47541
|
capName: "videoclips",
|
|
47220
47542
|
capScope: "device",
|
|
@@ -49261,6 +49583,11 @@ Object.freeze({
|
|
|
49261
49583
|
form: "single",
|
|
49262
49584
|
optional: false
|
|
49263
49585
|
}],
|
|
49586
|
+
"videoclips.getPlaybackOptions": [{
|
|
49587
|
+
name: "deviceId",
|
|
49588
|
+
form: "single",
|
|
49589
|
+
optional: false
|
|
49590
|
+
}],
|
|
49264
49591
|
"videoclips.listClips": [{
|
|
49265
49592
|
name: "deviceId",
|
|
49266
49593
|
form: "single",
|
|
@@ -49271,6 +49598,11 @@ Object.freeze({
|
|
|
49271
49598
|
form: "single",
|
|
49272
49599
|
optional: false
|
|
49273
49600
|
}],
|
|
49601
|
+
"videoclips.offerClipBytes": [{
|
|
49602
|
+
name: "deviceId",
|
|
49603
|
+
form: "single",
|
|
49604
|
+
optional: false
|
|
49605
|
+
}],
|
|
49274
49606
|
"videoclips.readClipBytes": [{
|
|
49275
49607
|
name: "deviceId",
|
|
49276
49608
|
form: "single",
|
|
@@ -79070,7 +79402,7 @@ var require_lz4 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
|
|
|
79070
79402
|
};
|
|
79071
79403
|
}));
|
|
79072
79404
|
//#endregion
|
|
79073
|
-
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-
|
|
79405
|
+
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-7B2Z5FIA.js
|
|
79074
79406
|
var import_fxp = require_fxp();
|
|
79075
79407
|
var import_lz4 = /* @__PURE__ */ require_chunk.__toESM(require_lz4(), 1);
|
|
79076
79408
|
function encodeHeader(h) {
|
|
@@ -82579,6 +82911,7 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
|
|
|
82579
82911
|
if (prev.superseded) continue;
|
|
82580
82912
|
prev.superseded = true;
|
|
82581
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)`));
|
|
82582
82915
|
}
|
|
82583
82916
|
const session = {
|
|
82584
82917
|
id: ++this.replaySessionSeq,
|
|
@@ -83301,6 +83634,10 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
|
|
|
83301
83634
|
cleanup();
|
|
83302
83635
|
reject(e instanceof Error ? e : new Error(String(e)));
|
|
83303
83636
|
};
|
|
83637
|
+
session.onSuperseded = (reason) => {
|
|
83638
|
+
if (chunks.length > 0) finish(Buffer.concat(chunks));
|
|
83639
|
+
else fail(reason);
|
|
83640
|
+
};
|
|
83304
83641
|
const armIdleFinish = () => {
|
|
83305
83642
|
if (idleTimer) clearTimeout(idleTimer);
|
|
83306
83643
|
idleTimer = setTimeout(() => {
|
|
@@ -154602,6 +154939,118 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
154602
154939
|
};
|
|
154603
154940
|
},
|
|
154604
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
|
+
/**
|
|
154605
155054
|
* Where this clip's STREAM is dialled (D597) — the same questions
|
|
154606
155055
|
* `readClipBytes` asks before the camera is touched (the id, the row, a
|
|
154607
155056
|
* file still being written), the same vocabulary, and NO fetch: the URL
|
|
@@ -155572,6 +156021,62 @@ function createClipService(deps) {
|
|
|
155572
156021
|
return outcome;
|
|
155573
156022
|
};
|
|
155574
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
|
+
/**
|
|
155575
156080
|
* The STREAM's path (`clip-stream-route.ts`): the same gates, the same row,
|
|
155576
156081
|
* nothing written. Beside the file path, not in its place.
|
|
155577
156082
|
*/
|
|
@@ -155611,6 +156116,7 @@ function createClipService(deps) {
|
|
|
155611
156116
|
served: profile === "high" ? "high" : "low"
|
|
155612
156117
|
}),
|
|
155613
156118
|
prepareBytes,
|
|
156119
|
+
prepareOffer,
|
|
155614
156120
|
prepareStreamDial: async ({ deviceId, row, twin, audio }) => {
|
|
155615
156121
|
const gate = await gateClipDevice({
|
|
155616
156122
|
deviceId,
|
|
@@ -170289,6 +170795,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
170289
170795
|
this.clipService = createClipService({
|
|
170290
170796
|
dataDir: this.ctx.dataDir,
|
|
170291
170797
|
logger: this.ctx.logger,
|
|
170798
|
+
...this.ctx.peerBytes !== void 0 ? { peerBytes: this.ctx.peerBytes } : {},
|
|
170292
170799
|
resolveDevice: async (deviceId) => {
|
|
170293
170800
|
const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
|
|
170294
170801
|
return device instanceof ReolinkCamera ? device.getClipDeviceRef() : null;
|
package/dist/addon.mjs
CHANGED
|
@@ -5386,7 +5386,7 @@ var ZodIssueCode = {
|
|
|
5386
5386
|
var ZodFirstPartyTypeKind;
|
|
5387
5387
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5388
5388
|
//#endregion
|
|
5389
|
-
//#region ../types/dist/sleep-
|
|
5389
|
+
//#region ../types/dist/sleep-COWaSCAi.mjs
|
|
5390
5390
|
/**
|
|
5391
5391
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5392
5392
|
* window to float samples (D455).
|
|
@@ -7602,6 +7602,24 @@ object({
|
|
|
7602
7602
|
unreachable: number()
|
|
7603
7603
|
})
|
|
7604
7604
|
});
|
|
7605
|
+
/** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
|
|
7606
|
+
var PeerBytesTicketSchema = object({
|
|
7607
|
+
/** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
|
|
7608
|
+
url: string().min(1),
|
|
7609
|
+
/**
|
|
7610
|
+
* The HOST node this URL means something on — the hub or a named agent,
|
|
7611
|
+
* never a runner. {@link AddonPeerBytes.open} compares it to its own and
|
|
7612
|
+
* refuses `cross-node` by name when they differ, without dialling.
|
|
7613
|
+
*/
|
|
7614
|
+
hostNodeId: string().min(1),
|
|
7615
|
+
expiresAtMs: number().int().nonnegative(),
|
|
7616
|
+
/**
|
|
7617
|
+
* What the producer DECLARED the body to be, when it knows — `null` when it
|
|
7618
|
+
* does not. Never `0` for unknown (D393): a consumer sizing a bound off this
|
|
7619
|
+
* must be able to tell "the producer did not say" from "the body is empty".
|
|
7620
|
+
*/
|
|
7621
|
+
declaredBytes: number().int().nonnegative().nullable()
|
|
7622
|
+
});
|
|
7605
7623
|
/**
|
|
7606
7624
|
* Adoption job — the background form of `device-adoption.adopt`.
|
|
7607
7625
|
*
|
|
@@ -25347,6 +25365,7 @@ _enum([
|
|
|
25347
25365
|
"sleeping",
|
|
25348
25366
|
"camera-refused",
|
|
25349
25367
|
"no-keyframe",
|
|
25368
|
+
"decode-failed",
|
|
25350
25369
|
"no-catalog-row",
|
|
25351
25370
|
"unsupported",
|
|
25352
25371
|
"unknown-device",
|
|
@@ -25650,12 +25669,37 @@ var ClipWakeSchema = _enum(["authorised"]);
|
|
|
25650
25669
|
* the size in the message, never truncates. Half a video is worse than an
|
|
25651
25670
|
* honest refusal.
|
|
25652
25671
|
*
|
|
25653
|
-
*
|
|
25654
|
-
*
|
|
25655
|
-
*
|
|
25672
|
+
* **Superseded as the way a clip export gets its bytes** (D613). The bound is
|
|
25673
|
+
* a property of the ENVELOPE, not of clips, so the cure was a transport with
|
|
25674
|
+
* no envelope: {@link videoclipsCapability.methods.offerClipBytes} hands back
|
|
25675
|
+
* a one-shot `peerBytes` ticket and the consumer streams it straight to disk,
|
|
25676
|
+
* holding nothing. This method stays for a consumer that genuinely wants the
|
|
25677
|
+
* bytes in hand, and for a provider not yet redeployed — with this bound,
|
|
25678
|
+
* which for an inline read is not going to move.
|
|
25656
25679
|
*/
|
|
25657
25680
|
var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
|
|
25658
25681
|
/**
|
|
25682
|
+
* Hard ceiling on ONE {@link videoclipsCapability.methods.offerClipBytes}
|
|
25683
|
+
* transfer — ten times {@link VIDEOCLIPS_MAX_READ_BYTES}, and the reasoning is
|
|
25684
|
+
* not "ten times more comfortable".
|
|
25685
|
+
*
|
|
25686
|
+
* The inline bound is set by what is HELD: a base64 envelope is the payload at
|
|
25687
|
+
* ~1.33× in the provider AND the same again in the caller, so 50 MiB of clip
|
|
25688
|
+
* is ~133 MiB of heap across two processes. Over `peerBytes` the consumer
|
|
25689
|
+
* holds one socket chunk at a time and writes straight through to the export
|
|
25690
|
+
* file, so its contribution is flat whatever the clip weighs. What is left is
|
|
25691
|
+
* the PRODUCER's own copy, and that is a property of how each provider makes a
|
|
25692
|
+
* clip rather than of this transport: Hikvision's fetch writes to a scratch
|
|
25693
|
+
* FILE and offers it (nothing is held), Reolink's cmd-5 transfer is collected
|
|
25694
|
+
* in memory and offered from there (one copy, the one it already had).
|
|
25695
|
+
*
|
|
25696
|
+
* So this number bounds the worst provider, not the transport, and it is named
|
|
25697
|
+
* separately so that lifting it is a statement about a provider rather than
|
|
25698
|
+
* about clips. Above it the provider REFUSES with the size in the message and
|
|
25699
|
+
* never truncates: half a video is worse than an honest refusal.
|
|
25700
|
+
*/
|
|
25701
|
+
var VIDEOCLIPS_MAX_OFFER_BYTES = 500 * 1024 * 1024;
|
|
25702
|
+
/**
|
|
25659
25703
|
* A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
|
|
25660
25704
|
*
|
|
25661
25705
|
* `bytes` is the DECODED length, so nobody infers it from the base64 length,
|
|
@@ -25687,6 +25731,32 @@ var ClipBytesSchema = object({
|
|
|
25687
25731
|
durationMs: number().positive().optional()
|
|
25688
25732
|
});
|
|
25689
25733
|
/**
|
|
25734
|
+
* Where a clip's finished bytes can be TAKEN (D613) — the answer to
|
|
25735
|
+
* {@link videoclipsCapability.methods.offerClipBytes}.
|
|
25736
|
+
*
|
|
25737
|
+
* Everything {@link ClipBytesSchema} carries except the bytes themselves, plus
|
|
25738
|
+
* the one-shot ticket that leads to them. The metadata is answered BEFORE the
|
|
25739
|
+
* transfer on purpose: a consumer learns which twin it got, what to call the
|
|
25740
|
+
* file and how long the clip runs without having to read a byte, so a decision
|
|
25741
|
+
* it would make on that metadata (a wrong twin, an implausible duration) costs
|
|
25742
|
+
* no transfer at all.
|
|
25743
|
+
*/
|
|
25744
|
+
var ClipBytesOfferSchema = object({
|
|
25745
|
+
/**
|
|
25746
|
+
* One shot, seconds-long, loopback, on the PROVIDER's own host. Open it with
|
|
25747
|
+
* `ctx.peerBytes.open(...)`, which refuses a ticket from another node by
|
|
25748
|
+
* name rather than dialling a port that means something else here.
|
|
25749
|
+
*/
|
|
25750
|
+
ticket: PeerBytesTicketSchema,
|
|
25751
|
+
contentType: string(),
|
|
25752
|
+
/** Suggested filename, extension included. */
|
|
25753
|
+
name: string(),
|
|
25754
|
+
/** Which twin was actually served — see {@link ClipBytesSchema.served}. */
|
|
25755
|
+
served: CamProfileSchema,
|
|
25756
|
+
/** See {@link ClipBytesSchema.durationMs}. Absent when nothing measured it. */
|
|
25757
|
+
durationMs: number().positive().optional()
|
|
25758
|
+
});
|
|
25759
|
+
/**
|
|
25690
25760
|
* Where a clip's STREAM can be dialled (D597) — the answer to
|
|
25691
25761
|
* {@link videoclipsCapability.methods.dialClipStream}.
|
|
25692
25762
|
*
|
|
@@ -25762,6 +25832,105 @@ var ClipStreamDialSchema = object({
|
|
|
25762
25832
|
/** Why `servedAudio` is `none` although sound was asked for. */
|
|
25763
25833
|
audioReason: ClipStreamAudioReasonSchema.optional()
|
|
25764
25834
|
});
|
|
25835
|
+
/**
|
|
25836
|
+
* The playback rates a clip can be DELIVERED at, ascending, always with `1`.
|
|
25837
|
+
*
|
|
25838
|
+
* These are the BROKER's: it re-paces frames it has already demuxed — the
|
|
25839
|
+
* `MonotonicClock` divides source elapsed time by the factor and the pacer
|
|
25840
|
+
* pushes that much faster — so the domain is the recorded one, {0} ∪ [0.25, 4],
|
|
25841
|
+
* and this is the discrete ladder drawn from it. `1.5` is in it because a
|
|
25842
|
+
* re-pacer has no reason to refuse it.
|
|
25843
|
+
*
|
|
25844
|
+
* `8` and `16` are deliberately NOT here. They are the CAMERA's own
|
|
25845
|
+
* `<playSpeed>` (Reolink cmd-5 replay), a different mechanism, still unwired
|
|
25846
|
+
* (D597, D600) — a rate change there costs a new dial and a new stream, and
|
|
25847
|
+
* the camera's 8x would need a resample the clip audio path has no decoder
|
|
25848
|
+
* for. Offering them today accepts a rate and delivers 1x, which is the whole
|
|
25849
|
+
* defect this list exists to end (D612). When `playSpeed` IS wired its rates
|
|
25850
|
+
* join THIS array — a second list elsewhere is the second authority D62
|
|
25851
|
+
* forbids.
|
|
25852
|
+
*
|
|
25853
|
+
* Every entry must survive the broker's `clampPlaybackRate` unchanged: a set
|
|
25854
|
+
* that offers what the clamp then moves is the same lie one step later.
|
|
25855
|
+
*/
|
|
25856
|
+
var CLIP_BROKER_PACED_RATES = [
|
|
25857
|
+
.25,
|
|
25858
|
+
.5,
|
|
25859
|
+
1,
|
|
25860
|
+
1.5,
|
|
25861
|
+
2,
|
|
25862
|
+
4
|
|
25863
|
+
];
|
|
25864
|
+
/**
|
|
25865
|
+
* The rates a REALTIME-BOUND stream can be delivered at.
|
|
25866
|
+
*
|
|
25867
|
+
* **Measured, and it is the whole reason this second ladder exists.** A
|
|
25868
|
+
* Hikvision RTSP replay is not a fast fetch: it delivers the recording at the
|
|
25869
|
+
* speed it was recorded. On 1436, 2026-09-23, one stream carried 176 access
|
|
25870
|
+
* units in 14 274 ms of wall clock at a measured 12.5 fps — 14.08 s of media
|
|
25871
|
+
* in 14.27 s, **1.01× realtime** — and a whole 18 s clip took 22.3 s to fetch
|
|
25872
|
+
* end to end. Reolink's cmd-5 transfer, which {@link CLIP_BROKER_PACED_RATES}
|
|
25873
|
+
* was written for, lands the same bytes at 49–62× and therefore has the entire
|
|
25874
|
+
* clip in hand within the first second.
|
|
25875
|
+
*
|
|
25876
|
+
* The broker's pacer consumes `rate` seconds of media per second of wall
|
|
25877
|
+
* clock while the socket supplies one. So at any rate above 1 the buffer
|
|
25878
|
+
* drains at `(rate − 1)×` and a stream that is only seconds old runs dry
|
|
25879
|
+
* almost at once — the viewer stops, the queue empties, and the provider's
|
|
25880
|
+
* own drain gate eventually reports `peer-stalled`. Nothing about that is a
|
|
25881
|
+
* bug to be fixed with a bigger buffer: there is no buffer, because the bytes
|
|
25882
|
+
* do not exist yet.
|
|
25883
|
+
*
|
|
25884
|
+
* So the honest answer is the DECLARATION (D612's own rule, applied to the
|
|
25885
|
+
* case it did not yet have): a rate this transport cannot deliver is never
|
|
25886
|
+
* offered. Below 1 is free — a slower pacer only lets the buffer grow.
|
|
25887
|
+
*
|
|
25888
|
+
* When a clip is served from a FILE the constraint is gone with the transport,
|
|
25889
|
+
* which is why `file` keeps the full ladder.
|
|
25890
|
+
*/
|
|
25891
|
+
var CLIP_REALTIME_SOURCE_RATES = [
|
|
25892
|
+
.25,
|
|
25893
|
+
.5,
|
|
25894
|
+
1
|
|
25895
|
+
];
|
|
25896
|
+
/**
|
|
25897
|
+
* What a surface may DRAW for this provider's clips — the answer to
|
|
25898
|
+
* {@link videoclipsCapability.methods.getPlaybackOptions} (D612).
|
|
25899
|
+
*
|
|
25900
|
+
* The envelope is a PROVIDER fact, not a clip fact, and that is measured, not
|
|
25901
|
+
* assumed: the broker's `chooseClipPath` reads exactly two inputs — whether
|
|
25902
|
+
* `dialClipStream` and `readClipBytes` are wired — and both are constants of
|
|
25903
|
+
* the broker's own closure over the provider's methods. The `profile` it is
|
|
25904
|
+
* handed is explicitly not read. So every clip of a provider is served the
|
|
25905
|
+
* same way, and a per-clip channel carried a value that could not vary. The
|
|
25906
|
+
* per-clip `clipTransport` server message was removed for exactly that reason.
|
|
25907
|
+
*
|
|
25908
|
+
* Queried per camera, before a clip is picked, so a control is rendered or
|
|
25909
|
+
* DISABLED rather than offered and refused at play time (D62: a disabled
|
|
25910
|
+
* control reads as unavailable, one that undoes the gesture reads as broken).
|
|
25911
|
+
*/
|
|
25912
|
+
var ClipPlaybackOptionsSchema = object({
|
|
25913
|
+
/**
|
|
25914
|
+
* How this provider's clips reach the player. `stream` is the provider's
|
|
25915
|
+
* forward-only fMP4 (D597); `file` is one bounded by-handle fetch of the
|
|
25916
|
+
* whole clip, `stbl` indexed (D575).
|
|
25917
|
+
*/
|
|
25918
|
+
transport: _enum(["stream", "file"]),
|
|
25919
|
+
/** `forward` = only ahead of the playhead. `free` = anywhere. */
|
|
25920
|
+
seek: _enum(["forward", "free"]),
|
|
25921
|
+
/** Frame-step BACKWARD is meaningful. Forward always is. */
|
|
25922
|
+
stepBack: boolean(),
|
|
25923
|
+
/** Whether the scrub gesture is served, as opposed to refused by name. */
|
|
25924
|
+
scrub: boolean(),
|
|
25925
|
+
/**
|
|
25926
|
+
* The rates that can be delivered, ascending, always containing `1`. The
|
|
25927
|
+
* viewer draws its picker from this and from nothing else — a constant it
|
|
25928
|
+
* keeps instead is the second authority that produced the defect: `8` and
|
|
25929
|
+
* `16` were offered, the broker clamped them to `4`, and no line anywhere
|
|
25930
|
+
* said so. `0` is not a member: pause is the absence of a rate.
|
|
25931
|
+
*/
|
|
25932
|
+
rates: array(number().positive()).min(1).readonly()
|
|
25933
|
+
});
|
|
25765
25934
|
var ClipSourceAvailabilitySchema = object({
|
|
25766
25935
|
state: _enum([
|
|
25767
25936
|
"ok",
|
|
@@ -25940,14 +26109,18 @@ var videoclipsCapability = {
|
|
|
25940
26109
|
*
|
|
25941
26110
|
* `getClipPlayback` is the right answer for a player: it hands back a URL
|
|
25942
26111
|
* on a plane the hub serves `access:'authenticated'`, which a browser and a
|
|
25943
|
-
* viewer session satisfy. It is the wrong answer for another ADDON
|
|
25944
|
-
*
|
|
25945
|
-
*
|
|
25946
|
-
*
|
|
25947
|
-
*
|
|
25948
|
-
*
|
|
25949
|
-
*
|
|
25950
|
-
*
|
|
26112
|
+
* viewer session satisfy. It is the wrong answer for another ADDON: the
|
|
26113
|
+
* hub's proxy in front of that plane takes only a user credential, which
|
|
26114
|
+
* an addon does not hold, so a recorder that wants a camera's clip cannot
|
|
26115
|
+
* fetch that URL. This method is the shape that answer forced — the same
|
|
26116
|
+
* one (and the same bound) as `recordingExport.readExportBytes`.
|
|
26117
|
+
*
|
|
26118
|
+
* **It is no longer the only seam.** Until D613 there was no addon→addon
|
|
26119
|
+
* byte transport at all; there is now
|
|
26120
|
+
* ({@link offerClipBytes}, over `ctx.peerBytes`), it holds nothing on
|
|
26121
|
+
* either side, and it is what a clip EXPORT uses. This method remains for
|
|
26122
|
+
* a consumer that genuinely wants the bytes in hand, and as the named
|
|
26123
|
+
* fallback for a provider not yet redeployed.
|
|
25951
26124
|
*
|
|
25952
26125
|
* Routing needs no `provider` pin: the id is source-prefixed and
|
|
25953
26126
|
* self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
|
|
@@ -26014,6 +26187,65 @@ var videoclipsCapability = {
|
|
|
26014
26187
|
auth: "protected"
|
|
26015
26188
|
}),
|
|
26016
26189
|
/**
|
|
26190
|
+
* Where this clip's finished bytes can be TAKEN — the by-handle read a
|
|
26191
|
+
* clip EXPORT pulls, over the addon→addon byte transport (D613).
|
|
26192
|
+
*
|
|
26193
|
+
* This is {@link readClipBytes} with the envelope removed. Same gates,
|
|
26194
|
+
* same vocabulary, same completion rules, same `served` contract — the
|
|
26195
|
+
* only difference is that the bytes travel over a one-shot loopback
|
|
26196
|
+
* socket instead of inside a base64 field, so neither side holds the
|
|
26197
|
+
* payload whole and the 50 MiB refusal on a long `high` twin stops
|
|
26198
|
+
* existing. The bound that remains is
|
|
26199
|
+
* {@link VIDEOCLIPS_MAX_OFFER_BYTES}, and it bounds the PRODUCER's own
|
|
26200
|
+
* copy rather than the transport.
|
|
26201
|
+
*
|
|
26202
|
+
* **The ticket is loopback and same-host.** A provider on an agent mints a
|
|
26203
|
+
* URL that means nothing on the hub, and `ctx.peerBytes.open` refuses it
|
|
26204
|
+
* `cross-node` by name rather than dialling whatever else holds that port
|
|
26205
|
+
* here. A consumer that can be on the other side of a node boundary from
|
|
26206
|
+
* its provider must be able to read that refusal and say so; it must not
|
|
26207
|
+
* treat it as "no bytes".
|
|
26208
|
+
*
|
|
26209
|
+
* **A ticket is a one-shot bearer credential with a seconds-long life.**
|
|
26210
|
+
* Take it immediately, never persist it, never log its `url`. An untaken
|
|
26211
|
+
* ticket costs the provider one map entry until its TTL, and outstanding
|
|
26212
|
+
* tickets are bounded — which is also what makes a per-frame misuse of
|
|
26213
|
+
* this method refuse by name rather than work slowly (D9/D18: this is a
|
|
26214
|
+
* by-handle fetch of finished media, not a frame pipe).
|
|
26215
|
+
*
|
|
26216
|
+
* Optional on the provider for the same reason `readClipBytes` is: a
|
|
26217
|
+
* source with no camera socket behind it has no bytes. A provider that
|
|
26218
|
+
* predates this method answers `NOT_IMPLEMENTED`, and a consumer may fall
|
|
26219
|
+
* back to `readClipBytes` — but it says so in the log, with the deploy
|
|
26220
|
+
* hint, because that fallback re-imposes the 50 MiB refusal and an
|
|
26221
|
+
* operator who sees `too-large-to-transfer` after this shipped is looking
|
|
26222
|
+
* at a stale addon, not at a clip that cannot be exported.
|
|
26223
|
+
*/
|
|
26224
|
+
offerClipBytes: optionalMethod(object({
|
|
26225
|
+
deviceId: number(),
|
|
26226
|
+
clipId: string().min(1),
|
|
26227
|
+
/** WHICH provider holds the bytes — see `readClipBytes.provider`. */
|
|
26228
|
+
provider: string().min(1),
|
|
26229
|
+
/** Which twin — `low | mid` → the sub file, `high` → the main twin. */
|
|
26230
|
+
profile: CamProfileSchema.optional(),
|
|
26231
|
+
/**
|
|
26232
|
+
* The CALLER's byte bound, so an over-size clip is refused before the
|
|
26233
|
+
* camera is touched rather than after. Capped by
|
|
26234
|
+
* {@link VIDEOCLIPS_MAX_OFFER_BYTES} whatever is passed; absent means
|
|
26235
|
+
* that ceiling.
|
|
26236
|
+
*/
|
|
26237
|
+
maxBytes: number().int().positive().optional(),
|
|
26238
|
+
/**
|
|
26239
|
+
* The operator's authorisation to wake a sleeping camera for this
|
|
26240
|
+
* read. Absent — the default — means a sleeping standalone battery
|
|
26241
|
+
* camera is REFUSED by name, before any session is opened.
|
|
26242
|
+
*/
|
|
26243
|
+
wake: ClipWakeSchema.optional()
|
|
26244
|
+
}), ClipBytesOfferSchema, {
|
|
26245
|
+
kind: "query",
|
|
26246
|
+
auth: "protected"
|
|
26247
|
+
}),
|
|
26248
|
+
/**
|
|
26017
26249
|
* Where this clip's STREAM can be dialled (D597) — the forward-only
|
|
26018
26250
|
* fMP4 the provider writes from the first muxed byte, for the broker to
|
|
26019
26251
|
* play through the same WebRTC session as recorded footage, with the
|
|
@@ -26050,10 +26282,88 @@ var videoclipsCapability = {
|
|
|
26050
26282
|
}), ClipStreamDialSchema, {
|
|
26051
26283
|
kind: "query",
|
|
26052
26284
|
auth: "protected"
|
|
26285
|
+
}),
|
|
26286
|
+
/**
|
|
26287
|
+
* What a surface may DRAW for this provider's clips: the rates it can be
|
|
26288
|
+
* played at, whether scrub is served, how far a position may be moved,
|
|
26289
|
+
* and whether a backward frame-step means anything (D612).
|
|
26290
|
+
*
|
|
26291
|
+
* **The only authority.** The per-clip `clipTransport` server message
|
|
26292
|
+
* that used to carry the same answer was removed: the broker's transport
|
|
26293
|
+
* choice reads nothing that varies per clip, so the clip level had no
|
|
26294
|
+
* information the provider does not already have, and two channels that
|
|
26295
|
+
* can disagree are worse than one (D62).
|
|
26296
|
+
*
|
|
26297
|
+
* Asked per camera and per provider, so it must stay CHEAP — it is a
|
|
26298
|
+
* statement about wiring, answered from a constant, never a call to the
|
|
26299
|
+
* camera. A provider answers with one of {@link CLIP_PLAYBACK_OPTIONS}
|
|
26300
|
+
* and never composes an envelope of its own.
|
|
26301
|
+
*
|
|
26302
|
+
* Optional, and absence is load-bearing: a provider that has not answered
|
|
26303
|
+
* has not restricted anything, and a viewer reads it as the freedom it
|
|
26304
|
+
* always had. See D612 on the rollout order that absence implies.
|
|
26305
|
+
*/
|
|
26306
|
+
getPlaybackOptions: optionalMethod(object({
|
|
26307
|
+
deviceId: number(),
|
|
26308
|
+
/** WHICH provider to ask — the `addonId` a {@link ClipSourceSchema}
|
|
26309
|
+
* row carries. Required for the same reason `listClips` requires it:
|
|
26310
|
+
* a collection cap has no "the bound one" to resolve to (D554). */
|
|
26311
|
+
provider: string().min(1)
|
|
26312
|
+
}), ClipPlaybackOptionsSchema, {
|
|
26313
|
+
kind: "query",
|
|
26314
|
+
auth: "protected"
|
|
26053
26315
|
})
|
|
26054
26316
|
}
|
|
26055
26317
|
};
|
|
26056
26318
|
/**
|
|
26319
|
+
* NOTE ON PLACEMENT: this block lives AFTER the capability definition on
|
|
26320
|
+
* purpose. `scripts/lib/parse-cap.ts` finds a cap by the FIRST
|
|
26321
|
+
* `export const <X> = {` in the file, so an object literal declared above
|
|
26322
|
+
* `videoclipsCapability` is silently taken for the capability itself and
|
|
26323
|
+
* codegen emits a `DeviceProxy` entry that does not type-check. Found the
|
|
26324
|
+
* hard way, 2026-09-23.
|
|
26325
|
+
*/
|
|
26326
|
+
/**
|
|
26327
|
+
* The two envelopes, spelled ONCE.
|
|
26328
|
+
*
|
|
26329
|
+
* A provider answers with the entry for the transport its own wiring buys —
|
|
26330
|
+
* `stream` if it implements `dialClipStream`, `file` if it implements only
|
|
26331
|
+
* `readClipBytes` — and never composes one of its own. One table is what
|
|
26332
|
+
* makes "the provider may not promise what no clip can be given" structural
|
|
26333
|
+
* instead of a discipline: there is nothing else to promise.
|
|
26334
|
+
*/
|
|
26335
|
+
var CLIP_PLAYBACK_OPTIONS = {
|
|
26336
|
+
stream: {
|
|
26337
|
+
transport: "stream",
|
|
26338
|
+
seek: "forward",
|
|
26339
|
+
stepBack: false,
|
|
26340
|
+
scrub: false,
|
|
26341
|
+
rates: CLIP_BROKER_PACED_RATES
|
|
26342
|
+
},
|
|
26343
|
+
/**
|
|
26344
|
+
* A `stream` whose SOURCE runs at realtime — a Hikvision RTSP replay.
|
|
26345
|
+
*
|
|
26346
|
+
* Same verbs as `stream`, a shorter rate ladder, and the difference is a
|
|
26347
|
+
* measurement rather than a preference: see
|
|
26348
|
+
* {@link CLIP_REALTIME_SOURCE_RATES}. A third entry rather than a parameter,
|
|
26349
|
+
* because a provider must still be able to do nothing but NAME one of these.
|
|
26350
|
+
*/
|
|
26351
|
+
realtimeStream: {
|
|
26352
|
+
transport: "stream",
|
|
26353
|
+
seek: "forward",
|
|
26354
|
+
stepBack: false,
|
|
26355
|
+
scrub: false,
|
|
26356
|
+
rates: CLIP_REALTIME_SOURCE_RATES
|
|
26357
|
+
},
|
|
26358
|
+
file: {
|
|
26359
|
+
transport: "file",
|
|
26360
|
+
seek: "free",
|
|
26361
|
+
stepBack: true,
|
|
26362
|
+
scrub: true,
|
|
26363
|
+
rates: CLIP_BROKER_PACED_RATES
|
|
26364
|
+
}
|
|
26365
|
+
};
|
|
26366
|
+
/**
|
|
26057
26367
|
* Optional client-side hints sent at session creation to help the provider
|
|
26058
26368
|
* pick the best native source. All fields optional — a viewer that knows
|
|
26059
26369
|
* nothing still gets a sane default. (Relocated from the retired `webrtc`
|
|
@@ -47198,6 +47508,12 @@ Object.freeze({
|
|
|
47198
47508
|
addonId: null,
|
|
47199
47509
|
access: "view"
|
|
47200
47510
|
},
|
|
47511
|
+
"videoclips.getPlaybackOptions": {
|
|
47512
|
+
capName: "videoclips",
|
|
47513
|
+
capScope: "device",
|
|
47514
|
+
addonId: null,
|
|
47515
|
+
access: "view"
|
|
47516
|
+
},
|
|
47201
47517
|
"videoclips.listClips": {
|
|
47202
47518
|
capName: "videoclips",
|
|
47203
47519
|
capScope: "device",
|
|
@@ -47210,6 +47526,12 @@ Object.freeze({
|
|
|
47210
47526
|
addonId: null,
|
|
47211
47527
|
access: "view"
|
|
47212
47528
|
},
|
|
47529
|
+
"videoclips.offerClipBytes": {
|
|
47530
|
+
capName: "videoclips",
|
|
47531
|
+
capScope: "device",
|
|
47532
|
+
addonId: null,
|
|
47533
|
+
access: "view"
|
|
47534
|
+
},
|
|
47213
47535
|
"videoclips.readClipBytes": {
|
|
47214
47536
|
capName: "videoclips",
|
|
47215
47537
|
capScope: "device",
|
|
@@ -49256,6 +49578,11 @@ Object.freeze({
|
|
|
49256
49578
|
form: "single",
|
|
49257
49579
|
optional: false
|
|
49258
49580
|
}],
|
|
49581
|
+
"videoclips.getPlaybackOptions": [{
|
|
49582
|
+
name: "deviceId",
|
|
49583
|
+
form: "single",
|
|
49584
|
+
optional: false
|
|
49585
|
+
}],
|
|
49259
49586
|
"videoclips.listClips": [{
|
|
49260
49587
|
name: "deviceId",
|
|
49261
49588
|
form: "single",
|
|
@@ -49266,6 +49593,11 @@ Object.freeze({
|
|
|
49266
49593
|
form: "single",
|
|
49267
49594
|
optional: false
|
|
49268
49595
|
}],
|
|
49596
|
+
"videoclips.offerClipBytes": [{
|
|
49597
|
+
name: "deviceId",
|
|
49598
|
+
form: "single",
|
|
49599
|
+
optional: false
|
|
49600
|
+
}],
|
|
49269
49601
|
"videoclips.readClipBytes": [{
|
|
49270
49602
|
name: "deviceId",
|
|
49271
49603
|
form: "single",
|
|
@@ -79065,7 +79397,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
79065
79397
|
};
|
|
79066
79398
|
}));
|
|
79067
79399
|
//#endregion
|
|
79068
|
-
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-
|
|
79400
|
+
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-7B2Z5FIA.js
|
|
79069
79401
|
var import_fxp = require_fxp();
|
|
79070
79402
|
var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
|
|
79071
79403
|
function encodeHeader(h) {
|
|
@@ -82574,6 +82906,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
82574
82906
|
if (prev.superseded) continue;
|
|
82575
82907
|
prev.superseded = true;
|
|
82576
82908
|
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.`);
|
|
82909
|
+
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)`));
|
|
82577
82910
|
}
|
|
82578
82911
|
const session = {
|
|
82579
82912
|
id: ++this.replaySessionSeq,
|
|
@@ -83296,6 +83629,10 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
83296
83629
|
cleanup();
|
|
83297
83630
|
reject(e instanceof Error ? e : new Error(String(e)));
|
|
83298
83631
|
};
|
|
83632
|
+
session.onSuperseded = (reason) => {
|
|
83633
|
+
if (chunks.length > 0) finish(Buffer.concat(chunks));
|
|
83634
|
+
else fail(reason);
|
|
83635
|
+
};
|
|
83299
83636
|
const armIdleFinish = () => {
|
|
83300
83637
|
if (idleTimer) clearTimeout(idleTimer);
|
|
83301
83638
|
idleTimer = setTimeout(() => {
|
|
@@ -154597,6 +154934,118 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
154597
154934
|
};
|
|
154598
154935
|
},
|
|
154599
154936
|
/**
|
|
154937
|
+
* The same clip, over the addon→addon byte transport (D613).
|
|
154938
|
+
*
|
|
154939
|
+
* Every gate {@link readClipBytes} runs, in the same order and with the
|
|
154940
|
+
* same vocabulary — the id, the row, a clip still being written, the twin,
|
|
154941
|
+
* the size the ROW declares, and the sleep gate, which the producer
|
|
154942
|
+
* decides before `getApi()` because on UDP the login IS the wake. The one
|
|
154943
|
+
* difference is the envelope: the bytes leave over a one-shot loopback
|
|
154944
|
+
* socket instead of inside a base64 field, so the caller holds nothing and
|
|
154945
|
+
* this process holds the one copy the cmd-5 transfer already produced.
|
|
154946
|
+
*
|
|
154947
|
+
* The bound is {@link VIDEOCLIPS_MAX_OFFER_BYTES}: ten times the inline
|
|
154948
|
+
* one, because the inline one was paying for two inflated copies and this
|
|
154949
|
+
* pays for one plain one.
|
|
154950
|
+
*/
|
|
154951
|
+
offerClipBytes: async ({ deviceId, clipId, profile, maxBytes, wake }) => {
|
|
154952
|
+
const tags = { deviceId };
|
|
154953
|
+
const refuse = (code, detail, meta) => {
|
|
154954
|
+
deps.logger.warn("videoclips: refusing a clip byte offer", {
|
|
154955
|
+
tags,
|
|
154956
|
+
meta: {
|
|
154957
|
+
clipId,
|
|
154958
|
+
branch: code,
|
|
154959
|
+
...meta
|
|
154960
|
+
}
|
|
154961
|
+
});
|
|
154962
|
+
throw new Error(clipExportFailure(code, detail));
|
|
154963
|
+
};
|
|
154964
|
+
const parsed = parseClipId(clipId);
|
|
154965
|
+
if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Reolink provider`, {});
|
|
154966
|
+
const clipKey = clipKeyFor(parsed.channel, parsed.fileName);
|
|
154967
|
+
const row = rowsByKey.get(`${deviceId}|${clipKey}`);
|
|
154968
|
+
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 });
|
|
154969
|
+
if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written, so it has no end to export`, { clipKey });
|
|
154970
|
+
const chosen = chooseClipTwin(profile, row);
|
|
154971
|
+
if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — serving the sub copy", {
|
|
154972
|
+
tags,
|
|
154973
|
+
meta: {
|
|
154974
|
+
clipId,
|
|
154975
|
+
clipKey,
|
|
154976
|
+
branch: "no-main-twin",
|
|
154977
|
+
asked: profile
|
|
154978
|
+
}
|
|
154979
|
+
});
|
|
154980
|
+
const twin = chosen.twin;
|
|
154981
|
+
const fileName = chosen.fileName;
|
|
154982
|
+
if (fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
|
|
154983
|
+
clipKey,
|
|
154984
|
+
twin
|
|
154985
|
+
});
|
|
154986
|
+
const served = chosen.served;
|
|
154987
|
+
const bound = Math.min(maxBytes ?? 524288e3, VIDEOCLIPS_MAX_OFFER_BYTES);
|
|
154988
|
+
const declaredBytes = twin === "main" ? row.mainBytes : row.subBytes;
|
|
154989
|
+
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`, {
|
|
154990
|
+
clipKey,
|
|
154991
|
+
twin,
|
|
154992
|
+
declaredBytes,
|
|
154993
|
+
bound
|
|
154994
|
+
});
|
|
154995
|
+
const outcome = await deps.prepareOffer({
|
|
154996
|
+
deviceId,
|
|
154997
|
+
row,
|
|
154998
|
+
fileName,
|
|
154999
|
+
twin,
|
|
155000
|
+
...wake !== void 0 ? { wake } : {}
|
|
155001
|
+
});
|
|
155002
|
+
if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
|
|
155003
|
+
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`, {
|
|
155004
|
+
clipKey,
|
|
155005
|
+
twin,
|
|
155006
|
+
bytes: outcome.bytes,
|
|
155007
|
+
bound
|
|
155008
|
+
});
|
|
155009
|
+
deps.logger.info("videoclips: offered a Reolink clip by handle", {
|
|
155010
|
+
tags,
|
|
155011
|
+
meta: {
|
|
155012
|
+
clipId,
|
|
155013
|
+
clipKey,
|
|
155014
|
+
twin,
|
|
155015
|
+
served,
|
|
155016
|
+
asked: profile ?? null,
|
|
155017
|
+
bytes: outcome.bytes,
|
|
155018
|
+
durationMs: outcome.durationMs,
|
|
155019
|
+
wake: wake ?? "none"
|
|
155020
|
+
}
|
|
155021
|
+
});
|
|
155022
|
+
return {
|
|
155023
|
+
ticket: outcome.ticket,
|
|
155024
|
+
contentType: "video/mp4",
|
|
155025
|
+
name: clipFileLabel(fileName),
|
|
155026
|
+
served,
|
|
155027
|
+
...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
|
|
155028
|
+
};
|
|
155029
|
+
},
|
|
155030
|
+
/**
|
|
155031
|
+
* What a surface may DRAW for this camera's clips (D612).
|
|
155032
|
+
*
|
|
155033
|
+
* A CONSTANT, and it has to be: this method is asked per camera, and the
|
|
155034
|
+
* answer is a fact about which accessors this provider wires, not about
|
|
155035
|
+
* the camera. Every clip here reaches the player over
|
|
155036
|
+
* {@link dialClipStream}'s forward-only stream, so every clip gets the
|
|
155037
|
+
* `stream` envelope — measured, not assumed: the broker's `chooseClipPath`
|
|
155038
|
+
* reads only whether the dial and the file read are wired, and never the
|
|
155039
|
+
* clip or the profile.
|
|
155040
|
+
*
|
|
155041
|
+
* The rates are the BROKER's re-pacing ladder. The camera's own
|
|
155042
|
+
* `<playSpeed>` (8, 16) is real and still unwired (D597, D600): it would
|
|
155043
|
+
* ride on the dial and cost a new stream per change, and its audio has no
|
|
155044
|
+
* resampler on this path. When it is wired its rates join
|
|
155045
|
+
* `CLIP_BROKER_PACED_RATES` — not a second list here.
|
|
155046
|
+
*/
|
|
155047
|
+
getPlaybackOptions: async () => CLIP_PLAYBACK_OPTIONS.stream,
|
|
155048
|
+
/**
|
|
154600
155049
|
* Where this clip's STREAM is dialled (D597) — the same questions
|
|
154601
155050
|
* `readClipBytes` asks before the camera is touched (the id, the row, a
|
|
154602
155051
|
* file still being written), the same vocabulary, and NO fetch: the URL
|
|
@@ -155567,6 +156016,62 @@ function createClipService(deps) {
|
|
|
155567
156016
|
return outcome;
|
|
155568
156017
|
};
|
|
155569
156018
|
/**
|
|
156019
|
+
* The same clip, offered over the addon→addon byte transport (D613).
|
|
156020
|
+
*
|
|
156021
|
+
* It reuses {@link prepareBytes} whole — one fetch path, one set of
|
|
156022
|
+
* completion rules, one scratch. A second producer here would be the second
|
|
156023
|
+
* authority D558 refuses to create. What changes is only how the result
|
|
156024
|
+
* LEAVES: `offerBuffer` writes it to a one-shot loopback socket in bounded
|
|
156025
|
+
* chunks, honouring `write()`'s return (D447), instead of base64-ing it into
|
|
156026
|
+
* a cap envelope that the caller then copies again.
|
|
156027
|
+
*/
|
|
156028
|
+
const prepareOffer = async (input) => {
|
|
156029
|
+
const peerBytes = deps.peerBytes;
|
|
156030
|
+
if (peerBytes === void 0) {
|
|
156031
|
+
deps.logger.warn("videoclips: no peer-bytes transport on this node", {
|
|
156032
|
+
tags: { deviceId: input.deviceId },
|
|
156033
|
+
meta: {
|
|
156034
|
+
clipKey: input.row.clipKey,
|
|
156035
|
+
branch: "no-peer-bytes"
|
|
156036
|
+
}
|
|
156037
|
+
});
|
|
156038
|
+
return {
|
|
156039
|
+
kind: "refused",
|
|
156040
|
+
code: "fetch-failed",
|
|
156041
|
+
detail: "this node has no addon-to-addon byte transport wired"
|
|
156042
|
+
};
|
|
156043
|
+
}
|
|
156044
|
+
const outcome = await prepareBytes(input);
|
|
156045
|
+
if (outcome.kind === "refused") return outcome;
|
|
156046
|
+
const offered = await peerBytes.offerBuffer({
|
|
156047
|
+
bytes: outcome.bytes,
|
|
156048
|
+
contentType: "video/mp4",
|
|
156049
|
+
label: "reolink-clip",
|
|
156050
|
+
deviceId: input.deviceId
|
|
156051
|
+
});
|
|
156052
|
+
if (offered.kind === "refused") {
|
|
156053
|
+
deps.logger.warn("videoclips: the clip byte offer could not be minted", {
|
|
156054
|
+
tags: { deviceId: input.deviceId },
|
|
156055
|
+
meta: {
|
|
156056
|
+
clipKey: input.row.clipKey,
|
|
156057
|
+
branch: offered.code,
|
|
156058
|
+
detail: offered.detail
|
|
156059
|
+
}
|
|
156060
|
+
});
|
|
156061
|
+
return {
|
|
156062
|
+
kind: "refused",
|
|
156063
|
+
code: "fetch-failed",
|
|
156064
|
+
detail: `${offered.code}: ${offered.detail}`
|
|
156065
|
+
};
|
|
156066
|
+
}
|
|
156067
|
+
return {
|
|
156068
|
+
kind: "ok",
|
|
156069
|
+
ticket: offered.ticket,
|
|
156070
|
+
durationMs: outcome.durationMs,
|
|
156071
|
+
bytes: outcome.bytes.byteLength
|
|
156072
|
+
};
|
|
156073
|
+
};
|
|
156074
|
+
/**
|
|
155570
156075
|
* The STREAM's path (`clip-stream-route.ts`): the same gates, the same row,
|
|
155571
156076
|
* nothing written. Beside the file path, not in its place.
|
|
155572
156077
|
*/
|
|
@@ -155606,6 +156111,7 @@ function createClipService(deps) {
|
|
|
155606
156111
|
served: profile === "high" ? "high" : "low"
|
|
155607
156112
|
}),
|
|
155608
156113
|
prepareBytes,
|
|
156114
|
+
prepareOffer,
|
|
155609
156115
|
prepareStreamDial: async ({ deviceId, row, twin, audio }) => {
|
|
155610
156116
|
const gate = await gateClipDevice({
|
|
155611
156117
|
deviceId,
|
|
@@ -170284,6 +170790,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
170284
170790
|
this.clipService = createClipService({
|
|
170285
170791
|
dataDir: this.ctx.dataDir,
|
|
170286
170792
|
logger: this.ctx.logger,
|
|
170793
|
+
...this.ctx.peerBytes !== void 0 ? { peerBytes: this.ctx.peerBytes } : {},
|
|
170287
170794
|
resolveDevice: async (deviceId) => {
|
|
170288
170795
|
const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
|
|
170289
170796
|
return device instanceof ReolinkCamera ? device.getClipDeviceRef() : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-provider-reolink",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.163",
|
|
4
4
|
"description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"publish:npm": "npm publish --access public"
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
|
-
"@apocaliss92/nodelink-js": "^0.10.
|
|
97
|
+
"@apocaliss92/nodelink-js": "^0.10.1"
|
|
98
98
|
},
|
|
99
99
|
"peerDependencies": {
|
|
100
100
|
"@camstack/types": "*",
|