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