@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.
@@ -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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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>`,