@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.
- package/dist/addon.js +8 -2
- package/dist/addon.mjs +8 -2
- package/dist/cap-call-context.d.ts +26 -0
- package/dist/capabilities/index.d.ts +8 -5
- package/dist/capabilities/osd-manager.cap.d.ts +900 -0
- package/dist/capabilities/pipeline-analytics.cap.d.ts +171 -4
- package/dist/capabilities/pipeline-orchestrator.cap.d.ts +36 -0
- package/dist/capabilities/privacy-mask.cap.d.ts +69 -9
- package/dist/capabilities/recording.cap.d.ts +27 -0
- package/dist/capabilities/schemas/streaming-shared.d.ts +2 -0
- package/dist/capabilities/server-management.cap.d.ts +3 -3
- package/dist/capabilities/snapshot.cap.d.ts +1 -1
- package/dist/capabilities/stream-broker.cap.d.ts +36 -0
- package/dist/capabilities/stream-params.cap.d.ts +8 -4
- package/dist/device/device-profile.d.ts +12 -4
- package/dist/device/system-mirror.d.ts +11 -0
- package/dist/ffmpeg/encode-defaults.d.ts +18 -0
- package/dist/ffmpeg/fmp4-box-splitter.d.ts +113 -0
- package/dist/ffmpeg/fmp4-fragment-child.d.ts +85 -0
- package/dist/ffmpeg/fmp4-fragment-plane.d.ts +142 -0
- package/dist/ffmpeg/invocation.d.ts +102 -2
- package/dist/ffmpeg/sharing-key.d.ts +54 -2
- package/dist/fmp4-box-splitter-B53u9-Nu.mjs +615 -0
- package/dist/fmp4-box-splitter-BkWH7O3L.js +686 -0
- package/dist/generated/addon-api.d.ts +117 -0
- package/dist/generated/cap-input-defaults.d.ts +1 -1
- package/dist/generated/capability-router-map.d.ts +5 -2
- package/dist/generated/device-proxy.d.ts +4 -2
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/system-proxy.d.ts +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1264 -394
- package/dist/index.mjs +1217 -385
- package/dist/interfaces/camera-switches.d.ts +211 -5
- package/dist/interfaces/inference-engine.d.ts +24 -3
- package/dist/interfaces/stream-broker.d.ts +18 -0
- package/dist/node.d.ts +4 -0
- package/dist/node.js +509 -3
- package/dist/node.mjs +507 -3
- package/dist/notification/schedule.d.ts +20 -0
- package/dist/pipeline/native-lease.d.ts +150 -0
- package/dist/{sleep-BmNKsY7v.mjs → sleep-cC4Fuup8.mjs} +59 -3
- package/dist/{sleep-Cvi1JxZp.js → sleep-eiC10_cX.js} +64 -2
- package/dist/types/pipeline-step.d.ts +1 -1
- package/dist/utils/addon-id.d.ts +30 -0
- package/package.json +1 -1
- package/dist/canonical-hash-7nfBbEqR.mjs +0 -35
- 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
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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 {
|
|
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
|