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