@camstack/types 1.2.267 → 1.2.269
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 +4 -4
- package/dist/addon.mjs +3 -3
- package/dist/capabilities/battery.cap.d.ts +17 -2
- package/dist/capabilities/capability-definition.d.ts +30 -1
- package/dist/capabilities/composer.cap.d.ts +105 -0
- package/dist/capabilities/consumables.cap.d.ts +79 -0
- package/dist/capabilities/core-blocks.cap.d.ts +1213 -104
- package/dist/capabilities/device-extension.cap.d.ts +12 -13
- package/dist/capabilities/device-manager.cap.d.ts +4 -4
- package/dist/capabilities/device-state.cap.d.ts +186 -2
- package/dist/capabilities/doorbell.cap.d.ts +4 -0
- package/dist/capabilities/index.d.ts +3 -3
- package/dist/capabilities/media-player.cap.d.ts +4 -4
- package/dist/capabilities/recording-archive.cap.d.ts +3 -3
- package/dist/catalogs/sensor-active-state.d.ts +25 -1
- package/dist/composition/composition-events.d.ts +42 -0
- package/dist/composition/composition-graph.d.ts +42 -9
- package/dist/composition/composition-items.d.ts +25 -0
- package/dist/composition/composition-report.d.ts +115 -12
- package/dist/composition/composition.d.ts +162 -3
- package/dist/composition/customization-name.d.ts +6 -0
- package/dist/composition/evaluate-field.d.ts +20 -0
- package/dist/composition/examples.d.ts +33 -0
- package/dist/composition/expression-kinds.d.ts +1 -0
- package/dist/composition/field-kinds.d.ts +42 -0
- package/dist/composition/index.d.ts +2 -0
- package/dist/composition/plan-result.d.ts +18 -0
- package/dist/composition/state-fields.d.ts +6 -0
- package/dist/composition/validate-composition.d.ts +44 -9
- package/dist/composition-BAH3n-zq.js +2582 -0
- package/dist/composition-D5BQywfe.mjs +2055 -0
- package/dist/device/device-context.d.ts +9 -0
- package/dist/device/device-runtime-state.d.ts +15 -1
- package/dist/device/index.d.ts +1 -1
- package/dist/device-extension/slot-descriptor.d.ts +8 -11
- package/dist/device-extension/slot-provider.d.ts +0 -2
- package/dist/enums/event-category.d.ts +25 -0
- package/dist/enums.js +1 -1
- package/dist/enums.mjs +1 -1
- package/dist/err-msg-DX6i_MY4.mjs +339 -0
- package/dist/err-msg-Dx2Kor0g.js +368 -0
- package/dist/{event-category-BVDXG4tB.mjs → event-category-C5xZWqz6.mjs} +31 -1
- package/dist/{event-category-BVfsrBYA.js → event-category-MbnB-6AN.js} +48 -0
- package/dist/expression/ast.d.ts +2 -0
- package/dist/expression/builtins.d.ts +9 -0
- package/dist/expression/evaluator.d.ts +8 -2
- package/dist/expression/expression-source.d.ts +26 -4
- package/dist/expression/index.d.ts +6 -6
- package/dist/generated/addon-api.d.ts +42 -0
- package/dist/generated/device-proxy.d.ts +1 -1
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/method-device-selectors.d.ts +2 -2
- package/dist/generated/system-proxy.d.ts +2 -2
- package/dist/index.d.ts +7 -6
- package/dist/index.js +1687 -1833
- package/dist/index.mjs +1359 -1550
- package/dist/interfaces/kernel-abstractions.d.ts +16 -0
- package/dist/interfaces/recording-config.d.ts +3 -3
- package/dist/interfaces/status-overlay.d.ts +42 -0
- package/dist/node.d.ts +1 -0
- package/dist/node.js +29 -8
- package/dist/node.mjs +23 -3
- package/dist/{sleep-8K9gue-H.js → sleep-Bg0IB7gg.js} +10 -367
- package/dist/{sleep-DC-wdyeS.mjs → sleep-E6eD4yQS.mjs} +11 -344
- package/package.json +1 -1
- package/dist/canonical-hash-CPK2Dy60.mjs +0 -715
- package/dist/canonical-hash-CSE4ioRi.js +0 -792
- package/dist/err-msg-COpsHMw2.js +0 -18
- package/dist/err-msg-IQTHeDzc.mjs +0 -13
|
@@ -0,0 +1,2055 @@
|
|
|
1
|
+
import { a as DeviceType, i as DeviceRole } from "./err-msg-DX6i_MY4.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
//#region src/ffmpeg/invocation.ts
|
|
5
|
+
var AUDIO_ENCODER_BY_CODEC = {
|
|
6
|
+
opus: "libopus",
|
|
7
|
+
aac: "aac",
|
|
8
|
+
pcmu: "pcm_mulaw",
|
|
9
|
+
pcma: "pcm_alaw"
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Camera-microphone audio, per codec. Lives HERE rather than in
|
|
13
|
+
* `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
|
|
14
|
+
* on these types); it is re-exported from there, which is where to read it.
|
|
15
|
+
*
|
|
16
|
+
* Every source in this repo is a mono camera mic. The former broker preset
|
|
17
|
+
* encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
|
|
18
|
+
* that is the value this consolidation changed.
|
|
19
|
+
*/
|
|
20
|
+
var AUDIO_PRESETS = {
|
|
21
|
+
aac: {
|
|
22
|
+
kind: "encode",
|
|
23
|
+
codec: "aac",
|
|
24
|
+
bitrateKbps: 128,
|
|
25
|
+
sampleRateHz: 48e3,
|
|
26
|
+
channels: 1
|
|
27
|
+
},
|
|
28
|
+
opus: {
|
|
29
|
+
kind: "encode",
|
|
30
|
+
codec: "opus",
|
|
31
|
+
bitrateKbps: 64,
|
|
32
|
+
sampleRateHz: 48e3,
|
|
33
|
+
channels: 1
|
|
34
|
+
},
|
|
35
|
+
pcmu: {
|
|
36
|
+
kind: "encode",
|
|
37
|
+
codec: "pcmu",
|
|
38
|
+
sampleRateHz: 8e3,
|
|
39
|
+
channels: 1
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
|
|
43
|
+
function logBannerArgs(level) {
|
|
44
|
+
return [
|
|
45
|
+
"-hide_banner",
|
|
46
|
+
"-loglevel",
|
|
47
|
+
level
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
/** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
|
|
51
|
+
function isSoftwareDecode(decodeHwAccel) {
|
|
52
|
+
return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
|
|
56
|
+
* appended to this list by a caller — that is the whole point of the function.
|
|
57
|
+
*/
|
|
58
|
+
function buildInputArgs(input, decodeHwAccel) {
|
|
59
|
+
const args = [];
|
|
60
|
+
if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
|
|
61
|
+
if (input.extraArgs?.length) args.push(...input.extraArgs);
|
|
62
|
+
if (input.decodeThreadCount !== void 0 && input.decodeThreadCount > 0) args.push("-threads", String(input.decodeThreadCount));
|
|
63
|
+
if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
|
|
64
|
+
if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
|
|
65
|
+
if (input.lowDelay === true) args.push("-flags", "low_delay");
|
|
66
|
+
if (input.useWallclockTimestamps === true) args.push("-use_wallclock_as_timestamps", "1");
|
|
67
|
+
if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
|
|
68
|
+
if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
|
|
69
|
+
if (input.rawVideo !== void 0) {
|
|
70
|
+
const raw = input.rawVideo;
|
|
71
|
+
args.push("-f", "rawvideo", "-pix_fmt", raw.pixelFormat, "-video_size", `${String(raw.width)}x${String(raw.height)}`, "-framerate", String(raw.framerate));
|
|
72
|
+
}
|
|
73
|
+
args.push("-i", input.url);
|
|
74
|
+
return args;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Derive the output `-map` selectors. The three cases, in the order they are
|
|
78
|
+
* decided:
|
|
79
|
+
*
|
|
80
|
+
* 1. **A filter graph** — the picture is the graph's declared video pad, and
|
|
81
|
+
* the audio is its audio pad when it has one, otherwise input 0's mic. A
|
|
82
|
+
* graph's output is not addressable any other way.
|
|
83
|
+
* 2. **N inputs, no graph** — map input 0 EXPLICITLY. ffmpeg's own selection
|
|
84
|
+
* picks the "best" stream across ALL inputs, so the egress would silently
|
|
85
|
+
* become whichever camera happens to have the largest frame.
|
|
86
|
+
* 3. **One input, no graph** — no `-map`. Anything else would change the argv
|
|
87
|
+
* of every call site that exists today for no gain.
|
|
88
|
+
*/
|
|
89
|
+
function resolveStreamMaps(selection) {
|
|
90
|
+
const graph = selection.filterGraph;
|
|
91
|
+
if (graph) return {
|
|
92
|
+
video: `[${graph.videoOutLabel}]`,
|
|
93
|
+
audio: graph.audioOutLabel ? `[${graph.audioOutLabel}]` : "0:a:0?"
|
|
94
|
+
};
|
|
95
|
+
if (selection.extraInputs?.length) return {
|
|
96
|
+
video: "0:v:0",
|
|
97
|
+
audio: "0:a:0?"
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
video: null,
|
|
101
|
+
audio: null
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
|
|
105
|
+
function buildVideoFilterArgs(scale, outputArgs, filterGraph, hwUpload) {
|
|
106
|
+
if (filterGraph) {
|
|
107
|
+
if (scale) throw new Error("FfmpegInvocation: video.scale cannot be combined with a filter graph — scale inside the graph instead (the -vf would feed an output the graph already feeds)");
|
|
108
|
+
if (outputArgs.some((a) => a === "-vf")) throw new Error("FfmpegInvocation: outputArgs carries a -vf while a filter graph feeds the same output — fold the filter into the graph");
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
if (outputArgs.some((a) => a === "-vf")) return [];
|
|
112
|
+
if (!scale) return hwUpload.length > 0 ? ["-vf", hwUpload] : [];
|
|
113
|
+
const suffix = hwUpload.length > 0 ? `,${hwUpload}` : "";
|
|
114
|
+
if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}${suffix}`];
|
|
115
|
+
return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${suffix}`];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The upload filter a hardware encode needs, or `''`.
|
|
119
|
+
*
|
|
120
|
+
* Nothing is uploaded when the DECODE already left the frames on the GPU
|
|
121
|
+
* (`-hwaccel vaapi`): they are surfaces already, and uploading a surface is an
|
|
122
|
+
* error rather than a no-op.
|
|
123
|
+
*/
|
|
124
|
+
function resolveHwUpload(device, decodeHwAccel) {
|
|
125
|
+
if (device === void 0) return "";
|
|
126
|
+
if (!isSoftwareDecode(decodeHwAccel)) return "";
|
|
127
|
+
return "format=nv12,hwupload";
|
|
128
|
+
}
|
|
129
|
+
/** Rate-control args for an encode plan. */
|
|
130
|
+
function buildRateControlArgs(video) {
|
|
131
|
+
const kbps = video.bitrateKbps;
|
|
132
|
+
if (kbps === void 0) return [];
|
|
133
|
+
const rc = video.rateControl ?? {
|
|
134
|
+
kind: "cap",
|
|
135
|
+
vbvSeconds: 2
|
|
136
|
+
};
|
|
137
|
+
const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
|
|
138
|
+
return [
|
|
139
|
+
...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
|
|
140
|
+
"-maxrate",
|
|
141
|
+
`${kbps}k`,
|
|
142
|
+
"-bufsize",
|
|
143
|
+
`${bufsize}k`
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
/** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
|
|
147
|
+
function buildVideoArgs(video, outputArgs, filterGraph, hwEncode) {
|
|
148
|
+
if (video.kind === "raw") {
|
|
149
|
+
if (filterGraph && video.filter !== void 0) throw new Error("FfmpegInvocation: video.filter cannot be combined with a filter graph — fold the filter into the graph");
|
|
150
|
+
return [
|
|
151
|
+
...video.filter !== void 0 ? ["-vf", video.filter] : [],
|
|
152
|
+
"-c:v",
|
|
153
|
+
"rawvideo",
|
|
154
|
+
"-pix_fmt",
|
|
155
|
+
video.pixelFormat,
|
|
156
|
+
...video.fps !== void 0 ? ["-r", String(video.fps)] : []
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
if (video.kind === "copy") {
|
|
160
|
+
if (filterGraph) throw new Error("FfmpegInvocation: a filter graph produces a NEW picture, so video.kind must be `encode`");
|
|
161
|
+
return [
|
|
162
|
+
"-c:v",
|
|
163
|
+
"copy",
|
|
164
|
+
...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
const hwUpload = hwEncode === void 0 ? "" : resolveHwUpload(hwEncode.device, hwEncode.decodeHwAccel);
|
|
168
|
+
if (hwEncode !== void 0 && filterGraph) throw new Error("FfmpegInvocation: a hardware encode fed by a filter graph must upload INSIDE the graph — hwEncodeDevice cannot be combined with filterGraph");
|
|
169
|
+
const args = [
|
|
170
|
+
...buildVideoFilterArgs(video.scale, outputArgs, filterGraph, hwUpload),
|
|
171
|
+
"-c:v",
|
|
172
|
+
video.encoder
|
|
173
|
+
];
|
|
174
|
+
if (video.preset !== void 0) args.push("-preset", video.preset);
|
|
175
|
+
if (video.tune !== void 0) args.push("-tune", video.tune);
|
|
176
|
+
if (video.singleSlicePerFrame === true && video.encoder === "libx264") args.push("-x264-params", "sliced-threads=0");
|
|
177
|
+
if (video.profile !== void 0) args.push("-profile:v", video.profile);
|
|
178
|
+
if (video.level !== void 0) args.push("-level", video.level);
|
|
179
|
+
if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
|
|
180
|
+
if (video.fps !== void 0) args.push("-r", String(video.fps));
|
|
181
|
+
if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
|
|
182
|
+
if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
|
|
183
|
+
if (video.bf !== void 0) args.push("-bf", String(video.bf));
|
|
184
|
+
args.push(...buildRateControlArgs(video));
|
|
185
|
+
if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
|
|
186
|
+
return args;
|
|
187
|
+
}
|
|
188
|
+
/** The whole audio block, after `-i`. */
|
|
189
|
+
function buildAudioArgs(audio) {
|
|
190
|
+
if (audio.kind === "none") return ["-an"];
|
|
191
|
+
if (audio.kind === "copy") return ["-c:a", "copy"];
|
|
192
|
+
const args = [];
|
|
193
|
+
if (audio.filter !== void 0) args.push("-af", audio.filter);
|
|
194
|
+
args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
|
|
195
|
+
if (audio.application !== void 0) args.push("-application", audio.application);
|
|
196
|
+
if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
|
|
197
|
+
if (audio.globalHeader === true) args.push("-flags", "+global_header");
|
|
198
|
+
if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
|
|
199
|
+
if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
|
|
200
|
+
if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
|
|
201
|
+
if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
|
|
202
|
+
return args;
|
|
203
|
+
}
|
|
204
|
+
/** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
|
|
205
|
+
function buildRtpOutputArgs(out) {
|
|
206
|
+
const args = [];
|
|
207
|
+
if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
|
|
208
|
+
if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
|
|
209
|
+
if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
|
|
210
|
+
args.push("-f", "rtp", out.url);
|
|
211
|
+
return args;
|
|
212
|
+
}
|
|
213
|
+
/** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
|
|
214
|
+
function isElementaryVideoSink(sink) {
|
|
215
|
+
return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The fragmented-MP4 muxer flags, in the order the recorder has proven them
|
|
219
|
+
* (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
|
|
220
|
+
* `-segment_format_options`, across every vendor in the fleet):
|
|
221
|
+
*
|
|
222
|
+
* - `frag_keyframe` — cut a fragment at each key frame, so every fragment
|
|
223
|
+
* opens on a sync sample. HKSV's whole requirement.
|
|
224
|
+
* - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
|
|
225
|
+
* is what makes the head a standalone INITIALISATION segment.
|
|
226
|
+
* - `default_base_moof` — fragment offsets are self-relative, so a fragment is
|
|
227
|
+
* demuxable without the bytes that preceded it. D31's byte-range read path
|
|
228
|
+
* depends on exactly this property of the recorder's segments.
|
|
229
|
+
*/
|
|
230
|
+
var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
|
|
231
|
+
/**
|
|
232
|
+
* The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
|
|
233
|
+
* union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
|
|
234
|
+
* which is what a plain `container` read would have done for `mp4` — a valid
|
|
235
|
+
* argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
|
|
236
|
+
* unusable byte stream.
|
|
237
|
+
*/
|
|
238
|
+
function buildStdoutOrRtspSinkArgs(sink) {
|
|
239
|
+
if (sink.kind === "rtsp-listen") return [
|
|
240
|
+
"-f",
|
|
241
|
+
"rtsp",
|
|
242
|
+
"-rtsp_transport",
|
|
243
|
+
"tcp",
|
|
244
|
+
"-rtsp_flags",
|
|
245
|
+
"listen",
|
|
246
|
+
sink.url
|
|
247
|
+
];
|
|
248
|
+
if (sink.kind === "rtp-outputs") return [];
|
|
249
|
+
return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
|
|
250
|
+
"-f",
|
|
251
|
+
sink.container,
|
|
252
|
+
"pipe:1"
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* How far BELOW the negotiated fragment length `-min_frag_duration` is set.
|
|
257
|
+
*
|
|
258
|
+
* `-min_frag_duration` refuses to cut before that much media has accumulated,
|
|
259
|
+
* and then waits for the next key frame. Set to exactly `fragmentMs`, the
|
|
260
|
+
* commonest camera configuration in existence — a key-frame grid EQUAL to the
|
|
261
|
+
* requested fragment length — lands the deadline on the same instant as the key
|
|
262
|
+
* frame, loses the race, and skips to the following one: **every fragment comes
|
|
263
|
+
* out at twice the requested length.**
|
|
264
|
+
*
|
|
265
|
+
* Measured on the live fleet 2026-08-07, camera 615, `-c:v copy` (D84):
|
|
266
|
+
*
|
|
267
|
+
* | slot | GOP | `-min_frag_duration` | median gap |
|
|
268
|
+
* | --- | --- | --- | --- |
|
|
269
|
+
* | 1280×720 | 40 f @ 10 fps = 4.0 s | 4000 ms | **7944 ms** |
|
|
270
|
+
* | 1280×720 | 40 f @ 10 fps = 4.0 s | 3600 ms | 3973 ms |
|
|
271
|
+
* | 3840×2160 | 100 f @ 25 fps = 4.0 s | 4000 ms | 8042 ms |
|
|
272
|
+
* | 3840×2160 | 100 f @ 25 fps = 4.0 s | 3600 ms | 3998 ms |
|
|
273
|
+
*
|
|
274
|
+
* A doubled fragment is not a cosmetic overshoot: HKSV requires every fragment
|
|
275
|
+
* to be no longer than the length the controller SELECTED, so the shipped-but-
|
|
276
|
+
* inert phase-1 sink would have violated the contract on its first real clip.
|
|
277
|
+
*
|
|
278
|
+
* 10 % is chosen against the two failures either side of it. Too small and
|
|
279
|
+
* ordinary jitter (measured spread 3953-4096 ms) re-loses the race; too large
|
|
280
|
+
* and a source with a key frame slightly EARLY than the grid gets cut there,
|
|
281
|
+
* yielding a short fragment for no reason.
|
|
282
|
+
*/
|
|
283
|
+
var FMP4_MIN_FRAG_MARGIN = .9;
|
|
284
|
+
/** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
|
|
285
|
+
function buildFmp4SinkArgs(sink) {
|
|
286
|
+
return [
|
|
287
|
+
"-movflags",
|
|
288
|
+
FMP4_MOVFLAGS,
|
|
289
|
+
"-min_frag_duration",
|
|
290
|
+
String(Math.max(0, Math.round(sink.fragmentMs * FMP4_MIN_FRAG_MARGIN * 1e3))),
|
|
291
|
+
"-f",
|
|
292
|
+
"mp4",
|
|
293
|
+
"pipe:1"
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
|
|
298
|
+
* audio optional so a source with no audio skips it instead of failing the
|
|
299
|
+
* whole invocation.
|
|
300
|
+
*/
|
|
301
|
+
function buildAudioSidecarArgs(sidecar) {
|
|
302
|
+
return [
|
|
303
|
+
"-map",
|
|
304
|
+
"0:a:0?",
|
|
305
|
+
...buildAudioArgs(sidecar.codec === "pcma" ? {
|
|
306
|
+
kind: "encode",
|
|
307
|
+
codec: "pcma",
|
|
308
|
+
sampleRateHz: 8e3,
|
|
309
|
+
channels: 1
|
|
310
|
+
} : AUDIO_PRESETS[sidecar.codec]),
|
|
311
|
+
...buildRtpOutputArgs({
|
|
312
|
+
url: sidecar.rtpUrl,
|
|
313
|
+
sdpFile: sidecar.sdpFile
|
|
314
|
+
})
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Assemble the full ffmpeg argument list. Layout:
|
|
319
|
+
*
|
|
320
|
+
* -hide_banner -loglevel <level>
|
|
321
|
+
* [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
|
|
322
|
+
* [<input.extraArgs>] │
|
|
323
|
+
* [-fflags <flag>…] │
|
|
324
|
+
* [-rtsp_transport tcp] │
|
|
325
|
+
* -i <url> ─┘ …repeated per extraInput
|
|
326
|
+
* [-filter_complex <graph>] ─ global, after the LAST -i
|
|
327
|
+
* [-map <video>] <video block> <threads> ─┐ OUTPUT options.
|
|
328
|
+
* [-map <audio>] <audio block> │
|
|
329
|
+
* <consumer outputArgs verbatim> │
|
|
330
|
+
* <sink> ─┘ terminal
|
|
331
|
+
*
|
|
332
|
+
* The `-map`s are DERIVED (see {@link resolveStreamMaps}), never constants:
|
|
333
|
+
* one input with no graph emits none at all, which is why the argv of every
|
|
334
|
+
* call site that predates the N-input extension is unchanged to the byte.
|
|
335
|
+
*
|
|
336
|
+
* {@link FfmpegInvocation.audioSidecar} is the one map that stays literal
|
|
337
|
+
* (`0:a:0?`): the sidecar is defined as the SOURCE's microphone on its own RTP
|
|
338
|
+
* output, not as whatever the main output happens to carry.
|
|
339
|
+
*/
|
|
340
|
+
/** The hardware-encode context for this invocation, or `undefined`. */
|
|
341
|
+
function hwEncodeFor(inv) {
|
|
342
|
+
return inv.hwEncodeDevice === void 0 ? void 0 : {
|
|
343
|
+
device: inv.hwEncodeDevice,
|
|
344
|
+
decodeHwAccel: inv.decodeHwAccel
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function buildFfmpegArgs(inv) {
|
|
348
|
+
const graph = inv.filterGraph ?? null;
|
|
349
|
+
const head = [
|
|
350
|
+
...logBannerArgs(inv.logLevel),
|
|
351
|
+
...inv.hwEncodeDevice !== void 0 ? ["-vaapi_device", inv.hwEncodeDevice.renderNode] : [],
|
|
352
|
+
...buildInputArgs(inv.input, inv.decodeHwAccel),
|
|
353
|
+
...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
|
|
354
|
+
...graph ? ["-filter_complex", graph.graph] : []
|
|
355
|
+
];
|
|
356
|
+
const threadArgs = [...inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [], ...inv.filterThreadCount !== void 0 && inv.filterThreadCount > 0 ? ["-filter_threads", String(inv.filterThreadCount)] : []];
|
|
357
|
+
const maps = resolveStreamMaps(inv);
|
|
358
|
+
const videoMapArgs = maps.video ? ["-map", maps.video] : [];
|
|
359
|
+
const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
|
|
360
|
+
if (inv.sink.kind === "rtp-outputs") {
|
|
361
|
+
const videoLeg = inv.sink.video ? [
|
|
362
|
+
"-an",
|
|
363
|
+
"-map",
|
|
364
|
+
maps.video ?? "0:v:0",
|
|
365
|
+
...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
|
|
366
|
+
...threadArgs,
|
|
367
|
+
...inv.outputArgs,
|
|
368
|
+
...buildRtpOutputArgs(inv.sink.video)
|
|
369
|
+
] : [];
|
|
370
|
+
const audioLeg = inv.sink.audio ? [
|
|
371
|
+
"-vn",
|
|
372
|
+
"-map",
|
|
373
|
+
maps.audio ?? "0:a:0?",
|
|
374
|
+
...buildAudioArgs(inv.audio),
|
|
375
|
+
...buildRtpOutputArgs(inv.sink.audio)
|
|
376
|
+
] : [];
|
|
377
|
+
return [
|
|
378
|
+
...head,
|
|
379
|
+
...videoLeg,
|
|
380
|
+
...audioLeg
|
|
381
|
+
];
|
|
382
|
+
}
|
|
383
|
+
const mutedAudio = isElementaryVideoSink(inv.sink) || inv.audio.kind === "none";
|
|
384
|
+
const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
|
|
385
|
+
return [
|
|
386
|
+
...head,
|
|
387
|
+
...videoMapArgs,
|
|
388
|
+
...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
|
|
389
|
+
...threadArgs,
|
|
390
|
+
...mutedAudio ? [] : audioMapArgs,
|
|
391
|
+
...audioArgs,
|
|
392
|
+
...inv.outputArgs,
|
|
393
|
+
...buildStdoutOrRtspSinkArgs(inv.sink),
|
|
394
|
+
...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Hardware ENCODER ids per decode-hwaccel backend — a static, deterministic
|
|
399
|
+
* map (the same shape the reference NVR uses: platform → encoder, no probe).
|
|
400
|
+
*/
|
|
401
|
+
var ENCODER_IDS_BY_BACKEND = {
|
|
402
|
+
videotoolbox: {
|
|
403
|
+
h264: "h264_videotoolbox",
|
|
404
|
+
h265: "hevc_videotoolbox"
|
|
405
|
+
},
|
|
406
|
+
vaapi: {
|
|
407
|
+
h264: "h264_vaapi",
|
|
408
|
+
h265: "hevc_vaapi"
|
|
409
|
+
},
|
|
410
|
+
qsv: {
|
|
411
|
+
h264: "h264_qsv",
|
|
412
|
+
h265: "hevc_qsv"
|
|
413
|
+
},
|
|
414
|
+
cuda: {
|
|
415
|
+
h264: "h264_nvenc",
|
|
416
|
+
h265: "hevc_nvenc"
|
|
417
|
+
},
|
|
418
|
+
nvdec: {
|
|
419
|
+
h264: "h264_nvenc",
|
|
420
|
+
h265: "hevc_nvenc"
|
|
421
|
+
},
|
|
422
|
+
amf: {
|
|
423
|
+
h264: "h264_amf",
|
|
424
|
+
h265: "hevc_amf"
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
/**
|
|
428
|
+
* The hardware encoder for a target codec on `backend`, or the software one.
|
|
429
|
+
* `'auto'` is NOT a backend identity (it is an instruction to ffmpeg), so it
|
|
430
|
+
* maps to software encoding.
|
|
431
|
+
*/
|
|
432
|
+
function pickVideoEncoder(target, backend, useHardware) {
|
|
433
|
+
const software = target === "h264" ? "libx264" : "libx265";
|
|
434
|
+
if (!useHardware || backend === null || isSoftwareDecode(backend) || backend === "auto") return software;
|
|
435
|
+
const ids = ENCODER_IDS_BY_BACKEND[backend.toLowerCase()];
|
|
436
|
+
if (!ids) return software;
|
|
437
|
+
return target === "h264" ? ids.h264 : ids.h265;
|
|
438
|
+
}
|
|
439
|
+
/** Map an `EncodeProfile.audio` to an audio plan. */
|
|
440
|
+
function audioPlanFromEncodeProfile(audio) {
|
|
441
|
+
if (audio === "passthrough") return { kind: "none" };
|
|
442
|
+
if (audio.codec === "copy") return { kind: "copy" };
|
|
443
|
+
return {
|
|
444
|
+
kind: "encode",
|
|
445
|
+
codec: audio.codec,
|
|
446
|
+
...audio.bitrateKbps !== void 0 ? { bitrateKbps: audio.bitrateKbps } : {},
|
|
447
|
+
...audio.sampleRateHz !== void 0 ? { sampleRateHz: audio.sampleRateHz } : {},
|
|
448
|
+
...audio.channels !== void 0 ? { channels: audio.channels } : {}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Adapt an `EncodeProfile` (the operator/consumer-facing shape) into an
|
|
453
|
+
* {@link FfmpegInvocation}. This is the ONLY bridge between the two models —
|
|
454
|
+
* a second one is how the repo grew two argv builders that disagreed about
|
|
455
|
+
* hardware.
|
|
456
|
+
*
|
|
457
|
+
* Smart video copy: when the source already speaks the requested codec the
|
|
458
|
+
* encode block is elided entirely and ffmpeg runs as a re-muxer on the video
|
|
459
|
+
* plane. Width / height / fps / bitrate in the profile are a downstream BUDGET,
|
|
460
|
+
* not a forced rescale.
|
|
461
|
+
*/
|
|
462
|
+
function invocationFromEncodeProfile(input) {
|
|
463
|
+
const v = input.profile.video;
|
|
464
|
+
const shouldCopy = v.codec === "copy" || input.forceReencode !== true && v.codec === input.sourceCodec;
|
|
465
|
+
const scale = v.width !== void 0 && v.height !== void 0 ? {
|
|
466
|
+
mode: "fit",
|
|
467
|
+
width: v.width,
|
|
468
|
+
height: v.height
|
|
469
|
+
} : null;
|
|
470
|
+
const target = v.codec === "h265" ? "h265" : "h264";
|
|
471
|
+
const video = shouldCopy ? {
|
|
472
|
+
kind: "copy",
|
|
473
|
+
...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
|
|
474
|
+
} : {
|
|
475
|
+
kind: "encode",
|
|
476
|
+
encoder: pickVideoEncoder(target, input.decodeHwAccel, input.hardwareEncoders === true),
|
|
477
|
+
scale,
|
|
478
|
+
...v.preset !== void 0 ? { preset: v.preset } : {},
|
|
479
|
+
...v.tune !== void 0 ? { tune: v.tune } : {},
|
|
480
|
+
...v.singleSlicePerFrame !== void 0 ? { singleSlicePerFrame: v.singleSlicePerFrame } : {},
|
|
481
|
+
...v.profile !== void 0 ? { profile: v.profile } : {},
|
|
482
|
+
...v.level !== void 0 ? { level: v.level } : {},
|
|
483
|
+
...input.pixelFormat !== void 0 ? { pixelFormat: input.pixelFormat } : {},
|
|
484
|
+
...v.fps !== void 0 ? { fps: v.fps } : {},
|
|
485
|
+
...v.gopFrames !== void 0 ? { gopFrames: v.gopFrames } : {},
|
|
486
|
+
...input.forceKeyFramesSeconds !== void 0 ? { forceKeyFramesSeconds: input.forceKeyFramesSeconds } : {},
|
|
487
|
+
...v.bf !== void 0 ? { bf: v.bf } : {},
|
|
488
|
+
...v.bitrateKbps !== void 0 ? { bitrateKbps: v.bitrateKbps } : {},
|
|
489
|
+
...input.rateControl !== void 0 ? { rateControl: input.rateControl } : {},
|
|
490
|
+
...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
|
|
491
|
+
};
|
|
492
|
+
return {
|
|
493
|
+
logLevel: input.logLevel ?? "error",
|
|
494
|
+
decodeHwAccel: input.decodeHwAccel,
|
|
495
|
+
input: {
|
|
496
|
+
url: input.sourceUrl,
|
|
497
|
+
rtspTransport: "tcp",
|
|
498
|
+
fflags: ["+discardcorrupt"],
|
|
499
|
+
...input.profile.inputArgs?.length ? { extraArgs: input.profile.inputArgs } : {}
|
|
500
|
+
},
|
|
501
|
+
video,
|
|
502
|
+
audio: audioPlanFromEncodeProfile(input.profile.audio),
|
|
503
|
+
threadCount: input.threadCount ?? 0,
|
|
504
|
+
outputArgs: input.profile.outputArgs ?? [],
|
|
505
|
+
sink: input.sink,
|
|
506
|
+
...input.audioSidecar !== void 0 ? { audioSidecar: input.audioSidecar } : {}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/ffmpeg/fmp4-box-splitter.ts
|
|
511
|
+
var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
|
|
512
|
+
/** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
|
|
513
|
+
var BOX_HEADER_BYTES = 8;
|
|
514
|
+
var LARGE_BOX_HEADER_BYTES = 16;
|
|
515
|
+
var Fmp4BoxSplitter = class {
|
|
516
|
+
maxUnitBytes;
|
|
517
|
+
/** Bytes of the CURRENT unit plus any partial box after it. */
|
|
518
|
+
buffer = new Uint8Array(0);
|
|
519
|
+
/** Where the current unit starts inside {@link buffer}. */
|
|
520
|
+
unitStart = 0;
|
|
521
|
+
/** Where the box scanner has reached inside {@link buffer}. */
|
|
522
|
+
cursor = 0;
|
|
523
|
+
state = "init";
|
|
524
|
+
nextSequence = 0;
|
|
525
|
+
faultReason = null;
|
|
526
|
+
interstitial = /* @__PURE__ */ new Set();
|
|
527
|
+
constructor(options = {}) {
|
|
528
|
+
this.maxUnitBytes = options.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Non-null once the stream cannot be split. The splitter emits nothing
|
|
532
|
+
* further, so a caller polls this to kill the child rather than watching a
|
|
533
|
+
* silent stall — a fragmenter that quietly stops producing looks exactly like
|
|
534
|
+
* a camera with no motion.
|
|
535
|
+
*/
|
|
536
|
+
get fault() {
|
|
537
|
+
return this.faultReason;
|
|
538
|
+
}
|
|
539
|
+
/** Bytes currently held. The memory bound, observable rather than asserted. */
|
|
540
|
+
get pendingBytes() {
|
|
541
|
+
return this.buffer.length - this.unitStart;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
|
|
545
|
+
* a stray `sidx`. Reported rather than dropped in silence: they are legal and
|
|
546
|
+
* useless to a fragment consumer, but a type nobody expected showing up here
|
|
547
|
+
* is the first symptom of a muxer that is not writing what we think it is.
|
|
548
|
+
*/
|
|
549
|
+
get discardedInterstitialTypes() {
|
|
550
|
+
return [...this.interstitial];
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Feed bytes; get back whatever units completed. Returns `[]` once faulted.
|
|
554
|
+
*/
|
|
555
|
+
push(chunk) {
|
|
556
|
+
if (this.faultReason !== null || chunk.length === 0) return [];
|
|
557
|
+
this.append(chunk);
|
|
558
|
+
if (this.pendingBytes > this.maxUnitBytes) return this.fail(`a single fMP4 unit exceeded ${this.maxUnitBytes} bytes — this stream is not fragmented`);
|
|
559
|
+
return this.drainBoxes();
|
|
560
|
+
}
|
|
561
|
+
append(chunk) {
|
|
562
|
+
if (this.buffer.length === 0) {
|
|
563
|
+
this.buffer = chunk.slice();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const next = new Uint8Array(this.buffer.length + chunk.length);
|
|
567
|
+
next.set(this.buffer, 0);
|
|
568
|
+
next.set(chunk, this.buffer.length);
|
|
569
|
+
this.buffer = next;
|
|
570
|
+
}
|
|
571
|
+
/** Consume every COMPLETE top-level box now in the buffer. */
|
|
572
|
+
drainBoxes() {
|
|
573
|
+
const units = [];
|
|
574
|
+
for (;;) {
|
|
575
|
+
const header = this.readHeader();
|
|
576
|
+
if (this.faultReason !== null) return units;
|
|
577
|
+
if (header === null) break;
|
|
578
|
+
if (this.cursor + header.totalBytes > this.buffer.length) break;
|
|
579
|
+
const boxStart = this.cursor;
|
|
580
|
+
const boxEnd = boxStart + header.totalBytes;
|
|
581
|
+
this.cursor = boxEnd;
|
|
582
|
+
const unit = this.consumeBox(header.type, boxStart, boxEnd);
|
|
583
|
+
if (this.faultReason !== null) return units;
|
|
584
|
+
if (unit !== null) units.push(unit);
|
|
585
|
+
}
|
|
586
|
+
this.compact();
|
|
587
|
+
return units;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Apply one box to the state machine. Returns a unit when this box CLOSED
|
|
591
|
+
* one, `null` otherwise.
|
|
592
|
+
*/
|
|
593
|
+
consumeBox(type, boxStart, boxEnd) {
|
|
594
|
+
if (this.state === "init") {
|
|
595
|
+
if (type !== "moof") return null;
|
|
596
|
+
if (boxStart === this.unitStart) {
|
|
597
|
+
this.fail("a moof arrived before any initialisation box — there is no ftyp/moov to send");
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
const init = this.emit("init", this.unitStart, boxStart);
|
|
601
|
+
this.unitStart = boxStart;
|
|
602
|
+
this.state = "fragment";
|
|
603
|
+
return init;
|
|
604
|
+
}
|
|
605
|
+
if (this.state === "idle") {
|
|
606
|
+
if (type !== "moof") {
|
|
607
|
+
this.interstitial.add(type);
|
|
608
|
+
this.unitStart = boxEnd;
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
this.unitStart = boxStart;
|
|
612
|
+
this.state = "fragment";
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
if (type !== "mdat") return null;
|
|
616
|
+
const fragment = this.emit("fragment", this.unitStart, boxEnd);
|
|
617
|
+
this.unitStart = boxEnd;
|
|
618
|
+
this.state = "idle";
|
|
619
|
+
return fragment;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Parse the header at {@link cursor}, or `null` when too few bytes have
|
|
623
|
+
* arrived to know. Faults on a size the splitter cannot honour.
|
|
624
|
+
*/
|
|
625
|
+
readHeader() {
|
|
626
|
+
const available = this.buffer.length - this.cursor;
|
|
627
|
+
if (available < BOX_HEADER_BYTES) return null;
|
|
628
|
+
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
629
|
+
const size = view.getUint32(this.cursor);
|
|
630
|
+
const type = String.fromCharCode(this.buffer[this.cursor + 4] ?? 0, this.buffer[this.cursor + 5] ?? 0, this.buffer[this.cursor + 6] ?? 0, this.buffer[this.cursor + 7] ?? 0);
|
|
631
|
+
if (size === 0) {
|
|
632
|
+
this.fail(`box "${type}" declares size 0 (to EOF) — an unbounded box cannot be fragmented`);
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
if (size === 1) {
|
|
636
|
+
if (available < LARGE_BOX_HEADER_BYTES) return null;
|
|
637
|
+
const large = view.getBigUint64(this.cursor + BOX_HEADER_BYTES);
|
|
638
|
+
if (large > BigInt(this.maxUnitBytes)) {
|
|
639
|
+
this.fail(`box "${type}" declares ${large} bytes, over the ${this.maxUnitBytes} byte bound`);
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
type,
|
|
644
|
+
totalBytes: Number(large)
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
if (size < BOX_HEADER_BYTES) {
|
|
648
|
+
this.fail(`box "${type}" declares an impossible size of ${size} bytes`);
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
type,
|
|
653
|
+
totalBytes: size
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
emit(kind, start, end) {
|
|
657
|
+
const sequence = this.nextSequence;
|
|
658
|
+
this.nextSequence += 1;
|
|
659
|
+
return {
|
|
660
|
+
kind,
|
|
661
|
+
data: this.buffer.slice(start, end),
|
|
662
|
+
sequence
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Drop everything already emitted or discarded. Without this the buffer is
|
|
667
|
+
* the whole stream and the process dies in hours, not minutes.
|
|
668
|
+
*/
|
|
669
|
+
compact() {
|
|
670
|
+
if (this.unitStart === 0) return;
|
|
671
|
+
this.buffer = this.buffer.slice(this.unitStart);
|
|
672
|
+
this.cursor -= this.unitStart;
|
|
673
|
+
this.unitStart = 0;
|
|
674
|
+
}
|
|
675
|
+
fail(reason) {
|
|
676
|
+
this.faultReason = reason;
|
|
677
|
+
this.buffer = new Uint8Array(0);
|
|
678
|
+
this.unitStart = 0;
|
|
679
|
+
this.cursor = 0;
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/utils/canonical-hash.ts
|
|
685
|
+
/**
|
|
686
|
+
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
687
|
+
* canonical form sorts object keys alphabetically at every depth so two
|
|
688
|
+
* structurally-equal inputs with different key insertion orders produce
|
|
689
|
+
* the same hash. Returns a 64-char lowercase hex digest.
|
|
690
|
+
*
|
|
691
|
+
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
692
|
+
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
693
|
+
* the last applied state — preventing user-visible "re-discovery"
|
|
694
|
+
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
695
|
+
* `DeviceBindingsChanged` for every cap registration, which without
|
|
696
|
+
* this guard would propagate redundant pushes.
|
|
697
|
+
*
|
|
698
|
+
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
699
|
+
* subscription. The proper fix is a single "device ready" lifecycle
|
|
700
|
+
* barrier so exports react only when the full cap set has landed —
|
|
701
|
+
* tracked separately for post-HA-integration work.
|
|
702
|
+
*/
|
|
703
|
+
function canonicalHash(value) {
|
|
704
|
+
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
705
|
+
return createHash("sha256").update(canonical ?? "").digest("hex");
|
|
706
|
+
}
|
|
707
|
+
function replaceWithSortedKeys(_key, value) {
|
|
708
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
709
|
+
const obj = value;
|
|
710
|
+
const out = {};
|
|
711
|
+
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
712
|
+
return out;
|
|
713
|
+
}
|
|
714
|
+
return value;
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/catalogs/sensor-active-state.ts
|
|
718
|
+
/**
|
|
719
|
+
* LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
|
|
720
|
+
* valutatore puro del suo FRONTE.
|
|
721
|
+
*
|
|
722
|
+
* Viveva dentro il builtin virtual-doorbell
|
|
723
|
+
* (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
|
|
724
|
+
* predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
|
|
725
|
+
* `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
|
|
726
|
+
* tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
|
|
727
|
+
* fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
|
|
728
|
+
* SPOSTA qui e il doorbell la ri-esporta.
|
|
729
|
+
*
|
|
730
|
+
* ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
|
|
731
|
+
* due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
|
|
732
|
+
* regola" (e include `presence`, `cover`, `alarm-panel`), questa a "qual è il
|
|
733
|
+
* booleano il cui FRONTE conta". Vocabolari deliberatamente diversi.
|
|
734
|
+
*/
|
|
735
|
+
/**
|
|
736
|
+
* Known binary / switch source caps → the boolean slice field whose
|
|
737
|
+
* false→true rise counts as ACTIVE. Every entry is "fire on active".
|
|
738
|
+
* Sensors whose "active" reading is not a plain boolean (presence's string
|
|
739
|
+
* state, connectivity's connected flag) are deliberately excluded — a
|
|
740
|
+
* reconnect is not a doorbell press, and it is not a recording either.
|
|
741
|
+
*/
|
|
742
|
+
var SOURCE_CAP_ACTIVE_FIELD = {
|
|
743
|
+
contact: "entryOpen",
|
|
744
|
+
binary: "on",
|
|
745
|
+
switch: "on",
|
|
746
|
+
motion: "detected",
|
|
747
|
+
flood: "flooded",
|
|
748
|
+
gas: "detected",
|
|
749
|
+
smoke: "detected",
|
|
750
|
+
"carbon-monoxide": "detected",
|
|
751
|
+
vibration: "detected",
|
|
752
|
+
tamper: "tampered"
|
|
753
|
+
};
|
|
754
|
+
/**
|
|
755
|
+
* The same caps → the slice field carrying the ms-epoch timestamp of the
|
|
756
|
+
* last transition. Every source cap MUST appear here (guarded by a spec):
|
|
757
|
+
* without a transition timestamp the evaluator cannot tell a genuine rise
|
|
758
|
+
* from a boot-time hydration when the FIRST slice it ever sees is already
|
|
759
|
+
* active, and errs towards silence — swallowing the rise.
|
|
760
|
+
*
|
|
761
|
+
* These timestamps are UPSTREAM ones, not ingest ones: the Home Assistant
|
|
762
|
+
* provider derives them from `state.last_changed`, so they survive our own
|
|
763
|
+
* restarts and correctly read as "hours ago" for a state that has been
|
|
764
|
+
* active for hours. `motion` names its rise timestamp `lastDetectedAt`.
|
|
765
|
+
*/
|
|
766
|
+
var SOURCE_CAP_CHANGED_AT_FIELD = {
|
|
767
|
+
contact: "lastChangedAt",
|
|
768
|
+
binary: "lastChangedAt",
|
|
769
|
+
switch: "lastChangedAt",
|
|
770
|
+
motion: "lastDetectedAt",
|
|
771
|
+
flood: "lastChangedAt",
|
|
772
|
+
gas: "lastChangedAt",
|
|
773
|
+
smoke: "lastChangedAt",
|
|
774
|
+
"carbon-monoxide": "lastChangedAt",
|
|
775
|
+
vibration: "lastChangedAt",
|
|
776
|
+
tamper: "lastChangedAt"
|
|
777
|
+
};
|
|
778
|
+
/** Cap names whose presence in a device's bindings qualify it as a source. */
|
|
779
|
+
var SOURCE_CAPS = Object.keys(SOURCE_CAP_ACTIVE_FIELD);
|
|
780
|
+
/**
|
|
781
|
+
* Device `type` values (from `DeviceType`) that can host a binary/switch
|
|
782
|
+
* source cap. Used by the camera's `device-multiselect` picker as the
|
|
783
|
+
* CLIENT-SIDE filter, alongside `SOURCE_CAPS`.
|
|
784
|
+
*
|
|
785
|
+
* Why types and not caps alone: the shared picker filters
|
|
786
|
+
* `deviceManager.listAll` rows client-side, and those rows carry only the
|
|
787
|
+
* device's advertised `features` — NOT its registered cap list. On the live
|
|
788
|
+
* cluster binary sensors and switches advertise EMPTY features (features
|
|
789
|
+
* mirror only a handful of caps like `motion-trigger`), so a caps-only
|
|
790
|
+
* filter matched against `features` would list nothing (the very bug this
|
|
791
|
+
* replaced, which relied on the now-empty `getAllBindings`). Matching by
|
|
792
|
+
* `type` is the reliable client-side signal; the union with `SOURCE_CAPS`
|
|
793
|
+
* still captures any device that DOES advertise a source-cap feature.
|
|
794
|
+
* `sensor` covers contact/motion/flood/gas/smoke/CO/vibration/tamper,
|
|
795
|
+
* `switch` covers switches, `control` covers generic binary actuators.
|
|
796
|
+
*/
|
|
797
|
+
var SOURCE_DEVICE_TYPES = [
|
|
798
|
+
"sensor",
|
|
799
|
+
"switch",
|
|
800
|
+
"control"
|
|
801
|
+
];
|
|
802
|
+
/** True when a cap is a recognised binary/switch source. */
|
|
803
|
+
function isSourceCap(capName) {
|
|
804
|
+
return Object.prototype.hasOwnProperty.call(SOURCE_CAP_ACTIVE_FIELD, capName);
|
|
805
|
+
}
|
|
806
|
+
/** Extract the "active" boolean a source cap's slice carries, or null when
|
|
807
|
+
* the cap is unknown or the field is missing / non-boolean. */
|
|
808
|
+
function sliceActiveValue(capName, slice) {
|
|
809
|
+
const field = SOURCE_CAP_ACTIVE_FIELD[capName];
|
|
810
|
+
if (field === void 0) return null;
|
|
811
|
+
const raw = slice[field];
|
|
812
|
+
return typeof raw === "boolean" ? raw : null;
|
|
813
|
+
}
|
|
814
|
+
/** Ms-epoch transition timestamp a source cap's slice carries, or null when
|
|
815
|
+
* it is absent, non-numeric or the zero "never observed" sentinel. */
|
|
816
|
+
function sliceChangedAt(capName, slice) {
|
|
817
|
+
const field = SOURCE_CAP_CHANGED_AT_FIELD[capName];
|
|
818
|
+
if (field === void 0) return null;
|
|
819
|
+
const raw = slice[field];
|
|
820
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return null;
|
|
821
|
+
return raw;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* How recent a source's own transition timestamp must be for a FIRST
|
|
825
|
+
* sighting that is already active to count as a genuine rise rather than a
|
|
826
|
+
* hydration of long-standing state.
|
|
827
|
+
*/
|
|
828
|
+
var DEFAULT_FIRST_SIGHTING_FRESHNESS_MS = 3e4;
|
|
829
|
+
/**
|
|
830
|
+
* The source-independent edge rule. The caller supplies an already extracted
|
|
831
|
+
* boolean level and source timestamp; this function owns first-sighting
|
|
832
|
+
* freshness and false-to-true detection.
|
|
833
|
+
*/
|
|
834
|
+
function evaluateSensorLevelEdge(input) {
|
|
835
|
+
const timestamp = input.changedAt;
|
|
836
|
+
const freshFloor = Math.max(input.startedAtMs, input.nowMs - input.firstSightingFreshnessMs);
|
|
837
|
+
const firstRise = input.priorLevel === void 0 && input.level && timestamp !== null && Number.isFinite(timestamp) && timestamp >= freshFloor;
|
|
838
|
+
const transitionRise = input.priorLevel === false && input.level && timestamp !== null && Number.isFinite(timestamp);
|
|
839
|
+
if ((firstRise || transitionRise) && timestamp !== null) return {
|
|
840
|
+
kind: "rise",
|
|
841
|
+
timestamp
|
|
842
|
+
};
|
|
843
|
+
return {
|
|
844
|
+
kind: "none",
|
|
845
|
+
nextPriorLevel: input.level
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* IL fronte. Puro: nessun orologio proprio, nessuna memoria — il chiamante
|
|
850
|
+
* porta `prior`, `nowMs` e il proprio `startedAtMs`.
|
|
851
|
+
*
|
|
852
|
+
* Il caso della PRIMA slice già attiva è trattato esplicitamente e vale come
|
|
853
|
+
* fronte solo se il timestamp UPSTREAM della transizione è posteriore a
|
|
854
|
+
* `startedAtMs` **e** entro `firstSightingFreshnessMs`. Entrambe le metà
|
|
855
|
+
* servono: la sola freschezza scatterebbe su un'idratazione al boot di uno
|
|
856
|
+
* stato flippato pochi secondi prima del riavvio, e il solo "dopo che abbiamo
|
|
857
|
+
* iniziato" scatterebbe, su un processo di lunga vita, per una sorgente
|
|
858
|
+
* adottata oggi il cui stato è cambiato ieri. Il caso ambiguo ERRA VERSO IL
|
|
859
|
+
* SILENZIO e lo dichiara (`baseline-seeded-stale-active`).
|
|
860
|
+
*/
|
|
861
|
+
function evaluateSensorEdge(input) {
|
|
862
|
+
const value = sliceActiveValue(input.capName, input.slice);
|
|
863
|
+
if (value === null) return {
|
|
864
|
+
edge: "none",
|
|
865
|
+
value: null,
|
|
866
|
+
reason: isSourceCap(input.capName) ? "non-boolean-value" : "unknown-cap"
|
|
867
|
+
};
|
|
868
|
+
if (evaluateSensorLevelEdge({
|
|
869
|
+
level: value,
|
|
870
|
+
changedAt: sliceChangedAt(input.capName, input.slice),
|
|
871
|
+
priorLevel: input.prior,
|
|
872
|
+
startedAtMs: input.startedAtMs,
|
|
873
|
+
nowMs: input.nowMs,
|
|
874
|
+
firstSightingFreshnessMs: input.firstSightingFreshnessMs
|
|
875
|
+
}).kind === "rise") return {
|
|
876
|
+
edge: "rising",
|
|
877
|
+
value: true
|
|
878
|
+
};
|
|
879
|
+
if (input.prior === void 0) {
|
|
880
|
+
if (!value) return {
|
|
881
|
+
edge: "none",
|
|
882
|
+
value,
|
|
883
|
+
reason: "baseline-seeded-inactive"
|
|
884
|
+
};
|
|
885
|
+
return {
|
|
886
|
+
edge: "none",
|
|
887
|
+
value,
|
|
888
|
+
reason: "baseline-seeded-stale-active"
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
if (input.prior === value) return {
|
|
892
|
+
edge: "none",
|
|
893
|
+
value,
|
|
894
|
+
reason: "no-change"
|
|
895
|
+
};
|
|
896
|
+
if (!value) return {
|
|
897
|
+
edge: "none",
|
|
898
|
+
value,
|
|
899
|
+
reason: "falling-edge"
|
|
900
|
+
};
|
|
901
|
+
return {
|
|
902
|
+
edge: "none",
|
|
903
|
+
value,
|
|
904
|
+
reason: "missing-timestamp"
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
//#endregion
|
|
908
|
+
//#region src/expression/errors.ts
|
|
909
|
+
/**
|
|
910
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
911
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
912
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
913
|
+
*/
|
|
914
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
915
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
916
|
+
var ExpressionParseError = class extends Error {
|
|
917
|
+
position;
|
|
918
|
+
constructor(message, position) {
|
|
919
|
+
super(message);
|
|
920
|
+
this.name = "ExpressionParseError";
|
|
921
|
+
this.position = position;
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
925
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
926
|
+
var ExpressionEvalError = class extends Error {
|
|
927
|
+
constructor(message) {
|
|
928
|
+
super(message);
|
|
929
|
+
this.name = "ExpressionEvalError";
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/expression/builtins.ts
|
|
934
|
+
function isStatefulBuiltin(name) {
|
|
935
|
+
return name === "rose" || name === "count";
|
|
936
|
+
}
|
|
937
|
+
function asFiniteNumber(value, name, index) {
|
|
938
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
939
|
+
return value;
|
|
940
|
+
}
|
|
941
|
+
function asString(value, name, index) {
|
|
942
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
943
|
+
return value;
|
|
944
|
+
}
|
|
945
|
+
function finiteResult(value, name) {
|
|
946
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
947
|
+
return value;
|
|
948
|
+
}
|
|
949
|
+
function allFiniteNumbers(args, name) {
|
|
950
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
951
|
+
}
|
|
952
|
+
function asBoolean(value, name, index) {
|
|
953
|
+
if (typeof value !== "boolean") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a boolean`);
|
|
954
|
+
return value;
|
|
955
|
+
}
|
|
956
|
+
/** A plain decimal (optional sign, fraction, exponent) — never hex, never `Infinity`, never a unit suffix. */
|
|
957
|
+
var DECIMAL = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
958
|
+
/**
|
|
959
|
+
* `number(x)`: a finite number, or a string that IS one, as a number; every
|
|
960
|
+
* other value — `''`, `'unavailable'`, a boolean, null — is `null`
|
|
961
|
+
* (unavailable, never 0: D393). The one bridge from a string-valued source (an
|
|
962
|
+
* HA sensor with a unit and no device_class is an `enum-sensor`) to a number.
|
|
963
|
+
*/
|
|
964
|
+
function toNumberOrNull(value) {
|
|
965
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
966
|
+
if (typeof value !== "string") return null;
|
|
967
|
+
const trimmed = value.trim();
|
|
968
|
+
if (!DECIMAL.test(trimmed)) return null;
|
|
969
|
+
const parsed = Number(trimmed);
|
|
970
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
971
|
+
}
|
|
972
|
+
var DEFAULT_ROSE_DEBOUNCE_MS = 2e3;
|
|
973
|
+
function evaluateRose(level, changedAt, debounceMs, call, field, env) {
|
|
974
|
+
const active = asBoolean(level, "rose", 0);
|
|
975
|
+
const timestamp = typeof changedAt === "number" && Number.isFinite(changedAt) ? changedAt : null;
|
|
976
|
+
const debounce = debounceMs === void 0 ? DEFAULT_ROSE_DEBOUNCE_MS : asFiniteNumber(debounceMs, "rose", 2);
|
|
977
|
+
if (debounce < 0) throw new ExpressionEvalError("rose: debounce must be a non-negative finite number");
|
|
978
|
+
const verdict = evaluateSensorLevelEdge({
|
|
979
|
+
level: active,
|
|
980
|
+
changedAt: timestamp,
|
|
981
|
+
priorLevel: call.priorLevel,
|
|
982
|
+
startedAtMs: env.startedAtMs,
|
|
983
|
+
nowMs: env.nowMs,
|
|
984
|
+
firstSightingFreshnessMs: DEFAULT_FIRST_SIGHTING_FRESHNESS_MS
|
|
985
|
+
});
|
|
986
|
+
call.priorLevel = verdict.kind === "rise" ? true : verdict.nextPriorLevel;
|
|
987
|
+
if (verdict.kind === "none") return env.currentOutput;
|
|
988
|
+
if (field.acceptedEdgeAt !== null && verdict.timestamp - field.acceptedEdgeAt < debounce) return env.currentOutput;
|
|
989
|
+
field.acceptedEdgeAt = verdict.timestamp;
|
|
990
|
+
return verdict.timestamp;
|
|
991
|
+
}
|
|
992
|
+
function evaluateCount(input, call, currentOutput) {
|
|
993
|
+
const currentCount = typeof currentOutput === "number" && Number.isFinite(currentOutput) && currentOutput >= 0 ? currentOutput : 0;
|
|
994
|
+
if (typeof input !== "number" || !Number.isFinite(input)) return currentCount;
|
|
995
|
+
if (call.highWater === null) {
|
|
996
|
+
call.highWater = input;
|
|
997
|
+
return currentCount;
|
|
998
|
+
}
|
|
999
|
+
if (input <= call.highWater) return currentCount;
|
|
1000
|
+
call.highWater = input;
|
|
1001
|
+
return currentCount + 1;
|
|
1002
|
+
}
|
|
1003
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
1004
|
+
var table = {
|
|
1005
|
+
min: {
|
|
1006
|
+
minArgs: 1,
|
|
1007
|
+
maxArgs: INF,
|
|
1008
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
1009
|
+
},
|
|
1010
|
+
max: {
|
|
1011
|
+
minArgs: 1,
|
|
1012
|
+
maxArgs: INF,
|
|
1013
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
1014
|
+
},
|
|
1015
|
+
abs: {
|
|
1016
|
+
minArgs: 1,
|
|
1017
|
+
maxArgs: 1,
|
|
1018
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
1019
|
+
},
|
|
1020
|
+
floor: {
|
|
1021
|
+
minArgs: 1,
|
|
1022
|
+
maxArgs: 1,
|
|
1023
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
1024
|
+
},
|
|
1025
|
+
ceil: {
|
|
1026
|
+
minArgs: 1,
|
|
1027
|
+
maxArgs: 1,
|
|
1028
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
1029
|
+
},
|
|
1030
|
+
sqrt: {
|
|
1031
|
+
minArgs: 1,
|
|
1032
|
+
maxArgs: 1,
|
|
1033
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
1034
|
+
},
|
|
1035
|
+
round: {
|
|
1036
|
+
minArgs: 1,
|
|
1037
|
+
maxArgs: 2,
|
|
1038
|
+
apply: (args) => {
|
|
1039
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
1040
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
1041
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
1042
|
+
const factor = 10 ** digits;
|
|
1043
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
pow: {
|
|
1047
|
+
minArgs: 2,
|
|
1048
|
+
maxArgs: 2,
|
|
1049
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
1050
|
+
},
|
|
1051
|
+
clamp: {
|
|
1052
|
+
minArgs: 3,
|
|
1053
|
+
maxArgs: 3,
|
|
1054
|
+
apply: (args) => {
|
|
1055
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
1056
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
1057
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
1058
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
1059
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
avg: {
|
|
1063
|
+
minArgs: 1,
|
|
1064
|
+
maxArgs: INF,
|
|
1065
|
+
apply: (args) => {
|
|
1066
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
1067
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
1068
|
+
}
|
|
1069
|
+
},
|
|
1070
|
+
sum: {
|
|
1071
|
+
minArgs: 1,
|
|
1072
|
+
maxArgs: INF,
|
|
1073
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
1074
|
+
},
|
|
1075
|
+
coalesce: {
|
|
1076
|
+
minArgs: 1,
|
|
1077
|
+
maxArgs: INF,
|
|
1078
|
+
apply: (args) => {
|
|
1079
|
+
for (const a of args) if (a !== null) return a;
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
age: {
|
|
1084
|
+
minArgs: 2,
|
|
1085
|
+
maxArgs: 2,
|
|
1086
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
1087
|
+
},
|
|
1088
|
+
convert: {
|
|
1089
|
+
minArgs: 3,
|
|
1090
|
+
maxArgs: 3,
|
|
1091
|
+
apply: (args, hooks) => {
|
|
1092
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
1093
|
+
const from = asString(args[1], "convert", 1).trim();
|
|
1094
|
+
const to = asString(args[2], "convert", 2).trim();
|
|
1095
|
+
if (hooks.convert) {
|
|
1096
|
+
const out = hooks.convert(x, from, to);
|
|
1097
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
1098
|
+
return finiteResult(out, "convert");
|
|
1099
|
+
}
|
|
1100
|
+
if (from === to) return x;
|
|
1101
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
any: {
|
|
1105
|
+
minArgs: 1,
|
|
1106
|
+
maxArgs: INF,
|
|
1107
|
+
apply: (args) => args.map((a, i) => asBoolean(a, "any", i)).some((b) => b)
|
|
1108
|
+
},
|
|
1109
|
+
all: {
|
|
1110
|
+
minArgs: 1,
|
|
1111
|
+
maxArgs: INF,
|
|
1112
|
+
apply: (args) => args.map((a, i) => asBoolean(a, "all", i)).every((b) => b)
|
|
1113
|
+
},
|
|
1114
|
+
within: {
|
|
1115
|
+
minArgs: 2,
|
|
1116
|
+
maxArgs: 2,
|
|
1117
|
+
apply: (args, hooks) => {
|
|
1118
|
+
const windowMs = asFiniteNumber(args[1], "within", 1);
|
|
1119
|
+
if (windowMs < 0) throw new ExpressionEvalError("within: the window must not be negative");
|
|
1120
|
+
const at = args[0];
|
|
1121
|
+
if (at === null) return false;
|
|
1122
|
+
const ts = asFiniteNumber(at, "within", 0);
|
|
1123
|
+
const now = hooks.now;
|
|
1124
|
+
if (now === void 0 || !Number.isFinite(now)) throw new ExpressionEvalError("within: no clock was supplied to this evaluation");
|
|
1125
|
+
const inside = now - ts <= windowMs;
|
|
1126
|
+
if (inside) hooks.noteDeadline?.(ts + windowMs + 1);
|
|
1127
|
+
return inside;
|
|
1128
|
+
}
|
|
1129
|
+
},
|
|
1130
|
+
number: {
|
|
1131
|
+
minArgs: 1,
|
|
1132
|
+
maxArgs: 1,
|
|
1133
|
+
apply: (args) => toNumberOrNull(args[0])
|
|
1134
|
+
},
|
|
1135
|
+
latest: {
|
|
1136
|
+
minArgs: 1,
|
|
1137
|
+
maxArgs: INF,
|
|
1138
|
+
apply: (args) => {
|
|
1139
|
+
const present = args.flatMap((a, i) => a === null ? [] : [asFiniteNumber(a, "latest", i)]);
|
|
1140
|
+
return present.length === 0 ? null : finiteResult(Math.max(...present), "latest");
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
rose: {
|
|
1144
|
+
minArgs: 2,
|
|
1145
|
+
maxArgs: 3,
|
|
1146
|
+
apply: () => {
|
|
1147
|
+
throw new ExpressionEvalError("rose: stateful evaluation context is required");
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
count: {
|
|
1151
|
+
minArgs: 1,
|
|
1152
|
+
maxArgs: 1,
|
|
1153
|
+
apply: () => {
|
|
1154
|
+
throw new ExpressionEvalError("count: stateful evaluation context is required");
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
/** Frozen, null-prototype builtin table. */
|
|
1159
|
+
var EXPRESSION_BUILTINS = Object.freeze(Object.assign(Object.create(null), table));
|
|
1160
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
1161
|
+
* callees at parse time (immediate author feedback). */
|
|
1162
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
1163
|
+
//#endregion
|
|
1164
|
+
//#region src/expression/limits.ts
|
|
1165
|
+
/**
|
|
1166
|
+
* Resource-bound constants for the safe expression engine.
|
|
1167
|
+
*
|
|
1168
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
1169
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
1170
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
1171
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
1172
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
1173
|
+
*/
|
|
1174
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
1175
|
+
* rejected without allocation. */
|
|
1176
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
1177
|
+
/** Max AST nodes — checked during parse; a deeply nested grouping that exceeds
|
|
1178
|
+
* this is rejected as "expression too complex". */
|
|
1179
|
+
var MAX_EXPRESSION_AST_NODES = 256;
|
|
1180
|
+
/** Defense-in-depth walker step budget — one increment per node visit during
|
|
1181
|
+
* evaluation. The grammar guarantees O(nodeCount) walks, so this can only trip
|
|
1182
|
+
* on a crafted maximum-size AST. */
|
|
1183
|
+
var MAX_EXPRESSION_EVAL_STEPS = 4096;
|
|
1184
|
+
/** Max named bindings on one {@link ExpressionSource}. */
|
|
1185
|
+
var MAX_EXPRESSION_BINDINGS = 32;
|
|
1186
|
+
/** Max positional arguments to any builtin call. */
|
|
1187
|
+
var MAX_EXPRESSION_CALL_ARGS = 16;
|
|
1188
|
+
/** LRU compile-cache capacity (parsed ASTs keyed by raw source string). */
|
|
1189
|
+
var EXPRESSION_COMPILE_CACHE_CAPACITY = 256;
|
|
1190
|
+
/** A legal binding / identifier name. */
|
|
1191
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1192
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
1193
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
1194
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
1195
|
+
"now",
|
|
1196
|
+
"true",
|
|
1197
|
+
"false",
|
|
1198
|
+
"null"
|
|
1199
|
+
]);
|
|
1200
|
+
//#endregion
|
|
1201
|
+
//#region src/expression/tokenizer.ts
|
|
1202
|
+
/**
|
|
1203
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
1204
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
1205
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
1206
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
1207
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
1208
|
+
* is a parse error with a source position, so member access / assignment /
|
|
1209
|
+
* template literals are lexically impossible.
|
|
1210
|
+
*/
|
|
1211
|
+
var KEYWORDS = new Set([
|
|
1212
|
+
"true",
|
|
1213
|
+
"false",
|
|
1214
|
+
"null"
|
|
1215
|
+
]);
|
|
1216
|
+
function isDigit(ch) {
|
|
1217
|
+
return ch >= "0" && ch <= "9";
|
|
1218
|
+
}
|
|
1219
|
+
function isIdentStart(ch) {
|
|
1220
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
1221
|
+
}
|
|
1222
|
+
function isIdentPart(ch) {
|
|
1223
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
1224
|
+
}
|
|
1225
|
+
function isWhitespace(ch) {
|
|
1226
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
1227
|
+
}
|
|
1228
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
1229
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
1230
|
+
* string. */
|
|
1231
|
+
function tokenize(source) {
|
|
1232
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
1233
|
+
const tokens = [];
|
|
1234
|
+
let i = 0;
|
|
1235
|
+
const n = source.length;
|
|
1236
|
+
while (i < n) {
|
|
1237
|
+
const ch = source[i];
|
|
1238
|
+
if (isWhitespace(ch)) {
|
|
1239
|
+
i += 1;
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
if (isDigit(ch)) {
|
|
1243
|
+
const start = i;
|
|
1244
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
1245
|
+
if (i < n && source[i] === ".") {
|
|
1246
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
1247
|
+
i += 1;
|
|
1248
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
1249
|
+
}
|
|
1250
|
+
const text = source.slice(start, i);
|
|
1251
|
+
const value = Number(text);
|
|
1252
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
1253
|
+
tokens.push({
|
|
1254
|
+
type: "number",
|
|
1255
|
+
value,
|
|
1256
|
+
pos: start
|
|
1257
|
+
});
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
if (ch === "'" || ch === "\"") {
|
|
1261
|
+
const quote = ch;
|
|
1262
|
+
const start = i;
|
|
1263
|
+
i += 1;
|
|
1264
|
+
let out = "";
|
|
1265
|
+
let closed = false;
|
|
1266
|
+
while (i < n) {
|
|
1267
|
+
const c = source[i];
|
|
1268
|
+
if (c === "\\") {
|
|
1269
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
1270
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
1271
|
+
out += next;
|
|
1272
|
+
i += 2;
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
1276
|
+
}
|
|
1277
|
+
if (c === quote) {
|
|
1278
|
+
closed = true;
|
|
1279
|
+
i += 1;
|
|
1280
|
+
break;
|
|
1281
|
+
}
|
|
1282
|
+
out += c;
|
|
1283
|
+
i += 1;
|
|
1284
|
+
}
|
|
1285
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
1286
|
+
tokens.push({
|
|
1287
|
+
type: "string",
|
|
1288
|
+
value: out,
|
|
1289
|
+
pos: start
|
|
1290
|
+
});
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
if (isIdentStart(ch)) {
|
|
1294
|
+
const start = i;
|
|
1295
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
1296
|
+
const text = source.slice(start, i);
|
|
1297
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
1298
|
+
type: "keyword",
|
|
1299
|
+
keyword: keywordOf(text),
|
|
1300
|
+
pos: start
|
|
1301
|
+
});
|
|
1302
|
+
else tokens.push({
|
|
1303
|
+
type: "identifier",
|
|
1304
|
+
name: text,
|
|
1305
|
+
pos: start
|
|
1306
|
+
});
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
1310
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
1311
|
+
tokens.push({
|
|
1312
|
+
type: "punct",
|
|
1313
|
+
punct: two,
|
|
1314
|
+
pos: i
|
|
1315
|
+
});
|
|
1316
|
+
i += 2;
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
if (isSinglePunct(ch)) {
|
|
1320
|
+
tokens.push({
|
|
1321
|
+
type: "punct",
|
|
1322
|
+
punct: ch,
|
|
1323
|
+
pos: i
|
|
1324
|
+
});
|
|
1325
|
+
i += 1;
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
1329
|
+
}
|
|
1330
|
+
tokens.push({
|
|
1331
|
+
type: "eof",
|
|
1332
|
+
pos: n
|
|
1333
|
+
});
|
|
1334
|
+
return tokens;
|
|
1335
|
+
}
|
|
1336
|
+
function keywordOf(text) {
|
|
1337
|
+
if (text === "true") return "true";
|
|
1338
|
+
if (text === "false") return "false";
|
|
1339
|
+
return "null";
|
|
1340
|
+
}
|
|
1341
|
+
function isSinglePunct(ch) {
|
|
1342
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
1343
|
+
}
|
|
1344
|
+
//#endregion
|
|
1345
|
+
//#region src/expression/parser.ts
|
|
1346
|
+
/**
|
|
1347
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
1348
|
+
*
|
|
1349
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
1350
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
1351
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
1352
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
1353
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
1354
|
+
* that references a since-removed builtin degrades at read.
|
|
1355
|
+
*
|
|
1356
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
1357
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
1358
|
+
*/
|
|
1359
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
1360
|
+
var BINARY_PRECEDENCE = {
|
|
1361
|
+
"||": 1,
|
|
1362
|
+
"&&": 2,
|
|
1363
|
+
"==": 3,
|
|
1364
|
+
"!=": 3,
|
|
1365
|
+
"<": 4,
|
|
1366
|
+
"<=": 4,
|
|
1367
|
+
">": 4,
|
|
1368
|
+
">=": 4,
|
|
1369
|
+
"+": 5,
|
|
1370
|
+
"-": 5,
|
|
1371
|
+
"*": 6,
|
|
1372
|
+
"/": 6,
|
|
1373
|
+
"%": 6
|
|
1374
|
+
};
|
|
1375
|
+
function isLogicalOp(op) {
|
|
1376
|
+
return op === "&&" || op === "||";
|
|
1377
|
+
}
|
|
1378
|
+
function isBinaryOp(op) {
|
|
1379
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
1380
|
+
}
|
|
1381
|
+
var Parser = class {
|
|
1382
|
+
tokens;
|
|
1383
|
+
pos = 0;
|
|
1384
|
+
nodeCount = 0;
|
|
1385
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
1386
|
+
callees = /* @__PURE__ */ new Set();
|
|
1387
|
+
constructor(tokens) {
|
|
1388
|
+
this.tokens = tokens;
|
|
1389
|
+
}
|
|
1390
|
+
parse() {
|
|
1391
|
+
const ast = this.parseTernary();
|
|
1392
|
+
const tok = this.peek();
|
|
1393
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
1394
|
+
return {
|
|
1395
|
+
ast,
|
|
1396
|
+
identifiers: this.identifiers,
|
|
1397
|
+
callees: this.callees,
|
|
1398
|
+
nodeCount: this.nodeCount
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
peek() {
|
|
1402
|
+
return this.tokens[this.pos];
|
|
1403
|
+
}
|
|
1404
|
+
next() {
|
|
1405
|
+
return this.tokens[this.pos++];
|
|
1406
|
+
}
|
|
1407
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
1408
|
+
expectPunct(punct) {
|
|
1409
|
+
const tok = this.peek();
|
|
1410
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
1411
|
+
this.pos += 1;
|
|
1412
|
+
}
|
|
1413
|
+
matchPunct(punct) {
|
|
1414
|
+
const tok = this.peek();
|
|
1415
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
1416
|
+
this.pos += 1;
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
return false;
|
|
1420
|
+
}
|
|
1421
|
+
countNode() {
|
|
1422
|
+
this.nodeCount += 1;
|
|
1423
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
1424
|
+
}
|
|
1425
|
+
parseTernary() {
|
|
1426
|
+
const test = this.parseBinary(1);
|
|
1427
|
+
if (this.matchPunct("?")) {
|
|
1428
|
+
const consequent = this.parseTernary();
|
|
1429
|
+
this.expectPunct(":");
|
|
1430
|
+
const alternate = this.parseTernary();
|
|
1431
|
+
this.countNode();
|
|
1432
|
+
return {
|
|
1433
|
+
kind: "conditional",
|
|
1434
|
+
test,
|
|
1435
|
+
consequent,
|
|
1436
|
+
alternate
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
return test;
|
|
1440
|
+
}
|
|
1441
|
+
parseBinary(minPrec) {
|
|
1442
|
+
let left = this.parseUnary();
|
|
1443
|
+
for (;;) {
|
|
1444
|
+
const tok = this.peek();
|
|
1445
|
+
if (tok.type !== "punct") break;
|
|
1446
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
1447
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
1448
|
+
const op = tok.punct;
|
|
1449
|
+
this.pos += 1;
|
|
1450
|
+
const right = this.parseBinary(prec + 1);
|
|
1451
|
+
this.countNode();
|
|
1452
|
+
if (isLogicalOp(op)) left = {
|
|
1453
|
+
kind: "logical",
|
|
1454
|
+
op,
|
|
1455
|
+
left,
|
|
1456
|
+
right
|
|
1457
|
+
};
|
|
1458
|
+
else if (isBinaryOp(op)) left = {
|
|
1459
|
+
kind: "binary",
|
|
1460
|
+
op,
|
|
1461
|
+
left,
|
|
1462
|
+
right
|
|
1463
|
+
};
|
|
1464
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
1465
|
+
}
|
|
1466
|
+
return left;
|
|
1467
|
+
}
|
|
1468
|
+
parseUnary() {
|
|
1469
|
+
const tok = this.peek();
|
|
1470
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
1471
|
+
const op = tok.punct;
|
|
1472
|
+
this.pos += 1;
|
|
1473
|
+
const operand = this.parseUnary();
|
|
1474
|
+
this.countNode();
|
|
1475
|
+
return {
|
|
1476
|
+
kind: "unary",
|
|
1477
|
+
op,
|
|
1478
|
+
operand
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
return this.parsePrimary();
|
|
1482
|
+
}
|
|
1483
|
+
parsePrimary() {
|
|
1484
|
+
const tok = this.next();
|
|
1485
|
+
switch (tok.type) {
|
|
1486
|
+
case "number":
|
|
1487
|
+
this.countNode();
|
|
1488
|
+
return {
|
|
1489
|
+
kind: "literal",
|
|
1490
|
+
value: tok.value
|
|
1491
|
+
};
|
|
1492
|
+
case "string":
|
|
1493
|
+
this.countNode();
|
|
1494
|
+
return {
|
|
1495
|
+
kind: "literal",
|
|
1496
|
+
value: tok.value
|
|
1497
|
+
};
|
|
1498
|
+
case "keyword":
|
|
1499
|
+
this.countNode();
|
|
1500
|
+
return {
|
|
1501
|
+
kind: "literal",
|
|
1502
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
1503
|
+
};
|
|
1504
|
+
case "identifier": {
|
|
1505
|
+
const nextTok = this.peek();
|
|
1506
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
1507
|
+
this.identifiers.add(tok.name);
|
|
1508
|
+
this.countNode();
|
|
1509
|
+
return {
|
|
1510
|
+
kind: "identifier",
|
|
1511
|
+
name: tok.name
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
case "punct":
|
|
1515
|
+
if (tok.punct === "(") {
|
|
1516
|
+
const inner = this.parseTernary();
|
|
1517
|
+
this.expectPunct(")");
|
|
1518
|
+
return inner;
|
|
1519
|
+
}
|
|
1520
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
1521
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
parseCall(callee, pos) {
|
|
1525
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
1526
|
+
this.expectPunct("(");
|
|
1527
|
+
const args = [];
|
|
1528
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
1529
|
+
args.push(this.parseTernary());
|
|
1530
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
1531
|
+
if (this.matchPunct(",")) continue;
|
|
1532
|
+
this.expectPunct(")");
|
|
1533
|
+
break;
|
|
1534
|
+
}
|
|
1535
|
+
this.callees.add(callee);
|
|
1536
|
+
this.countNode();
|
|
1537
|
+
return {
|
|
1538
|
+
kind: "call",
|
|
1539
|
+
callee,
|
|
1540
|
+
args
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
1545
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
1546
|
+
function parseExpression(source) {
|
|
1547
|
+
return new Parser(tokenize(source)).parse();
|
|
1548
|
+
}
|
|
1549
|
+
//#endregion
|
|
1550
|
+
//#region src/expression/compile.ts
|
|
1551
|
+
/**
|
|
1552
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
1553
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
1554
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
1555
|
+
* one per read on a hot resolve path.
|
|
1556
|
+
*
|
|
1557
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
1558
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
1559
|
+
* callers is safe and maximises hit rate.
|
|
1560
|
+
*/
|
|
1561
|
+
var cache = /* @__PURE__ */ new Map();
|
|
1562
|
+
function getCached(source) {
|
|
1563
|
+
const hit = cache.get(source);
|
|
1564
|
+
if (hit !== void 0) {
|
|
1565
|
+
cache.delete(source);
|
|
1566
|
+
cache.set(source, hit);
|
|
1567
|
+
return hit;
|
|
1568
|
+
}
|
|
1569
|
+
let result;
|
|
1570
|
+
try {
|
|
1571
|
+
result = {
|
|
1572
|
+
ok: true,
|
|
1573
|
+
parsed: parseExpression(source)
|
|
1574
|
+
};
|
|
1575
|
+
} catch (err) {
|
|
1576
|
+
result = {
|
|
1577
|
+
ok: false,
|
|
1578
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
cache.set(source, result);
|
|
1582
|
+
if (cache.size > 256) {
|
|
1583
|
+
const oldest = cache.keys().next().value;
|
|
1584
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
1585
|
+
}
|
|
1586
|
+
return result;
|
|
1587
|
+
}
|
|
1588
|
+
/** Compile `source` to a `ParsedExpression`, throwing `ExpressionParseError`
|
|
1589
|
+
* on failure. LRU/negative-cached. */
|
|
1590
|
+
function compileExpression(source) {
|
|
1591
|
+
const result = getCached(source);
|
|
1592
|
+
if (result.ok) return result.parsed;
|
|
1593
|
+
throw new ExpressionParseError(result.error);
|
|
1594
|
+
}
|
|
1595
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
1596
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
1597
|
+
function compileExpressionSafe(source) {
|
|
1598
|
+
return getCached(source);
|
|
1599
|
+
}
|
|
1600
|
+
//#endregion
|
|
1601
|
+
//#region src/expression/evaluator.ts
|
|
1602
|
+
/**
|
|
1603
|
+
* Tree-walking evaluator for the safe expression mini-language.
|
|
1604
|
+
*
|
|
1605
|
+
* SECURITY (spec §4 rule 2/5):
|
|
1606
|
+
* - The scope is an `Object.create(null)` copy of ONLY the caller's own
|
|
1607
|
+
* enumerable binding entries, so `name in scope` is a pure own-key check and
|
|
1608
|
+
* `constructor` / `__proto__` / `toString` are plain unknown identifiers.
|
|
1609
|
+
* - Performs ZERO I/O and never touches `globalThis` / `Date` / `Math`
|
|
1610
|
+
* directly — the only external calls are into the frozen builtin table.
|
|
1611
|
+
* - The grammar has no loops/recursion/lambdas, so a walk is O(nodeCount) by
|
|
1612
|
+
* construction; the step counter is defense-in-depth for a crafted max-size
|
|
1613
|
+
* AST. Nothing blocks: there are no timers, awaits or unbounded loops.
|
|
1614
|
+
*/
|
|
1615
|
+
var EMPTY_HOOKS = Object.freeze({});
|
|
1616
|
+
/** Build a null-prototype scope from own-enumerable binding entries. Inherited
|
|
1617
|
+
* keys of the input (e.g. from a `{__proto__: {...}}` payload) are NOT copied,
|
|
1618
|
+
* so nothing smuggles in via the prototype chain. */
|
|
1619
|
+
function createExpressionScope(bindings) {
|
|
1620
|
+
const scope = Object.create(null);
|
|
1621
|
+
for (const key of Object.keys(bindings)) if (Object.prototype.hasOwnProperty.call(bindings, key)) scope[key] = bindings[key];
|
|
1622
|
+
return scope;
|
|
1623
|
+
}
|
|
1624
|
+
function isFiniteNumber(value) {
|
|
1625
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1626
|
+
}
|
|
1627
|
+
/** JS truthiness of a primitive value. */
|
|
1628
|
+
function truthy(value) {
|
|
1629
|
+
return Boolean(value);
|
|
1630
|
+
}
|
|
1631
|
+
function requireFinite(value, context) {
|
|
1632
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${context} produced a non-finite result`);
|
|
1633
|
+
return value;
|
|
1634
|
+
}
|
|
1635
|
+
function step(ctx) {
|
|
1636
|
+
ctx.steps += 1;
|
|
1637
|
+
if (ctx.steps > ctx.maxSteps) throw new ExpressionEvalError("expression evaluation step budget exceeded");
|
|
1638
|
+
}
|
|
1639
|
+
function childPath(path, child) {
|
|
1640
|
+
return `${path}/${child}`;
|
|
1641
|
+
}
|
|
1642
|
+
function evalNode(node, ctx, path) {
|
|
1643
|
+
step(ctx);
|
|
1644
|
+
switch (node.kind) {
|
|
1645
|
+
case "literal": return node.value;
|
|
1646
|
+
case "identifier":
|
|
1647
|
+
if (!(node.name in ctx.scope)) throw new ExpressionEvalError(`unknown identifier: ${node.name}`);
|
|
1648
|
+
return ctx.scope[node.name];
|
|
1649
|
+
case "unary": return evalUnary(node.op, evalNode(node.operand, ctx, childPath(path, "operand")));
|
|
1650
|
+
case "binary": return evalBinary(node.op, evalNode(node.left, ctx, childPath(path, "left")), evalNode(node.right, ctx, childPath(path, "right")));
|
|
1651
|
+
case "logical": {
|
|
1652
|
+
const left = evalNode(node.left, ctx, childPath(path, "left"));
|
|
1653
|
+
if (node.op === "&&") return truthy(left) ? evalNode(node.right, ctx, childPath(path, "right")) : left;
|
|
1654
|
+
return truthy(left) ? left : evalNode(node.right, ctx, childPath(path, "right"));
|
|
1655
|
+
}
|
|
1656
|
+
case "conditional": return truthy(evalNode(node.test, ctx, childPath(path, "test"))) ? evalNode(node.consequent, ctx, childPath(path, "consequent")) : evalNode(node.alternate, ctx, childPath(path, "alternate"));
|
|
1657
|
+
case "call": {
|
|
1658
|
+
const args = node.args.map((arg, index) => evalNode(arg, ctx, childPath(path, `arg:${index}`)));
|
|
1659
|
+
return invokeCall(node.callee, args, path, ctx);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
function evalUnary(op, operand) {
|
|
1664
|
+
if (op === "!") return !truthy(operand);
|
|
1665
|
+
if (!isFiniteNumber(operand)) throw new ExpressionEvalError("unary \"-\" requires a finite number");
|
|
1666
|
+
return requireFinite(-operand, "unary \"-\"");
|
|
1667
|
+
}
|
|
1668
|
+
function evalBinary(op, left, right) {
|
|
1669
|
+
switch (op) {
|
|
1670
|
+
case "==": return left === right;
|
|
1671
|
+
case "!=": return left !== right;
|
|
1672
|
+
case "+":
|
|
1673
|
+
if (typeof left === "string" && typeof right === "string") return left + right;
|
|
1674
|
+
if (isFiniteNumber(left) && isFiniteNumber(right)) return requireFinite(left + right, "\"+\"");
|
|
1675
|
+
throw new ExpressionEvalError("\"+\" requires two numbers or two strings");
|
|
1676
|
+
case "-":
|
|
1677
|
+
case "*":
|
|
1678
|
+
case "/":
|
|
1679
|
+
case "%":
|
|
1680
|
+
if (!isFiniteNumber(left) || !isFiniteNumber(right)) throw new ExpressionEvalError(`"${op}" requires two finite numbers`);
|
|
1681
|
+
return requireFinite(op === "-" ? left - right : op === "*" ? left * right : op === "/" ? left / right : left % right, `"${op}"`);
|
|
1682
|
+
case "<":
|
|
1683
|
+
case "<=":
|
|
1684
|
+
case ">":
|
|
1685
|
+
case ">=":
|
|
1686
|
+
if (isFiniteNumber(left) && isFiniteNumber(right)) return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
|
|
1687
|
+
if (typeof left === "string" && typeof right === "string") return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
|
|
1688
|
+
throw new ExpressionEvalError(`"${op}" requires two numbers or two strings`);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
function invokeCall(callee, args, path, ctx) {
|
|
1692
|
+
if (!Object.prototype.hasOwnProperty.call(EXPRESSION_BUILTINS, callee)) throw new ExpressionEvalError(`unknown function: ${callee}`);
|
|
1693
|
+
const builtin = EXPRESSION_BUILTINS[callee];
|
|
1694
|
+
if (args.length < builtin.minArgs || args.length > builtin.maxArgs) throw new ExpressionEvalError(`${callee}: wrong number of arguments (${args.length})`);
|
|
1695
|
+
if (isStatefulBuiltin(callee)) {
|
|
1696
|
+
if (ctx.invokeStateful === void 0) throw new ExpressionEvalError(`${callee}: stateful evaluation context is required`);
|
|
1697
|
+
return ctx.invokeStateful({
|
|
1698
|
+
name: callee,
|
|
1699
|
+
path,
|
|
1700
|
+
args
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
return builtin.apply(args, ctx.hooks);
|
|
1704
|
+
}
|
|
1705
|
+
/** Evaluate an AST node against a scope. Throws `ExpressionEvalError` on any
|
|
1706
|
+
* runtime failure (unknown identifier, type mismatch, non-finite result,
|
|
1707
|
+
* step-budget exhaustion). */
|
|
1708
|
+
function evaluateAst(node, scope, opts) {
|
|
1709
|
+
return evalNode(node, {
|
|
1710
|
+
scope,
|
|
1711
|
+
hooks: opts?.hooks ?? EMPTY_HOOKS,
|
|
1712
|
+
maxSteps: opts?.maxSteps ?? 4096,
|
|
1713
|
+
...opts?.invokeStateful === void 0 ? {} : { invokeStateful: opts.invokeStateful },
|
|
1714
|
+
steps: 0
|
|
1715
|
+
}, "root");
|
|
1716
|
+
}
|
|
1717
|
+
//#endregion
|
|
1718
|
+
//#region src/expression/expression-source.ts
|
|
1719
|
+
function createStatefulExpressionMemory() {
|
|
1720
|
+
return {
|
|
1721
|
+
calls: /* @__PURE__ */ new Map(),
|
|
1722
|
+
acceptedEdgeAt: null
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
function cloneStatefulExpressionMemory(memory) {
|
|
1726
|
+
return {
|
|
1727
|
+
calls: new Map([...memory.calls].map(([path, call]) => [path, { ...call }])),
|
|
1728
|
+
acceptedEdgeAt: memory.acceptedEdgeAt
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
function evaluateStatefulExpressionCall(invocation, memory, env) {
|
|
1732
|
+
const existing = memory.calls.get(invocation.path);
|
|
1733
|
+
switch (invocation.name) {
|
|
1734
|
+
case "rose": {
|
|
1735
|
+
const call = existing?.kind === "rose" ? existing : { kind: "rose" };
|
|
1736
|
+
memory.calls.set(invocation.path, call);
|
|
1737
|
+
const currentOutput = env.currentOutput === null || typeof env.currentOutput === "number" && Number.isFinite(env.currentOutput) ? env.currentOutput : null;
|
|
1738
|
+
return evaluateRose(invocation.args[0], invocation.args[1], invocation.args[2], call, memory, {
|
|
1739
|
+
currentOutput,
|
|
1740
|
+
startedAtMs: env.startedAtMs,
|
|
1741
|
+
nowMs: env.nowMs
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
case "count": {
|
|
1745
|
+
const call = existing?.kind === "count" ? existing : {
|
|
1746
|
+
kind: "count",
|
|
1747
|
+
highWater: null
|
|
1748
|
+
};
|
|
1749
|
+
memory.calls.set(invocation.path, call);
|
|
1750
|
+
return evaluateCount(invocation.args[0], call, env.currentOutput);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
/** The `now` epoch-ms binding is auto-injected into every evaluation and is a
|
|
1755
|
+
* reserved binding name (authors may not rebind it). */
|
|
1756
|
+
var EXPRESSION_INJECTED_NOW = "now";
|
|
1757
|
+
/**
|
|
1758
|
+
* Coerce an untrusted `getByPath` / mirror read to an `ExpressionValue`.
|
|
1759
|
+
* Non-primitive values (objects, arrays, `undefined`, functions, bigint,
|
|
1760
|
+
* symbol) and non-finite numbers become `undefined` so the caller can apply
|
|
1761
|
+
* its binding-miss policy (→ `null`). `null` itself is a valid value.
|
|
1762
|
+
*/
|
|
1763
|
+
function toExpressionValue(raw) {
|
|
1764
|
+
if (raw === null) return null;
|
|
1765
|
+
if (typeof raw === "string") return raw;
|
|
1766
|
+
if (typeof raw === "boolean") return raw;
|
|
1767
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
1771
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
1772
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
1773
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
1774
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
1775
|
+
*/
|
|
1776
|
+
function validateExpressionSource(src) {
|
|
1777
|
+
const names = Object.keys(src.bindings);
|
|
1778
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
1779
|
+
for (const name of names) {
|
|
1780
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
1781
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
1782
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
1783
|
+
}
|
|
1784
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
1785
|
+
if (!compiled.ok) return compiled.error;
|
|
1786
|
+
const bound = new Set(names);
|
|
1787
|
+
for (const id of compiled.parsed.identifiers) {
|
|
1788
|
+
if (id === "now") continue;
|
|
1789
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
1790
|
+
}
|
|
1791
|
+
return null;
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Shared read-path evaluation. Builds a null-proto scope from `bindingValues`
|
|
1795
|
+
* plus the injected `now` (supplied by the caller for determinism and
|
|
1796
|
+
* testability), compiles via the LRU, and evaluates. Stateful calls are
|
|
1797
|
+
* delegated through `opts.invokeStateful` with their stable AST path. Any
|
|
1798
|
+
* failure (parse or eval) returns `{ ok: false }` — the caller treats that as
|
|
1799
|
+
* "skip this derivation", never as a throw that takes the pass down.
|
|
1800
|
+
*/
|
|
1801
|
+
function evaluateExpressionSource(expr, bindingValues, now, opts) {
|
|
1802
|
+
const compiled = compileExpressionSafe(expr);
|
|
1803
|
+
if (!compiled.ok) return {
|
|
1804
|
+
ok: false,
|
|
1805
|
+
error: compiled.error
|
|
1806
|
+
};
|
|
1807
|
+
const scope = createExpressionScope({
|
|
1808
|
+
...bindingValues,
|
|
1809
|
+
["now"]: now
|
|
1810
|
+
});
|
|
1811
|
+
const hooks = {
|
|
1812
|
+
...opts?.hooks,
|
|
1813
|
+
now
|
|
1814
|
+
};
|
|
1815
|
+
try {
|
|
1816
|
+
return {
|
|
1817
|
+
ok: true,
|
|
1818
|
+
value: evaluateAst(compiled.parsed.ast, scope, {
|
|
1819
|
+
...opts,
|
|
1820
|
+
hooks
|
|
1821
|
+
})
|
|
1822
|
+
};
|
|
1823
|
+
} catch (err) {
|
|
1824
|
+
return {
|
|
1825
|
+
ok: false,
|
|
1826
|
+
error: err instanceof ExpressionEvalError ? err.message : String(err)
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
//#endregion
|
|
1831
|
+
//#region src/composition/composition.ts
|
|
1832
|
+
/**
|
|
1833
|
+
* A COMPOSITION: a device assembled from other devices' state (D659).
|
|
1834
|
+
*
|
|
1835
|
+
* The data half of the composed-devices spec §1. A composition is stored inside
|
|
1836
|
+
* a core block (`CoreBlock.source.kind === 'composition'`) and run by the shared
|
|
1837
|
+
* `composer` runner. It names a TARGET and, for each capability of the target, a
|
|
1838
|
+
* FIELD MAP: every state field has exactly one source.
|
|
1839
|
+
*
|
|
1840
|
+
* Sources are keyed by `(addonId, stableId)`, NEVER by numeric device id. A
|
|
1841
|
+
* resync reissues numeric ids, and a composition that stored them would be
|
|
1842
|
+
* orphaned by the first re-adoption (the linked-devices lesson). A `stableId` is
|
|
1843
|
+
* unique only within its owning addon, so the owner travels with it.
|
|
1844
|
+
*
|
|
1845
|
+
* Later slices EXTEND these unions and do not reshape them. The variants a later
|
|
1846
|
+
* slice implements are already in the wire shape, and `validateComposition`
|
|
1847
|
+
* refuses them BY NAME until then:
|
|
1848
|
+
* feature `passthrough` → slice 3 · `commands` (`forward`) → slice 3 ·
|
|
1849
|
+
* source `code` → slice 4. Target `existing` (customize a device) is slice 2 (D663).
|
|
1850
|
+
*/
|
|
1851
|
+
/** The owner of every composed device: the `composer` builtin. */
|
|
1852
|
+
var COMPOSER_ADDON_ID = "composer";
|
|
1853
|
+
/**
|
|
1854
|
+
* The stored name of a block that customizes an EXISTING device starts with this
|
|
1855
|
+
* (`customizationBlockName`, `@camstack/types/node`). Never parsed to tell a
|
|
1856
|
+
* customization apart: `composition.target.kind === 'existing'` says that (D663).
|
|
1857
|
+
*/
|
|
1858
|
+
var CUSTOMIZATION_BLOCK_NAME_PREFIX = "customize:";
|
|
1859
|
+
/** A composed device's stableId is this prefix plus the block id. */
|
|
1860
|
+
var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
|
|
1861
|
+
/** A device with more capabilities than this is two devices. */
|
|
1862
|
+
var MAX_COMPOSITION_FEATURES = 16;
|
|
1863
|
+
var MAX_COMPOSITION_FIELDS_PER_FEATURE = 32;
|
|
1864
|
+
/** Longest snippet a `code` source may carry (slice 4). */
|
|
1865
|
+
var MAX_COMPOSITION_CODE_LENGTH = 2e4;
|
|
1866
|
+
var CompositionSourceRefSchema = z.object({
|
|
1867
|
+
/** The owning addon; `stableId` is unique only within it. */
|
|
1868
|
+
addonId: z.string().min(1),
|
|
1869
|
+
stableId: z.string().min(1)
|
|
1870
|
+
});
|
|
1871
|
+
/** One field of one capability of one source device. */
|
|
1872
|
+
var CompositionFieldReadSchema = z.object({
|
|
1873
|
+
source: CompositionSourceRefSchema,
|
|
1874
|
+
cap: z.string().min(1),
|
|
1875
|
+
/** Dotted path into the source cap's runtime-state slice. */
|
|
1876
|
+
fieldPath: z.string().min(1)
|
|
1877
|
+
});
|
|
1878
|
+
/** `from`: copy one source field verbatim. It is also an expression's `from` binding. */
|
|
1879
|
+
var CompositionFromSourceSchema = CompositionFieldReadSchema.extend({ kind: z.literal("from") });
|
|
1880
|
+
/** A constant binding. No device is read. */
|
|
1881
|
+
var CompositionLiteralBindingSchema = z.object({
|
|
1882
|
+
kind: z.literal("literal"),
|
|
1883
|
+
value: z.union([
|
|
1884
|
+
z.string(),
|
|
1885
|
+
z.number(),
|
|
1886
|
+
z.boolean(),
|
|
1887
|
+
z.null()
|
|
1888
|
+
])
|
|
1889
|
+
});
|
|
1890
|
+
var CompositionBindingSchema = z.discriminatedUnion("kind", [CompositionFromSourceSchema, CompositionLiteralBindingSchema]);
|
|
1891
|
+
/**
|
|
1892
|
+
* `expression`: a formula over named bindings, in the salvaged expression
|
|
1893
|
+
* engine. Validated at parse by the SAME function every other consumer runs,
|
|
1894
|
+
* so the editor and the store cannot disagree.
|
|
1895
|
+
*/
|
|
1896
|
+
var CompositionExpressionSourceSchema = z.object({
|
|
1897
|
+
kind: z.literal("expression"),
|
|
1898
|
+
expr: z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
1899
|
+
bindings: z.record(z.string().regex(EXPRESSION_IDENTIFIER_RE), CompositionBindingSchema)
|
|
1900
|
+
}).superRefine((src, ctx) => {
|
|
1901
|
+
const err = validateExpressionSource(src);
|
|
1902
|
+
if (err !== null) ctx.addIssue({
|
|
1903
|
+
code: "custom",
|
|
1904
|
+
message: err,
|
|
1905
|
+
path: ["expr"]
|
|
1906
|
+
});
|
|
1907
|
+
});
|
|
1908
|
+
/** `code`: RESERVED for slice 4 (own runner, one-way eject). */
|
|
1909
|
+
var CompositionCodeSourceSchema = z.object({
|
|
1910
|
+
kind: z.literal("code"),
|
|
1911
|
+
code: z.string().min(1).max(MAX_COMPOSITION_CODE_LENGTH)
|
|
1912
|
+
});
|
|
1913
|
+
var CompositionFieldSourceSchema = z.discriminatedUnion("kind", [
|
|
1914
|
+
CompositionFromSourceSchema,
|
|
1915
|
+
CompositionExpressionSourceSchema,
|
|
1916
|
+
CompositionCodeSourceSchema
|
|
1917
|
+
]);
|
|
1918
|
+
/** `forward`: RESERVED for slice 3. Argument mapping arrives there as an additive optional `args`. */
|
|
1919
|
+
var CompositionForwardCommandSchema = z.object({
|
|
1920
|
+
kind: z.literal("forward"),
|
|
1921
|
+
source: CompositionSourceRefSchema,
|
|
1922
|
+
cap: z.string().min(1),
|
|
1923
|
+
method: z.string().min(1)
|
|
1924
|
+
});
|
|
1925
|
+
var CompositionCommandTargetSchema = z.discriminatedUnion("kind", [CompositionForwardCommandSchema, CompositionCodeSourceSchema]);
|
|
1926
|
+
/** An item key inside an item-array cap (`consumables.items[<key>]`). */
|
|
1927
|
+
var COMPOSITION_ITEM_KEY_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
1928
|
+
var MAX_COMPOSITION_ITEMS = 16;
|
|
1929
|
+
/** The item-array path of every item-array cap that exists (`consumables.items`). */
|
|
1930
|
+
var COMPOSITION_ITEM_ARRAY_PATH = "items";
|
|
1931
|
+
/**
|
|
1932
|
+
* One item of an item-array cap (D663), addressed through the cap's
|
|
1933
|
+
* `status.itemArray` descriptor. `fields` are paths INSIDE the item, nested
|
|
1934
|
+
* allowed (`remaining.value`); the key and label come from the entry itself.
|
|
1935
|
+
*/
|
|
1936
|
+
var CompositionItemEntrySchema = z.object({
|
|
1937
|
+
label: z.string().min(1).max(80),
|
|
1938
|
+
fields: z.record(z.string().min(1), CompositionFieldSourceSchema)
|
|
1939
|
+
});
|
|
1940
|
+
var CompositionFieldsFeatureSchema = z.object({
|
|
1941
|
+
kind: z.literal("fields"),
|
|
1942
|
+
cap: z.string().min(1),
|
|
1943
|
+
enabled: z.boolean().optional(),
|
|
1944
|
+
fields: z.record(z.string().min(1), CompositionFieldSourceSchema).refine((fields) => Object.keys(fields).length <= 32, { message: `at most 32 fields per capability` }),
|
|
1945
|
+
/**
|
|
1946
|
+
* Items of the cap's item array, keyed by item key (D663). Naming `items`
|
|
1947
|
+
* owns the WHOLE array field: the composed value holds exactly these items.
|
|
1948
|
+
*/
|
|
1949
|
+
items: z.record(z.string().regex(COMPOSITION_ITEM_KEY_RE), CompositionItemEntrySchema).refine((items) => Object.keys(items).length <= 16, { message: `at most 16 items` }).optional(),
|
|
1950
|
+
/** RESERVED for slice 3; refused by the validator when non-empty. */
|
|
1951
|
+
commands: z.record(z.string().min(1), CompositionCommandTargetSchema).optional()
|
|
1952
|
+
});
|
|
1953
|
+
/** RESERVED for slice 3: a media / data-plane cap, whole, from one source. */
|
|
1954
|
+
var CompositionPassthroughFeatureSchema = z.object({
|
|
1955
|
+
kind: z.literal("passthrough"),
|
|
1956
|
+
cap: z.string().min(1),
|
|
1957
|
+
enabled: z.boolean().optional(),
|
|
1958
|
+
source: CompositionSourceRefSchema
|
|
1959
|
+
});
|
|
1960
|
+
var CompositionFeatureSchema = z.discriminatedUnion("kind", [CompositionFieldsFeatureSchema, CompositionPassthroughFeatureSchema]);
|
|
1961
|
+
/** Only an explicit `false` disables a feature; omitted preserves the existing wire default. */
|
|
1962
|
+
function compositionFeatureEnabled(feature) {
|
|
1963
|
+
return feature.enabled !== false;
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* A NEW device. It has no `name` of its own: the block's name IS the device's
|
|
1967
|
+
* name (2026-08-08). A second copy here would be a second writer of one string (D62).
|
|
1968
|
+
*/
|
|
1969
|
+
var CompositionNewTargetSchema = z.object({
|
|
1970
|
+
kind: z.literal("new"),
|
|
1971
|
+
type: z.enum(DeviceType),
|
|
1972
|
+
role: z.enum(DeviceRole).optional()
|
|
1973
|
+
});
|
|
1974
|
+
/** Customize an EXISTING device: each feature adds a capability or replaces fields of a native one (D663). */
|
|
1975
|
+
var CompositionExistingTargetSchema = z.object({
|
|
1976
|
+
kind: z.literal("existing"),
|
|
1977
|
+
device: CompositionSourceRefSchema
|
|
1978
|
+
});
|
|
1979
|
+
var CompositionTargetSchema = z.discriminatedUnion("kind", [CompositionNewTargetSchema, CompositionExistingTargetSchema]);
|
|
1980
|
+
var CompositionSchema = z.object({
|
|
1981
|
+
target: CompositionTargetSchema,
|
|
1982
|
+
features: z.array(CompositionFeatureSchema).min(1).max(16)
|
|
1983
|
+
}).superRefine((composition, ctx) => {
|
|
1984
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1985
|
+
composition.features.forEach((feature, index) => {
|
|
1986
|
+
if (seen.has(feature.cap)) ctx.addIssue({
|
|
1987
|
+
code: "custom",
|
|
1988
|
+
message: `capability \`${feature.cap}\` is composed twice — one feature per capability`,
|
|
1989
|
+
path: [
|
|
1990
|
+
"features",
|
|
1991
|
+
index,
|
|
1992
|
+
"cap"
|
|
1993
|
+
]
|
|
1994
|
+
});
|
|
1995
|
+
seen.add(feature.cap);
|
|
1996
|
+
});
|
|
1997
|
+
});
|
|
1998
|
+
function compositionSourceKey(ref) {
|
|
1999
|
+
return `${ref.addonId}/${ref.stableId}`;
|
|
2000
|
+
}
|
|
2001
|
+
function compositionSliceKey(ref, cap) {
|
|
2002
|
+
return `${compositionSourceKey(ref)}#${cap}`;
|
|
2003
|
+
}
|
|
2004
|
+
function compositionReadKey(read) {
|
|
2005
|
+
return `${compositionSliceKey(read.source, read.cap)}.${read.fieldPath}`;
|
|
2006
|
+
}
|
|
2007
|
+
function composedDeviceStableId(blockId) {
|
|
2008
|
+
return `${COMPOSED_DEVICE_STABLE_ID_PREFIX}${blockId}`;
|
|
2009
|
+
}
|
|
2010
|
+
function composedDeviceRef(blockId) {
|
|
2011
|
+
return {
|
|
2012
|
+
addonId: COMPOSER_ADDON_ID,
|
|
2013
|
+
stableId: composedDeviceStableId(blockId)
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
/** Every device field a source reads: `from` is one read, an expression is its `from` bindings. */
|
|
2017
|
+
function fieldReads(source) {
|
|
2018
|
+
switch (source.kind) {
|
|
2019
|
+
case "from": return [{
|
|
2020
|
+
source: source.source,
|
|
2021
|
+
cap: source.cap,
|
|
2022
|
+
fieldPath: source.fieldPath
|
|
2023
|
+
}];
|
|
2024
|
+
case "expression": return Object.values(source.bindings).flatMap((binding) => binding.kind === "from" ? [{
|
|
2025
|
+
source: binding.source,
|
|
2026
|
+
cap: binding.cap,
|
|
2027
|
+
fieldPath: binding.fieldPath
|
|
2028
|
+
}] : []);
|
|
2029
|
+
case "code": return [];
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
/** The ONE spelling of an item leaf as a field path: `items[desiccant].remaining.value`. */
|
|
2033
|
+
function compositionItemFieldPath(arrayPath, key, path) {
|
|
2034
|
+
return `${arrayPath}[${key}].${path}`;
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Every written field of a feature, item leaves under their bracketed path.
|
|
2038
|
+
* `arrayPath` is `'items'` for every item-array cap that exists
|
|
2039
|
+
* (`consumables.cap.ts`); the graph has no cap lookup, so the default stands
|
|
2040
|
+
* and `planItems` asserts it agrees with the cap's descriptor.
|
|
2041
|
+
*/
|
|
2042
|
+
function featureFieldEntries(feature, arrayPath = COMPOSITION_ITEM_ARRAY_PATH) {
|
|
2043
|
+
return [...Object.entries(feature.fields).map(([fieldPath, source]) => ({
|
|
2044
|
+
fieldPath,
|
|
2045
|
+
source
|
|
2046
|
+
})), ...Object.entries(feature.items ?? {}).flatMap(([key, item]) => Object.entries(item.fields).map(([path, source]) => ({
|
|
2047
|
+
fieldPath: compositionItemFieldPath(arrayPath, key, path),
|
|
2048
|
+
source
|
|
2049
|
+
})))];
|
|
2050
|
+
}
|
|
2051
|
+
function featureSources(feature) {
|
|
2052
|
+
return featureFieldEntries(feature).map((e) => e.source);
|
|
2053
|
+
}
|
|
2054
|
+
//#endregion
|
|
2055
|
+
export { MAX_EXPRESSION_AST_NODES as $, compositionFeatureEnabled as A, invocationFromEncodeProfile as At, createStatefulExpressionMemory as B, CompositionTargetSchema as C, Fmp4BoxSplitter as Ct, MAX_COMPOSITION_ITEMS as D, buildFfmpegArgs as Dt, MAX_COMPOSITION_FIELDS_PER_FEATURE as E, buildAudioArgs as Et, featureFieldEntries as F, createExpressionScope as G, evaluateStatefulExpressionCall as H, featureSources as I, compileExpressionSafe as J, evaluateAst as K, fieldReads as L, compositionReadKey as M, logBannerArgs as Mt, compositionSliceKey as N, pickVideoEncoder as Nt, composedDeviceRef as O, buildInputArgs as Ot, compositionSourceKey as P, resolveStreamMaps as Pt, EXPRESSION_IDENTIFIER_RE as Q, EXPRESSION_INJECTED_NOW as R, CompositionSourceRefSchema as S, canonicalHash as St, MAX_COMPOSITION_FEATURES as T, audioPlanFromEncodeProfile as Tt, toExpressionValue as U, evaluateExpressionSource as V, validateExpressionSource as W, tokenize as X, parseExpression as Y, EXPRESSION_COMPILE_CACHE_CAPACITY as Z, CompositionItemEntrySchema as _, evaluateSensorEdge as _t, CUSTOMIZATION_BLOCK_NAME_PREFIX as a, EXPRESSION_BUILTINS as at, CompositionPassthroughFeatureSchema as b, sliceActiveValue as bt, CompositionCommandTargetSchema as c, evaluateRose as ct, CompositionFeatureSchema as d, ExpressionParseError as dt, MAX_EXPRESSION_BINDINGS as et, CompositionFieldReadSchema as f, DEFAULT_FIRST_SIGHTING_FRESHNESS_MS as ft, CompositionFromSourceSchema as g, SOURCE_DEVICE_TYPES as gt, CompositionForwardCommandSchema as h, SOURCE_CAP_CHANGED_AT_FIELD as ht, COMPOSITION_ITEM_KEY_RE as i, RESERVED_BINDING_NAMES as it, compositionItemFieldPath as j, isSoftwareDecode as jt, composedDeviceStableId as k, buildVideoArgs as kt, CompositionExistingTargetSchema as l, isStatefulBuiltin as lt, CompositionFieldsFeatureSchema as m, SOURCE_CAP_ACTIVE_FIELD as mt, COMPOSER_ADDON_ID as n, MAX_EXPRESSION_EVAL_STEPS as nt, CompositionBindingSchema as o, EXPRESSION_BUILTIN_NAMES as ot, CompositionFieldSourceSchema as p, SOURCE_CAPS as pt, compileExpression as q, COMPOSITION_ITEM_ARRAY_PATH as r, MAX_EXPRESSION_SOURCE_LENGTH as rt, CompositionCodeSourceSchema as s, evaluateCount as st, COMPOSED_DEVICE_STABLE_ID_PREFIX as t, MAX_EXPRESSION_CALL_ARGS as tt, CompositionExpressionSourceSchema as u, ExpressionEvalError as ut, CompositionLiteralBindingSchema as v, evaluateSensorLevelEdge as vt, MAX_COMPOSITION_CODE_LENGTH as w, AUDIO_PRESETS as wt, CompositionSchema as x, sliceChangedAt as xt, CompositionNewTargetSchema as y, isSourceCap as yt, cloneStatefulExpressionMemory as z };
|