@camstack/addon-provider-hikvision 1.2.125 → 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 +1190 -65
  2. package/dist/addon.mjs +1191 -66
  3. 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-i3eUVc-d.mjs
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",
@@ -25059,6 +25078,8 @@ function clipReasonHeaderValue(reason) {
25059
25078
  }
25060
25079
  /** One {@link ClipStillRefusalCodeSchema} token, alone. */
25061
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";
25062
25083
  /**
25063
25084
  * The clip STREAM route's own statement of what it is (D597): the value is
25064
25085
  * `forward-only`. A producer writes it on the 200; a consumer that dials a
@@ -25317,12 +25338,37 @@ var ClipWakeSchema = _enum(["authorised"]);
25317
25338
  * the size in the message, never truncates. Half a video is worse than an
25318
25339
  * honest refusal.
25319
25340
  *
25320
- * The clean follow-on is a CHUNKED read so a `high` twin of a long clip stops
25321
- * being refusable at all. That is a later slice, named here so the bound is
25322
- * not mistaken for a design ceiling.
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.
25323
25348
  */
25324
25349
  var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
25325
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
+ /**
25326
25372
  * A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
25327
25373
  *
25328
25374
  * `bytes` is the DECODED length, so nobody infers it from the base64 length,
@@ -25354,6 +25400,32 @@ var ClipBytesSchema = object({
25354
25400
  durationMs: number().positive().optional()
25355
25401
  });
25356
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
+ /**
25357
25429
  * Where a clip's STREAM can be dialled (D597) — the answer to
25358
25430
  * {@link videoclipsCapability.methods.dialClipStream}.
25359
25431
  *
@@ -25429,6 +25501,105 @@ var ClipStreamDialSchema = object({
25429
25501
  /** Why `servedAudio` is `none` although sound was asked for. */
25430
25502
  audioReason: ClipStreamAudioReasonSchema.optional()
25431
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
+ });
25432
25603
  var ClipSourceAvailabilitySchema = object({
25433
25604
  state: _enum([
25434
25605
  "ok",
@@ -25607,14 +25778,18 @@ var videoclipsCapability = {
25607
25778
  *
25608
25779
  * `getClipPlayback` is the right answer for a player: it hands back a URL
25609
25780
  * on a plane the hub serves `access:'authenticated'`, which a browser and a
25610
- * viewer session satisfy. It is the wrong answer for another ADDON. There
25611
- * is no addon→addon byte transport in this framework — `AddonDataPlane`
25612
- * only lets an addon SERVE, on `127.0.0.1` behind a per-listener secret
25613
- * only the hub may present — so a recorder that wants a camera's clip
25614
- * cannot fetch that URL. This method is the one seam that exists for it,
25615
- * and it is deliberately the same shape (and the same bound) as
25616
- * `recordingExport.readExportBytes`, which exists for the mirror-image
25617
- * reason.
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.
25618
25793
  *
25619
25794
  * Routing needs no `provider` pin: the id is source-prefixed and
25620
25795
  * self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
@@ -25681,6 +25856,65 @@ var videoclipsCapability = {
25681
25856
  auth: "protected"
25682
25857
  }),
25683
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
+ /**
25684
25918
  * Where this clip's STREAM can be dialled (D597) — the forward-only
25685
25919
  * fMP4 the provider writes from the first muxed byte, for the broker to
25686
25920
  * play through the same WebRTC session as recorded footage, with the
@@ -25717,10 +25951,88 @@ var videoclipsCapability = {
25717
25951
  }), ClipStreamDialSchema, {
25718
25952
  kind: "query",
25719
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"
25720
25984
  })
25721
25985
  }
25722
25986
  };
25723
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
+ /**
25724
26036
  * Optional client-side hints sent at session creation to help the provider
25725
26037
  * pick the best native source. All fields optional — a viewer that knows
25726
26038
  * nothing still gets a sane default. (Relocated from the retired `webrtc`
@@ -46982,6 +47294,12 @@ Object.freeze({
46982
47294
  addonId: null,
46983
47295
  access: "view"
46984
47296
  },
47297
+ "videoclips.getPlaybackOptions": {
47298
+ capName: "videoclips",
47299
+ capScope: "device",
47300
+ addonId: null,
47301
+ access: "view"
47302
+ },
46985
47303
  "videoclips.listClips": {
46986
47304
  capName: "videoclips",
46987
47305
  capScope: "device",
@@ -46994,6 +47312,12 @@ Object.freeze({
46994
47312
  addonId: null,
46995
47313
  access: "view"
46996
47314
  },
47315
+ "videoclips.offerClipBytes": {
47316
+ capName: "videoclips",
47317
+ capScope: "device",
47318
+ addonId: null,
47319
+ access: "view"
47320
+ },
46997
47321
  "videoclips.readClipBytes": {
46998
47322
  capName: "videoclips",
46999
47323
  capScope: "device",
@@ -49040,6 +49364,11 @@ Object.freeze({
49040
49364
  form: "single",
49041
49365
  optional: false
49042
49366
  }],
49367
+ "videoclips.getPlaybackOptions": [{
49368
+ name: "deviceId",
49369
+ form: "single",
49370
+ optional: false
49371
+ }],
49043
49372
  "videoclips.listClips": [{
49044
49373
  name: "deviceId",
49045
49374
  form: "single",
@@ -49050,6 +49379,11 @@ Object.freeze({
49050
49379
  form: "single",
49051
49380
  optional: false
49052
49381
  }],
49382
+ "videoclips.offerClipBytes": [{
49383
+ name: "deviceId",
49384
+ form: "single",
49385
+ optional: false
49386
+ }],
49053
49387
  "videoclips.readClipBytes": [{
49054
49388
  name: "deviceId",
49055
49389
  form: "single",
@@ -49713,24 +50047,73 @@ DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
49713
50047
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
49714
50048
  var MB = 1024 * 1024;
49715
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;
49716
50062
  /** A clip key is a hex digest. Anything else is refused rather than joined. */
49717
50063
  var SAFE_KEY = /^[A-Za-z0-9_-]{1,128}$/;
49718
- var KIND = "clip-media";
49719
- var EXTENSION = ".mp4";
50064
+ var CLIP_THUMB_KIND = "clip-thumbs";
50065
+ var CLIP_THUMB_EXTENSION = ".jpg";
49720
50066
  var ClipArtifactStore = class {
49721
50067
  options;
49722
50068
  maxFiles;
49723
50069
  maxBytes;
50070
+ kind;
50071
+ extension;
49724
50072
  constructor(options) {
49725
50073
  this.options = options;
49726
50074
  this.maxFiles = options.maxFilesPerDevice ?? 10;
49727
50075
  this.maxBytes = options.maxBytesPerDevice ?? 209715200;
50076
+ this.kind = options.kind ?? "clip-media";
50077
+ this.extension = options.extension ?? ".mp4";
49728
50078
  }
49729
50079
  dirFor(deviceId) {
49730
- return join(this.options.root, KIND, String(deviceId));
50080
+ return join(this.options.root, this.kind, String(deviceId));
49731
50081
  }
49732
50082
  pathFor(deviceId, clipKey) {
49733
- return join(this.dirFor(deviceId), `${clipKey}${EXTENSION}`);
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);
49734
50117
  }
49735
50118
  /** A scratch path to mux INTO, with its directory made. */
49736
50119
  async reserve(deviceId, clipKey) {
@@ -49781,7 +50164,7 @@ var ClipArtifactStore = class {
49781
50164
  }
49782
50165
  const out = [];
49783
50166
  for (const name of names) {
49784
- if (!name.endsWith(EXTENSION)) continue;
50167
+ if (!name.endsWith(this.extension)) continue;
49785
50168
  const path = join(this.dirFor(deviceId), name);
49786
50169
  try {
49787
50170
  const info = await stat(path);
@@ -50130,6 +50513,27 @@ async function listClipRows(client, input) {
50130
50513
  };
50131
50514
  }
50132
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
50133
50537
  //#region src/clips/clip-data-plane.ts
50134
50538
  /**
50135
50539
  * The addon's two clip routes: the finished FILE, and the STREAM.
@@ -50163,8 +50567,11 @@ async function listClipRows(client, input) {
50163
50567
  /** URL namespaces under `/addon/provider-hikvision/`. */
50164
50568
  var CLIP_MEDIA_PREFIX = "clip";
50165
50569
  var CLIP_STREAM_PREFIX = "clip-stream";
50570
+ var CLIP_THUMB_PREFIX = "clip-thumb";
50166
50571
  /** A clip key is a hex digest; nothing else may reach the filesystem. */
50167
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})$/;
50168
50575
  function pathnameOf(req) {
50169
50576
  const raw = req.url ?? "/";
50170
50577
  const query = raw.indexOf("?");
@@ -50272,6 +50679,87 @@ function createClipPlaybackHandler(deps) {
50272
50679
  }).pipe(res);
50273
50680
  };
50274
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
+ }
50275
50763
  /** The data plane's own form: the clip is named on the path. */
50276
50764
  var resolveClipStreamPathTarget = (req) => {
50277
50765
  const match = MEDIA_PATH.exec(pathnameOf(req));
@@ -50298,11 +50786,12 @@ var STREAM_HEADERS = {
50298
50786
  "cache-control": "no-store",
50299
50787
  [CLIP_STREAM_TRANSPORT_HEADER]: "forward-only"
50300
50788
  };
50301
- /** `404` = nothing here to serve; `502` = the camera. */
50789
+ /** `404` = nothing here to serve; `502` = the camera; `503` = its one slot. */
50302
50790
  function streamRefusalStatus(code) {
50303
50791
  switch (code) {
50304
50792
  case "unknown-device":
50305
50793
  case "no-catalog-row": return 404;
50794
+ case "replay-busy": return 503;
50306
50795
  case "camera-refused":
50307
50796
  case "fetch-failed": return 502;
50308
50797
  }
@@ -50405,24 +50894,36 @@ function createClipStreamHandler(deps) {
50405
50894
  //#endregion
50406
50895
  //#region src/clips/clip-replay-rate.ts
50407
50896
  /**
50408
- * ## Why nothing is SNAPPED to a whole number
50409
- *
50410
- * A first version rounded an estimate that came within 4 % of an integer, on
50411
- * the theory that a variable rate lands near one. The fixture replay measures
50412
- * **12.5 fps exactly** — 7 200 ticks of a 90 kHz clock between every access
50413
- * unit — and 12.5 is within 4 % of 13, so the rule turned an exact measurement
50414
- * into a wrong one. The clock is integral and the arithmetic is exact; there
50415
- * is nothing here a heuristic can improve, and a number the camera's own
50416
- * timestamps produced is the one the file should carry.
50417
- */
50418
- /**
50419
- * The rate the timestamps say, or `null`.
50420
- *
50421
- * Fewer than two units, a span of zero, or a span that goes BACKWARD all
50422
- * answer `unmeasured`. The last one is not hypothetical: a u32 RTP timestamp
50423
- * wraps, and a wrapped span is a negative number that would otherwise mux the
50424
- * clip at a nonsense rate rather than refusing.
50425
- */
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
+ }
50426
50927
  function estimateReplayFps(timestamps, clockRate) {
50427
50928
  if (timestamps.length < 2 || clockRate <= 0) return {
50428
50929
  fps: null,
@@ -50459,6 +50960,44 @@ var CLIP_STREAM_OPUS_RATE_HZ = 48e3;
50459
50960
  /** The PCMU RTP clock, and the rate the archive already is. */
50460
50961
  var CLIP_STREAM_PCMU_RATE_HZ = 8e3;
50461
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
+ /**
50462
51001
  * MEASURED, not chosen: ffmpeg's MP4 muxer refuses `pcm_mulaw` and its MOV
50463
51002
  * muxer takes it. The boxes a consumer walks are the same either way.
50464
51003
  */
@@ -50491,11 +51030,19 @@ function commonArgs(input) {
50491
51030
  "error",
50492
51031
  "-nostdin",
50493
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),
50494
51037
  "-f",
50495
51038
  demuxer,
50496
51039
  "-i",
50497
51040
  "pipe:0",
50498
51041
  ...input.audio !== null ? [
51042
+ "-analyzeduration",
51043
+ "0",
51044
+ "-probesize",
51045
+ String(CLIP_MUX_PROBE_BYTES),
50499
51046
  "-f",
50500
51047
  "mulaw",
50501
51048
  "-ar",
@@ -50522,6 +51069,10 @@ function buildClipStreamMuxArgs(input) {
50522
51069
  ...commonArgs(input),
50523
51070
  "-movflags",
50524
51071
  CLIP_STREAM_MOVFLAGS,
51072
+ "-frag_duration",
51073
+ String(CLIP_STREAM_FRAG_DURATION_US),
51074
+ "-flush_packets",
51075
+ "1",
50525
51076
  "-f",
50526
51077
  clipStreamContainerFor(input.audio),
50527
51078
  "pipe:1"
@@ -51141,6 +51692,25 @@ function encodeRtspRequest(request) {
51141
51692
  return Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1");
51142
51693
  }
51143
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
+ /**
51144
51714
  * Open a replay and start delivering.
51145
51715
  *
51146
51716
  * Rejects when the session could not be OPENED — the handshake is where a
@@ -51166,6 +51736,12 @@ async function openRtspReplay(target, handlers, deps) {
51166
51736
  const ended = new Promise((resolve) => {
51167
51737
  endResolve = resolve;
51168
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
+ });
51169
51745
  let idleTimer = null;
51170
51746
  let handshakeTimer = null;
51171
51747
  let videoChannel = -1;
@@ -51183,6 +51759,36 @@ async function openRtspReplay(target, handlers, deps) {
51183
51759
  if (ticks <= 0) return null;
51184
51760
  return Math.round(ticks / (videoTrack?.clockRate ?? 9e4) * 1e3);
51185
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
+ };
51186
51792
  const finish = (end) => {
51187
51793
  if (settled) return;
51188
51794
  settled = true;
@@ -51195,6 +51801,14 @@ async function openRtspReplay(target, handlers, deps) {
51195
51801
  videoUnits += 1;
51196
51802
  handlers.onVideo(tail);
51197
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
+ }
51198
51812
  deps.logger.info("videoclips: a replay session ended", {
51199
51813
  tags,
51200
51814
  meta: {
@@ -51206,11 +51820,11 @@ async function openRtspReplay(target, handlers, deps) {
51206
51820
  ...end.kind === "complete" ? { deliveredMs: end.deliveredMs } : {},
51207
51821
  videoUnits,
51208
51822
  audioPackets,
51209
- droppedPayloads: depacketizer?.dropped ?? 0
51823
+ droppedPayloads: depacketizer?.dropped ?? 0,
51824
+ teardown
51210
51825
  }
51211
51826
  });
51212
- socket?.destroy();
51213
- socket = null;
51827
+ releaseSocket(teardown === "sent");
51214
51828
  endResolve?.(end);
51215
51829
  };
51216
51830
  /**
@@ -51416,12 +52030,9 @@ async function openRtspReplay(target, handlers, deps) {
51416
52030
  ended,
51417
52031
  video,
51418
52032
  hasAudio: audio !== void 0,
51419
- close: () => {
51420
- if (settled) return;
51421
- try {
51422
- write("TEARDOWN", sessionUrl, {});
51423
- } catch {}
52033
+ close: async () => {
51424
52034
  finish({ kind: "closed" });
52035
+ await slotFreed;
51425
52036
  }
51426
52037
  };
51427
52038
  }
@@ -51501,7 +52112,26 @@ async function prepareClipFile(input, deps) {
51501
52112
  detail: err instanceof Error ? err.message : String(err)
51502
52113
  };
51503
52114
  }
52115
+ const abort = () => void session.close();
52116
+ deps.signal?.addEventListener("abort", abort, { once: true });
51504
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
+ }
51505
52135
  if (end.kind === "failed") return {
51506
52136
  kind: "refused",
51507
52137
  code: "camera-refused",
@@ -51606,6 +52236,201 @@ async function prepareClipFile(input, deps) {
51606
52236
  durationMs: durationMs > 0 ? durationMs : null
51607
52237
  };
51608
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
+ }
51609
52434
  function pathToken(req) {
51610
52435
  const raw = req.url ?? "/";
51611
52436
  const query = raw.indexOf("?");
@@ -51824,6 +52649,8 @@ var ClipStreamRun = class {
51824
52649
  audioGate = null;
51825
52650
  peerGate = null;
51826
52651
  answered = false;
52652
+ /** Settles when this run no longer holds the camera's one replay slot. */
52653
+ slotFreed = Promise.resolve();
51827
52654
  settle = null;
51828
52655
  bytesOut = 0;
51829
52656
  accessUnits = 0;
@@ -51856,6 +52683,7 @@ var ClipStreamRun = class {
51856
52683
  });
51857
52684
  });
51858
52685
  this.disposeGates();
52686
+ await this.slotFreed;
51859
52687
  return outcome;
51860
52688
  }
51861
52689
  async start() {
@@ -51902,8 +52730,9 @@ var ClipStreamRun = class {
51902
52730
  return;
51903
52731
  }
51904
52732
  this.heldVideo.push(unit);
51905
- if (this.heldVideo.length < this.deps.probeTarget) return;
51906
- const rate = estimateReplayFps(this.heldVideo.map((held) => held.timestamp), this.replay?.video.clockRate ?? 9e4);
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);
51907
52736
  if (rate.fps === null) {
51908
52737
  if (this.heldVideo.length < this.deps.probeMax) return;
51909
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`);
@@ -52114,7 +52943,7 @@ var ClipStreamRun = class {
52114
52943
  answer(outcome) {
52115
52944
  this.answered = true;
52116
52945
  this.disposeGates();
52117
- this.replay?.close();
52946
+ this.slotFreed = this.replay?.close() ?? Promise.resolve();
52118
52947
  if (outcome.kind !== "served") this.mux?.kill("SIGKILL");
52119
52948
  this.settle?.(outcome);
52120
52949
  this.settle = null;
@@ -52639,7 +53468,11 @@ function createHikvisionVideoclipsProvider(deps) {
52639
53468
  const remember = (deviceId, rows) => {
52640
53469
  for (const row of rows) rowsByKey.set(`${String(deviceId)}|${row.clipKey}`, row);
52641
53470
  };
52642
- const toClip = (row, catalogAsOf) => ({
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) => ({
52643
53476
  id: mintClipId({
52644
53477
  source: CLIP_SOURCE_ONBOARD,
52645
53478
  trackId: row.trackId,
@@ -52652,7 +53485,7 @@ function createHikvisionVideoclipsProvider(deps) {
52652
53485
  endMs: row.endMs
52653
53486
  },
52654
53487
  ...row.recordTypes.length > 0 ? { nativeTypes: [...row.recordTypes] } : {},
52655
- thumbnailUnavailable: { reason: "unsupported" },
53488
+ ...await deps.hasVouchedStill(deviceId, row.clipKey) ? { thumbnail: deps.stillUrl(deviceId, row.clipKey) } : { thumbnailMint: deps.stillUrl(deviceId, row.clipKey) },
52656
53489
  catalogAsOf,
52657
53490
  streams: { main: {
52658
53491
  id: mintClipId({
@@ -52746,7 +53579,7 @@ function createHikvisionVideoclipsProvider(deps) {
52746
53579
  trackId: device.trackId
52747
53580
  }
52748
53581
  });
52749
- return capped.map((row) => toClip(row, catalogAsOf));
53582
+ return await Promise.all(capped.map(async (row) => await toClip(device.deviceId, row, catalogAsOf)));
52750
53583
  },
52751
53584
  /**
52752
53585
  * The player's URL — a finished MP4 on this addon's own media plane, with
@@ -52837,6 +53670,102 @@ function createHikvisionVideoclipsProvider(deps) {
52837
53670
  };
52838
53671
  },
52839
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
+ /**
52840
53769
  * Where this clip's STREAM is dialled (D597) — the same questions
52841
53770
  * `readClipBytes` asks before the camera is touched, the same vocabulary,
52842
53771
  * and NO fetch: the URL is one shot on this provider's own listener and
@@ -52886,9 +53815,22 @@ function createClipService(deps) {
52886
53815
  const now = deps.now ?? (() => Date.now());
52887
53816
  const logger = deps.logger;
52888
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
+ });
52889
53829
  const producer = createClipStreamProducer({
52890
53830
  logger,
52891
- ...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 } : {}
52892
53834
  });
52893
53835
  /** Every row the provider has listed, mirrored here so the ROUTES — which
52894
53836
  * are reached without a cap call — can resolve a key without the camera. */
@@ -52951,12 +53893,23 @@ function createClipService(deps) {
52951
53893
  detail: "no-catalog-row: list this window before asking for its stream"
52952
53894
  };
52953
53895
  }
52954
- const outcome = await producer.stream({
52955
- camera: cameraFor(camera),
52956
- row,
52957
- audio: target.audio,
52958
- sink
52959
- });
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
+ }
52960
53913
  switch (outcome.kind) {
52961
53914
  case "served": return { kind: "served" };
52962
53915
  case "cut": return { kind: "cut" };
@@ -52971,19 +53924,52 @@ function createClipService(deps) {
52971
53924
  logger,
52972
53925
  stream: streamRoute
52973
53926
  });
52974
- const fileFor = async (deviceId, clipKey) => {
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) => {
52975
53937
  const camera = await deps.resolveCamera(deviceId);
52976
53938
  if (camera === null) return {
52977
53939
  kind: "refused",
52978
- code: "fetch-failed",
53940
+ code: "unknown-device",
52979
53941
  detail: `device ${String(deviceId)} is not a Hikvision camera`
52980
53942
  };
52981
53943
  const row = rowsByKey.get(rowKey(deviceId, clipKey));
52982
53944
  if (row === void 0) return {
52983
53945
  kind: "refused",
52984
- code: "fetch-failed",
53946
+ code: "no-catalog-row",
52985
53947
  detail: "no-catalog-row: list the window that holds this clip first"
52986
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
+ }
52987
53973
  return await prepareClipFile({
52988
53974
  camera: cameraFor(camera),
52989
53975
  row,
@@ -52991,9 +53977,70 @@ function createClipService(deps) {
52991
53977
  }, {
52992
53978
  logger,
52993
53979
  store,
52994
- ...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {}
53980
+ ...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {},
53981
+ ...deps.openReplay !== void 0 ? { openReplay: deps.openReplay } : {}
52995
53982
  });
52996
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
+ };
52997
54044
  return {
52998
54045
  provider: createHikvisionVideoclipsProvider({
52999
54046
  now,
@@ -53117,11 +54164,68 @@ function createClipService(deps) {
53117
54164
  };
53118
54165
  },
53119
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
+ },
53120
54224
  prepareBytes: async ({ device, row, maxBytes }) => {
53121
- const file = await fileFor(device.deviceId, row.clipKey);
54225
+ const file = await fileFor(device.deviceId, row.clipKey, "file");
53122
54226
  if (file.kind === "refused") return {
53123
54227
  kind: "refused",
53124
- code: file.code,
54228
+ code: asExportFailure(file),
53125
54229
  detail: file.detail
53126
54230
  };
53127
54231
  if (file.bytes > maxBytes) return {
@@ -53156,10 +54260,11 @@ function createClipService(deps) {
53156
54260
  }),
53157
54261
  mediaPrefix: CLIP_MEDIA_PREFIX,
53158
54262
  streamPrefix: CLIP_STREAM_PREFIX,
54263
+ thumbPrefix: CLIP_THUMB_PREFIX,
53159
54264
  mediaHandler: createClipPlaybackHandler({
53160
54265
  logger,
53161
54266
  resolvePath: async (deviceId, clipKey) => {
53162
- const file = await fileFor(deviceId, clipKey);
54267
+ const file = await fileFor(deviceId, clipKey, "file");
53163
54268
  if (file.kind === "ok") return file.path;
53164
54269
  deps.logger.warn("videoclips: a clip file was refused", {
53165
54270
  tags: { deviceId },
@@ -53176,6 +54281,11 @@ function createClipService(deps) {
53176
54281
  logger,
53177
54282
  stream: streamRoute
53178
54283
  }),
54284
+ thumbHandler: createClipThumbHandler({
54285
+ logger,
54286
+ readCached: (deviceId, clipKey) => thumbs.read(deviceId, clipKey),
54287
+ mint: mintStill
54288
+ }),
53179
54289
  disposeClipStreamDial: () => dial.dispose()
53180
54290
  };
53181
54291
  }
@@ -70988,9 +72098,17 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
70988
72098
  capability: failureContributionCapability,
70989
72099
  provider: { list: () => intercomFailureReport.list() }
70990
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
+ }
70991
72107
  this.clipService = createClipService({
70992
72108
  dataDir: this.ctx.dataDir,
70993
72109
  logger: this.ctx.logger,
72110
+ ...ffmpegBinaryPath === null ? {} : { ffmpegBinaryPath },
72111
+ ...this.ctx.peerBytes !== void 0 ? { peerBytes: this.ctx.peerBytes } : {},
70994
72112
  resolveCamera: async (deviceId) => {
70995
72113
  const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
70996
72114
  return device instanceof HikvisionCamera ? device.getClipCamera() : null;
@@ -71017,11 +72135,18 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
71017
72135
  access: "authenticated",
71018
72136
  handler: service.streamHandler
71019
72137
  });
72138
+ const thumb = await this.ctx.dataPlane?.serve({
72139
+ prefix: service.thumbPrefix,
72140
+ access: "authenticated",
72141
+ handler: service.thumbHandler
72142
+ });
71020
72143
  this.ctx.logger.info("Hikvision clip data planes served", { meta: {
71021
72144
  mediaPath: `/addon/${HIKVISION_ADDON_ID}/${service.mediaPrefix}`,
71022
72145
  streamPath: `/addon/${HIKVISION_ADDON_ID}/${service.streamPrefix}`,
72146
+ thumbPath: `/addon/${HIKVISION_ADDON_ID}/${service.thumbPrefix}`,
71023
72147
  mediaServed: media !== void 0,
71024
- streamServed: stream !== void 0
72148
+ streamServed: stream !== void 0,
72149
+ thumbServed: thumb !== void 0
71025
72150
  } });
71026
72151
  } catch (err) {
71027
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) } });