@livedesk/hub 0.1.41 → 0.1.42
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/package.json +2 -2
- package/src/agents/agent-device-scope.js +29 -29
- package/src/agents/agent-manager.js +103 -103
- package/src/agents/agent-permission-store.js +78 -78
- package/src/agents/agent-runtime-error.js +9 -9
- package/src/agents/agent-settings.js +71 -71
- package/src/agents/codex-agent-runtime.js +594 -594
- package/src/agents/codex-mcp-server.js +100 -100
- package/src/filesystem/directory-reader.js +187 -187
- package/src/filesystem/hub-filesystem.js +78 -78
- package/src/filesystem/path-registry.js +78 -78
- package/src/filesystem/roots.js +115 -115
- package/src/frame-packet-contract.mjs +427 -427
- package/src/http/hub-ui-session.js +119 -119
- package/src/live-capture-transition-retry.mjs +297 -297
- package/src/live-desk-update.js +167 -108
- package/src/live-stream-monitor-contract.js +38 -38
- package/src/lzo1x.js +109 -109
- package/src/mode4-atlas-pool.js +137 -137
- package/src/mode4-atlas-sizing.js +32 -32
- package/src/mode4-atlas-worker.js +599 -599
- package/src/mode4-atlas.js +306 -306
- package/src/remote-audio-subscription-contract.mjs +109 -109
- package/src/remote-audio-subscription-contract.test.mjs +72 -72
- package/src/remote-hub.js +144 -144
- package/src/server.js +201 -200
- package/src/settings/effective-device-policy.js +16 -16
- package/src/transport/udp-protocol.js +453 -453
|
@@ -1,599 +1,599 @@
|
|
|
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
|
-
import { resolveMode4AtlasTileSize } from './mode4-atlas-sizing.js';
|
|
8
|
-
|
|
9
|
-
const require = createRequire(import.meta.url);
|
|
10
|
-
const DEFAULT_WIDTH = 1920;
|
|
11
|
-
const DEFAULT_HEIGHT = 1080;
|
|
12
|
-
const DEFAULT_FPS = 20;
|
|
13
|
-
const MAX_DEVICES = 100;
|
|
14
|
-
const FRAME_POOL_SIZE = 3;
|
|
15
|
-
const MAX_TILE_WIDTH = 640;
|
|
16
|
-
const MAX_TILE_HEIGHT = 360;
|
|
17
|
-
|
|
18
|
-
const rgb565Red = new Uint8Array(65_536);
|
|
19
|
-
const rgb565Green = new Uint8Array(65_536);
|
|
20
|
-
const rgb565Blue = new Uint8Array(65_536);
|
|
21
|
-
for (let pixel = 0; pixel < 65_536; pixel += 1) {
|
|
22
|
-
rgb565Red[pixel] = (((pixel >> 11) & 0x1f) * 255 / 31) | 0;
|
|
23
|
-
rgb565Green[pixel] = (((pixel >> 5) & 0x3f) * 255 / 63) | 0;
|
|
24
|
-
rgb565Blue[pixel] = ((pixel & 0x1f) * 255 / 31) | 0;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
let config = normalizeConfig({});
|
|
28
|
-
let layoutVersion = 0;
|
|
29
|
-
let layout = [];
|
|
30
|
-
let latestTiles = new Map();
|
|
31
|
-
let framePool = [];
|
|
32
|
-
let encoder = null;
|
|
33
|
-
let encoderCandidateIndex = 0;
|
|
34
|
-
let encoderCandidates = [];
|
|
35
|
-
let encoderOutputSeen = false;
|
|
36
|
-
let encoderGeneration = 0;
|
|
37
|
-
let encoderRestartTimer = null;
|
|
38
|
-
let encoderStartupTimer = null;
|
|
39
|
-
let encoderStopPromise = null;
|
|
40
|
-
let configureRevision = 0;
|
|
41
|
-
let configureChain = Promise.resolve();
|
|
42
|
-
let shutdownPromise = null;
|
|
43
|
-
let inputWaitTimer = null;
|
|
44
|
-
let outputBuffer = Buffer.alloc(0);
|
|
45
|
-
let frameSeq = 0;
|
|
46
|
-
let tickTimer = null;
|
|
47
|
-
let nextTickAt = 0;
|
|
48
|
-
let closed = false;
|
|
49
|
-
let lastComposeMs = 0;
|
|
50
|
-
let poolStarved = 0;
|
|
51
|
-
let inputRevision = 0;
|
|
52
|
-
let lastEncodedInputRevision = -1;
|
|
53
|
-
let lastAtlasWriteAtEpochMs = 0;
|
|
54
|
-
const ENCODER_STARTUP_OUTPUT_TIMEOUT_MS = 8_000;
|
|
55
|
-
const ENCODER_STDIN_DRAIN_MS = 250;
|
|
56
|
-
const ENCODER_TERM_EXIT_MS = 500;
|
|
57
|
-
const ENCODER_KILL_EXIT_MS = 750;
|
|
58
|
-
const ATLAS_KEEPALIVE_MS = 1_000;
|
|
59
|
-
|
|
60
|
-
function clamp(value, min, max, fallback) {
|
|
61
|
-
const number = Number(value);
|
|
62
|
-
return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.round(number))) : fallback;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function normalizeConfig(value) {
|
|
66
|
-
const deviceIds = [...new Set((Array.isArray(value?.deviceIds) ? value.deviceIds : [])
|
|
67
|
-
.map(item => String(item || '').trim()).filter(Boolean))].slice(0, MAX_DEVICES);
|
|
68
|
-
const maxWidth = clamp(value?.width, 640, 3840, DEFAULT_WIDTH);
|
|
69
|
-
const maxHeight = clamp(value?.height, 360, 2160, DEFAULT_HEIGHT);
|
|
70
|
-
const count = Math.max(1, deviceIds.length);
|
|
71
|
-
const tileSize = resolveMode4AtlasTileSize({
|
|
72
|
-
requestedWidth: value?.tileWidth,
|
|
73
|
-
requestedHeight: value?.tileHeight,
|
|
74
|
-
deviceCount: count,
|
|
75
|
-
atlasWidth: maxWidth,
|
|
76
|
-
atlasHeight: maxHeight
|
|
77
|
-
});
|
|
78
|
-
const columns = tileSize.columns;
|
|
79
|
-
const rows = tileSize.rows;
|
|
80
|
-
const sourceTileWidth = tileSize.width;
|
|
81
|
-
const sourceTileHeight = tileSize.height;
|
|
82
|
-
const tileWidth = Math.max(2, Math.min(sourceTileWidth, Math.floor(maxWidth / columns))) & ~1;
|
|
83
|
-
const tileHeight = Math.max(2, Math.min(sourceTileHeight, Math.floor(maxHeight / rows))) & ~1;
|
|
84
|
-
return {
|
|
85
|
-
deviceIds,
|
|
86
|
-
maxWidth,
|
|
87
|
-
maxHeight,
|
|
88
|
-
width: tileWidth * columns,
|
|
89
|
-
height: tileHeight * rows,
|
|
90
|
-
columns,
|
|
91
|
-
rows,
|
|
92
|
-
tileWidth,
|
|
93
|
-
tileHeight,
|
|
94
|
-
sourceTileWidth,
|
|
95
|
-
sourceTileHeight,
|
|
96
|
-
fps: clamp(value?.fps, 1, 30, DEFAULT_FPS)
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function send(message) {
|
|
101
|
-
if (process.connected) process.send?.(message);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function resolveFfmpegPaths() {
|
|
105
|
-
const paths = [];
|
|
106
|
-
const add = value => {
|
|
107
|
-
const path = String(value || '').trim();
|
|
108
|
-
if (!path || paths.includes(path)) return;
|
|
109
|
-
if (path === 'ffmpeg' || existsSync(path)) paths.push(path);
|
|
110
|
-
};
|
|
111
|
-
add(process.env.LIVEDESK_FFMPEG);
|
|
112
|
-
try { add(require('@ffmpeg-installer/ffmpeg')?.path); } catch {}
|
|
113
|
-
try { add(require('ffmpeg-static')); } catch {}
|
|
114
|
-
add('ffmpeg');
|
|
115
|
-
return paths;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function buildEncoderCandidates() {
|
|
119
|
-
const codecCandidates = process.platform === 'win32'
|
|
120
|
-
? [
|
|
121
|
-
['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']],
|
|
122
|
-
['windows-qsv', ['-c:v', 'h264_qsv', '-preset', 'veryfast', '-global_quality', '28', '-look_ahead', '0', '-g', String(config.fps), '-bf', '0']],
|
|
123
|
-
['windows-amf', ['-c:v', 'h264_amf', '-quality', 'speed', '-usage', 'lowlatency', '-rc', 'cqp', '-qp_i', '25', '-qp_p', '28', '-g', String(config.fps), '-bf', '0']]
|
|
124
|
-
]
|
|
125
|
-
: process.platform === 'darwin'
|
|
126
|
-
? [['macos-videotoolbox', ['-c:v', 'h264_videotoolbox', '-realtime', '1', '-allow_sw', '0', '-b:v', '6M', '-g', String(config.fps), '-bf', '0']]]
|
|
127
|
-
: [['linux-nvenc', ['-c:v', 'h264_nvenc', '-preset', 'llhp', '-profile:v', 'baseline', '-rc-lookahead', '0', '-zerolatency', '1', '-g', String(config.fps), '-bf', '0']]];
|
|
128
|
-
codecCandidates.push(['software-x264', [
|
|
129
|
-
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
|
130
|
-
'-profile:v', 'baseline', '-crf', '28', '-g', String(config.fps),
|
|
131
|
-
'-keyint_min', String(config.fps), '-sc_threshold', '0', '-bf', '0',
|
|
132
|
-
'-x264-params', 'aud=1:repeat-headers=1'
|
|
133
|
-
]]);
|
|
134
|
-
return resolveFfmpegPaths().flatMap(path => codecCandidates.map(([name, args]) => ({ path, name, args })));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function startEncoder() {
|
|
138
|
-
if (closed || encoder || encoderStopPromise || encoderCandidates.length === 0) return;
|
|
139
|
-
if (encoderCandidateIndex >= encoderCandidates.length) {
|
|
140
|
-
send({ type: 'status', state: 'error', error: 'No Mode 4 H.264 encoder is available.' });
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
const candidate = encoderCandidates[encoderCandidateIndex++];
|
|
144
|
-
encoderGeneration += 1;
|
|
145
|
-
const args = [
|
|
146
|
-
'-hide_banner', '-loglevel', 'warning',
|
|
147
|
-
'-f', 'rawvideo', '-pixel_format', 'rgb24',
|
|
148
|
-
'-video_size', `${config.width}x${config.height}`,
|
|
149
|
-
'-framerate', String(config.fps), '-i', 'pipe:0', '-an',
|
|
150
|
-
...candidate.args,
|
|
151
|
-
'-bsf:v', 'h264_metadata=aud=insert', '-f', 'h264', 'pipe:1'
|
|
152
|
-
];
|
|
153
|
-
const child = spawn(candidate.path, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
154
|
-
const encoderEntry = {
|
|
155
|
-
child,
|
|
156
|
-
candidate,
|
|
157
|
-
inputSeen: false,
|
|
158
|
-
stopping: false,
|
|
159
|
-
intentionalStop: false
|
|
160
|
-
};
|
|
161
|
-
encoder = encoderEntry;
|
|
162
|
-
encoderOutputSeen = false;
|
|
163
|
-
outputBuffer = Buffer.alloc(0);
|
|
164
|
-
send({
|
|
165
|
-
type: 'encoder-owner',
|
|
166
|
-
state: 'active',
|
|
167
|
-
pid: Number(child.pid || 0),
|
|
168
|
-
encoderGeneration,
|
|
169
|
-
encoder: candidate.name
|
|
170
|
-
});
|
|
171
|
-
child.stdout.on('data', appendEncodedBytes);
|
|
172
|
-
child.stdin.on('error', error => {
|
|
173
|
-
if (encoder?.child === child && !isClosedPipeError(error)) {
|
|
174
|
-
send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name} input: ${error.message}` });
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
child.stderr.on('data', chunk => {
|
|
178
|
-
const message = String(chunk || '').trim();
|
|
179
|
-
if (message) send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: ${message.slice(0, 600)}` });
|
|
180
|
-
});
|
|
181
|
-
child.on('error', error => send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: ${error.message}` }));
|
|
182
|
-
child.on('exit', code => {
|
|
183
|
-
clearTimeout(encoderStartupTimer);
|
|
184
|
-
encoderStartupTimer = null;
|
|
185
|
-
const wasCurrent = encoder?.child === child;
|
|
186
|
-
if (wasCurrent) encoder = null;
|
|
187
|
-
send({
|
|
188
|
-
type: 'encoder-owner',
|
|
189
|
-
state: 'exited',
|
|
190
|
-
pid: Number(child.pid || 0),
|
|
191
|
-
encoderGeneration,
|
|
192
|
-
encoder: candidate.name,
|
|
193
|
-
code
|
|
194
|
-
});
|
|
195
|
-
if (!closed && !encoderEntry.intentionalStop) {
|
|
196
|
-
flushEncodedUnit();
|
|
197
|
-
} else {
|
|
198
|
-
outputBuffer = Buffer.alloc(0);
|
|
199
|
-
}
|
|
200
|
-
if (closed || encoderEntry.intentionalStop) return;
|
|
201
|
-
send({ type: 'status', state: 'encoder-restart', encoder: candidate.name, code, emitted: encoderOutputSeen });
|
|
202
|
-
clearTimeout(encoderRestartTimer);
|
|
203
|
-
encoderRestartTimer = setTimeout(startEncoder, encoderOutputSeen ? 800 : 120);
|
|
204
|
-
});
|
|
205
|
-
send({ type: 'status', state: 'encoder-starting', encoder: candidate.name });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function isClosedPipeError(error) {
|
|
209
|
-
const code = String(error?.code || '').toUpperCase();
|
|
210
|
-
const message = String(error?.message || '').toLowerCase();
|
|
211
|
-
return code === 'EPIPE' || code === 'EOF' || code === 'ERR_STREAM_DESTROYED'
|
|
212
|
-
|| message.includes('write eof') || message.includes('broken pipe');
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function armEncoderStartupTimer(child, candidate) {
|
|
216
|
-
clearTimeout(encoderStartupTimer);
|
|
217
|
-
encoderStartupTimer = setTimeout(() => {
|
|
218
|
-
if (encoder?.child === child && encoder.inputSeen && !encoderOutputSeen) {
|
|
219
|
-
send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: no output after receiving input; trying the next encoder.` });
|
|
220
|
-
try { child.kill(); } catch {}
|
|
221
|
-
}
|
|
222
|
-
}, ENCODER_STARTUP_OUTPUT_TIMEOUT_MS);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function waitForChildExit(child, timeoutMs) {
|
|
226
|
-
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
227
|
-
return Promise.resolve(true);
|
|
228
|
-
}
|
|
229
|
-
return new Promise(resolve => {
|
|
230
|
-
let settled = false;
|
|
231
|
-
let timer = null;
|
|
232
|
-
const finish = exited => {
|
|
233
|
-
if (settled) return;
|
|
234
|
-
settled = true;
|
|
235
|
-
if (timer) clearTimeout(timer);
|
|
236
|
-
child.off('exit', handleExit);
|
|
237
|
-
child.off('close', handleExit);
|
|
238
|
-
resolve(exited);
|
|
239
|
-
};
|
|
240
|
-
const handleExit = () => finish(true);
|
|
241
|
-
child.once('exit', handleExit);
|
|
242
|
-
child.once('close', handleExit);
|
|
243
|
-
timer = setTimeout(() => finish(
|
|
244
|
-
child.exitCode !== null || child.signalCode !== null
|
|
245
|
-
), Math.max(1, Number(timeoutMs || 0)));
|
|
246
|
-
timer.unref?.();
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
async function stopEncoder(reason = 'stop') {
|
|
251
|
-
clearTimeout(encoderRestartTimer);
|
|
252
|
-
encoderRestartTimer = null;
|
|
253
|
-
clearTimeout(encoderStartupTimer);
|
|
254
|
-
encoderStartupTimer = null;
|
|
255
|
-
if (encoderStopPromise) return encoderStopPromise;
|
|
256
|
-
const encoderEntry = encoder;
|
|
257
|
-
const child = encoderEntry?.child;
|
|
258
|
-
if (!child) return true;
|
|
259
|
-
encoderEntry.stopping = true;
|
|
260
|
-
encoderEntry.intentionalStop = true;
|
|
261
|
-
|
|
262
|
-
const stopPromise = (async () => {
|
|
263
|
-
try { child.stdin.end(); } catch {}
|
|
264
|
-
let exited = await waitForChildExit(child, ENCODER_STDIN_DRAIN_MS);
|
|
265
|
-
if (!exited) {
|
|
266
|
-
try { child.kill('SIGTERM'); } catch {}
|
|
267
|
-
exited = await waitForChildExit(child, ENCODER_TERM_EXIT_MS);
|
|
268
|
-
}
|
|
269
|
-
if (!exited) {
|
|
270
|
-
try { child.kill('SIGKILL'); } catch {}
|
|
271
|
-
exited = await waitForChildExit(child, ENCODER_KILL_EXIT_MS);
|
|
272
|
-
}
|
|
273
|
-
if (!exited) {
|
|
274
|
-
send({
|
|
275
|
-
type: 'status',
|
|
276
|
-
state: 'encoder-stop-failed',
|
|
277
|
-
error: `Mode 4 encoder pid ${Number(child.pid || 0)} did not exit during ${reason}.`,
|
|
278
|
-
pid: Number(child.pid || 0),
|
|
279
|
-
encoderGeneration
|
|
280
|
-
});
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
if (encoder?.child === child) encoder = null;
|
|
284
|
-
return true;
|
|
285
|
-
})().finally(() => {
|
|
286
|
-
if (encoderStopPromise === stopPromise) encoderStopPromise = null;
|
|
287
|
-
});
|
|
288
|
-
encoderStopPromise = stopPromise;
|
|
289
|
-
return stopPromise;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function findStartCodes(buffer) {
|
|
293
|
-
const starts = [];
|
|
294
|
-
for (let index = 0; index + 3 < buffer.length; index += 1) {
|
|
295
|
-
if (buffer[index] !== 0 || buffer[index + 1] !== 0) continue;
|
|
296
|
-
if (buffer[index + 2] === 1) {
|
|
297
|
-
starts.push({ offset: index, header: index + 3 });
|
|
298
|
-
index += 2;
|
|
299
|
-
} else if (buffer[index + 2] === 0 && buffer[index + 3] === 1) {
|
|
300
|
-
starts.push({ offset: index, header: index + 4 });
|
|
301
|
-
index += 3;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return starts;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function appendEncodedBytes(chunk) {
|
|
308
|
-
outputBuffer = outputBuffer.length ? Buffer.concat([outputBuffer, chunk]) : Buffer.from(chunk);
|
|
309
|
-
while (true) {
|
|
310
|
-
const auds = findStartCodes(outputBuffer).filter(start => (outputBuffer[start.header] & 0x1f) === 9);
|
|
311
|
-
if (auds.length < 2) break;
|
|
312
|
-
const boundary = auds[1].offset;
|
|
313
|
-
emitEncodedUnit(outputBuffer.subarray(0, boundary));
|
|
314
|
-
outputBuffer = outputBuffer.subarray(boundary);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function flushEncodedUnit() {
|
|
319
|
-
if (outputBuffer.length > 0) emitEncodedUnit(outputBuffer);
|
|
320
|
-
outputBuffer = Buffer.alloc(0);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function emitEncodedUnit(payload) {
|
|
324
|
-
if (!payload?.length) return;
|
|
325
|
-
const starts = findStartCodes(payload);
|
|
326
|
-
const isKeyFrame = starts.some(start => (payload[start.header] & 0x1f) === 5);
|
|
327
|
-
encoderOutputSeen = true;
|
|
328
|
-
clearTimeout(encoderStartupTimer);
|
|
329
|
-
encoderStartupTimer = null;
|
|
330
|
-
frameSeq += 1;
|
|
331
|
-
const readyTiles = [...latestTiles.values()];
|
|
332
|
-
const inputTimes = readyTiles
|
|
333
|
-
.map(tile => Number(tile.hubReceivedAtEpochMs || 0))
|
|
334
|
-
.filter(value => value > 0);
|
|
335
|
-
const sourceNewestAtEpochMs = inputTimes.length > 0 ? Math.max(...inputTimes) : 0;
|
|
336
|
-
const sourceOldestAtEpochMs = inputTimes.length > 0 ? Math.min(...inputTimes) : 0;
|
|
337
|
-
send({
|
|
338
|
-
type: 'frame',
|
|
339
|
-
payload: Buffer.from(payload),
|
|
340
|
-
metadata: {
|
|
341
|
-
frameSeq,
|
|
342
|
-
streamFrameSeq: frameSeq,
|
|
343
|
-
width: config.width,
|
|
344
|
-
height: config.height,
|
|
345
|
-
fps: config.fps,
|
|
346
|
-
durationUs: Math.round(1_000_000 / config.fps),
|
|
347
|
-
timestampUs: Math.round(frameSeq * 1_000_000 / config.fps),
|
|
348
|
-
isKeyFrame,
|
|
349
|
-
chunkType: isKeyFrame ? 'key' : 'delta',
|
|
350
|
-
hardwareEncoder: encoder?.candidate?.name || '',
|
|
351
|
-
encoderGeneration,
|
|
352
|
-
layoutVersion,
|
|
353
|
-
composeMs: lastComposeMs,
|
|
354
|
-
poolStarved,
|
|
355
|
-
readyTileCount: latestTiles.size,
|
|
356
|
-
tileCount: config.deviceIds.length,
|
|
357
|
-
inputRevision,
|
|
358
|
-
sourceNewestAtEpochMs,
|
|
359
|
-
sourceOldestAtEpochMs,
|
|
360
|
-
sourceMaxAgeMs: sourceOldestAtEpochMs > 0 ? Math.max(0, Date.now() - sourceOldestAtEpochMs) : 0
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function rebuildLayout() {
|
|
366
|
-
const { columns, rows, tileWidth, tileHeight } = config;
|
|
367
|
-
layoutVersion += 1;
|
|
368
|
-
layout = config.deviceIds.map((deviceId, index) => ({
|
|
369
|
-
deviceId,
|
|
370
|
-
index,
|
|
371
|
-
column: index % columns,
|
|
372
|
-
row: Math.floor(index / columns),
|
|
373
|
-
x: (index % columns) * tileWidth,
|
|
374
|
-
y: Math.floor(index / columns) * tileHeight,
|
|
375
|
-
width: tileWidth,
|
|
376
|
-
height: tileHeight
|
|
377
|
-
}));
|
|
378
|
-
for (const tile of latestTiles.values()) tile.rendered = null;
|
|
379
|
-
framePool = Array.from({ length: FRAME_POOL_SIZE }, () => Buffer.alloc(config.width * config.height * 3));
|
|
380
|
-
send({ type: 'layout', layoutVersion, width: config.width, height: config.height, columns, rows, tiles: layout });
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function renderTile(tileState, tileLayout) {
|
|
384
|
-
const sourceWidth = tileState.width;
|
|
385
|
-
const sourceHeight = tileState.height;
|
|
386
|
-
const scale = Math.min(tileLayout.width / sourceWidth, tileLayout.height / sourceHeight);
|
|
387
|
-
const width = Math.max(1, Math.floor(sourceWidth * scale));
|
|
388
|
-
const height = Math.max(1, Math.floor(sourceHeight * scale));
|
|
389
|
-
const pixels = Buffer.allocUnsafe(width * height * 3);
|
|
390
|
-
let target = 0;
|
|
391
|
-
if (width === sourceWidth && height === sourceHeight) {
|
|
392
|
-
for (let source = 0; source < tileState.rgb565.length; source += 2) {
|
|
393
|
-
const pixel = tileState.rgb565[source] | (tileState.rgb565[source + 1] << 8);
|
|
394
|
-
pixels[target++] = rgb565Red[pixel];
|
|
395
|
-
pixels[target++] = rgb565Green[pixel];
|
|
396
|
-
pixels[target++] = rgb565Blue[pixel];
|
|
397
|
-
}
|
|
398
|
-
} else {
|
|
399
|
-
for (let y = 0; y < height; y += 1) {
|
|
400
|
-
const sourceY = Math.min(sourceHeight - 1, Math.floor((y + 0.5) * sourceHeight / height));
|
|
401
|
-
for (let x = 0; x < width; x += 1) {
|
|
402
|
-
const sourceX = Math.min(sourceWidth - 1, Math.floor((x + 0.5) * sourceWidth / width));
|
|
403
|
-
const source = (sourceY * sourceWidth + sourceX) * 2;
|
|
404
|
-
const pixel = tileState.rgb565[source] | (tileState.rgb565[source + 1] << 8);
|
|
405
|
-
pixels[target++] = rgb565Red[pixel];
|
|
406
|
-
pixels[target++] = rgb565Green[pixel];
|
|
407
|
-
pixels[target++] = rgb565Blue[pixel];
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
tileState.rendered = {
|
|
412
|
-
pixels,
|
|
413
|
-
width,
|
|
414
|
-
height,
|
|
415
|
-
x: tileLayout.x + Math.floor((tileLayout.width - width) / 2),
|
|
416
|
-
y: tileLayout.y + Math.floor((tileLayout.height - height) / 2),
|
|
417
|
-
layoutVersion
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function ingestFrame(message) {
|
|
422
|
-
const deviceId = String(message.deviceId || '');
|
|
423
|
-
if (!deviceId || !config.deviceIds.includes(deviceId)) return;
|
|
424
|
-
const width = clamp(message.width, 1, config.sourceTileWidth, 0);
|
|
425
|
-
const height = clamp(message.height, 1, config.sourceTileHeight, 0);
|
|
426
|
-
const expectedLength = width * height * 2;
|
|
427
|
-
if (!width || !height || Number(message.uncompressedByteLength) !== expectedLength) return;
|
|
428
|
-
try {
|
|
429
|
-
const contentHash = String(message.contentHash || '');
|
|
430
|
-
const previous = latestTiles.get(deviceId);
|
|
431
|
-
const inputMetadata = {
|
|
432
|
-
frameSeq: Number(message.frameSeq || 0),
|
|
433
|
-
monitorIndex: Number(message.monitorIndex || 0),
|
|
434
|
-
capturedAtEpochMs: Number(message.capturedAtEpochMs || 0),
|
|
435
|
-
hubReceivedAtEpochMs: Number(message.hubReceivedAtEpochMs || 0) || Date.now(),
|
|
436
|
-
contentHash
|
|
437
|
-
};
|
|
438
|
-
if (previous
|
|
439
|
-
&& contentHash
|
|
440
|
-
&& previous.contentHash === contentHash
|
|
441
|
-
&& previous.width === width
|
|
442
|
-
&& previous.height === height) {
|
|
443
|
-
Object.assign(previous, inputMetadata);
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
const compressed = Buffer.isBuffer(message.payload) ? message.payload : Buffer.from(message.payload || []);
|
|
447
|
-
const rgb565 = decodeLzo1xBlock(new Uint8Array(compressed), expectedLength);
|
|
448
|
-
const tileState = { width, height, rgb565, rendered: null, ...inputMetadata };
|
|
449
|
-
latestTiles.set(deviceId, tileState);
|
|
450
|
-
inputRevision += 1;
|
|
451
|
-
if (!encoder && encoderCandidates.length > 0) startEncoder();
|
|
452
|
-
if (config.deviceIds.length > 0 && config.deviceIds.every(id => latestTiles.has(id))) {
|
|
453
|
-
clearTimeout(inputWaitTimer);
|
|
454
|
-
inputWaitTimer = null;
|
|
455
|
-
}
|
|
456
|
-
const tileLayout = layout.find(item => item.deviceId === deviceId);
|
|
457
|
-
if (tileLayout) renderTile(tileState, tileLayout);
|
|
458
|
-
} catch (error) {
|
|
459
|
-
send({ type: 'log', level: 'warn', message: `Mode 4 frame rejected for ${deviceId}: ${error.message}` });
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function writeAtlasFrame() {
|
|
464
|
-
if (latestTiles.size === 0) return;
|
|
465
|
-
if (!encoder || encoder.stopping) {
|
|
466
|
-
if (encoder?.stopping) return;
|
|
467
|
-
startEncoder();
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
const nowEpochMs = Date.now();
|
|
471
|
-
if (inputRevision === lastEncodedInputRevision
|
|
472
|
-
&& nowEpochMs - lastAtlasWriteAtEpochMs < ATLAS_KEEPALIVE_MS) {
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
const composeStartedAt = performance.now();
|
|
476
|
-
const target = framePool.pop();
|
|
477
|
-
const stdin = encoder?.child?.stdin;
|
|
478
|
-
if (!target || !stdin || stdin.destroyed || !stdin.writable) {
|
|
479
|
-
if (!target) poolStarved += 1;
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
target.fill(0);
|
|
483
|
-
for (const tileLayout of layout) {
|
|
484
|
-
const tileState = latestTiles.get(tileLayout.deviceId);
|
|
485
|
-
if (!tileState) continue;
|
|
486
|
-
if (!tileState.rendered || tileState.rendered.layoutVersion !== layoutVersion) renderTile(tileState, tileLayout);
|
|
487
|
-
const rendered = tileState.rendered;
|
|
488
|
-
for (let row = 0; row < rendered.height; row += 1) {
|
|
489
|
-
const sourceStart = row * rendered.width * 3;
|
|
490
|
-
const targetStart = ((rendered.y + row) * config.width + rendered.x) * 3;
|
|
491
|
-
rendered.pixels.copy(target, targetStart, sourceStart, sourceStart + rendered.width * 3);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
lastComposeMs = Math.max(0, performance.now() - composeStartedAt);
|
|
495
|
-
try {
|
|
496
|
-
const encoderEntry = encoder;
|
|
497
|
-
if (!encoderEntry.inputSeen) {
|
|
498
|
-
encoderEntry.inputSeen = true;
|
|
499
|
-
armEncoderStartupTimer(encoderEntry.child, encoderEntry.candidate);
|
|
500
|
-
}
|
|
501
|
-
stdin.write(target, error => {
|
|
502
|
-
framePool.push(target);
|
|
503
|
-
if (error && encoder?.child === encoderEntry.child && !isClosedPipeError(error)) {
|
|
504
|
-
send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
lastEncodedInputRevision = inputRevision;
|
|
508
|
-
lastAtlasWriteAtEpochMs = nowEpochMs;
|
|
509
|
-
} catch (error) {
|
|
510
|
-
framePool.push(target);
|
|
511
|
-
if (!isClosedPipeError(error)) send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function startComposeClock() {
|
|
516
|
-
clearTimeout(tickTimer);
|
|
517
|
-
const intervalMs = 1000 / config.fps;
|
|
518
|
-
nextTickAt = performance.now() + intervalMs;
|
|
519
|
-
const tick = () => {
|
|
520
|
-
if (closed) return;
|
|
521
|
-
writeAtlasFrame();
|
|
522
|
-
const now = performance.now();
|
|
523
|
-
nextTickAt += intervalMs;
|
|
524
|
-
if (nextTickAt < now - intervalMs) nextTickAt = now + intervalMs;
|
|
525
|
-
tickTimer = setTimeout(tick, Math.max(0, nextTickAt - performance.now()));
|
|
526
|
-
};
|
|
527
|
-
tickTimer = setTimeout(tick, intervalMs);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
async function configure(value, revision) {
|
|
531
|
-
if (closed) return;
|
|
532
|
-
const next = normalizeConfig(value);
|
|
533
|
-
const encoderChanged = next.width !== config.width || next.height !== config.height || next.fps !== config.fps;
|
|
534
|
-
config = next;
|
|
535
|
-
lastEncodedInputRevision = -1;
|
|
536
|
-
latestTiles = new Map([...latestTiles].filter(([deviceId]) => config.deviceIds.includes(deviceId)));
|
|
537
|
-
clearTimeout(inputWaitTimer);
|
|
538
|
-
inputWaitTimer = config.deviceIds.length > 0 ? setTimeout(() => {
|
|
539
|
-
const missing = config.deviceIds.filter(deviceId => !latestTiles.has(deviceId));
|
|
540
|
-
if (missing.length > 0) {
|
|
541
|
-
send({
|
|
542
|
-
type: 'log',
|
|
543
|
-
level: 'warn',
|
|
544
|
-
message: `Mode 4 waiting for internal Atlas input from ${missing.length}/${config.deviceIds.length} devices: ${missing.slice(0, 8).join(', ')}`
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
}, 3000) : null;
|
|
548
|
-
rebuildLayout();
|
|
549
|
-
if (encoderChanged || !encoder) {
|
|
550
|
-
const stopped = await stopEncoder('reconfigure');
|
|
551
|
-
if (!stopped || closed || revision !== configureRevision) return;
|
|
552
|
-
encoderCandidates = buildEncoderCandidates();
|
|
553
|
-
encoderCandidateIndex = 0;
|
|
554
|
-
if (latestTiles.size > 0) startEncoder();
|
|
555
|
-
}
|
|
556
|
-
startComposeClock();
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
function queueConfigure(value) {
|
|
560
|
-
const revision = ++configureRevision;
|
|
561
|
-
configureChain = configureChain
|
|
562
|
-
.then(() => configure(value, revision))
|
|
563
|
-
.catch(error => {
|
|
564
|
-
if (!closed) {
|
|
565
|
-
send({
|
|
566
|
-
type: 'status',
|
|
567
|
-
state: 'configure-error',
|
|
568
|
-
error: error instanceof Error ? error.message : String(error)
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
});
|
|
572
|
-
return configureChain;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
process.on('message', message => {
|
|
576
|
-
if (message?.type === 'configure') void queueConfigure(message);
|
|
577
|
-
else if (message?.type === 'frame') ingestFrame(message);
|
|
578
|
-
else if (message?.type === 'close') void shutdown();
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
function shutdown() {
|
|
582
|
-
if (shutdownPromise) return shutdownPromise;
|
|
583
|
-
closed = true;
|
|
584
|
-
clearTimeout(tickTimer);
|
|
585
|
-
clearTimeout(inputWaitTimer);
|
|
586
|
-
clearTimeout(encoderRestartTimer);
|
|
587
|
-
clearTimeout(encoderStartupTimer);
|
|
588
|
-
shutdownPromise = (async () => {
|
|
589
|
-
await configureChain.catch(() => {});
|
|
590
|
-
const encoderExited = await stopEncoder('worker-shutdown');
|
|
591
|
-
process.exit(encoderExited ? 0 : 1);
|
|
592
|
-
})();
|
|
593
|
-
return shutdownPromise;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
process.on('disconnect', () => { void shutdown(); });
|
|
597
|
-
process.on('SIGTERM', () => { void shutdown(); });
|
|
598
|
-
process.on('SIGINT', () => { void shutdown(); });
|
|
599
|
-
send({ type: 'status', state: 'ready' });
|
|
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
|
+
import { resolveMode4AtlasTileSize } from './mode4-atlas-sizing.js';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const DEFAULT_WIDTH = 1920;
|
|
11
|
+
const DEFAULT_HEIGHT = 1080;
|
|
12
|
+
const DEFAULT_FPS = 20;
|
|
13
|
+
const MAX_DEVICES = 100;
|
|
14
|
+
const FRAME_POOL_SIZE = 3;
|
|
15
|
+
const MAX_TILE_WIDTH = 640;
|
|
16
|
+
const MAX_TILE_HEIGHT = 360;
|
|
17
|
+
|
|
18
|
+
const rgb565Red = new Uint8Array(65_536);
|
|
19
|
+
const rgb565Green = new Uint8Array(65_536);
|
|
20
|
+
const rgb565Blue = new Uint8Array(65_536);
|
|
21
|
+
for (let pixel = 0; pixel < 65_536; pixel += 1) {
|
|
22
|
+
rgb565Red[pixel] = (((pixel >> 11) & 0x1f) * 255 / 31) | 0;
|
|
23
|
+
rgb565Green[pixel] = (((pixel >> 5) & 0x3f) * 255 / 63) | 0;
|
|
24
|
+
rgb565Blue[pixel] = ((pixel & 0x1f) * 255 / 31) | 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let config = normalizeConfig({});
|
|
28
|
+
let layoutVersion = 0;
|
|
29
|
+
let layout = [];
|
|
30
|
+
let latestTiles = new Map();
|
|
31
|
+
let framePool = [];
|
|
32
|
+
let encoder = null;
|
|
33
|
+
let encoderCandidateIndex = 0;
|
|
34
|
+
let encoderCandidates = [];
|
|
35
|
+
let encoderOutputSeen = false;
|
|
36
|
+
let encoderGeneration = 0;
|
|
37
|
+
let encoderRestartTimer = null;
|
|
38
|
+
let encoderStartupTimer = null;
|
|
39
|
+
let encoderStopPromise = null;
|
|
40
|
+
let configureRevision = 0;
|
|
41
|
+
let configureChain = Promise.resolve();
|
|
42
|
+
let shutdownPromise = null;
|
|
43
|
+
let inputWaitTimer = null;
|
|
44
|
+
let outputBuffer = Buffer.alloc(0);
|
|
45
|
+
let frameSeq = 0;
|
|
46
|
+
let tickTimer = null;
|
|
47
|
+
let nextTickAt = 0;
|
|
48
|
+
let closed = false;
|
|
49
|
+
let lastComposeMs = 0;
|
|
50
|
+
let poolStarved = 0;
|
|
51
|
+
let inputRevision = 0;
|
|
52
|
+
let lastEncodedInputRevision = -1;
|
|
53
|
+
let lastAtlasWriteAtEpochMs = 0;
|
|
54
|
+
const ENCODER_STARTUP_OUTPUT_TIMEOUT_MS = 8_000;
|
|
55
|
+
const ENCODER_STDIN_DRAIN_MS = 250;
|
|
56
|
+
const ENCODER_TERM_EXIT_MS = 500;
|
|
57
|
+
const ENCODER_KILL_EXIT_MS = 750;
|
|
58
|
+
const ATLAS_KEEPALIVE_MS = 1_000;
|
|
59
|
+
|
|
60
|
+
function clamp(value, min, max, fallback) {
|
|
61
|
+
const number = Number(value);
|
|
62
|
+
return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.round(number))) : fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeConfig(value) {
|
|
66
|
+
const deviceIds = [...new Set((Array.isArray(value?.deviceIds) ? value.deviceIds : [])
|
|
67
|
+
.map(item => String(item || '').trim()).filter(Boolean))].slice(0, MAX_DEVICES);
|
|
68
|
+
const maxWidth = clamp(value?.width, 640, 3840, DEFAULT_WIDTH);
|
|
69
|
+
const maxHeight = clamp(value?.height, 360, 2160, DEFAULT_HEIGHT);
|
|
70
|
+
const count = Math.max(1, deviceIds.length);
|
|
71
|
+
const tileSize = resolveMode4AtlasTileSize({
|
|
72
|
+
requestedWidth: value?.tileWidth,
|
|
73
|
+
requestedHeight: value?.tileHeight,
|
|
74
|
+
deviceCount: count,
|
|
75
|
+
atlasWidth: maxWidth,
|
|
76
|
+
atlasHeight: maxHeight
|
|
77
|
+
});
|
|
78
|
+
const columns = tileSize.columns;
|
|
79
|
+
const rows = tileSize.rows;
|
|
80
|
+
const sourceTileWidth = tileSize.width;
|
|
81
|
+
const sourceTileHeight = tileSize.height;
|
|
82
|
+
const tileWidth = Math.max(2, Math.min(sourceTileWidth, Math.floor(maxWidth / columns))) & ~1;
|
|
83
|
+
const tileHeight = Math.max(2, Math.min(sourceTileHeight, Math.floor(maxHeight / rows))) & ~1;
|
|
84
|
+
return {
|
|
85
|
+
deviceIds,
|
|
86
|
+
maxWidth,
|
|
87
|
+
maxHeight,
|
|
88
|
+
width: tileWidth * columns,
|
|
89
|
+
height: tileHeight * rows,
|
|
90
|
+
columns,
|
|
91
|
+
rows,
|
|
92
|
+
tileWidth,
|
|
93
|
+
tileHeight,
|
|
94
|
+
sourceTileWidth,
|
|
95
|
+
sourceTileHeight,
|
|
96
|
+
fps: clamp(value?.fps, 1, 30, DEFAULT_FPS)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function send(message) {
|
|
101
|
+
if (process.connected) process.send?.(message);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveFfmpegPaths() {
|
|
105
|
+
const paths = [];
|
|
106
|
+
const add = value => {
|
|
107
|
+
const path = String(value || '').trim();
|
|
108
|
+
if (!path || paths.includes(path)) return;
|
|
109
|
+
if (path === 'ffmpeg' || existsSync(path)) paths.push(path);
|
|
110
|
+
};
|
|
111
|
+
add(process.env.LIVEDESK_FFMPEG);
|
|
112
|
+
try { add(require('@ffmpeg-installer/ffmpeg')?.path); } catch {}
|
|
113
|
+
try { add(require('ffmpeg-static')); } catch {}
|
|
114
|
+
add('ffmpeg');
|
|
115
|
+
return paths;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildEncoderCandidates() {
|
|
119
|
+
const codecCandidates = process.platform === 'win32'
|
|
120
|
+
? [
|
|
121
|
+
['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']],
|
|
122
|
+
['windows-qsv', ['-c:v', 'h264_qsv', '-preset', 'veryfast', '-global_quality', '28', '-look_ahead', '0', '-g', String(config.fps), '-bf', '0']],
|
|
123
|
+
['windows-amf', ['-c:v', 'h264_amf', '-quality', 'speed', '-usage', 'lowlatency', '-rc', 'cqp', '-qp_i', '25', '-qp_p', '28', '-g', String(config.fps), '-bf', '0']]
|
|
124
|
+
]
|
|
125
|
+
: process.platform === 'darwin'
|
|
126
|
+
? [['macos-videotoolbox', ['-c:v', 'h264_videotoolbox', '-realtime', '1', '-allow_sw', '0', '-b:v', '6M', '-g', String(config.fps), '-bf', '0']]]
|
|
127
|
+
: [['linux-nvenc', ['-c:v', 'h264_nvenc', '-preset', 'llhp', '-profile:v', 'baseline', '-rc-lookahead', '0', '-zerolatency', '1', '-g', String(config.fps), '-bf', '0']]];
|
|
128
|
+
codecCandidates.push(['software-x264', [
|
|
129
|
+
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
|
130
|
+
'-profile:v', 'baseline', '-crf', '28', '-g', String(config.fps),
|
|
131
|
+
'-keyint_min', String(config.fps), '-sc_threshold', '0', '-bf', '0',
|
|
132
|
+
'-x264-params', 'aud=1:repeat-headers=1'
|
|
133
|
+
]]);
|
|
134
|
+
return resolveFfmpegPaths().flatMap(path => codecCandidates.map(([name, args]) => ({ path, name, args })));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function startEncoder() {
|
|
138
|
+
if (closed || encoder || encoderStopPromise || encoderCandidates.length === 0) return;
|
|
139
|
+
if (encoderCandidateIndex >= encoderCandidates.length) {
|
|
140
|
+
send({ type: 'status', state: 'error', error: 'No Mode 4 H.264 encoder is available.' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const candidate = encoderCandidates[encoderCandidateIndex++];
|
|
144
|
+
encoderGeneration += 1;
|
|
145
|
+
const args = [
|
|
146
|
+
'-hide_banner', '-loglevel', 'warning',
|
|
147
|
+
'-f', 'rawvideo', '-pixel_format', 'rgb24',
|
|
148
|
+
'-video_size', `${config.width}x${config.height}`,
|
|
149
|
+
'-framerate', String(config.fps), '-i', 'pipe:0', '-an',
|
|
150
|
+
...candidate.args,
|
|
151
|
+
'-bsf:v', 'h264_metadata=aud=insert', '-f', 'h264', 'pipe:1'
|
|
152
|
+
];
|
|
153
|
+
const child = spawn(candidate.path, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
154
|
+
const encoderEntry = {
|
|
155
|
+
child,
|
|
156
|
+
candidate,
|
|
157
|
+
inputSeen: false,
|
|
158
|
+
stopping: false,
|
|
159
|
+
intentionalStop: false
|
|
160
|
+
};
|
|
161
|
+
encoder = encoderEntry;
|
|
162
|
+
encoderOutputSeen = false;
|
|
163
|
+
outputBuffer = Buffer.alloc(0);
|
|
164
|
+
send({
|
|
165
|
+
type: 'encoder-owner',
|
|
166
|
+
state: 'active',
|
|
167
|
+
pid: Number(child.pid || 0),
|
|
168
|
+
encoderGeneration,
|
|
169
|
+
encoder: candidate.name
|
|
170
|
+
});
|
|
171
|
+
child.stdout.on('data', appendEncodedBytes);
|
|
172
|
+
child.stdin.on('error', error => {
|
|
173
|
+
if (encoder?.child === child && !isClosedPipeError(error)) {
|
|
174
|
+
send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name} input: ${error.message}` });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
child.stderr.on('data', chunk => {
|
|
178
|
+
const message = String(chunk || '').trim();
|
|
179
|
+
if (message) send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: ${message.slice(0, 600)}` });
|
|
180
|
+
});
|
|
181
|
+
child.on('error', error => send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: ${error.message}` }));
|
|
182
|
+
child.on('exit', code => {
|
|
183
|
+
clearTimeout(encoderStartupTimer);
|
|
184
|
+
encoderStartupTimer = null;
|
|
185
|
+
const wasCurrent = encoder?.child === child;
|
|
186
|
+
if (wasCurrent) encoder = null;
|
|
187
|
+
send({
|
|
188
|
+
type: 'encoder-owner',
|
|
189
|
+
state: 'exited',
|
|
190
|
+
pid: Number(child.pid || 0),
|
|
191
|
+
encoderGeneration,
|
|
192
|
+
encoder: candidate.name,
|
|
193
|
+
code
|
|
194
|
+
});
|
|
195
|
+
if (!closed && !encoderEntry.intentionalStop) {
|
|
196
|
+
flushEncodedUnit();
|
|
197
|
+
} else {
|
|
198
|
+
outputBuffer = Buffer.alloc(0);
|
|
199
|
+
}
|
|
200
|
+
if (closed || encoderEntry.intentionalStop) return;
|
|
201
|
+
send({ type: 'status', state: 'encoder-restart', encoder: candidate.name, code, emitted: encoderOutputSeen });
|
|
202
|
+
clearTimeout(encoderRestartTimer);
|
|
203
|
+
encoderRestartTimer = setTimeout(startEncoder, encoderOutputSeen ? 800 : 120);
|
|
204
|
+
});
|
|
205
|
+
send({ type: 'status', state: 'encoder-starting', encoder: candidate.name });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isClosedPipeError(error) {
|
|
209
|
+
const code = String(error?.code || '').toUpperCase();
|
|
210
|
+
const message = String(error?.message || '').toLowerCase();
|
|
211
|
+
return code === 'EPIPE' || code === 'EOF' || code === 'ERR_STREAM_DESTROYED'
|
|
212
|
+
|| message.includes('write eof') || message.includes('broken pipe');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function armEncoderStartupTimer(child, candidate) {
|
|
216
|
+
clearTimeout(encoderStartupTimer);
|
|
217
|
+
encoderStartupTimer = setTimeout(() => {
|
|
218
|
+
if (encoder?.child === child && encoder.inputSeen && !encoderOutputSeen) {
|
|
219
|
+
send({ type: 'log', level: 'warn', message: `Mode 4 ${candidate.name}: no output after receiving input; trying the next encoder.` });
|
|
220
|
+
try { child.kill(); } catch {}
|
|
221
|
+
}
|
|
222
|
+
}, ENCODER_STARTUP_OUTPUT_TIMEOUT_MS);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function waitForChildExit(child, timeoutMs) {
|
|
226
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
227
|
+
return Promise.resolve(true);
|
|
228
|
+
}
|
|
229
|
+
return new Promise(resolve => {
|
|
230
|
+
let settled = false;
|
|
231
|
+
let timer = null;
|
|
232
|
+
const finish = exited => {
|
|
233
|
+
if (settled) return;
|
|
234
|
+
settled = true;
|
|
235
|
+
if (timer) clearTimeout(timer);
|
|
236
|
+
child.off('exit', handleExit);
|
|
237
|
+
child.off('close', handleExit);
|
|
238
|
+
resolve(exited);
|
|
239
|
+
};
|
|
240
|
+
const handleExit = () => finish(true);
|
|
241
|
+
child.once('exit', handleExit);
|
|
242
|
+
child.once('close', handleExit);
|
|
243
|
+
timer = setTimeout(() => finish(
|
|
244
|
+
child.exitCode !== null || child.signalCode !== null
|
|
245
|
+
), Math.max(1, Number(timeoutMs || 0)));
|
|
246
|
+
timer.unref?.();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function stopEncoder(reason = 'stop') {
|
|
251
|
+
clearTimeout(encoderRestartTimer);
|
|
252
|
+
encoderRestartTimer = null;
|
|
253
|
+
clearTimeout(encoderStartupTimer);
|
|
254
|
+
encoderStartupTimer = null;
|
|
255
|
+
if (encoderStopPromise) return encoderStopPromise;
|
|
256
|
+
const encoderEntry = encoder;
|
|
257
|
+
const child = encoderEntry?.child;
|
|
258
|
+
if (!child) return true;
|
|
259
|
+
encoderEntry.stopping = true;
|
|
260
|
+
encoderEntry.intentionalStop = true;
|
|
261
|
+
|
|
262
|
+
const stopPromise = (async () => {
|
|
263
|
+
try { child.stdin.end(); } catch {}
|
|
264
|
+
let exited = await waitForChildExit(child, ENCODER_STDIN_DRAIN_MS);
|
|
265
|
+
if (!exited) {
|
|
266
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
267
|
+
exited = await waitForChildExit(child, ENCODER_TERM_EXIT_MS);
|
|
268
|
+
}
|
|
269
|
+
if (!exited) {
|
|
270
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
271
|
+
exited = await waitForChildExit(child, ENCODER_KILL_EXIT_MS);
|
|
272
|
+
}
|
|
273
|
+
if (!exited) {
|
|
274
|
+
send({
|
|
275
|
+
type: 'status',
|
|
276
|
+
state: 'encoder-stop-failed',
|
|
277
|
+
error: `Mode 4 encoder pid ${Number(child.pid || 0)} did not exit during ${reason}.`,
|
|
278
|
+
pid: Number(child.pid || 0),
|
|
279
|
+
encoderGeneration
|
|
280
|
+
});
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
if (encoder?.child === child) encoder = null;
|
|
284
|
+
return true;
|
|
285
|
+
})().finally(() => {
|
|
286
|
+
if (encoderStopPromise === stopPromise) encoderStopPromise = null;
|
|
287
|
+
});
|
|
288
|
+
encoderStopPromise = stopPromise;
|
|
289
|
+
return stopPromise;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function findStartCodes(buffer) {
|
|
293
|
+
const starts = [];
|
|
294
|
+
for (let index = 0; index + 3 < buffer.length; index += 1) {
|
|
295
|
+
if (buffer[index] !== 0 || buffer[index + 1] !== 0) continue;
|
|
296
|
+
if (buffer[index + 2] === 1) {
|
|
297
|
+
starts.push({ offset: index, header: index + 3 });
|
|
298
|
+
index += 2;
|
|
299
|
+
} else if (buffer[index + 2] === 0 && buffer[index + 3] === 1) {
|
|
300
|
+
starts.push({ offset: index, header: index + 4 });
|
|
301
|
+
index += 3;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return starts;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function appendEncodedBytes(chunk) {
|
|
308
|
+
outputBuffer = outputBuffer.length ? Buffer.concat([outputBuffer, chunk]) : Buffer.from(chunk);
|
|
309
|
+
while (true) {
|
|
310
|
+
const auds = findStartCodes(outputBuffer).filter(start => (outputBuffer[start.header] & 0x1f) === 9);
|
|
311
|
+
if (auds.length < 2) break;
|
|
312
|
+
const boundary = auds[1].offset;
|
|
313
|
+
emitEncodedUnit(outputBuffer.subarray(0, boundary));
|
|
314
|
+
outputBuffer = outputBuffer.subarray(boundary);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function flushEncodedUnit() {
|
|
319
|
+
if (outputBuffer.length > 0) emitEncodedUnit(outputBuffer);
|
|
320
|
+
outputBuffer = Buffer.alloc(0);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function emitEncodedUnit(payload) {
|
|
324
|
+
if (!payload?.length) return;
|
|
325
|
+
const starts = findStartCodes(payload);
|
|
326
|
+
const isKeyFrame = starts.some(start => (payload[start.header] & 0x1f) === 5);
|
|
327
|
+
encoderOutputSeen = true;
|
|
328
|
+
clearTimeout(encoderStartupTimer);
|
|
329
|
+
encoderStartupTimer = null;
|
|
330
|
+
frameSeq += 1;
|
|
331
|
+
const readyTiles = [...latestTiles.values()];
|
|
332
|
+
const inputTimes = readyTiles
|
|
333
|
+
.map(tile => Number(tile.hubReceivedAtEpochMs || 0))
|
|
334
|
+
.filter(value => value > 0);
|
|
335
|
+
const sourceNewestAtEpochMs = inputTimes.length > 0 ? Math.max(...inputTimes) : 0;
|
|
336
|
+
const sourceOldestAtEpochMs = inputTimes.length > 0 ? Math.min(...inputTimes) : 0;
|
|
337
|
+
send({
|
|
338
|
+
type: 'frame',
|
|
339
|
+
payload: Buffer.from(payload),
|
|
340
|
+
metadata: {
|
|
341
|
+
frameSeq,
|
|
342
|
+
streamFrameSeq: frameSeq,
|
|
343
|
+
width: config.width,
|
|
344
|
+
height: config.height,
|
|
345
|
+
fps: config.fps,
|
|
346
|
+
durationUs: Math.round(1_000_000 / config.fps),
|
|
347
|
+
timestampUs: Math.round(frameSeq * 1_000_000 / config.fps),
|
|
348
|
+
isKeyFrame,
|
|
349
|
+
chunkType: isKeyFrame ? 'key' : 'delta',
|
|
350
|
+
hardwareEncoder: encoder?.candidate?.name || '',
|
|
351
|
+
encoderGeneration,
|
|
352
|
+
layoutVersion,
|
|
353
|
+
composeMs: lastComposeMs,
|
|
354
|
+
poolStarved,
|
|
355
|
+
readyTileCount: latestTiles.size,
|
|
356
|
+
tileCount: config.deviceIds.length,
|
|
357
|
+
inputRevision,
|
|
358
|
+
sourceNewestAtEpochMs,
|
|
359
|
+
sourceOldestAtEpochMs,
|
|
360
|
+
sourceMaxAgeMs: sourceOldestAtEpochMs > 0 ? Math.max(0, Date.now() - sourceOldestAtEpochMs) : 0
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function rebuildLayout() {
|
|
366
|
+
const { columns, rows, tileWidth, tileHeight } = config;
|
|
367
|
+
layoutVersion += 1;
|
|
368
|
+
layout = config.deviceIds.map((deviceId, index) => ({
|
|
369
|
+
deviceId,
|
|
370
|
+
index,
|
|
371
|
+
column: index % columns,
|
|
372
|
+
row: Math.floor(index / columns),
|
|
373
|
+
x: (index % columns) * tileWidth,
|
|
374
|
+
y: Math.floor(index / columns) * tileHeight,
|
|
375
|
+
width: tileWidth,
|
|
376
|
+
height: tileHeight
|
|
377
|
+
}));
|
|
378
|
+
for (const tile of latestTiles.values()) tile.rendered = null;
|
|
379
|
+
framePool = Array.from({ length: FRAME_POOL_SIZE }, () => Buffer.alloc(config.width * config.height * 3));
|
|
380
|
+
send({ type: 'layout', layoutVersion, width: config.width, height: config.height, columns, rows, tiles: layout });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderTile(tileState, tileLayout) {
|
|
384
|
+
const sourceWidth = tileState.width;
|
|
385
|
+
const sourceHeight = tileState.height;
|
|
386
|
+
const scale = Math.min(tileLayout.width / sourceWidth, tileLayout.height / sourceHeight);
|
|
387
|
+
const width = Math.max(1, Math.floor(sourceWidth * scale));
|
|
388
|
+
const height = Math.max(1, Math.floor(sourceHeight * scale));
|
|
389
|
+
const pixels = Buffer.allocUnsafe(width * height * 3);
|
|
390
|
+
let target = 0;
|
|
391
|
+
if (width === sourceWidth && height === sourceHeight) {
|
|
392
|
+
for (let source = 0; source < tileState.rgb565.length; source += 2) {
|
|
393
|
+
const pixel = tileState.rgb565[source] | (tileState.rgb565[source + 1] << 8);
|
|
394
|
+
pixels[target++] = rgb565Red[pixel];
|
|
395
|
+
pixels[target++] = rgb565Green[pixel];
|
|
396
|
+
pixels[target++] = rgb565Blue[pixel];
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
for (let y = 0; y < height; y += 1) {
|
|
400
|
+
const sourceY = Math.min(sourceHeight - 1, Math.floor((y + 0.5) * sourceHeight / height));
|
|
401
|
+
for (let x = 0; x < width; x += 1) {
|
|
402
|
+
const sourceX = Math.min(sourceWidth - 1, Math.floor((x + 0.5) * sourceWidth / width));
|
|
403
|
+
const source = (sourceY * sourceWidth + sourceX) * 2;
|
|
404
|
+
const pixel = tileState.rgb565[source] | (tileState.rgb565[source + 1] << 8);
|
|
405
|
+
pixels[target++] = rgb565Red[pixel];
|
|
406
|
+
pixels[target++] = rgb565Green[pixel];
|
|
407
|
+
pixels[target++] = rgb565Blue[pixel];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
tileState.rendered = {
|
|
412
|
+
pixels,
|
|
413
|
+
width,
|
|
414
|
+
height,
|
|
415
|
+
x: tileLayout.x + Math.floor((tileLayout.width - width) / 2),
|
|
416
|
+
y: tileLayout.y + Math.floor((tileLayout.height - height) / 2),
|
|
417
|
+
layoutVersion
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function ingestFrame(message) {
|
|
422
|
+
const deviceId = String(message.deviceId || '');
|
|
423
|
+
if (!deviceId || !config.deviceIds.includes(deviceId)) return;
|
|
424
|
+
const width = clamp(message.width, 1, config.sourceTileWidth, 0);
|
|
425
|
+
const height = clamp(message.height, 1, config.sourceTileHeight, 0);
|
|
426
|
+
const expectedLength = width * height * 2;
|
|
427
|
+
if (!width || !height || Number(message.uncompressedByteLength) !== expectedLength) return;
|
|
428
|
+
try {
|
|
429
|
+
const contentHash = String(message.contentHash || '');
|
|
430
|
+
const previous = latestTiles.get(deviceId);
|
|
431
|
+
const inputMetadata = {
|
|
432
|
+
frameSeq: Number(message.frameSeq || 0),
|
|
433
|
+
monitorIndex: Number(message.monitorIndex || 0),
|
|
434
|
+
capturedAtEpochMs: Number(message.capturedAtEpochMs || 0),
|
|
435
|
+
hubReceivedAtEpochMs: Number(message.hubReceivedAtEpochMs || 0) || Date.now(),
|
|
436
|
+
contentHash
|
|
437
|
+
};
|
|
438
|
+
if (previous
|
|
439
|
+
&& contentHash
|
|
440
|
+
&& previous.contentHash === contentHash
|
|
441
|
+
&& previous.width === width
|
|
442
|
+
&& previous.height === height) {
|
|
443
|
+
Object.assign(previous, inputMetadata);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const compressed = Buffer.isBuffer(message.payload) ? message.payload : Buffer.from(message.payload || []);
|
|
447
|
+
const rgb565 = decodeLzo1xBlock(new Uint8Array(compressed), expectedLength);
|
|
448
|
+
const tileState = { width, height, rgb565, rendered: null, ...inputMetadata };
|
|
449
|
+
latestTiles.set(deviceId, tileState);
|
|
450
|
+
inputRevision += 1;
|
|
451
|
+
if (!encoder && encoderCandidates.length > 0) startEncoder();
|
|
452
|
+
if (config.deviceIds.length > 0 && config.deviceIds.every(id => latestTiles.has(id))) {
|
|
453
|
+
clearTimeout(inputWaitTimer);
|
|
454
|
+
inputWaitTimer = null;
|
|
455
|
+
}
|
|
456
|
+
const tileLayout = layout.find(item => item.deviceId === deviceId);
|
|
457
|
+
if (tileLayout) renderTile(tileState, tileLayout);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
send({ type: 'log', level: 'warn', message: `Mode 4 frame rejected for ${deviceId}: ${error.message}` });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function writeAtlasFrame() {
|
|
464
|
+
if (latestTiles.size === 0) return;
|
|
465
|
+
if (!encoder || encoder.stopping) {
|
|
466
|
+
if (encoder?.stopping) return;
|
|
467
|
+
startEncoder();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const nowEpochMs = Date.now();
|
|
471
|
+
if (inputRevision === lastEncodedInputRevision
|
|
472
|
+
&& nowEpochMs - lastAtlasWriteAtEpochMs < ATLAS_KEEPALIVE_MS) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const composeStartedAt = performance.now();
|
|
476
|
+
const target = framePool.pop();
|
|
477
|
+
const stdin = encoder?.child?.stdin;
|
|
478
|
+
if (!target || !stdin || stdin.destroyed || !stdin.writable) {
|
|
479
|
+
if (!target) poolStarved += 1;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
target.fill(0);
|
|
483
|
+
for (const tileLayout of layout) {
|
|
484
|
+
const tileState = latestTiles.get(tileLayout.deviceId);
|
|
485
|
+
if (!tileState) continue;
|
|
486
|
+
if (!tileState.rendered || tileState.rendered.layoutVersion !== layoutVersion) renderTile(tileState, tileLayout);
|
|
487
|
+
const rendered = tileState.rendered;
|
|
488
|
+
for (let row = 0; row < rendered.height; row += 1) {
|
|
489
|
+
const sourceStart = row * rendered.width * 3;
|
|
490
|
+
const targetStart = ((rendered.y + row) * config.width + rendered.x) * 3;
|
|
491
|
+
rendered.pixels.copy(target, targetStart, sourceStart, sourceStart + rendered.width * 3);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
lastComposeMs = Math.max(0, performance.now() - composeStartedAt);
|
|
495
|
+
try {
|
|
496
|
+
const encoderEntry = encoder;
|
|
497
|
+
if (!encoderEntry.inputSeen) {
|
|
498
|
+
encoderEntry.inputSeen = true;
|
|
499
|
+
armEncoderStartupTimer(encoderEntry.child, encoderEntry.candidate);
|
|
500
|
+
}
|
|
501
|
+
stdin.write(target, error => {
|
|
502
|
+
framePool.push(target);
|
|
503
|
+
if (error && encoder?.child === encoderEntry.child && !isClosedPipeError(error)) {
|
|
504
|
+
send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
lastEncodedInputRevision = inputRevision;
|
|
508
|
+
lastAtlasWriteAtEpochMs = nowEpochMs;
|
|
509
|
+
} catch (error) {
|
|
510
|
+
framePool.push(target);
|
|
511
|
+
if (!isClosedPipeError(error)) send({ type: 'log', level: 'warn', message: `Mode 4 encoder write: ${error.message}` });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function startComposeClock() {
|
|
516
|
+
clearTimeout(tickTimer);
|
|
517
|
+
const intervalMs = 1000 / config.fps;
|
|
518
|
+
nextTickAt = performance.now() + intervalMs;
|
|
519
|
+
const tick = () => {
|
|
520
|
+
if (closed) return;
|
|
521
|
+
writeAtlasFrame();
|
|
522
|
+
const now = performance.now();
|
|
523
|
+
nextTickAt += intervalMs;
|
|
524
|
+
if (nextTickAt < now - intervalMs) nextTickAt = now + intervalMs;
|
|
525
|
+
tickTimer = setTimeout(tick, Math.max(0, nextTickAt - performance.now()));
|
|
526
|
+
};
|
|
527
|
+
tickTimer = setTimeout(tick, intervalMs);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function configure(value, revision) {
|
|
531
|
+
if (closed) return;
|
|
532
|
+
const next = normalizeConfig(value);
|
|
533
|
+
const encoderChanged = next.width !== config.width || next.height !== config.height || next.fps !== config.fps;
|
|
534
|
+
config = next;
|
|
535
|
+
lastEncodedInputRevision = -1;
|
|
536
|
+
latestTiles = new Map([...latestTiles].filter(([deviceId]) => config.deviceIds.includes(deviceId)));
|
|
537
|
+
clearTimeout(inputWaitTimer);
|
|
538
|
+
inputWaitTimer = config.deviceIds.length > 0 ? setTimeout(() => {
|
|
539
|
+
const missing = config.deviceIds.filter(deviceId => !latestTiles.has(deviceId));
|
|
540
|
+
if (missing.length > 0) {
|
|
541
|
+
send({
|
|
542
|
+
type: 'log',
|
|
543
|
+
level: 'warn',
|
|
544
|
+
message: `Mode 4 waiting for internal Atlas input from ${missing.length}/${config.deviceIds.length} devices: ${missing.slice(0, 8).join(', ')}`
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}, 3000) : null;
|
|
548
|
+
rebuildLayout();
|
|
549
|
+
if (encoderChanged || !encoder) {
|
|
550
|
+
const stopped = await stopEncoder('reconfigure');
|
|
551
|
+
if (!stopped || closed || revision !== configureRevision) return;
|
|
552
|
+
encoderCandidates = buildEncoderCandidates();
|
|
553
|
+
encoderCandidateIndex = 0;
|
|
554
|
+
if (latestTiles.size > 0) startEncoder();
|
|
555
|
+
}
|
|
556
|
+
startComposeClock();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function queueConfigure(value) {
|
|
560
|
+
const revision = ++configureRevision;
|
|
561
|
+
configureChain = configureChain
|
|
562
|
+
.then(() => configure(value, revision))
|
|
563
|
+
.catch(error => {
|
|
564
|
+
if (!closed) {
|
|
565
|
+
send({
|
|
566
|
+
type: 'status',
|
|
567
|
+
state: 'configure-error',
|
|
568
|
+
error: error instanceof Error ? error.message : String(error)
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
return configureChain;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
process.on('message', message => {
|
|
576
|
+
if (message?.type === 'configure') void queueConfigure(message);
|
|
577
|
+
else if (message?.type === 'frame') ingestFrame(message);
|
|
578
|
+
else if (message?.type === 'close') void shutdown();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
function shutdown() {
|
|
582
|
+
if (shutdownPromise) return shutdownPromise;
|
|
583
|
+
closed = true;
|
|
584
|
+
clearTimeout(tickTimer);
|
|
585
|
+
clearTimeout(inputWaitTimer);
|
|
586
|
+
clearTimeout(encoderRestartTimer);
|
|
587
|
+
clearTimeout(encoderStartupTimer);
|
|
588
|
+
shutdownPromise = (async () => {
|
|
589
|
+
await configureChain.catch(() => {});
|
|
590
|
+
const encoderExited = await stopEncoder('worker-shutdown');
|
|
591
|
+
process.exit(encoderExited ? 0 : 1);
|
|
592
|
+
})();
|
|
593
|
+
return shutdownPromise;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
process.on('disconnect', () => { void shutdown(); });
|
|
597
|
+
process.on('SIGTERM', () => { void shutdown(); });
|
|
598
|
+
process.on('SIGINT', () => { void shutdown(); });
|
|
599
|
+
send({ type: 'status', state: 'ready' });
|