dreamstack/engine/ds-screencast/capture.js

260 lines
9.7 KiB
JavaScript
Raw Normal View History

#!/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); });