@elizaos/plugin-streaming 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/api/stream-persistence.d.ts +68 -0
- package/dist/api/stream-persistence.js +181 -0
- package/dist/api/stream-persistence.js.map +1 -0
- package/dist/api/stream-route-state.d.ts +59 -0
- package/dist/api/stream-route-state.js +1 -0
- package/dist/api/stream-route-state.js.map +1 -0
- package/dist/api/stream-routes.d.ts +43 -0
- package/dist/api/stream-routes.js +609 -0
- package/dist/api/stream-routes.js.map +1 -0
- package/dist/api/streaming-text.d.ts +10 -0
- package/dist/api/streaming-text.js +88 -0
- package/dist/api/streaming-text.js.map +1 -0
- package/dist/api/streaming-types.d.ts +2 -0
- package/dist/api/streaming-types.js +1 -0
- package/dist/api/streaming-types.js.map +1 -0
- package/dist/api/tts-routes.d.ts +25 -0
- package/dist/api/tts-routes.js +158 -0
- package/dist/api/tts-routes.js.map +1 -0
- package/dist/core.d.ts +164 -0
- package/dist/core.js +495 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +147 -0
- package/dist/index.js.map +1 -0
- package/dist/services/stream-manager.d.ts +124 -0
- package/dist/services/stream-manager.js +531 -0
- package/dist/services/stream-manager.js.map +1 -0
- package/dist/services/tts-stream-bridge.d.ts +103 -0
- package/dist/services/tts-stream-bridge.js +327 -0
- package/dist/services/tts-stream-bridge.js.map +1 -0
- package/package.json +106 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
formatError,
|
|
4
|
+
logger,
|
|
5
|
+
readRequestBody,
|
|
6
|
+
readRequestBodyBuffer,
|
|
7
|
+
sendJson,
|
|
8
|
+
sendJsonError
|
|
9
|
+
} from "@elizaos/core";
|
|
10
|
+
import {
|
|
11
|
+
getHeadlessCaptureConfig,
|
|
12
|
+
readStreamSettings,
|
|
13
|
+
seedOverlayDefaults,
|
|
14
|
+
validateStreamSettings,
|
|
15
|
+
writeStreamSettings
|
|
16
|
+
} from "./stream-persistence.js";
|
|
17
|
+
const PLUGIN_BROWSER_PACKAGE = "@elizaos/plugin-browser";
|
|
18
|
+
async function loadBrowserCapture() {
|
|
19
|
+
return await import(PLUGIN_BROWSER_PACKAGE);
|
|
20
|
+
}
|
|
21
|
+
const MJPEG_BOUNDARY = "elizaframe";
|
|
22
|
+
const mjpegSubscribers = /* @__PURE__ */ new Set();
|
|
23
|
+
let latestFrame = null;
|
|
24
|
+
function pushFrameToSubscribers(frame) {
|
|
25
|
+
latestFrame = frame;
|
|
26
|
+
if (mjpegSubscribers.size === 0) return;
|
|
27
|
+
const header = `--${MJPEG_BOUNDARY}\r
|
|
28
|
+
Content-Type: image/jpeg\r
|
|
29
|
+
Content-Length: ${frame.length}\r
|
|
30
|
+
\r
|
|
31
|
+
`;
|
|
32
|
+
const headerBuf = Buffer.from(header, "ascii");
|
|
33
|
+
const trailer = Buffer.from("\r\n", "ascii");
|
|
34
|
+
const chunk = Buffer.concat([headerBuf, frame, trailer]);
|
|
35
|
+
const failed = [];
|
|
36
|
+
for (const sub of mjpegSubscribers) {
|
|
37
|
+
try {
|
|
38
|
+
sub.write(chunk);
|
|
39
|
+
} catch {
|
|
40
|
+
failed.push(sub);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const sub of failed) {
|
|
44
|
+
mjpegSubscribers.delete(sub);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function getActiveDestination(state) {
|
|
48
|
+
if (state.activeDestinationId) {
|
|
49
|
+
return state.destinations.get(state.activeDestinationId);
|
|
50
|
+
}
|
|
51
|
+
const first = state.destinations.values().next();
|
|
52
|
+
return first.done ? void 0 : first.value;
|
|
53
|
+
}
|
|
54
|
+
function json(res, data, status = 200) {
|
|
55
|
+
sendJson(res, data, status);
|
|
56
|
+
}
|
|
57
|
+
function error(res, message, status) {
|
|
58
|
+
sendJsonError(res, message, status);
|
|
59
|
+
}
|
|
60
|
+
function detectCaptureMode() {
|
|
61
|
+
const explicit = process.env.STREAM_MODE;
|
|
62
|
+
if (explicit === "ui" || explicit === "pipe") return "pipe";
|
|
63
|
+
if (explicit === "x11grab") return "x11grab";
|
|
64
|
+
if (explicit === "avfoundation" || explicit === "screen")
|
|
65
|
+
return "avfoundation";
|
|
66
|
+
if (explicit === "file") return "file";
|
|
67
|
+
if ("__elizaScreenCapture" in globalThis) {
|
|
68
|
+
return "pipe";
|
|
69
|
+
}
|
|
70
|
+
if (process.platform === "linux" && process.env.DISPLAY) return "x11grab";
|
|
71
|
+
if (process.platform === "darwin") return "avfoundation";
|
|
72
|
+
return "file";
|
|
73
|
+
}
|
|
74
|
+
async function ensureXvfb(display, resolution) {
|
|
75
|
+
if (process.platform !== "linux") return false;
|
|
76
|
+
if (!/^:\d+$/.test(display)) {
|
|
77
|
+
logger.warn(
|
|
78
|
+
`[stream] Invalid display format: ${display} (expected :<number>)`
|
|
79
|
+
);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const [w, h] = resolution.split("x");
|
|
83
|
+
if (!w || !h || !/^\d+$/.test(w) || !/^\d+$/.test(h)) {
|
|
84
|
+
logger.warn(`[stream] Invalid resolution for Xvfb: ${resolution}`);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (process.env.DISPLAY === display) return true;
|
|
88
|
+
try {
|
|
89
|
+
const { execSync } = await import("node:child_process");
|
|
90
|
+
try {
|
|
91
|
+
execSync(`xdpyinfo -display ${display}`, {
|
|
92
|
+
stdio: "ignore",
|
|
93
|
+
timeout: 3e3
|
|
94
|
+
});
|
|
95
|
+
logger.info(`[stream] Xvfb already running on display ${display}`);
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
const { spawn: spawnProc } = await import("node:child_process");
|
|
100
|
+
const xvfb = spawnProc(
|
|
101
|
+
"Xvfb",
|
|
102
|
+
[display, "-screen", "0", `${w}x${h}x24`, "-ac"],
|
|
103
|
+
{
|
|
104
|
+
stdio: "ignore",
|
|
105
|
+
detached: true
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
xvfb.unref();
|
|
109
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
110
|
+
logger.info(`[stream] Started Xvfb on display ${display} (${resolution})`);
|
|
111
|
+
process.env.DISPLAY = display;
|
|
112
|
+
return true;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger.warn(`[stream] Failed to start Xvfb: ${err}`);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function startStreamPipeline(state, rtmpUrl, rtmpKey) {
|
|
119
|
+
if (!/^rtmps?:\/\//i.test(rtmpUrl)) {
|
|
120
|
+
throw new Error("RTMP URL must use rtmp:// or rtmps:// scheme");
|
|
121
|
+
}
|
|
122
|
+
const activeDest = getActiveDestination(state);
|
|
123
|
+
if (activeDest) {
|
|
124
|
+
seedOverlayDefaults(activeDest);
|
|
125
|
+
}
|
|
126
|
+
const destId = activeDest?.id ?? null;
|
|
127
|
+
const mode = detectCaptureMode();
|
|
128
|
+
const audioSource = process.env.STREAM_AUDIO_SOURCE ?? "silent";
|
|
129
|
+
const audioDevice = process.env.STREAM_AUDIO_DEVICE;
|
|
130
|
+
const volume = parseInt(process.env.STREAM_VOLUME ?? "80", 10);
|
|
131
|
+
const resolution = "1280x720";
|
|
132
|
+
const baseConfig = {
|
|
133
|
+
rtmpUrl,
|
|
134
|
+
rtmpKey,
|
|
135
|
+
resolution,
|
|
136
|
+
bitrate: "1500k",
|
|
137
|
+
audioSource,
|
|
138
|
+
audioDevice,
|
|
139
|
+
volume
|
|
140
|
+
};
|
|
141
|
+
switch (mode) {
|
|
142
|
+
case "pipe": {
|
|
143
|
+
logger.info("[stream] Capture mode: pipe (desktop UI)");
|
|
144
|
+
await state.streamManager.start({
|
|
145
|
+
...baseConfig,
|
|
146
|
+
inputMode: "pipe",
|
|
147
|
+
framerate: 15
|
|
148
|
+
});
|
|
149
|
+
if (state.screenCapture && !state.screenCapture.isFrameCaptureActive()) {
|
|
150
|
+
try {
|
|
151
|
+
const captureOpts = {
|
|
152
|
+
fps: 15,
|
|
153
|
+
quality: 70,
|
|
154
|
+
endpoint: "/api/stream/frame"
|
|
155
|
+
};
|
|
156
|
+
if (state.activeStreamSource.type !== "stream-tab" && state.activeStreamSource.url) {
|
|
157
|
+
captureOpts.gameUrl = state.activeStreamSource.url;
|
|
158
|
+
}
|
|
159
|
+
await state.screenCapture.startFrameCapture(captureOpts);
|
|
160
|
+
logger.info("[stream] Auto-started desktop frame capture");
|
|
161
|
+
} catch (err) {
|
|
162
|
+
logger.warn(`[stream] Failed to auto-start frame capture: ${err}`);
|
|
163
|
+
}
|
|
164
|
+
} else if (!state.screenCapture) {
|
|
165
|
+
logger.warn(
|
|
166
|
+
"[stream] ScreenCaptureManager not available -- frame capture must be started manually"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "x11grab": {
|
|
172
|
+
const display = process.env.STREAM_DISPLAY ?? ":99";
|
|
173
|
+
logger.info(`[stream] Capture mode: x11grab (display ${display})`);
|
|
174
|
+
await ensureXvfb(display, resolution);
|
|
175
|
+
const captureUrl = state.captureUrl ?? process.env.STREAM_CAPTURE_URL ?? `http://127.0.0.1:${state.port ?? 2138}`;
|
|
176
|
+
try {
|
|
177
|
+
const { startBrowserCapture } = await loadBrowserCapture();
|
|
178
|
+
await startBrowserCapture({
|
|
179
|
+
url: captureUrl,
|
|
180
|
+
width: 1280,
|
|
181
|
+
height: 720,
|
|
182
|
+
quality: 70,
|
|
183
|
+
...getHeadlessCaptureConfig(destId)
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
logger.warn(`[stream] Browser launch on ${display} failed: ${err}`);
|
|
187
|
+
}
|
|
188
|
+
await state.streamManager.start({
|
|
189
|
+
...baseConfig,
|
|
190
|
+
inputMode: "x11grab",
|
|
191
|
+
display,
|
|
192
|
+
framerate: 30
|
|
193
|
+
});
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "avfoundation": {
|
|
197
|
+
const videoDevice = process.env.STREAM_VIDEO_DEVICE ?? "3";
|
|
198
|
+
logger.info(
|
|
199
|
+
`[stream] Capture mode: avfoundation (device ${videoDevice})`
|
|
200
|
+
);
|
|
201
|
+
await state.streamManager.start({
|
|
202
|
+
...baseConfig,
|
|
203
|
+
inputMode: "avfoundation",
|
|
204
|
+
videoDevice,
|
|
205
|
+
framerate: 30
|
|
206
|
+
});
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
default: {
|
|
210
|
+
const captureUrl = state.captureUrl ?? process.env.STREAM_CAPTURE_URL ?? `http://127.0.0.1:${state.port ?? 2138}`;
|
|
211
|
+
logger.info(
|
|
212
|
+
`[stream] Capture mode: file (browser capture -> ${captureUrl})`
|
|
213
|
+
);
|
|
214
|
+
const { startBrowserCapture, FRAME_FILE } = await loadBrowserCapture();
|
|
215
|
+
try {
|
|
216
|
+
await startBrowserCapture({
|
|
217
|
+
url: captureUrl,
|
|
218
|
+
width: 1280,
|
|
219
|
+
height: 720,
|
|
220
|
+
quality: 70,
|
|
221
|
+
...getHeadlessCaptureConfig(destId)
|
|
222
|
+
});
|
|
223
|
+
await new Promise((resolve) => {
|
|
224
|
+
const check = setInterval(() => {
|
|
225
|
+
try {
|
|
226
|
+
if (fs.existsSync(FRAME_FILE) && fs.statSync(FRAME_FILE).size > 0) {
|
|
227
|
+
clearInterval(check);
|
|
228
|
+
resolve(true);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
}, 200);
|
|
233
|
+
setTimeout(() => {
|
|
234
|
+
clearInterval(check);
|
|
235
|
+
resolve(false);
|
|
236
|
+
}, 1e4);
|
|
237
|
+
});
|
|
238
|
+
} catch (captureErr) {
|
|
239
|
+
logger.warn(`[stream] Browser capture failed: ${captureErr}`);
|
|
240
|
+
}
|
|
241
|
+
await state.streamManager.start({
|
|
242
|
+
...baseConfig,
|
|
243
|
+
inputMode: "file",
|
|
244
|
+
frameFile: FRAME_FILE,
|
|
245
|
+
framerate: 30
|
|
246
|
+
});
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { inputMode: mode || "file", audioSource };
|
|
251
|
+
}
|
|
252
|
+
async function handleStreamRoute(req, res, pathname, method, state) {
|
|
253
|
+
if (!pathname.startsWith("/api/stream/") && !pathname.startsWith("/api/streaming/")) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
if (method === "POST" && pathname === "/api/stream/frame") {
|
|
257
|
+
try {
|
|
258
|
+
const buf = await readRequestBodyBuffer(req, {
|
|
259
|
+
maxBytes: 2 * 1024 * 1024
|
|
260
|
+
});
|
|
261
|
+
if (!buf || buf.length === 0) {
|
|
262
|
+
error(res, "Empty frame", 400);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
pushFrameToSubscribers(buf);
|
|
266
|
+
if (state.streamManager.isRunning()) {
|
|
267
|
+
state.streamManager.writeFrame(buf);
|
|
268
|
+
}
|
|
269
|
+
res.writeHead(200);
|
|
270
|
+
res.end();
|
|
271
|
+
} catch {
|
|
272
|
+
error(res, "Frame write failed", 500);
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (method === "GET" && pathname === "/api/stream/screen") {
|
|
277
|
+
res.writeHead(200, {
|
|
278
|
+
"Content-Type": `multipart/x-mixed-replace; boundary=${MJPEG_BOUNDARY}`,
|
|
279
|
+
"Cache-Control": "no-store, no-cache",
|
|
280
|
+
Connection: "close",
|
|
281
|
+
"Access-Control-Allow-Origin": "*"
|
|
282
|
+
});
|
|
283
|
+
mjpegSubscribers.add(res);
|
|
284
|
+
if (latestFrame) {
|
|
285
|
+
const header = `--${MJPEG_BOUNDARY}\r
|
|
286
|
+
Content-Type: image/jpeg\r
|
|
287
|
+
Content-Length: ${latestFrame.length}\r
|
|
288
|
+
\r
|
|
289
|
+
`;
|
|
290
|
+
res.write(
|
|
291
|
+
Buffer.concat([
|
|
292
|
+
Buffer.from(header, "ascii"),
|
|
293
|
+
latestFrame,
|
|
294
|
+
Buffer.from("\r\n", "ascii")
|
|
295
|
+
])
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
const cleanup = () => {
|
|
299
|
+
mjpegSubscribers.delete(res);
|
|
300
|
+
};
|
|
301
|
+
req.on("close", cleanup);
|
|
302
|
+
req.on("error", cleanup);
|
|
303
|
+
res.on("close", cleanup);
|
|
304
|
+
res.on("error", cleanup);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
if (method === "POST" && pathname === "/api/stream/live") {
|
|
308
|
+
if (state.streamManager.isRunning()) {
|
|
309
|
+
const health = state.streamManager.getHealth();
|
|
310
|
+
json(res, {
|
|
311
|
+
ok: true,
|
|
312
|
+
live: true,
|
|
313
|
+
message: "Already streaming",
|
|
314
|
+
...health
|
|
315
|
+
});
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
const dest = getActiveDestination(state);
|
|
319
|
+
if (!dest) {
|
|
320
|
+
error(res, "No streaming destination configured", 400);
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const { rtmpUrl, rtmpKey } = await dest.getCredentials();
|
|
325
|
+
const { inputMode, audioSource } = await startStreamPipeline(
|
|
326
|
+
state,
|
|
327
|
+
rtmpUrl,
|
|
328
|
+
rtmpKey
|
|
329
|
+
);
|
|
330
|
+
await dest.onStreamStart?.();
|
|
331
|
+
json(res, {
|
|
332
|
+
ok: true,
|
|
333
|
+
live: true,
|
|
334
|
+
rtmpUrl,
|
|
335
|
+
inputMode,
|
|
336
|
+
audioSource,
|
|
337
|
+
destination: dest.id
|
|
338
|
+
});
|
|
339
|
+
} catch (err) {
|
|
340
|
+
error(res, formatError(err), 500);
|
|
341
|
+
}
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
if (method === "POST" && pathname === "/api/stream/offline") {
|
|
345
|
+
try {
|
|
346
|
+
try {
|
|
347
|
+
const { stopBrowserCapture } = await loadBrowserCapture();
|
|
348
|
+
await stopBrowserCapture();
|
|
349
|
+
} catch {
|
|
350
|
+
}
|
|
351
|
+
if (state.streamManager.isRunning()) {
|
|
352
|
+
await state.streamManager.stop();
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await getActiveDestination(state)?.onStreamStop?.();
|
|
356
|
+
} catch {
|
|
357
|
+
}
|
|
358
|
+
json(res, { ok: true, live: false });
|
|
359
|
+
} catch (err) {
|
|
360
|
+
error(res, String(err), 500);
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
if (method === "POST" && pathname === "/api/stream/start") {
|
|
365
|
+
try {
|
|
366
|
+
const bodyStr = await readRequestBody(req);
|
|
367
|
+
const body = typeof bodyStr === "string" ? JSON.parse(bodyStr) : bodyStr;
|
|
368
|
+
const rtmpUrl = body?.rtmpUrl;
|
|
369
|
+
const rtmpKey = body?.rtmpKey;
|
|
370
|
+
if (!rtmpUrl || !rtmpKey) {
|
|
371
|
+
error(res, "rtmpUrl and rtmpKey are required", 400);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
if (!/^rtmps?:\/\//i.test(rtmpUrl)) {
|
|
375
|
+
error(res, "rtmpUrl must use rtmp:// or rtmps:// scheme", 400);
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
const VALID_INPUT_MODES = ["testsrc", "avfoundation", "pipe"];
|
|
379
|
+
const inputMode = body?.inputMode ?? "testsrc";
|
|
380
|
+
if (!VALID_INPUT_MODES.includes(inputMode)) {
|
|
381
|
+
error(
|
|
382
|
+
res,
|
|
383
|
+
`inputMode must be one of: ${VALID_INPUT_MODES.join(", ")}`,
|
|
384
|
+
400
|
|
385
|
+
);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
const resolution = body?.resolution || "1280x720";
|
|
389
|
+
if (!/^\d{3,4}x\d{3,4}$/.test(resolution)) {
|
|
390
|
+
error(res, "resolution must match WIDTHxHEIGHT (e.g. 1280x720)", 400);
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
const bitrate = body?.bitrate || "2500k";
|
|
394
|
+
if (!/^\d+k$/.test(bitrate)) {
|
|
395
|
+
error(res, "bitrate must match NUMBERk (e.g. 2500k)", 400);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
const framerate = body?.framerate ?? 30;
|
|
399
|
+
if (typeof framerate !== "number" || !Number.isInteger(framerate) || framerate < 1 || framerate > 60) {
|
|
400
|
+
error(res, "framerate must be an integer between 1 and 60", 400);
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
await state.streamManager.start({
|
|
404
|
+
rtmpUrl,
|
|
405
|
+
rtmpKey,
|
|
406
|
+
inputMode,
|
|
407
|
+
resolution,
|
|
408
|
+
bitrate,
|
|
409
|
+
framerate
|
|
410
|
+
});
|
|
411
|
+
json(res, { ok: true, message: "Stream started" });
|
|
412
|
+
} catch (err) {
|
|
413
|
+
error(res, String(err), 500);
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
if (method === "POST" && pathname === "/api/stream/stop") {
|
|
418
|
+
try {
|
|
419
|
+
const result = await state.streamManager.stop();
|
|
420
|
+
json(res, { ok: true, ...result });
|
|
421
|
+
} catch (err) {
|
|
422
|
+
error(res, formatError(err), 500);
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
if (method === "GET" && pathname === "/api/stream/status") {
|
|
427
|
+
const health = state.streamManager.getHealth();
|
|
428
|
+
const activeDest = getActiveDestination(state);
|
|
429
|
+
const destInfo = activeDest ? { id: activeDest.id, name: activeDest.name } : null;
|
|
430
|
+
json(res, { ok: true, ...health, destination: destInfo });
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
if (method === "POST" && pathname === "/api/stream/volume") {
|
|
434
|
+
try {
|
|
435
|
+
const body = await readRequestBody(req);
|
|
436
|
+
const parsed = typeof body === "string" ? JSON.parse(body) : body;
|
|
437
|
+
const level = parsed?.volume;
|
|
438
|
+
if (typeof level !== "number" || !Number.isFinite(level) || level < 0 || level > 100) {
|
|
439
|
+
error(res, "volume must be a number between 0 and 100", 400);
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
await state.streamManager.setVolume(level);
|
|
443
|
+
json(res, {
|
|
444
|
+
ok: true,
|
|
445
|
+
volume: state.streamManager.getVolume(),
|
|
446
|
+
muted: state.streamManager.isMuted()
|
|
447
|
+
});
|
|
448
|
+
} catch (err) {
|
|
449
|
+
error(res, String(err), 500);
|
|
450
|
+
}
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
if (method === "POST" && pathname === "/api/stream/mute") {
|
|
454
|
+
try {
|
|
455
|
+
await state.streamManager.mute();
|
|
456
|
+
json(res, {
|
|
457
|
+
ok: true,
|
|
458
|
+
muted: true,
|
|
459
|
+
volume: state.streamManager.getVolume()
|
|
460
|
+
});
|
|
461
|
+
} catch (err) {
|
|
462
|
+
error(res, formatError(err), 500);
|
|
463
|
+
}
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
if (method === "POST" && pathname === "/api/stream/unmute") {
|
|
467
|
+
try {
|
|
468
|
+
await state.streamManager.unmute();
|
|
469
|
+
json(res, {
|
|
470
|
+
ok: true,
|
|
471
|
+
muted: false,
|
|
472
|
+
volume: state.streamManager.getVolume()
|
|
473
|
+
});
|
|
474
|
+
} catch (err) {
|
|
475
|
+
error(res, formatError(err), 500);
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
if (method === "GET" && pathname === "/api/streaming/destinations") {
|
|
480
|
+
const destinations = Array.from(state.destinations.values()).map((d) => ({
|
|
481
|
+
id: d.id,
|
|
482
|
+
name: d.name,
|
|
483
|
+
active: d.id === (state.activeDestinationId ?? state.destinations.keys().next().value)
|
|
484
|
+
}));
|
|
485
|
+
json(res, { ok: true, destinations });
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
if (method === "POST" && pathname === "/api/streaming/destination") {
|
|
489
|
+
try {
|
|
490
|
+
const body = await readRequestBody(req);
|
|
491
|
+
const parsed = typeof body === "string" ? JSON.parse(body) : body;
|
|
492
|
+
const destinationId = parsed?.destinationId;
|
|
493
|
+
if (!destinationId) {
|
|
494
|
+
error(res, "destinationId is required", 400);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
const target = state.destinations.get(destinationId);
|
|
498
|
+
if (target) {
|
|
499
|
+
state.activeDestinationId = destinationId;
|
|
500
|
+
json(res, {
|
|
501
|
+
ok: true,
|
|
502
|
+
destination: { id: target.id, name: target.name }
|
|
503
|
+
});
|
|
504
|
+
} else {
|
|
505
|
+
error(res, `Unknown destination: ${destinationId}`, 404);
|
|
506
|
+
}
|
|
507
|
+
} catch (err) {
|
|
508
|
+
error(res, formatError(err), 500);
|
|
509
|
+
}
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
if (method === "GET" && pathname === "/api/stream/settings") {
|
|
513
|
+
try {
|
|
514
|
+
const settings = readStreamSettings();
|
|
515
|
+
json(res, { ok: true, settings });
|
|
516
|
+
} catch (err) {
|
|
517
|
+
error(res, String(err), 500);
|
|
518
|
+
}
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
if (method === "POST" && pathname === "/api/stream/settings") {
|
|
522
|
+
try {
|
|
523
|
+
const body = await readRequestBody(req);
|
|
524
|
+
const parsed = typeof body === "string" ? JSON.parse(body) : body;
|
|
525
|
+
const result = validateStreamSettings(parsed?.settings);
|
|
526
|
+
if (result.error || !result.settings) {
|
|
527
|
+
error(res, result.error ?? "Invalid settings", 400);
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
const existing = readStreamSettings();
|
|
531
|
+
const merged = { ...existing, ...result.settings };
|
|
532
|
+
writeStreamSettings(merged);
|
|
533
|
+
if (typeof merged.avatarIndex === "number" && Number.isFinite(merged.avatarIndex)) {
|
|
534
|
+
try {
|
|
535
|
+
state.mirrorStreamAvatarToElizaConfig?.(merged.avatarIndex);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
logger.warn(
|
|
538
|
+
`[stream] mirrorStreamAvatarToElizaConfig failed (stream settings still saved): ${err instanceof Error ? err.message : String(err)}`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
json(res, { ok: true, settings: merged });
|
|
543
|
+
} catch (err) {
|
|
544
|
+
error(res, String(err), 500);
|
|
545
|
+
}
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
if (method === "GET" && pathname === "/api/stream/source") {
|
|
549
|
+
json(res, { source: state.activeStreamSource });
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
if (method === "POST" && pathname === "/api/stream/source") {
|
|
553
|
+
try {
|
|
554
|
+
const body = await readRequestBody(req);
|
|
555
|
+
const { sourceType, customUrl } = JSON.parse(
|
|
556
|
+
typeof body === "string" ? body : JSON.stringify(body)
|
|
557
|
+
);
|
|
558
|
+
if (!["stream-tab", "game", "custom-url"].includes(sourceType)) {
|
|
559
|
+
error(res, "Invalid sourceType", 400);
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
if (sourceType === "custom-url" && !customUrl) {
|
|
563
|
+
error(res, "customUrl required for custom-url source", 400);
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
if (sourceType === "game" && !customUrl) {
|
|
567
|
+
error(res, "customUrl required for game source", 400);
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
if ((sourceType === "game" || sourceType === "custom-url") && customUrl && !/^https?:\/\//i.test(customUrl)) {
|
|
571
|
+
error(res, "customUrl must use http:// or https:// scheme", 400);
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
if (state.screenCapture?.isFrameCaptureActive()) {
|
|
575
|
+
state.screenCapture.stopFrameCapture?.();
|
|
576
|
+
}
|
|
577
|
+
const captureOpts = {
|
|
578
|
+
fps: 15,
|
|
579
|
+
quality: 70,
|
|
580
|
+
endpoint: "/api/stream/frame"
|
|
581
|
+
};
|
|
582
|
+
if (sourceType === "game" || sourceType === "custom-url") {
|
|
583
|
+
captureOpts.gameUrl = customUrl;
|
|
584
|
+
}
|
|
585
|
+
state.activeStreamSource = { type: sourceType, url: customUrl };
|
|
586
|
+
if (state.streamManager.isRunning() && state.screenCapture) {
|
|
587
|
+
try {
|
|
588
|
+
await state.screenCapture.startFrameCapture(captureOpts);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
logger.warn(
|
|
591
|
+
`[stream] Failed to restart frame capture after source switch: ${err}`
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
json(res, { ok: true, source: state.activeStreamSource });
|
|
596
|
+
} catch (err) {
|
|
597
|
+
error(res, String(err), 500);
|
|
598
|
+
}
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
export {
|
|
604
|
+
detectCaptureMode,
|
|
605
|
+
ensureXvfb,
|
|
606
|
+
getActiveDestination,
|
|
607
|
+
handleStreamRoute
|
|
608
|
+
};
|
|
609
|
+
//# sourceMappingURL=stream-routes.js.map
|