[feat] add MCP terminal views and agent cursor overlays

This commit is contained in:
dijunkun
2026-11-03 03:00:57 +08:00
parent f57b4cc4e3
commit e1ae449d36
19 changed files with 576 additions and 25 deletions
+73 -2
View File
@@ -1,4 +1,6 @@
type FrameInfo = { width: number; height: number; fps: number; frames: number };
type Cursor = { type: "cursor"; visible: boolean; x: number; y: number;
generation: number; geometry_epoch: number; width: number; height: number };
function parameters(data: Uint8Array) {
let codec = "", signature = "";
for (let i = 0; i + 4 < data.length; i++) {
@@ -43,9 +45,18 @@ export class VideoPlayer {
private raw = false;
private software = false;
private automatic = true;
private cursor?: Cursor;
private cursorDrawn = "";
private resize?: ResizeObserver;
constructor(private canvas: HTMLCanvasElement,
private onFrame: (info: FrameInfo) => void,
private onError: (error: Error) => void) {}
private onError: (error: Error) => void,
private overlay?: HTMLCanvasElement) {
if (overlay) {
this.resize = new ResizeObserver(() => this.drawCursor());
this.resize.observe(overlay);
}
}
start(url: string, ticket: string, protocol = "h264-annexb/1", decoder = "auto") {
if (!["h264-annexb/1", "nv12/1"].includes(protocol) ||
@@ -63,7 +74,24 @@ export class VideoPlayer {
socket.onclose = () => { if (!this.stopped) this.fail(new Error("VIDEO_UNAVAILABLE")); };
this.firstFrame = setTimeout(() => this.fail(new Error("VIDEO_UNAVAILABLE")), 10000);
socket.onmessage = (event) => {
if (this.stopped || !(event.data instanceof ArrayBuffer)) return;
if (this.stopped) return;
if (typeof event.data === "string") {
try {
if (event.data.length > 1024) throw new Error();
const p = JSON.parse(event.data) as Cursor;
if (p.type !== "cursor" || typeof p.visible !== "boolean" ||
!Number.isFinite(p.x) || !Number.isFinite(p.y) || p.x < 0 || p.x > 1 || p.y < 0 || p.y > 1 ||
!Number.isSafeInteger(p.generation) || p.generation < 0 ||
!Number.isSafeInteger(p.geometry_epoch) || p.geometry_epoch < 0 ||
!Number.isInteger(p.width) || !Number.isInteger(p.height) ||
p.width < 0 || p.width > 8192 || p.height < 0 || p.height > 8192)
throw new Error();
this.cursor = p;
this.drawCursor();
} catch { this.fail(new Error("VIDEO_INVALID")); }
return;
}
if (!(event.data instanceof ArrayBuffer)) return;
const data = event.data;
if (data.byteLength < (this.raw ? 38 : 25) ||
data.byteLength > (this.raw ? 16777216 * 3 / 2 + 32 : 4 * 1024 * 1024 + 24))
@@ -196,6 +224,7 @@ export class VideoPlayer {
this.canvas.getContext("2d", { alpha: false })!.drawImage(frame, 0, 0);
clearTimeout(this.firstFrame);
this.count++;
this.drawCursor();
this.samples++;
const elapsed = performance.now() - this.sampledAt;
if (elapsed >= 1000) {
@@ -208,6 +237,45 @@ export class VideoPlayer {
} catch { this.fail(new Error("VIDEO_INVALID")); }
finally { frame.close(); }
}
private drawCursor() {
const overlay = this.overlay;
if (!overlay) return;
if (overlay.width !== this.canvas.width) overlay.width = this.canvas.width;
if (overlay.height !== this.canvas.height) overlay.height = this.canvas.height;
const p = this.cursor;
const state = JSON.stringify([this.stopped, !!this.count, this.generation,
overlay.width, overlay.height, overlay.clientWidth, overlay.clientHeight, p]);
if (state === this.cursorDrawn) return;
this.cursorDrawn = state;
const context = overlay.getContext("2d")!;
context.clearRect(0, 0, overlay.width, overlay.height);
if (this.stopped || !this.count || !p?.visible || p.generation !== this.generation ||
p.width !== overlay.width || p.height !== overlay.height) return;
// Match the video canvas's object-fit: contain, including letterboxing.
const scale = Math.min(overlay.clientWidth / overlay.width, overlay.clientHeight / overlay.height);
if (!scale) return;
context.save();
context.translate(Math.min(overlay.width - 1, p.x * overlay.width), Math.min(overlay.height - 1, p.y * overlay.height));
context.scale(1 / scale, 1 / scale);
context.beginPath();
context.moveTo(0, 0);
context.lineTo(0, 22);
context.lineTo(6, 17);
context.lineTo(10, 26);
context.lineTo(14, 24);
context.lineTo(10, 15);
context.lineTo(18, 15);
context.closePath();
context.fillStyle = "#3269e8";
context.strokeStyle = "#fff";
context.lineWidth = 2;
context.lineJoin = "round";
context.shadowColor = "#0009";
context.shadowBlur = 3;
context.fill();
context.stroke();
context.restore();
}
private fail(error: Error) {
if (this.failed || this.stopped) return;
this.failed = true;
@@ -216,6 +284,9 @@ export class VideoPlayer {
}
stop() {
this.stopped = true;
this.cursor = undefined;
this.resize?.disconnect();
this.drawCursor();
clearTimeout(this.firstFrame);
this.socket?.close();
if (this.decoder && this.decoder.state !== "closed") this.decoder.close();
+1
View File
@@ -82,6 +82,7 @@ button:disabled { cursor: default; color: #a3a7ad; background: #fafafa; border-c
/* The screen takes the remaining panel height; object-fit preserves the remote aspect ratio. */
.screen { position: relative; flex: 1; min-height: 60px; width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; isolation: isolate; border-radius: 8px; background: #17191c; }
.screen canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
#agent-cursor { pointer-events: none; z-index: 4; }
/* Overlay the control indicator so toggling control never resizes the video. */
.control-halo { display: none; }
#app[data-controlling="true"] .screen::before,
+2 -1
View File
@@ -55,6 +55,7 @@ root.innerHTML = `
</div>
<div class="screen" id="screen">
<canvas id="frame" role="img" aria-label="远程桌面实时画面" hidden></canvas>
<canvas id="agent-cursor" aria-hidden="true"></canvas>
<div class="control-halo" aria-hidden="true"></div>
<div id="empty"><h3 id="empty-title">未连接设备</h3><p id="empty-description">选择设备并输入访问密码。</p></div>
<div class="stale" id="stale" role="status" hidden>画面暂未更新</div>
@@ -393,7 +394,7 @@ async function startPreview() {
element<HTMLButtonElement>("focus-view").disabled = false;
text("video-state", "实时");
element("video-state").dataset.state = "live";
}, failed);
}, failed, element<HTMLCanvasElement>("agent-cursor"));
player.start(grant.url, grant.ticket, grant.protocol, grant.decoder);
} catch (error) {
failed(error instanceof Error ? error : new Error("VIDEO_UNAVAILABLE"));