livedesk 0.1.149 → 0.1.151

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
@@ -24,6 +24,13 @@ The client signs in with Google, discovers the active Hub, and connects to the w
24
24
  On Windows, enable **Start with Windows** on the connection page to reconnect
25
25
  automatically after reboot.
26
26
 
27
+ ## Frame modes
28
+
29
+ - Mode 2: independent 320x180 RGB565+LZO tiles for large, stable walls.
30
+ - Mode 3: direct hardware H.264 for focused remote control up to 4K.
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.
33
+
27
34
  ## Plans
28
35
 
29
36
  - Free: 5 personal devices with a standard wall banner ad.
package/hub/package.json CHANGED
@@ -14,8 +14,10 @@
14
14
  "check": "node --check src/server.js && node --check src/remote-hub.js"
15
15
  },
16
16
  "dependencies": {
17
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
17
18
  "cors": "^2.8.5",
18
19
  "express": "^4.21.2",
20
+ "ffmpeg-static": "^5.3.0",
19
21
  "path-to-regexp": "0.1.13",
20
22
  "ws": "^8.18.3"
21
23
  },
@@ -0,0 +1,109 @@
1
+ function requireInput(input, index, length) {
2
+ if (length < 0 || index < 0 || index + length > input.length) {
3
+ throw new Error('Invalid LZO1X block: input overrun.');
4
+ }
5
+ }
6
+
7
+ function readExtendedLength(input, state, basis) {
8
+ let zeroBytes = 0;
9
+ while (true) {
10
+ requireInput(input, state.inputIndex, 1);
11
+ if (input[state.inputIndex] !== 0) break;
12
+ zeroBytes += 1;
13
+ state.inputIndex += 1;
14
+ }
15
+ const length = zeroBytes * 255 + basis + input[state.inputIndex];
16
+ state.inputIndex += 1;
17
+ return length;
18
+ }
19
+
20
+ function copyLiteral(input, state, output, length) {
21
+ requireInput(input, state.inputIndex, length);
22
+ if (state.outputIndex + length > output.length) {
23
+ throw new Error('Invalid LZO1X block: output overrun.');
24
+ }
25
+ output.set(input.subarray(state.inputIndex, state.inputIndex + length), state.outputIndex);
26
+ state.inputIndex += length;
27
+ state.outputIndex += length;
28
+ }
29
+
30
+ export function decodeLzo1xBlock(input, outputLength) {
31
+ if (!(input instanceof Uint8Array) || input.length < 3 || outputLength < 0) {
32
+ throw new Error('Invalid LZO1X block.');
33
+ }
34
+ const output = new Uint8Array(outputLength);
35
+ const cursor = { inputIndex: 0, outputIndex: 0 };
36
+ let state = 0;
37
+ let matchLength = 0;
38
+ if (input[cursor.inputIndex] >= 22) {
39
+ const length = input[cursor.inputIndex] - 17;
40
+ cursor.inputIndex += 1;
41
+ copyLiteral(input, cursor, output, length);
42
+ state = 4;
43
+ } else if (input[cursor.inputIndex] >= 18) {
44
+ state = input[cursor.inputIndex] - 17;
45
+ cursor.inputIndex += 1;
46
+ copyLiteral(input, cursor, output, state);
47
+ }
48
+ while (true) {
49
+ requireInput(input, cursor.inputIndex, 1);
50
+ const instruction = input[cursor.inputIndex++];
51
+ let nextState;
52
+ let matchIndex;
53
+ if ((instruction & 0xc0) !== 0) {
54
+ requireInput(input, cursor.inputIndex, 1);
55
+ matchIndex = cursor.outputIndex - ((input[cursor.inputIndex] << 3) + ((instruction >> 2) & 7) + 1);
56
+ cursor.inputIndex += 1;
57
+ matchLength = (instruction >> 5) + 1;
58
+ nextState = instruction & 3;
59
+ } else if ((instruction & 0x20) !== 0) {
60
+ matchLength = (instruction & 0x1f) + 2;
61
+ if (matchLength === 2) matchLength += readExtendedLength(input, cursor, 31);
62
+ requireInput(input, cursor.inputIndex, 2);
63
+ const encoded = input[cursor.inputIndex] | (input[cursor.inputIndex + 1] << 8);
64
+ cursor.inputIndex += 2;
65
+ matchIndex = cursor.outputIndex - ((encoded >> 2) + 1);
66
+ nextState = encoded & 3;
67
+ } else if ((instruction & 0x10) !== 0) {
68
+ matchLength = (instruction & 7) + 2;
69
+ if (matchLength === 2) matchLength += readExtendedLength(input, cursor, 7);
70
+ requireInput(input, cursor.inputIndex, 2);
71
+ const encoded = input[cursor.inputIndex] | (input[cursor.inputIndex + 1] << 8);
72
+ cursor.inputIndex += 2;
73
+ matchIndex = cursor.outputIndex - (((instruction & 8) << 11) + (encoded >> 2));
74
+ nextState = encoded & 3;
75
+ if (matchIndex === cursor.outputIndex) break;
76
+ matchIndex -= 0x4000;
77
+ } else if (state === 0) {
78
+ let length = instruction + 3;
79
+ if (length === 3) length += readExtendedLength(input, cursor, 15);
80
+ copyLiteral(input, cursor, output, length);
81
+ state = 4;
82
+ continue;
83
+ } else if (state !== 4) {
84
+ requireInput(input, cursor.inputIndex, 1);
85
+ nextState = instruction & 3;
86
+ matchIndex = cursor.outputIndex - ((instruction >> 2) + (input[cursor.inputIndex] << 2) + 1);
87
+ cursor.inputIndex += 1;
88
+ matchLength = 2;
89
+ } else {
90
+ requireInput(input, cursor.inputIndex, 1);
91
+ nextState = instruction & 3;
92
+ matchIndex = cursor.outputIndex - ((instruction >> 2) + (input[cursor.inputIndex] << 2) + 2049);
93
+ cursor.inputIndex += 1;
94
+ matchLength = 3;
95
+ }
96
+ if (matchIndex < 0 || cursor.outputIndex + matchLength + nextState > output.length) {
97
+ throw new Error('Invalid LZO1X block: lookbehind overrun.');
98
+ }
99
+ for (let index = 0; index < matchLength; index += 1) {
100
+ output[cursor.outputIndex++] = output[matchIndex++];
101
+ }
102
+ state = nextState;
103
+ copyLiteral(input, cursor, output, nextState);
104
+ }
105
+ if (matchLength !== 3 || cursor.inputIndex !== input.length || cursor.outputIndex !== output.length) {
106
+ throw new Error('Invalid LZO1X block: incomplete output.');
107
+ }
108
+ return output;
109
+ }
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { createRequire } from 'node:module';
5
+ import { decodeLzo1xBlock } from './lzo1x.js';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const DEFAULT_WIDTH = 1920;
9
+ const DEFAULT_HEIGHT = 1080;
10
+ const DEFAULT_FPS = 20;
11
+ const MAX_DEVICES = 100;
12
+ const FRAME_POOL_SIZE = 3;
13
+
14
+ let config = normalizeConfig({});
15
+ let layoutVersion = 0;
16
+ let layout = [];
17
+ let latestTiles = new Map();
18
+ let framePool = [];
19
+ let encoder = null;
20
+ let encoderCandidateIndex = 0;
21
+ let encoderCandidates = [];
22
+ let encoderOutputSeen = false;
23
+ let encoderRestartTimer = null;
24
+ let encoderStartupTimer = null;
25
+ let outputBuffer = Buffer.alloc(0);
26
+ let frameSeq = 0;
27
+ let tickTimer = null;
28
+ let closed = false;
29
+
30
+ function clamp(value, min, max, fallback) {
31
+ const number = Number(value);
32
+ return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.round(number))) : fallback;
33
+ }
34
+
35
+ function normalizeConfig(value) {
36
+ const deviceIds = [...new Set((Array.isArray(value?.deviceIds) ? value.deviceIds : [])
37
+ .map(item => String(item || '').trim()).filter(Boolean))].slice(0, MAX_DEVICES);
38
+ return {
39
+ deviceIds,
40
+ width: clamp(value?.width, 640, 3840, DEFAULT_WIDTH),
41
+ height: clamp(value?.height, 360, 2160, DEFAULT_HEIGHT),
42
+ fps: clamp(value?.fps, 1, 30, DEFAULT_FPS)
43
+ };
44
+ }
45
+
46
+ function send(message) {
47
+ if (process.connected) process.send?.(message);
48
+ }
49
+
50
+ function resolveFfmpegPaths() {
51
+ const paths = [];
52
+ const add = value => {
53
+ const path = String(value || '').trim();
54
+ if (path && !paths.includes(path)) paths.push(path);
55
+ };
56
+ add(process.env.LIVEDESK_FFMPEG);
57
+ try { add(require('@ffmpeg-installer/ffmpeg')?.path); } catch {}
58
+ try { add(require('ffmpeg-static')); } catch {}
59
+ add('ffmpeg');
60
+ return paths;
61
+ }
62
+
63
+ function buildEncoderCandidates() {
64
+ const codecCandidates = process.platform === 'win32'
65
+ ? [
66
+ ['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']],
67
+ ['windows-qsv', ['-c:v', 'h264_qsv', '-preset', 'veryfast', '-global_quality', '28', '-look_ahead', '0', '-g', String(config.fps), '-bf', '0']],
68
+ ['windows-amf', ['-c:v', 'h264_amf', '-quality', 'speed', '-usage', 'lowlatency', '-rc', 'cqp', '-qp_i', '25', '-qp_p', '28', '-g', String(config.fps), '-bf', '0']]
69
+ ]
70
+ : process.platform === 'darwin'
71
+ ? [['macos-videotoolbox', ['-c:v', 'h264_videotoolbox', '-realtime', '1', '-allow_sw', '0', '-b:v', '6M', '-g', String(config.fps), '-bf', '0']]]
72
+ : [['linux-nvenc', ['-c:v', 'h264_nvenc', '-preset', 'llhp', '-profile:v', 'baseline', '-rc-lookahead', '0', '-zerolatency', '1', '-g', String(config.fps), '-bf', '0']]];
73
+ codecCandidates.push(['software-x264', [
74
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
75
+ '-profile:v', 'baseline', '-crf', '28', '-g', String(config.fps),
76
+ '-keyint_min', String(config.fps), '-sc_threshold', '0', '-bf', '0',
77
+ '-x264-params', 'aud=1:repeat-headers=1'
78
+ ]]);
79
+ return resolveFfmpegPaths().flatMap(path => codecCandidates.map(([name, args]) => ({ path, name, args })));
80
+ }
81
+
82
+ function startEncoder() {
83
+ if (closed || encoder || encoderCandidates.length === 0) return;
84
+ if (encoderCandidateIndex >= encoderCandidates.length) {
85
+ send({ type: 'status', state: 'error', error: 'No Mode 4 H.264 encoder is available.' });
86
+ return;
87
+ }
88
+ const candidate = encoderCandidates[encoderCandidateIndex++];
89
+ const args = [
90
+ '-hide_banner', '-loglevel', 'warning',
91
+ '-f', 'rawvideo', '-pixel_format', 'rgb24',
92
+ '-video_size', `${config.width}x${config.height}`,
93
+ '-framerate', String(config.fps), '-i', 'pipe:0', '-an',
94
+ ...candidate.args,
95
+ '-bsf:v', 'h264_metadata=aud=insert', '-f', 'h264', 'pipe:1'
96
+ ];
97
+ const child = spawn(candidate.path, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
98
+ encoder = { child, candidate };
99
+ encoderOutputSeen = false;
100
+ outputBuffer = Buffer.alloc(0);
101
+ child.stdout.on('data', appendEncodedBytes);
102
+ child.stdin.on('error', error => {
103
+ send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name} input: ${error.message}` });
104
+ });
105
+ child.stderr.on('data', chunk => {
106
+ const message = String(chunk || '').trim();
107
+ if (message) send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: ${message.slice(0, 600)}` });
108
+ });
109
+ child.on('error', error => send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: ${error.message}` }));
110
+ child.on('exit', code => {
111
+ clearTimeout(encoderStartupTimer);
112
+ encoderStartupTimer = null;
113
+ const wasCurrent = encoder?.child === child;
114
+ if (wasCurrent) encoder = null;
115
+ flushEncodedUnit();
116
+ if (closed) return;
117
+ send({ type: 'status', state: 'encoder-restart', encoder: candidate.name, code, emitted: encoderOutputSeen });
118
+ clearTimeout(encoderRestartTimer);
119
+ encoderRestartTimer = setTimeout(startEncoder, encoderOutputSeen ? 800 : 120);
120
+ });
121
+ clearTimeout(encoderStartupTimer);
122
+ encoderStartupTimer = setTimeout(() => {
123
+ if (encoder?.child === child && !encoderOutputSeen) {
124
+ send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: no output during startup; trying the next encoder.` });
125
+ try { child.kill(); } catch {}
126
+ }
127
+ }, 3500);
128
+ send({ type: 'status', state: 'encoder-starting', encoder: candidate.name });
129
+ }
130
+
131
+ function stopEncoder() {
132
+ clearTimeout(encoderRestartTimer);
133
+ encoderRestartTimer = null;
134
+ clearTimeout(encoderStartupTimer);
135
+ encoderStartupTimer = null;
136
+ const child = encoder?.child;
137
+ encoder = null;
138
+ if (!child) return;
139
+ try { child.stdin.end(); } catch {}
140
+ setTimeout(() => { try { child.kill(); } catch {} }, 250).unref?.();
141
+ }
142
+
143
+ function findStartCodes(buffer) {
144
+ const starts = [];
145
+ for (let index = 0; index + 3 < buffer.length; index += 1) {
146
+ if (buffer[index] !== 0 || buffer[index + 1] !== 0) continue;
147
+ if (buffer[index + 2] === 1) {
148
+ starts.push({ offset: index, header: index + 3 });
149
+ index += 2;
150
+ } else if (buffer[index + 2] === 0 && buffer[index + 3] === 1) {
151
+ starts.push({ offset: index, header: index + 4 });
152
+ index += 3;
153
+ }
154
+ }
155
+ return starts;
156
+ }
157
+
158
+ function appendEncodedBytes(chunk) {
159
+ outputBuffer = outputBuffer.length ? Buffer.concat([outputBuffer, chunk]) : Buffer.from(chunk);
160
+ while (true) {
161
+ const auds = findStartCodes(outputBuffer).filter(start => (outputBuffer[start.header] & 0x1f) === 9);
162
+ if (auds.length < 2) break;
163
+ const boundary = auds[1].offset;
164
+ emitEncodedUnit(outputBuffer.subarray(0, boundary));
165
+ outputBuffer = outputBuffer.subarray(boundary);
166
+ }
167
+ }
168
+
169
+ function flushEncodedUnit() {
170
+ if (outputBuffer.length > 0) emitEncodedUnit(outputBuffer);
171
+ outputBuffer = Buffer.alloc(0);
172
+ }
173
+
174
+ function emitEncodedUnit(payload) {
175
+ if (!payload?.length) return;
176
+ const starts = findStartCodes(payload);
177
+ const isKeyFrame = starts.some(start => (payload[start.header] & 0x1f) === 5);
178
+ encoderOutputSeen = true;
179
+ clearTimeout(encoderStartupTimer);
180
+ encoderStartupTimer = null;
181
+ frameSeq += 1;
182
+ send({
183
+ type: 'frame',
184
+ payload: Buffer.from(payload),
185
+ metadata: {
186
+ frameSeq,
187
+ streamFrameSeq: frameSeq,
188
+ width: config.width,
189
+ height: config.height,
190
+ fps: config.fps,
191
+ durationUs: Math.round(1_000_000 / config.fps),
192
+ timestampUs: Math.round(frameSeq * 1_000_000 / config.fps),
193
+ isKeyFrame,
194
+ chunkType: isKeyFrame ? 'key' : 'delta',
195
+ hardwareEncoder: encoder?.candidate?.name || '',
196
+ layoutVersion
197
+ }
198
+ });
199
+ }
200
+
201
+ 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);
207
+ layoutVersion += 1;
208
+ layout = config.deviceIds.map((deviceId, index) => ({
209
+ deviceId,
210
+ index,
211
+ column: index % columns,
212
+ 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
217
+ }));
218
+ for (const tile of latestTiles.values()) tile.rendered = null;
219
+ framePool = Array.from({ length: FRAME_POOL_SIZE }, () => Buffer.alloc(config.width * config.height * 3));
220
+ send({ type: 'layout', layoutVersion, width: config.width, height: config.height, columns, rows, tiles: layout });
221
+ }
222
+
223
+ function renderTile(tileState, tileLayout) {
224
+ const sourceWidth = tileState.width;
225
+ const sourceHeight = tileState.height;
226
+ const scale = Math.min(tileLayout.width / sourceWidth, tileLayout.height / sourceHeight);
227
+ const width = Math.max(1, Math.floor(sourceWidth * scale));
228
+ const height = Math.max(1, Math.floor(sourceHeight * scale));
229
+ const pixels = Buffer.allocUnsafe(width * height * 3);
230
+ let target = 0;
231
+ for (let y = 0; y < height; y += 1) {
232
+ const sourceY = Math.min(sourceHeight - 1, Math.floor((y + 0.5) * sourceHeight / height));
233
+ for (let x = 0; x < width; x += 1) {
234
+ const sourceX = Math.min(sourceWidth - 1, Math.floor((x + 0.5) * sourceWidth / width));
235
+ const source = (sourceY * sourceWidth + sourceX) * 2;
236
+ 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;
240
+ }
241
+ }
242
+ tileState.rendered = {
243
+ pixels,
244
+ width,
245
+ height,
246
+ x: tileLayout.x + Math.floor((tileLayout.width - width) / 2),
247
+ y: tileLayout.y + Math.floor((tileLayout.height - height) / 2),
248
+ layoutVersion
249
+ };
250
+ }
251
+
252
+ function ingestFrame(message) {
253
+ const deviceId = String(message.deviceId || '');
254
+ if (!deviceId || !config.deviceIds.includes(deviceId)) return;
255
+ const width = clamp(message.width, 1, 320, 0);
256
+ const height = clamp(message.height, 1, 180, 0);
257
+ const expectedLength = width * height * 2;
258
+ if (!width || !height || Number(message.uncompressedByteLength) !== expectedLength) return;
259
+ try {
260
+ const compressed = Buffer.isBuffer(message.payload) ? message.payload : Buffer.from(message.payload || []);
261
+ const rgb565 = decodeLzo1xBlock(new Uint8Array(compressed), expectedLength);
262
+ const tileState = { width, height, rgb565, rendered: null, frameSeq: Number(message.frameSeq || 0) };
263
+ latestTiles.set(deviceId, tileState);
264
+ const tileLayout = layout.find(item => item.deviceId === deviceId);
265
+ if (tileLayout) renderTile(tileState, tileLayout);
266
+ } catch (error) {
267
+ send({ type: 'log', level: 'warn', message: `Mode 4 frame rejected for ${deviceId}: ${error.message}` });
268
+ }
269
+ }
270
+
271
+ function writeAtlasFrame() {
272
+ const target = framePool.pop();
273
+ const stdin = encoder?.child?.stdin;
274
+ if (!target || !stdin || stdin.destroyed || !stdin.writable) return;
275
+ target.fill(0);
276
+ for (const tileLayout of layout) {
277
+ const tileState = latestTiles.get(tileLayout.deviceId);
278
+ if (!tileState) continue;
279
+ if (!tileState.rendered || tileState.rendered.layoutVersion !== layoutVersion) renderTile(tileState, tileLayout);
280
+ const rendered = tileState.rendered;
281
+ for (let row = 0; row < rendered.height; row += 1) {
282
+ const sourceStart = row * rendered.width * 3;
283
+ const targetStart = ((rendered.y + row) * config.width + rendered.x) * 3;
284
+ rendered.pixels.copy(target, targetStart, sourceStart, sourceStart + rendered.width * 3);
285
+ }
286
+ }
287
+ try {
288
+ stdin.write(target, error => {
289
+ framePool.push(target);
290
+ if (error) send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
291
+ });
292
+ } catch (error) {
293
+ framePool.push(target);
294
+ send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
295
+ }
296
+ }
297
+
298
+ function configure(value) {
299
+ const next = normalizeConfig(value);
300
+ const encoderChanged = next.width !== config.width || next.height !== config.height || next.fps !== config.fps;
301
+ config = next;
302
+ latestTiles = new Map([...latestTiles].filter(([deviceId]) => config.deviceIds.includes(deviceId)));
303
+ rebuildLayout();
304
+ if (encoderChanged || !encoder) {
305
+ stopEncoder();
306
+ encoderCandidates = buildEncoderCandidates();
307
+ encoderCandidateIndex = 0;
308
+ startEncoder();
309
+ }
310
+ clearInterval(tickTimer);
311
+ tickTimer = setInterval(writeAtlasFrame, Math.max(1, Math.round(1000 / config.fps)));
312
+ }
313
+
314
+ process.on('message', message => {
315
+ if (message?.type === 'configure') configure(message);
316
+ else if (message?.type === 'frame') ingestFrame(message);
317
+ else if (message?.type === 'close') shutdown();
318
+ });
319
+
320
+ function shutdown() {
321
+ if (closed) return;
322
+ closed = true;
323
+ clearInterval(tickTimer);
324
+ stopEncoder();
325
+ setTimeout(() => process.exit(0), 50).unref?.();
326
+ }
327
+
328
+ process.on('disconnect', shutdown);
329
+ process.on('SIGTERM', shutdown);
330
+ process.on('SIGINT', shutdown);
331
+ send({ type: 'status', state: 'ready' });
@@ -0,0 +1,91 @@
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.startWorker();
18
+ }
19
+
20
+ startWorker() {
21
+ if (this.closed || this.worker) return;
22
+ const worker = fork(workerPath, [], {
23
+ env: { ...process.env },
24
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
25
+ serialization: 'advanced',
26
+ windowsHide: true
27
+ });
28
+ this.worker = worker;
29
+ worker.on('message', message => {
30
+ if (message?.type === 'frame' && message.metadata && message.payload) {
31
+ this.onFrame({ metadata: message.metadata, payload: Buffer.from(message.payload) });
32
+ } else if (message?.type === 'layout') {
33
+ this.onLayout(message);
34
+ } else if (message?.type === 'status' || message?.type === 'log') {
35
+ this.onStatus(message);
36
+ }
37
+ });
38
+ worker.on('exit', code => {
39
+ if (this.worker === worker) this.worker = null;
40
+ if (this.closed) return;
41
+ this.onStatus({ type: 'status', state: 'worker-restart', code });
42
+ clearTimeout(this.restartTimer);
43
+ this.restartTimer = setTimeout(() => {
44
+ this.startWorker();
45
+ if (this.config) this.worker?.send({ type: 'configure', ...this.config });
46
+ }, 750);
47
+ });
48
+ worker.on('error', error => this.onStatus({ type: 'status', state: 'worker-error', error: error.message }));
49
+ if (this.config) worker.send({ type: 'configure', ...this.config });
50
+ }
51
+
52
+ configure(options = {}) {
53
+ this.deviceIds = [...new Set((Array.isArray(options.deviceIds) ? options.deviceIds : [])
54
+ .map(value => String(value || '').trim()).filter(Boolean))].slice(0, 100);
55
+ this.deviceSet = new Set(this.deviceIds);
56
+ this.config = {
57
+ deviceIds: this.deviceIds,
58
+ width: Number(options.width || 1920),
59
+ height: Number(options.height || 1080),
60
+ fps: Number(options.fps || 20)
61
+ };
62
+ this.worker?.send({ type: 'configure', ...this.config });
63
+ }
64
+
65
+ ingest(frameEvent) {
66
+ if (this.closed || !this.worker) return;
67
+ const frame = frameEvent?.frame || {};
68
+ const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
69
+ const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
70
+ if (!this.deviceSet.has(deviceId) || mode !== 'mode2-lzo' || !Buffer.isBuffer(frameEvent?.payload)) return;
71
+ this.worker.send({
72
+ type: 'frame',
73
+ deviceId,
74
+ frameSeq: Number(frame.frameSeq || 0),
75
+ width: Number(frame.width || 0),
76
+ height: Number(frame.height || 0),
77
+ uncompressedByteLength: Number(frame.uncompressedByteLength || 0),
78
+ payload: frameEvent.payload
79
+ });
80
+ }
81
+
82
+ close() {
83
+ if (this.closed) return;
84
+ this.closed = true;
85
+ clearTimeout(this.restartTimer);
86
+ const worker = this.worker;
87
+ this.worker = null;
88
+ try { worker?.send({ type: 'close' }); } catch {}
89
+ setTimeout(() => { try { worker?.kill(); } catch {} }, 400).unref?.();
90
+ }
91
+ }