@camstack/addon-export-hap 1.2.129 → 1.2.134

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.
@@ -5409,7 +5409,7 @@ var ZodIssueCode = {
5409
5409
  var ZodFirstPartyTypeKind;
5410
5410
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5411
5411
  //#endregion
5412
- //#region ../types/dist/sleep-CopaBJss.mjs
5412
+ //#region ../types/dist/sleep-BVhJDJka.mjs
5413
5413
  /**
5414
5414
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5415
5415
  * window to float samples (D455).
@@ -5609,12 +5609,6 @@ Object.fromEntries([
5609
5609
  icon: "circle-dot",
5610
5610
  order: 40
5611
5611
  },
5612
- {
5613
- id: "clips",
5614
- label: "Clips",
5615
- icon: "clapperboard",
5616
- order: 41
5617
- },
5618
5612
  {
5619
5613
  id: "engine",
5620
5614
  label: "Inference Engine",
@@ -7045,6 +7039,18 @@ function systemMethod(input, output, options) {
7045
7039
  systemOnly: true
7046
7040
  };
7047
7041
  }
7042
+ /**
7043
+ * A method a SOURCE of a collection cap may legitimately not serve — OPTIONAL
7044
+ * on `InferProvider`. The `providerOptional: true` literal is what
7045
+ * `InferProvider` keys on; see {@link CapabilityMethodSchema.providerOptional}
7046
+ * for when this is the honest answer and when it is a soft stub.
7047
+ */
7048
+ function optionalMethod(input, output, options) {
7049
+ return {
7050
+ ...method(input, output, options),
7051
+ providerOptional: true
7052
+ };
7053
+ }
7048
7054
  var StaticDirOutputSchema$1 = object({ staticDir: string() });
7049
7055
  var VersionOutputSchema$1 = object({ version: string() });
7050
7056
  method(_void(), StaticDirOutputSchema$1, { auth: "admin" }), method(_void(), VersionOutputSchema$1, { auth: "admin" });
@@ -8068,6 +8074,8 @@ var EVENT_OWNER_TYPES = [
8068
8074
  * nothing failing until a caller asked.
8069
8075
  */
8070
8076
  var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
8077
+ /** The same list as a Zod enum, for the cap input that carries it. */
8078
+ var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
8071
8079
  new Set(EVENT_OWNER_TYPES);
8072
8080
  var EncodeProfileSchema = object({
8073
8081
  video: object({
@@ -15047,8 +15055,6 @@ method(object({
15047
15055
  deviceId: number(),
15048
15056
  capName: string(),
15049
15057
  wrapperAddonId: string(),
15050
- /** The `ClipSource.source` id to toggle. Absent = all of the addon's. */
15051
- sourceId: string().optional(),
15052
15058
  active: boolean()
15053
15059
  }), _void(), {
15054
15060
  kind: "mutation",
@@ -21959,7 +21965,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21959
21965
  ownerType: EventOwnerTypeSchema,
21960
21966
  eventId: number().int(),
21961
21967
  deviceId: number()
21962
- }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
21968
+ }), array(MediaFileInfoSchema).readonly()), method(object({
21969
+ deviceId: number(),
21970
+ ownerKind: MediaPresenceOwnerKindSchema,
21971
+ ownerIds: array(string()).max(5e3)
21972
+ }), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
21963
21973
  kind: "mutation",
21964
21974
  auth: "admin"
21965
21975
  }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
@@ -24953,6 +24963,18 @@ method(VectorDeclareIndexInputSchema, _void(), {
24953
24963
  kind: "mutation",
24954
24964
  auth: "admin"
24955
24965
  }), method(VectorStatsInputSchema, VectorStatsResultSchema, { auth: "admin" });
24966
+ _enum([
24967
+ "queue-full",
24968
+ "camera-backoff",
24969
+ "sleeping",
24970
+ "camera-refused",
24971
+ "no-keyframe",
24972
+ "no-catalog-row",
24973
+ "unsupported",
24974
+ "unknown-device",
24975
+ "uid-missing"
24976
+ ]);
24977
+ _enum(["deferred", "final"]);
24956
24978
  var ClipSchema = object({
24957
24979
  /** Opaque, provider-namespaced id. The default provider encodes the time
24958
24980
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -25103,7 +25125,25 @@ var ClipSchema = object({
25103
25125
  */
25104
25126
  playable: boolean().optional(),
25105
25127
  /** Why {@link playable} is false, verbatim (`no-file-for-window`). */
25106
- unplayableReason: string().optional()
25128
+ unplayableReason: string().optional(),
25129
+ /**
25130
+ * This clip is STILL BEING WRITTEN, so {@link ClipSchema.timeRange}`.endMs`
25131
+ * is not its end.
25132
+ *
25133
+ * Absent — the common case — means the row is closed and its `endMs` is the
25134
+ * end of the recording. Present and `true` means the source told us the file
25135
+ * has no end yet, and the `endMs` we carry is whatever the camera's index
25136
+ * entry happened to hold: on a Reolink E1 Outdoor PoE (592, measured
25137
+ * 2026-09-20) the newest file `…_192927_000000_…_0.mp4` reported an `endTime`
25138
+ * of 19:29:56 and STILL reported it eight minutes later, while the file went
25139
+ * on growing. A surface that drew 29 s there was lying about a clip that
25140
+ * plays for minutes, on the one row the operator looks at first.
25141
+ *
25142
+ * `endMs` is deliberately still a number: it is the best bound anything has
25143
+ * for a mint window or a byte fetch, and every consumer already requires it.
25144
+ * This flag says what it is WORTH, and a duration is not drawn from it.
25145
+ */
25146
+ inProgress: boolean().optional()
25107
25147
  });
25108
25148
  var ClipPlaybackSchema = object({
25109
25149
  /**
@@ -25149,6 +25189,77 @@ var ClipPlaybackSchema = object({
25149
25189
  * which days it asked for and got nothing — "no clips" would be a lie about a
25150
25190
  * card that is full of them.
25151
25191
  */
25192
+ /**
25193
+ * The operator's authorisation to WAKE a sleeping camera for one clip read.
25194
+ *
25195
+ * ONE value, absent by default, and it is a `force`-shaped operator signal in
25196
+ * exactly the sense `snapshot-wake-gate.ts` uses the word: *"`snapshot.getSnapshot`'s
25197
+ * `force` flag, and nothing else… a background caller must never set it…
25198
+ * Stale but honest beats woken"*. A scheduler, a retry, a reconcile and a
25199
+ * prefetch never set it; a surface sets it only behind the same confirm the
25200
+ * "Wake and refresh" gesture uses, and it is refused below 15 % battery
25201
+ * exactly as that gesture is.
25202
+ *
25203
+ * The gate is decided BEFORE `getApi()`, because on UDP the login IS the wake
25204
+ * (D549) — a read that opened a session and then checked would have woken the
25205
+ * camera to find out it was not allowed to.
25206
+ *
25207
+ * **One authorised yes is one wake.** `next-natural` — take the clip the next
25208
+ * time the camera is awake for its own reasons — is deliberately not a member:
25209
+ * it was proposed, it is free on the battery, and the operator declined it on
25210
+ * 2026-09-20 (*"Quando un export viene richiesto si sveglia la camera."*,
25211
+ * D558 "Considered and not taken").
25212
+ */
25213
+ var ClipWakeSchema = _enum(["authorised"]);
25214
+ /**
25215
+ * Hard ceiling on ONE {@link videoclipsCapability.methods.readClipBytes} — the
25216
+ * same 50 MiB `RECORDING_EXPORT_MAX_READ_BYTES` uses, and for the same second
25217
+ * reason: the envelope is unary, so a base64 payload is held whole (~1.33× its
25218
+ * size) in the provider AND in the caller, on a hub this repo has already
25219
+ * OOM'd once (D9/D18).
25220
+ *
25221
+ * Measured clips sit far below it — 92 KB–2.29 MB for a sub twin, 455 KB for a
25222
+ * 16 s main clip — so the bound bites rarely. "Rarely" is not "never": a long
25223
+ * 4K main twin can exceed it, and above the bound the provider REFUSES with
25224
+ * the size in the message, never truncates. Half a video is worse than an
25225
+ * honest refusal.
25226
+ *
25227
+ * The clean follow-on is a CHUNKED read so a `high` twin of a long clip stops
25228
+ * being refusable at all. That is a later slice, named here so the bound is
25229
+ * not mistaken for a design ceiling.
25230
+ */
25231
+ var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
25232
+ /**
25233
+ * A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
25234
+ *
25235
+ * `bytes` is the DECODED length, so nobody infers it from the base64 length,
25236
+ * and `served` says which twin the caller actually got.
25237
+ */
25238
+ var ClipBytesSchema = object({
25239
+ base64: string(),
25240
+ contentType: string(),
25241
+ /** Suggested filename, extension included. */
25242
+ name: string(),
25243
+ bytes: number().int().nonnegative(),
25244
+ /**
25245
+ * Which twin was actually served — the same contract
25246
+ * {@link ClipPlaybackSchema.served} carries, and REQUIRED here because the
25247
+ * export record persists it: a row read a week later must say the same thing
25248
+ * the panel said at the moment of the tap. A missing main twin is never
25249
+ * served silently as if it were the asked-for quality (D549 15).
25250
+ */
25251
+ served: CamProfileSchema,
25252
+ /**
25253
+ * The DECODED duration of the delivered file, when the fetch measured one.
25254
+ *
25255
+ * A clip fetch verifies its own completion against the catalog row and
25256
+ * retries a materially short pass (D568); this is that measurement, carried
25257
+ * so a consumer can say the same thing rather than re-deriving it. Absent
25258
+ * when the producer did not measure — never zero, which would say the file
25259
+ * is empty.
25260
+ */
25261
+ durationMs: number().positive().optional()
25262
+ });
25152
25263
  var ClipSourceAvailabilitySchema = object({
25153
25264
  state: _enum([
25154
25265
  "ok",
@@ -25186,99 +25297,205 @@ var ClipSourceSchema = object({
25186
25297
  * alternative — a `native:reolink:* → provider-reolink` table inside the
25187
25298
  * widget — is a second authority on provider identity living in the one
25188
25299
  * package with no business knowing it, wrong the day a third source appears
25189
- * (D557). One addon may serve SEVERAL sources: `addon-provider-reolink`
25190
- * answers a hub child with both `native:reolink:onboard` and
25191
- * `native:reolink:hub`, which is why the per-device switch is keyed by
25192
- * SOURCE and not by this (D555).
25300
+ * (D557). One addon may serve SEVERAL sources, which is why the per-device
25301
+ * switch is keyed by SOURCE and not by this (D555) — `addon-provider-reolink`
25302
+ * served a hub child two of them until the two views were measured to be one
25303
+ * store read twice (D555) and then read ONE way (D565).
25193
25304
  *
25194
25305
  * Optional for version skew only. The collection dispatcher stamps it from
25195
25306
  * the registry, so a row that travelled through the fan-out carries the
25196
25307
  * authoritative id whatever the provider filled in.
25197
25308
  */
25198
25309
  addonId: string().optional(),
25199
- /**
25200
- * Which API this source resolved to FOR THIS CAMERA, when it has a choice.
25201
- *
25202
- * A source may cover one store through more than one surface — the Reolink
25203
- * provider reads a hub child through the parent's event log and a standalone
25204
- * through its own file list, because that is what each camera answers. The
25205
- * CHOICE is the provider's, made from what the camera is, and is never a row
25206
- * the operator has to understand; but it is REPORTED, because a source that
25207
- * silently reads a different API on two cameras and then behaves differently
25208
- * is the thing nobody can debug later. Absent when the source has only one
25209
- * way to read its store.
25210
- */
25211
- via: string().optional(),
25212
- availability: ClipSourceAvailabilitySchema,
25213
- /**
25214
- * The operator switched this source OFF for this camera.
25215
- *
25216
- * Deliberately NOT a member of {@link ClipSourceAvailabilitySchema}'s
25217
- * vocabulary. That enum models what the source CAN do — a sleeping camera, an
25218
- * unmounted card, an index that disagrees with its own calendar — and a
25219
- * switched-off source could answer perfectly well; the operator decided it
25220
- * should not. Folding the choice in is how `disabled` and `broken` stop being
25221
- * distinguishable, which is the D62 rule this repo has already paid for twice:
25222
- * an off switch is REPORTED off (`CameraStatus.switchedOff`, the same word),
25223
- * and disabled must never look like broken. It is also what lets every
25224
- * exhaustive consumer of the availability enum keep compiling.
25225
- *
25226
- * A switched-off source contributes NO clips (`listClips` never calls it) and
25227
- * its row carries no `catalogAsOf`: nothing confirms a catalog it is not
25228
- * allowed to serve, and a frozen age that can only grow draws a stalling
25229
- * source rather than an off switch.
25230
- *
25231
- * The row survives BECAUSE it is the control the operator switches the source
25232
- * back on from — D554 decision 4's rule ("a source that cannot answer
25233
- * produces a ROW, not an absence") applied to the one case D556 carved out of
25234
- * it, and D557's own kept property ("a source is never hidden, only its
25235
- * rows"). Absent means on.
25236
- *
25237
- * A provider never sets this — like {@link ClipSourceSchema.addonId} it is
25238
- * stamped by the collection dispatcher, which holds the registry's projection
25239
- * of the persisted authority (D556) and is the only place that knows it.
25240
- */
25241
- switchedOff: boolean().optional()
25242
- });
25243
- DeviceType.Camera, method(object({
25244
- deviceId: number(),
25245
- since: number(),
25246
- until: number(),
25247
- limit: number().int().positive().optional(),
25248
- /**
25249
- * View filter over {@link ClipSourceSchema.source} values — the
25250
- * picker's selection, forwarded so a provider need not list what
25251
- * nobody is looking at. ABSENT means every source this camera has,
25252
- * which is the honest default for a surface whose whole point is that
25253
- * nothing is hidden (D554 3). A provider with one source ignores it.
25254
- */
25255
- sources: array(string()).optional()
25256
- }), array(ClipSchema).readonly(), {
25257
- kind: "query",
25258
- auth: "protected"
25259
- }), method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
25260
- kind: "query",
25261
- auth: "protected"
25262
- }), method(object({
25263
- deviceId: number(),
25264
- clipId: string(),
25265
- /**
25266
- * Which twin to serve, on the ONE quality scale the system already has
25267
- * (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
25268
- * twin; both ids are already on the row so this never re-searches the
25269
- * camera. Absent means the provider's own default (the sub file, which
25270
- * every source is measured to hold).
25271
- *
25272
- * `auto` is deliberately NOT accepted here: a stored file has no
25273
- * broker session, so the adaptive tier cannot be resolved for it. The
25274
- * viewer resolves `auto` to a profile the same way live does, before
25275
- * it calls (D549 15).
25276
- */
25277
- profile: CamProfileSchema.optional()
25278
- }), ClipPlaybackSchema, {
25279
- kind: "query",
25280
- auth: "protected"
25310
+ availability: ClipSourceAvailabilitySchema
25281
25311
  });
25312
+ var videoclipsCapability = {
25313
+ name: "videoclips",
25314
+ scope: "device",
25315
+ mode: "collection",
25316
+ kind: "wrapper",
25317
+ defaultActive: true,
25318
+ /** A clip is a window over a camera's footage — the cap is meaningless on a
25319
+ * sensor, a button or an event emitter, and the `defaultActive` auto-bind
25320
+ * reads this to decide which devices it may claim. */
25321
+ deviceTypes: [DeviceType.Camera],
25322
+ /**
25323
+ * The Clips section of a camera's device details is FRAMEWORK-DERIVED (D14):
25324
+ * the aggregator turns this declaration into the `type:'widget'` section and
25325
+ * `DeviceDetail.tsx` is never edited. `videoclips` is the first WRAPPER cap
25326
+ * to declare one — every previous `host/` widget cap is `deviceNative` — so
25327
+ * `device-config-widget-wrapped-binding.spec.ts` pins that a `kind:'wrapped'`
25328
+ * binding entry derives the same section a native one does.
25329
+ *
25330
+ * **It is a SECTION of the Recording tab, at the end of it — not a tab of
25331
+ * its own.** It shipped as a `clips` top-tab and the operator rejected the
25332
+ * placement: *"utilizzerei la stessa tab recordings, lì abbiamo già tutto il
25333
+ * necessario, una nuova sezione alla fine per le clips"*. The Recording tab
25334
+ * already holds the recorder's panel, the schedule bands and the unified
25335
+ * retention policy; footage the camera itself holds is the same question,
25336
+ * asked of a different store. `order: 100` puts it after all of them with
25337
+ * room left in front. The `clips` entry in `WELL_KNOWN_TABS` went with it —
25338
+ * a well-known id nobody declares is an invitation to mint the tab again.
25339
+ *
25340
+ * `topTab` stays: the browser owns a pane (a day's tiles, a source list and
25341
+ * a player) and the Config tab's inner bar has no room for one. The widget
25342
+ * itself is `host/clips-browser` in ui-library's `HOST_WIDGETS`, and because
25343
+ * the admin Recordings page renders every `location:'top-tab'` +
25344
+ * `tab:'recording'` section behind its camera picker
25345
+ * (`CameraRecordingSettingsSection`), this declaration lands the section on
25346
+ * BOTH surfaces with no second wiring.
25347
+ */
25348
+ deviceConfig: { ui: {
25349
+ kind: "widget",
25350
+ widgetId: "host/clips-browser",
25351
+ tab: "recording",
25352
+ topTab: true,
25353
+ label: "Clips",
25354
+ order: 100
25355
+ } },
25356
+ methods: {
25357
+ listClips: method(object({
25358
+ deviceId: number(),
25359
+ since: number(),
25360
+ until: number(),
25361
+ limit: number().int().positive().optional(),
25362
+ /**
25363
+ * WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
25364
+ * carries, never a source id and never a list. **Required** (D554 amended).
25365
+ *
25366
+ * A provider the device is not bound to is refused by name rather than
25367
+ * answered by another one (D552's `rejectUnresolvedAddonPin` rule).
25368
+ *
25369
+ * It was optional, documented as "absent means the device's BOUND
25370
+ * provider, which is CamStack on every camera". No code implemented
25371
+ * that. Measured on the live hub 2026-09-20 — device 592, bound to
25372
+ * `recorder` AND `provider-reolink` — a bare call with `limit: 3`
25373
+ * answered SIX rows, three from each source, merged newest-first:
25374
+ * `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
25375
+ * so absence bought the union this method exists not to be, and
25376
+ * `limit` meant `limit × sources`.
25377
+ *
25378
+ * There is nothing to restore the default to. `getBindings` answers a
25379
+ * collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
25380
+ * `setWrapperActive` — the singleton authority that could name one —
25381
+ * throws for a collection cap by design. Naming CamStack here instead
25382
+ * would privilege one addon by id inside a surface whose premise is
25383
+ * that sources are peers, and would be wrong on the first camera with
25384
+ * no recorder binding. So absence is REFUSED, in the schema, where the
25385
+ * generated types make it unomittable rather than merely discouraged.
25386
+ *
25387
+ * The default belongs to the SURFACE, which has the `listSources` rows
25388
+ * and can say which one it picked (D569 § 1–3; the viewer already
25389
+ * always sends this).
25390
+ *
25391
+ * It replaced `sources?: string[]`, a VIEW filter over source ids that
25392
+ * assumed the answer was a fan-out over everything a camera has. The
25393
+ * operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
25394
+ * solo"* — so the list is asked of one provider at a time and there is
25395
+ * nothing to filter out of it.
25396
+ */
25397
+ provider: string().min(1)
25398
+ }), array(ClipSchema).readonly(), {
25399
+ kind: "query",
25400
+ auth: "protected"
25401
+ }),
25402
+ /**
25403
+ * The sources this camera has, WITH the reason any of them cannot answer.
25404
+ *
25405
+ * Asked separately from `listClips` because an empty clip list is
25406
+ * ambiguous and this is the only place the ambiguity is resolved: every
25407
+ * bound provider contributes its own rows, and a provider that could not
25408
+ * be reached at all still produces one row saying so. A surface that draws
25409
+ * "no clips" without reading this is drawing a guess.
25410
+ */
25411
+ listSources: method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
25412
+ kind: "query",
25413
+ auth: "protected"
25414
+ }),
25415
+ getClipPlayback: method(object({
25416
+ deviceId: number(),
25417
+ clipId: string(),
25418
+ /**
25419
+ * Which twin to serve, on the ONE quality scale the system already has
25420
+ * (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
25421
+ * twin; both ids are already on the row so this never re-searches the
25422
+ * camera. Absent means the provider's own default (the sub file, which
25423
+ * every source is measured to hold).
25424
+ *
25425
+ * `auto` is deliberately NOT accepted here: a stored file has no
25426
+ * broker session, so the adaptive tier cannot be resolved for it. The
25427
+ * viewer resolves `auto` to a profile the same way live does, before
25428
+ * it calls (D549 15).
25429
+ */
25430
+ profile: CamProfileSchema.optional()
25431
+ }), ClipPlaybackSchema, {
25432
+ kind: "query",
25433
+ auth: "protected"
25434
+ }),
25435
+ /**
25436
+ * This clip's BYTES, base64, bounded — the by-handle read a clip EXPORT
25437
+ * pulls once (D558).
25438
+ *
25439
+ * `getClipPlayback` is the right answer for a player: it hands back a URL
25440
+ * on a plane the hub serves `access:'authenticated'`, which a browser and a
25441
+ * viewer session satisfy. It is the wrong answer for another ADDON. There
25442
+ * is no addon→addon byte transport in this framework — `AddonDataPlane`
25443
+ * only lets an addon SERVE, on `127.0.0.1` behind a per-listener secret
25444
+ * only the hub may present — so a recorder that wants a camera's clip
25445
+ * cannot fetch that URL. This method is the one seam that exists for it,
25446
+ * and it is deliberately the same shape (and the same bound) as
25447
+ * `recordingExport.readExportBytes`, which exists for the mirror-image
25448
+ * reason.
25449
+ *
25450
+ * Routing needs no `provider` pin: the id is source-prefixed and
25451
+ * self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
25452
+ * to the source that claims it — and an id nobody claims is REFUSED rather
25453
+ * than answered by another source.
25454
+ *
25455
+ * The producer reuses the fetch path it already has, completion rules
25456
+ * included: a clip is taken by cmd 5 and finished on a short idle window
25457
+ * whose result is PROVED against the catalog row's own span, retried once
25458
+ * when it comes up materially short, and served-and-named when it is still
25459
+ * short (D568). A second fetch with different completion rules is exactly
25460
+ * the second authority D558 refuses to create.
25461
+ *
25462
+ * Every refusal THROWS with its reason and none of them is silent — the
25463
+ * sleep gate (decided before `getApi()`, liftable only by
25464
+ * {@link ClipWakeSchema}), a catalog row nobody claims, a clip with no
25465
+ * bytes behind it, a mux that failed, and the size bound. The caller turns
25466
+ * that reason into an operator-facing one; a truncated file is never an
25467
+ * answer.
25468
+ */
25469
+ readClipBytes: optionalMethod(object({
25470
+ deviceId: number(),
25471
+ clipId: string().min(1),
25472
+ /**
25473
+ * Which twin to fetch, on the mapping D549 15 already fixed:
25474
+ * `low | mid` → the sub file, `high` → the main twin. `auto` is not
25475
+ * accepted here for the same reason it is not accepted by
25476
+ * `getClipPlayback` — a stored file has no broker session, so the
25477
+ * adaptive tier cannot be resolved for it.
25478
+ */
25479
+ profile: CamProfileSchema.optional(),
25480
+ /**
25481
+ * The CALLER's byte bound, so an over-size clip is refused before it is
25482
+ * read and encoded rather than after. Capped by
25483
+ * {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
25484
+ * that ceiling.
25485
+ */
25486
+ maxBytes: number().int().positive().optional(),
25487
+ /**
25488
+ * The operator's authorisation to wake a sleeping camera for this
25489
+ * read. Absent — the default — means a sleeping standalone battery
25490
+ * camera is REFUSED by name, before any session is opened.
25491
+ */
25492
+ wake: ClipWakeSchema.optional()
25493
+ }), ClipBytesSchema, {
25494
+ kind: "query",
25495
+ auth: "protected"
25496
+ })
25497
+ }
25498
+ };
25282
25499
  /**
25283
25500
  * Optional client-side hints sent at session creation to help the provider
25284
25501
  * pick the best native source. All fields optional — a viewer that knows
@@ -31246,13 +31463,101 @@ var ExportStateSchema = _enum([
31246
31463
  "expired",
31247
31464
  "deleted"
31248
31465
  ]);
31249
- /** One export job / history row. */
31466
+ /**
31467
+ * WHAT an export is — the authority, as opposed to the four top-level fields
31468
+ * the Library sorts and labels on (D558 § 2.2).
31469
+ *
31470
+ * Read it through {@link exportSubjectOf}, never off the record directly: the
31471
+ * field is optional for the history rows written before it existed, and that
31472
+ * absence has exactly one interpreter.
31473
+ */
31474
+ var ExportSubjectSchema = discriminatedUnion("kind", [object({
31475
+ kind: literal("footage"),
31476
+ deviceId: number(),
31477
+ profiles: array(string()).min(1),
31478
+ fromMs: number(),
31479
+ toMs: number()
31480
+ }), object({
31481
+ kind: literal("clip"),
31482
+ deviceId: number(),
31483
+ provider: string().min(1),
31484
+ source: string().min(1),
31485
+ sourceLabel: string().min(1),
31486
+ clipId: string().min(1),
31487
+ /**
31488
+ * WHERE IN THE CATALOG to confirm this clip — the day window the surface was
31489
+ * already listing when the operator picked the segment.
31490
+ *
31491
+ * It is **not** a time range control and it never becomes one: the record's
31492
+ * `fromMs`/`toMs` come from the catalog ROW and from nothing a caller
31493
+ * supplied (D558 § 5.4.3), and a window that does not contain the clip is a
31494
+ * `catalog-miss`, not a silently wider search. It exists because
31495
+ * `videoclips.listClips` takes `since`/`until` and has no by-handle twin:
31496
+ * the catalog check D558 asks for is literally that call, and a call needs a
31497
+ * window. The surface has one — `ClipsBrowser`'s `dayWindow`, one local
31498
+ * wall-clock day, which is also the only width measured to be cheap (one
31499
+ * day on 592 lists 171 clips in 712 ms, a busy day 546 in 2.0 s; twenty days
31500
+ * of one child's events took 3.6–5.4 s).
31501
+ */
31502
+ catalogWindow: object({
31503
+ sinceMs: number(),
31504
+ untilMs: number()
31505
+ }),
31506
+ profile: _enum([
31507
+ "high",
31508
+ "mid",
31509
+ "low"
31510
+ ]),
31511
+ /**
31512
+ * The operator's authorisation to wake a sleeping camera for this export.
31513
+ * ONE definition, in the cap that owns the clip read ({@link ClipWakeSchema}),
31514
+ * because the gate that honours it is the clip provider's sleep gate — a
31515
+ * second enum here would be a second contract. Absent by default; never
31516
+ * settable by a scheduler or a retry.
31517
+ *
31518
+ * **One authorised yes is ONE wake.** A failed clip export is retried only by
31519
+ * an operator act that asks again; a queued job that outlived its wake fails
31520
+ * with a reason rather than waking on its turn; and this subject carries
31521
+ * exactly one profile precisely so one tap is never two fetches (D558 § 5.2).
31522
+ */
31523
+ wake: ClipWakeSchema.optional()
31524
+ })]);
31525
+ /**
31526
+ * One export job / history row.
31527
+ *
31528
+ * **`subject` is the AUTHORITY on what was exported. `deviceId`, `profile`,
31529
+ * `fromMs` and `toMs` are its PROJECTION** — kept top-level because the whole
31530
+ * Library sorts and labels on them (`library-items.ts` orders an export by
31531
+ * `fromMs`; `export-format.ts` draws `rangeLabel` from the pair), and a row
31532
+ * that did not fill them would sort under the epoch and render a blank range.
31533
+ * Write to the subject and read from the projection and the two will disagree;
31534
+ * the projection is derived at creation and never edited afterwards.
31535
+ *
31536
+ * And they mean DIFFERENT FACTS for the two kinds, which is the part a reader
31537
+ * who knows only the recording export will get wrong:
31538
+ *
31539
+ * | field | `kind:'footage'` | `kind:'clip'` |
31540
+ * | --- | --- | --- |
31541
+ * | `fromMs`/`toMs` | the stretch the operator ASKED for | the camera's own boundaries, always `clip.timeRange`, never anything a caller supplied |
31542
+ * | `profile` | the stream rendered | the twin actually SERVED (`subject.profile` is the one asked for) |
31543
+ *
31544
+ * Same type, different fact — the shape this repo keeps getting wrong (D385's
31545
+ * two authorities, D224's second copy).
31546
+ */
31250
31547
  var ExportRecordSchema = object({
31251
31548
  id: string(),
31252
31549
  deviceId: number(),
31253
31550
  profile: string(),
31254
31551
  fromMs: number(),
31255
31552
  toMs: number(),
31553
+ /**
31554
+ * What this export IS. Optional ONLY for the rows written before D558: the
31555
+ * store parses every row through this schema on every read, so a required
31556
+ * field would make the export AUDIT — which is the whole reason rows survive
31557
+ * file deletion — unreadable in one release. Absence means `footage`, and
31558
+ * {@link exportSubjectOf} is the one place that says so.
31559
+ */
31560
+ subject: ExportSubjectSchema.optional(),
31256
31561
  options: ExportOptionsSchema,
31257
31562
  state: ExportStateSchema,
31258
31563
  /** 0–100 while rendering; null otherwise. */
@@ -31269,6 +31574,18 @@ var ExportRecordSchema = object({
31269
31574
  /** Failure reason when state is 'failed'; null otherwise. */
31270
31575
  error: string().nullable()
31271
31576
  });
31577
+ _enum([
31578
+ "catalog-miss",
31579
+ "catalog-unreachable",
31580
+ "no-file-for-window",
31581
+ "clip-in-progress",
31582
+ "unsupported-option",
31583
+ "sleeping",
31584
+ "camera-refused",
31585
+ "fetch-failed",
31586
+ "too-large-to-transfer",
31587
+ "wake-expired"
31588
+ ]);
31272
31589
  /** Candidate download URLs (LAN first, then operator extra hosts). */
31273
31590
  var ExportDownloadSchema = object({
31274
31591
  url: string(),
@@ -31287,16 +31604,50 @@ var ExportBytesSchema = object({
31287
31604
  name: string(),
31288
31605
  bytes: number().int().nonnegative()
31289
31606
  });
31607
+ /** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
31608
+ function resolveExportProfiles(input) {
31609
+ if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
31610
+ if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
31611
+ return [];
31612
+ }
31290
31613
  method(object({
31291
31614
  deviceId: number(),
31292
31615
  /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
31293
31616
  profile: string().optional(),
31294
31617
  profiles: array(string()).min(1).optional(),
31295
- fromMs: number(),
31296
- toMs: number(),
31618
+ /** Footage only — a clip's boundaries are the camera's. */
31619
+ fromMs: number().optional(),
31620
+ toMs: number().optional(),
31621
+ /** What to export. Absent means the legacy flat footage request. */
31622
+ subject: ExportSubjectSchema.optional(),
31297
31623
  options: ExportOptionsSchema
31298
31624
  }).superRefine((v, ctx) => {
31299
- if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
31625
+ if (v.subject?.kind === "clip") {
31626
+ if (v.subject.deviceId !== v.deviceId) ctx.addIssue({
31627
+ code: ZodIssueCode.custom,
31628
+ message: `subject.deviceId (${v.subject.deviceId}) must equal deviceId (${v.deviceId}) — the top-level field is what per-device scope enforcement reads`,
31629
+ path: ["subject", "deviceId"]
31630
+ });
31631
+ if (v.fromMs !== void 0 || v.toMs !== void 0) ctx.addIssue({
31632
+ code: ZodIssueCode.custom,
31633
+ message: "a clip export asks for no time range: the camera chose the boundaries and they are read from the catalog row",
31634
+ path: ["fromMs"]
31635
+ });
31636
+ return;
31637
+ }
31638
+ if (v.subject?.kind === "footage" && v.subject.deviceId !== v.deviceId) ctx.addIssue({
31639
+ code: ZodIssueCode.custom,
31640
+ message: `subject.deviceId (${v.subject.deviceId}) must equal deviceId (${v.deviceId})`,
31641
+ path: ["subject", "deviceId"]
31642
+ });
31643
+ const fromMs = v.subject?.kind === "footage" ? v.subject.fromMs : v.fromMs;
31644
+ const toMs = v.subject?.kind === "footage" ? v.subject.toMs : v.toMs;
31645
+ if (typeof fromMs !== "number" || typeof toMs !== "number") ctx.addIssue({
31646
+ code: ZodIssueCode.custom,
31647
+ message: "a footage export needs fromMs and toMs",
31648
+ path: ["fromMs"]
31649
+ });
31650
+ if ((v.subject?.kind === "footage" ? [...v.subject.profiles] : resolveExportProfiles(v)).length < 1) ctx.addIssue({
31300
31651
  code: ZodIssueCode.custom,
31301
31652
  message: "pass profiles[] (min 1) or legacy profile",
31302
31653
  path: ["profiles"]
@@ -36717,6 +37068,12 @@ Object.freeze({
36717
37068
  addonId: null,
36718
37069
  access: "view"
36719
37070
  },
37071
+ "pipelineAnalytics.ownersWithMedia": {
37072
+ capName: "pipeline-analytics",
37073
+ capScope: "device",
37074
+ addonId: null,
37075
+ access: "view"
37076
+ },
36720
37077
  "pipelineAnalytics.pauseForStorageMigration": {
36721
37078
  capName: "pipeline-analytics",
36722
37079
  capScope: "device",
@@ -39315,6 +39672,12 @@ Object.freeze({
39315
39672
  addonId: null,
39316
39673
  access: "view"
39317
39674
  },
39675
+ "videoclips.readClipBytes": {
39676
+ capName: "videoclips",
39677
+ capScope: "device",
39678
+ addonId: null,
39679
+ access: "view"
39680
+ },
39318
39681
  "viewerUi.getStaticDir": {
39319
39682
  capName: "viewer-ui",
39320
39683
  capScope: "system",
@@ -40617,6 +40980,11 @@ Object.freeze({
40617
40980
  form: "single",
40618
40981
  optional: false
40619
40982
  }],
40983
+ "pipelineAnalytics.ownersWithMedia": [{
40984
+ name: "deviceId",
40985
+ form: "single",
40986
+ optional: false
40987
+ }],
40620
40988
  "pipelineAnalytics.proposeRetrainAnnotations": [{
40621
40989
  name: "deviceId",
40622
40990
  form: "single",
@@ -41328,6 +41696,11 @@ Object.freeze({
41328
41696
  form: "single",
41329
41697
  optional: false
41330
41698
  }],
41699
+ "videoclips.readClipBytes": [{
41700
+ name: "deviceId",
41701
+ form: "single",
41702
+ optional: false
41703
+ }],
41331
41704
  "waterHeater.setAway": [{
41332
41705
  name: "deviceId",
41333
41706
  form: "single",
@@ -89092,6 +89465,78 @@ function firstExposedAccessorySetupUri(exposed, logger) {
89092
89465
  }
89093
89466
  }
89094
89467
  //#endregion
89468
+ //#region src/hksv/build-outcome.ts
89469
+ /**
89470
+ * Every camera's last build verdict, in this process.
89471
+ *
89472
+ * A `Map` behind a named type rather than a bare one, because the thing that
89473
+ * matters about it is what `get` returning `undefined` MEANS: not "recording is
89474
+ * fine", but "no accessory has been built for this camera since the addon
89475
+ * started".
89476
+ */
89477
+ var HksvBuildOutcomes = class {
89478
+ byDevice = /* @__PURE__ */ new Map();
89479
+ note(outcome) {
89480
+ this.byDevice.set(outcome.deviceId, outcome);
89481
+ }
89482
+ /** `null` when nothing has been established for this camera yet. */
89483
+ lastFor(deviceId) {
89484
+ return this.byDevice.get(deviceId) ?? null;
89485
+ }
89486
+ /** The camera left HomeKit: its verdict is not a fact about anything now. */
89487
+ forget(deviceId) {
89488
+ this.byDevice.delete(deviceId);
89489
+ }
89490
+ };
89491
+ //#endregion
89492
+ //#region src/hksv/clip-ffmpeg-run.ts
89493
+ function createBoundedFfmpegRunner(input) {
89494
+ return (args) => new Promise((resolve) => {
89495
+ let settled = false;
89496
+ const finish = (run) => {
89497
+ if (settled) return;
89498
+ settled = true;
89499
+ clearTimeout(timer);
89500
+ resolve(run);
89501
+ };
89502
+ const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
89503
+ "ignore",
89504
+ "ignore",
89505
+ "pipe"
89506
+ ] });
89507
+ let stderr = "";
89508
+ child.stderr?.on("data", (chunk) => {
89509
+ if (stderr.length < 2e3) stderr += chunk.toString("utf8");
89510
+ });
89511
+ const timer = setTimeout(() => {
89512
+ child.kill("SIGKILL");
89513
+ finish({
89514
+ code: null,
89515
+ timedOut: true,
89516
+ spawnFailed: false,
89517
+ stderr
89518
+ });
89519
+ }, input.timeoutMs);
89520
+ timer.unref?.();
89521
+ child.on("error", (err) => {
89522
+ finish({
89523
+ code: null,
89524
+ timedOut: false,
89525
+ spawnFailed: true,
89526
+ stderr: err.message
89527
+ });
89528
+ });
89529
+ child.on("close", (code) => {
89530
+ finish({
89531
+ code,
89532
+ timedOut: false,
89533
+ spawnFailed: false,
89534
+ stderr
89535
+ });
89536
+ });
89537
+ });
89538
+ }
89539
+ //#endregion
89095
89540
  //#region src/hksv/clip-location.ts
89096
89541
  /** The declared id. Deployment-wide; see the docblock for why it is new. */
89097
89542
  var HOMEKIT_CLIPS_LOCATION_TYPE = "homekitClips";
@@ -89210,6 +89655,220 @@ var HksvClipLocation = class {
89210
89655
  }
89211
89656
  };
89212
89657
  //#endregion
89658
+ //#region src/hksv/clip-remux.ts
89659
+ /**
89660
+ * The faststart pass: the teed clip is made SEEKABLE, once, by the process that
89661
+ * minted it.
89662
+ *
89663
+ * ## Why the producer owes this
89664
+ *
89665
+ * The tee cuts like a pipe. ffmpeg writes `frag_keyframe+empty_moov` and never
89666
+ * returns to write a trailer, so on a teed clip `mvhd.duration` and
89667
+ * `mdhd.duration` are **0**, the `stbl` sample tables are EMPTY and there is no
89668
+ * `mfra`. Measured, on this hub's own files. The result decodes end to end — it
89669
+ * plays — but nothing can map a time to a byte in it, and a time-to-byte map is
89670
+ * the whole of what a scrub, a `Range` request and a stated duration are.
89671
+ *
89672
+ * The alternative was a remux in the CONSUMER, at play time. That puts an
89673
+ * ffmpeg per viewer in the broker's runner and buffers the whole file to write
89674
+ * a trailer, every time anybody drags — the cost D570 § 5(b) rejected, moved
89675
+ * one process along. Here it is one pass per clip, at the moment the clip is
89676
+ * born, measured at 40 ms and +0.7 % bytes. A clip is written once and read
89677
+ * many times; this is the side of that asymmetry the work belongs on.
89678
+ *
89679
+ * ## The constraint that outranks the feature
89680
+ *
89681
+ * Exactly as for the tee itself: **a clip that does not seek is a clip; a clip
89682
+ * the remux ate is a HomeKit recording the operator lost.** So the pass writes
89683
+ * a SIBLING and renames over the original only once it has vouched for it, it
89684
+ * never throws, and every way of failing has its own name on the record —
89685
+ * a missing binary, a deadline and a file ffmpeg blessed but did not fix are
89686
+ * three different operator actions.
89687
+ *
89688
+ * ## The vouch is a measurement, not an exit code
89689
+ *
89690
+ * `-movflags +faststart` exiting 0 is not evidence. {@link moovPrecedesMdat}
89691
+ * reads the top-level box order off the produced file and requires `moov`
89692
+ * ahead of the media — and requires the file to be PROGRESSIVE, because the
89693
+ * fragmented shape the tee already wrote is *also* moov-first and is exactly
89694
+ * what this pass exists to replace.
89695
+ */
89696
+ /** A remux that has not finished by here is not going to. */
89697
+ var CLIP_REMUX_TIMEOUT_MS = 3e4;
89698
+ /** The sibling the pass writes before it has earned the clip's own name. */
89699
+ var CLIP_REMUX_SUFFIX = ".faststart";
89700
+ /**
89701
+ * How much of the head is read back to judge the box order. The `moov` of a
89702
+ * short clip is a few hundred KB at most, and this only has to reach far enough
89703
+ * to meet the first `mdat` or `moof`.
89704
+ */
89705
+ var HEAD_BYTES = 1024 * 1024;
89706
+ function buildClipRemuxArgs(input) {
89707
+ return [
89708
+ "-hide_banner",
89709
+ "-loglevel",
89710
+ "error",
89711
+ "-nostdin",
89712
+ "-y",
89713
+ "-i",
89714
+ input.clipPath,
89715
+ "-c",
89716
+ "copy",
89717
+ "-movflags",
89718
+ "+faststart",
89719
+ "-f",
89720
+ "mp4",
89721
+ input.outPath
89722
+ ];
89723
+ }
89724
+ function createClipRemuxer(input) {
89725
+ return async ({ clipPath }) => {
89726
+ const outPath = `${clipPath}${CLIP_REMUX_SUFFIX}`;
89727
+ try {
89728
+ const run = await input.runner(buildClipRemuxArgs({
89729
+ clipPath,
89730
+ outPath
89731
+ }));
89732
+ if (run.spawnFailed) return await discard(outPath, "ffmpeg-missing");
89733
+ if (run.timedOut) return await discard(outPath, "timed-out");
89734
+ if (run.code !== 0) return await discard(outPath, "remux-failed");
89735
+ const size = await sizeOf(outPath);
89736
+ if (size === null || size === 0) return await discard(outPath, "remux-failed");
89737
+ const head = await readHead(outPath);
89738
+ if (!moovPrecedesMdat(head)) return await discard(outPath, "not-seekable");
89739
+ const durationMs = readMovieDurationMs(head);
89740
+ await (0, node_fs_promises.rename)(outPath, clipPath);
89741
+ return {
89742
+ ok: true,
89743
+ bytes: size,
89744
+ ...durationMs === null ? {} : { durationMs }
89745
+ };
89746
+ } catch {
89747
+ return await discard(outPath, "remux-failed");
89748
+ }
89749
+ };
89750
+ }
89751
+ async function discard(outPath, reason) {
89752
+ await (0, node_fs_promises.rm)(outPath, { force: true }).catch(() => void 0);
89753
+ return {
89754
+ ok: false,
89755
+ reason
89756
+ };
89757
+ }
89758
+ async function sizeOf(path) {
89759
+ try {
89760
+ return (await (0, node_fs_promises.stat)(path)).size;
89761
+ } catch {
89762
+ return null;
89763
+ }
89764
+ }
89765
+ async function readHead(path) {
89766
+ const buf = await (0, node_fs_promises.readFile)(path);
89767
+ return new Uint8Array(buf.buffer, buf.byteOffset, Math.min(buf.byteLength, HEAD_BYTES));
89768
+ }
89769
+ /**
89770
+ * Does this file carry a progressive index ahead of its media?
89771
+ *
89772
+ * Walks the TOP-LEVEL boxes only. Three verdicts collapse into `false`, and
89773
+ * each of them is a real file this store has held:
89774
+ *
89775
+ * - `moov` after `mdat` — a plain mux, seekable only once the whole file is
89776
+ * in hand, which over a `Range` reader means never.
89777
+ * - `moof` before any `mdat` — the FRAGMENTED shape the tee writes. Its
89778
+ * leading `moov` is the empty one `empty_moov` produced, so judging on
89779
+ * position alone would bless exactly the file this pass replaces.
89780
+ * - anything unreadable — a truncated header, or a box claiming a size that
89781
+ * does not advance the cursor. A corrupt file is never guessed at, and a
89782
+ * non-advancing size is how a scanner spins for ever.
89783
+ */
89784
+ function moovPrecedesMdat(bytes) {
89785
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
89786
+ let at = 0;
89787
+ let sawMoov = false;
89788
+ while (at + 8 <= bytes.byteLength) {
89789
+ const declared = view.getUint32(at);
89790
+ const type = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
89791
+ let size = declared;
89792
+ let header = 8;
89793
+ if (declared === 1) {
89794
+ if (at + 16 > bytes.byteLength) return false;
89795
+ const large = view.getBigUint64(at + 8);
89796
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return false;
89797
+ size = Number(large);
89798
+ header = 16;
89799
+ }
89800
+ if (size < header) return false;
89801
+ if (type === "moov") sawMoov = true;
89802
+ if (type === "mdat") return sawMoov;
89803
+ if (type === "moof") return false;
89804
+ at += size;
89805
+ }
89806
+ return false;
89807
+ }
89808
+ /**
89809
+ * The movie duration, in ms, from the `mvhd` inside `moov`.
89810
+ *
89811
+ * `null`, never `0`, for every way of not knowing — no `moov`, no `mvhd`, a
89812
+ * truncated header, a zero timescale, or the **zero duration `empty_moov`
89813
+ * writes**, which is the case that matters: believing it would replace a
89814
+ * duration that is merely wrong with one that confidently says the clip is
89815
+ * empty (D393).
89816
+ *
89817
+ * Worth having because the alternative number is wrong in a specific,
89818
+ * measurable way: `endedAtMs - startedAtMs` is how long the TEE ran, and the
89819
+ * first fragments it wrote were the prebuffer — footage older than the tee
89820
+ * itself. On 615 that is 12.0 s claimed against 19.99 s held.
89821
+ */
89822
+ function readMovieDurationMs(bytes) {
89823
+ const moov = findTopLevelBox(bytes, "moov");
89824
+ if (moov === null) return null;
89825
+ const mvhd = findTopLevelBox(moov, "mvhd");
89826
+ if (mvhd === null || mvhd.byteLength < 4) return null;
89827
+ const view = new DataView(mvhd.buffer, mvhd.byteOffset, mvhd.byteLength);
89828
+ const version = mvhd[0];
89829
+ const timescaleAt = version === 1 ? 20 : 12;
89830
+ const durationAt = timescaleAt + 4;
89831
+ if (durationAt + (version === 1 ? 8 : 4) > mvhd.byteLength) return null;
89832
+ const timescale = view.getUint32(timescaleAt);
89833
+ if (timescale === 0) return null;
89834
+ const duration = version === 1 ? Number(view.getBigUint64(durationAt)) : view.getUint32(durationAt);
89835
+ if (duration <= 0 || !Number.isFinite(duration)) return null;
89836
+ return Math.round(duration / timescale * 1e3);
89837
+ }
89838
+ /**
89839
+ * The PAYLOAD — header stripped — of the first top-level box of `type`, or
89840
+ * `null`. Stripped, because the only reason to hold a box here is to walk its
89841
+ * children, and leaving the header on makes the first child look like the
89842
+ * parent.
89843
+ *
89844
+ * Deliberately the same walk as {@link moovPrecedesMdat} rather than a shared
89845
+ * generator: that one answers a question about ORDER and stops at the media,
89846
+ * this one descends. Both refuse a box that does not advance the cursor, which
89847
+ * is the property neither can do without.
89848
+ */
89849
+ function findTopLevelBox(bytes, type) {
89850
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
89851
+ let at = 0;
89852
+ while (at + 8 <= bytes.byteLength) {
89853
+ const declared = view.getUint32(at);
89854
+ const found = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
89855
+ let size = declared;
89856
+ let header = 8;
89857
+ if (declared === 1) {
89858
+ if (at + 16 > bytes.byteLength) return null;
89859
+ const large = view.getBigUint64(at + 8);
89860
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return null;
89861
+ size = Number(large);
89862
+ header = 16;
89863
+ }
89864
+ if (size < header) return null;
89865
+ const end = Math.min(at + size, bytes.byteLength);
89866
+ if (found === type) return at + header >= end ? null : bytes.subarray(at + header, end);
89867
+ at += size;
89868
+ }
89869
+ return null;
89870
+ }
89871
+ //#endregion
89213
89872
  //#region src/hksv/clip-record.ts
89214
89873
  /**
89215
89874
  * What ONE teed HomeKit clip is, on disk.
@@ -89231,10 +89890,14 @@ var HksvClipLocation = class {
89231
89890
  * `deriveFragmentLengthMs` refuses a GOP longer than 8 s. A camera the tee
89232
89891
  * never writes for is not a gap in this store, it is a camera HKSV never
89233
89892
  * recorded; {@link HksvClipRecord} exists only for the ones it did.
89234
- * - **The duration is whatever iOS PULLED.** `durationMs` is the wall-clock
89235
- * span of the HDS stream (prebuffer replay plus however long the hub kept it
89236
- * open), not a window CamStack chose. A short clip is not a truncated one —
89237
- * {@link HksvClipRecord.truncated} is the only thing that says truncated.
89893
+ * - **The duration is whatever iOS PULLED.** Not a window CamStack chose, and
89894
+ * a short clip is not a truncated one — {@link HksvClipRecord.truncated} is
89895
+ * the only thing that says truncated. Since D578 it is READ FROM THE FILE
89896
+ * (`mvhd`, after the faststart pass), because the tee's own wall clock is
89897
+ * wrong in a specific direction: the clock starts when the tee does, and the
89898
+ * first fragments it writes are the PREBUFFER — footage older than the tee
89899
+ * itself. Measured on 615: 12.0 s claimed against 19.99 s held. The wall
89900
+ * clock remains the fallback for a file that could not be measured.
89238
89901
  *
89239
89902
  * ## `thumbnailFile` is VOUCHED
89240
89903
  *
@@ -89253,6 +89916,18 @@ var HksvThumbnailUnavailableReasonSchema = _enum([
89253
89916
  "no-init-segment",
89254
89917
  "not-attempted"
89255
89918
  ]);
89919
+ /**
89920
+ * Why a teed clip could not be made seekable. Named, never blank — a missing
89921
+ * binary, a deadline and a file ffmpeg blessed but did not fix are three
89922
+ * different operator actions, and "it does not seek" is none of them.
89923
+ */
89924
+ var HksvRemuxUnavailableReasonSchema = _enum([
89925
+ "remux-failed",
89926
+ "timed-out",
89927
+ "ffmpeg-missing",
89928
+ "not-seekable",
89929
+ "not-attempted"
89930
+ ]);
89256
89931
  /** Why a clip stopped short of the stream that fed it. */
89257
89932
  var HksvClipTruncationSchema = _enum([
89258
89933
  "size-cap",
@@ -89271,21 +89946,59 @@ var HksvClipRecordSchema = object({
89271
89946
  streamId: number().int(),
89272
89947
  startedAtMs: number().int(),
89273
89948
  endedAtMs: number().int(),
89274
- /** `endedAtMs - startedAtMs`. What iOS pulled, never a window we chose. */
89949
+ /**
89950
+ * What iOS pulled, never a window we chose — measured from the file's own
89951
+ * `mvhd` where the faststart pass could read it, and `endedAtMs -
89952
+ * startedAtMs` otherwise. See the docblock: the two differ by the prebuffer.
89953
+ */
89275
89954
  durationMs: number().int().nonnegative(),
89276
89955
  /** How much of the clip's head came from the prebuffer ring, by ARRIVAL age. */
89277
89956
  prebufferSpanMs: number().int().nonnegative(),
89957
+ /**
89958
+ * The size of {@link HksvClipRecord.file} AS IT NOW STANDS on disk — so on a
89959
+ * clip the faststart pass rewrote, the post-remux size, re-stat'd and never
89960
+ * carried over from the tee's own count. The pass costs about +0.7 %, and a
89961
+ * record stating the pre-remux figure would be wrong by exactly that for
89962
+ * ever, on the one field a `Range` reader and the disk accounting both use.
89963
+ */
89278
89964
  bytes: number().int().nonnegative(),
89279
89965
  fragments: number().int().nonnegative(),
89280
89966
  /** Basenames, relative to the clip's own device directory. */
89281
89967
  file: string().min(1),
89282
89968
  thumbnailFile: string().min(1).optional(),
89283
89969
  thumbnailUnavailable: object({ reason: HksvThumbnailUnavailableReasonSchema }).optional(),
89970
+ /**
89971
+ * The faststart pass ran and its output was VOUCHED: `moov` ahead of the
89972
+ * media, progressive, read back off the produced file. Present only when
89973
+ * that is true of {@link HksvClipRecord.file}.
89974
+ *
89975
+ * Three states, not two, and the third is the reason this is optional rather
89976
+ * than a boolean: `true` means it seeks, {@link
89977
+ * HksvClipRecord.remuxUnavailable} means it does not and says why, and
89978
+ * NEITHER means unknown — a sidecar written before this field existed cannot
89979
+ * be given one after the fact, and a reader must not read that silence as
89980
+ * "does not seek" (D393).
89981
+ */
89982
+ seekable: literal(true).optional(),
89983
+ remuxUnavailable: object({ reason: HksvRemuxUnavailableReasonSchema }).optional(),
89284
89984
  truncated: HksvClipTruncationSchema.optional(),
89285
89985
  /** iOS sent `ack` for this stream: HomeKit itself kept the clip. */
89286
89986
  acknowledgedByHomeKit: boolean(),
89287
89987
  width: number().int().positive(),
89288
89988
  height: number().int().positive(),
89989
+ /**
89990
+ * WHICH broker slot HomeKit recorded from, on the one quality scale the
89991
+ * system has (`high | mid | low`).
89992
+ *
89993
+ * Written by the tee from `pickRecordingSource`'s choice, because it is the
89994
+ * only thing that knows: the pixels do not say which profile produced them,
89995
+ * and a consumer that needs `served` (`getClipPlayback`, `readClipBytes`)
89996
+ * must never derive it from the resolution. Optional because a sidecar
89997
+ * written before this field existed cannot be given one after the fact — a
89998
+ * reader REFUSES to name a twin it was never told (D393), rather than
89999
+ * guessing one.
90000
+ */
90001
+ profile: CamProfileSchema.optional(),
89289
90002
  /** The advertised fragment length the source was cutting at. */
89290
90003
  fragmentMs: number().int().positive()
89291
90004
  });
@@ -89294,7 +90007,17 @@ var HksvClipRecordSchema = object({
89294
90007
  * else, so a pruner deleting a clip never has to guess which JPEG was its.
89295
90008
  */
89296
90009
  function clipFileNames(clipId) {
89297
- const stem = clipStem(clipId);
90010
+ return clipFileNamesForStem(clipStem(clipId));
90011
+ }
90012
+ /**
90013
+ * The same three names, from a stem a URL already carries.
90014
+ *
90015
+ * A data-plane route is handed the stem, not the id — `:` is legal on ext4 and
90016
+ * hostile in a path everywhere else, which is why {@link clipStem} exists — and
90017
+ * it must not re-derive the layout. One place mints these names, for both
90018
+ * callers.
90019
+ */
90020
+ function clipFileNamesForStem(stem) {
89298
90021
  return {
89299
90022
  clip: `${stem}.mp4`,
89300
90023
  thumbnail: `${stem}.jpg`,
@@ -89384,6 +90107,12 @@ function clipStem(clipId) {
89384
90107
  * deleted — this repo has twice shipped a green test because the fake supplied
89385
90108
  * what production forgot, and a promise is not a file.
89386
90109
  */
90110
+ /** An `errno` code off an unknown throw, without a cast. */
90111
+ function errnoCode(err) {
90112
+ if (typeof err !== "object" || err === null || !("code" in err)) return null;
90113
+ const { code } = err;
90114
+ return typeof code === "string" ? code : null;
90115
+ }
89387
90116
  /** See the class docblock for what each number is, and where it comes from. */
89388
90117
  var DEFAULT_CLIP_BOUNDS = {
89389
90118
  maxClipBytes: 256 * 1024 * 1024,
@@ -89433,7 +90162,7 @@ var HksvClipStore = class {
89433
90162
  const sink = await createFileSink((0, node_path.join)(dir, names.clip));
89434
90163
  this.inFlight.set(clipId, {
89435
90164
  deviceId: input.deviceId,
89436
- stem: stemOf(names.clip)
90165
+ stem: stemOf$1(names.clip)
89437
90166
  });
89438
90167
  return {
89439
90168
  clipId,
@@ -89454,14 +90183,52 @@ var HksvClipStore = class {
89454
90183
  }
89455
90184
  /** Every sidecar for a camera, newest first. What a later provider lists. */
89456
90185
  async listRecords(deviceId) {
89457
- const root = await this.rootOrNull();
89458
- if (root === null) return [];
90186
+ const catalog = await this.listCatalog(deviceId);
90187
+ return catalog.kind === "catalog" ? catalog.records : [];
90188
+ }
90189
+ /**
90190
+ * ONE directory read, answering the three questions a provider has to tell
90191
+ * apart: what was written, what is still THERE, and — when neither — whether
90192
+ * the location is gone or the read failed.
90193
+ *
90194
+ * `listRecords` is this method's `records` and nothing else, so the published
90195
+ * D550 contract and the provider can never disagree about what the store
90196
+ * holds. The file name set is here rather than a `stat` per clip because the
90197
+ * `readdir` has already answered it: a sidecar whose fMP4 was removed is a
90198
+ * row that must still LIST, saying it cannot be played (D549's `playable`),
90199
+ * and N syscalls to re-learn what one call said is the shape D447 charges
90200
+ * for.
90201
+ *
90202
+ * A device directory that does not exist is an EMPTY catalog, not a refusal:
90203
+ * the location is fine and this camera has recorded nothing. A `readdir` that
90204
+ * failed for any other reason is `failed` — a measurement that failed is
90205
+ * never folded into "no clips" (D393).
90206
+ */
90207
+ async listCatalog(deviceId) {
90208
+ let root;
90209
+ try {
90210
+ root = await this.input.resolveRoot();
90211
+ } catch (err) {
90212
+ return {
90213
+ kind: "failed",
90214
+ error: err instanceof Error ? err.message : String(err)
90215
+ };
90216
+ }
90217
+ if (root === null) return { kind: "no-root" };
89459
90218
  const dir = this.deviceDir(root, deviceId);
89460
90219
  let names;
89461
90220
  try {
89462
90221
  names = await (0, node_fs_promises.readdir)(dir);
89463
- } catch {
89464
- return [];
90222
+ } catch (err) {
90223
+ if (errnoCode(err) === "ENOENT") return {
90224
+ kind: "catalog",
90225
+ records: [],
90226
+ files: /* @__PURE__ */ new Set()
90227
+ };
90228
+ return {
90229
+ kind: "failed",
90230
+ error: err instanceof Error ? err.message : String(err)
90231
+ };
89465
90232
  }
89466
90233
  const records = [];
89467
90234
  for (const name of names) {
@@ -89469,7 +90236,24 @@ var HksvClipStore = class {
89469
90236
  const record = await this.readRecord((0, node_path.join)(dir, name), deviceId);
89470
90237
  if (record !== null) records.push(record);
89471
90238
  }
89472
- return records.sort((a, b) => b.startedAtMs - a.startedAtMs);
90239
+ return {
90240
+ kind: "catalog",
90241
+ records: records.toSorted((a, b) => b.startedAtMs - a.startedAtMs),
90242
+ files: new Set(names)
90243
+ };
90244
+ }
90245
+ /**
90246
+ * Where ONE artifact of a clip lives, or `null` when there is no root.
90247
+ *
90248
+ * The stem, never a path: the caller (a data-plane route) has a URL segment
90249
+ * and the store owns the layout. Nothing else may compose a path into the
90250
+ * clips location.
90251
+ */
90252
+ async artifactPath(deviceId, stem, kind) {
90253
+ const root = await this.rootOrNull();
90254
+ if (root === null) return null;
90255
+ const names = clipFileNamesForStem(stem);
90256
+ return (0, node_path.join)(this.deviceDir(root, deviceId), names[kind]);
89473
90257
  }
89474
90258
  /** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
89475
90259
  async acknowledge(deviceId, clipId) {
@@ -89533,6 +90317,7 @@ var HksvClipStore = class {
89533
90317
  });
89534
90318
  return null;
89535
90319
  }
90320
+ const remux = await this.remuxAndVouch(clipPath, tags, clipId);
89536
90321
  const thumbnail = await this.mintAndVouch({
89537
90322
  deviceId: input.deviceId,
89538
90323
  clipPath,
@@ -89547,17 +90332,19 @@ var HksvClipStore = class {
89547
90332
  streamId: input.streamId,
89548
90333
  startedAtMs: outcome.startedAtMs,
89549
90334
  endedAtMs: outcome.endedAtMs,
89550
- durationMs: Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
90335
+ durationMs: remux.ok && remux.durationMs !== void 0 ? remux.durationMs : Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
89551
90336
  prebufferSpanMs: input.prebufferSpanMs,
89552
- bytes: outcome.bytes,
90337
+ bytes: remux.ok ? remux.bytes : outcome.bytes,
89553
90338
  fragments: outcome.fragments,
89554
90339
  file: names.clip,
89555
90340
  ...thumbnail.ok ? { thumbnailFile: names.thumbnail } : { thumbnailUnavailable: { reason: thumbnail.reason } },
90341
+ ...remux.ok ? { seekable: true } : { remuxUnavailable: { reason: remux.reason } },
89556
90342
  ...outcome.truncated === null ? {} : { truncated: outcome.truncated },
89557
90343
  acknowledgedByHomeKit: false,
89558
90344
  width: input.width,
89559
90345
  height: input.height,
89560
- fragmentMs: input.fragmentMs
90346
+ fragmentMs: input.fragmentMs,
90347
+ ...input.profile === void 0 ? {} : { profile: input.profile }
89561
90348
  };
89562
90349
  await this.writeRecord((0, node_path.join)(dir, names.record), record);
89563
90350
  this.log.info("hksv clip tee: a HomeKit clip was KEPT", {
@@ -89581,6 +90368,46 @@ var HksvClipStore = class {
89581
90368
  * Mint, then CHECK. The minter's own verdict decides nothing on its own: a
89582
90369
  * JPEG is vouched by `stat`, never by a return value.
89583
90370
  */
90371
+ /**
90372
+ * Run the faststart pass, and never let it cost the clip.
90373
+ *
90374
+ * The remuxer already vouches for its own output and never throws by
90375
+ * contract — this wrapper exists for the one case a contract cannot cover: a
90376
+ * remuxer that throws anyway. A clip that does not seek is a clip; a clip
90377
+ * this pass ate is a HomeKit recording the operator lost, and that asymmetry
90378
+ * is the whole reason the failure is caught here and named on the record.
90379
+ */
90380
+ async remuxAndVouch(clipPath, tags, clipId) {
90381
+ const remux = this.input.remuxClip;
90382
+ if (remux === void 0) return {
90383
+ ok: false,
90384
+ reason: "not-attempted"
90385
+ };
90386
+ let result;
90387
+ try {
90388
+ result = await remux({ clipPath });
90389
+ } catch (err) {
90390
+ result = {
90391
+ ok: false,
90392
+ reason: "remux-failed"
90393
+ };
90394
+ this.log.warn("hksv clip tee: the faststart remuxer THREW — the clip is kept unseekable", {
90395
+ tags,
90396
+ meta: {
90397
+ clipId,
90398
+ error: err instanceof Error ? err.message : String(err)
90399
+ }
90400
+ });
90401
+ }
90402
+ if (!result.ok && result.reason !== "not-attempted") this.log.warn("hksv clip tee: the clip could not be made SEEKABLE — it plays, but nothing can scrub or Range it", {
90403
+ tags,
90404
+ meta: {
90405
+ clipId,
90406
+ reason: result.reason
90407
+ }
90408
+ });
90409
+ return result;
90410
+ }
89584
90411
  async mintAndVouch(mint) {
89585
90412
  const { deviceId, jpegPath } = mint;
89586
90413
  const minter = this.input.mintThumbnail;
@@ -89683,7 +90510,7 @@ var HksvClipStore = class {
89683
90510
  const clips = [];
89684
90511
  for (const name of names) {
89685
90512
  if (!name.endsWith(".mp4")) continue;
89686
- const stem = stemOf(name);
90513
+ const stem = stemOf$1(name);
89687
90514
  if (this.isInFlight(deviceId, stem)) continue;
89688
90515
  const bytes = await fileSize((0, node_path.join)(dir, name));
89689
90516
  const record = await this.readRecord((0, node_path.join)(dir, `${stem}.json`), deviceId);
@@ -89757,7 +90584,7 @@ var HksvClipStore = class {
89757
90584
  await (0, node_fs_promises.rename)(staging, path);
89758
90585
  }
89759
90586
  };
89760
- function stemOf(fileName) {
90587
+ function stemOf$1(fileName) {
89761
90588
  return fileName.replace(/\.[^.]+$/, "");
89762
90589
  }
89763
90590
  async function fileSize(path) {
@@ -89786,6 +90613,8 @@ async function createFileSink(path) {
89786
90613
  }
89787
90614
  };
89788
90615
  }
90616
+ /** A decode that has not finished by here is not going to. */
90617
+ var CLIP_THUMBNAIL_TIMEOUT_MS = 1e4;
89789
90618
  function buildClipThumbnailArgs(input) {
89790
90619
  return [
89791
90620
  "-hide_banner",
@@ -89840,59 +90669,583 @@ function createClipThumbnailMinter(input) {
89840
90669
  return { ok: true };
89841
90670
  };
89842
90671
  }
89843
- /**
89844
- * The production runner: one bounded ffmpeg, killed at the deadline.
89845
- *
89846
- * Bounded and killed, not merely awaited — an ffmpeg that never exits on a
89847
- * clip it cannot parse would otherwise hold a process per clip on a hub that
89848
- * already runs a permanent prebuffer per recorded camera.
89849
- */
89850
- function createFfmpegClipThumbnailRunner(input) {
89851
- const timeoutMs = input.timeoutMs ?? 1e4;
89852
- return (args) => new Promise((resolve) => {
89853
- let settled = false;
89854
- const finish = (run) => {
89855
- if (settled) return;
89856
- settled = true;
89857
- clearTimeout(timer);
89858
- resolve(run);
90672
+ //#endregion
90673
+ //#region src/hksv/videoclips-plane.ts
90674
+ /**
90675
+ * The two data-plane routes behind the HomeKit clip source.
90676
+ *
90677
+ * Both are hosted by `ctx.dataPlane.serve({ access: 'authenticated' })`, so the
90678
+ * hub authenticates and reverse-proxies and this addon produces the bytes.
90679
+ * **Neither puts a token in its URL**: the hub's `/addon/<id>/<prefix>` proxy
90680
+ * reads the credential from `authorization` OR the session cookie, exactly as
90681
+ * the recorder's own playback plane does, so a clip URL may sit in history, in
90682
+ * a `Referer` or in a shared log and open nothing (D549 22).
90683
+ *
90684
+ * ## Why the bytes are served HERE
90685
+ *
90686
+ * The clips were written by this process, to a `local-path` storage location,
90687
+ * and this addon is `hub-only`. A byte path that went anywhere else would be a
90688
+ * cross-addon media move — D9/D18, and the repo's one existing such move is
90689
+ * capped at 50 MiB with a docblock naming the OOM it caused. `createReadStream
90690
+ * ().pipe(res)` gives backpressure natively, which is why the data plane hands
90691
+ * an addon the REAL `res` (D447: a raw HTTP stream reads no chunk the socket
90692
+ * has not taken).
90693
+ *
90694
+ * ## What the media route does NOT do
90695
+ *
90696
+ * It does not remux. A teed clip is `ftyp` + an EMPTY `moov` + `moof`/`mdat`
90697
+ * fragments, cut wherever iOS stopped pulling, so the container carries no
90698
+ * duration and no index — measured on a byte-identical file
90699
+ * (`-movflags +frag_keyframe+empty_moov+default_base_moof`, the tee's own
90700
+ * argv): `mvhd.duration = 0`, `mdhd.duration = 0`, no `sidx`, and no `mfra`
90701
+ * because nothing wrote a trailer. It decodes end to end, and the row's
90702
+ * `timeRange` carries the real duration from the sidecar, but a player cannot
90703
+ * map a time to a byte offset from this file. What it would cost to change is
90704
+ * measured and written down in D571 rather than guessed at here.
90705
+ *
90706
+ * ## The thumbnail route has no mint
90707
+ *
90708
+ * It only ever serves a JPEG the store already stat'd — a still is VOUCHED at
90709
+ * write time or it does not exist, and nothing re-mints a teed clip. So a miss
90710
+ * is a plain 404, not the 204-with-a-reason contract a live camera needs.
90711
+ */
90712
+ /** URL namespaces under `/addon/export-hap/`. */
90713
+ var HKSV_CLIP_MEDIA_PREFIX = "hksv-clip";
90714
+ var HKSV_CLIP_THUMB_PREFIX = "hksv-clip-thumb";
90715
+ /** A stem is what `clipStem` mints: nothing else may reach the filesystem. */
90716
+ var MEDIA_PATH = /^\/(\d{1,12})\/([A-Za-z0-9_-]{1,128})\.mp4$/;
90717
+ var THUMB_PATH = /^\/(\d{1,12})\/([A-Za-z0-9_-]{1,128})\.jpg$/;
90718
+ /** `bytes=a-b` / `bytes=a-` / `bytes=-n`. `'unsatisfiable'` is a 416 rather
90719
+ * than a silently empty 206. */
90720
+ function parseRange(header, size) {
90721
+ if (header === void 0) return null;
90722
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
90723
+ if (match === null) return null;
90724
+ const [, rawStart, rawEnd] = match;
90725
+ if (rawStart === "" && rawEnd === "") return "unsatisfiable";
90726
+ if (rawStart === "") {
90727
+ const length = Number(rawEnd);
90728
+ if (length <= 0) return "unsatisfiable";
90729
+ return {
90730
+ start: Math.max(0, size - length),
90731
+ end: size - 1
89859
90732
  };
89860
- const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
89861
- "ignore",
89862
- "ignore",
89863
- "pipe"
89864
- ] });
89865
- let stderr = "";
89866
- child.stderr?.on("data", (chunk) => {
89867
- if (stderr.length < 2e3) stderr += chunk.toString("utf8");
89868
- });
89869
- const timer = setTimeout(() => {
89870
- child.kill("SIGKILL");
89871
- finish({
89872
- code: null,
89873
- timedOut: true,
89874
- spawnFailed: false,
89875
- stderr
90733
+ }
90734
+ const start = Number(rawStart);
90735
+ if (start >= size) return "unsatisfiable";
90736
+ const end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
90737
+ if (end < start) return "unsatisfiable";
90738
+ return {
90739
+ start,
90740
+ end
90741
+ };
90742
+ }
90743
+ function pathnameOf(req) {
90744
+ const raw = req.url ?? "/";
90745
+ const query = raw.indexOf("?");
90746
+ return query === -1 ? raw : raw.slice(0, query);
90747
+ }
90748
+ function address(req, pattern) {
90749
+ const match = pattern.exec(pathnameOf(req));
90750
+ if (match === null) return null;
90751
+ const deviceId = Number(match[1]);
90752
+ const stem = match[2];
90753
+ if (!Number.isInteger(deviceId) || stem === void 0) return null;
90754
+ return {
90755
+ deviceId,
90756
+ stem
90757
+ };
90758
+ }
90759
+ function createHksvClipPlanes(deps) {
90760
+ /**
90761
+ * The path of one artifact, or `null` with a reason ALREADY logged.
90762
+ *
90763
+ * A 404 is work this route dropped, and silence reads as "never happened" —
90764
+ * so every miss is one warn carrying `tags: { deviceId }`, which is the only
90765
+ * key a "why is 617 missing its clips and 615 not?" question can be answered
90766
+ * by.
90767
+ */
90768
+ async function locate(addressed, artifact) {
90769
+ const { deviceId, stem } = addressed;
90770
+ let path;
90771
+ try {
90772
+ path = await deps.artifactPath(deviceId, stem, artifact);
90773
+ } catch (err) {
90774
+ deps.logger.warn("videoclips: could not resolve a HomeKit clip artifact", {
90775
+ tags: { deviceId },
90776
+ meta: {
90777
+ stem,
90778
+ artifact,
90779
+ error: err instanceof Error ? err.message : String(err)
90780
+ }
89876
90781
  });
89877
- }, timeoutMs);
89878
- timer.unref?.();
89879
- child.on("error", (err) => {
89880
- finish({
89881
- code: null,
89882
- timedOut: false,
89883
- spawnFailed: true,
89884
- stderr: err.message
90782
+ return null;
90783
+ }
90784
+ if (path === null) {
90785
+ deps.logger.warn("videoclips: no writable HomeKit clips location — nothing to serve", {
90786
+ tags: { deviceId },
90787
+ meta: {
90788
+ stem,
90789
+ artifact
90790
+ }
89885
90791
  });
89886
- });
89887
- child.on("close", (code) => {
89888
- finish({
89889
- code,
89890
- timedOut: false,
89891
- spawnFailed: false,
89892
- stderr
90792
+ return null;
90793
+ }
90794
+ try {
90795
+ const stats = await (0, node_fs_promises.stat)(path);
90796
+ if (!stats.isFile()) throw new Error("not a file");
90797
+ return {
90798
+ path,
90799
+ size: stats.size
90800
+ };
90801
+ } catch (err) {
90802
+ deps.logger.warn("videoclips: a HomeKit clip artifact was asked for and is not there", {
90803
+ tags: { deviceId },
90804
+ meta: {
90805
+ stem,
90806
+ artifact,
90807
+ error: err instanceof Error ? err.message : String(err)
90808
+ }
89893
90809
  });
90810
+ return null;
90811
+ }
90812
+ }
90813
+ function readOnly(req, res) {
90814
+ if (req.method === "GET" || req.method === "HEAD") return true;
90815
+ res.writeHead(405, { allow: "GET, HEAD" });
90816
+ res.end();
90817
+ return false;
90818
+ }
90819
+ const mediaHandler = async (req, res) => {
90820
+ if (!readOnly(req, res)) return;
90821
+ const addressed = address(req, MEDIA_PATH);
90822
+ if (addressed === null) {
90823
+ res.writeHead(400);
90824
+ res.end();
90825
+ return;
90826
+ }
90827
+ const found = await locate(addressed, "clip");
90828
+ if (found === null) {
90829
+ res.writeHead(404);
90830
+ res.end();
90831
+ return;
90832
+ }
90833
+ const base = {
90834
+ "content-type": "video/mp4",
90835
+ "accept-ranges": "bytes",
90836
+ "cache-control": "private, max-age=0, must-revalidate"
90837
+ };
90838
+ const range = parseRange(req.headers.range, found.size);
90839
+ if (range === "unsatisfiable") {
90840
+ res.writeHead(416, {
90841
+ ...base,
90842
+ "content-range": `bytes */${String(found.size)}`
90843
+ });
90844
+ res.end();
90845
+ return;
90846
+ }
90847
+ if (range === null) {
90848
+ res.writeHead(200, {
90849
+ ...base,
90850
+ "content-length": String(found.size)
90851
+ });
90852
+ if (req.method === "HEAD") {
90853
+ res.end();
90854
+ return;
90855
+ }
90856
+ (0, node_fs.createReadStream)(found.path).pipe(res);
90857
+ return;
90858
+ }
90859
+ res.writeHead(206, {
90860
+ ...base,
90861
+ "content-range": `bytes ${String(range.start)}-${String(range.end)}/${String(found.size)}`,
90862
+ "content-length": String(range.end - range.start + 1)
89894
90863
  });
89895
- });
90864
+ if (req.method === "HEAD") {
90865
+ res.end();
90866
+ return;
90867
+ }
90868
+ (0, node_fs.createReadStream)(found.path, {
90869
+ start: range.start,
90870
+ end: range.end
90871
+ }).pipe(res);
90872
+ };
90873
+ const thumbHandler = async (req, res) => {
90874
+ if (!readOnly(req, res)) return;
90875
+ const addressed = address(req, THUMB_PATH);
90876
+ if (addressed === null) {
90877
+ res.writeHead(400);
90878
+ res.end();
90879
+ return;
90880
+ }
90881
+ const found = await locate(addressed, "thumbnail");
90882
+ if (found === null) {
90883
+ res.writeHead(404);
90884
+ res.end();
90885
+ return;
90886
+ }
90887
+ res.writeHead(200, {
90888
+ "content-type": "image/jpeg",
90889
+ "content-length": String(found.size),
90890
+ "cache-control": "private, max-age=31536000, immutable"
90891
+ });
90892
+ if (req.method === "HEAD") {
90893
+ res.end();
90894
+ return;
90895
+ }
90896
+ (0, node_fs.createReadStream)(found.path).pipe(res);
90897
+ };
90898
+ return {
90899
+ mediaPrefix: HKSV_CLIP_MEDIA_PREFIX,
90900
+ thumbPrefix: HKSV_CLIP_THUMB_PREFIX,
90901
+ mediaHandler,
90902
+ thumbHandler
90903
+ };
90904
+ }
90905
+ //#endregion
90906
+ //#region src/hksv/videoclips-source.ts
90907
+ /**
90908
+ * The `videoclips` SOURCE over the clips this addon teed to HomeKit.
90909
+ *
90910
+ * ## Where it runs, and why there is no hand-off
90911
+ *
90912
+ * `export-hap` is `placement: 'hub-only'` and the teed clips live on a
90913
+ * `local-path` storage location this process writes with `node:fs`. D550
90914
+ * recorded the open question as "the store lives in the hub-only `export-hap`
90915
+ * runner, so listing and serving must REACH it from wherever the provider
90916
+ * mounts". The answer is that the provider mounts HERE: `videoclips` is a
90917
+ * `scope:'device'` + `mode:'collection'` wrapper, so every addon that declares
90918
+ * it becomes one of the camera's bindings (`collectionBindings`, gated only by
90919
+ * the cap's `deviceTypes`), and `resolveWrapperNodeId` routes a wrapper to the
90920
+ * hub — which is where this addon already is. Nothing about a `videoclips`
90921
+ * provider requires owning the device.
90922
+ *
90923
+ * That removes the hand-off rather than building one, and it is the only shape
90924
+ * that obeys both rules D550 named: addons never import each other (this one
90925
+ * imports nothing — it reads its own store), and frames never cross a process
90926
+ * boundary (the bytes never enter a cap call at all; `getClipPlayback` answers
90927
+ * a URL into this addon's own data plane and the file is streamed with `Range`
90928
+ * from the disk it was written to). The rejected alternative was to register
90929
+ * the provider in the recorder and have it fetch clips over `ctx.api` — a
90930
+ * cross-addon byte move by handle at best, and the repo's one existing such
90931
+ * move carries a 50 MiB cap and a docblock naming the OOM it caused.
90932
+ *
90933
+ * ## The source EXISTS because of HomeKit, and is not a switch
90934
+ *
90935
+ * A camera has this source because it is exported to HomeKit AND HomeKit
90936
+ * recording is on for it; turn either off and the row is gone (D550, D569 § 5).
90937
+ * There is deliberately no enable control of its own — a knob over somebody
90938
+ * else's decision is D62's defect. `keepClips` is NOT that gate either: it
90939
+ * governs the TEE, and the clips already kept remain the truth about this
90940
+ * camera, so the row stays and its `reason` says the copies are off.
90941
+ *
90942
+ * ## `ok` is measured, and an empty list is not always an answer
90943
+ *
90944
+ * D561: `ok` may never be a default. Here the measurement is a real read of the
90945
+ * clips directory, and every answer this source gives — the source row and the
90946
+ * clip list — comes from ONE such read. The states are the existing five, and
90947
+ * no sixth was needed:
90948
+ *
90949
+ * - `ok` — the directory answered. Including with nothing: a camera that has
90950
+ * recorded no HomeKit clip yet is a complete list of zero, and the `reason`
90951
+ * says which flavour of nothing it is.
90952
+ * - `no-storage` — there is no writable `homekitClips` location: none seeded,
90953
+ * or it is `readonly` / `drain` / `disabled` (D385, named by
90954
+ * `clip-location.ts`, which is the only module that may interpret the mode).
90955
+ * Exactly the Reolink meaning: the medium is not there.
90956
+ * - `unreachable` — the storage cap could not be reached, or the directory
90957
+ * read threw. A measurement that failed is never folded into "no clips"
90958
+ * (D393).
90959
+ * - `index-empty` — HomeKit recording is switched ON for this camera and the
90960
+ * store holds nothing, because `buildHksvRecording` WITHHELD the
90961
+ * advertisement (no H.264 slot ≤ 1080p, a GOP longer than any fragment
90962
+ * length, or unreadable profiles). The camera's own switch disagrees with
90963
+ * its own index, which is what this state means on the Reolink surface too,
90964
+ * and the refusal travels verbatim in `reason`.
90965
+ * - `sleeping` — never. There is no camera to wake: the read is a local
90966
+ * directory read.
90967
+ */
90968
+ /** What the picker calls this source. */
90969
+ var HKSV_CLIP_SOURCE_LABEL = "HomeKit clips";
90970
+ /** Why a listed row has no bytes behind it. Verbatim on `unplayableReason`. */
90971
+ var CLIP_FILE_MISSING = "clip-file-missing";
90972
+ function createHksvVideoclipsProvider(deps) {
90973
+ const now = deps.now ?? Date.now;
90974
+ /**
90975
+ * The one read, and the state it proves.
90976
+ *
90977
+ * `null` means this camera has no HomeKit clip source at all — it is not
90978
+ * exported, or HomeKit recording is off for it. Not an availability: a row
90979
+ * that should not exist is not a row saying it is unhappy.
90980
+ */
90981
+ async function measure(deviceId) {
90982
+ const facts = deps.describeCamera(deviceId);
90983
+ if (!facts.exported || !facts.recording) return null;
90984
+ const catalog = await deps.readCatalog(deviceId);
90985
+ return {
90986
+ availability: availabilityFor(facts, catalog),
90987
+ catalog
90988
+ };
90989
+ }
90990
+ function availabilityFor(facts, catalog) {
90991
+ if (catalog.kind === "failed") return {
90992
+ state: "unreachable",
90993
+ reason: `the HomeKit clips location could not be read: ${catalog.error}`
90994
+ };
90995
+ if (catalog.kind === "no-root") {
90996
+ const refusal = deps.locationRefusal();
90997
+ if (refusal === "unreachable") return {
90998
+ state: "unreachable",
90999
+ reason: "the storage capability could not be reached (unreachable)"
91000
+ };
91001
+ return {
91002
+ state: "no-storage",
91003
+ reason: refusal === "not-writable" ? "the HomeKit clips location is not writable (not-writable) — readonly, draining or disabled" : "no HomeKit clips location exists yet (no-location)"
91004
+ };
91005
+ }
91006
+ const asOf = now();
91007
+ if (catalog.records.length > 0) return {
91008
+ state: "ok",
91009
+ catalogAsOf: asOf,
91010
+ ...facts.keepingClips ? {} : { reason: "copies are switched off for this camera — nothing new is being kept" }
91011
+ };
91012
+ const withheld = facts.lastBuild?.withheld;
91013
+ if (withheld !== void 0 && withheld !== null) {
91014
+ const detail = facts.lastBuild?.detail;
91015
+ return {
91016
+ state: "index-empty",
91017
+ catalogAsOf: asOf,
91018
+ reason: `HomeKit recording is switched on for this camera but the advertisement was WITHHELD (${withheld})` + (detail === null || detail === void 0 ? "" : `: ${detail}`) + " — HomeKit has never recorded it, so there is nothing to keep"
91019
+ };
91020
+ }
91021
+ return {
91022
+ state: "ok",
91023
+ catalogAsOf: asOf,
91024
+ reason: facts.keepingClips ? "nothing has been recorded by HomeKit for this camera yet" : "copies are switched off for this camera — nothing new is being kept, and nothing was"
91025
+ };
91026
+ }
91027
+ function toClip(deviceId, record, files) {
91028
+ const names = clipFileNames(record.clipId);
91029
+ const hasBytes = files.has(record.file);
91030
+ const vouchedThumb = record.thumbnailFile !== void 0 && files.has(record.thumbnailFile) ? deps.thumbnailUrl(deviceId, stemOf(names.thumbnail)) : null;
91031
+ return {
91032
+ id: record.clipId,
91033
+ source: HKSV_CLIP_SOURCE,
91034
+ kind: "native",
91035
+ timeRange: {
91036
+ startMs: record.startedAtMs,
91037
+ endMs: record.endedAtMs
91038
+ },
91039
+ ...vouchedThumb === null ? { thumbnailUnavailable: { reason: thumbnailRefusal(record, files) } } : { thumbnail: vouchedThumb },
91040
+ ...hasBytes ? {} : {
91041
+ playable: false,
91042
+ unplayableReason: CLIP_FILE_MISSING
91043
+ }
91044
+ };
91045
+ }
91046
+ /**
91047
+ * The store's five reasons, narrowed onto the cap's four.
91048
+ *
91049
+ * The narrowing is LOSSY and it is forced: `ClipSchema.thumbnailUnavailable`
91050
+ * is a closed enum that `ui-library` maps exhaustively, so widening it is a
91051
+ * breaking change to a consumer, and this source's reasons postdate it. The
91052
+ * lossless copy is not lost — it is on the sidecar and on the listing line —
91053
+ * and the split preserves the only distinction a surface can act on:
91054
+ *
91055
+ * - `no-keyframe` — the CLIP had nothing decodable at the trigger instant
91056
+ * (`no-init-segment`, `decode-failed`, `timed-out`).
91057
+ * - `unsupported` — no still exists and nothing here will make one
91058
+ * (`ffmpeg-missing`, `not-attempted`, or a JPEG that is no longer on
91059
+ * disk). Nothing re-mints a teed clip, so every one of these is final.
91060
+ */
91061
+ function thumbnailRefusal(record, files) {
91062
+ if (record.thumbnailFile !== void 0 && !files.has(record.thumbnailFile)) return "unsupported";
91063
+ const reason = record.thumbnailUnavailable?.reason;
91064
+ if (reason === "no-init-segment" || reason === "decode-failed" || reason === "timed-out") return "no-keyframe";
91065
+ return "unsupported";
91066
+ }
91067
+ return {
91068
+ listSources: async ({ deviceId }) => {
91069
+ const measured = await measure(deviceId);
91070
+ if (measured === null) return [];
91071
+ deps.logger.info("videoclips: measured the HomeKit clip source", {
91072
+ tags: { deviceId },
91073
+ meta: {
91074
+ source: HKSV_CLIP_SOURCE,
91075
+ state: measured.availability.state,
91076
+ reason: measured.availability.reason ?? null,
91077
+ clips: measured.catalog.kind === "catalog" ? measured.catalog.records.length : null
91078
+ }
91079
+ });
91080
+ return [{
91081
+ source: HKSV_CLIP_SOURCE,
91082
+ addonId: deps.addonId,
91083
+ label: HKSV_CLIP_SOURCE_LABEL,
91084
+ availability: measured.availability
91085
+ }];
91086
+ },
91087
+ listClips: async ({ deviceId, since, until, limit }) => {
91088
+ const measured = await measure(deviceId);
91089
+ if (measured === null) return [];
91090
+ const tags = { deviceId };
91091
+ const catalog = measured.catalog;
91092
+ const records = catalog.kind === "catalog" ? catalog.records : [];
91093
+ const files = catalog.kind === "catalog" ? catalog.files : /* @__PURE__ */ new Set();
91094
+ const ordered = records.filter((r) => r.startedAtMs <= until && r.endedAtMs >= since).toSorted((a, b) => b.startedAtMs - a.startedAtMs);
91095
+ const capped = limit === void 0 ? ordered : ordered.slice(0, limit);
91096
+ const clips = capped.map((record) => toClip(deviceId, record, files));
91097
+ const unplayable = clips.filter((c) => c.playable === false).length;
91098
+ const vouched = clips.filter((c) => c.thumbnail !== void 0).length;
91099
+ deps.logger.info("videoclips: listed the HomeKit clips", {
91100
+ tags,
91101
+ meta: {
91102
+ source: HKSV_CLIP_SOURCE,
91103
+ clips: clips.length,
91104
+ truncated: limit !== void 0 && ordered.length > limit,
91105
+ unplayable,
91106
+ thumbsVouched: vouched,
91107
+ thumbsUnavailable: clips.length - vouched,
91108
+ thumbnailReason: capped.find((r) => r.thumbnailFile === void 0)?.thumbnailUnavailable?.reason,
91109
+ state: measured.availability.state
91110
+ }
91111
+ });
91112
+ if (clips.length === 0 && measured.availability.state !== "ok") {
91113
+ deps.logger.warn("videoclips: nothing to list, and the HomeKit clip source cannot answer", {
91114
+ tags,
91115
+ meta: {
91116
+ branch: "refused-empty",
91117
+ state: measured.availability.state,
91118
+ reason: measured.availability.reason ?? null
91119
+ }
91120
+ });
91121
+ throw new Error(`videoclips: device ${String(deviceId)} listed no HomeKit clips, and that is not an empty window — the source is "${measured.availability.state}"` + (measured.availability.reason === void 0 ? "." : `: ${measured.availability.reason}`));
91122
+ }
91123
+ return clips;
91124
+ },
91125
+ getClipPlayback: async ({ deviceId, clipId, profile }) => {
91126
+ const record = await findRecord(deviceId, clipId);
91127
+ if (profile !== void 0) deps.logger.debug("videoclips: a HomeKit clip has one rendition; the profile is moot", {
91128
+ tags: { deviceId },
91129
+ meta: {
91130
+ clipId,
91131
+ profile,
91132
+ width: record.width,
91133
+ height: record.height
91134
+ }
91135
+ });
91136
+ return {
91137
+ playbackUrl: deps.mediaUrl(deviceId, stemOf(clipFileNames(clipId).clip)),
91138
+ format: "mp4",
91139
+ ...record.profile === void 0 ? {} : { served: record.profile }
91140
+ };
91141
+ },
91142
+ /**
91143
+ * The clip's BYTES, inline and bounded — the seam another ADDON pulls
91144
+ * (D558), never the path a player takes.
91145
+ *
91146
+ * A browser or a viewer session plays `getClipPlayback`'s URL; an addon
91147
+ * cannot, because `AddonDataPlane` only lets an addon SERVE. So this is the
91148
+ * one place HomeKit clip bytes enter a cap envelope, and the bound is the
91149
+ * cap's own 50 MiB — held whole, base64, in this process AND the caller's,
91150
+ * on a hub that has already been OOM'd once (D9/D18). Above the bound it
91151
+ * REFUSES with the size: half a video is worse than an honest refusal.
91152
+ *
91153
+ * A 256 MB clip (the tee's per-clip rail) is therefore refusable here and
91154
+ * playable through the route, which is stated rather than discovered.
91155
+ */
91156
+ readClipBytes: async ({ deviceId, clipId, maxBytes }) => {
91157
+ const record = await findRecord(deviceId, clipId);
91158
+ const stem = stemOf(clipFileNames(clipId).clip);
91159
+ if (record.profile === void 0) {
91160
+ deps.logger.warn("videoclips: a HomeKit clip cannot say which profile it recorded from", {
91161
+ tags: { deviceId },
91162
+ meta: {
91163
+ clipId,
91164
+ branch: "no-profile"
91165
+ }
91166
+ });
91167
+ throw new Error(`videoclips: clip "${clipId}" does not record which profile HomeKit recorded from, so the twin it would be served as cannot be named`);
91168
+ }
91169
+ const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
91170
+ if (record.bytes > bound) throw new Error(`videoclips: clip "${clipId}" is ${String(record.bytes)} bytes, over the ${String(bound)}-byte bound for an inline read — fetch it from its playback URL`);
91171
+ const path = await deps.clipFilePath(deviceId, stem);
91172
+ if (path === null) throw new Error(`videoclips: the HomeKit clips location for device ${String(deviceId)} is not readable`);
91173
+ let bytes;
91174
+ try {
91175
+ bytes = await (0, node_fs_promises.readFile)(path);
91176
+ } catch (err) {
91177
+ deps.logger.warn("videoclips: the fMP4 of a listed HomeKit clip could not be read", {
91178
+ tags: { deviceId },
91179
+ meta: {
91180
+ clipId,
91181
+ branch: CLIP_FILE_MISSING,
91182
+ error: errorText(err)
91183
+ }
91184
+ });
91185
+ throw new Error(`videoclips: clip "${clipId}" has a sidecar but no readable bytes (${CLIP_FILE_MISSING})`, { cause: err });
91186
+ }
91187
+ deps.logger.info("videoclips: served a HomeKit clip inline", {
91188
+ tags: { deviceId },
91189
+ meta: {
91190
+ clipId,
91191
+ bytes: bytes.byteLength,
91192
+ served: record.profile
91193
+ }
91194
+ });
91195
+ return {
91196
+ base64: bytes.toString("base64"),
91197
+ contentType: "video/mp4",
91198
+ name: `${stem}.mp4`,
91199
+ bytes: bytes.byteLength,
91200
+ served: record.profile,
91201
+ ...record.durationMs > 0 ? { durationMs: record.durationMs } : {}
91202
+ };
91203
+ }
91204
+ };
91205
+ /**
91206
+ * The catalog row for one clip, or a THROW naming which of the three things
91207
+ * went wrong: not ours, not held, or held with no bytes behind it. Shared by
91208
+ * the two byte paths so they can never disagree about what a clip id means.
91209
+ */
91210
+ async function findRecord(deviceId, clipId) {
91211
+ if (!clipId.startsWith(`hksv:`)) throw new Error(`videoclips: clip id "${clipId}" was not minted by the HomeKit source`);
91212
+ if (!clipId.startsWith(`hksv:${String(deviceId)}:`)) throw new Error(`videoclips: clip id "${clipId}" belongs to another camera — ids are self-contained and name the device that teed them`);
91213
+ const measured = await measure(deviceId);
91214
+ if (measured === null) throw new Error(`videoclips: device ${String(deviceId)} has no HomeKit clip source — it is not exported to HomeKit, or HomeKit recording is off for it`);
91215
+ const catalog = measured.catalog;
91216
+ if (catalog.kind !== "catalog") throw new Error(`videoclips: the HomeKit clips for device ${String(deviceId)} cannot be read — the source is "${measured.availability.state}"`);
91217
+ const record = catalog.records.find((r) => r.clipId === clipId) ?? null;
91218
+ if (record === null) {
91219
+ deps.logger.warn("videoclips: asked for a HomeKit clip the catalog does not hold", {
91220
+ tags: { deviceId },
91221
+ meta: {
91222
+ clipId,
91223
+ branch: "no-catalog-row"
91224
+ }
91225
+ });
91226
+ throw new Error(`videoclips: no clip "${clipId}" is held for device ${String(deviceId)}`);
91227
+ }
91228
+ if (!catalog.files.has(record.file)) {
91229
+ deps.logger.warn("videoclips: the fMP4 for a listed HomeKit clip is gone", {
91230
+ tags: { deviceId },
91231
+ meta: {
91232
+ clipId,
91233
+ branch: CLIP_FILE_MISSING,
91234
+ file: record.file
91235
+ }
91236
+ });
91237
+ throw new Error(`videoclips: clip "${clipId}" has a sidecar but no bytes (${CLIP_FILE_MISSING})`);
91238
+ }
91239
+ return record;
91240
+ }
91241
+ }
91242
+ function errorText(err) {
91243
+ return err instanceof Error ? err.message : String(err);
91244
+ }
91245
+ /** `<stem>.mp4` → `<stem>`. The routes address a stem; the store owns the rest. */
91246
+ function stemOf(fileName) {
91247
+ const dot = fileName.lastIndexOf(".");
91248
+ return dot === -1 ? fileName : fileName.slice(0, dot);
89896
91249
  }
89897
91250
  //#endregion
89898
91251
  //#region src/mappers/builders/generic/characteristic-update.ts
@@ -104721,7 +106074,8 @@ var ClipTeeRunner = class {
104721
106074
  prebufferSpanMs: this.request.prebufferSpanMs,
104722
106075
  width: this.input.width,
104723
106076
  height: this.input.height,
104724
- fragmentMs: this.input.fragmentMs
106077
+ fragmentMs: this.input.fragmentMs,
106078
+ ...this.input.profile === void 0 ? {} : { profile: this.input.profile }
104725
106079
  });
104726
106080
  if (open === null) {
104727
106081
  this.request.subscription.release();
@@ -105689,9 +107043,26 @@ async function buildHksvRecording(input) {
105689
107043
  const { bctx } = input;
105690
107044
  const { ctx, numericDeviceId } = bctx;
105691
107045
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
107046
+ /**
107047
+ * Record the verdict for the clip source, next to the log line that already
107048
+ * states it. A camera HomeKit never recorded is not "a camera with no clips",
107049
+ * and `listSources` can only say which it is if the refusal was written down
107050
+ * (D571). Every `return null` below passes through here.
107051
+ */
107052
+ const note = (withheld, detail) => {
107053
+ const outcome = {
107054
+ deviceId: numericDeviceId,
107055
+ advertised: withheld === null,
107056
+ withheld,
107057
+ detail,
107058
+ atMs: Date.now()
107059
+ };
107060
+ bctx.options.noteHksvOutcome?.(outcome);
107061
+ };
105692
107062
  const entries = await readProfileEntries(bctx);
105693
107063
  if (entries === null) {
105694
107064
  log.warn("export-hap: HKSV withheld — could not read the camera profiles", {});
107065
+ note("profiles-unreadable", "cameraStreams.getProfileRtspEntries could not be read");
105695
107066
  return null;
105696
107067
  }
105697
107068
  const choice = pickRecordingSource(entries);
@@ -105700,6 +107071,7 @@ async function buildHksvRecording(input) {
105700
107071
  refusal: choice.refusal,
105701
107072
  reason: refusalReason(choice.refusal)
105702
107073
  } });
107074
+ note("no-recordable-stream", `${choice.refusal}: ${refusalReason(choice.refusal)}`);
105703
107075
  return null;
105704
107076
  }
105705
107077
  const source = choice.source;
@@ -105710,8 +107082,11 @@ async function buildHksvRecording(input) {
105710
107082
  gopMs,
105711
107083
  brokerId: source.brokerId
105712
107084
  } });
107085
+ note("key-frame-interval", `gopMs=${String(gopMs ?? "unknown")}`);
105713
107086
  return null;
105714
107087
  }
107088
+ const parsedProfile = CamProfileSchema.safeParse(source.profile);
107089
+ const recordedProfile = parsedProfile.success ? parsedProfile.data : null;
105715
107090
  const fps = resolveFps(input.fpsByProfile, source.profile);
105716
107091
  const options = buildRecordingOptions({
105717
107092
  width: source.width,
@@ -105727,7 +107102,8 @@ async function buildHksvRecording(input) {
105727
107102
  store: clipStore,
105728
107103
  width: source.width,
105729
107104
  height: source.height,
105730
- fragmentMs: fragmentLengthMs
107105
+ fragmentMs: fragmentLengthMs,
107106
+ ...recordedProfile === null ? {} : { profile: recordedProfile }
105731
107107
  });
105732
107108
  const delegate = new HksvRecordingDelegate({
105733
107109
  logger: log,
@@ -105759,6 +107135,7 @@ async function buildHksvRecording(input) {
105759
107135
  clipsKept: openClipTee !== void 0,
105760
107136
  clipsSwitchedOn: keepClips
105761
107137
  } });
107138
+ note(null, null);
105762
107139
  return {
105763
107140
  options,
105764
107141
  delegate,
@@ -107009,22 +108386,43 @@ function syncStateToJson(map) {
107009
108386
  * capabilities bound to it — restricting coverage by type is precisely what
107010
108387
  * confined this exporter to cameras for its first three rounds.
107011
108388
  */
108389
+ /**
108390
+ * What an entry with no stored settings is read as — and what a camera
108391
+ * exported TODAY is built from. Exported so a spec can assert the one thing
108392
+ * that matters about it: it must resolve to NOT recording, or "off by default"
108393
+ * is true only for cameras exposed before the flip.
108394
+ */
107012
108395
  var DEFAULT_DEVICE_SETTINGS = {
107013
108396
  streamPreference: "auto",
107014
- hksvRecording: true
108397
+ hksvRecording: false
107015
108398
  };
107016
108399
  /**
107017
- * ON unless explicitly switched off — operator decision 2026-08-08 (flipped
107018
- * from the launch default of off). ABSENT must resolve to ON or the flip is a
107019
- * lie for every entry persisted before the field existed, so every read goes
107020
- * through this one resolver (`!== false`), never a scattered `=== true`. The
107021
- * cost that made off-by-default look prudent is measured and small on the only
107022
- * branch the recorder accepts (copy: 0.7 % of a core / ~30 MB RSS, D84), and a
107023
- * camera the recorder cannot copy refuses recording with a logged reason
107024
- * rather than paying for a transcode.
108400
+ * OFF unless explicitly switched on — operator decision 2026-09-21, reversing
108401
+ * the 2026-08-08 flip:
108402
+ *
108403
+ * > *"il recording deve essere off di default, non deve essere attivo quando
108404
+ * > si esporta una camera su HKSV"*
108405
+ *
108406
+ * What changed is not the cost the earlier record argued about — recording on
108407
+ * the copy branch really is 0.7 % of a core and ~30 MB (D84), and cheap it
108408
+ * remains. What changed is that on-by-default made the switch **say something
108409
+ * the system was not doing**. Measured on 2026-09-21: 592, 587, 618 and 640
108410
+ * all reported HomeKit recording ON while the advertisement was WITHHELD
108411
+ * (`no-enabled-stream` — no enabled RTSP profile), so HomeKit never recorded
108412
+ * them and the tee had nothing to keep. The operator was being told four
108413
+ * cameras were recording because nobody had turned them off.
108414
+ *
108415
+ * Exposing a camera to HomeKit is a decision about EXPOSURE. Recording is a
108416
+ * second decision and now has to be made.
108417
+ *
108418
+ * ABSENT resolves OFF — the load-bearing case, for the same reason it was
108419
+ * before and with the opposite answer: a camera whose settings predate the
108420
+ * field, or that was exposed before today, was never asked about. Every read
108421
+ * goes through this one resolver (`=== true`), never a scattered `!== false`,
108422
+ * because those two disagree about exactly that case.
107025
108423
  */
107026
108424
  function resolveHksvRecording(settings) {
107027
- return settings?.hksvRecording !== false;
108425
+ return settings?.hksvRecording === true;
107028
108426
  }
107029
108427
  /**
107030
108428
  * Whether this camera offers Apple's NEW camera service alongside the classic
@@ -107051,6 +108449,32 @@ function resolveHksvRecording(settings) {
107051
108449
  function resolveKeepHomekitClips(settings) {
107052
108450
  return settings?.keepClips !== false;
107053
108451
  }
108452
+ /** This addon's id, as the manifest declares it and the registry knows it. */
108453
+ var EXPORT_HAP_ADDON_ID = "export-hap";
108454
+ /**
108455
+ * Whether a camera HAS a HomeKit clip source, read from the authorities that
108456
+ * already own each half.
108457
+ *
108458
+ * The source exists because the camera is EXPORTED to HomeKit and HomeKit
108459
+ * RECORDING is on for it, and it goes away when either goes off (D550, D569 §
108460
+ * 5). There is no third switch: a knob of this source's own would be a second
108461
+ * authority over somebody else's decision (D62), and this function is the only
108462
+ * place the two are read together.
108463
+ *
108464
+ * `keepClips` is deliberately NOT part of existence. It governs the TEE, and
108465
+ * the clips already kept are still the truth about this camera — so the row
108466
+ * stays and says the copies are off, which is D62's other half: an off switch
108467
+ * is REPORTED off, never made to look like a broken camera.
108468
+ */
108469
+ function describeHksvCamera(entry, lastBuild) {
108470
+ const isCamera = entry !== null && entry.mapperKind === "camera";
108471
+ return {
108472
+ exported: isCamera,
108473
+ recording: isCamera && resolveHksvRecording(entry.settings),
108474
+ keepingClips: isCamera && resolveKeepHomekitClips(entry.settings),
108475
+ lastBuild
108476
+ };
108477
+ }
107054
108478
  /** What ships, and what an unset global resolves to. */
107055
108479
  var DEFAULT_CLIP_RETENTION_DAYS = 14;
107056
108480
  /** The rails on the retention itself. Zero would silently disable the tee. */
@@ -107177,6 +108601,16 @@ var ExportHapAddon = class extends BaseAddon {
107177
108601
  /** Resolves the declared `homekitClips` location; the doorbell invalidates it. */
107178
108602
  hksvClipLocation = null;
107179
108603
  /**
108604
+ * What `buildHksvRecording` decided per camera, in this process.
108605
+ *
108606
+ * A MIRROR of the build, written by it and read only to be REPORTED: the
108607
+ * clip source's `index-empty` ("the switch says record and the store holds
108608
+ * nothing") is only honest if it can name which refusal fired (D571).
108609
+ */
108610
+ hksvBuilds = new HksvBuildOutcomes();
108611
+ /** The clip byte + still routes. `null` until `onInitialize` has served them. */
108612
+ hksvClipPlanes = null;
108613
+ /**
107180
108614
  * THE bridge. Lazily created and published the first time a non-camera
107181
108615
  * accessory needs it; never torn down while the addon runs, because an
107182
108616
  * unpublish would drop a pairing the operator already entered a code for.
@@ -107210,11 +108644,18 @@ var ExportHapAddon = class extends BaseAddon {
107210
108644
  logger: this.ctx.logger.child("hksv-clips"),
107211
108645
  resolveRoot: () => this.hksvClipLocation?.root() ?? Promise.resolve(null),
107212
108646
  maxAgeMsFor: (deviceId) => resolveClipRetentionDays(this.findEntry(deviceId)?.settings, this.config.clipRetentionDays) * 24 * 60 * 60 * 1e3,
107213
- mintThumbnail: createClipThumbnailMinter({ runner: createFfmpegClipThumbnailRunner({
108647
+ mintThumbnail: createClipThumbnailMinter({ runner: createBoundedFfmpegRunner({
108648
+ ffmpegBinaryPath: "ffmpeg",
108649
+ spawnFn: node_child_process.spawn,
108650
+ timeoutMs: CLIP_THUMBNAIL_TIMEOUT_MS
108651
+ }) }),
108652
+ remuxClip: createClipRemuxer({ runner: createBoundedFfmpegRunner({
107214
108653
  ffmpegBinaryPath: "ffmpeg",
107215
- spawnFn: node_child_process.spawn
108654
+ spawnFn: node_child_process.spawn,
108655
+ timeoutMs: CLIP_REMUX_TIMEOUT_MS
107216
108656
  }) })
107217
108657
  });
108658
+ await this.serveHksvClipPlanes();
107218
108659
  const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
107219
108660
  const cleaned = this.config.exposed.filter((entry) => {
107220
108661
  if (validKinds.has(entry.mapperKind)) return true;
@@ -107243,35 +108684,49 @@ var ExportHapAddon = class extends BaseAddon {
107243
108684
  this.subscribeDeviceReadyForReconcile();
107244
108685
  this.subscribeStorageLocationsForClips();
107245
108686
  this.disposeSharedRebuildTimers();
108687
+ const provider = {
108688
+ getStatus: async () => {
108689
+ const anyPaired = Array.from(this.exposed.values()).some((m) => accessoryPaired(m.accessory));
108690
+ const linkState = this.lastError ? "error" : anyPaired ? "linked" : "unlinked";
108691
+ const setup = this.buildSetupBlock();
108692
+ return {
108693
+ linkState,
108694
+ exposedDeviceCount: this.exposed.size,
108695
+ ...this.lastError ? { error: this.lastError } : {},
108696
+ ...setup ? { setup } : {}
108697
+ };
108698
+ },
108699
+ listSupportedDeviceKinds: async () => [...HAP_EXPORTABLE_DEVICE_TYPES],
108700
+ listExposedDevices: async () => Array.from(this.exposed.entries()).map(([deviceId, m]) => {
108701
+ const entry = this.config.exposed.find((e) => e.deviceId === deviceId);
108702
+ return {
108703
+ deviceId,
108704
+ exposedAs: m.accessory.displayName,
108705
+ ...entry?.capabilities ? { capabilities: [...entry.capabilities] } : {}
108706
+ };
108707
+ }),
108708
+ exposeDevice: async ({ deviceId, capabilities }) => this.exposeDevice(deviceId, capabilities),
108709
+ unexposeDevice: async ({ deviceId }) => this.unexposeDevice(deviceId),
108710
+ getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
108711
+ getDeviceLiveContribution: async () => null,
108712
+ applyDeviceSettingsPatch: (input) => this.applyDeviceSettingsPatch(input.deviceId, input.patch)
108713
+ };
108714
+ const clipsProvider = createHksvVideoclipsProvider({
108715
+ logger: this.ctx.logger.child("hksv-clips"),
108716
+ addonId: EXPORT_HAP_ADDON_ID,
108717
+ describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId)),
108718
+ readCatalog: async (deviceId) => await this.hksvClipStore?.listCatalog(deviceId) ?? { kind: "no-root" },
108719
+ locationRefusal: () => this.hksvClipLocation?.lastRefusal ?? null,
108720
+ mediaUrl: (deviceId, stem) => this.hksvClipUrl("media", deviceId, stem),
108721
+ thumbnailUrl: (deviceId, stem) => this.hksvClipUrl("thumb", deviceId, stem),
108722
+ clipFilePath: async (deviceId, stem) => await this.hksvClipStore?.artifactPath(deviceId, stem, "clip") ?? null
108723
+ });
107246
108724
  return [{
107247
108725
  capability: deviceExportCapability,
107248
- provider: {
107249
- getStatus: async () => {
107250
- const anyPaired = Array.from(this.exposed.values()).some((m) => accessoryPaired(m.accessory));
107251
- const linkState = this.lastError ? "error" : anyPaired ? "linked" : "unlinked";
107252
- const setup = this.buildSetupBlock();
107253
- return {
107254
- linkState,
107255
- exposedDeviceCount: this.exposed.size,
107256
- ...this.lastError ? { error: this.lastError } : {},
107257
- ...setup ? { setup } : {}
107258
- };
107259
- },
107260
- listSupportedDeviceKinds: async () => [...HAP_EXPORTABLE_DEVICE_TYPES],
107261
- listExposedDevices: async () => Array.from(this.exposed.entries()).map(([deviceId, m]) => {
107262
- const entry = this.config.exposed.find((e) => e.deviceId === deviceId);
107263
- return {
107264
- deviceId,
107265
- exposedAs: m.accessory.displayName,
107266
- ...entry?.capabilities ? { capabilities: [...entry.capabilities] } : {}
107267
- };
107268
- }),
107269
- exposeDevice: async ({ deviceId, capabilities }) => this.exposeDevice(deviceId, capabilities),
107270
- unexposeDevice: async ({ deviceId }) => this.unexposeDevice(deviceId),
107271
- getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
107272
- getDeviceLiveContribution: async () => null,
107273
- applyDeviceSettingsPatch: (input) => this.applyDeviceSettingsPatch(input.deviceId, input.patch)
107274
- }
108726
+ provider
108727
+ }, {
108728
+ capability: videoclipsCapability,
108729
+ provider: clipsProvider
107275
108730
  }];
107276
108731
  }
107277
108732
  async onConfigChanged() {
@@ -107396,6 +108851,7 @@ var ExportHapAddon = class extends BaseAddon {
107396
108851
  if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
107397
108852
  if (options.clearPairing !== false) clearPairingFiles(accessoryUuidFor(mapperKind, numericId), this.ctx.logger);
107398
108853
  await this.forgetFingerprint(numericId);
108854
+ this.hksvBuilds.forget(numericId);
107399
108855
  log.info("export-hap: unexposed device");
107400
108856
  }
107401
108857
  async attachMapper(entry) {
@@ -107409,6 +108865,9 @@ var ExportHapAddon = class extends BaseAddon {
107409
108865
  ptzPulseMs: this.config.ptzPulseMs,
107410
108866
  decodeMemos: this.decodeMemos,
107411
108867
  hksvClipStore: this.hksvClipStore,
108868
+ noteHksvOutcome: (outcome) => {
108869
+ this.hksvBuilds.note(outcome);
108870
+ },
107412
108871
  hapDeviceSettings: {
107413
108872
  streamPreference: entrySettings.streamPreference ?? "auto",
107414
108873
  hksvRecording: resolveHksvRecording(entrySettings),
@@ -108028,6 +109487,57 @@ var ExportHapAddon = class extends BaseAddon {
108028
109487
  }
108029
109488
  return { success: true };
108030
109489
  }
109490
+ /**
109491
+ * Host the clip byte + still routes, and never let a host without the
109492
+ * facility take the addon down with it: a data plane is an enhancement to an
109493
+ * addon that already works, so a failure here degrades the clip surface and
109494
+ * nothing else. It is LOGGED, because a branch that drops work silently
109495
+ * reads as "never happened".
109496
+ *
109497
+ * `authenticated`, not `admin`, and NO TOKEN in either URL: the hub's
109498
+ * `/addon/<id>/<prefix>` proxy takes the session cookie, exactly as the
109499
+ * recorder's own playback plane does (D549 22).
109500
+ */
109501
+ async serveHksvClipPlanes() {
109502
+ const store = this.hksvClipStore;
109503
+ if (store === null) return;
109504
+ const planes = createHksvClipPlanes({
109505
+ logger: this.ctx.logger.child("hksv-clips"),
109506
+ artifactPath: (deviceId, stem, artifact) => store.artifactPath(deviceId, stem, artifact)
109507
+ });
109508
+ try {
109509
+ const media = await this.ctx.dataPlane?.serve({
109510
+ prefix: planes.mediaPrefix,
109511
+ access: "authenticated",
109512
+ handler: planes.mediaHandler
109513
+ });
109514
+ const thumb = await this.ctx.dataPlane?.serve({
109515
+ prefix: planes.thumbPrefix,
109516
+ access: "authenticated",
109517
+ handler: planes.thumbHandler
109518
+ });
109519
+ this.hksvClipPlanes = planes;
109520
+ this.ctx.logger.info("export-hap: HomeKit clip data planes served", { meta: {
109521
+ mediaPath: `/addon/${EXPORT_HAP_ADDON_ID}/${planes.mediaPrefix}`,
109522
+ thumbPath: `/addon/${EXPORT_HAP_ADDON_ID}/${planes.thumbPrefix}`,
109523
+ mediaServed: media !== void 0,
109524
+ thumbServed: thumb !== void 0
109525
+ } });
109526
+ } catch (err) {
109527
+ this.ctx.logger.warn("export-hap: HomeKit clip data planes failed to serve — clips will list but not play", { meta: { error: errMsg(err) } });
109528
+ }
109529
+ }
109530
+ /**
109531
+ * The client path of one clip artifact.
109532
+ *
109533
+ * Composed from the SERVED prefixes when the planes are up, so a URL is
109534
+ * never minted for a route that does not exist; the constants are the
109535
+ * fallback for the one window between registration and `serve` returning.
109536
+ */
109537
+ hksvClipUrl(kind, deviceId, stem) {
109538
+ const planes = this.hksvClipPlanes;
109539
+ return `/addon/${EXPORT_HAP_ADDON_ID}/${kind === "media" ? planes?.mediaPrefix ?? "hksv-clip" : planes?.thumbPrefix ?? "hksv-clip-thumb"}/${String(deviceId)}/${stem}.${kind === "media" ? "mp4" : "jpg"}`;
109540
+ }
108031
109541
  findEntry(deviceId) {
108032
109542
  const id = String(deviceId);
108033
109543
  return this.config.exposed.find((e) => e.deviceId === id) ?? null;
@@ -108045,10 +109555,13 @@ function errMsg(err) {
108045
109555
  }
108046
109556
  //#endregion
108047
109557
  exports.DEFAULT_CLIP_RETENTION_DAYS = DEFAULT_CLIP_RETENTION_DAYS;
109558
+ exports.DEFAULT_DEVICE_SETTINGS = DEFAULT_DEVICE_SETTINGS;
109559
+ exports.EXPORT_HAP_ADDON_ID = EXPORT_HAP_ADDON_ID;
108048
109560
  exports.ExportHapAddon = ExportHapAddon;
108049
109561
  exports.default = ExportHapAddon;
108050
109562
  exports.__toCommonJS = __toCommonJS;
108051
109563
  exports.deriveUsername = deriveUsername;
109564
+ exports.describeHksvCamera = describeHksvCamera;
108052
109565
  exports.initHapStorage = initHapStorage;
108053
109566
  exports.publishStandalone = publishStandalone;
108054
109567
  exports.resolveClipRetentionDays = resolveClipRetentionDays;