@camstack/addon-provider-amcrest 0.2.118 → 0.2.119

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 +3753 -129
  2. package/dist/addon.mjs +3753 -129
  3. package/package.json +7 -1
package/dist/addon.mjs CHANGED
@@ -2,6 +2,12 @@ import { createRequire } from "node:module";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { request } from "node:http";
4
4
  import { request as request$1 } from "node:https";
5
+ import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { createReadStream, createWriteStream } from "node:fs";
8
+ import { spawn } from "node:child_process";
9
+ import { Readable } from "node:stream";
10
+ import { pipeline } from "node:stream/promises";
5
11
  import { networkInterfaces } from "node:os";
6
12
  //#region \0rolldown/runtime.js
7
13
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
@@ -5368,7 +5374,7 @@ var ZodIssueCode = {
5368
5374
  var ZodFirstPartyTypeKind;
5369
5375
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5370
5376
  //#endregion
5371
- //#region ../types/dist/sleep-i3eUVc-d.mjs
5377
+ //#region ../types/dist/sleep-COWaSCAi.mjs
5372
5378
  /**
5373
5379
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5374
5380
  * window to float samples (D455).
@@ -7526,6 +7532,24 @@ object({
7526
7532
  unreachable: number()
7527
7533
  })
7528
7534
  });
7535
+ /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
7536
+ var PeerBytesTicketSchema = object({
7537
+ /** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
7538
+ url: string().min(1),
7539
+ /**
7540
+ * The HOST node this URL means something on — the hub or a named agent,
7541
+ * never a runner. {@link AddonPeerBytes.open} compares it to its own and
7542
+ * refuses `cross-node` by name when they differ, without dialling.
7543
+ */
7544
+ hostNodeId: string().min(1),
7545
+ expiresAtMs: number().int().nonnegative(),
7546
+ /**
7547
+ * What the producer DECLARED the body to be, when it knows — `null` when it
7548
+ * does not. Never `0` for unknown (D393): a consumer sizing a bound off this
7549
+ * must be able to tell "the producer did not say" from "the body is empty".
7550
+ */
7551
+ declaredBytes: number().int().nonnegative().nullable()
7552
+ });
7529
7553
  /**
7530
7554
  * Adoption job — the background form of `device-adoption.adopt`.
7531
7555
  *
@@ -24699,6 +24723,7 @@ _enum([
24699
24723
  "sleeping",
24700
24724
  "camera-refused",
24701
24725
  "no-keyframe",
24726
+ "decode-failed",
24702
24727
  "no-catalog-row",
24703
24728
  "unsupported",
24704
24729
  "unknown-device",
@@ -24942,6 +24967,49 @@ var ClipPlaybackSchema = object({
24942
24967
  */
24943
24968
  var ClipWakeSchema = _enum(["authorised"]);
24944
24969
  /**
24970
+ * Hard ceiling on ONE {@link videoclipsCapability.methods.readClipBytes} — the
24971
+ * same 50 MiB `RECORDING_EXPORT_MAX_READ_BYTES` uses, and for the same second
24972
+ * reason: the envelope is unary, so a base64 payload is held whole (~1.33× its
24973
+ * size) in the provider AND in the caller, on a hub this repo has already
24974
+ * OOM'd once (D9/D18).
24975
+ *
24976
+ * Measured clips sit far below it — 92 KB–2.29 MB for a sub twin, 455 KB for a
24977
+ * 16 s main clip — so the bound bites rarely. "Rarely" is not "never": a long
24978
+ * 4K main twin can exceed it, and above the bound the provider REFUSES with
24979
+ * the size in the message, never truncates. Half a video is worse than an
24980
+ * honest refusal.
24981
+ *
24982
+ * **Superseded as the way a clip export gets its bytes** (D613). The bound is
24983
+ * a property of the ENVELOPE, not of clips, so the cure was a transport with
24984
+ * no envelope: {@link videoclipsCapability.methods.offerClipBytes} hands back
24985
+ * a one-shot `peerBytes` ticket and the consumer streams it straight to disk,
24986
+ * holding nothing. This method stays for a consumer that genuinely wants the
24987
+ * bytes in hand, and for a provider not yet redeployed — with this bound,
24988
+ * which for an inline read is not going to move.
24989
+ */
24990
+ var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
24991
+ /**
24992
+ * Hard ceiling on ONE {@link videoclipsCapability.methods.offerClipBytes}
24993
+ * transfer — ten times {@link VIDEOCLIPS_MAX_READ_BYTES}, and the reasoning is
24994
+ * not "ten times more comfortable".
24995
+ *
24996
+ * The inline bound is set by what is HELD: a base64 envelope is the payload at
24997
+ * ~1.33× in the provider AND the same again in the caller, so 50 MiB of clip
24998
+ * is ~133 MiB of heap across two processes. Over `peerBytes` the consumer
24999
+ * holds one socket chunk at a time and writes straight through to the export
25000
+ * file, so its contribution is flat whatever the clip weighs. What is left is
25001
+ * the PRODUCER's own copy, and that is a property of how each provider makes a
25002
+ * clip rather than of this transport: Hikvision's fetch writes to a scratch
25003
+ * FILE and offers it (nothing is held), Reolink's cmd-5 transfer is collected
25004
+ * in memory and offered from there (one copy, the one it already had).
25005
+ *
25006
+ * So this number bounds the worst provider, not the transport, and it is named
25007
+ * separately so that lifting it is a statement about a provider rather than
25008
+ * about clips. Above it the provider REFUSES with the size in the message and
25009
+ * never truncates: half a video is worse than an honest refusal.
25010
+ */
25011
+ var VIDEOCLIPS_MAX_OFFER_BYTES = 500 * 1024 * 1024;
25012
+ /**
24945
25013
  * A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
24946
25014
  *
24947
25015
  * `bytes` is the DECODED length, so nobody infers it from the base64 length,
@@ -24973,6 +25041,32 @@ var ClipBytesSchema = object({
24973
25041
  durationMs: number().positive().optional()
24974
25042
  });
24975
25043
  /**
25044
+ * Where a clip's finished bytes can be TAKEN (D613) — the answer to
25045
+ * {@link videoclipsCapability.methods.offerClipBytes}.
25046
+ *
25047
+ * Everything {@link ClipBytesSchema} carries except the bytes themselves, plus
25048
+ * the one-shot ticket that leads to them. The metadata is answered BEFORE the
25049
+ * transfer on purpose: a consumer learns which twin it got, what to call the
25050
+ * file and how long the clip runs without having to read a byte, so a decision
25051
+ * it would make on that metadata (a wrong twin, an implausible duration) costs
25052
+ * no transfer at all.
25053
+ */
25054
+ var ClipBytesOfferSchema = object({
25055
+ /**
25056
+ * One shot, seconds-long, loopback, on the PROVIDER's own host. Open it with
25057
+ * `ctx.peerBytes.open(...)`, which refuses a ticket from another node by
25058
+ * name rather than dialling a port that means something else here.
25059
+ */
25060
+ ticket: PeerBytesTicketSchema,
25061
+ contentType: string(),
25062
+ /** Suggested filename, extension included. */
25063
+ name: string(),
25064
+ /** Which twin was actually served — see {@link ClipBytesSchema.served}. */
25065
+ served: CamProfileSchema,
25066
+ /** See {@link ClipBytesSchema.durationMs}. Absent when nothing measured it. */
25067
+ durationMs: number().positive().optional()
25068
+ });
25069
+ /**
24976
25070
  * Where a clip's STREAM can be dialled (D597) — the answer to
24977
25071
  * {@link videoclipsCapability.methods.dialClipStream}.
24978
25072
  *
@@ -25048,6 +25142,105 @@ var ClipStreamDialSchema = object({
25048
25142
  /** Why `servedAudio` is `none` although sound was asked for. */
25049
25143
  audioReason: ClipStreamAudioReasonSchema.optional()
25050
25144
  });
25145
+ /**
25146
+ * The playback rates a clip can be DELIVERED at, ascending, always with `1`.
25147
+ *
25148
+ * These are the BROKER's: it re-paces frames it has already demuxed — the
25149
+ * `MonotonicClock` divides source elapsed time by the factor and the pacer
25150
+ * pushes that much faster — so the domain is the recorded one, {0} ∪ [0.25, 4],
25151
+ * and this is the discrete ladder drawn from it. `1.5` is in it because a
25152
+ * re-pacer has no reason to refuse it.
25153
+ *
25154
+ * `8` and `16` are deliberately NOT here. They are the CAMERA's own
25155
+ * `<playSpeed>` (Reolink cmd-5 replay), a different mechanism, still unwired
25156
+ * (D597, D600) — a rate change there costs a new dial and a new stream, and
25157
+ * the camera's 8x would need a resample the clip audio path has no decoder
25158
+ * for. Offering them today accepts a rate and delivers 1x, which is the whole
25159
+ * defect this list exists to end (D612). When `playSpeed` IS wired its rates
25160
+ * join THIS array — a second list elsewhere is the second authority D62
25161
+ * forbids.
25162
+ *
25163
+ * Every entry must survive the broker's `clampPlaybackRate` unchanged: a set
25164
+ * that offers what the clamp then moves is the same lie one step later.
25165
+ */
25166
+ var CLIP_BROKER_PACED_RATES = [
25167
+ .25,
25168
+ .5,
25169
+ 1,
25170
+ 1.5,
25171
+ 2,
25172
+ 4
25173
+ ];
25174
+ /**
25175
+ * The rates a REALTIME-BOUND stream can be delivered at.
25176
+ *
25177
+ * **Measured, and it is the whole reason this second ladder exists.** A
25178
+ * Hikvision RTSP replay is not a fast fetch: it delivers the recording at the
25179
+ * speed it was recorded. On 1436, 2026-09-23, one stream carried 176 access
25180
+ * units in 14 274 ms of wall clock at a measured 12.5 fps — 14.08 s of media
25181
+ * in 14.27 s, **1.01× realtime** — and a whole 18 s clip took 22.3 s to fetch
25182
+ * end to end. Reolink's cmd-5 transfer, which {@link CLIP_BROKER_PACED_RATES}
25183
+ * was written for, lands the same bytes at 49–62× and therefore has the entire
25184
+ * clip in hand within the first second.
25185
+ *
25186
+ * The broker's pacer consumes `rate` seconds of media per second of wall
25187
+ * clock while the socket supplies one. So at any rate above 1 the buffer
25188
+ * drains at `(rate − 1)×` and a stream that is only seconds old runs dry
25189
+ * almost at once — the viewer stops, the queue empties, and the provider's
25190
+ * own drain gate eventually reports `peer-stalled`. Nothing about that is a
25191
+ * bug to be fixed with a bigger buffer: there is no buffer, because the bytes
25192
+ * do not exist yet.
25193
+ *
25194
+ * So the honest answer is the DECLARATION (D612's own rule, applied to the
25195
+ * case it did not yet have): a rate this transport cannot deliver is never
25196
+ * offered. Below 1 is free — a slower pacer only lets the buffer grow.
25197
+ *
25198
+ * When a clip is served from a FILE the constraint is gone with the transport,
25199
+ * which is why `file` keeps the full ladder.
25200
+ */
25201
+ var CLIP_REALTIME_SOURCE_RATES = [
25202
+ .25,
25203
+ .5,
25204
+ 1
25205
+ ];
25206
+ /**
25207
+ * What a surface may DRAW for this provider's clips — the answer to
25208
+ * {@link videoclipsCapability.methods.getPlaybackOptions} (D612).
25209
+ *
25210
+ * The envelope is a PROVIDER fact, not a clip fact, and that is measured, not
25211
+ * assumed: the broker's `chooseClipPath` reads exactly two inputs — whether
25212
+ * `dialClipStream` and `readClipBytes` are wired — and both are constants of
25213
+ * the broker's own closure over the provider's methods. The `profile` it is
25214
+ * handed is explicitly not read. So every clip of a provider is served the
25215
+ * same way, and a per-clip channel carried a value that could not vary. The
25216
+ * per-clip `clipTransport` server message was removed for exactly that reason.
25217
+ *
25218
+ * Queried per camera, before a clip is picked, so a control is rendered or
25219
+ * DISABLED rather than offered and refused at play time (D62: a disabled
25220
+ * control reads as unavailable, one that undoes the gesture reads as broken).
25221
+ */
25222
+ var ClipPlaybackOptionsSchema = object({
25223
+ /**
25224
+ * How this provider's clips reach the player. `stream` is the provider's
25225
+ * forward-only fMP4 (D597); `file` is one bounded by-handle fetch of the
25226
+ * whole clip, `stbl` indexed (D575).
25227
+ */
25228
+ transport: _enum(["stream", "file"]),
25229
+ /** `forward` = only ahead of the playhead. `free` = anywhere. */
25230
+ seek: _enum(["forward", "free"]),
25231
+ /** Frame-step BACKWARD is meaningful. Forward always is. */
25232
+ stepBack: boolean(),
25233
+ /** Whether the scrub gesture is served, as opposed to refused by name. */
25234
+ scrub: boolean(),
25235
+ /**
25236
+ * The rates that can be delivered, ascending, always containing `1`. The
25237
+ * viewer draws its picker from this and from nothing else — a constant it
25238
+ * keeps instead is the second authority that produced the defect: `8` and
25239
+ * `16` were offered, the broker clamped them to `4`, and no line anywhere
25240
+ * said so. `0` is not a member: pause is the absence of a rate.
25241
+ */
25242
+ rates: array(number().positive()).min(1).readonly()
25243
+ });
25051
25244
  var ClipSourceAvailabilitySchema = object({
25052
25245
  state: _enum([
25053
25246
  "ok",
@@ -25097,138 +25290,389 @@ var ClipSourceSchema = object({
25097
25290
  addonId: string().optional(),
25098
25291
  availability: ClipSourceAvailabilitySchema
25099
25292
  });
25100
- DeviceType.Camera, method(object({
25101
- deviceId: number(),
25102
- since: number(),
25103
- until: number(),
25104
- limit: number().int().positive().optional(),
25105
- /**
25106
- * WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
25107
- * carries, never a source id and never a list. **Required** (D554 amended).
25108
- *
25109
- * A provider the device is not bound to is refused by name rather than
25110
- * answered by another one (D552's `rejectUnresolvedAddonPin` rule).
25111
- *
25112
- * It was optional, documented as "absent means the device's BOUND
25113
- * provider, which is CamStack on every camera". No code implemented
25114
- * that. Measured on the live hub 2026-09-20 — device 592, bound to
25115
- * `recorder` AND `provider-reolink` — a bare call with `limit: 3`
25116
- * answered SIX rows, three from each source, merged newest-first:
25117
- * `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
25118
- * so absence bought the union this method exists not to be, and
25119
- * `limit` meant `limit × sources`.
25120
- *
25121
- * There is nothing to restore the default to. `getBindings` answers a
25122
- * collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
25123
- * `setWrapperActive` — the singleton authority that could name one —
25124
- * throws for a collection cap by design. Naming CamStack here instead
25125
- * would privilege one addon by id inside a surface whose premise is
25126
- * that sources are peers, and would be wrong on the first camera with
25127
- * no recorder binding. So absence is REFUSED, in the schema, where the
25128
- * generated types make it unomittable rather than merely discouraged.
25129
- *
25130
- * The default belongs to the SURFACE, which has the `listSources` rows
25131
- * and can say which one it picked (D569 § 1–3; the viewer already
25132
- * always sends this).
25133
- *
25134
- * It replaced `sources?: string[]`, a VIEW filter over source ids that
25135
- * assumed the answer was a fan-out over everything a camera has. The
25136
- * operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
25137
- * solo"* — so the list is asked of one provider at a time and there is
25138
- * nothing to filter out of it.
25139
- */
25140
- provider: string().min(1)
25141
- }), array(ClipSchema).readonly(), {
25142
- kind: "query",
25143
- auth: "protected"
25144
- }), method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
25145
- kind: "query",
25146
- auth: "protected"
25147
- }), method(object({
25148
- deviceId: number(),
25149
- clipId: string(),
25150
- /**
25151
- * Which twin to serve, on the ONE quality scale the system already has
25152
- * (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
25153
- * twin; both ids are already on the row so this never re-searches the
25154
- * camera. Absent means the provider's own default (the sub file, which
25155
- * every source is measured to hold).
25156
- *
25157
- * `auto` is deliberately NOT accepted here: a stored file has no
25158
- * broker session, so the adaptive tier cannot be resolved for it. The
25159
- * viewer resolves `auto` to a profile the same way live does, before
25160
- * it calls (D549 15).
25161
- */
25162
- profile: CamProfileSchema.optional()
25163
- }), ClipPlaybackSchema, {
25164
- kind: "query",
25165
- auth: "protected"
25166
- }), optionalMethod(object({
25167
- deviceId: number(),
25168
- clipId: string().min(1),
25169
- /**
25170
- * WHICH provider holds the bytes — the `addonId` a
25171
- * {@link ClipSourceSchema} row carries. **Required**, for the reason
25172
- * `listClips` states one method above and for a second one this method
25173
- * learned the hard way.
25174
- *
25175
- * The caller is the stream broker, and the broker lives in
25176
- * `addon-pipeline` — the same addon that registers the RECORDER's
25177
- * videoclips provider. With no provider named, the in-process
25178
- * resolution handed it its own sibling, which implements this method
25179
- * solely to explain that an analytics visit has no file behind it. So
25180
- * every onboard clip answered with that sentence, a flash of video and
25181
- * nothing more, while the SAME call from outside the addon reached the
25182
- * right provider and returned the bytes (measured on 3628,
25183
- * 2026-09-21).
25184
- */
25185
- provider: string().min(1),
25186
- /**
25187
- * Which twin to fetch, on the mapping D549 15 already fixed:
25188
- * `low | mid` → the sub file, `high` → the main twin. `auto` is not
25189
- * accepted here for the same reason it is not accepted by
25190
- * `getClipPlayback` — a stored file has no broker session, so the
25191
- * adaptive tier cannot be resolved for it.
25192
- */
25193
- profile: CamProfileSchema.optional(),
25194
- /**
25195
- * The CALLER's byte bound, so an over-size clip is refused before it is
25196
- * read and encoded rather than after. Capped by
25197
- * {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
25198
- * that ceiling.
25199
- */
25200
- maxBytes: number().int().positive().optional(),
25293
+ var videoclipsCapability = {
25294
+ name: "videoclips",
25295
+ scope: "device",
25296
+ mode: "collection",
25297
+ kind: "wrapper",
25298
+ defaultActive: true,
25299
+ /** A clip is a window over a camera's footage — the cap is meaningless on a
25300
+ * sensor, a button or an event emitter, and the `defaultActive` auto-bind
25301
+ * reads this to decide which devices it may claim. */
25302
+ deviceTypes: [DeviceType.Camera],
25201
25303
  /**
25202
- * The operator's authorisation to wake a sleeping camera for this
25203
- * read. Absent — the default — means a sleeping standalone battery
25204
- * camera is REFUSED by name, before any session is opened.
25304
+ * The Clips section of a camera's device details is FRAMEWORK-DERIVED (D14):
25305
+ * the aggregator turns this declaration into the `type:'widget'` section and
25306
+ * `DeviceDetail.tsx` is never edited. `videoclips` is the first WRAPPER cap
25307
+ * to declare one — every previous `host/` widget cap is `deviceNative` — so
25308
+ * `device-config-widget-wrapped-binding.spec.ts` pins that a `kind:'wrapped'`
25309
+ * binding entry derives the same section a native one does.
25310
+ *
25311
+ * **It is a SECTION of the Recording tab, at the end of it — not a tab of
25312
+ * its own.** It shipped as a `clips` top-tab and the operator rejected the
25313
+ * placement: *"utilizzerei la stessa tab recordings, lì abbiamo già tutto il
25314
+ * necessario, una nuova sezione alla fine per le clips"*. The Recording tab
25315
+ * already holds the recorder's panel, the schedule bands and the unified
25316
+ * retention policy; footage the camera itself holds is the same question,
25317
+ * asked of a different store. `order: 100` puts it after all of them with
25318
+ * room left in front. The `clips` entry in `WELL_KNOWN_TABS` went with it —
25319
+ * a well-known id nobody declares is an invitation to mint the tab again.
25320
+ *
25321
+ * `topTab` stays: the browser owns a pane (a day's tiles, a source list and
25322
+ * a player) and the Config tab's inner bar has no room for one. The widget
25323
+ * itself is `host/clips-browser` in ui-library's `HOST_WIDGETS`, and because
25324
+ * the admin Recordings page renders every `location:'top-tab'` +
25325
+ * `tab:'recording'` section behind its camera picker
25326
+ * (`CameraRecordingSettingsSection`), this declaration lands the section on
25327
+ * BOTH surfaces with no second wiring.
25205
25328
  */
25206
- wake: ClipWakeSchema.optional()
25207
- }), ClipBytesSchema, {
25208
- kind: "query",
25209
- auth: "protected"
25210
- }), optionalMethod(object({
25211
- deviceId: number(),
25212
- clipId: string().min(1),
25213
- /** WHICH provider holds the camera — see `readClipBytes.provider`. */
25214
- provider: string().min(1),
25215
- /** Which twin — `high` → the main file, everything else the sub (D599). */
25216
- profile: CamProfileSchema.optional(),
25329
+ deviceConfig: { ui: {
25330
+ kind: "widget",
25331
+ widgetId: "host/clips-browser",
25332
+ tab: "recording",
25333
+ topTab: true,
25334
+ label: "Clips",
25335
+ order: 100
25336
+ } },
25337
+ methods: {
25338
+ listClips: method(object({
25339
+ deviceId: number(),
25340
+ since: number(),
25341
+ until: number(),
25342
+ limit: number().int().positive().optional(),
25343
+ /**
25344
+ * WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
25345
+ * carries, never a source id and never a list. **Required** (D554 amended).
25346
+ *
25347
+ * A provider the device is not bound to is refused by name rather than
25348
+ * answered by another one (D552's `rejectUnresolvedAddonPin` rule).
25349
+ *
25350
+ * It was optional, documented as "absent means the device's BOUND
25351
+ * provider, which is CamStack on every camera". No code implemented
25352
+ * that. Measured on the live hub 2026-09-20 — device 592, bound to
25353
+ * `recorder` AND `provider-reolink` — a bare call with `limit: 3`
25354
+ * answered SIX rows, three from each source, merged newest-first:
25355
+ * `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
25356
+ * so absence bought the union this method exists not to be, and
25357
+ * `limit` meant `limit × sources`.
25358
+ *
25359
+ * There is nothing to restore the default to. `getBindings` answers a
25360
+ * collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
25361
+ * `setWrapperActive` — the singleton authority that could name one —
25362
+ * throws for a collection cap by design. Naming CamStack here instead
25363
+ * would privilege one addon by id inside a surface whose premise is
25364
+ * that sources are peers, and would be wrong on the first camera with
25365
+ * no recorder binding. So absence is REFUSED, in the schema, where the
25366
+ * generated types make it unomittable rather than merely discouraged.
25367
+ *
25368
+ * The default belongs to the SURFACE, which has the `listSources` rows
25369
+ * and can say which one it picked (D569 § 1–3; the viewer already
25370
+ * always sends this).
25371
+ *
25372
+ * It replaced `sources?: string[]`, a VIEW filter over source ids that
25373
+ * assumed the answer was a fan-out over everything a camera has. The
25374
+ * operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
25375
+ * solo"* — so the list is asked of one provider at a time and there is
25376
+ * nothing to filter out of it.
25377
+ */
25378
+ provider: string().min(1)
25379
+ }), array(ClipSchema).readonly(), {
25380
+ kind: "query",
25381
+ auth: "protected"
25382
+ }),
25383
+ /**
25384
+ * The sources this camera has, WITH the reason any of them cannot answer.
25385
+ *
25386
+ * Asked separately from `listClips` because an empty clip list is
25387
+ * ambiguous and this is the only place the ambiguity is resolved: every
25388
+ * bound provider contributes its own rows, and a provider that could not
25389
+ * be reached at all still produces one row saying so. A surface that draws
25390
+ * "no clips" without reading this is drawing a guess.
25391
+ */
25392
+ listSources: method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
25393
+ kind: "query",
25394
+ auth: "protected"
25395
+ }),
25396
+ getClipPlayback: method(object({
25397
+ deviceId: number(),
25398
+ clipId: string(),
25399
+ /**
25400
+ * Which twin to serve, on the ONE quality scale the system already has
25401
+ * (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
25402
+ * twin; both ids are already on the row so this never re-searches the
25403
+ * camera. Absent means the provider's own default (the sub file, which
25404
+ * every source is measured to hold).
25405
+ *
25406
+ * `auto` is deliberately NOT accepted here: a stored file has no
25407
+ * broker session, so the adaptive tier cannot be resolved for it. The
25408
+ * viewer resolves `auto` to a profile the same way live does, before
25409
+ * it calls (D549 15).
25410
+ */
25411
+ profile: CamProfileSchema.optional()
25412
+ }), ClipPlaybackSchema, {
25413
+ kind: "query",
25414
+ auth: "protected"
25415
+ }),
25416
+ /**
25417
+ * This clip's BYTES, base64, bounded — the by-handle read a clip EXPORT
25418
+ * pulls once (D558).
25419
+ *
25420
+ * `getClipPlayback` is the right answer for a player: it hands back a URL
25421
+ * on a plane the hub serves `access:'authenticated'`, which a browser and a
25422
+ * viewer session satisfy. It is the wrong answer for another ADDON: the
25423
+ * hub's proxy in front of that plane takes only a user credential, which
25424
+ * an addon does not hold, so a recorder that wants a camera's clip cannot
25425
+ * fetch that URL. This method is the shape that answer forced — the same
25426
+ * one (and the same bound) as `recordingExport.readExportBytes`.
25427
+ *
25428
+ * **It is no longer the only seam.** Until D613 there was no addon→addon
25429
+ * byte transport at all; there is now
25430
+ * ({@link offerClipBytes}, over `ctx.peerBytes`), it holds nothing on
25431
+ * either side, and it is what a clip EXPORT uses. This method remains for
25432
+ * a consumer that genuinely wants the bytes in hand, and as the named
25433
+ * fallback for a provider not yet redeployed.
25434
+ *
25435
+ * Routing needs no `provider` pin: the id is source-prefixed and
25436
+ * self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
25437
+ * to the source that claims it — and an id nobody claims is REFUSED rather
25438
+ * than answered by another source.
25439
+ *
25440
+ * The producer reuses the fetch path it already has, completion rules
25441
+ * included: a clip is taken by cmd 5 and finished on a short idle window
25442
+ * whose result is PROVED against the catalog row's own span, retried once
25443
+ * when it comes up materially short, and served-and-named when it is still
25444
+ * short (D568). A second fetch with different completion rules is exactly
25445
+ * the second authority D558 refuses to create.
25446
+ *
25447
+ * Every refusal THROWS with its reason and none of them is silent — the
25448
+ * sleep gate (decided before `getApi()`, liftable only by
25449
+ * {@link ClipWakeSchema}), a catalog row nobody claims, a clip with no
25450
+ * bytes behind it, a mux that failed, and the size bound. The caller turns
25451
+ * that reason into an operator-facing one; a truncated file is never an
25452
+ * answer.
25453
+ */
25454
+ readClipBytes: optionalMethod(object({
25455
+ deviceId: number(),
25456
+ clipId: string().min(1),
25457
+ /**
25458
+ * WHICH provider holds the bytes — the `addonId` a
25459
+ * {@link ClipSourceSchema} row carries. **Required**, for the reason
25460
+ * `listClips` states one method above and for a second one this method
25461
+ * learned the hard way.
25462
+ *
25463
+ * The caller is the stream broker, and the broker lives in
25464
+ * `addon-pipeline` — the same addon that registers the RECORDER's
25465
+ * videoclips provider. With no provider named, the in-process
25466
+ * resolution handed it its own sibling, which implements this method
25467
+ * solely to explain that an analytics visit has no file behind it. So
25468
+ * every onboard clip answered with that sentence, a flash of video and
25469
+ * nothing more, while the SAME call from outside the addon reached the
25470
+ * right provider and returned the bytes (measured on 3628,
25471
+ * 2026-09-21).
25472
+ */
25473
+ provider: string().min(1),
25474
+ /**
25475
+ * Which twin to fetch, on the mapping D549 15 already fixed:
25476
+ * `low | mid` → the sub file, `high` → the main twin. `auto` is not
25477
+ * accepted here for the same reason it is not accepted by
25478
+ * `getClipPlayback` — a stored file has no broker session, so the
25479
+ * adaptive tier cannot be resolved for it.
25480
+ */
25481
+ profile: CamProfileSchema.optional(),
25482
+ /**
25483
+ * The CALLER's byte bound, so an over-size clip is refused before it is
25484
+ * read and encoded rather than after. Capped by
25485
+ * {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
25486
+ * that ceiling.
25487
+ */
25488
+ maxBytes: number().int().positive().optional(),
25489
+ /**
25490
+ * The operator's authorisation to wake a sleeping camera for this
25491
+ * read. Absent — the default — means a sleeping standalone battery
25492
+ * camera is REFUSED by name, before any session is opened.
25493
+ */
25494
+ wake: ClipWakeSchema.optional()
25495
+ }), ClipBytesSchema, {
25496
+ kind: "query",
25497
+ auth: "protected"
25498
+ }),
25499
+ /**
25500
+ * Where this clip's finished bytes can be TAKEN — the by-handle read a
25501
+ * clip EXPORT pulls, over the addon→addon byte transport (D613).
25502
+ *
25503
+ * This is {@link readClipBytes} with the envelope removed. Same gates,
25504
+ * same vocabulary, same completion rules, same `served` contract — the
25505
+ * only difference is that the bytes travel over a one-shot loopback
25506
+ * socket instead of inside a base64 field, so neither side holds the
25507
+ * payload whole and the 50 MiB refusal on a long `high` twin stops
25508
+ * existing. The bound that remains is
25509
+ * {@link VIDEOCLIPS_MAX_OFFER_BYTES}, and it bounds the PRODUCER's own
25510
+ * copy rather than the transport.
25511
+ *
25512
+ * **The ticket is loopback and same-host.** A provider on an agent mints a
25513
+ * URL that means nothing on the hub, and `ctx.peerBytes.open` refuses it
25514
+ * `cross-node` by name rather than dialling whatever else holds that port
25515
+ * here. A consumer that can be on the other side of a node boundary from
25516
+ * its provider must be able to read that refusal and say so; it must not
25517
+ * treat it as "no bytes".
25518
+ *
25519
+ * **A ticket is a one-shot bearer credential with a seconds-long life.**
25520
+ * Take it immediately, never persist it, never log its `url`. An untaken
25521
+ * ticket costs the provider one map entry until its TTL, and outstanding
25522
+ * tickets are bounded — which is also what makes a per-frame misuse of
25523
+ * this method refuse by name rather than work slowly (D9/D18: this is a
25524
+ * by-handle fetch of finished media, not a frame pipe).
25525
+ *
25526
+ * Optional on the provider for the same reason `readClipBytes` is: a
25527
+ * source with no camera socket behind it has no bytes. A provider that
25528
+ * predates this method answers `NOT_IMPLEMENTED`, and a consumer may fall
25529
+ * back to `readClipBytes` — but it says so in the log, with the deploy
25530
+ * hint, because that fallback re-imposes the 50 MiB refusal and an
25531
+ * operator who sees `too-large-to-transfer` after this shipped is looking
25532
+ * at a stale addon, not at a clip that cannot be exported.
25533
+ */
25534
+ offerClipBytes: optionalMethod(object({
25535
+ deviceId: number(),
25536
+ clipId: string().min(1),
25537
+ /** WHICH provider holds the bytes — see `readClipBytes.provider`. */
25538
+ provider: string().min(1),
25539
+ /** Which twin — `low | mid` → the sub file, `high` → the main twin. */
25540
+ profile: CamProfileSchema.optional(),
25541
+ /**
25542
+ * The CALLER's byte bound, so an over-size clip is refused before the
25543
+ * camera is touched rather than after. Capped by
25544
+ * {@link VIDEOCLIPS_MAX_OFFER_BYTES} whatever is passed; absent means
25545
+ * that ceiling.
25546
+ */
25547
+ maxBytes: number().int().positive().optional(),
25548
+ /**
25549
+ * The operator's authorisation to wake a sleeping camera for this
25550
+ * read. Absent — the default — means a sleeping standalone battery
25551
+ * camera is REFUSED by name, before any session is opened.
25552
+ */
25553
+ wake: ClipWakeSchema.optional()
25554
+ }), ClipBytesOfferSchema, {
25555
+ kind: "query",
25556
+ auth: "protected"
25557
+ }),
25558
+ /**
25559
+ * Where this clip's STREAM can be dialled (D597) — the forward-only
25560
+ * fMP4 the provider writes from the first muxed byte, for the broker to
25561
+ * play through the same WebRTC session as recorded footage, with the
25562
+ * first frame at the first fragment instead of after the last byte.
25563
+ *
25564
+ * Nothing is fetched here: the answer is a one-shot URL on the
25565
+ * provider's own listener, and the camera is touched when it is dialled.
25566
+ * The gates the file path runs (`readClipBytes`) run on the dial too —
25567
+ * the id, the row, a clip still being written — and refuse in the same
25568
+ * vocabulary, THROWN with `clipExportFailure(<token>, <prose>)`. Every
25569
+ * profile is served (D599) and the answer states which twin it carries;
25570
+ * `high` is not refused. Optional on the provider for the same reason
25571
+ * `readClipBytes` is: a source with no camera socket behind it (the
25572
+ * recorder's analytics window, a HomeKit tee) has no stream.
25573
+ */
25574
+ dialClipStream: optionalMethod(object({
25575
+ deviceId: number(),
25576
+ clipId: string().min(1),
25577
+ /** WHICH provider holds the camera — see `readClipBytes.provider`. */
25578
+ provider: string().min(1),
25579
+ /** Which twin — `high` → the main file, everything else the sub (D599). */
25580
+ profile: CamProfileSchema.optional(),
25581
+ /**
25582
+ * Which audio codec the caller's session negotiated, so the provider
25583
+ * can mux it (D600). The ask travels on the DIAL because the mux is
25584
+ * spawned once, before the first byte, and cannot gain a track later.
25585
+ *
25586
+ * ABSENT IS `none`, never a guess: a caller that does not state an
25587
+ * ask is served the video-only stream that shipped first, so a broker
25588
+ * older than this field keeps working unchanged (D382 — an unstated
25589
+ * ask is zero).
25590
+ */
25591
+ audio: ClipStreamAudioSchema.optional()
25592
+ }), ClipStreamDialSchema, {
25593
+ kind: "query",
25594
+ auth: "protected"
25595
+ }),
25596
+ /**
25597
+ * What a surface may DRAW for this provider's clips: the rates it can be
25598
+ * played at, whether scrub is served, how far a position may be moved,
25599
+ * and whether a backward frame-step means anything (D612).
25600
+ *
25601
+ * **The only authority.** The per-clip `clipTransport` server message
25602
+ * that used to carry the same answer was removed: the broker's transport
25603
+ * choice reads nothing that varies per clip, so the clip level had no
25604
+ * information the provider does not already have, and two channels that
25605
+ * can disagree are worse than one (D62).
25606
+ *
25607
+ * Asked per camera and per provider, so it must stay CHEAP — it is a
25608
+ * statement about wiring, answered from a constant, never a call to the
25609
+ * camera. A provider answers with one of {@link CLIP_PLAYBACK_OPTIONS}
25610
+ * and never composes an envelope of its own.
25611
+ *
25612
+ * Optional, and absence is load-bearing: a provider that has not answered
25613
+ * has not restricted anything, and a viewer reads it as the freedom it
25614
+ * always had. See D612 on the rollout order that absence implies.
25615
+ */
25616
+ getPlaybackOptions: optionalMethod(object({
25617
+ deviceId: number(),
25618
+ /** WHICH provider to ask — the `addonId` a {@link ClipSourceSchema}
25619
+ * row carries. Required for the same reason `listClips` requires it:
25620
+ * a collection cap has no "the bound one" to resolve to (D554). */
25621
+ provider: string().min(1)
25622
+ }), ClipPlaybackOptionsSchema, {
25623
+ kind: "query",
25624
+ auth: "protected"
25625
+ })
25626
+ }
25627
+ };
25628
+ /**
25629
+ * NOTE ON PLACEMENT: this block lives AFTER the capability definition on
25630
+ * purpose. `scripts/lib/parse-cap.ts` finds a cap by the FIRST
25631
+ * `export const <X> = {` in the file, so an object literal declared above
25632
+ * `videoclipsCapability` is silently taken for the capability itself and
25633
+ * codegen emits a `DeviceProxy` entry that does not type-check. Found the
25634
+ * hard way, 2026-09-23.
25635
+ */
25636
+ /**
25637
+ * The two envelopes, spelled ONCE.
25638
+ *
25639
+ * A provider answers with the entry for the transport its own wiring buys —
25640
+ * `stream` if it implements `dialClipStream`, `file` if it implements only
25641
+ * `readClipBytes` — and never composes one of its own. One table is what
25642
+ * makes "the provider may not promise what no clip can be given" structural
25643
+ * instead of a discipline: there is nothing else to promise.
25644
+ */
25645
+ var CLIP_PLAYBACK_OPTIONS = {
25646
+ stream: {
25647
+ transport: "stream",
25648
+ seek: "forward",
25649
+ stepBack: false,
25650
+ scrub: false,
25651
+ rates: CLIP_BROKER_PACED_RATES
25652
+ },
25217
25653
  /**
25218
- * Which audio codec the caller's session negotiated, so the provider
25219
- * can mux it (D600). The ask travels on the DIAL because the mux is
25220
- * spawned once, before the first byte, and cannot gain a track later.
25654
+ * A `stream` whose SOURCE runs at realtime — a Hikvision RTSP replay.
25221
25655
  *
25222
- * ABSENT IS `none`, never a guess: a caller that does not state an
25223
- * ask is served the video-only stream that shipped first, so a broker
25224
- * older than this field keeps working unchanged (D382 — an unstated
25225
- * ask is zero).
25656
+ * Same verbs as `stream`, a shorter rate ladder, and the difference is a
25657
+ * measurement rather than a preference: see
25658
+ * {@link CLIP_REALTIME_SOURCE_RATES}. A third entry rather than a parameter,
25659
+ * because a provider must still be able to do nothing but NAME one of these.
25226
25660
  */
25227
- audio: ClipStreamAudioSchema.optional()
25228
- }), ClipStreamDialSchema, {
25229
- kind: "query",
25230
- auth: "protected"
25231
- });
25661
+ realtimeStream: {
25662
+ transport: "stream",
25663
+ seek: "forward",
25664
+ stepBack: false,
25665
+ scrub: false,
25666
+ rates: CLIP_REALTIME_SOURCE_RATES
25667
+ },
25668
+ file: {
25669
+ transport: "file",
25670
+ seek: "free",
25671
+ stepBack: true,
25672
+ scrub: true,
25673
+ rates: CLIP_BROKER_PACED_RATES
25674
+ }
25675
+ };
25232
25676
  /**
25233
25677
  * Optional client-side hints sent at session creation to help the provider
25234
25678
  * pick the best native source. All fields optional — a viewer that knows
@@ -27729,6 +28173,108 @@ var recordingOnboardCapability = {
27729
28173
  * whether persisting is worth a SQLite commit. */
27730
28174
  volatileStateFields: ["lastFetchedAt"]
27731
28175
  };
28176
+ /** Minutes in a day. `endMinute === 1440` means "to end of day". */
28177
+ var MINUTES_PER_DAY = 1440;
28178
+ /**
28179
+ * Day-of-week names in the cap's index order (0 = Monday), for messages
28180
+ * an operator reads and for Hikvision's `<DayOfWeek>` element.
28181
+ */
28182
+ var DAY_NAMES = [
28183
+ "Monday",
28184
+ "Tuesday",
28185
+ "Wednesday",
28186
+ "Thursday",
28187
+ "Friday",
28188
+ "Saturday",
28189
+ "Sunday"
28190
+ ];
28191
+ /**
28192
+ * Does `patch` ask this camera for something it cannot do?
28193
+ *
28194
+ * Returns the operator-readable reason, or `null` when every field in
28195
+ * the patch is within what `options` says this camera accepts. A
28196
+ * provider calls this BEFORE touching the camera and throws the string:
28197
+ * refusing is the point, and refusing identically on both vendors is why
28198
+ * this is one function.
28199
+ *
28200
+ * The order of checks is the order an operator would read them: the
28201
+ * scalar knobs first, then the schedule, because a schedule complaint is
28202
+ * longer and a scalar one is usually the real problem.
28203
+ */
28204
+ function describeOnboardRefusal(options, patch) {
28205
+ if (patch.enabled !== void 0 && !options.enabled.writable) return unwritable("the master recording switch", options.enabled.reason);
28206
+ if (patch.overwriteWhenFull !== void 0 && !options.overwriteWhenFull.writable) return unwritable("overwrite-when-full", options.overwriteWhenFull.reason);
28207
+ const preRefusal = refuseNumeric("pre-record seconds", patch.preRecordSec, options.preRecordSec, options.preRecordSecRange, options.preRecordSecAllowed);
28208
+ if (preRefusal) return preRefusal;
28209
+ const postRefusal = refuseNumeric("post-record seconds", patch.postRecordSec, options.postRecordSec, options.postRecordSecRange, options.postRecordSecAllowed);
28210
+ if (postRefusal) return postRefusal;
28211
+ const segmentRefusal = refuseNumeric("segment length (minutes)", patch.segmentMinutes, options.segmentMinutes, options.segmentMinutesRange, options.segmentMinutesAllowed);
28212
+ if (segmentRefusal) return segmentRefusal;
28213
+ if (patch.windows !== void 0) {
28214
+ const scheduleRefusal = refuseSchedule(options, patch.windows);
28215
+ if (scheduleRefusal) return scheduleRefusal;
28216
+ }
28217
+ return null;
28218
+ }
28219
+ function unwritable(what, reason) {
28220
+ return reason ? `this camera cannot change ${what}: ${reason}` : `this camera cannot change ${what}`;
28221
+ }
28222
+ function refuseNumeric(what, value, support, range, allowed) {
28223
+ if (value === void 0) return null;
28224
+ if (!support.writable) return unwritable(what, support.reason);
28225
+ if (!Number.isFinite(value)) return `${what} must be a number, got ${String(value)}`;
28226
+ if (allowed) {
28227
+ if (allowed.values.includes(value)) return null;
28228
+ const say = (v) => allowed.sentinel !== void 0 && v === allowed.sentinel.value ? allowed.sentinel.meaning === "no-limit" ? "no limit" : "off" : String(v);
28229
+ return `this camera accepts ${what} only as ${allowed.values.map(say).join(", ")}; ${say(value)} is not one of them`;
28230
+ }
28231
+ if (!range) return null;
28232
+ if (value < range.min || value > range.max) return `this camera accepts ${what} between ${String(range.min)} and ${String(range.max)}; ${String(value)} is outside that`;
28233
+ if (range.step > 0 && (value - range.min) % range.step !== 0) return `this camera accepts ${what} in steps of ${String(range.step)} from ${String(range.min)}; ${String(value)} is not one of them`;
28234
+ return null;
28235
+ }
28236
+ function refuseSchedule(options, windows) {
28237
+ const schedule = options.schedule;
28238
+ if (!schedule.support.writable) return unwritable("the recording schedule", schedule.support.reason);
28239
+ const allowed = new Set(schedule.triggers);
28240
+ for (const window of windows) {
28241
+ if (!allowed.has(window.trigger)) {
28242
+ const offer = schedule.triggers.length > 0 ? schedule.triggers.join(", ") : "none";
28243
+ return `this camera cannot record on "${window.trigger}"; it records on: ${offer}`;
28244
+ }
28245
+ if (window.endMinute <= window.startMinute) return `a window on ${dayName(window.day)} ends at or before it starts (${String(window.startMinute)} → ${String(window.endMinute)})`;
28246
+ const step = schedule.granularityMinutes;
28247
+ if (step > 1 && (window.startMinute % step !== 0 || window.endMinute % step !== 0)) return `this camera's schedule only moves in ${String(step)}-minute steps; the ${dayName(window.day)} window ${formatMinutes(window.startMinute)}–${formatMinutes(window.endMinute)} is not aligned to them`;
28248
+ }
28249
+ if (!schedule.supportsOverlappingTriggers) {
28250
+ const clash = findOverlapWithDifferentTrigger(windows);
28251
+ if (clash) return `this camera stores one trigger per time range, so "${clash.a.trigger}" and "${clash.b.trigger}" cannot both cover ${dayName(clash.a.day)} ${formatMinutes(Math.max(clash.a.startMinute, clash.b.startMinute))}–${formatMinutes(Math.min(clash.a.endMinute, clash.b.endMinute))}`;
28252
+ }
28253
+ return null;
28254
+ }
28255
+ function findOverlapWithDifferentTrigger(windows) {
28256
+ for (let i = 0; i < windows.length; i += 1) for (let j = i + 1; j < windows.length; j += 1) {
28257
+ const a = windows[i];
28258
+ const b = windows[j];
28259
+ if (!a || !b) continue;
28260
+ if (a.day !== b.day) continue;
28261
+ if (a.trigger === b.trigger) continue;
28262
+ if (a.startMinute < b.endMinute && b.startMinute < a.endMinute) return {
28263
+ a,
28264
+ b
28265
+ };
28266
+ }
28267
+ return null;
28268
+ }
28269
+ function dayName(day) {
28270
+ return DAY_NAMES[day] ?? `day ${String(day)}`;
28271
+ }
28272
+ /** `510` → `08:30`. For messages, not for the wire. */
28273
+ function formatMinutes(minute) {
28274
+ const hour = Math.floor(minute / 60);
28275
+ const rest = minute % 60;
28276
+ return `${String(hour).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
28277
+ }
27732
28278
  /**
27733
28279
  * Generic device-level status snapshot. Auto-registered by `BaseDevice`
27734
28280
  * for every device, regardless of provider — the kernel needs a uniform
@@ -33668,6 +34214,10 @@ _enum([
33668
34214
  "too-large-to-transfer",
33669
34215
  "wake-expired"
33670
34216
  ]);
34217
+ /** `<token>: <prose>` — the ONE composition of a clip export's failure string. */
34218
+ function clipExportFailure(code, detail) {
34219
+ return detail.length > 0 ? `${code}: ${detail}` : code;
34220
+ }
33671
34221
  /** Candidate download URLs (LAN first, then operator extra hosts). */
33672
34222
  var ExportDownloadSchema = object({
33673
34223
  url: string(),
@@ -46259,6 +46809,12 @@ Object.freeze({
46259
46809
  addonId: null,
46260
46810
  access: "view"
46261
46811
  },
46812
+ "videoclips.getPlaybackOptions": {
46813
+ capName: "videoclips",
46814
+ capScope: "device",
46815
+ addonId: null,
46816
+ access: "view"
46817
+ },
46262
46818
  "videoclips.listClips": {
46263
46819
  capName: "videoclips",
46264
46820
  capScope: "device",
@@ -46271,6 +46827,12 @@ Object.freeze({
46271
46827
  addonId: null,
46272
46828
  access: "view"
46273
46829
  },
46830
+ "videoclips.offerClipBytes": {
46831
+ capName: "videoclips",
46832
+ capScope: "device",
46833
+ addonId: null,
46834
+ access: "view"
46835
+ },
46274
46836
  "videoclips.readClipBytes": {
46275
46837
  capName: "videoclips",
46276
46838
  capScope: "device",
@@ -48317,6 +48879,11 @@ Object.freeze({
48317
48879
  form: "single",
48318
48880
  optional: false
48319
48881
  }],
48882
+ "videoclips.getPlaybackOptions": [{
48883
+ name: "deviceId",
48884
+ form: "single",
48885
+ optional: false
48886
+ }],
48320
48887
  "videoclips.listClips": [{
48321
48888
  name: "deviceId",
48322
48889
  form: "single",
@@ -48327,6 +48894,11 @@ Object.freeze({
48327
48894
  form: "single",
48328
48895
  optional: false
48329
48896
  }],
48897
+ "videoclips.offerClipBytes": [{
48898
+ name: "deviceId",
48899
+ form: "single",
48900
+ optional: false
48901
+ }],
48330
48902
  "videoclips.readClipBytes": [{
48331
48903
  name: "deviceId",
48332
48904
  form: "single",
@@ -49237,6 +49809,14 @@ var AmcrestCgiClient = class AmcrestCgiClient {
49237
49809
  * Write config values. Keys are raw Dahua paths (e.g.
49238
49810
  * `VideoColor[0][0].Brightness`); brackets are passed through unescaped
49239
49811
  * (Dahua requires them literal) and only values are URL-encoded.
49812
+ *
49813
+ * **Every key must belong to the SAME config table.** Measured on an
49814
+ * IP4M-1041B (2026-09-23): `MediaGlobal.PacketLength=6&Record[0].PreRecord=4`
49815
+ * answered `200 OK` and applied only `MediaGlobal`; with the operands
49816
+ * swapped, only `Record` changed. Within one table the write IS atomic —
49817
+ * two keys both land, and one bad key rejects the pair with a body of
49818
+ * `Error` under HTTP 200, which is why success is decided on the body
49819
+ * and never on the status code.
49240
49820
  */
49241
49821
  async setConfig(params) {
49242
49822
  const query = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
@@ -49244,6 +49824,78 @@ var AmcrestCgiClient = class AmcrestCgiClient {
49244
49824
  if (!/\bOK\b/i.test(body)) throw new Error(`setConfig rejected: ${body.trim().slice(0, 200)}`);
49245
49825
  }
49246
49826
  /**
49827
+ * Read the camera's own storage inventory.
49828
+ *
49829
+ * `storageDevice.cgi?action=getDeviceAllInfo` — verified 200 on an
49830
+ * IP4M-1041B, serving the same rows RPC2's `storage.getDeviceAllInfo`
49831
+ * returns, so this cap needs no RPC2 session. (`action=getCaps` on the
49832
+ * same CGI is 501 on this firmware; the record caps come from
49833
+ * {@link getRecordManagerCaps} instead.)
49834
+ */
49835
+ async getStorageDeviceAllInfo() {
49836
+ return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/storageDevice.cgi?action=getDeviceAllInfo"));
49837
+ }
49838
+ /**
49839
+ * Open a recorded FILE for reading — `GET /cgi-bin/RPC_Loadfile<FilePath>`.
49840
+ *
49841
+ * Measured on device 3836 (2026-09-23): **200**, a `content-length` that is
49842
+ * byte-exact against the catalog row's `Length`, TTFB 142–211 ms, 2.87–3.5
49843
+ * MB/s, and a genuine self-contained ISO MP4 with an AAC track. Three
49844
+ * simultaneous pulls of three files all completed byte-exact and nothing
49845
+ * wedged afterwards — the opposite of the Reolink behaviour D611 records.
49846
+ *
49847
+ * Two hazards a caller MUST handle, both measured:
49848
+ *
49849
+ * - **`Range` is ignored.** 200 with the whole body and no `content-range`,
49850
+ * on every form tried. There is no partial read of a recorded file.
49851
+ * - **A path that does not exist closes the TCP socket with zero bytes and
49852
+ * NO HTTP status.** A real path answers 200 on the same endpoint, so the
49853
+ * bare close specifically means "not found" — and it reaches the caller as
49854
+ * a thrown transport error, which must become a NAMED refusal and never an
49855
+ * absence (D611).
49856
+ *
49857
+ * `content-type` is `application/http` — not a media type. It is deliberately
49858
+ * not returned: a consumer that trusted it would mislabel an MP4.
49859
+ */
49860
+ async openRecordedFile(filePath, timeoutMs) {
49861
+ const encoded = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
49862
+ const res = await this.send(`/cgi-bin/RPC_Loadfile${encoded}`, timeoutMs);
49863
+ const declared = res.headers.get("content-length");
49864
+ const contentLength = declared === null ? null : Number(declared);
49865
+ return {
49866
+ status: res.status,
49867
+ contentLength: contentLength !== null && Number.isFinite(contentLength) && contentLength > 0 ? contentLength : null,
49868
+ body: res.body
49869
+ };
49870
+ }
49871
+ /**
49872
+ * What time the camera thinks it is, VERBATIM — `result=2026-09-23 11:29:27`.
49873
+ *
49874
+ * Its own LOCAL wall clock, with no zone on it, and the only thing that can
49875
+ * say what that clock is: measured 2026-09-23 answering `11:29:27` against a
49876
+ * host UTC of `09:29:27`. Everything the clip catalog sends or reads is in
49877
+ * this clock, so `clip-clock.ts` turns this string into the one offset the
49878
+ * whole clip path uses. Parsed there, never here: a read that failed must
49879
+ * reach the caller as a failure, not as a zero offset that looks like UTC.
49880
+ */
49881
+ async getCurrentTime() {
49882
+ return await this.getText("/cgi-bin/global.cgi?action=getCurrentTime");
49883
+ }
49884
+ /**
49885
+ * The camera's own statement of what it accepts for recording —
49886
+ * `MaxPreRecordTime`, `PacketLengthRange[0..1]`, `SupportHoliday`.
49887
+ */
49888
+ async getRecordManagerCaps() {
49889
+ return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/recordManager.cgi?action=getCaps"));
49890
+ }
49891
+ /**
49892
+ * The camera's own statement of what an event handler accepts —
49893
+ * `RecordLatch[0..1]` is the post-record bound.
49894
+ */
49895
+ async getEventManagerCaps() {
49896
+ return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/eventManager.cgi?action=getCaps"));
49897
+ }
49898
+ /**
49247
49899
  * Open the Dahua CGI event-manager attach stream — a long-lived
49248
49900
  * `multipart/x-mixed-replace` GET that pushes camera events
49249
49901
  * (`VideoMotion` Start/Stop, heartbeats, …) until the connection drops
@@ -49628,6 +50280,689 @@ function capExposureModeToAmcrest(mode) {
49628
50280
  return mode === "manual" ? 4 : 0;
49629
50281
  }
49630
50282
  //#endregion
50283
+ //#region src/clips/clip-availability.ts
50284
+ /**
50285
+ * What the storage read means for this SOURCE.
50286
+ *
50287
+ * `null` means "nothing to report" — the card is there and readable, so the
50288
+ * next question may be asked. Every other answer is terminal and the camera is
50289
+ * not searched.
50290
+ */
50291
+ function availabilityFromStorage(storage) {
50292
+ switch (storage.kind) {
50293
+ case "absent": return {
50294
+ state: "no-storage",
50295
+ reason: storage.reason
50296
+ };
50297
+ case "unknown": return {
50298
+ state: "unreachable",
50299
+ reason: storage.reason
50300
+ };
50301
+ case "present": return null;
50302
+ }
50303
+ }
50304
+ /**
50305
+ * The second gate, and it is not optional.
50306
+ *
50307
+ * Every time this provider sends the camera travels as the camera's LOCAL wall
50308
+ * clock. Without a measured offset there is no window to ask for — and the
50309
+ * failure mode of guessing is not an error, it is footage from two hours away
50310
+ * presented as the footage that was asked for. So a clock that could not be
50311
+ * read is `unreachable` by name, before a search is built.
50312
+ */
50313
+ function availabilityFromClock(clock) {
50314
+ if (clock !== null) return null;
50315
+ return {
50316
+ state: "unreachable",
50317
+ reason: "The camera did not answer what time it thinks it is, and its recordings are stamped in its own local clock — a window asked for without that offset would return the wrong footage."
50318
+ };
50319
+ }
50320
+ //#endregion
50321
+ //#region src/recording-onboard-mapping.ts
50322
+ /**
50323
+ * Bit position → the cap's trigger vocabulary.
50324
+ *
50325
+ * Every `TimeSection` entry is `"<mask> HH:MM:SS-HH:MM:SS"`, and `<mask>`
50326
+ * is the SET of triggers that time range records on. 3836 carries
50327
+ * `393222` = `0x60006` on all seven weekday rows: bits 1, 2, 17 and 18.
50328
+ *
50329
+ * Only these three bits are NAMED here, because only these three are
50330
+ * defensible from the Dahua CGI specification. Bits 17 and 18 are real
50331
+ * on this firmware and this repo cannot say what they mean — most likely
50332
+ * two IVS/smart kinds, and "most likely" is exactly the guess D608
50333
+ * forbids. An unnamed bit is therefore COUNTED as an unreadable window
50334
+ * and the schedule is reported unwritable (D391), which on 3836 it is.
50335
+ *
50336
+ * To lift that: identify bits 17 and 18 — set one of them alone on a
50337
+ * slot, observe which event actually starts a recording — and add them
50338
+ * here. Nothing else in this module changes.
50339
+ */
50340
+ var TRIGGER_BY_BIT = {
50341
+ 0: "continuous",
50342
+ 1: "motion",
50343
+ 2: "alarmInput"
50344
+ };
50345
+ /** Triggers an Amcrest schedule can be written with. */
50346
+ var AMCREST_TRIGGERS = [
50347
+ "continuous",
50348
+ "motion",
50349
+ "alarmInput"
50350
+ ];
50351
+ /** The cap's trigger vocabulary → its bit position. */
50352
+ var BIT_BY_TRIGGER = {
50353
+ continuous: 0,
50354
+ motion: 1,
50355
+ alarmInput: 2
50356
+ };
50357
+ /**
50358
+ * Dahua row index → the cap's day index.
50359
+ *
50360
+ * **Layout: row 0 is SUNDAY**, which the Dahua CGI specification states
50361
+ * and every Dahua/Amcrest UI renders Sunday-first; the cap's day 0 is
50362
+ * Monday (ISO). This is an ASSUMPTION in exactly the sense D608 records
50363
+ * for Reolink's mask: all seven weekday rows on 3836 are identical, and a
50364
+ * uniform schedule reads the same under either layout, so the reads
50365
+ * shipped here are exact and the assumption is unfalsifiable from this
50366
+ * camera. {@link weekdayRowsAreUniform} names the case. To settle it: set
50367
+ * one weekday only from the Amcrest app and re-read `Record`.
50368
+ */
50369
+ function rowToCapDay(row) {
50370
+ return (row + 6) % 7;
50371
+ }
50372
+ /** The cap's day index → the Dahua row index. Inverse of {@link rowToCapDay}. */
50373
+ function capDayToRow(day) {
50374
+ return (day + 1) % 7;
50375
+ }
50376
+ /** The Dahua key for one schedule slot. */
50377
+ function timeSectionKey(channel, row, slot) {
50378
+ return `Record[${String(channel)}].TimeSection[${String(row)}][${String(slot)}]`;
50379
+ }
50380
+ /**
50381
+ * A number the camera stated, or null when it did not state one.
50382
+ *
50383
+ * Never 0 for a missing or unparseable value: a measurement that failed
50384
+ * is not a measurement (D393). `TotalBytes` arrives as
50385
+ * `134202327040.000000` over CGI, so this parses a float and does not
50386
+ * demand an integer spelling.
50387
+ */
50388
+ function numberAt(table, key) {
50389
+ const raw = table.get(key);
50390
+ if (raw === void 0 || raw.trim() === "") return null;
50391
+ const value = Number(raw);
50392
+ return Number.isFinite(value) ? value : null;
50393
+ }
50394
+ /** A boolean the camera stated, or null when it did not state one. */
50395
+ function booleanAt(table, key) {
50396
+ const raw = table.get(key);
50397
+ if (raw === void 0) return null;
50398
+ if (raw === "true") return true;
50399
+ if (raw === "false") return false;
50400
+ return null;
50401
+ }
50402
+ var BYTES_PER_MB = 1024 * 1024;
50403
+ /** Bytes → whole MB, or null when the byte count was never stated (D393). */
50404
+ function toMegabytes(bytes) {
50405
+ if (bytes === null) return null;
50406
+ return Math.round(bytes / BYTES_PER_MB);
50407
+ }
50408
+ var VOLUME_STATUS_BY_STATE = {
50409
+ Success: "ok",
50410
+ Running: "ok",
50411
+ Error: "error",
50412
+ Unformatted: "unformatted",
50413
+ NoExist: "offline",
50414
+ Offline: "offline",
50415
+ Sleeping: "ok"
50416
+ };
50417
+ /**
50418
+ * Read `storageDevice.cgi?action=getDeviceAllInfo`.
50419
+ *
50420
+ * Verbatim from 3836 with the card fitted:
50421
+ *
50422
+ * ```
50423
+ * list.info[0].Name=/dev/mmc0
50424
+ * list.info[0].State=Success
50425
+ * list.info[0].Detail[0].Path=/mnt/sd
50426
+ * list.info[0].Detail[0].TotalBytes=134202327040.000000
50427
+ * list.info[0].Detail[0].UsedBytes=345309184.000000
50428
+ * list.info[0].Detail[0].Type=ReadWrite
50429
+ * list.info[0].Detail[0].IsError=false
50430
+ * ```
50431
+ *
50432
+ * EVERY detail row is returned, not the first usable one — an operator
50433
+ * asking what is in this camera is owed the camera's whole answer. A
50434
+ * table with no `info` row at all is `absent`: that is what a camera with
50435
+ * no card answers, and it is the read that turns
50436
+ * {@link RecordingOnboardStatus.recordingToNowhere} back on. An
50437
+ * unreadable answer is `unknown`, never folded into `absent` (D393).
50438
+ */
50439
+ function parseOnboardStorage(table) {
50440
+ const deviceIndices = /* @__PURE__ */ new Set();
50441
+ for (const key of table.keys()) {
50442
+ const match = /^list\.info\[(\d+)\]\./.exec(key);
50443
+ if (match?.[1] !== void 0) deviceIndices.add(Number(match[1]));
50444
+ }
50445
+ if (deviceIndices.size === 0) return {
50446
+ kind: "absent",
50447
+ reason: "The camera reports no storage device — there is no card in it."
50448
+ };
50449
+ const volumes = [];
50450
+ for (const device of [...deviceIndices].toSorted((a, b) => a - b)) {
50451
+ const prefix = `list.info[${String(device)}]`;
50452
+ const name = table.get(`${prefix}.Name`) ?? `device ${String(device)}`;
50453
+ const state = table.get(`${prefix}.State`);
50454
+ const detailIndices = /* @__PURE__ */ new Set();
50455
+ for (const key of table.keys()) {
50456
+ const match = new RegExp(`^list\\.info\\[${String(device)}\\]\\.Detail\\[(\\d+)\\]\\.`).exec(key);
50457
+ if (match?.[1] !== void 0) detailIndices.add(Number(match[1]));
50458
+ }
50459
+ if (detailIndices.size === 0) {
50460
+ volumes.push({
50461
+ id: name,
50462
+ label: name,
50463
+ status: statusOf(state, null),
50464
+ capacityMb: null,
50465
+ freeMb: null
50466
+ });
50467
+ continue;
50468
+ }
50469
+ for (const detail of [...detailIndices].toSorted((a, b) => a - b)) {
50470
+ const dPrefix = `${prefix}.Detail[${String(detail)}]`;
50471
+ const path = table.get(`${dPrefix}.Path`);
50472
+ const total = numberAt(table, `${dPrefix}.TotalBytes`);
50473
+ const used = numberAt(table, `${dPrefix}.UsedBytes`);
50474
+ const type = table.get(`${dPrefix}.Type`);
50475
+ const isError = booleanAt(table, `${dPrefix}.IsError`);
50476
+ const volume = {
50477
+ id: path === void 0 ? `${name}#${String(detail)}` : `${name}:${path}`,
50478
+ ...path === void 0 ? {} : { label: path },
50479
+ status: statusOf(state, isError),
50480
+ capacityMb: toMegabytes(total),
50481
+ freeMb: total === null || used === null ? null : toMegabytes(total - used),
50482
+ ...type === void 0 ? {} : { writable: type === "ReadWrite" }
50483
+ };
50484
+ volumes.push(volume);
50485
+ }
50486
+ }
50487
+ if (!volumes.some((volume) => volume.status === "ok")) return {
50488
+ kind: "absent",
50489
+ reason: "The camera lists storage, but no volume it can write to — the card is missing, unformatted or failed."
50490
+ };
50491
+ return {
50492
+ kind: "present",
50493
+ volumes
50494
+ };
50495
+ }
50496
+ function statusOf(state, isError) {
50497
+ if (isError === true) return "error";
50498
+ if (state === void 0) return "unknown";
50499
+ return VOLUME_STATUS_BY_STATE[state] ?? "unknown";
50500
+ }
50501
+ /**
50502
+ * `"393222 00:00:00-23:59:59"` → its three parts, or null when the slot
50503
+ * is not something this reader understands.
50504
+ *
50505
+ * `23:59:59` is the firmware's spelling of end-of-day and becomes 1440,
50506
+ * exactly as Hikvision's `24:00` does — collapsing it to 1439 would shave
50507
+ * a minute off every whole-day window, and to 0 would empty it. Any OTHER
50508
+ * non-zero seconds value is refused rather than rounded: the cap's
50509
+ * resolution is minutes and rounding is how an operator's 06:30:30
50510
+ * becomes 06:30 with nothing saying so.
50511
+ */
50512
+ function parseTimeSectionSlot(raw) {
50513
+ const match = /^\s*(-?\d+)\s+(\d{2}):(\d{2}):(\d{2})-(\d{2}):(\d{2}):(\d{2})\s*$/.exec(raw);
50514
+ if (!match) return null;
50515
+ const mask = Number(match[1]);
50516
+ if (!Number.isInteger(mask) || mask < 0) return null;
50517
+ const start = clockToMinutes(match[2], match[3], match[4]);
50518
+ const end = clockToMinutes(match[5], match[6], match[7]);
50519
+ if (start === null || end === null) return null;
50520
+ return {
50521
+ mask,
50522
+ startMinute: start,
50523
+ endMinute: end
50524
+ };
50525
+ }
50526
+ function clockToMinutes(hour, minute, second) {
50527
+ if (hour === void 0 || minute === void 0 || second === void 0) return null;
50528
+ const h = Number(hour);
50529
+ const m = Number(minute);
50530
+ const s = Number(second);
50531
+ if (h > 23 || m > 59 || s > 59) return null;
50532
+ if (h === 23 && m === 59 && s === 59) return MINUTES_PER_DAY;
50533
+ if (s !== 0) return null;
50534
+ return h * 60 + m;
50535
+ }
50536
+ /** Every bit set in `mask`, low to high. */
50537
+ function setBits(mask) {
50538
+ const out = [];
50539
+ for (let bit = 0; bit < 32; bit += 1) if ((mask & 1 << bit) !== 0) out.push(bit);
50540
+ return out;
50541
+ }
50542
+ /**
50543
+ * Expand the `Record[ch].TimeSection[row][slot]` grid into windows.
50544
+ *
50545
+ * One slot can name SEVERAL triggers at once — that is what the bitfield
50546
+ * is — so one slot becomes one window per NAMED bit. An unnamed bit, a
50547
+ * slot that will not parse, and every bit of the holiday row are counted
50548
+ * as unreadable: each is coverage the camera holds and the operator is
50549
+ * not being shown, and a schedule that silently shows fewer rows than the
50550
+ * camera holds is how a shorter one gets saved back (D391).
50551
+ */
50552
+ function parseSchedule(table, channel) {
50553
+ const windows = [];
50554
+ let unreadable = 0;
50555
+ let anyCoverage = false;
50556
+ for (let row = 0; row <= 7; row += 1) for (let slot = 0; slot < 6; slot += 1) {
50557
+ const raw = table.get(timeSectionKey(channel, row, slot));
50558
+ if (raw === void 0) continue;
50559
+ const parsed = parseTimeSectionSlot(raw);
50560
+ if (parsed === null) {
50561
+ unreadable += 1;
50562
+ anyCoverage = true;
50563
+ continue;
50564
+ }
50565
+ if (parsed.mask === 0) continue;
50566
+ anyCoverage = true;
50567
+ const bits = setBits(parsed.mask);
50568
+ if (row === 7) {
50569
+ unreadable += bits.length;
50570
+ continue;
50571
+ }
50572
+ if (parsed.endMinute <= parsed.startMinute) {
50573
+ unreadable += bits.length;
50574
+ continue;
50575
+ }
50576
+ for (const bit of bits) {
50577
+ const trigger = TRIGGER_BY_BIT[bit];
50578
+ if (trigger === void 0) {
50579
+ unreadable += 1;
50580
+ continue;
50581
+ }
50582
+ windows.push({
50583
+ trigger,
50584
+ day: rowToCapDay(row),
50585
+ startMinute: parsed.startMinute,
50586
+ endMinute: parsed.endMinute
50587
+ });
50588
+ }
50589
+ }
50590
+ return {
50591
+ windows,
50592
+ unreadable,
50593
+ anyCoverage
50594
+ };
50595
+ }
50596
+ /** Read both caps tables into {@link AmcrestRecordLimits}. */
50597
+ function parseRecordLimits(input) {
50598
+ const record = input.recordCaps;
50599
+ const event = input.eventCaps;
50600
+ const pair = (table, key) => {
50601
+ if (table === null) return null;
50602
+ const min = numberAt(table, `${key}[0]`);
50603
+ const max = numberAt(table, `${key}[1]`);
50604
+ if (min === null || max === null || max < min) return null;
50605
+ return {
50606
+ min,
50607
+ max
50608
+ };
50609
+ };
50610
+ return {
50611
+ maxPreRecordSec: record === null ? null : numberAt(record, "MaxPreRecordTime"),
50612
+ packetLengthMinutes: pair(record, "PacketLengthRange"),
50613
+ recordLatchSec: pair(event, "RecordLatch")
50614
+ };
50615
+ }
50616
+ /** Index of the local storage group, or null when the camera named none. */
50617
+ function findLocalStorageGroup(table) {
50618
+ for (const [key, value] of table) {
50619
+ const match = /^StorageGroup\[(\d+)\]\.Name$/.exec(key);
50620
+ if (match?.[1] !== void 0 && value === "ReadWrite") return Number(match[1]);
50621
+ }
50622
+ return null;
50623
+ }
50624
+ /**
50625
+ * Overwrite-when-full, from BOTH places this firmware keeps it.
50626
+ *
50627
+ * Measured: `MediaGlobal.OverWrite` and `StorageGroup[0].OverWrite` are
50628
+ * INDEPENDENT — setting the first to `false` left the second `true` — and
50629
+ * nothing on the camera says which one the recorder honours. The card is
50630
+ * 134 GB and settling it by filling it is not an experiment this repo is
50631
+ * going to run.
50632
+ *
50633
+ * So the read is the two authorities' AGREEMENT: equal → that value;
50634
+ * different → null, which the status renders as "the camera did not say"
50635
+ * rather than picking a winner. The WRITE sets both (one request per
50636
+ * table), so a value CamStack wrote can never be the disagreeing case.
50637
+ */
50638
+ function readOverwriteWhenFull(input) {
50639
+ const global = input.mediaGlobal === null ? null : booleanAt(input.mediaGlobal, "MediaGlobal.OverWrite");
50640
+ const groupIndex = input.storageGroup === null ? null : findLocalStorageGroup(input.storageGroup);
50641
+ const group = input.storageGroup === null || groupIndex === null ? null : booleanAt(input.storageGroup, `StorageGroup[${String(groupIndex)}].OverWrite`);
50642
+ if (global === null) return group;
50643
+ if (group === null) return global;
50644
+ return global === group ? global : null;
50645
+ }
50646
+ /**
50647
+ * Post-record seconds, from every event handler that records.
50648
+ *
50649
+ * Dahua keeps post-record per EVENT HANDLER (`…EventHandler.RecordLatch`),
50650
+ * not once per channel. 3836 exposes exactly one — `MotionDetect[0]`;
50651
+ * `SmartMotionDetect`, `AlarmLocal`, `VideoBlind` and `VideoLoss` all
50652
+ * answer HTTP 400 on this firmware, and its single `VideoAnalyseRule`
50653
+ * (`LeTrack`, disabled) carries no `EventHandler`.
50654
+ *
50655
+ * Where several DO exist and disagree, there is no single number to
50656
+ * report: this returns null and the field says why, rather than picking
50657
+ * one handler's value and calling it the camera's.
50658
+ */
50659
+ function readPostRecordSec(motionDetect) {
50660
+ if (motionDetect === null) return {
50661
+ value: null,
50662
+ handlers: 0,
50663
+ disagree: false
50664
+ };
50665
+ const values = [];
50666
+ for (const [key, raw] of motionDetect) {
50667
+ if (!key.endsWith(".EventHandler.RecordLatch")) continue;
50668
+ const value = Number(raw);
50669
+ if (Number.isFinite(value)) values.push(value);
50670
+ }
50671
+ if (values.length === 0) return {
50672
+ value: null,
50673
+ handlers: 0,
50674
+ disagree: false
50675
+ };
50676
+ const first = values[0] ?? null;
50677
+ const disagree = values.some((value) => value !== first);
50678
+ return {
50679
+ value: disagree ? null : first,
50680
+ handlers: values.length,
50681
+ disagree
50682
+ };
50683
+ }
50684
+ /**
50685
+ * Fold the reads into the cap's status.
50686
+ *
50687
+ * `tracks` is empty and `primaryTrackId` null, as on Reolink: a Dahua
50688
+ * channel exposes no track list, and inventing one synthetic row would
50689
+ * put a fact in the UI the camera never stated.
50690
+ *
50691
+ * `enabled` is the CONJUNCTION of the two switches that both have to be
50692
+ * on for the camera to record on its schedule — `RecordMode[ch].Mode`
50693
+ * (0 auto / 1 manual / 2 off) and `Record[ch].Enable`. Either one off
50694
+ * means nothing is recorded, so reporting only one of them would call a
50695
+ * silent camera enabled.
50696
+ */
50697
+ function buildOnboardStatus(input) {
50698
+ const { reads } = input;
50699
+ const channel = reads.channel;
50700
+ const schedule = reads.record === null ? {
50701
+ windows: [],
50702
+ unreadable: 0,
50703
+ anyCoverage: false
50704
+ } : parseSchedule(reads.record, channel);
50705
+ const mode = reads.recordMode === null ? null : numberAt(reads.recordMode, `RecordMode[${String(channel)}].Mode`);
50706
+ const recordEnable = reads.record === null ? null : booleanAt(reads.record, `Record[${String(channel)}].Enable`);
50707
+ const enabled = mode === null || recordEnable === null ? null : mode !== 2 && recordEnable;
50708
+ const post = readPostRecordSec(reads.motionDetect);
50709
+ return {
50710
+ storage: reads.storage,
50711
+ tracks: [],
50712
+ primaryTrackId: null,
50713
+ enabled,
50714
+ overwriteWhenFull: readOverwriteWhenFull({
50715
+ mediaGlobal: reads.mediaGlobal,
50716
+ storageGroup: reads.storageGroup
50717
+ }),
50718
+ preRecordSec: reads.record === null ? null : numberAt(reads.record, `Record[${String(channel)}].PreRecord`),
50719
+ postRecordSec: post.value,
50720
+ segmentMinutes: reads.mediaGlobal === null ? null : numberAt(reads.mediaGlobal, "MediaGlobal.PacketLength"),
50721
+ windows: [...schedule.windows],
50722
+ unreadableWindows: schedule.unreadable,
50723
+ recordingToNowhere: enabled === true && schedule.anyCoverage && reads.storage.kind === "absent",
50724
+ lastFetchedAt: input.now
50725
+ };
50726
+ }
50727
+ /** Why nothing can be written before CamStack has read the camera. */
50728
+ var AMCREST_NOT_READ_REASON = "CamStack has not read this camera yet";
50729
+ /** Why a field is closed when the camera never stated its limits. */
50730
+ var AMCREST_NO_LIMITS_REASON = "this camera did not state the values it accepts for this setting, and it was measured storing values outside its own limits without complaint";
50731
+ /** Why the schedule is closed when part of it could not be read. */
50732
+ var AMCREST_PARTIAL_SCHEDULE_REASON = "CamStack could not read every recording window this camera holds — it records on triggers this version cannot name — so replacing them would delete the ones it could not show";
50733
+ /** Why post-record is closed when the handlers disagree. */
50734
+ var AMCREST_POST_RECORD_SPLIT_REASON = "this camera keeps post-record separately per event type and they do not agree, so there is no single value to show or write";
50735
+ /**
50736
+ * What THIS Amcrest accepts.
50737
+ *
50738
+ * Writability comes from the camera's OWN statement, never a vendor
50739
+ * default: `recordManager.cgi?action=getCaps` bounds pre-record and
50740
+ * segment length, `eventManager.cgi?action=getCaps` bounds post-record.
50741
+ * A caps read that failed closes its field, because this firmware was
50742
+ * measured answering `200 OK` to `PacketLength=99` and `RecordLatch=999`
50743
+ * — values its own caps forbid. Nothing but the refusal before the wire
50744
+ * stops those.
50745
+ */
50746
+ function buildOnboardOptions(input) {
50747
+ const reached = input.status !== null;
50748
+ const closed = (reason) => ({
50749
+ readable: reached,
50750
+ writable: false,
50751
+ reason: reached ? reason : AMCREST_NOT_READ_REASON
50752
+ });
50753
+ const open = {
50754
+ readable: true,
50755
+ writable: true
50756
+ };
50757
+ const gate = (allowed, reason) => reached && allowed ? open : closed(reason);
50758
+ const limits = input.limits;
50759
+ const preOk = limits.maxPreRecordSec !== null;
50760
+ const segmentOk = limits.packetLengthMinutes !== null;
50761
+ const postOk = limits.recordLatchSec !== null && input.postRecord.handlers > 0 && !input.postRecord.disagree;
50762
+ const scheduleReadable = (input.status?.unreadableWindows ?? 0) === 0;
50763
+ return {
50764
+ enabled: gate(true, AMCREST_NOT_READ_REASON),
50765
+ overwriteWhenFull: gate(true, AMCREST_NOT_READ_REASON),
50766
+ preRecordSec: gate(preOk, AMCREST_NO_LIMITS_REASON),
50767
+ ...limits.maxPreRecordSec === null ? {} : { preRecordSecRange: {
50768
+ min: 0,
50769
+ max: limits.maxPreRecordSec,
50770
+ step: 1
50771
+ } },
50772
+ postRecordSec: gate(postOk, input.postRecord.disagree ? AMCREST_POST_RECORD_SPLIT_REASON : AMCREST_NO_LIMITS_REASON),
50773
+ ...limits.recordLatchSec === null ? {} : { postRecordSecRange: {
50774
+ min: limits.recordLatchSec.min,
50775
+ max: limits.recordLatchSec.max,
50776
+ step: 1
50777
+ } },
50778
+ segmentMinutes: gate(segmentOk, AMCREST_NO_LIMITS_REASON),
50779
+ ...limits.packetLengthMinutes === null ? {} : { segmentMinutesRange: {
50780
+ min: limits.packetLengthMinutes.min,
50781
+ max: limits.packetLengthMinutes.max,
50782
+ step: 1
50783
+ } },
50784
+ schedule: {
50785
+ support: gate(scheduleReadable, AMCREST_PARTIAL_SCHEDULE_REASON),
50786
+ granularityMinutes: 1,
50787
+ triggers: [...AMCREST_TRIGGERS],
50788
+ supportsOverlappingTriggers: true
50789
+ }
50790
+ };
50791
+ }
50792
+ /**
50793
+ * Turn a cap patch into one request per config table.
50794
+ *
50795
+ * `windows` is the COMPLETE new set, so every weekday row is rewritten —
50796
+ * including a day the new set does not mention, whose slots must be
50797
+ * written back as `0 00:00:00-23:59:59` or the camera keeps recording on
50798
+ * a day the operator just cleared. Slots beyond the new windows are
50799
+ * cleared the same way. The HOLIDAY row is never touched: the cap cannot
50800
+ * express it, so rewriting it would delete coverage nobody was shown.
50801
+ *
50802
+ * `enabled` is read-modify-write on `RecordMode`: turning recording back
50803
+ * ON restores `auto` only when the camera was `off`, so a camera the
50804
+ * operator had put in `manual` stays in `manual`.
50805
+ */
50806
+ function buildOnboardWrites(patch, current) {
50807
+ const ch = String(current.channel);
50808
+ const writes = [];
50809
+ const recordParams = {};
50810
+ const recordVerify = {};
50811
+ if (patch.enabled !== void 0) {
50812
+ recordParams[`Record[${ch}].Enable`] = patch.enabled;
50813
+ recordVerify["enabled"] = {
50814
+ key: `Record[${ch}].Enable`,
50815
+ expect: patch.enabled ? "true" : "false"
50816
+ };
50817
+ }
50818
+ if (patch.preRecordSec !== void 0) {
50819
+ recordParams[`Record[${ch}].PreRecord`] = patch.preRecordSec;
50820
+ recordVerify["preRecordSec"] = {
50821
+ key: `Record[${ch}].PreRecord`,
50822
+ expect: String(patch.preRecordSec)
50823
+ };
50824
+ }
50825
+ if (patch.windows !== void 0) {
50826
+ const slots = windowsToTimeSections(patch.windows);
50827
+ for (let row = 0; row < 7; row += 1) for (let slot = 0; slot < 6; slot += 1) {
50828
+ const value = slots[row]?.[slot] ?? "0 00:00:00-23:59:59";
50829
+ const key = timeSectionKey(current.channel, row, slot);
50830
+ recordParams[key] = value;
50831
+ recordVerify[`windows[${String(row)}][${String(slot)}]`] = {
50832
+ key,
50833
+ expect: value
50834
+ };
50835
+ }
50836
+ }
50837
+ if (Object.keys(recordParams).length > 0) writes.push({
50838
+ table: "Record",
50839
+ params: recordParams,
50840
+ verify: recordVerify
50841
+ });
50842
+ if (patch.enabled !== void 0) {
50843
+ const mode = patch.enabled ? current.recordMode === 2 || current.recordMode === null ? 0 : current.recordMode : 2;
50844
+ writes.push({
50845
+ table: "RecordMode",
50846
+ params: { [`RecordMode[${ch}].Mode`]: mode },
50847
+ verify: { enabledMode: {
50848
+ key: `RecordMode[${ch}].Mode`,
50849
+ expect: String(mode)
50850
+ } }
50851
+ });
50852
+ }
50853
+ const mediaParams = {};
50854
+ const mediaVerify = {};
50855
+ if (patch.overwriteWhenFull !== void 0) {
50856
+ mediaParams["MediaGlobal.OverWrite"] = patch.overwriteWhenFull;
50857
+ mediaVerify["overwriteWhenFull"] = {
50858
+ key: "MediaGlobal.OverWrite",
50859
+ expect: patch.overwriteWhenFull ? "true" : "false"
50860
+ };
50861
+ }
50862
+ if (patch.segmentMinutes !== void 0) {
50863
+ mediaParams["MediaGlobal.PacketLength"] = patch.segmentMinutes;
50864
+ mediaVerify["segmentMinutes"] = {
50865
+ key: "MediaGlobal.PacketLength",
50866
+ expect: String(patch.segmentMinutes)
50867
+ };
50868
+ }
50869
+ if (Object.keys(mediaParams).length > 0) writes.push({
50870
+ table: "MediaGlobal",
50871
+ params: mediaParams,
50872
+ verify: mediaVerify
50873
+ });
50874
+ if (patch.overwriteWhenFull !== void 0 && current.storageGroupIndex !== null) {
50875
+ const key = `StorageGroup[${String(current.storageGroupIndex)}].OverWrite`;
50876
+ writes.push({
50877
+ table: "StorageGroup",
50878
+ params: { [key]: patch.overwriteWhenFull },
50879
+ verify: { overwriteWhenFullGroup: {
50880
+ key,
50881
+ expect: patch.overwriteWhenFull ? "true" : "false"
50882
+ } }
50883
+ });
50884
+ }
50885
+ if (patch.postRecordSec !== void 0) {
50886
+ const key = `MotionDetect[${ch}].EventHandler.RecordLatch`;
50887
+ writes.push({
50888
+ table: "MotionDetect",
50889
+ params: { [key]: patch.postRecordSec },
50890
+ verify: { postRecordSec: {
50891
+ key,
50892
+ expect: String(patch.postRecordSec)
50893
+ } }
50894
+ });
50895
+ }
50896
+ return writes;
50897
+ }
50898
+ /**
50899
+ * Windows → the weekday rows' slots.
50900
+ *
50901
+ * Windows sharing a day and a time range are MERGED into one slot whose
50902
+ * mask carries every trigger — that is what the bitfield is, and emitting
50903
+ * one slot per trigger would burn through the six the camera has.
50904
+ *
50905
+ * A day needing more than {@link SLOTS_PER_ROW} distinct ranges yields
50906
+ * only the first six; the caller refuses that case before the wire rather
50907
+ * than letting the extra ones fall off here. A trigger with no bit is
50908
+ * skipped for the same reason — `describeOnboardRefusal` has already
50909
+ * refused it against `options.schedule.triggers`.
50910
+ */
50911
+ function windowsToTimeSections(windows) {
50912
+ const rows = Array.from({ length: 7 }, () => []);
50913
+ for (let day = 0; day < 7; day += 1) {
50914
+ const byRange = /* @__PURE__ */ new Map();
50915
+ for (const window of windows) {
50916
+ if (window.day !== day) continue;
50917
+ const bit = BIT_BY_TRIGGER[window.trigger];
50918
+ if (bit === void 0) continue;
50919
+ const range = `${minutesToClock(window.startMinute)}-${minutesToClock(window.endMinute)}`;
50920
+ byRange.set(range, (byRange.get(range) ?? 0) | 1 << bit);
50921
+ }
50922
+ const row = capDayToRow(day);
50923
+ rows[row] = [...byRange.entries()].toSorted((a, b) => a[0].localeCompare(b[0])).map(([range, mask]) => `${String(mask)} ${range}`);
50924
+ }
50925
+ return rows;
50926
+ }
50927
+ /**
50928
+ * The one refusal `describeOnboardRefusal` cannot make for this vendor.
50929
+ *
50930
+ * A Dahua weekday row holds {@link SLOTS_PER_ROW} time ranges and no
50931
+ * more. A day asking for more would be written as its first six and the
50932
+ * rest would vanish into a `200 OK` — the silent drop this whole cap
50933
+ * exists to prevent — so it is refused by name, before the wire, with the
50934
+ * same voice the shared vocabulary uses.
50935
+ *
50936
+ * Returns null when the set fits.
50937
+ */
50938
+ function describeAmcrestScheduleRefusal(windows) {
50939
+ for (const [day, needed] of slotsNeededPerDay(windows)) if (needed > 6) return `this camera holds ${String(6)} recording periods per day and ${DAY_NAMES[day] ?? `day ${String(day)}`} asks for ${String(needed)}`;
50940
+ return null;
50941
+ }
50942
+ /** How many distinct time ranges a day would need. For the refusal. */
50943
+ function slotsNeededPerDay(windows) {
50944
+ const out = /* @__PURE__ */ new Map();
50945
+ for (let day = 0; day < 7; day += 1) {
50946
+ const ranges = /* @__PURE__ */ new Set();
50947
+ for (const window of windows) {
50948
+ if (window.day !== day) continue;
50949
+ ranges.add(`${String(window.startMinute)}-${String(window.endMinute)}`);
50950
+ }
50951
+ out.set(day, ranges.size);
50952
+ }
50953
+ return out;
50954
+ }
50955
+ /**
50956
+ * `1440` → `23:59:59`, the firmware's own end-of-day; everything else
50957
+ * `HH:MM:00`.
50958
+ */
50959
+ function minutesToClock(minute) {
50960
+ if (minute >= 1440) return "23:59:59";
50961
+ const hour = Math.floor(minute / 60);
50962
+ const rest = minute % 60;
50963
+ return `${String(hour).padStart(2, "0")}:${String(rest).padStart(2, "0")}:00`;
50964
+ }
50965
+ //#endregion
49631
50966
  //#region src/extra-settings.ts
49632
50967
  /**
49633
50968
  * Driver-authored device-settings sections for the camera facts that no
@@ -50107,6 +51442,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
50107
51442
  this.registerOsdCap();
50108
51443
  this.registerDayNightCap();
50109
51444
  this.registerImageSettingsCap();
51445
+ this.registerRecordingOnboardCap();
50110
51446
  }
50111
51447
  /** Dahua CGI `eventManager` attach codes — VideoMotion is the base
50112
51448
  * onboard-motion signal (Start/Stop). Smart sub-codes
@@ -50146,6 +51482,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
50146
51482
  dayNightRefreshInFlight = null;
50147
51483
  /** Single-flight guard for the `image-settings` camera refresh. */
50148
51484
  imageSettingsRefreshInFlight = null;
51485
+ /** Single-flight guard for the `recording-onboard` camera refresh. */
51486
+ recordingOnboardRefreshInFlight = null;
50149
51487
  /** Channel is 1-based; defaults to the schema's configured value. */
50150
51488
  get channel() {
50151
51489
  return this.config.get("channel") ?? 1;
@@ -50168,6 +51506,47 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
50168
51506
  source: "operator"
50169
51507
  };
50170
51508
  }
51509
+ /**
51510
+ * What the clip service needs to reach this camera's own recordings.
51511
+ *
51512
+ * The CGI client is the DEVICE's, not a second one: it carries a warm
51513
+ * digest state whose nonce-count is monotonic, and a second client for the
51514
+ * clip path would advance a second one against the same camera.
51515
+ *
51516
+ * `readStorage` is the SAME read and the SAME parser the `recording-onboard`
51517
+ * cap uses (D614) — `storageDevice.cgi?action=getDeviceAllInfo` through
51518
+ * `parseOnboardStorage` — mapped into the clip cap's own three states. There
51519
+ * is one storage reader in this addon and this is a caller of it, not a
51520
+ * second one. It is read live rather than taken off the cap's runtime slice
51521
+ * because that slice is `unknown` until the cap's first refresh, and
51522
+ * "CamStack has not read this camera yet" must not reach an operator as
51523
+ * "this camera cannot be reached".
51524
+ */
51525
+ getClipCamera() {
51526
+ const https = this.config.get("https") ?? false;
51527
+ return {
51528
+ deviceId: this.id,
51529
+ host: this.config.get("host"),
51530
+ port: this.config.get("port") ?? (https ? 443 : 80),
51531
+ https,
51532
+ username: this.config.get("username") ?? "admin",
51533
+ password: this.config.get("password"),
51534
+ channel: this.channel,
51535
+ client: this.ensureClient(),
51536
+ readStorage: async () => {
51537
+ let table;
51538
+ try {
51539
+ table = await this.ensureClient().getStorageDeviceAllInfo();
51540
+ } catch (err) {
51541
+ return {
51542
+ state: "unreachable",
51543
+ reason: `The camera did not answer the storage read: ${err instanceof Error ? err.message : String(err)}`
51544
+ };
51545
+ }
51546
+ return availabilityFromStorage(parseOnboardStorage(table));
51547
+ }
51548
+ };
51549
+ }
50171
51550
  ensureClient() {
50172
51551
  if (this.client) return this.client;
50173
51552
  this.client = this.buildClient();
@@ -51674,6 +53053,238 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
51674
53053
  this.ctx.registerNativeCap(imageSettingsCapability, provider);
51675
53054
  this.ctx.logger.info("amcrest: image-settings cap registered", { tags: { deviceId: this.id } });
51676
53055
  }
53056
+ /**
53057
+ * Register the vendor-neutral `recording-onboard` cap.
53058
+ *
53059
+ * The camera's own card and the camera's own schedule — not CamStack's
53060
+ * recorder. The whole surface rides the existing digest CGI client:
53061
+ * `storageDevice.cgi?action=getDeviceAllInfo` serves the card,
53062
+ * `configManager.cgi` serves `Record` / `RecordMode` / `MediaGlobal` /
53063
+ * `StorageGroup` / `MotionDetect`, and `recordManager.cgi` +
53064
+ * `eventManager.cgi` serve the limits. No RPC2 session is needed.
53065
+ *
53066
+ * Writes follow D610 whole, and this firmware earns every word of it:
53067
+ *
53068
+ * - **Refused before the wire**, against what the camera STATED —
53069
+ * because `PacketLength=99` and `RecordLatch=999` were both stored
53070
+ * although the camera's own caps forbid them.
53071
+ * - **One request per config TABLE**, because a `setConfig` naming
53072
+ * two answers `200 OK` and applies only the first.
53073
+ * - **Verified after it by re-reading**, per field. Success is the
53074
+ * read-back agreeing, never the absence of a thrown error — the
53075
+ * body reads `Error` under HTTP 200 when a key is refused.
53076
+ */
53077
+ registerRecordingOnboardCap() {
53078
+ const CAP_NAME = "recording-onboard";
53079
+ const configChannel = Math.max(0, this.channel - 1);
53080
+ const emptyStatus = () => ({
53081
+ storage: {
53082
+ kind: "unknown",
53083
+ reason: "CamStack has not read this camera yet."
53084
+ },
53085
+ tracks: [],
53086
+ primaryTrackId: null,
53087
+ enabled: null,
53088
+ overwriteWhenFull: null,
53089
+ preRecordSec: null,
53090
+ postRecordSec: null,
53091
+ segmentMinutes: null,
53092
+ windows: [],
53093
+ unreadableWindows: 0,
53094
+ recordingToNowhere: false,
53095
+ lastFetchedAt: 0
53096
+ });
53097
+ /** The camera's own statement of what it accepts. */
53098
+ let lastLimits = {
53099
+ maxPreRecordSec: null,
53100
+ packetLengthMinutes: null,
53101
+ recordLatchSec: null
53102
+ };
53103
+ /** How many event handlers hold a post-record, and whether they agree. */
53104
+ let lastPostRecord = {
53105
+ handlers: 0,
53106
+ disagree: false
53107
+ };
53108
+ /** `RecordMode[ch].Mode`, so turning recording back ON can preserve `manual`. */
53109
+ let lastRecordMode = null;
53110
+ /** Which `StorageGroup` row is the local card. */
53111
+ let lastStorageGroupIndex = null;
53112
+ /** Read every table this cap needs. A table that failed is null, never empty. */
53113
+ const readAll = async () => {
53114
+ const client = this.ensureClient();
53115
+ const [storageTable, record, recordMode, mediaGlobal, storageGroup, motionDetect] = await Promise.all([
53116
+ client.getStorageDeviceAllInfo().catch(() => null),
53117
+ client.getConfig("Record").catch(() => null),
53118
+ client.getConfig("RecordMode").catch(() => null),
53119
+ client.getConfig("MediaGlobal").catch(() => null),
53120
+ client.getConfig("StorageGroup").catch(() => null),
53121
+ client.getConfig("MotionDetect").catch(() => null)
53122
+ ]);
53123
+ return {
53124
+ storage: storageTable === null ? {
53125
+ kind: "unknown",
53126
+ reason: "The camera did not answer the storage read."
53127
+ } : parseOnboardStorage(storageTable),
53128
+ record,
53129
+ recordMode,
53130
+ mediaGlobal,
53131
+ storageGroup,
53132
+ motionDetect,
53133
+ channel: configChannel
53134
+ };
53135
+ };
53136
+ const applyReads = (reads) => {
53137
+ lastRecordMode = reads.recordMode === null ? null : Number(reads.recordMode.get(`RecordMode[${String(configChannel)}].Mode`) ?? NaN);
53138
+ if (lastRecordMode !== null && !Number.isFinite(lastRecordMode)) lastRecordMode = null;
53139
+ lastStorageGroupIndex = reads.storageGroup === null ? null : findLocalStorageGroup(reads.storageGroup);
53140
+ const post = readPostRecordSec(reads.motionDetect);
53141
+ lastPostRecord = {
53142
+ handlers: post.handlers,
53143
+ disagree: post.disagree
53144
+ };
53145
+ return buildOnboardStatus({
53146
+ reads,
53147
+ now: Date.now()
53148
+ });
53149
+ };
53150
+ const refreshFromCamera = async () => {
53151
+ if (this.recordingOnboardRefreshInFlight) return this.recordingOnboardRefreshInFlight;
53152
+ const promise = (async () => {
53153
+ try {
53154
+ const client = this.ensureClient();
53155
+ const [reads, recordCaps, eventCaps] = await Promise.all([
53156
+ readAll(),
53157
+ client.getRecordManagerCaps().catch(() => null),
53158
+ client.getEventManagerCaps().catch(() => null)
53159
+ ]);
53160
+ lastLimits = parseRecordLimits({
53161
+ recordCaps,
53162
+ eventCaps
53163
+ });
53164
+ const next = applyReads(reads);
53165
+ this.runtimeState.setCapState(CAP_NAME, next);
53166
+ if (next.recordingToNowhere) this.ctx.logger.warn("amcrest onboard recording: camera is scheduled to record and has no usable storage", {
53167
+ tags: { deviceId: this.id },
53168
+ meta: {
53169
+ windows: next.windows.length,
53170
+ unreadable: next.unreadableWindows
53171
+ }
53172
+ });
53173
+ if (next.unreadableWindows > 0) this.ctx.logger.warn("amcrest onboard recording: some schedule slots could not be read — the schedule shown is incomplete and cannot be written", {
53174
+ tags: { deviceId: this.id },
53175
+ meta: {
53176
+ unreadableWindows: next.unreadableWindows,
53177
+ readWindows: next.windows.length
53178
+ }
53179
+ });
53180
+ } catch (err) {
53181
+ this.ctx.logger.debug("amcrest onboard recording refresh failed — keeping last slice", {
53182
+ tags: { deviceId: this.id },
53183
+ meta: { error: err instanceof Error ? err.message : String(err) }
53184
+ });
53185
+ }
53186
+ })();
53187
+ this.recordingOnboardRefreshInFlight = promise;
53188
+ try {
53189
+ await promise;
53190
+ } finally {
53191
+ this.recordingOnboardRefreshInFlight = null;
53192
+ }
53193
+ };
53194
+ const bridge = createRuntimeStateBridge({
53195
+ runtimeState: this.runtimeState,
53196
+ cap: recordingOnboardCapability,
53197
+ ownDeviceId: this.id,
53198
+ refresh: refreshFromCamera,
53199
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
53200
+ staleRead: "serve-and-revalidate",
53201
+ logger: this.ctx.logger,
53202
+ empty: emptyStatus
53203
+ });
53204
+ const readOptions = () => buildOnboardOptions({
53205
+ status: this.runtimeState.getCapState(CAP_NAME) ?? null,
53206
+ limits: lastLimits,
53207
+ postRecord: lastPostRecord
53208
+ });
53209
+ const provider = {
53210
+ getStatus: bridge.getStatus,
53211
+ getOptions: async ({ deviceId }) => {
53212
+ if (deviceId !== this.id) throw new Error(`AmcrestCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
53213
+ await bridge.ensureFresh();
53214
+ return readOptions();
53215
+ },
53216
+ setSettings: async ({ deviceId, settings }) => {
53217
+ if (deviceId !== this.id) return;
53218
+ if (Object.keys(settings).length === 0) return;
53219
+ await bridge.ensureFresh();
53220
+ const refusal = describeOnboardRefusal(readOptions(), settings) ?? (settings.windows === void 0 ? null : describeAmcrestScheduleRefusal(settings.windows));
53221
+ if (refusal !== null) {
53222
+ this.ctx.logger.warn("amcrest onboard recording: refusing a write this camera cannot take", {
53223
+ tags: { deviceId: this.id },
53224
+ meta: {
53225
+ refusal,
53226
+ fields: Object.keys(settings)
53227
+ }
53228
+ });
53229
+ throw new Error(`device ${String(this.id)}: ${refusal}`);
53230
+ }
53231
+ const writes = buildOnboardWrites(settings, {
53232
+ channel: configChannel,
53233
+ recordMode: lastRecordMode,
53234
+ storageGroupIndex: lastStorageGroupIndex
53235
+ });
53236
+ const client = this.ensureClient();
53237
+ const rejected = [];
53238
+ for (const write of writes) try {
53239
+ await client.setConfig(write.params);
53240
+ } catch (err) {
53241
+ rejected.push(`${write.table}: ${err instanceof Error ? err.message : String(err)}`);
53242
+ }
53243
+ const after = await readAll();
53244
+ this.runtimeState.setCapState(CAP_NAME, applyReads(after));
53245
+ const byTable = {
53246
+ Record: after.record,
53247
+ RecordMode: after.recordMode,
53248
+ MediaGlobal: after.mediaGlobal,
53249
+ StorageGroup: after.storageGroup,
53250
+ MotionDetect: after.motionDetect
53251
+ };
53252
+ const discarded = [];
53253
+ for (const write of writes) {
53254
+ const table = byTable[write.table] ?? null;
53255
+ if (table === null) {
53256
+ discarded.push(`${write.table}: the camera would not say what it stored`);
53257
+ continue;
53258
+ }
53259
+ for (const [field, check] of Object.entries(write.verify)) {
53260
+ const stored = table.get(check.key);
53261
+ if (stored === check.expect) continue;
53262
+ discarded.push(`${field}: asked ${check.expect}, camera holds ${stored ?? "(nothing)"}`);
53263
+ }
53264
+ }
53265
+ if (rejected.length > 0 || discarded.length > 0) {
53266
+ this.ctx.logger.warn("amcrest onboard recording: the camera did not keep part of the write", {
53267
+ tags: { deviceId: this.id },
53268
+ meta: {
53269
+ rejected,
53270
+ discarded,
53271
+ fields: Object.keys(settings)
53272
+ }
53273
+ });
53274
+ throw new Error(`device ${String(this.id)}: the camera kept its own values — ${[...rejected, ...discarded].join("; ")}`);
53275
+ }
53276
+ this.ctx.logger.info("amcrest onboard recording: write applied and verified", {
53277
+ tags: { deviceId: this.id },
53278
+ meta: {
53279
+ fields: Object.keys(settings),
53280
+ requests: writes.length
53281
+ }
53282
+ });
53283
+ }
53284
+ };
53285
+ this.ctx.registerNativeCap(recordingOnboardCapability, provider);
53286
+ this.ctx.logger.info("amcrest: recording-onboard cap registered", { tags: { deviceId: this.id } });
53287
+ }
51677
53288
  };
51678
53289
  /** Normalize Dahua's `Video.Compression` string (`'H.264'`, `'H.265'`,
51679
53290
  * `'MJPG'`, …) into the cap's `'h264' | 'h265'` enum. Returns
@@ -51706,6 +53317,1987 @@ function collectAudioEnableKeys(table) {
51706
53317
  for (const key of table.keys()) if (/^Encode\[0\]\.(?:MainFormat|ExtraFormat)\[\d+\]\.AudioEnable$/.test(key)) out.push(key);
51707
53318
  return out.toSorted();
51708
53319
  }
53320
+ var EMPTY_PARAMS = Object.freeze({});
53321
+ function isRecord$1(value) {
53322
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53323
+ }
53324
+ /**
53325
+ * Error codes that mean "your session is gone", so one silent re-login is the
53326
+ * right answer rather than a refusal the operator reads.
53327
+ *
53328
+ * Deliberately a SHORT list. An unknown code is a refusal — guessing that any
53329
+ * failure is a stale session turns one camera refusal into two round-trips and
53330
+ * the same refusal, and `findFile`'s 285409284 (which means "nothing matched,
53331
+ * or your condition was malformed") would then be retried on every empty day.
53332
+ */
53333
+ var RPC2_SESSION_LOST_CODES = new Set([268632064, 268632081]);
53334
+ function md5Upper(input) {
53335
+ return createHash("md5").update(input, "utf8").digest("hex").toUpperCase();
53336
+ }
53337
+ /**
53338
+ * A minimal Dahua RPC2 client. One per camera.
53339
+ *
53340
+ * It exposes exactly what the clip catalog needs — `call`, plus the login
53341
+ * `call` performs for itself. There is no generic "raw request" escape hatch:
53342
+ * a second way into this protocol is a second place to get the session
53343
+ * discipline wrong.
53344
+ */
53345
+ var AmcrestRpc2Client = class {
53346
+ base;
53347
+ username;
53348
+ password;
53349
+ timeoutMs;
53350
+ fetchImpl;
53351
+ now;
53352
+ session = null;
53353
+ nextId = 0;
53354
+ loginInFlight = null;
53355
+ constructor(opts) {
53356
+ this.base = `${opts.https ? "https" : "http"}://${opts.host}:${String(opts.port)}`;
53357
+ this.username = opts.username;
53358
+ this.password = opts.password;
53359
+ this.timeoutMs = opts.defaultTimeoutMs ?? 15e3;
53360
+ this.fetchImpl = opts.fetchImpl ?? fetch;
53361
+ this.now = opts.now ?? (() => Date.now());
53362
+ }
53363
+ /**
53364
+ * One RPC2 call, logging in first when there is no live session.
53365
+ *
53366
+ * `object` is the find-object handle for the `mediaFileFind.*` methods that
53367
+ * take one; absent for the factory and for `global.*`.
53368
+ */
53369
+ async call(method, params, object) {
53370
+ const session = await this.ensureSession();
53371
+ if (session.kind === "unreachable") return {
53372
+ kind: "unreachable",
53373
+ detail: session.detail
53374
+ };
53375
+ if (session.kind === "refused") return {
53376
+ kind: "refused",
53377
+ error: session.error,
53378
+ params: EMPTY_PARAMS,
53379
+ session: null
53380
+ };
53381
+ const first = await this.post("/RPC2", method, params, object, session.session);
53382
+ if (first.kind === "refused" && first.error.code !== null && RPC2_SESSION_LOST_CODES.has(first.error.code)) {
53383
+ this.session = null;
53384
+ const fresh = await this.ensureSession();
53385
+ if (fresh.kind !== "ok") return first;
53386
+ return await this.post("/RPC2", method, params, object, fresh.session);
53387
+ }
53388
+ return first;
53389
+ }
53390
+ /** Drop the session and tell the camera, best-effort. For a client whose
53391
+ * device is going away. */
53392
+ async logout() {
53393
+ const session = this.session;
53394
+ this.session = null;
53395
+ if (session === null) return;
53396
+ await this.post("/RPC2", "global.logout", null, void 0, session.id);
53397
+ }
53398
+ /** Forget the cached session WITHOUT telling the camera — for a client whose
53399
+ * host or credentials just changed under it, where the old session id means
53400
+ * nothing at the new address. */
53401
+ reset() {
53402
+ this.session = null;
53403
+ }
53404
+ async ensureSession() {
53405
+ const live = this.session;
53406
+ if (live !== null && this.now() - live.mintedAtMs < 3e4) return {
53407
+ kind: "ok",
53408
+ session: live.id
53409
+ };
53410
+ this.session = null;
53411
+ const inFlight = this.loginInFlight;
53412
+ if (inFlight !== null) return await inFlight;
53413
+ const promise = this.login();
53414
+ this.loginInFlight = promise;
53415
+ try {
53416
+ return await promise;
53417
+ } finally {
53418
+ this.loginInFlight = null;
53419
+ }
53420
+ }
53421
+ async login() {
53422
+ const challenge = await this.post("/RPC2_Login", "global.login", {
53423
+ userName: this.username,
53424
+ password: "",
53425
+ clientType: "Web3.0"
53426
+ }, void 0, null);
53427
+ if (challenge.kind === "unreachable") return {
53428
+ kind: "unreachable",
53429
+ detail: challenge.detail
53430
+ };
53431
+ const sessionId = challenge.session;
53432
+ const realm = challenge.params["realm"];
53433
+ const random = challenge.params["random"];
53434
+ if (sessionId === null || typeof realm !== "string" || typeof random !== "string" || realm === "" || random === "") return {
53435
+ kind: "refused",
53436
+ error: challenge.kind === "refused" ? challenge.error : {
53437
+ code: null,
53438
+ message: "the login challenge carried no realm/random"
53439
+ }
53440
+ };
53441
+ const inner = md5Upper(`${this.username}:${realm}:${this.password}`);
53442
+ const hashed = md5Upper(`${this.username}:${random}:${inner}`);
53443
+ const confirmed = await this.post("/RPC2_Login", "global.login", {
53444
+ userName: this.username,
53445
+ password: hashed,
53446
+ clientType: "Web3.0",
53447
+ loginType: "Direct",
53448
+ authorityType: "Default",
53449
+ passwordType: "Default"
53450
+ }, void 0, sessionId);
53451
+ if (confirmed.kind === "unreachable") return {
53452
+ kind: "unreachable",
53453
+ detail: confirmed.detail
53454
+ };
53455
+ if (confirmed.kind === "refused") return {
53456
+ kind: "refused",
53457
+ error: confirmed.error
53458
+ };
53459
+ this.session = {
53460
+ id: sessionId,
53461
+ mintedAtMs: this.now()
53462
+ };
53463
+ return {
53464
+ kind: "ok",
53465
+ session: sessionId
53466
+ };
53467
+ }
53468
+ async post(path, method, params, object, session) {
53469
+ this.nextId += 1;
53470
+ const body = {
53471
+ method,
53472
+ params,
53473
+ id: this.nextId
53474
+ };
53475
+ if (object !== void 0) body["object"] = object;
53476
+ if (session !== null) body["session"] = session;
53477
+ const controller = new AbortController();
53478
+ const timer = setTimeout(() => {
53479
+ controller.abort();
53480
+ }, this.timeoutMs);
53481
+ try {
53482
+ const res = await this.fetchImpl(`${this.base}${path}`, {
53483
+ method: "POST",
53484
+ headers: { "content-type": "application/json" },
53485
+ body: JSON.stringify(body),
53486
+ signal: controller.signal
53487
+ });
53488
+ const text = await res.text();
53489
+ let parsed;
53490
+ try {
53491
+ parsed = JSON.parse(text);
53492
+ } catch {
53493
+ return {
53494
+ kind: "unreachable",
53495
+ detail: `RPC2 ${method} answered HTTP ${String(res.status)} with a body that is not JSON`
53496
+ };
53497
+ }
53498
+ if (!isRecord$1(parsed)) return {
53499
+ kind: "unreachable",
53500
+ detail: `RPC2 ${method} answered a non-object body`
53501
+ };
53502
+ const answeredParams = isRecord$1(parsed["params"]) ? parsed["params"] : EMPTY_PARAMS;
53503
+ const answeredSession = readSessionId(parsed["session"]);
53504
+ const answeredResult = parsed["result"];
53505
+ if (answeredResult !== false && answeredResult !== void 0 && answeredResult !== null) return {
53506
+ kind: "ok",
53507
+ params: answeredParams,
53508
+ session: answeredSession,
53509
+ result: answeredResult
53510
+ };
53511
+ return {
53512
+ kind: "refused",
53513
+ error: readError(parsed["error"]),
53514
+ params: answeredParams,
53515
+ session: answeredSession
53516
+ };
53517
+ } catch (err) {
53518
+ return {
53519
+ kind: "unreachable",
53520
+ detail: err instanceof Error ? err.message : String(err)
53521
+ };
53522
+ } finally {
53523
+ clearTimeout(timer);
53524
+ }
53525
+ }
53526
+ };
53527
+ function readSessionId(raw) {
53528
+ if (typeof raw === "string" && raw !== "") return raw;
53529
+ if (typeof raw === "number") return String(raw);
53530
+ return null;
53531
+ }
53532
+ function readError(raw) {
53533
+ if (!isRecord$1(raw)) return {
53534
+ code: null,
53535
+ message: ""
53536
+ };
53537
+ return {
53538
+ code: typeof raw["code"] === "number" ? raw["code"] : null,
53539
+ message: typeof raw["message"] === "string" ? raw["message"] : ""
53540
+ };
53541
+ }
53542
+ /**
53543
+ * A clip key is `<startSec>-<endSec>` (`clip-identity.ts`). Anything else is
53544
+ * REFUSED rather than joined: a key reaches this from a URL path segment, and
53545
+ * a `..` in it is a write outside the scratch.
53546
+ */
53547
+ var SAFE_KEY = /^[0-9]{1,20}-[0-9]{1,20}$/;
53548
+ var KIND = "clip-media";
53549
+ var EXTENSION = ".mp4";
53550
+ var ClipArtifactStore = class {
53551
+ options;
53552
+ maxFiles;
53553
+ maxBytes;
53554
+ constructor(options) {
53555
+ this.options = options;
53556
+ this.maxFiles = options.maxFilesPerDevice ?? 8;
53557
+ this.maxBytes = options.maxBytesPerDevice ?? 419430400;
53558
+ }
53559
+ /** Is this a key this store will touch at all? Exposed so a ROUTE can refuse
53560
+ * a bad segment by name before it reaches the filesystem. */
53561
+ static isSafeKey(clipKey) {
53562
+ return SAFE_KEY.test(clipKey);
53563
+ }
53564
+ dirFor(deviceId) {
53565
+ return join(this.options.root, KIND, String(deviceId));
53566
+ }
53567
+ pathFor(deviceId, clipKey) {
53568
+ return join(this.dirFor(deviceId), `${clipKey}${EXTENSION}`);
53569
+ }
53570
+ /** A scratch path to write INTO, with its directory made. */
53571
+ async reserve(deviceId, clipKey) {
53572
+ if (!SAFE_KEY.test(clipKey)) throw new Error(`clip artifact store: refused key "${clipKey}" — not a safe artifact key`);
53573
+ await mkdir(this.dirFor(deviceId), { recursive: true });
53574
+ return `${this.pathFor(deviceId, clipKey)}.${String(process.pid)}.part`;
53575
+ }
53576
+ /** Present AND carrying bytes — never a memory of having written one. */
53577
+ async pathIfPresent(deviceId, clipKey) {
53578
+ if (!SAFE_KEY.test(clipKey)) return null;
53579
+ const path = this.pathFor(deviceId, clipKey);
53580
+ try {
53581
+ const info = await stat(path);
53582
+ return info.isFile() && info.size > 0 ? path : null;
53583
+ } catch {
53584
+ return null;
53585
+ }
53586
+ }
53587
+ /**
53588
+ * Publish a reserved path under its key and enforce the bounds.
53589
+ *
53590
+ * A key already present wins: artifacts are immutable, and the temp file is
53591
+ * removed rather than renamed over it.
53592
+ */
53593
+ async publish(deviceId, clipKey, tempPath) {
53594
+ const existing = await this.pathIfPresent(deviceId, clipKey);
53595
+ if (existing !== null) {
53596
+ await rm(tempPath, { force: true });
53597
+ return existing;
53598
+ }
53599
+ const path = this.pathFor(deviceId, clipKey);
53600
+ await rename(tempPath, path);
53601
+ await this.enforceBounds(deviceId);
53602
+ return path;
53603
+ }
53604
+ async forgetDevice(deviceId) {
53605
+ await rm(this.dirFor(deviceId), {
53606
+ recursive: true,
53607
+ force: true
53608
+ });
53609
+ }
53610
+ async listEntries(deviceId) {
53611
+ let names;
53612
+ try {
53613
+ names = await readdir(this.dirFor(deviceId));
53614
+ } catch {
53615
+ return [];
53616
+ }
53617
+ const out = [];
53618
+ for (const name of names) {
53619
+ if (!name.endsWith(EXTENSION)) continue;
53620
+ const path = join(this.dirFor(deviceId), name);
53621
+ try {
53622
+ const info = await stat(path);
53623
+ if (info.isFile()) out.push({
53624
+ path,
53625
+ bytes: info.size,
53626
+ mtimeMs: info.mtimeMs
53627
+ });
53628
+ } catch {}
53629
+ }
53630
+ return out;
53631
+ }
53632
+ /** Oldest-first eviction until BOTH bounds hold. */
53633
+ async enforceBounds(deviceId) {
53634
+ const entries = [...await this.listEntries(deviceId)].toSorted((a, b) => a.mtimeMs - b.mtimeMs);
53635
+ let files = entries.length;
53636
+ let bytes = entries.reduce((sum, entry) => sum + entry.bytes, 0);
53637
+ for (const entry of entries) {
53638
+ if (files <= this.maxFiles && bytes <= this.maxBytes) break;
53639
+ try {
53640
+ await rm(entry.path, { force: true });
53641
+ files -= 1;
53642
+ bytes -= entry.bytes;
53643
+ } catch {}
53644
+ }
53645
+ }
53646
+ };
53647
+ //#endregion
53648
+ //#region src/clips/clip-clock.ts
53649
+ /**
53650
+ * The camera's clock, and the ONE place a time crosses between it and ours.
53651
+ *
53652
+ * **This is the trap that would put every clip two hours away.** Hikvision
53653
+ * stamps its catalog in UTC with a `Z`. Dahua does not: `mediaFileFind`'s
53654
+ * `StartTime`/`EndTime`, and the `starttime`/`endtime` that `/cam/playback`
53655
+ * accepts, are the camera's own LOCAL wall clock with no zone on them.
53656
+ * Measured 2026-09-23 on device 3836: `global.cgi?action=getCurrentTime`
53657
+ * answered `2026-09-23 11:29:27` while the host's UTC was `09:29:27.711`.
53658
+ *
53659
+ * ## Why the offset is READ and never inferred
53660
+ *
53661
+ * Two shortcuts both fail:
53662
+ *
53663
+ * - **The node's own timezone.** The addon runs wherever the runner runs; a
53664
+ * hub in a UTC container would read the camera's strings two hours early
53665
+ * and ask `/cam/playback` for footage that does not exist yet.
53666
+ * - **The camera's configured `NTP.TimeZone`.** It is a Dahua zone INDEX, it
53667
+ * says nothing about DST on the day in question, and this addon already
53668
+ * knows one Dahua index table is not a fact about the wire.
53669
+ *
53670
+ * So the offset is MEASURED: `getCurrentTime` against our own `Date.now()`.
53671
+ *
53672
+ * ## The offset is RAW, not rounded to a timezone
53673
+ *
53674
+ * It is tempting to round the measured difference to the nearest 15 minutes
53675
+ * and call it a UTC offset. That is wrong for this purpose. What the catalog
53676
+ * needs is a map between *the camera's clock* and *ours* — and a camera whose
53677
+ * clock has drifted ten minutes stamps its files ten minutes off. Carrying the
53678
+ * drift is what makes "the window the operator is looking at" and "the window
53679
+ * the camera will serve" the same window. Rounding it away would reintroduce
53680
+ * exactly the error it was meant to remove.
53681
+ *
53682
+ * A LARGE disagreement is still worth saying out loud, because it is an
53683
+ * operator-visible fault on the camera and not a fact about clips — see
53684
+ * {@link CAMERA_CLOCK_DISAGREEMENT_WARN_MS}. It is not a refusal: the map is
53685
+ * self-consistent at any offset, and refusing would break a working camera
53686
+ * over a cosmetic complaint.
53687
+ *
53688
+ * Pure. No I/O — the caller does the read and hands the two numbers in.
53689
+ */
53690
+ /**
53691
+ * How long one clock read is trusted.
53692
+ *
53693
+ * Long enough that a listing does not pay for it every time, short enough that
53694
+ * a DST rollover — which moves the offset by a whole hour — is corrected
53695
+ * within one refresh rather than sending every subsequent search an hour
53696
+ * astray.
53697
+ */
53698
+ var CAMERA_CLOCK_STALE_MS = 10 * 6e4;
53699
+ /**
53700
+ * Past this, the camera's clock and ours disagree by more than any timezone
53701
+ * plus a plausible drift, so the difference is a camera fault worth a line.
53702
+ * 15 hours: the widest real UTC offset is +14, and this leaves room for it.
53703
+ */
53704
+ var CAMERA_CLOCK_DISAGREEMENT_WARN_MS = 900 * 6e4;
53705
+ /**
53706
+ * The raw body of `global.cgi?action=getCurrentTime`, which answers
53707
+ * `result=2026-09-23 11:29:27`. `null` when there is no timestamp in it — a
53708
+ * read that failed is not a reading (D393), and the caller must refuse rather
53709
+ * than fall back to its own clock.
53710
+ */
53711
+ function parseCurrentTimeBody(body) {
53712
+ return /result\s*=\s*(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/.exec(body)?.[1]?.replace("T", " ") ?? null;
53713
+ }
53714
+ /**
53715
+ * A Dahua wall-clock string read AS IF it were UTC — the intermediate every
53716
+ * conversion goes through. `null` on anything that is not the exact shape,
53717
+ * including the empty string and a date the calendar does not hold.
53718
+ */
53719
+ function parseWallClockAsUtcMs(wallClock) {
53720
+ const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(wallClock.trim());
53721
+ if (match === null) return null;
53722
+ const [, y, mo, d, h, mi, s] = match;
53723
+ if (y === void 0 || mo === void 0 || d === void 0) return null;
53724
+ if (h === void 0 || mi === void 0 || s === void 0) return null;
53725
+ const year = Number(y);
53726
+ const month = Number(mo);
53727
+ const day = Number(d);
53728
+ const hour = Number(h);
53729
+ const minute = Number(mi);
53730
+ const second = Number(s);
53731
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null;
53732
+ if (hour > 23 || minute > 59 || second > 59) return null;
53733
+ const ms = Date.UTC(year, month - 1, day, hour, minute, second);
53734
+ const back = new Date(ms);
53735
+ if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1) return null;
53736
+ if (back.getUTCDate() !== day) return null;
53737
+ return ms;
53738
+ }
53739
+ /**
53740
+ * The measured map. `null` when the camera's answer could not be parsed —
53741
+ * never a zero offset, which is the same shape as "the camera is on UTC" and
53742
+ * would silently move an archive by the timezone it really has.
53743
+ */
53744
+ function deriveCameraClock(input) {
53745
+ const wall = parseCurrentTimeBody(input.currentTimeBody);
53746
+ if (wall === null) return null;
53747
+ const asUtc = parseWallClockAsUtcMs(wall);
53748
+ if (asUtc === null) return null;
53749
+ const hostAt = ((input.hostUtcBeforeMs ?? input.hostUtcAfterMs) + input.hostUtcAfterMs) / 2;
53750
+ return {
53751
+ offsetMs: Math.round((asUtc + 500 - hostAt) / 1e3) * 1e3,
53752
+ readAtMs: input.hostUtcAfterMs
53753
+ };
53754
+ }
53755
+ /** Is this measurement still worth using? */
53756
+ function cameraClockIsFresh(clock, nowMs) {
53757
+ return nowMs - clock.readAtMs < CAMERA_CLOCK_STALE_MS;
53758
+ }
53759
+ /** Does the difference deserve an operator-facing line? */
53760
+ function cameraClockDisagrees(clock) {
53761
+ return Math.abs(clock.offsetMs) > CAMERA_CLOCK_DISAGREEMENT_WARN_MS;
53762
+ }
53763
+ /** An epoch instant as the camera's own wall clock — what a search condition
53764
+ * and a `/cam/playback` query string both take. */
53765
+ function toCameraWallClock(epochMs, clock) {
53766
+ const shifted = new Date(epochMs + clock.offsetMs);
53767
+ const pad = (value, width) => String(value).padStart(width, "0");
53768
+ return `${pad(shifted.getUTCFullYear(), 4)}-${pad(shifted.getUTCMonth() + 1, 2)}-${pad(shifted.getUTCDate(), 2)} ${pad(shifted.getUTCHours(), 2)}:${pad(shifted.getUTCMinutes(), 2)}:${pad(shifted.getUTCSeconds(), 2)}`;
53769
+ }
53770
+ /** The camera's own wall clock as an epoch instant. `null` on a string this
53771
+ * provider could not read — a row with an unreadable time is dropped BY NAME
53772
+ * rather than placed at the epoch. */
53773
+ function fromCameraWallClock(wallClock, clock) {
53774
+ const asUtc = parseWallClockAsUtcMs(wallClock);
53775
+ if (asUtc === null) return null;
53776
+ return asUtc - clock.offsetMs;
53777
+ }
53778
+ //#endregion
53779
+ //#region src/clips/clip-identity.ts
53780
+ /**
53781
+ * An Amcrest clip's IDENTITY — and it is a TIME WINDOW, which is the one thing
53782
+ * that makes this provider different from its two siblings.
53783
+ *
53784
+ * `native:amcrest:onboard:<startSec>:<endSec>`, epoch seconds, OUR clock.
53785
+ *
53786
+ * ## Why the window and not the file name
53787
+ *
53788
+ * Because the camera accepts two different handles for the same recording and
53789
+ * they are not interchangeable (measured 2026-09-23, device 3836):
53790
+ *
53791
+ * - **`/cam/playback?channel=1&starttime=…&endtime=…`** — RTSP playback. The
53792
+ * ONLY form that serves: `?filename=<FilePath>` answers 404 and so does the
53793
+ * bare path form `rtsp://…/mnt/sd/…mp4`.
53794
+ * - **`/cgi-bin/RPC_Loadfile<FilePath>`** — the bytes. Takes the file path and
53795
+ * nothing else.
53796
+ *
53797
+ * So the window is the identity and the path is a property of the row. A
53798
+ * provider that conflated them would address playback by a handle playback
53799
+ * refuses.
53800
+ *
53801
+ * ## The id is self-contained and the row is still required
53802
+ *
53803
+ * The window in the id is enough to build a playback URI, which makes a
53804
+ * deep-link readable and a log line diagnosable. It deliberately does NOT make
53805
+ * the id sufficient: every method still resolves the catalog ROW, and an id
53806
+ * whose row this node has not listed is `catalog-miss` — the truth ("I have
53807
+ * never heard of it"), not a failure. Otherwise a caller could mint an id for
53808
+ * a window nobody checked exists, and the camera would answer a `DESCRIBE`
53809
+ * 404 that this firmware ALSO uses for "all three playback slots are busy".
53810
+ *
53811
+ * Pure. No I/O, no clock.
53812
+ */
53813
+ /** The camera's own recordings — the one Amcrest source. */
53814
+ var CLIP_SOURCE_ONBOARD = "native:amcrest:onboard";
53815
+ /** Every source this provider can name. Order is the picker's order. */
53816
+ var AMCREST_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
53817
+ /**
53818
+ * The key a row is filed under, and the segment a data-plane URL carries.
53819
+ *
53820
+ * `<startSec>-<endSec>` rather than a hash: it is already short, already
53821
+ * unique (two files cannot own the same window on one channel), URL-safe
53822
+ * without encoding, and readable in a log line — which a sha1 is not, on a
53823
+ * provider whose whole identity question is "which window".
53824
+ */
53825
+ function clipKeyFor(startMs, endMs) {
53826
+ return `${String(Math.floor(startMs / 1e3))}-${String(Math.floor(endMs / 1e3))}`;
53827
+ }
53828
+ function mintClipId(parts) {
53829
+ return `${parts.source}:${clipKeyFor(parts.startMs, parts.endMs)}`;
53830
+ }
53831
+ /**
53832
+ * Parse an id this provider minted. `null` for anything else — including an id
53833
+ * from the analytics, Reolink or Hikvision source, which is how the collection
53834
+ * dispatcher's "nobody claimed this id" branch stays honest.
53835
+ */
53836
+ function parseClipId(clipId) {
53837
+ const source = AMCREST_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
53838
+ if (source === void 0) return null;
53839
+ const rest = clipId.slice(source.length + 1);
53840
+ const match = /^(\d+)-(\d+)$/.exec(rest);
53841
+ if (match === null) return null;
53842
+ const startSec = Number(match[1]);
53843
+ const endSec = Number(match[2]);
53844
+ if (!Number.isSafeInteger(startSec) || !Number.isSafeInteger(endSec)) return null;
53845
+ if (endSec <= startSec) return null;
53846
+ return {
53847
+ source,
53848
+ startMs: startSec * 1e3,
53849
+ endMs: endSec * 1e3
53850
+ };
53851
+ }
53852
+ /** The key a clip id names, without re-deriving the arithmetic. */
53853
+ function clipKeyOf(parts) {
53854
+ return clipKeyFor(parts.startMs, parts.endMs);
53855
+ }
53856
+ //#endregion
53857
+ //#region src/clips/clip-search.ts
53858
+ /**
53859
+ * One `mediaFileFind` row, read. Pure: no RPC, no clock read, no logger.
53860
+ *
53861
+ * ## `Summary` is never read, and that is a hard rule
53862
+ *
53863
+ * Every row this camera returns carries, on a plain `VideoMotion` recording in
53864
+ * a living room, on a camera with no ANPR licence:
53865
+ *
53866
+ * ```json
53867
+ * "Summary": { "TrafficCar": { "PlateNumber": " ", "PlateColor": "Yellow",
53868
+ * "PlateType": "Yellow", "Speed": 60,
53869
+ * "VehicleColor": "White" } }
53870
+ * ```
53871
+ *
53872
+ * `PlateNumber` is a single space and `Speed` is a constant 60 on all 60 rows
53873
+ * measured. It is an uninitialised struct serialised as data. A provider that
53874
+ * surfaced it would invent a licence-plate read and a 60 km/h vehicle on every
53875
+ * motion clip. {@link parseClipRow} reads `Events` and `Flags` and nothing
53876
+ * else about what happened.
53877
+ *
53878
+ * ## `Type` is an ECHO of the filter, not a fact about the file
53879
+ *
53880
+ * Measured 2026-09-23: the SAME recording comes back as `Type: "dav"` when the
53881
+ * search asked `Types: ["dav"]` and as `Type: "mp4"` when it asked
53882
+ * `Types: ["mp4"]` — identical `FilePath` in both, which really ends `.mp4`
53883
+ * and really is an ISO MP4. So the field is not carried onto the row at all.
53884
+ * The filter still matters enormously — see {@link buildFindCondition}.
53885
+ *
53886
+ * ## `Length` is the file, `Duration` is the WINDOW
53887
+ *
53888
+ * `Duration` is the wall-clock span between `StartTime` and `EndTime`. The
53889
+ * container inside runs about 7.5 % shorter (74 s row → 68.433 s media,
53890
+ * measured with `ffprobe`). Both are carried, separately, and a completion
53891
+ * check that compares the delivered media against the ROW naively would fire
53892
+ * on every clip this camera makes.
53893
+ */
53894
+ /**
53895
+ * The file kinds a CLIP may be.
53896
+ *
53897
+ * **This filter is not cosmetic.** An unfiltered `Flags: ["Event"]` search
53898
+ * over the same window returned 60 rows of which **45 were single JPEG
53899
+ * snapshots** — `Duration: 0`, `StartTime === EndTime`, ~250 KB each. Listing
53900
+ * them would hand the operator three quarters of a catalog that cannot be
53901
+ * played, with a zero-length window that `/cam/playback` has nothing to serve.
53902
+ * `Types: ["dav"]` returned exactly the 15 recordings.
53903
+ */
53904
+ var CLIP_FIND_TYPES = ["dav"];
53905
+ /**
53906
+ * The record reasons a clip may carry.
53907
+ *
53908
+ * All three, and not just `Event`, because the filter is what the camera
53909
+ * matches on: a camera scheduled for CONTINUOUS recording writes `Timing`
53910
+ * rows, and an `Event`-only search on such a camera would report an empty card
53911
+ * as confidently as a real one. Measured: a three-flag condition returns the
53912
+ * same 15 rows an `Event`-only one does on this camera, so the wider filter
53913
+ * costs nothing where it is not needed.
53914
+ */
53915
+ var CLIP_FIND_FLAGS = [
53916
+ "Timing",
53917
+ "Event",
53918
+ "Manual"
53919
+ ];
53920
+ function isRecord(value) {
53921
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53922
+ }
53923
+ function readStrings(value) {
53924
+ if (!Array.isArray(value)) return [];
53925
+ const out = [];
53926
+ for (const entry of value) if (typeof entry === "string" && entry !== "") out.push(entry);
53927
+ return out;
53928
+ }
53929
+ /**
53930
+ * A positive finite number, or `null`.
53931
+ *
53932
+ * `0` becomes `null` on purpose for `Length`: a zero-byte recording is not a
53933
+ * measurement, and a bound check that reads it as "comfortably under the
53934
+ * ceiling" would let an unmeasurable file through the one gate that exists to
53935
+ * stop it.
53936
+ */
53937
+ function readPositiveNumber(value) {
53938
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
53939
+ return value;
53940
+ }
53941
+ function parseClipRow(raw, clock) {
53942
+ if (!isRecord(raw)) return {
53943
+ kind: "rejected",
53944
+ reason: "no-file-path"
53945
+ };
53946
+ const filePath = raw["FilePath"];
53947
+ if (typeof filePath !== "string" || filePath === "") return {
53948
+ kind: "rejected",
53949
+ reason: "no-file-path"
53950
+ };
53951
+ const startRaw = raw["StartTime"];
53952
+ const endRaw = raw["EndTime"];
53953
+ const startMs = typeof startRaw === "string" ? fromCameraWallClock(startRaw, clock) : null;
53954
+ if (startMs === null) return {
53955
+ kind: "rejected",
53956
+ reason: "unreadable-start"
53957
+ };
53958
+ const endMs = typeof endRaw === "string" ? fromCameraWallClock(endRaw, clock) : null;
53959
+ if (endMs === null) return {
53960
+ kind: "rejected",
53961
+ reason: "unreadable-end"
53962
+ };
53963
+ if (endMs <= startMs) return {
53964
+ kind: "rejected",
53965
+ reason: "not-a-window"
53966
+ };
53967
+ const declaredDuration = readPositiveNumber(raw["Duration"]);
53968
+ const channelRaw = raw["Channel"];
53969
+ return {
53970
+ kind: "ok",
53971
+ row: {
53972
+ clipKey: clipKeyFor(startMs, endMs),
53973
+ startMs,
53974
+ endMs,
53975
+ windowMs: declaredDuration !== null ? declaredDuration * 1e3 : endMs - startMs,
53976
+ sizeBytes: readPositiveNumber(raw["Length"]),
53977
+ filePath,
53978
+ events: readStrings(raw["Events"]),
53979
+ flags: readStrings(raw["Flags"]),
53980
+ channel: typeof channelRaw === "number" && Number.isInteger(channelRaw) ? channelRaw : 0
53981
+ }
53982
+ };
53983
+ }
53984
+ function buildFindCondition(input) {
53985
+ return {
53986
+ Channel: input.channel,
53987
+ StartTime: input.startWallClock,
53988
+ EndTime: input.endWallClock,
53989
+ Flags: CLIP_FIND_FLAGS,
53990
+ Types: CLIP_FIND_TYPES
53991
+ };
53992
+ }
53993
+ /**
53994
+ * Has the walk reached the end?
53995
+ *
53996
+ * `found < count` and nothing else. **`mediaFileFind.getCount` must never be
53997
+ * called**: over CGI it is `501 Not Implemented`, and over RPC2 it is worse —
53998
+ * measured answering `{"params":{"count":0},"result":true}` on a find object
53999
+ * whose `findFile` had just succeeded and from which rows were then
54000
+ * enumerated. That is an authoritative-looking zero from a method that
54001
+ * answered successfully, which is the exact shape D393 forbids, produced by
54002
+ * the camera itself.
54003
+ */
54004
+ function findWalkIsComplete(found, asked) {
54005
+ return found < asked;
54006
+ }
54007
+ //#endregion
54008
+ //#region src/clips/clip-catalog.ts
54009
+ /**
54010
+ * The control window — the camera's own wall clock, wide enough that no card
54011
+ * can fall outside it. Measured accepted on this firmware (`findFile` true in
54012
+ * 361 ms).
54013
+ */
54014
+ var CLIP_CONTROL_WINDOW = {
54015
+ start: "1970-01-01 00:00:00",
54016
+ end: "2100-01-01 00:00:00"
54017
+ };
54018
+ function describeError(error) {
54019
+ const code = error.code === null ? "no code" : `code ${String(error.code)}`;
54020
+ return error.message === "" ? `${code}, no message` : `${code}: ${error.message}`;
54021
+ }
54022
+ async function runSearch(deps, input) {
54023
+ const tags = { deviceId: deps.deviceId };
54024
+ const created = await deps.rpc.call("mediaFileFind.factory.create", null);
54025
+ if (created.kind === "unreachable") return {
54026
+ kind: "unreachable",
54027
+ detail: created.detail
54028
+ };
54029
+ if (created.kind === "refused") return {
54030
+ kind: "unreachable",
54031
+ detail: `the camera refused a find object (${describeError(created.error)})`
54032
+ };
54033
+ const handle = created.result;
54034
+ if (typeof handle !== "number" || !Number.isFinite(handle)) return {
54035
+ kind: "unreachable",
54036
+ detail: "the camera answered a find-object handle this provider could not read"
54037
+ };
54038
+ try {
54039
+ const condition = buildFindCondition({
54040
+ channel: input.channel,
54041
+ startWallClock: input.startWallClock,
54042
+ endWallClock: input.endWallClock
54043
+ });
54044
+ const started = await deps.rpc.call("mediaFileFind.findFile", { condition }, handle);
54045
+ if (started.kind === "unreachable") return {
54046
+ kind: "unreachable",
54047
+ detail: started.detail
54048
+ };
54049
+ if (started.kind === "refused") return {
54050
+ kind: "refused",
54051
+ detail: describeError(started.error)
54052
+ };
54053
+ if (!input.enumerate) return {
54054
+ kind: "ok",
54055
+ rows: [],
54056
+ pages: 0,
54057
+ rejected: {},
54058
+ rejectedCount: 0
54059
+ };
54060
+ const rows = [];
54061
+ const rejected = {};
54062
+ let rejectedCount = 0;
54063
+ let pages = 0;
54064
+ for (; pages < 64;) {
54065
+ const page = await deps.rpc.call("mediaFileFind.findNextFile", { count: 64 }, handle);
54066
+ pages += 1;
54067
+ if (page.kind === "unreachable") return {
54068
+ kind: "unreachable",
54069
+ detail: page.detail
54070
+ };
54071
+ if (page.kind === "refused") return {
54072
+ kind: "unreachable",
54073
+ detail: `page ${String(pages)} of the search refused (${describeError(page.error)})`
54074
+ };
54075
+ const foundRaw = page.params["found"];
54076
+ const found = typeof foundRaw === "number" && Number.isFinite(foundRaw) ? foundRaw : 0;
54077
+ const infos = page.params["infos"];
54078
+ const list = Array.isArray(infos) ? infos : [];
54079
+ for (const raw of list) {
54080
+ const parsed = parseClipRow(raw, input.clock);
54081
+ if (parsed.kind === "ok") {
54082
+ rows.push(parsed.row);
54083
+ continue;
54084
+ }
54085
+ rejected[parsed.reason] = (rejected[parsed.reason] ?? 0) + 1;
54086
+ rejectedCount += 1;
54087
+ }
54088
+ if (findWalkIsComplete(found, 64)) break;
54089
+ if (list.length === 0) {
54090
+ deps.logger.warn("videoclips: a search page claimed more rows and carried none", {
54091
+ tags,
54092
+ meta: {
54093
+ branch: "find-empty-page",
54094
+ page: pages,
54095
+ found
54096
+ }
54097
+ });
54098
+ break;
54099
+ }
54100
+ }
54101
+ if (pages >= 64) deps.logger.warn("videoclips: a search never terminated — stopping at the page bound", {
54102
+ tags,
54103
+ meta: {
54104
+ branch: "find-page-bound",
54105
+ pages,
54106
+ rows: rows.length
54107
+ }
54108
+ });
54109
+ return {
54110
+ kind: "ok",
54111
+ rows,
54112
+ pages,
54113
+ rejected,
54114
+ rejectedCount
54115
+ };
54116
+ } finally {
54117
+ await releaseFindObject(deps, handle);
54118
+ }
54119
+ }
54120
+ async function releaseFindObject(deps, handle) {
54121
+ for (const method of ["mediaFileFind.close", "mediaFileFind.destroy"]) {
54122
+ const released = await deps.rpc.call(method, null, handle);
54123
+ if (released.kind === "ok") continue;
54124
+ deps.logger.warn("videoclips: a find object was not released", {
54125
+ tags: { deviceId: deps.deviceId },
54126
+ meta: {
54127
+ branch: "find-object-leak",
54128
+ method,
54129
+ detail: released.kind === "refused" ? describeError(released.error) : released.detail
54130
+ }
54131
+ });
54132
+ }
54133
+ }
54134
+ /**
54135
+ * Every row the camera holds in a window.
54136
+ *
54137
+ * The window travels as the CAMERA's wall clock, converted through the
54138
+ * measured offset. A UTC window would be two hours off in summer and one in
54139
+ * winter, and would silently return the wrong footage rather than an error.
54140
+ */
54141
+ async function readClipRows(deps, input) {
54142
+ const tags = { deviceId: deps.deviceId };
54143
+ const startWallClock = toCameraWallClock(input.sinceMs, input.clock);
54144
+ const endWallClock = toCameraWallClock(input.untilMs, input.clock);
54145
+ const asked = await runSearch(deps, {
54146
+ startWallClock,
54147
+ endWallClock,
54148
+ channel: input.channel,
54149
+ clock: input.clock,
54150
+ enumerate: true
54151
+ });
54152
+ if (asked.kind === "ok") {
54153
+ if (asked.rejectedCount > 0) deps.logger.warn("videoclips: the camera sent rows this provider could not read", {
54154
+ tags,
54155
+ meta: {
54156
+ branch: "rows-rejected",
54157
+ rejected: asked.rejected,
54158
+ rejectedCount: asked.rejectedCount,
54159
+ kept: asked.rows.length
54160
+ }
54161
+ });
54162
+ return asked;
54163
+ }
54164
+ if (asked.kind === "unreachable") return {
54165
+ kind: "unreachable",
54166
+ detail: asked.detail
54167
+ };
54168
+ const control = await runSearch(deps, {
54169
+ startWallClock: CLIP_CONTROL_WINDOW.start,
54170
+ endWallClock: CLIP_CONTROL_WINDOW.end,
54171
+ channel: input.channel,
54172
+ clock: input.clock,
54173
+ enumerate: false
54174
+ });
54175
+ if (control.kind === "unreachable") return {
54176
+ kind: "unreachable",
54177
+ detail: `the camera refused the search for ${startWallClock} … ${endWallClock} (${asked.detail}) and did not answer the control search (${control.detail})`
54178
+ };
54179
+ if (control.kind === "ok") {
54180
+ deps.logger.info("videoclips: the window holds nothing, proved against a control search", {
54181
+ tags,
54182
+ meta: {
54183
+ branch: "empty-window",
54184
+ startWallClock,
54185
+ endWallClock,
54186
+ refusal: asked.detail
54187
+ }
54188
+ });
54189
+ return { kind: "empty-window" };
54190
+ }
54191
+ return {
54192
+ kind: "index-empty",
54193
+ detail: `the camera refused both the asked window (${startWallClock} … ${endWallClock}: ${asked.detail}) and an unbounded control search (${CLIP_CONTROL_WINDOW.start} … ${CLIP_CONTROL_WINDOW.end}: ${control.detail}) — its index answers nothing anywhere`
54194
+ };
54195
+ }
54196
+ //#endregion
54197
+ //#region src/clips/clip-data-plane.ts
54198
+ /**
54199
+ * The addon's clip FILE route.
54200
+ *
54201
+ * Hosted by `ctx.dataPlane.serve({ access: 'authenticated' })`, so the hub
54202
+ * authenticates and reverse-proxies and the addon produces the bytes. **No
54203
+ * token in the URL**: the hub's `/addon/<id>/<prefix>` proxy reads the
54204
+ * credential from `authorization` or the session cookie, so a clip URL may sit
54205
+ * in history, in a `Referer` or in a shared log and open nothing (D549 22).
54206
+ *
54207
+ * It serves the re-indexed MP4 — `moov` first — with `Range`, which is what a
54208
+ * `<video>` element needs to show a duration and to seek. The camera's own
54209
+ * copy has the `moov` LAST and ignores `Range` outright, so the seekability
54210
+ * this route offers is bought by `clip-file.ts`'s one `-c copy` pass and is
54211
+ * not a property of the wire.
54212
+ *
54213
+ * There is deliberately **no STREAM route here**. The camera's forward-only
54214
+ * `/cam/playback` replay is real and is not wired — see the ADR: it is a
54215
+ * separate slice, it needs a provider-owned slot gate because the camera's own
54216
+ * exhaustion signal is a 404 that means "gone", and it cannot honestly answer
54217
+ * the broker's rate ladder.
54218
+ *
54219
+ * `HEAD` answers headers and touches no camera.
54220
+ */
54221
+ /** URL namespace under `/addon/provider-amcrest/`. */
54222
+ var CLIP_MEDIA_PREFIX = "clip";
54223
+ /**
54224
+ * A clip key is `<startSec>-<endSec>` and nothing else may reach the
54225
+ * filesystem. The store refuses the same shape a second time — two walls,
54226
+ * because this one is reachable from a URL.
54227
+ */
54228
+ var MEDIA_PATH = /^\/(\d+)\/(\d{1,20}-\d{1,20})\.mp4$/;
54229
+ function pathnameOf(req) {
54230
+ const raw = req.url ?? "/";
54231
+ const query = raw.indexOf("?");
54232
+ return query === -1 ? raw : raw.slice(0, query);
54233
+ }
54234
+ /**
54235
+ * `bytes=a-b` / `bytes=a-` / `bytes=-n`.
54236
+ *
54237
+ * `'unsatisfiable'` is a 416 rather than a silently empty 206: a player that
54238
+ * asked past the end must learn it did, not receive zero bytes that look like
54239
+ * the end of the file.
54240
+ */
54241
+ function parseRange(header, size) {
54242
+ if (header === void 0) return null;
54243
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
54244
+ if (match === null) return null;
54245
+ const [, rawStart, rawEnd] = match;
54246
+ if (rawStart === "" && rawEnd === "") return "unsatisfiable";
54247
+ if (rawStart === "") {
54248
+ const length = Number(rawEnd);
54249
+ if (length <= 0) return "unsatisfiable";
54250
+ return {
54251
+ start: Math.max(0, size - length),
54252
+ end: size - 1
54253
+ };
54254
+ }
54255
+ const start = Number(rawStart);
54256
+ if (start >= size) return "unsatisfiable";
54257
+ const end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
54258
+ if (end < start) return "unsatisfiable";
54259
+ return {
54260
+ start,
54261
+ end
54262
+ };
54263
+ }
54264
+ function createClipPlaybackHandler(deps) {
54265
+ return async (req, res) => {
54266
+ if (req.method !== "GET" && req.method !== "HEAD") {
54267
+ res.writeHead(405, { allow: "GET, HEAD" });
54268
+ res.end();
54269
+ return;
54270
+ }
54271
+ const match = MEDIA_PATH.exec(pathnameOf(req));
54272
+ if (match === null) {
54273
+ res.writeHead(400);
54274
+ res.end();
54275
+ return;
54276
+ }
54277
+ const deviceId = Number(match[1]);
54278
+ const clipKey = match[2] ?? "";
54279
+ let path;
54280
+ try {
54281
+ path = await deps.resolvePath(deviceId, clipKey);
54282
+ } catch (err) {
54283
+ deps.logger.warn("videoclips: could not prepare a clip for playback", {
54284
+ tags: { deviceId },
54285
+ meta: {
54286
+ clipKey,
54287
+ branch: "prepare-threw",
54288
+ error: err instanceof Error ? err.message : String(err)
54289
+ }
54290
+ });
54291
+ res.writeHead(500);
54292
+ res.end();
54293
+ return;
54294
+ }
54295
+ if (path === null) {
54296
+ res.writeHead(404);
54297
+ res.end();
54298
+ return;
54299
+ }
54300
+ const size = (await stat(path)).size;
54301
+ const base = {
54302
+ "content-type": "video/mp4",
54303
+ "accept-ranges": "bytes",
54304
+ "cache-control": "private, max-age=0, must-revalidate"
54305
+ };
54306
+ const range = parseRange(req.headers.range, size);
54307
+ if (range === "unsatisfiable") {
54308
+ res.writeHead(416, {
54309
+ ...base,
54310
+ "content-range": `bytes */${String(size)}`
54311
+ });
54312
+ res.end();
54313
+ return;
54314
+ }
54315
+ if (range === null) {
54316
+ res.writeHead(200, {
54317
+ ...base,
54318
+ "content-length": String(size)
54319
+ });
54320
+ if (req.method === "HEAD") {
54321
+ res.end();
54322
+ return;
54323
+ }
54324
+ createReadStream(path).pipe(res);
54325
+ return;
54326
+ }
54327
+ res.writeHead(206, {
54328
+ ...base,
54329
+ "content-range": `bytes ${String(range.start)}-${String(range.end)}/${String(size)}`,
54330
+ "content-length": String(range.end - range.start + 1)
54331
+ });
54332
+ if (req.method === "HEAD") {
54333
+ res.end();
54334
+ return;
54335
+ }
54336
+ createReadStream(path, {
54337
+ start: range.start,
54338
+ end: range.end
54339
+ }).pipe(res);
54340
+ };
54341
+ }
54342
+ /** How long one remux may run before it is killed. A `-c copy` pass over
54343
+ * 56 MB is seconds; a minute is a bound on a hang, not a budget. */
54344
+ var CLIP_FILE_REMUX_TIMEOUT_MS = 6e4;
54345
+ /**
54346
+ * ffmpeg's own line for the container it opened —
54347
+ * `Duration: 00:01:08.43, start: …`. MEASURED, off the file we are about to
54348
+ * serve, which is the only thing that can say how long it really runs.
54349
+ */
54350
+ function parseFfmpegDurationMs(stderr) {
54351
+ const match = /Duration:\s*(\d+):(\d{2}):(\d{2})\.(\d{1,3})/.exec(stderr);
54352
+ if (match === null) return null;
54353
+ const [, h, m, s, frac] = match;
54354
+ if (h === void 0 || m === void 0 || s === void 0 || frac === void 0) return null;
54355
+ const ms = Number(frac.padEnd(3, "0"));
54356
+ const total = ((Number(h) * 60 + Number(m)) * 60 + Number(s)) * 1e3 + ms;
54357
+ return total > 0 ? total : null;
54358
+ }
54359
+ /** The `-c copy -movflags +faststart` pass, spelled once. */
54360
+ function buildClipFaststartArgs(source, target) {
54361
+ return [
54362
+ "-hide_banner",
54363
+ "-loglevel",
54364
+ "info",
54365
+ "-y",
54366
+ "-i",
54367
+ source,
54368
+ "-c",
54369
+ "copy",
54370
+ "-movflags",
54371
+ "+faststart",
54372
+ "-f",
54373
+ "mp4",
54374
+ target
54375
+ ];
54376
+ }
54377
+ async function fetchRecording(input, deps, target, maxFetchBytes) {
54378
+ let download;
54379
+ try {
54380
+ download = await deps.client.openRecordedFile(input.row.filePath);
54381
+ } catch (err) {
54382
+ return {
54383
+ kind: "refused",
54384
+ bytes: 0,
54385
+ code: "camera-refused",
54386
+ detail: `the camera closed the connection for "${input.row.filePath}" without answering (${err instanceof Error ? err.message : String(err)}) — on this firmware that is how a recording it no longer holds is refused`
54387
+ };
54388
+ }
54389
+ if (download.status !== 200) return {
54390
+ kind: "refused",
54391
+ bytes: 0,
54392
+ code: "camera-refused",
54393
+ detail: `the camera answered HTTP ${String(download.status)} for "${input.row.filePath}"`
54394
+ };
54395
+ if (download.contentLength !== null && download.contentLength > maxFetchBytes) return {
54396
+ kind: "refused",
54397
+ bytes: 0,
54398
+ code: "too-large-to-transfer",
54399
+ detail: `the camera declares "${input.row.filePath}" as ${String(download.contentLength)} bytes, over the ${String(maxFetchBytes)}-byte fetch ceiling`
54400
+ };
54401
+ const body = download.body;
54402
+ if (body === null) return {
54403
+ kind: "refused",
54404
+ bytes: 0,
54405
+ code: "fetch-failed",
54406
+ detail: `the camera answered 200 for "${input.row.filePath}" and sent no body`
54407
+ };
54408
+ let bytes = 0;
54409
+ let overflowed = false;
54410
+ const source = Readable.fromWeb(body);
54411
+ source.on("data", (chunk) => {
54412
+ bytes += chunk.byteLength;
54413
+ if (bytes > maxFetchBytes && !overflowed) {
54414
+ overflowed = true;
54415
+ source.destroy(/* @__PURE__ */ new Error(`fetch ceiling of ${String(maxFetchBytes)} bytes passed`));
54416
+ }
54417
+ });
54418
+ try {
54419
+ await pipeline(source, createWriteStream(target));
54420
+ } catch (err) {
54421
+ if (overflowed) return {
54422
+ kind: "refused",
54423
+ bytes,
54424
+ code: "too-large-to-transfer",
54425
+ detail: `"${input.row.filePath}" passed the ${String(maxFetchBytes)}-byte fetch ceiling before the camera finished it`
54426
+ };
54427
+ return {
54428
+ kind: "refused",
54429
+ bytes,
54430
+ code: "fetch-failed",
54431
+ detail: `the transfer of "${input.row.filePath}" ended after ${String(bytes)} bytes: ${err instanceof Error ? err.message : String(err)}`
54432
+ };
54433
+ }
54434
+ if (bytes === 0) return {
54435
+ kind: "refused",
54436
+ bytes: 0,
54437
+ code: "fetch-failed",
54438
+ detail: `the camera answered 200 for "${input.row.filePath}" and delivered no bytes`
54439
+ };
54440
+ if (download.contentLength !== null && bytes !== download.contentLength) return {
54441
+ kind: "refused",
54442
+ bytes,
54443
+ code: "fetch-failed",
54444
+ detail: `the camera declared ${String(download.contentLength)} bytes for "${input.row.filePath}" and delivered ${String(bytes)}`
54445
+ };
54446
+ return {
54447
+ kind: "ok",
54448
+ bytes
54449
+ };
54450
+ }
54451
+ /**
54452
+ * Fetch a whole recording and re-index it, or answer the cached file.
54453
+ *
54454
+ * Never throws for a gated branch: a refusal carries its code and its prose so
54455
+ * the caller can turn it into the cap's own vocabulary.
54456
+ */
54457
+ async function prepareClipFile(input, deps) {
54458
+ const tags = { deviceId: input.deviceId };
54459
+ const meta = { clipKey: input.row.clipKey };
54460
+ const cached = await deps.store.pathIfPresent(input.deviceId, input.row.clipKey);
54461
+ if (cached !== null) {
54462
+ const info = await stat(cached);
54463
+ deps.logger.debug("videoclips: served a clip file from the scratch", {
54464
+ tags,
54465
+ meta: {
54466
+ ...meta,
54467
+ bytes: info.size
54468
+ }
54469
+ });
54470
+ return {
54471
+ kind: "ok",
54472
+ path: cached,
54473
+ bytes: info.size,
54474
+ durationMs: null
54475
+ };
54476
+ }
54477
+ const maxFetchBytes = deps.maxFetchBytes ?? 536870912;
54478
+ const startedAt = Date.now();
54479
+ const raw = `${await deps.store.reserve(input.deviceId, input.row.clipKey)}.raw`;
54480
+ const temp = await deps.store.reserve(input.deviceId, input.row.clipKey);
54481
+ try {
54482
+ const fetched = await fetchRecording(input, deps, raw, maxFetchBytes);
54483
+ if (fetched.kind === "refused") {
54484
+ deps.logger.warn("videoclips: a clip fetch was refused", {
54485
+ tags,
54486
+ meta: {
54487
+ ...meta,
54488
+ branch: fetched.code ?? "fetch-failed",
54489
+ bytes: fetched.bytes
54490
+ }
54491
+ });
54492
+ return {
54493
+ kind: "refused",
54494
+ code: fetched.code ?? "fetch-failed",
54495
+ detail: fetched.detail ?? "the fetch failed"
54496
+ };
54497
+ }
54498
+ const fetchedAt = Date.now();
54499
+ const args = buildClipFaststartArgs(raw, temp);
54500
+ const child = deps.spawnMux?.([...args]) ?? spawn(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
54501
+ "ignore",
54502
+ "ignore",
54503
+ "pipe"
54504
+ ] });
54505
+ let stderr = "";
54506
+ child.stderr?.on("data", (chunk) => {
54507
+ stderr = `${stderr}${chunk.toString()}`.slice(-4e3);
54508
+ });
54509
+ const killer = setTimeout(() => {
54510
+ child.kill("SIGKILL");
54511
+ }, CLIP_FILE_REMUX_TIMEOUT_MS);
54512
+ const exit = await new Promise((resolve) => {
54513
+ child.on("close", (code) => {
54514
+ resolve(code);
54515
+ });
54516
+ child.on("error", () => {
54517
+ resolve(-1);
54518
+ });
54519
+ });
54520
+ clearTimeout(killer);
54521
+ if (exit !== 0) {
54522
+ deps.logger.warn("videoclips: a clip re-index failed", {
54523
+ tags,
54524
+ meta: {
54525
+ ...meta,
54526
+ branch: "fetch-failed",
54527
+ exit,
54528
+ stderr: stderr.trim().slice(-300)
54529
+ }
54530
+ });
54531
+ return {
54532
+ kind: "refused",
54533
+ code: "fetch-failed",
54534
+ detail: `ffmpeg exited ${String(exit)}: ${stderr.trim().slice(-300)}`
54535
+ };
54536
+ }
54537
+ const durationMs = parseFfmpegDurationMs(stderr);
54538
+ const path = await deps.store.publish(input.deviceId, input.row.clipKey, temp);
54539
+ const info = await stat(path);
54540
+ deps.logger.info("videoclips: prepared an Amcrest clip file", {
54541
+ tags,
54542
+ meta: {
54543
+ ...meta,
54544
+ fetchedBytes: fetched.bytes,
54545
+ listedBytes: input.row.sizeBytes,
54546
+ bytes: info.size,
54547
+ durationMs,
54548
+ rowWindowMs: input.row.windowMs,
54549
+ fetchMs: fetchedAt - startedAt,
54550
+ remuxMs: Date.now() - fetchedAt
54551
+ }
54552
+ });
54553
+ return {
54554
+ kind: "ok",
54555
+ path,
54556
+ bytes: info.size,
54557
+ durationMs
54558
+ };
54559
+ } finally {
54560
+ await rm(raw, { force: true });
54561
+ await rm(temp, { force: true });
54562
+ }
54563
+ }
54564
+ //#endregion
54565
+ //#region src/clips/clip-twins.ts
54566
+ /**
54567
+ * The one mapping. An ABSENT profile is not an upgrade: a caller that stated
54568
+ * no preference stated no ask, and D382's rule is that an unstated ask is
54569
+ * zero — there is nothing for a line to be about.
54570
+ */
54571
+ function chooseClipQuality(profile) {
54572
+ return {
54573
+ twin: "main",
54574
+ served: "high",
54575
+ upgraded: profile !== void 0 && profile !== "high"
54576
+ };
54577
+ }
54578
+ /**
54579
+ * One-per-(camera, asked profile) bookkeeping for the upgrade line.
54580
+ *
54581
+ * Deliberately a plain object rather than a module-level map: a second runner
54582
+ * of this addon must not inherit another's "already said that".
54583
+ */
54584
+ var ClipUpgradeNotes = class {
54585
+ said = /* @__PURE__ */ new Map();
54586
+ /** Note one upgraded ask and say whether it is worth a line. */
54587
+ note(deviceId, asked) {
54588
+ const key = `${String(deviceId)}|${asked}`;
54589
+ const seen = this.said.get(key);
54590
+ if (seen === void 0) {
54591
+ this.said.set(key, 0);
54592
+ return {
54593
+ kind: "say",
54594
+ asked,
54595
+ suppressedSince: 0
54596
+ };
54597
+ }
54598
+ this.said.set(key, seen + 1);
54599
+ return { kind: "silent" };
54600
+ }
54601
+ /** How many asks this camera/profile pair has made since its one line. Read
54602
+ * by the listing log, so the quiet carries its own denominator. */
54603
+ suppressed(deviceId, asked) {
54604
+ return this.said.get(`${String(deviceId)}|${asked}`) ?? 0;
54605
+ }
54606
+ };
54607
+ //#endregion
54608
+ //#region src/clips/clip-window.ts
54609
+ /**
54610
+ * A row whose `endMs` is not after its `startMs` is treated as an INSTANT at
54611
+ * `startMs` — judging it by a bad end would drop a recording rather than
54612
+ * misplace it, and one of those is recoverable.
54613
+ */
54614
+ function clipOverlapsWindow(row, window) {
54615
+ const end = row.endMs > row.startMs ? row.endMs : row.startMs;
54616
+ return row.startMs < window.until && end > window.since;
54617
+ }
54618
+ function narrowClipsToWindow(rows, window) {
54619
+ const kept = rows.filter((row) => clipOverlapsWindow(row, window));
54620
+ return {
54621
+ kept,
54622
+ outOfWindow: rows.length - kept.length
54623
+ };
54624
+ }
54625
+ //#endregion
54626
+ //#region src/clips/amcrest-videoclips-provider.ts
54627
+ /**
54628
+ * The name an export carries away.
54629
+ *
54630
+ * The camera's own file name is `10.23.27-10.24.41[M][0@0][0].mp4` — square
54631
+ * brackets, an `@`, and a clock with no date. Every one of those is a hazard in
54632
+ * a downloads folder and none of them says WHEN. So the name is minted from the
54633
+ * window, in ISO, and the extension is re-stated because what we hand over is
54634
+ * an MP4 whatever the camera called it.
54635
+ */
54636
+ function clipFileLabel(row) {
54637
+ return `amcrest_${new Date(row.startMs).toISOString().replace(/[:.]/g, "-").slice(0, 19)}.mp4`;
54638
+ }
54639
+ /**
54640
+ * What the camera's own event strings mean in the cap's vocabulary.
54641
+ *
54642
+ * `VideoMotion` is the only value 60 of 60 measured rows carried, so this is a
54643
+ * mapping written against ONE observed string and it says so. Everything else
54644
+ * is `native` — the camera's words survive verbatim on `nativeTypes`, which is
54645
+ * the lossless copy D549 20 requires precisely because this table is lossy.
54646
+ */
54647
+ function clipKindFor(row) {
54648
+ if (row.events.some((event) => event.toLowerCase().includes("motion"))) return "motion";
54649
+ return "native";
54650
+ }
54651
+ function createAmcrestVideoclipsProvider(deps) {
54652
+ /** Last known availability per device, written by every read. */
54653
+ const health = /* @__PURE__ */ new Map();
54654
+ /** Every row this provider has listed, by `deviceId|clipKey`, so a byte
54655
+ * offer resolves without re-searching the camera. */
54656
+ const rowsByKey = /* @__PURE__ */ new Map();
54657
+ const upgrades = new ClipUpgradeNotes();
54658
+ const rowKey = (deviceId, clipKey) => `${String(deviceId)}|${clipKey}`;
54659
+ const remember = (deviceId, rows) => {
54660
+ for (const row of rows) rowsByKey.set(rowKey(deviceId, row.clipKey), row);
54661
+ };
54662
+ const toClip = (row, catalogAsOf) => {
54663
+ const id = mintClipId({
54664
+ source: CLIP_SOURCE_ONBOARD,
54665
+ startMs: row.startMs,
54666
+ endMs: row.endMs
54667
+ });
54668
+ return {
54669
+ id,
54670
+ source: CLIP_SOURCE_ONBOARD,
54671
+ kind: clipKindFor(row),
54672
+ timeRange: {
54673
+ startMs: row.startMs,
54674
+ endMs: row.endMs
54675
+ },
54676
+ ...row.events.length > 0 ? { nativeTypes: [...row.events] } : {},
54677
+ thumbnailUnavailable: { reason: "unsupported" },
54678
+ catalogAsOf,
54679
+ streams: { main: {
54680
+ id,
54681
+ ...row.sizeBytes !== null ? { bytes: row.sizeBytes } : {}
54682
+ } },
54683
+ supportSub: false
54684
+ };
54685
+ };
54686
+ const noteUpgrade = (deviceId, profile) => {
54687
+ if (profile === void 0) return;
54688
+ if (upgrades.note(deviceId, profile).kind !== "say") return;
54689
+ deps.logger.warn("videoclips: this camera archives only the main copy — every ask is served it", {
54690
+ tags: { deviceId },
54691
+ meta: {
54692
+ branch: "quality-upgraded",
54693
+ asked: profile,
54694
+ served: "high",
54695
+ note: "further upgraded asks for this camera and profile are not logged"
54696
+ }
54697
+ });
54698
+ };
54699
+ return {
54700
+ listSources: async ({ deviceId }) => {
54701
+ const device = await deps.resolveDevice(deviceId);
54702
+ if (device === null) return [];
54703
+ const availability = await deps.probeAvailability(device);
54704
+ health.set(deviceId, availability);
54705
+ return [{
54706
+ source: CLIP_SOURCE_ONBOARD,
54707
+ addonId: AMCREST_ADDON_ID,
54708
+ label: "Camera storage",
54709
+ availability
54710
+ }];
54711
+ },
54712
+ listClips: async ({ deviceId, since, until, limit }) => {
54713
+ const device = await deps.resolveDevice(deviceId);
54714
+ if (device === null) return [];
54715
+ const tags = { deviceId };
54716
+ const outcome = await deps.readCatalog({
54717
+ device,
54718
+ sinceMs: since,
54719
+ untilMs: until
54720
+ });
54721
+ if (outcome.kind === "refused") {
54722
+ health.set(deviceId, outcome.availability);
54723
+ deps.logger.warn("videoclips: nothing to list, and the source cannot answer", {
54724
+ tags,
54725
+ meta: {
54726
+ branch: "refused-empty",
54727
+ state: outcome.availability.state,
54728
+ reason: outcome.availability.reason ?? null
54729
+ }
54730
+ });
54731
+ throw new Error(`videoclips: device ${String(deviceId)} listed no clips, and that is not an empty window — the source is "${outcome.availability.state}"` + (outcome.availability.reason !== void 0 ? `: ${outcome.availability.reason}` : "."));
54732
+ }
54733
+ const catalogAsOf = deps.now();
54734
+ health.set(deviceId, {
54735
+ state: "ok",
54736
+ catalogAsOf
54737
+ });
54738
+ if (outcome.kind === "empty") {
54739
+ deps.logger.info("videoclips: the window holds no clips", {
54740
+ tags,
54741
+ meta: {
54742
+ branch: "empty-window",
54743
+ source: CLIP_SOURCE_ONBOARD,
54744
+ since,
54745
+ until
54746
+ }
54747
+ });
54748
+ return [];
54749
+ }
54750
+ remember(deviceId, outcome.rows);
54751
+ const narrowed = narrowClipsToWindow(outcome.rows, {
54752
+ since,
54753
+ until
54754
+ });
54755
+ if (narrowed.outOfWindow > 0) deps.logger.info("videoclips: narrowed a listing to the window it was asked for", {
54756
+ tags,
54757
+ meta: {
54758
+ branch: "listing-narrowed",
54759
+ listed: outcome.rows.length,
54760
+ kept: narrowed.kept.length,
54761
+ outOfWindow: narrowed.outOfWindow,
54762
+ since,
54763
+ until
54764
+ }
54765
+ });
54766
+ const ordered = narrowed.kept.toSorted((a, b) => b.startMs - a.startMs);
54767
+ const capped = limit !== void 0 ? ordered.slice(0, limit) : ordered;
54768
+ deps.logger.info("videoclips: listed an Amcrest catalog", {
54769
+ tags,
54770
+ meta: {
54771
+ source: CLIP_SOURCE_ONBOARD,
54772
+ clips: capped.length,
54773
+ truncated: limit !== void 0 && ordered.length > limit,
54774
+ findChannel: device.findChannel
54775
+ }
54776
+ });
54777
+ return capped.map((row) => toClip(row, catalogAsOf));
54778
+ },
54779
+ /**
54780
+ * The player's URL — a finished, `moov`-first MP4 on this addon's own
54781
+ * media plane, with `Range`, because the surface hands it to a `<video>`
54782
+ * element that has to show a duration and seek.
54783
+ */
54784
+ getClipPlayback: async ({ deviceId, clipId, profile }) => {
54785
+ const parsed = parseClipId(clipId);
54786
+ if (parsed === null) throw new Error(`videoclips: clip id "${clipId}" was not minted by the Amcrest provider`);
54787
+ if (await deps.resolveDevice(deviceId) === null) throw new Error(`videoclips: device ${String(deviceId)} is not an Amcrest camera`);
54788
+ const clipKey = clipKeyOf(parsed);
54789
+ if (!rowsByKey.has(rowKey(deviceId, clipKey))) throw new Error(clipExportFailure("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${String(deviceId)} — list the window that holds it first`));
54790
+ const quality = chooseClipQuality(profile);
54791
+ if (quality.upgraded) noteUpgrade(deviceId, profile);
54792
+ return {
54793
+ playbackUrl: deps.playbackUrl(deviceId, clipKey),
54794
+ format: "mp4",
54795
+ served: quality.served
54796
+ };
54797
+ },
54798
+ /**
54799
+ * This clip's BYTES, base64, bounded — the broker's file feeder (D575).
54800
+ *
54801
+ * **It is wired, and it will refuse about a fifth of this camera's
54802
+ * catalog, by name.** Ten measured clips ran 33.4–72.9 MiB and 2 of them
54803
+ * exceed `VIDEOCLIPS_MAX_READ_BYTES` (50 MiB); the worst the camera can
54804
+ * structurally produce — one motion episode cut at the 5-minute
54805
+ * `PacketLength` cap, at the peak achieved 6.64 Mbit/s — is 238 MiB. The
54806
+ * envelope is unary, so that payload is held at ~1.33× in the provider AND
54807
+ * in the caller, on a hub this repo has already OOM'd (D9/D18), and the
54808
+ * bound is not going to move for an inline read.
54809
+ *
54810
+ * It is wired anyway, and the alternative was measured rather than
54811
+ * assumed: the broker's `chooseClipPath` reads whether *the broker* has a
54812
+ * file read wired, not whether this provider implements one. Leaving the
54813
+ * method off would make the broker choose the file path and then call a
54814
+ * method that answers `NOT_IMPLEMENTED` — an unnamed failure in place of
54815
+ * the named refusal this returns. A named `too-large-to-transfer` that an
54816
+ * operator can read is strictly better than an error nobody can, and it is
54817
+ * also the honest measurement that argues for the camera's own
54818
+ * forward-only replay as the next slice.
54819
+ *
54820
+ * {@link offerClipBytes} is the path an EXPORT takes, holds nothing on
54821
+ * either side, and reaches 500 MiB — every clip this camera can make.
54822
+ *
54823
+ * The cheap questions come first and none of them touches the camera: the
54824
+ * id, the row, and the SIZE the catalog already declared (D54).
54825
+ */
54826
+ readClipBytes: async ({ deviceId, clipId, profile, maxBytes }) => {
54827
+ const tags = { deviceId };
54828
+ const refuse = (code, detail, meta) => {
54829
+ deps.logger.warn("videoclips: refusing a clip byte read", {
54830
+ tags,
54831
+ meta: {
54832
+ clipId,
54833
+ branch: code,
54834
+ ...meta
54835
+ }
54836
+ });
54837
+ throw new Error(clipExportFailure(code, detail));
54838
+ };
54839
+ const device = await deps.resolveDevice(deviceId);
54840
+ if (device === null) return refuse("catalog-miss", `device ${String(deviceId)} is not an Amcrest camera`, {});
54841
+ const parsed = parseClipId(clipId);
54842
+ if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Amcrest provider`, {});
54843
+ const clipKey = clipKeyOf(parsed);
54844
+ const row = rowsByKey.get(rowKey(deviceId, clipKey));
54845
+ 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 });
54846
+ const quality = chooseClipQuality(profile);
54847
+ if (quality.upgraded) noteUpgrade(deviceId, profile);
54848
+ const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
54849
+ if (row.sizeBytes !== null && 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 an inline read — play it from its playback URL instead`, {
54850
+ clipKey,
54851
+ listedBytes: row.sizeBytes,
54852
+ bound
54853
+ });
54854
+ const outcome = await deps.prepareBytes({
54855
+ device,
54856
+ row,
54857
+ maxBytes: bound
54858
+ });
54859
+ if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
54860
+ if (outcome.bytes.byteLength > bound) return refuse("too-large-to-transfer", `clip "${clipId}" re-indexed to ${String(outcome.bytes.byteLength)} bytes, over the ${String(bound)}-byte bound for an inline read`, {
54861
+ clipKey,
54862
+ bytes: outcome.bytes.byteLength,
54863
+ bound
54864
+ });
54865
+ deps.logger.info("videoclips: served an Amcrest clip inline", {
54866
+ tags,
54867
+ meta: {
54868
+ clipId,
54869
+ clipKey,
54870
+ served: quality.served,
54871
+ asked: profile ?? null,
54872
+ bytes: outcome.bytes.byteLength,
54873
+ durationMs: outcome.durationMs
54874
+ }
54875
+ });
54876
+ return {
54877
+ base64: outcome.bytes.toString("base64"),
54878
+ contentType: "video/mp4",
54879
+ name: clipFileLabel(row),
54880
+ bytes: outcome.bytes.byteLength,
54881
+ served: quality.served,
54882
+ ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
54883
+ };
54884
+ },
54885
+ /**
54886
+ * Where this clip's finished bytes can be TAKEN (D613) — the by-handle
54887
+ * read a clip EXPORT pulls.
54888
+ *
54889
+ * {@link readClipBytes} with the envelope removed, and on this camera that
54890
+ * is the difference between exporting a fifth of the catalog and all of
54891
+ * it: the bound here is `VIDEOCLIPS_MAX_OFFER_BYTES` (500 MiB), which
54892
+ * every measured clip (33.4–72.9 MiB) and the worst structurally possible
54893
+ * one (238 MiB) fit with room. Nothing is held on either side — the bytes
54894
+ * leave over a one-shot loopback socket, straight off the scratch file.
54895
+ *
54896
+ * The cheap questions come first and none of them touches the camera: the
54897
+ * id, the row, and the SIZE the catalog already declared (D54).
54898
+ */
54899
+ offerClipBytes: async ({ deviceId, clipId, profile, maxBytes }) => {
54900
+ const tags = { deviceId };
54901
+ const refuse = (code, detail, meta) => {
54902
+ deps.logger.warn("videoclips: refusing a clip byte offer", {
54903
+ tags,
54904
+ meta: {
54905
+ clipId,
54906
+ branch: code,
54907
+ ...meta
54908
+ }
54909
+ });
54910
+ throw new Error(clipExportFailure(code, detail));
54911
+ };
54912
+ const device = await deps.resolveDevice(deviceId);
54913
+ if (device === null) return refuse("catalog-miss", `device ${String(deviceId)} is not an Amcrest camera`, {});
54914
+ const parsed = parseClipId(clipId);
54915
+ if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Amcrest provider`, {});
54916
+ const clipKey = clipKeyOf(parsed);
54917
+ const row = rowsByKey.get(rowKey(deviceId, clipKey));
54918
+ 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 });
54919
+ const quality = chooseClipQuality(profile);
54920
+ if (quality.upgraded) noteUpgrade(deviceId, profile);
54921
+ const bound = Math.min(maxBytes ?? 524288e3, VIDEOCLIPS_MAX_OFFER_BYTES);
54922
+ if (row.sizeBytes !== null && 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`, {
54923
+ clipKey,
54924
+ listedBytes: row.sizeBytes,
54925
+ bound
54926
+ });
54927
+ const outcome = await deps.prepareOffer({
54928
+ device,
54929
+ row,
54930
+ maxBytes: bound
54931
+ });
54932
+ if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
54933
+ deps.logger.info("videoclips: offered an Amcrest clip by handle", {
54934
+ tags,
54935
+ meta: {
54936
+ clipId,
54937
+ clipKey,
54938
+ served: quality.served,
54939
+ asked: profile ?? null,
54940
+ bytes: outcome.bytes,
54941
+ durationMs: outcome.durationMs,
54942
+ expiresInMs: outcome.ticket.expiresAtMs - deps.now()
54943
+ }
54944
+ });
54945
+ return {
54946
+ ticket: outcome.ticket,
54947
+ contentType: "video/mp4",
54948
+ name: clipFileLabel(row),
54949
+ served: quality.served,
54950
+ ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
54951
+ };
54952
+ },
54953
+ /**
54954
+ * What a surface may DRAW for this camera's clips (D612).
54955
+ *
54956
+ * A CONSTANT, and one of the two the contract spells — a provider never
54957
+ * composes an envelope of its own. `file`, because this provider reaches
54958
+ * the player through one bounded by-handle fetch of a whole, `stbl`-indexed
54959
+ * MP4: the camera hands over a self-contained ISO MP4 with the `moov` at
54960
+ * the end and no ranged read, so the clip is pulled once, made seekable
54961
+ * once, and then everything the `file` envelope promises — free seek,
54962
+ * backward frame-step, scrub, the broker's whole rate ladder — is true of
54963
+ * it.
54964
+ *
54965
+ * The camera's OWN forward-only replay (`/cam/playback` over RTSP) is real
54966
+ * and is deliberately NOT what this answers, because it is not wired: it
54967
+ * would need `dialClipStream`, and the one rate it could honestly offer is
54968
+ * `[1]` — `Scale` is accepted, never echoed, and a cold `Scale: 4.0` drain
54969
+ * delivered 10 % of the packets in 42 % of the time with the audio dropped
54970
+ * entirely. Answering `stream` before that path exists would promise a
54971
+ * transport with no producer behind it.
54972
+ */
54973
+ getPlaybackOptions: async () => CLIP_PLAYBACK_OPTIONS.file
54974
+ };
54975
+ }
54976
+ //#endregion
54977
+ //#region src/clips/clip-service.ts
54978
+ function createClipService(deps) {
54979
+ const now = deps.now ?? (() => Date.now());
54980
+ const logger = deps.logger;
54981
+ const store = new ClipArtifactStore({ root: deps.dataDir });
54982
+ /** One RPC2 client per camera. Minted lazily: a camera nobody asks for clips
54983
+ * about never logs in. */
54984
+ const rpcByDevice = /* @__PURE__ */ new Map();
54985
+ /** The measured camera-clock offset, per device. */
54986
+ const clockByDevice = /* @__PURE__ */ new Map();
54987
+ /** Every row the provider has listed, mirrored here so the media ROUTE —
54988
+ * which is reached without a cap call — resolves a key without the camera. */
54989
+ const rowsByKey = /* @__PURE__ */ new Map();
54990
+ const rowKey = (deviceId, clipKey) => `${String(deviceId)}|${clipKey}`;
54991
+ const rpcFor = (camera) => {
54992
+ const existing = rpcByDevice.get(camera.deviceId);
54993
+ if (existing !== void 0) return existing;
54994
+ const client = new AmcrestRpc2Client({
54995
+ host: camera.host,
54996
+ port: camera.port,
54997
+ https: camera.https,
54998
+ username: camera.username,
54999
+ password: camera.password,
55000
+ now
55001
+ });
55002
+ rpcByDevice.set(camera.deviceId, client);
55003
+ return client;
55004
+ };
55005
+ /**
55006
+ * The camera's clock, measured and cached.
55007
+ *
55008
+ * `null` when the read failed — never a zero offset, which is the same shape
55009
+ * as "this camera runs on UTC" and would move its whole archive by the
55010
+ * timezone it really has.
55011
+ */
55012
+ const clockFor = async (camera) => {
55013
+ const cached = clockByDevice.get(camera.deviceId);
55014
+ if (cached !== void 0 && cameraClockIsFresh(cached, now())) return cached;
55015
+ let body;
55016
+ const sentAt = now();
55017
+ try {
55018
+ body = await camera.client.getCurrentTime();
55019
+ } catch (err) {
55020
+ logger.warn("videoclips: the camera did not say what time it thinks it is", {
55021
+ tags: { deviceId: camera.deviceId },
55022
+ meta: {
55023
+ branch: "clock-unread",
55024
+ error: err instanceof Error ? err.message : String(err)
55025
+ }
55026
+ });
55027
+ return null;
55028
+ }
55029
+ const clock = deriveCameraClock({
55030
+ currentTimeBody: body,
55031
+ hostUtcBeforeMs: sentAt,
55032
+ hostUtcAfterMs: now()
55033
+ });
55034
+ if (clock === null) {
55035
+ logger.warn("videoclips: the camera answered a time this provider could not read", {
55036
+ tags: { deviceId: camera.deviceId },
55037
+ meta: { branch: "clock-unparsed" }
55038
+ });
55039
+ return null;
55040
+ }
55041
+ clockByDevice.set(camera.deviceId, clock);
55042
+ if (cameraClockDisagrees(clock)) logger.warn("videoclips: the camera's clock disagrees with the hub by more than any timezone", {
55043
+ tags: { deviceId: camera.deviceId },
55044
+ meta: {
55045
+ branch: "clock-disagrees",
55046
+ offsetMs: clock.offsetMs
55047
+ }
55048
+ });
55049
+ else logger.debug("videoclips: measured this camera’s clock offset", {
55050
+ tags: { deviceId: camera.deviceId },
55051
+ meta: { offsetMs: clock.offsetMs }
55052
+ });
55053
+ return clock;
55054
+ };
55055
+ const resolveDevice = async (deviceId) => {
55056
+ const camera = await deps.resolveCamera(deviceId);
55057
+ if (camera === null) return null;
55058
+ return {
55059
+ deviceId,
55060
+ findChannel: Math.max(0, camera.channel - 1)
55061
+ };
55062
+ };
55063
+ const fileFor = async (deviceId, clipKey) => {
55064
+ const camera = await deps.resolveCamera(deviceId);
55065
+ if (camera === null) return {
55066
+ kind: "refused",
55067
+ code: "fetch-failed",
55068
+ detail: `device ${String(deviceId)} is not an Amcrest camera`
55069
+ };
55070
+ const row = rowsByKey.get(rowKey(deviceId, clipKey));
55071
+ if (row === void 0) return {
55072
+ kind: "refused",
55073
+ code: "fetch-failed",
55074
+ detail: "no-catalog-row: list the window that holds this clip first"
55075
+ };
55076
+ return await prepareClipFile({
55077
+ deviceId,
55078
+ row
55079
+ }, {
55080
+ logger,
55081
+ store,
55082
+ client: camera.client,
55083
+ ...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {}
55084
+ });
55085
+ };
55086
+ return {
55087
+ provider: createAmcrestVideoclipsProvider({
55088
+ now,
55089
+ logger,
55090
+ resolveDevice,
55091
+ probeAvailability: async (device) => {
55092
+ const camera = await deps.resolveCamera(device.deviceId);
55093
+ if (camera === null) return {
55094
+ state: "unreachable",
55095
+ reason: "this device is not an Amcrest camera"
55096
+ };
55097
+ const tags = { deviceId: device.deviceId };
55098
+ const storage = await camera.readStorage();
55099
+ if (storage !== null) {
55100
+ logger.info("videoclips: the storage read settled this source", {
55101
+ tags,
55102
+ meta: {
55103
+ branch: storage.state,
55104
+ reason: storage.reason ?? null
55105
+ }
55106
+ });
55107
+ return storage;
55108
+ }
55109
+ const clockRefusal = availabilityFromClock(await clockFor(camera));
55110
+ if (clockRefusal !== null) return clockRefusal;
55111
+ return {
55112
+ state: "ok",
55113
+ catalogAsOf: now()
55114
+ };
55115
+ },
55116
+ readCatalog: async ({ device, sinceMs, untilMs }) => {
55117
+ const camera = await deps.resolveCamera(device.deviceId);
55118
+ if (camera === null) return {
55119
+ kind: "refused",
55120
+ availability: {
55121
+ state: "unreachable",
55122
+ reason: "this device is not an Amcrest camera"
55123
+ }
55124
+ };
55125
+ const storage = await camera.readStorage();
55126
+ if (storage !== null) return {
55127
+ kind: "refused",
55128
+ availability: storage
55129
+ };
55130
+ const clock = await clockFor(camera);
55131
+ const clockRefusal = availabilityFromClock(clock);
55132
+ if (clockRefusal !== null || clock === null) return {
55133
+ kind: "refused",
55134
+ availability: clockRefusal ?? {
55135
+ state: "unreachable",
55136
+ reason: "no camera clock"
55137
+ }
55138
+ };
55139
+ const outcome = await readClipRows({
55140
+ rpc: rpcFor(camera),
55141
+ logger,
55142
+ deviceId: device.deviceId
55143
+ }, {
55144
+ channel: device.findChannel,
55145
+ sinceMs,
55146
+ untilMs,
55147
+ clock
55148
+ });
55149
+ switch (outcome.kind) {
55150
+ case "unreachable":
55151
+ logger.warn("videoclips: the catalog search did not complete", {
55152
+ tags: { deviceId: device.deviceId },
55153
+ meta: {
55154
+ branch: "camera-refused",
55155
+ error: outcome.detail
55156
+ }
55157
+ });
55158
+ return {
55159
+ kind: "refused",
55160
+ availability: {
55161
+ state: "unreachable",
55162
+ reason: outcome.detail
55163
+ }
55164
+ };
55165
+ case "index-empty":
55166
+ logger.warn("videoclips: the camera’s index answers nothing anywhere", {
55167
+ tags: { deviceId: device.deviceId },
55168
+ meta: {
55169
+ branch: "index-empty",
55170
+ error: outcome.detail
55171
+ }
55172
+ });
55173
+ return {
55174
+ kind: "refused",
55175
+ availability: {
55176
+ state: "index-empty",
55177
+ reason: outcome.detail
55178
+ }
55179
+ };
55180
+ case "empty-window": return { kind: "empty" };
55181
+ case "ok":
55182
+ for (const row of outcome.rows) rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
55183
+ logger.info("videoclips: the camera answered a catalog search", {
55184
+ tags: { deviceId: device.deviceId },
55185
+ meta: {
55186
+ rows: outcome.rows.length,
55187
+ pages: outcome.pages,
55188
+ rejected: outcome.rejectedCount,
55189
+ findChannel: device.findChannel,
55190
+ oldestStartMs: outcome.rows[0]?.startMs ?? null,
55191
+ newestStartMs: outcome.rows[outcome.rows.length - 1]?.startMs ?? null
55192
+ }
55193
+ });
55194
+ return {
55195
+ kind: "ok",
55196
+ rows: outcome.rows
55197
+ };
55198
+ }
55199
+ },
55200
+ playbackUrl: (deviceId, clipKey) => `/addon/${AMCREST_ADDON_ID}/${CLIP_MEDIA_PREFIX}/${String(deviceId)}/${clipKey}.mp4`,
55201
+ prepareBytes: async ({ device, row, maxBytes }) => {
55202
+ rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
55203
+ const file = await fileFor(device.deviceId, row.clipKey);
55204
+ if (file.kind === "refused") return {
55205
+ kind: "refused",
55206
+ code: file.code,
55207
+ detail: file.detail
55208
+ };
55209
+ if (file.bytes > maxBytes) return {
55210
+ kind: "refused",
55211
+ code: "too-large-to-transfer",
55212
+ detail: `clip "${row.clipKey}" is ${String(file.bytes)} bytes, over the ${String(maxBytes)}-byte bound for an inline read — play it from its playback URL`
55213
+ };
55214
+ return {
55215
+ kind: "ok",
55216
+ bytes: await readFile(file.path),
55217
+ durationMs: file.durationMs
55218
+ };
55219
+ },
55220
+ prepareOffer: async ({ device, row, maxBytes }) => {
55221
+ const peerBytes = deps.peerBytes;
55222
+ if (peerBytes === void 0) {
55223
+ logger.warn("videoclips: no peer-bytes transport on this node", {
55224
+ tags: { deviceId: device.deviceId },
55225
+ meta: {
55226
+ clipKey: row.clipKey,
55227
+ branch: "no-peer-bytes"
55228
+ }
55229
+ });
55230
+ return {
55231
+ kind: "refused",
55232
+ code: "fetch-failed",
55233
+ detail: "this node has no addon-to-addon byte transport wired"
55234
+ };
55235
+ }
55236
+ rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
55237
+ const file = await fileFor(device.deviceId, row.clipKey);
55238
+ if (file.kind === "refused") return {
55239
+ kind: "refused",
55240
+ code: file.code,
55241
+ detail: file.detail
55242
+ };
55243
+ if (file.bytes > maxBytes) return {
55244
+ kind: "refused",
55245
+ code: "too-large-to-transfer",
55246
+ detail: `clip "${row.clipKey}" is ${String(file.bytes)} bytes, over the ${String(maxBytes)}-byte bound for one transfer`
55247
+ };
55248
+ const offered = await peerBytes.offerFile({
55249
+ path: file.path,
55250
+ contentType: "video/mp4",
55251
+ label: "amcrest-clip",
55252
+ deviceId: device.deviceId
55253
+ });
55254
+ if (offered.kind === "refused") {
55255
+ logger.warn("videoclips: the clip byte offer could not be minted", {
55256
+ tags: { deviceId: device.deviceId },
55257
+ meta: {
55258
+ clipKey: row.clipKey,
55259
+ branch: offered.code,
55260
+ detail: offered.detail
55261
+ }
55262
+ });
55263
+ return {
55264
+ kind: "refused",
55265
+ code: "fetch-failed",
55266
+ detail: `${offered.code}: ${offered.detail}`
55267
+ };
55268
+ }
55269
+ return {
55270
+ kind: "ok",
55271
+ ticket: offered.ticket,
55272
+ durationMs: file.durationMs,
55273
+ bytes: file.bytes
55274
+ };
55275
+ }
55276
+ }),
55277
+ mediaPrefix: CLIP_MEDIA_PREFIX,
55278
+ mediaHandler: createClipPlaybackHandler({
55279
+ logger,
55280
+ resolvePath: async (deviceId, clipKey) => {
55281
+ const file = await fileFor(deviceId, clipKey);
55282
+ if (file.kind === "ok") return file.path;
55283
+ logger.warn("videoclips: a clip file was refused", {
55284
+ tags: { deviceId },
55285
+ meta: {
55286
+ clipKey,
55287
+ branch: file.code,
55288
+ reason: file.detail
55289
+ }
55290
+ });
55291
+ return null;
55292
+ }
55293
+ }),
55294
+ forgetDevice: (deviceId) => {
55295
+ rpcByDevice.get(deviceId)?.reset();
55296
+ rpcByDevice.delete(deviceId);
55297
+ clockByDevice.delete(deviceId);
55298
+ }
55299
+ };
55300
+ }
51709
55301
  //#endregion
51710
55302
  //#region ../../node_modules/xml2js/lib/defaults.js
51711
55303
  var require_defaults = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -60771,7 +64363,39 @@ var AmcrestProviderAddon = class extends BaseDeviceProvider {
60771
64363
  throw new Error(`Amcrest: probe on ${host || "(unknown host)"} resolved neither mac nor host address — cannot persist a stable row key. Verify network reachability + credentials, then retry.`);
60772
64364
  }
60773
64365
  async onInitialize() {
60774
- return await super.onInitialize();
64366
+ const regs = await super.onInitialize();
64367
+ this.clipService = createClipService({
64368
+ dataDir: this.ctx.dataDir,
64369
+ logger: this.ctx.logger,
64370
+ ...this.ctx.peerBytes !== void 0 ? { peerBytes: this.ctx.peerBytes } : {},
64371
+ resolveCamera: async (deviceId) => {
64372
+ const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
64373
+ return device instanceof AmcrestCamera ? device.getClipCamera() : null;
64374
+ }
64375
+ });
64376
+ regs.push({
64377
+ capability: videoclipsCapability,
64378
+ provider: this.clipService.provider
64379
+ });
64380
+ await this.serveClipPlane(this.clipService);
64381
+ return regs;
64382
+ }
64383
+ /** The clip service: catalog, the player's file route, the export's bytes. */
64384
+ clipService = null;
64385
+ async serveClipPlane(service) {
64386
+ try {
64387
+ const media = await this.ctx.dataPlane?.serve({
64388
+ prefix: service.mediaPrefix,
64389
+ access: "authenticated",
64390
+ handler: service.mediaHandler
64391
+ });
64392
+ this.ctx.logger.info("Amcrest clip data plane served", { meta: {
64393
+ mediaPath: `/addon/${AMCREST_ADDON_ID}/${service.mediaPrefix}`,
64394
+ mediaServed: media !== void 0
64395
+ } });
64396
+ } catch (err) {
64397
+ this.ctx.logger.warn("Amcrest clip data plane failed to serve — clips will not play", { meta: { error: err instanceof Error ? err.message : String(err) } });
64398
+ }
60775
64399
  }
60776
64400
  async supportsDiscovery() {
60777
64401
  return true;