@trillboards/edge-sdk 0.2.138 → 0.2.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/README.md +2 -2
  2. package/deploy/detector/pyproject.toml +20 -2
  3. package/deploy/detector/tests/test_execution.py +124 -0
  4. package/deploy/detector/tests/test_model_identity.py +80 -0
  5. package/deploy/detector/tests/test_registry.py +18 -0
  6. package/deploy/detector/tests/test_server.py +3 -1
  7. package/deploy/detector/tests/test_version_stamp.py +4 -1
  8. package/deploy/detector/trillboards_detector/_version.py +2 -2
  9. package/deploy/detector/trillboards_detector/cli.py +3 -0
  10. package/deploy/detector/trillboards_detector/detections.py +7 -0
  11. package/deploy/detector/trillboards_detector/errors.py +4 -0
  12. package/deploy/detector/trillboards_detector/execution.py +109 -0
  13. package/deploy/detector/trillboards_detector/loader.py +6 -3
  14. package/deploy/detector/trillboards_detector/registry.py +27 -5
  15. package/deploy/detector/trillboards_detector/server.py +29 -2
  16. package/deploy/detector/trillboards_detector/session.py +15 -3
  17. package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
  18. package/deploy/litertlm/test_verify_model_proof.py +30 -0
  19. package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
  20. package/deploy/litertlm/verify_model_proof.py +30 -0
  21. package/deploy/macos/TrillboardsSetupApp.m +131 -3
  22. package/deploy/macos/build-macos-pkg.sh +13 -10
  23. package/deploy/macos/scripts/setup-detector.sh +2 -2
  24. package/deploy/macos/scripts/setup-litertlm.sh +66 -30
  25. package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
  26. package/deploy/setup-guidance.json +112 -0
  27. package/deploy/systemd/setup-detector.sh +2 -2
  28. package/deploy/systemd/setup-litertlm.sh +51 -27
  29. package/deploy/windows/build-windows.ps1 +11 -6
  30. package/deploy/windows/bundle-windows-installer.ps1 +3 -3
  31. package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
  32. package/deploy/windows/install-update.ps1 +52 -9
  33. package/deploy/windows/set-executable-icon.ps1 +24 -5
  34. package/deploy/windows/setup-detector.ps1 +142 -4
  35. package/deploy/windows/setup-litertlm.ps1 +53 -10
  36. package/deploy/windows/trillboards-setup.ps1 +101 -2
  37. package/dist/AudienceAnalyzeClient.d.ts +10 -0
  38. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  39. package/dist/AudienceAnalyzeClient.js +5 -4
  40. package/dist/AudienceAnalyzeClient.js.map +1 -1
  41. package/dist/CloudSensingManager.d.ts +38 -1
  42. package/dist/CloudSensingManager.d.ts.map +1 -1
  43. package/dist/CloudSensingManager.js +403 -25
  44. package/dist/CloudSensingManager.js.map +1 -1
  45. package/dist/DetectorFieldProducer.d.ts +5 -1
  46. package/dist/DetectorFieldProducer.d.ts.map +1 -1
  47. package/dist/DetectorFieldProducer.js +8 -1
  48. package/dist/DetectorFieldProducer.js.map +1 -1
  49. package/dist/DurableVlmCandidateSpool.d.ts +83 -2
  50. package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
  51. package/dist/DurableVlmCandidateSpool.js +596 -30
  52. package/dist/DurableVlmCandidateSpool.js.map +1 -1
  53. package/dist/EdgeAgent.js +1 -1
  54. package/dist/FrameGroupingShadow.d.ts +51 -0
  55. package/dist/FrameGroupingShadow.d.ts.map +1 -0
  56. package/dist/FrameGroupingShadow.js +158 -0
  57. package/dist/FrameGroupingShadow.js.map +1 -0
  58. package/dist/ImageClassifierFieldProducer.d.ts +36 -0
  59. package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
  60. package/dist/ImageClassifierFieldProducer.js +150 -0
  61. package/dist/ImageClassifierFieldProducer.js.map +1 -0
  62. package/dist/LiteRtVlmProvider.d.ts +5 -0
  63. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  64. package/dist/LiteRtVlmProvider.js +72 -7
  65. package/dist/LiteRtVlmProvider.js.map +1 -1
  66. package/dist/LocalDetectorClient.d.ts +6 -0
  67. package/dist/LocalDetectorClient.d.ts.map +1 -1
  68. package/dist/LocalDetectorClient.js +8 -0
  69. package/dist/LocalDetectorClient.js.map +1 -1
  70. package/dist/OrderedFrameReview.d.ts +57 -0
  71. package/dist/OrderedFrameReview.d.ts.map +1 -0
  72. package/dist/OrderedFrameReview.js +121 -0
  73. package/dist/OrderedFrameReview.js.map +1 -0
  74. package/dist/OrderedReviewClient.d.ts +38 -0
  75. package/dist/OrderedReviewClient.d.ts.map +1 -0
  76. package/dist/OrderedReviewClient.js +121 -0
  77. package/dist/OrderedReviewClient.js.map +1 -0
  78. package/dist/OrderedSequenceStore.d.ts +69 -0
  79. package/dist/OrderedSequenceStore.d.ts.map +1 -0
  80. package/dist/OrderedSequenceStore.js +257 -0
  81. package/dist/OrderedSequenceStore.js.map +1 -0
  82. package/dist/ProfileReviewWork.d.ts +68 -0
  83. package/dist/ProfileReviewWork.d.ts.map +1 -0
  84. package/dist/ProfileReviewWork.js +150 -0
  85. package/dist/ProfileReviewWork.js.map +1 -0
  86. package/dist/RetainedFieldContract.d.ts +21 -0
  87. package/dist/RetainedFieldContract.d.ts.map +1 -0
  88. package/dist/RetainedFieldContract.js +92 -0
  89. package/dist/RetainedFieldContract.js.map +1 -0
  90. package/dist/ReviewModelUse.d.ts +7 -0
  91. package/dist/ReviewModelUse.d.ts.map +1 -0
  92. package/dist/ReviewModelUse.js +30 -0
  93. package/dist/ReviewModelUse.js.map +1 -0
  94. package/dist/ReviewPriority.d.ts +12 -0
  95. package/dist/ReviewPriority.d.ts.map +1 -0
  96. package/dist/ReviewPriority.js +45 -0
  97. package/dist/ReviewPriority.js.map +1 -0
  98. package/dist/RuntimeModelSelection.d.ts +10 -0
  99. package/dist/RuntimeModelSelection.d.ts.map +1 -0
  100. package/dist/RuntimeModelSelection.js +58 -0
  101. package/dist/RuntimeModelSelection.js.map +1 -0
  102. package/dist/SceneChangeDetector.d.ts +1 -1
  103. package/dist/SceneChangeDetector.d.ts.map +1 -1
  104. package/dist/SceneChangeDetector.js +18 -6
  105. package/dist/SceneChangeDetector.js.map +1 -1
  106. package/dist/ScopedVlmReview.d.ts +76 -0
  107. package/dist/ScopedVlmReview.d.ts.map +1 -0
  108. package/dist/ScopedVlmReview.js +267 -0
  109. package/dist/ScopedVlmReview.js.map +1 -0
  110. package/dist/SensingProfileClient.d.ts +50 -0
  111. package/dist/SensingProfileClient.d.ts.map +1 -1
  112. package/dist/SensingProfileClient.js +58 -2
  113. package/dist/SensingProfileClient.js.map +1 -1
  114. package/dist/TemporalReviewClient.d.ts +43 -0
  115. package/dist/TemporalReviewClient.d.ts.map +1 -0
  116. package/dist/TemporalReviewClient.js +117 -0
  117. package/dist/TemporalReviewClient.js.map +1 -0
  118. package/dist/TemporalReviewJournal.d.ts +91 -0
  119. package/dist/TemporalReviewJournal.d.ts.map +1 -0
  120. package/dist/TemporalReviewJournal.js +337 -0
  121. package/dist/TemporalReviewJournal.js.map +1 -0
  122. package/dist/UrgentReviewTriage.d.ts +35 -0
  123. package/dist/UrgentReviewTriage.d.ts.map +1 -0
  124. package/dist/UrgentReviewTriage.js +52 -0
  125. package/dist/UrgentReviewTriage.js.map +1 -0
  126. package/dist/VideoSegmentUploader.d.ts +293 -0
  127. package/dist/VideoSegmentUploader.d.ts.map +1 -0
  128. package/dist/VideoSegmentUploader.js +1002 -0
  129. package/dist/VideoSegmentUploader.js.map +1 -0
  130. package/dist/VlmCandidateBuffer.d.ts +19 -0
  131. package/dist/VlmCandidateBuffer.d.ts.map +1 -1
  132. package/dist/VlmCandidateBuffer.js.map +1 -1
  133. package/dist/autoSense.d.ts +2 -2
  134. package/dist/autoSense.d.ts.map +1 -1
  135. package/dist/autoSense.js +16 -11
  136. package/dist/autoSense.js.map +1 -1
  137. package/dist/cli.js +1 -1
  138. package/dist/cli.js.map +1 -1
  139. package/dist/config.d.ts +2 -0
  140. package/dist/config.d.ts.map +1 -1
  141. package/dist/config.js.map +1 -1
  142. package/dist/continuousReview.d.ts +71 -0
  143. package/dist/continuousReview.d.ts.map +1 -0
  144. package/dist/continuousReview.js +53 -0
  145. package/dist/continuousReview.js.map +1 -0
  146. package/dist/detectorCountReview.d.ts +9 -0
  147. package/dist/detectorCountReview.d.ts.map +1 -0
  148. package/dist/detectorCountReview.js +79 -0
  149. package/dist/detectorCountReview.js.map +1 -0
  150. package/dist/diagnose.d.ts +1 -1
  151. package/dist/diagnose.js +1 -1
  152. package/dist/installerOnboardingMode.d.ts.map +1 -1
  153. package/dist/installerOnboardingMode.js +5 -3
  154. package/dist/installerOnboardingMode.js.map +1 -1
  155. package/dist/localInferenceSchema.d.ts +8 -0
  156. package/dist/localInferenceSchema.d.ts.map +1 -1
  157. package/dist/localInferenceSchema.js +3 -3
  158. package/dist/localInferenceSchema.js.map +1 -1
  159. package/dist/localSenseE2eHarness.d.ts +12 -0
  160. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  161. package/dist/localSenseE2eHarness.js +77 -4
  162. package/dist/localSenseE2eHarness.js.map +1 -1
  163. package/dist/localSensing.d.ts +91 -18
  164. package/dist/localSensing.d.ts.map +1 -1
  165. package/dist/localSensing.js +678 -153
  166. package/dist/localSensing.js.map +1 -1
  167. package/dist/mixedScopedCounts.d.ts +17 -0
  168. package/dist/mixedScopedCounts.d.ts.map +1 -0
  169. package/dist/mixedScopedCounts.js +90 -0
  170. package/dist/mixedScopedCounts.js.map +1 -0
  171. package/dist/sensingCatchRate.d.ts +9 -5
  172. package/dist/sensingCatchRate.d.ts.map +1 -1
  173. package/dist/sensingCatchRate.js +10 -6
  174. package/dist/sensingCatchRate.js.map +1 -1
  175. package/dist/sensingRunMode.d.ts +5 -0
  176. package/dist/sensingRunMode.d.ts.map +1 -1
  177. package/dist/sensingRunMode.js +56 -0
  178. package/dist/sensingRunMode.js.map +1 -1
  179. package/package.json +7 -4
  180. package/retained/ArchiveLifecycle.cjs +79 -0
  181. package/retained/ModelStageScheduler.cjs +66 -0
  182. package/retained/ProfileAuthorityClient.cjs +88 -0
  183. package/retained/RetainedCaptureClient.cjs +164 -0
  184. package/retained/WaitRecheckCaptureClient.cjs +109 -0
  185. package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
  186. package/retained/__tests__/runtime.test.cjs +384 -0
  187. package/retained/alert-rule-policy.cjs +37 -0
  188. package/retained/manager.cjs +133 -0
  189. package/retained/pilot-profile-client.cjs +82 -0
  190. package/retained/temporal-grouping.cjs +78 -0
  191. package/retained/website-api-capability.cjs +122 -0
@@ -0,0 +1,1002 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CctvVideoRecorder = exports.VideoSegmentUploader = exports.defaultBlobPutter = exports.MAX_SEGMENT_BYTES = void 0;
37
+ exports.putTimeoutMs = putTimeoutMs;
38
+ exports.probeMp4File = probeMp4File;
39
+ exports.hashFile = hashFile;
40
+ exports.macHardwarePortType = macHardwarePortType;
41
+ exports.hostUploadNetwork = hostUploadNetwork;
42
+ exports.__resetHostUploadNetworkForTests = __resetHostUploadNetworkForTests;
43
+ exports.attachCctvVideoRecorder = attachCctvVideoRecorder;
44
+ exports.activeCctvVideoRecorders = activeCctvVideoRecorders;
45
+ exports.__resetCctvVideoRecordersForTests = __resetCctvVideoRecordersForTests;
46
+ const fs = __importStar(require("fs"));
47
+ const http = __importStar(require("http"));
48
+ const https = __importStar(require("https"));
49
+ const path = __importStar(require("path"));
50
+ const child_process_1 = require("child_process");
51
+ const crypto_1 = require("crypto");
52
+ const AudienceAnalyzeClient_1 = require("./AudienceAnalyzeClient");
53
+ const enterpriseNetwork_1 = require("./enterpriseNetwork");
54
+ const SensingProfileClient_1 = require("./SensingProfileClient");
55
+ /** The route's ceiling on one segment: 256 MiB (268,435,456 bytes). */
56
+ exports.MAX_SEGMENT_BYTES = 268435456;
57
+ /** `PUT` budget floor, and the throughput it assumes: max(60 s, 2 x bytes / 125,000 B/s). */
58
+ const PUT_TIMEOUT_FLOOR_MS = 60000;
59
+ const PUT_ASSUMED_BYTES_PER_SEC = 125000;
60
+ /** A PUT socket that moves nothing for this long is dead, whatever the total budget. */
61
+ const PUT_IDLE_TIMEOUT_MS = 60000;
62
+ const PRESIGN_PATH = '/v2/earner/video-segment-upload-url';
63
+ const FINALIZE_PATH = '/v2/earner/video-segment-finalize';
64
+ /** Finalize streams the whole blob back to hash it; give it the PUT's floor. */
65
+ const FINALIZE_TIMEOUT_MS = 120000;
66
+ const PRESIGN_TIMEOUT_MS = 30000;
67
+ const DEFAULT_RETRY_BASE_MS = 5000;
68
+ const DEFAULT_RETRY_MAX_MS = 15 * 60 * 1000;
69
+ /** C4: `agent_version` is at most 32 characters. */
70
+ const AGENT_VERSION_MAX = 32;
71
+ /** `max(60 s, 2 x bytes / 125,000 B/s)`, in ms. */
72
+ function putTimeoutMs(bytes) {
73
+ return Math.max(PUT_TIMEOUT_FLOOR_MS, Math.ceil(((2 * bytes) / PUT_ASSUMED_BYTES_PER_SEC) * 1000));
74
+ }
75
+ function childBoxes(buf, start, end) {
76
+ const out = [];
77
+ let o = start;
78
+ while (o + 8 <= end) {
79
+ let size = buf.readUInt32BE(o);
80
+ const type = buf.toString('latin1', o + 4, o + 8);
81
+ let header = 8;
82
+ if (size === 1) {
83
+ if (o + 16 > end)
84
+ break;
85
+ const big = buf.readBigUInt64BE(o + 8);
86
+ if (big > BigInt(Number.MAX_SAFE_INTEGER))
87
+ break;
88
+ size = Number(big);
89
+ header = 16;
90
+ }
91
+ else if (size === 0) {
92
+ size = end - o;
93
+ }
94
+ if (size < header || o + size > end)
95
+ break;
96
+ out.push({ type, start: o + header, end: o + size });
97
+ o += size;
98
+ }
99
+ return out;
100
+ }
101
+ function child(buf, parent, type) {
102
+ return childBoxes(buf, parent.start, parent.end).find((b) => b.type === type) ?? null;
103
+ }
104
+ const H264_ENTRIES = new Set(['avc1', 'avc3']);
105
+ const HEVC_ENTRIES = new Set(['hvc1', 'hev1']);
106
+ /** A moov larger than this is not one of our segments (300 s at 60 fps is ~0.3 MB of tables). */
107
+ const MAX_MOOV_BYTES = 64 * 1024 * 1024;
108
+ /** Read the video track out of a parsed `moov`. */
109
+ function parseMoov(moov) {
110
+ const root = { type: 'moov', start: 0, end: moov.length };
111
+ let video = null;
112
+ let hasAudio = false;
113
+ for (const trak of childBoxes(moov, root.start, root.end).filter((b) => b.type === 'trak')) {
114
+ const mdia = child(moov, trak, 'mdia');
115
+ if (!mdia)
116
+ continue;
117
+ const hdlr = child(moov, mdia, 'hdlr');
118
+ if (!hdlr || hdlr.end - hdlr.start < 12)
119
+ continue;
120
+ const handler = moov.toString('latin1', hdlr.start + 8, hdlr.start + 12);
121
+ if (handler === 'soun') {
122
+ hasAudio = true;
123
+ continue;
124
+ }
125
+ if (handler !== 'vide' || video)
126
+ continue;
127
+ const mdhd = child(moov, mdia, 'mdhd');
128
+ const stbl = (() => {
129
+ const minf = child(moov, mdia, 'minf');
130
+ return minf ? child(moov, minf, 'stbl') : null;
131
+ })();
132
+ if (!mdhd || !stbl)
133
+ return null;
134
+ const v1 = moov[mdhd.start] === 1;
135
+ const timescale = moov.readUInt32BE(mdhd.start + (v1 ? 20 : 12));
136
+ const mdhdDuration = v1 ? Number(moov.readBigUInt64BE(mdhd.start + 24)) : moov.readUInt32BE(mdhd.start + 16);
137
+ const stsd = child(moov, stbl, 'stsd');
138
+ const stts = child(moov, stbl, 'stts');
139
+ const stsz = child(moov, stbl, 'stsz');
140
+ if (!stsd || !stts || !stsz || timescale <= 0)
141
+ return null;
142
+ const entry = stsd.start + 8;
143
+ if (entry + 36 > stsd.end)
144
+ return null;
145
+ const sampleEntry = moov.toString('latin1', entry + 4, entry + 8);
146
+ const width = moov.readUInt16BE(entry + 32);
147
+ const height = moov.readUInt16BE(entry + 34);
148
+ let frames = 0;
149
+ let ticks = 0;
150
+ const sttsCount = moov.readUInt32BE(stts.start + 4);
151
+ for (let i = 0; i < sttsCount; i += 1) {
152
+ const at = stts.start + 8 + i * 8;
153
+ if (at + 8 > stts.end)
154
+ return null;
155
+ const count = moov.readUInt32BE(at);
156
+ frames += count;
157
+ ticks += count * moov.readUInt32BE(at + 4);
158
+ }
159
+ const sampleSize = moov.readUInt32BE(stsz.start + 4);
160
+ const sampleCount = moov.readUInt32BE(stsz.start + 8);
161
+ let videoBytes = 0;
162
+ if (sampleSize !== 0) {
163
+ videoBytes = sampleSize * sampleCount;
164
+ }
165
+ else {
166
+ if (stsz.start + 12 + sampleCount * 4 > stsz.end)
167
+ return null;
168
+ for (let i = 0; i < sampleCount; i += 1)
169
+ videoBytes += moov.readUInt32BE(stsz.start + 12 + i * 4);
170
+ }
171
+ const durationTicks = ticks > 0 ? ticks : mdhdDuration;
172
+ const durationMs = (durationTicks / timescale) * 1000;
173
+ if (frames <= 0 || durationMs <= 0 || width <= 0 || height <= 0)
174
+ return null;
175
+ video = {
176
+ codec: H264_ENTRIES.has(sampleEntry) ? 'h264' : HEVC_ENTRIES.has(sampleEntry) ? 'hevc' : sampleEntry.toLowerCase(),
177
+ sampleEntry,
178
+ width,
179
+ height,
180
+ frames,
181
+ durationMs,
182
+ fps: frames / (durationMs / 1000),
183
+ videoBytes,
184
+ hasAudio: false,
185
+ };
186
+ }
187
+ return video ? { ...video, hasAudio } : null;
188
+ }
189
+ /**
190
+ * A CLOSED MP4 with a video track, measured from its own boxes — or null.
191
+ *
192
+ * Null is the "never upload this" answer: a file whose top-level boxes run
193
+ * past its end, or that has no `moov`, is a segment ffmpeg was still writing
194
+ * (measured: a SIGTERM'd segment is mdat with no moov). It is the last gate
195
+ * between a partial file and the corpus, after the provider's list check.
196
+ */
197
+ function probeMp4File(file) {
198
+ let fd;
199
+ try {
200
+ fd = fs.openSync(file, 'r');
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ try {
206
+ const size = fs.fstatSync(fd).size;
207
+ const head = Buffer.alloc(16);
208
+ let offset = 0;
209
+ let moov = null;
210
+ while (offset + 8 <= size) {
211
+ const n = fs.readSync(fd, head, 0, 16, offset);
212
+ if (n < 8)
213
+ return null;
214
+ let boxSize = head.readUInt32BE(0);
215
+ const type = head.toString('latin1', 4, 8);
216
+ let header = 8;
217
+ if (boxSize === 1) {
218
+ if (n < 16)
219
+ return null;
220
+ const big = head.readBigUInt64BE(8);
221
+ if (big > BigInt(Number.MAX_SAFE_INTEGER))
222
+ return null;
223
+ boxSize = Number(big);
224
+ header = 16;
225
+ }
226
+ else if (boxSize === 0) {
227
+ boxSize = size - offset;
228
+ }
229
+ if (boxSize < header || offset + boxSize > size)
230
+ return null;
231
+ if (type === 'moov') {
232
+ if (boxSize - header > MAX_MOOV_BYTES)
233
+ return null;
234
+ moov = Buffer.alloc(boxSize - header);
235
+ if (fs.readSync(fd, moov, 0, moov.length, offset + header) !== moov.length)
236
+ return null;
237
+ }
238
+ offset += boxSize;
239
+ }
240
+ if (offset !== size || !moov)
241
+ return null;
242
+ return parseMoov(moov);
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ finally {
248
+ fs.closeSync(fd);
249
+ }
250
+ }
251
+ /** SHA-256 (hex, the segment_id) and MD5 (base64, Content-MD5) in one read. */
252
+ function hashFile(file) {
253
+ return new Promise((resolve, reject) => {
254
+ const sha = (0, crypto_1.createHash)('sha256');
255
+ const md5 = (0, crypto_1.createHash)('md5');
256
+ let bytes = 0;
257
+ const stream = fs.createReadStream(file, { highWaterMark: 1 << 20 });
258
+ stream.on('data', (chunk) => {
259
+ const buf = chunk;
260
+ sha.update(buf);
261
+ md5.update(buf);
262
+ bytes += buf.length;
263
+ });
264
+ stream.on('error', reject);
265
+ stream.on('end', () => resolve({ sha256: sha.digest('hex'), md5Base64: md5.digest('base64'), bytes }));
266
+ });
267
+ }
268
+ /** The URL without its query: a presigned URL's query IS the credential. */
269
+ function redactSignedUrl(url) {
270
+ try {
271
+ const u = new URL(url);
272
+ return `${u.protocol}//${u.host}${u.pathname}`;
273
+ }
274
+ catch {
275
+ return '[unparseable upload URL]';
276
+ }
277
+ }
278
+ /**
279
+ * Stream a file into a presigned PUT. Never buffers the file: a 256 MiB
280
+ * segment costs one 64 KB read buffer. Bounded twice — a total budget
281
+ * (`putTimeoutMs`) and an idle socket timeout — because a stalled upload must
282
+ * not hold the queue past the URL's own lifetime.
283
+ */
284
+ const defaultBlobPutter = (url, file, options) => new Promise((resolve, reject) => {
285
+ let parsed;
286
+ try {
287
+ parsed = new URL(url);
288
+ }
289
+ catch (err) {
290
+ reject(new Error(`invalid upload URL: ${err.message}`));
291
+ return;
292
+ }
293
+ const isHttps = parsed.protocol === 'https:';
294
+ const mod = isHttps ? https : http;
295
+ const baseOptions = {
296
+ method: 'PUT',
297
+ headers: {
298
+ 'Content-Type': options.contentType,
299
+ 'Content-Length': String(options.bytes),
300
+ // Azure checks the block against this and refuses a mismatch (400).
301
+ 'Content-MD5': options.contentMd5,
302
+ },
303
+ timeout: PUT_IDLE_TIMEOUT_MS,
304
+ ...(isHttps ? { agent: (0, enterpriseNetwork_1.getEnterpriseAgent)() } : {}),
305
+ };
306
+ const proxied = isHttps
307
+ ? { options: baseOptions, proxyUrl: null }
308
+ : (0, enterpriseNetwork_1.applyHttpProxy)(url, baseOptions);
309
+ const target = redactSignedUrl(url);
310
+ let settled = false;
311
+ const source = fs.createReadStream(file);
312
+ const finish = (fn) => {
313
+ if (settled)
314
+ return;
315
+ settled = true;
316
+ clearTimeout(deadline);
317
+ fn();
318
+ };
319
+ const fail = (err) => finish(() => {
320
+ source.destroy();
321
+ req.destroy();
322
+ reject((0, enterpriseNetwork_1.diagnoseTransportError)(err, target));
323
+ });
324
+ const deadline = setTimeout(() => fail(new Error(`video segment upload exceeded ${options.timeoutMs} ms`)), options.timeoutMs);
325
+ if (typeof deadline.unref === 'function')
326
+ deadline.unref();
327
+ const onResponse = (res) => {
328
+ const chunks = [];
329
+ let kept = 0;
330
+ res.on('data', (chunk) => {
331
+ if (kept < 4096) {
332
+ chunks.push(chunk);
333
+ kept += chunk.length;
334
+ }
335
+ });
336
+ res.on('end', () => finish(() => resolve({ statusCode: res.statusCode || 0, body: Buffer.concat(chunks).toString('utf8').slice(0, 4096) })));
337
+ res.once('error', (err) => fail(err));
338
+ res.once('aborted', () => fail(new Error('video segment upload response aborted')));
339
+ };
340
+ const req = proxied.proxyUrl
341
+ ? mod.request(proxied.options, onResponse)
342
+ : mod.request(url, baseOptions, onResponse);
343
+ req.on('error', (err) => fail(err));
344
+ req.on('timeout', () => req.destroy(new Error('video segment upload stalled')));
345
+ source.on('error', (err) => fail(err));
346
+ source.pipe(req);
347
+ });
348
+ exports.defaultBlobPutter = defaultBlobPutter;
349
+ // ---------------------------------------------------------------------------
350
+ // The host's own network, as its platform reports it
351
+ // ---------------------------------------------------------------------------
352
+ /** How long the host's network answer is reused: the uplink of a fixed box changes on IT timescales. */
353
+ const NETWORK_CACHE_MS = 10 * 60 * 1000;
354
+ let cachedNetwork = null;
355
+ function normalizeNetwork(value) {
356
+ return value === 'wifi' || value === 'ethernet' || value === 'cellular' ? value : 'unknown';
357
+ }
358
+ function execText(cmd, args, timeoutMs) {
359
+ return new Promise((resolve) => {
360
+ (0, child_process_1.execFile)(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout) => {
361
+ resolve(err ? '' : String(stdout || ''));
362
+ });
363
+ });
364
+ }
365
+ /**
366
+ * macOS: the default route's interface, named by `networksetup`'s hardware
367
+ * port list (`Hardware Port: Wi-Fi` / `Device: en0`).
368
+ */
369
+ function macHardwarePortType(hardwarePorts, iface) {
370
+ for (const block of hardwarePorts.split(/\n\s*\n/)) {
371
+ const port = /Hardware Port:\s*(.+)/.exec(block)?.[1]?.trim() ?? '';
372
+ const device = /Device:\s*(\S+)/.exec(block)?.[1]?.trim() ?? '';
373
+ if (device !== iface)
374
+ continue;
375
+ if (/wi-?fi|airport/i.test(port))
376
+ return 'wifi';
377
+ if (/iphone|cellular|wwan|modem/i.test(port))
378
+ return 'cellular';
379
+ if (/ethernet|lan|thunderbolt/i.test(port))
380
+ return 'ethernet';
381
+ return 'unknown';
382
+ }
383
+ return 'unknown';
384
+ }
385
+ async function probeHostNetwork() {
386
+ if (process.platform === 'linux') {
387
+ // The adapter reads /sys/class/net only — no subprocess.
388
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
389
+ const { LinuxPlatformAdapter } = require('@trillboards/edge-platform-linux');
390
+ return normalizeNetwork(new LinuxPlatformAdapter().getNetworkType());
391
+ }
392
+ if (process.platform === 'win32') {
393
+ // The adapter's own query, run asynchronously (its getNetworkType blocks the
394
+ // event loop on PowerShell for seconds), parsed by the adapter's parser.
395
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
396
+ const { WindowsPlatformAdapter } = require('@trillboards/edge-platform-windows');
397
+ const out = await execText('powershell', [
398
+ '-NoProfile',
399
+ '-Command',
400
+ 'Get-NetAdapter | Where-Object Status -eq Up | Select-Object -Property Name, InterfaceDescription, MediaType | ConvertTo-Json',
401
+ ], 5000);
402
+ return out ? normalizeNetwork(WindowsPlatformAdapter.parseNetworkType(out)) : 'unknown';
403
+ }
404
+ if (process.platform === 'darwin') {
405
+ const route = await execText('route', ['-n', 'get', 'default'], 3000);
406
+ const iface = /interface:\s*(\S+)/.exec(route)?.[1];
407
+ if (!iface)
408
+ return 'unknown';
409
+ return macHardwarePortType(await execText('networksetup', ['-listallhardwareports'], 3000), iface);
410
+ }
411
+ return 'unknown';
412
+ }
413
+ /** The host's uplink type, cached. Any probe failure is 'unknown', never a guess. */
414
+ async function hostUploadNetwork(nowMs = Date.now()) {
415
+ if (cachedNetwork && nowMs - cachedNetwork.atMs < NETWORK_CACHE_MS)
416
+ return cachedNetwork.value;
417
+ let value;
418
+ try {
419
+ value = await probeHostNetwork();
420
+ }
421
+ catch {
422
+ value = 'unknown';
423
+ }
424
+ cachedNetwork = { value, atMs: nowMs };
425
+ return value;
426
+ }
427
+ /** Test seam. */
428
+ function __resetHostUploadNetworkForTests() {
429
+ cachedNetwork = null;
430
+ }
431
+ // ---------------------------------------------------------------------------
432
+ // The uploader
433
+ // ---------------------------------------------------------------------------
434
+ /** The connector's own version — what `agent_version` names for a CCTV segment. */
435
+ function connectorVersion() {
436
+ try {
437
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
438
+ return String(require('../package.json').version || '');
439
+ }
440
+ catch {
441
+ return '';
442
+ }
443
+ }
444
+ class VideoSegmentUploader {
445
+ constructor(options) {
446
+ this.queue = [];
447
+ this.inFlight = null;
448
+ this.draining = false;
449
+ this.timer = null;
450
+ this.stopped = false;
451
+ this.stats = {
452
+ queued: 0,
453
+ inFlight: false,
454
+ uploaded: 0,
455
+ uploadedBytes: 0,
456
+ uploadedSeconds: 0,
457
+ rejected: {},
458
+ invalid: 0,
459
+ unsupportedCodec: 0,
460
+ oversize: 0,
461
+ missing: 0,
462
+ failures: { presign: 0, put: 0, finalize: 0 },
463
+ lastStatus: null,
464
+ lastError: null,
465
+ lastUploadAtMs: null,
466
+ };
467
+ if (!options.apiBaseUrl)
468
+ throw new Error('apiBaseUrl is required for VideoSegmentUploader');
469
+ if (!options.deviceToken)
470
+ throw new Error('deviceToken is required for VideoSegmentUploader');
471
+ this.apiBase = options.apiBaseUrl.replace(/\/+$/, '');
472
+ this.deviceToken = options.deviceToken;
473
+ this.agentVersion = (options.agentVersion ?? connectorVersion()).slice(0, AGENT_VERSION_MAX);
474
+ this.poster = options.poster ?? AudienceAnalyzeClient_1.defaultHttpPoster;
475
+ this.putBlob = options.putBlob ?? exports.defaultBlobPutter;
476
+ this.network = options.network ?? (() => hostUploadNetwork());
477
+ this.probe = options.probe ?? probeMp4File;
478
+ this.now = options.now ?? Date.now;
479
+ this.retryBaseMs = Math.max(0, options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS);
480
+ this.retryMaxMs = Math.max(this.retryBaseMs, options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS);
481
+ this.log = options.log ?? ((level, message) => (level === 'warn' ? console.warn : console.log)(message));
482
+ }
483
+ /** Queue a closed segment. Oldest capture first; a file already queued is ignored. */
484
+ enqueue(segment) {
485
+ if (this.stopped || !segment?.file)
486
+ return;
487
+ if (this.inFlight?.segment.file === segment.file)
488
+ return;
489
+ if (this.queue.some((item) => item.segment.file === segment.file))
490
+ return;
491
+ this.queue.push({ segment, attempts: 0, nextAtMs: this.now(), phase: 'upload' });
492
+ this.queue.sort((a, b) => a.segment.captureStartMs - b.segment.captureStartMs);
493
+ this.kick();
494
+ }
495
+ /** The ring deleted this file; stop trying to deliver it. */
496
+ forget(file) {
497
+ const i = this.queue.findIndex((item) => item.segment.file === file);
498
+ if (i < 0)
499
+ return;
500
+ this.queue.splice(i, 1);
501
+ this.stats.missing += 1;
502
+ }
503
+ stop() {
504
+ this.stopped = true;
505
+ if (this.timer) {
506
+ clearTimeout(this.timer);
507
+ this.timer = null;
508
+ }
509
+ }
510
+ getStats() {
511
+ return {
512
+ ...this.stats,
513
+ queued: this.queue.length,
514
+ inFlight: this.inFlight !== null,
515
+ rejected: { ...this.stats.rejected },
516
+ failures: { ...this.stats.failures },
517
+ };
518
+ }
519
+ /** Deliver every item that is due now, one at a time. Resolves when none is due. */
520
+ async flush() {
521
+ if (this.draining)
522
+ return;
523
+ this.draining = true;
524
+ try {
525
+ for (;;) {
526
+ if (this.stopped)
527
+ break;
528
+ const nowMs = this.now();
529
+ const i = this.queue.findIndex((item) => item.nextAtMs <= nowMs);
530
+ if (i < 0)
531
+ break;
532
+ const [item] = this.queue.splice(i, 1);
533
+ this.inFlight = item;
534
+ let outcome;
535
+ try {
536
+ outcome = await this.deliver(item);
537
+ }
538
+ catch (err) {
539
+ this.stats.lastError = errorText(err);
540
+ outcome = 'retry';
541
+ }
542
+ finally {
543
+ this.inFlight = null;
544
+ }
545
+ if (outcome === 'retry' && !this.stopped) {
546
+ item.attempts += 1;
547
+ item.nextAtMs = this.now() + Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** Math.min(item.attempts - 1, 20));
548
+ this.queue.push(item);
549
+ this.queue.sort((a, b) => a.segment.captureStartMs - b.segment.captureStartMs);
550
+ }
551
+ }
552
+ }
553
+ finally {
554
+ this.draining = false;
555
+ this.kick();
556
+ }
557
+ }
558
+ kick() {
559
+ if (this.stopped || this.draining || this.queue.length === 0)
560
+ return;
561
+ const due = Math.min(...this.queue.map((item) => item.nextAtMs));
562
+ const delay = Math.max(0, due - this.now());
563
+ if (this.timer)
564
+ clearTimeout(this.timer);
565
+ this.timer = setTimeout(() => {
566
+ this.timer = null;
567
+ void this.flush();
568
+ }, delay);
569
+ if (typeof this.timer.unref === 'function')
570
+ this.timer.unref();
571
+ }
572
+ /** One attempt at one segment: presign → PUT → finalize, resuming at finalize. */
573
+ async deliver(item) {
574
+ const { segment } = item;
575
+ let bytes;
576
+ try {
577
+ bytes = fs.statSync(segment.file).size;
578
+ }
579
+ catch {
580
+ this.stats.missing += 1;
581
+ return 'done';
582
+ }
583
+ if (!item.probe) {
584
+ const probe = this.probe(segment.file);
585
+ if (!probe) {
586
+ this.stats.invalid += 1;
587
+ this.discard(segment, 'not a complete MP4 with a video track');
588
+ return 'done';
589
+ }
590
+ if (probe.codec !== 'h264' && probe.codec !== 'hevc') {
591
+ this.stats.unsupportedCodec += 1;
592
+ this.discard(segment, `codec ${probe.codec} is not h264 or hevc`);
593
+ return 'done';
594
+ }
595
+ item.probe = probe;
596
+ }
597
+ if (bytes < 1 || bytes > exports.MAX_SEGMENT_BYTES) {
598
+ this.stats.oversize += 1;
599
+ this.discard(segment, `${bytes} bytes is outside 1..${exports.MAX_SEGMENT_BYTES}`);
600
+ return 'done';
601
+ }
602
+ if (!item.hash || item.hash.bytes !== bytes) {
603
+ item.hash = await hashFile(segment.file);
604
+ item.phase = 'upload';
605
+ }
606
+ const { sha256, md5Base64 } = item.hash;
607
+ const captureStartMs = Math.round(segment.captureStartMs);
608
+ if (item.phase === 'upload') {
609
+ const presign = await this.postJson(PRESIGN_PATH, { segment_id: sha256, bytes, content_type: 'video/mp4', capture_start_ms: captureStartMs }, PRESIGN_TIMEOUT_MS);
610
+ const uploadUrl = presign.body?.uploadUrl;
611
+ if (presign.statusCode !== 200 || typeof uploadUrl !== 'string' || !uploadUrl) {
612
+ this.stats.failures.presign += 1;
613
+ this.noteFailure('presign', presign.statusCode, presign.rawBody);
614
+ return 'retry';
615
+ }
616
+ let put;
617
+ try {
618
+ put = await this.putBlob(uploadUrl, segment.file, {
619
+ bytes,
620
+ contentMd5: md5Base64,
621
+ contentType: 'video/mp4',
622
+ timeoutMs: putTimeoutMs(bytes),
623
+ });
624
+ }
625
+ catch (err) {
626
+ this.stats.failures.put += 1;
627
+ this.noteFailure('put', null, errorText(err));
628
+ return 'retry';
629
+ }
630
+ if (put.statusCode < 200 || put.statusCode >= 300) {
631
+ this.stats.failures.put += 1;
632
+ this.noteFailure('put', put.statusCode, put.body);
633
+ return 'retry';
634
+ }
635
+ item.phase = 'finalize';
636
+ }
637
+ const network = normalizeNetwork(await (async () => this.network())().catch(() => 'unknown'));
638
+ const finalize = await this.postJson(FINALIZE_PATH, this.finalizeBody(item, bytes, network), FINALIZE_TIMEOUT_MS);
639
+ this.stats.lastStatus = finalize.statusCode;
640
+ if (finalize.statusCode === 200) {
641
+ this.stats.uploaded += 1;
642
+ this.stats.uploadedBytes += bytes;
643
+ this.stats.uploadedSeconds += item.probe.durationMs / 1000;
644
+ this.stats.lastUploadAtMs = this.now();
645
+ this.deleteLocal(segment);
646
+ return 'done';
647
+ }
648
+ if (finalize.statusCode === 422) {
649
+ // The server read different bytes than we hashed and deleted the blob.
650
+ const reason = typeof finalize.body?.reason === 'string' ? finalize.body.reason : 'unprocessable';
651
+ this.stats.rejected[reason] = (this.stats.rejected[reason] ?? 0) + 1;
652
+ this.log('warn', `[video] ${path.basename(segment.file)} rejected at finalize (${reason}); deleted locally`);
653
+ this.deleteLocal(segment);
654
+ return 'done';
655
+ }
656
+ this.stats.failures.finalize += 1;
657
+ this.noteFailure('finalize', finalize.statusCode, finalize.rawBody);
658
+ // 409 not_uploaded: the blob is not there — PUT again. Anything else
659
+ // (503 index_unavailable, transport): finalize again; the blob stays.
660
+ if (finalize.statusCode === 409)
661
+ item.phase = 'upload';
662
+ return 'retry';
663
+ }
664
+ /** C4, snake_case. Server-authoritative fields (screen, device, source, key) are never sent. */
665
+ finalizeBody(item, bytes, network) {
666
+ const probe = item.probe;
667
+ const hash = item.hash;
668
+ const durationMs = Math.round(probe.durationMs);
669
+ return {
670
+ segment_id: hash.sha256,
671
+ bytes,
672
+ content_md5: hash.md5Base64,
673
+ capture_start_ms: Math.round(item.segment.captureStartMs),
674
+ capture_end_ms: Math.round(item.segment.captureEndMs),
675
+ duration_ms: durationMs,
676
+ codec: probe.codec,
677
+ width: probe.width,
678
+ height: probe.height,
679
+ fps: Math.round(probe.fps * 1000) / 1000,
680
+ video_bitrate_bps: Math.round((probe.videoBytes * 8) / (probe.durationMs / 1000)),
681
+ has_audio: probe.hasAudio,
682
+ audio_codec: null,
683
+ audio_bitrate_bps: null,
684
+ trigger_kind: 'continuous',
685
+ trigger_id: '',
686
+ agent_version: this.agentVersion,
687
+ calibration_hash: null,
688
+ // No fix on a fixed camera: the server fills the registered location.
689
+ gps_lat: null,
690
+ gps_lon: null,
691
+ gps_accuracy_m: null,
692
+ gps_fix_at_ms: null,
693
+ upload_network: network,
694
+ };
695
+ }
696
+ async postJson(routePath, payload, timeoutMs) {
697
+ const headers = {
698
+ 'Content-Type': 'application/json',
699
+ Accept: 'application/json',
700
+ // Device-request auth + replay guard (see middleware/deviceRequestAuth.js).
701
+ 'X-Device-Token': this.deviceToken,
702
+ 'X-Trillboard-Device-Token': this.deviceToken,
703
+ 'X-Device-Timestamp': String(Date.now()),
704
+ 'X-Device-Nonce': (0, crypto_1.randomUUID)(),
705
+ };
706
+ let result;
707
+ try {
708
+ result = await this.poster(`${this.apiBase}${routePath}`, { method: 'POST', headers, timeoutMs }, JSON.stringify(payload));
709
+ }
710
+ catch (err) {
711
+ return { statusCode: 0, body: null, rawBody: errorText(err) };
712
+ }
713
+ let body = null;
714
+ try {
715
+ body = result.body ? JSON.parse(result.body) : null;
716
+ }
717
+ catch {
718
+ body = null;
719
+ }
720
+ return { statusCode: result.statusCode, body, rawBody: result.body };
721
+ }
722
+ noteFailure(phase, status, detail) {
723
+ this.stats.lastStatus = status;
724
+ this.stats.lastError = `${phase}${status !== null ? ` HTTP ${status}` : ''}: ${String(detail || '').slice(0, 300)}`;
725
+ }
726
+ discard(segment, why) {
727
+ this.log('warn', `[video] ${path.basename(segment.file)} not uploaded: ${why}; deleted locally`);
728
+ this.deleteLocal(segment);
729
+ }
730
+ deleteLocal(segment) {
731
+ for (const file of [segment.file, segment.metaFile ?? segment.file.replace(/\.mp4$/i, '.json')]) {
732
+ try {
733
+ fs.unlinkSync(file);
734
+ }
735
+ catch {
736
+ /* already gone (the ring may have taken it) */
737
+ }
738
+ }
739
+ }
740
+ }
741
+ exports.VideoSegmentUploader = VideoSegmentUploader;
742
+ function errorText(err) {
743
+ return err instanceof Error ? err.message : String(err);
744
+ }
745
+ /** Seconds in a UTC day: a budget at or above it is continuous recording. */
746
+ const SECONDS_PER_DAY = 86400;
747
+ /**
748
+ * The segment length and ring the provider holds before the server has ever
749
+ * answered. Recording is OFF until it does; these only size the ring that
750
+ * leftovers from an earlier run sit in. They mirror the runtime_config seeds
751
+ * CCTV_VIDEO_SEGMENT_SECONDS '60' and CCTV_VIDEO_RING_SECONDS '3600'.
752
+ */
753
+ const UNANSWERED_SEGMENT_SECONDS = 60;
754
+ const UNANSWERED_RING_SECONDS = 3600;
755
+ /** Same cadence as the local profile cache (`DEFAULT_PROFILE_TTL_MS`). */
756
+ const DEFAULT_KNOB_POLL_MS = 5 * 60 * 1000;
757
+ const KNOBS_FILE = 'video-knobs.json';
758
+ const DAILY_FILE = 'video-daily.json';
759
+ function utcDay(ms) {
760
+ return new Date(ms).toISOString().slice(0, 10);
761
+ }
762
+ function writeJsonAtomic(file, value) {
763
+ const tmp = `${file}.tmp`;
764
+ fs.writeFileSync(tmp, JSON.stringify(value));
765
+ fs.renameSync(tmp, file);
766
+ }
767
+ function sameKnobs(a, b) {
768
+ return !!a && a.dailySeconds === b.dailySeconds && a.segmentSeconds === b.segmentSeconds && a.ringSeconds === b.ringSeconds;
769
+ }
770
+ /**
771
+ * One per recording feed. Applies the server's knobs to the provider, keeps
772
+ * a per-UTC-day recorded-seconds budget, and hands every closed segment to
773
+ * the uploader.
774
+ *
775
+ * Knobs follow the deployedCadenceSource rule: a failed fetch, an API that
776
+ * does not send `video`, and an unreadable `video` all KEEP the last applied
777
+ * value — persisted beside the segments, so a restart with the API down
778
+ * records exactly as before. Until the server has ever answered, recording is
779
+ * off. Everything starts at the provider's first `open()`, so a recorder that
780
+ * is built and never opened makes no request and writes nothing.
781
+ */
782
+ class CctvVideoRecorder {
783
+ constructor(options) {
784
+ this.knobs = null;
785
+ this.daily = { day: '', recordedSeconds: 0 };
786
+ this.recording = false;
787
+ this.started = false;
788
+ this.stopped = false;
789
+ this.pollTimer = null;
790
+ this.dayTimer = null;
791
+ this.counts = { polls: 0, keptOnFailedFetch: 0, keptOnSilence: 0, keptOnMalformed: 0 };
792
+ this.provider = options.provider;
793
+ this.dir = options.dir;
794
+ this.feedName = options.feedName;
795
+ this.profileClient = options.profileClient;
796
+ this.uploader = options.uploader;
797
+ this.pollMs = Math.max(1000, options.pollMs ?? DEFAULT_KNOB_POLL_MS);
798
+ this.now = options.now ?? Date.now;
799
+ this.log = options.log ?? ((level, message) => (level === 'warn' ? console.warn : console.log)(message));
800
+ this.provider.on('segment', (segment) => this.onSegment(segment));
801
+ this.provider.on('segment_evicted', (evicted) => this.uploader.forget(evicted.file));
802
+ this.provider.on('opening', () => this.start());
803
+ }
804
+ /** Idempotent. Runs synchronously inside the provider's `open()`. */
805
+ start() {
806
+ if (this.started || this.stopped)
807
+ return;
808
+ this.started = true;
809
+ this.loadState();
810
+ this.apply();
811
+ void this.poll();
812
+ this.pollTimer = setInterval(() => void this.poll(), this.pollMs);
813
+ if (typeof this.pollTimer.unref === 'function')
814
+ this.pollTimer.unref();
815
+ }
816
+ stop() {
817
+ this.stopped = true;
818
+ if (this.pollTimer)
819
+ clearInterval(this.pollTimer);
820
+ if (this.dayTimer)
821
+ clearTimeout(this.dayTimer);
822
+ this.pollTimer = null;
823
+ this.dayTimer = null;
824
+ this.uploader.stop();
825
+ }
826
+ getStats() {
827
+ return {
828
+ knobs: this.knobs ? { ...this.knobs } : null,
829
+ recording: this.recording,
830
+ day: this.daily.day,
831
+ recordedSecondsToday: Math.round(this.daily.recordedSeconds * 1000) / 1000,
832
+ polls: this.counts.polls,
833
+ keptOnFailedFetch: this.counts.keptOnFailedFetch,
834
+ keptOnSilence: this.counts.keptOnSilence,
835
+ keptOnMalformed: this.counts.keptOnMalformed,
836
+ };
837
+ }
838
+ /** One knob fetch. Only an explicit, readable `video` block changes anything. */
839
+ async poll() {
840
+ if (this.stopped)
841
+ return;
842
+ this.counts.polls += 1;
843
+ let profile;
844
+ try {
845
+ profile = await this.profileClient.fetchProfile();
846
+ }
847
+ catch {
848
+ profile = null;
849
+ }
850
+ if (!profile) {
851
+ this.counts.keptOnFailedFetch += 1;
852
+ return;
853
+ }
854
+ if (profile.video === undefined) {
855
+ this.counts.keptOnSilence += 1;
856
+ return;
857
+ }
858
+ if (profile.video === null) {
859
+ this.counts.keptOnMalformed += 1;
860
+ return;
861
+ }
862
+ if (sameKnobs(this.knobs, profile.video))
863
+ return;
864
+ this.knobs = { ...profile.video };
865
+ this.persist(KNOBS_FILE, { version: 1, ...this.knobs, appliedAtMs: this.now() });
866
+ this.log('info', `[video] ${this.feedName}: ${this.knobs.dailySeconds}s/day, ${this.knobs.segmentSeconds}s segments, ring ${this.knobs.ringSeconds}s`);
867
+ this.apply();
868
+ }
869
+ apply() {
870
+ if (this.stopped)
871
+ return;
872
+ this.rollDay();
873
+ const k = this.knobs;
874
+ if (!k) {
875
+ this.recording = false;
876
+ this.provider.setSegmentRecording({
877
+ dir: this.dir,
878
+ segmentSeconds: UNANSWERED_SEGMENT_SECONDS,
879
+ ringSeconds: UNANSWERED_RING_SECONDS,
880
+ enabled: false,
881
+ });
882
+ return;
883
+ }
884
+ const budgetLeft = k.dailySeconds >= SECONDS_PER_DAY || this.daily.recordedSeconds < k.dailySeconds;
885
+ this.recording = k.dailySeconds > 0 && budgetLeft;
886
+ this.provider.setSegmentRecording({
887
+ dir: this.dir,
888
+ segmentSeconds: k.segmentSeconds,
889
+ ringSeconds: k.ringSeconds,
890
+ enabled: this.recording,
891
+ });
892
+ if (k.dailySeconds > 0 && !budgetLeft)
893
+ this.scheduleNextDay();
894
+ }
895
+ onSegment(segment) {
896
+ if (!segment.recovered) {
897
+ this.rollDay();
898
+ this.daily.recordedSeconds += Math.max(0, segment.endSec - segment.startSec);
899
+ this.persist(DAILY_FILE, this.daily);
900
+ const k = this.knobs;
901
+ if (this.recording && k && k.dailySeconds < SECONDS_PER_DAY && this.daily.recordedSeconds >= k.dailySeconds) {
902
+ this.log('info', `[video] ${this.feedName}: today's ${k.dailySeconds}s recorded; resuming at 00:00 UTC`);
903
+ this.apply();
904
+ }
905
+ }
906
+ this.uploader.enqueue(segment);
907
+ }
908
+ rollDay() {
909
+ const day = utcDay(this.now());
910
+ if (day !== this.daily.day)
911
+ this.daily = { day, recordedSeconds: 0 };
912
+ }
913
+ scheduleNextDay() {
914
+ if (this.dayTimer)
915
+ return;
916
+ const nowMs = this.now();
917
+ const nextDayMs = Date.UTC(new Date(nowMs).getUTCFullYear(), new Date(nowMs).getUTCMonth(), new Date(nowMs).getUTCDate() + 1);
918
+ this.dayTimer = setTimeout(() => {
919
+ this.dayTimer = null;
920
+ this.apply();
921
+ }, Math.max(1000, nextDayMs - nowMs + 1000));
922
+ if (typeof this.dayTimer.unref === 'function')
923
+ this.dayTimer.unref();
924
+ }
925
+ loadState() {
926
+ try {
927
+ const raw = JSON.parse(fs.readFileSync(path.join(this.dir, KNOBS_FILE), 'utf8'));
928
+ const ok = [raw?.dailySeconds, raw?.segmentSeconds, raw?.ringSeconds].every((n) => Number.isInteger(n) && n >= 0);
929
+ if (ok && raw.segmentSeconds > 0) {
930
+ this.knobs = { dailySeconds: raw.dailySeconds, segmentSeconds: raw.segmentSeconds, ringSeconds: raw.ringSeconds };
931
+ }
932
+ }
933
+ catch {
934
+ /* never answered on this box */
935
+ }
936
+ try {
937
+ const raw = JSON.parse(fs.readFileSync(path.join(this.dir, DAILY_FILE), 'utf8'));
938
+ if (typeof raw?.day === 'string' && Number.isFinite(raw?.recordedSeconds) && raw.recordedSeconds >= 0) {
939
+ this.daily = { day: raw.day, recordedSeconds: raw.recordedSeconds };
940
+ }
941
+ }
942
+ catch {
943
+ /* first day */
944
+ }
945
+ }
946
+ persist(name, value) {
947
+ try {
948
+ fs.mkdirSync(this.dir, { recursive: true });
949
+ writeJsonAtomic(path.join(this.dir, name), value);
950
+ }
951
+ catch (err) {
952
+ this.log('warn', `[video] ${this.feedName}: could not save ${name}: ${errorText(err)}`);
953
+ }
954
+ }
955
+ }
956
+ exports.CctvVideoRecorder = CctvVideoRecorder;
957
+ // ---------------------------------------------------------------------------
958
+ // Wiring: one recorder per recording feed, one per directory
959
+ // ---------------------------------------------------------------------------
960
+ const recordersByDir = new Map();
961
+ /**
962
+ * Attach a recorder to a freshly built provider. Returns null — and records
963
+ * nothing — when the installed edge-platform-rtsp predates segment recording
964
+ * (edge-sdk accepts any `^0.2.0`). A second recorder for the same directory
965
+ * replaces the first, so two managers can never deliver one ring twice.
966
+ */
967
+ function attachCctvVideoRecorder(args) {
968
+ const provider = args.provider;
969
+ if (!provider || typeof provider.setSegmentRecording !== 'function' || typeof provider.on !== 'function') {
970
+ return null;
971
+ }
972
+ const uploader = new VideoSegmentUploader({
973
+ apiBaseUrl: args.apiBaseUrl,
974
+ deviceToken: args.deviceToken,
975
+ ...(args.httpPoster ? { poster: args.httpPoster } : {}),
976
+ });
977
+ const profileClient = new SensingProfileClient_1.SensingProfileClient({ apiBaseUrl: args.apiBaseUrl, deviceToken: args.deviceToken }, args.httpPoster);
978
+ const recorder = new CctvVideoRecorder({
979
+ provider: provider,
980
+ dir: args.dir,
981
+ feedName: args.feedName,
982
+ profileClient,
983
+ uploader,
984
+ ...(args.pollMs !== undefined ? { pollMs: args.pollMs } : {}),
985
+ });
986
+ const previous = recordersByDir.get(args.dir);
987
+ if (previous)
988
+ previous.stop();
989
+ recordersByDir.set(args.dir, recorder);
990
+ return recorder;
991
+ }
992
+ /** Every attached recorder, for diagnostics. */
993
+ function activeCctvVideoRecorders() {
994
+ return [...recordersByDir.entries()].map(([dir, recorder]) => ({ dir, recorder }));
995
+ }
996
+ /** Test seam. */
997
+ function __resetCctvVideoRecordersForTests() {
998
+ for (const recorder of recordersByDir.values())
999
+ recorder.stop();
1000
+ recordersByDir.clear();
1001
+ }
1002
+ //# sourceMappingURL=VideoSegmentUploader.js.map