@camstack/addon-pipeline 1.2.136 → 1.2.138

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 (41) hide show
  1. package/dist/audio-analyzer/index.js +7 -7
  2. package/dist/audio-analyzer/index.mjs +4 -4
  3. package/dist/detection-pipeline/index.js +61 -23
  4. package/dist/detection-pipeline/index.mjs +53 -15
  5. package/dist/{dist-BzLiQ8Ue.js → dist-BuIP0499.js} +315 -25
  6. package/dist/{dist-C1dXLLQq.mjs → dist-CmEYRK7G.mjs} +304 -26
  7. package/dist/{event-loop-stall-monitor-DjV4Bow7.js → event-loop-stall-monitor-Bef2jXMX.js} +1 -1
  8. package/dist/{event-loop-stall-monitor-CU-xxX5c.mjs → event-loop-stall-monitor-JGTFb0Uv.mjs} +1 -1
  9. package/dist/{lazy-sharp-DxWmhTqk.js → lazy-sharp-DH2eVehm.js} +3 -3
  10. package/dist/motion-wasm/index.js +3 -3
  11. package/dist/motion-wasm/index.mjs +3 -3
  12. package/dist/node-ChdX9lv7.js +721 -0
  13. package/dist/node-J07GBWT3.mjs +690 -0
  14. package/dist/pipeline-runner/index.js +87 -18
  15. package/dist/pipeline-runner/index.mjs +87 -18
  16. package/dist/{process-memory-CZK3D3kM.js → process-memory-CJoZemF4.js} +2 -2
  17. package/dist/{process-memory-CaEwi5Cd.mjs → process-memory-DAKxUb9d.mjs} +2 -2
  18. package/dist/recorder/index.js +39 -7
  19. package/dist/recorder/index.mjs +36 -4
  20. package/dist/{segment-demux-js-BbpqygHL.js → segment-demux-js-BgXbXZnp.js} +1 -1
  21. package/dist/{segment-demux-js-njRO85gd.mjs → segment-demux-js-Ck9ly4bX.mjs} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +115 -5
  23. package/dist/session-decode/decode-worker-child.mjs +115 -5
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLtMmF1E.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cad8960O.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-xdmO8wYp.mjs +26 -0
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BrTM_tsb.mjs +26 -0
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-CP7Xhd8J.mjs → hostInit-Gd3OcDIa.mjs} +3 -3
  31. package/dist/stream-broker/index.js +245 -535
  32. package/dist/stream-broker/index.mjs +242 -530
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-DvTZBvh2.js → worker-protocol-Bpz62eyB.js} +2 -1
  35. package/dist/{worker-protocol-DxsVx0G7.mjs → worker-protocol-wMS29PG9.mjs} +2 -1
  36. package/package.json +17 -3
  37. package/dist/addon-utils-CKf8kbwh.js +0 -625
  38. package/dist/addon-utils-CgBCF-mE.mjs +0 -583
  39. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.mjs +0 -26
  40. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DKMXG57X.mjs +0 -26
  41. package/dist/{lazy-sharp-BtT5x-qP.mjs → lazy-sharp-DXsqwpph.mjs} +1 -1
@@ -0,0 +1,721 @@
1
+ const require_dist = require("./dist-BuIP0499.js");
2
+ let node_crypto = require("node:crypto");
3
+ let node_fs = require("node:fs");
4
+ node_fs = require_dist.__toESM(node_fs, 1);
5
+ let node_path = require("node:path");
6
+ node_path = require_dist.__toESM(node_path, 1);
7
+ require("node:child_process");
8
+ let node_fs_promises = require("node:fs/promises");
9
+ /**
10
+ * The registry an addon claims its OWN child processes in — the spawn-site
11
+ * half of `load-contribution.cap.ts`.
12
+ *
13
+ * ## Where a claim is made, and where it is released
14
+ *
15
+ * At the spawn site, and nowhere else. It is the only place that knows both
16
+ * halves: the recorder's controller knows it is starting ffmpeg for camera 615
17
+ * profile `high`; the decode coordinator knows the session it is forking a
18
+ * worker for. Nothing has to deduce it afterwards, and no entity needs a
19
+ * global list of which pid belongs to which camera.
20
+ *
21
+ * {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
22
+ * way to release. That is not decoration: the alternative — `release(pid)` —
23
+ * lets a late release from a dead generation delete the live claim of a
24
+ * process that inherited the same pid, which is precisely how a per-camera
25
+ * chart charges one camera for another camera's work. A handle can only ever
26
+ * remove the entry it created.
27
+ *
28
+ * ## What the registry does NOT do
29
+ *
30
+ * It keeps no history, no counters and no timers. It holds live claims and
31
+ * answers questions about them; when the process holding it dies, every claim
32
+ * in it dies with it, which is correct — those children were its children.
33
+ *
34
+ * ## The numbers
35
+ *
36
+ * {@link ChildCostRegistry.contributions} reads each claimed child's
37
+ * CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
38
+ * it is asked. On demand, never on a timer: a new periodic per-node sampler is
39
+ * the defect half of `docs/architecture/load-ledger.md` documents. A counter
40
+ * can be differenced by whoever already keeps a history; a rate cannot be
41
+ * un-averaged.
42
+ *
43
+ * Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
44
+ * are ABSENT — never zero. Zero would say the camera cost nothing.
45
+ */
46
+ /**
47
+ * Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
48
+ * `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
49
+ * `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
50
+ * kernel configuration we ship to. A wrong value would scale every CPU number
51
+ * by a constant — visible immediately, not a silent skew.
52
+ */
53
+ var CLOCK_TICKS_PER_SEC = 100;
54
+ /** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
55
+ var PAGE_BYTES = 4096;
56
+ /**
57
+ * The handle a spawn site gets when nobody is collecting — a test harness, or
58
+ * a runner built before its addon registered a registry. Reporting nothing is
59
+ * the correct behaviour: the process still appears in the node's process
60
+ * snapshot, unclaimed, which is exactly what "nobody reported this" should
61
+ * look like.
62
+ */
63
+ var NO_COST_CLAIM = { release: () => void 0 };
64
+ var nodeProcStatReader = {
65
+ readStat: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/stat`, "utf8"),
66
+ readStatm: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/statm`, "utf8")
67
+ };
68
+ /**
69
+ * Cumulative CPU seconds from a `/proc/<pid>/stat` line.
70
+ *
71
+ * The `comm` field is field 2, wrapped in parentheses, and can itself contain
72
+ * spaces and parentheses — so the only correct parse cuts at the LAST `)` and
73
+ * indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
74
+ * (field 14) is index 11 and `stime` (field 15) is index 12.
75
+ *
76
+ * `null` for a line that does not parse: a process that exited between the
77
+ * claim and the read leaves a truncated or empty file, and that is normal.
78
+ */
79
+ function parseProcCpuSeconds(line) {
80
+ const close = line.lastIndexOf(")");
81
+ if (close < 0) return null;
82
+ const rest = line.slice(close + 1).trim().split(/\s+/);
83
+ const utime = Number(rest[11]);
84
+ const stime = Number(rest[12]);
85
+ if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
86
+ return (utime + stime) / CLOCK_TICKS_PER_SEC;
87
+ }
88
+ /**
89
+ * Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
90
+ * in pages. `null` when the line does not parse.
91
+ */
92
+ function parseProcRssBytes(line) {
93
+ const fields = line.trim().split(/\s+/);
94
+ const residentPages = Number(fields[1]);
95
+ if (!Number.isFinite(residentPages)) return null;
96
+ return residentPages * PAGE_BYTES;
97
+ }
98
+ /**
99
+ * What the OS will say about one process right now: cumulative CPU seconds and
100
+ * resident bytes, each ABSENT when it cannot be read.
101
+ *
102
+ * Exported because not every cost has a spawn site inside a registry. The
103
+ * shared inference pool is the case that forced it: its process is created
104
+ * deep inside the engine factory and belongs to no camera, so it is reported
105
+ * as an `unattributable` contribution built from the live pool's pids rather
106
+ * than from a claim — but its numbers must be read the same way, by the same
107
+ * parsers, or two "CPU seconds" in one payload would mean two things.
108
+ */
109
+ async function readProcessCost(pid, reader = nodeProcStatReader) {
110
+ let cpuSeconds = null;
111
+ let rssBytes = null;
112
+ try {
113
+ cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
114
+ } catch {
115
+ cpuSeconds = null;
116
+ }
117
+ try {
118
+ rssBytes = parseProcRssBytes(await reader.readStatm(pid));
119
+ } catch {
120
+ rssBytes = null;
121
+ }
122
+ return {
123
+ ...cpuSeconds === null ? {} : { cpuSeconds },
124
+ ...rssBytes === null ? {} : { rssBytes }
125
+ };
126
+ }
127
+ var ChildCostRegistry = class {
128
+ reader;
129
+ now;
130
+ claims = /* @__PURE__ */ new Map();
131
+ constructor(reader = nodeProcStatReader, now = Date.now) {
132
+ this.reader = reader;
133
+ this.now = now;
134
+ }
135
+ /**
136
+ * Record one child. The returned handle is the only way to remove it.
137
+ *
138
+ * A claim with no pid is dropped rather than stored: it could carry no
139
+ * measurement, and an entry with a camera and no numbers reads on a chart as
140
+ * a camera that cost nothing.
141
+ */
142
+ claim(input) {
143
+ const pid = input.pid;
144
+ if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
145
+ const key = Symbol("child-cost-claim");
146
+ this.claims.set(key, {
147
+ role: input.role,
148
+ deviceId: input.deviceId,
149
+ unit: input.unit,
150
+ attribution: input.attribution ?? "measured",
151
+ pid,
152
+ startedAtMs: this.now()
153
+ });
154
+ return { release: () => {
155
+ this.claims.delete(key);
156
+ } };
157
+ }
158
+ /** Live claims, in claim order. Diagnostics and tests; not a contribution. */
159
+ list() {
160
+ return [...this.claims.values()].map((c) => ({
161
+ role: c.role,
162
+ deviceId: c.deviceId,
163
+ unit: c.unit,
164
+ attribution: c.attribution,
165
+ pid: c.pid
166
+ }));
167
+ }
168
+ /**
169
+ * This addon's contribution: one entry per live claim, with whatever the OS
170
+ * will tell us about that child right now.
171
+ *
172
+ * A child that has exited between the claim and this read contributes an
173
+ * entry with no numbers rather than no entry — the unit exists, the addon
174
+ * believes it is running, and hiding it would make a dying writer look like
175
+ * a writer that was never started.
176
+ */
177
+ async contributions() {
178
+ const out = [];
179
+ for (const claim of this.claims.values()) out.push({
180
+ role: claim.role,
181
+ deviceId: claim.deviceId,
182
+ attribution: claim.attribution,
183
+ unit: claim.unit,
184
+ pid: claim.pid,
185
+ startedAtMs: claim.startedAtMs,
186
+ ...await readProcessCost(claim.pid, this.reader)
187
+ });
188
+ return out;
189
+ }
190
+ };
191
+ /**
192
+ * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
193
+ * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
194
+ *
195
+ * ## Why a plane and not a callback
196
+ *
197
+ * The operator's requirement for HKSV was explicit: the live fMP4 source built
198
+ * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
199
+ * CamStack as an additional videoclip source alongside the recorder and the NC
200
+ * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
201
+ * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
202
+ * a second ffmpeg child of the same camera. So this is the same shape the
203
+ * broker's other multi-consumer surfaces already have
204
+ * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
205
+ * one producer.
206
+ *
207
+ * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
208
+ * clip source; both are named here so the seam is not re-invented, and neither
209
+ * is built.
210
+ *
211
+ * ## The init segment is RETAINED
212
+ *
213
+ * A subscriber that attaches mid-stream — the clip consumer joining an already
214
+ * running HKSV session, which is the whole dual-use case — receives the
215
+ * retained `ftyp`+`moov` as its first packet and then live fragments. Without
216
+ * retention its fragments are undecodable and the failure looks like a codec
217
+ * problem.
218
+ *
219
+ * ## A slow subscriber is CLOSED, never silently gapped
220
+ *
221
+ * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
222
+ * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
223
+ * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
224
+ * shows a clip that fails to play with nothing anywhere saying why. So a
225
+ * subscription whose queue overflows is ENDED with a reason, loudly, and the
226
+ * other subscriptions are untouched.
227
+ *
228
+ * ## The PREBUFFER (phase 3)
229
+ *
230
+ * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
231
+ * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
232
+ * motion edge has none. So the plane optionally retains the last few fragments
233
+ * and replays them to a subscriber that asks for them.
234
+ *
235
+ * Three things this ring gets right, each of which is a measured fact rather
236
+ * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
237
+ *
238
+ * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
239
+ * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
240
+ * window. A time-only bound is a per-camera RAM figure nobody can predict.
241
+ * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
242
+ * splitter deliberately never computes a fragment's duration (a second
243
+ * opinion about a fact the muxer owns), and a prebuffer cares about how long
244
+ * ago the bytes turned up, which is exactly what arrival time answers.
245
+ * - **A replay is not backlog.** A subscriber taking N retained fragments gets
246
+ * its queue capacity raised by N for them, because closing a subscriber as a
247
+ * slow consumer for the prebuffer it explicitly asked for would be the
248
+ * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
249
+ * ring of 4, the guaranteed one.
250
+ *
251
+ * ## `isLast`
252
+ *
253
+ * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
254
+ * `isLast` — a generator that finishes without it produces the twelve-second
255
+ * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
256
+ * plane therefore computes it at DELIVERY time: a packet is last when the plane
257
+ * has ended and nothing remains queued behind it. A subscription that ends
258
+ * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
259
+ * the future delegate must not open an HDS stream it cannot feed.
260
+ */
261
+ var DEFAULT_QUEUE_CAPACITY = 4;
262
+ var Fmp4FragmentPlane = class {
263
+ logger;
264
+ prebuffer;
265
+ now;
266
+ subscriptions = /* @__PURE__ */ new Map();
267
+ /** The last init unit seen, handed to every later subscriber. */
268
+ retainedInit = null;
269
+ ended = false;
270
+ /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
271
+ ring = [];
272
+ ringBytes = 0;
273
+ constructor(logger, prebuffer, now = Date.now) {
274
+ this.logger = logger;
275
+ this.prebuffer = prebuffer;
276
+ this.now = now;
277
+ }
278
+ get subscriberCount() {
279
+ return this.subscriptions.size;
280
+ }
281
+ /** True once {@link end} has been called — no further units are accepted. */
282
+ get isEnded() {
283
+ return this.ended;
284
+ }
285
+ /** What the prebuffer ring holds right now. All zeroes when disabled. */
286
+ prebufferStats() {
287
+ const oldest = this.ring[0];
288
+ return {
289
+ fragments: this.ring.length,
290
+ bytes: this.ringBytes,
291
+ spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
292
+ };
293
+ }
294
+ subscribe(input) {
295
+ const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
296
+ const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
297
+ const sub = {
298
+ id: `fmp4-${(0, node_crypto.randomUUID)()}`,
299
+ tag: input.tag,
300
+ subscribedAt: this.now(),
301
+ capacity: requested + replay.length,
302
+ queue: [],
303
+ delivered: 0,
304
+ closedReason: null,
305
+ wake: null,
306
+ iterating: false
307
+ };
308
+ this.subscriptions.set(sub.id, sub);
309
+ if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
310
+ for (const retained of replay) this.enqueue(sub, retained.unit);
311
+ if (this.ended) this.closeSubscription(sub, "ended");
312
+ this.logger?.info("fmp4 plane: subscribed", { meta: {
313
+ subscriptionId: sub.id,
314
+ tag: sub.tag,
315
+ hasRetainedInit: this.retainedInit !== null,
316
+ prebufferFragments: replay.length,
317
+ prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
318
+ } });
319
+ return this.facade(sub);
320
+ }
321
+ /**
322
+ * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
323
+ * emits exactly one per child, and a second means the child was respawned, in
324
+ * which case the old one describes a stream that no longer exists.
325
+ */
326
+ publish(unit) {
327
+ if (this.ended) return;
328
+ if (unit.kind === "init") {
329
+ this.retainedInit = unit;
330
+ this.ring.length = 0;
331
+ this.ringBytes = 0;
332
+ } else this.retain(unit);
333
+ for (const sub of this.subscriptions.values()) {
334
+ if (sub.closedReason !== null) continue;
335
+ this.enqueue(sub, unit);
336
+ }
337
+ }
338
+ /**
339
+ * The producer stopped. Every subscriber drains what it holds; its final
340
+ * packet carries `isLast`, and its generator then completes.
341
+ */
342
+ end(reason = "producer ended") {
343
+ if (this.ended) return;
344
+ this.ended = true;
345
+ this.logger?.info("fmp4 plane: ended", { meta: {
346
+ reason,
347
+ subscribers: this.subscriptions.size
348
+ } });
349
+ for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
350
+ }
351
+ listSubscribers() {
352
+ return [...this.subscriptions.values()].map((s) => ({
353
+ tag: s.tag,
354
+ subscribedAt: s.subscribedAt,
355
+ delivered: s.delivered,
356
+ closedReason: s.closedReason
357
+ }));
358
+ }
359
+ /** End and forget everything. Idempotent. */
360
+ dispose() {
361
+ this.end("disposed");
362
+ this.subscriptions.clear();
363
+ this.retainedInit = null;
364
+ this.ring.length = 0;
365
+ this.ringBytes = 0;
366
+ }
367
+ /**
368
+ * Add one fragment to the ring and evict from the front until BOTH bounds
369
+ * hold. Eviction is oldest-first, which is the one place in this file where
370
+ * dropping is correct: the ring is context, not stream — nobody is mid-decode
371
+ * on it, and a subscriber only ever receives a contiguous tail of it.
372
+ */
373
+ retain(unit) {
374
+ const prebuffer = this.prebuffer;
375
+ if (prebuffer === void 0) return;
376
+ const arrivedAt = this.now();
377
+ this.ring.push({
378
+ unit,
379
+ arrivedAt
380
+ });
381
+ this.ringBytes += unit.data.length;
382
+ const cutoff = arrivedAt - prebuffer.windowMs;
383
+ while (this.ring.length > 0) {
384
+ const oldest = this.ring[0];
385
+ if (oldest === void 0) break;
386
+ const tooOld = oldest.arrivedAt < cutoff;
387
+ const tooBig = this.ringBytes > prebuffer.maxBytes;
388
+ if (!tooOld && !tooBig || this.ring.length === 1) break;
389
+ this.ring.shift();
390
+ this.ringBytes -= oldest.unit.data.length;
391
+ }
392
+ }
393
+ /**
394
+ * The ring as a subscriber should receive it — window applied AT SUBSCRIBE
395
+ * time, not only at publish time. A camera that went quiet keeps its last
396
+ * fragment in the ring indefinitely (see the never-evict-the-newest rule),
397
+ * and replaying a 40-second-old fragment as "prebuffer" would put stale video
398
+ * at the head of a clip iOS presents as the moment of the event.
399
+ */
400
+ trimmedRing() {
401
+ const prebuffer = this.prebuffer;
402
+ if (prebuffer === void 0) return [];
403
+ const cutoff = this.now() - prebuffer.windowMs;
404
+ return this.ring.filter((r) => r.arrivedAt >= cutoff);
405
+ }
406
+ enqueue(sub, unit) {
407
+ if (sub.queue.length >= sub.capacity) {
408
+ this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
409
+ subscriptionId: sub.id,
410
+ tag: sub.tag,
411
+ capacity: sub.capacity,
412
+ delivered: sub.delivered
413
+ } });
414
+ this.closeSubscription(sub, "slow-consumer");
415
+ return;
416
+ }
417
+ sub.queue.push({
418
+ kind: unit.kind,
419
+ data: unit.data,
420
+ sequence: unit.sequence,
421
+ isLast: false
422
+ });
423
+ this.wake(sub);
424
+ }
425
+ closeSubscription(sub, reason) {
426
+ if (sub.closedReason !== null) return;
427
+ sub.closedReason = reason;
428
+ if (reason === "slow-consumer") sub.queue.length = 0;
429
+ this.wake(sub);
430
+ }
431
+ wake(sub) {
432
+ const resume = sub.wake;
433
+ sub.wake = null;
434
+ resume?.();
435
+ }
436
+ facade(sub) {
437
+ const plane = this;
438
+ return {
439
+ id: sub.id,
440
+ tag: sub.tag,
441
+ get delivered() {
442
+ return sub.delivered;
443
+ },
444
+ get closedReason() {
445
+ return sub.closedReason;
446
+ },
447
+ packets: () => plane.iterate(sub),
448
+ release: () => {
449
+ plane.closeSubscription(sub, "released");
450
+ plane.subscriptions.delete(sub.id);
451
+ }
452
+ };
453
+ }
454
+ async *iterate(sub) {
455
+ if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
456
+ sub.iterating = true;
457
+ for (;;) {
458
+ const next = sub.queue.shift();
459
+ if (next === void 0) {
460
+ if (sub.closedReason !== null) return;
461
+ await new Promise((resolve) => {
462
+ sub.wake = resolve;
463
+ });
464
+ continue;
465
+ }
466
+ const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
467
+ sub.delivered += 1;
468
+ yield {
469
+ ...next,
470
+ isLast
471
+ };
472
+ if (isLast) return;
473
+ }
474
+ }
475
+ };
476
+ var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
477
+ /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
478
+ var FRAGMENT_LOG_EVERY = 30;
479
+ var Fmp4FragmentChild = class {
480
+ deps;
481
+ args;
482
+ child = null;
483
+ splitter = new require_dist.Fmp4BoxSplitter();
484
+ stopped = false;
485
+ unitsOut = 0;
486
+ activeHwAccel = null;
487
+ constructor(deps, args) {
488
+ this.deps = deps;
489
+ this.args = args;
490
+ }
491
+ /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
492
+ async start() {
493
+ const requested = this.args.invocation.decodeHwAccel;
494
+ this.activeHwAccel = requested;
495
+ try {
496
+ await this.spawnAttempt(requested);
497
+ return;
498
+ } catch (err) {
499
+ if (this.stopped) throw err;
500
+ if (requested === null || require_dist.isSoftwareDecode(requested)) throw err;
501
+ this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
502
+ tags: { deviceId: this.args.deviceId },
503
+ meta: {
504
+ sourceId: this.args.sourceId,
505
+ decodeHwAccel: requested,
506
+ error: require_dist.errMsg(err)
507
+ }
508
+ });
509
+ this.killChild();
510
+ this.splitter = new require_dist.Fmp4BoxSplitter();
511
+ this.activeHwAccel = null;
512
+ await this.spawnAttempt(null);
513
+ }
514
+ }
515
+ /** The backend the child ACTUALLY ran with — `null` for software. */
516
+ activeDecodeHwAccel() {
517
+ const value = this.activeHwAccel;
518
+ return value === null || value === "none" || value === "copy" ? null : value;
519
+ }
520
+ /** Kill ffmpeg and end the plane. Idempotent. */
521
+ async stop() {
522
+ if (this.stopped) return;
523
+ this.stopped = true;
524
+ this.killChild();
525
+ this.args.plane.end("the fragment child stopped");
526
+ }
527
+ spawnAttempt(decodeHwAccel) {
528
+ const args = require_dist.buildFfmpegArgs({
529
+ ...this.args.invocation,
530
+ decodeHwAccel,
531
+ sink: {
532
+ kind: "stdout",
533
+ container: "mp4",
534
+ fragmentMs: this.args.fragmentMs
535
+ }
536
+ });
537
+ this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
538
+ tags: { deviceId: this.args.deviceId },
539
+ meta: {
540
+ sourceId: this.args.sourceId,
541
+ fragmentMs: this.args.fragmentMs,
542
+ decodeHwAccel: decodeHwAccel ?? "software",
543
+ argv: args.join(" ")
544
+ }
545
+ });
546
+ return new Promise((resolve, reject) => {
547
+ const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
548
+ "ignore",
549
+ "pipe",
550
+ "pipe"
551
+ ] });
552
+ this.child = child;
553
+ let settled = false;
554
+ /**
555
+ * This attempt FAILED. Set before the kill, because SIGTERM makes the
556
+ * child exit and that exit must not be reported as a death: the retry —
557
+ * or the caller's rejection — already owns what happens next. Without it
558
+ * the timeout path ends the plane the software retry is about to fill,
559
+ * and the consumer sees a stream that stopped for no reason. A "which
560
+ * spawn is current" counter does NOT cover this: the retry has not been
561
+ * spawned when the kill's exit arrives.
562
+ */
563
+ let failed = false;
564
+ /**
565
+ * This attempt is still the live producer: it has not failed (a failure
566
+ * hands ownership to the retry, or to the caller's rejection) and nothing
567
+ * has stopped the child. Those two cover every way an attempt stops being
568
+ * current — `start` only respawns after a rejection.
569
+ */
570
+ const isCurrent = () => !this.stopped && !failed;
571
+ const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
572
+ const settle = (fail) => {
573
+ if (settled) return;
574
+ settled = true;
575
+ clearTimeout(timer);
576
+ if (fail) {
577
+ failed = true;
578
+ reject(fail);
579
+ } else resolve();
580
+ };
581
+ const timer = setTimeout(() => {
582
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
583
+ this.killChild();
584
+ }, timeoutMs);
585
+ timer.unref?.();
586
+ child.stdout?.on("data", (chunk) => {
587
+ for (const unit of this.splitter.push(chunk)) {
588
+ this.unitsOut += 1;
589
+ this.args.plane.publish(unit);
590
+ if (unit.kind === "init") {
591
+ this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
592
+ tags: { deviceId: this.args.deviceId },
593
+ meta: {
594
+ sourceId: this.args.sourceId,
595
+ bytes: unit.data.length
596
+ }
597
+ });
598
+ settle();
599
+ } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
600
+ tags: { deviceId: this.args.deviceId },
601
+ meta: {
602
+ sourceId: this.args.sourceId,
603
+ unitsOut: this.unitsOut,
604
+ bytes: unit.data.length,
605
+ subscribers: this.args.plane.subscriberCount
606
+ }
607
+ });
608
+ }
609
+ const fault = this.splitter.fault;
610
+ if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
611
+ });
612
+ child.stderr?.setEncoding("utf8");
613
+ child.stderr?.on("data", (line) => {
614
+ this.deps.logger.debug("fmp4 fragment child ffmpeg", {
615
+ tags: { deviceId: this.args.deviceId },
616
+ meta: {
617
+ sourceId: this.args.sourceId,
618
+ line: line.trim()
619
+ }
620
+ });
621
+ });
622
+ child.once("error", (err) => {
623
+ if (!settled) {
624
+ settle(err);
625
+ return;
626
+ }
627
+ if (!isCurrent()) return;
628
+ this.args.plane.end("the fragment child errored");
629
+ this.deps.onChildExit?.(err);
630
+ });
631
+ child.once("exit", (code, signal) => {
632
+ if (!settled) {
633
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
634
+ return;
635
+ }
636
+ if (!isCurrent()) return;
637
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
638
+ this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
639
+ tags: { deviceId: this.args.deviceId },
640
+ meta: {
641
+ sourceId: this.args.sourceId,
642
+ code,
643
+ signal,
644
+ unitsOut: this.unitsOut
645
+ }
646
+ });
647
+ this.args.plane.end("the fragment child exited");
648
+ this.deps.onChildExit?.(error);
649
+ });
650
+ });
651
+ }
652
+ /**
653
+ * The byte stream stopped being splittable. Not recoverable — the splitter
654
+ * cannot resynchronise mid-box — so the child is a corpse and every consumer
655
+ * has to be told, loudly, with the reason.
656
+ */
657
+ onFault(reason, wasLive, current, settle) {
658
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
659
+ this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
660
+ tags: { deviceId: this.args.deviceId },
661
+ meta: {
662
+ sourceId: this.args.sourceId,
663
+ unitsOut: this.unitsOut,
664
+ interstitial: this.splitter.discardedInterstitialTypes,
665
+ reason
666
+ }
667
+ });
668
+ this.killChild();
669
+ settle(error);
670
+ if (wasLive && current) {
671
+ this.args.plane.end("the fragment child produced unsplittable output");
672
+ this.deps.onChildExit?.(error);
673
+ }
674
+ }
675
+ killChild() {
676
+ const child = this.child;
677
+ this.child = null;
678
+ if (child && !child.killed) try {
679
+ child.kill("SIGTERM");
680
+ } catch (err) {
681
+ this.deps.logger.warn("fmp4 fragment child: kill error", {
682
+ tags: { deviceId: this.args.deviceId },
683
+ meta: {
684
+ sourceId: this.args.sourceId,
685
+ error: require_dist.errMsg(err)
686
+ }
687
+ });
688
+ }
689
+ }
690
+ };
691
+ //#endregion
692
+ Object.defineProperty(exports, "ChildCostRegistry", {
693
+ enumerable: true,
694
+ get: function() {
695
+ return ChildCostRegistry;
696
+ }
697
+ });
698
+ Object.defineProperty(exports, "Fmp4FragmentChild", {
699
+ enumerable: true,
700
+ get: function() {
701
+ return Fmp4FragmentChild;
702
+ }
703
+ });
704
+ Object.defineProperty(exports, "Fmp4FragmentPlane", {
705
+ enumerable: true,
706
+ get: function() {
707
+ return Fmp4FragmentPlane;
708
+ }
709
+ });
710
+ Object.defineProperty(exports, "NO_COST_CLAIM", {
711
+ enumerable: true,
712
+ get: function() {
713
+ return NO_COST_CLAIM;
714
+ }
715
+ });
716
+ Object.defineProperty(exports, "readProcessCost", {
717
+ enumerable: true,
718
+ get: function() {
719
+ return readProcessCost;
720
+ }
721
+ });