@houwert/conductor 0.25.0 → 0.26.0
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/dist/commands/stream-server.js +28 -0
- package/dist/daemon/h264-annexb.js +264 -0
- package/dist/daemon/server.js +47 -0
- package/dist/daemon/video-hub.js +58 -0
- package/dist/daemon/video-protocol.js +38 -0
- package/dist/daemon/video-server.js +88 -0
- package/dist/daemon/video-source.js +142 -0
- package/dist/drivers/bootstrap.js +38 -0
- package/dist/index.js +5 -0
- package/dist/runner.js +45 -0
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +15 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HELP = void 0;
|
|
4
|
+
exports.streamServer = streamServer;
|
|
5
|
+
exports.HELP = ` stream-server Start (if needed) and print the live video-stream WebSocket for the device`;
|
|
6
|
+
const runner_js_1 = require("../runner.js");
|
|
7
|
+
const output_js_1 = require("../output.js");
|
|
8
|
+
/**
|
|
9
|
+
* Ensure the device daemon + streaming-video socket are up and print the
|
|
10
|
+
* loopback WebSocket URL a viewer subscribes to for the live H.264 stream.
|
|
11
|
+
* See docs/device-video-stream.md.
|
|
12
|
+
*/
|
|
13
|
+
async function streamServer(opts = {}, sessionName = 'default') {
|
|
14
|
+
try {
|
|
15
|
+
const info = await (0, runner_js_1.streamServerInfo)(sessionName);
|
|
16
|
+
if (opts.json) {
|
|
17
|
+
(0, output_js_1.printData)(info, opts);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
console.log(`stream server [${info.device}] (${info.platform}, ${info.codec}): ${info.url}`);
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
(0, output_js_1.printError)(`stream-server — ${err instanceof Error ? err.message : String(err)}`, opts);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* H.264 Annex B streaming parser (daemon side).
|
|
4
|
+
*
|
|
5
|
+
* Splits the raw Annex B byte stream from the capture backend into access units,
|
|
6
|
+
* extracts SPS/PPS to build the stream config, and preserves each access unit
|
|
7
|
+
* **in Annex B form** (start codes intact) for forwarding to subscribers — the
|
|
8
|
+
* wire contract is "config + a sequence of Annex B access units, keyframe-led".
|
|
9
|
+
*
|
|
10
|
+
* Ported from Argus's electron/services/simulator/h264-parser.ts; the SPS
|
|
11
|
+
* bit-reader is identical, but access units are emitted as Annex B rather than
|
|
12
|
+
* converted to AVCC length-prefix (that conversion is left to WebCodecs clients).
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.H264AnnexBParser = void 0;
|
|
16
|
+
const NAL_SLICE = 1;
|
|
17
|
+
const NAL_IDR = 5;
|
|
18
|
+
const NAL_SPS = 7;
|
|
19
|
+
const NAL_PPS = 8;
|
|
20
|
+
// ── Exp-Golomb reader (minimal, for SPS parsing) ────────────────────────────
|
|
21
|
+
class ExpGolombReader {
|
|
22
|
+
constructor(data) {
|
|
23
|
+
this.data = data;
|
|
24
|
+
this.byteOffset = 0;
|
|
25
|
+
this.bitOffset = 0;
|
|
26
|
+
}
|
|
27
|
+
readBit() {
|
|
28
|
+
if (this.byteOffset >= this.data.length)
|
|
29
|
+
return 0;
|
|
30
|
+
const bit = (this.data[this.byteOffset] >> (7 - this.bitOffset)) & 1;
|
|
31
|
+
this.bitOffset++;
|
|
32
|
+
if (this.bitOffset === 8) {
|
|
33
|
+
this.bitOffset = 0;
|
|
34
|
+
this.byteOffset++;
|
|
35
|
+
}
|
|
36
|
+
return bit;
|
|
37
|
+
}
|
|
38
|
+
readBits(n) {
|
|
39
|
+
let val = 0;
|
|
40
|
+
for (let i = 0; i < n; i++)
|
|
41
|
+
val = (val << 1) | this.readBit();
|
|
42
|
+
return val;
|
|
43
|
+
}
|
|
44
|
+
readUE() {
|
|
45
|
+
let zeros = 0;
|
|
46
|
+
while (this.readBit() === 0 && zeros < 31)
|
|
47
|
+
zeros++;
|
|
48
|
+
if (zeros === 0)
|
|
49
|
+
return 0;
|
|
50
|
+
return (1 << zeros) - 1 + this.readBits(zeros);
|
|
51
|
+
}
|
|
52
|
+
readSE() {
|
|
53
|
+
const val = this.readUE();
|
|
54
|
+
return val % 2 === 0 ? -(val >> 1) : (val + 1) >> 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Parse the SPS fields needed for config. `body` is the NAL after the header byte. */
|
|
58
|
+
function parseSps(body) {
|
|
59
|
+
const r = new ExpGolombReader(body);
|
|
60
|
+
const profileIdc = r.readBits(8);
|
|
61
|
+
const constraintFlags = r.readBits(8);
|
|
62
|
+
const levelIdc = r.readBits(8);
|
|
63
|
+
r.readUE(); // seq_parameter_set_id
|
|
64
|
+
if ([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134].includes(profileIdc)) {
|
|
65
|
+
const chromaFormatIdc = r.readUE();
|
|
66
|
+
if (chromaFormatIdc === 3)
|
|
67
|
+
r.readBits(1);
|
|
68
|
+
r.readUE(); // bit_depth_luma_minus8
|
|
69
|
+
r.readUE(); // bit_depth_chroma_minus8
|
|
70
|
+
r.readBits(1); // qpprime_y_zero_transform_bypass_flag
|
|
71
|
+
if (r.readBits(1)) {
|
|
72
|
+
const count = chromaFormatIdc !== 3 ? 8 : 12;
|
|
73
|
+
for (let i = 0; i < count; i++) {
|
|
74
|
+
if (r.readBits(1)) {
|
|
75
|
+
const size = i < 6 ? 16 : 64;
|
|
76
|
+
let lastScale = 8;
|
|
77
|
+
let nextScale = 8;
|
|
78
|
+
for (let j = 0; j < size; j++) {
|
|
79
|
+
if (nextScale !== 0)
|
|
80
|
+
nextScale = (lastScale + r.readSE() + 256) % 256;
|
|
81
|
+
lastScale = nextScale === 0 ? lastScale : nextScale;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
r.readUE(); // log2_max_frame_num_minus4
|
|
88
|
+
const picOrderCntType = r.readUE();
|
|
89
|
+
if (picOrderCntType === 0) {
|
|
90
|
+
r.readUE();
|
|
91
|
+
}
|
|
92
|
+
else if (picOrderCntType === 1) {
|
|
93
|
+
r.readBits(1);
|
|
94
|
+
r.readSE();
|
|
95
|
+
r.readSE();
|
|
96
|
+
const n = r.readUE();
|
|
97
|
+
for (let i = 0; i < n; i++)
|
|
98
|
+
r.readSE();
|
|
99
|
+
}
|
|
100
|
+
r.readUE(); // max_num_ref_frames
|
|
101
|
+
r.readBits(1); // gaps_in_frame_num_value_allowed_flag
|
|
102
|
+
const picWidthInMbsMinus1 = r.readUE();
|
|
103
|
+
const picHeightInMapUnitsMinus1 = r.readUE();
|
|
104
|
+
const frameMbsOnlyFlag = r.readBits(1);
|
|
105
|
+
let width = (picWidthInMbsMinus1 + 1) * 16;
|
|
106
|
+
let height = (2 - frameMbsOnlyFlag) * (picHeightInMapUnitsMinus1 + 1) * 16;
|
|
107
|
+
if (!frameMbsOnlyFlag)
|
|
108
|
+
r.readBits(1);
|
|
109
|
+
r.readBits(1); // direct_8x8_inference_flag
|
|
110
|
+
if (r.readBits(1)) {
|
|
111
|
+
// frame_cropping_flag — 4:2:0 crop units are 2px
|
|
112
|
+
const cl = r.readUE();
|
|
113
|
+
const cr = r.readUE();
|
|
114
|
+
const ct = r.readUE();
|
|
115
|
+
const cb = r.readUE();
|
|
116
|
+
width -= (cl + cr) * 2;
|
|
117
|
+
height -= (ct + cb) * 2;
|
|
118
|
+
}
|
|
119
|
+
return { profileIdc, constraintFlags, levelIdc, width, height };
|
|
120
|
+
}
|
|
121
|
+
function buildCodecString(info) {
|
|
122
|
+
const pp = info.profileIdc.toString(16).padStart(2, '0');
|
|
123
|
+
const cc = info.constraintFlags.toString(16).padStart(2, '0');
|
|
124
|
+
const ll = info.levelIdc.toString(16).padStart(2, '0');
|
|
125
|
+
return `avc1.${pp}${cc}${ll}`;
|
|
126
|
+
}
|
|
127
|
+
/** AVCDecoderConfigurationRecord from raw SPS/PPS NAL bodies (with NAL header byte). */
|
|
128
|
+
function buildAvcC(sps, pps) {
|
|
129
|
+
const buf = Buffer.alloc(11 + sps.length + pps.length);
|
|
130
|
+
buf[0] = 1; // configurationVersion
|
|
131
|
+
buf[1] = sps[1]; // profile
|
|
132
|
+
buf[2] = sps[2]; // compatibility
|
|
133
|
+
buf[3] = sps[3]; // level
|
|
134
|
+
buf[4] = 0xff; // lengthSizeMinusOne = 3
|
|
135
|
+
buf[5] = 0xe1; // numOfSPS = 1
|
|
136
|
+
buf.writeUInt16BE(sps.length, 6);
|
|
137
|
+
Buffer.from(sps).copy(buf, 8);
|
|
138
|
+
const off = 8 + sps.length;
|
|
139
|
+
buf[off] = 1; // numOfPPS
|
|
140
|
+
buf.writeUInt16BE(pps.length, off + 1);
|
|
141
|
+
Buffer.from(pps).copy(buf, off + 3);
|
|
142
|
+
return buf;
|
|
143
|
+
}
|
|
144
|
+
/** Find the next Annex B start code (00 00 01 or 00 00 00 01). */
|
|
145
|
+
function findStartCode(buf, offset) {
|
|
146
|
+
for (let i = offset; i < buf.length - 2; i++) {
|
|
147
|
+
if (buf[i] === 0 && buf[i + 1] === 0) {
|
|
148
|
+
if (buf[i + 2] === 1)
|
|
149
|
+
return { index: i, length: 3 };
|
|
150
|
+
if (buf[i + 2] === 0 && i + 3 < buf.length && buf[i + 3] === 1)
|
|
151
|
+
return { index: i, length: 4 };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Streaming Annex B parser. Feed raw capture bytes with push(); emits config
|
|
158
|
+
* once SPS+PPS are seen, then one Annex B access unit per VCL frame.
|
|
159
|
+
*/
|
|
160
|
+
class H264AnnexBParser {
|
|
161
|
+
constructor(cbs, fps = 30) {
|
|
162
|
+
this.cbs = cbs;
|
|
163
|
+
this.buffer = Buffer.alloc(0);
|
|
164
|
+
this.sps = null;
|
|
165
|
+
this.pps = null;
|
|
166
|
+
this.config = null;
|
|
167
|
+
// NALs (each Annex-B, start-code prefixed) buffered for the current access unit.
|
|
168
|
+
this.au = [];
|
|
169
|
+
this.auIsKey = false;
|
|
170
|
+
this.fps = fps;
|
|
171
|
+
}
|
|
172
|
+
reset() {
|
|
173
|
+
this.buffer = Buffer.alloc(0);
|
|
174
|
+
this.sps = null;
|
|
175
|
+
this.pps = null;
|
|
176
|
+
this.config = null;
|
|
177
|
+
this.au = [];
|
|
178
|
+
this.auIsKey = false;
|
|
179
|
+
}
|
|
180
|
+
push(chunk) {
|
|
181
|
+
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
182
|
+
this.processBuffer();
|
|
183
|
+
}
|
|
184
|
+
processBuffer() {
|
|
185
|
+
let sc = findStartCode(this.buffer, 0);
|
|
186
|
+
if (!sc)
|
|
187
|
+
return;
|
|
188
|
+
while (true) {
|
|
189
|
+
const next = findStartCode(this.buffer, sc.index + sc.length);
|
|
190
|
+
if (!next)
|
|
191
|
+
break; // incomplete NAL — wait for more data
|
|
192
|
+
const nal = this.buffer.subarray(sc.index + sc.length, next.index);
|
|
193
|
+
// Keep the whole unit including start code so subscribers get Annex B.
|
|
194
|
+
const withStartCode = this.buffer.subarray(sc.index, next.index);
|
|
195
|
+
this.processNal(nal, withStartCode);
|
|
196
|
+
sc = next;
|
|
197
|
+
}
|
|
198
|
+
// Retain the trailing (unterminated) NAL for the next push.
|
|
199
|
+
if (sc.index > 0)
|
|
200
|
+
this.buffer = Buffer.from(this.buffer.subarray(sc.index));
|
|
201
|
+
// Guard against unbounded growth on a stuck stream.
|
|
202
|
+
if (this.buffer.length > 4 * 1024 * 1024) {
|
|
203
|
+
this.buffer = Buffer.from(this.buffer.subarray(-1024 * 1024));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
processNal(nal, withStartCode) {
|
|
207
|
+
if (nal.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
const nalType = nal[0] & 0x1f;
|
|
210
|
+
if (nalType === NAL_SPS) {
|
|
211
|
+
this.sps = Buffer.from(nal);
|
|
212
|
+
this.emitConfigIfReady();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (nalType === NAL_PPS) {
|
|
216
|
+
this.pps = Buffer.from(nal);
|
|
217
|
+
this.emitConfigIfReady();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (nalType === NAL_IDR || nalType === NAL_SLICE) {
|
|
221
|
+
// Flush any previously buffered AU before starting this VCL frame.
|
|
222
|
+
if (this.au.length > 0)
|
|
223
|
+
this.emitAccessUnit();
|
|
224
|
+
this.auIsKey = nalType === NAL_IDR;
|
|
225
|
+
this.au.push(Buffer.from(withStartCode));
|
|
226
|
+
this.emitAccessUnit();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// SEI / AUD / other — attach to the current access unit.
|
|
230
|
+
this.au.push(Buffer.from(withStartCode));
|
|
231
|
+
}
|
|
232
|
+
emitAccessUnit() {
|
|
233
|
+
if (this.au.length === 0)
|
|
234
|
+
return;
|
|
235
|
+
if (!this.config) {
|
|
236
|
+
// Can't decode without config yet — drop until SPS/PPS arrive.
|
|
237
|
+
this.au = [];
|
|
238
|
+
this.auIsKey = false;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const data = this.au.length === 1 ? this.au[0] : Buffer.concat(this.au);
|
|
242
|
+
this.cbs.onFrame(data, this.auIsKey);
|
|
243
|
+
this.au = [];
|
|
244
|
+
this.auIsKey = false;
|
|
245
|
+
}
|
|
246
|
+
emitConfigIfReady() {
|
|
247
|
+
if (this.config || !this.sps || !this.pps)
|
|
248
|
+
return;
|
|
249
|
+
const info = parseSps(this.sps.subarray(1));
|
|
250
|
+
this.config = {
|
|
251
|
+
codec: 'h264',
|
|
252
|
+
width: info.width,
|
|
253
|
+
height: info.height,
|
|
254
|
+
rotation: 0,
|
|
255
|
+
fps: this.fps,
|
|
256
|
+
codecString: buildCodecString(info),
|
|
257
|
+
sps: this.sps,
|
|
258
|
+
pps: this.pps,
|
|
259
|
+
avcC: buildAvcC(this.sps, this.pps),
|
|
260
|
+
};
|
|
261
|
+
this.cbs.onConfig(this.config);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
exports.H264AnnexBParser = H264AnnexBParser;
|
package/dist/daemon/server.js
CHANGED
|
@@ -28,6 +28,8 @@ const input_server_js_1 = require("./input-server.js");
|
|
|
28
28
|
const input_router_js_1 = require("./input-router.js");
|
|
29
29
|
const input_backends_js_1 = require("./input-backends.js");
|
|
30
30
|
const ios_hid_js_1 = require("../drivers/ios-hid.js");
|
|
31
|
+
const video_server_js_1 = require("./video-server.js");
|
|
32
|
+
const video_source_js_1 = require("./video-source.js");
|
|
31
33
|
const log_collector_js_1 = require("./log-collector.js");
|
|
32
34
|
const session_js_1 = require("../session.js");
|
|
33
35
|
const sessionName = process.argv[2] ?? 'default';
|
|
@@ -86,6 +88,8 @@ let logCollector = null;
|
|
|
86
88
|
let inputServer = null;
|
|
87
89
|
let inputPort = null;
|
|
88
90
|
let hidClient = null;
|
|
91
|
+
let videoServer = null;
|
|
92
|
+
let streamPort = null;
|
|
89
93
|
/**
|
|
90
94
|
* Start the streaming-input WebSocket server for the current device, once its
|
|
91
95
|
* driver is up. iOS/tvOS/Android only — web keeps its per-event REST path.
|
|
@@ -139,6 +143,33 @@ async function startInputServerForPlatform() {
|
|
|
139
143
|
inputPort = port;
|
|
140
144
|
dlog(`input server listening on ${port}`);
|
|
141
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Start the streaming-video WebSocket server for the current device, once its
|
|
148
|
+
* driver is up. iOS/tvOS only for now (host-side SimulatorKit capture); the
|
|
149
|
+
* binary must be built/downloaded. Capture is lazy: it doesn't spawn until the
|
|
150
|
+
* first subscriber connects, and stops when the last one leaves.
|
|
151
|
+
*/
|
|
152
|
+
async function startVideoServerForPlatform() {
|
|
153
|
+
if (videoServer)
|
|
154
|
+
return;
|
|
155
|
+
if (driverPlatform !== 'ios' && driverPlatform !== 'tvos')
|
|
156
|
+
return;
|
|
157
|
+
const binary = await (0, bootstrap_js_1.getCaptureBinaryPath)();
|
|
158
|
+
if (!binary) {
|
|
159
|
+
dlog('video capture binary not present — stream server disabled for this device');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const port = await (0, bootstrap_js_1.getStreamPort)(sessionName);
|
|
163
|
+
videoServer = await (0, video_server_js_1.startVideoServer)({
|
|
164
|
+
port,
|
|
165
|
+
device: sessionName,
|
|
166
|
+
platform: driverPlatform,
|
|
167
|
+
makeSource: (hub) => new video_source_js_1.IOSCaptureSource(binary, sessionName, hub, dlog),
|
|
168
|
+
dlog,
|
|
169
|
+
});
|
|
170
|
+
streamPort = port;
|
|
171
|
+
dlog(`video server listening on ${port}`);
|
|
172
|
+
}
|
|
142
173
|
const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
|
|
143
174
|
let _restartInProgress = false;
|
|
144
175
|
let _driverStarted = false;
|
|
@@ -239,6 +270,15 @@ async function main() {
|
|
|
239
270
|
}
|
|
240
271
|
inputServer = null;
|
|
241
272
|
}
|
|
273
|
+
if (videoServer) {
|
|
274
|
+
try {
|
|
275
|
+
await videoServer.close();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* ok */
|
|
279
|
+
}
|
|
280
|
+
videoServer = null;
|
|
281
|
+
}
|
|
242
282
|
if (hidClient) {
|
|
243
283
|
hidClient.stop();
|
|
244
284
|
hidClient = null;
|
|
@@ -366,6 +406,7 @@ async function main() {
|
|
|
366
406
|
platform: driverPlatform,
|
|
367
407
|
driverPort,
|
|
368
408
|
inputPort,
|
|
409
|
+
streamPort,
|
|
369
410
|
cdpUrl: cdpUrl ?? null,
|
|
370
411
|
cdpTargetId: cdpTargetId ?? null,
|
|
371
412
|
chromiumCdpPort: driverPlatform === 'web' ? (0, web_server_js_1.getCdpPort)() : null,
|
|
@@ -419,6 +460,12 @@ async function main() {
|
|
|
419
460
|
catch (err) {
|
|
420
461
|
dlog(`Input server startup error: ${err instanceof Error ? err.message : String(err)}`);
|
|
421
462
|
}
|
|
463
|
+
try {
|
|
464
|
+
await startVideoServerForPlatform();
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
dlog(`Video server startup error: ${err instanceof Error ? err.message : String(err)}`);
|
|
468
|
+
}
|
|
422
469
|
}
|
|
423
470
|
// Start collecting logs once the driver is (or was already) running.
|
|
424
471
|
if (_driverStarted) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fan-out hub for a single device's H.264 stream: one capture, N subscribers.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors Argus's videoForward.ts. The last-seen config and the last keyframe
|
|
6
|
+
* access unit are cached so a late subscriber can configure its decoder and
|
|
7
|
+
* start rendering immediately, without waiting for the next natural keyframe.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.VideoHub = void 0;
|
|
11
|
+
class VideoHub {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.listeners = new Set();
|
|
14
|
+
this.lastConfig = null;
|
|
15
|
+
this.lastKeyframe = null;
|
|
16
|
+
}
|
|
17
|
+
get subscriberCount() {
|
|
18
|
+
return this.listeners.size;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Subscribe. If a config (and keyframe) are already cached they are delivered
|
|
22
|
+
* synchronously so the decoder starts without waiting. Returns an unsubscribe.
|
|
23
|
+
*/
|
|
24
|
+
subscribe(listener) {
|
|
25
|
+
this.listeners.add(listener);
|
|
26
|
+
if (this.lastConfig) {
|
|
27
|
+
listener.onConfig(this.lastConfig);
|
|
28
|
+
if (this.lastKeyframe)
|
|
29
|
+
listener.onFrame(this.lastKeyframe, true);
|
|
30
|
+
}
|
|
31
|
+
return () => this.listeners.delete(listener);
|
|
32
|
+
}
|
|
33
|
+
emitConfig(config) {
|
|
34
|
+
this.lastConfig = config;
|
|
35
|
+
// A config change invalidates the cached keyframe (dimensions may differ).
|
|
36
|
+
this.lastKeyframe = null;
|
|
37
|
+
for (const l of this.listeners)
|
|
38
|
+
l.onConfig(config);
|
|
39
|
+
}
|
|
40
|
+
emitFrame(annexB, keyFrame) {
|
|
41
|
+
if (keyFrame)
|
|
42
|
+
this.lastKeyframe = annexB;
|
|
43
|
+
for (const l of this.listeners)
|
|
44
|
+
l.onFrame(annexB, keyFrame);
|
|
45
|
+
}
|
|
46
|
+
/** Current coded dimensions, if known. */
|
|
47
|
+
dims() {
|
|
48
|
+
return this.lastConfig
|
|
49
|
+
? { width: this.lastConfig.width, height: this.lastConfig.height }
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
/** Drop cached state when capture ends. */
|
|
53
|
+
clear() {
|
|
54
|
+
this.lastConfig = null;
|
|
55
|
+
this.lastKeyframe = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.VideoHub = VideoHub;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Streaming device-video protocol (conductor ⇄ subscribers).
|
|
4
|
+
*
|
|
5
|
+
* The capture counterpart to input-protocol.ts. A persistent per-device
|
|
6
|
+
* WebSocket carries a low-latency H.264 stream: on connect the server sends one
|
|
7
|
+
* JSON `config` frame (codec + dimensions + SPS/PPS), then a sequence of
|
|
8
|
+
* **binary** frames, each a single H.264 Annex B access unit (keyframe-led). One
|
|
9
|
+
* capture feeds N subscribers (fan-out); a late joiner always gets the cached
|
|
10
|
+
* config + a fresh keyframe immediately.
|
|
11
|
+
*
|
|
12
|
+
* Web devices advertise a different codec (JPEG over CDP screencast) in the same
|
|
13
|
+
* `config` frame so clients pick a decoder from the wire, not the URL.
|
|
14
|
+
*
|
|
15
|
+
* See docs/device-video-stream.md for the design.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.VIDEO_PROTOCOL_VERSION = void 0;
|
|
19
|
+
exports.toConfigFrame = toConfigFrame;
|
|
20
|
+
exports.VIDEO_PROTOCOL_VERSION = 1;
|
|
21
|
+
/** Serialize an in-daemon config into the JSON frame sent to a subscriber. */
|
|
22
|
+
function toConfigFrame(cfg, device, platform) {
|
|
23
|
+
return {
|
|
24
|
+
t: 'config',
|
|
25
|
+
protocol: exports.VIDEO_PROTOCOL_VERSION,
|
|
26
|
+
device,
|
|
27
|
+
platform,
|
|
28
|
+
codec: cfg.codec,
|
|
29
|
+
width: cfg.width,
|
|
30
|
+
height: cfg.height,
|
|
31
|
+
rotation: cfg.rotation,
|
|
32
|
+
fps: cfg.fps,
|
|
33
|
+
codecString: cfg.codecString,
|
|
34
|
+
sps: cfg.sps ? cfg.sps.toString('base64') : undefined,
|
|
35
|
+
pps: cfg.pps ? cfg.pps.toString('base64') : undefined,
|
|
36
|
+
avcC: cfg.avcC ? cfg.avcC.toString('base64') : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startVideoServer = startVideoServer;
|
|
4
|
+
/**
|
|
5
|
+
* Streaming video WebSocket server (one per device daemon).
|
|
6
|
+
*
|
|
7
|
+
* The capture counterpart to input-server.ts. Bound to loopback; N clients open
|
|
8
|
+
* a socket and receive one JSON `config` frame followed by binary H.264 Annex B
|
|
9
|
+
* access units. All subscribers share one capture via a VideoHub — the first
|
|
10
|
+
* connection starts the capture backend, the last one out stops it. A late
|
|
11
|
+
* joiner gets the cached config + keyframe immediately (hub responsibility).
|
|
12
|
+
*/
|
|
13
|
+
const ws_1 = require("ws");
|
|
14
|
+
const video_protocol_js_1 = require("./video-protocol.js");
|
|
15
|
+
const video_hub_js_1 = require("./video-hub.js");
|
|
16
|
+
function sendJson(ws, frame) {
|
|
17
|
+
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
18
|
+
ws.send(JSON.stringify(frame));
|
|
19
|
+
}
|
|
20
|
+
function startVideoServer(opts) {
|
|
21
|
+
const host = opts.host ?? '127.0.0.1';
|
|
22
|
+
const dlog = opts.dlog ?? (() => { });
|
|
23
|
+
const hub = new video_hub_js_1.VideoHub();
|
|
24
|
+
const source = opts.makeSource(hub);
|
|
25
|
+
// Serialize start/stop so a rapid connect/disconnect churn can't race the
|
|
26
|
+
// capture backend into an inconsistent state.
|
|
27
|
+
let lifecycle = Promise.resolve();
|
|
28
|
+
const runExclusive = (fn) => {
|
|
29
|
+
lifecycle = lifecycle.then(fn, fn);
|
|
30
|
+
return lifecycle;
|
|
31
|
+
};
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const wss = new ws_1.WebSocketServer({ host, port: opts.port });
|
|
34
|
+
wss.on('error', reject);
|
|
35
|
+
wss.on('listening', () => {
|
|
36
|
+
const addr = wss.address();
|
|
37
|
+
const boundPort = typeof addr === 'object' && addr ? addr.port : opts.port;
|
|
38
|
+
dlog(`video socket ready at ws://${host}:${boundPort}/stream (${opts.platform})`);
|
|
39
|
+
resolve({
|
|
40
|
+
port: boundPort,
|
|
41
|
+
hub,
|
|
42
|
+
close: () => runExclusive(async () => {
|
|
43
|
+
for (const client of wss.clients)
|
|
44
|
+
client.close();
|
|
45
|
+
await source.stop().catch(() => { });
|
|
46
|
+
await new Promise((res) => wss.close(() => res()));
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
wss.on('connection', (ws) => {
|
|
51
|
+
const listener = {
|
|
52
|
+
onConfig: (config) => sendJson(ws, (0, video_protocol_js_1.toConfigFrame)(config, opts.device, opts.platform)),
|
|
53
|
+
onFrame: (annexB, _keyFrame) => {
|
|
54
|
+
if (ws.readyState === ws_1.WebSocket.OPEN)
|
|
55
|
+
ws.send(annexB);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const unsubscribe = hub.subscribe(listener);
|
|
59
|
+
const wasFirst = hub.subscriberCount === 1;
|
|
60
|
+
if (wasFirst) {
|
|
61
|
+
void runExclusive(async () => {
|
|
62
|
+
try {
|
|
63
|
+
await source.start();
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
dlog(`capture start error: ${err instanceof Error ? err.message : String(err)}`);
|
|
67
|
+
sendJson(ws, {
|
|
68
|
+
t: 'notice',
|
|
69
|
+
code: 'capture_failed',
|
|
70
|
+
msg: 'capture backend failed to start',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const teardown = () => {
|
|
76
|
+
unsubscribe();
|
|
77
|
+
if (hub.subscriberCount === 0) {
|
|
78
|
+
void runExclusive(() => source.stop().catch(() => { }));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
ws.on('close', teardown);
|
|
82
|
+
ws.on('error', (err) => {
|
|
83
|
+
dlog(`video socket error: ${err instanceof Error ? err.message : String(err)}`);
|
|
84
|
+
});
|
|
85
|
+
// Clients are pure subscribers; inbound messages are ignored.
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.IOSCaptureSource = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Capture sources — the producer half of the video stream. A source drives one
|
|
9
|
+
* platform's capture and feeds decoded config/access-units into a VideoHub. The
|
|
10
|
+
* video server ref-counts subscribers and calls start()/stop() so exactly one
|
|
11
|
+
* capture runs while ≥1 subscriber is connected (last one out stops it).
|
|
12
|
+
*
|
|
13
|
+
* iOS/tvOS: spawns the host-side `conductor-capture` binary (packages/ios-capture),
|
|
14
|
+
* which captures the Simulator framebuffer via SimulatorKit, VideoToolbox-encodes
|
|
15
|
+
* H.264, and serves a raw Annex B TCP stream. We connect to that port, parse the
|
|
16
|
+
* elementary stream, and forward.
|
|
17
|
+
*/
|
|
18
|
+
const child_process_1 = require("child_process");
|
|
19
|
+
const net_1 = __importDefault(require("net"));
|
|
20
|
+
const readline_1 = __importDefault(require("readline"));
|
|
21
|
+
const h264_annexb_js_1 = require("./h264-annexb.js");
|
|
22
|
+
/** Restart the capture backend at most this often if the TCP stream drops. */
|
|
23
|
+
const RESTART_BACKOFF_MS = 1000;
|
|
24
|
+
class IOSCaptureSource {
|
|
25
|
+
constructor(binaryPath, udid, hub, dlog = () => { }) {
|
|
26
|
+
this.binaryPath = binaryPath;
|
|
27
|
+
this.udid = udid;
|
|
28
|
+
this.hub = hub;
|
|
29
|
+
this.dlog = dlog;
|
|
30
|
+
this.proc = null;
|
|
31
|
+
this.rl = null;
|
|
32
|
+
this.socket = null;
|
|
33
|
+
this.parser = null;
|
|
34
|
+
this.pending = [];
|
|
35
|
+
this.active = false;
|
|
36
|
+
this.restarting = false;
|
|
37
|
+
}
|
|
38
|
+
async start() {
|
|
39
|
+
if (this.active)
|
|
40
|
+
return;
|
|
41
|
+
this.active = true;
|
|
42
|
+
await this.spawnAndCapture();
|
|
43
|
+
}
|
|
44
|
+
async spawnAndCapture() {
|
|
45
|
+
this.proc = (0, child_process_1.spawn)(this.binaryPath, [], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
46
|
+
this.rl = readline_1.default.createInterface({ input: this.proc.stdout });
|
|
47
|
+
this.rl.on('line', (line) => {
|
|
48
|
+
const resolve = this.pending.shift();
|
|
49
|
+
if (!resolve)
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
resolve(JSON.parse(line));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
resolve({ ok: false, error: `bad response: ${line}` });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
this.proc.on('exit', (code) => {
|
|
59
|
+
this.dlog(`capture backend exited (code=${code ?? '?'})`);
|
|
60
|
+
this.proc = null;
|
|
61
|
+
this.rl = null;
|
|
62
|
+
while (this.pending.length)
|
|
63
|
+
this.pending.shift()({ ok: false, error: 'capture exited' });
|
|
64
|
+
this.maybeRestart();
|
|
65
|
+
});
|
|
66
|
+
const res = await this.send({ cmd: 'start_capture', udid: this.udid });
|
|
67
|
+
if (!res.ok || typeof res.port !== 'number') {
|
|
68
|
+
throw new Error(`start_capture failed: ${res.error ?? 'no port returned'}`);
|
|
69
|
+
}
|
|
70
|
+
this.connectStream(res.port);
|
|
71
|
+
this.dlog(`capture started, streaming H.264 from 127.0.0.1:${res.port}`);
|
|
72
|
+
}
|
|
73
|
+
connectStream(port) {
|
|
74
|
+
const parser = new h264_annexb_js_1.H264AnnexBParser({
|
|
75
|
+
onConfig: (config) => {
|
|
76
|
+
this.dlog(`H.264 config ${config.codecString} ${config.width}x${config.height}`);
|
|
77
|
+
this.hub.emitConfig(config);
|
|
78
|
+
},
|
|
79
|
+
onFrame: (annexB, keyFrame) => this.hub.emitFrame(annexB, keyFrame),
|
|
80
|
+
});
|
|
81
|
+
this.parser = parser;
|
|
82
|
+
const socket = net_1.default.connect(port, '127.0.0.1', () => {
|
|
83
|
+
this.dlog(`connected to capture stream on ${port}`);
|
|
84
|
+
});
|
|
85
|
+
socket.on('data', (chunk) => parser.push(chunk));
|
|
86
|
+
socket.on('error', (err) => this.dlog(`capture stream error: ${err.message}`));
|
|
87
|
+
socket.on('close', () => {
|
|
88
|
+
this.dlog('capture stream closed');
|
|
89
|
+
this.maybeRestart();
|
|
90
|
+
});
|
|
91
|
+
this.socket = socket;
|
|
92
|
+
}
|
|
93
|
+
/** If capture died while subscribers still want it, relaunch after a short backoff. */
|
|
94
|
+
maybeRestart() {
|
|
95
|
+
if (!this.active || this.restarting)
|
|
96
|
+
return;
|
|
97
|
+
this.restarting = true;
|
|
98
|
+
this.teardownTransport();
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
this.restarting = false;
|
|
101
|
+
if (!this.active)
|
|
102
|
+
return;
|
|
103
|
+
this.dlog('restarting capture backend');
|
|
104
|
+
this.spawnAndCapture().catch((err) => this.dlog(`capture restart failed: ${err.message}`));
|
|
105
|
+
}, RESTART_BACKOFF_MS).unref?.();
|
|
106
|
+
}
|
|
107
|
+
teardownTransport() {
|
|
108
|
+
this.socket?.destroy();
|
|
109
|
+
this.socket = null;
|
|
110
|
+
this.parser?.reset();
|
|
111
|
+
this.parser = null;
|
|
112
|
+
}
|
|
113
|
+
async stop() {
|
|
114
|
+
if (!this.active)
|
|
115
|
+
return;
|
|
116
|
+
this.active = false;
|
|
117
|
+
try {
|
|
118
|
+
if (this.proc)
|
|
119
|
+
await this.send({ cmd: 'stop_capture' }).catch(() => { });
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
this.teardownTransport();
|
|
123
|
+
this.rl?.close();
|
|
124
|
+
this.rl = null;
|
|
125
|
+
this.proc?.kill();
|
|
126
|
+
this.proc = null;
|
|
127
|
+
this.hub.clear();
|
|
128
|
+
this.dlog('capture stopped');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
send(req) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
if (!this.proc || !this.proc.stdin?.writable) {
|
|
134
|
+
resolve({ ok: false, error: 'capture process not running' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.pending.push(resolve);
|
|
138
|
+
this.proc.stdin.write(JSON.stringify(req) + '\n');
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
exports.IOSCaptureSource = IOSCaptureSource;
|
|
@@ -6,8 +6,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.detectPlatform = detectPlatform;
|
|
7
7
|
exports.getDriverPort = getDriverPort;
|
|
8
8
|
exports.getInputPort = getInputPort;
|
|
9
|
+
exports.getStreamPort = getStreamPort;
|
|
9
10
|
exports.getInprocDylibPath = getInprocDylibPath;
|
|
10
11
|
exports.getHidBinaryPath = getHidBinaryPath;
|
|
12
|
+
exports.getCaptureBinaryPath = getCaptureBinaryPath;
|
|
11
13
|
exports.installDriver = installDriver;
|
|
12
14
|
exports.isSimulatorBooted = isSimulatorBooted;
|
|
13
15
|
exports.isPortOpen = isPortOpen;
|
|
@@ -93,6 +95,7 @@ const ANDROID_BASE_PORT = 3763;
|
|
|
93
95
|
const WEB_BASE_PORT = 4075;
|
|
94
96
|
const VEGA_BASE_PORT = 5075;
|
|
95
97
|
const INPUT_BASE_PORT = 7075;
|
|
98
|
+
const STREAM_BASE_PORT = 8075;
|
|
96
99
|
const PORT_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'ports.json');
|
|
97
100
|
const PORT_LOCK = PORT_FILE + '.lock';
|
|
98
101
|
const PORT_LOCK_TIMEOUT_MS = 5000;
|
|
@@ -200,6 +203,27 @@ async function getInputPort(deviceId) {
|
|
|
200
203
|
return port;
|
|
201
204
|
});
|
|
202
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Assign and persist a streaming-video WebSocket port for a device. Its own
|
|
208
|
+
* namespace: a device has a driver port (control), an input port (pointer/key
|
|
209
|
+
* frames), and this stream port (H.264 video fan-out).
|
|
210
|
+
*/
|
|
211
|
+
async function getStreamPort(deviceId) {
|
|
212
|
+
return withPortLock(() => {
|
|
213
|
+
const state = readPortState();
|
|
214
|
+
if (!state.streamAssignments)
|
|
215
|
+
state.streamAssignments = {};
|
|
216
|
+
if (state.nextStreamPort === undefined)
|
|
217
|
+
state.nextStreamPort = STREAM_BASE_PORT;
|
|
218
|
+
if (state.streamAssignments[deviceId] !== undefined) {
|
|
219
|
+
return state.streamAssignments[deviceId];
|
|
220
|
+
}
|
|
221
|
+
const port = state.nextStreamPort++;
|
|
222
|
+
state.streamAssignments[deviceId] = port;
|
|
223
|
+
writePortState(state);
|
|
224
|
+
return port;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
203
227
|
// ── Driver paths (bundled dev fallback + runtime download cache) ──────────────
|
|
204
228
|
/**
|
|
205
229
|
* Walk up from __dirname to find the package root (the directory containing
|
|
@@ -274,6 +298,20 @@ async function getHidBinaryPath() {
|
|
|
274
298
|
const p = path_1.default.join(dir, 'ios-hid', 'conductor-hid');
|
|
275
299
|
return fs_1.default.existsSync(p) ? p : null;
|
|
276
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Absolute path to the host-side Simulator video capture binary
|
|
303
|
+
* (`ios-capture/conductor-capture`), built by
|
|
304
|
+
* `packages/ios-capture/tools/build-capture.sh`. Captures the framebuffer via
|
|
305
|
+
* SimulatorKit and serves a VideoToolbox H.264 Annex B stream. Returns null if
|
|
306
|
+
* it hasn't been built (streaming falls back to unavailable).
|
|
307
|
+
*/
|
|
308
|
+
async function getCaptureBinaryPath() {
|
|
309
|
+
const dir = await getDriversDir().catch(() => null);
|
|
310
|
+
if (!dir)
|
|
311
|
+
return null;
|
|
312
|
+
const p = path_1.default.join(dir, 'ios-capture', 'conductor-capture');
|
|
313
|
+
return fs_1.default.existsSync(p) ? p : null;
|
|
314
|
+
}
|
|
277
315
|
async function ensureDriversCache(pkgRoot) {
|
|
278
316
|
const pkgJsonPath = path_1.default.join(pkgRoot, 'package.json');
|
|
279
317
|
const pkg = JSON.parse(fs_1.default.readFileSync(pkgJsonPath, 'utf-8'));
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,7 @@ const press_key_js_1 = require("./commands/press-key.js");
|
|
|
30
30
|
const session_js_1 = require("./commands/session.js");
|
|
31
31
|
const daemon_js_1 = require("./commands/daemon.js");
|
|
32
32
|
const input_server_js_1 = require("./commands/input-server.js");
|
|
33
|
+
const stream_server_js_1 = require("./commands/stream-server.js");
|
|
33
34
|
const install_js_1 = require("./commands/install.js");
|
|
34
35
|
const init_js_1 = require("./commands/init.js");
|
|
35
36
|
const device_pool_js_1 = require("./commands/device-pool.js");
|
|
@@ -135,6 +136,7 @@ const COMMAND_HELP = {
|
|
|
135
136
|
'daemon-stop': daemon_js_1.HELP_DAEMON_STOP,
|
|
136
137
|
'daemon-status': daemon_js_1.HELP_DAEMON_STATUS,
|
|
137
138
|
'input-server': input_server_js_1.HELP,
|
|
139
|
+
'stream-server': stream_server_js_1.HELP,
|
|
138
140
|
'device-pool': device_pool_js_1.HELP,
|
|
139
141
|
'run-parallel': run_parallel_js_1.HELP,
|
|
140
142
|
'run-sequence': run_sequence_js_1.HELP,
|
|
@@ -847,6 +849,9 @@ async function main() {
|
|
|
847
849
|
case 'input-server':
|
|
848
850
|
exitCode = await (0, input_server_js_1.inputServer)(opts, sessionName);
|
|
849
851
|
break;
|
|
852
|
+
case 'stream-server':
|
|
853
|
+
exitCode = await (0, stream_server_js_1.streamServer)(opts, sessionName);
|
|
854
|
+
break;
|
|
850
855
|
case 'device-pool': {
|
|
851
856
|
const acquire = argv['acquire'];
|
|
852
857
|
const release = argv['release'];
|
package/dist/runner.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.detectFirstDevice = detectFirstDevice;
|
|
|
4
4
|
exports.getDriver = getDriver;
|
|
5
5
|
exports.prewarmDriver = prewarmDriver;
|
|
6
6
|
exports.inputServerInfo = inputServerInfo;
|
|
7
|
+
exports.streamServerInfo = streamServerInfo;
|
|
7
8
|
exports.runDirect = runDirect;
|
|
8
9
|
exports.spawnCommand = spawnCommand;
|
|
9
10
|
exports.runInlineFlow = runInlineFlow;
|
|
@@ -276,6 +277,50 @@ async function inputServerInfo(sessionName = 'default') {
|
|
|
276
277
|
}
|
|
277
278
|
throw new Error(`Input server for ${deviceId} did not come up within timeout.`);
|
|
278
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Resolve the streaming-video socket for a session's device, starting the
|
|
282
|
+
* daemon (and its driver + video server) if needed. Returns the loopback
|
|
283
|
+
* WebSocket URL a viewer subscribes to. Throws if the platform has no live
|
|
284
|
+
* stream, the capture binary isn't built, or the port never comes up.
|
|
285
|
+
*/
|
|
286
|
+
async function streamServerInfo(sessionName = 'default') {
|
|
287
|
+
const deviceId = await resolveDeviceId(sessionName);
|
|
288
|
+
if (!deviceId) {
|
|
289
|
+
throw new Error('No device found. Connect a device or start a simulator, then run again.');
|
|
290
|
+
}
|
|
291
|
+
const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
|
|
292
|
+
if (platform !== 'ios' && platform !== 'tvos') {
|
|
293
|
+
throw new Error(`Live video streaming is not yet available for ${platform} devices.`);
|
|
294
|
+
}
|
|
295
|
+
await (0, client_js_1.startDaemon)(deviceId);
|
|
296
|
+
// The video server starts just after the input server — poll status until its port appears.
|
|
297
|
+
const start = Date.now();
|
|
298
|
+
const deadline = start + 60000;
|
|
299
|
+
while (Date.now() < deadline) {
|
|
300
|
+
const status = await (0, client_js_1.fetchDaemonStatus)(deviceId);
|
|
301
|
+
if (status && typeof status.streamPort === 'number') {
|
|
302
|
+
return {
|
|
303
|
+
device: deviceId,
|
|
304
|
+
platform,
|
|
305
|
+
streamPort: status.streamPort,
|
|
306
|
+
url: `ws://127.0.0.1:${status.streamPort}/stream?device=${encodeURIComponent(deviceId)}&platform=${platform}`,
|
|
307
|
+
codec: 'h264',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// A daemon whose input server is up but that still reports no streamPort
|
|
311
|
+
// after a grace period means the capture binary isn't built/available —
|
|
312
|
+
// fail fast rather than waiting out the full timeout.
|
|
313
|
+
if (status &&
|
|
314
|
+
status.streamPort === null &&
|
|
315
|
+
typeof status.inputPort === 'number' &&
|
|
316
|
+
Date.now() - start > 5000) {
|
|
317
|
+
throw new Error(`Video capture backend is not available for ${deviceId} ` +
|
|
318
|
+
`(the conductor-capture binary is missing from the installed drivers).`);
|
|
319
|
+
}
|
|
320
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
321
|
+
}
|
|
322
|
+
throw new Error(`Video server for ${deviceId} did not come up within timeout.`);
|
|
323
|
+
}
|
|
279
324
|
/**
|
|
280
325
|
* Execute a function with the driver for the given session.
|
|
281
326
|
* Returns a RunResult for consistent error handling across commands.
|
package/package.json
CHANGED
|
@@ -52,6 +52,7 @@ conductor assert-visible "Dashboard"
|
|
|
52
52
|
| `conductor clipboard read` / `clipboard write <text>` / `paste` | Clipboard (iOS) |
|
|
53
53
|
| `conductor list-options [command]` | List valid values for enumerated params |
|
|
54
54
|
| `conductor input-server` | Start (if needed) and print the streaming-input WebSocket URL for the device |
|
|
55
|
+
| `conductor stream-server` | Start (if needed) and print the live video-stream WebSocket URL for the device |
|
|
55
56
|
|
|
56
57
|
## Streaming input (host IDEs)
|
|
57
58
|
|
|
@@ -65,6 +66,20 @@ accepts normalized (0..1) frames: `pointer{id,phase,x,y}`, `key{code,mods,down}`
|
|
|
65
66
|
owns coord→device translation and keymaps. For scripted, one-off actions use the
|
|
66
67
|
discrete commands above — this is for interactive host UIs.
|
|
67
68
|
|
|
69
|
+
## Streaming video (host IDEs / device viewers)
|
|
70
|
+
|
|
71
|
+
For live device mirroring, `conductor stream-server` ensures the daemon + capture
|
|
72
|
+
backend are up and prints the loopback URL
|
|
73
|
+
(`ws://127.0.0.1:<port>/stream?device=<id>&platform=<ios|tvos|android|web>`; also
|
|
74
|
+
in `daemon-status --json` as `streamPort`). One capture fans out to N subscribers.
|
|
75
|
+
On connect the server sends a JSON `config` frame
|
|
76
|
+
(`{t:"config",codec:"h264",width,height,rotation,fps,sps,pps,avcC,codecString}`),
|
|
77
|
+
then **binary** frames — each one H.264 Annex B access unit, keyframe-led; a late
|
|
78
|
+
joiner gets the cached config + a keyframe immediately. iOS/tvOS only for now
|
|
79
|
+
(host-side SimulatorKit → VideoToolbox capture); Android/web are follow-ons.
|
|
80
|
+
This is capture only — input stays on `input-server`. For a still image use
|
|
81
|
+
`screenshot` / `capture-ui`; this socket is for continuous low-latency mirroring.
|
|
82
|
+
|
|
68
83
|
## Discovering valid values
|
|
69
84
|
|
|
70
85
|
Several commands only accept a fixed set of values (`press-key <key>`,
|