@camstack/types 1.2.43 → 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/capabilities/index.d.ts +5 -2
- 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 +10 -0
- package/dist/capabilities/schemas/streaming-shared.d.ts +2 -0
- package/dist/capabilities/server-management.cap.d.ts +3 -3
- package/dist/capabilities/stream-broker.cap.d.ts +32 -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 +4 -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 +85 -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 +3 -1
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/system-proxy.d.ts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +581 -404
- package/dist/index.mjs +558 -394
- package/dist/interfaces/camera-switches.d.ts +48 -0
- 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/{sleep-DtstvzWm.mjs → sleep-cC4Fuup8.mjs} +26 -1
- package/dist/{sleep-Bx9IIoT0.js → sleep-eiC10_cX.js} +26 -1
- package/dist/types/pipeline-step.d.ts +1 -1
- package/package.json +1 -1
- package/dist/canonical-hash-7nfBbEqR.mjs +0 -35
- package/dist/canonical-hash-BcZHRHIx.js +0 -40
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
19
19
|
* | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
|
|
20
20
|
* | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
|
|
21
|
+
* | `broker-audio` | `streamBroker.setDeviceAudioMute` → `DeviceOverride.audioMuted` | `StreamBroker.setAudioMuted` drops the audio plane at the source: no `type:'audio'` packet leaves `fanOutEncoded`, no RTP reaches the restreamer, and the restreamer serves the video-only SDP |
|
|
22
|
+
*
|
|
23
|
+
* ## `device-audio` and `broker-audio` are two functions, not two knobs
|
|
24
|
+
*
|
|
25
|
+
* They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
|
|
26
|
+
* `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
|
|
27
|
+
* genuinely stops, it survives CamStack entirely, and it costs a multi-second
|
|
28
|
+
* encoder restart on every flip. `broker-audio` writes THIS server, so it is
|
|
29
|
+
* instant, vendor-independent and reversible without touching the camera, and
|
|
30
|
+
* a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
|
|
31
|
+
* D62 forbids a second switch that *disagrees* with the first; these two
|
|
32
|
+
* cannot disagree, because neither reads the other's store — the camera holds
|
|
33
|
+
* one, the broker holds the other, and each reports its own fact.
|
|
21
34
|
*
|
|
22
35
|
* ## The two switches whose authority is not on this server
|
|
23
36
|
*
|
|
@@ -83,6 +96,7 @@ export declare const CameraSwitchIdSchema: z.ZodEnum<{
|
|
|
83
96
|
notifications: "notifications";
|
|
84
97
|
"object-detection": "object-detection";
|
|
85
98
|
"device-audio": "device-audio";
|
|
99
|
+
"broker-audio": "broker-audio";
|
|
86
100
|
}>;
|
|
87
101
|
export type CameraSwitchId = z.infer<typeof CameraSwitchIdSchema>;
|
|
88
102
|
/**
|
|
@@ -96,6 +110,10 @@ export type CameraSwitchId = z.infer<typeof CameraSwitchIdSchema>;
|
|
|
96
110
|
* `object-detection` despite feeding it: a mask blanks REGIONS, so its blast
|
|
97
111
|
* radius is partial, and the "broadest first" rule does not rank a partial
|
|
98
112
|
* control above a whole-function one.
|
|
113
|
+
*
|
|
114
|
+
* `broker-audio` sits directly BELOW `device-audio` by the same source-first
|
|
115
|
+
* rule: the camera's microphone feeds the broker, so silencing the camera
|
|
116
|
+
* leaves the broker's mute with nothing to suppress; the reverse is not true.
|
|
99
117
|
*/
|
|
100
118
|
export declare const CAMERA_SWITCH_ORDER: readonly CameraSwitchId[];
|
|
101
119
|
/**
|
|
@@ -119,6 +137,8 @@ export declare const CameraSwitchAuthoritySchema: z.ZodDiscriminatedUnion<[z.Zod
|
|
|
119
137
|
}, z.core.$strip>, z.ZodObject<{
|
|
120
138
|
kind: z.ZodLiteral<"camera-mask">;
|
|
121
139
|
capName: z.ZodString;
|
|
140
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
141
|
+
kind: z.ZodLiteral<"broker-audio-mute">;
|
|
122
142
|
}, z.core.$strip>], "kind">;
|
|
123
143
|
export type CameraSwitchAuthority = z.infer<typeof CameraSwitchAuthoritySchema>;
|
|
124
144
|
/**
|
|
@@ -149,6 +169,7 @@ export declare const CameraSwitchSchema: z.ZodObject<{
|
|
|
149
169
|
notifications: "notifications";
|
|
150
170
|
"object-detection": "object-detection";
|
|
151
171
|
"device-audio": "device-audio";
|
|
172
|
+
"broker-audio": "broker-audio";
|
|
152
173
|
}>;
|
|
153
174
|
label: z.ZodString;
|
|
154
175
|
costWhenOff: z.ZodString;
|
|
@@ -174,6 +195,8 @@ export declare const CameraSwitchSchema: z.ZodObject<{
|
|
|
174
195
|
}, z.core.$strip>, z.ZodObject<{
|
|
175
196
|
kind: z.ZodLiteral<"camera-mask">;
|
|
176
197
|
capName: z.ZodString;
|
|
198
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
199
|
+
kind: z.ZodLiteral<"broker-audio-mute">;
|
|
177
200
|
}, z.core.$strip>], "kind">;
|
|
178
201
|
}, z.core.$strip>;
|
|
179
202
|
export type CameraSwitch = z.infer<typeof CameraSwitchSchema>;
|
|
@@ -189,6 +212,7 @@ export declare const CameraSwitchGroupSchema: z.ZodObject<{
|
|
|
189
212
|
notifications: "notifications";
|
|
190
213
|
"object-detection": "object-detection";
|
|
191
214
|
"device-audio": "device-audio";
|
|
215
|
+
"broker-audio": "broker-audio";
|
|
192
216
|
}>;
|
|
193
217
|
label: z.ZodString;
|
|
194
218
|
costWhenOff: z.ZodString;
|
|
@@ -214,6 +238,8 @@ export declare const CameraSwitchGroupSchema: z.ZodObject<{
|
|
|
214
238
|
}, z.core.$strip>, z.ZodObject<{
|
|
215
239
|
kind: z.ZodLiteral<"camera-mask">;
|
|
216
240
|
capName: z.ZodString;
|
|
241
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
242
|
+
kind: z.ZodLiteral<"broker-audio-mute">;
|
|
217
243
|
}, z.core.$strip>], "kind">;
|
|
218
244
|
}, z.core.$strip>>>;
|
|
219
245
|
fetchedAt: z.ZodNumber;
|
|
@@ -305,6 +331,28 @@ export interface CameraSwitchDerivationInput {
|
|
|
305
331
|
* `null` = the cap is bound to this camera but did not answer.
|
|
306
332
|
*/
|
|
307
333
|
readonly privacyMask: PrivacyMaskSwitchState | null;
|
|
334
|
+
/**
|
|
335
|
+
* The BROKER's audio-plane policy for this camera, read from the
|
|
336
|
+
* stream-broker on the device's source-owner node. `null` = the broker did
|
|
337
|
+
* not answer, which renders no control rather than a control defaulted to
|
|
338
|
+
* "audio flows".
|
|
339
|
+
*/
|
|
340
|
+
readonly brokerAudio: BrokerAudioSwitchState | null;
|
|
341
|
+
}
|
|
342
|
+
/** What the stream-broker reported about its audio plane for one camera. */
|
|
343
|
+
export interface BrokerAudioSwitchState {
|
|
344
|
+
/**
|
|
345
|
+
* `DeviceOverride.audioMuted` — the broker's own persisted policy. `true`
|
|
346
|
+
* means the broker drops this camera's audio on every plane it serves.
|
|
347
|
+
*
|
|
348
|
+
* There is no `supported` companion, deliberately. Muting a camera that is
|
|
349
|
+
* currently silent is not a dead control: the policy governs what happens
|
|
350
|
+
* when sound DOES appear (a camera unmuted at the device, a stream that
|
|
351
|
+
* only carries audio at night), so it is meaningful to set in advance.
|
|
352
|
+
* That is the opposite of the privacy mask with zero zones, where flipping
|
|
353
|
+
* the master enable is provably a no-op until something else changes.
|
|
354
|
+
*/
|
|
355
|
+
readonly muted: boolean;
|
|
308
356
|
}
|
|
309
357
|
/** What the camera-probed audio switch reported for one camera. */
|
|
310
358
|
export interface DeviceAudioSwitchState {
|
|
@@ -208,6 +208,24 @@ export interface BrokerStats {
|
|
|
208
208
|
readonly channels: number;
|
|
209
209
|
readonly supported: boolean;
|
|
210
210
|
} | null;
|
|
211
|
+
/**
|
|
212
|
+
* BROKER-SIDE AUDIO MUTE ([D83](../../../../docs/decisions/adr-0083.md)).
|
|
213
|
+
* `true` = this broker is deliberately distributing none of the device's
|
|
214
|
+
* audio, live or recorded. It is what tells a silent camera apart from a
|
|
215
|
+
* broken one on the stream panel: a broker holding an `audio` descriptor
|
|
216
|
+
* with `audioMuted: true` is doing exactly what it was asked.
|
|
217
|
+
*
|
|
218
|
+
* `audioMutedDropped` counts the audio units the mute threw away since the
|
|
219
|
+
* current dial — the proof the mute is on the packet path and not merely
|
|
220
|
+
* persisted.
|
|
221
|
+
*
|
|
222
|
+
* NOTE: this interface is the hand-written twin of `BrokerStatsSchema` in
|
|
223
|
+
* `capabilities/schemas/streaming-shared.ts`. They are not generated from
|
|
224
|
+
* each other; a field added to one and not the other compiles and then
|
|
225
|
+
* silently disappears on the wire.
|
|
226
|
+
*/
|
|
227
|
+
readonly audioMuted?: boolean;
|
|
228
|
+
readonly audioMutedDropped?: number;
|
|
211
229
|
}
|
|
212
230
|
export interface IStreamBroker {
|
|
213
231
|
readonly deviceId: string;
|
package/dist/node.d.ts
CHANGED
|
@@ -8,3 +8,7 @@ export { canonicalDeviceFingerprint, diffExportTargets, resolveExportFingerprint
|
|
|
8
8
|
export type { DeviceExportShape, ExportDelta, ExportTargetEntry, } from './utils/export-reconciler.js';
|
|
9
9
|
export { FfmpegProcess } from './ffmpeg/process.js';
|
|
10
10
|
export type { FfmpegProcessOptions, FfmpegExit, FfmpegExitClass } from './ffmpeg/process.js';
|
|
11
|
+
export { Fmp4FragmentPlane } from './ffmpeg/fmp4-fragment-plane.js';
|
|
12
|
+
export type { Fmp4CloseReason, Fmp4Packet, Fmp4PrebufferOptions, Fmp4PrebufferStats, Fmp4SubscribeInput, Fmp4SubscriberInfo, Fmp4Subscription, } from './ffmpeg/fmp4-fragment-plane.js';
|
|
13
|
+
export { Fmp4FragmentChild } from './ffmpeg/fmp4-fragment-child.js';
|
|
14
|
+
export type { Fmp4FragmentChildArgs, Fmp4FragmentChildDeps, } from './ffmpeg/fmp4-fragment-child.js';
|
package/dist/node.js
CHANGED
|
@@ -21,7 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
21
21
|
enumerable: true
|
|
22
22
|
}) : target, mod));
|
|
23
23
|
//#endregion
|
|
24
|
-
const
|
|
24
|
+
const require_fmp4_box_splitter = require("./fmp4-box-splitter-BkWH7O3L.js");
|
|
25
25
|
const require_err_msg = require("./err-msg-COpsHMw2.js");
|
|
26
26
|
let node_crypto = require("node:crypto");
|
|
27
27
|
let node_fs = require("node:fs");
|
|
@@ -609,7 +609,7 @@ var FilesystemStorageProvider = class {
|
|
|
609
609
|
*/
|
|
610
610
|
function canonicalDeviceFingerprint(shape) {
|
|
611
611
|
const features = [...new Set(shape.features)].toSorted();
|
|
612
|
-
return
|
|
612
|
+
return require_fmp4_box_splitter.canonicalHash({
|
|
613
613
|
deviceType: shape.deviceType,
|
|
614
614
|
features
|
|
615
615
|
});
|
|
@@ -922,12 +922,518 @@ var FfmpegProcess = class {
|
|
|
922
922
|
}
|
|
923
923
|
};
|
|
924
924
|
//#endregion
|
|
925
|
+
//#region src/ffmpeg/fmp4-fragment-plane.ts
|
|
926
|
+
/**
|
|
927
|
+
* Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
|
|
928
|
+
* {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
|
|
929
|
+
*
|
|
930
|
+
* ## Why a plane and not a callback
|
|
931
|
+
*
|
|
932
|
+
* The operator's requirement for HKSV was explicit: the live fMP4 source built
|
|
933
|
+
* for it must be **dual-use**, so a HomeKit-triggered recording also lands in
|
|
934
|
+
* CamStack as an additional videoclip source alongside the recorder and the NC
|
|
935
|
+
* clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
|
|
936
|
+
* (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
|
|
937
|
+
* a second ffmpeg child of the same camera. So this is the same shape the
|
|
938
|
+
* broker's other multi-consumer surfaces already have
|
|
939
|
+
* (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
|
|
940
|
+
* one producer.
|
|
941
|
+
*
|
|
942
|
+
* **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
|
|
943
|
+
* clip source; both are named here so the seam is not re-invented, and neither
|
|
944
|
+
* is built.
|
|
945
|
+
*
|
|
946
|
+
* ## The init segment is RETAINED
|
|
947
|
+
*
|
|
948
|
+
* A subscriber that attaches mid-stream — the clip consumer joining an already
|
|
949
|
+
* running HKSV session, which is the whole dual-use case — receives the
|
|
950
|
+
* retained `ftyp`+`moov` as its first packet and then live fragments. Without
|
|
951
|
+
* retention its fragments are undecodable and the failure looks like a codec
|
|
952
|
+
* problem.
|
|
953
|
+
*
|
|
954
|
+
* ## A slow subscriber is CLOSED, never silently gapped
|
|
955
|
+
*
|
|
956
|
+
* `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
|
|
957
|
+
* click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
|
|
958
|
+
* `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
|
|
959
|
+
* shows a clip that fails to play with nothing anywhere saying why. So a
|
|
960
|
+
* subscription whose queue overflows is ENDED with a reason, loudly, and the
|
|
961
|
+
* other subscriptions are untouched.
|
|
962
|
+
*
|
|
963
|
+
* ## The PREBUFFER (phase 3)
|
|
964
|
+
*
|
|
965
|
+
* HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
|
|
966
|
+
* is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
|
|
967
|
+
* motion edge has none. So the plane optionally retains the last few fragments
|
|
968
|
+
* and replays them to a subscriber that asks for them.
|
|
969
|
+
*
|
|
970
|
+
* Three things this ring gets right, each of which is a measured fact rather
|
|
971
|
+
* than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
|
|
972
|
+
*
|
|
973
|
+
* - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
|
|
974
|
+
* fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
|
|
975
|
+
* window. A time-only bound is a per-camera RAM figure nobody can predict.
|
|
976
|
+
* - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
|
|
977
|
+
* splitter deliberately never computes a fragment's duration (a second
|
|
978
|
+
* opinion about a fact the muxer owns), and a prebuffer cares about how long
|
|
979
|
+
* ago the bytes turned up, which is exactly what arrival time answers.
|
|
980
|
+
* - **A replay is not backlog.** A subscriber taking N retained fragments gets
|
|
981
|
+
* its queue capacity raised by N for them, because closing a subscriber as a
|
|
982
|
+
* slow consumer for the prebuffer it explicitly asked for would be the
|
|
983
|
+
* stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
|
|
984
|
+
* ring of 4, the guaranteed one.
|
|
985
|
+
*
|
|
986
|
+
* ## `isLast`
|
|
987
|
+
*
|
|
988
|
+
* hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
|
|
989
|
+
* `isLast` — a generator that finishes without it produces the twelve-second
|
|
990
|
+
* timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
|
|
991
|
+
* plane therefore computes it at DELIVERY time: a packet is last when the plane
|
|
992
|
+
* has ended and nothing remains queued behind it. A subscription that ends
|
|
993
|
+
* having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
|
|
994
|
+
* the future delegate must not open an HDS stream it cannot feed.
|
|
995
|
+
*/
|
|
996
|
+
var DEFAULT_QUEUE_CAPACITY = 4;
|
|
997
|
+
var Fmp4FragmentPlane = class {
|
|
998
|
+
logger;
|
|
999
|
+
prebuffer;
|
|
1000
|
+
now;
|
|
1001
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
1002
|
+
/** The last init unit seen, handed to every later subscriber. */
|
|
1003
|
+
retainedInit = null;
|
|
1004
|
+
ended = false;
|
|
1005
|
+
/** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
|
|
1006
|
+
ring = [];
|
|
1007
|
+
ringBytes = 0;
|
|
1008
|
+
constructor(logger, prebuffer, now = Date.now) {
|
|
1009
|
+
this.logger = logger;
|
|
1010
|
+
this.prebuffer = prebuffer;
|
|
1011
|
+
this.now = now;
|
|
1012
|
+
}
|
|
1013
|
+
get subscriberCount() {
|
|
1014
|
+
return this.subscriptions.size;
|
|
1015
|
+
}
|
|
1016
|
+
/** True once {@link end} has been called — no further units are accepted. */
|
|
1017
|
+
get isEnded() {
|
|
1018
|
+
return this.ended;
|
|
1019
|
+
}
|
|
1020
|
+
/** What the prebuffer ring holds right now. All zeroes when disabled. */
|
|
1021
|
+
prebufferStats() {
|
|
1022
|
+
const oldest = this.ring[0];
|
|
1023
|
+
return {
|
|
1024
|
+
fragments: this.ring.length,
|
|
1025
|
+
bytes: this.ringBytes,
|
|
1026
|
+
spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
subscribe(input) {
|
|
1030
|
+
const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
|
|
1031
|
+
const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
|
|
1032
|
+
const sub = {
|
|
1033
|
+
id: `fmp4-${(0, node_crypto.randomUUID)()}`,
|
|
1034
|
+
tag: input.tag,
|
|
1035
|
+
subscribedAt: this.now(),
|
|
1036
|
+
capacity: requested + replay.length,
|
|
1037
|
+
queue: [],
|
|
1038
|
+
delivered: 0,
|
|
1039
|
+
closedReason: null,
|
|
1040
|
+
wake: null,
|
|
1041
|
+
iterating: false
|
|
1042
|
+
};
|
|
1043
|
+
this.subscriptions.set(sub.id, sub);
|
|
1044
|
+
if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
|
|
1045
|
+
for (const retained of replay) this.enqueue(sub, retained.unit);
|
|
1046
|
+
if (this.ended) this.closeSubscription(sub, "ended");
|
|
1047
|
+
this.logger?.info("fmp4 plane: subscribed", { meta: {
|
|
1048
|
+
subscriptionId: sub.id,
|
|
1049
|
+
tag: sub.tag,
|
|
1050
|
+
hasRetainedInit: this.retainedInit !== null,
|
|
1051
|
+
prebufferFragments: replay.length,
|
|
1052
|
+
prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
|
|
1053
|
+
} });
|
|
1054
|
+
return this.facade(sub);
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
|
|
1058
|
+
* emits exactly one per child, and a second means the child was respawned, in
|
|
1059
|
+
* which case the old one describes a stream that no longer exists.
|
|
1060
|
+
*/
|
|
1061
|
+
publish(unit) {
|
|
1062
|
+
if (this.ended) return;
|
|
1063
|
+
if (unit.kind === "init") {
|
|
1064
|
+
this.retainedInit = unit;
|
|
1065
|
+
this.ring.length = 0;
|
|
1066
|
+
this.ringBytes = 0;
|
|
1067
|
+
} else this.retain(unit);
|
|
1068
|
+
for (const sub of this.subscriptions.values()) {
|
|
1069
|
+
if (sub.closedReason !== null) continue;
|
|
1070
|
+
this.enqueue(sub, unit);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* The producer stopped. Every subscriber drains what it holds; its final
|
|
1075
|
+
* packet carries `isLast`, and its generator then completes.
|
|
1076
|
+
*/
|
|
1077
|
+
end(reason = "producer ended") {
|
|
1078
|
+
if (this.ended) return;
|
|
1079
|
+
this.ended = true;
|
|
1080
|
+
this.logger?.info("fmp4 plane: ended", { meta: {
|
|
1081
|
+
reason,
|
|
1082
|
+
subscribers: this.subscriptions.size
|
|
1083
|
+
} });
|
|
1084
|
+
for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
|
|
1085
|
+
}
|
|
1086
|
+
listSubscribers() {
|
|
1087
|
+
return [...this.subscriptions.values()].map((s) => ({
|
|
1088
|
+
tag: s.tag,
|
|
1089
|
+
subscribedAt: s.subscribedAt,
|
|
1090
|
+
delivered: s.delivered,
|
|
1091
|
+
closedReason: s.closedReason
|
|
1092
|
+
}));
|
|
1093
|
+
}
|
|
1094
|
+
/** End and forget everything. Idempotent. */
|
|
1095
|
+
dispose() {
|
|
1096
|
+
this.end("disposed");
|
|
1097
|
+
this.subscriptions.clear();
|
|
1098
|
+
this.retainedInit = null;
|
|
1099
|
+
this.ring.length = 0;
|
|
1100
|
+
this.ringBytes = 0;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Add one fragment to the ring and evict from the front until BOTH bounds
|
|
1104
|
+
* hold. Eviction is oldest-first, which is the one place in this file where
|
|
1105
|
+
* dropping is correct: the ring is context, not stream — nobody is mid-decode
|
|
1106
|
+
* on it, and a subscriber only ever receives a contiguous tail of it.
|
|
1107
|
+
*/
|
|
1108
|
+
retain(unit) {
|
|
1109
|
+
const prebuffer = this.prebuffer;
|
|
1110
|
+
if (prebuffer === void 0) return;
|
|
1111
|
+
const arrivedAt = this.now();
|
|
1112
|
+
this.ring.push({
|
|
1113
|
+
unit,
|
|
1114
|
+
arrivedAt
|
|
1115
|
+
});
|
|
1116
|
+
this.ringBytes += unit.data.length;
|
|
1117
|
+
const cutoff = arrivedAt - prebuffer.windowMs;
|
|
1118
|
+
while (this.ring.length > 0) {
|
|
1119
|
+
const oldest = this.ring[0];
|
|
1120
|
+
if (oldest === void 0) break;
|
|
1121
|
+
const tooOld = oldest.arrivedAt < cutoff;
|
|
1122
|
+
const tooBig = this.ringBytes > prebuffer.maxBytes;
|
|
1123
|
+
if (!tooOld && !tooBig || this.ring.length === 1) break;
|
|
1124
|
+
this.ring.shift();
|
|
1125
|
+
this.ringBytes -= oldest.unit.data.length;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* The ring as a subscriber should receive it — window applied AT SUBSCRIBE
|
|
1130
|
+
* time, not only at publish time. A camera that went quiet keeps its last
|
|
1131
|
+
* fragment in the ring indefinitely (see the never-evict-the-newest rule),
|
|
1132
|
+
* and replaying a 40-second-old fragment as "prebuffer" would put stale video
|
|
1133
|
+
* at the head of a clip iOS presents as the moment of the event.
|
|
1134
|
+
*/
|
|
1135
|
+
trimmedRing() {
|
|
1136
|
+
const prebuffer = this.prebuffer;
|
|
1137
|
+
if (prebuffer === void 0) return [];
|
|
1138
|
+
const cutoff = this.now() - prebuffer.windowMs;
|
|
1139
|
+
return this.ring.filter((r) => r.arrivedAt >= cutoff);
|
|
1140
|
+
}
|
|
1141
|
+
enqueue(sub, unit) {
|
|
1142
|
+
if (sub.queue.length >= sub.capacity) {
|
|
1143
|
+
this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
|
|
1144
|
+
subscriptionId: sub.id,
|
|
1145
|
+
tag: sub.tag,
|
|
1146
|
+
capacity: sub.capacity,
|
|
1147
|
+
delivered: sub.delivered
|
|
1148
|
+
} });
|
|
1149
|
+
this.closeSubscription(sub, "slow-consumer");
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
sub.queue.push({
|
|
1153
|
+
kind: unit.kind,
|
|
1154
|
+
data: unit.data,
|
|
1155
|
+
sequence: unit.sequence,
|
|
1156
|
+
isLast: false
|
|
1157
|
+
});
|
|
1158
|
+
this.wake(sub);
|
|
1159
|
+
}
|
|
1160
|
+
closeSubscription(sub, reason) {
|
|
1161
|
+
if (sub.closedReason !== null) return;
|
|
1162
|
+
sub.closedReason = reason;
|
|
1163
|
+
if (reason === "slow-consumer") sub.queue.length = 0;
|
|
1164
|
+
this.wake(sub);
|
|
1165
|
+
}
|
|
1166
|
+
wake(sub) {
|
|
1167
|
+
const resume = sub.wake;
|
|
1168
|
+
sub.wake = null;
|
|
1169
|
+
resume?.();
|
|
1170
|
+
}
|
|
1171
|
+
facade(sub) {
|
|
1172
|
+
const plane = this;
|
|
1173
|
+
return {
|
|
1174
|
+
id: sub.id,
|
|
1175
|
+
tag: sub.tag,
|
|
1176
|
+
get delivered() {
|
|
1177
|
+
return sub.delivered;
|
|
1178
|
+
},
|
|
1179
|
+
get closedReason() {
|
|
1180
|
+
return sub.closedReason;
|
|
1181
|
+
},
|
|
1182
|
+
packets: () => plane.iterate(sub),
|
|
1183
|
+
release: () => {
|
|
1184
|
+
plane.closeSubscription(sub, "released");
|
|
1185
|
+
plane.subscriptions.delete(sub.id);
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
async *iterate(sub) {
|
|
1190
|
+
if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
|
|
1191
|
+
sub.iterating = true;
|
|
1192
|
+
for (;;) {
|
|
1193
|
+
const next = sub.queue.shift();
|
|
1194
|
+
if (next === void 0) {
|
|
1195
|
+
if (sub.closedReason !== null) return;
|
|
1196
|
+
await new Promise((resolve) => {
|
|
1197
|
+
sub.wake = resolve;
|
|
1198
|
+
});
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
|
|
1202
|
+
sub.delivered += 1;
|
|
1203
|
+
yield {
|
|
1204
|
+
...next,
|
|
1205
|
+
isLast
|
|
1206
|
+
};
|
|
1207
|
+
if (isLast) return;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
//#endregion
|
|
1212
|
+
//#region src/ffmpeg/fmp4-fragment-child.ts
|
|
1213
|
+
var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
|
|
1214
|
+
/** Heartbeat cadence — ~2 minutes of 4 s fragments. */
|
|
1215
|
+
var FRAGMENT_LOG_EVERY = 30;
|
|
1216
|
+
var Fmp4FragmentChild = class {
|
|
1217
|
+
deps;
|
|
1218
|
+
args;
|
|
1219
|
+
child = null;
|
|
1220
|
+
splitter = new require_fmp4_box_splitter.Fmp4BoxSplitter();
|
|
1221
|
+
stopped = false;
|
|
1222
|
+
unitsOut = 0;
|
|
1223
|
+
activeHwAccel = null;
|
|
1224
|
+
constructor(deps, args) {
|
|
1225
|
+
this.deps = deps;
|
|
1226
|
+
this.args = args;
|
|
1227
|
+
}
|
|
1228
|
+
/** Spawn, and resolve once the INIT segment has been cut out of stdout. */
|
|
1229
|
+
async start() {
|
|
1230
|
+
const requested = this.args.invocation.decodeHwAccel;
|
|
1231
|
+
this.activeHwAccel = requested;
|
|
1232
|
+
try {
|
|
1233
|
+
await this.spawnAttempt(requested);
|
|
1234
|
+
return;
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
if (this.stopped) throw err;
|
|
1237
|
+
if (requested === null || require_fmp4_box_splitter.isSoftwareDecode(requested)) throw err;
|
|
1238
|
+
this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
|
|
1239
|
+
tags: { deviceId: this.args.deviceId },
|
|
1240
|
+
meta: {
|
|
1241
|
+
sourceId: this.args.sourceId,
|
|
1242
|
+
decodeHwAccel: requested,
|
|
1243
|
+
error: require_err_msg.errMsg(err)
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
this.killChild();
|
|
1247
|
+
this.splitter = new require_fmp4_box_splitter.Fmp4BoxSplitter();
|
|
1248
|
+
this.activeHwAccel = null;
|
|
1249
|
+
await this.spawnAttempt(null);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/** The backend the child ACTUALLY ran with — `null` for software. */
|
|
1253
|
+
activeDecodeHwAccel() {
|
|
1254
|
+
const value = this.activeHwAccel;
|
|
1255
|
+
return value === null || value === "none" || value === "copy" ? null : value;
|
|
1256
|
+
}
|
|
1257
|
+
/** Kill ffmpeg and end the plane. Idempotent. */
|
|
1258
|
+
async stop() {
|
|
1259
|
+
if (this.stopped) return;
|
|
1260
|
+
this.stopped = true;
|
|
1261
|
+
this.killChild();
|
|
1262
|
+
this.args.plane.end("the fragment child stopped");
|
|
1263
|
+
}
|
|
1264
|
+
spawnAttempt(decodeHwAccel) {
|
|
1265
|
+
const args = require_fmp4_box_splitter.buildFfmpegArgs({
|
|
1266
|
+
...this.args.invocation,
|
|
1267
|
+
decodeHwAccel,
|
|
1268
|
+
sink: {
|
|
1269
|
+
kind: "stdout",
|
|
1270
|
+
container: "mp4",
|
|
1271
|
+
fragmentMs: this.args.fragmentMs
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
|
|
1275
|
+
tags: { deviceId: this.args.deviceId },
|
|
1276
|
+
meta: {
|
|
1277
|
+
sourceId: this.args.sourceId,
|
|
1278
|
+
fragmentMs: this.args.fragmentMs,
|
|
1279
|
+
decodeHwAccel: decodeHwAccel ?? "software",
|
|
1280
|
+
argv: args.join(" ")
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
return new Promise((resolve, reject) => {
|
|
1284
|
+
const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
|
|
1285
|
+
"ignore",
|
|
1286
|
+
"pipe",
|
|
1287
|
+
"pipe"
|
|
1288
|
+
] });
|
|
1289
|
+
this.child = child;
|
|
1290
|
+
let settled = false;
|
|
1291
|
+
/**
|
|
1292
|
+
* This attempt FAILED. Set before the kill, because SIGTERM makes the
|
|
1293
|
+
* child exit and that exit must not be reported as a death: the retry —
|
|
1294
|
+
* or the caller's rejection — already owns what happens next. Without it
|
|
1295
|
+
* the timeout path ends the plane the software retry is about to fill,
|
|
1296
|
+
* and the consumer sees a stream that stopped for no reason. A "which
|
|
1297
|
+
* spawn is current" counter does NOT cover this: the retry has not been
|
|
1298
|
+
* spawned when the kill's exit arrives.
|
|
1299
|
+
*/
|
|
1300
|
+
let failed = false;
|
|
1301
|
+
/**
|
|
1302
|
+
* This attempt is still the live producer: it has not failed (a failure
|
|
1303
|
+
* hands ownership to the retry, or to the caller's rejection) and nothing
|
|
1304
|
+
* has stopped the child. Those two cover every way an attempt stops being
|
|
1305
|
+
* current — `start` only respawns after a rejection.
|
|
1306
|
+
*/
|
|
1307
|
+
const isCurrent = () => !this.stopped && !failed;
|
|
1308
|
+
const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
|
|
1309
|
+
const settle = (fail) => {
|
|
1310
|
+
if (settled) return;
|
|
1311
|
+
settled = true;
|
|
1312
|
+
clearTimeout(timer);
|
|
1313
|
+
if (fail) {
|
|
1314
|
+
failed = true;
|
|
1315
|
+
reject(fail);
|
|
1316
|
+
} else resolve();
|
|
1317
|
+
};
|
|
1318
|
+
const timer = setTimeout(() => {
|
|
1319
|
+
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
|
|
1320
|
+
this.killChild();
|
|
1321
|
+
}, timeoutMs);
|
|
1322
|
+
timer.unref?.();
|
|
1323
|
+
child.stdout?.on("data", (chunk) => {
|
|
1324
|
+
for (const unit of this.splitter.push(chunk)) {
|
|
1325
|
+
this.unitsOut += 1;
|
|
1326
|
+
this.args.plane.publish(unit);
|
|
1327
|
+
if (unit.kind === "init") {
|
|
1328
|
+
this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
|
|
1329
|
+
tags: { deviceId: this.args.deviceId },
|
|
1330
|
+
meta: {
|
|
1331
|
+
sourceId: this.args.sourceId,
|
|
1332
|
+
bytes: unit.data.length
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
settle();
|
|
1336
|
+
} else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
|
|
1337
|
+
tags: { deviceId: this.args.deviceId },
|
|
1338
|
+
meta: {
|
|
1339
|
+
sourceId: this.args.sourceId,
|
|
1340
|
+
unitsOut: this.unitsOut,
|
|
1341
|
+
bytes: unit.data.length,
|
|
1342
|
+
subscribers: this.args.plane.subscriberCount
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
const fault = this.splitter.fault;
|
|
1347
|
+
if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
|
|
1348
|
+
});
|
|
1349
|
+
child.stderr?.setEncoding("utf8");
|
|
1350
|
+
child.stderr?.on("data", (line) => {
|
|
1351
|
+
this.deps.logger.debug("fmp4 fragment child ffmpeg", {
|
|
1352
|
+
tags: { deviceId: this.args.deviceId },
|
|
1353
|
+
meta: {
|
|
1354
|
+
sourceId: this.args.sourceId,
|
|
1355
|
+
line: line.trim()
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
});
|
|
1359
|
+
child.once("error", (err) => {
|
|
1360
|
+
if (!settled) {
|
|
1361
|
+
settle(err);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
if (!isCurrent()) return;
|
|
1365
|
+
this.args.plane.end("the fragment child errored");
|
|
1366
|
+
this.deps.onChildExit?.(err);
|
|
1367
|
+
});
|
|
1368
|
+
child.once("exit", (code, signal) => {
|
|
1369
|
+
if (!settled) {
|
|
1370
|
+
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (!isCurrent()) return;
|
|
1374
|
+
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
|
|
1375
|
+
this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
|
|
1376
|
+
tags: { deviceId: this.args.deviceId },
|
|
1377
|
+
meta: {
|
|
1378
|
+
sourceId: this.args.sourceId,
|
|
1379
|
+
code,
|
|
1380
|
+
signal,
|
|
1381
|
+
unitsOut: this.unitsOut
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
this.args.plane.end("the fragment child exited");
|
|
1385
|
+
this.deps.onChildExit?.(error);
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* The byte stream stopped being splittable. Not recoverable — the splitter
|
|
1391
|
+
* cannot resynchronise mid-box — so the child is a corpse and every consumer
|
|
1392
|
+
* has to be told, loudly, with the reason.
|
|
1393
|
+
*/
|
|
1394
|
+
onFault(reason, wasLive, current, settle) {
|
|
1395
|
+
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
|
|
1396
|
+
this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
|
|
1397
|
+
tags: { deviceId: this.args.deviceId },
|
|
1398
|
+
meta: {
|
|
1399
|
+
sourceId: this.args.sourceId,
|
|
1400
|
+
unitsOut: this.unitsOut,
|
|
1401
|
+
interstitial: this.splitter.discardedInterstitialTypes,
|
|
1402
|
+
reason
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
this.killChild();
|
|
1406
|
+
settle(error);
|
|
1407
|
+
if (wasLive && current) {
|
|
1408
|
+
this.args.plane.end("the fragment child produced unsplittable output");
|
|
1409
|
+
this.deps.onChildExit?.(error);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
killChild() {
|
|
1413
|
+
const child = this.child;
|
|
1414
|
+
this.child = null;
|
|
1415
|
+
if (child && !child.killed) try {
|
|
1416
|
+
child.kill("SIGTERM");
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
this.deps.logger.warn("fmp4 fragment child: kill error", {
|
|
1419
|
+
tags: { deviceId: this.args.deviceId },
|
|
1420
|
+
meta: {
|
|
1421
|
+
sourceId: this.args.sourceId,
|
|
1422
|
+
error: require_err_msg.errMsg(err)
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
//#endregion
|
|
925
1429
|
exports.FfmpegProcess = FfmpegProcess;
|
|
926
1430
|
exports.FilesystemStorageProvider = FilesystemStorageProvider;
|
|
1431
|
+
exports.Fmp4FragmentChild = Fmp4FragmentChild;
|
|
1432
|
+
exports.Fmp4FragmentPlane = Fmp4FragmentPlane;
|
|
927
1433
|
exports.PYTHON_VERSION = PYTHON_VERSION;
|
|
928
1434
|
exports.buildBinaryPath = buildBinaryPath;
|
|
929
1435
|
exports.canonicalDeviceFingerprint = canonicalDeviceFingerprint;
|
|
930
|
-
exports.canonicalHash =
|
|
1436
|
+
exports.canonicalHash = require_fmp4_box_splitter.canonicalHash;
|
|
931
1437
|
exports.diffExportTargets = diffExportTargets;
|
|
932
1438
|
exports.downloadBinary = downloadBinary;
|
|
933
1439
|
exports.ensureBinary = ensureBinary;
|