@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
@@ -54,11 +54,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
54
54
  return (mod && mod.__esModule) ? mod : { "default": mod };
55
55
  };
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
- exports.VideoPipelineService = void 0;
57
+ exports.VideoPipelineService = exports.COMPOSITE_CELL_H = exports.COMPOSITE_CELL_W = void 0;
58
58
  exports.resolveOutputPath = resolveOutputPath;
59
59
  exports.buildRecordArgs = buildRecordArgs;
60
60
  exports.buildCompositeArgs = buildCompositeArgs;
61
61
  exports.remuxToStandardMp4 = remuxToStandardMp4;
62
+ exports.compositeGrid = compositeGrid;
63
+ exports.cellOrigin = cellOrigin;
64
+ exports.buildCompositeFilterGraph = buildCompositeFilterGraph;
62
65
  const typedi_1 = require("typedi");
63
66
  const ProcessRegistry_1 = require("./ProcessRegistry");
64
67
  const child_process_1 = require("child_process");
@@ -314,6 +317,9 @@ function waitForFfmpegExit(proc, label) {
314
317
  timers.push(setTimeout(finish, FFMPEG_STOP_TIMEOUT_MS + 2000));
315
318
  });
316
319
  }
320
+ /** Default composite cell, portrait; each input is letterboxed into one. */
321
+ exports.COMPOSITE_CELL_W = 540;
322
+ exports.COMPOSITE_CELL_H = 960;
317
323
  /**
318
324
  * Intelligent Video Pipeline Service
319
325
  *
@@ -470,32 +476,24 @@ let VideoPipelineService = class VideoPipelineService {
470
476
  */
471
477
  startComposite(options) {
472
478
  return __awaiter(this, void 0, void 0, function* () {
473
- var _a, _b, _c;
479
+ var _a;
474
480
  const { groupId, inputs, outputPath } = options;
475
481
  if (this.activeComposites.has(groupId)) {
476
482
  logger_1.default.warn(`[VideoPipeline] Composite already running for group ${groupId}`);
477
- return;
483
+ return compositeGeometryFor(inputs.length, options);
478
484
  }
479
485
  if (inputs.length < 2) {
480
486
  throw new Error(`[VideoPipeline] startComposite requires at least 2 inputs, got ${inputs.length}`);
481
487
  }
482
488
  const layout = pickLayout(inputs.length);
483
- const cellW = (_a = options.cellWidth) !== null && _a !== void 0 ? _a : 540;
484
- const cellH = (_b = options.cellHeight) !== null && _b !== void 0 ? _b : 960;
489
+ const { cellW, cellH } = compositeGeometryFor(inputs.length, options);
485
490
  const outputDir = path_1.default.dirname(outputPath);
486
491
  if (!fs.existsSync(outputDir)) {
487
492
  fs.mkdirSync(outputDir, { recursive: true });
488
493
  }
489
494
  // Settle so all sources have primed at least one frame.
490
495
  yield new Promise((r) => setTimeout(r, 750));
491
- // Build the filtergraph: scale+pad each input to a uniform cell, then stack.
492
- const filterParts = [];
493
- for (let i = 0; i < inputs.length; i++) {
494
- filterParts.push(`[${i}:v]scale=${cellW}:${cellH}:force_original_aspect_ratio=decrease,` +
495
- `pad=${cellW}:${cellH}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v${i}]`);
496
- }
497
- filterParts.push(buildStackFilter(inputs.length, layout, cellW, cellH));
498
- const filterGraph = filterParts.join(';');
496
+ const filterGraph = buildCompositeFilterGraph(inputs.length, cellW, cellH);
499
497
  // Same wall-clock timing contract as the single-device recorder; see
500
498
  // buildCompositeArgs / buildRecordArgs for the rationale + regression numbers.
501
499
  const args = buildCompositeArgs({ inputs, filterGraph, outputPath, isMac: this.isMac });
@@ -512,7 +510,7 @@ let VideoPipelineService = class VideoPipelineService {
512
510
  udid: inputs.map((i) => i.udid).join(','),
513
511
  process: proc,
514
512
  });
515
- (_c = proc.stderr) === null || _c === void 0 ? void 0 : _c.on('data', (data) => {
513
+ (_a = proc.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (data) => {
516
514
  const msg = data.toString();
517
515
  if (msg.toLowerCase().includes('error')) {
518
516
  logger_1.default.error(`[VideoPipeline] FFMPEG composite error [${groupId}]: ${msg}`);
@@ -526,6 +524,7 @@ let VideoPipelineService = class VideoPipelineService {
526
524
  });
527
525
  this.activeComposites.set(groupId, proc);
528
526
  this.compositePaths.set(groupId, outputPath);
527
+ return Object.assign({ cellW, cellH }, compositeGrid(inputs.length));
529
528
  });
530
529
  }
531
530
  /**
@@ -591,6 +590,37 @@ function pickLayout(n) {
591
590
  return '2x2';
592
591
  return '3x2'; // up to 6
593
592
  }
593
+ /** Grid for N inputs, row-major, as buildStackFilter lays the cells out. */
594
+ function compositeGrid(n) {
595
+ const layout = pickLayout(n);
596
+ if (layout === '2x1')
597
+ return { cols: 2, rows: 1 };
598
+ if (layout === '2x2')
599
+ return { cols: 2, rows: 2 };
600
+ return { cols: 3, rows: 2 };
601
+ }
602
+ /** Top-left pixel of cell `i`, matching the hstack/xstack layouts. */
603
+ function cellOrigin(i, cols, cellW, cellH) {
604
+ return { x: (i % cols) * cellW, y: Math.floor(i / cols) * cellH };
605
+ }
606
+ /**
607
+ * The composite filtergraph: scale+pad each input to a uniform cell, then
608
+ * stack. Burn-in reads the same geometry (compositeGrid / cellOrigin) to put
609
+ * each device's marks in its cell.
610
+ */
611
+ function buildCompositeFilterGraph(n, cellW, cellH) {
612
+ const parts = [];
613
+ for (let i = 0; i < n; i++) {
614
+ parts.push(`[${i}:v]scale=${cellW}:${cellH}:force_original_aspect_ratio=decrease,` +
615
+ `pad=${cellW}:${cellH}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v${i}]`);
616
+ }
617
+ parts.push(buildStackFilter(n, pickLayout(n), cellW, cellH));
618
+ return parts.join(';');
619
+ }
620
+ function compositeGeometryFor(n, options) {
621
+ var _a, _b;
622
+ return Object.assign({ cellW: (_a = options.cellWidth) !== null && _a !== void 0 ? _a : exports.COMPOSITE_CELL_W, cellH: (_b = options.cellHeight) !== null && _b !== void 0 ? _b : exports.COMPOSITE_CELL_H }, compositeGrid(n));
623
+ }
594
624
  /**
595
625
  * Build the final stacking filter clause that consumes the [v0..vN-1] cells
596
626
  * and produces a single [v] output, padded with black if N < grid cells.
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.fillMissingScreenSize = fillMissingScreenSize;
13
+ /** One lookup per device at a time: a reload attaches the ticket and stream together. */
14
+ const inFlight = new Map();
15
+ /**
16
+ * Fetch and store a device's screen size when it isn't known yet.
17
+ *
18
+ * The dashboard only enables taps on a tile once the size is known, because it
19
+ * converts tile positions into device pixels. stream/start used to be the only
20
+ * place that fetched it, but a reload restores tiles through stream/status,
21
+ * stream/ticket and the H.264 socket without ever calling stream/start. After
22
+ * a server restart the size was never fetched, and restored tiles could not be
23
+ * tapped at all. Every stream attach point calls this now.
24
+ *
25
+ * Never throws; resolves true when a size was stored.
26
+ */
27
+ function fillMissingScreenSize(device, deps) {
28
+ if (device.screenWidth && device.screenHeight)
29
+ return Promise.resolve(false);
30
+ const existing = inFlight.get(device.udid);
31
+ if (existing)
32
+ return existing;
33
+ const task = (() => __awaiter(this, void 0, void 0, function* () {
34
+ try {
35
+ const manager = yield deps.managerFor(device.platform);
36
+ if (!(manager === null || manager === void 0 ? void 0 : manager.getAdditionalDeviceInfo))
37
+ return false;
38
+ const info = yield manager.getAdditionalDeviceInfo(device);
39
+ if (!(info === null || info === void 0 ? void 0 : info.screenWidth) || !(info === null || info === void 0 ? void 0 : info.screenHeight))
40
+ return false;
41
+ yield deps.updateDevice(device.udid, device.host, info);
42
+ return true;
43
+ }
44
+ catch (_a) {
45
+ return false;
46
+ }
47
+ }))().finally(() => inFlight.delete(device.udid));
48
+ inFlight.set(device.udid, task);
49
+ return task;
50
+ }
@@ -56,6 +56,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
57
  exports.RecordingOrchestrator = exports.RecordingError = void 0;
58
58
  exports.compositeOutputPath = compositeOutputPath;
59
+ exports.compositeLayoutPath = compositeLayoutPath;
59
60
  const typedi_1 = require("typedi");
60
61
  const fs = __importStar(require("fs"));
61
62
  const path = __importStar(require("path"));
@@ -72,6 +73,7 @@ const manualLock_1 = require("./manualLock");
72
73
  const ensureMjpegForRecording_1 = require("./ensureMjpegForRecording");
73
74
  const probeDuration_1 = require("./probeDuration");
74
75
  const annotationImage_1 = require("./annotationImage");
76
+ const recordingTiming_1 = require("./recordingTiming");
75
77
  const attributes_1 = require("../telemetry/attributes");
76
78
  const logger_1 = __importDefault(require("../../logger"));
77
79
  const ArtifactStore_1 = require("../artifacts/ArtifactStore");
@@ -118,6 +120,10 @@ function ensureOtelInstruments() {
118
120
  function compositeOutputPath(groupId) {
119
121
  return typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve('_groups', groupId, 'composite.mp4');
120
122
  }
123
+ /** Which recording sits in which composite cell, beside the composite. */
124
+ function compositeLayoutPath(groupId) {
125
+ return path.join(path.dirname(compositeOutputPath(groupId)), 'composite.json');
126
+ }
121
127
  class RecordingError extends Error {
122
128
  constructor(code, busyDevices, limit, active) {
123
129
  super(code);
@@ -130,7 +136,7 @@ class RecordingError extends Error {
130
136
  exports.RecordingError = RecordingError;
131
137
  let RecordingOrchestrator = class RecordingOrchestrator {
132
138
  constructor(deps = {}) {
133
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
139
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
134
140
  /**
135
141
  * Recording ids currently being closed out. Both the user-initiated stop and
136
142
  * an ffmpeg self-exit finalize rows, and they can land at the same instant —
@@ -148,6 +154,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
148
154
  this.unblockDeviceFn = (_g = deps.unblockDeviceFn) !== null && _g !== void 0 ? _g : device_service_1.unblockDevice;
149
155
  this.ensureMjpegPortFn = (_h = deps.ensureMjpegPortFn) !== null && _h !== void 0 ? _h : ensureMjpegForRecording_1.ensureMjpegForRecording;
150
156
  this.probeDurationMsFn = (_j = deps.probeDurationMsFn) !== null && _j !== void 0 ? _j : probeDuration_1.probeVideoDurationMs;
157
+ this.now = (_k = deps.now) !== null && _k !== void 0 ? _k : Date.now;
151
158
  }
152
159
  get busyPrecheck() {
153
160
  return this._deps.busyPrecheck;
@@ -253,6 +260,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
253
260
  // Android H.264 preview does not open an MJPEG port.
254
261
  const recordings = [];
255
262
  const mjpegPorts = {};
263
+ const spawned = [];
256
264
  for (let i = 0; i < udids.length; i++) {
257
265
  const id = recordingIds[i];
258
266
  const udid = udids[i];
@@ -279,6 +287,8 @@ let RecordingOrchestrator = class RecordingOrchestrator {
279
287
  // device stopped answering and the MJPEG server disconnected it.
280
288
  onExit: (code) => void this.handleSourceEnded(id, code),
281
289
  });
290
+ // startRecording resolves as its ffmpeg spawns: this video's t=0.
291
+ spawned.push({ filePath, spawnedAtMs: this.now() });
282
292
  recordings.push({ id, udid, status: 'RECORDING' });
283
293
  }
284
294
  catch (err) {
@@ -322,13 +332,14 @@ let RecordingOrchestrator = class RecordingOrchestrator {
322
332
  }
323
333
  if (compositeInputs.length >= 2) {
324
334
  const compositePath = compositeOutputPath(groupId);
325
- yield this.videoPipeline.startComposite({
335
+ const geometry = yield this.videoPipeline.startComposite({
326
336
  groupId,
327
337
  inputs: compositeInputs,
328
338
  outputPath: compositePath,
329
339
  });
330
340
  recLog.info(`Composite recording started for group ${groupId} → ${compositePath}`);
331
341
  compositeEnabled = true;
342
+ this.writeCompositeLayout(groupId, geometry, compositeInputs, recordings);
332
343
  }
333
344
  else {
334
345
  recLog.warn(`Composite skipped for group ${groupId}: not enough MJPEG ports resolved (${compositeInputs.length}/${recordings.length}).`);
@@ -351,6 +362,11 @@ let RecordingOrchestrator = class RecordingOrchestrator {
351
362
  recordings: recordings.map((r) => ({ id: r.id, udid: r.udid })),
352
363
  startedAt,
353
364
  });
365
+ // The dashboard stamps its t=0 when this returns, and every mark's
366
+ // timecode counts from it. The per-device videos started earlier.
367
+ const groupT0Ms = this.now();
368
+ for (const s of spawned)
369
+ this.recordTiming(s.filePath, s.spawnedAtMs, groupT0Ms);
354
370
  return { groupId, recordings, startedAt, compositeEnabled };
355
371
  });
356
372
  }
@@ -509,6 +525,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
509
525
  this.finalizing.delete(r.id);
510
526
  }
511
527
  }
528
+ this.prewarmCompositeRender(groupId);
512
529
  this.eventMgr.emitRecordingStopped({ groupId, recordings: out });
513
530
  span.setStatus({ code: api_1.SpanStatusCode.OK });
514
531
  return { groupId, recordings: out };
@@ -523,6 +540,85 @@ let RecordingOrchestrator = class RecordingOrchestrator {
523
540
  }
524
541
  });
525
542
  }
543
+ /**
544
+ * Fire-and-forget, like the per-device pre-render: burn marks into the
545
+ * composite now so its download is served warm. Only groups whose composite
546
+ * has a layout record and marks do any work.
547
+ */
548
+ prewarmCompositeRender(groupId) {
549
+ void (() => __awaiter(this, void 0, void 0, function* () {
550
+ var _a;
551
+ try {
552
+ if (!fs.existsSync(compositeLayoutPath(groupId)))
553
+ return;
554
+ const { AnnotationRenderService } = yield Promise.resolve().then(() => __importStar(require('./annotation-render')));
555
+ const out = yield typedi_1.Container.get(AnnotationRenderService).resolveCompositePath(groupId);
556
+ if (out.annotated)
557
+ recLog.info(`Pre-rendered annotated composite for ${groupId}`);
558
+ }
559
+ catch (err) {
560
+ recLog.warn(`Composite pre-render failed for ${groupId}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
561
+ }
562
+ }))();
563
+ }
564
+ /** Best effort: without it the video's marks are simply not shifted. */
565
+ recordTiming(filePath, spawnedAtMs, groupT0Ms) {
566
+ var _a;
567
+ try {
568
+ (0, recordingTiming_1.writeRecordingTiming)(filePath, { spawnedAtMs, groupT0Ms });
569
+ }
570
+ catch (err) {
571
+ recLog.warn(`Recording timing not saved for ${filePath}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
572
+ }
573
+ }
574
+ /** The group's t=0, from any of its recordings that has a timing record. */
575
+ groupT0Ms(groupId, exceptId) {
576
+ return __awaiter(this, void 0, void 0, function* () {
577
+ var _a;
578
+ try {
579
+ for (const r of (yield this.store.listGroup(groupId))) {
580
+ if (r.id === exceptId || !r.file_path)
581
+ continue;
582
+ const t0 = (_a = (0, recordingTiming_1.readRecordingTiming)(r.file_path)) === null || _a === void 0 ? void 0 : _a.groupT0Ms;
583
+ if (t0 !== undefined)
584
+ return t0;
585
+ }
586
+ }
587
+ catch (_b) {
588
+ /* unknown t=0: leave this device unshifted */
589
+ }
590
+ return undefined;
591
+ });
592
+ }
593
+ /**
594
+ * Record the composite's cells so marks can be burned into it later. Best
595
+ * effort: without the file the composite is simply served without marks.
596
+ */
597
+ writeCompositeLayout(groupId, geometry, inputs, recordings) {
598
+ var _a;
599
+ if (!geometry)
600
+ return;
601
+ try {
602
+ const layout = {
603
+ version: 1,
604
+ cellW: geometry.cellW,
605
+ cellH: geometry.cellH,
606
+ cols: geometry.cols,
607
+ rows: geometry.rows,
608
+ cells: inputs.map((input, index) => ({
609
+ index,
610
+ udid: input.udid,
611
+ recordingId: recordings.find((r) => r.udid === input.udid).id,
612
+ })),
613
+ };
614
+ const file = compositeLayoutPath(groupId);
615
+ fs.mkdirSync(path.dirname(file), { recursive: true });
616
+ fs.writeFileSync(file, JSON.stringify(layout, null, 2));
617
+ }
618
+ catch (err) {
619
+ recLog.warn(`Composite layout not recorded for ${groupId}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
620
+ }
621
+ }
526
622
  /**
527
623
  * Fire-and-forget: burn annotations into `<id>.annotated.mp4` right after
528
624
  * stop so the dashboard Download is served from a warm cache instead of an
@@ -715,6 +811,11 @@ let RecordingOrchestrator = class RecordingOrchestrator {
715
811
  outputPath: filePath,
716
812
  mjpegPort,
717
813
  });
814
+ // Joined after t=0: its marks shift back by how late it started.
815
+ const spawnedAtMs = this.now();
816
+ const groupT0Ms = yield this.groupT0Ms(groupId, recordingId);
817
+ if (groupT0Ms !== undefined)
818
+ this.recordTiming(filePath, spawnedAtMs, groupT0Ms);
718
819
  }
719
820
  catch (err) {
720
821
  recLog.error(`Failed to add device ${udid} to group ${groupId}: ${err === null || err === void 0 ? void 0 : err.message}`);
@@ -9,7 +9,9 @@ const deviceAccessPolicy_1 = require("../device-access/deviceAccessPolicy");
9
9
  * user's recording. Only open marks come back, because a cleared mark is no
10
10
  * longer on screen.
11
11
  */
12
- function selectOwnActiveGroups(rows, lockOf, actor) {
12
+ function selectOwnActiveGroups(rows, lockOf, actor,
13
+ /** The group's recorded t=0 for a row, when its timing record exists. */
14
+ t0Of = () => undefined) {
13
15
  var _a;
14
16
  const groups = new Map();
15
17
  for (const r of rows) {
@@ -22,7 +24,11 @@ function selectOwnActiveGroups(rows, lockOf, actor) {
22
24
  const mine = list.some((r) => (0, deviceAccessPolicy_1.isSelfManualLock)(lockOf(r.device_udid), r.device_udid, actor.userId, actor.apiKeyId));
23
25
  if (!mine)
24
26
  continue;
25
- const started = Math.min(...list.map((r) => new Date(r.started_at).getTime()));
27
+ // Resume on the dashboard's original t=0 (when start() returned) so marks
28
+ // drawn after a reload share a timebase with those drawn before it. Older
29
+ // recordings have no timing record; their earliest start is close enough.
30
+ const recordedT0 = list.map(t0Of).find((v) => v !== undefined);
31
+ const started = recordedT0 !== null && recordedT0 !== void 0 ? recordedT0 : Math.min(...list.map((r) => new Date(r.started_at).getTime()));
26
32
  out.push({
27
33
  groupId,
28
34
  startedAt: new Date(started).toISOString(),