@camstack/types 1.2.42 → 1.2.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +8 -2
- package/dist/addon.mjs +8 -2
- package/dist/cap-call-context.d.ts +26 -0
- package/dist/capabilities/index.d.ts +8 -5
- package/dist/capabilities/osd-manager.cap.d.ts +900 -0
- package/dist/capabilities/pipeline-analytics.cap.d.ts +171 -4
- package/dist/capabilities/pipeline-orchestrator.cap.d.ts +36 -0
- package/dist/capabilities/privacy-mask.cap.d.ts +69 -9
- package/dist/capabilities/recording.cap.d.ts +27 -0
- package/dist/capabilities/schemas/streaming-shared.d.ts +2 -0
- package/dist/capabilities/server-management.cap.d.ts +3 -3
- package/dist/capabilities/snapshot.cap.d.ts +1 -1
- package/dist/capabilities/stream-broker.cap.d.ts +36 -0
- package/dist/capabilities/stream-params.cap.d.ts +8 -4
- package/dist/device/device-profile.d.ts +12 -4
- package/dist/device/system-mirror.d.ts +11 -0
- package/dist/ffmpeg/encode-defaults.d.ts +18 -0
- package/dist/ffmpeg/fmp4-box-splitter.d.ts +113 -0
- package/dist/ffmpeg/fmp4-fragment-child.d.ts +85 -0
- package/dist/ffmpeg/fmp4-fragment-plane.d.ts +142 -0
- package/dist/ffmpeg/invocation.d.ts +102 -2
- package/dist/ffmpeg/sharing-key.d.ts +54 -2
- package/dist/fmp4-box-splitter-B53u9-Nu.mjs +615 -0
- package/dist/fmp4-box-splitter-BkWH7O3L.js +686 -0
- package/dist/generated/addon-api.d.ts +117 -0
- package/dist/generated/cap-input-defaults.d.ts +1 -1
- package/dist/generated/capability-router-map.d.ts +5 -2
- package/dist/generated/device-proxy.d.ts +4 -2
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/system-proxy.d.ts +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1264 -394
- package/dist/index.mjs +1217 -385
- package/dist/interfaces/camera-switches.d.ts +211 -5
- package/dist/interfaces/inference-engine.d.ts +24 -3
- package/dist/interfaces/stream-broker.d.ts +18 -0
- package/dist/node.d.ts +4 -0
- package/dist/node.js +509 -3
- package/dist/node.mjs +507 -3
- package/dist/notification/schedule.d.ts +20 -0
- package/dist/pipeline/native-lease.d.ts +150 -0
- package/dist/{sleep-BmNKsY7v.mjs → sleep-cC4Fuup8.mjs} +59 -3
- package/dist/{sleep-Cvi1JxZp.js → sleep-eiC10_cX.js} +64 -2
- package/dist/types/pipeline-step.d.ts +1 -1
- package/dist/utils/addon-id.d.ts +30 -0
- package/package.json +1 -1
- package/dist/canonical-hash-7nfBbEqR.mjs +0 -35
- package/dist/canonical-hash-BcZHRHIx.js +0 -40
package/dist/node.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as canonicalHash, o as buildFfmpegArgs, t as Fmp4BoxSplitter, u as isSoftwareDecode } from "./fmp4-box-splitter-B53u9-Nu.mjs";
|
|
2
2
|
import { t as errMsg } from "./err-msg-IQTHeDzc.mjs";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import * as path from "node:path";
|
|
@@ -899,4 +899,508 @@ var FfmpegProcess = class {
|
|
|
899
899
|
}
|
|
900
900
|
};
|
|
901
901
|
//#endregion
|
|
902
|
-
|
|
902
|
+
//#region src/ffmpeg/fmp4-fragment-plane.ts
|
|
903
|
+
/**
|
|
904
|
+
* Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
|
|
905
|
+
* {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
|
|
906
|
+
*
|
|
907
|
+
* ## Why a plane and not a callback
|
|
908
|
+
*
|
|
909
|
+
* The operator's requirement for HKSV was explicit: the live fMP4 source built
|
|
910
|
+
* for it must be **dual-use**, so a HomeKit-triggered recording also lands in
|
|
911
|
+
* CamStack as an additional videoclip source alongside the recorder and the NC
|
|
912
|
+
* clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
|
|
913
|
+
* (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
|
|
914
|
+
* a second ffmpeg child of the same camera. So this is the same shape the
|
|
915
|
+
* broker's other multi-consumer surfaces already have
|
|
916
|
+
* (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
|
|
917
|
+
* one producer.
|
|
918
|
+
*
|
|
919
|
+
* **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
|
|
920
|
+
* clip source; both are named here so the seam is not re-invented, and neither
|
|
921
|
+
* is built.
|
|
922
|
+
*
|
|
923
|
+
* ## The init segment is RETAINED
|
|
924
|
+
*
|
|
925
|
+
* A subscriber that attaches mid-stream — the clip consumer joining an already
|
|
926
|
+
* running HKSV session, which is the whole dual-use case — receives the
|
|
927
|
+
* retained `ftyp`+`moov` as its first packet and then live fragments. Without
|
|
928
|
+
* retention its fragments are undecodable and the failure looks like a codec
|
|
929
|
+
* problem.
|
|
930
|
+
*
|
|
931
|
+
* ## A slow subscriber is CLOSED, never silently gapped
|
|
932
|
+
*
|
|
933
|
+
* `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
|
|
934
|
+
* click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
|
|
935
|
+
* `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
|
|
936
|
+
* shows a clip that fails to play with nothing anywhere saying why. So a
|
|
937
|
+
* subscription whose queue overflows is ENDED with a reason, loudly, and the
|
|
938
|
+
* other subscriptions are untouched.
|
|
939
|
+
*
|
|
940
|
+
* ## The PREBUFFER (phase 3)
|
|
941
|
+
*
|
|
942
|
+
* HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
|
|
943
|
+
* is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
|
|
944
|
+
* motion edge has none. So the plane optionally retains the last few fragments
|
|
945
|
+
* and replays them to a subscriber that asks for them.
|
|
946
|
+
*
|
|
947
|
+
* Three things this ring gets right, each of which is a measured fact rather
|
|
948
|
+
* than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
|
|
949
|
+
*
|
|
950
|
+
* - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
|
|
951
|
+
* fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
|
|
952
|
+
* window. A time-only bound is a per-camera RAM figure nobody can predict.
|
|
953
|
+
* - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
|
|
954
|
+
* splitter deliberately never computes a fragment's duration (a second
|
|
955
|
+
* opinion about a fact the muxer owns), and a prebuffer cares about how long
|
|
956
|
+
* ago the bytes turned up, which is exactly what arrival time answers.
|
|
957
|
+
* - **A replay is not backlog.** A subscriber taking N retained fragments gets
|
|
958
|
+
* its queue capacity raised by N for them, because closing a subscriber as a
|
|
959
|
+
* slow consumer for the prebuffer it explicitly asked for would be the
|
|
960
|
+
* stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
|
|
961
|
+
* ring of 4, the guaranteed one.
|
|
962
|
+
*
|
|
963
|
+
* ## `isLast`
|
|
964
|
+
*
|
|
965
|
+
* hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
|
|
966
|
+
* `isLast` — a generator that finishes without it produces the twelve-second
|
|
967
|
+
* timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
|
|
968
|
+
* plane therefore computes it at DELIVERY time: a packet is last when the plane
|
|
969
|
+
* has ended and nothing remains queued behind it. A subscription that ends
|
|
970
|
+
* having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
|
|
971
|
+
* the future delegate must not open an HDS stream it cannot feed.
|
|
972
|
+
*/
|
|
973
|
+
var DEFAULT_QUEUE_CAPACITY = 4;
|
|
974
|
+
var Fmp4FragmentPlane = class {
|
|
975
|
+
logger;
|
|
976
|
+
prebuffer;
|
|
977
|
+
now;
|
|
978
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
979
|
+
/** The last init unit seen, handed to every later subscriber. */
|
|
980
|
+
retainedInit = null;
|
|
981
|
+
ended = false;
|
|
982
|
+
/** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
|
|
983
|
+
ring = [];
|
|
984
|
+
ringBytes = 0;
|
|
985
|
+
constructor(logger, prebuffer, now = Date.now) {
|
|
986
|
+
this.logger = logger;
|
|
987
|
+
this.prebuffer = prebuffer;
|
|
988
|
+
this.now = now;
|
|
989
|
+
}
|
|
990
|
+
get subscriberCount() {
|
|
991
|
+
return this.subscriptions.size;
|
|
992
|
+
}
|
|
993
|
+
/** True once {@link end} has been called — no further units are accepted. */
|
|
994
|
+
get isEnded() {
|
|
995
|
+
return this.ended;
|
|
996
|
+
}
|
|
997
|
+
/** What the prebuffer ring holds right now. All zeroes when disabled. */
|
|
998
|
+
prebufferStats() {
|
|
999
|
+
const oldest = this.ring[0];
|
|
1000
|
+
return {
|
|
1001
|
+
fragments: this.ring.length,
|
|
1002
|
+
bytes: this.ringBytes,
|
|
1003
|
+
spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
subscribe(input) {
|
|
1007
|
+
const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
|
|
1008
|
+
const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
|
|
1009
|
+
const sub = {
|
|
1010
|
+
id: `fmp4-${randomUUID()}`,
|
|
1011
|
+
tag: input.tag,
|
|
1012
|
+
subscribedAt: this.now(),
|
|
1013
|
+
capacity: requested + replay.length,
|
|
1014
|
+
queue: [],
|
|
1015
|
+
delivered: 0,
|
|
1016
|
+
closedReason: null,
|
|
1017
|
+
wake: null,
|
|
1018
|
+
iterating: false
|
|
1019
|
+
};
|
|
1020
|
+
this.subscriptions.set(sub.id, sub);
|
|
1021
|
+
if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
|
|
1022
|
+
for (const retained of replay) this.enqueue(sub, retained.unit);
|
|
1023
|
+
if (this.ended) this.closeSubscription(sub, "ended");
|
|
1024
|
+
this.logger?.info("fmp4 plane: subscribed", { meta: {
|
|
1025
|
+
subscriptionId: sub.id,
|
|
1026
|
+
tag: sub.tag,
|
|
1027
|
+
hasRetainedInit: this.retainedInit !== null,
|
|
1028
|
+
prebufferFragments: replay.length,
|
|
1029
|
+
prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
|
|
1030
|
+
} });
|
|
1031
|
+
return this.facade(sub);
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
|
|
1035
|
+
* emits exactly one per child, and a second means the child was respawned, in
|
|
1036
|
+
* which case the old one describes a stream that no longer exists.
|
|
1037
|
+
*/
|
|
1038
|
+
publish(unit) {
|
|
1039
|
+
if (this.ended) return;
|
|
1040
|
+
if (unit.kind === "init") {
|
|
1041
|
+
this.retainedInit = unit;
|
|
1042
|
+
this.ring.length = 0;
|
|
1043
|
+
this.ringBytes = 0;
|
|
1044
|
+
} else this.retain(unit);
|
|
1045
|
+
for (const sub of this.subscriptions.values()) {
|
|
1046
|
+
if (sub.closedReason !== null) continue;
|
|
1047
|
+
this.enqueue(sub, unit);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* The producer stopped. Every subscriber drains what it holds; its final
|
|
1052
|
+
* packet carries `isLast`, and its generator then completes.
|
|
1053
|
+
*/
|
|
1054
|
+
end(reason = "producer ended") {
|
|
1055
|
+
if (this.ended) return;
|
|
1056
|
+
this.ended = true;
|
|
1057
|
+
this.logger?.info("fmp4 plane: ended", { meta: {
|
|
1058
|
+
reason,
|
|
1059
|
+
subscribers: this.subscriptions.size
|
|
1060
|
+
} });
|
|
1061
|
+
for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
|
|
1062
|
+
}
|
|
1063
|
+
listSubscribers() {
|
|
1064
|
+
return [...this.subscriptions.values()].map((s) => ({
|
|
1065
|
+
tag: s.tag,
|
|
1066
|
+
subscribedAt: s.subscribedAt,
|
|
1067
|
+
delivered: s.delivered,
|
|
1068
|
+
closedReason: s.closedReason
|
|
1069
|
+
}));
|
|
1070
|
+
}
|
|
1071
|
+
/** End and forget everything. Idempotent. */
|
|
1072
|
+
dispose() {
|
|
1073
|
+
this.end("disposed");
|
|
1074
|
+
this.subscriptions.clear();
|
|
1075
|
+
this.retainedInit = null;
|
|
1076
|
+
this.ring.length = 0;
|
|
1077
|
+
this.ringBytes = 0;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Add one fragment to the ring and evict from the front until BOTH bounds
|
|
1081
|
+
* hold. Eviction is oldest-first, which is the one place in this file where
|
|
1082
|
+
* dropping is correct: the ring is context, not stream — nobody is mid-decode
|
|
1083
|
+
* on it, and a subscriber only ever receives a contiguous tail of it.
|
|
1084
|
+
*/
|
|
1085
|
+
retain(unit) {
|
|
1086
|
+
const prebuffer = this.prebuffer;
|
|
1087
|
+
if (prebuffer === void 0) return;
|
|
1088
|
+
const arrivedAt = this.now();
|
|
1089
|
+
this.ring.push({
|
|
1090
|
+
unit,
|
|
1091
|
+
arrivedAt
|
|
1092
|
+
});
|
|
1093
|
+
this.ringBytes += unit.data.length;
|
|
1094
|
+
const cutoff = arrivedAt - prebuffer.windowMs;
|
|
1095
|
+
while (this.ring.length > 0) {
|
|
1096
|
+
const oldest = this.ring[0];
|
|
1097
|
+
if (oldest === void 0) break;
|
|
1098
|
+
const tooOld = oldest.arrivedAt < cutoff;
|
|
1099
|
+
const tooBig = this.ringBytes > prebuffer.maxBytes;
|
|
1100
|
+
if (!tooOld && !tooBig || this.ring.length === 1) break;
|
|
1101
|
+
this.ring.shift();
|
|
1102
|
+
this.ringBytes -= oldest.unit.data.length;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* The ring as a subscriber should receive it — window applied AT SUBSCRIBE
|
|
1107
|
+
* time, not only at publish time. A camera that went quiet keeps its last
|
|
1108
|
+
* fragment in the ring indefinitely (see the never-evict-the-newest rule),
|
|
1109
|
+
* and replaying a 40-second-old fragment as "prebuffer" would put stale video
|
|
1110
|
+
* at the head of a clip iOS presents as the moment of the event.
|
|
1111
|
+
*/
|
|
1112
|
+
trimmedRing() {
|
|
1113
|
+
const prebuffer = this.prebuffer;
|
|
1114
|
+
if (prebuffer === void 0) return [];
|
|
1115
|
+
const cutoff = this.now() - prebuffer.windowMs;
|
|
1116
|
+
return this.ring.filter((r) => r.arrivedAt >= cutoff);
|
|
1117
|
+
}
|
|
1118
|
+
enqueue(sub, unit) {
|
|
1119
|
+
if (sub.queue.length >= sub.capacity) {
|
|
1120
|
+
this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
|
|
1121
|
+
subscriptionId: sub.id,
|
|
1122
|
+
tag: sub.tag,
|
|
1123
|
+
capacity: sub.capacity,
|
|
1124
|
+
delivered: sub.delivered
|
|
1125
|
+
} });
|
|
1126
|
+
this.closeSubscription(sub, "slow-consumer");
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
sub.queue.push({
|
|
1130
|
+
kind: unit.kind,
|
|
1131
|
+
data: unit.data,
|
|
1132
|
+
sequence: unit.sequence,
|
|
1133
|
+
isLast: false
|
|
1134
|
+
});
|
|
1135
|
+
this.wake(sub);
|
|
1136
|
+
}
|
|
1137
|
+
closeSubscription(sub, reason) {
|
|
1138
|
+
if (sub.closedReason !== null) return;
|
|
1139
|
+
sub.closedReason = reason;
|
|
1140
|
+
if (reason === "slow-consumer") sub.queue.length = 0;
|
|
1141
|
+
this.wake(sub);
|
|
1142
|
+
}
|
|
1143
|
+
wake(sub) {
|
|
1144
|
+
const resume = sub.wake;
|
|
1145
|
+
sub.wake = null;
|
|
1146
|
+
resume?.();
|
|
1147
|
+
}
|
|
1148
|
+
facade(sub) {
|
|
1149
|
+
const plane = this;
|
|
1150
|
+
return {
|
|
1151
|
+
id: sub.id,
|
|
1152
|
+
tag: sub.tag,
|
|
1153
|
+
get delivered() {
|
|
1154
|
+
return sub.delivered;
|
|
1155
|
+
},
|
|
1156
|
+
get closedReason() {
|
|
1157
|
+
return sub.closedReason;
|
|
1158
|
+
},
|
|
1159
|
+
packets: () => plane.iterate(sub),
|
|
1160
|
+
release: () => {
|
|
1161
|
+
plane.closeSubscription(sub, "released");
|
|
1162
|
+
plane.subscriptions.delete(sub.id);
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
async *iterate(sub) {
|
|
1167
|
+
if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
|
|
1168
|
+
sub.iterating = true;
|
|
1169
|
+
for (;;) {
|
|
1170
|
+
const next = sub.queue.shift();
|
|
1171
|
+
if (next === void 0) {
|
|
1172
|
+
if (sub.closedReason !== null) return;
|
|
1173
|
+
await new Promise((resolve) => {
|
|
1174
|
+
sub.wake = resolve;
|
|
1175
|
+
});
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
|
|
1179
|
+
sub.delivered += 1;
|
|
1180
|
+
yield {
|
|
1181
|
+
...next,
|
|
1182
|
+
isLast
|
|
1183
|
+
};
|
|
1184
|
+
if (isLast) return;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
//#endregion
|
|
1189
|
+
//#region src/ffmpeg/fmp4-fragment-child.ts
|
|
1190
|
+
var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
|
|
1191
|
+
/** Heartbeat cadence — ~2 minutes of 4 s fragments. */
|
|
1192
|
+
var FRAGMENT_LOG_EVERY = 30;
|
|
1193
|
+
var Fmp4FragmentChild = class {
|
|
1194
|
+
deps;
|
|
1195
|
+
args;
|
|
1196
|
+
child = null;
|
|
1197
|
+
splitter = new Fmp4BoxSplitter();
|
|
1198
|
+
stopped = false;
|
|
1199
|
+
unitsOut = 0;
|
|
1200
|
+
activeHwAccel = null;
|
|
1201
|
+
constructor(deps, args) {
|
|
1202
|
+
this.deps = deps;
|
|
1203
|
+
this.args = args;
|
|
1204
|
+
}
|
|
1205
|
+
/** Spawn, and resolve once the INIT segment has been cut out of stdout. */
|
|
1206
|
+
async start() {
|
|
1207
|
+
const requested = this.args.invocation.decodeHwAccel;
|
|
1208
|
+
this.activeHwAccel = requested;
|
|
1209
|
+
try {
|
|
1210
|
+
await this.spawnAttempt(requested);
|
|
1211
|
+
return;
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
if (this.stopped) throw err;
|
|
1214
|
+
if (requested === null || isSoftwareDecode(requested)) throw err;
|
|
1215
|
+
this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
|
|
1216
|
+
tags: { deviceId: this.args.deviceId },
|
|
1217
|
+
meta: {
|
|
1218
|
+
sourceId: this.args.sourceId,
|
|
1219
|
+
decodeHwAccel: requested,
|
|
1220
|
+
error: errMsg(err)
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
this.killChild();
|
|
1224
|
+
this.splitter = new Fmp4BoxSplitter();
|
|
1225
|
+
this.activeHwAccel = null;
|
|
1226
|
+
await this.spawnAttempt(null);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
/** The backend the child ACTUALLY ran with — `null` for software. */
|
|
1230
|
+
activeDecodeHwAccel() {
|
|
1231
|
+
const value = this.activeHwAccel;
|
|
1232
|
+
return value === null || value === "none" || value === "copy" ? null : value;
|
|
1233
|
+
}
|
|
1234
|
+
/** Kill ffmpeg and end the plane. Idempotent. */
|
|
1235
|
+
async stop() {
|
|
1236
|
+
if (this.stopped) return;
|
|
1237
|
+
this.stopped = true;
|
|
1238
|
+
this.killChild();
|
|
1239
|
+
this.args.plane.end("the fragment child stopped");
|
|
1240
|
+
}
|
|
1241
|
+
spawnAttempt(decodeHwAccel) {
|
|
1242
|
+
const args = buildFfmpegArgs({
|
|
1243
|
+
...this.args.invocation,
|
|
1244
|
+
decodeHwAccel,
|
|
1245
|
+
sink: {
|
|
1246
|
+
kind: "stdout",
|
|
1247
|
+
container: "mp4",
|
|
1248
|
+
fragmentMs: this.args.fragmentMs
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
|
|
1252
|
+
tags: { deviceId: this.args.deviceId },
|
|
1253
|
+
meta: {
|
|
1254
|
+
sourceId: this.args.sourceId,
|
|
1255
|
+
fragmentMs: this.args.fragmentMs,
|
|
1256
|
+
decodeHwAccel: decodeHwAccel ?? "software",
|
|
1257
|
+
argv: args.join(" ")
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
return new Promise((resolve, reject) => {
|
|
1261
|
+
const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
|
|
1262
|
+
"ignore",
|
|
1263
|
+
"pipe",
|
|
1264
|
+
"pipe"
|
|
1265
|
+
] });
|
|
1266
|
+
this.child = child;
|
|
1267
|
+
let settled = false;
|
|
1268
|
+
/**
|
|
1269
|
+
* This attempt FAILED. Set before the kill, because SIGTERM makes the
|
|
1270
|
+
* child exit and that exit must not be reported as a death: the retry —
|
|
1271
|
+
* or the caller's rejection — already owns what happens next. Without it
|
|
1272
|
+
* the timeout path ends the plane the software retry is about to fill,
|
|
1273
|
+
* and the consumer sees a stream that stopped for no reason. A "which
|
|
1274
|
+
* spawn is current" counter does NOT cover this: the retry has not been
|
|
1275
|
+
* spawned when the kill's exit arrives.
|
|
1276
|
+
*/
|
|
1277
|
+
let failed = false;
|
|
1278
|
+
/**
|
|
1279
|
+
* This attempt is still the live producer: it has not failed (a failure
|
|
1280
|
+
* hands ownership to the retry, or to the caller's rejection) and nothing
|
|
1281
|
+
* has stopped the child. Those two cover every way an attempt stops being
|
|
1282
|
+
* current — `start` only respawns after a rejection.
|
|
1283
|
+
*/
|
|
1284
|
+
const isCurrent = () => !this.stopped && !failed;
|
|
1285
|
+
const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
|
|
1286
|
+
const settle = (fail) => {
|
|
1287
|
+
if (settled) return;
|
|
1288
|
+
settled = true;
|
|
1289
|
+
clearTimeout(timer);
|
|
1290
|
+
if (fail) {
|
|
1291
|
+
failed = true;
|
|
1292
|
+
reject(fail);
|
|
1293
|
+
} else resolve();
|
|
1294
|
+
};
|
|
1295
|
+
const timer = setTimeout(() => {
|
|
1296
|
+
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
|
|
1297
|
+
this.killChild();
|
|
1298
|
+
}, timeoutMs);
|
|
1299
|
+
timer.unref?.();
|
|
1300
|
+
child.stdout?.on("data", (chunk) => {
|
|
1301
|
+
for (const unit of this.splitter.push(chunk)) {
|
|
1302
|
+
this.unitsOut += 1;
|
|
1303
|
+
this.args.plane.publish(unit);
|
|
1304
|
+
if (unit.kind === "init") {
|
|
1305
|
+
this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
|
|
1306
|
+
tags: { deviceId: this.args.deviceId },
|
|
1307
|
+
meta: {
|
|
1308
|
+
sourceId: this.args.sourceId,
|
|
1309
|
+
bytes: unit.data.length
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
settle();
|
|
1313
|
+
} else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
|
|
1314
|
+
tags: { deviceId: this.args.deviceId },
|
|
1315
|
+
meta: {
|
|
1316
|
+
sourceId: this.args.sourceId,
|
|
1317
|
+
unitsOut: this.unitsOut,
|
|
1318
|
+
bytes: unit.data.length,
|
|
1319
|
+
subscribers: this.args.plane.subscriberCount
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
const fault = this.splitter.fault;
|
|
1324
|
+
if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
|
|
1325
|
+
});
|
|
1326
|
+
child.stderr?.setEncoding("utf8");
|
|
1327
|
+
child.stderr?.on("data", (line) => {
|
|
1328
|
+
this.deps.logger.debug("fmp4 fragment child ffmpeg", {
|
|
1329
|
+
tags: { deviceId: this.args.deviceId },
|
|
1330
|
+
meta: {
|
|
1331
|
+
sourceId: this.args.sourceId,
|
|
1332
|
+
line: line.trim()
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
child.once("error", (err) => {
|
|
1337
|
+
if (!settled) {
|
|
1338
|
+
settle(err);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (!isCurrent()) return;
|
|
1342
|
+
this.args.plane.end("the fragment child errored");
|
|
1343
|
+
this.deps.onChildExit?.(err);
|
|
1344
|
+
});
|
|
1345
|
+
child.once("exit", (code, signal) => {
|
|
1346
|
+
if (!settled) {
|
|
1347
|
+
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (!isCurrent()) return;
|
|
1351
|
+
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
|
|
1352
|
+
this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
|
|
1353
|
+
tags: { deviceId: this.args.deviceId },
|
|
1354
|
+
meta: {
|
|
1355
|
+
sourceId: this.args.sourceId,
|
|
1356
|
+
code,
|
|
1357
|
+
signal,
|
|
1358
|
+
unitsOut: this.unitsOut
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
this.args.plane.end("the fragment child exited");
|
|
1362
|
+
this.deps.onChildExit?.(error);
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* The byte stream stopped being splittable. Not recoverable — the splitter
|
|
1368
|
+
* cannot resynchronise mid-box — so the child is a corpse and every consumer
|
|
1369
|
+
* has to be told, loudly, with the reason.
|
|
1370
|
+
*/
|
|
1371
|
+
onFault(reason, wasLive, current, settle) {
|
|
1372
|
+
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
|
|
1373
|
+
this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
|
|
1374
|
+
tags: { deviceId: this.args.deviceId },
|
|
1375
|
+
meta: {
|
|
1376
|
+
sourceId: this.args.sourceId,
|
|
1377
|
+
unitsOut: this.unitsOut,
|
|
1378
|
+
interstitial: this.splitter.discardedInterstitialTypes,
|
|
1379
|
+
reason
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
this.killChild();
|
|
1383
|
+
settle(error);
|
|
1384
|
+
if (wasLive && current) {
|
|
1385
|
+
this.args.plane.end("the fragment child produced unsplittable output");
|
|
1386
|
+
this.deps.onChildExit?.(error);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
killChild() {
|
|
1390
|
+
const child = this.child;
|
|
1391
|
+
this.child = null;
|
|
1392
|
+
if (child && !child.killed) try {
|
|
1393
|
+
child.kill("SIGTERM");
|
|
1394
|
+
} catch (err) {
|
|
1395
|
+
this.deps.logger.warn("fmp4 fragment child: kill error", {
|
|
1396
|
+
tags: { deviceId: this.args.deviceId },
|
|
1397
|
+
meta: {
|
|
1398
|
+
sourceId: this.args.sourceId,
|
|
1399
|
+
error: errMsg(err)
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
//#endregion
|
|
1406
|
+
export { FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, resolveExportFingerprint };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule-window evaluation for the notification-rule vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Hoisted out of the notification-centre's rule engine when a SECOND
|
|
5
|
+
* consumer appeared (the OSD manager gates an overlay on the same
|
|
6
|
+
* `NcSchedule`). Addons never import each other, so the alternative was a
|
|
7
|
+
* copy — and this repo has already recorded what a second copy of a
|
|
8
|
+
* vocabulary costs (`condition-taxonomy.ts`): two implementations, free to
|
|
9
|
+
* drift, with nothing that fails when they do.
|
|
10
|
+
*
|
|
11
|
+
* Pure: no clock of its own, `atMs` is always supplied by the caller.
|
|
12
|
+
*/
|
|
13
|
+
import type { NcSchedule } from '../capabilities/notification-rules.cap.js';
|
|
14
|
+
/**
|
|
15
|
+
* Is the schedule active at `atMs`? No schedule = always active. Windows
|
|
16
|
+
* are OR'd; a window with `startMinute > endMinute` crosses midnight (it
|
|
17
|
+
* starts on a listed day and spills into the next). `invert` flips the
|
|
18
|
+
* result (active OUTSIDE the windows).
|
|
19
|
+
*/
|
|
20
|
+
export declare function isScheduleActive(schedule: NcSchedule | undefined, atMs: number): boolean;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
|
|
3
|
+
* decode worker's native-resolution frame retention.
|
|
4
|
+
*
|
|
5
|
+
* ## Why they live here and not in the addon that reads them
|
|
6
|
+
*
|
|
7
|
+
* The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
|
|
8
|
+
* the READER is a private child process of `addon-pipeline`'s pipeline-runner.
|
|
9
|
+
* Addons never import each other, so a key owned by either side would have to
|
|
10
|
+
* be hand-copied by the other — and a hand-copied key is how a setting silently
|
|
11
|
+
* stops arriving while both sides still look correct. Same reasoning, same
|
|
12
|
+
* placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
|
|
13
|
+
*
|
|
14
|
+
* ## Why cluster-wide and not per-node
|
|
15
|
+
*
|
|
16
|
+
* The lease is a per-decode-worker RAM window. Its purpose — the late
|
|
17
|
+
* cross-process native crop landing on a full-resolution frame rather than the
|
|
18
|
+
* ≤640 detection fallback — is a property of the PIPELINE, not of a node's
|
|
19
|
+
* hardware: a per-node TTL would mean the same camera produces different crop
|
|
20
|
+
* quality depending on which node the balancer placed it on, and nobody could
|
|
21
|
+
* tell that from the stored media. Node-level RAM pressure is already handled
|
|
22
|
+
* by the per-session budget ceiling, which is itself one of these knobs.
|
|
23
|
+
*
|
|
24
|
+
* ## What each knob costs
|
|
25
|
+
*
|
|
26
|
+
* A retained frame is a full NATIVE-resolution copy in system RAM. With the
|
|
27
|
+
* default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
|
|
28
|
+
* 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
|
|
29
|
+
* for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
|
|
30
|
+
* resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
|
|
31
|
+
* clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
|
|
32
|
+
* admission" for what actually gets admitted.
|
|
33
|
+
*/
|
|
34
|
+
import { z } from 'zod';
|
|
35
|
+
import type { HydratedSettingsView } from './detail-crop.js';
|
|
36
|
+
/**
|
|
37
|
+
* Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
|
|
38
|
+
* (cluster-wide) settings. Keys are unique across that addon's whole schema, so
|
|
39
|
+
* the reader can walk every section instead of trusting the section id.
|
|
40
|
+
*/
|
|
41
|
+
export declare const NATIVE_LEASE_SECTION_ID = "native-lease";
|
|
42
|
+
export declare const NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
|
|
43
|
+
export declare const NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
|
|
44
|
+
export declare const NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
|
|
45
|
+
export declare const NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
|
|
46
|
+
/**
|
|
47
|
+
* WHICH delivered frames the decode worker retains a native copy of.
|
|
48
|
+
*
|
|
49
|
+
* - `all` — every frame the worker delivered to the runner. The shipped
|
|
50
|
+
* behaviour, and the only correct one if something can ask for a crop of a
|
|
51
|
+
* frame the runner never sent to inference.
|
|
52
|
+
* - `inferred` — only the frames the runner ADMITTED to its detection queue.
|
|
53
|
+
* A native-crop request always names a `frameId` that rode an inference
|
|
54
|
+
* result, so that is the only set a request can name. How much it drops is
|
|
55
|
+
* the two-plane governor's admit ratio and nothing else: measured at ~50% on
|
|
56
|
+
* this cluster, not the ~80% the design sketch assumed, because the governor
|
|
57
|
+
* was not throttling as hard as the sketch supposed. Read
|
|
58
|
+
* `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
|
|
59
|
+
* of you rather than quoting a number from here. The newest delivered frame is
|
|
60
|
+
* croppable regardless — it is still the worker's reserved slot, not a lease —
|
|
61
|
+
* which covers the one-frame race between a mark and the supersede that
|
|
62
|
+
* consumes it.
|
|
63
|
+
*/
|
|
64
|
+
export declare const NativeLeaseAdmissionSchema: z.ZodEnum<{
|
|
65
|
+
all: "all";
|
|
66
|
+
inferred: "inferred";
|
|
67
|
+
}>;
|
|
68
|
+
export type NativeLeaseAdmission = z.infer<typeof NativeLeaseAdmissionSchema>;
|
|
69
|
+
/**
|
|
70
|
+
* Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
|
|
71
|
+
* the slider) because the value also travels to a forked child process, where a
|
|
72
|
+
* junk number would silently become a 0-length or unbounded retention window.
|
|
73
|
+
*/
|
|
74
|
+
export declare const NativeLeaseSettingsSchema: z.ZodObject<{
|
|
75
|
+
ttlMs: z.ZodNumber;
|
|
76
|
+
budgetMb: z.ZodNumber;
|
|
77
|
+
activityMs: z.ZodNumber;
|
|
78
|
+
admission: z.ZodEnum<{
|
|
79
|
+
all: "all";
|
|
80
|
+
inferred: "inferred";
|
|
81
|
+
}>;
|
|
82
|
+
}, z.core.$strip>;
|
|
83
|
+
export type NativeLeaseSettings = z.infer<typeof NativeLeaseSettingsSchema>;
|
|
84
|
+
/**
|
|
85
|
+
* The values in force when the operator has set nothing — byte-for-byte the
|
|
86
|
+
* constants the decode worker shipped with as env-var defaults, so making these
|
|
87
|
+
* settings changed no behaviour on the day it landed.
|
|
88
|
+
*/
|
|
89
|
+
export declare const DEFAULT_NATIVE_LEASE_SETTINGS: NativeLeaseSettings;
|
|
90
|
+
/**
|
|
91
|
+
* Only the knobs the operator has ACTUALLY set. Distinguishing "set" from
|
|
92
|
+
* "absent" is what makes the documented precedence (setting > env > default)
|
|
93
|
+
* expressible: an absent knob leaves the env-var escape hatch working.
|
|
94
|
+
*/
|
|
95
|
+
export type NativeLeaseSettingsOverride = Partial<NativeLeaseSettings>;
|
|
96
|
+
/** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
|
|
97
|
+
export declare const NATIVE_LEASE_TTL_FIELD: {
|
|
98
|
+
readonly min: 250;
|
|
99
|
+
readonly max: 10000;
|
|
100
|
+
readonly step: 50;
|
|
101
|
+
readonly default: number;
|
|
102
|
+
};
|
|
103
|
+
export declare const NATIVE_LEASE_BUDGET_FIELD: {
|
|
104
|
+
readonly min: 0;
|
|
105
|
+
readonly max: 4096;
|
|
106
|
+
readonly step: 64;
|
|
107
|
+
readonly default: number;
|
|
108
|
+
};
|
|
109
|
+
export declare const NATIVE_LEASE_ACTIVITY_FIELD: {
|
|
110
|
+
readonly min: 0;
|
|
111
|
+
readonly max: 120000;
|
|
112
|
+
readonly step: 1000;
|
|
113
|
+
readonly default: number;
|
|
114
|
+
};
|
|
115
|
+
/** Select options for the admission knob (orchestrator settings UI). */
|
|
116
|
+
export declare const NATIVE_LEASE_ADMISSION_FIELD: {
|
|
117
|
+
readonly options: readonly [{
|
|
118
|
+
readonly value: "all";
|
|
119
|
+
readonly label: "Every delivered frame";
|
|
120
|
+
}, {
|
|
121
|
+
readonly value: "inferred";
|
|
122
|
+
readonly label: "Only frames sent to inference";
|
|
123
|
+
}];
|
|
124
|
+
readonly default: "all" | "inferred";
|
|
125
|
+
};
|
|
126
|
+
/** The four knobs, as a key union. */
|
|
127
|
+
export type NativeLeaseKnob = keyof NativeLeaseSettings;
|
|
128
|
+
/** The three knobs whose value is a bounded integer (everything but `admission`). */
|
|
129
|
+
export type NativeLeaseNumberKnob = 'ttlMs' | 'budgetMb' | 'activityMs';
|
|
130
|
+
/**
|
|
131
|
+
* Narrow a FLAT settings record to the knobs the operator set.
|
|
132
|
+
*
|
|
133
|
+
* Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
|
|
134
|
+
* budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
|
|
135
|
+
* clamped, not defaulted) so the caller can still fall through to the env
|
|
136
|
+
* override — clamping here would turn a typo into a value nobody chose. See
|
|
137
|
+
* {@link readKnob} for why the default counts as unset.
|
|
138
|
+
*/
|
|
139
|
+
export declare function readNativeLeaseOverride(config: Readonly<Record<string, unknown>>): NativeLeaseSettingsOverride;
|
|
140
|
+
/**
|
|
141
|
+
* Extract the operator's lease overrides from an
|
|
142
|
+
* `addon-settings.getGlobalSettings` payload.
|
|
143
|
+
*
|
|
144
|
+
* Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
|
|
145
|
+
* alone: the keys are unique across the addon's schema, and a section rename
|
|
146
|
+
* must not silently revert the whole cluster to the defaults. A `null` payload
|
|
147
|
+
* (addon mid-boot) means "operator set nothing" — the env/default fallback then
|
|
148
|
+
* applies, which is the correct read of "I could not ask".
|
|
149
|
+
*/
|
|
150
|
+
export declare function pickNativeLeaseOverride(view: HydratedSettingsView | null): NativeLeaseSettingsOverride;
|