livedesk 0.1.151 → 0.1.153

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/README.md CHANGED
@@ -29,7 +29,7 @@ automatically after reboot.
29
29
  - Mode 2: independent 320x180 RGB565+LZO tiles for large, stable walls.
30
30
  - Mode 3: direct hardware H.264 for focused remote control up to 4K.
31
31
  - Mode 4: client Mode 2 inputs composited by an isolated Hub worker into one
32
- 1920x1080 H.264 Atlas stream for the browser wall.
32
+ bounded H.264 Atlas stream, then split by UV into the normal browser slots.
33
33
 
34
34
  ## Plans
35
35
 
@@ -9,7 +9,18 @@ const DEFAULT_WIDTH = 1920;
9
9
  const DEFAULT_HEIGHT = 1080;
10
10
  const DEFAULT_FPS = 20;
11
11
  const MAX_DEVICES = 100;
12
- const FRAME_POOL_SIZE = 3;
12
+ const FRAME_POOL_SIZE = 8;
13
+ const MAX_TILE_WIDTH = 320;
14
+ const MAX_TILE_HEIGHT = 180;
15
+
16
+ const rgb565Red = new Uint8Array(65_536);
17
+ const rgb565Green = new Uint8Array(65_536);
18
+ const rgb565Blue = new Uint8Array(65_536);
19
+ for (let pixel = 0; pixel < 65_536; pixel += 1) {
20
+ rgb565Red[pixel] = (((pixel >> 11) & 0x1f) * 255 / 31) | 0;
21
+ rgb565Green[pixel] = (((pixel >> 5) & 0x3f) * 255 / 63) | 0;
22
+ rgb565Blue[pixel] = ((pixel & 0x1f) * 255 / 31) | 0;
23
+ }
13
24
 
14
25
  let config = normalizeConfig({});
15
26
  let layoutVersion = 0;
@@ -22,10 +33,14 @@ let encoderCandidates = [];
22
33
  let encoderOutputSeen = false;
23
34
  let encoderRestartTimer = null;
24
35
  let encoderStartupTimer = null;
36
+ let inputWaitTimer = null;
25
37
  let outputBuffer = Buffer.alloc(0);
26
38
  let frameSeq = 0;
27
39
  let tickTimer = null;
40
+ let nextTickAt = 0;
28
41
  let closed = false;
42
+ let lastComposeMs = 0;
43
+ let poolStarved = 0;
29
44
 
30
45
  function clamp(value, min, max, fallback) {
31
46
  const number = Number(value);
@@ -35,10 +50,23 @@ function clamp(value, min, max, fallback) {
35
50
  function normalizeConfig(value) {
36
51
  const deviceIds = [...new Set((Array.isArray(value?.deviceIds) ? value.deviceIds : [])
37
52
  .map(item => String(item || '').trim()).filter(Boolean))].slice(0, MAX_DEVICES);
53
+ const maxWidth = clamp(value?.width, 640, 3840, DEFAULT_WIDTH);
54
+ const maxHeight = clamp(value?.height, 360, 2160, DEFAULT_HEIGHT);
55
+ const count = Math.max(1, deviceIds.length);
56
+ const columns = Math.max(1, Math.ceil(Math.sqrt(count)));
57
+ const rows = Math.max(1, Math.ceil(count / columns));
58
+ const tileWidth = Math.max(2, Math.min(MAX_TILE_WIDTH, Math.floor(maxWidth / columns))) & ~1;
59
+ const tileHeight = Math.max(2, Math.min(MAX_TILE_HEIGHT, Math.floor(maxHeight / rows))) & ~1;
38
60
  return {
39
61
  deviceIds,
40
- width: clamp(value?.width, 640, 3840, DEFAULT_WIDTH),
41
- height: clamp(value?.height, 360, 2160, DEFAULT_HEIGHT),
62
+ maxWidth,
63
+ maxHeight,
64
+ width: tileWidth * columns,
65
+ height: tileHeight * rows,
66
+ columns,
67
+ rows,
68
+ tileWidth,
69
+ tileHeight,
42
70
  fps: clamp(value?.fps, 1, 30, DEFAULT_FPS)
43
71
  };
44
72
  }
@@ -193,27 +221,27 @@ function emitEncodedUnit(payload) {
193
221
  isKeyFrame,
194
222
  chunkType: isKeyFrame ? 'key' : 'delta',
195
223
  hardwareEncoder: encoder?.candidate?.name || '',
196
- layoutVersion
224
+ layoutVersion,
225
+ composeMs: lastComposeMs,
226
+ poolStarved,
227
+ readyTileCount: latestTiles.size,
228
+ tileCount: config.deviceIds.length
197
229
  }
198
230
  });
199
231
  }
200
232
 
201
233
  function rebuildLayout() {
202
- const count = Math.max(1, config.deviceIds.length);
203
- const columns = Math.max(1, Math.ceil(Math.sqrt(count)));
204
- const rows = Math.max(1, Math.ceil(count / columns));
205
- const cellWidth = Math.floor(config.width / columns);
206
- const cellHeight = Math.floor(config.height / rows);
234
+ const { columns, rows, tileWidth, tileHeight } = config;
207
235
  layoutVersion += 1;
208
236
  layout = config.deviceIds.map((deviceId, index) => ({
209
237
  deviceId,
210
238
  index,
211
239
  column: index % columns,
212
240
  row: Math.floor(index / columns),
213
- x: (index % columns) * cellWidth,
214
- y: Math.floor(index / columns) * cellHeight,
215
- width: index % columns === columns - 1 ? config.width - (index % columns) * cellWidth : cellWidth,
216
- height: Math.floor(index / columns) === rows - 1 ? config.height - Math.floor(index / columns) * cellHeight : cellHeight
241
+ x: (index % columns) * tileWidth,
242
+ y: Math.floor(index / columns) * tileHeight,
243
+ width: tileWidth,
244
+ height: tileHeight
217
245
  }));
218
246
  for (const tile of latestTiles.values()) tile.rendered = null;
219
247
  framePool = Array.from({ length: FRAME_POOL_SIZE }, () => Buffer.alloc(config.width * config.height * 3));
@@ -228,17 +256,26 @@ function renderTile(tileState, tileLayout) {
228
256
  const height = Math.max(1, Math.floor(sourceHeight * scale));
229
257
  const pixels = Buffer.allocUnsafe(width * height * 3);
230
258
  let target = 0;
259
+ if (width === sourceWidth && height === sourceHeight) {
260
+ for (let source = 0; source < tileState.rgb565.length; source += 2) {
261
+ const pixel = tileState.rgb565[source] | (tileState.rgb565[source + 1] << 8);
262
+ pixels[target++] = rgb565Red[pixel];
263
+ pixels[target++] = rgb565Green[pixel];
264
+ pixels[target++] = rgb565Blue[pixel];
265
+ }
266
+ } else {
231
267
  for (let y = 0; y < height; y += 1) {
232
268
  const sourceY = Math.min(sourceHeight - 1, Math.floor((y + 0.5) * sourceHeight / height));
233
269
  for (let x = 0; x < width; x += 1) {
234
270
  const sourceX = Math.min(sourceWidth - 1, Math.floor((x + 0.5) * sourceWidth / width));
235
271
  const source = (sourceY * sourceWidth + sourceX) * 2;
236
272
  const pixel = tileState.rgb565[source] | (tileState.rgb565[source + 1] << 8);
237
- pixels[target++] = ((pixel >> 11) & 0x1f) * 255 / 31;
238
- pixels[target++] = ((pixel >> 5) & 0x3f) * 255 / 63;
239
- pixels[target++] = (pixel & 0x1f) * 255 / 31;
273
+ pixels[target++] = rgb565Red[pixel];
274
+ pixels[target++] = rgb565Green[pixel];
275
+ pixels[target++] = rgb565Blue[pixel];
240
276
  }
241
277
  }
278
+ }
242
279
  tileState.rendered = {
243
280
  pixels,
244
281
  width,
@@ -261,6 +298,10 @@ function ingestFrame(message) {
261
298
  const rgb565 = decodeLzo1xBlock(new Uint8Array(compressed), expectedLength);
262
299
  const tileState = { width, height, rgb565, rendered: null, frameSeq: Number(message.frameSeq || 0) };
263
300
  latestTiles.set(deviceId, tileState);
301
+ if (config.deviceIds.length > 0 && config.deviceIds.every(id => latestTiles.has(id))) {
302
+ clearTimeout(inputWaitTimer);
303
+ inputWaitTimer = null;
304
+ }
264
305
  const tileLayout = layout.find(item => item.deviceId === deviceId);
265
306
  if (tileLayout) renderTile(tileState, tileLayout);
266
307
  } catch (error) {
@@ -269,9 +310,14 @@ function ingestFrame(message) {
269
310
  }
270
311
 
271
312
  function writeAtlasFrame() {
313
+ if (latestTiles.size === 0) return;
314
+ const composeStartedAt = performance.now();
272
315
  const target = framePool.pop();
273
316
  const stdin = encoder?.child?.stdin;
274
- if (!target || !stdin || stdin.destroyed || !stdin.writable) return;
317
+ if (!target || !stdin || stdin.destroyed || !stdin.writable) {
318
+ if (!target) poolStarved += 1;
319
+ return;
320
+ }
275
321
  target.fill(0);
276
322
  for (const tileLayout of layout) {
277
323
  const tileState = latestTiles.get(tileLayout.deviceId);
@@ -284,6 +330,7 @@ function writeAtlasFrame() {
284
330
  rendered.pixels.copy(target, targetStart, sourceStart, sourceStart + rendered.width * 3);
285
331
  }
286
332
  }
333
+ lastComposeMs = Math.max(0, performance.now() - composeStartedAt);
287
334
  try {
288
335
  stdin.write(target, error => {
289
336
  framePool.push(target);
@@ -295,11 +342,37 @@ function writeAtlasFrame() {
295
342
  }
296
343
  }
297
344
 
345
+ function startComposeClock() {
346
+ clearTimeout(tickTimer);
347
+ const intervalMs = 1000 / config.fps;
348
+ nextTickAt = performance.now() + intervalMs;
349
+ const tick = () => {
350
+ if (closed) return;
351
+ writeAtlasFrame();
352
+ const now = performance.now();
353
+ nextTickAt += intervalMs;
354
+ if (nextTickAt < now - intervalMs) nextTickAt = now + intervalMs;
355
+ tickTimer = setTimeout(tick, Math.max(0, nextTickAt - performance.now()));
356
+ };
357
+ tickTimer = setTimeout(tick, intervalMs);
358
+ }
359
+
298
360
  function configure(value) {
299
361
  const next = normalizeConfig(value);
300
362
  const encoderChanged = next.width !== config.width || next.height !== config.height || next.fps !== config.fps;
301
363
  config = next;
302
364
  latestTiles = new Map([...latestTiles].filter(([deviceId]) => config.deviceIds.includes(deviceId)));
365
+ clearTimeout(inputWaitTimer);
366
+ inputWaitTimer = config.deviceIds.length > 0 ? setTimeout(() => {
367
+ const missing = config.deviceIds.filter(deviceId => !latestTiles.has(deviceId));
368
+ if (missing.length > 0) {
369
+ send({
370
+ type: 'log',
371
+ level: 'warn',
372
+ message: `Mode 4 waiting for Mode 2 input from ${missing.length}/${config.deviceIds.length} devices: ${missing.slice(0, 8).join(', ')}`
373
+ });
374
+ }
375
+ }, 3000) : null;
303
376
  rebuildLayout();
304
377
  if (encoderChanged || !encoder) {
305
378
  stopEncoder();
@@ -307,8 +380,7 @@ function configure(value) {
307
380
  encoderCandidateIndex = 0;
308
381
  startEncoder();
309
382
  }
310
- clearInterval(tickTimer);
311
- tickTimer = setInterval(writeAtlasFrame, Math.max(1, Math.round(1000 / config.fps)));
383
+ startComposeClock();
312
384
  }
313
385
 
314
386
  process.on('message', message => {
@@ -320,7 +392,8 @@ process.on('message', message => {
320
392
  function shutdown() {
321
393
  if (closed) return;
322
394
  closed = true;
323
- clearInterval(tickTimer);
395
+ clearTimeout(tickTimer);
396
+ clearTimeout(inputWaitTimer);
324
397
  stopEncoder();
325
398
  setTimeout(() => process.exit(0), 50).unref?.();
326
399
  }
@@ -14,6 +14,8 @@ export class Mode4AtlasSession {
14
14
  this.closed = false;
15
15
  this.worker = null;
16
16
  this.restartTimer = null;
17
+ this.pendingFrames = new Map();
18
+ this.frameSendsInFlight = new Set();
17
19
  this.startWorker();
18
20
  }
19
21
 
@@ -37,6 +39,7 @@ export class Mode4AtlasSession {
37
39
  });
38
40
  worker.on('exit', code => {
39
41
  if (this.worker === worker) this.worker = null;
42
+ this.frameSendsInFlight.clear();
40
43
  if (this.closed) return;
41
44
  this.onStatus({ type: 'status', state: 'worker-restart', code });
42
45
  clearTimeout(this.restartTimer);
@@ -46,13 +49,18 @@ export class Mode4AtlasSession {
46
49
  }, 750);
47
50
  });
48
51
  worker.on('error', error => this.onStatus({ type: 'status', state: 'worker-error', error: error.message }));
49
- if (this.config) worker.send({ type: 'configure', ...this.config });
52
+ if (this.config) {
53
+ worker.send({ type: 'configure', ...this.config }, () => {
54
+ for (const deviceId of this.pendingFrames.keys()) this.pumpDeviceFrame(deviceId);
55
+ });
56
+ }
50
57
  }
51
58
 
52
59
  configure(options = {}) {
53
60
  this.deviceIds = [...new Set((Array.isArray(options.deviceIds) ? options.deviceIds : [])
54
61
  .map(value => String(value || '').trim()).filter(Boolean))].slice(0, 100);
55
62
  this.deviceSet = new Set(this.deviceIds);
63
+ this.pendingFrames = new Map([...this.pendingFrames].filter(([deviceId]) => this.deviceSet.has(deviceId)));
56
64
  this.config = {
57
65
  deviceIds: this.deviceIds,
58
66
  width: Number(options.width || 1920),
@@ -62,13 +70,33 @@ export class Mode4AtlasSession {
62
70
  this.worker?.send({ type: 'configure', ...this.config });
63
71
  }
64
72
 
73
+ pumpDeviceFrame(deviceId) {
74
+ if (this.closed || !this.worker || this.frameSendsInFlight.has(deviceId)) return;
75
+ const message = this.pendingFrames.get(deviceId);
76
+ if (!message) return;
77
+ this.pendingFrames.delete(deviceId);
78
+ this.frameSendsInFlight.add(deviceId);
79
+ try {
80
+ this.worker.send(message, error => {
81
+ this.frameSendsInFlight.delete(deviceId);
82
+ if (error && !this.closed) {
83
+ this.onStatus({ type: 'log', level: 'warn', message: `Mode 4 input ${deviceId}: ${error.message}` });
84
+ }
85
+ this.pumpDeviceFrame(deviceId);
86
+ });
87
+ } catch (error) {
88
+ this.frameSendsInFlight.delete(deviceId);
89
+ this.onStatus({ type: 'log', level: 'warn', message: `Mode 4 input ${deviceId}: ${error.message}` });
90
+ }
91
+ }
92
+
65
93
  ingest(frameEvent) {
66
94
  if (this.closed || !this.worker) return;
67
95
  const frame = frameEvent?.frame || {};
68
96
  const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
69
97
  const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
70
98
  if (!this.deviceSet.has(deviceId) || mode !== 'mode2-lzo' || !Buffer.isBuffer(frameEvent?.payload)) return;
71
- this.worker.send({
99
+ this.pendingFrames.set(deviceId, {
72
100
  type: 'frame',
73
101
  deviceId,
74
102
  frameSeq: Number(frame.frameSeq || 0),
@@ -77,12 +105,15 @@ export class Mode4AtlasSession {
77
105
  uncompressedByteLength: Number(frame.uncompressedByteLength || 0),
78
106
  payload: frameEvent.payload
79
107
  });
108
+ this.pumpDeviceFrame(deviceId);
80
109
  }
81
110
 
82
111
  close() {
83
112
  if (this.closed) return;
84
113
  this.closed = true;
85
114
  clearTimeout(this.restartTimer);
115
+ this.pendingFrames.clear();
116
+ this.frameSendsInFlight.clear();
86
117
  const worker = this.worker;
87
118
  this.worker = null;
88
119
  try { worker?.send({ type: 'close' }); } catch {}
@@ -4588,6 +4588,7 @@ export function createRemoteHub(options = {}) {
4588
4588
  if (device.activeLiveStream?.streamId === streamId
4589
4589
  && options.forceRestart !== true
4590
4590
  && options.reuseExisting === true
4591
+ && liveStreamMatchesOptions(device.activeLiveStream, normalized)
4591
4592
  && liveStreamIsReusable(device.activeLiveStream)) {
4592
4593
  return {
4593
4594
  ok: true,
package/hub/src/server.js CHANGED
@@ -773,7 +773,11 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
773
773
  convertMs: Number(frame.convertMs || 0) || 0,
774
774
  compressMs: Number(frame.compressMs || 0) || 0,
775
775
  sameContentStreak: Number(frame.sameContentStreak || 0) || 0,
776
- layoutVersion: Number(frame.layoutVersion || 0) || 0
776
+ layoutVersion: Number(frame.layoutVersion || 0) || 0,
777
+ atlasComposeMs: Number(frame.atlasComposeMs || 0) || 0,
778
+ atlasPoolStarved: Number(frame.atlasPoolStarved || 0) || 0,
779
+ atlasReadyTiles: Number(frame.atlasReadyTiles || 0) || 0,
780
+ atlasTileCount: Number(frame.atlasTileCount || 0) || 0
777
781
  };
778
782
  const metaBuffer = Buffer.from(JSON.stringify(metadata), 'utf8');
779
783
  const header = Buffer.allocUnsafe(4);
@@ -913,7 +917,11 @@ function sendMode4AtlasFrame(ws, output) {
913
917
  receivedAt: new Date().toISOString(),
914
918
  hardwareEncoder: String(metadata.hardwareEncoder || ''),
915
919
  platformProfile: 'mode4-hub-atlas-worker',
916
- layoutVersion: Number(metadata.layoutVersion || 0)
920
+ layoutVersion: Number(metadata.layoutVersion || 0),
921
+ atlasComposeMs: Number(metadata.composeMs || 0),
922
+ atlasPoolStarved: Number(metadata.poolStarved || 0),
923
+ atlasReadyTiles: Number(metadata.readyTileCount || 0),
924
+ atlasTileCount: Number(metadata.tileCount || 0)
917
925
  }
918
926
  };
919
927
  const lane = ensureFrameClientSendLane(ws);
@@ -952,7 +960,7 @@ function configureMode4Atlas(ws, payload = {}) {
952
960
  mode: 'mode2-lzo',
953
961
  frameMode: 'mode2-lzo',
954
962
  monitorIndex: Number(monitorSelections[deviceId] || 0),
955
- reuseExisting: true,
963
+ reuseExisting: false,
956
964
  silentReuse: true
957
965
  });
958
966
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.151",
3
+ "version": "0.1.153",
4
4
  "description": "LiveDesk Hub and client launcher",
5
5
  "type": "module",
6
6
  "bin": {