dreamstack/engine/ds-screencast/capture.js

259 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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