@camstack/addon-provider-reolink 1.2.159 → 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 +2178 -79
- package/dist/addon.mjs +2178 -79
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -10,6 +10,7 @@ path = require_chunk.__toESM(path, 1);
|
|
|
10
10
|
let crypto$1 = require("crypto");
|
|
11
11
|
crypto$1 = require_chunk.__toESM(crypto$1, 1);
|
|
12
12
|
let child_process = require("child_process");
|
|
13
|
+
let node_http = require("node:http");
|
|
13
14
|
let node_fs_promises = require("node:fs/promises");
|
|
14
15
|
let node_path = require("node:path");
|
|
15
16
|
let stream = require("stream");
|
|
@@ -5390,7 +5391,7 @@ var ZodIssueCode = {
|
|
|
5390
5391
|
var ZodFirstPartyTypeKind;
|
|
5391
5392
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5392
5393
|
//#endregion
|
|
5393
|
-
//#region ../types/dist/sleep-
|
|
5394
|
+
//#region ../types/dist/sleep-De1BvmWo.mjs
|
|
5394
5395
|
/**
|
|
5395
5396
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5396
5397
|
* window to float samples (D455).
|
|
@@ -7859,10 +7860,20 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
|
7859
7860
|
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7860
7861
|
"no-provider",
|
|
7861
7862
|
"source-unreachable",
|
|
7862
|
-
"not-configured",
|
|
7863
7863
|
"device-disabled"
|
|
7864
7864
|
]);
|
|
7865
7865
|
/**
|
|
7866
|
+
* Something true about a switch that is nonetheless OFFERED.
|
|
7867
|
+
*
|
|
7868
|
+
* The distinction from {@link CameraSwitchUnavailableReasonSchema} is the
|
|
7869
|
+
* whole point: a reason replaces the control, a warning travels WITH it. The
|
|
7870
|
+
* privacy mask taught us the difference — greying the one button that starts
|
|
7871
|
+
* the feature teaches the operator the feature is broken, while a live toggle
|
|
7872
|
+
* that flips nothing teaches them the switch is broken. Neither is true; the
|
|
7873
|
+
* honest surface is a working toggle that says what is missing.
|
|
7874
|
+
*/
|
|
7875
|
+
var CameraSwitchWarningSchema = _enum(["no-privacy-zones"]);
|
|
7876
|
+
/**
|
|
7866
7877
|
* One switch, resolved for one camera.
|
|
7867
7878
|
*
|
|
7868
7879
|
* `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
|
|
@@ -7881,6 +7892,11 @@ var CameraSwitchSchema = object({
|
|
|
7881
7892
|
/** False = do not render a control. `unavailableReason` says why. */
|
|
7882
7893
|
available: boolean(),
|
|
7883
7894
|
unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
|
|
7895
|
+
/**
|
|
7896
|
+
* Present on an AVAILABLE switch only. Rendered beside the live control;
|
|
7897
|
+
* never a substitute for one.
|
|
7898
|
+
*/
|
|
7899
|
+
warning: CameraSwitchWarningSchema.optional(),
|
|
7884
7900
|
/** Current state. Meaningless when `available` is false — read it as `true`. */
|
|
7885
7901
|
enabled: boolean(),
|
|
7886
7902
|
authority: CameraSwitchAuthoritySchema
|
|
@@ -25144,6 +25160,15 @@ var CLIP_STILL_REASON_HEADER = "x-camstack-reason";
|
|
|
25144
25160
|
var CLIP_STILL_REASON_CODE_HEADER = "x-camstack-reason-code";
|
|
25145
25161
|
/** One {@link ClipStillDispositionSchema} token, alone. */
|
|
25146
25162
|
var CLIP_STILL_DISPOSITION_HEADER = "x-camstack-disposition";
|
|
25163
|
+
/**
|
|
25164
|
+
* The clip STREAM route's own statement of what it is (D597): the value is
|
|
25165
|
+
* `forward-only`. A producer writes it on the 200; a consumer that dials a
|
|
25166
|
+
* URL and does not read it back refuses to play, because a progressive file
|
|
25167
|
+
* through a forward-only feeder looks like it worked until the first seek.
|
|
25168
|
+
* Named here because BOTH the provider and the broker spell it, and two
|
|
25169
|
+
* copies of a header name is how one of them stops being checked.
|
|
25170
|
+
*/
|
|
25171
|
+
var CLIP_STREAM_TRANSPORT_HEADER = "x-camstack-clip-transport";
|
|
25147
25172
|
var ClipSchema = object({
|
|
25148
25173
|
/** Opaque, provider-namespaced id. The default provider encodes the time
|
|
25149
25174
|
* window so `getClipPlayback` is self-contained (no event re-query). */
|
|
@@ -25429,6 +25454,82 @@ var ClipBytesSchema = object({
|
|
|
25429
25454
|
*/
|
|
25430
25455
|
durationMs: number().positive().optional()
|
|
25431
25456
|
});
|
|
25457
|
+
/**
|
|
25458
|
+
* Where a clip's STREAM can be dialled (D597) — the answer to
|
|
25459
|
+
* {@link videoclipsCapability.methods.dialClipStream}.
|
|
25460
|
+
*
|
|
25461
|
+
* `url` is plain `http://` on the provider's own token-gated listener, one
|
|
25462
|
+
* shot, short-lived. It is NOT the hub-proxied `/addon/<id>/clip-stream/…`
|
|
25463
|
+
* path: that plane takes only a user credential, which an addon does not
|
|
25464
|
+
* hold, and the framework offers no addon→addon byte transport — the one
|
|
25465
|
+
* precedent is the broker's own restream URLs (`getStreamWithCodec`), which
|
|
25466
|
+
* a consumer dials directly, and this is that precedent one addon over.
|
|
25467
|
+
*/
|
|
25468
|
+
/**
|
|
25469
|
+
* The audio a clip stream can be asked for, and the audio it carries.
|
|
25470
|
+
*
|
|
25471
|
+
* The camera stores AAC and no WebRTC session speaks it, so a stream that
|
|
25472
|
+
* carries sound carries it TRANSCODED, muxed as a second track beside the
|
|
25473
|
+
* video by the one ffmpeg the provider already runs (D600). `opus` is
|
|
25474
|
+
* 48 kHz mono, `pcmu` is 8 kHz µ-law — the two codecs a broker session
|
|
25475
|
+
* negotiates — and `none` is a stream asked for, or served, without sound.
|
|
25476
|
+
*/
|
|
25477
|
+
var ClipStreamAudioSchema = _enum([
|
|
25478
|
+
"opus",
|
|
25479
|
+
"pcmu",
|
|
25480
|
+
"none"
|
|
25481
|
+
]);
|
|
25482
|
+
/**
|
|
25483
|
+
* Why a stream that was ASKED for sound is not carrying it (D600).
|
|
25484
|
+
*
|
|
25485
|
+
* Only ever set beside `servedAudio: 'none'` after a non-`none` ask. Absent
|
|
25486
|
+
* means the ask was honoured, or there was no ask.
|
|
25487
|
+
*
|
|
25488
|
+
* - `not-requested` — the caller asked for `none`.
|
|
25489
|
+
* - `unsupported-ask` — the provider cannot produce that codec.
|
|
25490
|
+
*
|
|
25491
|
+
* Whether the RECORDING has sound at all is not knowable here: the camera
|
|
25492
|
+
* declares no audio track before its first frame arrives. That answer is in
|
|
25493
|
+
* the stream itself — an fMP4 with no audio track — and the broker reads it
|
|
25494
|
+
* there. A dial that says `opus` promises the codec, never the content.
|
|
25495
|
+
*/
|
|
25496
|
+
var ClipStreamAudioReasonSchema = _enum(["not-requested", "unsupported-ask"]);
|
|
25497
|
+
var ClipStreamDialSchema = object({
|
|
25498
|
+
url: string().min(1),
|
|
25499
|
+
/** After this the token is gone and the URL answers 404 `dial-expired`. */
|
|
25500
|
+
expiresAtMs: number(),
|
|
25501
|
+
/** What the stream IS — a consumer that cannot honour it must not dial. */
|
|
25502
|
+
transport: literal("forward-only"),
|
|
25503
|
+
/**
|
|
25504
|
+
* Which twin the stream ACTUALLY carries — not what was asked for.
|
|
25505
|
+
*
|
|
25506
|
+
* The stream serves every profile (D599), and a recording whose main twin
|
|
25507
|
+
* does not exist is streamed as its sub copy, which this field reports as
|
|
25508
|
+
* `low`. That is the whole reason the dial no longer refuses `high`:
|
|
25509
|
+
* stating what was served is what makes accepting honest, where a silent
|
|
25510
|
+
* substitution would be the 33x lie D576 forbids. Stated rather than
|
|
25511
|
+
* assumed, the way {@link ClipPlaybackSchema.served} is (D549 15).
|
|
25512
|
+
*/
|
|
25513
|
+
served: CamProfileSchema,
|
|
25514
|
+
/**
|
|
25515
|
+
* Which audio the stream will ACTUALLY carry — `served`'s companion for
|
|
25516
|
+
* sound, and the same reasoning (D599, D600).
|
|
25517
|
+
*
|
|
25518
|
+
* The broker asks for the codec its WebRTC session negotiated, and the
|
|
25519
|
+
* provider either muxes that codec or carries none. Stating it is what
|
|
25520
|
+
* lets the broker wire the matching sink before the first byte, and what
|
|
25521
|
+
* keeps a session from being handed a codec it cannot send — the audio
|
|
25522
|
+
* shape of the substitution `served` exists to prevent.
|
|
25523
|
+
*
|
|
25524
|
+
* It is a promise about the CODEC, not about the content: a recording with
|
|
25525
|
+
* no microphone track answers `opus` here and then arrives with no audio
|
|
25526
|
+
* track at all. That second answer is in the stream, and the broker reads
|
|
25527
|
+
* it from the `moov`.
|
|
25528
|
+
*/
|
|
25529
|
+
servedAudio: ClipStreamAudioSchema,
|
|
25530
|
+
/** Why `servedAudio` is `none` although sound was asked for. */
|
|
25531
|
+
audioReason: ClipStreamAudioReasonSchema.optional()
|
|
25532
|
+
});
|
|
25432
25533
|
var ClipSourceAvailabilitySchema = object({
|
|
25433
25534
|
state: _enum([
|
|
25434
25535
|
"ok",
|
|
@@ -25679,6 +25780,44 @@ var videoclipsCapability = {
|
|
|
25679
25780
|
}), ClipBytesSchema, {
|
|
25680
25781
|
kind: "query",
|
|
25681
25782
|
auth: "protected"
|
|
25783
|
+
}),
|
|
25784
|
+
/**
|
|
25785
|
+
* Where this clip's STREAM can be dialled (D597) — the forward-only
|
|
25786
|
+
* fMP4 the provider writes from the first muxed byte, for the broker to
|
|
25787
|
+
* play through the same WebRTC session as recorded footage, with the
|
|
25788
|
+
* first frame at the first fragment instead of after the last byte.
|
|
25789
|
+
*
|
|
25790
|
+
* Nothing is fetched here: the answer is a one-shot URL on the
|
|
25791
|
+
* provider's own listener, and the camera is touched when it is dialled.
|
|
25792
|
+
* The gates the file path runs (`readClipBytes`) run on the dial too —
|
|
25793
|
+
* the id, the row, a clip still being written — and refuse in the same
|
|
25794
|
+
* vocabulary, THROWN with `clipExportFailure(<token>, <prose>)`. Every
|
|
25795
|
+
* profile is served (D599) and the answer states which twin it carries;
|
|
25796
|
+
* `high` is not refused. Optional on the provider for the same reason
|
|
25797
|
+
* `readClipBytes` is: a source with no camera socket behind it (the
|
|
25798
|
+
* recorder's analytics window, a HomeKit tee) has no stream.
|
|
25799
|
+
*/
|
|
25800
|
+
dialClipStream: optionalMethod(object({
|
|
25801
|
+
deviceId: number(),
|
|
25802
|
+
clipId: string().min(1),
|
|
25803
|
+
/** WHICH provider holds the camera — see `readClipBytes.provider`. */
|
|
25804
|
+
provider: string().min(1),
|
|
25805
|
+
/** Which twin — `high` → the main file, everything else the sub (D599). */
|
|
25806
|
+
profile: CamProfileSchema.optional(),
|
|
25807
|
+
/**
|
|
25808
|
+
* Which audio codec the caller's session negotiated, so the provider
|
|
25809
|
+
* can mux it (D600). The ask travels on the DIAL because the mux is
|
|
25810
|
+
* spawned once, before the first byte, and cannot gain a track later.
|
|
25811
|
+
*
|
|
25812
|
+
* ABSENT IS `none`, never a guess: a caller that does not state an
|
|
25813
|
+
* ask is served the video-only stream that shipped first, so a broker
|
|
25814
|
+
* older than this field keeps working unchanged (D382 — an unstated
|
|
25815
|
+
* ask is zero).
|
|
25816
|
+
*/
|
|
25817
|
+
audio: ClipStreamAudioSchema.optional()
|
|
25818
|
+
}), ClipStreamDialSchema, {
|
|
25819
|
+
kind: "query",
|
|
25820
|
+
auth: "protected"
|
|
25682
25821
|
})
|
|
25683
25822
|
}
|
|
25684
25823
|
};
|
|
@@ -38017,6 +38156,1329 @@ var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
|
|
|
38017
38156
|
DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
|
|
38018
38157
|
new Set(Object.values(DeviceType));
|
|
38019
38158
|
DeviceFeature.BatteryOperated;
|
|
38159
|
+
/**
|
|
38160
|
+
* The vocabulary, in the order a picker offers it.
|
|
38161
|
+
*
|
|
38162
|
+
* Adding a key here is not enough to make it pickable: both glyph tables must
|
|
38163
|
+
* gain it too, and their tests fail until they do. That is the intended
|
|
38164
|
+
* friction — it is the only thing that keeps the two surfaces honest.
|
|
38165
|
+
*/
|
|
38166
|
+
var DISPLAY_ICON_VOCABULARY = [
|
|
38167
|
+
{
|
|
38168
|
+
key: "camera",
|
|
38169
|
+
group: "device",
|
|
38170
|
+
synonyms: [
|
|
38171
|
+
"telecamera",
|
|
38172
|
+
"videocamera",
|
|
38173
|
+
"camera",
|
|
38174
|
+
"cam"
|
|
38175
|
+
]
|
|
38176
|
+
},
|
|
38177
|
+
{
|
|
38178
|
+
key: "network",
|
|
38179
|
+
group: "device",
|
|
38180
|
+
synonyms: [
|
|
38181
|
+
"rete",
|
|
38182
|
+
"network",
|
|
38183
|
+
"lan"
|
|
38184
|
+
]
|
|
38185
|
+
},
|
|
38186
|
+
{
|
|
38187
|
+
key: "toggle-left",
|
|
38188
|
+
group: "device",
|
|
38189
|
+
synonyms: [
|
|
38190
|
+
"interruttore",
|
|
38191
|
+
"switch",
|
|
38192
|
+
"toggle"
|
|
38193
|
+
]
|
|
38194
|
+
},
|
|
38195
|
+
{
|
|
38196
|
+
key: "toggle-right",
|
|
38197
|
+
group: "device",
|
|
38198
|
+
synonyms: [
|
|
38199
|
+
"interruttore",
|
|
38200
|
+
"switch",
|
|
38201
|
+
"toggle",
|
|
38202
|
+
"acceso"
|
|
38203
|
+
]
|
|
38204
|
+
},
|
|
38205
|
+
{
|
|
38206
|
+
key: "circle-dot",
|
|
38207
|
+
group: "device",
|
|
38208
|
+
synonyms: [
|
|
38209
|
+
"pulsante",
|
|
38210
|
+
"bottone",
|
|
38211
|
+
"button",
|
|
38212
|
+
"dot"
|
|
38213
|
+
]
|
|
38214
|
+
},
|
|
38215
|
+
{
|
|
38216
|
+
key: "refresh-cw",
|
|
38217
|
+
group: "device",
|
|
38218
|
+
synonyms: [
|
|
38219
|
+
"aggiorna",
|
|
38220
|
+
"ricarica",
|
|
38221
|
+
"refresh",
|
|
38222
|
+
"reload",
|
|
38223
|
+
"ciclo"
|
|
38224
|
+
]
|
|
38225
|
+
},
|
|
38226
|
+
{
|
|
38227
|
+
key: "box",
|
|
38228
|
+
group: "device",
|
|
38229
|
+
synonyms: [
|
|
38230
|
+
"scatola",
|
|
38231
|
+
"box",
|
|
38232
|
+
"contenitore"
|
|
38233
|
+
]
|
|
38234
|
+
},
|
|
38235
|
+
{
|
|
38236
|
+
key: "scroll-text",
|
|
38237
|
+
group: "device",
|
|
38238
|
+
synonyms: [
|
|
38239
|
+
"registro",
|
|
38240
|
+
"log",
|
|
38241
|
+
"testo"
|
|
38242
|
+
]
|
|
38243
|
+
},
|
|
38244
|
+
{
|
|
38245
|
+
key: "workflow",
|
|
38246
|
+
group: "device",
|
|
38247
|
+
synonyms: [
|
|
38248
|
+
"automazione",
|
|
38249
|
+
"flusso",
|
|
38250
|
+
"workflow",
|
|
38251
|
+
"scenario"
|
|
38252
|
+
]
|
|
38253
|
+
},
|
|
38254
|
+
{
|
|
38255
|
+
key: "lock",
|
|
38256
|
+
group: "device",
|
|
38257
|
+
synonyms: [
|
|
38258
|
+
"serratura",
|
|
38259
|
+
"chiave",
|
|
38260
|
+
"lucchetto",
|
|
38261
|
+
"chiuso",
|
|
38262
|
+
"locked"
|
|
38263
|
+
]
|
|
38264
|
+
},
|
|
38265
|
+
{
|
|
38266
|
+
key: "lock-open",
|
|
38267
|
+
group: "device",
|
|
38268
|
+
synonyms: [
|
|
38269
|
+
"serratura",
|
|
38270
|
+
"aperta",
|
|
38271
|
+
"sbloccato",
|
|
38272
|
+
"unlocked"
|
|
38273
|
+
]
|
|
38274
|
+
},
|
|
38275
|
+
{
|
|
38276
|
+
key: "blinds",
|
|
38277
|
+
group: "device",
|
|
38278
|
+
synonyms: [
|
|
38279
|
+
"tapparella",
|
|
38280
|
+
"persiana",
|
|
38281
|
+
"serranda",
|
|
38282
|
+
"avvolgibile",
|
|
38283
|
+
"shutter",
|
|
38284
|
+
"blind",
|
|
38285
|
+
"veneziana"
|
|
38286
|
+
]
|
|
38287
|
+
},
|
|
38288
|
+
{
|
|
38289
|
+
key: "air-vent",
|
|
38290
|
+
group: "device",
|
|
38291
|
+
synonyms: [
|
|
38292
|
+
"bocchetta",
|
|
38293
|
+
"aria",
|
|
38294
|
+
"ventilazione",
|
|
38295
|
+
"condizionatore",
|
|
38296
|
+
"vent"
|
|
38297
|
+
]
|
|
38298
|
+
},
|
|
38299
|
+
{
|
|
38300
|
+
key: "monitor-play",
|
|
38301
|
+
group: "device",
|
|
38302
|
+
synonyms: [
|
|
38303
|
+
"schermo",
|
|
38304
|
+
"riproduzione",
|
|
38305
|
+
"player",
|
|
38306
|
+
"display"
|
|
38307
|
+
]
|
|
38308
|
+
},
|
|
38309
|
+
{
|
|
38310
|
+
key: "sliders-horizontal",
|
|
38311
|
+
group: "device",
|
|
38312
|
+
synonyms: [
|
|
38313
|
+
"regolazione",
|
|
38314
|
+
"cursori",
|
|
38315
|
+
"impostazioni",
|
|
38316
|
+
"slider"
|
|
38317
|
+
]
|
|
38318
|
+
},
|
|
38319
|
+
{
|
|
38320
|
+
key: "map-pin",
|
|
38321
|
+
group: "device",
|
|
38322
|
+
synonyms: [
|
|
38323
|
+
"posizione",
|
|
38324
|
+
"luogo",
|
|
38325
|
+
"mappa",
|
|
38326
|
+
"pin"
|
|
38327
|
+
]
|
|
38328
|
+
},
|
|
38329
|
+
{
|
|
38330
|
+
key: "bot",
|
|
38331
|
+
group: "device",
|
|
38332
|
+
synonyms: [
|
|
38333
|
+
"robot",
|
|
38334
|
+
"aspirapolvere",
|
|
38335
|
+
"bot"
|
|
38336
|
+
]
|
|
38337
|
+
},
|
|
38338
|
+
{
|
|
38339
|
+
key: "tractor",
|
|
38340
|
+
group: "device",
|
|
38341
|
+
synonyms: [
|
|
38342
|
+
"trattore",
|
|
38343
|
+
"tosaerba",
|
|
38344
|
+
"giardino"
|
|
38345
|
+
]
|
|
38346
|
+
},
|
|
38347
|
+
{
|
|
38348
|
+
key: "image",
|
|
38349
|
+
group: "device",
|
|
38350
|
+
synonyms: [
|
|
38351
|
+
"immagine",
|
|
38352
|
+
"foto",
|
|
38353
|
+
"snapshot"
|
|
38354
|
+
]
|
|
38355
|
+
},
|
|
38356
|
+
{
|
|
38357
|
+
key: "bone",
|
|
38358
|
+
group: "device",
|
|
38359
|
+
synonyms: ["osso", "cane"]
|
|
38360
|
+
},
|
|
38361
|
+
{
|
|
38362
|
+
key: "door-closed",
|
|
38363
|
+
group: "device",
|
|
38364
|
+
synonyms: [
|
|
38365
|
+
"porta",
|
|
38366
|
+
"chiusa",
|
|
38367
|
+
"contatto"
|
|
38368
|
+
]
|
|
38369
|
+
},
|
|
38370
|
+
{
|
|
38371
|
+
key: "door-open",
|
|
38372
|
+
group: "device",
|
|
38373
|
+
synonyms: [
|
|
38374
|
+
"porta",
|
|
38375
|
+
"aperta",
|
|
38376
|
+
"contatto",
|
|
38377
|
+
"ingresso",
|
|
38378
|
+
"uscita"
|
|
38379
|
+
]
|
|
38380
|
+
},
|
|
38381
|
+
{
|
|
38382
|
+
key: "crosshair",
|
|
38383
|
+
group: "device",
|
|
38384
|
+
synonyms: [
|
|
38385
|
+
"mirino",
|
|
38386
|
+
"puntamento",
|
|
38387
|
+
"centro"
|
|
38388
|
+
]
|
|
38389
|
+
},
|
|
38390
|
+
{
|
|
38391
|
+
key: "eye",
|
|
38392
|
+
group: "device",
|
|
38393
|
+
synonyms: [
|
|
38394
|
+
"occhio",
|
|
38395
|
+
"visibile",
|
|
38396
|
+
"visto"
|
|
38397
|
+
]
|
|
38398
|
+
},
|
|
38399
|
+
{
|
|
38400
|
+
key: "eye-off",
|
|
38401
|
+
group: "device",
|
|
38402
|
+
synonyms: [
|
|
38403
|
+
"privacy",
|
|
38404
|
+
"nascosto",
|
|
38405
|
+
"oscurato",
|
|
38406
|
+
"mascherato",
|
|
38407
|
+
"invisibile"
|
|
38408
|
+
]
|
|
38409
|
+
},
|
|
38410
|
+
{
|
|
38411
|
+
key: "smartphone",
|
|
38412
|
+
group: "device",
|
|
38413
|
+
synonyms: [
|
|
38414
|
+
"telefono",
|
|
38415
|
+
"cellulare",
|
|
38416
|
+
"smartphone"
|
|
38417
|
+
]
|
|
38418
|
+
},
|
|
38419
|
+
{
|
|
38420
|
+
key: "phone",
|
|
38421
|
+
group: "device",
|
|
38422
|
+
synonyms: ["telefono", "chiamata"]
|
|
38423
|
+
},
|
|
38424
|
+
{
|
|
38425
|
+
key: "tablet",
|
|
38426
|
+
group: "device",
|
|
38427
|
+
synonyms: ["tablet", "ipad"]
|
|
38428
|
+
},
|
|
38429
|
+
{
|
|
38430
|
+
key: "laptop",
|
|
38431
|
+
group: "device",
|
|
38432
|
+
synonyms: [
|
|
38433
|
+
"portatile",
|
|
38434
|
+
"laptop",
|
|
38435
|
+
"notebook"
|
|
38436
|
+
]
|
|
38437
|
+
},
|
|
38438
|
+
{
|
|
38439
|
+
key: "monitor",
|
|
38440
|
+
group: "device",
|
|
38441
|
+
synonyms: ["monitor", "schermo"]
|
|
38442
|
+
},
|
|
38443
|
+
{
|
|
38444
|
+
key: "tv",
|
|
38445
|
+
group: "device",
|
|
38446
|
+
synonyms: ["televisore", "tv"]
|
|
38447
|
+
},
|
|
38448
|
+
{
|
|
38449
|
+
key: "speaker",
|
|
38450
|
+
group: "device",
|
|
38451
|
+
synonyms: [
|
|
38452
|
+
"altoparlante",
|
|
38453
|
+
"cassa",
|
|
38454
|
+
"speaker"
|
|
38455
|
+
]
|
|
38456
|
+
},
|
|
38457
|
+
{
|
|
38458
|
+
key: "printer",
|
|
38459
|
+
group: "device",
|
|
38460
|
+
synonyms: ["stampante"]
|
|
38461
|
+
},
|
|
38462
|
+
{
|
|
38463
|
+
key: "projector",
|
|
38464
|
+
group: "device",
|
|
38465
|
+
synonyms: ["proiettore"]
|
|
38466
|
+
},
|
|
38467
|
+
{
|
|
38468
|
+
key: "gamepad-2",
|
|
38469
|
+
group: "device",
|
|
38470
|
+
synonyms: [
|
|
38471
|
+
"console",
|
|
38472
|
+
"gioco",
|
|
38473
|
+
"joystick"
|
|
38474
|
+
]
|
|
38475
|
+
},
|
|
38476
|
+
{
|
|
38477
|
+
key: "radio",
|
|
38478
|
+
group: "device",
|
|
38479
|
+
synonyms: ["radio"]
|
|
38480
|
+
},
|
|
38481
|
+
{
|
|
38482
|
+
key: "router",
|
|
38483
|
+
group: "device",
|
|
38484
|
+
synonyms: ["router", "modem"]
|
|
38485
|
+
},
|
|
38486
|
+
{
|
|
38487
|
+
key: "bluetooth",
|
|
38488
|
+
group: "device",
|
|
38489
|
+
synonyms: ["bluetooth"]
|
|
38490
|
+
},
|
|
38491
|
+
{
|
|
38492
|
+
key: "cpu",
|
|
38493
|
+
group: "device",
|
|
38494
|
+
synonyms: [
|
|
38495
|
+
"processore",
|
|
38496
|
+
"cpu",
|
|
38497
|
+
"chip",
|
|
38498
|
+
"dispositivo"
|
|
38499
|
+
]
|
|
38500
|
+
},
|
|
38501
|
+
{
|
|
38502
|
+
key: "hard-drive",
|
|
38503
|
+
group: "device",
|
|
38504
|
+
synonyms: [
|
|
38505
|
+
"disco",
|
|
38506
|
+
"hdd",
|
|
38507
|
+
"archiviazione",
|
|
38508
|
+
"storage"
|
|
38509
|
+
]
|
|
38510
|
+
},
|
|
38511
|
+
{
|
|
38512
|
+
key: "server",
|
|
38513
|
+
group: "device",
|
|
38514
|
+
synonyms: ["server", "nas"]
|
|
38515
|
+
},
|
|
38516
|
+
{
|
|
38517
|
+
key: "mic",
|
|
38518
|
+
group: "device",
|
|
38519
|
+
synonyms: ["microfono", "audio"]
|
|
38520
|
+
},
|
|
38521
|
+
{
|
|
38522
|
+
key: "mic-off",
|
|
38523
|
+
group: "device",
|
|
38524
|
+
synonyms: [
|
|
38525
|
+
"microfono",
|
|
38526
|
+
"spento",
|
|
38527
|
+
"muto"
|
|
38528
|
+
]
|
|
38529
|
+
},
|
|
38530
|
+
{
|
|
38531
|
+
key: "video",
|
|
38532
|
+
group: "device",
|
|
38533
|
+
synonyms: ["video"]
|
|
38534
|
+
},
|
|
38535
|
+
{
|
|
38536
|
+
key: "video-off",
|
|
38537
|
+
group: "device",
|
|
38538
|
+
synonyms: ["video", "spento"]
|
|
38539
|
+
},
|
|
38540
|
+
{
|
|
38541
|
+
key: "music",
|
|
38542
|
+
group: "device",
|
|
38543
|
+
synonyms: ["musica"]
|
|
38544
|
+
},
|
|
38545
|
+
{
|
|
38546
|
+
key: "plug",
|
|
38547
|
+
group: "device",
|
|
38548
|
+
synonyms: [
|
|
38549
|
+
"presa",
|
|
38550
|
+
"spina",
|
|
38551
|
+
"plug"
|
|
38552
|
+
]
|
|
38553
|
+
},
|
|
38554
|
+
{
|
|
38555
|
+
key: "plug-zap",
|
|
38556
|
+
group: "device",
|
|
38557
|
+
synonyms: ["presa", "alimentata"]
|
|
38558
|
+
},
|
|
38559
|
+
{
|
|
38560
|
+
key: "power",
|
|
38561
|
+
group: "device",
|
|
38562
|
+
synonyms: [
|
|
38563
|
+
"accensione",
|
|
38564
|
+
"alimentazione",
|
|
38565
|
+
"power"
|
|
38566
|
+
]
|
|
38567
|
+
},
|
|
38568
|
+
{
|
|
38569
|
+
key: "power-off",
|
|
38570
|
+
group: "device",
|
|
38571
|
+
synonyms: ["spegnimento", "spento"]
|
|
38572
|
+
},
|
|
38573
|
+
{
|
|
38574
|
+
key: "cable",
|
|
38575
|
+
group: "device",
|
|
38576
|
+
synonyms: ["cavo"]
|
|
38577
|
+
},
|
|
38578
|
+
{
|
|
38579
|
+
key: "battery",
|
|
38580
|
+
group: "device",
|
|
38581
|
+
synonyms: ["batteria"]
|
|
38582
|
+
},
|
|
38583
|
+
{
|
|
38584
|
+
key: "battery-charging",
|
|
38585
|
+
group: "device",
|
|
38586
|
+
synonyms: [
|
|
38587
|
+
"batteria",
|
|
38588
|
+
"carica",
|
|
38589
|
+
"ricarica"
|
|
38590
|
+
]
|
|
38591
|
+
},
|
|
38592
|
+
{
|
|
38593
|
+
key: "battery-full",
|
|
38594
|
+
group: "device",
|
|
38595
|
+
synonyms: [
|
|
38596
|
+
"batteria",
|
|
38597
|
+
"carica",
|
|
38598
|
+
"piena"
|
|
38599
|
+
]
|
|
38600
|
+
},
|
|
38601
|
+
{
|
|
38602
|
+
key: "battery-low",
|
|
38603
|
+
group: "device",
|
|
38604
|
+
synonyms: ["batteria", "scarica"]
|
|
38605
|
+
},
|
|
38606
|
+
{
|
|
38607
|
+
key: "key",
|
|
38608
|
+
group: "device",
|
|
38609
|
+
synonyms: ["chiave"]
|
|
38610
|
+
},
|
|
38611
|
+
{
|
|
38612
|
+
key: "key-round",
|
|
38613
|
+
group: "device",
|
|
38614
|
+
synonyms: ["chiave", "tonda"]
|
|
38615
|
+
},
|
|
38616
|
+
{
|
|
38617
|
+
key: "mailbox",
|
|
38618
|
+
group: "device",
|
|
38619
|
+
synonyms: [
|
|
38620
|
+
"cassetta",
|
|
38621
|
+
"postale",
|
|
38622
|
+
"posta"
|
|
38623
|
+
]
|
|
38624
|
+
},
|
|
38625
|
+
{
|
|
38626
|
+
key: "package",
|
|
38627
|
+
group: "device",
|
|
38628
|
+
synonyms: ["pacco", "consegna"]
|
|
38629
|
+
},
|
|
38630
|
+
{
|
|
38631
|
+
key: "shield",
|
|
38632
|
+
group: "device",
|
|
38633
|
+
synonyms: [
|
|
38634
|
+
"scudo",
|
|
38635
|
+
"protezione",
|
|
38636
|
+
"sicurezza",
|
|
38637
|
+
"allarme"
|
|
38638
|
+
]
|
|
38639
|
+
},
|
|
38640
|
+
{
|
|
38641
|
+
key: "shield-check",
|
|
38642
|
+
group: "device",
|
|
38643
|
+
synonyms: [
|
|
38644
|
+
"protetto",
|
|
38645
|
+
"sicuro",
|
|
38646
|
+
"armato"
|
|
38647
|
+
]
|
|
38648
|
+
},
|
|
38649
|
+
{
|
|
38650
|
+
key: "shield-off",
|
|
38651
|
+
group: "device",
|
|
38652
|
+
synonyms: [
|
|
38653
|
+
"protezione",
|
|
38654
|
+
"disattivata",
|
|
38655
|
+
"disarmato"
|
|
38656
|
+
]
|
|
38657
|
+
},
|
|
38658
|
+
{
|
|
38659
|
+
key: "briefcase",
|
|
38660
|
+
group: "device",
|
|
38661
|
+
synonyms: [
|
|
38662
|
+
"ufficio",
|
|
38663
|
+
"lavoro",
|
|
38664
|
+
"valigetta",
|
|
38665
|
+
"studio"
|
|
38666
|
+
]
|
|
38667
|
+
},
|
|
38668
|
+
{
|
|
38669
|
+
key: "lightbulb",
|
|
38670
|
+
group: "lighting",
|
|
38671
|
+
synonyms: [
|
|
38672
|
+
"luce",
|
|
38673
|
+
"lampadina",
|
|
38674
|
+
"lampada",
|
|
38675
|
+
"light"
|
|
38676
|
+
]
|
|
38677
|
+
},
|
|
38678
|
+
{
|
|
38679
|
+
key: "lamp",
|
|
38680
|
+
group: "lighting",
|
|
38681
|
+
synonyms: ["lampada", "abatjour"]
|
|
38682
|
+
},
|
|
38683
|
+
{
|
|
38684
|
+
key: "lamp-ceiling",
|
|
38685
|
+
group: "lighting",
|
|
38686
|
+
synonyms: [
|
|
38687
|
+
"plafoniera",
|
|
38688
|
+
"lampadario",
|
|
38689
|
+
"luce",
|
|
38690
|
+
"soffitto"
|
|
38691
|
+
]
|
|
38692
|
+
},
|
|
38693
|
+
{
|
|
38694
|
+
key: "lamp-desk",
|
|
38695
|
+
group: "lighting",
|
|
38696
|
+
synonyms: [
|
|
38697
|
+
"lampada",
|
|
38698
|
+
"scrivania",
|
|
38699
|
+
"abatjour"
|
|
38700
|
+
]
|
|
38701
|
+
},
|
|
38702
|
+
{
|
|
38703
|
+
key: "lamp-floor",
|
|
38704
|
+
group: "lighting",
|
|
38705
|
+
synonyms: [
|
|
38706
|
+
"piantana",
|
|
38707
|
+
"lampada",
|
|
38708
|
+
"terra"
|
|
38709
|
+
]
|
|
38710
|
+
},
|
|
38711
|
+
{
|
|
38712
|
+
key: "flashlight",
|
|
38713
|
+
group: "lighting",
|
|
38714
|
+
synonyms: [
|
|
38715
|
+
"faretto",
|
|
38716
|
+
"torcia",
|
|
38717
|
+
"spot",
|
|
38718
|
+
"faro"
|
|
38719
|
+
]
|
|
38720
|
+
},
|
|
38721
|
+
{
|
|
38722
|
+
key: "heater",
|
|
38723
|
+
group: "lighting",
|
|
38724
|
+
synonyms: [
|
|
38725
|
+
"riscaldamento",
|
|
38726
|
+
"termosifone",
|
|
38727
|
+
"stufa",
|
|
38728
|
+
"calore"
|
|
38729
|
+
]
|
|
38730
|
+
},
|
|
38731
|
+
{
|
|
38732
|
+
key: "snowflake",
|
|
38733
|
+
group: "lighting",
|
|
38734
|
+
synonyms: [
|
|
38735
|
+
"freddo",
|
|
38736
|
+
"raffrescamento",
|
|
38737
|
+
"aria",
|
|
38738
|
+
"condizionata",
|
|
38739
|
+
"neve"
|
|
38740
|
+
]
|
|
38741
|
+
},
|
|
38742
|
+
{
|
|
38743
|
+
key: "thermometer-sun",
|
|
38744
|
+
group: "lighting",
|
|
38745
|
+
synonyms: [
|
|
38746
|
+
"caldo",
|
|
38747
|
+
"temperatura",
|
|
38748
|
+
"alta"
|
|
38749
|
+
]
|
|
38750
|
+
},
|
|
38751
|
+
{
|
|
38752
|
+
key: "thermometer-snowflake",
|
|
38753
|
+
group: "lighting",
|
|
38754
|
+
synonyms: [
|
|
38755
|
+
"freddo",
|
|
38756
|
+
"temperatura",
|
|
38757
|
+
"bassa"
|
|
38758
|
+
]
|
|
38759
|
+
},
|
|
38760
|
+
{
|
|
38761
|
+
key: "refrigerator",
|
|
38762
|
+
group: "lighting",
|
|
38763
|
+
synonyms: ["frigorifero", "frigo"]
|
|
38764
|
+
},
|
|
38765
|
+
{
|
|
38766
|
+
key: "microwave",
|
|
38767
|
+
group: "lighting",
|
|
38768
|
+
synonyms: ["microonde", "forno"]
|
|
38769
|
+
},
|
|
38770
|
+
{
|
|
38771
|
+
key: "washing-machine",
|
|
38772
|
+
group: "lighting",
|
|
38773
|
+
synonyms: [
|
|
38774
|
+
"lavatrice",
|
|
38775
|
+
"lavastoviglie",
|
|
38776
|
+
"asciugatrice"
|
|
38777
|
+
]
|
|
38778
|
+
},
|
|
38779
|
+
{
|
|
38780
|
+
key: "cooking-pot",
|
|
38781
|
+
group: "lighting",
|
|
38782
|
+
synonyms: [
|
|
38783
|
+
"pentola",
|
|
38784
|
+
"cucina",
|
|
38785
|
+
"fornello"
|
|
38786
|
+
]
|
|
38787
|
+
},
|
|
38788
|
+
{
|
|
38789
|
+
key: "utensils",
|
|
38790
|
+
group: "lighting",
|
|
38791
|
+
synonyms: [
|
|
38792
|
+
"cucina",
|
|
38793
|
+
"posate",
|
|
38794
|
+
"sala",
|
|
38795
|
+
"pranzo"
|
|
38796
|
+
]
|
|
38797
|
+
},
|
|
38798
|
+
{
|
|
38799
|
+
key: "thermometer",
|
|
38800
|
+
group: "sensing",
|
|
38801
|
+
synonyms: [
|
|
38802
|
+
"temperatura",
|
|
38803
|
+
"termometro",
|
|
38804
|
+
"termostato"
|
|
38805
|
+
]
|
|
38806
|
+
},
|
|
38807
|
+
{
|
|
38808
|
+
key: "droplets",
|
|
38809
|
+
group: "sensing",
|
|
38810
|
+
synonyms: [
|
|
38811
|
+
"umidita",
|
|
38812
|
+
"acqua",
|
|
38813
|
+
"allagamento",
|
|
38814
|
+
"perdita"
|
|
38815
|
+
]
|
|
38816
|
+
},
|
|
38817
|
+
{
|
|
38818
|
+
key: "droplet",
|
|
38819
|
+
group: "sensing",
|
|
38820
|
+
synonyms: [
|
|
38821
|
+
"goccia",
|
|
38822
|
+
"acqua",
|
|
38823
|
+
"perdita",
|
|
38824
|
+
"allagamento"
|
|
38825
|
+
]
|
|
38826
|
+
},
|
|
38827
|
+
{
|
|
38828
|
+
key: "gauge",
|
|
38829
|
+
group: "sensing",
|
|
38830
|
+
synonyms: [
|
|
38831
|
+
"misura",
|
|
38832
|
+
"livello",
|
|
38833
|
+
"pressione",
|
|
38834
|
+
"indicatore"
|
|
38835
|
+
]
|
|
38836
|
+
},
|
|
38837
|
+
{
|
|
38838
|
+
key: "gauge2",
|
|
38839
|
+
group: "sensing",
|
|
38840
|
+
synonyms: [
|
|
38841
|
+
"misura",
|
|
38842
|
+
"livello",
|
|
38843
|
+
"pressione",
|
|
38844
|
+
"indicatore"
|
|
38845
|
+
],
|
|
38846
|
+
aliasOf: "gauge"
|
|
38847
|
+
},
|
|
38848
|
+
{
|
|
38849
|
+
key: "sun",
|
|
38850
|
+
group: "sensing",
|
|
38851
|
+
synonyms: [
|
|
38852
|
+
"sole",
|
|
38853
|
+
"luminosita",
|
|
38854
|
+
"giorno",
|
|
38855
|
+
"esterno"
|
|
38856
|
+
]
|
|
38857
|
+
},
|
|
38858
|
+
{
|
|
38859
|
+
key: "sun-medium",
|
|
38860
|
+
group: "sensing",
|
|
38861
|
+
synonyms: ["sole", "luminosita"]
|
|
38862
|
+
},
|
|
38863
|
+
{
|
|
38864
|
+
key: "sunrise",
|
|
38865
|
+
group: "sensing",
|
|
38866
|
+
synonyms: ["alba"]
|
|
38867
|
+
},
|
|
38868
|
+
{
|
|
38869
|
+
key: "moon",
|
|
38870
|
+
group: "sensing",
|
|
38871
|
+
synonyms: [
|
|
38872
|
+
"notte",
|
|
38873
|
+
"luna",
|
|
38874
|
+
"notturna",
|
|
38875
|
+
"nightvision"
|
|
38876
|
+
]
|
|
38877
|
+
},
|
|
38878
|
+
{
|
|
38879
|
+
key: "wind",
|
|
38880
|
+
group: "sensing",
|
|
38881
|
+
synonyms: [
|
|
38882
|
+
"vento",
|
|
38883
|
+
"aria",
|
|
38884
|
+
"gas",
|
|
38885
|
+
"monossido",
|
|
38886
|
+
"co"
|
|
38887
|
+
]
|
|
38888
|
+
},
|
|
38889
|
+
{
|
|
38890
|
+
key: "cloud",
|
|
38891
|
+
group: "sensing",
|
|
38892
|
+
synonyms: ["nuvola", "nuvoloso"]
|
|
38893
|
+
},
|
|
38894
|
+
{
|
|
38895
|
+
key: "cloud-rain",
|
|
38896
|
+
group: "sensing",
|
|
38897
|
+
synonyms: ["pioggia"]
|
|
38898
|
+
},
|
|
38899
|
+
{
|
|
38900
|
+
key: "cloud-sun",
|
|
38901
|
+
group: "sensing",
|
|
38902
|
+
synonyms: [
|
|
38903
|
+
"variabile",
|
|
38904
|
+
"sole",
|
|
38905
|
+
"nuvole"
|
|
38906
|
+
]
|
|
38907
|
+
},
|
|
38908
|
+
{
|
|
38909
|
+
key: "waves",
|
|
38910
|
+
group: "sensing",
|
|
38911
|
+
synonyms: [
|
|
38912
|
+
"onde",
|
|
38913
|
+
"acqua",
|
|
38914
|
+
"vibrazione"
|
|
38915
|
+
]
|
|
38916
|
+
},
|
|
38917
|
+
{
|
|
38918
|
+
key: "sprout",
|
|
38919
|
+
group: "sensing",
|
|
38920
|
+
synonyms: [
|
|
38921
|
+
"irrigazione",
|
|
38922
|
+
"pianta",
|
|
38923
|
+
"germoglio",
|
|
38924
|
+
"orto"
|
|
38925
|
+
]
|
|
38926
|
+
},
|
|
38927
|
+
{
|
|
38928
|
+
key: "leaf",
|
|
38929
|
+
group: "sensing",
|
|
38930
|
+
synonyms: [
|
|
38931
|
+
"foglia",
|
|
38932
|
+
"pianta",
|
|
38933
|
+
"verde"
|
|
38934
|
+
]
|
|
38935
|
+
},
|
|
38936
|
+
{
|
|
38937
|
+
key: "flower",
|
|
38938
|
+
group: "sensing",
|
|
38939
|
+
synonyms: ["fiore", "pianta"]
|
|
38940
|
+
},
|
|
38941
|
+
{
|
|
38942
|
+
key: "zap",
|
|
38943
|
+
group: "sensing",
|
|
38944
|
+
synonyms: [
|
|
38945
|
+
"energia",
|
|
38946
|
+
"corrente",
|
|
38947
|
+
"consumo",
|
|
38948
|
+
"watt",
|
|
38949
|
+
"elettricita"
|
|
38950
|
+
]
|
|
38951
|
+
},
|
|
38952
|
+
{
|
|
38953
|
+
key: "flame",
|
|
38954
|
+
group: "sensing",
|
|
38955
|
+
synonyms: [
|
|
38956
|
+
"fiamma",
|
|
38957
|
+
"gas",
|
|
38958
|
+
"fuoco",
|
|
38959
|
+
"incendio"
|
|
38960
|
+
]
|
|
38961
|
+
},
|
|
38962
|
+
{
|
|
38963
|
+
key: "bell",
|
|
38964
|
+
group: "sensing",
|
|
38965
|
+
synonyms: [
|
|
38966
|
+
"campanello",
|
|
38967
|
+
"suoneria",
|
|
38968
|
+
"notifica",
|
|
38969
|
+
"bell",
|
|
38970
|
+
"chime"
|
|
38971
|
+
]
|
|
38972
|
+
},
|
|
38973
|
+
{
|
|
38974
|
+
key: "bell-ring",
|
|
38975
|
+
group: "sensing",
|
|
38976
|
+
synonyms: [
|
|
38977
|
+
"campanello",
|
|
38978
|
+
"suona",
|
|
38979
|
+
"doorbell",
|
|
38980
|
+
"notifica"
|
|
38981
|
+
]
|
|
38982
|
+
},
|
|
38983
|
+
{
|
|
38984
|
+
key: "bell-off",
|
|
38985
|
+
group: "sensing",
|
|
38986
|
+
synonyms: [
|
|
38987
|
+
"silenzioso",
|
|
38988
|
+
"notifiche",
|
|
38989
|
+
"disattivate",
|
|
38990
|
+
"muto"
|
|
38991
|
+
]
|
|
38992
|
+
},
|
|
38993
|
+
{
|
|
38994
|
+
key: "siren",
|
|
38995
|
+
group: "sensing",
|
|
38996
|
+
synonyms: ["sirena", "allarme"]
|
|
38997
|
+
},
|
|
38998
|
+
{
|
|
38999
|
+
key: "radar",
|
|
39000
|
+
group: "sensing",
|
|
39001
|
+
synonyms: [
|
|
39002
|
+
"movimento",
|
|
39003
|
+
"presenza",
|
|
39004
|
+
"pir",
|
|
39005
|
+
"rilevamento",
|
|
39006
|
+
"radar"
|
|
39007
|
+
]
|
|
39008
|
+
},
|
|
39009
|
+
{
|
|
39010
|
+
key: "activity",
|
|
39011
|
+
group: "sensing",
|
|
39012
|
+
synonyms: [
|
|
39013
|
+
"attivita",
|
|
39014
|
+
"sensore",
|
|
39015
|
+
"generico"
|
|
39016
|
+
]
|
|
39017
|
+
},
|
|
39018
|
+
{
|
|
39019
|
+
key: "hash",
|
|
39020
|
+
group: "sensing",
|
|
39021
|
+
synonyms: [
|
|
39022
|
+
"numero",
|
|
39023
|
+
"contatore",
|
|
39024
|
+
"valore"
|
|
39025
|
+
]
|
|
39026
|
+
},
|
|
39027
|
+
{
|
|
39028
|
+
key: "clock",
|
|
39029
|
+
group: "sensing",
|
|
39030
|
+
synonyms: [
|
|
39031
|
+
"orologio",
|
|
39032
|
+
"ora",
|
|
39033
|
+
"tempo"
|
|
39034
|
+
]
|
|
39035
|
+
},
|
|
39036
|
+
{
|
|
39037
|
+
key: "timer",
|
|
39038
|
+
group: "sensing",
|
|
39039
|
+
synonyms: [
|
|
39040
|
+
"timer",
|
|
39041
|
+
"conto",
|
|
39042
|
+
"rovescia"
|
|
39043
|
+
]
|
|
39044
|
+
},
|
|
39045
|
+
{
|
|
39046
|
+
key: "calendar",
|
|
39047
|
+
group: "sensing",
|
|
39048
|
+
synonyms: [
|
|
39049
|
+
"calendario",
|
|
39050
|
+
"data",
|
|
39051
|
+
"pianificazione"
|
|
39052
|
+
]
|
|
39053
|
+
},
|
|
39054
|
+
{
|
|
39055
|
+
key: "pipette",
|
|
39056
|
+
group: "sensing",
|
|
39057
|
+
synonyms: [
|
|
39058
|
+
"qualita",
|
|
39059
|
+
"aria",
|
|
39060
|
+
"misura",
|
|
39061
|
+
"campione"
|
|
39062
|
+
]
|
|
39063
|
+
},
|
|
39064
|
+
{
|
|
39065
|
+
key: "fan",
|
|
39066
|
+
group: "sensing",
|
|
39067
|
+
synonyms: [
|
|
39068
|
+
"ventilatore",
|
|
39069
|
+
"ventola",
|
|
39070
|
+
"aria"
|
|
39071
|
+
]
|
|
39072
|
+
},
|
|
39073
|
+
{
|
|
39074
|
+
key: "bath",
|
|
39075
|
+
group: "sensing",
|
|
39076
|
+
synonyms: [
|
|
39077
|
+
"bagno",
|
|
39078
|
+
"vasca",
|
|
39079
|
+
"doccia"
|
|
39080
|
+
]
|
|
39081
|
+
},
|
|
39082
|
+
{
|
|
39083
|
+
key: "footprints",
|
|
39084
|
+
group: "sensing",
|
|
39085
|
+
synonyms: [
|
|
39086
|
+
"movimento",
|
|
39087
|
+
"passi",
|
|
39088
|
+
"presenza",
|
|
39089
|
+
"motion"
|
|
39090
|
+
]
|
|
39091
|
+
},
|
|
39092
|
+
{
|
|
39093
|
+
key: "alarm-smoke",
|
|
39094
|
+
group: "sensing",
|
|
39095
|
+
synonyms: [
|
|
39096
|
+
"fumo",
|
|
39097
|
+
"antincendio",
|
|
39098
|
+
"rilevatore"
|
|
39099
|
+
]
|
|
39100
|
+
},
|
|
39101
|
+
{
|
|
39102
|
+
key: "shield-alert",
|
|
39103
|
+
group: "sensing",
|
|
39104
|
+
synonyms: [
|
|
39105
|
+
"manomissione",
|
|
39106
|
+
"allarme",
|
|
39107
|
+
"tamper",
|
|
39108
|
+
"avviso"
|
|
39109
|
+
]
|
|
39110
|
+
},
|
|
39111
|
+
{
|
|
39112
|
+
key: "vibrate",
|
|
39113
|
+
group: "sensing",
|
|
39114
|
+
synonyms: [
|
|
39115
|
+
"vibrazione",
|
|
39116
|
+
"urto",
|
|
39117
|
+
"tamper"
|
|
39118
|
+
]
|
|
39119
|
+
},
|
|
39120
|
+
{
|
|
39121
|
+
key: "wifi",
|
|
39122
|
+
group: "sensing",
|
|
39123
|
+
synonyms: [
|
|
39124
|
+
"wifi",
|
|
39125
|
+
"rete",
|
|
39126
|
+
"segnale"
|
|
39127
|
+
]
|
|
39128
|
+
},
|
|
39129
|
+
{
|
|
39130
|
+
key: "wifi-off",
|
|
39131
|
+
group: "sensing",
|
|
39132
|
+
synonyms: [
|
|
39133
|
+
"wifi",
|
|
39134
|
+
"assente",
|
|
39135
|
+
"offline",
|
|
39136
|
+
"disconnesso"
|
|
39137
|
+
]
|
|
39138
|
+
},
|
|
39139
|
+
{
|
|
39140
|
+
key: "volume-2",
|
|
39141
|
+
group: "sensing",
|
|
39142
|
+
synonyms: [
|
|
39143
|
+
"volume",
|
|
39144
|
+
"audio",
|
|
39145
|
+
"altoparlante",
|
|
39146
|
+
"suono"
|
|
39147
|
+
]
|
|
39148
|
+
},
|
|
39149
|
+
{
|
|
39150
|
+
key: "volume-x",
|
|
39151
|
+
group: "sensing",
|
|
39152
|
+
synonyms: [
|
|
39153
|
+
"muto",
|
|
39154
|
+
"silenziato",
|
|
39155
|
+
"volume",
|
|
39156
|
+
"spento"
|
|
39157
|
+
]
|
|
39158
|
+
},
|
|
39159
|
+
{
|
|
39160
|
+
key: "heart",
|
|
39161
|
+
group: "sensing",
|
|
39162
|
+
synonyms: [
|
|
39163
|
+
"cuore",
|
|
39164
|
+
"preferito",
|
|
39165
|
+
"salute"
|
|
39166
|
+
]
|
|
39167
|
+
},
|
|
39168
|
+
{
|
|
39169
|
+
key: "heart-pulse",
|
|
39170
|
+
group: "sensing",
|
|
39171
|
+
synonyms: [
|
|
39172
|
+
"battito",
|
|
39173
|
+
"salute",
|
|
39174
|
+
"monitoraggio"
|
|
39175
|
+
]
|
|
39176
|
+
},
|
|
39177
|
+
{
|
|
39178
|
+
key: "star",
|
|
39179
|
+
group: "sensing",
|
|
39180
|
+
synonyms: [
|
|
39181
|
+
"preferito",
|
|
39182
|
+
"stella",
|
|
39183
|
+
"importante"
|
|
39184
|
+
]
|
|
39185
|
+
},
|
|
39186
|
+
{
|
|
39187
|
+
key: "sparkles",
|
|
39188
|
+
group: "sensing",
|
|
39189
|
+
synonyms: [
|
|
39190
|
+
"automatico",
|
|
39191
|
+
"intelligente",
|
|
39192
|
+
"ai",
|
|
39193
|
+
"scena"
|
|
39194
|
+
]
|
|
39195
|
+
},
|
|
39196
|
+
{
|
|
39197
|
+
key: "house",
|
|
39198
|
+
group: "place",
|
|
39199
|
+
synonyms: [
|
|
39200
|
+
"casa",
|
|
39201
|
+
"abitazione",
|
|
39202
|
+
"home"
|
|
39203
|
+
]
|
|
39204
|
+
},
|
|
39205
|
+
{
|
|
39206
|
+
key: "sofa",
|
|
39207
|
+
group: "place",
|
|
39208
|
+
synonyms: [
|
|
39209
|
+
"salone",
|
|
39210
|
+
"soggiorno",
|
|
39211
|
+
"divano",
|
|
39212
|
+
"living"
|
|
39213
|
+
]
|
|
39214
|
+
},
|
|
39215
|
+
{
|
|
39216
|
+
key: "bed-double",
|
|
39217
|
+
group: "place",
|
|
39218
|
+
synonyms: [
|
|
39219
|
+
"camera",
|
|
39220
|
+
"letto",
|
|
39221
|
+
"matrimoniale"
|
|
39222
|
+
]
|
|
39223
|
+
},
|
|
39224
|
+
{
|
|
39225
|
+
key: "bed",
|
|
39226
|
+
group: "place",
|
|
39227
|
+
synonyms: [
|
|
39228
|
+
"cameretta",
|
|
39229
|
+
"letto",
|
|
39230
|
+
"singolo"
|
|
39231
|
+
]
|
|
39232
|
+
},
|
|
39233
|
+
{
|
|
39234
|
+
key: "warehouse",
|
|
39235
|
+
group: "place",
|
|
39236
|
+
synonyms: [
|
|
39237
|
+
"magazzino",
|
|
39238
|
+
"capanno",
|
|
39239
|
+
"deposito",
|
|
39240
|
+
"cantina"
|
|
39241
|
+
]
|
|
39242
|
+
},
|
|
39243
|
+
{
|
|
39244
|
+
key: "trees",
|
|
39245
|
+
group: "place",
|
|
39246
|
+
synonyms: [
|
|
39247
|
+
"giardino",
|
|
39248
|
+
"alberi",
|
|
39249
|
+
"esterno",
|
|
39250
|
+
"parco"
|
|
39251
|
+
]
|
|
39252
|
+
},
|
|
39253
|
+
{
|
|
39254
|
+
key: "tree-pine",
|
|
39255
|
+
group: "place",
|
|
39256
|
+
synonyms: [
|
|
39257
|
+
"albero",
|
|
39258
|
+
"pino",
|
|
39259
|
+
"giardino"
|
|
39260
|
+
]
|
|
39261
|
+
},
|
|
39262
|
+
{
|
|
39263
|
+
key: "fence",
|
|
39264
|
+
group: "place",
|
|
39265
|
+
synonyms: [
|
|
39266
|
+
"recinto",
|
|
39267
|
+
"cancello",
|
|
39268
|
+
"staccionata",
|
|
39269
|
+
"perimetro"
|
|
39270
|
+
]
|
|
39271
|
+
},
|
|
39272
|
+
{
|
|
39273
|
+
key: "store",
|
|
39274
|
+
group: "place",
|
|
39275
|
+
synonyms: [
|
|
39276
|
+
"negozio",
|
|
39277
|
+
"ingresso",
|
|
39278
|
+
"vetrina"
|
|
39279
|
+
]
|
|
39280
|
+
},
|
|
39281
|
+
{
|
|
39282
|
+
key: "building",
|
|
39283
|
+
group: "place",
|
|
39284
|
+
synonyms: ["edificio", "palazzo"]
|
|
39285
|
+
},
|
|
39286
|
+
{
|
|
39287
|
+
key: "building-2",
|
|
39288
|
+
group: "place",
|
|
39289
|
+
synonyms: [
|
|
39290
|
+
"edificio",
|
|
39291
|
+
"uffici",
|
|
39292
|
+
"condominio"
|
|
39293
|
+
]
|
|
39294
|
+
},
|
|
39295
|
+
{
|
|
39296
|
+
key: "mountain",
|
|
39297
|
+
group: "place",
|
|
39298
|
+
synonyms: [
|
|
39299
|
+
"montagna",
|
|
39300
|
+
"esterno",
|
|
39301
|
+
"panorama"
|
|
39302
|
+
]
|
|
39303
|
+
},
|
|
39304
|
+
{
|
|
39305
|
+
key: "umbrella",
|
|
39306
|
+
group: "place",
|
|
39307
|
+
synonyms: [
|
|
39308
|
+
"tenda",
|
|
39309
|
+
"markise",
|
|
39310
|
+
"ombrellone",
|
|
39311
|
+
"pergola",
|
|
39312
|
+
"veranda"
|
|
39313
|
+
]
|
|
39314
|
+
},
|
|
39315
|
+
{
|
|
39316
|
+
key: "dumbbell",
|
|
39317
|
+
group: "place",
|
|
39318
|
+
synonyms: ["palestra", "attrezzi"]
|
|
39319
|
+
},
|
|
39320
|
+
{
|
|
39321
|
+
key: "wrench",
|
|
39322
|
+
group: "place",
|
|
39323
|
+
synonyms: [
|
|
39324
|
+
"officina",
|
|
39325
|
+
"manutenzione",
|
|
39326
|
+
"attrezzi",
|
|
39327
|
+
"utenze"
|
|
39328
|
+
]
|
|
39329
|
+
},
|
|
39330
|
+
{
|
|
39331
|
+
key: "hammer",
|
|
39332
|
+
group: "place",
|
|
39333
|
+
synonyms: [
|
|
39334
|
+
"officina",
|
|
39335
|
+
"martello",
|
|
39336
|
+
"lavori"
|
|
39337
|
+
]
|
|
39338
|
+
},
|
|
39339
|
+
{
|
|
39340
|
+
key: "drill",
|
|
39341
|
+
group: "place",
|
|
39342
|
+
synonyms: [
|
|
39343
|
+
"officina",
|
|
39344
|
+
"trapano",
|
|
39345
|
+
"lavori"
|
|
39346
|
+
]
|
|
39347
|
+
},
|
|
39348
|
+
{
|
|
39349
|
+
key: "waves-ladder",
|
|
39350
|
+
group: "place",
|
|
39351
|
+
synonyms: ["piscina", "vasca"]
|
|
39352
|
+
},
|
|
39353
|
+
{
|
|
39354
|
+
key: "circle-parking",
|
|
39355
|
+
group: "place",
|
|
39356
|
+
synonyms: [
|
|
39357
|
+
"parcheggio",
|
|
39358
|
+
"posto",
|
|
39359
|
+
"auto",
|
|
39360
|
+
"garage"
|
|
39361
|
+
]
|
|
39362
|
+
},
|
|
39363
|
+
{
|
|
39364
|
+
key: "route",
|
|
39365
|
+
group: "place",
|
|
39366
|
+
synonyms: [
|
|
39367
|
+
"percorso",
|
|
39368
|
+
"strada",
|
|
39369
|
+
"vialetto",
|
|
39370
|
+
"tragitto"
|
|
39371
|
+
]
|
|
39372
|
+
},
|
|
39373
|
+
{
|
|
39374
|
+
key: "car",
|
|
39375
|
+
group: "life",
|
|
39376
|
+
synonyms: [
|
|
39377
|
+
"auto",
|
|
39378
|
+
"macchina",
|
|
39379
|
+
"veicolo",
|
|
39380
|
+
"parcheggio"
|
|
39381
|
+
]
|
|
39382
|
+
},
|
|
39383
|
+
{
|
|
39384
|
+
key: "bike",
|
|
39385
|
+
group: "life",
|
|
39386
|
+
synonyms: [
|
|
39387
|
+
"bicicletta",
|
|
39388
|
+
"bici",
|
|
39389
|
+
"moto"
|
|
39390
|
+
]
|
|
39391
|
+
},
|
|
39392
|
+
{
|
|
39393
|
+
key: "bus",
|
|
39394
|
+
group: "life",
|
|
39395
|
+
synonyms: ["autobus", "pullman"]
|
|
39396
|
+
},
|
|
39397
|
+
{
|
|
39398
|
+
key: "truck",
|
|
39399
|
+
group: "life",
|
|
39400
|
+
synonyms: [
|
|
39401
|
+
"camion",
|
|
39402
|
+
"furgone",
|
|
39403
|
+
"consegna"
|
|
39404
|
+
]
|
|
39405
|
+
},
|
|
39406
|
+
{
|
|
39407
|
+
key: "plane",
|
|
39408
|
+
group: "life",
|
|
39409
|
+
synonyms: ["aereo"]
|
|
39410
|
+
},
|
|
39411
|
+
{
|
|
39412
|
+
key: "train-front",
|
|
39413
|
+
group: "life",
|
|
39414
|
+
synonyms: ["treno"]
|
|
39415
|
+
},
|
|
39416
|
+
{
|
|
39417
|
+
key: "sailboat",
|
|
39418
|
+
group: "life",
|
|
39419
|
+
synonyms: ["barca", "imbarcazione"]
|
|
39420
|
+
},
|
|
39421
|
+
{
|
|
39422
|
+
key: "dog",
|
|
39423
|
+
group: "life",
|
|
39424
|
+
synonyms: ["cane", "animale"]
|
|
39425
|
+
},
|
|
39426
|
+
{
|
|
39427
|
+
key: "cat",
|
|
39428
|
+
group: "life",
|
|
39429
|
+
synonyms: ["gatto", "animale"]
|
|
39430
|
+
},
|
|
39431
|
+
{
|
|
39432
|
+
key: "bird",
|
|
39433
|
+
group: "life",
|
|
39434
|
+
synonyms: ["uccello", "animale"]
|
|
39435
|
+
},
|
|
39436
|
+
{
|
|
39437
|
+
key: "paw-print",
|
|
39438
|
+
group: "life",
|
|
39439
|
+
synonyms: [
|
|
39440
|
+
"animale",
|
|
39441
|
+
"zampa",
|
|
39442
|
+
"pet"
|
|
39443
|
+
]
|
|
39444
|
+
},
|
|
39445
|
+
{
|
|
39446
|
+
key: "baby",
|
|
39447
|
+
group: "life",
|
|
39448
|
+
synonyms: [
|
|
39449
|
+
"neonato",
|
|
39450
|
+
"bimbo",
|
|
39451
|
+
"cameretta",
|
|
39452
|
+
"babymonitor"
|
|
39453
|
+
]
|
|
39454
|
+
},
|
|
39455
|
+
{
|
|
39456
|
+
key: "user",
|
|
39457
|
+
group: "life",
|
|
39458
|
+
synonyms: ["persona", "utente"]
|
|
39459
|
+
},
|
|
39460
|
+
{
|
|
39461
|
+
key: "users",
|
|
39462
|
+
group: "life",
|
|
39463
|
+
synonyms: [
|
|
39464
|
+
"persone",
|
|
39465
|
+
"gruppo",
|
|
39466
|
+
"famiglia"
|
|
39467
|
+
]
|
|
39468
|
+
},
|
|
39469
|
+
{
|
|
39470
|
+
key: "person-standing",
|
|
39471
|
+
group: "life",
|
|
39472
|
+
synonyms: [
|
|
39473
|
+
"persona",
|
|
39474
|
+
"piedi",
|
|
39475
|
+
"presenza"
|
|
39476
|
+
]
|
|
39477
|
+
}
|
|
39478
|
+
];
|
|
39479
|
+
DISPLAY_ICON_VOCABULARY.map((e) => e.key);
|
|
39480
|
+
DISPLAY_ICON_VOCABULARY.filter((e) => e.aliasOf === void 0).map((e) => e.key);
|
|
39481
|
+
new Map(DISPLAY_ICON_VOCABULARY.map((e) => [e.key, e]));
|
|
38020
39482
|
var LAST_FETCHED_FIELD = "lastFetchedAt";
|
|
38021
39483
|
/**
|
|
38022
39484
|
* How long a bridge stops re-attempting a refresh that did not land.
|
|
@@ -44962,6 +46424,12 @@ Object.freeze({
|
|
|
44962
46424
|
addonId: null,
|
|
44963
46425
|
access: "view"
|
|
44964
46426
|
},
|
|
46427
|
+
"videoclips.dialClipStream": {
|
|
46428
|
+
capName: "videoclips",
|
|
46429
|
+
capScope: "device",
|
|
46430
|
+
addonId: null,
|
|
46431
|
+
access: "view"
|
|
46432
|
+
},
|
|
44965
46433
|
"videoclips.getClipPlayback": {
|
|
44966
46434
|
capName: "videoclips",
|
|
44967
46435
|
capScope: "device",
|
|
@@ -47001,6 +48469,11 @@ Object.freeze({
|
|
|
47001
48469
|
form: "single",
|
|
47002
48470
|
optional: false
|
|
47003
48471
|
}],
|
|
48472
|
+
"videoclips.dialClipStream": [{
|
|
48473
|
+
name: "deviceId",
|
|
48474
|
+
form: "single",
|
|
48475
|
+
optional: false
|
|
48476
|
+
}],
|
|
47004
48477
|
"videoclips.getClipPlayback": [{
|
|
47005
48478
|
name: "deviceId",
|
|
47006
48479
|
form: "single",
|
|
@@ -149069,11 +150542,35 @@ function createClipPlaybackHandler(deps) {
|
|
|
149069
150542
|
* `HEAD` answers the headers and touches no camera.
|
|
149070
150543
|
*/
|
|
149071
150544
|
var CLIP_STREAM_PREFIX = "clip-stream";
|
|
150545
|
+
/** The data plane's own form: the clip is named on the path. */
|
|
150546
|
+
var resolveClipStreamPathTarget = (req) => {
|
|
150547
|
+
const match = MEDIA_PATH.exec(pathnameOf(req));
|
|
150548
|
+
if (match === null) return {
|
|
150549
|
+
kind: "refused",
|
|
150550
|
+
status: 400,
|
|
150551
|
+
code: "bad-path",
|
|
150552
|
+
detail: "not /<deviceId>/<clipKey>.mp4"
|
|
150553
|
+
};
|
|
150554
|
+
return {
|
|
150555
|
+
kind: "target",
|
|
150556
|
+
target: {
|
|
150557
|
+
deviceId: Number(match[1]),
|
|
150558
|
+
clipKey: match[2] ?? "",
|
|
150559
|
+
twin: "sub",
|
|
150560
|
+
audio: "none"
|
|
150561
|
+
}
|
|
150562
|
+
};
|
|
150563
|
+
};
|
|
150564
|
+
/**
|
|
150565
|
+
* Everything but the content type, which the MUX decides: a stream carrying
|
|
150566
|
+
* µ-law is a QuickTime container (measured — ffmpeg's MP4 muxer refuses
|
|
150567
|
+
* `pcm_mulaw`), and a header that said `video/mp4` for it would be a small
|
|
150568
|
+
* lie on a plane whose whole point is saying what it really serves.
|
|
150569
|
+
*/
|
|
149072
150570
|
var STREAM_HEADERS = {
|
|
149073
|
-
"content-type": "video/mp4",
|
|
149074
150571
|
"accept-ranges": "none",
|
|
149075
150572
|
"cache-control": "no-store",
|
|
149076
|
-
[
|
|
150573
|
+
[CLIP_STREAM_TRANSPORT_HEADER]: "forward-only"
|
|
149077
150574
|
};
|
|
149078
150575
|
/** The status a refusal answers. `404` = nothing here to serve; `409` = a
|
|
149079
150576
|
* clip that exists and cannot be served right now; `502` = the camera. */
|
|
@@ -149089,22 +150586,33 @@ function streamRefusalStatus(code) {
|
|
|
149089
150586
|
}
|
|
149090
150587
|
}
|
|
149091
150588
|
function createClipStreamHandler(deps) {
|
|
150589
|
+
const resolveTarget = deps.target ?? resolveClipStreamPathTarget;
|
|
149092
150590
|
return async (req, res) => {
|
|
149093
150591
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
149094
150592
|
res.writeHead(405, { allow: "GET, HEAD" });
|
|
149095
150593
|
res.end();
|
|
149096
150594
|
return;
|
|
149097
150595
|
}
|
|
149098
|
-
const
|
|
149099
|
-
if (
|
|
149100
|
-
|
|
150596
|
+
const resolved = resolveTarget(req);
|
|
150597
|
+
if (resolved.kind === "refused") {
|
|
150598
|
+
deps.logger.warn("videoclips: clip stream request names no clip", { meta: {
|
|
150599
|
+
code: resolved.code,
|
|
150600
|
+
reason: resolved.detail,
|
|
150601
|
+
status: resolved.status
|
|
150602
|
+
} });
|
|
150603
|
+
res.writeHead(resolved.status, {
|
|
150604
|
+
[CLIP_STILL_REASON_HEADER]: resolved.detail.slice(0, 500),
|
|
150605
|
+
[CLIP_STILL_REASON_CODE_HEADER]: resolved.code
|
|
150606
|
+
});
|
|
149101
150607
|
res.end();
|
|
149102
150608
|
return;
|
|
149103
150609
|
}
|
|
149104
|
-
const deviceId =
|
|
149105
|
-
const clipKey = match[2] ?? "";
|
|
150610
|
+
const { deviceId, clipKey } = resolved.target;
|
|
149106
150611
|
if (req.method === "HEAD") {
|
|
149107
|
-
res.writeHead(200,
|
|
150612
|
+
res.writeHead(200, {
|
|
150613
|
+
"content-type": "video/mp4",
|
|
150614
|
+
...STREAM_HEADERS
|
|
150615
|
+
});
|
|
149108
150616
|
res.end();
|
|
149109
150617
|
return;
|
|
149110
150618
|
}
|
|
@@ -149116,8 +150624,11 @@ function createClipStreamHandler(deps) {
|
|
|
149116
150624
|
}
|
|
149117
150625
|
});
|
|
149118
150626
|
const sink = {
|
|
149119
|
-
begin: () => {
|
|
149120
|
-
res.writeHead(200,
|
|
150627
|
+
begin: (contentType) => {
|
|
150628
|
+
res.writeHead(200, {
|
|
150629
|
+
"content-type": contentType,
|
|
150630
|
+
...STREAM_HEADERS
|
|
150631
|
+
});
|
|
149121
150632
|
},
|
|
149122
150633
|
write: (chunk) => res.write(chunk),
|
|
149123
150634
|
onceDrain: (listener) => {
|
|
@@ -149135,7 +150646,7 @@ function createClipStreamHandler(deps) {
|
|
|
149135
150646
|
};
|
|
149136
150647
|
let outcome;
|
|
149137
150648
|
try {
|
|
149138
|
-
outcome = await deps.stream(
|
|
150649
|
+
outcome = await deps.stream(resolved.target, sink);
|
|
149139
150650
|
} catch (err) {
|
|
149140
150651
|
deps.logger.warn("videoclips: the clip stream route threw", {
|
|
149141
150652
|
tags: { deviceId },
|
|
@@ -149348,16 +150859,39 @@ function probeClipStreamRate(timestampsUs) {
|
|
|
149348
150859
|
accessUnits: timestampsUs.length
|
|
149349
150860
|
};
|
|
149350
150861
|
}
|
|
150862
|
+
/** Opus at 48 kHz — the only rate the codec has, and the session's RTP clock. */
|
|
150863
|
+
var CLIP_STREAM_OPUS_RATE_HZ = 48e3;
|
|
150864
|
+
/** µ-law at 8 kHz — the PCMU RTP clock. */
|
|
150865
|
+
var CLIP_STREAM_PCMU_RATE_HZ = 8e3;
|
|
149351
150866
|
/**
|
|
149352
|
-
*
|
|
150867
|
+
* The container a stream is muxed into, for a given audio ask.
|
|
150868
|
+
*
|
|
150869
|
+
* MEASURED, not chosen: ffmpeg's MP4 muxer refuses `pcm_mulaw`
|
|
150870
|
+
* (`Could not find tag for codec pcm_mulaw in stream #1`), and its MOV muxer
|
|
150871
|
+
* takes it as a `ulaw` sample entry. The boxes a consumer walks are the same
|
|
150872
|
+
* either way — `ftyp`, an empty `moov`, then `moof`+`mdat` — so this changes
|
|
150873
|
+
* the content type and nothing else.
|
|
150874
|
+
*/
|
|
150875
|
+
function clipStreamContainerFor(audio) {
|
|
150876
|
+
return audio === "pcmu" ? "mov" : "mp4";
|
|
150877
|
+
}
|
|
150878
|
+
/** What the route puts on `content-type` for {@link clipStreamContainerFor}. */
|
|
150879
|
+
function clipStreamContentTypeFor(audio) {
|
|
150880
|
+
return clipStreamContainerFor(audio) === "mov" ? "video/quicktime" : "video/mp4";
|
|
150881
|
+
}
|
|
150882
|
+
/**
|
|
150883
|
+
* One ffmpeg, stdin to stdout, fragmented, with an optional second input.
|
|
149353
150884
|
*
|
|
149354
150885
|
* Mirrors `buildClipMuxArgs` where a stream can: the demuxer is named from
|
|
149355
150886
|
* the DETECTED codec (the BcMedia header lies on a hub child), `-r` is an
|
|
149356
150887
|
* input option before its own `-i`, and the video is copied. Where it
|
|
149357
|
-
* cannot — `+faststart
|
|
150888
|
+
* cannot — `+faststart` — the file header says why; where it now can — the
|
|
150889
|
+
* audio input — it is an ENCODE rather than the file path's copy, because
|
|
150890
|
+
* this output is consumed by a WebRTC sender and not by a player.
|
|
149358
150891
|
*/
|
|
149359
150892
|
function buildClipStreamMuxArgs(input) {
|
|
149360
150893
|
const demuxer = input.videoType === "H265" ? "hevc" : "h264";
|
|
150894
|
+
const audio = input.audio;
|
|
149361
150895
|
return [
|
|
149362
150896
|
"-hide_banner",
|
|
149363
150897
|
"-loglevel",
|
|
@@ -149368,12 +150902,42 @@ function buildClipStreamMuxArgs(input) {
|
|
|
149368
150902
|
demuxer,
|
|
149369
150903
|
"-i",
|
|
149370
150904
|
"pipe:0",
|
|
150905
|
+
...audio !== null ? [
|
|
150906
|
+
"-f",
|
|
150907
|
+
"aac",
|
|
150908
|
+
"-i",
|
|
150909
|
+
`pipe:${String(3)}`
|
|
150910
|
+
] : [],
|
|
150911
|
+
...audio !== null ? [
|
|
150912
|
+
"-map",
|
|
150913
|
+
"0:v",
|
|
150914
|
+
"-map",
|
|
150915
|
+
"1:a"
|
|
150916
|
+
] : [],
|
|
149371
150917
|
"-c:v",
|
|
149372
150918
|
"copy",
|
|
150919
|
+
...audio === "opus" ? [
|
|
150920
|
+
"-c:a",
|
|
150921
|
+
"libopus",
|
|
150922
|
+
"-ar",
|
|
150923
|
+
String(CLIP_STREAM_OPUS_RATE_HZ),
|
|
150924
|
+
"-ac",
|
|
150925
|
+
String(1),
|
|
150926
|
+
"-b:a",
|
|
150927
|
+
"24k"
|
|
150928
|
+
] : [],
|
|
150929
|
+
...audio === "pcmu" ? [
|
|
150930
|
+
"-c:a",
|
|
150931
|
+
"pcm_mulaw",
|
|
150932
|
+
"-ar",
|
|
150933
|
+
String(CLIP_STREAM_PCMU_RATE_HZ),
|
|
150934
|
+
"-ac",
|
|
150935
|
+
String(1)
|
|
150936
|
+
] : [],
|
|
149373
150937
|
"-movflags",
|
|
149374
150938
|
CLIP_STREAM_MOVFLAGS,
|
|
149375
150939
|
"-f",
|
|
149376
|
-
|
|
150940
|
+
clipStreamContainerFor(audio),
|
|
149377
150941
|
"pipe:1"
|
|
149378
150942
|
];
|
|
149379
150943
|
}
|
|
@@ -149461,10 +151025,22 @@ function judgeClipDuration(input) {
|
|
|
149461
151025
|
}
|
|
149462
151026
|
//#endregion
|
|
149463
151027
|
//#region src/clips/clip-stream.ts
|
|
151028
|
+
function isWritable(stream) {
|
|
151029
|
+
return "write" in stream && typeof stream.write === "function";
|
|
151030
|
+
}
|
|
151031
|
+
/** The stream at `index`, if it is one this process can be WRITTEN to. */
|
|
151032
|
+
function writableAt(stdio, index) {
|
|
151033
|
+
const candidate = stdio[index];
|
|
151034
|
+
if (candidate === null || candidate === void 0) return null;
|
|
151035
|
+
return isWritable(candidate) ? candidate : null;
|
|
151036
|
+
}
|
|
149464
151037
|
var defaultDecoder = (handlers) => new require_chunk_ZBACPERX.BcMediaAnnexBDecoder({
|
|
149465
151038
|
strict: false,
|
|
149466
151039
|
onVideoAccessUnit: handlers.onVideoAccessUnit,
|
|
149467
|
-
onAudioFrame: () => handlers.onAudioFrame(
|
|
151040
|
+
onAudioFrame: (frame) => handlers.onAudioFrame({
|
|
151041
|
+
audioType: frame.audioType,
|
|
151042
|
+
data: frame.data
|
|
151043
|
+
})
|
|
149468
151044
|
});
|
|
149469
151045
|
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
149470
151046
|
function createClipStreamProducer(deps) {
|
|
@@ -149554,7 +151130,16 @@ var ClipStreamRun = class {
|
|
|
149554
151130
|
input;
|
|
149555
151131
|
attempt;
|
|
149556
151132
|
accessUnits = 0;
|
|
151133
|
+
/** Every audio frame the decoder handed over, of any codec. */
|
|
149557
151134
|
audioFrames = 0;
|
|
151135
|
+
audioFramesDropped = 0;
|
|
151136
|
+
audioFramesAfterMux = 0;
|
|
151137
|
+
/** ADTS frames held until the mux exists — the audio half of the probe. */
|
|
151138
|
+
audioProbe = [];
|
|
151139
|
+
audioProbeBytes = 0;
|
|
151140
|
+
audioMuxed = null;
|
|
151141
|
+
audioSilentReason = null;
|
|
151142
|
+
audioIn = null;
|
|
149558
151143
|
timestampsUs = [];
|
|
149559
151144
|
/** Access units held until the rate is probed — the frames in flight. */
|
|
149560
151145
|
probe = [];
|
|
@@ -149572,6 +151157,7 @@ var ClipStreamRun = class {
|
|
|
149572
151157
|
bytesOut = 0;
|
|
149573
151158
|
cut = null;
|
|
149574
151159
|
stdinGate;
|
|
151160
|
+
audioStdinGate;
|
|
149575
151161
|
peerGate;
|
|
149576
151162
|
tags;
|
|
149577
151163
|
constructor(deps, input, attempt) {
|
|
@@ -149583,6 +151169,10 @@ var ClipStreamRun = class {
|
|
|
149583
151169
|
timeoutMs: deps.drainTimeoutMs,
|
|
149584
151170
|
onStall: () => this.sever("mux-stalled", "ffmpeg stopped taking access units")
|
|
149585
151171
|
});
|
|
151172
|
+
this.audioStdinGate = createDrainGate({
|
|
151173
|
+
timeoutMs: deps.drainTimeoutMs,
|
|
151174
|
+
onStall: () => this.sever("mux-audio-stalled", "ffmpeg stopped taking audio frames")
|
|
151175
|
+
});
|
|
149586
151176
|
this.peerGate = createDrainGate({
|
|
149587
151177
|
timeoutMs: deps.drainTimeoutMs,
|
|
149588
151178
|
onStall: () => this.sever("peer-stalled", "the peer stopped taking bytes")
|
|
@@ -149595,9 +151185,7 @@ var ClipStreamRun = class {
|
|
|
149595
151185
|
});
|
|
149596
151186
|
const decoder = this.deps.createDecoder({
|
|
149597
151187
|
onVideoAccessUnit: (unit) => this.onAccessUnit(unit),
|
|
149598
|
-
onAudioFrame: () =>
|
|
149599
|
-
this.audioFrames += 1;
|
|
149600
|
-
}
|
|
151188
|
+
onAudioFrame: (frame) => this.onAudioFrame(frame)
|
|
149601
151189
|
});
|
|
149602
151190
|
this.deps.logger.info("videoclips: streaming a clip file from the camera", {
|
|
149603
151191
|
tags: this.tags,
|
|
@@ -149608,6 +151196,7 @@ var ClipStreamRun = class {
|
|
|
149608
151196
|
startMs: row.startMs,
|
|
149609
151197
|
endMs: row.endMs,
|
|
149610
151198
|
attempt: this.attempt,
|
|
151199
|
+
audioAsked: this.input.audio,
|
|
149611
151200
|
idleTimeoutMs: 300
|
|
149612
151201
|
}
|
|
149613
151202
|
});
|
|
@@ -149654,12 +151243,68 @@ var ClipStreamRun = class {
|
|
|
149654
151243
|
}
|
|
149655
151244
|
this.feed(unit.annexB);
|
|
149656
151245
|
}
|
|
151246
|
+
/**
|
|
151247
|
+
* One audio frame from the decoder.
|
|
151248
|
+
*
|
|
151249
|
+
* Before the mux exists the ADTS frames are HELD, exactly as the video
|
|
151250
|
+
* probe holds access units, because the spawn cannot gain an input later
|
|
151251
|
+
* and ffmpeg will not write a byte until every input it was given has been
|
|
151252
|
+
* probed. After it exists they are either fed or counted as dropped — and
|
|
151253
|
+
* a frame that arrives after a SILENT spawn is counted apart, because that
|
|
151254
|
+
* one is a recording whose sound was there and was decided away.
|
|
151255
|
+
*/
|
|
151256
|
+
onAudioFrame(frame) {
|
|
151257
|
+
if (this.cut !== null) return;
|
|
151258
|
+
this.audioFrames += 1;
|
|
151259
|
+
if (this.input.audio === "none" || frame.audioType !== "Aac" || frame.data.byteLength === 0) {
|
|
151260
|
+
this.audioFramesDropped += 1;
|
|
151261
|
+
return;
|
|
151262
|
+
}
|
|
151263
|
+
if (this.mux === null) {
|
|
151264
|
+
if (this.audioProbe.length >= 4096 || this.audioProbeBytes + frame.data.byteLength > 4194304) {
|
|
151265
|
+
this.audioFramesDropped += 1;
|
|
151266
|
+
return;
|
|
151267
|
+
}
|
|
151268
|
+
this.audioProbe = [...this.audioProbe, frame.data];
|
|
151269
|
+
this.audioProbeBytes += frame.data.byteLength;
|
|
151270
|
+
return;
|
|
151271
|
+
}
|
|
151272
|
+
if (this.audioMuxed === null) {
|
|
151273
|
+
this.audioFramesDropped += 1;
|
|
151274
|
+
this.audioFramesAfterMux += 1;
|
|
151275
|
+
return;
|
|
151276
|
+
}
|
|
151277
|
+
this.feedAudio(frame.data);
|
|
151278
|
+
}
|
|
151279
|
+
/**
|
|
151280
|
+
* What the mux will carry as sound, decided ONCE, before the spawn.
|
|
151281
|
+
*
|
|
151282
|
+
* The gate is `an ADTS frame is already in hand`, not `the ask was not
|
|
151283
|
+
* none`: ffmpeg probes `pipe:3` before it writes output, so declaring an
|
|
151284
|
+
* input that never delivers would hold the VIDEO hostage for a recording
|
|
151285
|
+
* that simply has no microphone. An ADPCM-only camera is named separately
|
|
151286
|
+
* from a silent one — the file path draws the same distinction
|
|
151287
|
+
* (`decideClipAudio`) and for the same reason.
|
|
151288
|
+
*/
|
|
151289
|
+
decideAudio() {
|
|
151290
|
+
const asked = this.input.audio;
|
|
151291
|
+
if (asked === "none") {
|
|
151292
|
+
this.audioSilentReason = "not-requested";
|
|
151293
|
+
return;
|
|
151294
|
+
}
|
|
151295
|
+
if (this.audioProbe.length > 0) {
|
|
151296
|
+
this.audioMuxed = asked;
|
|
151297
|
+
return;
|
|
151298
|
+
}
|
|
151299
|
+
this.audioSilentReason = this.audioFrames > 0 ? "audio-not-copyable" : "no-audio-track";
|
|
151300
|
+
}
|
|
149657
151301
|
/** Spawn ffmpeg with the probed rate and hand it what was held. */
|
|
149658
151302
|
startMux() {
|
|
149659
151303
|
const first = this.probe[0];
|
|
149660
151304
|
if (first === void 0 || this.mux !== null) return;
|
|
149661
151305
|
this.videoType = first.videoType;
|
|
149662
151306
|
this.rate = probeClipStreamRate(this.timestampsUs);
|
|
151307
|
+
this.decideAudio();
|
|
149663
151308
|
const { row, twin } = this.input;
|
|
149664
151309
|
if (this.rate.fpsSource !== "timestamps") this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
|
|
149665
151310
|
tags: this.tags,
|
|
@@ -149674,13 +151319,33 @@ var ClipStreamRun = class {
|
|
|
149674
151319
|
try {
|
|
149675
151320
|
mux = this.deps.spawnMux(buildClipStreamMuxArgs({
|
|
149676
151321
|
videoType: this.videoType,
|
|
149677
|
-
fps: this.rate.fps
|
|
151322
|
+
fps: this.rate.fps,
|
|
151323
|
+
audio: this.audioMuxed
|
|
149678
151324
|
}));
|
|
149679
151325
|
} catch (err) {
|
|
149680
151326
|
this.sever("mux-failed", `ffmpeg could not be spawned: ${describe(err)}`);
|
|
149681
151327
|
return;
|
|
149682
151328
|
}
|
|
149683
151329
|
this.mux = mux;
|
|
151330
|
+
if (this.audioMuxed !== null) {
|
|
151331
|
+
this.audioIn = writableAt(mux.stdio, 3);
|
|
151332
|
+
if (this.audioIn === null) {
|
|
151333
|
+
this.audioMuxed = null;
|
|
151334
|
+
this.audioSilentReason = "no-audio-track";
|
|
151335
|
+
this.deps.logger.warn("videoclips: a clip stream mux exposed no audio pipe", {
|
|
151336
|
+
tags: this.tags,
|
|
151337
|
+
meta: {
|
|
151338
|
+
clipKey: row.clipKey,
|
|
151339
|
+
twin,
|
|
151340
|
+
branch: "audio-pipe-missing"
|
|
151341
|
+
}
|
|
151342
|
+
});
|
|
151343
|
+
this.sever("mux-failed", "the mux was spawned with an audio input it does not expose");
|
|
151344
|
+
return;
|
|
151345
|
+
}
|
|
151346
|
+
this.audioIn.on("error", (err) => this.sever("mux-failed", `ffmpeg audio stdin: ${err.message}`));
|
|
151347
|
+
this.audioIn.on("drain", () => this.audioStdinGate.drained());
|
|
151348
|
+
}
|
|
149684
151349
|
this.muxClosed = new Promise((resolve) => {
|
|
149685
151350
|
mux.on("close", (code) => resolve(code));
|
|
149686
151351
|
mux.on("error", (err) => {
|
|
@@ -149703,24 +151368,38 @@ var ClipStreamRun = class {
|
|
|
149703
151368
|
videoType: this.videoType,
|
|
149704
151369
|
fps: this.rate.fps,
|
|
149705
151370
|
fpsSource: this.rate.fpsSource,
|
|
149706
|
-
probeAccessUnits: this.rate.accessUnits
|
|
151371
|
+
probeAccessUnits: this.rate.accessUnits,
|
|
151372
|
+
audioAsked: this.input.audio,
|
|
151373
|
+
audioMuxed: this.audioMuxed,
|
|
151374
|
+
audioSilentReason: this.audioSilentReason,
|
|
151375
|
+
probeAudioFrames: this.audioProbe.length
|
|
149707
151376
|
}
|
|
149708
151377
|
});
|
|
149709
151378
|
const held = this.probe;
|
|
149710
151379
|
this.probe = [];
|
|
151380
|
+
const heldAudio = this.audioProbe;
|
|
151381
|
+
this.audioProbe = [];
|
|
151382
|
+
this.audioProbeBytes = 0;
|
|
149711
151383
|
for (const unit of held) this.feed(unit.annexB);
|
|
151384
|
+
if (this.audioMuxed === null) this.audioFramesDropped += heldAudio.length;
|
|
151385
|
+
else for (const frame of heldAudio) this.feedAudio(frame);
|
|
149712
151386
|
}
|
|
149713
151387
|
feed(annexB) {
|
|
149714
151388
|
if (this.cut !== null || this.mux?.stdin === null || this.mux?.stdin === void 0) return;
|
|
149715
151389
|
this.stdinGate.wrote(this.mux.stdin.write(annexB));
|
|
149716
151390
|
}
|
|
151391
|
+
feedAudio(adts) {
|
|
151392
|
+
const audioIn = this.audioIn;
|
|
151393
|
+
if (this.cut !== null || audioIn === null) return;
|
|
151394
|
+
this.audioStdinGate.wrote(audioIn.write(adts));
|
|
151395
|
+
}
|
|
149717
151396
|
/** ffmpeg's output, to the peer, honouring `write()`'s return (D447). */
|
|
149718
151397
|
onMuxOut(chunk) {
|
|
149719
151398
|
if (this.cut !== null) return;
|
|
149720
151399
|
const { sink } = this.input;
|
|
149721
151400
|
if (!this.begun) {
|
|
149722
151401
|
this.begun = true;
|
|
149723
|
-
sink.begin();
|
|
151402
|
+
sink.begin(clipStreamContentTypeFor(this.audioMuxed));
|
|
149724
151403
|
}
|
|
149725
151404
|
const accepted = sink.write(chunk);
|
|
149726
151405
|
this.bytesOut += chunk.byteLength;
|
|
@@ -149746,6 +151425,7 @@ var ClipStreamRun = class {
|
|
|
149746
151425
|
detail
|
|
149747
151426
|
};
|
|
149748
151427
|
this.stdinGate.dispose();
|
|
151428
|
+
this.audioStdinGate.dispose();
|
|
149749
151429
|
this.peerGate.dispose();
|
|
149750
151430
|
this.mux?.kill("SIGKILL");
|
|
149751
151431
|
this.deps.logger.warn("videoclips: a clip stream was cut", {
|
|
@@ -149757,7 +151437,10 @@ var ClipStreamRun = class {
|
|
|
149757
151437
|
detail,
|
|
149758
151438
|
accessUnits: this.accessUnits,
|
|
149759
151439
|
bytesOut: this.bytesOut,
|
|
149760
|
-
begun: this.begun
|
|
151440
|
+
begun: this.begun,
|
|
151441
|
+
audioMuxed: this.audioMuxed,
|
|
151442
|
+
audioFramesIn: this.audioFrames,
|
|
151443
|
+
audioFramesDropped: this.audioFramesDropped
|
|
149761
151444
|
}
|
|
149762
151445
|
});
|
|
149763
151446
|
if (this.begun) this.input.sink.destroy();
|
|
@@ -149803,6 +151486,7 @@ var ClipStreamRun = class {
|
|
|
149803
151486
|
detail: "no mux after the transfer ended"
|
|
149804
151487
|
});
|
|
149805
151488
|
mux.stdin?.end();
|
|
151489
|
+
this.audioIn?.end();
|
|
149806
151490
|
const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
|
|
149807
151491
|
const code = await this.muxClosed;
|
|
149808
151492
|
clearTimeout(flush);
|
|
@@ -149828,11 +151512,13 @@ var ClipStreamRun = class {
|
|
|
149828
151512
|
decodedSeconds: durationSeconds,
|
|
149829
151513
|
...row.inProgress === true ? { inProgress: true } : {}
|
|
149830
151514
|
});
|
|
151515
|
+
const audio = this.audioReport();
|
|
149831
151516
|
const meta = {
|
|
149832
151517
|
clipKey: row.clipKey,
|
|
149833
151518
|
twin,
|
|
149834
151519
|
attempt: this.attempt,
|
|
149835
151520
|
accessUnits: this.accessUnits,
|
|
151521
|
+
audioMuxed: audio.muxed,
|
|
149836
151522
|
bytesOut: this.bytesOut,
|
|
149837
151523
|
durationSeconds,
|
|
149838
151524
|
expectedMs: row.endMs - row.startMs,
|
|
@@ -149864,19 +151550,11 @@ var ClipStreamRun = class {
|
|
|
149864
151550
|
branch: "stream-served"
|
|
149865
151551
|
}
|
|
149866
151552
|
});
|
|
149867
|
-
|
|
149868
|
-
tags: this.tags,
|
|
149869
|
-
meta: {
|
|
149870
|
-
clipKey: row.clipKey,
|
|
149871
|
-
twin,
|
|
149872
|
-
branch: "stream-video-only",
|
|
149873
|
-
audioFramesDropped: this.audioFrames
|
|
149874
|
-
}
|
|
149875
|
-
});
|
|
151553
|
+
this.reportAudio(audio);
|
|
149876
151554
|
return {
|
|
149877
151555
|
kind: "served",
|
|
149878
151556
|
accessUnits: this.accessUnits,
|
|
149879
|
-
|
|
151557
|
+
audio,
|
|
149880
151558
|
bytesOut: this.bytesOut,
|
|
149881
151559
|
durationSeconds,
|
|
149882
151560
|
verdict,
|
|
@@ -149884,15 +151562,269 @@ var ClipStreamRun = class {
|
|
|
149884
151562
|
attempts: this.attempt
|
|
149885
151563
|
};
|
|
149886
151564
|
}
|
|
151565
|
+
audioReport() {
|
|
151566
|
+
return {
|
|
151567
|
+
asked: this.input.audio,
|
|
151568
|
+
muxed: this.audioMuxed,
|
|
151569
|
+
reason: this.audioSilentReason,
|
|
151570
|
+
framesIn: this.audioFrames,
|
|
151571
|
+
framesDropped: this.audioFramesDropped,
|
|
151572
|
+
framesAfterMux: this.audioFramesAfterMux
|
|
151573
|
+
};
|
|
151574
|
+
}
|
|
151575
|
+
/**
|
|
151576
|
+
* Say what became of the sound, on EVERY served stream — including the one
|
|
151577
|
+
* that carried it.
|
|
151578
|
+
*
|
|
151579
|
+
* A branch that accepts work and produces nothing is the branch that has to
|
|
151580
|
+
* speak (D391), and silence is exactly what that looks like here. But a
|
|
151581
|
+
* stream that DID carry audio says so too, with the frame count, because
|
|
151582
|
+
* "the clip was silent" and "the clip had sound and you did not hear it"
|
|
151583
|
+
* are answered from this line and nowhere else.
|
|
151584
|
+
*/
|
|
151585
|
+
reportAudio(audio) {
|
|
151586
|
+
const { row, twin } = this.input;
|
|
151587
|
+
const meta = {
|
|
151588
|
+
clipKey: row.clipKey,
|
|
151589
|
+
twin,
|
|
151590
|
+
audioAsked: audio.asked,
|
|
151591
|
+
audioMuxed: audio.muxed,
|
|
151592
|
+
audioFramesIn: audio.framesIn,
|
|
151593
|
+
audioFramesDropped: audio.framesDropped,
|
|
151594
|
+
audioFramesAfterMux: audio.framesAfterMux
|
|
151595
|
+
};
|
|
151596
|
+
if (audio.muxed !== null) {
|
|
151597
|
+
this.deps.logger.info("videoclips: a clip stream carried sound", {
|
|
151598
|
+
tags: this.tags,
|
|
151599
|
+
meta: {
|
|
151600
|
+
...meta,
|
|
151601
|
+
branch: "stream-with-audio"
|
|
151602
|
+
}
|
|
151603
|
+
});
|
|
151604
|
+
return;
|
|
151605
|
+
}
|
|
151606
|
+
if (audio.framesAfterMux > 0) {
|
|
151607
|
+
this.deps.logger.warn("videoclips: a clip stream dropped sound that arrived too late", {
|
|
151608
|
+
tags: this.tags,
|
|
151609
|
+
meta: {
|
|
151610
|
+
...meta,
|
|
151611
|
+
branch: "stream-audio-after-mux"
|
|
151612
|
+
}
|
|
151613
|
+
});
|
|
151614
|
+
return;
|
|
151615
|
+
}
|
|
151616
|
+
if (audio.asked === "none") {
|
|
151617
|
+
this.deps.logger.info("videoclips: a clip stream was served without sound", {
|
|
151618
|
+
tags: this.tags,
|
|
151619
|
+
meta: {
|
|
151620
|
+
...meta,
|
|
151621
|
+
branch: "stream-video-only"
|
|
151622
|
+
}
|
|
151623
|
+
});
|
|
151624
|
+
return;
|
|
151625
|
+
}
|
|
151626
|
+
this.deps.logger.warn("videoclips: a clip stream was asked for sound and had none", {
|
|
151627
|
+
tags: this.tags,
|
|
151628
|
+
meta: {
|
|
151629
|
+
...meta,
|
|
151630
|
+
branch: `stream-silent:${audio.reason ?? "unknown"}`
|
|
151631
|
+
}
|
|
151632
|
+
});
|
|
151633
|
+
}
|
|
149887
151634
|
};
|
|
149888
151635
|
function describe(err) {
|
|
149889
151636
|
return err instanceof Error ? err.message : String(err);
|
|
149890
151637
|
}
|
|
151638
|
+
function pathToken(req) {
|
|
151639
|
+
const raw = req.url ?? "/";
|
|
151640
|
+
const query = raw.indexOf("?");
|
|
151641
|
+
return (query === -1 ? raw : raw.slice(0, query)).replace(/^\/+/, "");
|
|
151642
|
+
}
|
|
151643
|
+
function createClipStreamDial(deps) {
|
|
151644
|
+
const now = deps.now ?? (() => Date.now());
|
|
151645
|
+
const ttlMs = deps.ttlMs ?? 3e4;
|
|
151646
|
+
const maxOutstanding = deps.maxOutstanding ?? 64;
|
|
151647
|
+
const host = deps.host ?? "127.0.0.1";
|
|
151648
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
151649
|
+
/** Tokens already dialled, kept for their TTL so a replay is named `dial-used`, not `dial-unknown`. */
|
|
151650
|
+
const used = /* @__PURE__ */ new Map();
|
|
151651
|
+
let server = null;
|
|
151652
|
+
let baseUrl = null;
|
|
151653
|
+
let listening = null;
|
|
151654
|
+
const sweep = () => {
|
|
151655
|
+
const at = now();
|
|
151656
|
+
for (const [token, minted] of tokens) if (minted.expiresAtMs <= at) tokens.delete(token);
|
|
151657
|
+
for (const [token, expiresAtMs] of used) if (expiresAtMs <= at) used.delete(token);
|
|
151658
|
+
};
|
|
151659
|
+
const resolveTarget = (req) => {
|
|
151660
|
+
const token = pathToken(req);
|
|
151661
|
+
const minted = tokens.get(token);
|
|
151662
|
+
if (minted === void 0) {
|
|
151663
|
+
if (used.get(token) !== void 0) return {
|
|
151664
|
+
kind: "refused",
|
|
151665
|
+
status: 410,
|
|
151666
|
+
code: "dial-used",
|
|
151667
|
+
detail: "this dial was already taken"
|
|
151668
|
+
};
|
|
151669
|
+
return {
|
|
151670
|
+
kind: "refused",
|
|
151671
|
+
status: 404,
|
|
151672
|
+
code: "dial-unknown",
|
|
151673
|
+
detail: "no such dial"
|
|
151674
|
+
};
|
|
151675
|
+
}
|
|
151676
|
+
if (minted.expiresAtMs <= now()) {
|
|
151677
|
+
tokens.delete(token);
|
|
151678
|
+
return {
|
|
151679
|
+
kind: "refused",
|
|
151680
|
+
status: 410,
|
|
151681
|
+
code: "dial-expired",
|
|
151682
|
+
detail: "this dial has expired"
|
|
151683
|
+
};
|
|
151684
|
+
}
|
|
151685
|
+
if (req.method === "GET") {
|
|
151686
|
+
tokens.delete(token);
|
|
151687
|
+
used.set(token, minted.expiresAtMs);
|
|
151688
|
+
deps.logger.info("videoclips: a clip stream dial was taken", {
|
|
151689
|
+
tags: { deviceId: minted.target.deviceId },
|
|
151690
|
+
meta: {
|
|
151691
|
+
clipKey: minted.target.clipKey,
|
|
151692
|
+
twin: minted.target.twin,
|
|
151693
|
+
audio: minted.target.audio,
|
|
151694
|
+
outstanding: tokens.size
|
|
151695
|
+
}
|
|
151696
|
+
});
|
|
151697
|
+
}
|
|
151698
|
+
return {
|
|
151699
|
+
kind: "target",
|
|
151700
|
+
target: minted.target
|
|
151701
|
+
};
|
|
151702
|
+
};
|
|
151703
|
+
const handler = createClipStreamHandler({
|
|
151704
|
+
stream: deps.stream,
|
|
151705
|
+
logger: deps.logger,
|
|
151706
|
+
target: resolveTarget
|
|
151707
|
+
});
|
|
151708
|
+
const listen = () => {
|
|
151709
|
+
if (listening !== null) return listening;
|
|
151710
|
+
listening = new Promise((resolve, reject) => {
|
|
151711
|
+
const created = (0, node_http.createServer)((req, res) => {
|
|
151712
|
+
handler(req, res).catch((err) => {
|
|
151713
|
+
deps.logger.warn("videoclips: the clip stream dial handler threw", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
151714
|
+
if (!res.headersSent) res.writeHead(500);
|
|
151715
|
+
res.end();
|
|
151716
|
+
});
|
|
151717
|
+
});
|
|
151718
|
+
created.on("error", (err) => {
|
|
151719
|
+
listening = null;
|
|
151720
|
+
reject(err);
|
|
151721
|
+
});
|
|
151722
|
+
created.listen(0, host, () => {
|
|
151723
|
+
const address = created.address();
|
|
151724
|
+
if (address === null || typeof address === "string") {
|
|
151725
|
+
created.close();
|
|
151726
|
+
listening = null;
|
|
151727
|
+
reject(/* @__PURE__ */ new Error("the dial listener bound no TCP port"));
|
|
151728
|
+
return;
|
|
151729
|
+
}
|
|
151730
|
+
server = created;
|
|
151731
|
+
baseUrl = `http://${host}:${address.port}`;
|
|
151732
|
+
deps.logger.info("videoclips: clip stream dial listener bound", { meta: {
|
|
151733
|
+
host,
|
|
151734
|
+
port: address.port,
|
|
151735
|
+
ttlMs,
|
|
151736
|
+
maxOutstanding
|
|
151737
|
+
} });
|
|
151738
|
+
resolve(baseUrl);
|
|
151739
|
+
});
|
|
151740
|
+
});
|
|
151741
|
+
return listening;
|
|
151742
|
+
};
|
|
151743
|
+
return {
|
|
151744
|
+
get outstanding() {
|
|
151745
|
+
sweep();
|
|
151746
|
+
return tokens.size;
|
|
151747
|
+
},
|
|
151748
|
+
mint: async (input) => {
|
|
151749
|
+
sweep();
|
|
151750
|
+
if (tokens.size >= maxOutstanding) {
|
|
151751
|
+
deps.logger.warn("videoclips: clip stream dial refused — too many outstanding", {
|
|
151752
|
+
tags: { deviceId: input.deviceId },
|
|
151753
|
+
meta: {
|
|
151754
|
+
clipKey: input.clipKey,
|
|
151755
|
+
twin: input.twin,
|
|
151756
|
+
outstanding: tokens.size,
|
|
151757
|
+
maxOutstanding
|
|
151758
|
+
}
|
|
151759
|
+
});
|
|
151760
|
+
return {
|
|
151761
|
+
kind: "refused",
|
|
151762
|
+
code: "dial-exhausted",
|
|
151763
|
+
detail: `${tokens.size} dials outstanding against a bound of ${maxOutstanding}`
|
|
151764
|
+
};
|
|
151765
|
+
}
|
|
151766
|
+
let base;
|
|
151767
|
+
try {
|
|
151768
|
+
base = baseUrl ?? await listen();
|
|
151769
|
+
} catch (err) {
|
|
151770
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
151771
|
+
deps.logger.warn("videoclips: clip stream dial refused — no listener", {
|
|
151772
|
+
tags: { deviceId: input.deviceId },
|
|
151773
|
+
meta: {
|
|
151774
|
+
clipKey: input.clipKey,
|
|
151775
|
+
twin: input.twin,
|
|
151776
|
+
error: detail
|
|
151777
|
+
}
|
|
151778
|
+
});
|
|
151779
|
+
return {
|
|
151780
|
+
kind: "refused",
|
|
151781
|
+
code: "listener-failed",
|
|
151782
|
+
detail
|
|
151783
|
+
};
|
|
151784
|
+
}
|
|
151785
|
+
const token = (0, node_crypto.randomBytes)(24).toString("base64url");
|
|
151786
|
+
const expiresAtMs = now() + ttlMs;
|
|
151787
|
+
tokens.set(token, {
|
|
151788
|
+
target: input,
|
|
151789
|
+
expiresAtMs
|
|
151790
|
+
});
|
|
151791
|
+
deps.logger.info("videoclips: clip stream dial minted", {
|
|
151792
|
+
tags: { deviceId: input.deviceId },
|
|
151793
|
+
meta: {
|
|
151794
|
+
clipKey: input.clipKey,
|
|
151795
|
+
twin: input.twin,
|
|
151796
|
+
audio: input.audio,
|
|
151797
|
+
expiresInMs: ttlMs,
|
|
151798
|
+
outstanding: tokens.size
|
|
151799
|
+
}
|
|
151800
|
+
});
|
|
151801
|
+
return {
|
|
151802
|
+
kind: "ticket",
|
|
151803
|
+
url: `${base}/${token}`,
|
|
151804
|
+
expiresAtMs
|
|
151805
|
+
};
|
|
151806
|
+
},
|
|
151807
|
+
dispose: async () => {
|
|
151808
|
+
tokens.clear();
|
|
151809
|
+
used.clear();
|
|
151810
|
+
const open = server;
|
|
151811
|
+
server = null;
|
|
151812
|
+
baseUrl = null;
|
|
151813
|
+
listening = null;
|
|
151814
|
+
if (open === null) return;
|
|
151815
|
+
await new Promise((resolve) => {
|
|
151816
|
+
open.close(() => resolve());
|
|
151817
|
+
open.closeAllConnections();
|
|
151818
|
+
});
|
|
151819
|
+
deps.logger.info("videoclips: clip stream dial listener closed", {});
|
|
151820
|
+
}
|
|
151821
|
+
};
|
|
151822
|
+
}
|
|
149891
151823
|
//#endregion
|
|
149892
151824
|
//#region src/clips/clip-stream-route.ts
|
|
149893
151825
|
function createClipStreamRoute(deps) {
|
|
149894
|
-
return async (
|
|
149895
|
-
const twin =
|
|
151826
|
+
return async (target, sink) => {
|
|
151827
|
+
const { deviceId, clipKey, twin, audio } = target;
|
|
149896
151828
|
const gate = await deps.gate({
|
|
149897
151829
|
deviceId,
|
|
149898
151830
|
clipKey,
|
|
@@ -149935,11 +151867,28 @@ function createClipStreamRoute(deps) {
|
|
|
149935
151867
|
detail: "clip-in-progress: the camera is still writing this file; it has no end to stream to"
|
|
149936
151868
|
};
|
|
149937
151869
|
}
|
|
151870
|
+
const fileName = twin === "main" ? row.mainFileName ?? "" : row.subFileName;
|
|
151871
|
+
if (fileName === "") {
|
|
151872
|
+
deps.logger.warn("videoclips: no byte handle for the twin this clip stream names", {
|
|
151873
|
+
tags: { deviceId },
|
|
151874
|
+
meta: {
|
|
151875
|
+
clipKey,
|
|
151876
|
+
twin,
|
|
151877
|
+
branch: "no-catalog-row"
|
|
151878
|
+
}
|
|
151879
|
+
});
|
|
151880
|
+
return {
|
|
151881
|
+
kind: "refused",
|
|
151882
|
+
code: "no-catalog-row",
|
|
151883
|
+
detail: `no-catalog-row: this clip is listed with no ${twin} file behind it`
|
|
151884
|
+
};
|
|
151885
|
+
}
|
|
149938
151886
|
const outcome = await deps.producer.stream({
|
|
149939
151887
|
device: gate.device,
|
|
149940
151888
|
row,
|
|
149941
|
-
fileName
|
|
151889
|
+
fileName,
|
|
149942
151890
|
twin,
|
|
151891
|
+
audio,
|
|
149943
151892
|
sink
|
|
149944
151893
|
});
|
|
149945
151894
|
switch (outcome.kind) {
|
|
@@ -150185,6 +152134,76 @@ function listableClips(rows, window) {
|
|
|
150185
152134
|
inProgress: inWindow.length - kept.length
|
|
150186
152135
|
};
|
|
150187
152136
|
}
|
|
152137
|
+
function pairTwins(subs, mains) {
|
|
152138
|
+
const datedSubs = subs.filter((f) => f.startTime instanceof Date);
|
|
152139
|
+
const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
|
|
152140
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
152141
|
+
const pairs = [];
|
|
152142
|
+
for (const sub of datedSubs.toSorted((a, b) => startOf(a) - startOf(b))) {
|
|
152143
|
+
const subStart = startOf(sub);
|
|
152144
|
+
let bestIndex = -1;
|
|
152145
|
+
let bestDelta = Number.POSITIVE_INFINITY;
|
|
152146
|
+
for (const [index, candidate] of datedMains.entries()) {
|
|
152147
|
+
if (claimed.has(index)) continue;
|
|
152148
|
+
const delta = Math.abs(startOf(candidate) - subStart);
|
|
152149
|
+
if (delta > 3e3) continue;
|
|
152150
|
+
if (delta < bestDelta) {
|
|
152151
|
+
bestDelta = delta;
|
|
152152
|
+
bestIndex = index;
|
|
152153
|
+
}
|
|
152154
|
+
}
|
|
152155
|
+
if (bestIndex === -1) {
|
|
152156
|
+
pairs.push({ sub });
|
|
152157
|
+
continue;
|
|
152158
|
+
}
|
|
152159
|
+
claimed.add(bestIndex);
|
|
152160
|
+
const main = datedMains[bestIndex];
|
|
152161
|
+
pairs.push(main === void 0 ? { sub } : {
|
|
152162
|
+
sub,
|
|
152163
|
+
main
|
|
152164
|
+
});
|
|
152165
|
+
}
|
|
152166
|
+
return pairs.toSorted((a, b) => startOf(b.sub) - startOf(a.sub));
|
|
152167
|
+
}
|
|
152168
|
+
function startOf(file) {
|
|
152169
|
+
return file.startTime?.getTime() ?? 0;
|
|
152170
|
+
}
|
|
152171
|
+
/**
|
|
152172
|
+
* The ONE place that turns a requested profile into a twin.
|
|
152173
|
+
*
|
|
152174
|
+
* Three call sites used to decide this independently — `getClipPlayback`,
|
|
152175
|
+
* `readClipBytes` and the stream dial — and they did not agree: the dial
|
|
152176
|
+
* REFUSED `high` outright while the other two served the sub copy with a
|
|
152177
|
+
* warning. Same question, three answers, and the one that refused made `high`
|
|
152178
|
+
* look unsupported rather than unavailable for that recording.
|
|
152179
|
+
*
|
|
152180
|
+
* The mapping is the profile ladder the rest of the system already uses:
|
|
152181
|
+
* `high` reaches for the main twin, everything else is the sub. A `high` with
|
|
152182
|
+
* no main twin degrades to the sub and SAYS SO through `served` and
|
|
152183
|
+
* `downgraded` — "there is no high-quality copy" is an answer (`pairTwins`
|
|
152184
|
+
* models it as `main: undefined`), and a silent substitution is what D576
|
|
152185
|
+
* forbids. Refusing was the other way of getting it wrong.
|
|
152186
|
+
*
|
|
152187
|
+
* An ABSENT profile is not `high`: a caller that stated no preference gets the
|
|
152188
|
+
* cheap copy, which is what all three call sites did before this function
|
|
152189
|
+
* existed (`profile === 'high'` on `undefined` is false).
|
|
152190
|
+
*/
|
|
152191
|
+
function chooseClipTwin(profile, row) {
|
|
152192
|
+
const wantsMain = profile === "high";
|
|
152193
|
+
const mainFileName = wantsMain ? row.mainFileName : void 0;
|
|
152194
|
+
if (mainFileName !== void 0) return {
|
|
152195
|
+
twin: "main",
|
|
152196
|
+
fileName: mainFileName,
|
|
152197
|
+
served: "high",
|
|
152198
|
+
downgraded: false
|
|
152199
|
+
};
|
|
152200
|
+
return {
|
|
152201
|
+
twin: "sub",
|
|
152202
|
+
fileName: row.subFileName,
|
|
152203
|
+
served: "low",
|
|
152204
|
+
downgraded: wantsMain
|
|
152205
|
+
};
|
|
152206
|
+
}
|
|
150188
152207
|
//#endregion
|
|
150189
152208
|
//#region src/clips/clip-identity.ts
|
|
150190
152209
|
/**
|
|
@@ -150390,40 +152409,6 @@ function rankOf(label) {
|
|
|
150390
152409
|
const at = CLASS_RANK.indexOf(label);
|
|
150391
152410
|
return at === -1 ? CLASS_RANK.length : at;
|
|
150392
152411
|
}
|
|
150393
|
-
function pairTwins(subs, mains) {
|
|
150394
|
-
const datedSubs = subs.filter((f) => f.startTime instanceof Date);
|
|
150395
|
-
const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
|
|
150396
|
-
const claimed = /* @__PURE__ */ new Set();
|
|
150397
|
-
const pairs = [];
|
|
150398
|
-
for (const sub of datedSubs.toSorted((a, b) => startOf(a) - startOf(b))) {
|
|
150399
|
-
const subStart = startOf(sub);
|
|
150400
|
-
let bestIndex = -1;
|
|
150401
|
-
let bestDelta = Number.POSITIVE_INFINITY;
|
|
150402
|
-
for (const [index, candidate] of datedMains.entries()) {
|
|
150403
|
-
if (claimed.has(index)) continue;
|
|
150404
|
-
const delta = Math.abs(startOf(candidate) - subStart);
|
|
150405
|
-
if (delta > 3e3) continue;
|
|
150406
|
-
if (delta < bestDelta) {
|
|
150407
|
-
bestDelta = delta;
|
|
150408
|
-
bestIndex = index;
|
|
150409
|
-
}
|
|
150410
|
-
}
|
|
150411
|
-
if (bestIndex === -1) {
|
|
150412
|
-
pairs.push({ sub });
|
|
150413
|
-
continue;
|
|
150414
|
-
}
|
|
150415
|
-
claimed.add(bestIndex);
|
|
150416
|
-
const main = datedMains[bestIndex];
|
|
150417
|
-
pairs.push(main === void 0 ? { sub } : {
|
|
150418
|
-
sub,
|
|
150419
|
-
main
|
|
150420
|
-
});
|
|
150421
|
-
}
|
|
150422
|
-
return pairs.toSorted((a, b) => startOf(b.sub) - startOf(a.sub));
|
|
150423
|
-
}
|
|
150424
|
-
function startOf(file) {
|
|
150425
|
-
return file.startTime?.getTime() ?? 0;
|
|
150426
|
-
}
|
|
150427
152412
|
var partsCache = /* @__PURE__ */ new Map();
|
|
150428
152413
|
function formatterFor$1(timeZone) {
|
|
150429
152414
|
const cached = partsCache.get(timeZone);
|
|
@@ -151125,14 +153110,17 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
151125
153110
|
if (device === null) throw new Error(`videoclips: device ${deviceId} is not a Reolink camera`);
|
|
151126
153111
|
if (device.isBattery && device.sleeping && !device.isHubChild) throw new Error(`videoclips: device ${deviceId} is a standalone battery camera and is sleeping — fetching a clip would wake it. Use "Wake and refresh" first.`);
|
|
151127
153112
|
const row = rowsByKey.get(`${deviceId}|${clipKeyFor(parsed.channel, parsed.fileName)}`) ?? null;
|
|
151128
|
-
const
|
|
151129
|
-
|
|
153113
|
+
const chosen = row === null ? null : chooseClipTwin(profile, {
|
|
153114
|
+
subFileName: row.subFileName,
|
|
153115
|
+
...row.mainFileName !== void 0 ? { mainFileName: row.mainFileName } : {}
|
|
153116
|
+
});
|
|
153117
|
+
const fileName = chosen === null || chosen.fileName === "" ? parsed.fileName : chosen.fileName;
|
|
151130
153118
|
const prepared = await deps.preparePlayback({
|
|
151131
153119
|
device,
|
|
151132
153120
|
row,
|
|
151133
153121
|
fileName,
|
|
151134
153122
|
clipKey: clipKeyFor(parsed.channel, parsed.fileName),
|
|
151135
|
-
profile:
|
|
153123
|
+
profile: chosen?.served ?? "low"
|
|
151136
153124
|
});
|
|
151137
153125
|
return {
|
|
151138
153126
|
playbackUrl: prepared.playbackUrl,
|
|
@@ -151193,8 +153181,8 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
151193
153181
|
const row = rowsByKey.get(`${deviceId}|${clipKey}`);
|
|
151194
153182
|
if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${deviceId} — list the day that holds it first`, { clipKey });
|
|
151195
153183
|
if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written, so it has no end to export`, { clipKey });
|
|
151196
|
-
const
|
|
151197
|
-
if (
|
|
153184
|
+
const chosen = chooseClipTwin(profile, row);
|
|
153185
|
+
if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — serving the sub copy", {
|
|
151198
153186
|
tags,
|
|
151199
153187
|
meta: {
|
|
151200
153188
|
clipId,
|
|
@@ -151203,13 +153191,13 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
151203
153191
|
asked: profile
|
|
151204
153192
|
}
|
|
151205
153193
|
});
|
|
151206
|
-
const twin =
|
|
151207
|
-
const fileName =
|
|
153194
|
+
const twin = chosen.twin;
|
|
153195
|
+
const fileName = chosen.fileName;
|
|
151208
153196
|
if (fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
|
|
151209
153197
|
clipKey,
|
|
151210
153198
|
twin
|
|
151211
153199
|
});
|
|
151212
|
-
const served =
|
|
153200
|
+
const served = chosen.served;
|
|
151213
153201
|
const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
|
|
151214
153202
|
const declaredBytes = twin === "main" ? row.mainBytes : row.subBytes;
|
|
151215
153203
|
if (declaredBytes !== void 0 && declaredBytes > bound) return refuse("too-large-to-transfer", `the camera lists the ${twin} file of clip "${clipId}" as ${declaredBytes} bytes, over the ${bound}-byte bound for an inline read — play it from its playback URL instead`, {
|
|
@@ -151253,6 +153241,75 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
151253
153241
|
served,
|
|
151254
153242
|
...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
|
|
151255
153243
|
};
|
|
153244
|
+
},
|
|
153245
|
+
/**
|
|
153246
|
+
* Where this clip's STREAM is dialled (D597) — the same questions
|
|
153247
|
+
* `readClipBytes` asks before the camera is touched (the id, the row, a
|
|
153248
|
+
* file still being written), the same vocabulary, and NO fetch: the URL
|
|
153249
|
+
* is one shot on the provider's own listener and the camera is opened
|
|
153250
|
+
* when the broker dials it.
|
|
153251
|
+
*
|
|
153252
|
+
* It serves EVERY profile, through the same {@link chooseClipTwin} the
|
|
153253
|
+
* byte read asks (D599). It used to refuse `high` by name, on the grounds
|
|
153254
|
+
* that handing a sub copy to a `high` ask is the silent 33× substitution
|
|
153255
|
+
* D576 forbids — true, but the refusal was not the cure. The cure is
|
|
153256
|
+
* `served`, which is on this answer and states which twin the stream
|
|
153257
|
+
* actually carries. Refusing left the operator with `high` looking
|
|
153258
|
+
* unsupported, and left two transports behind one pointer.
|
|
153259
|
+
*/
|
|
153260
|
+
dialClipStream: async ({ deviceId, clipId, profile, audio }) => {
|
|
153261
|
+
const tags = { deviceId };
|
|
153262
|
+
const refuse = (code, detail, meta) => {
|
|
153263
|
+
deps.logger.warn("videoclips: refusing a clip stream dial", {
|
|
153264
|
+
tags,
|
|
153265
|
+
meta: {
|
|
153266
|
+
clipId,
|
|
153267
|
+
branch: code,
|
|
153268
|
+
...meta
|
|
153269
|
+
}
|
|
153270
|
+
});
|
|
153271
|
+
throw new Error(clipExportFailure(code, detail));
|
|
153272
|
+
};
|
|
153273
|
+
const parsed = parseClipId(clipId);
|
|
153274
|
+
if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Reolink provider`, {});
|
|
153275
|
+
const clipKey = clipKeyFor(parsed.channel, parsed.fileName);
|
|
153276
|
+
const row = rowsByKey.get(`${deviceId}|${clipKey}`);
|
|
153277
|
+
if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${deviceId} — list the day that holds it first`, { clipKey });
|
|
153278
|
+
if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written; it has no end to stream to`, { clipKey });
|
|
153279
|
+
const chosen = chooseClipTwin(profile, row);
|
|
153280
|
+
if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — streaming the sub copy", {
|
|
153281
|
+
tags,
|
|
153282
|
+
meta: {
|
|
153283
|
+
clipId,
|
|
153284
|
+
clipKey,
|
|
153285
|
+
branch: "no-main-twin",
|
|
153286
|
+
asked: profile
|
|
153287
|
+
}
|
|
153288
|
+
});
|
|
153289
|
+
if (chosen.fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
|
|
153290
|
+
clipKey,
|
|
153291
|
+
twin: chosen.twin
|
|
153292
|
+
});
|
|
153293
|
+
const askedAudio = audio ?? "none";
|
|
153294
|
+
const audioReason = askedAudio === "none" ? "not-requested" : null;
|
|
153295
|
+
const outcome = await deps.prepareStreamDial({
|
|
153296
|
+
deviceId,
|
|
153297
|
+
row,
|
|
153298
|
+
twin: chosen.twin,
|
|
153299
|
+
audio: askedAudio
|
|
153300
|
+
});
|
|
153301
|
+
if (outcome.kind === "refused") return refuse(outcome.code, outcome.detail, {
|
|
153302
|
+
clipKey,
|
|
153303
|
+
twin: chosen.twin
|
|
153304
|
+
});
|
|
153305
|
+
return {
|
|
153306
|
+
url: outcome.url,
|
|
153307
|
+
expiresAtMs: outcome.expiresAtMs,
|
|
153308
|
+
transport: "forward-only",
|
|
153309
|
+
served: chosen.served,
|
|
153310
|
+
servedAudio: askedAudio,
|
|
153311
|
+
...audioReason !== null ? { audioReason } : {}
|
|
153312
|
+
};
|
|
151256
153313
|
}
|
|
151257
153314
|
};
|
|
151258
153315
|
/**
|
|
@@ -152166,12 +154223,23 @@ function createClipService(deps) {
|
|
|
152166
154223
|
producer: createClipStreamProducer({
|
|
152167
154224
|
logger: deps.logger,
|
|
152168
154225
|
spawnMux: (args) => (0, node_child_process.spawn)(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
|
|
154226
|
+
"pipe",
|
|
152169
154227
|
"pipe",
|
|
152170
154228
|
"pipe",
|
|
152171
154229
|
"pipe"
|
|
152172
154230
|
] })
|
|
152173
154231
|
})
|
|
152174
154232
|
});
|
|
154233
|
+
/**
|
|
154234
|
+
* The stream's DIAL (D597): the same route behind a one-shot token on a
|
|
154235
|
+
* loopback listener, because the broker holds no credential the data
|
|
154236
|
+
* plane or the hub proxy would take. See `clip-stream-dial.ts`.
|
|
154237
|
+
*/
|
|
154238
|
+
const streamDial = createClipStreamDial({
|
|
154239
|
+
logger: deps.logger,
|
|
154240
|
+
stream: streamClip,
|
|
154241
|
+
now
|
|
154242
|
+
});
|
|
152175
154243
|
const provider = createReolinkVideoclipsProvider({
|
|
152176
154244
|
now,
|
|
152177
154245
|
cache,
|
|
@@ -152184,11 +154252,41 @@ function createClipService(deps) {
|
|
|
152184
154252
|
served: profile === "high" ? "high" : "low"
|
|
152185
154253
|
}),
|
|
152186
154254
|
prepareBytes,
|
|
154255
|
+
prepareStreamDial: async ({ deviceId, row, twin, audio }) => {
|
|
154256
|
+
const gate = await gateClipDevice({
|
|
154257
|
+
deviceId,
|
|
154258
|
+
clipKey: row.clipKey,
|
|
154259
|
+
twin,
|
|
154260
|
+
path: "stream"
|
|
154261
|
+
});
|
|
154262
|
+
if (gate.kind === "refused") return {
|
|
154263
|
+
kind: "refused",
|
|
154264
|
+
code: gate.code,
|
|
154265
|
+
detail: gate.detail
|
|
154266
|
+
};
|
|
154267
|
+
const minted = await streamDial.mint({
|
|
154268
|
+
deviceId,
|
|
154269
|
+
clipKey: row.clipKey,
|
|
154270
|
+
twin,
|
|
154271
|
+
audio
|
|
154272
|
+
});
|
|
154273
|
+
if (minted.kind === "refused") return {
|
|
154274
|
+
kind: "refused",
|
|
154275
|
+
code: "fetch-failed",
|
|
154276
|
+
detail: `${minted.code}: ${minted.detail}`
|
|
154277
|
+
};
|
|
154278
|
+
return {
|
|
154279
|
+
kind: "ok",
|
|
154280
|
+
url: minted.url,
|
|
154281
|
+
expiresAtMs: minted.expiresAtMs
|
|
154282
|
+
};
|
|
154283
|
+
},
|
|
152187
154284
|
logger: deps.logger
|
|
152188
154285
|
});
|
|
152189
154286
|
return {
|
|
152190
154287
|
provider,
|
|
152191
154288
|
cache,
|
|
154289
|
+
disposeClipStreamDial: () => streamDial.dispose(),
|
|
152192
154290
|
refreshCatalog: async (deviceId) => {
|
|
152193
154291
|
const until = now();
|
|
152194
154292
|
await provider.listClips({
|
|
@@ -166224,6 +168322,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
166224
168322
|
this.clipRefreshTimer = null;
|
|
166225
168323
|
}
|
|
166226
168324
|
this.logChannels?.stop();
|
|
168325
|
+
await this.clipService?.disposeClipStreamDial().catch(() => {});
|
|
166227
168326
|
await this.emailPushServer?.stop().catch(() => {});
|
|
166228
168327
|
await this.autodetectCache.dispose();
|
|
166229
168328
|
}
|