@mindexec/cli 0.2.443 → 0.2.444
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/lzo1x.js +110 -0
- package/mode4-atlas-worker.js +440 -0
- package/mode4-atlas.js +130 -0
- package/package.json +6 -1
- package/server.js +151 -0
package/lzo1x.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
}
|
|
110
|
+
|
|
@@ -0,0 +1,440 @@
|
|
|
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' });
|
|
440
|
+
|
package/mode4-atlas.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
}
|
|
130
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindexec/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.444",
|
|
4
4
|
"description": "MindExec local runtime and bridge CLI",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"launch-bridge.cjs",
|
|
11
11
|
"server.js",
|
|
12
12
|
"remote-hub.js",
|
|
13
|
+
"lzo1x.js",
|
|
14
|
+
"mode4-atlas.js",
|
|
15
|
+
"mode4-atlas-worker.js",
|
|
13
16
|
"codex-runtime.js",
|
|
14
17
|
"codex-model-catalog.js",
|
|
15
18
|
"port-guard.cjs",
|
|
@@ -59,11 +62,13 @@
|
|
|
59
62
|
"node": ">=20"
|
|
60
63
|
},
|
|
61
64
|
"dependencies": {
|
|
65
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
62
66
|
"@mindexec/remote": "^0.1.17",
|
|
63
67
|
"@openai/codex-sdk": "^0.144.1",
|
|
64
68
|
"chokidar": "^3.6.0",
|
|
65
69
|
"cors": "^2.8.5",
|
|
66
70
|
"express": "^4.18.2",
|
|
71
|
+
"ffmpeg-static": "^5.3.0",
|
|
67
72
|
"multer": "^2.2.0",
|
|
68
73
|
"playwright-core": "^1.53.0",
|
|
69
74
|
"sharp": "^0.33.2",
|
package/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'url';
|
|
|
24
24
|
import { createCodexRuntime } from './codex-runtime.js';
|
|
25
25
|
import { createCodexModelCatalog } from './codex-model-catalog.js';
|
|
26
26
|
import { createRemoteHub } from './remote-hub.js';
|
|
27
|
+
import { Mode4AtlasSession } from './mode4-atlas.js';
|
|
27
28
|
import portGuard from './port-guard.cjs';
|
|
28
29
|
|
|
29
30
|
const execAsync = promisify(exec);
|
|
@@ -2399,12 +2400,15 @@ app.get('/api/local-files/:id', async (req, res) => {
|
|
|
2399
2400
|
const httpServer = createServer(app);
|
|
2400
2401
|
const wss = new WebSocketServer({ noServer: true });
|
|
2401
2402
|
const remoteFrameWss = new WebSocketServer({ noServer: true });
|
|
2403
|
+
const remoteAtlasWss = new WebSocketServer({ noServer: true });
|
|
2402
2404
|
const remoteInputWss = new WebSocketServer({ noServer: true });
|
|
2403
2405
|
const wsClients = new Set();
|
|
2404
2406
|
const remoteFrameClients = new Set();
|
|
2407
|
+
const remoteAtlasClients = new Map();
|
|
2405
2408
|
const remoteInputClients = new Set();
|
|
2406
2409
|
const shellJobs = new Map();
|
|
2407
2410
|
let remoteFrameClientSeq = 0;
|
|
2411
|
+
let remoteAtlasClientSeq = 0;
|
|
2408
2412
|
let remoteInputClientSeq = 0;
|
|
2409
2413
|
const remoteFrameWsDiagnostics = {
|
|
2410
2414
|
opened: 0,
|
|
@@ -2480,6 +2484,14 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
2480
2484
|
return;
|
|
2481
2485
|
}
|
|
2482
2486
|
|
|
2487
|
+
if (parsed.pathname === '/api/remote/atlas/ws'
|
|
2488
|
+
&& (!bridgeAuthRequired || token === bridgeToken)) {
|
|
2489
|
+
remoteAtlasWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2490
|
+
remoteAtlasWss.emit('connection', ws, req);
|
|
2491
|
+
});
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2483
2495
|
if (parsed.pathname === '/api/remote/input/ws'
|
|
2484
2496
|
&& (!bridgeAuthRequired || token === bridgeToken)) {
|
|
2485
2497
|
remoteInputWss.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -2740,6 +2752,15 @@ function buildRemoteFrameBinaryPacket(frameEvent) {
|
|
|
2740
2752
|
durationUs: Number.isFinite(Number(frame.durationUs)) ? Number(frame.durationUs) : 0,
|
|
2741
2753
|
streamFrameSeq: Number.isFinite(Number(frame.streamFrameSeq)) ? Number(frame.streamFrameSeq) : 0,
|
|
2742
2754
|
commandId: frame.commandId || ''
|
|
2755
|
+
,layoutVersion: Number(frame.layoutVersion || 0) || 0
|
|
2756
|
+
,atlasComposeMs: Number(frame.atlasComposeMs || 0) || 0
|
|
2757
|
+
,atlasPoolStarved: Number(frame.atlasPoolStarved || 0) || 0
|
|
2758
|
+
,atlasReadyTiles: Number(frame.atlasReadyTiles || 0) || 0
|
|
2759
|
+
,atlasTileCount: Number(frame.atlasTileCount || 0) || 0
|
|
2760
|
+
,hardwareEncoder: frame.hardwareEncoder || ''
|
|
2761
|
+
,platformProfile: frame.platformProfile || ''
|
|
2762
|
+
,workerGeneration: Number(frame.workerGeneration || 0) || 0
|
|
2763
|
+
,encoderGeneration: Number(frame.encoderGeneration || 0) || 0
|
|
2743
2764
|
};
|
|
2744
2765
|
const metaBuffer = Buffer.from(JSON.stringify(metadata), 'utf8');
|
|
2745
2766
|
const header = Buffer.allocUnsafe(4);
|
|
@@ -2747,7 +2768,84 @@ function buildRemoteFrameBinaryPacket(frameEvent) {
|
|
|
2747
2768
|
return Buffer.concat([header, metaBuffer, payload], header.length + metaBuffer.length + payload.length);
|
|
2748
2769
|
}
|
|
2749
2770
|
|
|
2771
|
+
function sendMode4AtlasFrame(ws, output) {
|
|
2772
|
+
if (!ws || ws.readyState !== 1 || !output?.payload?.length) return;
|
|
2773
|
+
const metadata = output.metadata || {};
|
|
2774
|
+
const frameSeq = Number(metadata.frameSeq || 0) || 0;
|
|
2775
|
+
const isKeyFrame = metadata.isKeyFrame === true;
|
|
2776
|
+
const workerGeneration = Math.max(1, Number(metadata.workerGeneration || 1) || 1);
|
|
2777
|
+
const encoderGeneration = Math.max(1, Number(metadata.encoderGeneration || 1) || 1);
|
|
2778
|
+
const streamId = `${ws.remoteAtlasStreamId}-w${workerGeneration}-e${encoderGeneration}`;
|
|
2779
|
+
const width = Number(metadata.width || 1920) || 1920;
|
|
2780
|
+
const height = Number(metadata.height || 1080) || 1080;
|
|
2781
|
+
const frameEvent = {
|
|
2782
|
+
kind: 'live',
|
|
2783
|
+
deviceId: '__mindexec_mode4_atlas__',
|
|
2784
|
+
mimeType: 'video/h264',
|
|
2785
|
+
byteLength: output.payload.length,
|
|
2786
|
+
payload: output.payload,
|
|
2787
|
+
frame: {
|
|
2788
|
+
deviceId: '__mindexec_mode4_atlas__', frameSeq,
|
|
2789
|
+
streamFrameSeq: Number(metadata.streamFrameSeq || frameSeq) || frameSeq,
|
|
2790
|
+
streamId, commandId: streamId, width, height,
|
|
2791
|
+
sourceWidth: width, sourceHeight: height,
|
|
2792
|
+
mimeType: 'video/h264', mode: 'mode4-h264-atlas', frameMode: 'mode4-h264-atlas',
|
|
2793
|
+
frameProfile: 'mode4-h264-atlas-v1', encoding: 'video-atlas', codec: 'h264',
|
|
2794
|
+
compression: 'video/h264', h264Format: 'annexb', isKeyFrame,
|
|
2795
|
+
chunkType: isKeyFrame ? 'key' : 'delta',
|
|
2796
|
+
timestampUs: Number(metadata.timestampUs || 0), durationUs: Number(metadata.durationUs || 0),
|
|
2797
|
+
fps: Number(metadata.fps || 20), capturedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
|
|
2798
|
+
hardwareEncoder: String(metadata.hardwareEncoder || ''), platformProfile: 'mode4-hub-atlas-worker',
|
|
2799
|
+
workerGeneration, encoderGeneration, layoutVersion: Number(metadata.layoutVersion || 0),
|
|
2800
|
+
atlasComposeMs: Number(metadata.composeMs || 0), atlasPoolStarved: Number(metadata.poolStarved || 0),
|
|
2801
|
+
atlasReadyTiles: Number(metadata.readyTileCount || 0), atlasTileCount: Number(metadata.tileCount || 0)
|
|
2802
|
+
}
|
|
2803
|
+
};
|
|
2804
|
+
const packet = buildRemoteFrameBinaryPacket(frameEvent);
|
|
2805
|
+
if (!packet || ws.bufferedAmount > 8 * 1024 * 1024) return;
|
|
2806
|
+
try { ws.send(packet, { binary: true }); } catch { /* client closed */ }
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
function normalizeRemoteAtlasMonitorSelections(value) {
|
|
2810
|
+
if (!value || typeof value !== 'object') return {};
|
|
2811
|
+
const result = {};
|
|
2812
|
+
for (const [key, entry] of Object.entries(value)) result[String(key)] = Math.max(0, Number(entry) || 0);
|
|
2813
|
+
return result;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
function configureMode4Atlas(ws, payload = {}) {
|
|
2817
|
+
const deviceIds = normalizeRemoteFrameWsDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId).slice(0, 100);
|
|
2818
|
+
const monitorSelections = normalizeRemoteAtlasMonitorSelections(payload.monitorSelections);
|
|
2819
|
+
const sharpTiles = Number(payload.tileWidth) >= 480 || Number(payload.tileHeight) >= 270;
|
|
2820
|
+
const tileWidth = sharpTiles ? 480 : 320;
|
|
2821
|
+
const tileHeight = sharpTiles ? 270 : 180;
|
|
2822
|
+
ws.remoteAtlasDeviceIds = new Set(deviceIds);
|
|
2823
|
+
ws.remoteAtlasInputOptions = { tileWidth, tileHeight, monitorSelections };
|
|
2824
|
+
ws.remoteAtlasSession.configure({
|
|
2825
|
+
deviceIds,
|
|
2826
|
+
width: clampRemoteFrameWsNumber(payload.width, 640, 3840, 1920),
|
|
2827
|
+
height: clampRemoteFrameWsNumber(payload.height, 360, 2160, 1080),
|
|
2828
|
+
tileWidth, tileHeight,
|
|
2829
|
+
fps: clampRemoteFrameWsNumber(payload.fps, 1, 30, 20)
|
|
2830
|
+
});
|
|
2831
|
+
for (const deviceId of deviceIds) startMode4AtlasInput(ws, deviceId, 'atlas-configure');
|
|
2832
|
+
sendRemoteFrameClientJson(ws, { type: 'Mode4AtlasSubscription', deviceIds, inputMode: 'mode2-lzo', outputMode: 'mode4-h264-atlas', tileWidth, tileHeight, timestamp: new Date().toISOString() });
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure') {
|
|
2836
|
+
const options = ws?.remoteAtlasInputOptions;
|
|
2837
|
+
if (!options || !ws.remoteAtlasDeviceIds?.has(deviceId)) return { ok: false, error: 'atlas-device-not-configured' };
|
|
2838
|
+
return remoteHub.startLiveStream(deviceId, {
|
|
2839
|
+
fps: 8, maxWidth: options.tileWidth, maxHeight: options.tileHeight, quality: 60,
|
|
2840
|
+
mode: 'mode2-lzo', frameMode: 'mode2-lzo', streamPurpose: 'atlas',
|
|
2841
|
+
monitorIndex: Number(options.monitorSelections?.[deviceId] || 0),
|
|
2842
|
+
streamId: `atlas-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
2843
|
+
restartReason: reason
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2750
2847
|
function broadcastRemoteBinaryFrame(frameEvent) {
|
|
2848
|
+
for (const session of remoteAtlasClients.values()) session.ingest(frameEvent);
|
|
2751
2849
|
const frame = frameEvent?.frame || {};
|
|
2752
2850
|
remoteFrameWsDiagnostics.framesReceived += 1;
|
|
2753
2851
|
remoteFrameWsDiagnostics.lastFrameAt = new Date().toISOString();
|
|
@@ -2908,6 +3006,44 @@ remoteFrameWss.on('connection', (ws, req) => {
|
|
|
2908
3006
|
});
|
|
2909
3007
|
});
|
|
2910
3008
|
|
|
3009
|
+
remoteAtlasWss.on('connection', (ws) => {
|
|
3010
|
+
ws.binaryType = 'arraybuffer';
|
|
3011
|
+
ws.remoteAtlasClientId = `ratlas-${++remoteAtlasClientSeq}`;
|
|
3012
|
+
ws.remoteAtlasStreamId = `mode4-atlas-${crypto.randomUUID()}`;
|
|
3013
|
+
try { ws._socket?.setNoDelay?.(true); } catch { /* ignore */ }
|
|
3014
|
+
const session = new Mode4AtlasSession({
|
|
3015
|
+
onFrame: output => sendMode4AtlasFrame(ws, output),
|
|
3016
|
+
onLayout: layout => sendRemoteFrameClientJson(ws, { ...layout, type: 'Mode4AtlasLayout' }),
|
|
3017
|
+
onStatus: status => sendRemoteFrameClientJson(ws, { ...status, type: 'Mode4AtlasStatus' })
|
|
3018
|
+
});
|
|
3019
|
+
ws.remoteAtlasSession = session;
|
|
3020
|
+
remoteAtlasClients.set(ws, session);
|
|
3021
|
+
let cleaned = false;
|
|
3022
|
+
const cleanup = () => {
|
|
3023
|
+
if (cleaned) return;
|
|
3024
|
+
cleaned = true;
|
|
3025
|
+
remoteAtlasClients.delete(ws);
|
|
3026
|
+
session.close();
|
|
3027
|
+
ws.remoteAtlasSession = null;
|
|
3028
|
+
};
|
|
3029
|
+
ws.on('message', data => {
|
|
3030
|
+
try {
|
|
3031
|
+
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
3032
|
+
if (payload?.type === 'subscribe' || payload?.type === 'configure') configureMode4Atlas(ws, payload);
|
|
3033
|
+
} catch {
|
|
3034
|
+
sendRemoteFrameClientJson(ws, { type: 'Mode4AtlasError', error: 'invalid-message' });
|
|
3035
|
+
}
|
|
3036
|
+
});
|
|
3037
|
+
ws.on('close', cleanup);
|
|
3038
|
+
ws.on('error', cleanup);
|
|
3039
|
+
sendRemoteFrameClientJson(ws, {
|
|
3040
|
+
type: 'Mode4AtlasSocketReady',
|
|
3041
|
+
protocol: 'mindexec.remote.atlas.h264.v1',
|
|
3042
|
+
clientId: ws.remoteAtlasClientId,
|
|
3043
|
+
timestamp: new Date().toISOString()
|
|
3044
|
+
});
|
|
3045
|
+
});
|
|
3046
|
+
|
|
2911
3047
|
function sendRemoteInputClientJson(ws, payload) {
|
|
2912
3048
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
2913
3049
|
return false;
|
|
@@ -12418,6 +12554,7 @@ app.get('/api/status', async (req, res) => {
|
|
|
12418
12554
|
wsToken,
|
|
12419
12555
|
wsPath: '/events',
|
|
12420
12556
|
remoteFrameWsPath: '/api/remote/frames/ws',
|
|
12557
|
+
remoteAtlasWsPath: '/api/remote/atlas/ws',
|
|
12421
12558
|
remoteInputWsPath: '/api/remote/input/ws',
|
|
12422
12559
|
bridgeToken,
|
|
12423
12560
|
bridgeTokenHeader,
|
|
@@ -14342,6 +14479,14 @@ async function shutdownBridge(signal) {
|
|
|
14342
14479
|
}
|
|
14343
14480
|
}
|
|
14344
14481
|
|
|
14482
|
+
for (const client of remoteAtlasClients.keys()) {
|
|
14483
|
+
try { client.close(); } catch { /* Ignore close race */ }
|
|
14484
|
+
}
|
|
14485
|
+
for (const session of remoteAtlasClients.values()) {
|
|
14486
|
+
try { session.close(); } catch { /* Ignore atlas worker shutdown errors */ }
|
|
14487
|
+
}
|
|
14488
|
+
remoteAtlasClients.clear();
|
|
14489
|
+
|
|
14345
14490
|
for (const client of remoteInputClients) {
|
|
14346
14491
|
try {
|
|
14347
14492
|
client.close();
|
|
@@ -14356,6 +14501,12 @@ async function shutdownBridge(signal) {
|
|
|
14356
14501
|
// Ignore if already closed
|
|
14357
14502
|
}
|
|
14358
14503
|
|
|
14504
|
+
try {
|
|
14505
|
+
remoteAtlasWss.close();
|
|
14506
|
+
} catch {
|
|
14507
|
+
// Ignore if already closed
|
|
14508
|
+
}
|
|
14509
|
+
|
|
14359
14510
|
try {
|
|
14360
14511
|
remoteInputWss.close();
|
|
14361
14512
|
} catch {
|