@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.
- package/dist/addon.js +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/canonical-hash-7nfBbEqR.mjs +35 -0
- package/dist/canonical-hash-BcZHRHIx.js +40 -0
- package/dist/capabilities/index.d.ts +2 -2
- package/dist/capabilities/notification-rules.cap.d.ts +41 -0
- package/dist/capabilities/pipeline-analytics.cap.d.ts +92 -4
- package/dist/capabilities/pipeline-orchestrator.cap.d.ts +123 -0
- package/dist/capabilities/pipeline-runner.cap.d.ts +119 -1
- package/dist/capabilities/platform-probe.cap.d.ts +3 -3
- package/dist/capabilities/recording.cap.d.ts +3 -0
- package/dist/capabilities/stream-broker.cap.d.ts +300 -0
- package/dist/encode-profile.d.ts +2 -0
- package/dist/ffmpeg/encode-defaults.d.ts +89 -0
- package/dist/ffmpeg/hwaccel.d.ts +98 -0
- package/dist/ffmpeg/invocation.d.ts +250 -0
- package/dist/ffmpeg/process.d.ts +135 -0
- package/dist/ffmpeg/sharing-key.d.ts +39 -0
- package/dist/generated/addon-api.d.ts +60 -4
- package/dist/generated/device-proxy.d.ts +1 -1
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/system-proxy.d.ts +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1354 -20
- package/dist/index.mjs +1316 -21
- package/dist/interfaces/camera-switches.d.ts +217 -0
- package/dist/interfaces/ops-log.d.ts +4 -0
- package/dist/interfaces/pipeline-runner-capability.d.ts +9 -1
- package/dist/node.d.ts +2 -0
- package/dist/node.js +270 -36
- package/dist/node.mjs +269 -36
- package/dist/{sleep-CXimb854.mjs → sleep-BmNKsY7v.mjs} +5 -0
- package/dist/{sleep-DTce7-ch.js → sleep-Cvi1JxZp.js} +5 -0
- 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;
|