feat(cdp): Phase 2 - real-time UI mirroring via screencast + remote input

This commit is contained in:
2026-03-07 20:41:53 +09:00
parent 9234be33db
commit 4b855c9e57
6 changed files with 526 additions and 1 deletions

View File

@@ -25,6 +25,8 @@ class CDPClient {
this.pollInterval = null;
this.onChatUpdate = null; // callback(html)
this.onDisconnect = null; // callback()
this.onScreencastFrame = null; // callback({data, metadata})
this.screencastRunning = false;
}
/**
@@ -268,7 +270,7 @@ class CDPClient {
}
/**
* 스크린샷 (Phase 2 미리보기용)
* 스크린샷 (미리보기용)
*/
async captureScreenshot() {
if (!this.connected || !this.client) return null;
@@ -280,6 +282,118 @@ class CDPClient {
return null;
}
}
// ─── Phase 2: Screencast & Remote Input ────────────────
/**
* 실시간 화면 스트리밍 시작
* Page.startScreencast로 프레임을 수신하고 onScreencastFrame 콜백으로 전달
*/
async startScreencast(options = {}) {
if (!this.connected || !this.client) return false;
if (this.screencastRunning) return true;
const {
format = 'jpeg',
quality = 60,
maxWidth = 1280,
maxHeight = 900,
everyNthFrame = 2,
} = options;
try {
// 프레임 이벤트 리스너 등록
this.client.Page.screencastFrame(async (params) => {
// ACK — 이걸 보내야 다음 프레임이 옴
try {
await this.client.Page.screencastFrameAck({ sessionId: params.sessionId });
} catch { /* ignore */ }
if (this.onScreencastFrame) {
this.onScreencastFrame({
data: params.data, // base64 JPEG
metadata: params.metadata, // {offsetTop, pageScaleFactor, deviceWidth, deviceHeight, ...}
sessionId: params.sessionId,
});
}
});
await this.client.Page.startScreencast({
format,
quality,
maxWidth,
maxHeight,
everyNthFrame,
});
this.screencastRunning = true;
console.log(`[CDP] Screencast 시작 (${maxWidth}x${maxHeight}, q${quality}, every ${everyNthFrame}th frame)`);
return true;
} catch (err) {
console.error('[CDP] Screencast 시작 오류:', err.message);
return false;
}
}
/**
* 실시간 화면 스트리밍 중지
*/
async stopScreencast() {
if (!this.connected || !this.client || !this.screencastRunning) return;
try {
await this.client.Page.stopScreencast();
} catch { /* ignore */ }
this.screencastRunning = false;
this.onScreencastFrame = null;
console.log('[CDP] Screencast 중지');
}
/**
* 원격 입력 이벤트 디스패치
* @param {Object} evt — { type: 'mouse'|'key'|'scroll', ... }
*/
async dispatchInput(evt) {
if (!this.connected || !this.client) return;
try {
switch (evt.type) {
case 'mouse':
await this.client.Input.dispatchMouseEvent({
type: evt.action, // mousePressed, mouseReleased, mouseMoved
x: evt.x,
y: evt.y,
button: evt.button || 'left',
clickCount: evt.clickCount || 1,
modifiers: evt.modifiers || 0,
});
break;
case 'scroll':
await this.client.Input.dispatchMouseEvent({
type: 'mouseWheel',
x: evt.x,
y: evt.y,
deltaX: evt.deltaX || 0,
deltaY: evt.deltaY || 0,
});
break;
case 'key':
await this.client.Input.dispatchKeyEvent({
type: evt.action, // keyDown, keyUp, char
key: evt.key,
code: evt.code || '',
text: evt.text || '',
nativeVirtualKeyCode: evt.keyCode || 0,
windowsVirtualKeyCode: evt.keyCode || 0,
modifiers: evt.modifiers || 0,
});
break;
}
} catch (err) {
// 입력 이벤트 오류는 빈번할 수 있으므로 조용히 처리
}
}
}
module.exports = CDPClient;

View File

@@ -43,6 +43,9 @@ wss.on('connection', (ws) => {
const state = wsClients.get(ws);
if (state?.activeSessionId) {
stopSessionPolling(state.activeSessionId);
// 스크린캠스트도 중지
const session = sessionManager.getSession(state.activeSessionId);
if (session) session.client.stopScreencast();
}
wsClients.delete(ws);
console.log('[WS] 클라이언트 연결 해제');
@@ -126,6 +129,56 @@ async function handleWsMessage(ws, msg) {
break;
}
// ─── Phase 2: Screencast & Remote Input ────────────
case 'start_screencast': {
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
if (!session) {
ws.send(JSON.stringify({ type: 'error', message: '활성 세션 없음' }));
return;
}
// 스크린캠스트 프레임 콜백 등록
session.client.onScreencastFrame = (frame) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'screencast_frame',
sessionId: session.id,
data: frame.data,
metadata: frame.metadata,
}));
}
};
const ok = await session.client.startScreencast(msg.options || {});
ws.send(JSON.stringify({
type: 'screencast_started',
sessionId: session.id,
success: ok,
}));
break;
}
case 'stop_screencast': {
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
if (session) {
await session.client.stopScreencast();
}
ws.send(JSON.stringify({
type: 'screencast_stopped',
sessionId: msg.sessionId || state.activeSessionId,
}));
break;
}
case 'input_event': {
const session = sessionManager.getSession(msg.sessionId || state.activeSessionId);
if (session) {
await session.client.dispatchInput(msg.event);
}
break;
}
default:
ws.send(JSON.stringify({ type: 'error', message: `알 수 없는 메시지 타입: ${msg.type}` }));
}