@livedesk/hub 0.1.0 → 0.1.1
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 +7 -1
- package/package.json +5 -2
- package/src/filesystem/directory-reader.js +187 -0
- package/src/filesystem/hub-filesystem.js +78 -0
- package/src/filesystem/path-registry.js +78 -0
- package/src/filesystem/roots.js +115 -0
- package/src/filesystem/shared-folders.js +180 -0
- package/src/filesystem/transfer-jobs.js +229 -0
- package/src/lzo1x.js +109 -0
- package/src/mode4-atlas-worker.js +439 -0
- package/src/mode4-atlas.js +129 -0
- package/src/remote-hub.js +1295 -163
- package/src/server.js +1150 -69
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { decodeLzo1xBlock } from './lzo1x.js';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const DEFAULT_WIDTH = 1920;
|
|
10
|
+
const DEFAULT_HEIGHT = 1080;
|
|
11
|
+
const DEFAULT_FPS = 20;
|
|
12
|
+
const MAX_DEVICES = 100;
|
|
13
|
+
const FRAME_POOL_SIZE = 8;
|
|
14
|
+
const MAX_TILE_WIDTH = 480;
|
|
15
|
+
const MAX_TILE_HEIGHT = 270;
|
|
16
|
+
|
|
17
|
+
const rgb565Red = new Uint8Array(65_536);
|
|
18
|
+
const rgb565Green = new Uint8Array(65_536);
|
|
19
|
+
const rgb565Blue = new Uint8Array(65_536);
|
|
20
|
+
for (let pixel = 0; pixel < 65_536; pixel += 1) {
|
|
21
|
+
rgb565Red[pixel] = (((pixel >> 11) & 0x1f) * 255 / 31) | 0;
|
|
22
|
+
rgb565Green[pixel] = (((pixel >> 5) & 0x3f) * 255 / 63) | 0;
|
|
23
|
+
rgb565Blue[pixel] = ((pixel & 0x1f) * 255 / 31) | 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let config = normalizeConfig({});
|
|
27
|
+
let layoutVersion = 0;
|
|
28
|
+
let layout = [];
|
|
29
|
+
let latestTiles = new Map();
|
|
30
|
+
let framePool = [];
|
|
31
|
+
let encoder = null;
|
|
32
|
+
let encoderCandidateIndex = 0;
|
|
33
|
+
let encoderCandidates = [];
|
|
34
|
+
let encoderOutputSeen = false;
|
|
35
|
+
let encoderGeneration = 0;
|
|
36
|
+
let encoderRestartTimer = null;
|
|
37
|
+
let encoderStartupTimer = null;
|
|
38
|
+
let inputWaitTimer = null;
|
|
39
|
+
let outputBuffer = Buffer.alloc(0);
|
|
40
|
+
let frameSeq = 0;
|
|
41
|
+
let tickTimer = null;
|
|
42
|
+
let nextTickAt = 0;
|
|
43
|
+
let closed = false;
|
|
44
|
+
let lastComposeMs = 0;
|
|
45
|
+
let poolStarved = 0;
|
|
46
|
+
const ENCODER_STARTUP_OUTPUT_TIMEOUT_MS = 8_000;
|
|
47
|
+
|
|
48
|
+
function clamp(value, min, max, fallback) {
|
|
49
|
+
const number = Number(value);
|
|
50
|
+
return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.round(number))) : fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeConfig(value) {
|
|
54
|
+
const deviceIds = [...new Set((Array.isArray(value?.deviceIds) ? value.deviceIds : [])
|
|
55
|
+
.map(item => String(item || '').trim()).filter(Boolean))].slice(0, MAX_DEVICES);
|
|
56
|
+
const maxWidth = clamp(value?.width, 640, 3840, DEFAULT_WIDTH);
|
|
57
|
+
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
|
+
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 tileWidth = Math.max(2, Math.min(sourceTileWidth, Math.floor(maxWidth / columns))) & ~1;
|
|
65
|
+
const tileHeight = Math.max(2, Math.min(sourceTileHeight, Math.floor(maxHeight / rows))) & ~1;
|
|
66
|
+
return {
|
|
67
|
+
deviceIds,
|
|
68
|
+
maxWidth,
|
|
69
|
+
maxHeight,
|
|
70
|
+
width: tileWidth * columns,
|
|
71
|
+
height: tileHeight * rows,
|
|
72
|
+
columns,
|
|
73
|
+
rows,
|
|
74
|
+
tileWidth,
|
|
75
|
+
tileHeight,
|
|
76
|
+
sourceTileWidth,
|
|
77
|
+
sourceTileHeight,
|
|
78
|
+
fps: clamp(value?.fps, 1, 30, DEFAULT_FPS)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function send(message) {
|
|
83
|
+
if (process.connected) process.send?.(message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveFfmpegPaths() {
|
|
87
|
+
const paths = [];
|
|
88
|
+
const add = value => {
|
|
89
|
+
const path = String(value || '').trim();
|
|
90
|
+
if (!path || paths.includes(path)) return;
|
|
91
|
+
if (path === 'ffmpeg' || existsSync(path)) paths.push(path);
|
|
92
|
+
};
|
|
93
|
+
add(process.env.LIVEDESK_FFMPEG);
|
|
94
|
+
try { add(require('@ffmpeg-installer/ffmpeg')?.path); } catch {}
|
|
95
|
+
try { add(require('ffmpeg-static')); } catch {}
|
|
96
|
+
add('ffmpeg');
|
|
97
|
+
return paths;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildEncoderCandidates() {
|
|
101
|
+
const codecCandidates = process.platform === 'win32'
|
|
102
|
+
? [
|
|
103
|
+
['windows-nvenc', ['-c:v', 'h264_nvenc', '-preset', 'llhp', '-profile:v', 'baseline', '-rc:v', 'cbr_ld_hq', '-b:v', '6M', '-maxrate', '6M', '-bufsize', '1M', '-rc-lookahead', '0', '-zerolatency', '1', '-g', String(config.fps), '-bf', '0']],
|
|
104
|
+
['windows-qsv', ['-c:v', 'h264_qsv', '-preset', 'veryfast', '-global_quality', '28', '-look_ahead', '0', '-g', String(config.fps), '-bf', '0']],
|
|
105
|
+
['windows-amf', ['-c:v', 'h264_amf', '-quality', 'speed', '-usage', 'lowlatency', '-rc', 'cqp', '-qp_i', '25', '-qp_p', '28', '-g', String(config.fps), '-bf', '0']]
|
|
106
|
+
]
|
|
107
|
+
: process.platform === 'darwin'
|
|
108
|
+
? [['macos-videotoolbox', ['-c:v', 'h264_videotoolbox', '-realtime', '1', '-allow_sw', '0', '-b:v', '6M', '-g', String(config.fps), '-bf', '0']]]
|
|
109
|
+
: [['linux-nvenc', ['-c:v', 'h264_nvenc', '-preset', 'llhp', '-profile:v', 'baseline', '-rc-lookahead', '0', '-zerolatency', '1', '-g', String(config.fps), '-bf', '0']]];
|
|
110
|
+
codecCandidates.push(['software-x264', [
|
|
111
|
+
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
|
112
|
+
'-profile:v', 'baseline', '-crf', '28', '-g', String(config.fps),
|
|
113
|
+
'-keyint_min', String(config.fps), '-sc_threshold', '0', '-bf', '0',
|
|
114
|
+
'-x264-params', 'aud=1:repeat-headers=1'
|
|
115
|
+
]]);
|
|
116
|
+
return resolveFfmpegPaths().flatMap(path => codecCandidates.map(([name, args]) => ({ path, name, args })));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function startEncoder() {
|
|
120
|
+
if (closed || encoder || encoderCandidates.length === 0) return;
|
|
121
|
+
if (encoderCandidateIndex >= encoderCandidates.length) {
|
|
122
|
+
send({ type: 'status', state: 'error', error: 'No Mode 4 H.264 encoder is available.' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const candidate = encoderCandidates[encoderCandidateIndex++];
|
|
126
|
+
encoderGeneration += 1;
|
|
127
|
+
const args = [
|
|
128
|
+
'-hide_banner', '-loglevel', 'warning',
|
|
129
|
+
'-f', 'rawvideo', '-pixel_format', 'rgb24',
|
|
130
|
+
'-video_size', `${config.width}x${config.height}`,
|
|
131
|
+
'-framerate', String(config.fps), '-i', 'pipe:0', '-an',
|
|
132
|
+
...candidate.args,
|
|
133
|
+
'-bsf:v', 'h264_metadata=aud=insert', '-f', 'h264', 'pipe:1'
|
|
134
|
+
];
|
|
135
|
+
const child = spawn(candidate.path, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
136
|
+
encoder = { child, candidate, inputSeen: false };
|
|
137
|
+
encoderOutputSeen = false;
|
|
138
|
+
outputBuffer = Buffer.alloc(0);
|
|
139
|
+
child.stdout.on('data', appendEncodedBytes);
|
|
140
|
+
child.stdin.on('error', error => {
|
|
141
|
+
if (encoder?.child === child && !isClosedPipeError(error)) {
|
|
142
|
+
send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name} input: ${error.message}` });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
child.stderr.on('data', chunk => {
|
|
146
|
+
const message = String(chunk || '').trim();
|
|
147
|
+
if (message) send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: ${message.slice(0, 600)}` });
|
|
148
|
+
});
|
|
149
|
+
child.on('error', error => send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: ${error.message}` }));
|
|
150
|
+
child.on('exit', code => {
|
|
151
|
+
clearTimeout(encoderStartupTimer);
|
|
152
|
+
encoderStartupTimer = null;
|
|
153
|
+
const wasCurrent = encoder?.child === child;
|
|
154
|
+
if (wasCurrent) encoder = null;
|
|
155
|
+
flushEncodedUnit();
|
|
156
|
+
if (closed) return;
|
|
157
|
+
send({ type: 'status', state: 'encoder-restart', encoder: candidate.name, code, emitted: encoderOutputSeen });
|
|
158
|
+
clearTimeout(encoderRestartTimer);
|
|
159
|
+
encoderRestartTimer = setTimeout(startEncoder, encoderOutputSeen ? 800 : 120);
|
|
160
|
+
});
|
|
161
|
+
send({ type: 'status', state: 'encoder-starting', encoder: candidate.name });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isClosedPipeError(error) {
|
|
165
|
+
const code = String(error?.code || '').toUpperCase();
|
|
166
|
+
const message = String(error?.message || '').toLowerCase();
|
|
167
|
+
return code === 'EPIPE' || code === 'EOF' || code === 'ERR_STREAM_DESTROYED'
|
|
168
|
+
|| message.includes('write eof') || message.includes('broken pipe');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function armEncoderStartupTimer(child, candidate) {
|
|
172
|
+
clearTimeout(encoderStartupTimer);
|
|
173
|
+
encoderStartupTimer = setTimeout(() => {
|
|
174
|
+
if (encoder?.child === child && encoder.inputSeen && !encoderOutputSeen) {
|
|
175
|
+
send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: no output after receiving input; trying the next encoder.` });
|
|
176
|
+
try { child.kill(); } catch {}
|
|
177
|
+
}
|
|
178
|
+
}, ENCODER_STARTUP_OUTPUT_TIMEOUT_MS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function stopEncoder() {
|
|
182
|
+
clearTimeout(encoderRestartTimer);
|
|
183
|
+
encoderRestartTimer = null;
|
|
184
|
+
clearTimeout(encoderStartupTimer);
|
|
185
|
+
encoderStartupTimer = null;
|
|
186
|
+
const child = encoder?.child;
|
|
187
|
+
encoder = null;
|
|
188
|
+
if (!child) return;
|
|
189
|
+
try { child.stdin.end(); } catch {}
|
|
190
|
+
setTimeout(() => { try { child.kill(); } catch {} }, 250).unref?.();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function findStartCodes(buffer) {
|
|
194
|
+
const starts = [];
|
|
195
|
+
for (let index = 0; index + 3 < buffer.length; index += 1) {
|
|
196
|
+
if (buffer[index] !== 0 || buffer[index + 1] !== 0) continue;
|
|
197
|
+
if (buffer[index + 2] === 1) {
|
|
198
|
+
starts.push({ offset: index, header: index + 3 });
|
|
199
|
+
index += 2;
|
|
200
|
+
} else if (buffer[index + 2] === 0 && buffer[index + 3] === 1) {
|
|
201
|
+
starts.push({ offset: index, header: index + 4 });
|
|
202
|
+
index += 3;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return starts;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function appendEncodedBytes(chunk) {
|
|
209
|
+
outputBuffer = outputBuffer.length ? Buffer.concat([outputBuffer, chunk]) : Buffer.from(chunk);
|
|
210
|
+
while (true) {
|
|
211
|
+
const auds = findStartCodes(outputBuffer).filter(start => (outputBuffer[start.header] & 0x1f) === 9);
|
|
212
|
+
if (auds.length < 2) break;
|
|
213
|
+
const boundary = auds[1].offset;
|
|
214
|
+
emitEncodedUnit(outputBuffer.subarray(0, boundary));
|
|
215
|
+
outputBuffer = outputBuffer.subarray(boundary);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function flushEncodedUnit() {
|
|
220
|
+
if (outputBuffer.length > 0) emitEncodedUnit(outputBuffer);
|
|
221
|
+
outputBuffer = Buffer.alloc(0);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function emitEncodedUnit(payload) {
|
|
225
|
+
if (!payload?.length) return;
|
|
226
|
+
const starts = findStartCodes(payload);
|
|
227
|
+
const isKeyFrame = starts.some(start => (payload[start.header] & 0x1f) === 5);
|
|
228
|
+
encoderOutputSeen = true;
|
|
229
|
+
clearTimeout(encoderStartupTimer);
|
|
230
|
+
encoderStartupTimer = null;
|
|
231
|
+
frameSeq += 1;
|
|
232
|
+
send({
|
|
233
|
+
type: 'frame',
|
|
234
|
+
payload: Buffer.from(payload),
|
|
235
|
+
metadata: {
|
|
236
|
+
frameSeq,
|
|
237
|
+
streamFrameSeq: frameSeq,
|
|
238
|
+
width: config.width,
|
|
239
|
+
height: config.height,
|
|
240
|
+
fps: config.fps,
|
|
241
|
+
durationUs: Math.round(1_000_000 / config.fps),
|
|
242
|
+
timestampUs: Math.round(frameSeq * 1_000_000 / config.fps),
|
|
243
|
+
isKeyFrame,
|
|
244
|
+
chunkType: isKeyFrame ? 'key' : 'delta',
|
|
245
|
+
hardwareEncoder: encoder?.candidate?.name || '',
|
|
246
|
+
encoderGeneration,
|
|
247
|
+
layoutVersion,
|
|
248
|
+
composeMs: lastComposeMs,
|
|
249
|
+
poolStarved,
|
|
250
|
+
readyTileCount: latestTiles.size,
|
|
251
|
+
tileCount: config.deviceIds.length
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function rebuildLayout() {
|
|
257
|
+
const { columns, rows, tileWidth, tileHeight } = config;
|
|
258
|
+
layoutVersion += 1;
|
|
259
|
+
layout = config.deviceIds.map((deviceId, index) => ({
|
|
260
|
+
deviceId,
|
|
261
|
+
index,
|
|
262
|
+
column: index % columns,
|
|
263
|
+
row: Math.floor(index / columns),
|
|
264
|
+
x: (index % columns) * tileWidth,
|
|
265
|
+
y: Math.floor(index / columns) * tileHeight,
|
|
266
|
+
width: tileWidth,
|
|
267
|
+
height: tileHeight
|
|
268
|
+
}));
|
|
269
|
+
for (const tile of latestTiles.values()) tile.rendered = null;
|
|
270
|
+
framePool = Array.from({ length: FRAME_POOL_SIZE }, () => Buffer.alloc(config.width * config.height * 3));
|
|
271
|
+
send({ type: 'layout', layoutVersion, width: config.width, height: config.height, columns, rows, tiles: layout });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderTile(tileState, tileLayout) {
|
|
275
|
+
const sourceWidth = tileState.width;
|
|
276
|
+
const sourceHeight = tileState.height;
|
|
277
|
+
const scale = Math.min(tileLayout.width / sourceWidth, tileLayout.height / sourceHeight);
|
|
278
|
+
const width = Math.max(1, Math.floor(sourceWidth * scale));
|
|
279
|
+
const height = Math.max(1, Math.floor(sourceHeight * scale));
|
|
280
|
+
const pixels = Buffer.allocUnsafe(width * height * 3);
|
|
281
|
+
let target = 0;
|
|
282
|
+
if (width === sourceWidth && height === sourceHeight) {
|
|
283
|
+
for (let source = 0; source < tileState.rgb565.length; source += 2) {
|
|
284
|
+
const pixel = tileState.rgb565[source] | (tileState.rgb565[source + 1] << 8);
|
|
285
|
+
pixels[target++] = rgb565Red[pixel];
|
|
286
|
+
pixels[target++] = rgb565Green[pixel];
|
|
287
|
+
pixels[target++] = rgb565Blue[pixel];
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
for (let y = 0; y < height; y += 1) {
|
|
291
|
+
const sourceY = Math.min(sourceHeight - 1, Math.floor((y + 0.5) * sourceHeight / height));
|
|
292
|
+
for (let x = 0; x < width; x += 1) {
|
|
293
|
+
const sourceX = Math.min(sourceWidth - 1, Math.floor((x + 0.5) * sourceWidth / width));
|
|
294
|
+
const source = (sourceY * sourceWidth + sourceX) * 2;
|
|
295
|
+
const pixel = tileState.rgb565[source] | (tileState.rgb565[source + 1] << 8);
|
|
296
|
+
pixels[target++] = rgb565Red[pixel];
|
|
297
|
+
pixels[target++] = rgb565Green[pixel];
|
|
298
|
+
pixels[target++] = rgb565Blue[pixel];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
tileState.rendered = {
|
|
303
|
+
pixels,
|
|
304
|
+
width,
|
|
305
|
+
height,
|
|
306
|
+
x: tileLayout.x + Math.floor((tileLayout.width - width) / 2),
|
|
307
|
+
y: tileLayout.y + Math.floor((tileLayout.height - height) / 2),
|
|
308
|
+
layoutVersion
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function ingestFrame(message) {
|
|
313
|
+
const deviceId = String(message.deviceId || '');
|
|
314
|
+
if (!deviceId || !config.deviceIds.includes(deviceId)) return;
|
|
315
|
+
const width = clamp(message.width, 1, config.sourceTileWidth, 0);
|
|
316
|
+
const height = clamp(message.height, 1, config.sourceTileHeight, 0);
|
|
317
|
+
const expectedLength = width * height * 2;
|
|
318
|
+
if (!width || !height || Number(message.uncompressedByteLength) !== expectedLength) return;
|
|
319
|
+
try {
|
|
320
|
+
const compressed = Buffer.isBuffer(message.payload) ? message.payload : Buffer.from(message.payload || []);
|
|
321
|
+
const rgb565 = decodeLzo1xBlock(new Uint8Array(compressed), expectedLength);
|
|
322
|
+
const tileState = { width, height, rgb565, rendered: null, frameSeq: Number(message.frameSeq || 0) };
|
|
323
|
+
latestTiles.set(deviceId, tileState);
|
|
324
|
+
if (!encoder && encoderCandidates.length > 0) startEncoder();
|
|
325
|
+
if (config.deviceIds.length > 0 && config.deviceIds.every(id => latestTiles.has(id))) {
|
|
326
|
+
clearTimeout(inputWaitTimer);
|
|
327
|
+
inputWaitTimer = null;
|
|
328
|
+
}
|
|
329
|
+
const tileLayout = layout.find(item => item.deviceId === deviceId);
|
|
330
|
+
if (tileLayout) renderTile(tileState, tileLayout);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
send({ type: 'log', level: 'warn', message: `Mode 4 frame rejected for ${deviceId}: ${error.message}` });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function writeAtlasFrame() {
|
|
337
|
+
if (latestTiles.size === 0) return;
|
|
338
|
+
if (!encoder) {
|
|
339
|
+
startEncoder();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const composeStartedAt = performance.now();
|
|
343
|
+
const target = framePool.pop();
|
|
344
|
+
const stdin = encoder?.child?.stdin;
|
|
345
|
+
if (!target || !stdin || stdin.destroyed || !stdin.writable) {
|
|
346
|
+
if (!target) poolStarved += 1;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
target.fill(0);
|
|
350
|
+
for (const tileLayout of layout) {
|
|
351
|
+
const tileState = latestTiles.get(tileLayout.deviceId);
|
|
352
|
+
if (!tileState) continue;
|
|
353
|
+
if (!tileState.rendered || tileState.rendered.layoutVersion !== layoutVersion) renderTile(tileState, tileLayout);
|
|
354
|
+
const rendered = tileState.rendered;
|
|
355
|
+
for (let row = 0; row < rendered.height; row += 1) {
|
|
356
|
+
const sourceStart = row * rendered.width * 3;
|
|
357
|
+
const targetStart = ((rendered.y + row) * config.width + rendered.x) * 3;
|
|
358
|
+
rendered.pixels.copy(target, targetStart, sourceStart, sourceStart + rendered.width * 3);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
lastComposeMs = Math.max(0, performance.now() - composeStartedAt);
|
|
362
|
+
try {
|
|
363
|
+
const encoderEntry = encoder;
|
|
364
|
+
if (!encoderEntry.inputSeen) {
|
|
365
|
+
encoderEntry.inputSeen = true;
|
|
366
|
+
armEncoderStartupTimer(encoderEntry.child, encoderEntry.candidate);
|
|
367
|
+
}
|
|
368
|
+
stdin.write(target, error => {
|
|
369
|
+
framePool.push(target);
|
|
370
|
+
if (error && encoder?.child === encoderEntry.child && !isClosedPipeError(error)) {
|
|
371
|
+
send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
} catch (error) {
|
|
375
|
+
framePool.push(target);
|
|
376
|
+
if (!isClosedPipeError(error)) send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function startComposeClock() {
|
|
381
|
+
clearTimeout(tickTimer);
|
|
382
|
+
const intervalMs = 1000 / config.fps;
|
|
383
|
+
nextTickAt = performance.now() + intervalMs;
|
|
384
|
+
const tick = () => {
|
|
385
|
+
if (closed) return;
|
|
386
|
+
writeAtlasFrame();
|
|
387
|
+
const now = performance.now();
|
|
388
|
+
nextTickAt += intervalMs;
|
|
389
|
+
if (nextTickAt < now - intervalMs) nextTickAt = now + intervalMs;
|
|
390
|
+
tickTimer = setTimeout(tick, Math.max(0, nextTickAt - performance.now()));
|
|
391
|
+
};
|
|
392
|
+
tickTimer = setTimeout(tick, intervalMs);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function configure(value) {
|
|
396
|
+
const next = normalizeConfig(value);
|
|
397
|
+
const encoderChanged = next.width !== config.width || next.height !== config.height || next.fps !== config.fps;
|
|
398
|
+
config = next;
|
|
399
|
+
latestTiles = new Map([...latestTiles].filter(([deviceId]) => config.deviceIds.includes(deviceId)));
|
|
400
|
+
clearTimeout(inputWaitTimer);
|
|
401
|
+
inputWaitTimer = config.deviceIds.length > 0 ? setTimeout(() => {
|
|
402
|
+
const missing = config.deviceIds.filter(deviceId => !latestTiles.has(deviceId));
|
|
403
|
+
if (missing.length > 0) {
|
|
404
|
+
send({
|
|
405
|
+
type: 'log',
|
|
406
|
+
level: 'warn',
|
|
407
|
+
message: `Mode 4 waiting for Mode 2 input from ${missing.length}/${config.deviceIds.length} devices: ${missing.slice(0, 8).join(', ')}`
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}, 3000) : null;
|
|
411
|
+
rebuildLayout();
|
|
412
|
+
if (encoderChanged || !encoder) {
|
|
413
|
+
stopEncoder();
|
|
414
|
+
encoderCandidates = buildEncoderCandidates();
|
|
415
|
+
encoderCandidateIndex = 0;
|
|
416
|
+
if (latestTiles.size > 0) startEncoder();
|
|
417
|
+
}
|
|
418
|
+
startComposeClock();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
process.on('message', message => {
|
|
422
|
+
if (message?.type === 'configure') configure(message);
|
|
423
|
+
else if (message?.type === 'frame') ingestFrame(message);
|
|
424
|
+
else if (message?.type === 'close') shutdown();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
function shutdown() {
|
|
428
|
+
if (closed) return;
|
|
429
|
+
closed = true;
|
|
430
|
+
clearTimeout(tickTimer);
|
|
431
|
+
clearTimeout(inputWaitTimer);
|
|
432
|
+
stopEncoder();
|
|
433
|
+
setTimeout(() => process.exit(0), 50).unref?.();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
process.on('disconnect', shutdown);
|
|
437
|
+
process.on('SIGTERM', shutdown);
|
|
438
|
+
process.on('SIGINT', shutdown);
|
|
439
|
+
send({ type: 'status', state: 'ready' });
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const workerPath = fileURLToPath(new URL('./mode4-atlas-worker.js', import.meta.url));
|
|
5
|
+
|
|
6
|
+
export class Mode4AtlasSession {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.onFrame = typeof options.onFrame === 'function' ? options.onFrame : () => {};
|
|
9
|
+
this.onLayout = typeof options.onLayout === 'function' ? options.onLayout : () => {};
|
|
10
|
+
this.onStatus = typeof options.onStatus === 'function' ? options.onStatus : () => {};
|
|
11
|
+
this.deviceIds = [];
|
|
12
|
+
this.deviceSet = new Set();
|
|
13
|
+
this.config = null;
|
|
14
|
+
this.closed = false;
|
|
15
|
+
this.worker = null;
|
|
16
|
+
this.restartTimer = null;
|
|
17
|
+
this.pendingFrames = new Map();
|
|
18
|
+
this.frameSendsInFlight = new Set();
|
|
19
|
+
this.workerGeneration = 0;
|
|
20
|
+
this.startWorker();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
startWorker() {
|
|
24
|
+
if (this.closed || this.worker) return;
|
|
25
|
+
const workerGeneration = ++this.workerGeneration;
|
|
26
|
+
const worker = fork(workerPath, [], {
|
|
27
|
+
env: { ...process.env },
|
|
28
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
29
|
+
serialization: 'advanced',
|
|
30
|
+
windowsHide: true
|
|
31
|
+
});
|
|
32
|
+
this.worker = worker;
|
|
33
|
+
worker.on('message', message => {
|
|
34
|
+
if (message?.type === 'frame' && message.metadata && message.payload) {
|
|
35
|
+
this.onFrame({
|
|
36
|
+
metadata: { ...message.metadata, workerGeneration },
|
|
37
|
+
payload: Buffer.from(message.payload)
|
|
38
|
+
});
|
|
39
|
+
} else if (message?.type === 'layout') {
|
|
40
|
+
this.onLayout(message);
|
|
41
|
+
} else if (message?.type === 'status' || message?.type === 'log') {
|
|
42
|
+
this.onStatus(message);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
worker.on('exit', code => {
|
|
46
|
+
if (this.worker === worker) this.worker = null;
|
|
47
|
+
this.frameSendsInFlight.clear();
|
|
48
|
+
if (this.closed) return;
|
|
49
|
+
this.onStatus({ type: 'status', state: 'worker-restart', code });
|
|
50
|
+
clearTimeout(this.restartTimer);
|
|
51
|
+
this.restartTimer = setTimeout(() => {
|
|
52
|
+
this.startWorker();
|
|
53
|
+
if (this.config) this.worker?.send({ type: 'configure', ...this.config });
|
|
54
|
+
}, 750);
|
|
55
|
+
});
|
|
56
|
+
worker.on('error', error => this.onStatus({ type: 'status', state: 'worker-error', error: error.message }));
|
|
57
|
+
if (this.config) {
|
|
58
|
+
worker.send({ type: 'configure', ...this.config }, () => {
|
|
59
|
+
for (const deviceId of this.pendingFrames.keys()) this.pumpDeviceFrame(deviceId);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
configure(options = {}) {
|
|
65
|
+
this.deviceIds = [...new Set((Array.isArray(options.deviceIds) ? options.deviceIds : [])
|
|
66
|
+
.map(value => String(value || '').trim()).filter(Boolean))].slice(0, 100);
|
|
67
|
+
this.deviceSet = new Set(this.deviceIds);
|
|
68
|
+
this.pendingFrames = new Map([...this.pendingFrames].filter(([deviceId]) => this.deviceSet.has(deviceId)));
|
|
69
|
+
this.config = {
|
|
70
|
+
deviceIds: this.deviceIds,
|
|
71
|
+
width: Number(options.width || 1920),
|
|
72
|
+
height: Number(options.height || 1080),
|
|
73
|
+
tileWidth: Number(options.tileWidth || 320),
|
|
74
|
+
tileHeight: Number(options.tileHeight || 180),
|
|
75
|
+
fps: Number(options.fps || 20)
|
|
76
|
+
};
|
|
77
|
+
this.worker?.send({ type: 'configure', ...this.config });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pumpDeviceFrame(deviceId) {
|
|
81
|
+
if (this.closed || !this.worker || this.frameSendsInFlight.has(deviceId)) return;
|
|
82
|
+
const message = this.pendingFrames.get(deviceId);
|
|
83
|
+
if (!message) return;
|
|
84
|
+
this.pendingFrames.delete(deviceId);
|
|
85
|
+
this.frameSendsInFlight.add(deviceId);
|
|
86
|
+
try {
|
|
87
|
+
this.worker.send(message, error => {
|
|
88
|
+
this.frameSendsInFlight.delete(deviceId);
|
|
89
|
+
if (error && !this.closed) {
|
|
90
|
+
this.onStatus({ type: 'log', level: 'warn', message: `Mode 4 input ${deviceId}: ${error.message}` });
|
|
91
|
+
}
|
|
92
|
+
this.pumpDeviceFrame(deviceId);
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
this.frameSendsInFlight.delete(deviceId);
|
|
96
|
+
this.onStatus({ type: 'log', level: 'warn', message: `Mode 4 input ${deviceId}: ${error.message}` });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ingest(frameEvent) {
|
|
101
|
+
if (this.closed || !this.worker) return;
|
|
102
|
+
const frame = frameEvent?.frame || {};
|
|
103
|
+
const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
|
|
104
|
+
const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
|
|
105
|
+
if (!this.deviceSet.has(deviceId) || mode !== 'mode2-lzo' || !Buffer.isBuffer(frameEvent?.payload)) return;
|
|
106
|
+
this.pendingFrames.set(deviceId, {
|
|
107
|
+
type: 'frame',
|
|
108
|
+
deviceId,
|
|
109
|
+
frameSeq: Number(frame.frameSeq || 0),
|
|
110
|
+
width: Number(frame.width || 0),
|
|
111
|
+
height: Number(frame.height || 0),
|
|
112
|
+
uncompressedByteLength: Number(frame.uncompressedByteLength || 0),
|
|
113
|
+
payload: frameEvent.payload
|
|
114
|
+
});
|
|
115
|
+
this.pumpDeviceFrame(deviceId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
close() {
|
|
119
|
+
if (this.closed) return;
|
|
120
|
+
this.closed = true;
|
|
121
|
+
clearTimeout(this.restartTimer);
|
|
122
|
+
this.pendingFrames.clear();
|
|
123
|
+
this.frameSendsInFlight.clear();
|
|
124
|
+
const worker = this.worker;
|
|
125
|
+
this.worker = null;
|
|
126
|
+
try { worker?.send({ type: 'close' }); } catch {}
|
|
127
|
+
setTimeout(() => { try { worker?.kill(); } catch {} }, 400).unref?.();
|
|
128
|
+
}
|
|
129
|
+
}
|