@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
|
@@ -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
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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(),
|