@camstack/types 1.2.42 → 1.2.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/addon.js +8 -2
  2. package/dist/addon.mjs +8 -2
  3. package/dist/cap-call-context.d.ts +26 -0
  4. package/dist/capabilities/index.d.ts +8 -5
  5. package/dist/capabilities/osd-manager.cap.d.ts +900 -0
  6. package/dist/capabilities/pipeline-analytics.cap.d.ts +171 -4
  7. package/dist/capabilities/pipeline-orchestrator.cap.d.ts +36 -0
  8. package/dist/capabilities/privacy-mask.cap.d.ts +69 -9
  9. package/dist/capabilities/recording.cap.d.ts +27 -0
  10. package/dist/capabilities/schemas/streaming-shared.d.ts +2 -0
  11. package/dist/capabilities/server-management.cap.d.ts +3 -3
  12. package/dist/capabilities/snapshot.cap.d.ts +1 -1
  13. package/dist/capabilities/stream-broker.cap.d.ts +36 -0
  14. package/dist/capabilities/stream-params.cap.d.ts +8 -4
  15. package/dist/device/device-profile.d.ts +12 -4
  16. package/dist/device/system-mirror.d.ts +11 -0
  17. package/dist/ffmpeg/encode-defaults.d.ts +18 -0
  18. package/dist/ffmpeg/fmp4-box-splitter.d.ts +113 -0
  19. package/dist/ffmpeg/fmp4-fragment-child.d.ts +85 -0
  20. package/dist/ffmpeg/fmp4-fragment-plane.d.ts +142 -0
  21. package/dist/ffmpeg/invocation.d.ts +102 -2
  22. package/dist/ffmpeg/sharing-key.d.ts +54 -2
  23. package/dist/fmp4-box-splitter-B53u9-Nu.mjs +615 -0
  24. package/dist/fmp4-box-splitter-BkWH7O3L.js +686 -0
  25. package/dist/generated/addon-api.d.ts +117 -0
  26. package/dist/generated/cap-input-defaults.d.ts +1 -1
  27. package/dist/generated/capability-router-map.d.ts +5 -2
  28. package/dist/generated/device-proxy.d.ts +4 -2
  29. package/dist/generated/method-access-map.d.ts +1 -1
  30. package/dist/generated/system-proxy.d.ts +2 -0
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.js +1264 -394
  33. package/dist/index.mjs +1217 -385
  34. package/dist/interfaces/camera-switches.d.ts +211 -5
  35. package/dist/interfaces/inference-engine.d.ts +24 -3
  36. package/dist/interfaces/stream-broker.d.ts +18 -0
  37. package/dist/node.d.ts +4 -0
  38. package/dist/node.js +509 -3
  39. package/dist/node.mjs +507 -3
  40. package/dist/notification/schedule.d.ts +20 -0
  41. package/dist/pipeline/native-lease.d.ts +150 -0
  42. package/dist/{sleep-BmNKsY7v.mjs → sleep-cC4Fuup8.mjs} +59 -3
  43. package/dist/{sleep-Cvi1JxZp.js → sleep-eiC10_cX.js} +64 -2
  44. package/dist/types/pipeline-step.d.ts +1 -1
  45. package/dist/utils/addon-id.d.ts +30 -0
  46. package/package.json +1 -1
  47. package/dist/canonical-hash-7nfBbEqR.mjs +0 -35
  48. package/dist/canonical-hash-BcZHRHIx.js +0 -40
@@ -420,6 +420,7 @@ export declare const EgressTranscodeRequestSchema: z.ZodObject<{
420
420
  h264_mp4toannexb: "h264_mp4toannexb";
421
421
  hevc_mp4toannexb: "hevc_mp4toannexb";
422
422
  }>>;
423
+ publishLocally: z.ZodOptional<z.ZodBoolean>;
423
424
  pixelFormat: z.ZodOptional<z.ZodEnum<{
424
425
  yuv420p: "yuv420p";
425
426
  nv12: "nv12";
@@ -450,6 +451,7 @@ export declare const EgressTranscodeSchema: z.ZodObject<{
450
451
  transcoded: z.ZodBoolean;
451
452
  encoder: z.ZodString;
452
453
  decodeHwAccel: z.ZodNullable<z.ZodString>;
454
+ camStreamId: z.ZodNullable<z.ZodString>;
453
455
  }, z.core.$strip>;
454
456
  export type EgressTranscode = z.infer<typeof EgressTranscodeSchema>;
455
457
  export declare const streamBrokerCapability: {
@@ -687,6 +689,8 @@ export declare const streamBrokerCapability: {
687
689
  channels: z.ZodNumber;
688
690
  supported: z.ZodBoolean;
689
691
  }, z.core.$strip>>>;
692
+ audioMuted: z.ZodOptional<z.ZodBoolean>;
693
+ audioMutedDropped: z.ZodOptional<z.ZodNumber>;
690
694
  }, z.core.$strip>, import("./capability-definition.js").CapabilityMethodKind>;
691
695
  /**
692
696
  * Force a one-shot probe of a single source stream: transiently dial the
@@ -955,6 +959,7 @@ export declare const streamBrokerCapability: {
955
959
  h264_mp4toannexb: "h264_mp4toannexb";
956
960
  hevc_mp4toannexb: "hevc_mp4toannexb";
957
961
  }>>;
962
+ publishLocally: z.ZodOptional<z.ZodBoolean>;
958
963
  pixelFormat: z.ZodOptional<z.ZodEnum<{
959
964
  yuv420p: "yuv420p";
960
965
  nv12: "nv12";
@@ -983,6 +988,7 @@ export declare const streamBrokerCapability: {
983
988
  transcoded: z.ZodBoolean;
984
989
  encoder: z.ZodString;
985
990
  decodeHwAccel: z.ZodNullable<z.ZodString>;
991
+ camStreamId: z.ZodNullable<z.ZodString>;
986
992
  }, z.core.$strip>, "mutation">;
987
993
  /** Drop one reference. The child dies when the last holder releases. */
988
994
  readonly releaseEgressTranscode: import("./capability-definition.js").CapabilityMethodSchema<z.ZodObject<{
@@ -1147,6 +1153,36 @@ export declare const streamBrokerCapability: {
1147
1153
  readonly isRtspEnabled: import("./capability-definition.js").CapabilityMethodSchema<z.ZodObject<{
1148
1154
  brokerId: z.ZodString;
1149
1155
  }, z.core.$strip>, z.ZodBoolean, import("./capability-definition.js").CapabilityMethodKind>;
1156
+ /**
1157
+ * ── Per-device audio-plane policy (D83) ───────────────────────────
1158
+ *
1159
+ * The BROKER-side mute: while `muted`, this node distributes none of
1160
+ * the device's audio, on any plane it serves — live (WebRTC / encoded
1161
+ * subscribers) AND recording (the RTSP restreamer the recorder pulls,
1162
+ * which additionally serves the video-only SDP so the recorder's ffmpeg
1163
+ * never declares an audio stream it will not receive).
1164
+ *
1165
+ * Keyed by `deviceId`, not `brokerId`: every one of a camera's streams
1166
+ * carries the same microphone, and a per-stream answer would let main
1167
+ * and sub disagree about whether the camera is silent.
1168
+ *
1169
+ * The state lives in the broker's existing `DeviceOverride` blob — no
1170
+ * new store — and the mute is applied to a fresh broker at creation, so
1171
+ * a restart, a re-dial or a catalog republish never un-mutes a camera.
1172
+ */
1173
+ readonly getDeviceAudioMute: import("./capability-definition.js").CapabilityMethodSchema<z.ZodObject<{
1174
+ deviceId: z.ZodNumber;
1175
+ }, z.core.$strip>, z.ZodObject<{
1176
+ muted: z.ZodBoolean;
1177
+ appliedBrokers: z.ZodNumber;
1178
+ }, z.core.$strip>, import("./capability-definition.js").CapabilityMethodKind>;
1179
+ readonly setDeviceAudioMute: import("./capability-definition.js").CapabilityMethodSchema<z.ZodObject<{
1180
+ deviceId: z.ZodNumber;
1181
+ muted: z.ZodBoolean;
1182
+ }, z.core.$strip>, z.ZodObject<{
1183
+ muted: z.ZodBoolean;
1184
+ appliedBrokers: z.ZodNumber;
1185
+ }, z.core.$strip>, "mutation">;
1150
1186
  };
1151
1187
  readonly events: {
1152
1188
  readonly onCamStreamDemand: import("./capability-definition.js").CapabilityEventSchema<z.ZodObject<{
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { type InferNativeProvider } from './capability-definition.js';
3
2
  import { DeviceType } from '../device/device-type.js';
3
+ import { type InferNativeProvider } from './capability-definition.js';
4
4
  /** One of the camera's stream profiles. */
5
5
  export declare const StreamProfileSchema: z.ZodEnum<{
6
6
  sub: "sub";
@@ -183,7 +183,13 @@ export declare const StreamParamsOptionsSchema: z.ZodObject<{
183
183
  }, z.core.$strip>>;
184
184
  }, z.core.$strip>;
185
185
  /** A partial change to one profile — every field optional; a provider
186
- * ignores fields it doesn't support. */
186
+ * ignores fields it doesn't support.
187
+ *
188
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
189
+ * reachable from no form and honoured by exactly one provider, while the
190
+ * camera's microphone is a whole-device fact. It now has one writer,
191
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
192
+ * `privacy-mask.cap.ts`. */
187
193
  export declare const StreamProfilePatchSchema: z.ZodObject<{
188
194
  width: z.ZodOptional<z.ZodNumber>;
189
195
  height: z.ZodOptional<z.ZodNumber>;
@@ -203,7 +209,6 @@ export declare const StreamProfilePatchSchema: z.ZodObject<{
203
209
  main: "main";
204
210
  }>>;
205
211
  gop: z.ZodOptional<z.ZodNumber>;
206
- audio: z.ZodOptional<z.ZodBoolean>;
207
212
  }, z.core.$strip>;
208
213
  export type StreamParamsStatus = z.infer<typeof StreamParamsStatusSchema>;
209
214
  export type StreamParamsOptions = z.infer<typeof StreamParamsOptionsSchema>;
@@ -316,7 +321,6 @@ export declare const streamParamsCapability: {
316
321
  main: "main";
317
322
  }>>;
318
323
  gop: z.ZodOptional<z.ZodNumber>;
319
- audio: z.ZodOptional<z.ZodBoolean>;
320
324
  }, z.core.$strip>;
321
325
  }, z.core.$strip>, z.ZodVoid, "mutation">;
322
326
  /**
@@ -120,10 +120,18 @@ export interface DeviceProfile {
120
120
  */
121
121
  readonly defaults: DeviceProfileDefaults;
122
122
  /**
123
- * Generic settings overrides (snapshot interval, pre-buffer flag,
124
- * etc.). Same sticky semantics as defaults applied on first read,
125
- * operator override wins. Keys are dot-paths interpreted by the
126
- * per-driver / per-addon config schema.
123
+ * Generic settings overrides, keyed by dot-path, intended to be
124
+ * interpreted by the per-driver / per-addon config schema.
125
+ *
126
+ * **Nothing reads this today.** It is a declared extension point with no
127
+ * consumer anywhere in the workspace, so any entry placed here is inert. It
128
+ * shipped populated — `snapshot.minRefreshIntervalSec: 3600` and
129
+ * `streamBroker.preBufferEnabled: false` on the battery profile — which read
130
+ * as live policy and were not: both behaviours are really enforced off
131
+ * `DeviceFeature.BatteryOperated` directly, by `BATTERY_DEFAULT_MAX_AGE_S`
132
+ * in `snapshot.addon.ts` and by `getEffectivePreBufferSec` in
133
+ * `stream-broker-manager.ts`. Emptied 2026-08-07; keep it empty until a
134
+ * reader exists, or an entry here is just documentation that lies.
127
135
  */
128
136
  readonly settings: Readonly<Record<string, unknown>>;
129
137
  }
@@ -62,6 +62,17 @@ export interface SystemMirrorApi extends SliceHandleApi {
62
62
  addonId?: string;
63
63
  }): Promise<ReadonlyArray<DeviceInfo>>;
64
64
  };
65
+ /**
66
+ * Single-device metadata read. Required — `refreshDeviceMetadata`
67
+ * runs once per `device.registered` / `device.updated` event, and a
68
+ * full-catalog fetch there is what melts the hub during a
69
+ * deploy/restart storm (see `refreshDeviceMetadata`).
70
+ */
71
+ readonly getDevice: {
72
+ query(input: {
73
+ deviceId: number;
74
+ }): Promise<DeviceInfo | null>;
75
+ };
65
76
  };
66
77
  readonly deviceState: SliceHandleApi['deviceState'] & {
67
78
  readonly getAllSnapshots: {
@@ -56,6 +56,24 @@ export declare const WEBRTC_EGRESS_PROFILE: EncodeProfile;
56
56
  * audio plane (out-of-band), so this is `passthrough` exactly like the browser
57
57
  * — see the ADR for why the previous in-band Opus was encoded and discarded.
58
58
  */
59
+ /**
60
+ * Alexa's egress asks for OPUS, and that is a change with a history.
61
+ *
62
+ * The previous profile encoded Opus IN-BAND into an MPEG-TS, where it was
63
+ * discarded: Opus is `stream_type 0x06` and the broker's demuxer maps only
64
+ * `0x0f` (aac), `0x03`/`0x04` (mp2) and `0x81` (ac3). Every frame it produced
65
+ * died at the demuxer, so the encode was replaced with `passthrough` — correct,
66
+ * because paying libopus for nothing is worse than silence.
67
+ *
68
+ * But `passthrough` means the egress emits NO audio at all, and Alexa's audio
69
+ * does not arrive by magic: it rides the published stream, which is what the
70
+ * WebRTC session dials. So the Echo had video and silence either way.
71
+ *
72
+ * Opus here now reaches the egress AUDIO SIDECAR — a separate RTP leg the
73
+ * restreamer grafts onto its SDP — which never touches the MPEG-TS demuxer that
74
+ * killed the in-band attempt. Same codec, different plane, and this one the
75
+ * consumer can actually negotiate.
76
+ */
59
77
  export declare const ALEXA_EGRESS_PROFILE: EncodeProfile;
60
78
  /** VBV window for a consumer whose budget is enforced per second (HomeKit). */
61
79
  export declare const RATE_CONTROL_TIGHT: FfmpegRateControl;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Fmp4BoxSplitter — cut an ffmpeg fragmented-MP4 byte stream into the units a
3
+ * consumer of fragments actually needs: ONE initialisation segment
4
+ * (`ftyp`+`moov`) followed by one unit per `moof`+`mdat` pair.
5
+ *
6
+ * This is the shape HomeKit Secure Video's `CameraRecordingDelegate` yields
7
+ * (`RecordingPacket` #0 is the initialisation, the rest are media fragments),
8
+ * and it is the same shape a CamStack clip source wants — which is the point.
9
+ * One fragmenter, two consumers ([roadmap 4b](../../../../../docs/roadmap.md)).
10
+ * The splitter therefore knows about NEITHER of them: it emits units, and
11
+ * {@link import('./fmp4-fragment-plane.js').Fmp4FragmentPlane} decides who gets
12
+ * them.
13
+ *
14
+ * ## Pure, streaming, bounded
15
+ *
16
+ * No I/O, no timers, no ffmpeg. `push` takes whatever bytes arrived and returns
17
+ * whatever units completed — a caller may hand it one byte at a time or a
18
+ * megabyte, and the units are identical either way (asserted). Memory is
19
+ * bounded by {@link Fmp4SplitterOptions.maxUnitBytes}: the splitter holds at
20
+ * most one in-progress unit plus a partial box, and a stream that exceeds the
21
+ * bound FAULTS rather than growing, because the alternative on a live 24/7
22
+ * source is the broker's heap.
23
+ *
24
+ * ## The box rules, from ISO/IEC 14496-12
25
+ *
26
+ * A box header is `size:uint32` + `type:4 chars`. `size == 1` means the real
27
+ * size is a `uint64` in the next 8 bytes; `size == 0` means "to end of file".
28
+ * A size-0 box is a FAULT here and not a special case: it cannot be cut into
29
+ * fragments at all, and pretending otherwise would produce one enormous unit
30
+ * whose bytes never arrive.
31
+ *
32
+ * ## What it does NOT do
33
+ *
34
+ * It never inspects `tfdt`/`trun` and never computes a fragment's duration. The
35
+ * fragment length is decided upstream by the muxer (`-min_frag_duration` plus a
36
+ * key-frame grid, see `@camstack/types` `ffmpeg/invocation.ts`), and a duration
37
+ * parsed here would be a SECOND opinion about the same fact — the failure mode
38
+ * D67 exists to prevent. A consumer that must verify conformance measures the
39
+ * arrival cadence, which is the thing it actually cares about.
40
+ */
41
+ /** `init` is the `ftyp`+`moov` head; `fragment` is one `moof`+`mdat` pair. */
42
+ export type Fmp4UnitKind = 'init' | 'fragment';
43
+ export interface Fmp4Unit {
44
+ readonly kind: Fmp4UnitKind;
45
+ /** A standalone copy — the splitter never hands out a view into its buffer. */
46
+ readonly data: Uint8Array;
47
+ /** `0` for the initialisation segment, then `1, 2, 3…` per fragment. */
48
+ readonly sequence: number;
49
+ }
50
+ export interface Fmp4SplitterOptions {
51
+ /**
52
+ * The most bytes one unit may reach before the stream is declared unusable.
53
+ * A 4-second 1080p fragment is a few hundred KB; the default leaves three
54
+ * orders of magnitude of headroom and still bounds a source that is not
55
+ * really fMP4 (a plain MP4 with a trailing `moov`, say, whose single `mdat`
56
+ * is the entire recording).
57
+ */
58
+ readonly maxUnitBytes?: number;
59
+ }
60
+ export declare class Fmp4BoxSplitter {
61
+ private readonly maxUnitBytes;
62
+ /** Bytes of the CURRENT unit plus any partial box after it. */
63
+ private buffer;
64
+ /** Where the current unit starts inside {@link buffer}. */
65
+ private unitStart;
66
+ /** Where the box scanner has reached inside {@link buffer}. */
67
+ private cursor;
68
+ private state;
69
+ private nextSequence;
70
+ private faultReason;
71
+ private readonly interstitial;
72
+ constructor(options?: Fmp4SplitterOptions);
73
+ /**
74
+ * Non-null once the stream cannot be split. The splitter emits nothing
75
+ * further, so a caller polls this to kill the child rather than watching a
76
+ * silent stall — a fragmenter that quietly stops producing looks exactly like
77
+ * a camera with no motion.
78
+ */
79
+ get fault(): string | null;
80
+ /** Bytes currently held. The memory bound, observable rather than asserted. */
81
+ get pendingBytes(): number;
82
+ /**
83
+ * Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
84
+ * a stray `sidx`. Reported rather than dropped in silence: they are legal and
85
+ * useless to a fragment consumer, but a type nobody expected showing up here
86
+ * is the first symptom of a muxer that is not writing what we think it is.
87
+ */
88
+ get discardedInterstitialTypes(): readonly string[];
89
+ /**
90
+ * Feed bytes; get back whatever units completed. Returns `[]` once faulted.
91
+ */
92
+ push(chunk: Uint8Array): readonly Fmp4Unit[];
93
+ private append;
94
+ /** Consume every COMPLETE top-level box now in the buffer. */
95
+ private drainBoxes;
96
+ /**
97
+ * Apply one box to the state machine. Returns a unit when this box CLOSED
98
+ * one, `null` otherwise.
99
+ */
100
+ private consumeBox;
101
+ /**
102
+ * Parse the header at {@link cursor}, or `null` when too few bytes have
103
+ * arrived to know. Faults on a size the splitter cannot honour.
104
+ */
105
+ private readHeader;
106
+ private emit;
107
+ /**
108
+ * Drop everything already emitted or discarded. Without this the buffer is
109
+ * the whole stream and the process dies in hours, not minutes.
110
+ */
111
+ private compact;
112
+ private fail;
113
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Fmp4FragmentChild — one ffmpeg writing a fragmented MP4 to stdout, split into
3
+ * units and fanned out on a {@link Fmp4FragmentPlane}.
4
+ *
5
+ * ## Why this lives in the PRIMITIVE and not in the broker
6
+ *
7
+ * It was written as the stream-broker's third egress transport, on the premise
8
+ * ([D80](../../../../docs/decisions/adr-0080.md)) that its consumers would be
9
+ * in-process with the broker and could hold a live plane object. **They are
10
+ * not.** `addon-export-hap` — the HKSV recording delegate, the first real
11
+ * consumer — is a `hub-only` addon in its OWN runner ([D2](../../../../docs/decisions/adr-0002.md)),
12
+ * and a plane cannot cross a process boundary. The choice was a second copy of
13
+ * the splitter in the exporter, or one copy here beside the sink that produces
14
+ * the bytes. The sink, the splitter, the plane and this child are one primitive
15
+ * ([D84](../../../../docs/decisions/adr-0084.md)); `EgressTranscodeManager`
16
+ * keeps only what is broker business — sharing key, refcount, release grace.
17
+ *
18
+ * It is deliberately the same shape as `TranscodeEgress` (the dial transport)
19
+ * and `createPushChild` (the push one), because the three failures that cost
20
+ * sessions on those paths are the same three here:
21
+ *
22
+ * - **It resolves on the first UNIT, never on the first byte.** A child whose
23
+ * bytes do not parse as fMP4 is a child that will never produce a fragment,
24
+ * and resolving on bytes hands the caller a handle nothing feeds.
25
+ * - **It retries once in SOFTWARE.** `-hwaccel vaapi` fails to create a device
26
+ * inside the stream-broker runner on the live hub (`code=244`), and the push
27
+ * transport was dark for exactly as long as it lacked this retry.
28
+ * - **A child that dies AFTER going live says so.** Silence reads as "it is
29
+ * running"; the plane must be ended so a consumer's generator completes
30
+ * rather than hanging on a producer that no longer exists.
31
+ *
32
+ * Two consumers: `EgressTranscodeManager.acquireFragments` (the broker, for a
33
+ * future CamStack clip source — roadmap 4b half 2) and `HksvFragmentSource` in
34
+ * `addon-export-hap`. It stays unreachable from `acquireEgressTranscode`'s
35
+ * request schema on purpose (see `egressTransportFromRequest`): a plane is a
36
+ * live object, so an off-node caller could not use one anyway.
37
+ */
38
+ import type { spawn as nodeSpawn } from 'node:child_process';
39
+ import type { IScopedLogger } from '../interfaces/logging.js';
40
+ import type { FfmpegInvocation } from './invocation.js';
41
+ import type { Fmp4FragmentPlane } from './fmp4-fragment-plane.js';
42
+ export interface Fmp4FragmentChildDeps {
43
+ readonly logger: IScopedLogger;
44
+ readonly ffmpegBinaryPath: string;
45
+ readonly spawnFn: typeof nodeSpawn;
46
+ /** How long the child may produce no UNIT before it is declared dead. */
47
+ readonly firstUnitTimeoutMs?: number;
48
+ /** The child exited, or its output stopped parsing, AFTER it went live. */
49
+ readonly onChildExit?: (error: Error) => void;
50
+ }
51
+ export interface Fmp4FragmentChildArgs {
52
+ readonly sourceId: string;
53
+ /** Every line about this child is grouped per camera, without exception. */
54
+ readonly deviceId: number;
55
+ /** The negotiated fragment length — argv only; the splitter never reads it. */
56
+ readonly fragmentMs: number;
57
+ /** The invocation minus the sink, which this child owns. */
58
+ readonly invocation: Omit<FfmpegInvocation, 'sink' | 'audioSidecar'>;
59
+ /** Where the units go. Owned by the caller, ended by this child. */
60
+ readonly plane: Fmp4FragmentPlane;
61
+ }
62
+ export declare class Fmp4FragmentChild {
63
+ private readonly deps;
64
+ private readonly args;
65
+ private child;
66
+ private splitter;
67
+ private stopped;
68
+ private unitsOut;
69
+ private activeHwAccel;
70
+ constructor(deps: Fmp4FragmentChildDeps, args: Fmp4FragmentChildArgs);
71
+ /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
72
+ start(): Promise<void>;
73
+ /** The backend the child ACTUALLY ran with — `null` for software. */
74
+ activeDecodeHwAccel(): string | null;
75
+ /** Kill ffmpeg and end the plane. Idempotent. */
76
+ stop(): Promise<void>;
77
+ private spawnAttempt;
78
+ /**
79
+ * The byte stream stopped being splittable. Not recoverable — the splitter
80
+ * cannot resynchronise mid-box — so the child is a corpse and every consumer
81
+ * has to be told, loudly, with the reason.
82
+ */
83
+ private onFault;
84
+ private killChild;
85
+ }
@@ -0,0 +1,142 @@
1
+ import type { IScopedLogger } from '../interfaces/logging.js';
2
+ import type { Fmp4Unit, Fmp4UnitKind } from './fmp4-box-splitter.js';
3
+ /** One unit as a subscriber receives it. */
4
+ export interface Fmp4Packet {
5
+ readonly kind: Fmp4UnitKind;
6
+ readonly data: Uint8Array;
7
+ /** The producer's sequence number. NOT contiguous per subscriber — a
8
+ * subscriber that attached late starts at the retained init's own number. */
9
+ readonly sequence: number;
10
+ /** The final packet this subscriber will receive. Exactly one, or none. */
11
+ readonly isLast: boolean;
12
+ }
13
+ /** Why a subscription stopped. */
14
+ export type Fmp4CloseReason =
15
+ /** The producer ended: the child exited, or the consumer released it. */
16
+ 'ended'
17
+ /** The subscriber did not drain fast enough; its stream would have a hole. */
18
+ | 'slow-consumer'
19
+ /** The subscriber closed it. */
20
+ | 'released';
21
+ export interface Fmp4Subscription {
22
+ readonly id: string;
23
+ readonly tag: string;
24
+ /** Packets delivered so far — the premise the `isLast` contract rests on. */
25
+ readonly delivered: number;
26
+ /** Non-null once no further packets will arrive. */
27
+ readonly closedReason: Fmp4CloseReason | null;
28
+ /**
29
+ * The packet stream, with real backpressure: the generator suspends until a
30
+ * packet is queued, so a consumer that awaits each packet paces itself.
31
+ * The bounded queue only exists for the case where it does NOT.
32
+ */
33
+ packets(): AsyncGenerator<Fmp4Packet>;
34
+ /** Stop receiving. Idempotent. */
35
+ release(): void;
36
+ }
37
+ export interface Fmp4SubscriberInfo {
38
+ readonly tag: string;
39
+ readonly subscribedAt: number;
40
+ readonly delivered: number;
41
+ readonly closedReason: Fmp4CloseReason | null;
42
+ }
43
+ export interface Fmp4SubscribeInput {
44
+ /** Short caller identity for diagnostics — `hksv/615`, `clips/615`. */
45
+ readonly tag: string;
46
+ /**
47
+ * Fragments a subscriber may fall behind by before it is closed. Four is two
48
+ * negotiated fragment lengths of slack on a 4 s cadence — enough to ride out
49
+ * a GC pause, far short of the memory a real stall would take.
50
+ *
51
+ * A prebuffer replay is added ON TOP of this, never counted against it.
52
+ */
53
+ readonly queueCapacity?: number;
54
+ /**
55
+ * Replay the retained prebuffer before live fragments. Off by default: a
56
+ * clip consumer that joins an ongoing recording wants what happens NEXT, and
57
+ * handing it seconds of the past would silently shift its clip's start.
58
+ */
59
+ readonly withPrebuffer?: boolean;
60
+ }
61
+ /** How much past the plane keeps for a {@link Fmp4SubscribeInput.withPrebuffer}. */
62
+ export interface Fmp4PrebufferOptions {
63
+ /**
64
+ * How far back the ring reaches, by fragment ARRIVAL time. HKSV's minimum
65
+ * prebuffer is 4000 ms and the fragment cadence is the source GOP on a copy
66
+ * branch (measured: 8 s on this fleet), so a window shorter than two
67
+ * cadences can hold nothing at all at the moment it is asked for.
68
+ */
69
+ readonly windowMs: number;
70
+ /**
71
+ * The hard ceiling. Non-negotiable, because the window alone does not bound
72
+ * RAM: same 8 s, 255 KB at 720p and 6.35 MB at 4K on the same cameras.
73
+ */
74
+ readonly maxBytes: number;
75
+ }
76
+ /** What the ring is holding right now — diagnostics, and a delegate's gate. */
77
+ export interface Fmp4PrebufferStats {
78
+ readonly fragments: number;
79
+ readonly bytes: number;
80
+ /** Age of the OLDEST retained fragment. `0` when the ring is empty. */
81
+ readonly spanMs: number;
82
+ }
83
+ export declare class Fmp4FragmentPlane {
84
+ private readonly logger?;
85
+ private readonly prebuffer?;
86
+ /** Injectable clock — the ring's window is the one thing here that is
87
+ * wall-clock dependent, and a test that sleeps 8 real seconds is a test
88
+ * nobody runs. */
89
+ private readonly now;
90
+ private readonly subscriptions;
91
+ /** The last init unit seen, handed to every later subscriber. */
92
+ private retainedInit;
93
+ private ended;
94
+ /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
95
+ private readonly ring;
96
+ private ringBytes;
97
+ constructor(logger?: IScopedLogger | undefined, prebuffer?: Fmp4PrebufferOptions | undefined,
98
+ /** Injectable clock — the ring's window is the one thing here that is
99
+ * wall-clock dependent, and a test that sleeps 8 real seconds is a test
100
+ * nobody runs. */
101
+ now?: () => number);
102
+ get subscriberCount(): number;
103
+ /** True once {@link end} has been called — no further units are accepted. */
104
+ get isEnded(): boolean;
105
+ /** What the prebuffer ring holds right now. All zeroes when disabled. */
106
+ prebufferStats(): Fmp4PrebufferStats;
107
+ subscribe(input: Fmp4SubscribeInput): Fmp4Subscription;
108
+ /**
109
+ * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
110
+ * emits exactly one per child, and a second means the child was respawned, in
111
+ * which case the old one describes a stream that no longer exists.
112
+ */
113
+ publish(unit: Fmp4Unit): void;
114
+ /**
115
+ * The producer stopped. Every subscriber drains what it holds; its final
116
+ * packet carries `isLast`, and its generator then completes.
117
+ */
118
+ end(reason?: string): void;
119
+ listSubscribers(): readonly Fmp4SubscriberInfo[];
120
+ /** End and forget everything. Idempotent. */
121
+ dispose(): void;
122
+ /**
123
+ * Add one fragment to the ring and evict from the front until BOTH bounds
124
+ * hold. Eviction is oldest-first, which is the one place in this file where
125
+ * dropping is correct: the ring is context, not stream — nobody is mid-decode
126
+ * on it, and a subscriber only ever receives a contiguous tail of it.
127
+ */
128
+ private retain;
129
+ /**
130
+ * The ring as a subscriber should receive it — window applied AT SUBSCRIBE
131
+ * time, not only at publish time. A camera that went quiet keeps its last
132
+ * fragment in the ring indefinitely (see the never-evict-the-newest rule),
133
+ * and replaying a 40-second-old fragment as "prebuffer" would put stale video
134
+ * at the head of a clip iOS presents as the moment of the event.
135
+ */
136
+ private trimmedRing;
137
+ private enqueue;
138
+ private closeSubscription;
139
+ private wake;
140
+ private facade;
141
+ private iterate;
142
+ }
@@ -21,7 +21,7 @@
21
21
  * `-i`; everything that configures an OUTPUT comes after. `__tests__/
22
22
  * ffmpeg-invocation.spec.ts` asserts INDEXES, never membership.
23
23
  */
24
- import type { EncodeProfile, AudioEncode } from '../encode-profile.js';
24
+ import type { AudioEncode, EncodeProfile } from '../encode-profile.js';
25
25
  /** Everything that configures the ffmpeg INPUT. Emitted strictly before `-i`. */
26
26
  export interface FfmpegInputPlan {
27
27
  /** An RTSP url, a `pipe:0` stdin feed, or a file path. */
@@ -34,6 +34,25 @@ export interface FfmpegInputPlan {
34
34
  readonly rtspTransport?: 'tcp' | 'udp' | null;
35
35
  /** `-fflags` values, e.g. `['+discardcorrupt']`, `['+genpts']`. */
36
36
  readonly fflags?: readonly string[];
37
+ /**
38
+ * `-analyzeduration` (microseconds) and `-probesize` (bytes): how long ffmpeg
39
+ * inspects the input before it emits ANYTHING.
40
+ *
41
+ * Unset means ffmpeg's defaults — 5 s and 5 MB — and on a live egress pull
42
+ * that is a five-second stall before the first packet, paid by whoever is
43
+ * staring at the player. Measured on the live hub: a HomeKit session on a
44
+ * 9 fps slot took 9 s from `stream started` to the first packet out of
45
+ * ffmpeg and 12.2 s to the first key frame. The SLOWER the slot, the longer
46
+ * the wait, because the default budget is wall-clock over an input that
47
+ * delivers few frames per second — which is why the 25 fps slot looked fine
48
+ * and the 9 fps one looked broken.
49
+ *
50
+ * Safe to shrink on RTSP specifically: the SDP already declares the codec,
51
+ * so there is nothing for the probe to discover. Leave unset for inputs that
52
+ * genuinely need discovery (a file of unknown provenance, a raw pipe).
53
+ */
54
+ readonly analyzeDurationUs?: number;
55
+ readonly probeSizeBytes?: number;
37
56
  /** Caller-supplied input options (an `EncodeProfile.inputArgs`, probe flags). */
38
57
  readonly extraArgs?: readonly string[];
39
58
  }
@@ -82,6 +101,21 @@ export interface FfmpegVideoEncodePlan {
82
101
  readonly pixelFormat?: string;
83
102
  readonly fps?: number;
84
103
  readonly gopFrames?: number;
104
+ /**
105
+ * `-force_key_frames expr:gte(t,n_forced*<sec>)` — an IDR every `<sec>`
106
+ * seconds of OUTPUT time, regardless of what the source does.
107
+ *
108
+ * Only a fragmented-MP4 consumer needs this today, and it needs it for a
109
+ * structural reason rather than a quality one: an fMP4 fragment written with
110
+ * `frag_keyframe` is cut at a key frame, so the key-frame cadence IS the
111
+ * fragment cadence. `-g` bounds the GOP the encoder would choose anyway;
112
+ * this pins the boundary to a wall-clock grid the muxer can meet.
113
+ *
114
+ * Structurally absent from {@link FfmpegVideoCopyPlan}: a copy has no
115
+ * encoder to instruct, and ffmpeg accepts the flag there, warns, and ignores
116
+ * it — the silent-no-op shape this file exists to prevent.
117
+ */
118
+ readonly forceKeyFramesSeconds?: number;
85
119
  readonly bf?: number;
86
120
  readonly bitrateKbps?: number;
87
121
  readonly rateControl?: FfmpegRateControl;
@@ -136,13 +170,48 @@ export interface FfmpegRtpOutput {
136
170
  /** Path ffmpeg writes this output's SDP to (`-sdp_file`). */
137
171
  readonly sdpFile?: string;
138
172
  }
173
+ /**
174
+ * A fragmented-MP4 bytestream on stdout: `ftyp`+`moov` once, then a
175
+ * `moof`+`mdat` per fragment. The shape HomeKit Secure Video's recording
176
+ * delegate consumes, and the shape a CamStack clip source can consume from the
177
+ * same child (D67's primitive, one fragmenter, two consumers).
178
+ *
179
+ * ## Why `frag_keyframe` + `min_frag_duration`, and NOT `frag_duration`
180
+ *
181
+ * HKSV requires every media fragment to START on a sync sample. `frag_keyframe`
182
+ * cuts a fragment at each key frame, which satisfies that by construction.
183
+ * `-frag_duration` cuts at a wall-clock boundary *whether or not* a key frame
184
+ * has arrived, so a source whose IDR is late produces a fragment that opens on
185
+ * a delta frame — accepted by the muxer, rejected by the consumer, and
186
+ * invisible in an argv test. `-min_frag_duration` is the safe half of the pair:
187
+ * it only ever DELAYS a cut, so the boundary stays a key frame and a source
188
+ * with a 1 s GOP does not emit four fragments where one was negotiated.
189
+ *
190
+ * The consequence, stated because it decides what the caller must do: the
191
+ * fragment length is the key-frame cadence, so a caller that wants ~N-second
192
+ * fragments must also pin {@link FfmpegVideoEncodePlan.forceKeyFramesSeconds}.
193
+ * On a `copy` plan it cannot, and the fragment length is then whatever the
194
+ * SOURCE's GOP makes it — `fragmentMs` becomes a floor, not a target.
195
+ */
196
+ export interface FfmpegFmp4Sink {
197
+ readonly kind: 'stdout';
198
+ readonly container: 'mp4';
199
+ /**
200
+ * The negotiated fragment length in milliseconds. Emitted as
201
+ * `-min_frag_duration` (microseconds) at {@link FMP4_MIN_FRAG_MARGIN} of this
202
+ * value — read that constant before changing anything here, the margin is
203
+ * what stops a camera whose GOP equals `fragmentMs` emitting fragments at
204
+ * twice the requested length.
205
+ */
206
+ readonly fragmentMs: number;
207
+ }
139
208
  export type FfmpegSink = {
140
209
  readonly kind: 'rtsp-listen';
141
210
  readonly url: string;
142
211
  } | {
143
212
  readonly kind: 'stdout';
144
213
  readonly container: 'h264' | 'hevc' | 'mpegts';
145
- }
214
+ } | FfmpegFmp4Sink
146
215
  /**
147
216
  * Two independent mapped RTP outputs. Generic ffmpeg RTP vocabulary — the
148
217
  * SRTP encryption, the port allocation and the payload-type numbers stay
@@ -235,6 +304,37 @@ export interface EncodeProfileInvocationInput {
235
304
  readonly logLevel?: 'error' | 'warning' | 'info';
236
305
  readonly threadCount?: number;
237
306
  readonly audioSidecar?: FfmpegAudioSidecar | null;
307
+ /**
308
+ * How the encoder is bounded. `EncodeProfile` carries a bitrate but not the
309
+ * WINDOW it is enforced over, and the window is the difference between a
310
+ * HomeKit stream that fits its per-second budget and one that overshoots on
311
+ * every key frame. Absent ⇒ the relaxed two-second default a browser wants.
312
+ *
313
+ * The three overlays below are on the INVOCATION rather than on
314
+ * `EncodeProfile` on purpose: they are consumer-protocol facts
315
+ * (`acquireEgressTranscode` exposes each as its own named request field),
316
+ * while `EncodeProfile` is the operator-facing shape a derived-stream editor
317
+ * writes. Folding them into the profile would put ffmpeg detail in front of
318
+ * an operator who has no way to judge it.
319
+ */
320
+ readonly rateControl?: FfmpegRateControl;
321
+ /** `-bsf:v` — `dump_extra` for a consumer that negotiates its own SDP. */
322
+ readonly bitstreamFilter?: string;
323
+ /** `-pix_fmt`. */
324
+ readonly pixelFormat?: string;
325
+ /**
326
+ * `-force_key_frames` on a fixed grid — see
327
+ * {@link FfmpegVideoEncodePlan.forceKeyFramesSeconds}. An overlay like the
328
+ * three above rather than an `EncodeProfile` field: it is a CONSUMER-protocol
329
+ * fact (an fMP4 fragment length the consumer negotiated), not something an
330
+ * operator authoring a derived stream can judge.
331
+ *
332
+ * Applies only to the encode branch. On a smart COPY there is no encoder to
333
+ * instruct, and the caller — not this adapter — has to decide whether a
334
+ * source-dictated GOP is acceptable, because only the caller knows what it
335
+ * promised its consumer.
336
+ */
337
+ readonly forceKeyFramesSeconds?: number;
238
338
  }
239
339
  /**
240
340
  * Adapt an `EncodeProfile` (the operator/consumer-facing shape) into an