@camstack/addon-provider-reolink 1.2.158 → 1.2.160
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 +3143 -209
- package/dist/addon.mjs +3143 -209
- package/package.json +2 -2
package/dist/addon.js
CHANGED
|
@@ -10,6 +10,7 @@ path = require_chunk.__toESM(path, 1);
|
|
|
10
10
|
let crypto$1 = require("crypto");
|
|
11
11
|
crypto$1 = require_chunk.__toESM(crypto$1, 1);
|
|
12
12
|
let child_process = require("child_process");
|
|
13
|
+
let node_http = require("node:http");
|
|
13
14
|
let node_fs_promises = require("node:fs/promises");
|
|
14
15
|
let node_path = require("node:path");
|
|
15
16
|
let stream = require("stream");
|
|
@@ -5390,7 +5391,7 @@ var ZodIssueCode = {
|
|
|
5390
5391
|
var ZodFirstPartyTypeKind;
|
|
5391
5392
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5392
5393
|
//#endregion
|
|
5393
|
-
//#region ../types/dist/sleep-
|
|
5394
|
+
//#region ../types/dist/sleep-De1BvmWo.mjs
|
|
5394
5395
|
/**
|
|
5395
5396
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5396
5397
|
* window to float samples (D455).
|
|
@@ -7859,10 +7860,20 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
|
7859
7860
|
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7860
7861
|
"no-provider",
|
|
7861
7862
|
"source-unreachable",
|
|
7862
|
-
"not-configured",
|
|
7863
7863
|
"device-disabled"
|
|
7864
7864
|
]);
|
|
7865
7865
|
/**
|
|
7866
|
+
* Something true about a switch that is nonetheless OFFERED.
|
|
7867
|
+
*
|
|
7868
|
+
* The distinction from {@link CameraSwitchUnavailableReasonSchema} is the
|
|
7869
|
+
* whole point: a reason replaces the control, a warning travels WITH it. The
|
|
7870
|
+
* privacy mask taught us the difference — greying the one button that starts
|
|
7871
|
+
* the feature teaches the operator the feature is broken, while a live toggle
|
|
7872
|
+
* that flips nothing teaches them the switch is broken. Neither is true; the
|
|
7873
|
+
* honest surface is a working toggle that says what is missing.
|
|
7874
|
+
*/
|
|
7875
|
+
var CameraSwitchWarningSchema = _enum(["no-privacy-zones"]);
|
|
7876
|
+
/**
|
|
7866
7877
|
* One switch, resolved for one camera.
|
|
7867
7878
|
*
|
|
7868
7879
|
* `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
|
|
@@ -7881,6 +7892,11 @@ var CameraSwitchSchema = object({
|
|
|
7881
7892
|
/** False = do not render a control. `unavailableReason` says why. */
|
|
7882
7893
|
available: boolean(),
|
|
7883
7894
|
unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
|
|
7895
|
+
/**
|
|
7896
|
+
* Present on an AVAILABLE switch only. Rendered beside the live control;
|
|
7897
|
+
* never a substitute for one.
|
|
7898
|
+
*/
|
|
7899
|
+
warning: CameraSwitchWarningSchema.optional(),
|
|
7884
7900
|
/** Current state. Meaningless when `available` is false — read it as `true`. */
|
|
7885
7901
|
enabled: boolean(),
|
|
7886
7902
|
authority: CameraSwitchAuthoritySchema
|
|
@@ -25144,6 +25160,15 @@ var CLIP_STILL_REASON_HEADER = "x-camstack-reason";
|
|
|
25144
25160
|
var CLIP_STILL_REASON_CODE_HEADER = "x-camstack-reason-code";
|
|
25145
25161
|
/** One {@link ClipStillDispositionSchema} token, alone. */
|
|
25146
25162
|
var CLIP_STILL_DISPOSITION_HEADER = "x-camstack-disposition";
|
|
25163
|
+
/**
|
|
25164
|
+
* The clip STREAM route's own statement of what it is (D597): the value is
|
|
25165
|
+
* `forward-only`. A producer writes it on the 200; a consumer that dials a
|
|
25166
|
+
* URL and does not read it back refuses to play, because a progressive file
|
|
25167
|
+
* through a forward-only feeder looks like it worked until the first seek.
|
|
25168
|
+
* Named here because BOTH the provider and the broker spell it, and two
|
|
25169
|
+
* copies of a header name is how one of them stops being checked.
|
|
25170
|
+
*/
|
|
25171
|
+
var CLIP_STREAM_TRANSPORT_HEADER = "x-camstack-clip-transport";
|
|
25147
25172
|
var ClipSchema = object({
|
|
25148
25173
|
/** Opaque, provider-namespaced id. The default provider encodes the time
|
|
25149
25174
|
* window so `getClipPlayback` is self-contained (no event re-query). */
|
|
@@ -25429,6 +25454,82 @@ var ClipBytesSchema = object({
|
|
|
25429
25454
|
*/
|
|
25430
25455
|
durationMs: number().positive().optional()
|
|
25431
25456
|
});
|
|
25457
|
+
/**
|
|
25458
|
+
* Where a clip's STREAM can be dialled (D597) — the answer to
|
|
25459
|
+
* {@link videoclipsCapability.methods.dialClipStream}.
|
|
25460
|
+
*
|
|
25461
|
+
* `url` is plain `http://` on the provider's own token-gated listener, one
|
|
25462
|
+
* shot, short-lived. It is NOT the hub-proxied `/addon/<id>/clip-stream/…`
|
|
25463
|
+
* path: that plane takes only a user credential, which an addon does not
|
|
25464
|
+
* hold, and the framework offers no addon→addon byte transport — the one
|
|
25465
|
+
* precedent is the broker's own restream URLs (`getStreamWithCodec`), which
|
|
25466
|
+
* a consumer dials directly, and this is that precedent one addon over.
|
|
25467
|
+
*/
|
|
25468
|
+
/**
|
|
25469
|
+
* The audio a clip stream can be asked for, and the audio it carries.
|
|
25470
|
+
*
|
|
25471
|
+
* The camera stores AAC and no WebRTC session speaks it, so a stream that
|
|
25472
|
+
* carries sound carries it TRANSCODED, muxed as a second track beside the
|
|
25473
|
+
* video by the one ffmpeg the provider already runs (D600). `opus` is
|
|
25474
|
+
* 48 kHz mono, `pcmu` is 8 kHz µ-law — the two codecs a broker session
|
|
25475
|
+
* negotiates — and `none` is a stream asked for, or served, without sound.
|
|
25476
|
+
*/
|
|
25477
|
+
var ClipStreamAudioSchema = _enum([
|
|
25478
|
+
"opus",
|
|
25479
|
+
"pcmu",
|
|
25480
|
+
"none"
|
|
25481
|
+
]);
|
|
25482
|
+
/**
|
|
25483
|
+
* Why a stream that was ASKED for sound is not carrying it (D600).
|
|
25484
|
+
*
|
|
25485
|
+
* Only ever set beside `servedAudio: 'none'` after a non-`none` ask. Absent
|
|
25486
|
+
* means the ask was honoured, or there was no ask.
|
|
25487
|
+
*
|
|
25488
|
+
* - `not-requested` — the caller asked for `none`.
|
|
25489
|
+
* - `unsupported-ask` — the provider cannot produce that codec.
|
|
25490
|
+
*
|
|
25491
|
+
* Whether the RECORDING has sound at all is not knowable here: the camera
|
|
25492
|
+
* declares no audio track before its first frame arrives. That answer is in
|
|
25493
|
+
* the stream itself — an fMP4 with no audio track — and the broker reads it
|
|
25494
|
+
* there. A dial that says `opus` promises the codec, never the content.
|
|
25495
|
+
*/
|
|
25496
|
+
var ClipStreamAudioReasonSchema = _enum(["not-requested", "unsupported-ask"]);
|
|
25497
|
+
var ClipStreamDialSchema = object({
|
|
25498
|
+
url: string().min(1),
|
|
25499
|
+
/** After this the token is gone and the URL answers 404 `dial-expired`. */
|
|
25500
|
+
expiresAtMs: number(),
|
|
25501
|
+
/** What the stream IS — a consumer that cannot honour it must not dial. */
|
|
25502
|
+
transport: literal("forward-only"),
|
|
25503
|
+
/**
|
|
25504
|
+
* Which twin the stream ACTUALLY carries — not what was asked for.
|
|
25505
|
+
*
|
|
25506
|
+
* The stream serves every profile (D599), and a recording whose main twin
|
|
25507
|
+
* does not exist is streamed as its sub copy, which this field reports as
|
|
25508
|
+
* `low`. That is the whole reason the dial no longer refuses `high`:
|
|
25509
|
+
* stating what was served is what makes accepting honest, where a silent
|
|
25510
|
+
* substitution would be the 33x lie D576 forbids. Stated rather than
|
|
25511
|
+
* assumed, the way {@link ClipPlaybackSchema.served} is (D549 15).
|
|
25512
|
+
*/
|
|
25513
|
+
served: CamProfileSchema,
|
|
25514
|
+
/**
|
|
25515
|
+
* Which audio the stream will ACTUALLY carry — `served`'s companion for
|
|
25516
|
+
* sound, and the same reasoning (D599, D600).
|
|
25517
|
+
*
|
|
25518
|
+
* The broker asks for the codec its WebRTC session negotiated, and the
|
|
25519
|
+
* provider either muxes that codec or carries none. Stating it is what
|
|
25520
|
+
* lets the broker wire the matching sink before the first byte, and what
|
|
25521
|
+
* keeps a session from being handed a codec it cannot send — the audio
|
|
25522
|
+
* shape of the substitution `served` exists to prevent.
|
|
25523
|
+
*
|
|
25524
|
+
* It is a promise about the CODEC, not about the content: a recording with
|
|
25525
|
+
* no microphone track answers `opus` here and then arrives with no audio
|
|
25526
|
+
* track at all. That second answer is in the stream, and the broker reads
|
|
25527
|
+
* it from the `moov`.
|
|
25528
|
+
*/
|
|
25529
|
+
servedAudio: ClipStreamAudioSchema,
|
|
25530
|
+
/** Why `servedAudio` is `none` although sound was asked for. */
|
|
25531
|
+
audioReason: ClipStreamAudioReasonSchema.optional()
|
|
25532
|
+
});
|
|
25432
25533
|
var ClipSourceAvailabilitySchema = object({
|
|
25433
25534
|
state: _enum([
|
|
25434
25535
|
"ok",
|
|
@@ -25679,6 +25780,44 @@ var videoclipsCapability = {
|
|
|
25679
25780
|
}), ClipBytesSchema, {
|
|
25680
25781
|
kind: "query",
|
|
25681
25782
|
auth: "protected"
|
|
25783
|
+
}),
|
|
25784
|
+
/**
|
|
25785
|
+
* Where this clip's STREAM can be dialled (D597) — the forward-only
|
|
25786
|
+
* fMP4 the provider writes from the first muxed byte, for the broker to
|
|
25787
|
+
* play through the same WebRTC session as recorded footage, with the
|
|
25788
|
+
* first frame at the first fragment instead of after the last byte.
|
|
25789
|
+
*
|
|
25790
|
+
* Nothing is fetched here: the answer is a one-shot URL on the
|
|
25791
|
+
* provider's own listener, and the camera is touched when it is dialled.
|
|
25792
|
+
* The gates the file path runs (`readClipBytes`) run on the dial too —
|
|
25793
|
+
* the id, the row, a clip still being written — and refuse in the same
|
|
25794
|
+
* vocabulary, THROWN with `clipExportFailure(<token>, <prose>)`. Every
|
|
25795
|
+
* profile is served (D599) and the answer states which twin it carries;
|
|
25796
|
+
* `high` is not refused. Optional on the provider for the same reason
|
|
25797
|
+
* `readClipBytes` is: a source with no camera socket behind it (the
|
|
25798
|
+
* recorder's analytics window, a HomeKit tee) has no stream.
|
|
25799
|
+
*/
|
|
25800
|
+
dialClipStream: optionalMethod(object({
|
|
25801
|
+
deviceId: number(),
|
|
25802
|
+
clipId: string().min(1),
|
|
25803
|
+
/** WHICH provider holds the camera — see `readClipBytes.provider`. */
|
|
25804
|
+
provider: string().min(1),
|
|
25805
|
+
/** Which twin — `high` → the main file, everything else the sub (D599). */
|
|
25806
|
+
profile: CamProfileSchema.optional(),
|
|
25807
|
+
/**
|
|
25808
|
+
* Which audio codec the caller's session negotiated, so the provider
|
|
25809
|
+
* can mux it (D600). The ask travels on the DIAL because the mux is
|
|
25810
|
+
* spawned once, before the first byte, and cannot gain a track later.
|
|
25811
|
+
*
|
|
25812
|
+
* ABSENT IS `none`, never a guess: a caller that does not state an
|
|
25813
|
+
* ask is served the video-only stream that shipped first, so a broker
|
|
25814
|
+
* older than this field keeps working unchanged (D382 — an unstated
|
|
25815
|
+
* ask is zero).
|
|
25816
|
+
*/
|
|
25817
|
+
audio: ClipStreamAudioSchema.optional()
|
|
25818
|
+
}), ClipStreamDialSchema, {
|
|
25819
|
+
kind: "query",
|
|
25820
|
+
auth: "protected"
|
|
25682
25821
|
})
|
|
25683
25822
|
}
|
|
25684
25823
|
};
|
|
@@ -38017,6 +38156,1329 @@ var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
|
|
|
38017
38156
|
DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
|
|
38018
38157
|
new Set(Object.values(DeviceType));
|
|
38019
38158
|
DeviceFeature.BatteryOperated;
|
|
38159
|
+
/**
|
|
38160
|
+
* The vocabulary, in the order a picker offers it.
|
|
38161
|
+
*
|
|
38162
|
+
* Adding a key here is not enough to make it pickable: both glyph tables must
|
|
38163
|
+
* gain it too, and their tests fail until they do. That is the intended
|
|
38164
|
+
* friction — it is the only thing that keeps the two surfaces honest.
|
|
38165
|
+
*/
|
|
38166
|
+
var DISPLAY_ICON_VOCABULARY = [
|
|
38167
|
+
{
|
|
38168
|
+
key: "camera",
|
|
38169
|
+
group: "device",
|
|
38170
|
+
synonyms: [
|
|
38171
|
+
"telecamera",
|
|
38172
|
+
"videocamera",
|
|
38173
|
+
"camera",
|
|
38174
|
+
"cam"
|
|
38175
|
+
]
|
|
38176
|
+
},
|
|
38177
|
+
{
|
|
38178
|
+
key: "network",
|
|
38179
|
+
group: "device",
|
|
38180
|
+
synonyms: [
|
|
38181
|
+
"rete",
|
|
38182
|
+
"network",
|
|
38183
|
+
"lan"
|
|
38184
|
+
]
|
|
38185
|
+
},
|
|
38186
|
+
{
|
|
38187
|
+
key: "toggle-left",
|
|
38188
|
+
group: "device",
|
|
38189
|
+
synonyms: [
|
|
38190
|
+
"interruttore",
|
|
38191
|
+
"switch",
|
|
38192
|
+
"toggle"
|
|
38193
|
+
]
|
|
38194
|
+
},
|
|
38195
|
+
{
|
|
38196
|
+
key: "toggle-right",
|
|
38197
|
+
group: "device",
|
|
38198
|
+
synonyms: [
|
|
38199
|
+
"interruttore",
|
|
38200
|
+
"switch",
|
|
38201
|
+
"toggle",
|
|
38202
|
+
"acceso"
|
|
38203
|
+
]
|
|
38204
|
+
},
|
|
38205
|
+
{
|
|
38206
|
+
key: "circle-dot",
|
|
38207
|
+
group: "device",
|
|
38208
|
+
synonyms: [
|
|
38209
|
+
"pulsante",
|
|
38210
|
+
"bottone",
|
|
38211
|
+
"button",
|
|
38212
|
+
"dot"
|
|
38213
|
+
]
|
|
38214
|
+
},
|
|
38215
|
+
{
|
|
38216
|
+
key: "refresh-cw",
|
|
38217
|
+
group: "device",
|
|
38218
|
+
synonyms: [
|
|
38219
|
+
"aggiorna",
|
|
38220
|
+
"ricarica",
|
|
38221
|
+
"refresh",
|
|
38222
|
+
"reload",
|
|
38223
|
+
"ciclo"
|
|
38224
|
+
]
|
|
38225
|
+
},
|
|
38226
|
+
{
|
|
38227
|
+
key: "box",
|
|
38228
|
+
group: "device",
|
|
38229
|
+
synonyms: [
|
|
38230
|
+
"scatola",
|
|
38231
|
+
"box",
|
|
38232
|
+
"contenitore"
|
|
38233
|
+
]
|
|
38234
|
+
},
|
|
38235
|
+
{
|
|
38236
|
+
key: "scroll-text",
|
|
38237
|
+
group: "device",
|
|
38238
|
+
synonyms: [
|
|
38239
|
+
"registro",
|
|
38240
|
+
"log",
|
|
38241
|
+
"testo"
|
|
38242
|
+
]
|
|
38243
|
+
},
|
|
38244
|
+
{
|
|
38245
|
+
key: "workflow",
|
|
38246
|
+
group: "device",
|
|
38247
|
+
synonyms: [
|
|
38248
|
+
"automazione",
|
|
38249
|
+
"flusso",
|
|
38250
|
+
"workflow",
|
|
38251
|
+
"scenario"
|
|
38252
|
+
]
|
|
38253
|
+
},
|
|
38254
|
+
{
|
|
38255
|
+
key: "lock",
|
|
38256
|
+
group: "device",
|
|
38257
|
+
synonyms: [
|
|
38258
|
+
"serratura",
|
|
38259
|
+
"chiave",
|
|
38260
|
+
"lucchetto",
|
|
38261
|
+
"chiuso",
|
|
38262
|
+
"locked"
|
|
38263
|
+
]
|
|
38264
|
+
},
|
|
38265
|
+
{
|
|
38266
|
+
key: "lock-open",
|
|
38267
|
+
group: "device",
|
|
38268
|
+
synonyms: [
|
|
38269
|
+
"serratura",
|
|
38270
|
+
"aperta",
|
|
38271
|
+
"sbloccato",
|
|
38272
|
+
"unlocked"
|
|
38273
|
+
]
|
|
38274
|
+
},
|
|
38275
|
+
{
|
|
38276
|
+
key: "blinds",
|
|
38277
|
+
group: "device",
|
|
38278
|
+
synonyms: [
|
|
38279
|
+
"tapparella",
|
|
38280
|
+
"persiana",
|
|
38281
|
+
"serranda",
|
|
38282
|
+
"avvolgibile",
|
|
38283
|
+
"shutter",
|
|
38284
|
+
"blind",
|
|
38285
|
+
"veneziana"
|
|
38286
|
+
]
|
|
38287
|
+
},
|
|
38288
|
+
{
|
|
38289
|
+
key: "air-vent",
|
|
38290
|
+
group: "device",
|
|
38291
|
+
synonyms: [
|
|
38292
|
+
"bocchetta",
|
|
38293
|
+
"aria",
|
|
38294
|
+
"ventilazione",
|
|
38295
|
+
"condizionatore",
|
|
38296
|
+
"vent"
|
|
38297
|
+
]
|
|
38298
|
+
},
|
|
38299
|
+
{
|
|
38300
|
+
key: "monitor-play",
|
|
38301
|
+
group: "device",
|
|
38302
|
+
synonyms: [
|
|
38303
|
+
"schermo",
|
|
38304
|
+
"riproduzione",
|
|
38305
|
+
"player",
|
|
38306
|
+
"display"
|
|
38307
|
+
]
|
|
38308
|
+
},
|
|
38309
|
+
{
|
|
38310
|
+
key: "sliders-horizontal",
|
|
38311
|
+
group: "device",
|
|
38312
|
+
synonyms: [
|
|
38313
|
+
"regolazione",
|
|
38314
|
+
"cursori",
|
|
38315
|
+
"impostazioni",
|
|
38316
|
+
"slider"
|
|
38317
|
+
]
|
|
38318
|
+
},
|
|
38319
|
+
{
|
|
38320
|
+
key: "map-pin",
|
|
38321
|
+
group: "device",
|
|
38322
|
+
synonyms: [
|
|
38323
|
+
"posizione",
|
|
38324
|
+
"luogo",
|
|
38325
|
+
"mappa",
|
|
38326
|
+
"pin"
|
|
38327
|
+
]
|
|
38328
|
+
},
|
|
38329
|
+
{
|
|
38330
|
+
key: "bot",
|
|
38331
|
+
group: "device",
|
|
38332
|
+
synonyms: [
|
|
38333
|
+
"robot",
|
|
38334
|
+
"aspirapolvere",
|
|
38335
|
+
"bot"
|
|
38336
|
+
]
|
|
38337
|
+
},
|
|
38338
|
+
{
|
|
38339
|
+
key: "tractor",
|
|
38340
|
+
group: "device",
|
|
38341
|
+
synonyms: [
|
|
38342
|
+
"trattore",
|
|
38343
|
+
"tosaerba",
|
|
38344
|
+
"giardino"
|
|
38345
|
+
]
|
|
38346
|
+
},
|
|
38347
|
+
{
|
|
38348
|
+
key: "image",
|
|
38349
|
+
group: "device",
|
|
38350
|
+
synonyms: [
|
|
38351
|
+
"immagine",
|
|
38352
|
+
"foto",
|
|
38353
|
+
"snapshot"
|
|
38354
|
+
]
|
|
38355
|
+
},
|
|
38356
|
+
{
|
|
38357
|
+
key: "bone",
|
|
38358
|
+
group: "device",
|
|
38359
|
+
synonyms: ["osso", "cane"]
|
|
38360
|
+
},
|
|
38361
|
+
{
|
|
38362
|
+
key: "door-closed",
|
|
38363
|
+
group: "device",
|
|
38364
|
+
synonyms: [
|
|
38365
|
+
"porta",
|
|
38366
|
+
"chiusa",
|
|
38367
|
+
"contatto"
|
|
38368
|
+
]
|
|
38369
|
+
},
|
|
38370
|
+
{
|
|
38371
|
+
key: "door-open",
|
|
38372
|
+
group: "device",
|
|
38373
|
+
synonyms: [
|
|
38374
|
+
"porta",
|
|
38375
|
+
"aperta",
|
|
38376
|
+
"contatto",
|
|
38377
|
+
"ingresso",
|
|
38378
|
+
"uscita"
|
|
38379
|
+
]
|
|
38380
|
+
},
|
|
38381
|
+
{
|
|
38382
|
+
key: "crosshair",
|
|
38383
|
+
group: "device",
|
|
38384
|
+
synonyms: [
|
|
38385
|
+
"mirino",
|
|
38386
|
+
"puntamento",
|
|
38387
|
+
"centro"
|
|
38388
|
+
]
|
|
38389
|
+
},
|
|
38390
|
+
{
|
|
38391
|
+
key: "eye",
|
|
38392
|
+
group: "device",
|
|
38393
|
+
synonyms: [
|
|
38394
|
+
"occhio",
|
|
38395
|
+
"visibile",
|
|
38396
|
+
"visto"
|
|
38397
|
+
]
|
|
38398
|
+
},
|
|
38399
|
+
{
|
|
38400
|
+
key: "eye-off",
|
|
38401
|
+
group: "device",
|
|
38402
|
+
synonyms: [
|
|
38403
|
+
"privacy",
|
|
38404
|
+
"nascosto",
|
|
38405
|
+
"oscurato",
|
|
38406
|
+
"mascherato",
|
|
38407
|
+
"invisibile"
|
|
38408
|
+
]
|
|
38409
|
+
},
|
|
38410
|
+
{
|
|
38411
|
+
key: "smartphone",
|
|
38412
|
+
group: "device",
|
|
38413
|
+
synonyms: [
|
|
38414
|
+
"telefono",
|
|
38415
|
+
"cellulare",
|
|
38416
|
+
"smartphone"
|
|
38417
|
+
]
|
|
38418
|
+
},
|
|
38419
|
+
{
|
|
38420
|
+
key: "phone",
|
|
38421
|
+
group: "device",
|
|
38422
|
+
synonyms: ["telefono", "chiamata"]
|
|
38423
|
+
},
|
|
38424
|
+
{
|
|
38425
|
+
key: "tablet",
|
|
38426
|
+
group: "device",
|
|
38427
|
+
synonyms: ["tablet", "ipad"]
|
|
38428
|
+
},
|
|
38429
|
+
{
|
|
38430
|
+
key: "laptop",
|
|
38431
|
+
group: "device",
|
|
38432
|
+
synonyms: [
|
|
38433
|
+
"portatile",
|
|
38434
|
+
"laptop",
|
|
38435
|
+
"notebook"
|
|
38436
|
+
]
|
|
38437
|
+
},
|
|
38438
|
+
{
|
|
38439
|
+
key: "monitor",
|
|
38440
|
+
group: "device",
|
|
38441
|
+
synonyms: ["monitor", "schermo"]
|
|
38442
|
+
},
|
|
38443
|
+
{
|
|
38444
|
+
key: "tv",
|
|
38445
|
+
group: "device",
|
|
38446
|
+
synonyms: ["televisore", "tv"]
|
|
38447
|
+
},
|
|
38448
|
+
{
|
|
38449
|
+
key: "speaker",
|
|
38450
|
+
group: "device",
|
|
38451
|
+
synonyms: [
|
|
38452
|
+
"altoparlante",
|
|
38453
|
+
"cassa",
|
|
38454
|
+
"speaker"
|
|
38455
|
+
]
|
|
38456
|
+
},
|
|
38457
|
+
{
|
|
38458
|
+
key: "printer",
|
|
38459
|
+
group: "device",
|
|
38460
|
+
synonyms: ["stampante"]
|
|
38461
|
+
},
|
|
38462
|
+
{
|
|
38463
|
+
key: "projector",
|
|
38464
|
+
group: "device",
|
|
38465
|
+
synonyms: ["proiettore"]
|
|
38466
|
+
},
|
|
38467
|
+
{
|
|
38468
|
+
key: "gamepad-2",
|
|
38469
|
+
group: "device",
|
|
38470
|
+
synonyms: [
|
|
38471
|
+
"console",
|
|
38472
|
+
"gioco",
|
|
38473
|
+
"joystick"
|
|
38474
|
+
]
|
|
38475
|
+
},
|
|
38476
|
+
{
|
|
38477
|
+
key: "radio",
|
|
38478
|
+
group: "device",
|
|
38479
|
+
synonyms: ["radio"]
|
|
38480
|
+
},
|
|
38481
|
+
{
|
|
38482
|
+
key: "router",
|
|
38483
|
+
group: "device",
|
|
38484
|
+
synonyms: ["router", "modem"]
|
|
38485
|
+
},
|
|
38486
|
+
{
|
|
38487
|
+
key: "bluetooth",
|
|
38488
|
+
group: "device",
|
|
38489
|
+
synonyms: ["bluetooth"]
|
|
38490
|
+
},
|
|
38491
|
+
{
|
|
38492
|
+
key: "cpu",
|
|
38493
|
+
group: "device",
|
|
38494
|
+
synonyms: [
|
|
38495
|
+
"processore",
|
|
38496
|
+
"cpu",
|
|
38497
|
+
"chip",
|
|
38498
|
+
"dispositivo"
|
|
38499
|
+
]
|
|
38500
|
+
},
|
|
38501
|
+
{
|
|
38502
|
+
key: "hard-drive",
|
|
38503
|
+
group: "device",
|
|
38504
|
+
synonyms: [
|
|
38505
|
+
"disco",
|
|
38506
|
+
"hdd",
|
|
38507
|
+
"archiviazione",
|
|
38508
|
+
"storage"
|
|
38509
|
+
]
|
|
38510
|
+
},
|
|
38511
|
+
{
|
|
38512
|
+
key: "server",
|
|
38513
|
+
group: "device",
|
|
38514
|
+
synonyms: ["server", "nas"]
|
|
38515
|
+
},
|
|
38516
|
+
{
|
|
38517
|
+
key: "mic",
|
|
38518
|
+
group: "device",
|
|
38519
|
+
synonyms: ["microfono", "audio"]
|
|
38520
|
+
},
|
|
38521
|
+
{
|
|
38522
|
+
key: "mic-off",
|
|
38523
|
+
group: "device",
|
|
38524
|
+
synonyms: [
|
|
38525
|
+
"microfono",
|
|
38526
|
+
"spento",
|
|
38527
|
+
"muto"
|
|
38528
|
+
]
|
|
38529
|
+
},
|
|
38530
|
+
{
|
|
38531
|
+
key: "video",
|
|
38532
|
+
group: "device",
|
|
38533
|
+
synonyms: ["video"]
|
|
38534
|
+
},
|
|
38535
|
+
{
|
|
38536
|
+
key: "video-off",
|
|
38537
|
+
group: "device",
|
|
38538
|
+
synonyms: ["video", "spento"]
|
|
38539
|
+
},
|
|
38540
|
+
{
|
|
38541
|
+
key: "music",
|
|
38542
|
+
group: "device",
|
|
38543
|
+
synonyms: ["musica"]
|
|
38544
|
+
},
|
|
38545
|
+
{
|
|
38546
|
+
key: "plug",
|
|
38547
|
+
group: "device",
|
|
38548
|
+
synonyms: [
|
|
38549
|
+
"presa",
|
|
38550
|
+
"spina",
|
|
38551
|
+
"plug"
|
|
38552
|
+
]
|
|
38553
|
+
},
|
|
38554
|
+
{
|
|
38555
|
+
key: "plug-zap",
|
|
38556
|
+
group: "device",
|
|
38557
|
+
synonyms: ["presa", "alimentata"]
|
|
38558
|
+
},
|
|
38559
|
+
{
|
|
38560
|
+
key: "power",
|
|
38561
|
+
group: "device",
|
|
38562
|
+
synonyms: [
|
|
38563
|
+
"accensione",
|
|
38564
|
+
"alimentazione",
|
|
38565
|
+
"power"
|
|
38566
|
+
]
|
|
38567
|
+
},
|
|
38568
|
+
{
|
|
38569
|
+
key: "power-off",
|
|
38570
|
+
group: "device",
|
|
38571
|
+
synonyms: ["spegnimento", "spento"]
|
|
38572
|
+
},
|
|
38573
|
+
{
|
|
38574
|
+
key: "cable",
|
|
38575
|
+
group: "device",
|
|
38576
|
+
synonyms: ["cavo"]
|
|
38577
|
+
},
|
|
38578
|
+
{
|
|
38579
|
+
key: "battery",
|
|
38580
|
+
group: "device",
|
|
38581
|
+
synonyms: ["batteria"]
|
|
38582
|
+
},
|
|
38583
|
+
{
|
|
38584
|
+
key: "battery-charging",
|
|
38585
|
+
group: "device",
|
|
38586
|
+
synonyms: [
|
|
38587
|
+
"batteria",
|
|
38588
|
+
"carica",
|
|
38589
|
+
"ricarica"
|
|
38590
|
+
]
|
|
38591
|
+
},
|
|
38592
|
+
{
|
|
38593
|
+
key: "battery-full",
|
|
38594
|
+
group: "device",
|
|
38595
|
+
synonyms: [
|
|
38596
|
+
"batteria",
|
|
38597
|
+
"carica",
|
|
38598
|
+
"piena"
|
|
38599
|
+
]
|
|
38600
|
+
},
|
|
38601
|
+
{
|
|
38602
|
+
key: "battery-low",
|
|
38603
|
+
group: "device",
|
|
38604
|
+
synonyms: ["batteria", "scarica"]
|
|
38605
|
+
},
|
|
38606
|
+
{
|
|
38607
|
+
key: "key",
|
|
38608
|
+
group: "device",
|
|
38609
|
+
synonyms: ["chiave"]
|
|
38610
|
+
},
|
|
38611
|
+
{
|
|
38612
|
+
key: "key-round",
|
|
38613
|
+
group: "device",
|
|
38614
|
+
synonyms: ["chiave", "tonda"]
|
|
38615
|
+
},
|
|
38616
|
+
{
|
|
38617
|
+
key: "mailbox",
|
|
38618
|
+
group: "device",
|
|
38619
|
+
synonyms: [
|
|
38620
|
+
"cassetta",
|
|
38621
|
+
"postale",
|
|
38622
|
+
"posta"
|
|
38623
|
+
]
|
|
38624
|
+
},
|
|
38625
|
+
{
|
|
38626
|
+
key: "package",
|
|
38627
|
+
group: "device",
|
|
38628
|
+
synonyms: ["pacco", "consegna"]
|
|
38629
|
+
},
|
|
38630
|
+
{
|
|
38631
|
+
key: "shield",
|
|
38632
|
+
group: "device",
|
|
38633
|
+
synonyms: [
|
|
38634
|
+
"scudo",
|
|
38635
|
+
"protezione",
|
|
38636
|
+
"sicurezza",
|
|
38637
|
+
"allarme"
|
|
38638
|
+
]
|
|
38639
|
+
},
|
|
38640
|
+
{
|
|
38641
|
+
key: "shield-check",
|
|
38642
|
+
group: "device",
|
|
38643
|
+
synonyms: [
|
|
38644
|
+
"protetto",
|
|
38645
|
+
"sicuro",
|
|
38646
|
+
"armato"
|
|
38647
|
+
]
|
|
38648
|
+
},
|
|
38649
|
+
{
|
|
38650
|
+
key: "shield-off",
|
|
38651
|
+
group: "device",
|
|
38652
|
+
synonyms: [
|
|
38653
|
+
"protezione",
|
|
38654
|
+
"disattivata",
|
|
38655
|
+
"disarmato"
|
|
38656
|
+
]
|
|
38657
|
+
},
|
|
38658
|
+
{
|
|
38659
|
+
key: "briefcase",
|
|
38660
|
+
group: "device",
|
|
38661
|
+
synonyms: [
|
|
38662
|
+
"ufficio",
|
|
38663
|
+
"lavoro",
|
|
38664
|
+
"valigetta",
|
|
38665
|
+
"studio"
|
|
38666
|
+
]
|
|
38667
|
+
},
|
|
38668
|
+
{
|
|
38669
|
+
key: "lightbulb",
|
|
38670
|
+
group: "lighting",
|
|
38671
|
+
synonyms: [
|
|
38672
|
+
"luce",
|
|
38673
|
+
"lampadina",
|
|
38674
|
+
"lampada",
|
|
38675
|
+
"light"
|
|
38676
|
+
]
|
|
38677
|
+
},
|
|
38678
|
+
{
|
|
38679
|
+
key: "lamp",
|
|
38680
|
+
group: "lighting",
|
|
38681
|
+
synonyms: ["lampada", "abatjour"]
|
|
38682
|
+
},
|
|
38683
|
+
{
|
|
38684
|
+
key: "lamp-ceiling",
|
|
38685
|
+
group: "lighting",
|
|
38686
|
+
synonyms: [
|
|
38687
|
+
"plafoniera",
|
|
38688
|
+
"lampadario",
|
|
38689
|
+
"luce",
|
|
38690
|
+
"soffitto"
|
|
38691
|
+
]
|
|
38692
|
+
},
|
|
38693
|
+
{
|
|
38694
|
+
key: "lamp-desk",
|
|
38695
|
+
group: "lighting",
|
|
38696
|
+
synonyms: [
|
|
38697
|
+
"lampada",
|
|
38698
|
+
"scrivania",
|
|
38699
|
+
"abatjour"
|
|
38700
|
+
]
|
|
38701
|
+
},
|
|
38702
|
+
{
|
|
38703
|
+
key: "lamp-floor",
|
|
38704
|
+
group: "lighting",
|
|
38705
|
+
synonyms: [
|
|
38706
|
+
"piantana",
|
|
38707
|
+
"lampada",
|
|
38708
|
+
"terra"
|
|
38709
|
+
]
|
|
38710
|
+
},
|
|
38711
|
+
{
|
|
38712
|
+
key: "flashlight",
|
|
38713
|
+
group: "lighting",
|
|
38714
|
+
synonyms: [
|
|
38715
|
+
"faretto",
|
|
38716
|
+
"torcia",
|
|
38717
|
+
"spot",
|
|
38718
|
+
"faro"
|
|
38719
|
+
]
|
|
38720
|
+
},
|
|
38721
|
+
{
|
|
38722
|
+
key: "heater",
|
|
38723
|
+
group: "lighting",
|
|
38724
|
+
synonyms: [
|
|
38725
|
+
"riscaldamento",
|
|
38726
|
+
"termosifone",
|
|
38727
|
+
"stufa",
|
|
38728
|
+
"calore"
|
|
38729
|
+
]
|
|
38730
|
+
},
|
|
38731
|
+
{
|
|
38732
|
+
key: "snowflake",
|
|
38733
|
+
group: "lighting",
|
|
38734
|
+
synonyms: [
|
|
38735
|
+
"freddo",
|
|
38736
|
+
"raffrescamento",
|
|
38737
|
+
"aria",
|
|
38738
|
+
"condizionata",
|
|
38739
|
+
"neve"
|
|
38740
|
+
]
|
|
38741
|
+
},
|
|
38742
|
+
{
|
|
38743
|
+
key: "thermometer-sun",
|
|
38744
|
+
group: "lighting",
|
|
38745
|
+
synonyms: [
|
|
38746
|
+
"caldo",
|
|
38747
|
+
"temperatura",
|
|
38748
|
+
"alta"
|
|
38749
|
+
]
|
|
38750
|
+
},
|
|
38751
|
+
{
|
|
38752
|
+
key: "thermometer-snowflake",
|
|
38753
|
+
group: "lighting",
|
|
38754
|
+
synonyms: [
|
|
38755
|
+
"freddo",
|
|
38756
|
+
"temperatura",
|
|
38757
|
+
"bassa"
|
|
38758
|
+
]
|
|
38759
|
+
},
|
|
38760
|
+
{
|
|
38761
|
+
key: "refrigerator",
|
|
38762
|
+
group: "lighting",
|
|
38763
|
+
synonyms: ["frigorifero", "frigo"]
|
|
38764
|
+
},
|
|
38765
|
+
{
|
|
38766
|
+
key: "microwave",
|
|
38767
|
+
group: "lighting",
|
|
38768
|
+
synonyms: ["microonde", "forno"]
|
|
38769
|
+
},
|
|
38770
|
+
{
|
|
38771
|
+
key: "washing-machine",
|
|
38772
|
+
group: "lighting",
|
|
38773
|
+
synonyms: [
|
|
38774
|
+
"lavatrice",
|
|
38775
|
+
"lavastoviglie",
|
|
38776
|
+
"asciugatrice"
|
|
38777
|
+
]
|
|
38778
|
+
},
|
|
38779
|
+
{
|
|
38780
|
+
key: "cooking-pot",
|
|
38781
|
+
group: "lighting",
|
|
38782
|
+
synonyms: [
|
|
38783
|
+
"pentola",
|
|
38784
|
+
"cucina",
|
|
38785
|
+
"fornello"
|
|
38786
|
+
]
|
|
38787
|
+
},
|
|
38788
|
+
{
|
|
38789
|
+
key: "utensils",
|
|
38790
|
+
group: "lighting",
|
|
38791
|
+
synonyms: [
|
|
38792
|
+
"cucina",
|
|
38793
|
+
"posate",
|
|
38794
|
+
"sala",
|
|
38795
|
+
"pranzo"
|
|
38796
|
+
]
|
|
38797
|
+
},
|
|
38798
|
+
{
|
|
38799
|
+
key: "thermometer",
|
|
38800
|
+
group: "sensing",
|
|
38801
|
+
synonyms: [
|
|
38802
|
+
"temperatura",
|
|
38803
|
+
"termometro",
|
|
38804
|
+
"termostato"
|
|
38805
|
+
]
|
|
38806
|
+
},
|
|
38807
|
+
{
|
|
38808
|
+
key: "droplets",
|
|
38809
|
+
group: "sensing",
|
|
38810
|
+
synonyms: [
|
|
38811
|
+
"umidita",
|
|
38812
|
+
"acqua",
|
|
38813
|
+
"allagamento",
|
|
38814
|
+
"perdita"
|
|
38815
|
+
]
|
|
38816
|
+
},
|
|
38817
|
+
{
|
|
38818
|
+
key: "droplet",
|
|
38819
|
+
group: "sensing",
|
|
38820
|
+
synonyms: [
|
|
38821
|
+
"goccia",
|
|
38822
|
+
"acqua",
|
|
38823
|
+
"perdita",
|
|
38824
|
+
"allagamento"
|
|
38825
|
+
]
|
|
38826
|
+
},
|
|
38827
|
+
{
|
|
38828
|
+
key: "gauge",
|
|
38829
|
+
group: "sensing",
|
|
38830
|
+
synonyms: [
|
|
38831
|
+
"misura",
|
|
38832
|
+
"livello",
|
|
38833
|
+
"pressione",
|
|
38834
|
+
"indicatore"
|
|
38835
|
+
]
|
|
38836
|
+
},
|
|
38837
|
+
{
|
|
38838
|
+
key: "gauge2",
|
|
38839
|
+
group: "sensing",
|
|
38840
|
+
synonyms: [
|
|
38841
|
+
"misura",
|
|
38842
|
+
"livello",
|
|
38843
|
+
"pressione",
|
|
38844
|
+
"indicatore"
|
|
38845
|
+
],
|
|
38846
|
+
aliasOf: "gauge"
|
|
38847
|
+
},
|
|
38848
|
+
{
|
|
38849
|
+
key: "sun",
|
|
38850
|
+
group: "sensing",
|
|
38851
|
+
synonyms: [
|
|
38852
|
+
"sole",
|
|
38853
|
+
"luminosita",
|
|
38854
|
+
"giorno",
|
|
38855
|
+
"esterno"
|
|
38856
|
+
]
|
|
38857
|
+
},
|
|
38858
|
+
{
|
|
38859
|
+
key: "sun-medium",
|
|
38860
|
+
group: "sensing",
|
|
38861
|
+
synonyms: ["sole", "luminosita"]
|
|
38862
|
+
},
|
|
38863
|
+
{
|
|
38864
|
+
key: "sunrise",
|
|
38865
|
+
group: "sensing",
|
|
38866
|
+
synonyms: ["alba"]
|
|
38867
|
+
},
|
|
38868
|
+
{
|
|
38869
|
+
key: "moon",
|
|
38870
|
+
group: "sensing",
|
|
38871
|
+
synonyms: [
|
|
38872
|
+
"notte",
|
|
38873
|
+
"luna",
|
|
38874
|
+
"notturna",
|
|
38875
|
+
"nightvision"
|
|
38876
|
+
]
|
|
38877
|
+
},
|
|
38878
|
+
{
|
|
38879
|
+
key: "wind",
|
|
38880
|
+
group: "sensing",
|
|
38881
|
+
synonyms: [
|
|
38882
|
+
"vento",
|
|
38883
|
+
"aria",
|
|
38884
|
+
"gas",
|
|
38885
|
+
"monossido",
|
|
38886
|
+
"co"
|
|
38887
|
+
]
|
|
38888
|
+
},
|
|
38889
|
+
{
|
|
38890
|
+
key: "cloud",
|
|
38891
|
+
group: "sensing",
|
|
38892
|
+
synonyms: ["nuvola", "nuvoloso"]
|
|
38893
|
+
},
|
|
38894
|
+
{
|
|
38895
|
+
key: "cloud-rain",
|
|
38896
|
+
group: "sensing",
|
|
38897
|
+
synonyms: ["pioggia"]
|
|
38898
|
+
},
|
|
38899
|
+
{
|
|
38900
|
+
key: "cloud-sun",
|
|
38901
|
+
group: "sensing",
|
|
38902
|
+
synonyms: [
|
|
38903
|
+
"variabile",
|
|
38904
|
+
"sole",
|
|
38905
|
+
"nuvole"
|
|
38906
|
+
]
|
|
38907
|
+
},
|
|
38908
|
+
{
|
|
38909
|
+
key: "waves",
|
|
38910
|
+
group: "sensing",
|
|
38911
|
+
synonyms: [
|
|
38912
|
+
"onde",
|
|
38913
|
+
"acqua",
|
|
38914
|
+
"vibrazione"
|
|
38915
|
+
]
|
|
38916
|
+
},
|
|
38917
|
+
{
|
|
38918
|
+
key: "sprout",
|
|
38919
|
+
group: "sensing",
|
|
38920
|
+
synonyms: [
|
|
38921
|
+
"irrigazione",
|
|
38922
|
+
"pianta",
|
|
38923
|
+
"germoglio",
|
|
38924
|
+
"orto"
|
|
38925
|
+
]
|
|
38926
|
+
},
|
|
38927
|
+
{
|
|
38928
|
+
key: "leaf",
|
|
38929
|
+
group: "sensing",
|
|
38930
|
+
synonyms: [
|
|
38931
|
+
"foglia",
|
|
38932
|
+
"pianta",
|
|
38933
|
+
"verde"
|
|
38934
|
+
]
|
|
38935
|
+
},
|
|
38936
|
+
{
|
|
38937
|
+
key: "flower",
|
|
38938
|
+
group: "sensing",
|
|
38939
|
+
synonyms: ["fiore", "pianta"]
|
|
38940
|
+
},
|
|
38941
|
+
{
|
|
38942
|
+
key: "zap",
|
|
38943
|
+
group: "sensing",
|
|
38944
|
+
synonyms: [
|
|
38945
|
+
"energia",
|
|
38946
|
+
"corrente",
|
|
38947
|
+
"consumo",
|
|
38948
|
+
"watt",
|
|
38949
|
+
"elettricita"
|
|
38950
|
+
]
|
|
38951
|
+
},
|
|
38952
|
+
{
|
|
38953
|
+
key: "flame",
|
|
38954
|
+
group: "sensing",
|
|
38955
|
+
synonyms: [
|
|
38956
|
+
"fiamma",
|
|
38957
|
+
"gas",
|
|
38958
|
+
"fuoco",
|
|
38959
|
+
"incendio"
|
|
38960
|
+
]
|
|
38961
|
+
},
|
|
38962
|
+
{
|
|
38963
|
+
key: "bell",
|
|
38964
|
+
group: "sensing",
|
|
38965
|
+
synonyms: [
|
|
38966
|
+
"campanello",
|
|
38967
|
+
"suoneria",
|
|
38968
|
+
"notifica",
|
|
38969
|
+
"bell",
|
|
38970
|
+
"chime"
|
|
38971
|
+
]
|
|
38972
|
+
},
|
|
38973
|
+
{
|
|
38974
|
+
key: "bell-ring",
|
|
38975
|
+
group: "sensing",
|
|
38976
|
+
synonyms: [
|
|
38977
|
+
"campanello",
|
|
38978
|
+
"suona",
|
|
38979
|
+
"doorbell",
|
|
38980
|
+
"notifica"
|
|
38981
|
+
]
|
|
38982
|
+
},
|
|
38983
|
+
{
|
|
38984
|
+
key: "bell-off",
|
|
38985
|
+
group: "sensing",
|
|
38986
|
+
synonyms: [
|
|
38987
|
+
"silenzioso",
|
|
38988
|
+
"notifiche",
|
|
38989
|
+
"disattivate",
|
|
38990
|
+
"muto"
|
|
38991
|
+
]
|
|
38992
|
+
},
|
|
38993
|
+
{
|
|
38994
|
+
key: "siren",
|
|
38995
|
+
group: "sensing",
|
|
38996
|
+
synonyms: ["sirena", "allarme"]
|
|
38997
|
+
},
|
|
38998
|
+
{
|
|
38999
|
+
key: "radar",
|
|
39000
|
+
group: "sensing",
|
|
39001
|
+
synonyms: [
|
|
39002
|
+
"movimento",
|
|
39003
|
+
"presenza",
|
|
39004
|
+
"pir",
|
|
39005
|
+
"rilevamento",
|
|
39006
|
+
"radar"
|
|
39007
|
+
]
|
|
39008
|
+
},
|
|
39009
|
+
{
|
|
39010
|
+
key: "activity",
|
|
39011
|
+
group: "sensing",
|
|
39012
|
+
synonyms: [
|
|
39013
|
+
"attivita",
|
|
39014
|
+
"sensore",
|
|
39015
|
+
"generico"
|
|
39016
|
+
]
|
|
39017
|
+
},
|
|
39018
|
+
{
|
|
39019
|
+
key: "hash",
|
|
39020
|
+
group: "sensing",
|
|
39021
|
+
synonyms: [
|
|
39022
|
+
"numero",
|
|
39023
|
+
"contatore",
|
|
39024
|
+
"valore"
|
|
39025
|
+
]
|
|
39026
|
+
},
|
|
39027
|
+
{
|
|
39028
|
+
key: "clock",
|
|
39029
|
+
group: "sensing",
|
|
39030
|
+
synonyms: [
|
|
39031
|
+
"orologio",
|
|
39032
|
+
"ora",
|
|
39033
|
+
"tempo"
|
|
39034
|
+
]
|
|
39035
|
+
},
|
|
39036
|
+
{
|
|
39037
|
+
key: "timer",
|
|
39038
|
+
group: "sensing",
|
|
39039
|
+
synonyms: [
|
|
39040
|
+
"timer",
|
|
39041
|
+
"conto",
|
|
39042
|
+
"rovescia"
|
|
39043
|
+
]
|
|
39044
|
+
},
|
|
39045
|
+
{
|
|
39046
|
+
key: "calendar",
|
|
39047
|
+
group: "sensing",
|
|
39048
|
+
synonyms: [
|
|
39049
|
+
"calendario",
|
|
39050
|
+
"data",
|
|
39051
|
+
"pianificazione"
|
|
39052
|
+
]
|
|
39053
|
+
},
|
|
39054
|
+
{
|
|
39055
|
+
key: "pipette",
|
|
39056
|
+
group: "sensing",
|
|
39057
|
+
synonyms: [
|
|
39058
|
+
"qualita",
|
|
39059
|
+
"aria",
|
|
39060
|
+
"misura",
|
|
39061
|
+
"campione"
|
|
39062
|
+
]
|
|
39063
|
+
},
|
|
39064
|
+
{
|
|
39065
|
+
key: "fan",
|
|
39066
|
+
group: "sensing",
|
|
39067
|
+
synonyms: [
|
|
39068
|
+
"ventilatore",
|
|
39069
|
+
"ventola",
|
|
39070
|
+
"aria"
|
|
39071
|
+
]
|
|
39072
|
+
},
|
|
39073
|
+
{
|
|
39074
|
+
key: "bath",
|
|
39075
|
+
group: "sensing",
|
|
39076
|
+
synonyms: [
|
|
39077
|
+
"bagno",
|
|
39078
|
+
"vasca",
|
|
39079
|
+
"doccia"
|
|
39080
|
+
]
|
|
39081
|
+
},
|
|
39082
|
+
{
|
|
39083
|
+
key: "footprints",
|
|
39084
|
+
group: "sensing",
|
|
39085
|
+
synonyms: [
|
|
39086
|
+
"movimento",
|
|
39087
|
+
"passi",
|
|
39088
|
+
"presenza",
|
|
39089
|
+
"motion"
|
|
39090
|
+
]
|
|
39091
|
+
},
|
|
39092
|
+
{
|
|
39093
|
+
key: "alarm-smoke",
|
|
39094
|
+
group: "sensing",
|
|
39095
|
+
synonyms: [
|
|
39096
|
+
"fumo",
|
|
39097
|
+
"antincendio",
|
|
39098
|
+
"rilevatore"
|
|
39099
|
+
]
|
|
39100
|
+
},
|
|
39101
|
+
{
|
|
39102
|
+
key: "shield-alert",
|
|
39103
|
+
group: "sensing",
|
|
39104
|
+
synonyms: [
|
|
39105
|
+
"manomissione",
|
|
39106
|
+
"allarme",
|
|
39107
|
+
"tamper",
|
|
39108
|
+
"avviso"
|
|
39109
|
+
]
|
|
39110
|
+
},
|
|
39111
|
+
{
|
|
39112
|
+
key: "vibrate",
|
|
39113
|
+
group: "sensing",
|
|
39114
|
+
synonyms: [
|
|
39115
|
+
"vibrazione",
|
|
39116
|
+
"urto",
|
|
39117
|
+
"tamper"
|
|
39118
|
+
]
|
|
39119
|
+
},
|
|
39120
|
+
{
|
|
39121
|
+
key: "wifi",
|
|
39122
|
+
group: "sensing",
|
|
39123
|
+
synonyms: [
|
|
39124
|
+
"wifi",
|
|
39125
|
+
"rete",
|
|
39126
|
+
"segnale"
|
|
39127
|
+
]
|
|
39128
|
+
},
|
|
39129
|
+
{
|
|
39130
|
+
key: "wifi-off",
|
|
39131
|
+
group: "sensing",
|
|
39132
|
+
synonyms: [
|
|
39133
|
+
"wifi",
|
|
39134
|
+
"assente",
|
|
39135
|
+
"offline",
|
|
39136
|
+
"disconnesso"
|
|
39137
|
+
]
|
|
39138
|
+
},
|
|
39139
|
+
{
|
|
39140
|
+
key: "volume-2",
|
|
39141
|
+
group: "sensing",
|
|
39142
|
+
synonyms: [
|
|
39143
|
+
"volume",
|
|
39144
|
+
"audio",
|
|
39145
|
+
"altoparlante",
|
|
39146
|
+
"suono"
|
|
39147
|
+
]
|
|
39148
|
+
},
|
|
39149
|
+
{
|
|
39150
|
+
key: "volume-x",
|
|
39151
|
+
group: "sensing",
|
|
39152
|
+
synonyms: [
|
|
39153
|
+
"muto",
|
|
39154
|
+
"silenziato",
|
|
39155
|
+
"volume",
|
|
39156
|
+
"spento"
|
|
39157
|
+
]
|
|
39158
|
+
},
|
|
39159
|
+
{
|
|
39160
|
+
key: "heart",
|
|
39161
|
+
group: "sensing",
|
|
39162
|
+
synonyms: [
|
|
39163
|
+
"cuore",
|
|
39164
|
+
"preferito",
|
|
39165
|
+
"salute"
|
|
39166
|
+
]
|
|
39167
|
+
},
|
|
39168
|
+
{
|
|
39169
|
+
key: "heart-pulse",
|
|
39170
|
+
group: "sensing",
|
|
39171
|
+
synonyms: [
|
|
39172
|
+
"battito",
|
|
39173
|
+
"salute",
|
|
39174
|
+
"monitoraggio"
|
|
39175
|
+
]
|
|
39176
|
+
},
|
|
39177
|
+
{
|
|
39178
|
+
key: "star",
|
|
39179
|
+
group: "sensing",
|
|
39180
|
+
synonyms: [
|
|
39181
|
+
"preferito",
|
|
39182
|
+
"stella",
|
|
39183
|
+
"importante"
|
|
39184
|
+
]
|
|
39185
|
+
},
|
|
39186
|
+
{
|
|
39187
|
+
key: "sparkles",
|
|
39188
|
+
group: "sensing",
|
|
39189
|
+
synonyms: [
|
|
39190
|
+
"automatico",
|
|
39191
|
+
"intelligente",
|
|
39192
|
+
"ai",
|
|
39193
|
+
"scena"
|
|
39194
|
+
]
|
|
39195
|
+
},
|
|
39196
|
+
{
|
|
39197
|
+
key: "house",
|
|
39198
|
+
group: "place",
|
|
39199
|
+
synonyms: [
|
|
39200
|
+
"casa",
|
|
39201
|
+
"abitazione",
|
|
39202
|
+
"home"
|
|
39203
|
+
]
|
|
39204
|
+
},
|
|
39205
|
+
{
|
|
39206
|
+
key: "sofa",
|
|
39207
|
+
group: "place",
|
|
39208
|
+
synonyms: [
|
|
39209
|
+
"salone",
|
|
39210
|
+
"soggiorno",
|
|
39211
|
+
"divano",
|
|
39212
|
+
"living"
|
|
39213
|
+
]
|
|
39214
|
+
},
|
|
39215
|
+
{
|
|
39216
|
+
key: "bed-double",
|
|
39217
|
+
group: "place",
|
|
39218
|
+
synonyms: [
|
|
39219
|
+
"camera",
|
|
39220
|
+
"letto",
|
|
39221
|
+
"matrimoniale"
|
|
39222
|
+
]
|
|
39223
|
+
},
|
|
39224
|
+
{
|
|
39225
|
+
key: "bed",
|
|
39226
|
+
group: "place",
|
|
39227
|
+
synonyms: [
|
|
39228
|
+
"cameretta",
|
|
39229
|
+
"letto",
|
|
39230
|
+
"singolo"
|
|
39231
|
+
]
|
|
39232
|
+
},
|
|
39233
|
+
{
|
|
39234
|
+
key: "warehouse",
|
|
39235
|
+
group: "place",
|
|
39236
|
+
synonyms: [
|
|
39237
|
+
"magazzino",
|
|
39238
|
+
"capanno",
|
|
39239
|
+
"deposito",
|
|
39240
|
+
"cantina"
|
|
39241
|
+
]
|
|
39242
|
+
},
|
|
39243
|
+
{
|
|
39244
|
+
key: "trees",
|
|
39245
|
+
group: "place",
|
|
39246
|
+
synonyms: [
|
|
39247
|
+
"giardino",
|
|
39248
|
+
"alberi",
|
|
39249
|
+
"esterno",
|
|
39250
|
+
"parco"
|
|
39251
|
+
]
|
|
39252
|
+
},
|
|
39253
|
+
{
|
|
39254
|
+
key: "tree-pine",
|
|
39255
|
+
group: "place",
|
|
39256
|
+
synonyms: [
|
|
39257
|
+
"albero",
|
|
39258
|
+
"pino",
|
|
39259
|
+
"giardino"
|
|
39260
|
+
]
|
|
39261
|
+
},
|
|
39262
|
+
{
|
|
39263
|
+
key: "fence",
|
|
39264
|
+
group: "place",
|
|
39265
|
+
synonyms: [
|
|
39266
|
+
"recinto",
|
|
39267
|
+
"cancello",
|
|
39268
|
+
"staccionata",
|
|
39269
|
+
"perimetro"
|
|
39270
|
+
]
|
|
39271
|
+
},
|
|
39272
|
+
{
|
|
39273
|
+
key: "store",
|
|
39274
|
+
group: "place",
|
|
39275
|
+
synonyms: [
|
|
39276
|
+
"negozio",
|
|
39277
|
+
"ingresso",
|
|
39278
|
+
"vetrina"
|
|
39279
|
+
]
|
|
39280
|
+
},
|
|
39281
|
+
{
|
|
39282
|
+
key: "building",
|
|
39283
|
+
group: "place",
|
|
39284
|
+
synonyms: ["edificio", "palazzo"]
|
|
39285
|
+
},
|
|
39286
|
+
{
|
|
39287
|
+
key: "building-2",
|
|
39288
|
+
group: "place",
|
|
39289
|
+
synonyms: [
|
|
39290
|
+
"edificio",
|
|
39291
|
+
"uffici",
|
|
39292
|
+
"condominio"
|
|
39293
|
+
]
|
|
39294
|
+
},
|
|
39295
|
+
{
|
|
39296
|
+
key: "mountain",
|
|
39297
|
+
group: "place",
|
|
39298
|
+
synonyms: [
|
|
39299
|
+
"montagna",
|
|
39300
|
+
"esterno",
|
|
39301
|
+
"panorama"
|
|
39302
|
+
]
|
|
39303
|
+
},
|
|
39304
|
+
{
|
|
39305
|
+
key: "umbrella",
|
|
39306
|
+
group: "place",
|
|
39307
|
+
synonyms: [
|
|
39308
|
+
"tenda",
|
|
39309
|
+
"markise",
|
|
39310
|
+
"ombrellone",
|
|
39311
|
+
"pergola",
|
|
39312
|
+
"veranda"
|
|
39313
|
+
]
|
|
39314
|
+
},
|
|
39315
|
+
{
|
|
39316
|
+
key: "dumbbell",
|
|
39317
|
+
group: "place",
|
|
39318
|
+
synonyms: ["palestra", "attrezzi"]
|
|
39319
|
+
},
|
|
39320
|
+
{
|
|
39321
|
+
key: "wrench",
|
|
39322
|
+
group: "place",
|
|
39323
|
+
synonyms: [
|
|
39324
|
+
"officina",
|
|
39325
|
+
"manutenzione",
|
|
39326
|
+
"attrezzi",
|
|
39327
|
+
"utenze"
|
|
39328
|
+
]
|
|
39329
|
+
},
|
|
39330
|
+
{
|
|
39331
|
+
key: "hammer",
|
|
39332
|
+
group: "place",
|
|
39333
|
+
synonyms: [
|
|
39334
|
+
"officina",
|
|
39335
|
+
"martello",
|
|
39336
|
+
"lavori"
|
|
39337
|
+
]
|
|
39338
|
+
},
|
|
39339
|
+
{
|
|
39340
|
+
key: "drill",
|
|
39341
|
+
group: "place",
|
|
39342
|
+
synonyms: [
|
|
39343
|
+
"officina",
|
|
39344
|
+
"trapano",
|
|
39345
|
+
"lavori"
|
|
39346
|
+
]
|
|
39347
|
+
},
|
|
39348
|
+
{
|
|
39349
|
+
key: "waves-ladder",
|
|
39350
|
+
group: "place",
|
|
39351
|
+
synonyms: ["piscina", "vasca"]
|
|
39352
|
+
},
|
|
39353
|
+
{
|
|
39354
|
+
key: "circle-parking",
|
|
39355
|
+
group: "place",
|
|
39356
|
+
synonyms: [
|
|
39357
|
+
"parcheggio",
|
|
39358
|
+
"posto",
|
|
39359
|
+
"auto",
|
|
39360
|
+
"garage"
|
|
39361
|
+
]
|
|
39362
|
+
},
|
|
39363
|
+
{
|
|
39364
|
+
key: "route",
|
|
39365
|
+
group: "place",
|
|
39366
|
+
synonyms: [
|
|
39367
|
+
"percorso",
|
|
39368
|
+
"strada",
|
|
39369
|
+
"vialetto",
|
|
39370
|
+
"tragitto"
|
|
39371
|
+
]
|
|
39372
|
+
},
|
|
39373
|
+
{
|
|
39374
|
+
key: "car",
|
|
39375
|
+
group: "life",
|
|
39376
|
+
synonyms: [
|
|
39377
|
+
"auto",
|
|
39378
|
+
"macchina",
|
|
39379
|
+
"veicolo",
|
|
39380
|
+
"parcheggio"
|
|
39381
|
+
]
|
|
39382
|
+
},
|
|
39383
|
+
{
|
|
39384
|
+
key: "bike",
|
|
39385
|
+
group: "life",
|
|
39386
|
+
synonyms: [
|
|
39387
|
+
"bicicletta",
|
|
39388
|
+
"bici",
|
|
39389
|
+
"moto"
|
|
39390
|
+
]
|
|
39391
|
+
},
|
|
39392
|
+
{
|
|
39393
|
+
key: "bus",
|
|
39394
|
+
group: "life",
|
|
39395
|
+
synonyms: ["autobus", "pullman"]
|
|
39396
|
+
},
|
|
39397
|
+
{
|
|
39398
|
+
key: "truck",
|
|
39399
|
+
group: "life",
|
|
39400
|
+
synonyms: [
|
|
39401
|
+
"camion",
|
|
39402
|
+
"furgone",
|
|
39403
|
+
"consegna"
|
|
39404
|
+
]
|
|
39405
|
+
},
|
|
39406
|
+
{
|
|
39407
|
+
key: "plane",
|
|
39408
|
+
group: "life",
|
|
39409
|
+
synonyms: ["aereo"]
|
|
39410
|
+
},
|
|
39411
|
+
{
|
|
39412
|
+
key: "train-front",
|
|
39413
|
+
group: "life",
|
|
39414
|
+
synonyms: ["treno"]
|
|
39415
|
+
},
|
|
39416
|
+
{
|
|
39417
|
+
key: "sailboat",
|
|
39418
|
+
group: "life",
|
|
39419
|
+
synonyms: ["barca", "imbarcazione"]
|
|
39420
|
+
},
|
|
39421
|
+
{
|
|
39422
|
+
key: "dog",
|
|
39423
|
+
group: "life",
|
|
39424
|
+
synonyms: ["cane", "animale"]
|
|
39425
|
+
},
|
|
39426
|
+
{
|
|
39427
|
+
key: "cat",
|
|
39428
|
+
group: "life",
|
|
39429
|
+
synonyms: ["gatto", "animale"]
|
|
39430
|
+
},
|
|
39431
|
+
{
|
|
39432
|
+
key: "bird",
|
|
39433
|
+
group: "life",
|
|
39434
|
+
synonyms: ["uccello", "animale"]
|
|
39435
|
+
},
|
|
39436
|
+
{
|
|
39437
|
+
key: "paw-print",
|
|
39438
|
+
group: "life",
|
|
39439
|
+
synonyms: [
|
|
39440
|
+
"animale",
|
|
39441
|
+
"zampa",
|
|
39442
|
+
"pet"
|
|
39443
|
+
]
|
|
39444
|
+
},
|
|
39445
|
+
{
|
|
39446
|
+
key: "baby",
|
|
39447
|
+
group: "life",
|
|
39448
|
+
synonyms: [
|
|
39449
|
+
"neonato",
|
|
39450
|
+
"bimbo",
|
|
39451
|
+
"cameretta",
|
|
39452
|
+
"babymonitor"
|
|
39453
|
+
]
|
|
39454
|
+
},
|
|
39455
|
+
{
|
|
39456
|
+
key: "user",
|
|
39457
|
+
group: "life",
|
|
39458
|
+
synonyms: ["persona", "utente"]
|
|
39459
|
+
},
|
|
39460
|
+
{
|
|
39461
|
+
key: "users",
|
|
39462
|
+
group: "life",
|
|
39463
|
+
synonyms: [
|
|
39464
|
+
"persone",
|
|
39465
|
+
"gruppo",
|
|
39466
|
+
"famiglia"
|
|
39467
|
+
]
|
|
39468
|
+
},
|
|
39469
|
+
{
|
|
39470
|
+
key: "person-standing",
|
|
39471
|
+
group: "life",
|
|
39472
|
+
synonyms: [
|
|
39473
|
+
"persona",
|
|
39474
|
+
"piedi",
|
|
39475
|
+
"presenza"
|
|
39476
|
+
]
|
|
39477
|
+
}
|
|
39478
|
+
];
|
|
39479
|
+
DISPLAY_ICON_VOCABULARY.map((e) => e.key);
|
|
39480
|
+
DISPLAY_ICON_VOCABULARY.filter((e) => e.aliasOf === void 0).map((e) => e.key);
|
|
39481
|
+
new Map(DISPLAY_ICON_VOCABULARY.map((e) => [e.key, e]));
|
|
38020
39482
|
var LAST_FETCHED_FIELD = "lastFetchedAt";
|
|
38021
39483
|
/**
|
|
38022
39484
|
* How long a bridge stops re-attempting a refresh that did not land.
|
|
@@ -44962,6 +46424,12 @@ Object.freeze({
|
|
|
44962
46424
|
addonId: null,
|
|
44963
46425
|
access: "view"
|
|
44964
46426
|
},
|
|
46427
|
+
"videoclips.dialClipStream": {
|
|
46428
|
+
capName: "videoclips",
|
|
46429
|
+
capScope: "device",
|
|
46430
|
+
addonId: null,
|
|
46431
|
+
access: "view"
|
|
46432
|
+
},
|
|
44965
46433
|
"videoclips.getClipPlayback": {
|
|
44966
46434
|
capName: "videoclips",
|
|
44967
46435
|
capScope: "device",
|
|
@@ -47001,6 +48469,11 @@ Object.freeze({
|
|
|
47001
48469
|
form: "single",
|
|
47002
48470
|
optional: false
|
|
47003
48471
|
}],
|
|
48472
|
+
"videoclips.dialClipStream": [{
|
|
48473
|
+
name: "deviceId",
|
|
48474
|
+
form: "single",
|
|
48475
|
+
optional: false
|
|
48476
|
+
}],
|
|
47004
48477
|
"videoclips.getClipPlayback": [{
|
|
47005
48478
|
name: "deviceId",
|
|
47006
48479
|
form: "single",
|
|
@@ -76815,7 +78288,7 @@ var require_lz4 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
|
|
|
76815
78288
|
};
|
|
76816
78289
|
}));
|
|
76817
78290
|
//#endregion
|
|
76818
|
-
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-
|
|
78291
|
+
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-GNC5U3YA.js
|
|
76819
78292
|
var import_fxp = require_fxp();
|
|
76820
78293
|
var import_lz4 = /* @__PURE__ */ require_chunk.__toESM(require_lz4(), 1);
|
|
76821
78294
|
function encodeHeader(h) {
|
|
@@ -80980,6 +82453,7 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
|
|
|
80980
82453
|
lockedChannelId = frame.header.channelId;
|
|
80981
82454
|
lockedStreamType = frame.header.streamType;
|
|
80982
82455
|
}
|
|
82456
|
+
params.onChunk?.(decrypted);
|
|
80983
82457
|
chunks.push(decrypted);
|
|
80984
82458
|
armIdleFinish();
|
|
80985
82459
|
if (frame.header.responseCode === 201) finish(Buffer.concat(chunks));
|
|
@@ -92986,7 +94460,8 @@ ${stderr}`));
|
|
|
92986
94460
|
payloadXml,
|
|
92987
94461
|
streamType: 0,
|
|
92988
94462
|
timeoutMs,
|
|
92989
|
-
...params.idleTimeoutMs != null ? { idleTimeoutMs: params.idleTimeoutMs } : {}
|
|
94463
|
+
...params.idleTimeoutMs != null ? { idleTimeoutMs: params.idleTimeoutMs } : {},
|
|
94464
|
+
...params.onChunk ? { onChunk: params.onChunk } : {}
|
|
92990
94465
|
});
|
|
92991
94466
|
} catch (e) {
|
|
92992
94467
|
trace(`download failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -149050,6 +150525,161 @@ function createClipPlaybackHandler(deps) {
|
|
|
149050
150525
|
}).pipe(res);
|
|
149051
150526
|
};
|
|
149052
150527
|
}
|
|
150528
|
+
/**
|
|
150529
|
+
* `/addon/provider-reolink/clip-stream/<deviceId>/<clipKey>.mp4` — the same
|
|
150530
|
+
* recording as `clip/…`, served as it ARRIVES from the camera.
|
|
150531
|
+
*
|
|
150532
|
+
* **Not `Range`-able, and it says so.** The response has no length: it is a
|
|
150533
|
+
* fragmented MP4 written by an ffmpeg that is still reading the camera when
|
|
150534
|
+
* the first byte leaves. So there is no `content-length`, `accept-ranges` is
|
|
150535
|
+
* `none`, a `Range` header is IGNORED (a 206 over something with no length
|
|
150536
|
+
* would be a lie), and the peer may not cache it (`no-store`) — a stream is
|
|
150537
|
+
* not an immutable artifact, the file route is. The status line is written
|
|
150538
|
+
* on the first muxed byte, never before: until then every refusal still owns
|
|
150539
|
+
* it, and a camera that serves nothing answers a status with a reason, not an
|
|
150540
|
+
* empty 200 (D391).
|
|
150541
|
+
*
|
|
150542
|
+
* `HEAD` answers the headers and touches no camera.
|
|
150543
|
+
*/
|
|
150544
|
+
var CLIP_STREAM_PREFIX = "clip-stream";
|
|
150545
|
+
/** The data plane's own form: the clip is named on the path. */
|
|
150546
|
+
var resolveClipStreamPathTarget = (req) => {
|
|
150547
|
+
const match = MEDIA_PATH.exec(pathnameOf(req));
|
|
150548
|
+
if (match === null) return {
|
|
150549
|
+
kind: "refused",
|
|
150550
|
+
status: 400,
|
|
150551
|
+
code: "bad-path",
|
|
150552
|
+
detail: "not /<deviceId>/<clipKey>.mp4"
|
|
150553
|
+
};
|
|
150554
|
+
return {
|
|
150555
|
+
kind: "target",
|
|
150556
|
+
target: {
|
|
150557
|
+
deviceId: Number(match[1]),
|
|
150558
|
+
clipKey: match[2] ?? "",
|
|
150559
|
+
twin: "sub",
|
|
150560
|
+
audio: "none"
|
|
150561
|
+
}
|
|
150562
|
+
};
|
|
150563
|
+
};
|
|
150564
|
+
/**
|
|
150565
|
+
* Everything but the content type, which the MUX decides: a stream carrying
|
|
150566
|
+
* µ-law is a QuickTime container (measured — ffmpeg's MP4 muxer refuses
|
|
150567
|
+
* `pcm_mulaw`), and a header that said `video/mp4` for it would be a small
|
|
150568
|
+
* lie on a plane whose whole point is saying what it really serves.
|
|
150569
|
+
*/
|
|
150570
|
+
var STREAM_HEADERS = {
|
|
150571
|
+
"accept-ranges": "none",
|
|
150572
|
+
"cache-control": "no-store",
|
|
150573
|
+
[CLIP_STREAM_TRANSPORT_HEADER]: "forward-only"
|
|
150574
|
+
};
|
|
150575
|
+
/** The status a refusal answers. `404` = nothing here to serve; `409` = a
|
|
150576
|
+
* clip that exists and cannot be served right now; `502` = the camera. */
|
|
150577
|
+
function streamRefusalStatus(code) {
|
|
150578
|
+
switch (code) {
|
|
150579
|
+
case "unknown-device":
|
|
150580
|
+
case "no-catalog-row": return 404;
|
|
150581
|
+
case "sleeping":
|
|
150582
|
+
case "uid-missing":
|
|
150583
|
+
case "clip-in-progress": return 409;
|
|
150584
|
+
case "camera-refused":
|
|
150585
|
+
case "fetch-failed": return 502;
|
|
150586
|
+
}
|
|
150587
|
+
}
|
|
150588
|
+
function createClipStreamHandler(deps) {
|
|
150589
|
+
const resolveTarget = deps.target ?? resolveClipStreamPathTarget;
|
|
150590
|
+
return async (req, res) => {
|
|
150591
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
150592
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
150593
|
+
res.end();
|
|
150594
|
+
return;
|
|
150595
|
+
}
|
|
150596
|
+
const resolved = resolveTarget(req);
|
|
150597
|
+
if (resolved.kind === "refused") {
|
|
150598
|
+
deps.logger.warn("videoclips: clip stream request names no clip", { meta: {
|
|
150599
|
+
code: resolved.code,
|
|
150600
|
+
reason: resolved.detail,
|
|
150601
|
+
status: resolved.status
|
|
150602
|
+
} });
|
|
150603
|
+
res.writeHead(resolved.status, {
|
|
150604
|
+
[CLIP_STILL_REASON_HEADER]: resolved.detail.slice(0, 500),
|
|
150605
|
+
[CLIP_STILL_REASON_CODE_HEADER]: resolved.code
|
|
150606
|
+
});
|
|
150607
|
+
res.end();
|
|
150608
|
+
return;
|
|
150609
|
+
}
|
|
150610
|
+
const { deviceId, clipKey } = resolved.target;
|
|
150611
|
+
if (req.method === "HEAD") {
|
|
150612
|
+
res.writeHead(200, {
|
|
150613
|
+
"content-type": "video/mp4",
|
|
150614
|
+
...STREAM_HEADERS
|
|
150615
|
+
});
|
|
150616
|
+
res.end();
|
|
150617
|
+
return;
|
|
150618
|
+
}
|
|
150619
|
+
if (req.headers.range !== void 0) deps.logger.debug("videoclips: a Range header on the clip stream route is ignored", {
|
|
150620
|
+
tags: { deviceId },
|
|
150621
|
+
meta: {
|
|
150622
|
+
clipKey,
|
|
150623
|
+
range: req.headers.range
|
|
150624
|
+
}
|
|
150625
|
+
});
|
|
150626
|
+
const sink = {
|
|
150627
|
+
begin: (contentType) => {
|
|
150628
|
+
res.writeHead(200, {
|
|
150629
|
+
"content-type": contentType,
|
|
150630
|
+
...STREAM_HEADERS
|
|
150631
|
+
});
|
|
150632
|
+
},
|
|
150633
|
+
write: (chunk) => res.write(chunk),
|
|
150634
|
+
onceDrain: (listener) => {
|
|
150635
|
+
res.once("drain", listener);
|
|
150636
|
+
},
|
|
150637
|
+
onceClose: (listener) => {
|
|
150638
|
+
res.once("close", listener);
|
|
150639
|
+
},
|
|
150640
|
+
end: () => {
|
|
150641
|
+
res.end();
|
|
150642
|
+
},
|
|
150643
|
+
destroy: () => {
|
|
150644
|
+
res.destroy();
|
|
150645
|
+
}
|
|
150646
|
+
};
|
|
150647
|
+
let outcome;
|
|
150648
|
+
try {
|
|
150649
|
+
outcome = await deps.stream(resolved.target, sink);
|
|
150650
|
+
} catch (err) {
|
|
150651
|
+
deps.logger.warn("videoclips: the clip stream route threw", {
|
|
150652
|
+
tags: { deviceId },
|
|
150653
|
+
meta: {
|
|
150654
|
+
clipKey,
|
|
150655
|
+
branch: "stream-threw",
|
|
150656
|
+
error: err instanceof Error ? err.message : String(err)
|
|
150657
|
+
}
|
|
150658
|
+
});
|
|
150659
|
+
if (res.headersSent) res.destroy();
|
|
150660
|
+
else {
|
|
150661
|
+
res.writeHead(500);
|
|
150662
|
+
res.end();
|
|
150663
|
+
}
|
|
150664
|
+
return;
|
|
150665
|
+
}
|
|
150666
|
+
if (outcome.kind !== "refused") return;
|
|
150667
|
+
deps.logger.info("videoclips: clip stream not served", {
|
|
150668
|
+
tags: { deviceId },
|
|
150669
|
+
meta: {
|
|
150670
|
+
clipKey,
|
|
150671
|
+
code: outcome.code,
|
|
150672
|
+
reason: outcome.detail,
|
|
150673
|
+
status: streamRefusalStatus(outcome.code)
|
|
150674
|
+
}
|
|
150675
|
+
});
|
|
150676
|
+
res.writeHead(streamRefusalStatus(outcome.code), {
|
|
150677
|
+
[CLIP_STILL_REASON_HEADER]: outcome.detail.slice(0, 500),
|
|
150678
|
+
[CLIP_STILL_REASON_CODE_HEADER]: outcome.code
|
|
150679
|
+
});
|
|
150680
|
+
res.end();
|
|
150681
|
+
};
|
|
150682
|
+
}
|
|
149053
150683
|
//#endregion
|
|
149054
150684
|
//#region src/clips/clip-mux.ts
|
|
149055
150685
|
/** A clip that has not transferred by here is not going to. */
|
|
@@ -149207,6 +150837,147 @@ function describeMuxFailure(run) {
|
|
|
149207
150837
|
if (run.code !== 0) return `mux-failed (exit ${String(run.code)}): ${run.stderr.slice(0, 300)}`;
|
|
149208
150838
|
return null;
|
|
149209
150839
|
}
|
|
150840
|
+
/** The fragmented container a stream is muxed into — see the file header. */
|
|
150841
|
+
var CLIP_STREAM_MOVFLAGS = "frag_keyframe+empty_moov+default_base_moof";
|
|
150842
|
+
/**
|
|
150843
|
+
* The rate the probe measured, through the library's one derivation.
|
|
150844
|
+
*
|
|
150845
|
+
* `estimateVideoTiming` falls back to the info header when the timestamps
|
|
150846
|
+
* cannot say; that fallback is REFUSED here (`fpsSource: 'unmeasured'`), as
|
|
150847
|
+
* D567 says, so a guess is never handed to `-r` under a measurement's name.
|
|
150848
|
+
*/
|
|
150849
|
+
function probeClipStreamRate(timestampsUs) {
|
|
150850
|
+
const timing = estimateVideoTiming({ timestampsUs });
|
|
150851
|
+
if (timing.fpsSource !== "timestamps" || timing.fps === null) return {
|
|
150852
|
+
fps: null,
|
|
150853
|
+
fpsSource: "unmeasured",
|
|
150854
|
+
accessUnits: timestampsUs.length
|
|
150855
|
+
};
|
|
150856
|
+
return {
|
|
150857
|
+
fps: timing.fps,
|
|
150858
|
+
fpsSource: "timestamps",
|
|
150859
|
+
accessUnits: timestampsUs.length
|
|
150860
|
+
};
|
|
150861
|
+
}
|
|
150862
|
+
/** Opus at 48 kHz — the only rate the codec has, and the session's RTP clock. */
|
|
150863
|
+
var CLIP_STREAM_OPUS_RATE_HZ = 48e3;
|
|
150864
|
+
/** µ-law at 8 kHz — the PCMU RTP clock. */
|
|
150865
|
+
var CLIP_STREAM_PCMU_RATE_HZ = 8e3;
|
|
150866
|
+
/**
|
|
150867
|
+
* The container a stream is muxed into, for a given audio ask.
|
|
150868
|
+
*
|
|
150869
|
+
* MEASURED, not chosen: ffmpeg's MP4 muxer refuses `pcm_mulaw`
|
|
150870
|
+
* (`Could not find tag for codec pcm_mulaw in stream #1`), and its MOV muxer
|
|
150871
|
+
* takes it as a `ulaw` sample entry. The boxes a consumer walks are the same
|
|
150872
|
+
* either way — `ftyp`, an empty `moov`, then `moof`+`mdat` — so this changes
|
|
150873
|
+
* the content type and nothing else.
|
|
150874
|
+
*/
|
|
150875
|
+
function clipStreamContainerFor(audio) {
|
|
150876
|
+
return audio === "pcmu" ? "mov" : "mp4";
|
|
150877
|
+
}
|
|
150878
|
+
/** What the route puts on `content-type` for {@link clipStreamContainerFor}. */
|
|
150879
|
+
function clipStreamContentTypeFor(audio) {
|
|
150880
|
+
return clipStreamContainerFor(audio) === "mov" ? "video/quicktime" : "video/mp4";
|
|
150881
|
+
}
|
|
150882
|
+
/**
|
|
150883
|
+
* One ffmpeg, stdin to stdout, fragmented, with an optional second input.
|
|
150884
|
+
*
|
|
150885
|
+
* Mirrors `buildClipMuxArgs` where a stream can: the demuxer is named from
|
|
150886
|
+
* the DETECTED codec (the BcMedia header lies on a hub child), `-r` is an
|
|
150887
|
+
* input option before its own `-i`, and the video is copied. Where it
|
|
150888
|
+
* cannot — `+faststart` — the file header says why; where it now can — the
|
|
150889
|
+
* audio input — it is an ENCODE rather than the file path's copy, because
|
|
150890
|
+
* this output is consumed by a WebRTC sender and not by a player.
|
|
150891
|
+
*/
|
|
150892
|
+
function buildClipStreamMuxArgs(input) {
|
|
150893
|
+
const demuxer = input.videoType === "H265" ? "hevc" : "h264";
|
|
150894
|
+
const audio = input.audio;
|
|
150895
|
+
return [
|
|
150896
|
+
"-hide_banner",
|
|
150897
|
+
"-loglevel",
|
|
150898
|
+
"error",
|
|
150899
|
+
"-nostdin",
|
|
150900
|
+
...input.fps !== null && Number.isFinite(input.fps) && input.fps > 0 ? ["-r", String(input.fps)] : [],
|
|
150901
|
+
"-f",
|
|
150902
|
+
demuxer,
|
|
150903
|
+
"-i",
|
|
150904
|
+
"pipe:0",
|
|
150905
|
+
...audio !== null ? [
|
|
150906
|
+
"-f",
|
|
150907
|
+
"aac",
|
|
150908
|
+
"-i",
|
|
150909
|
+
`pipe:${String(3)}`
|
|
150910
|
+
] : [],
|
|
150911
|
+
...audio !== null ? [
|
|
150912
|
+
"-map",
|
|
150913
|
+
"0:v",
|
|
150914
|
+
"-map",
|
|
150915
|
+
"1:a"
|
|
150916
|
+
] : [],
|
|
150917
|
+
"-c:v",
|
|
150918
|
+
"copy",
|
|
150919
|
+
...audio === "opus" ? [
|
|
150920
|
+
"-c:a",
|
|
150921
|
+
"libopus",
|
|
150922
|
+
"-ar",
|
|
150923
|
+
String(CLIP_STREAM_OPUS_RATE_HZ),
|
|
150924
|
+
"-ac",
|
|
150925
|
+
String(1),
|
|
150926
|
+
"-b:a",
|
|
150927
|
+
"24k"
|
|
150928
|
+
] : [],
|
|
150929
|
+
...audio === "pcmu" ? [
|
|
150930
|
+
"-c:a",
|
|
150931
|
+
"pcm_mulaw",
|
|
150932
|
+
"-ar",
|
|
150933
|
+
String(CLIP_STREAM_PCMU_RATE_HZ),
|
|
150934
|
+
"-ac",
|
|
150935
|
+
String(1)
|
|
150936
|
+
] : [],
|
|
150937
|
+
"-movflags",
|
|
150938
|
+
CLIP_STREAM_MOVFLAGS,
|
|
150939
|
+
"-f",
|
|
150940
|
+
clipStreamContainerFor(audio),
|
|
150941
|
+
"pipe:1"
|
|
150942
|
+
];
|
|
150943
|
+
}
|
|
150944
|
+
/**
|
|
150945
|
+
* The D447 rule as a state machine, so it can be proved without a socket.
|
|
150946
|
+
*
|
|
150947
|
+
* A `false` write ARMS the timer; further `false` writes while it is armed do
|
|
150948
|
+
* not push the deadline out. `drain` — the hop's own word that it took what
|
|
150949
|
+
* it held — disarms it. Stall fires at most once, and a disposed gate is
|
|
150950
|
+
* silent for ever.
|
|
150951
|
+
*/
|
|
150952
|
+
function createDrainGate(deps) {
|
|
150953
|
+
let handle = null;
|
|
150954
|
+
let stalled = false;
|
|
150955
|
+
const disarm = () => {
|
|
150956
|
+
if (handle !== null) clearTimeout(handle);
|
|
150957
|
+
handle = null;
|
|
150958
|
+
};
|
|
150959
|
+
return {
|
|
150960
|
+
wrote(accepted) {
|
|
150961
|
+
if (accepted || handle !== null || stalled) return;
|
|
150962
|
+
handle = setTimeout(() => {
|
|
150963
|
+
handle = null;
|
|
150964
|
+
if (stalled) return;
|
|
150965
|
+
stalled = true;
|
|
150966
|
+
deps.onStall();
|
|
150967
|
+
}, deps.timeoutMs);
|
|
150968
|
+
},
|
|
150969
|
+
drained() {
|
|
150970
|
+
disarm();
|
|
150971
|
+
},
|
|
150972
|
+
waiting() {
|
|
150973
|
+
return handle !== null;
|
|
150974
|
+
},
|
|
150975
|
+
dispose() {
|
|
150976
|
+
disarm();
|
|
150977
|
+
stalled = true;
|
|
150978
|
+
}
|
|
150979
|
+
};
|
|
150980
|
+
}
|
|
149210
150981
|
//#endregion
|
|
149211
150982
|
//#region src/clips/clip-truncation.ts
|
|
149212
150983
|
/**
|
|
@@ -149253,6 +151024,885 @@ function judgeClipDuration(input) {
|
|
|
149253
151024
|
};
|
|
149254
151025
|
}
|
|
149255
151026
|
//#endregion
|
|
151027
|
+
//#region src/clips/clip-stream.ts
|
|
151028
|
+
function isWritable(stream) {
|
|
151029
|
+
return "write" in stream && typeof stream.write === "function";
|
|
151030
|
+
}
|
|
151031
|
+
/** The stream at `index`, if it is one this process can be WRITTEN to. */
|
|
151032
|
+
function writableAt(stdio, index) {
|
|
151033
|
+
const candidate = stdio[index];
|
|
151034
|
+
if (candidate === null || candidate === void 0) return null;
|
|
151035
|
+
return isWritable(candidate) ? candidate : null;
|
|
151036
|
+
}
|
|
151037
|
+
var defaultDecoder = (handlers) => new require_chunk_ZBACPERX.BcMediaAnnexBDecoder({
|
|
151038
|
+
strict: false,
|
|
151039
|
+
onVideoAccessUnit: handlers.onVideoAccessUnit,
|
|
151040
|
+
onAudioFrame: (frame) => handlers.onAudioFrame({
|
|
151041
|
+
audioType: frame.audioType,
|
|
151042
|
+
data: frame.data
|
|
151043
|
+
})
|
|
151044
|
+
});
|
|
151045
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
151046
|
+
function createClipStreamProducer(deps) {
|
|
151047
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
151048
|
+
const createDecoder = deps.createDecoder ?? defaultDecoder;
|
|
151049
|
+
const drainTimeoutMs = deps.drainTimeoutMs ?? 1e4;
|
|
151050
|
+
const flushTimeoutMs = deps.flushTimeoutMs ?? 15e3;
|
|
151051
|
+
const lanes = /* @__PURE__ */ new Map();
|
|
151052
|
+
const activeByHost = /* @__PURE__ */ new Map();
|
|
151053
|
+
const runAttempt = (input, attempt) => new ClipStreamRun({
|
|
151054
|
+
logger: deps.logger,
|
|
151055
|
+
spawnMux: deps.spawnMux,
|
|
151056
|
+
createDecoder,
|
|
151057
|
+
drainTimeoutMs,
|
|
151058
|
+
flushTimeoutMs
|
|
151059
|
+
}, input, attempt).run();
|
|
151060
|
+
const runAttempts = async (input) => {
|
|
151061
|
+
const { deviceId } = input.device;
|
|
151062
|
+
let lastDetail = "no reason given";
|
|
151063
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
151064
|
+
if (attempt > 1) await sleep(500 * 2 ** (attempt - 2));
|
|
151065
|
+
const result = await runAttempt(input, attempt);
|
|
151066
|
+
if (result.kind !== "no-media") return result;
|
|
151067
|
+
lastDetail = result.detail;
|
|
151068
|
+
deps.logger.warn("videoclips: a clip stream produced no media — retrying", {
|
|
151069
|
+
tags: { deviceId },
|
|
151070
|
+
meta: {
|
|
151071
|
+
clipKey: input.row.clipKey,
|
|
151072
|
+
twin: input.twin,
|
|
151073
|
+
branch: "stream-no-media",
|
|
151074
|
+
attempt,
|
|
151075
|
+
error: lastDetail
|
|
151076
|
+
}
|
|
151077
|
+
});
|
|
151078
|
+
}
|
|
151079
|
+
deps.logger.warn("videoclips: the camera never delivered this clip stream", {
|
|
151080
|
+
tags: { deviceId },
|
|
151081
|
+
meta: {
|
|
151082
|
+
clipKey: input.row.clipKey,
|
|
151083
|
+
twin: input.twin,
|
|
151084
|
+
branch: "camera-refused",
|
|
151085
|
+
attempts: 3,
|
|
151086
|
+
error: lastDetail
|
|
151087
|
+
}
|
|
151088
|
+
});
|
|
151089
|
+
return {
|
|
151090
|
+
kind: "refused",
|
|
151091
|
+
code: "camera-refused",
|
|
151092
|
+
detail: `clip stream never landed after 3 attempts — ${lastDetail}`,
|
|
151093
|
+
attempts: 3
|
|
151094
|
+
};
|
|
151095
|
+
};
|
|
151096
|
+
return {
|
|
151097
|
+
active: (hostKey) => activeByHost.get(hostKey) ?? 0,
|
|
151098
|
+
stream: (input) => {
|
|
151099
|
+
const { hostKey, deviceId } = input.device;
|
|
151100
|
+
const ahead = activeByHost.get(hostKey) ?? 0;
|
|
151101
|
+
if (ahead > 0) deps.logger.info("videoclips: a clip stream is queued behind another on this host", {
|
|
151102
|
+
tags: { deviceId },
|
|
151103
|
+
meta: {
|
|
151104
|
+
clipKey: input.row.clipKey,
|
|
151105
|
+
twin: input.twin,
|
|
151106
|
+
branch: "stream-queued",
|
|
151107
|
+
ahead
|
|
151108
|
+
}
|
|
151109
|
+
});
|
|
151110
|
+
activeByHost.set(hostKey, ahead + 1);
|
|
151111
|
+
const run = (lanes.get(hostKey) ?? Promise.resolve()).then(() => runAttempts(input)).finally(() => {
|
|
151112
|
+
const left = (activeByHost.get(hostKey) ?? 1) - 1;
|
|
151113
|
+
if (left <= 0) activeByHost.delete(hostKey);
|
|
151114
|
+
else activeByHost.set(hostKey, left);
|
|
151115
|
+
});
|
|
151116
|
+
lanes.set(hostKey, run.then(() => void 0, () => void 0));
|
|
151117
|
+
return run;
|
|
151118
|
+
}
|
|
151119
|
+
};
|
|
151120
|
+
}
|
|
151121
|
+
/**
|
|
151122
|
+
* One transfer: camera → decoder → ffmpeg → peer, with a cut on every hop.
|
|
151123
|
+
*
|
|
151124
|
+
* A class because the run is a state machine with a dozen fields the chunk
|
|
151125
|
+
* callback and the download promise both touch; passing them around as
|
|
151126
|
+
* closures over `let`s was less readable, not more immutable.
|
|
151127
|
+
*/
|
|
151128
|
+
var ClipStreamRun = class {
|
|
151129
|
+
deps;
|
|
151130
|
+
input;
|
|
151131
|
+
attempt;
|
|
151132
|
+
accessUnits = 0;
|
|
151133
|
+
/** Every audio frame the decoder handed over, of any codec. */
|
|
151134
|
+
audioFrames = 0;
|
|
151135
|
+
audioFramesDropped = 0;
|
|
151136
|
+
audioFramesAfterMux = 0;
|
|
151137
|
+
/** ADTS frames held until the mux exists — the audio half of the probe. */
|
|
151138
|
+
audioProbe = [];
|
|
151139
|
+
audioProbeBytes = 0;
|
|
151140
|
+
audioMuxed = null;
|
|
151141
|
+
audioSilentReason = null;
|
|
151142
|
+
audioIn = null;
|
|
151143
|
+
timestampsUs = [];
|
|
151144
|
+
/** Access units held until the rate is probed — the frames in flight. */
|
|
151145
|
+
probe = [];
|
|
151146
|
+
mux = null;
|
|
151147
|
+
muxStderr = "";
|
|
151148
|
+
muxClosed = null;
|
|
151149
|
+
rate = {
|
|
151150
|
+
fps: null,
|
|
151151
|
+
fpsSource: "unmeasured",
|
|
151152
|
+
accessUnits: 0
|
|
151153
|
+
};
|
|
151154
|
+
videoType = null;
|
|
151155
|
+
begun = false;
|
|
151156
|
+
ended = false;
|
|
151157
|
+
bytesOut = 0;
|
|
151158
|
+
cut = null;
|
|
151159
|
+
stdinGate;
|
|
151160
|
+
audioStdinGate;
|
|
151161
|
+
peerGate;
|
|
151162
|
+
tags;
|
|
151163
|
+
constructor(deps, input, attempt) {
|
|
151164
|
+
this.deps = deps;
|
|
151165
|
+
this.input = input;
|
|
151166
|
+
this.attempt = attempt;
|
|
151167
|
+
this.tags = { deviceId: input.device.deviceId };
|
|
151168
|
+
this.stdinGate = createDrainGate({
|
|
151169
|
+
timeoutMs: deps.drainTimeoutMs,
|
|
151170
|
+
onStall: () => this.sever("mux-stalled", "ffmpeg stopped taking access units")
|
|
151171
|
+
});
|
|
151172
|
+
this.audioStdinGate = createDrainGate({
|
|
151173
|
+
timeoutMs: deps.drainTimeoutMs,
|
|
151174
|
+
onStall: () => this.sever("mux-audio-stalled", "ffmpeg stopped taking audio frames")
|
|
151175
|
+
});
|
|
151176
|
+
this.peerGate = createDrainGate({
|
|
151177
|
+
timeoutMs: deps.drainTimeoutMs,
|
|
151178
|
+
onStall: () => this.sever("peer-stalled", "the peer stopped taking bytes")
|
|
151179
|
+
});
|
|
151180
|
+
}
|
|
151181
|
+
async run() {
|
|
151182
|
+
const { device, row, fileName, twin, sink } = this.input;
|
|
151183
|
+
sink.onceClose(() => {
|
|
151184
|
+
if (!this.ended) this.sever("peer-closed", "the peer closed the connection");
|
|
151185
|
+
});
|
|
151186
|
+
const decoder = this.deps.createDecoder({
|
|
151187
|
+
onVideoAccessUnit: (unit) => this.onAccessUnit(unit),
|
|
151188
|
+
onAudioFrame: (frame) => this.onAudioFrame(frame)
|
|
151189
|
+
});
|
|
151190
|
+
this.deps.logger.info("videoclips: streaming a clip file from the camera", {
|
|
151191
|
+
tags: this.tags,
|
|
151192
|
+
meta: {
|
|
151193
|
+
clipKey: row.clipKey,
|
|
151194
|
+
twin,
|
|
151195
|
+
fileName,
|
|
151196
|
+
startMs: row.startMs,
|
|
151197
|
+
endMs: row.endMs,
|
|
151198
|
+
attempt: this.attempt,
|
|
151199
|
+
audioAsked: this.input.audio,
|
|
151200
|
+
idleTimeoutMs: 300
|
|
151201
|
+
}
|
|
151202
|
+
});
|
|
151203
|
+
let api;
|
|
151204
|
+
try {
|
|
151205
|
+
api = await device.getApi();
|
|
151206
|
+
} catch (err) {
|
|
151207
|
+
return {
|
|
151208
|
+
kind: "no-media",
|
|
151209
|
+
detail: describe(err)
|
|
151210
|
+
};
|
|
151211
|
+
}
|
|
151212
|
+
try {
|
|
151213
|
+
await api.fileInfoListReplayBinaryDownload({
|
|
151214
|
+
channel: device.channel,
|
|
151215
|
+
...device.uid !== void 0 ? { uid: device.uid } : {},
|
|
151216
|
+
fileName,
|
|
151217
|
+
idleTimeoutMs: 300,
|
|
151218
|
+
onChunk: (chunk) => this.onChunk(decoder, chunk)
|
|
151219
|
+
});
|
|
151220
|
+
} catch (err) {
|
|
151221
|
+
return this.onTransferFailed(err);
|
|
151222
|
+
}
|
|
151223
|
+
return this.onTransferEnded();
|
|
151224
|
+
}
|
|
151225
|
+
/** The camera's bytes, as they arrive. A cut run THROWS here: the only stop. */
|
|
151226
|
+
onChunk(decoder, chunk) {
|
|
151227
|
+
if (this.cut !== null) throw new Error(`clip stream cut: ${this.cut.reason}`);
|
|
151228
|
+
try {
|
|
151229
|
+
decoder.push(chunk);
|
|
151230
|
+
} catch (err) {
|
|
151231
|
+
this.sever("decode-failed", describe(err));
|
|
151232
|
+
throw err;
|
|
151233
|
+
}
|
|
151234
|
+
}
|
|
151235
|
+
onAccessUnit(unit) {
|
|
151236
|
+
if (this.cut !== null) return;
|
|
151237
|
+
this.accessUnits += 1;
|
|
151238
|
+
this.timestampsUs.push(unit.microseconds);
|
|
151239
|
+
if (this.mux === null) {
|
|
151240
|
+
this.probe = [...this.probe, unit];
|
|
151241
|
+
if (this.probe.length >= 16) this.startMux();
|
|
151242
|
+
return;
|
|
151243
|
+
}
|
|
151244
|
+
this.feed(unit.annexB);
|
|
151245
|
+
}
|
|
151246
|
+
/**
|
|
151247
|
+
* One audio frame from the decoder.
|
|
151248
|
+
*
|
|
151249
|
+
* Before the mux exists the ADTS frames are HELD, exactly as the video
|
|
151250
|
+
* probe holds access units, because the spawn cannot gain an input later
|
|
151251
|
+
* and ffmpeg will not write a byte until every input it was given has been
|
|
151252
|
+
* probed. After it exists they are either fed or counted as dropped — and
|
|
151253
|
+
* a frame that arrives after a SILENT spawn is counted apart, because that
|
|
151254
|
+
* one is a recording whose sound was there and was decided away.
|
|
151255
|
+
*/
|
|
151256
|
+
onAudioFrame(frame) {
|
|
151257
|
+
if (this.cut !== null) return;
|
|
151258
|
+
this.audioFrames += 1;
|
|
151259
|
+
if (this.input.audio === "none" || frame.audioType !== "Aac" || frame.data.byteLength === 0) {
|
|
151260
|
+
this.audioFramesDropped += 1;
|
|
151261
|
+
return;
|
|
151262
|
+
}
|
|
151263
|
+
if (this.mux === null) {
|
|
151264
|
+
if (this.audioProbe.length >= 4096 || this.audioProbeBytes + frame.data.byteLength > 4194304) {
|
|
151265
|
+
this.audioFramesDropped += 1;
|
|
151266
|
+
return;
|
|
151267
|
+
}
|
|
151268
|
+
this.audioProbe = [...this.audioProbe, frame.data];
|
|
151269
|
+
this.audioProbeBytes += frame.data.byteLength;
|
|
151270
|
+
return;
|
|
151271
|
+
}
|
|
151272
|
+
if (this.audioMuxed === null) {
|
|
151273
|
+
this.audioFramesDropped += 1;
|
|
151274
|
+
this.audioFramesAfterMux += 1;
|
|
151275
|
+
return;
|
|
151276
|
+
}
|
|
151277
|
+
this.feedAudio(frame.data);
|
|
151278
|
+
}
|
|
151279
|
+
/**
|
|
151280
|
+
* What the mux will carry as sound, decided ONCE, before the spawn.
|
|
151281
|
+
*
|
|
151282
|
+
* The gate is `an ADTS frame is already in hand`, not `the ask was not
|
|
151283
|
+
* none`: ffmpeg probes `pipe:3` before it writes output, so declaring an
|
|
151284
|
+
* input that never delivers would hold the VIDEO hostage for a recording
|
|
151285
|
+
* that simply has no microphone. An ADPCM-only camera is named separately
|
|
151286
|
+
* from a silent one — the file path draws the same distinction
|
|
151287
|
+
* (`decideClipAudio`) and for the same reason.
|
|
151288
|
+
*/
|
|
151289
|
+
decideAudio() {
|
|
151290
|
+
const asked = this.input.audio;
|
|
151291
|
+
if (asked === "none") {
|
|
151292
|
+
this.audioSilentReason = "not-requested";
|
|
151293
|
+
return;
|
|
151294
|
+
}
|
|
151295
|
+
if (this.audioProbe.length > 0) {
|
|
151296
|
+
this.audioMuxed = asked;
|
|
151297
|
+
return;
|
|
151298
|
+
}
|
|
151299
|
+
this.audioSilentReason = this.audioFrames > 0 ? "audio-not-copyable" : "no-audio-track";
|
|
151300
|
+
}
|
|
151301
|
+
/** Spawn ffmpeg with the probed rate and hand it what was held. */
|
|
151302
|
+
startMux() {
|
|
151303
|
+
const first = this.probe[0];
|
|
151304
|
+
if (first === void 0 || this.mux !== null) return;
|
|
151305
|
+
this.videoType = first.videoType;
|
|
151306
|
+
this.rate = probeClipStreamRate(this.timestampsUs);
|
|
151307
|
+
this.decideAudio();
|
|
151308
|
+
const { row, twin } = this.input;
|
|
151309
|
+
if (this.rate.fpsSource !== "timestamps") this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
|
|
151310
|
+
tags: this.tags,
|
|
151311
|
+
meta: {
|
|
151312
|
+
clipKey: row.clipKey,
|
|
151313
|
+
twin,
|
|
151314
|
+
branch: "fps-unmeasured",
|
|
151315
|
+
probeAccessUnits: this.rate.accessUnits
|
|
151316
|
+
}
|
|
151317
|
+
});
|
|
151318
|
+
let mux;
|
|
151319
|
+
try {
|
|
151320
|
+
mux = this.deps.spawnMux(buildClipStreamMuxArgs({
|
|
151321
|
+
videoType: this.videoType,
|
|
151322
|
+
fps: this.rate.fps,
|
|
151323
|
+
audio: this.audioMuxed
|
|
151324
|
+
}));
|
|
151325
|
+
} catch (err) {
|
|
151326
|
+
this.sever("mux-failed", `ffmpeg could not be spawned: ${describe(err)}`);
|
|
151327
|
+
return;
|
|
151328
|
+
}
|
|
151329
|
+
this.mux = mux;
|
|
151330
|
+
if (this.audioMuxed !== null) {
|
|
151331
|
+
this.audioIn = writableAt(mux.stdio, 3);
|
|
151332
|
+
if (this.audioIn === null) {
|
|
151333
|
+
this.audioMuxed = null;
|
|
151334
|
+
this.audioSilentReason = "no-audio-track";
|
|
151335
|
+
this.deps.logger.warn("videoclips: a clip stream mux exposed no audio pipe", {
|
|
151336
|
+
tags: this.tags,
|
|
151337
|
+
meta: {
|
|
151338
|
+
clipKey: row.clipKey,
|
|
151339
|
+
twin,
|
|
151340
|
+
branch: "audio-pipe-missing"
|
|
151341
|
+
}
|
|
151342
|
+
});
|
|
151343
|
+
this.sever("mux-failed", "the mux was spawned with an audio input it does not expose");
|
|
151344
|
+
return;
|
|
151345
|
+
}
|
|
151346
|
+
this.audioIn.on("error", (err) => this.sever("mux-failed", `ffmpeg audio stdin: ${err.message}`));
|
|
151347
|
+
this.audioIn.on("drain", () => this.audioStdinGate.drained());
|
|
151348
|
+
}
|
|
151349
|
+
this.muxClosed = new Promise((resolve) => {
|
|
151350
|
+
mux.on("close", (code) => resolve(code));
|
|
151351
|
+
mux.on("error", (err) => {
|
|
151352
|
+
this.sever("mux-failed", `ffmpeg failed: ${err.message}`);
|
|
151353
|
+
resolve(null);
|
|
151354
|
+
});
|
|
151355
|
+
});
|
|
151356
|
+
mux.stderr?.on("data", (chunk) => {
|
|
151357
|
+
if (this.muxStderr.length < 2e3) this.muxStderr += chunk.toString("utf8");
|
|
151358
|
+
});
|
|
151359
|
+
mux.stdin?.on("error", (err) => this.sever("mux-failed", `ffmpeg stdin: ${err.message}`));
|
|
151360
|
+
mux.stdin?.on("drain", () => this.stdinGate.drained());
|
|
151361
|
+
mux.stdout?.on("data", (chunk) => this.onMuxOut(chunk));
|
|
151362
|
+
mux.stdout?.on("end", () => this.finishSink());
|
|
151363
|
+
this.deps.logger.info("videoclips: a clip stream is muxing", {
|
|
151364
|
+
tags: this.tags,
|
|
151365
|
+
meta: {
|
|
151366
|
+
clipKey: row.clipKey,
|
|
151367
|
+
twin,
|
|
151368
|
+
videoType: this.videoType,
|
|
151369
|
+
fps: this.rate.fps,
|
|
151370
|
+
fpsSource: this.rate.fpsSource,
|
|
151371
|
+
probeAccessUnits: this.rate.accessUnits,
|
|
151372
|
+
audioAsked: this.input.audio,
|
|
151373
|
+
audioMuxed: this.audioMuxed,
|
|
151374
|
+
audioSilentReason: this.audioSilentReason,
|
|
151375
|
+
probeAudioFrames: this.audioProbe.length
|
|
151376
|
+
}
|
|
151377
|
+
});
|
|
151378
|
+
const held = this.probe;
|
|
151379
|
+
this.probe = [];
|
|
151380
|
+
const heldAudio = this.audioProbe;
|
|
151381
|
+
this.audioProbe = [];
|
|
151382
|
+
this.audioProbeBytes = 0;
|
|
151383
|
+
for (const unit of held) this.feed(unit.annexB);
|
|
151384
|
+
if (this.audioMuxed === null) this.audioFramesDropped += heldAudio.length;
|
|
151385
|
+
else for (const frame of heldAudio) this.feedAudio(frame);
|
|
151386
|
+
}
|
|
151387
|
+
feed(annexB) {
|
|
151388
|
+
if (this.cut !== null || this.mux?.stdin === null || this.mux?.stdin === void 0) return;
|
|
151389
|
+
this.stdinGate.wrote(this.mux.stdin.write(annexB));
|
|
151390
|
+
}
|
|
151391
|
+
feedAudio(adts) {
|
|
151392
|
+
const audioIn = this.audioIn;
|
|
151393
|
+
if (this.cut !== null || audioIn === null) return;
|
|
151394
|
+
this.audioStdinGate.wrote(audioIn.write(adts));
|
|
151395
|
+
}
|
|
151396
|
+
/** ffmpeg's output, to the peer, honouring `write()`'s return (D447). */
|
|
151397
|
+
onMuxOut(chunk) {
|
|
151398
|
+
if (this.cut !== null) return;
|
|
151399
|
+
const { sink } = this.input;
|
|
151400
|
+
if (!this.begun) {
|
|
151401
|
+
this.begun = true;
|
|
151402
|
+
sink.begin(clipStreamContentTypeFor(this.audioMuxed));
|
|
151403
|
+
}
|
|
151404
|
+
const accepted = sink.write(chunk);
|
|
151405
|
+
this.bytesOut += chunk.byteLength;
|
|
151406
|
+
this.peerGate.wrote(accepted);
|
|
151407
|
+
if (accepted) return;
|
|
151408
|
+
const stdout = this.mux?.stdout;
|
|
151409
|
+
stdout?.pause();
|
|
151410
|
+
sink.onceDrain(() => {
|
|
151411
|
+
this.peerGate.drained();
|
|
151412
|
+
stdout?.resume();
|
|
151413
|
+
});
|
|
151414
|
+
}
|
|
151415
|
+
finishSink() {
|
|
151416
|
+
if (this.cut !== null || this.ended) return;
|
|
151417
|
+
this.ended = true;
|
|
151418
|
+
this.input.sink.end();
|
|
151419
|
+
}
|
|
151420
|
+
/** Sever every hop, once, and say why. */
|
|
151421
|
+
sever(reason, detail) {
|
|
151422
|
+
if (this.cut !== null) return;
|
|
151423
|
+
this.cut = {
|
|
151424
|
+
reason,
|
|
151425
|
+
detail
|
|
151426
|
+
};
|
|
151427
|
+
this.stdinGate.dispose();
|
|
151428
|
+
this.audioStdinGate.dispose();
|
|
151429
|
+
this.peerGate.dispose();
|
|
151430
|
+
this.mux?.kill("SIGKILL");
|
|
151431
|
+
this.deps.logger.warn("videoclips: a clip stream was cut", {
|
|
151432
|
+
tags: this.tags,
|
|
151433
|
+
meta: {
|
|
151434
|
+
clipKey: this.input.row.clipKey,
|
|
151435
|
+
twin: this.input.twin,
|
|
151436
|
+
branch: reason,
|
|
151437
|
+
detail,
|
|
151438
|
+
accessUnits: this.accessUnits,
|
|
151439
|
+
bytesOut: this.bytesOut,
|
|
151440
|
+
begun: this.begun,
|
|
151441
|
+
audioMuxed: this.audioMuxed,
|
|
151442
|
+
audioFramesIn: this.audioFrames,
|
|
151443
|
+
audioFramesDropped: this.audioFramesDropped
|
|
151444
|
+
}
|
|
151445
|
+
});
|
|
151446
|
+
if (this.begun) this.input.sink.destroy();
|
|
151447
|
+
}
|
|
151448
|
+
cutOutcome(cut) {
|
|
151449
|
+
if (this.begun) return {
|
|
151450
|
+
kind: "cut",
|
|
151451
|
+
reason: cut.reason,
|
|
151452
|
+
detail: cut.detail,
|
|
151453
|
+
bytesOut: this.bytesOut
|
|
151454
|
+
};
|
|
151455
|
+
return {
|
|
151456
|
+
kind: "refused",
|
|
151457
|
+
code: "fetch-failed",
|
|
151458
|
+
detail: `${cut.reason}: ${cut.detail}`,
|
|
151459
|
+
attempts: this.attempt
|
|
151460
|
+
};
|
|
151461
|
+
}
|
|
151462
|
+
onTransferFailed(err) {
|
|
151463
|
+
if (this.cut !== null) return this.cutOutcome(this.cut);
|
|
151464
|
+
if (this.accessUnits === 0) return {
|
|
151465
|
+
kind: "no-media",
|
|
151466
|
+
detail: describe(err)
|
|
151467
|
+
};
|
|
151468
|
+
this.sever("camera-failed", describe(err));
|
|
151469
|
+
return this.cutOutcome({
|
|
151470
|
+
reason: "camera-failed",
|
|
151471
|
+
detail: describe(err)
|
|
151472
|
+
});
|
|
151473
|
+
}
|
|
151474
|
+
async onTransferEnded() {
|
|
151475
|
+
if (this.cut !== null) return this.cutOutcome(this.cut);
|
|
151476
|
+
if (this.accessUnits === 0) return {
|
|
151477
|
+
kind: "no-media",
|
|
151478
|
+
detail: "the camera answered the transfer with no access unit"
|
|
151479
|
+
};
|
|
151480
|
+
if (this.mux === null) this.startMux();
|
|
151481
|
+
const cutAtStart = this.cut;
|
|
151482
|
+
if (cutAtStart !== null) return this.cutOutcome(cutAtStart);
|
|
151483
|
+
const mux = this.mux;
|
|
151484
|
+
if (mux === null || this.muxClosed === null) return this.cutOutcome({
|
|
151485
|
+
reason: "mux-failed",
|
|
151486
|
+
detail: "no mux after the transfer ended"
|
|
151487
|
+
});
|
|
151488
|
+
mux.stdin?.end();
|
|
151489
|
+
this.audioIn?.end();
|
|
151490
|
+
const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
|
|
151491
|
+
const code = await this.muxClosed;
|
|
151492
|
+
clearTimeout(flush);
|
|
151493
|
+
const cutAfter = this.cut;
|
|
151494
|
+
if (cutAfter !== null) return this.cutOutcome(cutAfter);
|
|
151495
|
+
if (code !== 0) {
|
|
151496
|
+
this.sever("mux-failed", `ffmpeg exited ${String(code)}: ${this.muxStderr.slice(0, 300)}`);
|
|
151497
|
+
return this.cutOutcome({
|
|
151498
|
+
reason: "mux-failed",
|
|
151499
|
+
detail: this.muxStderr.slice(0, 300)
|
|
151500
|
+
});
|
|
151501
|
+
}
|
|
151502
|
+
this.finishSink();
|
|
151503
|
+
return this.served();
|
|
151504
|
+
}
|
|
151505
|
+
/** The stream landed: judge it against the row, out loud, and answer. */
|
|
151506
|
+
served() {
|
|
151507
|
+
const { row, twin } = this.input;
|
|
151508
|
+
const timing = estimateVideoTiming({ timestampsUs: this.timestampsUs });
|
|
151509
|
+
const durationSeconds = timing.fpsSource === "timestamps" ? timing.durationSeconds : null;
|
|
151510
|
+
const verdict = judgeClipDuration({
|
|
151511
|
+
expectedMs: row.endMs - row.startMs,
|
|
151512
|
+
decodedSeconds: durationSeconds,
|
|
151513
|
+
...row.inProgress === true ? { inProgress: true } : {}
|
|
151514
|
+
});
|
|
151515
|
+
const audio = this.audioReport();
|
|
151516
|
+
const meta = {
|
|
151517
|
+
clipKey: row.clipKey,
|
|
151518
|
+
twin,
|
|
151519
|
+
attempt: this.attempt,
|
|
151520
|
+
accessUnits: this.accessUnits,
|
|
151521
|
+
audioMuxed: audio.muxed,
|
|
151522
|
+
bytesOut: this.bytesOut,
|
|
151523
|
+
durationSeconds,
|
|
151524
|
+
expectedMs: row.endMs - row.startMs,
|
|
151525
|
+
fps: this.rate.fps,
|
|
151526
|
+
fpsSource: this.rate.fpsSource,
|
|
151527
|
+
videoType: this.videoType
|
|
151528
|
+
};
|
|
151529
|
+
if (verdict.kind === "short") this.deps.logger.warn("videoclips: a clip stream came up short against its row", {
|
|
151530
|
+
tags: this.tags,
|
|
151531
|
+
meta: {
|
|
151532
|
+
...meta,
|
|
151533
|
+
branch: "stream-short",
|
|
151534
|
+
shortfallSeconds: verdict.shortfallSeconds,
|
|
151535
|
+
retried: false
|
|
151536
|
+
}
|
|
151537
|
+
});
|
|
151538
|
+
else if (verdict.kind === "unknown") this.deps.logger.warn("videoclips: a clip stream could not be checked for truncation", {
|
|
151539
|
+
tags: this.tags,
|
|
151540
|
+
meta: {
|
|
151541
|
+
...meta,
|
|
151542
|
+
branch: "completeness-unverified",
|
|
151543
|
+
reason: verdict.reason
|
|
151544
|
+
}
|
|
151545
|
+
});
|
|
151546
|
+
else this.deps.logger.info("videoclips: a clip stream landed whole", {
|
|
151547
|
+
tags: this.tags,
|
|
151548
|
+
meta: {
|
|
151549
|
+
...meta,
|
|
151550
|
+
branch: "stream-served"
|
|
151551
|
+
}
|
|
151552
|
+
});
|
|
151553
|
+
this.reportAudio(audio);
|
|
151554
|
+
return {
|
|
151555
|
+
kind: "served",
|
|
151556
|
+
accessUnits: this.accessUnits,
|
|
151557
|
+
audio,
|
|
151558
|
+
bytesOut: this.bytesOut,
|
|
151559
|
+
durationSeconds,
|
|
151560
|
+
verdict,
|
|
151561
|
+
rate: this.rate,
|
|
151562
|
+
attempts: this.attempt
|
|
151563
|
+
};
|
|
151564
|
+
}
|
|
151565
|
+
audioReport() {
|
|
151566
|
+
return {
|
|
151567
|
+
asked: this.input.audio,
|
|
151568
|
+
muxed: this.audioMuxed,
|
|
151569
|
+
reason: this.audioSilentReason,
|
|
151570
|
+
framesIn: this.audioFrames,
|
|
151571
|
+
framesDropped: this.audioFramesDropped,
|
|
151572
|
+
framesAfterMux: this.audioFramesAfterMux
|
|
151573
|
+
};
|
|
151574
|
+
}
|
|
151575
|
+
/**
|
|
151576
|
+
* Say what became of the sound, on EVERY served stream — including the one
|
|
151577
|
+
* that carried it.
|
|
151578
|
+
*
|
|
151579
|
+
* A branch that accepts work and produces nothing is the branch that has to
|
|
151580
|
+
* speak (D391), and silence is exactly what that looks like here. But a
|
|
151581
|
+
* stream that DID carry audio says so too, with the frame count, because
|
|
151582
|
+
* "the clip was silent" and "the clip had sound and you did not hear it"
|
|
151583
|
+
* are answered from this line and nowhere else.
|
|
151584
|
+
*/
|
|
151585
|
+
reportAudio(audio) {
|
|
151586
|
+
const { row, twin } = this.input;
|
|
151587
|
+
const meta = {
|
|
151588
|
+
clipKey: row.clipKey,
|
|
151589
|
+
twin,
|
|
151590
|
+
audioAsked: audio.asked,
|
|
151591
|
+
audioMuxed: audio.muxed,
|
|
151592
|
+
audioFramesIn: audio.framesIn,
|
|
151593
|
+
audioFramesDropped: audio.framesDropped,
|
|
151594
|
+
audioFramesAfterMux: audio.framesAfterMux
|
|
151595
|
+
};
|
|
151596
|
+
if (audio.muxed !== null) {
|
|
151597
|
+
this.deps.logger.info("videoclips: a clip stream carried sound", {
|
|
151598
|
+
tags: this.tags,
|
|
151599
|
+
meta: {
|
|
151600
|
+
...meta,
|
|
151601
|
+
branch: "stream-with-audio"
|
|
151602
|
+
}
|
|
151603
|
+
});
|
|
151604
|
+
return;
|
|
151605
|
+
}
|
|
151606
|
+
if (audio.framesAfterMux > 0) {
|
|
151607
|
+
this.deps.logger.warn("videoclips: a clip stream dropped sound that arrived too late", {
|
|
151608
|
+
tags: this.tags,
|
|
151609
|
+
meta: {
|
|
151610
|
+
...meta,
|
|
151611
|
+
branch: "stream-audio-after-mux"
|
|
151612
|
+
}
|
|
151613
|
+
});
|
|
151614
|
+
return;
|
|
151615
|
+
}
|
|
151616
|
+
if (audio.asked === "none") {
|
|
151617
|
+
this.deps.logger.info("videoclips: a clip stream was served without sound", {
|
|
151618
|
+
tags: this.tags,
|
|
151619
|
+
meta: {
|
|
151620
|
+
...meta,
|
|
151621
|
+
branch: "stream-video-only"
|
|
151622
|
+
}
|
|
151623
|
+
});
|
|
151624
|
+
return;
|
|
151625
|
+
}
|
|
151626
|
+
this.deps.logger.warn("videoclips: a clip stream was asked for sound and had none", {
|
|
151627
|
+
tags: this.tags,
|
|
151628
|
+
meta: {
|
|
151629
|
+
...meta,
|
|
151630
|
+
branch: `stream-silent:${audio.reason ?? "unknown"}`
|
|
151631
|
+
}
|
|
151632
|
+
});
|
|
151633
|
+
}
|
|
151634
|
+
};
|
|
151635
|
+
function describe(err) {
|
|
151636
|
+
return err instanceof Error ? err.message : String(err);
|
|
151637
|
+
}
|
|
151638
|
+
function pathToken(req) {
|
|
151639
|
+
const raw = req.url ?? "/";
|
|
151640
|
+
const query = raw.indexOf("?");
|
|
151641
|
+
return (query === -1 ? raw : raw.slice(0, query)).replace(/^\/+/, "");
|
|
151642
|
+
}
|
|
151643
|
+
function createClipStreamDial(deps) {
|
|
151644
|
+
const now = deps.now ?? (() => Date.now());
|
|
151645
|
+
const ttlMs = deps.ttlMs ?? 3e4;
|
|
151646
|
+
const maxOutstanding = deps.maxOutstanding ?? 64;
|
|
151647
|
+
const host = deps.host ?? "127.0.0.1";
|
|
151648
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
151649
|
+
/** Tokens already dialled, kept for their TTL so a replay is named `dial-used`, not `dial-unknown`. */
|
|
151650
|
+
const used = /* @__PURE__ */ new Map();
|
|
151651
|
+
let server = null;
|
|
151652
|
+
let baseUrl = null;
|
|
151653
|
+
let listening = null;
|
|
151654
|
+
const sweep = () => {
|
|
151655
|
+
const at = now();
|
|
151656
|
+
for (const [token, minted] of tokens) if (minted.expiresAtMs <= at) tokens.delete(token);
|
|
151657
|
+
for (const [token, expiresAtMs] of used) if (expiresAtMs <= at) used.delete(token);
|
|
151658
|
+
};
|
|
151659
|
+
const resolveTarget = (req) => {
|
|
151660
|
+
const token = pathToken(req);
|
|
151661
|
+
const minted = tokens.get(token);
|
|
151662
|
+
if (minted === void 0) {
|
|
151663
|
+
if (used.get(token) !== void 0) return {
|
|
151664
|
+
kind: "refused",
|
|
151665
|
+
status: 410,
|
|
151666
|
+
code: "dial-used",
|
|
151667
|
+
detail: "this dial was already taken"
|
|
151668
|
+
};
|
|
151669
|
+
return {
|
|
151670
|
+
kind: "refused",
|
|
151671
|
+
status: 404,
|
|
151672
|
+
code: "dial-unknown",
|
|
151673
|
+
detail: "no such dial"
|
|
151674
|
+
};
|
|
151675
|
+
}
|
|
151676
|
+
if (minted.expiresAtMs <= now()) {
|
|
151677
|
+
tokens.delete(token);
|
|
151678
|
+
return {
|
|
151679
|
+
kind: "refused",
|
|
151680
|
+
status: 410,
|
|
151681
|
+
code: "dial-expired",
|
|
151682
|
+
detail: "this dial has expired"
|
|
151683
|
+
};
|
|
151684
|
+
}
|
|
151685
|
+
if (req.method === "GET") {
|
|
151686
|
+
tokens.delete(token);
|
|
151687
|
+
used.set(token, minted.expiresAtMs);
|
|
151688
|
+
deps.logger.info("videoclips: a clip stream dial was taken", {
|
|
151689
|
+
tags: { deviceId: minted.target.deviceId },
|
|
151690
|
+
meta: {
|
|
151691
|
+
clipKey: minted.target.clipKey,
|
|
151692
|
+
twin: minted.target.twin,
|
|
151693
|
+
audio: minted.target.audio,
|
|
151694
|
+
outstanding: tokens.size
|
|
151695
|
+
}
|
|
151696
|
+
});
|
|
151697
|
+
}
|
|
151698
|
+
return {
|
|
151699
|
+
kind: "target",
|
|
151700
|
+
target: minted.target
|
|
151701
|
+
};
|
|
151702
|
+
};
|
|
151703
|
+
const handler = createClipStreamHandler({
|
|
151704
|
+
stream: deps.stream,
|
|
151705
|
+
logger: deps.logger,
|
|
151706
|
+
target: resolveTarget
|
|
151707
|
+
});
|
|
151708
|
+
const listen = () => {
|
|
151709
|
+
if (listening !== null) return listening;
|
|
151710
|
+
listening = new Promise((resolve, reject) => {
|
|
151711
|
+
const created = (0, node_http.createServer)((req, res) => {
|
|
151712
|
+
handler(req, res).catch((err) => {
|
|
151713
|
+
deps.logger.warn("videoclips: the clip stream dial handler threw", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
151714
|
+
if (!res.headersSent) res.writeHead(500);
|
|
151715
|
+
res.end();
|
|
151716
|
+
});
|
|
151717
|
+
});
|
|
151718
|
+
created.on("error", (err) => {
|
|
151719
|
+
listening = null;
|
|
151720
|
+
reject(err);
|
|
151721
|
+
});
|
|
151722
|
+
created.listen(0, host, () => {
|
|
151723
|
+
const address = created.address();
|
|
151724
|
+
if (address === null || typeof address === "string") {
|
|
151725
|
+
created.close();
|
|
151726
|
+
listening = null;
|
|
151727
|
+
reject(/* @__PURE__ */ new Error("the dial listener bound no TCP port"));
|
|
151728
|
+
return;
|
|
151729
|
+
}
|
|
151730
|
+
server = created;
|
|
151731
|
+
baseUrl = `http://${host}:${address.port}`;
|
|
151732
|
+
deps.logger.info("videoclips: clip stream dial listener bound", { meta: {
|
|
151733
|
+
host,
|
|
151734
|
+
port: address.port,
|
|
151735
|
+
ttlMs,
|
|
151736
|
+
maxOutstanding
|
|
151737
|
+
} });
|
|
151738
|
+
resolve(baseUrl);
|
|
151739
|
+
});
|
|
151740
|
+
});
|
|
151741
|
+
return listening;
|
|
151742
|
+
};
|
|
151743
|
+
return {
|
|
151744
|
+
get outstanding() {
|
|
151745
|
+
sweep();
|
|
151746
|
+
return tokens.size;
|
|
151747
|
+
},
|
|
151748
|
+
mint: async (input) => {
|
|
151749
|
+
sweep();
|
|
151750
|
+
if (tokens.size >= maxOutstanding) {
|
|
151751
|
+
deps.logger.warn("videoclips: clip stream dial refused — too many outstanding", {
|
|
151752
|
+
tags: { deviceId: input.deviceId },
|
|
151753
|
+
meta: {
|
|
151754
|
+
clipKey: input.clipKey,
|
|
151755
|
+
twin: input.twin,
|
|
151756
|
+
outstanding: tokens.size,
|
|
151757
|
+
maxOutstanding
|
|
151758
|
+
}
|
|
151759
|
+
});
|
|
151760
|
+
return {
|
|
151761
|
+
kind: "refused",
|
|
151762
|
+
code: "dial-exhausted",
|
|
151763
|
+
detail: `${tokens.size} dials outstanding against a bound of ${maxOutstanding}`
|
|
151764
|
+
};
|
|
151765
|
+
}
|
|
151766
|
+
let base;
|
|
151767
|
+
try {
|
|
151768
|
+
base = baseUrl ?? await listen();
|
|
151769
|
+
} catch (err) {
|
|
151770
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
151771
|
+
deps.logger.warn("videoclips: clip stream dial refused — no listener", {
|
|
151772
|
+
tags: { deviceId: input.deviceId },
|
|
151773
|
+
meta: {
|
|
151774
|
+
clipKey: input.clipKey,
|
|
151775
|
+
twin: input.twin,
|
|
151776
|
+
error: detail
|
|
151777
|
+
}
|
|
151778
|
+
});
|
|
151779
|
+
return {
|
|
151780
|
+
kind: "refused",
|
|
151781
|
+
code: "listener-failed",
|
|
151782
|
+
detail
|
|
151783
|
+
};
|
|
151784
|
+
}
|
|
151785
|
+
const token = (0, node_crypto.randomBytes)(24).toString("base64url");
|
|
151786
|
+
const expiresAtMs = now() + ttlMs;
|
|
151787
|
+
tokens.set(token, {
|
|
151788
|
+
target: input,
|
|
151789
|
+
expiresAtMs
|
|
151790
|
+
});
|
|
151791
|
+
deps.logger.info("videoclips: clip stream dial minted", {
|
|
151792
|
+
tags: { deviceId: input.deviceId },
|
|
151793
|
+
meta: {
|
|
151794
|
+
clipKey: input.clipKey,
|
|
151795
|
+
twin: input.twin,
|
|
151796
|
+
audio: input.audio,
|
|
151797
|
+
expiresInMs: ttlMs,
|
|
151798
|
+
outstanding: tokens.size
|
|
151799
|
+
}
|
|
151800
|
+
});
|
|
151801
|
+
return {
|
|
151802
|
+
kind: "ticket",
|
|
151803
|
+
url: `${base}/${token}`,
|
|
151804
|
+
expiresAtMs
|
|
151805
|
+
};
|
|
151806
|
+
},
|
|
151807
|
+
dispose: async () => {
|
|
151808
|
+
tokens.clear();
|
|
151809
|
+
used.clear();
|
|
151810
|
+
const open = server;
|
|
151811
|
+
server = null;
|
|
151812
|
+
baseUrl = null;
|
|
151813
|
+
listening = null;
|
|
151814
|
+
if (open === null) return;
|
|
151815
|
+
await new Promise((resolve) => {
|
|
151816
|
+
open.close(() => resolve());
|
|
151817
|
+
open.closeAllConnections();
|
|
151818
|
+
});
|
|
151819
|
+
deps.logger.info("videoclips: clip stream dial listener closed", {});
|
|
151820
|
+
}
|
|
151821
|
+
};
|
|
151822
|
+
}
|
|
151823
|
+
//#endregion
|
|
151824
|
+
//#region src/clips/clip-stream-route.ts
|
|
151825
|
+
function createClipStreamRoute(deps) {
|
|
151826
|
+
return async (target, sink) => {
|
|
151827
|
+
const { deviceId, clipKey, twin, audio } = target;
|
|
151828
|
+
const gate = await deps.gate({
|
|
151829
|
+
deviceId,
|
|
151830
|
+
clipKey,
|
|
151831
|
+
twin,
|
|
151832
|
+
path: "stream"
|
|
151833
|
+
});
|
|
151834
|
+
if (gate.kind === "refused") return {
|
|
151835
|
+
kind: "refused",
|
|
151836
|
+
code: gate.branch,
|
|
151837
|
+
detail: gate.detail
|
|
151838
|
+
};
|
|
151839
|
+
const row = deps.rowFor(deviceId, clipKey);
|
|
151840
|
+
if (row === null) {
|
|
151841
|
+
deps.logger.warn("videoclips: no catalog row for this clip stream", {
|
|
151842
|
+
tags: { deviceId },
|
|
151843
|
+
meta: {
|
|
151844
|
+
clipKey,
|
|
151845
|
+
twin,
|
|
151846
|
+
branch: "no-catalog-row"
|
|
151847
|
+
}
|
|
151848
|
+
});
|
|
151849
|
+
return {
|
|
151850
|
+
kind: "refused",
|
|
151851
|
+
code: "no-catalog-row",
|
|
151852
|
+
detail: "no-catalog-row: list this day before asking for its stream"
|
|
151853
|
+
};
|
|
151854
|
+
}
|
|
151855
|
+
if (row.inProgress === true) {
|
|
151856
|
+
deps.logger.warn("videoclips: refusing to stream a clip the camera is still writing", {
|
|
151857
|
+
tags: { deviceId },
|
|
151858
|
+
meta: {
|
|
151859
|
+
clipKey,
|
|
151860
|
+
twin,
|
|
151861
|
+
branch: "clip-in-progress"
|
|
151862
|
+
}
|
|
151863
|
+
});
|
|
151864
|
+
return {
|
|
151865
|
+
kind: "refused",
|
|
151866
|
+
code: "clip-in-progress",
|
|
151867
|
+
detail: "clip-in-progress: the camera is still writing this file; it has no end to stream to"
|
|
151868
|
+
};
|
|
151869
|
+
}
|
|
151870
|
+
const fileName = twin === "main" ? row.mainFileName ?? "" : row.subFileName;
|
|
151871
|
+
if (fileName === "") {
|
|
151872
|
+
deps.logger.warn("videoclips: no byte handle for the twin this clip stream names", {
|
|
151873
|
+
tags: { deviceId },
|
|
151874
|
+
meta: {
|
|
151875
|
+
clipKey,
|
|
151876
|
+
twin,
|
|
151877
|
+
branch: "no-catalog-row"
|
|
151878
|
+
}
|
|
151879
|
+
});
|
|
151880
|
+
return {
|
|
151881
|
+
kind: "refused",
|
|
151882
|
+
code: "no-catalog-row",
|
|
151883
|
+
detail: `no-catalog-row: this clip is listed with no ${twin} file behind it`
|
|
151884
|
+
};
|
|
151885
|
+
}
|
|
151886
|
+
const outcome = await deps.producer.stream({
|
|
151887
|
+
device: gate.device,
|
|
151888
|
+
row,
|
|
151889
|
+
fileName,
|
|
151890
|
+
twin,
|
|
151891
|
+
audio,
|
|
151892
|
+
sink
|
|
151893
|
+
});
|
|
151894
|
+
switch (outcome.kind) {
|
|
151895
|
+
case "served": return { kind: "served" };
|
|
151896
|
+
case "cut": return { kind: "cut" };
|
|
151897
|
+
case "refused": return {
|
|
151898
|
+
kind: "refused",
|
|
151899
|
+
code: outcome.code,
|
|
151900
|
+
detail: outcome.detail
|
|
151901
|
+
};
|
|
151902
|
+
}
|
|
151903
|
+
};
|
|
151904
|
+
}
|
|
151905
|
+
//#endregion
|
|
149256
151906
|
//#region src/clips/clip-thumb-queue.ts
|
|
149257
151907
|
/**
|
|
149258
151908
|
* The provider's OWN CoverPreview lane — two queues, one in flight per host.
|
|
@@ -149432,210 +152082,58 @@ function decideClipPrefetch(input) {
|
|
|
149432
152082
|
return { kind: "run" };
|
|
149433
152083
|
}
|
|
149434
152084
|
//#endregion
|
|
149435
|
-
//#region src/clips/clip-
|
|
152085
|
+
//#region src/clips/clip-window.ts
|
|
149436
152086
|
/**
|
|
149437
|
-
*
|
|
149438
|
-
* the key its cached bytes are filed under.
|
|
152087
|
+
* Does this row overlap the window?
|
|
149439
152088
|
*
|
|
149440
|
-
*
|
|
149441
|
-
*
|
|
149442
|
-
*
|
|
149443
|
-
*
|
|
149444
|
-
* D554 5 routes a scalar cap call by the longest source that prefixes it, and
|
|
149445
|
-
* because an id naming a source this provider no longer serves must be
|
|
149446
|
-
* REFUSED rather than routed by prefix luck.
|
|
152089
|
+
* Half-open on both ends (`start < until`, `end > since`), so a clip that ends
|
|
152090
|
+
* exactly when the window begins is OUT and a clip that begins exactly when it
|
|
152091
|
+
* ends is OUT — otherwise two adjacent windows both claim the same boundary
|
|
152092
|
+
* clip and a surface paging through them renders it twice.
|
|
149447
152093
|
*
|
|
149448
|
-
*
|
|
149449
|
-
*
|
|
149450
|
-
*
|
|
152094
|
+
* A row whose `endMs` is not after its `startMs` is treated as an INSTANT at
|
|
152095
|
+
* `startMs`. That is not defensive padding: a clip still being written carries
|
|
152096
|
+
* a frozen `endMs` from the camera's index (D563), and on this fleet that has
|
|
152097
|
+
* been observed equal to — and once behind — its own start. Judging such a row
|
|
152098
|
+
* by a bad end would drop a recording that is happening right now.
|
|
149451
152099
|
*/
|
|
152100
|
+
function clipOverlapsWindow(row, window) {
|
|
152101
|
+
const end = row.endMs > row.startMs ? row.endMs : row.startMs;
|
|
152102
|
+
return row.startMs < window.until && end > window.since;
|
|
152103
|
+
}
|
|
149452
152104
|
/**
|
|
149453
|
-
*
|
|
152105
|
+
* What the provider ANSWERS with — the one gate, so there is a single place
|
|
152106
|
+
* that decides what leaves this listing and a single line that reports it.
|
|
149454
152107
|
*
|
|
149455
|
-
*
|
|
149456
|
-
* view, D549 11) for a few hours, on the premise that the two might hold
|
|
149457
|
-
* DIFFERENT sets. Our own measurement disproved that premise: the hub's uid
|
|
149458
|
-
* addresses no catalog at all (empty month over 24 months, zero files, zero
|
|
149459
|
-
* rows) while the child's uid addresses all of it, cmd 5 serves identical
|
|
149460
|
-
* bytes under either, and on 3628 the two views cover each other almost
|
|
149461
|
-
* exactly — 29 events, 28 inside the 20 listed files, and all 20 files
|
|
149462
|
-
* carrying an event. They are two VIEWS of one store.
|
|
152108
|
+
* ## Why a clip still being written is not listed
|
|
149463
152109
|
*
|
|
149464
|
-
*
|
|
149465
|
-
*
|
|
149466
|
-
* Later the same day the pick went too (D565): `FileInfoList` is the catalog
|
|
149467
|
-
* on every topology, so `via` had one value and is gone. What is left is one
|
|
149468
|
-
* source reading one API — and `clipKeyFor` below is still keyed off
|
|
149469
|
-
* `(channel, fileName)` rather than the source, which is now simply true
|
|
149470
|
-
* rather than a concession.
|
|
149471
|
-
*/
|
|
149472
|
-
var CLIP_SOURCE_ONBOARD = "native:reolink:onboard";
|
|
149473
|
-
/** Every source this provider can name. Order is the picker's order. */
|
|
149474
|
-
var REOLINK_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
|
|
149475
|
-
function encodeFileName(fileName) {
|
|
149476
|
-
return Buffer.from(fileName, "utf8").toString("base64url");
|
|
149477
|
-
}
|
|
149478
|
-
function mintClipId(parts) {
|
|
149479
|
-
const base = `${parts.source}:${parts.channel}:${encodeFileName(parts.fileName)}`;
|
|
149480
|
-
return parts.window === void 0 ? base : `${base}:${parts.window.startMs}-${parts.window.endMs}`;
|
|
149481
|
-
}
|
|
149482
|
-
/**
|
|
149483
|
-
* Parse an id this provider minted. `null` for anything else — including an id
|
|
149484
|
-
* from the analytics or HKSV source, which is how the collection dispatcher's
|
|
149485
|
-
* "nobody claimed this id" branch stays honest.
|
|
149486
|
-
*/
|
|
149487
|
-
function parseClipId(clipId) {
|
|
149488
|
-
const source = REOLINK_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
|
|
149489
|
-
if (source === void 0) return null;
|
|
149490
|
-
const rest = clipId.slice(source.length + 1);
|
|
149491
|
-
const sep = rest.indexOf(":");
|
|
149492
|
-
if (sep <= 0) return null;
|
|
149493
|
-
const channel = Number(rest.slice(0, sep));
|
|
149494
|
-
if (!Number.isInteger(channel) || channel < 0) return null;
|
|
149495
|
-
const payload = rest.slice(sep + 1);
|
|
149496
|
-
if (payload === "") return null;
|
|
149497
|
-
const windowSep = payload.indexOf(":");
|
|
149498
|
-
const encoded = windowSep === -1 ? payload : payload.slice(0, windowSep);
|
|
149499
|
-
if (encoded === "") return null;
|
|
149500
|
-
const fileName = Buffer.from(encoded, "base64url").toString("utf8");
|
|
149501
|
-
if (fileName === "") return null;
|
|
149502
|
-
if (windowSep === -1) return {
|
|
149503
|
-
source,
|
|
149504
|
-
channel,
|
|
149505
|
-
fileName
|
|
149506
|
-
};
|
|
149507
|
-
const match = /^(\d+)-(\d+)$/.exec(payload.slice(windowSep + 1));
|
|
149508
|
-
if (match === null) return null;
|
|
149509
|
-
return {
|
|
149510
|
-
source,
|
|
149511
|
-
channel,
|
|
149512
|
-
fileName,
|
|
149513
|
-
window: {
|
|
149514
|
-
startMs: Number(match[1]),
|
|
149515
|
-
endMs: Number(match[2])
|
|
149516
|
-
}
|
|
149517
|
-
};
|
|
149518
|
-
}
|
|
149519
|
-
/**
|
|
149520
|
-
* The on-disk key for a clip's derived artifacts (its thumbnail, its muxed
|
|
149521
|
-
* MP4). Derived from `(channel, fileName)` and NOT from the source: a
|
|
149522
|
-
* thumbnail belongs to the FILE, so any surface that names the same file
|
|
149523
|
-
* finds the same picture and the camera is never asked for a second
|
|
149524
|
-
* CoverPreview of it.
|
|
152110
|
+
* The operator, 2026-09-22: *"Niente clips in preparazione, non ha senso
|
|
152111
|
+
* visualizzarle."* He is right, and the reasons had been accumulating:
|
|
149525
152112
|
*
|
|
149526
|
-
*
|
|
149527
|
-
* the
|
|
149528
|
-
|
|
149529
|
-
|
|
149530
|
-
|
|
149531
|
-
|
|
149532
|
-
|
|
149533
|
-
|
|
149534
|
-
|
|
149535
|
-
|
|
149536
|
-
|
|
149537
|
-
*
|
|
149538
|
-
|
|
149539
|
-
|
|
149540
|
-
face: "person",
|
|
149541
|
-
visitor: "person",
|
|
149542
|
-
vehicle: "vehicle",
|
|
149543
|
-
nonmotorveh: "vehicle",
|
|
149544
|
-
dog_cat: "animal",
|
|
149545
|
-
package: "package"
|
|
149546
|
-
};
|
|
149547
|
-
/** Natives that state a KIND rather than a class. */
|
|
149548
|
-
var KIND_BY_NATIVE = {
|
|
149549
|
-
md: "motion",
|
|
149550
|
-
pir: "motion",
|
|
149551
|
-
cry: "audio",
|
|
149552
|
-
other: "motion"
|
|
149553
|
-
};
|
|
149554
|
-
/** What the subject DID — carried beside a class, never as one. */
|
|
149555
|
-
var BEHAVIOUR_BY_NATIVE = {
|
|
149556
|
-
crossline: "crossline",
|
|
149557
|
-
intrude: "intrude",
|
|
149558
|
-
intrusion: "intrude",
|
|
149559
|
-
loitering: "loitering",
|
|
149560
|
-
visitor: "doorbell"
|
|
149561
|
-
};
|
|
149562
|
-
/**
|
|
149563
|
-
* Recording REASONS — why the file exists, not what is in it. They get no
|
|
149564
|
-
* label and no behaviour; the row still lists, showing the reason raw.
|
|
149565
|
-
* `none` is the camera declining to classify, which is not the same as the
|
|
149566
|
-
* camera saying "nothing". `other` used to live here and is now a motion KIND
|
|
149567
|
-
* — see {@link KIND_BY_NATIVE}.
|
|
149568
|
-
*/
|
|
149569
|
-
var RECORDING_REASONS = new Set([
|
|
149570
|
-
"sched",
|
|
149571
|
-
"manual",
|
|
149572
|
-
"io",
|
|
149573
|
-
"IPCInputOutput",
|
|
149574
|
-
"legacy",
|
|
149575
|
-
"loss",
|
|
149576
|
-
"answer",
|
|
149577
|
-
"tamper",
|
|
149578
|
-
"lmsg",
|
|
149579
|
-
"none"
|
|
149580
|
-
]);
|
|
149581
|
-
/**
|
|
149582
|
-
* Ordering for `labels` — "dominant first". A person outranks a vehicle
|
|
149583
|
-
* outranks an animal outranks a package; anything unknown sorts last so a
|
|
149584
|
-
* newer firmware's invention never displaces a class the operator filters on.
|
|
152113
|
+
* - its `endMs` is a frozen guess — the camera's index keeps whatever it held
|
|
152114
|
+
* when the file was opened, measured on 592 as the same `endTime` for eight
|
|
152115
|
+
* minutes (D563) — so the row's duration, its position on the axis and its
|
|
152116
|
+
* height are all wrong and all move;
|
|
152117
|
+
* - `readClipBytes` already refuses it by name (`clip-in-progress`): it has no
|
|
152118
|
+
* end to export against, so the completion rule has nothing to prove the
|
|
152119
|
+
* fetch finished (D568);
|
|
152120
|
+
* - it cannot be looped, and the surface has to explain why (`resolveClipLoop`);
|
|
152121
|
+
* - the player route would file a TRUNCATED copy under an immutable key, so
|
|
152122
|
+
* the first seconds become permanently what that clip is.
|
|
152123
|
+
*
|
|
152124
|
+
* Four different places bending around a row that cannot be played properly.
|
|
152125
|
+
* Not listing it deletes all four questions instead of answering them: the
|
|
152126
|
+
* clip appears, whole, on the next listing after the camera closes the file.
|
|
149585
152127
|
*/
|
|
149586
|
-
|
|
149587
|
-
|
|
149588
|
-
|
|
149589
|
-
"animal",
|
|
149590
|
-
"package"
|
|
149591
|
-
];
|
|
149592
|
-
/** Split the `recordType` CSV a file carries into raw types, de-duplicated,
|
|
149593
|
-
* in wire order. Case is NOT normalised: `IPCInputOutput` is what the camera
|
|
149594
|
-
* said and what `nativeTypes` must carry back. */
|
|
149595
|
-
function parseNativeTypes(recordType) {
|
|
149596
|
-
if (recordType === void 0) return [];
|
|
149597
|
-
const out = [];
|
|
149598
|
-
for (const part of recordType.split(",")) {
|
|
149599
|
-
const trimmed = part.trim();
|
|
149600
|
-
if (trimmed === "" || out.includes(trimmed)) continue;
|
|
149601
|
-
out.push(trimmed);
|
|
149602
|
-
}
|
|
149603
|
-
return out;
|
|
149604
|
-
}
|
|
149605
|
-
function classifyNativeTypes(nativeTypes) {
|
|
149606
|
-
const labels = [];
|
|
149607
|
-
const behaviours = [];
|
|
149608
|
-
let kind = "native";
|
|
149609
|
-
for (const raw of nativeTypes) {
|
|
149610
|
-
const behaviour = BEHAVIOUR_BY_NATIVE[raw];
|
|
149611
|
-
if (behaviour !== void 0 && !behaviours.includes(behaviour)) behaviours.push(behaviour);
|
|
149612
|
-
const cls = CLASS_BY_NATIVE[raw];
|
|
149613
|
-
if (cls !== void 0) {
|
|
149614
|
-
if (!labels.includes(cls)) labels.push(cls);
|
|
149615
|
-
continue;
|
|
149616
|
-
}
|
|
149617
|
-
const kindOf = KIND_BY_NATIVE[raw];
|
|
149618
|
-
if (kindOf !== void 0) {
|
|
149619
|
-
if (kind === "native") kind = kindOf;
|
|
149620
|
-
continue;
|
|
149621
|
-
}
|
|
149622
|
-
if (RECORDING_REASONS.has(raw)) continue;
|
|
149623
|
-
if (behaviour !== void 0) continue;
|
|
149624
|
-
const unknown = `unknown:${raw}`;
|
|
149625
|
-
if (!labels.includes(unknown)) labels.push(unknown);
|
|
149626
|
-
}
|
|
149627
|
-
if (labels.some((label) => !label.startsWith("unknown:"))) kind = "native";
|
|
152128
|
+
function listableClips(rows, window) {
|
|
152129
|
+
const inWindow = rows.filter((row) => clipOverlapsWindow(row, window));
|
|
152130
|
+
const kept = inWindow.filter((row) => row.inProgress !== true);
|
|
149628
152131
|
return {
|
|
149629
|
-
|
|
149630
|
-
|
|
149631
|
-
|
|
149632
|
-
kind
|
|
152132
|
+
kept,
|
|
152133
|
+
outOfWindow: rows.length - inWindow.length,
|
|
152134
|
+
inProgress: inWindow.length - kept.length
|
|
149633
152135
|
};
|
|
149634
152136
|
}
|
|
149635
|
-
function rankOf(label) {
|
|
149636
|
-
const at = CLASS_RANK.indexOf(label);
|
|
149637
|
-
return at === -1 ? CLASS_RANK.length : at;
|
|
149638
|
-
}
|
|
149639
152137
|
function pairTwins(subs, mains) {
|
|
149640
152138
|
const datedSubs = subs.filter((f) => f.startTime instanceof Date);
|
|
149641
152139
|
const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
|
|
@@ -149670,6 +152168,247 @@ function pairTwins(subs, mains) {
|
|
|
149670
152168
|
function startOf(file) {
|
|
149671
152169
|
return file.startTime?.getTime() ?? 0;
|
|
149672
152170
|
}
|
|
152171
|
+
/**
|
|
152172
|
+
* The ONE place that turns a requested profile into a twin.
|
|
152173
|
+
*
|
|
152174
|
+
* Three call sites used to decide this independently — `getClipPlayback`,
|
|
152175
|
+
* `readClipBytes` and the stream dial — and they did not agree: the dial
|
|
152176
|
+
* REFUSED `high` outright while the other two served the sub copy with a
|
|
152177
|
+
* warning. Same question, three answers, and the one that refused made `high`
|
|
152178
|
+
* look unsupported rather than unavailable for that recording.
|
|
152179
|
+
*
|
|
152180
|
+
* The mapping is the profile ladder the rest of the system already uses:
|
|
152181
|
+
* `high` reaches for the main twin, everything else is the sub. A `high` with
|
|
152182
|
+
* no main twin degrades to the sub and SAYS SO through `served` and
|
|
152183
|
+
* `downgraded` — "there is no high-quality copy" is an answer (`pairTwins`
|
|
152184
|
+
* models it as `main: undefined`), and a silent substitution is what D576
|
|
152185
|
+
* forbids. Refusing was the other way of getting it wrong.
|
|
152186
|
+
*
|
|
152187
|
+
* An ABSENT profile is not `high`: a caller that stated no preference gets the
|
|
152188
|
+
* cheap copy, which is what all three call sites did before this function
|
|
152189
|
+
* existed (`profile === 'high'` on `undefined` is false).
|
|
152190
|
+
*/
|
|
152191
|
+
function chooseClipTwin(profile, row) {
|
|
152192
|
+
const wantsMain = profile === "high";
|
|
152193
|
+
const mainFileName = wantsMain ? row.mainFileName : void 0;
|
|
152194
|
+
if (mainFileName !== void 0) return {
|
|
152195
|
+
twin: "main",
|
|
152196
|
+
fileName: mainFileName,
|
|
152197
|
+
served: "high",
|
|
152198
|
+
downgraded: false
|
|
152199
|
+
};
|
|
152200
|
+
return {
|
|
152201
|
+
twin: "sub",
|
|
152202
|
+
fileName: row.subFileName,
|
|
152203
|
+
served: "low",
|
|
152204
|
+
downgraded: wantsMain
|
|
152205
|
+
};
|
|
152206
|
+
}
|
|
152207
|
+
//#endregion
|
|
152208
|
+
//#region src/clips/clip-identity.ts
|
|
152209
|
+
/**
|
|
152210
|
+
* A Reolink clip's IDENTITY — the id the operator's surface carries around and
|
|
152211
|
+
* the key its cached bytes are filed under.
|
|
152212
|
+
*
|
|
152213
|
+
* `native:reolink:<source>:<channel>:<base64url(fileName)>` (D549 3). It is
|
|
152214
|
+
* self-contained on purpose: the thumbnail route, the playback route and the
|
|
152215
|
+
* collection dispatcher each resolve a clip from the id alone, so none of them
|
|
152216
|
+
* re-lists the camera to answer a click. The source is INSIDE the id because
|
|
152217
|
+
* D554 5 routes a scalar cap call by the longest source that prefixes it, and
|
|
152218
|
+
* because an id naming a source this provider no longer serves must be
|
|
152219
|
+
* REFUSED rather than routed by prefix luck.
|
|
152220
|
+
*
|
|
152221
|
+
* base64url, not base64: a hub's `<Id>` is a real path
|
|
152222
|
+
* (`/mnt/sda/U10<uid>-<name>/RecS02_…mp4`), and `/` and `+` in a URL path
|
|
152223
|
+
* segment are a different path or a space.
|
|
152224
|
+
*/
|
|
152225
|
+
/**
|
|
152226
|
+
* The camera's OWN recordings — the ONE Reolink source.
|
|
152227
|
+
*
|
|
152228
|
+
* It was two (`onboard` for the files view, `hub` for the parent's event
|
|
152229
|
+
* view, D549 11) for a few hours, on the premise that the two might hold
|
|
152230
|
+
* DIFFERENT sets. Our own measurement disproved that premise: the hub's uid
|
|
152231
|
+
* addresses no catalog at all (empty month over 24 months, zero files, zero
|
|
152232
|
+
* rows) while the child's uid addresses all of it, cmd 5 serves identical
|
|
152233
|
+
* bytes under either, and on 3628 the two views cover each other almost
|
|
152234
|
+
* exactly — 29 events, 28 inside the 20 listed files, and all 20 files
|
|
152235
|
+
* carrying an event. They are two VIEWS of one store.
|
|
152236
|
+
*
|
|
152237
|
+
* So the operator collapsed them (2026-09-20): one source, which picked the
|
|
152238
|
+
* best API for what the camera IS, and REPORTED the pick on `ClipSource.via`.
|
|
152239
|
+
* Later the same day the pick went too (D565): `FileInfoList` is the catalog
|
|
152240
|
+
* on every topology, so `via` had one value and is gone. What is left is one
|
|
152241
|
+
* source reading one API — and `clipKeyFor` below is still keyed off
|
|
152242
|
+
* `(channel, fileName)` rather than the source, which is now simply true
|
|
152243
|
+
* rather than a concession.
|
|
152244
|
+
*/
|
|
152245
|
+
var CLIP_SOURCE_ONBOARD = "native:reolink:onboard";
|
|
152246
|
+
/** Every source this provider can name. Order is the picker's order. */
|
|
152247
|
+
var REOLINK_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
|
|
152248
|
+
function encodeFileName(fileName) {
|
|
152249
|
+
return Buffer.from(fileName, "utf8").toString("base64url");
|
|
152250
|
+
}
|
|
152251
|
+
function mintClipId(parts) {
|
|
152252
|
+
const base = `${parts.source}:${parts.channel}:${encodeFileName(parts.fileName)}`;
|
|
152253
|
+
return parts.window === void 0 ? base : `${base}:${parts.window.startMs}-${parts.window.endMs}`;
|
|
152254
|
+
}
|
|
152255
|
+
/**
|
|
152256
|
+
* Parse an id this provider minted. `null` for anything else — including an id
|
|
152257
|
+
* from the analytics or HKSV source, which is how the collection dispatcher's
|
|
152258
|
+
* "nobody claimed this id" branch stays honest.
|
|
152259
|
+
*/
|
|
152260
|
+
function parseClipId(clipId) {
|
|
152261
|
+
const source = REOLINK_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
|
|
152262
|
+
if (source === void 0) return null;
|
|
152263
|
+
const rest = clipId.slice(source.length + 1);
|
|
152264
|
+
const sep = rest.indexOf(":");
|
|
152265
|
+
if (sep <= 0) return null;
|
|
152266
|
+
const channel = Number(rest.slice(0, sep));
|
|
152267
|
+
if (!Number.isInteger(channel) || channel < 0) return null;
|
|
152268
|
+
const payload = rest.slice(sep + 1);
|
|
152269
|
+
if (payload === "") return null;
|
|
152270
|
+
const windowSep = payload.indexOf(":");
|
|
152271
|
+
const encoded = windowSep === -1 ? payload : payload.slice(0, windowSep);
|
|
152272
|
+
if (encoded === "") return null;
|
|
152273
|
+
const fileName = Buffer.from(encoded, "base64url").toString("utf8");
|
|
152274
|
+
if (fileName === "") return null;
|
|
152275
|
+
if (windowSep === -1) return {
|
|
152276
|
+
source,
|
|
152277
|
+
channel,
|
|
152278
|
+
fileName
|
|
152279
|
+
};
|
|
152280
|
+
const match = /^(\d+)-(\d+)$/.exec(payload.slice(windowSep + 1));
|
|
152281
|
+
if (match === null) return null;
|
|
152282
|
+
return {
|
|
152283
|
+
source,
|
|
152284
|
+
channel,
|
|
152285
|
+
fileName,
|
|
152286
|
+
window: {
|
|
152287
|
+
startMs: Number(match[1]),
|
|
152288
|
+
endMs: Number(match[2])
|
|
152289
|
+
}
|
|
152290
|
+
};
|
|
152291
|
+
}
|
|
152292
|
+
/**
|
|
152293
|
+
* The on-disk key for a clip's derived artifacts (its thumbnail, its muxed
|
|
152294
|
+
* MP4). Derived from `(channel, fileName)` and NOT from the source: a
|
|
152295
|
+
* thumbnail belongs to the FILE, so any surface that names the same file
|
|
152296
|
+
* finds the same picture and the camera is never asked for a second
|
|
152297
|
+
* CoverPreview of it.
|
|
152298
|
+
*
|
|
152299
|
+
* A hash rather than the encoded name because a hub `<Id>` is a full path and
|
|
152300
|
+
* the encoded form is long enough to hit a file-name limit.
|
|
152301
|
+
*/
|
|
152302
|
+
function clipKeyFor(channel, fileName) {
|
|
152303
|
+
return (0, node_crypto.createHash)("sha1").update(`${channel}${fileName}`, "utf8").digest("hex");
|
|
152304
|
+
}
|
|
152305
|
+
//#endregion
|
|
152306
|
+
//#region src/clips/clip-native-types.ts
|
|
152307
|
+
/** Cap on `ClipSchema.labels`, mirrored from `@camstack/types` MAX_CLIP_LABELS. */
|
|
152308
|
+
var MAX_LABELS = 3;
|
|
152309
|
+
/** Native → object class. The same rule `native-detection-mapping.ts` applies
|
|
152310
|
+
* to live boxes, so a clip and a live box never disagree about a dog. */
|
|
152311
|
+
var CLASS_BY_NATIVE = {
|
|
152312
|
+
people: "person",
|
|
152313
|
+
face: "person",
|
|
152314
|
+
visitor: "person",
|
|
152315
|
+
vehicle: "vehicle",
|
|
152316
|
+
nonmotorveh: "vehicle",
|
|
152317
|
+
dog_cat: "animal",
|
|
152318
|
+
package: "package"
|
|
152319
|
+
};
|
|
152320
|
+
/** Natives that state a KIND rather than a class. */
|
|
152321
|
+
var KIND_BY_NATIVE = {
|
|
152322
|
+
md: "motion",
|
|
152323
|
+
pir: "motion",
|
|
152324
|
+
cry: "audio",
|
|
152325
|
+
other: "motion"
|
|
152326
|
+
};
|
|
152327
|
+
/** What the subject DID — carried beside a class, never as one. */
|
|
152328
|
+
var BEHAVIOUR_BY_NATIVE = {
|
|
152329
|
+
crossline: "crossline",
|
|
152330
|
+
intrude: "intrude",
|
|
152331
|
+
intrusion: "intrude",
|
|
152332
|
+
loitering: "loitering",
|
|
152333
|
+
visitor: "doorbell"
|
|
152334
|
+
};
|
|
152335
|
+
/**
|
|
152336
|
+
* Recording REASONS — why the file exists, not what is in it. They get no
|
|
152337
|
+
* label and no behaviour; the row still lists, showing the reason raw.
|
|
152338
|
+
* `none` is the camera declining to classify, which is not the same as the
|
|
152339
|
+
* camera saying "nothing". `other` used to live here and is now a motion KIND
|
|
152340
|
+
* — see {@link KIND_BY_NATIVE}.
|
|
152341
|
+
*/
|
|
152342
|
+
var RECORDING_REASONS = new Set([
|
|
152343
|
+
"sched",
|
|
152344
|
+
"manual",
|
|
152345
|
+
"io",
|
|
152346
|
+
"IPCInputOutput",
|
|
152347
|
+
"legacy",
|
|
152348
|
+
"loss",
|
|
152349
|
+
"answer",
|
|
152350
|
+
"tamper",
|
|
152351
|
+
"lmsg",
|
|
152352
|
+
"none"
|
|
152353
|
+
]);
|
|
152354
|
+
/**
|
|
152355
|
+
* Ordering for `labels` — "dominant first". A person outranks a vehicle
|
|
152356
|
+
* outranks an animal outranks a package; anything unknown sorts last so a
|
|
152357
|
+
* newer firmware's invention never displaces a class the operator filters on.
|
|
152358
|
+
*/
|
|
152359
|
+
var CLASS_RANK = [
|
|
152360
|
+
"person",
|
|
152361
|
+
"vehicle",
|
|
152362
|
+
"animal",
|
|
152363
|
+
"package"
|
|
152364
|
+
];
|
|
152365
|
+
/** Split the `recordType` CSV a file carries into raw types, de-duplicated,
|
|
152366
|
+
* in wire order. Case is NOT normalised: `IPCInputOutput` is what the camera
|
|
152367
|
+
* said and what `nativeTypes` must carry back. */
|
|
152368
|
+
function parseNativeTypes(recordType) {
|
|
152369
|
+
if (recordType === void 0) return [];
|
|
152370
|
+
const out = [];
|
|
152371
|
+
for (const part of recordType.split(",")) {
|
|
152372
|
+
const trimmed = part.trim();
|
|
152373
|
+
if (trimmed === "" || out.includes(trimmed)) continue;
|
|
152374
|
+
out.push(trimmed);
|
|
152375
|
+
}
|
|
152376
|
+
return out;
|
|
152377
|
+
}
|
|
152378
|
+
function classifyNativeTypes(nativeTypes) {
|
|
152379
|
+
const labels = [];
|
|
152380
|
+
const behaviours = [];
|
|
152381
|
+
let kind = "native";
|
|
152382
|
+
for (const raw of nativeTypes) {
|
|
152383
|
+
const behaviour = BEHAVIOUR_BY_NATIVE[raw];
|
|
152384
|
+
if (behaviour !== void 0 && !behaviours.includes(behaviour)) behaviours.push(behaviour);
|
|
152385
|
+
const cls = CLASS_BY_NATIVE[raw];
|
|
152386
|
+
if (cls !== void 0) {
|
|
152387
|
+
if (!labels.includes(cls)) labels.push(cls);
|
|
152388
|
+
continue;
|
|
152389
|
+
}
|
|
152390
|
+
const kindOf = KIND_BY_NATIVE[raw];
|
|
152391
|
+
if (kindOf !== void 0) {
|
|
152392
|
+
if (kind === "native") kind = kindOf;
|
|
152393
|
+
continue;
|
|
152394
|
+
}
|
|
152395
|
+
if (RECORDING_REASONS.has(raw)) continue;
|
|
152396
|
+
if (behaviour !== void 0) continue;
|
|
152397
|
+
const unknown = `unknown:${raw}`;
|
|
152398
|
+
if (!labels.includes(unknown)) labels.push(unknown);
|
|
152399
|
+
}
|
|
152400
|
+
if (labels.some((label) => !label.startsWith("unknown:"))) kind = "native";
|
|
152401
|
+
return {
|
|
152402
|
+
labels: labels.toSorted((a, b) => rankOf(a) - rankOf(b)).slice(0, MAX_LABELS),
|
|
152403
|
+
behaviours,
|
|
152404
|
+
nativeTypes: [...nativeTypes],
|
|
152405
|
+
kind
|
|
152406
|
+
};
|
|
152407
|
+
}
|
|
152408
|
+
function rankOf(label) {
|
|
152409
|
+
const at = CLASS_RANK.indexOf(label);
|
|
152410
|
+
return at === -1 ? CLASS_RANK.length : at;
|
|
152411
|
+
}
|
|
149673
152412
|
var partsCache = /* @__PURE__ */ new Map();
|
|
149674
152413
|
function formatterFor$1(timeZone) {
|
|
149675
152414
|
const cached = partsCache.get(timeZone);
|
|
@@ -150204,7 +152943,24 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
150204
152943
|
state: "ok",
|
|
150205
152944
|
catalogAsOf: deps.now()
|
|
150206
152945
|
});
|
|
150207
|
-
|
|
152946
|
+
for (const row of rows) rowsByKey.set(`${device.deviceId}|${row.clipKey}`, row);
|
|
152947
|
+
const narrowed = listableClips(rows, {
|
|
152948
|
+
since,
|
|
152949
|
+
until
|
|
152950
|
+
});
|
|
152951
|
+
if (narrowed.outOfWindow > 0 || narrowed.inProgress > 0) deps.logger.info("videoclips: narrowed a day listing to what it can answer", {
|
|
152952
|
+
tags,
|
|
152953
|
+
meta: {
|
|
152954
|
+
branch: "listing-narrowed",
|
|
152955
|
+
listed: rows.length,
|
|
152956
|
+
kept: narrowed.kept.length,
|
|
152957
|
+
outOfWindow: narrowed.outOfWindow,
|
|
152958
|
+
inProgress: narrowed.inProgress,
|
|
152959
|
+
since,
|
|
152960
|
+
until
|
|
152961
|
+
}
|
|
152962
|
+
});
|
|
152963
|
+
return narrowed.kept;
|
|
150208
152964
|
};
|
|
150209
152965
|
/**
|
|
150210
152966
|
* Give a day's rows a class when the FILES carried none.
|
|
@@ -150354,14 +153110,17 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
150354
153110
|
if (device === null) throw new Error(`videoclips: device ${deviceId} is not a Reolink camera`);
|
|
150355
153111
|
if (device.isBattery && device.sleeping && !device.isHubChild) throw new Error(`videoclips: device ${deviceId} is a standalone battery camera and is sleeping — fetching a clip would wake it. Use "Wake and refresh" first.`);
|
|
150356
153112
|
const row = rowsByKey.get(`${deviceId}|${clipKeyFor(parsed.channel, parsed.fileName)}`) ?? null;
|
|
150357
|
-
const
|
|
150358
|
-
|
|
153113
|
+
const chosen = row === null ? null : chooseClipTwin(profile, {
|
|
153114
|
+
subFileName: row.subFileName,
|
|
153115
|
+
...row.mainFileName !== void 0 ? { mainFileName: row.mainFileName } : {}
|
|
153116
|
+
});
|
|
153117
|
+
const fileName = chosen === null || chosen.fileName === "" ? parsed.fileName : chosen.fileName;
|
|
150359
153118
|
const prepared = await deps.preparePlayback({
|
|
150360
153119
|
device,
|
|
150361
153120
|
row,
|
|
150362
153121
|
fileName,
|
|
150363
153122
|
clipKey: clipKeyFor(parsed.channel, parsed.fileName),
|
|
150364
|
-
profile:
|
|
153123
|
+
profile: chosen?.served ?? "low"
|
|
150365
153124
|
});
|
|
150366
153125
|
return {
|
|
150367
153126
|
playbackUrl: prepared.playbackUrl,
|
|
@@ -150422,8 +153181,8 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
150422
153181
|
const row = rowsByKey.get(`${deviceId}|${clipKey}`);
|
|
150423
153182
|
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 });
|
|
150424
153183
|
if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written, so it has no end to export`, { clipKey });
|
|
150425
|
-
const
|
|
150426
|
-
if (
|
|
153184
|
+
const chosen = chooseClipTwin(profile, row);
|
|
153185
|
+
if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — serving the sub copy", {
|
|
150427
153186
|
tags,
|
|
150428
153187
|
meta: {
|
|
150429
153188
|
clipId,
|
|
@@ -150432,13 +153191,13 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
150432
153191
|
asked: profile
|
|
150433
153192
|
}
|
|
150434
153193
|
});
|
|
150435
|
-
const twin =
|
|
150436
|
-
const fileName =
|
|
153194
|
+
const twin = chosen.twin;
|
|
153195
|
+
const fileName = chosen.fileName;
|
|
150437
153196
|
if (fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
|
|
150438
153197
|
clipKey,
|
|
150439
153198
|
twin
|
|
150440
153199
|
});
|
|
150441
|
-
const served =
|
|
153200
|
+
const served = chosen.served;
|
|
150442
153201
|
const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
|
|
150443
153202
|
const declaredBytes = twin === "main" ? row.mainBytes : row.subBytes;
|
|
150444
153203
|
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 an inline read — play it from its playback URL instead`, {
|
|
@@ -150482,6 +153241,75 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
150482
153241
|
served,
|
|
150483
153242
|
...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
|
|
150484
153243
|
};
|
|
153244
|
+
},
|
|
153245
|
+
/**
|
|
153246
|
+
* Where this clip's STREAM is dialled (D597) — the same questions
|
|
153247
|
+
* `readClipBytes` asks before the camera is touched (the id, the row, a
|
|
153248
|
+
* file still being written), the same vocabulary, and NO fetch: the URL
|
|
153249
|
+
* is one shot on the provider's own listener and the camera is opened
|
|
153250
|
+
* when the broker dials it.
|
|
153251
|
+
*
|
|
153252
|
+
* It serves EVERY profile, through the same {@link chooseClipTwin} the
|
|
153253
|
+
* byte read asks (D599). It used to refuse `high` by name, on the grounds
|
|
153254
|
+
* that handing a sub copy to a `high` ask is the silent 33× substitution
|
|
153255
|
+
* D576 forbids — true, but the refusal was not the cure. The cure is
|
|
153256
|
+
* `served`, which is on this answer and states which twin the stream
|
|
153257
|
+
* actually carries. Refusing left the operator with `high` looking
|
|
153258
|
+
* unsupported, and left two transports behind one pointer.
|
|
153259
|
+
*/
|
|
153260
|
+
dialClipStream: async ({ deviceId, clipId, profile, audio }) => {
|
|
153261
|
+
const tags = { deviceId };
|
|
153262
|
+
const refuse = (code, detail, meta) => {
|
|
153263
|
+
deps.logger.warn("videoclips: refusing a clip stream dial", {
|
|
153264
|
+
tags,
|
|
153265
|
+
meta: {
|
|
153266
|
+
clipId,
|
|
153267
|
+
branch: code,
|
|
153268
|
+
...meta
|
|
153269
|
+
}
|
|
153270
|
+
});
|
|
153271
|
+
throw new Error(clipExportFailure(code, detail));
|
|
153272
|
+
};
|
|
153273
|
+
const parsed = parseClipId(clipId);
|
|
153274
|
+
if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Reolink provider`, {});
|
|
153275
|
+
const clipKey = clipKeyFor(parsed.channel, parsed.fileName);
|
|
153276
|
+
const row = rowsByKey.get(`${deviceId}|${clipKey}`);
|
|
153277
|
+
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 });
|
|
153278
|
+
if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written; it has no end to stream to`, { clipKey });
|
|
153279
|
+
const chosen = chooseClipTwin(profile, row);
|
|
153280
|
+
if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — streaming the sub copy", {
|
|
153281
|
+
tags,
|
|
153282
|
+
meta: {
|
|
153283
|
+
clipId,
|
|
153284
|
+
clipKey,
|
|
153285
|
+
branch: "no-main-twin",
|
|
153286
|
+
asked: profile
|
|
153287
|
+
}
|
|
153288
|
+
});
|
|
153289
|
+
if (chosen.fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
|
|
153290
|
+
clipKey,
|
|
153291
|
+
twin: chosen.twin
|
|
153292
|
+
});
|
|
153293
|
+
const askedAudio = audio ?? "none";
|
|
153294
|
+
const audioReason = askedAudio === "none" ? "not-requested" : null;
|
|
153295
|
+
const outcome = await deps.prepareStreamDial({
|
|
153296
|
+
deviceId,
|
|
153297
|
+
row,
|
|
153298
|
+
twin: chosen.twin,
|
|
153299
|
+
audio: askedAudio
|
|
153300
|
+
});
|
|
153301
|
+
if (outcome.kind === "refused") return refuse(outcome.code, outcome.detail, {
|
|
153302
|
+
clipKey,
|
|
153303
|
+
twin: chosen.twin
|
|
153304
|
+
});
|
|
153305
|
+
return {
|
|
153306
|
+
url: outcome.url,
|
|
153307
|
+
expiresAtMs: outcome.expiresAtMs,
|
|
153308
|
+
transport: "forward-only",
|
|
153309
|
+
served: chosen.served,
|
|
153310
|
+
servedAudio: askedAudio,
|
|
153311
|
+
...audioReason !== null ? { audioReason } : {}
|
|
153312
|
+
};
|
|
150485
153313
|
}
|
|
150486
153314
|
};
|
|
150487
153315
|
/**
|
|
@@ -151112,9 +153940,15 @@ function createClipService(deps) {
|
|
|
151112
153940
|
* clip-export vocabulary, so the provider can hand the engine a token it
|
|
151113
153941
|
* believes rather than prose it has to guess at (D572).
|
|
151114
153942
|
*/
|
|
151115
|
-
|
|
151116
|
-
|
|
151117
|
-
|
|
153943
|
+
/**
|
|
153944
|
+
* The gates every clip byte path runs, in ONE place: the device, the
|
|
153945
|
+
* sleeping standalone, the uid. The file producer and the stream producer
|
|
153946
|
+
* both call this and neither decides any of it again — a second copy of
|
|
153947
|
+
* the sleeping gate is a second chance to wake a camera nobody authorised.
|
|
153948
|
+
* Every refusal is logged HERE, with its branch, so a caller cannot forget.
|
|
153949
|
+
*/
|
|
153950
|
+
const gateClipDevice = async (input) => {
|
|
153951
|
+
const { deviceId, clipKey, twin, path } = input;
|
|
151118
153952
|
const tags = { deviceId };
|
|
151119
153953
|
const device = await deps.resolveDevice(deviceId);
|
|
151120
153954
|
if (device === null) {
|
|
@@ -151123,11 +153957,13 @@ function createClipService(deps) {
|
|
|
151123
153957
|
meta: {
|
|
151124
153958
|
clipKey,
|
|
151125
153959
|
twin,
|
|
153960
|
+
path,
|
|
151126
153961
|
branch: "unknown-device"
|
|
151127
153962
|
}
|
|
151128
153963
|
});
|
|
151129
153964
|
return {
|
|
151130
153965
|
kind: "refused",
|
|
153966
|
+
branch: "unknown-device",
|
|
151131
153967
|
code: "catalog-unreachable",
|
|
151132
153968
|
detail: `device ${deviceId} is not a Reolink camera on this node`
|
|
151133
153969
|
};
|
|
@@ -151139,11 +153975,13 @@ function createClipService(deps) {
|
|
|
151139
153975
|
meta: {
|
|
151140
153976
|
clipKey,
|
|
151141
153977
|
twin,
|
|
153978
|
+
path,
|
|
151142
153979
|
branch: "sleeping"
|
|
151143
153980
|
}
|
|
151144
153981
|
});
|
|
151145
153982
|
return {
|
|
151146
153983
|
kind: "refused",
|
|
153984
|
+
branch: "sleeping",
|
|
151147
153985
|
code: "sleeping",
|
|
151148
153986
|
detail: `device ${deviceId} is a standalone battery camera and is sleeping — fetching a clip would wake it, and no operator authorisation was carried`
|
|
151149
153987
|
};
|
|
@@ -151153,6 +153991,7 @@ function createClipService(deps) {
|
|
|
151153
153991
|
meta: {
|
|
151154
153992
|
clipKey,
|
|
151155
153993
|
twin,
|
|
153994
|
+
path,
|
|
151156
153995
|
branch: "wake-authorised"
|
|
151157
153996
|
}
|
|
151158
153997
|
});
|
|
@@ -151163,15 +154002,39 @@ function createClipService(deps) {
|
|
|
151163
154002
|
meta: {
|
|
151164
154003
|
clipKey,
|
|
151165
154004
|
twin,
|
|
154005
|
+
path,
|
|
151166
154006
|
branch: "uid-missing"
|
|
151167
154007
|
}
|
|
151168
154008
|
});
|
|
151169
154009
|
return {
|
|
151170
154010
|
kind: "refused",
|
|
154011
|
+
branch: "uid-missing",
|
|
151171
154012
|
code: "catalog-unreachable",
|
|
151172
154013
|
detail: UID_MISSING_REASON
|
|
151173
154014
|
};
|
|
151174
154015
|
}
|
|
154016
|
+
return {
|
|
154017
|
+
kind: "ok",
|
|
154018
|
+
device
|
|
154019
|
+
};
|
|
154020
|
+
};
|
|
154021
|
+
const produceTwin = async (input) => {
|
|
154022
|
+
const { deviceId, row, fileName, twin } = input;
|
|
154023
|
+
const clipKey = row.clipKey;
|
|
154024
|
+
const tags = { deviceId };
|
|
154025
|
+
const gate = await gateClipDevice({
|
|
154026
|
+
deviceId,
|
|
154027
|
+
clipKey,
|
|
154028
|
+
twin,
|
|
154029
|
+
path: "file",
|
|
154030
|
+
...input.wake !== void 0 ? { wake: input.wake } : {}
|
|
154031
|
+
});
|
|
154032
|
+
if (gate.kind === "refused") return {
|
|
154033
|
+
kind: "refused",
|
|
154034
|
+
code: gate.code,
|
|
154035
|
+
detail: gate.detail
|
|
154036
|
+
};
|
|
154037
|
+
const { device } = gate;
|
|
151175
154038
|
let downloaded;
|
|
151176
154039
|
deps.logger.info("videoclips: fetching a clip file from the camera", {
|
|
151177
154040
|
tags,
|
|
@@ -151349,6 +154212,34 @@ function createClipService(deps) {
|
|
|
151349
154212
|
if (outcome.kind === "ok" && twin === "sub") await media.put(deviceId, row.clipKey, outcome.bytes);
|
|
151350
154213
|
return outcome;
|
|
151351
154214
|
};
|
|
154215
|
+
/**
|
|
154216
|
+
* The STREAM's path (`clip-stream-route.ts`): the same gates, the same row,
|
|
154217
|
+
* nothing written. Beside the file path, not in its place.
|
|
154218
|
+
*/
|
|
154219
|
+
const streamClip = createClipStreamRoute({
|
|
154220
|
+
logger: deps.logger,
|
|
154221
|
+
gate: gateClipDevice,
|
|
154222
|
+
rowFor,
|
|
154223
|
+
producer: createClipStreamProducer({
|
|
154224
|
+
logger: deps.logger,
|
|
154225
|
+
spawnMux: (args) => (0, node_child_process.spawn)(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
|
|
154226
|
+
"pipe",
|
|
154227
|
+
"pipe",
|
|
154228
|
+
"pipe",
|
|
154229
|
+
"pipe"
|
|
154230
|
+
] })
|
|
154231
|
+
})
|
|
154232
|
+
});
|
|
154233
|
+
/**
|
|
154234
|
+
* The stream's DIAL (D597): the same route behind a one-shot token on a
|
|
154235
|
+
* loopback listener, because the broker holds no credential the data
|
|
154236
|
+
* plane or the hub proxy would take. See `clip-stream-dial.ts`.
|
|
154237
|
+
*/
|
|
154238
|
+
const streamDial = createClipStreamDial({
|
|
154239
|
+
logger: deps.logger,
|
|
154240
|
+
stream: streamClip,
|
|
154241
|
+
now
|
|
154242
|
+
});
|
|
151352
154243
|
const provider = createReolinkVideoclipsProvider({
|
|
151353
154244
|
now,
|
|
151354
154245
|
cache,
|
|
@@ -151361,11 +154252,41 @@ function createClipService(deps) {
|
|
|
151361
154252
|
served: profile === "high" ? "high" : "low"
|
|
151362
154253
|
}),
|
|
151363
154254
|
prepareBytes,
|
|
154255
|
+
prepareStreamDial: async ({ deviceId, row, twin, audio }) => {
|
|
154256
|
+
const gate = await gateClipDevice({
|
|
154257
|
+
deviceId,
|
|
154258
|
+
clipKey: row.clipKey,
|
|
154259
|
+
twin,
|
|
154260
|
+
path: "stream"
|
|
154261
|
+
});
|
|
154262
|
+
if (gate.kind === "refused") return {
|
|
154263
|
+
kind: "refused",
|
|
154264
|
+
code: gate.code,
|
|
154265
|
+
detail: gate.detail
|
|
154266
|
+
};
|
|
154267
|
+
const minted = await streamDial.mint({
|
|
154268
|
+
deviceId,
|
|
154269
|
+
clipKey: row.clipKey,
|
|
154270
|
+
twin,
|
|
154271
|
+
audio
|
|
154272
|
+
});
|
|
154273
|
+
if (minted.kind === "refused") return {
|
|
154274
|
+
kind: "refused",
|
|
154275
|
+
code: "fetch-failed",
|
|
154276
|
+
detail: `${minted.code}: ${minted.detail}`
|
|
154277
|
+
};
|
|
154278
|
+
return {
|
|
154279
|
+
kind: "ok",
|
|
154280
|
+
url: minted.url,
|
|
154281
|
+
expiresAtMs: minted.expiresAtMs
|
|
154282
|
+
};
|
|
154283
|
+
},
|
|
151364
154284
|
logger: deps.logger
|
|
151365
154285
|
});
|
|
151366
154286
|
return {
|
|
151367
154287
|
provider,
|
|
151368
154288
|
cache,
|
|
154289
|
+
disposeClipStreamDial: () => streamDial.dispose(),
|
|
151369
154290
|
refreshCatalog: async (deviceId) => {
|
|
151370
154291
|
const until = now();
|
|
151371
154292
|
await provider.listClips({
|
|
@@ -151378,6 +154299,11 @@ function createClipService(deps) {
|
|
|
151378
154299
|
hostBusy: (hostKey) => queue.busy(hostKey),
|
|
151379
154300
|
thumbPrefix: CLIP_THUMB_PREFIX,
|
|
151380
154301
|
mediaPrefix: CLIP_MEDIA_PREFIX,
|
|
154302
|
+
streamPrefix: CLIP_STREAM_PREFIX,
|
|
154303
|
+
streamHandler: createClipStreamHandler({
|
|
154304
|
+
stream: streamClip,
|
|
154305
|
+
logger: deps.logger
|
|
154306
|
+
}),
|
|
151381
154307
|
thumbHandler: createClipThumbHandler({
|
|
151382
154308
|
readCached: (deviceId, clipKey) => thumbs.read(deviceId, clipKey),
|
|
151383
154309
|
mint: (deviceId, clipKey) => mintThumbnail(deviceId, clipKey, "interactive"),
|
|
@@ -165396,6 +168322,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
165396
168322
|
this.clipRefreshTimer = null;
|
|
165397
168323
|
}
|
|
165398
168324
|
this.logChannels?.stop();
|
|
168325
|
+
await this.clipService?.disposeClipStreamDial().catch(() => {});
|
|
165399
168326
|
await this.emailPushServer?.stop().catch(() => {});
|
|
165400
168327
|
await this.autodetectCache.dispose();
|
|
165401
168328
|
}
|
|
@@ -165617,11 +168544,18 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
165617
168544
|
access: "authenticated",
|
|
165618
168545
|
handler: service.playbackHandler
|
|
165619
168546
|
});
|
|
168547
|
+
const stream = await this.ctx.dataPlane?.serve({
|
|
168548
|
+
prefix: service.streamPrefix,
|
|
168549
|
+
access: "authenticated",
|
|
168550
|
+
handler: service.streamHandler
|
|
168551
|
+
});
|
|
165620
168552
|
this.ctx.logger.info("Reolink clip data planes served", { meta: {
|
|
165621
168553
|
thumbPath: `/addon/${REOLINK_ADDON_ID}/${service.thumbPrefix}`,
|
|
165622
168554
|
mediaPath: `/addon/${REOLINK_ADDON_ID}/${service.mediaPrefix}`,
|
|
168555
|
+
streamPath: `/addon/${REOLINK_ADDON_ID}/${service.streamPrefix}`,
|
|
165623
168556
|
thumbServed: thumb !== void 0,
|
|
165624
|
-
mediaServed: media !== void 0
|
|
168557
|
+
mediaServed: media !== void 0,
|
|
168558
|
+
streamServed: stream !== void 0
|
|
165625
168559
|
} });
|
|
165626
168560
|
} catch (err) {
|
|
165627
168561
|
this.ctx.logger.warn("Reolink clip data planes failed to serve — clips will not render", { meta: { error: err instanceof Error ? err.message : String(err) } });
|