@livedesk/hub 0.1.5 → 0.1.6

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.
@@ -0,0 +1,123 @@
1
+ import { Mode4AtlasSession } from './mode4-atlas.js';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ export function buildMode4AtlasSessionKey(config = {}) {
5
+ return JSON.stringify({
6
+ deviceIds: Array.isArray(config.deviceIds) ? config.deviceIds : [],
7
+ width: Number(config.width || 1920),
8
+ height: Number(config.height || 1080),
9
+ tileWidth: Number(config.tileWidth || 320),
10
+ tileHeight: Number(config.tileHeight || 180),
11
+ fps: Number(config.fps || 20)
12
+ });
13
+ }
14
+
15
+ export function planMode4AtlasInputTransitions(previous = {}, next = {}) {
16
+ const previousIds = new Set(Array.isArray(previous.inputDeviceIds) ? previous.inputDeviceIds : []);
17
+ const nextIds = new Set(Array.isArray(next.inputDeviceIds) ? next.inputDeviceIds : []);
18
+ const previousMonitorSelections = previous.monitorSelections || {};
19
+ const nextMonitorSelections = next.monitorSelections || {};
20
+ const captureSizeChanged = previousIds.size > 0
21
+ && (Number(previous.tileWidth || 0) !== Number(next.tileWidth || 0)
22
+ || Number(previous.tileHeight || 0) !== Number(next.tileHeight || 0));
23
+ const captureFpsChanged = previousIds.size > 0
24
+ && Number(previous.inputFps || 0) !== Number(next.inputFps || 0);
25
+ const stop = [...previousIds].filter(deviceId => !nextIds.has(deviceId));
26
+ const start = [];
27
+ const restart = [];
28
+ const unchanged = [];
29
+
30
+ for (const deviceId of nextIds) {
31
+ if (!previousIds.has(deviceId)) {
32
+ start.push(deviceId);
33
+ continue;
34
+ }
35
+ const previousMonitorIndex = Number(previousMonitorSelections[deviceId] || 0);
36
+ const nextMonitorIndex = Number(nextMonitorSelections[deviceId] || 0);
37
+ if (captureSizeChanged || captureFpsChanged || previousMonitorIndex !== nextMonitorIndex) {
38
+ restart.push(deviceId);
39
+ } else {
40
+ unchanged.push(deviceId);
41
+ }
42
+ }
43
+
44
+ return { stop, start, restart, unchanged };
45
+ }
46
+
47
+ export class Mode4AtlasPool {
48
+ constructor(options = {}) {
49
+ this.createSession = typeof options.createSession === 'function'
50
+ ? options.createSession
51
+ : callbacks => new Mode4AtlasSession(callbacks);
52
+ this.onStatus = typeof options.onStatus === 'function' ? options.onStatus : () => {};
53
+ this.entries = new Map();
54
+ }
55
+
56
+ acquire(config, callbacks = {}) {
57
+ const key = buildMode4AtlasSessionKey(config);
58
+ let entry = this.entries.get(key);
59
+ if (!entry) {
60
+ entry = {
61
+ key,
62
+ clients: new Map(),
63
+ latestLayout: null,
64
+ session: null,
65
+ streamId: `mode4-atlas-shared-${randomUUID()}`
66
+ };
67
+ entry.session = this.createSession({
68
+ onFrame: output => {
69
+ for (const listener of entry.clients.values()) listener.onFrame?.(output);
70
+ },
71
+ onLayout: layout => {
72
+ entry.latestLayout = layout;
73
+ for (const listener of entry.clients.values()) listener.onLayout?.(layout);
74
+ },
75
+ onStatus: status => {
76
+ this.onStatus(status);
77
+ for (const listener of entry.clients.values()) listener.onStatus?.(status);
78
+ }
79
+ });
80
+ entry.session.configure(config);
81
+ this.entries.set(key, entry);
82
+ }
83
+
84
+ const clientId = Symbol(key);
85
+ entry.clients.set(clientId, callbacks);
86
+ if (entry.latestLayout) {
87
+ queueMicrotask(() => {
88
+ if (entry.clients.has(clientId)) callbacks.onLayout?.(entry.latestLayout);
89
+ });
90
+ }
91
+
92
+ let released = false;
93
+ return {
94
+ key,
95
+ session: entry.session,
96
+ streamId: entry.streamId,
97
+ release: () => {
98
+ if (released) return;
99
+ released = true;
100
+ entry.clients.delete(clientId);
101
+ if (entry.clients.size === 0) {
102
+ this.entries.delete(key);
103
+ entry.session.close();
104
+ }
105
+ }
106
+ };
107
+ }
108
+
109
+ ingest(frameEvent) {
110
+ for (const entry of this.entries.values()) {
111
+ entry.session.ingest(frameEvent);
112
+ }
113
+ }
114
+
115
+ get size() {
116
+ return this.entries.size;
117
+ }
118
+
119
+ close() {
120
+ for (const entry of this.entries.values()) entry.session.close();
121
+ this.entries.clear();
122
+ }
123
+ }
@@ -0,0 +1,32 @@
1
+ const TILE_PRESETS = Object.freeze([
2
+ Object.freeze({ width: 320, height: 180 }),
3
+ Object.freeze({ width: 480, height: 270 }),
4
+ Object.freeze({ width: 640, height: 360 })
5
+ ]);
6
+
7
+ export function resolveMode4AtlasTileSize(options = {}) {
8
+ const deviceCount = Math.max(1, Math.floor(Number(options.deviceCount) || 1));
9
+ const atlasWidth = Math.max(640, Math.floor(Number(options.atlasWidth) || 1920));
10
+ const atlasHeight = Math.max(360, Math.floor(Number(options.atlasHeight) || 1080));
11
+ const requestedWidth = Number(options.requestedWidth) || 320;
12
+ const requestedHeight = Number(options.requestedHeight) || 180;
13
+ const columns = Math.max(1, Math.ceil(Math.sqrt(deviceCount)));
14
+ const rows = Math.max(1, Math.ceil(deviceCount / columns));
15
+ const requestedPresetIndex = requestedWidth >= 640 || requestedHeight >= 360
16
+ ? 2
17
+ : requestedWidth >= 480 || requestedHeight >= 270
18
+ ? 1
19
+ : 0;
20
+ const supportedCandidates = TILE_PRESETS.slice(0, requestedPresetIndex + 1).reverse();
21
+ const selected = supportedCandidates.find(candidate => (
22
+ candidate.width * columns <= atlasWidth
23
+ && candidate.height * rows <= atlasHeight
24
+ )) || TILE_PRESETS[0];
25
+
26
+ return {
27
+ width: selected.width,
28
+ height: selected.height,
29
+ columns,
30
+ rows
31
+ };
32
+ }
@@ -4,15 +4,16 @@ import { spawn } from 'node:child_process';
4
4
  import { createRequire } from 'node:module';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { decodeLzo1xBlock } from './lzo1x.js';
7
+ import { resolveMode4AtlasTileSize } from './mode4-atlas-sizing.js';
7
8
 
8
9
  const require = createRequire(import.meta.url);
9
10
  const DEFAULT_WIDTH = 1920;
10
11
  const DEFAULT_HEIGHT = 1080;
11
12
  const DEFAULT_FPS = 20;
12
13
  const MAX_DEVICES = 100;
13
- const FRAME_POOL_SIZE = 8;
14
- const MAX_TILE_WIDTH = 480;
15
- const MAX_TILE_HEIGHT = 270;
14
+ const FRAME_POOL_SIZE = 3;
15
+ const MAX_TILE_WIDTH = 640;
16
+ const MAX_TILE_HEIGHT = 360;
16
17
 
17
18
  const rgb565Red = new Uint8Array(65_536);
18
19
  const rgb565Green = new Uint8Array(65_536);
@@ -43,7 +44,11 @@ let nextTickAt = 0;
43
44
  let closed = false;
44
45
  let lastComposeMs = 0;
45
46
  let poolStarved = 0;
47
+ let inputRevision = 0;
48
+ let lastEncodedInputRevision = -1;
49
+ let lastAtlasWriteAtEpochMs = 0;
46
50
  const ENCODER_STARTUP_OUTPUT_TIMEOUT_MS = 8_000;
51
+ const ATLAS_KEEPALIVE_MS = 1_000;
47
52
 
48
53
  function clamp(value, min, max, fallback) {
49
54
  const number = Number(value);
@@ -55,12 +60,18 @@ function normalizeConfig(value) {
55
60
  .map(item => String(item || '').trim()).filter(Boolean))].slice(0, MAX_DEVICES);
56
61
  const maxWidth = clamp(value?.width, 640, 3840, DEFAULT_WIDTH);
57
62
  const maxHeight = clamp(value?.height, 360, 2160, DEFAULT_HEIGHT);
58
- const sharpTiles = Number(value?.tileWidth) >= 480 || Number(value?.tileHeight) >= 270;
59
- const sourceTileWidth = sharpTiles ? 480 : 320;
60
- const sourceTileHeight = sharpTiles ? 270 : 180;
61
63
  const count = Math.max(1, deviceIds.length);
62
- const columns = Math.max(1, Math.ceil(Math.sqrt(count)));
63
- const rows = Math.max(1, Math.ceil(count / columns));
64
+ const tileSize = resolveMode4AtlasTileSize({
65
+ requestedWidth: value?.tileWidth,
66
+ requestedHeight: value?.tileHeight,
67
+ deviceCount: count,
68
+ atlasWidth: maxWidth,
69
+ atlasHeight: maxHeight
70
+ });
71
+ const columns = tileSize.columns;
72
+ const rows = tileSize.rows;
73
+ const sourceTileWidth = tileSize.width;
74
+ const sourceTileHeight = tileSize.height;
64
75
  const tileWidth = Math.max(2, Math.min(sourceTileWidth, Math.floor(maxWidth / columns))) & ~1;
65
76
  const tileHeight = Math.max(2, Math.min(sourceTileHeight, Math.floor(maxHeight / rows))) & ~1;
66
77
  return {
@@ -229,6 +240,12 @@ function emitEncodedUnit(payload) {
229
240
  clearTimeout(encoderStartupTimer);
230
241
  encoderStartupTimer = null;
231
242
  frameSeq += 1;
243
+ const readyTiles = [...latestTiles.values()];
244
+ const inputTimes = readyTiles
245
+ .map(tile => Number(tile.hubReceivedAtEpochMs || 0))
246
+ .filter(value => value > 0);
247
+ const sourceNewestAtEpochMs = inputTimes.length > 0 ? Math.max(...inputTimes) : 0;
248
+ const sourceOldestAtEpochMs = inputTimes.length > 0 ? Math.min(...inputTimes) : 0;
232
249
  send({
233
250
  type: 'frame',
234
251
  payload: Buffer.from(payload),
@@ -248,7 +265,11 @@ function emitEncodedUnit(payload) {
248
265
  composeMs: lastComposeMs,
249
266
  poolStarved,
250
267
  readyTileCount: latestTiles.size,
251
- tileCount: config.deviceIds.length
268
+ tileCount: config.deviceIds.length,
269
+ inputRevision,
270
+ sourceNewestAtEpochMs,
271
+ sourceOldestAtEpochMs,
272
+ sourceMaxAgeMs: sourceOldestAtEpochMs > 0 ? Math.max(0, Date.now() - sourceOldestAtEpochMs) : 0
252
273
  }
253
274
  });
254
275
  }
@@ -317,10 +338,28 @@ function ingestFrame(message) {
317
338
  const expectedLength = width * height * 2;
318
339
  if (!width || !height || Number(message.uncompressedByteLength) !== expectedLength) return;
319
340
  try {
341
+ const contentHash = String(message.contentHash || '');
342
+ const previous = latestTiles.get(deviceId);
343
+ const inputMetadata = {
344
+ frameSeq: Number(message.frameSeq || 0),
345
+ monitorIndex: Number(message.monitorIndex || 0),
346
+ capturedAtEpochMs: Number(message.capturedAtEpochMs || 0),
347
+ hubReceivedAtEpochMs: Number(message.hubReceivedAtEpochMs || 0) || Date.now(),
348
+ contentHash
349
+ };
350
+ if (previous
351
+ && contentHash
352
+ && previous.contentHash === contentHash
353
+ && previous.width === width
354
+ && previous.height === height) {
355
+ Object.assign(previous, inputMetadata);
356
+ return;
357
+ }
320
358
  const compressed = Buffer.isBuffer(message.payload) ? message.payload : Buffer.from(message.payload || []);
321
359
  const rgb565 = decodeLzo1xBlock(new Uint8Array(compressed), expectedLength);
322
- const tileState = { width, height, rgb565, rendered: null, frameSeq: Number(message.frameSeq || 0) };
360
+ const tileState = { width, height, rgb565, rendered: null, ...inputMetadata };
323
361
  latestTiles.set(deviceId, tileState);
362
+ inputRevision += 1;
324
363
  if (!encoder && encoderCandidates.length > 0) startEncoder();
325
364
  if (config.deviceIds.length > 0 && config.deviceIds.every(id => latestTiles.has(id))) {
326
365
  clearTimeout(inputWaitTimer);
@@ -339,6 +378,11 @@ function writeAtlasFrame() {
339
378
  startEncoder();
340
379
  return;
341
380
  }
381
+ const nowEpochMs = Date.now();
382
+ if (inputRevision === lastEncodedInputRevision
383
+ && nowEpochMs - lastAtlasWriteAtEpochMs < ATLAS_KEEPALIVE_MS) {
384
+ return;
385
+ }
342
386
  const composeStartedAt = performance.now();
343
387
  const target = framePool.pop();
344
388
  const stdin = encoder?.child?.stdin;
@@ -371,6 +415,8 @@ function writeAtlasFrame() {
371
415
  send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
372
416
  }
373
417
  });
418
+ lastEncodedInputRevision = inputRevision;
419
+ lastAtlasWriteAtEpochMs = nowEpochMs;
374
420
  } catch (error) {
375
421
  framePool.push(target);
376
422
  if (!isClosedPipeError(error)) send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
@@ -396,6 +442,7 @@ function configure(value) {
396
442
  const next = normalizeConfig(value);
397
443
  const encoderChanged = next.width !== config.width || next.height !== config.height || next.fps !== config.fps;
398
444
  config = next;
445
+ lastEncodedInputRevision = -1;
399
446
  latestTiles = new Map([...latestTiles].filter(([deviceId]) => config.deviceIds.includes(deviceId)));
400
447
  clearTimeout(inputWaitTimer);
401
448
  inputWaitTimer = config.deviceIds.length > 0 ? setTimeout(() => {
@@ -404,7 +451,7 @@ function configure(value) {
404
451
  send({
405
452
  type: 'log',
406
453
  level: 'warn',
407
- message: `Mode 4 waiting for Mode 2 input from ${missing.length}/${config.deviceIds.length} devices: ${missing.slice(0, 8).join(', ')}`
454
+ message: `Mode 4 waiting for internal Atlas input from ${missing.length}/${config.deviceIds.length} devices: ${missing.slice(0, 8).join(', ')}`
408
455
  });
409
456
  }
410
457
  }, 3000) : null;
@@ -107,7 +107,11 @@ export class Mode4AtlasSession {
107
107
  const frame = frameEvent?.frame || {};
108
108
  const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
109
109
  const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
110
- if (!this.inputDeviceSet.has(deviceId) || mode !== 'mode2-lzo' || !Buffer.isBuffer(frameEvent?.payload)) return;
110
+ const streamPurpose = String(frame.streamPurpose || '').trim().toLowerCase();
111
+ if (!this.inputDeviceSet.has(deviceId)
112
+ || mode !== 'mode2-lzo'
113
+ || streamPurpose !== 'atlas'
114
+ || !Buffer.isBuffer(frameEvent?.payload)) return;
111
115
  this.pendingFrames.set(deviceId, {
112
116
  type: 'frame',
113
117
  deviceId,
@@ -115,6 +119,10 @@ export class Mode4AtlasSession {
115
119
  width: Number(frame.width || 0),
116
120
  height: Number(frame.height || 0),
117
121
  uncompressedByteLength: Number(frame.uncompressedByteLength || 0),
122
+ monitorIndex: Number(frame.monitorIndex || 0),
123
+ capturedAtEpochMs: Date.parse(frame.capturedAt || '') || 0,
124
+ hubReceivedAtEpochMs: Date.parse(frame.receivedAt || '') || Date.now(),
125
+ contentHash: String(frame.contentHash || ''),
118
126
  payload: frameEvent.payload
119
127
  });
120
128
  this.pumpDeviceFrame(deviceId);