@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.
Files changed (82) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{ApiKeyGate-B_1qvA7M.js → ApiKeyGate-Snh2iZH5.js} +1 -1
  3. package/lib/public/assets/{BugReportButton-0m4q010L.js → BugReportButton-Sk0KGufH.js} +1 -1
  4. package/lib/public/assets/DeviceMosaicView-O292tZkA.js +2 -0
  5. package/lib/public/assets/{EmptyState-CTmGgoSm.js → EmptyState-CQun3BJE.js} +1 -1
  6. package/lib/public/assets/{FieldGroup-oWAJBBAu.js → FieldGroup-DmYZP2j7.js} +1 -1
  7. package/lib/public/assets/{Modal-LXKxgyV7.js → Modal-C5W8lMJ6.js} +1 -1
  8. package/lib/public/assets/{SegmentedControl-BSWMM3qE.js → SegmentedControl-BIzlKyUd.js} +1 -1
  9. package/lib/public/assets/{SettingCard-CeHOIGW9.js → SettingCard-DAFqQw7S.js} +1 -1
  10. package/lib/public/assets/{Table-EHWJCCzN.js → Table-DRkkM4OR.js} +1 -1
  11. package/lib/public/assets/{activity-CHfTPuBn.js → activity-B6x69oOy.js} +1 -1
  12. package/lib/public/assets/{ai-settings-B_dT4ctk.js → ai-settings-CSZQis7F.js} +1 -1
  13. package/lib/public/assets/{api-keys-BIKaKigW.js → api-keys-4ddjSngL.js} +1 -1
  14. package/lib/public/assets/{apps-Dp4crlsn.js → apps-CePIfnu9.js} +1 -1
  15. package/lib/public/assets/{arrow-left-QDqb-yc1.js → arrow-left-CaOe18yC.js} +1 -1
  16. package/lib/public/assets/{arrow-right-D_onnhWU.js → arrow-right-FEQsqDvH.js} +1 -1
  17. package/lib/public/assets/{arrow-up-right-DOwr8XxB.js → arrow-up-right-Dnszb73S.js} +1 -1
  18. package/lib/public/assets/{auth-shell-NbHUThYR.js → auth-shell-Vq1rYIVD.js} +2 -2
  19. package/lib/public/assets/{builds-page-CQseApdL.js → builds-page-FfsUegKZ.js} +1 -1
  20. package/lib/public/assets/{button-Do8mBvkU.js → button-DrqhMLZ0.js} +1 -1
  21. package/lib/public/assets/{calendar-DrH0mjSD.js → calendar-BuRuUa7q.js} +1 -1
  22. package/lib/public/assets/{check-C8Un1cwf.js → check-DYccNk73.js} +1 -1
  23. package/lib/public/assets/{chevron-right-DHjIgZvn.js → chevron-right-C6ny35i3.js} +1 -1
  24. package/lib/public/assets/{circle-check-Dkg-Ka4_.js → circle-check-Cokzy9a2.js} +1 -1
  25. package/lib/public/assets/{circle-x-BPKWtaZx.js → circle-x-BhUzyxKR.js} +1 -1
  26. package/lib/public/assets/{clock-CQLUBaRx.js → clock-BCG0drfS.js} +1 -1
  27. package/lib/public/assets/{copy-CWx0Ec1c.js → copy-BKOm9hko.js} +1 -1
  28. package/lib/public/assets/{device-explorer-BjrTj6Sm.js → device-explorer-CIjs3BXP.js} +1 -1
  29. package/lib/public/assets/{download-DJ_pjhGv.js → download-R2k7Nd4P.js} +1 -1
  30. package/lib/public/assets/{forgot-password-MmPdS0S2.js → forgot-password-CiZcr-gB.js} +1 -1
  31. package/lib/public/assets/{funnel-DR8YKoFW.js → funnel-SD4F5IdR.js} +1 -1
  32. package/lib/public/assets/{index-DbzQAv86.js → index-BHDlQcd6.js} +2 -2
  33. package/lib/public/assets/{input-CKKtQEU5.js → input-Ilib6feD.js} +1 -1
  34. package/lib/public/assets/{layers-DprXM0fz.js → layers-CLuKN5Oy.js} +1 -1
  35. package/lib/public/assets/{lock-BXGh7bJZ.js → lock-s9ZhGDFF.js} +1 -1
  36. package/lib/public/assets/{login-DOYeWP9H.js → login-EkaxK7K5.js} +1 -1
  37. package/lib/public/assets/{maintenance-settings-w8U3BRZk.js → maintenance-settings-yYhZaU7n.js} +1 -1
  38. package/lib/public/assets/{mouse-pointer-2-BD4cWXDU.js → mouse-pointer-2-C6yy1DTx.js} +1 -1
  39. package/lib/public/assets/{network-C5bJeATc.js → network-B8fPwmW9.js} +1 -1
  40. package/lib/public/assets/{overview-DzAXi4vj.js → overview-DrTe5eJi.js} +1 -1
  41. package/lib/public/assets/{page-header-CqaoOYPH.js → page-header-DhD-Kua9.js} +1 -1
  42. package/lib/public/assets/{plus-CJfVcjJv.js → plus-B75ovH3O.js} +1 -1
  43. package/lib/public/assets/{profile-page-Mk1yk1c4.js → profile-page-C2p5rB3x.js} +1 -1
  44. package/lib/public/assets/{reset-password-D1gtSgzr.js → reset-password-tCemYrqV.js} +1 -1
  45. package/lib/public/assets/{runbook-page-B7EjVqsY.js → runbook-page-Dq3wdSII.js} +1 -1
  46. package/lib/public/assets/{select-DVYczzD1.js → select-Cfv-njLE.js} +1 -1
  47. package/lib/public/assets/{selector-detail-page-C6b-Z-ep.js → selector-detail-page-8xf4CxH5.js} +1 -1
  48. package/lib/public/assets/{selector-health-DhzDZLlF.js → selector-health-BP45B44s.js} +1 -1
  49. package/lib/public/assets/{selector-health-page-w5zIE-r8.js → selector-health-page-D-8SdN_l.js} +1 -1
  50. package/lib/public/assets/{session-detail-page-CiPVLY-1.js → session-detail-page-Dm4tnoTT.js} +1 -1
  51. package/lib/public/assets/{settings-Bm-LdOxO.js → settings-ChfaAd8F.js} +1 -1
  52. package/lib/public/assets/{status-pill-outline-BgQ_Ur6B.js → status-pill-outline-CLa0C1Sg.js} +1 -1
  53. package/lib/public/assets/{tablet-1GdHxaSx.js → tablet-0sIFwy2Z.js} +1 -1
  54. package/lib/public/assets/{teams-D8rZexDl.js → teams-BFhWbK5i.js} +1 -1
  55. package/lib/public/assets/{trash-2-DJgpvIoq.js → trash-2-BcQxmJP-.js} +1 -1
  56. package/lib/public/assets/{upload-BJ0V1QbY.js → upload-BucqEWub.js} +1 -1
  57. package/lib/public/assets/{use-password-reset-mode-BVFQr1mo.js → use-password-reset-mode-XoxXVCzm.js} +1 -1
  58. package/lib/public/assets/{users-Bxnci4rK.js → users-B7uveXL_.js} +1 -1
  59. package/lib/public/assets/{users-DUb_rmxk.js → users-DyB6E3FF.js} +1 -1
  60. package/lib/public/assets/{webhook-settings-DL54Ijod.js → webhook-settings-qoHAETzC.js} +1 -1
  61. package/lib/public/assets/{zap-BLSqrVs9.js → zap-CqGOjAjn.js} +1 -1
  62. package/lib/public/index.html +1 -1
  63. package/lib/src/app/routers/control.js +18 -15
  64. package/lib/src/app/routers/recordings.js +5 -3
  65. package/lib/src/services/VideoPipelineService.js +44 -14
  66. package/lib/src/services/fillMissingScreenSize.js +50 -0
  67. package/lib/src/services/recording/RecordingOrchestrator.js +103 -2
  68. package/lib/src/services/recording/activeRecordings.js +8 -2
  69. package/lib/src/services/recording/annotation-render.js +189 -47
  70. package/lib/src/services/recording/probeDuration.js +21 -0
  71. package/lib/src/services/recording/proof-bundle.js +38 -15
  72. package/lib/src/services/recording/recordingTiming.js +86 -0
  73. package/lib/test/unit/active-recordings.spec.js +8 -0
  74. package/lib/test/unit/annotation-render.spec.js +226 -0
  75. package/lib/test/unit/composite-geometry.spec.js +80 -0
  76. package/lib/test/unit/fill-missing-screen-size.spec.js +65 -0
  77. package/lib/test/unit/proof-bundle.spec.js +65 -0
  78. package/lib/test/unit/recording-orchestrator.spec.js +95 -0
  79. package/lib/test/unit/recording-timing.spec.js +88 -0
  80. package/lib/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +1 -1
  82. 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
- const annotations = ((_a = rec.annotations) !== null && _a !== void 0 ? _a : []);
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
- let needsRender = true;
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
- if (fs.existsSync(outPath) &&
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 (_b) {
144
- needsRender = true;
176
+ catch (_c) {
177
+ return raw;
145
178
  }
146
- if (needsRender) {
147
- yield this.renderCached(outPath, stampPath, stamp, rec.file_path, annotations);
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, sourcePath, annotations) {
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
- try {
165
- if (fs.existsSync(outPath) &&
166
- fs.existsSync(stampPath) &&
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=iw*${this.f(left)}:y=ih*${this.f(top)}:w=iw*${this.f(bw)}:h=ih*${this.f(bh)}:color=${color}@0.35:t=fill:${enable}`);
222
- parts.push(`drawbox=x=iw*${this.f(left)}:y=ih*${this.f(top)}:w=iw*${this.f(bw)}:h=ih*${this.f(bh)}:color=${color}:t=6:${enable}`);
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=iw*${this.f(left)}:y=ih*${this.f(top)}:w=iw*${this.f(bw)}:h=ih*${this.f(bh)}:color=${color}@0.35:t=fill:${enable}`);
236
- parts.push(`drawbox=x=iw*${this.f(left)}:y=ih*${this.f(top)}:w=iw*${this.f(bw)}:h=ih*${this.f(bh)}:color=${color}:t=6:${enable}`);
237
- parts.push(`drawbox=x=iw*${this.f(x1)}-14:y=ih*${this.f(y1)}-14:w=28:h=28:color=${color}:t=fill:${enable}`);
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
- parts.push(`drawtext=fontfile=${font}:text=${text}:expansion=none:x=w*${this.f(g.x)}:y=h*${this.f(g.y)}:fontcolor=${color}:fontsize=28:${enable}`);
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 = `iw*${this.f(g.x)}`;
251
- const y = `ih*${this.f(g.y)}`;
252
- const w = `iw*${this.f(g.w)}`;
253
- const h = `ih*${this.f(g.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
- const plan = this.buildRenderGraph(annotations, durationSec, imageFor);
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 noText = annotations.filter((a) => a.shape !== 'TEXT' || !!imageFor(a));
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
- collectVideoEntries(groupId) {
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
- const recordings = (yield this.store.listGroup(groupId));
118
- const entries = [];
122
+ var _a;
123
+ let compositePath;
119
124
  try {
120
- const compositePath = (0, RecordingOrchestrator_1.compositeOutputPath)(groupId);
121
- if (fs.existsSync(compositePath) && fs.statSync(compositePath).size > 0) {
122
- entries.push({ filePath: compositePath, name: 'composite.mp4' });
123
- }
125
+ compositePath = (0, RecordingOrchestrator_1.compositeOutputPath)(groupId);
126
+ }
127
+ catch (_b) {
128
+ return null; // ArtifactStore unset (unit tests)
124
129
  }
125
- catch (_a) {
126
- /* ArtifactStore may be unset in unit tests; skip composite. */
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 (_b) {
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 (_c) {
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 compositePath = (0, RecordingOrchestrator_1.compositeOutputPath)(groupId);
238
- if (fs.existsSync(compositePath)) {
239
- archive.file(compositePath, { name: 'composite.mp4' });
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
  });