@camstack/addon-provider-hikvision 1.2.124 → 1.2.126
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 +1235 -68
- package/dist/addon.mjs +1236 -69
- package/package.json +1 -1
package/dist/addon.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { createReadStream } from "node:fs";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
@@ -5373,7 +5373,7 @@ var ZodIssueCode = {
|
|
|
5373
5373
|
var ZodFirstPartyTypeKind;
|
|
5374
5374
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5375
5375
|
//#endregion
|
|
5376
|
-
//#region ../types/dist/sleep-
|
|
5376
|
+
//#region ../types/dist/sleep-COWaSCAi.mjs
|
|
5377
5377
|
/**
|
|
5378
5378
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5379
5379
|
* window to float samples (D455).
|
|
@@ -7543,6 +7543,24 @@ object({
|
|
|
7543
7543
|
unreachable: number()
|
|
7544
7544
|
})
|
|
7545
7545
|
});
|
|
7546
|
+
/** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
|
|
7547
|
+
var PeerBytesTicketSchema = object({
|
|
7548
|
+
/** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
|
|
7549
|
+
url: string().min(1),
|
|
7550
|
+
/**
|
|
7551
|
+
* The HOST node this URL means something on — the hub or a named agent,
|
|
7552
|
+
* never a runner. {@link AddonPeerBytes.open} compares it to its own and
|
|
7553
|
+
* refuses `cross-node` by name when they differ, without dialling.
|
|
7554
|
+
*/
|
|
7555
|
+
hostNodeId: string().min(1),
|
|
7556
|
+
expiresAtMs: number().int().nonnegative(),
|
|
7557
|
+
/**
|
|
7558
|
+
* What the producer DECLARED the body to be, when it knows — `null` when it
|
|
7559
|
+
* does not. Never `0` for unknown (D393): a consumer sizing a bound off this
|
|
7560
|
+
* must be able to tell "the producer did not say" from "the body is empty".
|
|
7561
|
+
*/
|
|
7562
|
+
declaredBytes: number().int().nonnegative().nullable()
|
|
7563
|
+
});
|
|
7546
7564
|
/**
|
|
7547
7565
|
* Adoption job — the background form of `device-adoption.adopt`.
|
|
7548
7566
|
*
|
|
@@ -25016,6 +25034,7 @@ _enum([
|
|
|
25016
25034
|
"sleeping",
|
|
25017
25035
|
"camera-refused",
|
|
25018
25036
|
"no-keyframe",
|
|
25037
|
+
"decode-failed",
|
|
25019
25038
|
"no-catalog-row",
|
|
25020
25039
|
"unsupported",
|
|
25021
25040
|
"unknown-device",
|
|
@@ -25024,8 +25043,43 @@ _enum([
|
|
|
25024
25043
|
_enum(["deferred", "final"]);
|
|
25025
25044
|
/** The camera's / provider's own words. Kept verbatim, never parsed. */
|
|
25026
25045
|
var CLIP_STILL_REASON_HEADER = "x-camstack-reason";
|
|
25046
|
+
/**
|
|
25047
|
+
* Make a refusal sentence safe to put in an HTTP header.
|
|
25048
|
+
*
|
|
25049
|
+
* **An HTTP header value is Latin-1.** Node throws `Invalid character in
|
|
25050
|
+
* header content` on anything above U+00FF, the handler dies mid-response,
|
|
25051
|
+
* and the client gets a socket hang up — no status, no reason, nothing. The
|
|
25052
|
+
* mechanism that exists to EXPLAIN a refusal is then the thing that silences
|
|
25053
|
+
* it, and the surface shows "nothing happened".
|
|
25054
|
+
*
|
|
25055
|
+
* That is not hypothetical: measured 2026-09-23 on 1436, a Hikvision replay
|
|
25056
|
+
* refusal read `SETUP of the video track answered 500 — the camera refused
|
|
25057
|
+
* this replay`, and the EM DASH in it threw on every clip the operator
|
|
25058
|
+
* opened. This repository writes its refusals in prose, and its prose uses
|
|
25059
|
+
* `—`, `’` and `…` everywhere, so every reason string is a candidate.
|
|
25060
|
+
*
|
|
25061
|
+
* Substitutes rather than strips: an operator reading a log or a response
|
|
25062
|
+
* should get a readable sentence, not one with holes in it. The typographic
|
|
25063
|
+
* characters this repo actually writes have exact ASCII equivalents; anything
|
|
25064
|
+
* else non-Latin-1 becomes `?`, which is visible rather than silent. Control
|
|
25065
|
+
* characters go too — a newline in a header value is a response-splitting
|
|
25066
|
+
* bug, not a formatting one.
|
|
25067
|
+
*/
|
|
25068
|
+
function clipReasonHeaderValue(reason) {
|
|
25069
|
+
const folded = reason.replace(/[\u2013\u2014\u2212]/g, "-").replace(/[\u2018\u2019\u201b]/g, "'").replace(/[\u201c\u201d]/g, "\"").replace(/\u2026/g, "...").replace(/[\u00a0\u2007\u202f]/g, " ");
|
|
25070
|
+
let out = "";
|
|
25071
|
+
for (const ch of folded) {
|
|
25072
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
25073
|
+
if (code < 32 || code === 127) out += " ";
|
|
25074
|
+
else if (code > 255) out += "?";
|
|
25075
|
+
else out += ch;
|
|
25076
|
+
}
|
|
25077
|
+
return out.slice(0, 500).trim();
|
|
25078
|
+
}
|
|
25027
25079
|
/** One {@link ClipStillRefusalCodeSchema} token, alone. */
|
|
25028
25080
|
var CLIP_STILL_REASON_CODE_HEADER = "x-camstack-reason-code";
|
|
25081
|
+
/** One {@link ClipStillDispositionSchema} token, alone. */
|
|
25082
|
+
var CLIP_STILL_DISPOSITION_HEADER = "x-camstack-disposition";
|
|
25029
25083
|
/**
|
|
25030
25084
|
* The clip STREAM route's own statement of what it is (D597): the value is
|
|
25031
25085
|
* `forward-only`. A producer writes it on the 200; a consumer that dials a
|
|
@@ -25284,12 +25338,37 @@ var ClipWakeSchema = _enum(["authorised"]);
|
|
|
25284
25338
|
* the size in the message, never truncates. Half a video is worse than an
|
|
25285
25339
|
* honest refusal.
|
|
25286
25340
|
*
|
|
25287
|
-
*
|
|
25288
|
-
*
|
|
25289
|
-
*
|
|
25341
|
+
* **Superseded as the way a clip export gets its bytes** (D613). The bound is
|
|
25342
|
+
* a property of the ENVELOPE, not of clips, so the cure was a transport with
|
|
25343
|
+
* no envelope: {@link videoclipsCapability.methods.offerClipBytes} hands back
|
|
25344
|
+
* a one-shot `peerBytes` ticket and the consumer streams it straight to disk,
|
|
25345
|
+
* holding nothing. This method stays for a consumer that genuinely wants the
|
|
25346
|
+
* bytes in hand, and for a provider not yet redeployed — with this bound,
|
|
25347
|
+
* which for an inline read is not going to move.
|
|
25290
25348
|
*/
|
|
25291
25349
|
var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
|
|
25292
25350
|
/**
|
|
25351
|
+
* Hard ceiling on ONE {@link videoclipsCapability.methods.offerClipBytes}
|
|
25352
|
+
* transfer — ten times {@link VIDEOCLIPS_MAX_READ_BYTES}, and the reasoning is
|
|
25353
|
+
* not "ten times more comfortable".
|
|
25354
|
+
*
|
|
25355
|
+
* The inline bound is set by what is HELD: a base64 envelope is the payload at
|
|
25356
|
+
* ~1.33× in the provider AND the same again in the caller, so 50 MiB of clip
|
|
25357
|
+
* is ~133 MiB of heap across two processes. Over `peerBytes` the consumer
|
|
25358
|
+
* holds one socket chunk at a time and writes straight through to the export
|
|
25359
|
+
* file, so its contribution is flat whatever the clip weighs. What is left is
|
|
25360
|
+
* the PRODUCER's own copy, and that is a property of how each provider makes a
|
|
25361
|
+
* clip rather than of this transport: Hikvision's fetch writes to a scratch
|
|
25362
|
+
* FILE and offers it (nothing is held), Reolink's cmd-5 transfer is collected
|
|
25363
|
+
* in memory and offered from there (one copy, the one it already had).
|
|
25364
|
+
*
|
|
25365
|
+
* So this number bounds the worst provider, not the transport, and it is named
|
|
25366
|
+
* separately so that lifting it is a statement about a provider rather than
|
|
25367
|
+
* about clips. Above it the provider REFUSES with the size in the message and
|
|
25368
|
+
* never truncates: half a video is worse than an honest refusal.
|
|
25369
|
+
*/
|
|
25370
|
+
var VIDEOCLIPS_MAX_OFFER_BYTES = 500 * 1024 * 1024;
|
|
25371
|
+
/**
|
|
25293
25372
|
* A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
|
|
25294
25373
|
*
|
|
25295
25374
|
* `bytes` is the DECODED length, so nobody infers it from the base64 length,
|
|
@@ -25321,6 +25400,32 @@ var ClipBytesSchema = object({
|
|
|
25321
25400
|
durationMs: number().positive().optional()
|
|
25322
25401
|
});
|
|
25323
25402
|
/**
|
|
25403
|
+
* Where a clip's finished bytes can be TAKEN (D613) — the answer to
|
|
25404
|
+
* {@link videoclipsCapability.methods.offerClipBytes}.
|
|
25405
|
+
*
|
|
25406
|
+
* Everything {@link ClipBytesSchema} carries except the bytes themselves, plus
|
|
25407
|
+
* the one-shot ticket that leads to them. The metadata is answered BEFORE the
|
|
25408
|
+
* transfer on purpose: a consumer learns which twin it got, what to call the
|
|
25409
|
+
* file and how long the clip runs without having to read a byte, so a decision
|
|
25410
|
+
* it would make on that metadata (a wrong twin, an implausible duration) costs
|
|
25411
|
+
* no transfer at all.
|
|
25412
|
+
*/
|
|
25413
|
+
var ClipBytesOfferSchema = object({
|
|
25414
|
+
/**
|
|
25415
|
+
* One shot, seconds-long, loopback, on the PROVIDER's own host. Open it with
|
|
25416
|
+
* `ctx.peerBytes.open(...)`, which refuses a ticket from another node by
|
|
25417
|
+
* name rather than dialling a port that means something else here.
|
|
25418
|
+
*/
|
|
25419
|
+
ticket: PeerBytesTicketSchema,
|
|
25420
|
+
contentType: string(),
|
|
25421
|
+
/** Suggested filename, extension included. */
|
|
25422
|
+
name: string(),
|
|
25423
|
+
/** Which twin was actually served — see {@link ClipBytesSchema.served}. */
|
|
25424
|
+
served: CamProfileSchema,
|
|
25425
|
+
/** See {@link ClipBytesSchema.durationMs}. Absent when nothing measured it. */
|
|
25426
|
+
durationMs: number().positive().optional()
|
|
25427
|
+
});
|
|
25428
|
+
/**
|
|
25324
25429
|
* Where a clip's STREAM can be dialled (D597) — the answer to
|
|
25325
25430
|
* {@link videoclipsCapability.methods.dialClipStream}.
|
|
25326
25431
|
*
|
|
@@ -25396,6 +25501,105 @@ var ClipStreamDialSchema = object({
|
|
|
25396
25501
|
/** Why `servedAudio` is `none` although sound was asked for. */
|
|
25397
25502
|
audioReason: ClipStreamAudioReasonSchema.optional()
|
|
25398
25503
|
});
|
|
25504
|
+
/**
|
|
25505
|
+
* The playback rates a clip can be DELIVERED at, ascending, always with `1`.
|
|
25506
|
+
*
|
|
25507
|
+
* These are the BROKER's: it re-paces frames it has already demuxed — the
|
|
25508
|
+
* `MonotonicClock` divides source elapsed time by the factor and the pacer
|
|
25509
|
+
* pushes that much faster — so the domain is the recorded one, {0} ∪ [0.25, 4],
|
|
25510
|
+
* and this is the discrete ladder drawn from it. `1.5` is in it because a
|
|
25511
|
+
* re-pacer has no reason to refuse it.
|
|
25512
|
+
*
|
|
25513
|
+
* `8` and `16` are deliberately NOT here. They are the CAMERA's own
|
|
25514
|
+
* `<playSpeed>` (Reolink cmd-5 replay), a different mechanism, still unwired
|
|
25515
|
+
* (D597, D600) — a rate change there costs a new dial and a new stream, and
|
|
25516
|
+
* the camera's 8x would need a resample the clip audio path has no decoder
|
|
25517
|
+
* for. Offering them today accepts a rate and delivers 1x, which is the whole
|
|
25518
|
+
* defect this list exists to end (D612). When `playSpeed` IS wired its rates
|
|
25519
|
+
* join THIS array — a second list elsewhere is the second authority D62
|
|
25520
|
+
* forbids.
|
|
25521
|
+
*
|
|
25522
|
+
* Every entry must survive the broker's `clampPlaybackRate` unchanged: a set
|
|
25523
|
+
* that offers what the clamp then moves is the same lie one step later.
|
|
25524
|
+
*/
|
|
25525
|
+
var CLIP_BROKER_PACED_RATES = [
|
|
25526
|
+
.25,
|
|
25527
|
+
.5,
|
|
25528
|
+
1,
|
|
25529
|
+
1.5,
|
|
25530
|
+
2,
|
|
25531
|
+
4
|
|
25532
|
+
];
|
|
25533
|
+
/**
|
|
25534
|
+
* The rates a REALTIME-BOUND stream can be delivered at.
|
|
25535
|
+
*
|
|
25536
|
+
* **Measured, and it is the whole reason this second ladder exists.** A
|
|
25537
|
+
* Hikvision RTSP replay is not a fast fetch: it delivers the recording at the
|
|
25538
|
+
* speed it was recorded. On 1436, 2026-09-23, one stream carried 176 access
|
|
25539
|
+
* units in 14 274 ms of wall clock at a measured 12.5 fps — 14.08 s of media
|
|
25540
|
+
* in 14.27 s, **1.01× realtime** — and a whole 18 s clip took 22.3 s to fetch
|
|
25541
|
+
* end to end. Reolink's cmd-5 transfer, which {@link CLIP_BROKER_PACED_RATES}
|
|
25542
|
+
* was written for, lands the same bytes at 49–62× and therefore has the entire
|
|
25543
|
+
* clip in hand within the first second.
|
|
25544
|
+
*
|
|
25545
|
+
* The broker's pacer consumes `rate` seconds of media per second of wall
|
|
25546
|
+
* clock while the socket supplies one. So at any rate above 1 the buffer
|
|
25547
|
+
* drains at `(rate − 1)×` and a stream that is only seconds old runs dry
|
|
25548
|
+
* almost at once — the viewer stops, the queue empties, and the provider's
|
|
25549
|
+
* own drain gate eventually reports `peer-stalled`. Nothing about that is a
|
|
25550
|
+
* bug to be fixed with a bigger buffer: there is no buffer, because the bytes
|
|
25551
|
+
* do not exist yet.
|
|
25552
|
+
*
|
|
25553
|
+
* So the honest answer is the DECLARATION (D612's own rule, applied to the
|
|
25554
|
+
* case it did not yet have): a rate this transport cannot deliver is never
|
|
25555
|
+
* offered. Below 1 is free — a slower pacer only lets the buffer grow.
|
|
25556
|
+
*
|
|
25557
|
+
* When a clip is served from a FILE the constraint is gone with the transport,
|
|
25558
|
+
* which is why `file` keeps the full ladder.
|
|
25559
|
+
*/
|
|
25560
|
+
var CLIP_REALTIME_SOURCE_RATES = [
|
|
25561
|
+
.25,
|
|
25562
|
+
.5,
|
|
25563
|
+
1
|
|
25564
|
+
];
|
|
25565
|
+
/**
|
|
25566
|
+
* What a surface may DRAW for this provider's clips — the answer to
|
|
25567
|
+
* {@link videoclipsCapability.methods.getPlaybackOptions} (D612).
|
|
25568
|
+
*
|
|
25569
|
+
* The envelope is a PROVIDER fact, not a clip fact, and that is measured, not
|
|
25570
|
+
* assumed: the broker's `chooseClipPath` reads exactly two inputs — whether
|
|
25571
|
+
* `dialClipStream` and `readClipBytes` are wired — and both are constants of
|
|
25572
|
+
* the broker's own closure over the provider's methods. The `profile` it is
|
|
25573
|
+
* handed is explicitly not read. So every clip of a provider is served the
|
|
25574
|
+
* same way, and a per-clip channel carried a value that could not vary. The
|
|
25575
|
+
* per-clip `clipTransport` server message was removed for exactly that reason.
|
|
25576
|
+
*
|
|
25577
|
+
* Queried per camera, before a clip is picked, so a control is rendered or
|
|
25578
|
+
* DISABLED rather than offered and refused at play time (D62: a disabled
|
|
25579
|
+
* control reads as unavailable, one that undoes the gesture reads as broken).
|
|
25580
|
+
*/
|
|
25581
|
+
var ClipPlaybackOptionsSchema = object({
|
|
25582
|
+
/**
|
|
25583
|
+
* How this provider's clips reach the player. `stream` is the provider's
|
|
25584
|
+
* forward-only fMP4 (D597); `file` is one bounded by-handle fetch of the
|
|
25585
|
+
* whole clip, `stbl` indexed (D575).
|
|
25586
|
+
*/
|
|
25587
|
+
transport: _enum(["stream", "file"]),
|
|
25588
|
+
/** `forward` = only ahead of the playhead. `free` = anywhere. */
|
|
25589
|
+
seek: _enum(["forward", "free"]),
|
|
25590
|
+
/** Frame-step BACKWARD is meaningful. Forward always is. */
|
|
25591
|
+
stepBack: boolean(),
|
|
25592
|
+
/** Whether the scrub gesture is served, as opposed to refused by name. */
|
|
25593
|
+
scrub: boolean(),
|
|
25594
|
+
/**
|
|
25595
|
+
* The rates that can be delivered, ascending, always containing `1`. The
|
|
25596
|
+
* viewer draws its picker from this and from nothing else — a constant it
|
|
25597
|
+
* keeps instead is the second authority that produced the defect: `8` and
|
|
25598
|
+
* `16` were offered, the broker clamped them to `4`, and no line anywhere
|
|
25599
|
+
* said so. `0` is not a member: pause is the absence of a rate.
|
|
25600
|
+
*/
|
|
25601
|
+
rates: array(number().positive()).min(1).readonly()
|
|
25602
|
+
});
|
|
25399
25603
|
var ClipSourceAvailabilitySchema = object({
|
|
25400
25604
|
state: _enum([
|
|
25401
25605
|
"ok",
|
|
@@ -25574,14 +25778,18 @@ var videoclipsCapability = {
|
|
|
25574
25778
|
*
|
|
25575
25779
|
* `getClipPlayback` is the right answer for a player: it hands back a URL
|
|
25576
25780
|
* on a plane the hub serves `access:'authenticated'`, which a browser and a
|
|
25577
|
-
* viewer session satisfy. It is the wrong answer for another ADDON
|
|
25578
|
-
*
|
|
25579
|
-
*
|
|
25580
|
-
*
|
|
25581
|
-
*
|
|
25582
|
-
*
|
|
25583
|
-
*
|
|
25584
|
-
*
|
|
25781
|
+
* viewer session satisfy. It is the wrong answer for another ADDON: the
|
|
25782
|
+
* hub's proxy in front of that plane takes only a user credential, which
|
|
25783
|
+
* an addon does not hold, so a recorder that wants a camera's clip cannot
|
|
25784
|
+
* fetch that URL. This method is the shape that answer forced — the same
|
|
25785
|
+
* one (and the same bound) as `recordingExport.readExportBytes`.
|
|
25786
|
+
*
|
|
25787
|
+
* **It is no longer the only seam.** Until D613 there was no addon→addon
|
|
25788
|
+
* byte transport at all; there is now
|
|
25789
|
+
* ({@link offerClipBytes}, over `ctx.peerBytes`), it holds nothing on
|
|
25790
|
+
* either side, and it is what a clip EXPORT uses. This method remains for
|
|
25791
|
+
* a consumer that genuinely wants the bytes in hand, and as the named
|
|
25792
|
+
* fallback for a provider not yet redeployed.
|
|
25585
25793
|
*
|
|
25586
25794
|
* Routing needs no `provider` pin: the id is source-prefixed and
|
|
25587
25795
|
* self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
|
|
@@ -25648,6 +25856,65 @@ var videoclipsCapability = {
|
|
|
25648
25856
|
auth: "protected"
|
|
25649
25857
|
}),
|
|
25650
25858
|
/**
|
|
25859
|
+
* Where this clip's finished bytes can be TAKEN — the by-handle read a
|
|
25860
|
+
* clip EXPORT pulls, over the addon→addon byte transport (D613).
|
|
25861
|
+
*
|
|
25862
|
+
* This is {@link readClipBytes} with the envelope removed. Same gates,
|
|
25863
|
+
* same vocabulary, same completion rules, same `served` contract — the
|
|
25864
|
+
* only difference is that the bytes travel over a one-shot loopback
|
|
25865
|
+
* socket instead of inside a base64 field, so neither side holds the
|
|
25866
|
+
* payload whole and the 50 MiB refusal on a long `high` twin stops
|
|
25867
|
+
* existing. The bound that remains is
|
|
25868
|
+
* {@link VIDEOCLIPS_MAX_OFFER_BYTES}, and it bounds the PRODUCER's own
|
|
25869
|
+
* copy rather than the transport.
|
|
25870
|
+
*
|
|
25871
|
+
* **The ticket is loopback and same-host.** A provider on an agent mints a
|
|
25872
|
+
* URL that means nothing on the hub, and `ctx.peerBytes.open` refuses it
|
|
25873
|
+
* `cross-node` by name rather than dialling whatever else holds that port
|
|
25874
|
+
* here. A consumer that can be on the other side of a node boundary from
|
|
25875
|
+
* its provider must be able to read that refusal and say so; it must not
|
|
25876
|
+
* treat it as "no bytes".
|
|
25877
|
+
*
|
|
25878
|
+
* **A ticket is a one-shot bearer credential with a seconds-long life.**
|
|
25879
|
+
* Take it immediately, never persist it, never log its `url`. An untaken
|
|
25880
|
+
* ticket costs the provider one map entry until its TTL, and outstanding
|
|
25881
|
+
* tickets are bounded — which is also what makes a per-frame misuse of
|
|
25882
|
+
* this method refuse by name rather than work slowly (D9/D18: this is a
|
|
25883
|
+
* by-handle fetch of finished media, not a frame pipe).
|
|
25884
|
+
*
|
|
25885
|
+
* Optional on the provider for the same reason `readClipBytes` is: a
|
|
25886
|
+
* source with no camera socket behind it has no bytes. A provider that
|
|
25887
|
+
* predates this method answers `NOT_IMPLEMENTED`, and a consumer may fall
|
|
25888
|
+
* back to `readClipBytes` — but it says so in the log, with the deploy
|
|
25889
|
+
* hint, because that fallback re-imposes the 50 MiB refusal and an
|
|
25890
|
+
* operator who sees `too-large-to-transfer` after this shipped is looking
|
|
25891
|
+
* at a stale addon, not at a clip that cannot be exported.
|
|
25892
|
+
*/
|
|
25893
|
+
offerClipBytes: optionalMethod(object({
|
|
25894
|
+
deviceId: number(),
|
|
25895
|
+
clipId: string().min(1),
|
|
25896
|
+
/** WHICH provider holds the bytes — see `readClipBytes.provider`. */
|
|
25897
|
+
provider: string().min(1),
|
|
25898
|
+
/** Which twin — `low | mid` → the sub file, `high` → the main twin. */
|
|
25899
|
+
profile: CamProfileSchema.optional(),
|
|
25900
|
+
/**
|
|
25901
|
+
* The CALLER's byte bound, so an over-size clip is refused before the
|
|
25902
|
+
* camera is touched rather than after. Capped by
|
|
25903
|
+
* {@link VIDEOCLIPS_MAX_OFFER_BYTES} whatever is passed; absent means
|
|
25904
|
+
* that ceiling.
|
|
25905
|
+
*/
|
|
25906
|
+
maxBytes: number().int().positive().optional(),
|
|
25907
|
+
/**
|
|
25908
|
+
* The operator's authorisation to wake a sleeping camera for this
|
|
25909
|
+
* read. Absent — the default — means a sleeping standalone battery
|
|
25910
|
+
* camera is REFUSED by name, before any session is opened.
|
|
25911
|
+
*/
|
|
25912
|
+
wake: ClipWakeSchema.optional()
|
|
25913
|
+
}), ClipBytesOfferSchema, {
|
|
25914
|
+
kind: "query",
|
|
25915
|
+
auth: "protected"
|
|
25916
|
+
}),
|
|
25917
|
+
/**
|
|
25651
25918
|
* Where this clip's STREAM can be dialled (D597) — the forward-only
|
|
25652
25919
|
* fMP4 the provider writes from the first muxed byte, for the broker to
|
|
25653
25920
|
* play through the same WebRTC session as recorded footage, with the
|
|
@@ -25684,10 +25951,88 @@ var videoclipsCapability = {
|
|
|
25684
25951
|
}), ClipStreamDialSchema, {
|
|
25685
25952
|
kind: "query",
|
|
25686
25953
|
auth: "protected"
|
|
25954
|
+
}),
|
|
25955
|
+
/**
|
|
25956
|
+
* What a surface may DRAW for this provider's clips: the rates it can be
|
|
25957
|
+
* played at, whether scrub is served, how far a position may be moved,
|
|
25958
|
+
* and whether a backward frame-step means anything (D612).
|
|
25959
|
+
*
|
|
25960
|
+
* **The only authority.** The per-clip `clipTransport` server message
|
|
25961
|
+
* that used to carry the same answer was removed: the broker's transport
|
|
25962
|
+
* choice reads nothing that varies per clip, so the clip level had no
|
|
25963
|
+
* information the provider does not already have, and two channels that
|
|
25964
|
+
* can disagree are worse than one (D62).
|
|
25965
|
+
*
|
|
25966
|
+
* Asked per camera and per provider, so it must stay CHEAP — it is a
|
|
25967
|
+
* statement about wiring, answered from a constant, never a call to the
|
|
25968
|
+
* camera. A provider answers with one of {@link CLIP_PLAYBACK_OPTIONS}
|
|
25969
|
+
* and never composes an envelope of its own.
|
|
25970
|
+
*
|
|
25971
|
+
* Optional, and absence is load-bearing: a provider that has not answered
|
|
25972
|
+
* has not restricted anything, and a viewer reads it as the freedom it
|
|
25973
|
+
* always had. See D612 on the rollout order that absence implies.
|
|
25974
|
+
*/
|
|
25975
|
+
getPlaybackOptions: optionalMethod(object({
|
|
25976
|
+
deviceId: number(),
|
|
25977
|
+
/** WHICH provider to ask — the `addonId` a {@link ClipSourceSchema}
|
|
25978
|
+
* row carries. Required for the same reason `listClips` requires it:
|
|
25979
|
+
* a collection cap has no "the bound one" to resolve to (D554). */
|
|
25980
|
+
provider: string().min(1)
|
|
25981
|
+
}), ClipPlaybackOptionsSchema, {
|
|
25982
|
+
kind: "query",
|
|
25983
|
+
auth: "protected"
|
|
25687
25984
|
})
|
|
25688
25985
|
}
|
|
25689
25986
|
};
|
|
25690
25987
|
/**
|
|
25988
|
+
* NOTE ON PLACEMENT: this block lives AFTER the capability definition on
|
|
25989
|
+
* purpose. `scripts/lib/parse-cap.ts` finds a cap by the FIRST
|
|
25990
|
+
* `export const <X> = {` in the file, so an object literal declared above
|
|
25991
|
+
* `videoclipsCapability` is silently taken for the capability itself and
|
|
25992
|
+
* codegen emits a `DeviceProxy` entry that does not type-check. Found the
|
|
25993
|
+
* hard way, 2026-09-23.
|
|
25994
|
+
*/
|
|
25995
|
+
/**
|
|
25996
|
+
* The two envelopes, spelled ONCE.
|
|
25997
|
+
*
|
|
25998
|
+
* A provider answers with the entry for the transport its own wiring buys —
|
|
25999
|
+
* `stream` if it implements `dialClipStream`, `file` if it implements only
|
|
26000
|
+
* `readClipBytes` — and never composes one of its own. One table is what
|
|
26001
|
+
* makes "the provider may not promise what no clip can be given" structural
|
|
26002
|
+
* instead of a discipline: there is nothing else to promise.
|
|
26003
|
+
*/
|
|
26004
|
+
var CLIP_PLAYBACK_OPTIONS = {
|
|
26005
|
+
stream: {
|
|
26006
|
+
transport: "stream",
|
|
26007
|
+
seek: "forward",
|
|
26008
|
+
stepBack: false,
|
|
26009
|
+
scrub: false,
|
|
26010
|
+
rates: CLIP_BROKER_PACED_RATES
|
|
26011
|
+
},
|
|
26012
|
+
/**
|
|
26013
|
+
* A `stream` whose SOURCE runs at realtime — a Hikvision RTSP replay.
|
|
26014
|
+
*
|
|
26015
|
+
* Same verbs as `stream`, a shorter rate ladder, and the difference is a
|
|
26016
|
+
* measurement rather than a preference: see
|
|
26017
|
+
* {@link CLIP_REALTIME_SOURCE_RATES}. A third entry rather than a parameter,
|
|
26018
|
+
* because a provider must still be able to do nothing but NAME one of these.
|
|
26019
|
+
*/
|
|
26020
|
+
realtimeStream: {
|
|
26021
|
+
transport: "stream",
|
|
26022
|
+
seek: "forward",
|
|
26023
|
+
stepBack: false,
|
|
26024
|
+
scrub: false,
|
|
26025
|
+
rates: CLIP_REALTIME_SOURCE_RATES
|
|
26026
|
+
},
|
|
26027
|
+
file: {
|
|
26028
|
+
transport: "file",
|
|
26029
|
+
seek: "free",
|
|
26030
|
+
stepBack: true,
|
|
26031
|
+
scrub: true,
|
|
26032
|
+
rates: CLIP_BROKER_PACED_RATES
|
|
26033
|
+
}
|
|
26034
|
+
};
|
|
26035
|
+
/**
|
|
25691
26036
|
* Optional client-side hints sent at session creation to help the provider
|
|
25692
26037
|
* pick the best native source. All fields optional — a viewer that knows
|
|
25693
26038
|
* nothing still gets a sane default. (Relocated from the retired `webrtc`
|
|
@@ -46949,6 +47294,12 @@ Object.freeze({
|
|
|
46949
47294
|
addonId: null,
|
|
46950
47295
|
access: "view"
|
|
46951
47296
|
},
|
|
47297
|
+
"videoclips.getPlaybackOptions": {
|
|
47298
|
+
capName: "videoclips",
|
|
47299
|
+
capScope: "device",
|
|
47300
|
+
addonId: null,
|
|
47301
|
+
access: "view"
|
|
47302
|
+
},
|
|
46952
47303
|
"videoclips.listClips": {
|
|
46953
47304
|
capName: "videoclips",
|
|
46954
47305
|
capScope: "device",
|
|
@@ -46961,6 +47312,12 @@ Object.freeze({
|
|
|
46961
47312
|
addonId: null,
|
|
46962
47313
|
access: "view"
|
|
46963
47314
|
},
|
|
47315
|
+
"videoclips.offerClipBytes": {
|
|
47316
|
+
capName: "videoclips",
|
|
47317
|
+
capScope: "device",
|
|
47318
|
+
addonId: null,
|
|
47319
|
+
access: "view"
|
|
47320
|
+
},
|
|
46964
47321
|
"videoclips.readClipBytes": {
|
|
46965
47322
|
capName: "videoclips",
|
|
46966
47323
|
capScope: "device",
|
|
@@ -49007,6 +49364,11 @@ Object.freeze({
|
|
|
49007
49364
|
form: "single",
|
|
49008
49365
|
optional: false
|
|
49009
49366
|
}],
|
|
49367
|
+
"videoclips.getPlaybackOptions": [{
|
|
49368
|
+
name: "deviceId",
|
|
49369
|
+
form: "single",
|
|
49370
|
+
optional: false
|
|
49371
|
+
}],
|
|
49010
49372
|
"videoclips.listClips": [{
|
|
49011
49373
|
name: "deviceId",
|
|
49012
49374
|
form: "single",
|
|
@@ -49017,6 +49379,11 @@ Object.freeze({
|
|
|
49017
49379
|
form: "single",
|
|
49018
49380
|
optional: false
|
|
49019
49381
|
}],
|
|
49382
|
+
"videoclips.offerClipBytes": [{
|
|
49383
|
+
name: "deviceId",
|
|
49384
|
+
form: "single",
|
|
49385
|
+
optional: false
|
|
49386
|
+
}],
|
|
49020
49387
|
"videoclips.readClipBytes": [{
|
|
49021
49388
|
name: "deviceId",
|
|
49022
49389
|
form: "single",
|
|
@@ -49680,24 +50047,73 @@ DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
|
|
|
49680
50047
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
49681
50048
|
var MB = 1024 * 1024;
|
|
49682
50049
|
1024 * MB, 3072 * MB;
|
|
50050
|
+
/**
|
|
50051
|
+
* Per-device bounds for the STILLS, which are a different artifact entirely.
|
|
50052
|
+
*
|
|
50053
|
+
* A still is ~30 KB against a clip's 40 MB, and it is the thing an operator
|
|
50054
|
+
* scrolls past sixty of. It therefore outlives the media it was cut from: the
|
|
50055
|
+
* clip scratch holds ten segments and rolls, and a camera whose stills lived
|
|
50056
|
+
* in the same directory would lose the picture of every clip but the last ten.
|
|
50057
|
+
* Two thousand files and 64 MiB — the same pair the Reolink store uses, and
|
|
50058
|
+
* the byte bound still governs.
|
|
50059
|
+
*/
|
|
50060
|
+
var THUMB_MAX_FILES_PER_DEVICE = 2e3;
|
|
50061
|
+
var THUMB_MAX_BYTES_PER_DEVICE = 64 * 1024 * 1024;
|
|
49683
50062
|
/** A clip key is a hex digest. Anything else is refused rather than joined. */
|
|
49684
50063
|
var SAFE_KEY = /^[A-Za-z0-9_-]{1,128}$/;
|
|
49685
|
-
var
|
|
49686
|
-
var
|
|
50064
|
+
var CLIP_THUMB_KIND = "clip-thumbs";
|
|
50065
|
+
var CLIP_THUMB_EXTENSION = ".jpg";
|
|
49687
50066
|
var ClipArtifactStore = class {
|
|
49688
50067
|
options;
|
|
49689
50068
|
maxFiles;
|
|
49690
50069
|
maxBytes;
|
|
50070
|
+
kind;
|
|
50071
|
+
extension;
|
|
49691
50072
|
constructor(options) {
|
|
49692
50073
|
this.options = options;
|
|
49693
50074
|
this.maxFiles = options.maxFilesPerDevice ?? 10;
|
|
49694
50075
|
this.maxBytes = options.maxBytesPerDevice ?? 209715200;
|
|
50076
|
+
this.kind = options.kind ?? "clip-media";
|
|
50077
|
+
this.extension = options.extension ?? ".mp4";
|
|
49695
50078
|
}
|
|
49696
50079
|
dirFor(deviceId) {
|
|
49697
|
-
return join(this.options.root,
|
|
50080
|
+
return join(this.options.root, this.kind, String(deviceId));
|
|
49698
50081
|
}
|
|
49699
50082
|
pathFor(deviceId, clipKey) {
|
|
49700
|
-
return join(this.dirFor(deviceId), `${clipKey}${
|
|
50083
|
+
return join(this.dirFor(deviceId), `${clipKey}${this.extension}`);
|
|
50084
|
+
}
|
|
50085
|
+
/**
|
|
50086
|
+
* Is there really a file here?
|
|
50087
|
+
*
|
|
50088
|
+
* THE VOUCH. A provider says `thumbnail: <url>` only when this answered
|
|
50089
|
+
* true, because that field means "a still exists" and a URL that then
|
|
50090
|
+
* answers 204 is a request every tile makes and every tile loses (D549 5).
|
|
50091
|
+
*/
|
|
50092
|
+
async has(deviceId, clipKey) {
|
|
50093
|
+
return await this.pathIfPresent(deviceId, clipKey) !== null;
|
|
50094
|
+
}
|
|
50095
|
+
/** The bytes, or `null` — never a zero-length file dressed as a still. */
|
|
50096
|
+
async read(deviceId, clipKey) {
|
|
50097
|
+
const path = await this.pathIfPresent(deviceId, clipKey);
|
|
50098
|
+
if (path === null) return null;
|
|
50099
|
+
try {
|
|
50100
|
+
return await readFile(path);
|
|
50101
|
+
} catch {
|
|
50102
|
+
return null;
|
|
50103
|
+
}
|
|
50104
|
+
}
|
|
50105
|
+
/**
|
|
50106
|
+
* Write bytes under a key, immutably, and enforce the bounds.
|
|
50107
|
+
*
|
|
50108
|
+
* Temp-then-rename, so a crash never leaves a truncated file under the real
|
|
50109
|
+
* key — and a key already present WINS, so two racing mints cost one file.
|
|
50110
|
+
*/
|
|
50111
|
+
async put(deviceId, clipKey, bytes) {
|
|
50112
|
+
const existing = await this.pathIfPresent(deviceId, clipKey);
|
|
50113
|
+
if (existing !== null) return existing;
|
|
50114
|
+
const temp = await this.reserve(deviceId, clipKey);
|
|
50115
|
+
await writeFile(temp, bytes);
|
|
50116
|
+
return await this.publish(deviceId, clipKey, temp);
|
|
49701
50117
|
}
|
|
49702
50118
|
/** A scratch path to mux INTO, with its directory made. */
|
|
49703
50119
|
async reserve(deviceId, clipKey) {
|
|
@@ -49748,7 +50164,7 @@ var ClipArtifactStore = class {
|
|
|
49748
50164
|
}
|
|
49749
50165
|
const out = [];
|
|
49750
50166
|
for (const name of names) {
|
|
49751
|
-
if (!name.endsWith(
|
|
50167
|
+
if (!name.endsWith(this.extension)) continue;
|
|
49752
50168
|
const path = join(this.dirFor(deviceId), name);
|
|
49753
50169
|
try {
|
|
49754
50170
|
const info = await stat(path);
|
|
@@ -50097,6 +50513,27 @@ async function listClipRows(client, input) {
|
|
|
50097
50513
|
};
|
|
50098
50514
|
}
|
|
50099
50515
|
//#endregion
|
|
50516
|
+
//#region src/clips/clip-still-refusal.ts
|
|
50517
|
+
/** "You are in the queue." */
|
|
50518
|
+
function deferStill(code, reason, retryAfterMs) {
|
|
50519
|
+
return {
|
|
50520
|
+
ok: false,
|
|
50521
|
+
code,
|
|
50522
|
+
disposition: "deferred",
|
|
50523
|
+
reason,
|
|
50524
|
+
retryAfterMs
|
|
50525
|
+
};
|
|
50526
|
+
}
|
|
50527
|
+
/** "This will not happen." */
|
|
50528
|
+
function refuseStill(code, reason) {
|
|
50529
|
+
return {
|
|
50530
|
+
ok: false,
|
|
50531
|
+
code,
|
|
50532
|
+
disposition: "final",
|
|
50533
|
+
reason
|
|
50534
|
+
};
|
|
50535
|
+
}
|
|
50536
|
+
//#endregion
|
|
50100
50537
|
//#region src/clips/clip-data-plane.ts
|
|
50101
50538
|
/**
|
|
50102
50539
|
* The addon's two clip routes: the finished FILE, and the STREAM.
|
|
@@ -50130,8 +50567,11 @@ async function listClipRows(client, input) {
|
|
|
50130
50567
|
/** URL namespaces under `/addon/provider-hikvision/`. */
|
|
50131
50568
|
var CLIP_MEDIA_PREFIX = "clip";
|
|
50132
50569
|
var CLIP_STREAM_PREFIX = "clip-stream";
|
|
50570
|
+
var CLIP_THUMB_PREFIX = "clip-thumb";
|
|
50133
50571
|
/** A clip key is a hex digest; nothing else may reach the filesystem. */
|
|
50134
50572
|
var MEDIA_PATH = /^\/(\d+)\/([A-Za-z0-9_-]{1,128})\.mp4$/;
|
|
50573
|
+
/** The still route carries no extension: the content type says what it is. */
|
|
50574
|
+
var THUMB_PATH = /^\/(\d+)\/([A-Za-z0-9_-]{1,128})$/;
|
|
50135
50575
|
function pathnameOf(req) {
|
|
50136
50576
|
const raw = req.url ?? "/";
|
|
50137
50577
|
const query = raw.indexOf("?");
|
|
@@ -50239,6 +50679,87 @@ function createClipPlaybackHandler(deps) {
|
|
|
50239
50679
|
}).pipe(res);
|
|
50240
50680
|
};
|
|
50241
50681
|
}
|
|
50682
|
+
/**
|
|
50683
|
+
* `/addon/provider-hikvision/clip-thumb/<deviceId>/<clipKey>`.
|
|
50684
|
+
*
|
|
50685
|
+
* 200 with the JPEG, or 204 with a reason a tile can read. Never a 404 and
|
|
50686
|
+
* never a placeholder: a still that does not exist is an ABSENCE, and it is
|
|
50687
|
+
* reported as one (D315).
|
|
50688
|
+
*/
|
|
50689
|
+
function createClipThumbHandler(deps) {
|
|
50690
|
+
return async (req, res) => {
|
|
50691
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
50692
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
50693
|
+
res.end();
|
|
50694
|
+
return;
|
|
50695
|
+
}
|
|
50696
|
+
const match = THUMB_PATH.exec(pathnameOf(req));
|
|
50697
|
+
if (match === null) {
|
|
50698
|
+
res.writeHead(400);
|
|
50699
|
+
res.end();
|
|
50700
|
+
return;
|
|
50701
|
+
}
|
|
50702
|
+
const deviceId = Number(match[1]);
|
|
50703
|
+
const clipKey = match[2] ?? "";
|
|
50704
|
+
const cached = await deps.readCached(deviceId, clipKey);
|
|
50705
|
+
if (cached !== null) {
|
|
50706
|
+
writeJpeg(res, cached, req.method === "HEAD");
|
|
50707
|
+
return;
|
|
50708
|
+
}
|
|
50709
|
+
let minted;
|
|
50710
|
+
try {
|
|
50711
|
+
minted = await deps.mint(deviceId, clipKey);
|
|
50712
|
+
} catch (err) {
|
|
50713
|
+
minted = refuseStill("camera-refused", err instanceof Error ? err.message : String(err));
|
|
50714
|
+
}
|
|
50715
|
+
if (!minted.ok) {
|
|
50716
|
+
writeStillRefusal(res, minted, deviceId, deps.logger, clipKey);
|
|
50717
|
+
return;
|
|
50718
|
+
}
|
|
50719
|
+
writeJpeg(res, minted.bytes, req.method === "HEAD");
|
|
50720
|
+
};
|
|
50721
|
+
}
|
|
50722
|
+
/**
|
|
50723
|
+
* The 204, with everything the tile needs to decide what to draw next.
|
|
50724
|
+
*
|
|
50725
|
+
* A FINAL refusal is work that was dropped, so it is warned, per camera. A
|
|
50726
|
+
* DEFERRED one dropped nothing — the still is queued behind the camera's one
|
|
50727
|
+
* replay slot — and warning it would put a line on the log per tile of a page.
|
|
50728
|
+
*/
|
|
50729
|
+
function writeStillRefusal(res, refusal, deviceId, logger, clipKey) {
|
|
50730
|
+
const line = "videoclips: thumbnail not minted";
|
|
50731
|
+
const extras = {
|
|
50732
|
+
tags: { deviceId },
|
|
50733
|
+
meta: {
|
|
50734
|
+
clipKey,
|
|
50735
|
+
code: refusal.code,
|
|
50736
|
+
disposition: refusal.disposition,
|
|
50737
|
+
reason: refusal.reason
|
|
50738
|
+
}
|
|
50739
|
+
};
|
|
50740
|
+
if (refusal.disposition === "final") logger.warn(line, extras);
|
|
50741
|
+
else logger.debug(line, extras);
|
|
50742
|
+
const retryAfterMs = refusal.disposition === "deferred" ? refusal.retryAfterMs : void 0;
|
|
50743
|
+
res.writeHead(204, {
|
|
50744
|
+
[CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(refusal.reason),
|
|
50745
|
+
[CLIP_STILL_REASON_CODE_HEADER]: refusal.code,
|
|
50746
|
+
[CLIP_STILL_DISPOSITION_HEADER]: refusal.disposition,
|
|
50747
|
+
...retryAfterMs === void 0 ? {} : { "retry-after": String(Math.max(1, Math.ceil(retryAfterMs / 1e3))) }
|
|
50748
|
+
});
|
|
50749
|
+
res.end();
|
|
50750
|
+
}
|
|
50751
|
+
function writeJpeg(res, bytes, headOnly) {
|
|
50752
|
+
res.writeHead(200, {
|
|
50753
|
+
"content-type": "image/jpeg",
|
|
50754
|
+
"content-length": String(bytes.byteLength),
|
|
50755
|
+
"cache-control": "private, max-age=31536000, immutable"
|
|
50756
|
+
});
|
|
50757
|
+
if (headOnly) {
|
|
50758
|
+
res.end();
|
|
50759
|
+
return;
|
|
50760
|
+
}
|
|
50761
|
+
res.end(bytes);
|
|
50762
|
+
}
|
|
50242
50763
|
/** The data plane's own form: the clip is named on the path. */
|
|
50243
50764
|
var resolveClipStreamPathTarget = (req) => {
|
|
50244
50765
|
const match = MEDIA_PATH.exec(pathnameOf(req));
|
|
@@ -50265,11 +50786,12 @@ var STREAM_HEADERS = {
|
|
|
50265
50786
|
"cache-control": "no-store",
|
|
50266
50787
|
[CLIP_STREAM_TRANSPORT_HEADER]: "forward-only"
|
|
50267
50788
|
};
|
|
50268
|
-
/** `404` = nothing here to serve; `502` = the camera. */
|
|
50789
|
+
/** `404` = nothing here to serve; `502` = the camera; `503` = its one slot. */
|
|
50269
50790
|
function streamRefusalStatus(code) {
|
|
50270
50791
|
switch (code) {
|
|
50271
50792
|
case "unknown-device":
|
|
50272
50793
|
case "no-catalog-row": return 404;
|
|
50794
|
+
case "replay-busy": return 503;
|
|
50273
50795
|
case "camera-refused":
|
|
50274
50796
|
case "fetch-failed": return 502;
|
|
50275
50797
|
}
|
|
@@ -50290,7 +50812,7 @@ function createClipStreamHandler(deps) {
|
|
|
50290
50812
|
status: resolved.status
|
|
50291
50813
|
} });
|
|
50292
50814
|
res.writeHead(resolved.status, {
|
|
50293
|
-
[CLIP_STILL_REASON_HEADER]: resolved.detail
|
|
50815
|
+
[CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(resolved.detail),
|
|
50294
50816
|
[CLIP_STILL_REASON_CODE_HEADER]: resolved.code
|
|
50295
50817
|
});
|
|
50296
50818
|
res.end();
|
|
@@ -50363,7 +50885,7 @@ function createClipStreamHandler(deps) {
|
|
|
50363
50885
|
}
|
|
50364
50886
|
});
|
|
50365
50887
|
res.writeHead(streamRefusalStatus(outcome.code), {
|
|
50366
|
-
[CLIP_STILL_REASON_HEADER]: outcome.detail
|
|
50888
|
+
[CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(outcome.detail),
|
|
50367
50889
|
[CLIP_STILL_REASON_CODE_HEADER]: outcome.code
|
|
50368
50890
|
});
|
|
50369
50891
|
res.end();
|
|
@@ -50372,24 +50894,36 @@ function createClipStreamHandler(deps) {
|
|
|
50372
50894
|
//#endregion
|
|
50373
50895
|
//#region src/clips/clip-replay-rate.ts
|
|
50374
50896
|
/**
|
|
50375
|
-
*
|
|
50376
|
-
*
|
|
50377
|
-
*
|
|
50378
|
-
*
|
|
50379
|
-
*
|
|
50380
|
-
*
|
|
50381
|
-
*
|
|
50382
|
-
*
|
|
50383
|
-
*
|
|
50384
|
-
|
|
50385
|
-
|
|
50386
|
-
*
|
|
50387
|
-
*
|
|
50388
|
-
*
|
|
50389
|
-
*
|
|
50390
|
-
*
|
|
50391
|
-
*
|
|
50392
|
-
|
|
50897
|
+
* Do these timestamps ALREADY say the rate, so that waiting for more cannot
|
|
50898
|
+
* change the answer?
|
|
50899
|
+
*
|
|
50900
|
+
* This is arithmetic, not a heuristic. {@link estimateReplayFps} computes
|
|
50901
|
+
* `(n - 1) × clockRate ÷ (last − first)`. If every consecutive span equals `d`
|
|
50902
|
+
* then `last − first = (n − 1) × d` and the estimate is `clockRate ÷ d` for
|
|
50903
|
+
* EVERY n — the sixteenth unit produces the same number as the fourth. So a
|
|
50904
|
+
* probe that has seen {@link REPLAY_RATE_EXACT_SPANS} identical integer spans
|
|
50905
|
+
* is not guessing when it stops; it is declining to wait 1.0 s of a camera's
|
|
50906
|
+
* own realtime replay for a number it already holds.
|
|
50907
|
+
*
|
|
50908
|
+
* **Why this matters more here than anywhere else.** A Hikvision replay
|
|
50909
|
+
* delivers at 1× (measured 2026-09-23 on 1436: 176 access units in 14.27 s of
|
|
50910
|
+
* wall clock at 12.5 fps, 1.01× realtime). Every access unit the probe holds
|
|
50911
|
+
* back is therefore 80 ms the operator waits before the first frame, and the
|
|
50912
|
+
* full sixteen measured 1301 ms of a 4316 ms time-to-first-frame. The spans on
|
|
50913
|
+
* that camera are 7200 ticks of a 90 kHz clock, every one of them.
|
|
50914
|
+
*
|
|
50915
|
+
* Integer equality, never a tolerance: a variable-rate replay differs by at
|
|
50916
|
+
* least one tick and falls through to the full window, which is the behaviour
|
|
50917
|
+
* that shipped.
|
|
50918
|
+
*/
|
|
50919
|
+
function replayFpsIsExact(timestamps) {
|
|
50920
|
+
if (timestamps.length < 4) return false;
|
|
50921
|
+
const first = timestamps[0] ?? 0;
|
|
50922
|
+
const span = (timestamps[1] ?? 0) - first;
|
|
50923
|
+
if (span <= 0) return false;
|
|
50924
|
+
for (let i = 2; i < timestamps.length; i += 1) if ((timestamps[i] ?? 0) - (timestamps[i - 1] ?? 0) !== span) return false;
|
|
50925
|
+
return true;
|
|
50926
|
+
}
|
|
50393
50927
|
function estimateReplayFps(timestamps, clockRate) {
|
|
50394
50928
|
if (timestamps.length < 2 || clockRate <= 0) return {
|
|
50395
50929
|
fps: null,
|
|
@@ -50426,6 +50960,44 @@ var CLIP_STREAM_OPUS_RATE_HZ = 48e3;
|
|
|
50426
50960
|
/** The PCMU RTP clock, and the rate the archive already is. */
|
|
50427
50961
|
var CLIP_STREAM_PCMU_RATE_HZ = 8e3;
|
|
50428
50962
|
/**
|
|
50963
|
+
* How much of an input ffmpeg may READ before it will write a header.
|
|
50964
|
+
*
|
|
50965
|
+
* ffmpeg probes every input to discover what is in it. Both defaults are
|
|
50966
|
+
* enormous for this use — `probesize` 5 MB, `analyzeduration` 5 s — and both
|
|
50967
|
+
* are paid in the operator's wait, because a Hikvision replay arrives at 1×
|
|
50968
|
+
* (measured 1.01× on 1436, 2026-09-23) so "5 MB of input" is seconds of wall
|
|
50969
|
+
* clock, not microseconds of buffer.
|
|
50970
|
+
*
|
|
50971
|
+
* **Nothing here is being discovered.** The demuxer is named (`-f hevc` /
|
|
50972
|
+
* `-f h264`, from the SDP's own `a=rtpmap`), the frame rate is named (`-r`,
|
|
50973
|
+
* measured from the replay's RTP clock and refused when it cannot be), and the
|
|
50974
|
+
* µ-law input is named down to its sample rate and channel count. A probe can
|
|
50975
|
+
* only re-derive what the caller already stated.
|
|
50976
|
+
*
|
|
50977
|
+
* Measured end to end against the real 1436 clip (18 s, 4K HEVC, 12.5 fps,
|
|
50978
|
+
* GOP 50): with the defaults the init segment reached the peer at 3889 ms and
|
|
50979
|
+
* the first media fragment at 3891 ms; with these two flags, 171 ms and
|
|
50980
|
+
* 2269 ms. 32 KiB is two orders of magnitude over the parameter sets the hevc
|
|
50981
|
+
* demuxer needs and still one one-hundred-and-fiftieth of the default.
|
|
50982
|
+
*/
|
|
50983
|
+
var CLIP_MUX_PROBE_BYTES = 32768;
|
|
50984
|
+
/**
|
|
50985
|
+
* How long a fragment may run before it is CLOSED, keyframe or not.
|
|
50986
|
+
*
|
|
50987
|
+
* `frag_keyframe` alone closes a fragment only at the next keyframe, so the
|
|
50988
|
+
* first fragment a peer can see spans a whole GOP. On 1436 the GOP is 50
|
|
50989
|
+
* frames at 12.5 fps — 4 s of media, and at 1× arrival that is 4 s of the
|
|
50990
|
+
* operator's wait for a first picture that was already decodable.
|
|
50991
|
+
*
|
|
50992
|
+
* Half a second, so a fragment closes on the frag_duration long before it
|
|
50993
|
+
* closes on a keyframe. Measured on the same clip: the first media fragment
|
|
50994
|
+
* moved from 2269 ms to 219 ms, and the whole 18 s output grew by 4 044 bytes
|
|
50995
|
+
* (0.04 %) — the cost of a `moof` every half second.
|
|
50996
|
+
*
|
|
50997
|
+
* Only the STREAM form takes it. The file form is not fragmented.
|
|
50998
|
+
*/
|
|
50999
|
+
var CLIP_STREAM_FRAG_DURATION_US = 5e5;
|
|
51000
|
+
/**
|
|
50429
51001
|
* MEASURED, not chosen: ffmpeg's MP4 muxer refuses `pcm_mulaw` and its MOV
|
|
50430
51002
|
* muxer takes it. The boxes a consumer walks are the same either way.
|
|
50431
51003
|
*/
|
|
@@ -50458,11 +51030,19 @@ function commonArgs(input) {
|
|
|
50458
51030
|
"error",
|
|
50459
51031
|
"-nostdin",
|
|
50460
51032
|
...input.fps !== null && Number.isFinite(input.fps) && input.fps > 0 ? ["-r", String(input.fps)] : [],
|
|
51033
|
+
"-analyzeduration",
|
|
51034
|
+
"0",
|
|
51035
|
+
"-probesize",
|
|
51036
|
+
String(CLIP_MUX_PROBE_BYTES),
|
|
50461
51037
|
"-f",
|
|
50462
51038
|
demuxer,
|
|
50463
51039
|
"-i",
|
|
50464
51040
|
"pipe:0",
|
|
50465
51041
|
...input.audio !== null ? [
|
|
51042
|
+
"-analyzeduration",
|
|
51043
|
+
"0",
|
|
51044
|
+
"-probesize",
|
|
51045
|
+
String(CLIP_MUX_PROBE_BYTES),
|
|
50466
51046
|
"-f",
|
|
50467
51047
|
"mulaw",
|
|
50468
51048
|
"-ar",
|
|
@@ -50489,6 +51069,10 @@ function buildClipStreamMuxArgs(input) {
|
|
|
50489
51069
|
...commonArgs(input),
|
|
50490
51070
|
"-movflags",
|
|
50491
51071
|
CLIP_STREAM_MOVFLAGS,
|
|
51072
|
+
"-frag_duration",
|
|
51073
|
+
String(CLIP_STREAM_FRAG_DURATION_US),
|
|
51074
|
+
"-flush_packets",
|
|
51075
|
+
"1",
|
|
50492
51076
|
"-f",
|
|
50493
51077
|
clipStreamContainerFor(input.audio),
|
|
50494
51078
|
"pipe:1"
|
|
@@ -51108,6 +51692,25 @@ function encodeRtspRequest(request) {
|
|
|
51108
51692
|
return Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1");
|
|
51109
51693
|
}
|
|
51110
51694
|
/**
|
|
51695
|
+
* How long the socket may take to put a queued `TEARDOWN` on the wire.
|
|
51696
|
+
*
|
|
51697
|
+
* **This is a bound on OUR socket, not a claim about the camera.** A
|
|
51698
|
+
* `TEARDOWN` is two hundred bytes on a LAN; two seconds is three orders of
|
|
51699
|
+
* magnitude over that, and it only ever elapses for a peer that is not reading
|
|
51700
|
+
* at all — which is a socket we want destroyed anyway.
|
|
51701
|
+
*
|
|
51702
|
+
* It exists because `socket.destroy()` DISCARDS whatever `write()` queued.
|
|
51703
|
+
* The old `close()` wrote `TEARDOWN` and destroyed the socket in the next
|
|
51704
|
+
* statement, and the ordinary end of a segment — a quiet socket, which is how
|
|
51705
|
+
* every complete Hikvision replay ends — never wrote one at all. This firmware
|
|
51706
|
+
* serves ONE playback session and frees it on `TEARDOWN` or on its own 60 s
|
|
51707
|
+
* timeout, so a fetch that skipped the `TEARDOWN` left the next clip an
|
|
51708
|
+
* operator tapped to meet `SETUP … 500` for up to a minute. Measured on 1436,
|
|
51709
|
+
* 2026-09-23 09:25:07: a stream cut at 09:25:00 and the replay seven seconds
|
|
51710
|
+
* later was refused 500 while `ContentMgmt/search` kept answering.
|
|
51711
|
+
*/
|
|
51712
|
+
var RTSP_TEARDOWN_FLUSH_MS = 2e3;
|
|
51713
|
+
/**
|
|
51111
51714
|
* Open a replay and start delivering.
|
|
51112
51715
|
*
|
|
51113
51716
|
* Rejects when the session could not be OPENED — the handshake is where a
|
|
@@ -51133,6 +51736,12 @@ async function openRtspReplay(target, handlers, deps) {
|
|
|
51133
51736
|
const ended = new Promise((resolve) => {
|
|
51134
51737
|
endResolve = resolve;
|
|
51135
51738
|
});
|
|
51739
|
+
const teardownFlushMs = deps.teardownFlushMs ?? 2e3;
|
|
51740
|
+
/** Resolves when this provider no longer holds the camera's replay slot. */
|
|
51741
|
+
let slotResolve = null;
|
|
51742
|
+
const slotFreed = new Promise((resolve) => {
|
|
51743
|
+
slotResolve = resolve;
|
|
51744
|
+
});
|
|
51136
51745
|
let idleTimer = null;
|
|
51137
51746
|
let handshakeTimer = null;
|
|
51138
51747
|
let videoChannel = -1;
|
|
@@ -51150,6 +51759,36 @@ async function openRtspReplay(target, handlers, deps) {
|
|
|
51150
51759
|
if (ticks <= 0) return null;
|
|
51151
51760
|
return Math.round(ticks / (videoTrack?.clockRate ?? 9e4) * 1e3);
|
|
51152
51761
|
};
|
|
51762
|
+
/**
|
|
51763
|
+
* Give the socket back, and say whether the camera's slot went with it.
|
|
51764
|
+
*
|
|
51765
|
+
* `flush` is true exactly when a `TEARDOWN` was queued: the socket is then
|
|
51766
|
+
* `end()`ed so the kernel puts it on the wire, and only destroyed if it has
|
|
51767
|
+
* not closed inside {@link RTSP_TEARDOWN_FLUSH_MS}. `destroy()` on a socket
|
|
51768
|
+
* with a queued write throws that write away, which is how the slot came to
|
|
51769
|
+
* be held past the end of a fetch nobody thought had failed.
|
|
51770
|
+
*/
|
|
51771
|
+
const releaseSocket = (flush) => {
|
|
51772
|
+
const live = socket;
|
|
51773
|
+
socket = null;
|
|
51774
|
+
if (live === null) {
|
|
51775
|
+
slotResolve?.();
|
|
51776
|
+
slotResolve = null;
|
|
51777
|
+
return;
|
|
51778
|
+
}
|
|
51779
|
+
live.once("close", () => {
|
|
51780
|
+
slotResolve?.();
|
|
51781
|
+
slotResolve = null;
|
|
51782
|
+
});
|
|
51783
|
+
if (!flush) {
|
|
51784
|
+
live.destroy();
|
|
51785
|
+
return;
|
|
51786
|
+
}
|
|
51787
|
+
const timer = setTimeout(() => live.destroy(), teardownFlushMs);
|
|
51788
|
+
timer.unref?.();
|
|
51789
|
+
live.once("close", () => clearTimeout(timer));
|
|
51790
|
+
live.end();
|
|
51791
|
+
};
|
|
51153
51792
|
const finish = (end) => {
|
|
51154
51793
|
if (settled) return;
|
|
51155
51794
|
settled = true;
|
|
@@ -51162,6 +51801,14 @@ async function openRtspReplay(target, handlers, deps) {
|
|
|
51162
51801
|
videoUnits += 1;
|
|
51163
51802
|
handlers.onVideo(tail);
|
|
51164
51803
|
}
|
|
51804
|
+
let teardown = "no-session";
|
|
51805
|
+
if (socket === null || socket.destroyed) teardown = "no-socket";
|
|
51806
|
+
else if (rtspSession !== null) try {
|
|
51807
|
+
write("TEARDOWN", sessionUrl, {});
|
|
51808
|
+
teardown = "sent";
|
|
51809
|
+
} catch {
|
|
51810
|
+
teardown = "no-socket";
|
|
51811
|
+
}
|
|
51165
51812
|
deps.logger.info("videoclips: a replay session ended", {
|
|
51166
51813
|
tags,
|
|
51167
51814
|
meta: {
|
|
@@ -51173,11 +51820,11 @@ async function openRtspReplay(target, handlers, deps) {
|
|
|
51173
51820
|
...end.kind === "complete" ? { deliveredMs: end.deliveredMs } : {},
|
|
51174
51821
|
videoUnits,
|
|
51175
51822
|
audioPackets,
|
|
51176
|
-
droppedPayloads: depacketizer?.dropped ?? 0
|
|
51823
|
+
droppedPayloads: depacketizer?.dropped ?? 0,
|
|
51824
|
+
teardown
|
|
51177
51825
|
}
|
|
51178
51826
|
});
|
|
51179
|
-
|
|
51180
|
-
socket = null;
|
|
51827
|
+
releaseSocket(teardown === "sent");
|
|
51181
51828
|
endResolve?.(end);
|
|
51182
51829
|
};
|
|
51183
51830
|
/**
|
|
@@ -51383,12 +52030,9 @@ async function openRtspReplay(target, handlers, deps) {
|
|
|
51383
52030
|
ended,
|
|
51384
52031
|
video,
|
|
51385
52032
|
hasAudio: audio !== void 0,
|
|
51386
|
-
close: () => {
|
|
51387
|
-
if (settled) return;
|
|
51388
|
-
try {
|
|
51389
|
-
write("TEARDOWN", sessionUrl, {});
|
|
51390
|
-
} catch {}
|
|
52033
|
+
close: async () => {
|
|
51391
52034
|
finish({ kind: "closed" });
|
|
52035
|
+
await slotFreed;
|
|
51392
52036
|
}
|
|
51393
52037
|
};
|
|
51394
52038
|
}
|
|
@@ -51468,7 +52112,26 @@ async function prepareClipFile(input, deps) {
|
|
|
51468
52112
|
detail: err instanceof Error ? err.message : String(err)
|
|
51469
52113
|
};
|
|
51470
52114
|
}
|
|
52115
|
+
const abort = () => void session.close();
|
|
52116
|
+
deps.signal?.addEventListener("abort", abort, { once: true });
|
|
51471
52117
|
const end = await session.ended;
|
|
52118
|
+
deps.signal?.removeEventListener("abort", abort);
|
|
52119
|
+
await session.close();
|
|
52120
|
+
if (deps.signal?.aborted === true) {
|
|
52121
|
+
deps.logger.debug("videoclips: a clip fetch gave the slot back", {
|
|
52122
|
+
tags,
|
|
52123
|
+
meta: {
|
|
52124
|
+
...meta,
|
|
52125
|
+
branch: "preempted",
|
|
52126
|
+
accessUnits: units.length
|
|
52127
|
+
}
|
|
52128
|
+
});
|
|
52129
|
+
return {
|
|
52130
|
+
kind: "refused",
|
|
52131
|
+
code: "fetch-failed",
|
|
52132
|
+
detail: "preempted: the camera’s one replay slot was wanted by a stream"
|
|
52133
|
+
};
|
|
52134
|
+
}
|
|
51472
52135
|
if (end.kind === "failed") return {
|
|
51473
52136
|
kind: "refused",
|
|
51474
52137
|
code: "camera-refused",
|
|
@@ -51573,6 +52236,201 @@ async function prepareClipFile(input, deps) {
|
|
|
51573
52236
|
durationMs: durationMs > 0 ? durationMs : null
|
|
51574
52237
|
};
|
|
51575
52238
|
}
|
|
52239
|
+
//#endregion
|
|
52240
|
+
//#region src/clips/replay-lane.ts
|
|
52241
|
+
/**
|
|
52242
|
+
* How long an ask may wait for a slot that is being released.
|
|
52243
|
+
*
|
|
52244
|
+
* {@link RTSP_TEARDOWN_FLUSH_MS} plus a second of margin: a holder that is
|
|
52245
|
+
* shutting down frees the slot inside its own flush bound, and anything longer
|
|
52246
|
+
* than that is a holder that is still playing.
|
|
52247
|
+
*/
|
|
52248
|
+
var REPLAY_LANE_WAIT_MS = RTSP_TEARDOWN_FLUSH_MS + 1e3;
|
|
52249
|
+
/**
|
|
52250
|
+
* Which purposes are SPECULATIVE — work nobody is waiting on.
|
|
52251
|
+
*
|
|
52252
|
+
* A still is minted for a tile that is not on screen yet. A stream and a file
|
|
52253
|
+
* are an operator who has already tapped. When the two want the same camera,
|
|
52254
|
+
* the operator wins: the speculative holder is asked to give the slot back
|
|
52255
|
+
* (`yield`) and the ask waits for it, instead of being refused for want of
|
|
52256
|
+
* work that could have been done a minute later.
|
|
52257
|
+
*
|
|
52258
|
+
* Without this, opening a list of clips would start a realtime fetch per tile
|
|
52259
|
+
* and every playback tap during it would meet `replay-busy` — which is a
|
|
52260
|
+
* regression traded for a thumbnail, and not a trade anyone asked for.
|
|
52261
|
+
*/
|
|
52262
|
+
var SPECULATIVE = new Set(["still"]);
|
|
52263
|
+
function createReplayLane(deps) {
|
|
52264
|
+
const now = deps.now ?? (() => Date.now());
|
|
52265
|
+
const waitMs = deps.waitMs ?? REPLAY_LANE_WAIT_MS;
|
|
52266
|
+
const holding = /* @__PURE__ */ new Map();
|
|
52267
|
+
const take = (deviceId, purpose, yieldNow) => {
|
|
52268
|
+
const entry = {
|
|
52269
|
+
purpose,
|
|
52270
|
+
sinceMs: now(),
|
|
52271
|
+
waiters: [],
|
|
52272
|
+
yieldNow,
|
|
52273
|
+
yielded: false
|
|
52274
|
+
};
|
|
52275
|
+
holding.set(deviceId, entry);
|
|
52276
|
+
let released = false;
|
|
52277
|
+
return {
|
|
52278
|
+
kind: "held",
|
|
52279
|
+
release: () => {
|
|
52280
|
+
if (released) return;
|
|
52281
|
+
released = true;
|
|
52282
|
+
if (holding.get(deviceId) !== entry) return;
|
|
52283
|
+
holding.delete(deviceId);
|
|
52284
|
+
entry.waiters.shift()?.();
|
|
52285
|
+
}
|
|
52286
|
+
};
|
|
52287
|
+
};
|
|
52288
|
+
return {
|
|
52289
|
+
held: (deviceId) => holding.has(deviceId),
|
|
52290
|
+
acquire: async (deviceId, purpose, yieldNow) => {
|
|
52291
|
+
const askedAt = now();
|
|
52292
|
+
const current = holding.get(deviceId);
|
|
52293
|
+
if (current === void 0) return take(deviceId, purpose, yieldNow ?? null);
|
|
52294
|
+
if (!SPECULATIVE.has(purpose) && SPECULATIVE.has(current.purpose) && current.yieldNow !== null && !current.yielded) {
|
|
52295
|
+
current.yielded = true;
|
|
52296
|
+
deps.logger.debug("videoclips: asking a speculative replay to give the slot back", {
|
|
52297
|
+
tags: { deviceId },
|
|
52298
|
+
meta: {
|
|
52299
|
+
branch: "replay-yield",
|
|
52300
|
+
asked: purpose,
|
|
52301
|
+
holder: current.purpose
|
|
52302
|
+
}
|
|
52303
|
+
});
|
|
52304
|
+
current.yieldNow();
|
|
52305
|
+
}
|
|
52306
|
+
await new Promise((resolve) => {
|
|
52307
|
+
let timer = null;
|
|
52308
|
+
const wake = () => {
|
|
52309
|
+
if (timer !== null) clearTimeout(timer);
|
|
52310
|
+
timer = null;
|
|
52311
|
+
resolve();
|
|
52312
|
+
};
|
|
52313
|
+
timer = setTimeout(() => {
|
|
52314
|
+
timer = null;
|
|
52315
|
+
const index = current.waiters.indexOf(wake);
|
|
52316
|
+
if (index !== -1) current.waiters.splice(index, 1);
|
|
52317
|
+
resolve();
|
|
52318
|
+
}, waitMs);
|
|
52319
|
+
current.waiters.push(wake);
|
|
52320
|
+
});
|
|
52321
|
+
const after = holding.get(deviceId);
|
|
52322
|
+
if (after === void 0) return take(deviceId, purpose, yieldNow ?? null);
|
|
52323
|
+
const heldForMs = now() - after.sinceMs;
|
|
52324
|
+
const waitedMs = now() - askedAt;
|
|
52325
|
+
const detail = `replay-busy: this camera serves one playback session at a time and its ${after.purpose} has held it for ${String(heldForMs)} ms — nothing about this clip, and nothing a retry against the camera would change`;
|
|
52326
|
+
deps.logger.warn("videoclips: a replay was refused — the camera’s slot is held", {
|
|
52327
|
+
tags: { deviceId },
|
|
52328
|
+
meta: {
|
|
52329
|
+
branch: "replay-busy",
|
|
52330
|
+
asked: purpose,
|
|
52331
|
+
holder: after.purpose,
|
|
52332
|
+
heldForMs,
|
|
52333
|
+
waitedMs
|
|
52334
|
+
}
|
|
52335
|
+
});
|
|
52336
|
+
return {
|
|
52337
|
+
kind: "busy",
|
|
52338
|
+
holder: after.purpose,
|
|
52339
|
+
heldForMs,
|
|
52340
|
+
waitedMs,
|
|
52341
|
+
detail
|
|
52342
|
+
};
|
|
52343
|
+
}
|
|
52344
|
+
};
|
|
52345
|
+
}
|
|
52346
|
+
/** Where inside a clip the still is cut, in seconds. Exported to be proved. */
|
|
52347
|
+
function clipStillSeekSeconds(durationMs) {
|
|
52348
|
+
return Math.max(0, durationMs / 2e3);
|
|
52349
|
+
}
|
|
52350
|
+
/** What ffmpeg is told. Pure, so the arguments can be proved without a spawn. */
|
|
52351
|
+
function buildClipStillArgs(input) {
|
|
52352
|
+
return [
|
|
52353
|
+
"-hide_banner",
|
|
52354
|
+
"-loglevel",
|
|
52355
|
+
"error",
|
|
52356
|
+
"-nostdin",
|
|
52357
|
+
"-ss",
|
|
52358
|
+
clipStillSeekSeconds(input.durationMs).toFixed(3),
|
|
52359
|
+
"-i",
|
|
52360
|
+
input.path,
|
|
52361
|
+
"-frames:v",
|
|
52362
|
+
"1",
|
|
52363
|
+
"-vf",
|
|
52364
|
+
`scale=${String(640)}:-2:flags=bicubic`,
|
|
52365
|
+
"-q:v",
|
|
52366
|
+
String(4),
|
|
52367
|
+
"-f",
|
|
52368
|
+
"mjpeg",
|
|
52369
|
+
"pipe:1"
|
|
52370
|
+
];
|
|
52371
|
+
}
|
|
52372
|
+
/**
|
|
52373
|
+
* Cut one still. Never throws: every dead end is a named refusal.
|
|
52374
|
+
*/
|
|
52375
|
+
async function cutClipStill(input, deps) {
|
|
52376
|
+
const args = buildClipStillArgs(input);
|
|
52377
|
+
let child;
|
|
52378
|
+
try {
|
|
52379
|
+
child = deps.spawnStill?.([...args]) ?? spawn(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
|
|
52380
|
+
"ignore",
|
|
52381
|
+
"pipe",
|
|
52382
|
+
"pipe"
|
|
52383
|
+
] });
|
|
52384
|
+
} catch (err) {
|
|
52385
|
+
return {
|
|
52386
|
+
kind: "refused",
|
|
52387
|
+
code: "decode-failed",
|
|
52388
|
+
detail: `ffmpeg could not be spawned: ${err instanceof Error ? err.message : String(err)}`
|
|
52389
|
+
};
|
|
52390
|
+
}
|
|
52391
|
+
const chunks = [];
|
|
52392
|
+
let stderr = "";
|
|
52393
|
+
child.stdout?.on("data", (chunk) => chunks.push(chunk));
|
|
52394
|
+
child.stderr?.on("data", (chunk) => {
|
|
52395
|
+
stderr += chunk.toString("utf8");
|
|
52396
|
+
});
|
|
52397
|
+
const timeoutMs = deps.timeoutMs ?? 1e4;
|
|
52398
|
+
const exit = await new Promise((resolve) => {
|
|
52399
|
+
const timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
52400
|
+
timer.unref?.();
|
|
52401
|
+
child.once("error", () => {
|
|
52402
|
+
clearTimeout(timer);
|
|
52403
|
+
resolve(null);
|
|
52404
|
+
});
|
|
52405
|
+
child.once("close", (code) => {
|
|
52406
|
+
clearTimeout(timer);
|
|
52407
|
+
resolve(code);
|
|
52408
|
+
});
|
|
52409
|
+
});
|
|
52410
|
+
if (exit === "timeout") {
|
|
52411
|
+
child.kill("SIGKILL");
|
|
52412
|
+
return {
|
|
52413
|
+
kind: "refused",
|
|
52414
|
+
code: "decode-failed",
|
|
52415
|
+
detail: `ffmpeg did not answer inside ${String(timeoutMs)} ms`
|
|
52416
|
+
};
|
|
52417
|
+
}
|
|
52418
|
+
const bytes = Buffer.concat(chunks);
|
|
52419
|
+
if (exit !== 0) return {
|
|
52420
|
+
kind: "refused",
|
|
52421
|
+
code: "decode-failed",
|
|
52422
|
+
detail: `ffmpeg exited ${String(exit)}: ${stderr.trim().slice(0, 300)}`
|
|
52423
|
+
};
|
|
52424
|
+
if (bytes.byteLength === 0) return {
|
|
52425
|
+
kind: "refused",
|
|
52426
|
+
code: "no-keyframe",
|
|
52427
|
+
detail: `ffmpeg read the clip and produced no frame at ${clipStillSeekSeconds(input.durationMs).toFixed(3)} s`
|
|
52428
|
+
};
|
|
52429
|
+
return {
|
|
52430
|
+
kind: "ok",
|
|
52431
|
+
bytes
|
|
52432
|
+
};
|
|
52433
|
+
}
|
|
51576
52434
|
function pathToken(req) {
|
|
51577
52435
|
const raw = req.url ?? "/";
|
|
51578
52436
|
const query = raw.indexOf("?");
|
|
@@ -51791,6 +52649,8 @@ var ClipStreamRun = class {
|
|
|
51791
52649
|
audioGate = null;
|
|
51792
52650
|
peerGate = null;
|
|
51793
52651
|
answered = false;
|
|
52652
|
+
/** Settles when this run no longer holds the camera's one replay slot. */
|
|
52653
|
+
slotFreed = Promise.resolve();
|
|
51794
52654
|
settle = null;
|
|
51795
52655
|
bytesOut = 0;
|
|
51796
52656
|
accessUnits = 0;
|
|
@@ -51823,6 +52683,7 @@ var ClipStreamRun = class {
|
|
|
51823
52683
|
});
|
|
51824
52684
|
});
|
|
51825
52685
|
this.disposeGates();
|
|
52686
|
+
await this.slotFreed;
|
|
51826
52687
|
return outcome;
|
|
51827
52688
|
}
|
|
51828
52689
|
async start() {
|
|
@@ -51869,8 +52730,9 @@ var ClipStreamRun = class {
|
|
|
51869
52730
|
return;
|
|
51870
52731
|
}
|
|
51871
52732
|
this.heldVideo.push(unit);
|
|
51872
|
-
|
|
51873
|
-
|
|
52733
|
+
const timestamps = this.heldVideo.map((held) => held.timestamp);
|
|
52734
|
+
if (this.heldVideo.length < this.deps.probeTarget && !replayFpsIsExact(timestamps)) return;
|
|
52735
|
+
const rate = estimateReplayFps(timestamps, this.replay?.video.clockRate ?? 9e4);
|
|
51874
52736
|
if (rate.fps === null) {
|
|
51875
52737
|
if (this.heldVideo.length < this.deps.probeMax) return;
|
|
51876
52738
|
this.refuse("fetch-failed", `rate-unmeasured: ${String(this.heldVideo.length)} access units and the replay's own timestamps still cannot say this clip's frame rate`);
|
|
@@ -52081,7 +52943,7 @@ var ClipStreamRun = class {
|
|
|
52081
52943
|
answer(outcome) {
|
|
52082
52944
|
this.answered = true;
|
|
52083
52945
|
this.disposeGates();
|
|
52084
|
-
this.replay?.close();
|
|
52946
|
+
this.slotFreed = this.replay?.close() ?? Promise.resolve();
|
|
52085
52947
|
if (outcome.kind !== "served") this.mux?.kill("SIGKILL");
|
|
52086
52948
|
this.settle?.(outcome);
|
|
52087
52949
|
this.settle = null;
|
|
@@ -52606,7 +53468,11 @@ function createHikvisionVideoclipsProvider(deps) {
|
|
|
52606
53468
|
const remember = (deviceId, rows) => {
|
|
52607
53469
|
for (const row of rows) rowsByKey.set(`${String(deviceId)}|${row.clipKey}`, row);
|
|
52608
53470
|
};
|
|
52609
|
-
|
|
53471
|
+
/**
|
|
53472
|
+
* ASYNC, because the still is vouched by a `stat` and a vouch that guessed
|
|
53473
|
+
* would be worse than no still at all.
|
|
53474
|
+
*/
|
|
53475
|
+
const toClip = async (deviceId, row, catalogAsOf) => ({
|
|
52610
53476
|
id: mintClipId({
|
|
52611
53477
|
source: CLIP_SOURCE_ONBOARD,
|
|
52612
53478
|
trackId: row.trackId,
|
|
@@ -52619,7 +53485,7 @@ function createHikvisionVideoclipsProvider(deps) {
|
|
|
52619
53485
|
endMs: row.endMs
|
|
52620
53486
|
},
|
|
52621
53487
|
...row.recordTypes.length > 0 ? { nativeTypes: [...row.recordTypes] } : {},
|
|
52622
|
-
|
|
53488
|
+
...await deps.hasVouchedStill(deviceId, row.clipKey) ? { thumbnail: deps.stillUrl(deviceId, row.clipKey) } : { thumbnailMint: deps.stillUrl(deviceId, row.clipKey) },
|
|
52623
53489
|
catalogAsOf,
|
|
52624
53490
|
streams: { main: {
|
|
52625
53491
|
id: mintClipId({
|
|
@@ -52713,7 +53579,7 @@ function createHikvisionVideoclipsProvider(deps) {
|
|
|
52713
53579
|
trackId: device.trackId
|
|
52714
53580
|
}
|
|
52715
53581
|
});
|
|
52716
|
-
return capped.map((row) => toClip(row, catalogAsOf));
|
|
53582
|
+
return await Promise.all(capped.map(async (row) => await toClip(device.deviceId, row, catalogAsOf)));
|
|
52717
53583
|
},
|
|
52718
53584
|
/**
|
|
52719
53585
|
* The player's URL — a finished MP4 on this addon's own media plane, with
|
|
@@ -52804,6 +53670,102 @@ function createHikvisionVideoclipsProvider(deps) {
|
|
|
52804
53670
|
};
|
|
52805
53671
|
},
|
|
52806
53672
|
/**
|
|
53673
|
+
* The same clip, over the addon→addon byte transport (D613).
|
|
53674
|
+
*
|
|
53675
|
+
* ONE difference from {@link readClipBytes}: the bytes leave over a
|
|
53676
|
+
* one-shot loopback socket instead of inside a base64 field, so neither
|
|
53677
|
+
* this process nor the caller holds the payload. On this vendor nothing is
|
|
53678
|
+
* held at all — the fetch path already lands the clip in a scratch FILE
|
|
53679
|
+
* and the offer streams that file.
|
|
53680
|
+
*
|
|
53681
|
+
* The cheap questions come first and none of them touches the camera: the
|
|
53682
|
+
* id, the row, and the SIZE the catalog already declared — the rule bulk
|
|
53683
|
+
* work over stored media already follows (D54). The bound is
|
|
53684
|
+
* {@link VIDEOCLIPS_MAX_OFFER_BYTES} and it is a statement about what a
|
|
53685
|
+
* PRODUCER holds, which here is nothing; it is applied anyway so the
|
|
53686
|
+
* ceiling is one number across vendors rather than a per-vendor guess.
|
|
53687
|
+
*/
|
|
53688
|
+
offerClipBytes: async ({ deviceId, clipId, profile, maxBytes }) => {
|
|
53689
|
+
const tags = { deviceId };
|
|
53690
|
+
const refuse = (code, detail, meta) => {
|
|
53691
|
+
deps.logger.warn("videoclips: refusing a clip byte offer", {
|
|
53692
|
+
tags,
|
|
53693
|
+
meta: {
|
|
53694
|
+
clipId,
|
|
53695
|
+
branch: code,
|
|
53696
|
+
...meta
|
|
53697
|
+
}
|
|
53698
|
+
});
|
|
53699
|
+
throw new Error(clipExportFailure(code, detail));
|
|
53700
|
+
};
|
|
53701
|
+
const device = await deps.resolveDevice(deviceId);
|
|
53702
|
+
if (device === null) return refuse("catalog-miss", `device ${String(deviceId)} is not a Hikvision camera`, {});
|
|
53703
|
+
const parsed = parseClipId(clipId);
|
|
53704
|
+
if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Hikvision provider`, {});
|
|
53705
|
+
const clipKey = clipKeyFor(parsed.trackId, parsed.fileName);
|
|
53706
|
+
const row = rowsByKey.get(`${String(deviceId)}|${clipKey}`);
|
|
53707
|
+
if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${String(deviceId)} — list the window that holds it first`, { clipKey });
|
|
53708
|
+
const quality = chooseClipQuality(profile);
|
|
53709
|
+
if (quality.upgraded) noteUpgrade(deviceId, clipId, profile);
|
|
53710
|
+
const bound = Math.min(maxBytes ?? 524288e3, VIDEOCLIPS_MAX_OFFER_BYTES);
|
|
53711
|
+
if (row.sizeBytes !== void 0 && row.sizeBytes > bound) return refuse("too-large-to-transfer", `the camera lists clip "${clipId}" as ${String(row.sizeBytes)} bytes, over the ${String(bound)}-byte bound for one transfer`, {
|
|
53712
|
+
clipKey,
|
|
53713
|
+
listedBytes: row.sizeBytes,
|
|
53714
|
+
bound
|
|
53715
|
+
});
|
|
53716
|
+
const outcome = await deps.prepareOffer({
|
|
53717
|
+
device,
|
|
53718
|
+
row,
|
|
53719
|
+
maxBytes: bound
|
|
53720
|
+
});
|
|
53721
|
+
if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
|
|
53722
|
+
deps.logger.info("videoclips: offered a Hikvision clip by handle", {
|
|
53723
|
+
tags,
|
|
53724
|
+
meta: {
|
|
53725
|
+
clipId,
|
|
53726
|
+
clipKey,
|
|
53727
|
+
served: quality.served,
|
|
53728
|
+
asked: profile ?? null,
|
|
53729
|
+
bytes: outcome.bytes,
|
|
53730
|
+
durationMs: outcome.durationMs,
|
|
53731
|
+
expiresInMs: outcome.ticket.expiresAtMs - deps.now()
|
|
53732
|
+
}
|
|
53733
|
+
});
|
|
53734
|
+
return {
|
|
53735
|
+
ticket: outcome.ticket,
|
|
53736
|
+
contentType: "video/mp4",
|
|
53737
|
+
name: clipFileLabel(row),
|
|
53738
|
+
served: quality.served,
|
|
53739
|
+
...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
|
|
53740
|
+
};
|
|
53741
|
+
},
|
|
53742
|
+
/**
|
|
53743
|
+
* What a surface may DRAW for this camera's clips (D612).
|
|
53744
|
+
*
|
|
53745
|
+
* A CONSTANT, and it has to be: this method is asked per camera, and the
|
|
53746
|
+
* answer is a fact about which accessors this provider wires, not about
|
|
53747
|
+
* the camera. Every clip here reaches the player over
|
|
53748
|
+
* {@link dialClipStream}'s forward-only stream, so every clip gets the
|
|
53749
|
+
* `stream` envelope — measured, not assumed: the broker's `chooseClipPath`
|
|
53750
|
+
* reads only whether the dial and the file read are wired, and never the
|
|
53751
|
+
* clip or the profile.
|
|
53752
|
+
*
|
|
53753
|
+
* The rates are the broker's re-pacing ladder CAPPED AT REALTIME, because
|
|
53754
|
+
* this camera's replay is not a fetch: it delivers the recording at the
|
|
53755
|
+
* speed it was recorded (1.01× measured on 1436, 2026-09-23 — 176 access
|
|
53756
|
+
* units in 14.27 s at 12.5 fps). The pacer consumes `rate` seconds of
|
|
53757
|
+
* media per second while the socket supplies one, so anything above 1
|
|
53758
|
+
* empties a buffer that never had the rest of the clip in it. Below 1 is
|
|
53759
|
+
* free. See `CLIP_REALTIME_SOURCE_RATES` for the full measurement; this is
|
|
53760
|
+
* D612's own rule reaching the case it did not yet have.
|
|
53761
|
+
*
|
|
53762
|
+
* The camera's own `Scale` header is a different mechanism and is
|
|
53763
|
+
* deliberately unreachable from this provider (`rtsp-replay-session.ts`):
|
|
53764
|
+
* the feasibility note measured `Scale: 8.0` answering 200 and silently
|
|
53765
|
+
* corrupting the media.
|
|
53766
|
+
*/
|
|
53767
|
+
getPlaybackOptions: async () => CLIP_PLAYBACK_OPTIONS.realtimeStream,
|
|
53768
|
+
/**
|
|
52807
53769
|
* Where this clip's STREAM is dialled (D597) — the same questions
|
|
52808
53770
|
* `readClipBytes` asks before the camera is touched, the same vocabulary,
|
|
52809
53771
|
* and NO fetch: the URL is one shot on this provider's own listener and
|
|
@@ -52853,9 +53815,22 @@ function createClipService(deps) {
|
|
|
52853
53815
|
const now = deps.now ?? (() => Date.now());
|
|
52854
53816
|
const logger = deps.logger;
|
|
52855
53817
|
const store = new ClipArtifactStore({ root: deps.dataDir });
|
|
53818
|
+
const thumbs = new ClipArtifactStore({
|
|
53819
|
+
root: deps.dataDir,
|
|
53820
|
+
kind: CLIP_THUMB_KIND,
|
|
53821
|
+
extension: CLIP_THUMB_EXTENSION,
|
|
53822
|
+
maxFilesPerDevice: THUMB_MAX_FILES_PER_DEVICE,
|
|
53823
|
+
maxBytesPerDevice: THUMB_MAX_BYTES_PER_DEVICE
|
|
53824
|
+
});
|
|
53825
|
+
const lane = createReplayLane({
|
|
53826
|
+
logger,
|
|
53827
|
+
...deps.replayWaitMs !== void 0 ? { waitMs: deps.replayWaitMs } : {}
|
|
53828
|
+
});
|
|
52856
53829
|
const producer = createClipStreamProducer({
|
|
52857
53830
|
logger,
|
|
52858
|
-
...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {}
|
|
53831
|
+
...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {},
|
|
53832
|
+
...deps.openReplay !== void 0 ? { openReplay: deps.openReplay } : {},
|
|
53833
|
+
...deps.spawnMux !== void 0 ? { spawnMux: deps.spawnMux } : {}
|
|
52859
53834
|
});
|
|
52860
53835
|
/** Every row the provider has listed, mirrored here so the ROUTES — which
|
|
52861
53836
|
* are reached without a cap call — can resolve a key without the camera. */
|
|
@@ -52918,12 +53893,23 @@ function createClipService(deps) {
|
|
|
52918
53893
|
detail: "no-catalog-row: list this window before asking for its stream"
|
|
52919
53894
|
};
|
|
52920
53895
|
}
|
|
52921
|
-
const
|
|
52922
|
-
|
|
52923
|
-
|
|
52924
|
-
|
|
52925
|
-
|
|
52926
|
-
}
|
|
53896
|
+
const lease = await lane.acquire(target.deviceId, "stream");
|
|
53897
|
+
if (lease.kind === "busy") return {
|
|
53898
|
+
kind: "refused",
|
|
53899
|
+
code: "replay-busy",
|
|
53900
|
+
detail: lease.detail
|
|
53901
|
+
};
|
|
53902
|
+
let outcome;
|
|
53903
|
+
try {
|
|
53904
|
+
outcome = await producer.stream({
|
|
53905
|
+
camera: cameraFor(camera),
|
|
53906
|
+
row,
|
|
53907
|
+
audio: target.audio,
|
|
53908
|
+
sink
|
|
53909
|
+
});
|
|
53910
|
+
} finally {
|
|
53911
|
+
lease.release();
|
|
53912
|
+
}
|
|
52927
53913
|
switch (outcome.kind) {
|
|
52928
53914
|
case "served": return { kind: "served" };
|
|
52929
53915
|
case "cut": return { kind: "cut" };
|
|
@@ -52938,19 +53924,52 @@ function createClipService(deps) {
|
|
|
52938
53924
|
logger,
|
|
52939
53925
|
stream: streamRoute
|
|
52940
53926
|
});
|
|
52941
|
-
|
|
53927
|
+
/**
|
|
53928
|
+
* The one archived copy of a clip, fetched if it is not held.
|
|
53929
|
+
*
|
|
53930
|
+
* `purpose` names who is asking, because the camera's one replay slot is one
|
|
53931
|
+
* slot: a still, a playback URL and an export all queue on it, and the lane
|
|
53932
|
+
* refuses by name rather than dialling into an opaque 500. The refusal is
|
|
53933
|
+
* reported in THIS function's own vocabulary — including `replay-busy` — and
|
|
53934
|
+
* each caller maps it into the vocabulary its own answer travels on.
|
|
53935
|
+
*/
|
|
53936
|
+
const fileFor = async (deviceId, clipKey, purpose) => {
|
|
52942
53937
|
const camera = await deps.resolveCamera(deviceId);
|
|
52943
53938
|
if (camera === null) return {
|
|
52944
53939
|
kind: "refused",
|
|
52945
|
-
code: "
|
|
53940
|
+
code: "unknown-device",
|
|
52946
53941
|
detail: `device ${String(deviceId)} is not a Hikvision camera`
|
|
52947
53942
|
};
|
|
52948
53943
|
const row = rowsByKey.get(rowKey(deviceId, clipKey));
|
|
52949
53944
|
if (row === void 0) return {
|
|
52950
53945
|
kind: "refused",
|
|
52951
|
-
code: "
|
|
53946
|
+
code: "no-catalog-row",
|
|
52952
53947
|
detail: "no-catalog-row: list the window that holds this clip first"
|
|
52953
53948
|
};
|
|
53949
|
+
if (await store.pathIfPresent(deviceId, clipKey) === null) {
|
|
53950
|
+
const preempt = new AbortController();
|
|
53951
|
+
const lease = await lane.acquire(deviceId, purpose, () => preempt.abort());
|
|
53952
|
+
if (lease.kind === "busy") return {
|
|
53953
|
+
kind: "refused",
|
|
53954
|
+
code: "replay-busy",
|
|
53955
|
+
detail: lease.detail
|
|
53956
|
+
};
|
|
53957
|
+
try {
|
|
53958
|
+
return await prepareClipFile({
|
|
53959
|
+
camera: cameraFor(camera),
|
|
53960
|
+
row,
|
|
53961
|
+
audio: true
|
|
53962
|
+
}, {
|
|
53963
|
+
logger,
|
|
53964
|
+
store,
|
|
53965
|
+
signal: preempt.signal,
|
|
53966
|
+
...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {},
|
|
53967
|
+
...deps.openReplay !== void 0 ? { openReplay: deps.openReplay } : {}
|
|
53968
|
+
});
|
|
53969
|
+
} finally {
|
|
53970
|
+
lease.release();
|
|
53971
|
+
}
|
|
53972
|
+
}
|
|
52954
53973
|
return await prepareClipFile({
|
|
52955
53974
|
camera: cameraFor(camera),
|
|
52956
53975
|
row,
|
|
@@ -52958,9 +53977,70 @@ function createClipService(deps) {
|
|
|
52958
53977
|
}, {
|
|
52959
53978
|
logger,
|
|
52960
53979
|
store,
|
|
52961
|
-
...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {}
|
|
53980
|
+
...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {},
|
|
53981
|
+
...deps.openReplay !== void 0 ? { openReplay: deps.openReplay } : {}
|
|
52962
53982
|
});
|
|
52963
53983
|
};
|
|
53984
|
+
/**
|
|
53985
|
+
* `fileFor`'s vocabulary, in the one a clip EXPORT answers on.
|
|
53986
|
+
*
|
|
53987
|
+
* `ClipExportFailure` is a cross-provider enum in `@camstack/types` and a
|
|
53988
|
+
* Hikvision-shaped fact has no business widening it, so the two codes it
|
|
53989
|
+
* does not have are folded HERE, in one place, rather than at each call
|
|
53990
|
+
* site. Nothing is lost: `detail` opens with the real branch and the lane
|
|
53991
|
+
* has already logged the line that names the holder.
|
|
53992
|
+
*/
|
|
53993
|
+
const asExportFailure = (refusal) => {
|
|
53994
|
+
switch (refusal.code) {
|
|
53995
|
+
case "replay-busy": return "camera-refused";
|
|
53996
|
+
case "no-catalog-row": return "catalog-miss";
|
|
53997
|
+
case "unknown-device": return "fetch-failed";
|
|
53998
|
+
default: return refusal.code;
|
|
53999
|
+
}
|
|
54000
|
+
};
|
|
54001
|
+
/**
|
|
54002
|
+
* One clip's STILL: cut from the clip's own archived copy, at its midpoint.
|
|
54003
|
+
*
|
|
54004
|
+
* The gates are the cheap ones first (D54) and they are the same gates the
|
|
54005
|
+
* media path uses, in the same order — this function adds no second opinion
|
|
54006
|
+
* about which clip a key means. Its whole job is to turn `fileFor`'s answer
|
|
54007
|
+
* into the still vocabulary, and to cut one frame when there is a file.
|
|
54008
|
+
*/
|
|
54009
|
+
const mintStill = async (deviceId, clipKey) => {
|
|
54010
|
+
const file = await fileFor(deviceId, clipKey, "still");
|
|
54011
|
+
if (file.kind === "refused") {
|
|
54012
|
+
if (file.code === "replay-busy") return deferStill("queue-full", file.detail, REPLAY_LANE_WAIT_MS);
|
|
54013
|
+
if (file.code === "unknown-device") return refuseStill("unknown-device", file.detail);
|
|
54014
|
+
if (file.code === "no-catalog-row") return refuseStill("no-catalog-row", file.detail);
|
|
54015
|
+
return refuseStill("camera-refused", file.detail);
|
|
54016
|
+
}
|
|
54017
|
+
const row = rowsByKey.get(rowKey(deviceId, clipKey));
|
|
54018
|
+
const durationMs = file.durationMs ?? (row === void 0 ? 0 : row.endMs - row.startMs);
|
|
54019
|
+
if (durationMs <= 0) return refuseStill("no-keyframe", "this clip has no length to take a midpoint of — nothing measured it and its catalog row spans nothing");
|
|
54020
|
+
const cut = await cutClipStill({
|
|
54021
|
+
path: file.path,
|
|
54022
|
+
durationMs
|
|
54023
|
+
}, {
|
|
54024
|
+
logger,
|
|
54025
|
+
...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {},
|
|
54026
|
+
...deps.spawnStill !== void 0 ? { spawnStill: deps.spawnStill } : {}
|
|
54027
|
+
});
|
|
54028
|
+
if (cut.kind === "refused") return refuseStill(cut.code, cut.detail);
|
|
54029
|
+
await thumbs.put(deviceId, clipKey, cut.bytes);
|
|
54030
|
+
logger.debug("videoclips: a clip still was minted", {
|
|
54031
|
+
tags: { deviceId },
|
|
54032
|
+
meta: {
|
|
54033
|
+
clipKey,
|
|
54034
|
+
bytes: cut.bytes.byteLength,
|
|
54035
|
+
durationMs,
|
|
54036
|
+
measured: file.durationMs !== null
|
|
54037
|
+
}
|
|
54038
|
+
});
|
|
54039
|
+
return {
|
|
54040
|
+
ok: true,
|
|
54041
|
+
bytes: cut.bytes
|
|
54042
|
+
};
|
|
54043
|
+
};
|
|
52964
54044
|
return {
|
|
52965
54045
|
provider: createHikvisionVideoclipsProvider({
|
|
52966
54046
|
now,
|
|
@@ -53084,11 +54164,68 @@ function createClipService(deps) {
|
|
|
53084
54164
|
};
|
|
53085
54165
|
},
|
|
53086
54166
|
playbackUrl: (deviceId, clipKey) => `/addon/provider-hikvision/${CLIP_MEDIA_PREFIX}/${String(deviceId)}/${clipKey}.mp4`,
|
|
54167
|
+
hasVouchedStill: (deviceId, clipKey) => thumbs.has(deviceId, clipKey),
|
|
54168
|
+
stillUrl: (deviceId, clipKey) => `/addon/provider-hikvision/${CLIP_THUMB_PREFIX}/${String(deviceId)}/${clipKey}`,
|
|
54169
|
+
prepareOffer: async ({ device, row, maxBytes }) => {
|
|
54170
|
+
const peerBytes = deps.peerBytes;
|
|
54171
|
+
if (peerBytes === void 0) {
|
|
54172
|
+
logger.warn("videoclips: no peer-bytes transport on this node", {
|
|
54173
|
+
tags: { deviceId: device.deviceId },
|
|
54174
|
+
meta: {
|
|
54175
|
+
clipKey: row.clipKey,
|
|
54176
|
+
branch: "no-peer-bytes"
|
|
54177
|
+
}
|
|
54178
|
+
});
|
|
54179
|
+
return {
|
|
54180
|
+
kind: "refused",
|
|
54181
|
+
code: "fetch-failed",
|
|
54182
|
+
detail: "this node has no addon-to-addon byte transport wired"
|
|
54183
|
+
};
|
|
54184
|
+
}
|
|
54185
|
+
const file = await fileFor(device.deviceId, row.clipKey, "file");
|
|
54186
|
+
if (file.kind === "refused") return {
|
|
54187
|
+
kind: "refused",
|
|
54188
|
+
code: asExportFailure(file),
|
|
54189
|
+
detail: file.detail
|
|
54190
|
+
};
|
|
54191
|
+
if (file.bytes > maxBytes) return {
|
|
54192
|
+
kind: "refused",
|
|
54193
|
+
code: "too-large-to-transfer",
|
|
54194
|
+
detail: `clip "${row.fileName}" muxed to ${String(file.bytes)} bytes, over the ${String(maxBytes)}-byte bound for one transfer`
|
|
54195
|
+
};
|
|
54196
|
+
const offered = await peerBytes.offerFile({
|
|
54197
|
+
path: file.path,
|
|
54198
|
+
contentType: "video/mp4",
|
|
54199
|
+
label: "hikvision-clip",
|
|
54200
|
+
deviceId: device.deviceId
|
|
54201
|
+
});
|
|
54202
|
+
if (offered.kind === "refused") {
|
|
54203
|
+
logger.warn("videoclips: the clip byte offer could not be minted", {
|
|
54204
|
+
tags: { deviceId: device.deviceId },
|
|
54205
|
+
meta: {
|
|
54206
|
+
clipKey: row.clipKey,
|
|
54207
|
+
branch: offered.code,
|
|
54208
|
+
detail: offered.detail
|
|
54209
|
+
}
|
|
54210
|
+
});
|
|
54211
|
+
return {
|
|
54212
|
+
kind: "refused",
|
|
54213
|
+
code: "fetch-failed",
|
|
54214
|
+
detail: `${offered.code}: ${offered.detail}`
|
|
54215
|
+
};
|
|
54216
|
+
}
|
|
54217
|
+
return {
|
|
54218
|
+
kind: "ok",
|
|
54219
|
+
ticket: offered.ticket,
|
|
54220
|
+
durationMs: file.durationMs,
|
|
54221
|
+
bytes: file.bytes
|
|
54222
|
+
};
|
|
54223
|
+
},
|
|
53087
54224
|
prepareBytes: async ({ device, row, maxBytes }) => {
|
|
53088
|
-
const file = await fileFor(device.deviceId, row.clipKey);
|
|
54225
|
+
const file = await fileFor(device.deviceId, row.clipKey, "file");
|
|
53089
54226
|
if (file.kind === "refused") return {
|
|
53090
54227
|
kind: "refused",
|
|
53091
|
-
code: file
|
|
54228
|
+
code: asExportFailure(file),
|
|
53092
54229
|
detail: file.detail
|
|
53093
54230
|
};
|
|
53094
54231
|
if (file.bytes > maxBytes) return {
|
|
@@ -53123,17 +54260,32 @@ function createClipService(deps) {
|
|
|
53123
54260
|
}),
|
|
53124
54261
|
mediaPrefix: CLIP_MEDIA_PREFIX,
|
|
53125
54262
|
streamPrefix: CLIP_STREAM_PREFIX,
|
|
54263
|
+
thumbPrefix: CLIP_THUMB_PREFIX,
|
|
53126
54264
|
mediaHandler: createClipPlaybackHandler({
|
|
53127
54265
|
logger,
|
|
53128
54266
|
resolvePath: async (deviceId, clipKey) => {
|
|
53129
|
-
const file = await fileFor(deviceId, clipKey);
|
|
53130
|
-
|
|
54267
|
+
const file = await fileFor(deviceId, clipKey, "file");
|
|
54268
|
+
if (file.kind === "ok") return file.path;
|
|
54269
|
+
deps.logger.warn("videoclips: a clip file was refused", {
|
|
54270
|
+
tags: { deviceId },
|
|
54271
|
+
meta: {
|
|
54272
|
+
clipKey,
|
|
54273
|
+
branch: file.code,
|
|
54274
|
+
reason: file.detail
|
|
54275
|
+
}
|
|
54276
|
+
});
|
|
54277
|
+
return null;
|
|
53131
54278
|
}
|
|
53132
54279
|
}),
|
|
53133
54280
|
streamHandler: createClipStreamHandler({
|
|
53134
54281
|
logger,
|
|
53135
54282
|
stream: streamRoute
|
|
53136
54283
|
}),
|
|
54284
|
+
thumbHandler: createClipThumbHandler({
|
|
54285
|
+
logger,
|
|
54286
|
+
readCached: (deviceId, clipKey) => thumbs.read(deviceId, clipKey),
|
|
54287
|
+
mint: mintStill
|
|
54288
|
+
}),
|
|
53137
54289
|
disposeClipStreamDial: () => dial.dispose()
|
|
53138
54290
|
};
|
|
53139
54291
|
}
|
|
@@ -70946,9 +72098,17 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
|
|
|
70946
72098
|
capability: failureContributionCapability,
|
|
70947
72099
|
provider: { list: () => intercomFailureReport.list() }
|
|
70948
72100
|
});
|
|
72101
|
+
let ffmpegBinaryPath = null;
|
|
72102
|
+
try {
|
|
72103
|
+
ffmpegBinaryPath = await this.ctx.deps.ensureFfmpeg();
|
|
72104
|
+
} catch (err) {
|
|
72105
|
+
this.ctx.logger.warn("the pinned ffmpeg could not be resolved — clip muxing will use PATH", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
72106
|
+
}
|
|
70949
72107
|
this.clipService = createClipService({
|
|
70950
72108
|
dataDir: this.ctx.dataDir,
|
|
70951
72109
|
logger: this.ctx.logger,
|
|
72110
|
+
...ffmpegBinaryPath === null ? {} : { ffmpegBinaryPath },
|
|
72111
|
+
...this.ctx.peerBytes !== void 0 ? { peerBytes: this.ctx.peerBytes } : {},
|
|
70952
72112
|
resolveCamera: async (deviceId) => {
|
|
70953
72113
|
const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
|
|
70954
72114
|
return device instanceof HikvisionCamera ? device.getClipCamera() : null;
|
|
@@ -70975,11 +72135,18 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
|
|
|
70975
72135
|
access: "authenticated",
|
|
70976
72136
|
handler: service.streamHandler
|
|
70977
72137
|
});
|
|
72138
|
+
const thumb = await this.ctx.dataPlane?.serve({
|
|
72139
|
+
prefix: service.thumbPrefix,
|
|
72140
|
+
access: "authenticated",
|
|
72141
|
+
handler: service.thumbHandler
|
|
72142
|
+
});
|
|
70978
72143
|
this.ctx.logger.info("Hikvision clip data planes served", { meta: {
|
|
70979
72144
|
mediaPath: `/addon/${HIKVISION_ADDON_ID}/${service.mediaPrefix}`,
|
|
70980
72145
|
streamPath: `/addon/${HIKVISION_ADDON_ID}/${service.streamPrefix}`,
|
|
72146
|
+
thumbPath: `/addon/${HIKVISION_ADDON_ID}/${service.thumbPrefix}`,
|
|
70981
72147
|
mediaServed: media !== void 0,
|
|
70982
|
-
streamServed: stream !== void 0
|
|
72148
|
+
streamServed: stream !== void 0,
|
|
72149
|
+
thumbServed: thumb !== void 0
|
|
70983
72150
|
} });
|
|
70984
72151
|
} catch (err) {
|
|
70985
72152
|
this.ctx.logger.warn("Hikvision clip data planes failed to serve — clips will not render", { meta: { error: err instanceof Error ? err.message : String(err) } });
|