259 lines
9.7 KiB
JavaScript
259 lines
9.7 KiB
JavaScript
#!/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 = `<!DOCTYPE html><html><head>
|
||
<title>DreamStack Screencast</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{background:#0a0a0a;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:system-ui;color:#999}
|
||
canvas{border:1px solid #333;border-radius:8px;max-height:85vh;cursor:crosshair}
|
||
#hud{margin-top:10px;font:13px/1.5 monospace;text-align:center}
|
||
h3{margin-bottom:6px;color:#555;font-size:14px}
|
||
</style></head><body>
|
||
<h3>DreamStack Screencast Monitor</h3>
|
||
<canvas id="c" width="${WIDTH}" height="${HEIGHT}"></canvas>
|
||
<div id="hud">Connecting…</div>
|
||
<script>
|
||
const c=document.getElementById('c'),ctx=c.getContext('2d'),hud=document.getElementById('hud');
|
||
c.style.width=Math.min(${WIDTH},innerWidth*.45)+'px';c.style.height='auto';
|
||
let fr=0,by=0,t=Date.now();
|
||
const ws=new WebSocket('ws://'+location.hostname+':${WS_PORT}');
|
||
ws.binaryType='arraybuffer';
|
||
ws.onopen=()=>{hud.textContent='Connected — waiting for frames…'};
|
||
ws.onmessage=e=>{
|
||
const buf=new Uint8Array(e.data);
|
||
if(buf[0]!==0x50)return;
|
||
const jpeg=buf.slice(9);fr++;by+=jpeg.length;
|
||
const blob=new Blob([jpeg],{type:'image/jpeg'});
|
||
const url=URL.createObjectURL(blob);
|
||
const img=new Image();
|
||
img.onload=()=>{ctx.drawImage(img,0,0);URL.revokeObjectURL(url)};
|
||
img.src=url;
|
||
const now=Date.now();
|
||
if(now-t>1000){
|
||
hud.textContent='FPS: '+(fr/((now-t)/1000)).toFixed(1)+' | '+(by/1024/((now-t)/1000)).toFixed(0)+' KB/s | Frame: '+(jpeg.length/1024).toFixed(1)+'KB';
|
||
fr=0;by=0;t=now;
|
||
}
|
||
};
|
||
c.addEventListener('click',e=>{
|
||
const r=c.getBoundingClientRect(),sx=${WIDTH}/r.width,sy=${HEIGHT}/r.height;
|
||
const x=Math.round((e.clientX-r.left)*sx),y=Math.round((e.clientY-r.top)*sy);
|
||
const b=new Uint8Array(7);const dv=new DataView(b.buffer);
|
||
b[0]=0x60;b[1]=0;dv.setUint16(2,x,true);dv.setUint16(4,y,true);b[6]=0;
|
||
ws.send(b);
|
||
setTimeout(()=>{const e2=new Uint8Array(7);const d2=new DataView(e2.buffer);e2[0]=0x60;e2[1]=2;d2.setUint16(2,x,true);d2.setUint16(4,y,true);e2[6]=0;ws.send(e2)},50);
|
||
hud.textContent+=' | Click: ('+x+','+y+')';
|
||
});
|
||
ws.onclose=()=>{hud.textContent='Disconnected — reload to retry'};
|
||
</script></body></html>`;
|
||
|
||
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); });
|