#!/usr/bin/env node
/**
* DreamStack Screencast — CDP Capture Agent
*
* Streams any web page to DreamStack panels via Chrome DevTools Protocol.
* Zero changes to the target app — just point at any URL.
*
* Usage:
* node capture.js [url] [--headless] [--fps=N] [--quality=N]
*
* Examples:
* node capture.js http://localhost:3000
* node capture.js https://react.dev --headless --fps=30
*/
const CDP = require('chrome-remote-interface');
const { WebSocketServer } = require('ws');
const http = require('http');
const { spawn } = require('child_process');
// ─── Config ───
const TARGET_URL = process.argv[2] || 'http://localhost:3000';
const WIDTH = 800;
const HEIGHT = 1280;
const WS_PORT = 9300;
const MONITOR_PORT = 9301;
const CDP_PORT = 9222;
const QUALITY = parseInt((process.argv.find(a => a.startsWith('--quality=')) || '').split('=')[1] || '75');
const MAX_FPS = parseInt((process.argv.find(a => a.startsWith('--fps=')) || '').split('=')[1] || '30');
const HEADLESS = process.argv.includes('--headless');
const clients = new Set();
let frameCount = 0;
let bytesSent = 0;
const t0 = Date.now();
// ─── 1. Launch Chrome ───
function launchChrome() {
return new Promise((resolve, reject) => {
const args = [
`--remote-debugging-port=${CDP_PORT}`,
`--window-size=${WIDTH},${HEIGHT}`,
'--disable-gpu', '--no-first-run', '--no-default-browser-check',
'--disable-extensions', '--disable-translate', '--disable-sync',
'--disable-background-networking', '--disable-default-apps',
'--mute-audio', '--no-sandbox',
];
if (HEADLESS) args.push('--headless=new');
args.push('about:blank');
const proc = spawn('google-chrome', args, { stdio: ['pipe', 'pipe', 'pipe'] });
proc.stderr.on('data', d => {
if (d.toString().includes('DevTools listening')) resolve(proc);
});
proc.on('error', reject);
proc.on('exit', code => { console.log(`[Chrome] exit ${code}`); process.exit(0); });
// Fallback timeout
setTimeout(() => resolve(proc), 4000);
});
}
// ─── 2. WebSocket server for panels/monitor ───
function startWS() {
const wss = new WebSocketServer({ host: '0.0.0.0', port: WS_PORT });
wss.on('connection', (ws, req) => {
clients.add(ws);
console.log(`[WS] +1 panel (${clients.size}) from ${req.socket.remoteAddress}`);
ws.on('close', () => { clients.delete(ws); console.log(`[WS] -1 panel (${clients.size})`); });
ws.on('message', data => { ws._inputHandler?.(data); });
});
console.log(`[WS] Panels: ws://0.0.0.0:${WS_PORT}`);
return wss;
}
// ─── 3. Monitor page ───
function startMonitor() {
const html = `
DreamStack Screencast
DreamStack Screencast Monitor
Connecting…
`;
http.createServer((_, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(html); })
.listen(MONITOR_PORT, '0.0.0.0', () => console.log(`[Monitor] http://0.0.0.0:${MONITOR_PORT}`));
}
// ─── 4. CDP screencast loop ───
async function startScreencast() {
let client;
// Retry CDP connect (Chrome may still be starting)
for (let i = 0; i < 10; i++) {
try {
client = await CDP({ port: CDP_PORT });
break;
} catch {
await new Promise(r => setTimeout(r, 1000));
}
}
if (!client) throw new Error('Cannot connect to Chrome CDP');
const { Page, Input, Emulation } = client;
await Page.enable();
await Emulation.setDeviceMetricsOverride({
width: WIDTH, height: HEIGHT, deviceScaleFactor: 1, mobile: true,
});
await Emulation.setTouchEmulationEnabled({ enabled: true });
await Page.navigate({ url: TARGET_URL });
await new Promise(r => setTimeout(r, 2000)); // let page load
// Wire up input forwarding from panels
for (const ws of clients) {
ws._inputHandler = (data) => handleInput(Buffer.from(data), Input, Page);
}
// Also for future connections
const origAdd = clients.add.bind(clients);
clients.add = function (ws) {
origAdd(ws);
ws._inputHandler = (data) => handleInput(Buffer.from(data), Input, Page);
};
// Start screencast
await Page.startScreencast({
format: 'jpeg', quality: QUALITY,
maxWidth: WIDTH, maxHeight: HEIGHT,
everyNthFrame: Math.max(1, Math.round(60 / MAX_FPS)),
});
// Listen for frames via the event API
client.on('event', (message) => {
if (message.method !== 'Page.screencastFrame') return;
const { sessionId, data, metadata } = message.params;
// ACK immediately (fire-and-forget)
Page.screencastFrameAck({ sessionId }).catch(() => { });
frameCount++;
const jpegBuf = Buffer.from(data, 'base64');
bytesSent += jpegBuf.length;
// Build frame: [0x50][ts:u32LE][w:u16LE][h:u16LE][jpeg...]
const hdr = Buffer.alloc(9);
hdr[0] = 0x50;
hdr.writeUInt32LE((Date.now() - t0) >>> 0, 1);
hdr.writeUInt16LE(metadata.deviceWidth || WIDTH, 5);
hdr.writeUInt16LE(metadata.deviceHeight || HEIGHT, 7);
const frame = Buffer.concat([hdr, jpegBuf]);
// Broadcast
for (const ws of clients) {
if (ws.readyState === 1) ws.send(frame);
}
if (frameCount % 60 === 0) {
const elapsed = (Date.now() - t0) / 1000;
console.log(`[Cast] #${frameCount} | ${(jpegBuf.length / 1024).toFixed(1)}KB | avg ${(bytesSent / 1024 / elapsed).toFixed(0)} KB/s | ${clients.size} panels`);
}
});
console.log(`[CDP] Casting ${TARGET_URL} → ${WIDTH}×${HEIGHT} @ q${QUALITY}`);
}
// ─── Input handler ───
function handleInput(buf, Input, Page) {
if (buf.length < 1) return;
const t = buf[0];
if (t === 0x60 && buf.length >= 7) {
const phase = buf[1];
const x = buf.readUInt16LE(2), y = buf.readUInt16LE(4);
const type = phase === 0 ? 'touchStart' : phase === 1 ? 'touchMove' : 'touchEnd';
Input.dispatchTouchEvent({
type, touchPoints: [{ x, y, id: buf[6] || 0, radiusX: 10, radiusY: 10, force: phase === 2 ? 0 : 1 }]
}).catch(() => { });
}
if (t === 0x61 && buf.length >= 6) {
const x = buf.readUInt16LE(1), y = buf.readUInt16LE(3);
Input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }).catch(() => { });
setTimeout(() => Input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }).catch(() => { }), 50);
}
if (t === 0x63 && buf.length >= 3) {
const len = buf.readUInt16LE(1);
const url = buf.slice(3, 3 + len).toString();
Page.navigate({ url }).catch(() => { });
console.log(`[Nav] → ${url}`);
}
}
// ─── Main ───
async function main() {
console.log(`\n DreamStack Screencast`);
console.log(` ─────────────────────`);
console.log(` URL: ${TARGET_URL}`);
console.log(` Viewport: ${WIDTH}×${HEIGHT}`);
console.log(` Quality: ${QUALITY}% FPS: ${MAX_FPS}`);
console.log(` Headless: ${HEADLESS}\n`);
const chrome = await launchChrome();
startWS();
startMonitor();
await startScreencast();
console.log(`\n ✓ Streaming! Panels → ws://0.0.0.0:${WS_PORT}`);
console.log(` ✓ Monitor → http://localhost:${MONITOR_PORT}\n`);
process.on('SIGINT', () => {
console.log('\n[Stop]');
chrome.kill();
process.exit(0);
});
}
main().catch(err => { console.error('Fatal:', err); process.exit(1); });