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 +1 -1
- package/hub/src/mode4-atlas-worker.js +93 -20
- package/hub/src/mode4-atlas.js +33 -2
- package/hub/src/remote-hub.js +1 -0
- package/hub/src/server.js +11 -3
- package/package.json +1 -1
- package/web/dist/assets/index-Bl4ndc5t.js +10 -0
- package/web/dist/assets/index-Ciy01vvN.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-359HTh4H.css +0 -1
- package/web/dist/assets/index-C5qiAnWV.js +0 -10
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
|
-
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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) *
|
|
214
|
-
y: Math.floor(index / columns) *
|
|
215
|
-
width:
|
|
216
|
-
height:
|
|
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++] =
|
|
238
|
-
pixels[target++] =
|
|
239
|
-
pixels[target++] =
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
395
|
+
clearTimeout(tickTimer);
|
|
396
|
+
clearTimeout(inputWaitTimer);
|
|
324
397
|
stopEncoder();
|
|
325
398
|
setTimeout(() => process.exit(0), 50).unref?.();
|
|
326
399
|
}
|
package/hub/src/mode4-atlas.js
CHANGED
|
@@ -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)
|
|
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.
|
|
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 {}
|
package/hub/src/remote-hub.js
CHANGED
|
@@ -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:
|
|
963
|
+
reuseExisting: false,
|
|
956
964
|
silentReuse: true
|
|
957
965
|
});
|
|
958
966
|
}
|