@camstack/types 1.2.41 → 1.2.42

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 (34) hide show
  1. package/dist/addon.js +1 -1
  2. package/dist/addon.mjs +1 -1
  3. package/dist/canonical-hash-7nfBbEqR.mjs +35 -0
  4. package/dist/canonical-hash-BcZHRHIx.js +40 -0
  5. package/dist/capabilities/index.d.ts +2 -2
  6. package/dist/capabilities/notification-rules.cap.d.ts +41 -0
  7. package/dist/capabilities/pipeline-analytics.cap.d.ts +92 -4
  8. package/dist/capabilities/pipeline-orchestrator.cap.d.ts +123 -0
  9. package/dist/capabilities/pipeline-runner.cap.d.ts +119 -1
  10. package/dist/capabilities/platform-probe.cap.d.ts +3 -3
  11. package/dist/capabilities/recording.cap.d.ts +3 -0
  12. package/dist/capabilities/stream-broker.cap.d.ts +300 -0
  13. package/dist/encode-profile.d.ts +2 -0
  14. package/dist/ffmpeg/encode-defaults.d.ts +89 -0
  15. package/dist/ffmpeg/hwaccel.d.ts +98 -0
  16. package/dist/ffmpeg/invocation.d.ts +250 -0
  17. package/dist/ffmpeg/process.d.ts +135 -0
  18. package/dist/ffmpeg/sharing-key.d.ts +39 -0
  19. package/dist/generated/addon-api.d.ts +60 -4
  20. package/dist/generated/device-proxy.d.ts +1 -1
  21. package/dist/generated/method-access-map.d.ts +1 -1
  22. package/dist/generated/system-proxy.d.ts +2 -2
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +1354 -20
  25. package/dist/index.mjs +1316 -21
  26. package/dist/interfaces/camera-switches.d.ts +217 -0
  27. package/dist/interfaces/ops-log.d.ts +4 -0
  28. package/dist/interfaces/pipeline-runner-capability.d.ts +9 -1
  29. package/dist/node.d.ts +2 -0
  30. package/dist/node.js +270 -36
  31. package/dist/node.mjs +269 -36
  32. package/dist/{sleep-CXimb854.mjs → sleep-BmNKsY7v.mjs} +5 -0
  33. package/dist/{sleep-DTce7-ch.js → sleep-Cvi1JxZp.js} +5 -0
  34. package/package.json +1 -1
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
3
+ * pipeline functions an operator thinks in terms of.
4
+ *
5
+ * ## This file adds no state
6
+ *
7
+ * Every switch here is a VIEW onto an authority that already existed
8
+ * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
9
+ * group is that there is exactly one place each function is turned off, and
10
+ * the group routes to it:
11
+ *
12
+ * | Switch | Authority | Proven "off stops the work" gate |
13
+ * | --- | --- | --- |
14
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
15
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
16
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
17
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
18
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
19
+ *
20
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
21
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
22
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
23
+ * surface that decision never got.
24
+ *
25
+ * ## Two rules that are load-bearing
26
+ *
27
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
28
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
29
+ * Expressing "off" by clearing bands destroys the operator's schedule and
30
+ * turning the camera back on would then silently record nothing.
31
+ * - **A switch that is off must be reported as off**, not merely produce
32
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
33
+ * "disabled by an operator" instead of "broken" — see
34
+ * `CameraStatus.switchedOff`.
35
+ */
36
+ import { z } from 'zod';
37
+ /**
38
+ * The five functions the operator named (2026-08-05). Deliberately NOT one id
39
+ * per pipeline step: face recognition and plate/LPR are per-step toggles on
40
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
41
+ * editor, not in a five-button safety group.
42
+ */
43
+ export declare const CameraSwitchIdSchema: z.ZodEnum<{
44
+ "audio-analysis": "audio-analysis";
45
+ recording: "recording";
46
+ "stream-broker": "stream-broker";
47
+ notifications: "notifications";
48
+ "object-detection": "object-detection";
49
+ }>;
50
+ export type CameraSwitchId = z.infer<typeof CameraSwitchIdSchema>;
51
+ /** Stable render order — broadest blast radius first. */
52
+ export declare const CAMERA_SWITCH_ORDER: readonly CameraSwitchId[];
53
+ /**
54
+ * WHERE the switch's state actually lives. A discriminated union rather than a
55
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
56
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
57
+ * a compile error rather than a review comment.
58
+ */
59
+ export declare const CameraSwitchAuthoritySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
60
+ kind: z.ZodLiteral<"device-disabled">;
61
+ }, z.core.$strip>, z.ZodObject<{
62
+ kind: z.ZodLiteral<"wrapper-binding">;
63
+ capName: z.ZodString;
64
+ }, z.core.$strip>, z.ZodObject<{
65
+ kind: z.ZodLiteral<"recording-config">;
66
+ }, z.core.$strip>, z.ZodObject<{
67
+ kind: z.ZodLiteral<"notification-mute">;
68
+ }, z.core.$strip>], "kind">;
69
+ export type CameraSwitchAuthority = z.infer<typeof CameraSwitchAuthoritySchema>;
70
+ /**
71
+ * Why a switch is not offered for this camera. Rendered instead of the
72
+ * control, never as a dead control — an absent function and a broken one must
73
+ * not look the same.
74
+ */
75
+ export declare const CameraSwitchUnavailableReasonSchema: z.ZodEnum<{
76
+ "no-provider": "no-provider";
77
+ "source-unreachable": "source-unreachable";
78
+ }>;
79
+ export type CameraSwitchUnavailableReason = z.infer<typeof CameraSwitchUnavailableReasonSchema>;
80
+ /**
81
+ * One switch, resolved for one camera.
82
+ *
83
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
84
+ * client-side: the viewer is a separate repository that does not import
85
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
86
+ * that will disagree with itself. Five rows per camera is nothing.
87
+ */
88
+ export declare const CameraSwitchSchema: z.ZodObject<{
89
+ id: z.ZodEnum<{
90
+ "audio-analysis": "audio-analysis";
91
+ recording: "recording";
92
+ "stream-broker": "stream-broker";
93
+ notifications: "notifications";
94
+ "object-detection": "object-detection";
95
+ }>;
96
+ label: z.ZodString;
97
+ costWhenOff: z.ZodString;
98
+ available: z.ZodBoolean;
99
+ unavailableReason: z.ZodOptional<z.ZodEnum<{
100
+ "no-provider": "no-provider";
101
+ "source-unreachable": "source-unreachable";
102
+ }>>;
103
+ enabled: z.ZodBoolean;
104
+ authority: z.ZodDiscriminatedUnion<[z.ZodObject<{
105
+ kind: z.ZodLiteral<"device-disabled">;
106
+ }, z.core.$strip>, z.ZodObject<{
107
+ kind: z.ZodLiteral<"wrapper-binding">;
108
+ capName: z.ZodString;
109
+ }, z.core.$strip>, z.ZodObject<{
110
+ kind: z.ZodLiteral<"recording-config">;
111
+ }, z.core.$strip>, z.ZodObject<{
112
+ kind: z.ZodLiteral<"notification-mute">;
113
+ }, z.core.$strip>], "kind">;
114
+ }, z.core.$strip>;
115
+ export type CameraSwitch = z.infer<typeof CameraSwitchSchema>;
116
+ /** The whole group for one camera. */
117
+ export declare const CameraSwitchGroupSchema: z.ZodObject<{
118
+ deviceId: z.ZodNumber;
119
+ switches: z.ZodReadonly<z.ZodArray<z.ZodObject<{
120
+ id: z.ZodEnum<{
121
+ "audio-analysis": "audio-analysis";
122
+ recording: "recording";
123
+ "stream-broker": "stream-broker";
124
+ notifications: "notifications";
125
+ "object-detection": "object-detection";
126
+ }>;
127
+ label: z.ZodString;
128
+ costWhenOff: z.ZodString;
129
+ available: z.ZodBoolean;
130
+ unavailableReason: z.ZodOptional<z.ZodEnum<{
131
+ "no-provider": "no-provider";
132
+ "source-unreachable": "source-unreachable";
133
+ }>>;
134
+ enabled: z.ZodBoolean;
135
+ authority: z.ZodDiscriminatedUnion<[z.ZodObject<{
136
+ kind: z.ZodLiteral<"device-disabled">;
137
+ }, z.core.$strip>, z.ZodObject<{
138
+ kind: z.ZodLiteral<"wrapper-binding">;
139
+ capName: z.ZodString;
140
+ }, z.core.$strip>, z.ZodObject<{
141
+ kind: z.ZodLiteral<"recording-config">;
142
+ }, z.core.$strip>, z.ZodObject<{
143
+ kind: z.ZodLiteral<"notification-mute">;
144
+ }, z.core.$strip>], "kind">;
145
+ }, z.core.$strip>>>;
146
+ fetchedAt: z.ZodNumber;
147
+ }, z.core.$strip>;
148
+ export type CameraSwitchGroup = z.infer<typeof CameraSwitchGroupSchema>;
149
+ /**
150
+ * The wrapper capability each wrapper-backed switch controls. Named constants
151
+ * because the same strings appear in `legacy-migrations.ts`, in
152
+ * `isCapActiveForDevice` call sites and in the fake harness — a typo in any of
153
+ * them is a switch that silently writes a binding nobody reads.
154
+ */
155
+ export declare const DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
156
+ export declare const AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
157
+ /** Static half of a switch: everything that does not depend on a device. */
158
+ export interface CameraSwitchDescriptor {
159
+ readonly id: CameraSwitchId;
160
+ readonly label: string;
161
+ readonly costWhenOff: string;
162
+ readonly authority: CameraSwitchAuthority;
163
+ }
164
+ /**
165
+ * THE catalog. One entry per switch; the cost lines are the operator-facing
166
+ * contract and are written to be true rather than reassuring.
167
+ */
168
+ export declare const CAMERA_SWITCH_CATALOG: Readonly<Record<CameraSwitchId, CameraSwitchDescriptor>>;
169
+ /**
170
+ * Everything the pure derivation needs, gathered by the caller. A `null`
171
+ * sub-state means the owning source did not answer — which produces
172
+ * `available: false` with `source-unreachable`, NEVER a control defaulted to
173
+ * "on". Guessing a switch is on when we could not read it is how an operator
174
+ * learns a function is off a week later.
175
+ */
176
+ export interface CameraSwitchDerivationInput {
177
+ readonly deviceId: number;
178
+ /** `deviceManager` soft-disable flag for this device. */
179
+ readonly deviceDisabled: boolean;
180
+ /**
181
+ * Capability names bindable for this device's TYPE
182
+ * (`deviceManager.listBindableCapsForDeviceType`). This is what makes the
183
+ * group derived rather than hardcoded: a deployment with no audio analyzer
184
+ * has no `audio-analysis` here, so no audio switch is rendered.
185
+ * `null` = the lookup failed.
186
+ */
187
+ readonly bindableCapNames: readonly string[] | null;
188
+ /**
189
+ * Capability names currently bound `kind: 'wrapped'` for this device
190
+ * (`deviceManager.getBindings`). Absence IS how a deactivated wrapper is
191
+ * represented — there is no `active: false` row.
192
+ */
193
+ readonly activeWrapperCapNames: readonly string[] | null;
194
+ /** `RecordingConfig.enabled`; `null` when the recording cap did not answer. */
195
+ readonly recordingEnabled: boolean | null;
196
+ /** `notificationRules` per-camera mute; `null` when the cap did not answer. */
197
+ readonly notificationsMuted: boolean | null;
198
+ }
199
+ /**
200
+ * Pure derivation of the whole group. No I/O — the orchestrator gathers, this
201
+ * decides, so the decision is testable without a hub.
202
+ *
203
+ * Order is {@link CAMERA_SWITCH_ORDER}; unavailable switches are RETURNED
204
+ * rather than filtered out, so a client can explain the gap instead of
205
+ * silently rendering four buttons where another camera shows five.
206
+ */
207
+ export declare function deriveCameraSwitches(input: CameraSwitchDerivationInput): readonly CameraSwitch[];
208
+ /**
209
+ * The ids an operator has switched OFF, for a status surface.
210
+ *
211
+ * This is the answer to "a disabled function must be visible as DISABLED, not
212
+ * merely quiet": a camera reporting zero detections with
213
+ * `switchedOff: ['object-detection']` was turned off; the same camera with an
214
+ * empty list is broken. Unavailable switches never appear — a function nobody
215
+ * provides was not switched off by anyone.
216
+ */
217
+ export declare function switchedOffIds(switches: readonly CameraSwitch[]): readonly CameraSwitchId[];
@@ -23,6 +23,7 @@ export declare const OpsLogOpSchema: z.ZodEnum<{
23
23
  rescan: "rescan";
24
24
  "retention-run": "retention-run";
25
25
  relocate: "relocate";
26
+ "orphan-audit": "orphan-audit";
26
27
  }>;
27
28
  export type OpsLogOp = z.infer<typeof OpsLogOpSchema>;
28
29
  /** Why the operation ran. */
@@ -31,6 +32,7 @@ export declare const OpsLogReasonSchema: z.ZodEnum<{
31
32
  operator: "operator";
32
33
  retention: "retention";
33
34
  quota: "quota";
35
+ maintenance: "maintenance";
34
36
  }>;
35
37
  export type OpsLogReason = z.infer<typeof OpsLogReasonSchema>;
36
38
  /** One audit row, shared verbatim by both domains. */
@@ -47,12 +49,14 @@ export declare const OpsLogEntrySchema: z.ZodObject<{
47
49
  rescan: "rescan";
48
50
  "retention-run": "retention-run";
49
51
  relocate: "relocate";
52
+ "orphan-audit": "orphan-audit";
50
53
  }>;
51
54
  reason: z.ZodEnum<{
52
55
  manual: "manual";
53
56
  operator: "operator";
54
57
  retention: "retention";
55
58
  quota: "quota";
59
+ maintenance: "maintenance";
56
60
  }>;
57
61
  deviceId: z.ZodNullable<z.ZodNumber>;
58
62
  nodeId: z.ZodString;
@@ -1,7 +1,7 @@
1
- import type { OrchestratorMetrics, CameraMetrics } from './api-shared.js';
2
1
  import type { PipelineStepInputOutput } from '../capabilities/pipeline-executor.cap.js';
3
2
  import type { Zone } from '../capabilities/zones.cap.js';
4
3
  import type { PipelinePhaseMode } from '../device/device-profile.js';
4
+ import type { CameraMetrics, OrchestratorMetrics } from './api-shared.js';
5
5
  /**
6
6
  * Camera assignment payload sent by the pipeline-orchestrator to a runner
7
7
  * via `attachCamera`. Carries everything the runner needs to subscribe to
@@ -204,4 +204,12 @@ export interface IPipelineRunnerProvider {
204
204
  * `null` when neither `frameHandle` nor `cropJpeg` resolves to a crop.
205
205
  */
206
206
  runDetailSubtree(input: import('../capabilities/pipeline-runner.cap.js').RunDetailSubtreeInput): Promise<import('../capabilities/pipeline-runner.cap.js').RunDetailSubtreeResult | null>;
207
+ /**
208
+ * Run ONE enrichment step against caller-supplied pixels with NO camera
209
+ * session — no attach, no handle, no device affinity. See
210
+ * `pipelineRunnerCapability.runStatelessStep`
211
+ * (`capabilities/pipeline-runner.cap.ts`) for the full contract, including
212
+ * why the model pin is refused rather than substituted.
213
+ */
214
+ runStatelessStep(input: import('../capabilities/pipeline-runner.cap.js').RunStatelessStepInput): Promise<import('../capabilities/pipeline-runner.cap.js').RunStatelessStepResult>;
207
215
  }
package/dist/node.d.ts CHANGED
@@ -6,3 +6,5 @@ export { FilesystemStorageProvider } from './storage/filesystem-storage-provider
6
6
  export { canonicalHash } from './utils/canonical-hash.js';
7
7
  export { canonicalDeviceFingerprint, diffExportTargets, resolveExportFingerprint, } from './utils/export-reconciler.js';
8
8
  export type { DeviceExportShape, ExportDelta, ExportTargetEntry, } from './utils/export-reconciler.js';
9
+ export { FfmpegProcess } from './ffmpeg/process.js';
10
+ export type { FfmpegProcessOptions, FfmpegExit, FfmpegExitClass } from './ffmpeg/process.js';
package/dist/node.js CHANGED
@@ -21,7 +21,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
+ const require_canonical_hash = require("./canonical-hash-BcZHRHIx.js");
24
25
  const require_err_msg = require("./err-msg-COpsHMw2.js");
26
+ let node_crypto = require("node:crypto");
25
27
  let node_fs = require("node:fs");
26
28
  node_fs = __toESM(node_fs);
27
29
  let node_path = require("node:path");
@@ -29,7 +31,6 @@ node_path = __toESM(node_path);
29
31
  let node_stream_promises = require("node:stream/promises");
30
32
  let node_stream = require("node:stream");
31
33
  let node_child_process = require("node:child_process");
32
- let node_crypto = require("node:crypto");
33
34
  //#region src/deps/binary-downloader.ts
34
35
  /**
35
36
  * Recursively find the first file named exactly `name` under `dir`. Used as the
@@ -600,39 +601,6 @@ var FilesystemStorageProvider = class {
600
601
  }
601
602
  };
602
603
  //#endregion
603
- //#region src/utils/canonical-hash.ts
604
- /**
605
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
606
- * canonical form sorts object keys alphabetically at every depth so two
607
- * structurally-equal inputs with different key insertion orders produce
608
- * the same hash. Returns a 64-char lowercase hex digest.
609
- *
610
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
611
- * accessory-rebuild work when the upstream shape is byte-identical to
612
- * the last applied state — preventing user-visible "re-discovery"
613
- * notifications on every addon-runner respawn. Each respawn re-fires
614
- * `DeviceBindingsChanged` for every cap registration, which without
615
- * this guard would propagate redundant pushes.
616
- *
617
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
618
- * subscription. The proper fix is a single "device ready" lifecycle
619
- * barrier so exports react only when the full cap set has landed —
620
- * tracked separately for post-HA-integration work.
621
- */
622
- function canonicalHash(value) {
623
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
624
- return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
625
- }
626
- function replaceWithSortedKeys(_key, value) {
627
- if (value && typeof value === "object" && !Array.isArray(value)) {
628
- const obj = value;
629
- const out = {};
630
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
631
- return out;
632
- }
633
- return value;
634
- }
635
- //#endregion
636
604
  //#region src/utils/export-reconciler.ts
637
605
  /**
638
606
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
@@ -641,7 +609,7 @@ function replaceWithSortedKeys(_key, value) {
641
609
  */
642
610
  function canonicalDeviceFingerprint(shape) {
643
611
  const features = [...new Set(shape.features)].toSorted();
644
- return canonicalHash({
612
+ return require_canonical_hash.canonicalHash({
645
613
  deviceType: shape.deviceType,
646
614
  features
647
615
  });
@@ -689,11 +657,277 @@ function resolveExportFingerprint(input) {
689
657
  return input.persisted ?? input.fresh;
690
658
  }
691
659
  //#endregion
660
+ //#region src/ffmpeg/process.ts
661
+ /**
662
+ * `FfmpegProcess` — the ONE spawn/lifecycle wrapper for a live-media ffmpeg.
663
+ *
664
+ * Extracted from `TranscodeEgress.spawnAttempt`, which was already the most
665
+ * complete of the repo's hand-rolled lifecycles: first-data deadline, hardware
666
+ * →software retry, SIGTERM-then-SIGKILL. This generalises it and adds the two
667
+ * things every copy was missing — a `tags: { deviceId }` on every line, and a
668
+ * bounded restart — so a consumer gets them by construction instead of by
669
+ * remembering.
670
+ *
671
+ * ## What it owns
672
+ *
673
+ * - spawn, with the argv from the ONE builder (`./invocation.js`);
674
+ * - a FIRST-DATA deadline: an ffmpeg that starts but never emits is dead, and
675
+ * nothing downstream can tell that apart from a slow camera;
676
+ * - HARDWARE→SOFTWARE retry, announced at `warn`. A silent downgrade on this
677
+ * hub is a flow bug, not a capability limit — see `docs/design/decode-path.md`;
678
+ * - exit classification (`ok` / `signalled-by-us` / `crashed` / `no-output`);
679
+ * - bounded restart with backoff, and a terminal give-up (never an infinite
680
+ * loop — the same rule `CrashSupervisor` enforces for runners, D6);
681
+ * - SIGTERM then SIGKILL after a grace, gated on the child not having already
682
+ * exited.
683
+ *
684
+ * ## What it does NOT own
685
+ *
686
+ * The output PLUMBING. A consumer attaches to `stdout` / `stderr` itself,
687
+ * because what the bytes mean is the consumer's business: the broker deframes
688
+ * Annex-B into a restreamer, the WebRTC leg regroups access units, HomeKit
689
+ * writes nothing to stdout at all (its output is two RTP sockets). A wrapper
690
+ * that also owned the bytes would need a mode per consumer, which is the same
691
+ * mistake as one builder per consumer.
692
+ */
693
+ var DEFAULT_FIRST_DATA_TIMEOUT_MS = 8e3;
694
+ var DEFAULT_RESTART_DELAY_MS = 1e3;
695
+ var DEFAULT_STABLE_RUN_MS = 3e4;
696
+ var DEFAULT_KILL_GRACE_MS = 500;
697
+ var STDERR_TAIL_LINES = 12;
698
+ var FfmpegProcess = class {
699
+ opts;
700
+ child = null;
701
+ stopped = false;
702
+ producedOutput = false;
703
+ consecutiveFailures = 0;
704
+ startedAtMs = 0;
705
+ stderrTail = [];
706
+ activeHwAccel;
707
+ triedSoftwareFallback = false;
708
+ firstDataTimer = null;
709
+ constructor(opts) {
710
+ this.opts = opts;
711
+ this.activeHwAccel = opts.decodeHwAccel;
712
+ }
713
+ /** Queryable tags on every line — `deviceId` is never optional. */
714
+ get logTags() {
715
+ return {
716
+ deviceId: this.opts.deviceId,
717
+ ...this.opts.tags
718
+ };
719
+ }
720
+ get now() {
721
+ return (this.opts.now ?? Date.now)();
722
+ }
723
+ /** `true` while a child is running. */
724
+ isRunning() {
725
+ return this.child !== null && !this.stopped;
726
+ }
727
+ /** The backend the CURRENT child decodes with (`null` ⇒ software). */
728
+ activeDecodeHwAccel() {
729
+ return this.activeHwAccel;
730
+ }
731
+ /**
732
+ * Spawn the first child. Resolves as soon as it produces output; rejects if
733
+ * it dies or stays silent past the deadline AFTER the software retry has
734
+ * also been exhausted. A caller that wants fire-and-forget can ignore the
735
+ * promise — the restart loop runs regardless.
736
+ */
737
+ start() {
738
+ return new Promise((resolve, reject) => {
739
+ this.spawnAttempt(resolve, reject);
740
+ });
741
+ }
742
+ spawnAttempt(onLive, onDead) {
743
+ if (this.stopped) return;
744
+ const args = [...this.opts.buildArgs(this.activeHwAccel)];
745
+ const spawnFn = this.opts.spawnFn ?? node_child_process.spawn;
746
+ const setTimeoutImpl = this.opts.setTimeoutFn ?? setTimeout;
747
+ this.opts.logger.info(`ffmpeg ${this.opts.role}: spawning`, {
748
+ tags: this.logTags,
749
+ meta: {
750
+ decodeHwAccel: this.activeHwAccel ?? "software",
751
+ attempt: this.consecutiveFailures + 1
752
+ }
753
+ });
754
+ let child;
755
+ try {
756
+ child = spawnFn(this.opts.binaryPath, args, { stdio: this.opts.stdio ?? [
757
+ "ignore",
758
+ "pipe",
759
+ "pipe"
760
+ ] });
761
+ } catch (err) {
762
+ this.handleFailure("crashed", null, null, err, onLive, onDead);
763
+ return;
764
+ }
765
+ this.child = child;
766
+ this.startedAtMs = this.now;
767
+ this.producedOutput = false;
768
+ this.stderrTail = [];
769
+ let settled = false;
770
+ const timeoutMs = this.opts.firstDataTimeoutMs ?? DEFAULT_FIRST_DATA_TIMEOUT_MS;
771
+ if (timeoutMs > 0) this.firstDataTimer = setTimeoutImpl(() => {
772
+ if (settled || this.producedOutput || this.stopped) return;
773
+ settled = true;
774
+ this.opts.logger.warn(`ffmpeg ${this.opts.role}: no output within ${timeoutMs}ms`, {
775
+ tags: this.logTags,
776
+ meta: { decodeHwAccel: this.activeHwAccel ?? "software" }
777
+ });
778
+ this.killChild();
779
+ this.handleFailure("no-output", null, null, null, onLive, onDead);
780
+ }, timeoutMs);
781
+ child.stderr?.setEncoding("utf8");
782
+ child.stderr?.on("data", (line) => {
783
+ const text = String(line).trim();
784
+ if (text.length === 0) return;
785
+ this.stderrTail.push(text);
786
+ if (this.stderrTail.length > STDERR_TAIL_LINES) this.stderrTail.shift();
787
+ this.opts.logger.debug(`ffmpeg ${this.opts.role}`, {
788
+ tags: this.logTags,
789
+ meta: { line: text }
790
+ });
791
+ });
792
+ child.once("error", (err) => {
793
+ if (settled || this.stopped) return;
794
+ settled = true;
795
+ this.handleFailure("crashed", null, null, err, onLive, onDead);
796
+ });
797
+ child.once("exit", (code, signal) => {
798
+ this.clearFirstDataTimer();
799
+ if (this.child === child) this.child = null;
800
+ if (this.stopped) {
801
+ this.report("stopped", code, signal);
802
+ return;
803
+ }
804
+ if (settled && this.producedOutput) {
805
+ this.report("crashed", code, signal);
806
+ this.scheduleRestart(onLive, onDead);
807
+ return;
808
+ }
809
+ if (settled) return;
810
+ settled = true;
811
+ this.handleFailure(this.producedOutput ? "crashed" : "no-output", code, signal, null, onLive, onDead);
812
+ });
813
+ this.opts.onChild(child);
814
+ child.stdout?.once("data", () => {
815
+ this.producedOutput = true;
816
+ this.clearFirstDataTimer();
817
+ if (settled) return;
818
+ settled = true;
819
+ this.opts.logger.info(`ffmpeg ${this.opts.role}: live`, {
820
+ tags: this.logTags,
821
+ meta: { decodeHwAccel: this.activeHwAccel ?? "software" }
822
+ });
823
+ onLive();
824
+ });
825
+ }
826
+ /**
827
+ * A child failed before going live. Try SOFTWARE once if it was decoding in
828
+ * hardware — loudly — then fall through to the bounded restart.
829
+ */
830
+ handleFailure(classification, code, signal, err, onLive, onDead) {
831
+ this.report(classification, code, signal, err);
832
+ if (this.activeHwAccel !== null && !this.triedSoftwareFallback && !this.stopped) {
833
+ this.triedSoftwareFallback = true;
834
+ this.opts.logger.warn(`ffmpeg ${this.opts.role}: hardware decode produced no output — retrying in SOFTWARE`, {
835
+ tags: this.logTags,
836
+ meta: {
837
+ decodeHwAccel: this.activeHwAccel,
838
+ classification,
839
+ code,
840
+ signal,
841
+ stderrTail: this.stderrTail
842
+ }
843
+ });
844
+ this.activeHwAccel = null;
845
+ this.spawnAttempt(onLive, onDead);
846
+ return;
847
+ }
848
+ this.consecutiveFailures += 1;
849
+ const maxRestarts = this.opts.maxRestarts ?? 0;
850
+ if (this.consecutiveFailures > maxRestarts || this.stopped) {
851
+ const reason = `ffmpeg ${this.opts.role} gave up after ${this.consecutiveFailures} attempt(s) (${classification}, code=${code} signal=${signal})`;
852
+ this.opts.logger.error(reason, {
853
+ tags: this.logTags,
854
+ meta: { stderrTail: this.stderrTail }
855
+ });
856
+ onDead(new Error(reason));
857
+ return;
858
+ }
859
+ this.scheduleRestart(onLive, onDead);
860
+ }
861
+ scheduleRestart(onLive, onDead) {
862
+ if (this.stopped) return;
863
+ const maxRestarts = this.opts.maxRestarts ?? 0;
864
+ if (maxRestarts === 0) return;
865
+ if (this.producedOutput && this.now - this.startedAtMs >= (this.opts.stableRunMs ?? DEFAULT_STABLE_RUN_MS)) this.consecutiveFailures = 0;
866
+ if (this.consecutiveFailures > maxRestarts) return;
867
+ (this.opts.setTimeoutFn ?? setTimeout)(() => {
868
+ if (this.stopped) return;
869
+ this.spawnAttempt(onLive, onDead);
870
+ }, (this.opts.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS) * Math.min(this.consecutiveFailures + 1, 8));
871
+ }
872
+ report(classification, code, signal, err) {
873
+ const exit = {
874
+ classification,
875
+ code,
876
+ signal,
877
+ stderrTail: [...this.stderrTail],
878
+ decodeHwAccel: this.activeHwAccel
879
+ };
880
+ if (classification === "crashed" || classification === "no-output") this.opts.logger.warn(`ffmpeg ${this.opts.role} exited: ${classification}`, {
881
+ tags: this.logTags,
882
+ meta: {
883
+ code,
884
+ signal,
885
+ decodeHwAccel: this.activeHwAccel ?? "software",
886
+ error: err instanceof Error ? err.message : err === void 0 ? void 0 : String(err),
887
+ stderrTail: exit.stderrTail
888
+ }
889
+ });
890
+ this.opts.onExit?.(exit);
891
+ }
892
+ clearFirstDataTimer() {
893
+ if (this.firstDataTimer !== null) {
894
+ clearTimeout(this.firstDataTimer);
895
+ this.firstDataTimer = null;
896
+ }
897
+ }
898
+ /** Terminate for good. Idempotent; no restart follows. */
899
+ stop() {
900
+ if (this.stopped) return;
901
+ this.stopped = true;
902
+ this.clearFirstDataTimer();
903
+ this.killChild();
904
+ }
905
+ killChild() {
906
+ const child = this.child;
907
+ this.child = null;
908
+ if (!child) return;
909
+ if (child.exitCode !== null || child.signalCode !== null) return;
910
+ try {
911
+ child.kill("SIGTERM");
912
+ } catch {
913
+ return;
914
+ }
915
+ const grace = (this.opts.setTimeoutFn ?? setTimeout)(() => {
916
+ if (child.exitCode !== null || child.signalCode !== null) return;
917
+ try {
918
+ child.kill("SIGKILL");
919
+ } catch {}
920
+ }, this.opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS);
921
+ if (typeof grace === "object" && grace !== null && "unref" in grace) grace.unref();
922
+ }
923
+ };
924
+ //#endregion
925
+ exports.FfmpegProcess = FfmpegProcess;
692
926
  exports.FilesystemStorageProvider = FilesystemStorageProvider;
693
927
  exports.PYTHON_VERSION = PYTHON_VERSION;
694
928
  exports.buildBinaryPath = buildBinaryPath;
695
929
  exports.canonicalDeviceFingerprint = canonicalDeviceFingerprint;
696
- exports.canonicalHash = canonicalHash;
930
+ exports.canonicalHash = require_canonical_hash.canonicalHash;
697
931
  exports.diffExportTargets = diffExportTargets;
698
932
  exports.downloadBinary = downloadBinary;
699
933
  exports.ensureBinary = ensureBinary;