@xenon-device-management/xenon 1.22.0 → 1.22.2
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/lib/package.json +1 -1
- package/lib/public/assets/{ApiKeyGate-B_1qvA7M.js → ApiKeyGate-Snh2iZH5.js} +1 -1
- package/lib/public/assets/{BugReportButton-0m4q010L.js → BugReportButton-Sk0KGufH.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-O292tZkA.js +2 -0
- package/lib/public/assets/{EmptyState-CTmGgoSm.js → EmptyState-CQun3BJE.js} +1 -1
- package/lib/public/assets/{FieldGroup-oWAJBBAu.js → FieldGroup-DmYZP2j7.js} +1 -1
- package/lib/public/assets/{Modal-LXKxgyV7.js → Modal-C5W8lMJ6.js} +1 -1
- package/lib/public/assets/{SegmentedControl-BSWMM3qE.js → SegmentedControl-BIzlKyUd.js} +1 -1
- package/lib/public/assets/{SettingCard-CeHOIGW9.js → SettingCard-DAFqQw7S.js} +1 -1
- package/lib/public/assets/{Table-EHWJCCzN.js → Table-DRkkM4OR.js} +1 -1
- package/lib/public/assets/{activity-CHfTPuBn.js → activity-B6x69oOy.js} +1 -1
- package/lib/public/assets/{ai-settings-B_dT4ctk.js → ai-settings-CSZQis7F.js} +1 -1
- package/lib/public/assets/{api-keys-BIKaKigW.js → api-keys-4ddjSngL.js} +1 -1
- package/lib/public/assets/{apps-Dp4crlsn.js → apps-CePIfnu9.js} +1 -1
- package/lib/public/assets/{arrow-left-QDqb-yc1.js → arrow-left-CaOe18yC.js} +1 -1
- package/lib/public/assets/{arrow-right-D_onnhWU.js → arrow-right-FEQsqDvH.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DOwr8XxB.js → arrow-up-right-Dnszb73S.js} +1 -1
- package/lib/public/assets/{auth-shell-NbHUThYR.js → auth-shell-Vq1rYIVD.js} +2 -2
- package/lib/public/assets/{builds-page-CQseApdL.js → builds-page-FfsUegKZ.js} +1 -1
- package/lib/public/assets/{button-Do8mBvkU.js → button-DrqhMLZ0.js} +1 -1
- package/lib/public/assets/{calendar-DrH0mjSD.js → calendar-BuRuUa7q.js} +1 -1
- package/lib/public/assets/{check-C8Un1cwf.js → check-DYccNk73.js} +1 -1
- package/lib/public/assets/{chevron-right-DHjIgZvn.js → chevron-right-C6ny35i3.js} +1 -1
- package/lib/public/assets/{circle-check-Dkg-Ka4_.js → circle-check-Cokzy9a2.js} +1 -1
- package/lib/public/assets/{circle-x-BPKWtaZx.js → circle-x-BhUzyxKR.js} +1 -1
- package/lib/public/assets/{clock-CQLUBaRx.js → clock-BCG0drfS.js} +1 -1
- package/lib/public/assets/{copy-CWx0Ec1c.js → copy-BKOm9hko.js} +1 -1
- package/lib/public/assets/{device-explorer-BjrTj6Sm.js → device-explorer-CIjs3BXP.js} +1 -1
- package/lib/public/assets/{download-DJ_pjhGv.js → download-R2k7Nd4P.js} +1 -1
- package/lib/public/assets/{forgot-password-MmPdS0S2.js → forgot-password-CiZcr-gB.js} +1 -1
- package/lib/public/assets/{funnel-DR8YKoFW.js → funnel-SD4F5IdR.js} +1 -1
- package/lib/public/assets/{index-DbzQAv86.js → index-BHDlQcd6.js} +2 -2
- package/lib/public/assets/{input-CKKtQEU5.js → input-Ilib6feD.js} +1 -1
- package/lib/public/assets/{layers-DprXM0fz.js → layers-CLuKN5Oy.js} +1 -1
- package/lib/public/assets/{lock-BXGh7bJZ.js → lock-s9ZhGDFF.js} +1 -1
- package/lib/public/assets/{login-DOYeWP9H.js → login-EkaxK7K5.js} +1 -1
- package/lib/public/assets/{maintenance-settings-w8U3BRZk.js → maintenance-settings-yYhZaU7n.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-BD4cWXDU.js → mouse-pointer-2-C6yy1DTx.js} +1 -1
- package/lib/public/assets/{network-C5bJeATc.js → network-B8fPwmW9.js} +1 -1
- package/lib/public/assets/{overview-DzAXi4vj.js → overview-DrTe5eJi.js} +1 -1
- package/lib/public/assets/{page-header-CqaoOYPH.js → page-header-DhD-Kua9.js} +1 -1
- package/lib/public/assets/{plus-CJfVcjJv.js → plus-B75ovH3O.js} +1 -1
- package/lib/public/assets/{profile-page-Mk1yk1c4.js → profile-page-C2p5rB3x.js} +1 -1
- package/lib/public/assets/{reset-password-D1gtSgzr.js → reset-password-tCemYrqV.js} +1 -1
- package/lib/public/assets/{runbook-page-B7EjVqsY.js → runbook-page-Dq3wdSII.js} +1 -1
- package/lib/public/assets/{select-DVYczzD1.js → select-Cfv-njLE.js} +1 -1
- package/lib/public/assets/{selector-detail-page-C6b-Z-ep.js → selector-detail-page-8xf4CxH5.js} +1 -1
- package/lib/public/assets/{selector-health-DhzDZLlF.js → selector-health-BP45B44s.js} +1 -1
- package/lib/public/assets/{selector-health-page-w5zIE-r8.js → selector-health-page-D-8SdN_l.js} +1 -1
- package/lib/public/assets/{session-detail-page-CiPVLY-1.js → session-detail-page-Dm4tnoTT.js} +1 -1
- package/lib/public/assets/{settings-Bm-LdOxO.js → settings-ChfaAd8F.js} +1 -1
- package/lib/public/assets/{status-pill-outline-BgQ_Ur6B.js → status-pill-outline-CLa0C1Sg.js} +1 -1
- package/lib/public/assets/{tablet-1GdHxaSx.js → tablet-0sIFwy2Z.js} +1 -1
- package/lib/public/assets/{teams-D8rZexDl.js → teams-BFhWbK5i.js} +1 -1
- package/lib/public/assets/{trash-2-DJgpvIoq.js → trash-2-BcQxmJP-.js} +1 -1
- package/lib/public/assets/{upload-BJ0V1QbY.js → upload-BucqEWub.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BVFQr1mo.js → use-password-reset-mode-XoxXVCzm.js} +1 -1
- package/lib/public/assets/{users-Bxnci4rK.js → users-B7uveXL_.js} +1 -1
- package/lib/public/assets/{users-DUb_rmxk.js → users-DyB6E3FF.js} +1 -1
- package/lib/public/assets/{webhook-settings-DL54Ijod.js → webhook-settings-qoHAETzC.js} +1 -1
- package/lib/public/assets/{zap-BLSqrVs9.js → zap-CqGOjAjn.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/routers/control.js +18 -15
- package/lib/src/app/routers/recordings.js +5 -3
- package/lib/src/services/VideoPipelineService.js +44 -14
- package/lib/src/services/fillMissingScreenSize.js +50 -0
- package/lib/src/services/recording/RecordingOrchestrator.js +103 -2
- package/lib/src/services/recording/activeRecordings.js +8 -2
- package/lib/src/services/recording/annotation-render.js +189 -47
- package/lib/src/services/recording/probeDuration.js +21 -0
- package/lib/src/services/recording/proof-bundle.js +38 -15
- package/lib/src/services/recording/recordingTiming.js +86 -0
- package/lib/test/unit/active-recordings.spec.js +8 -0
- package/lib/test/unit/annotation-render.spec.js +226 -0
- package/lib/test/unit/composite-geometry.spec.js +80 -0
- package/lib/test/unit/fill-missing-screen-size.spec.js +65 -0
- package/lib/test/unit/proof-bundle.spec.js +65 -0
- package/lib/test/unit/recording-orchestrator.spec.js +95 -0
- package/lib/test/unit/recording-timing.spec.js +88 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/lib/public/assets/DeviceMosaicView-D4uKl1Gv.js +0 -2
|
@@ -56,6 +56,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
56
56
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
57
57
|
exports.AnnotationRenderService = exports.ANNOTATION_FONT_PATH = void 0;
|
|
58
58
|
exports.escapeFilterValue = escapeFilterValue;
|
|
59
|
+
exports.containBoxPx = containBoxPx;
|
|
59
60
|
const typedi_1 = require("typedi");
|
|
60
61
|
const child_process_1 = require("child_process");
|
|
61
62
|
const fs = __importStar(require("fs"));
|
|
@@ -64,6 +65,7 @@ const recording_store_1 = require("./recording-store");
|
|
|
64
65
|
const ffmpegPath_1 = require("../../helpers/ffmpegPath");
|
|
65
66
|
const probeDuration_1 = require("./probeDuration");
|
|
66
67
|
const annotationImage_1 = require("./annotationImage");
|
|
68
|
+
const recordingTiming_1 = require("./recordingTiming");
|
|
67
69
|
const logger_1 = __importDefault(require("../../logger"));
|
|
68
70
|
const renderLog = logger_1.default.scope('AnnotationRender');
|
|
69
71
|
// All ffmpeg spawns go through the bundled binary (resolveFfmpegPath) — a bare
|
|
@@ -93,6 +95,17 @@ function escapeFilterValue(value) {
|
|
|
93
95
|
const forOptions = value.replace(/[\\':]/g, '\\$&');
|
|
94
96
|
return forOptions.replace(/[\\'[\],;]/g, '\\$&');
|
|
95
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Where the composite's `scale=W:H:force_original_aspect_ratio=decrease,
|
|
100
|
+
* pad=W:H:(ow-iw)/2:(oh-ih)/2` puts a srcW x srcH picture inside a W x H cell.
|
|
101
|
+
* Measured against the bundled ffmpeg: sizes round to nearest, offsets
|
|
102
|
+
* truncate (no rounding to even).
|
|
103
|
+
*/
|
|
104
|
+
function containBoxPx(cellW, cellH, srcW, srcH) {
|
|
105
|
+
const w = Math.min(cellW, Math.round((cellH * srcW) / srcH));
|
|
106
|
+
const h = Math.min(cellH, Math.round((cellW * srcH) / srcW));
|
|
107
|
+
return { x: Math.trunc((cellW - w) / 2), y: Math.trunc((cellH - h) / 2), w, h };
|
|
108
|
+
}
|
|
96
109
|
/**
|
|
97
110
|
* Burns annotation metadata into an mp4 (ffmpeg drawbox / drawellipse / lines).
|
|
98
111
|
* Used by the dashboard download paths so "Download video" includes what the
|
|
@@ -123,7 +136,10 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
123
136
|
if (!rec.file_path || !fs.existsSync(rec.file_path)) {
|
|
124
137
|
throw new Error(`Source video missing for ${recordingId}`);
|
|
125
138
|
}
|
|
126
|
-
|
|
139
|
+
// Timecodes count from the dashboard's t=0; this video may have started
|
|
140
|
+
// before it (multi-device groups) or after it (added devices). Only the
|
|
141
|
+
// per-device video needs this: the composite starts at t=0.
|
|
142
|
+
const annotations = (0, recordingTiming_1.shiftAnnotations)(((_a = rec.annotations) !== null && _a !== void 0 ? _a : []), (0, recordingTiming_1.markShiftMs)((0, recordingTiming_1.readRecordingTiming)(rec.file_path)));
|
|
127
143
|
if (annotations.length === 0) {
|
|
128
144
|
return { filePath: rec.file_path, annotated: false };
|
|
129
145
|
}
|
|
@@ -132,46 +148,98 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
132
148
|
// Invalidate when source or annotation set changes (annotations land after
|
|
133
149
|
// the mp4 is finalized, so source mtime alone is not enough).
|
|
134
150
|
const stamp = this.cacheStamp(rec.file_path, annotations);
|
|
135
|
-
|
|
151
|
+
if (!this.isFresh(outPath, stampPath, stamp)) {
|
|
152
|
+
yield this.renderCached(outPath, stampPath, stamp, () => this.renderToFile(rec.file_path, outPath, annotations));
|
|
153
|
+
}
|
|
154
|
+
return { filePath: outPath, annotated: true };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The group's composite with every device's marks burned into its own cell.
|
|
159
|
+
* Raw when the composite has no layout record (recorded before marks could
|
|
160
|
+
* be placed in it) or no cell has marks.
|
|
161
|
+
*/
|
|
162
|
+
resolveCompositePath(groupId) {
|
|
163
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
164
|
+
var _a, _b;
|
|
165
|
+
// Lazy: keeps the orchestrator and pipeline graphs out of this module's load.
|
|
166
|
+
const { compositeOutputPath, compositeLayoutPath } = yield Promise.resolve().then(() => __importStar(require('./RecordingOrchestrator')));
|
|
167
|
+
const { cellOrigin } = yield Promise.resolve().then(() => __importStar(require('../VideoPipelineService')));
|
|
168
|
+
const compositePath = compositeOutputPath(groupId);
|
|
169
|
+
if (!fs.existsSync(compositePath))
|
|
170
|
+
throw new Error(`Composite missing for ${groupId}`);
|
|
171
|
+
const raw = { filePath: compositePath, annotated: false };
|
|
172
|
+
let layout;
|
|
136
173
|
try {
|
|
137
|
-
|
|
138
|
-
fs.existsSync(stampPath) &&
|
|
139
|
-
fs.readFileSync(stampPath, 'utf8') === stamp) {
|
|
140
|
-
needsRender = false;
|
|
141
|
-
}
|
|
174
|
+
layout = JSON.parse(fs.readFileSync(compositeLayoutPath(groupId), 'utf8'));
|
|
142
175
|
}
|
|
143
|
-
catch (
|
|
144
|
-
|
|
176
|
+
catch (_c) {
|
|
177
|
+
return raw;
|
|
145
178
|
}
|
|
146
|
-
|
|
147
|
-
|
|
179
|
+
const recordings = (yield this.store.listGroup(groupId));
|
|
180
|
+
const byId = new Map(recordings.map((r) => [r.id, r]));
|
|
181
|
+
const cells = [];
|
|
182
|
+
for (const cell of (_a = layout.cells) !== null && _a !== void 0 ? _a : []) {
|
|
183
|
+
const rec = byId.get(cell.recordingId);
|
|
184
|
+
const annotations = ((_b = rec === null || rec === void 0 ? void 0 : rec.annotations) !== null && _b !== void 0 ? _b : []);
|
|
185
|
+
if (!rec || annotations.length === 0)
|
|
186
|
+
continue;
|
|
187
|
+
// The per-device video and the composite read the same source, so its
|
|
188
|
+
// frame size is the size the composite scaled into this cell.
|
|
189
|
+
const size = rec.file_path && fs.existsSync(rec.file_path)
|
|
190
|
+
? yield this.probeFrameSize(rec.file_path)
|
|
191
|
+
: undefined;
|
|
192
|
+
const inner = size
|
|
193
|
+
? containBoxPx(layout.cellW, layout.cellH, size.w, size.h)
|
|
194
|
+
: { x: 0, y: 0, w: layout.cellW, h: layout.cellH };
|
|
195
|
+
const origin = cellOrigin(cell.index, layout.cols, layout.cellW, layout.cellH);
|
|
196
|
+
cells.push({
|
|
197
|
+
box: { x: origin.x + inner.x, y: origin.y + inner.y, w: inner.w, h: inner.h },
|
|
198
|
+
annotations,
|
|
199
|
+
imageFor: (a) => this.imageFor(rec.file_path, a),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (cells.length === 0)
|
|
203
|
+
return raw;
|
|
204
|
+
const outPath = path.join(path.dirname(compositePath), 'composite.annotated.mp4');
|
|
205
|
+
const stampPath = `${outPath}.stamp`;
|
|
206
|
+
const stamp = this.compositeStamp(compositePath, cells);
|
|
207
|
+
if (!this.isFresh(outPath, stampPath, stamp)) {
|
|
208
|
+
yield this.renderCached(outPath, stampPath, stamp, () => __awaiter(this, void 0, void 0, function* () {
|
|
209
|
+
const durationSec = yield this.probeDurationSec(compositePath);
|
|
210
|
+
yield this.renderPlan(compositePath, outPath, (withText) => this.buildCompositeGraph(withText
|
|
211
|
+
? cells
|
|
212
|
+
: cells.map((c) => (Object.assign(Object.assign({}, c), { annotations: this.withoutUndrawableText(c.annotations, c.imageFor) }))), durationSec));
|
|
213
|
+
}));
|
|
148
214
|
}
|
|
149
215
|
return { filePath: outPath, annotated: true };
|
|
150
216
|
});
|
|
151
217
|
}
|
|
218
|
+
isFresh(outPath, stampPath, stamp) {
|
|
219
|
+
try {
|
|
220
|
+
return (fs.existsSync(outPath) &&
|
|
221
|
+
fs.existsSync(stampPath) &&
|
|
222
|
+
fs.readFileSync(stampPath, 'utf8') === stamp);
|
|
223
|
+
}
|
|
224
|
+
catch (_a) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
152
228
|
/**
|
|
153
229
|
* Render `outPath` at most once across concurrent callers (keyed by outPath).
|
|
154
230
|
* Prevents two `ffmpeg -y` processes from writing the same annotated mp4 while
|
|
155
231
|
* a download streams it — see {@link renderInFlight}.
|
|
156
232
|
*/
|
|
157
|
-
renderCached(outPath, stampPath, stamp,
|
|
233
|
+
renderCached(outPath, stampPath, stamp, render) {
|
|
158
234
|
const existing = this.renderInFlight.get(outPath);
|
|
159
235
|
if (existing)
|
|
160
236
|
return existing;
|
|
161
237
|
const task = (() => __awaiter(this, void 0, void 0, function* () {
|
|
162
238
|
// A render that finished between the caller's cache check and now may have
|
|
163
239
|
// already produced a valid file — re-check before spending another pass.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
fs.readFileSync(stampPath, 'utf8') === stamp) {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
catch (_a) {
|
|
172
|
-
/* fall through to render */
|
|
173
|
-
}
|
|
174
|
-
yield this.renderToFile(sourcePath, outPath, annotations);
|
|
240
|
+
if (this.isFresh(outPath, stampPath, stamp))
|
|
241
|
+
return;
|
|
242
|
+
yield render();
|
|
175
243
|
fs.writeFileSync(stampPath, stamp, 'utf8');
|
|
176
244
|
}))().finally(() => this.renderInFlight.delete(outPath));
|
|
177
245
|
this.renderInFlight.set(outPath, task);
|
|
@@ -193,7 +261,14 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
193
261
|
});
|
|
194
262
|
}
|
|
195
263
|
/** Pure helper — exported for unit tests. */
|
|
196
|
-
buildFilterParts(annotations, videoDurationSec
|
|
264
|
+
buildFilterParts(annotations, videoDurationSec,
|
|
265
|
+
/** Draw inside this pixel box (a composite cell's picture); default: the whole frame. */
|
|
266
|
+
box) {
|
|
267
|
+
// Frame-relative expressions by default; absolute pixels inside a box.
|
|
268
|
+
const X = (v) => (box ? `${Math.round(box.x + v * box.w)}` : `iw*${this.f(v)}`);
|
|
269
|
+
const Y = (v) => (box ? `${Math.round(box.y + v * box.h)}` : `ih*${this.f(v)}`);
|
|
270
|
+
const W = (v) => (box ? `${Math.round(v * box.w)}` : `iw*${this.f(v)}`);
|
|
271
|
+
const H = (v) => (box ? `${Math.round(v * box.h)}` : `ih*${this.f(v)}`);
|
|
197
272
|
const parts = [];
|
|
198
273
|
for (const a of annotations) {
|
|
199
274
|
let g = {};
|
|
@@ -218,8 +293,8 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
218
293
|
const top = Math.max(0, cy - ry);
|
|
219
294
|
const bw = Math.min(1, rx * 2);
|
|
220
295
|
const bh = Math.min(1, ry * 2);
|
|
221
|
-
parts.push(`drawbox=x
|
|
222
|
-
parts.push(`drawbox=x
|
|
296
|
+
parts.push(`drawbox=x=${X(left)}:y=${Y(top)}:w=${W(bw)}:h=${H(bh)}:color=${color}@0.35:t=fill:${enable}`);
|
|
297
|
+
parts.push(`drawbox=x=${X(left)}:y=${Y(top)}:w=${W(bw)}:h=${H(bh)}:color=${color}:t=6:${enable}`);
|
|
223
298
|
continue;
|
|
224
299
|
}
|
|
225
300
|
if (a.shape === 'ARROW') {
|
|
@@ -232,9 +307,9 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
232
307
|
const top = Math.min(y0, y1);
|
|
233
308
|
const bw = Math.max(0.01, Math.abs(x1 - x0));
|
|
234
309
|
const bh = Math.max(0.01, Math.abs(y1 - y0));
|
|
235
|
-
parts.push(`drawbox=x
|
|
236
|
-
parts.push(`drawbox=x
|
|
237
|
-
parts.push(`drawbox=x
|
|
310
|
+
parts.push(`drawbox=x=${X(left)}:y=${Y(top)}:w=${W(bw)}:h=${H(bh)}:color=${color}@0.35:t=fill:${enable}`);
|
|
311
|
+
parts.push(`drawbox=x=${X(left)}:y=${Y(top)}:w=${W(bw)}:h=${H(bh)}:color=${color}:t=6:${enable}`);
|
|
312
|
+
parts.push(`drawbox=x=${X(x1)}-14:y=${Y(y1)}-14:w=28:h=28:color=${color}:t=fill:${enable}`);
|
|
238
313
|
continue;
|
|
239
314
|
}
|
|
240
315
|
if (a.shape === 'TEXT' && a.text) {
|
|
@@ -242,15 +317,19 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
242
317
|
// explicit font file on this ffmpeg.
|
|
243
318
|
const font = escapeFilterValue(exports.ANNOTATION_FONT_PATH);
|
|
244
319
|
const text = escapeFilterValue(String(a.text));
|
|
245
|
-
|
|
320
|
+
const gx = Number(g.x) || 0;
|
|
321
|
+
const gy = Number(g.y) || 0;
|
|
322
|
+
const tx = box ? `x=${X(gx)}` : `x=w*${this.f(gx)}`;
|
|
323
|
+
const ty = box ? `y=${Y(gy)}` : `y=h*${this.f(gy)}`;
|
|
324
|
+
parts.push(`drawtext=fontfile=${font}:text=${text}:expansion=none:${tx}:${ty}:fontcolor=${color}:fontsize=28:${enable}`);
|
|
246
325
|
continue;
|
|
247
326
|
}
|
|
248
327
|
// RECT + FREEHAND (bounding box) — filled wash + thick border so burn-in
|
|
249
328
|
// stays obvious after yuv420 / downscale.
|
|
250
|
-
const x =
|
|
251
|
-
const y =
|
|
252
|
-
const w =
|
|
253
|
-
const h =
|
|
329
|
+
const x = X(Number(g.x) || 0);
|
|
330
|
+
const y = Y(Number(g.y) || 0);
|
|
331
|
+
const w = W(Number(g.w) || 0);
|
|
332
|
+
const h = H(Number(g.h) || 0);
|
|
254
333
|
parts.push(`drawbox=x=${x}:y=${y}:w=${w}:h=${h}:color=${color}@0.35:t=fill:${enable}`);
|
|
255
334
|
parts.push(`drawbox=x=${x}:y=${y}:w=${w}:h=${h}:color=${color}:t=6:${enable}`);
|
|
256
335
|
}
|
|
@@ -310,13 +389,23 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
310
389
|
}
|
|
311
390
|
renderToFile(sourcePath, outPath, annotations) {
|
|
312
391
|
return __awaiter(this, void 0, void 0, function* () {
|
|
313
|
-
var _a;
|
|
314
392
|
// Probe the recorded span so late marks (timecode past EOF) get clamped in
|
|
315
393
|
// instead of vanishing. Best-effort: without a duration the timecodes are
|
|
316
394
|
// used unclamped (prior behavior).
|
|
317
395
|
const durationSec = yield this.probeDurationSec(sourcePath);
|
|
318
396
|
const imageFor = (a) => this.imageFor(sourcePath, a);
|
|
319
|
-
|
|
397
|
+
yield this.renderPlan(sourcePath, outPath, (withText) => this.buildRenderGraph(withText ? annotations : this.withoutUndrawableText(annotations, imageFor), durationSec, imageFor));
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Run a graph, or copy the source when there is nothing to draw. If it fails
|
|
402
|
+
* with text in it, retry once without the text marks: one undrawable text
|
|
403
|
+
* mark must not cost every other mark.
|
|
404
|
+
*/
|
|
405
|
+
renderPlan(sourcePath, outPath, build) {
|
|
406
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
407
|
+
var _a;
|
|
408
|
+
const plan = build(true);
|
|
320
409
|
if (!plan) {
|
|
321
410
|
fs.copyFileSync(sourcePath, outPath);
|
|
322
411
|
return;
|
|
@@ -327,11 +416,8 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
327
416
|
catch (err) {
|
|
328
417
|
if (!plan.hasText)
|
|
329
418
|
throw err;
|
|
330
|
-
// One undrawable text mark must not cost every other mark: the bundled
|
|
331
|
-
// ffmpeg has freetype but no fontconfig, so drawtext cannot find a font.
|
|
332
419
|
renderLog.warn(`Text marks could not be drawn, rendering without them: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
333
|
-
const
|
|
334
|
-
const retry = this.buildRenderGraph(noText, durationSec, imageFor);
|
|
420
|
+
const retry = build(false);
|
|
335
421
|
if (!retry) {
|
|
336
422
|
fs.copyFileSync(sourcePath, outPath);
|
|
337
423
|
return;
|
|
@@ -340,6 +426,52 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
340
426
|
}
|
|
341
427
|
});
|
|
342
428
|
}
|
|
429
|
+
withoutUndrawableText(annotations, imageFor) {
|
|
430
|
+
return annotations.filter((a) => a.shape !== 'TEXT' || !!imageFor(a));
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* One graph over the whole composite: every cell's box-drawn marks first,
|
|
434
|
+
* then each image mark scaled to its cell's picture and overlaid there.
|
|
435
|
+
*/
|
|
436
|
+
buildCompositeGraph(cells, videoDurationSec) {
|
|
437
|
+
const parts = [];
|
|
438
|
+
const images = [];
|
|
439
|
+
for (const cell of cells) {
|
|
440
|
+
const drawn = [];
|
|
441
|
+
for (const a of cell.annotations) {
|
|
442
|
+
const file = cell.imageFor(a);
|
|
443
|
+
if (!file) {
|
|
444
|
+
drawn.push(a);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const enable = this.enableExpr(a, videoDurationSec);
|
|
448
|
+
if (enable)
|
|
449
|
+
images.push({ file, enable, box: cell.box });
|
|
450
|
+
}
|
|
451
|
+
parts.push(...this.buildFilterParts(drawn, videoDurationSec, cell.box));
|
|
452
|
+
}
|
|
453
|
+
const hasText = parts.some((p) => p.startsWith('drawtext='));
|
|
454
|
+
const chunks = [];
|
|
455
|
+
let cur = '[0:v]';
|
|
456
|
+
if (parts.length > 0) {
|
|
457
|
+
chunks.push(`[0:v]${parts.join(',')}[d0]`);
|
|
458
|
+
cur = '[d0]';
|
|
459
|
+
}
|
|
460
|
+
images.forEach(({ enable, box }, i) => {
|
|
461
|
+
const k = i + 1;
|
|
462
|
+
chunks.push(`[${k}:v]scale=${box.w}:${box.h}[o${k}]`);
|
|
463
|
+
chunks.push(`${cur}[o${k}]overlay=${box.x}:${box.y}:${enable}[v${k}]`);
|
|
464
|
+
cur = `[v${k}]`;
|
|
465
|
+
});
|
|
466
|
+
if (cur === '[0:v]')
|
|
467
|
+
return null;
|
|
468
|
+
return { inputs: images.map((i) => i.file), graph: chunks.join(';'), output: cur, hasText };
|
|
469
|
+
}
|
|
470
|
+
probeFrameSize(filePath) {
|
|
471
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
472
|
+
return (0, probeDuration_1.probeVideoFrameSize)(filePath);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
343
475
|
imageFor(sourcePath, a) {
|
|
344
476
|
if (!a.id)
|
|
345
477
|
return undefined;
|
|
@@ -449,17 +581,27 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
449
581
|
}
|
|
450
582
|
cacheStamp(sourcePath, annotations) {
|
|
451
583
|
const srcMtime = fs.statSync(sourcePath).mtimeMs;
|
|
452
|
-
const parts = annotations.map((a) =>
|
|
453
|
-
var _a, _b, _c, _d, _e;
|
|
454
|
-
// A late-arriving image or a later clear must bust the cache.
|
|
455
|
-
const img = this.imageFor(sourcePath, a);
|
|
456
|
-
const imgMtime = img ? fs.statSync(img).mtimeMs : 0;
|
|
457
|
-
return `${(_a = a.id) !== null && _a !== void 0 ? _a : ''}:${(_b = a.timecode_ms) !== null && _b !== void 0 ? _b : 0}:${(_c = a.end_timecode_ms) !== null && _c !== void 0 ? _c : ''}:${a.shape}:${a.geometry}:${(_d = a.color) !== null && _d !== void 0 ? _d : ''}:${(_e = a.text) !== null && _e !== void 0 ? _e : ''}:${imgMtime}`;
|
|
458
|
-
});
|
|
584
|
+
const parts = annotations.map((a) => this.markStamp(a, (m) => this.imageFor(sourcePath, m)));
|
|
459
585
|
// Bump prefix when burn-in style changes so cached .annotated.mp4 is rebuilt.
|
|
460
586
|
// v3: time windows + image overlays.
|
|
461
587
|
return `v3|${srcMtime}|${parts.join('|')}`;
|
|
462
588
|
}
|
|
589
|
+
/** Composite cache stamp: the source, each cell's box, and every mark in it. */
|
|
590
|
+
compositeStamp(compositePath, cells) {
|
|
591
|
+
const srcMtime = fs.statSync(compositePath).mtimeMs;
|
|
592
|
+
const perCell = cells.map((c) => {
|
|
593
|
+
const b = `${c.box.x},${c.box.y},${c.box.w},${c.box.h}`;
|
|
594
|
+
return `${b}|${c.annotations.map((a) => this.markStamp(a, c.imageFor)).join('|')}`;
|
|
595
|
+
});
|
|
596
|
+
return `c1|${srcMtime}|${perCell.join('#')}`;
|
|
597
|
+
}
|
|
598
|
+
/** A late-arriving image or a later clear must bust the cache. */
|
|
599
|
+
markStamp(a, imageFor) {
|
|
600
|
+
var _a, _b, _c, _d, _e;
|
|
601
|
+
const img = imageFor(a);
|
|
602
|
+
const imgMtime = img ? fs.statSync(img).mtimeMs : 0;
|
|
603
|
+
return `${(_a = a.id) !== null && _a !== void 0 ? _a : ''}:${(_b = a.timecode_ms) !== null && _b !== void 0 ? _b : 0}:${(_c = a.end_timecode_ms) !== null && _c !== void 0 ? _c : ''}:${a.shape}:${a.geometry}:${(_d = a.color) !== null && _d !== void 0 ? _d : ''}:${(_e = a.text) !== null && _e !== void 0 ? _e : ''}:${imgMtime}`;
|
|
604
|
+
}
|
|
463
605
|
f(n) {
|
|
464
606
|
const num = Number(n);
|
|
465
607
|
if (!Number.isFinite(num))
|
|
@@ -14,9 +14,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.PROBE_TIMEOUT_MS = void 0;
|
|
16
16
|
exports.parseFfmpegHeaderDurationSec = parseFfmpegHeaderDurationSec;
|
|
17
|
+
exports.parseFfmpegFrameSize = parseFfmpegFrameSize;
|
|
17
18
|
exports.parseFfmpegProgressDurationSec = parseFfmpegProgressDurationSec;
|
|
18
19
|
exports.probeVideoDurationSec = probeVideoDurationSec;
|
|
19
20
|
exports.probeVideoDurationMs = probeVideoDurationMs;
|
|
21
|
+
exports.probeVideoFrameSize = probeVideoFrameSize;
|
|
20
22
|
const child_process_1 = require("child_process");
|
|
21
23
|
const path_1 = __importDefault(require("path"));
|
|
22
24
|
const typedi_1 = require("typedi");
|
|
@@ -48,6 +50,18 @@ function parseFfmpegHeaderDurationSec(stderr) {
|
|
|
48
50
|
// 0 is not an answer — let the caller fall back rather than record a zero.
|
|
49
51
|
return Number.isFinite(sec) && sec > 0 ? sec : undefined;
|
|
50
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Frame size from the first video stream line of `ffmpeg -i`, e.g.
|
|
55
|
+
* `Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, …), 720x1480, …`. The
|
|
56
|
+
* size is the first `, WxH` followed by a separator, which skips codec tags
|
|
57
|
+
* like `0x31637661`.
|
|
58
|
+
*/
|
|
59
|
+
function parseFfmpegFrameSize(stderr) {
|
|
60
|
+
const m = stderr.match(/Video: .*?, (\d{2,5})x(\d{2,5})(?=[,\s\]])/);
|
|
61
|
+
if (!m)
|
|
62
|
+
return undefined;
|
|
63
|
+
return { w: Number(m[1]), h: Number(m[2]) };
|
|
64
|
+
}
|
|
51
65
|
/** Duration from the last `out_time_us` of an `-f null` decode pass. */
|
|
52
66
|
function parseFfmpegProgressDurationSec(stdout) {
|
|
53
67
|
const matches = [...stdout.matchAll(/out_time_us=(\d+)/g)];
|
|
@@ -147,3 +161,10 @@ function probeVideoDurationMs(filePath_1) {
|
|
|
147
161
|
return sec === undefined ? undefined : Math.round(sec * 1000);
|
|
148
162
|
});
|
|
149
163
|
}
|
|
164
|
+
/** A video's frame size, from its container header. Undefined on failure. */
|
|
165
|
+
function probeVideoFrameSize(filePath_1) {
|
|
166
|
+
return __awaiter(this, arguments, void 0, function* (filePath, opts = {}) {
|
|
167
|
+
var _a;
|
|
168
|
+
return runProbe(['-hide_banner', '-i', filePath], `probe-size:${path_1.default.basename(filePath)}`, ({ stderr }) => parseFfmpegFrameSize(stderr), (_a = opts.timeoutMs) !== null && _a !== void 0 ? _a : exports.PROBE_TIMEOUT_MS);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -112,19 +112,43 @@ let ProofBundleService = class ProofBundleService {
|
|
|
112
112
|
this.populateVideosOnly(archive, groupId).catch((err) => archive.emit('error', err));
|
|
113
113
|
return archive;
|
|
114
114
|
}
|
|
115
|
-
|
|
115
|
+
/**
|
|
116
|
+
* The composite every download gets: with the devices' marks burned in when
|
|
117
|
+
* that render works, otherwise the raw composite. Null when the group has
|
|
118
|
+
* none (single-device groups never do).
|
|
119
|
+
*/
|
|
120
|
+
resolveCompositeFile(groupId) {
|
|
116
121
|
return __awaiter(this, void 0, void 0, function* () {
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
var _a;
|
|
123
|
+
let compositePath;
|
|
119
124
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
compositePath = (0, RecordingOrchestrator_1.compositeOutputPath)(groupId);
|
|
126
|
+
}
|
|
127
|
+
catch (_b) {
|
|
128
|
+
return null; // ArtifactStore unset (unit tests)
|
|
124
129
|
}
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
if (!fs.existsSync(compositePath) || fs.statSync(compositePath).size === 0)
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
const { AnnotationRenderService } = yield Promise.resolve().then(() => __importStar(require('./annotation-render')));
|
|
134
|
+
return (yield typedi_1.Container.get(AnnotationRenderService).resolveCompositePath(groupId)).filePath;
|
|
127
135
|
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
const { default: log } = yield Promise.resolve().then(() => __importStar(require('../../logger')));
|
|
138
|
+
log
|
|
139
|
+
.scope('ProofBundle')
|
|
140
|
+
.warn(`Composite burn-in skipped for ${groupId}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
141
|
+
return compositePath;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
collectVideoEntries(groupId) {
|
|
146
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
147
|
+
const recordings = (yield this.store.listGroup(groupId));
|
|
148
|
+
const entries = [];
|
|
149
|
+
const composite = yield this.resolveCompositeFile(groupId);
|
|
150
|
+
if (composite)
|
|
151
|
+
entries.push({ filePath: composite, name: 'composite.mp4' });
|
|
128
152
|
for (const r of recordings) {
|
|
129
153
|
try {
|
|
130
154
|
if (r.file_path && fs.existsSync(r.file_path) && fs.statSync(r.file_path).size > 0) {
|
|
@@ -134,7 +158,7 @@ let ProofBundleService = class ProofBundleService {
|
|
|
134
158
|
const rendered = yield typedi_1.Container.get(AnnotationRenderService).resolvePlayablePath(r.id);
|
|
135
159
|
filePath = rendered.filePath;
|
|
136
160
|
}
|
|
137
|
-
catch (
|
|
161
|
+
catch (_a) {
|
|
138
162
|
/* keep clean source */
|
|
139
163
|
}
|
|
140
164
|
entries.push({
|
|
@@ -143,7 +167,7 @@ let ProofBundleService = class ProofBundleService {
|
|
|
143
167
|
});
|
|
144
168
|
}
|
|
145
169
|
}
|
|
146
|
-
catch (
|
|
170
|
+
catch (_b) {
|
|
147
171
|
/* skip */
|
|
148
172
|
}
|
|
149
173
|
}
|
|
@@ -234,10 +258,9 @@ let ProofBundleService = class ProofBundleService {
|
|
|
234
258
|
name: 'README.md',
|
|
235
259
|
});
|
|
236
260
|
// Mosaic-wide composite mp4 (only present for multi-device groups).
|
|
237
|
-
const
|
|
238
|
-
if (
|
|
239
|
-
archive.file(
|
|
240
|
-
}
|
|
261
|
+
const composite = yield this.resolveCompositeFile(groupId);
|
|
262
|
+
if (composite)
|
|
263
|
+
archive.file(composite, { name: 'composite.mp4' });
|
|
241
264
|
for (const r of recordings) {
|
|
242
265
|
const base = `devices/${r.device_udid}`;
|
|
243
266
|
if (r.file_path && fs.existsSync(r.file_path)) {
|
|
@@ -0,0 +1,86 @@
|
|
|
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.recordingTimingPath = recordingTimingPath;
|
|
37
|
+
exports.writeRecordingTiming = writeRecordingTiming;
|
|
38
|
+
exports.readRecordingTiming = readRecordingTiming;
|
|
39
|
+
exports.markShiftMs = markShiftMs;
|
|
40
|
+
exports.shiftAnnotations = shiftAnnotations;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
/** Beyond this the file is corrupt, not a real start-up gap. */
|
|
44
|
+
const MAX_SHIFT_MS = 5 * 60 * 1000;
|
|
45
|
+
/** `<recordings>/<id>/timing.json`, removed with the recording's directory. */
|
|
46
|
+
function recordingTimingPath(videoFilePath) {
|
|
47
|
+
return path.join(path.dirname(path.dirname(videoFilePath)), 'timing.json');
|
|
48
|
+
}
|
|
49
|
+
/** Best effort: without the file the video is rendered unshifted, as before. */
|
|
50
|
+
function writeRecordingTiming(videoFilePath, timing) {
|
|
51
|
+
const file = recordingTimingPath(videoFilePath);
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
53
|
+
fs.writeFileSync(file, JSON.stringify(Object.assign({ version: 1 }, timing)));
|
|
54
|
+
}
|
|
55
|
+
function readRecordingTiming(videoFilePath) {
|
|
56
|
+
try {
|
|
57
|
+
const t = JSON.parse(fs.readFileSync(recordingTimingPath(videoFilePath), 'utf8'));
|
|
58
|
+
if ((t === null || t === void 0 ? void 0 : t.version) === 1 && Number.isFinite(t.spawnedAtMs) && Number.isFinite(t.groupT0Ms)) {
|
|
59
|
+
return { version: 1, spawnedAtMs: t.spawnedAtMs, groupT0Ms: t.groupT0Ms };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (_a) {
|
|
63
|
+
/* missing or unreadable: no timing */
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* How far to move a mark in this recording's own video: a mark at timecode T
|
|
69
|
+
* happened T + (t0 - spawn) into it. Negative for a device added after t=0.
|
|
70
|
+
*/
|
|
71
|
+
function markShiftMs(timing) {
|
|
72
|
+
if (!timing)
|
|
73
|
+
return 0;
|
|
74
|
+
const shift = Math.round(timing.groupT0Ms - timing.spawnedAtMs);
|
|
75
|
+
return Math.abs(shift) > MAX_SHIFT_MS ? 0 : shift;
|
|
76
|
+
}
|
|
77
|
+
function shiftAnnotations(annotations, shiftMs) {
|
|
78
|
+
if (shiftMs === 0)
|
|
79
|
+
return annotations;
|
|
80
|
+
return annotations.map((a) => {
|
|
81
|
+
var _a;
|
|
82
|
+
return (Object.assign(Object.assign({}, a), { timecode_ms: ((_a = a.timecode_ms) !== null && _a !== void 0 ? _a : 0) + shiftMs, end_timecode_ms: a.end_timecode_ms === null || a.end_timecode_ms === undefined
|
|
83
|
+
? a.end_timecode_ms
|
|
84
|
+
: a.end_timecode_ms + shiftMs }));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -72,4 +72,12 @@ describe('selectOwnActiveGroups', () => {
|
|
|
72
72
|
userId: 'alice',
|
|
73
73
|
})).to.deep.equal([]);
|
|
74
74
|
});
|
|
75
|
+
// A reload must keep the dashboard's timebase: its t=0 is when start()
|
|
76
|
+
// returned, not when the first device's ffmpeg spawned (about 1.2 s earlier
|
|
77
|
+
// with two devices), or marks drawn after a reload land late.
|
|
78
|
+
it("resumes on the group's recorded t=0 when there is one", () => {
|
|
79
|
+
const t0 = Date.parse('2026-09-25T10:00:02.350Z');
|
|
80
|
+
const out = (0, activeRecordings_1.selectOwnActiveGroups)([row('r1', 'g1', 'U1', '2026-09-25T10:00:01Z')], lockOf, { userId: 'alice' }, () => t0);
|
|
81
|
+
(0, chai_1.expect)(out[0].startedAt).to.equal('2026-09-25T10:00:02.350Z');
|
|
82
|
+
});
|
|
75
83
|
});
|