mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
fix(web): correct mouse coordinates for direct h264 frames
This commit is contained in:
@@ -8,6 +8,25 @@ import { resolutionAtom } from '@/jotai/screen.ts';
|
|||||||
|
|
||||||
import { MouseAbsoluteEvent } from './types.ts';
|
import { MouseAbsoluteEvent } from './types.ts';
|
||||||
|
|
||||||
|
type MediaSize = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FrameContent = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullFrameContent = (mediaSize: MediaSize): FrameContent => ({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: mediaSize.width,
|
||||||
|
height: mediaSize.height
|
||||||
|
});
|
||||||
|
|
||||||
enum MouseButton {
|
enum MouseButton {
|
||||||
Left = 0,
|
Left = 0,
|
||||||
Middle = 1,
|
Middle = 1,
|
||||||
@@ -52,6 +71,7 @@ export const Absolute = () => {
|
|||||||
const target = screen;
|
const target = screen;
|
||||||
const mouse = mouseRef.current;
|
const mouse = mouseRef.current;
|
||||||
const pressedMouseButtons = new Set<number>();
|
const pressedMouseButtons = new Set<number>();
|
||||||
|
let frameContentCache: { key: string; checkedAt: number; content: FrameContent } | null = null;
|
||||||
let pendingMove: { x: number; y: number } | null = null;
|
let pendingMove: { x: number; y: number } | null = null;
|
||||||
let moveFrame: number | null = null;
|
let moveFrame: number | null = null;
|
||||||
|
|
||||||
@@ -67,6 +87,9 @@ export const Absolute = () => {
|
|||||||
target.addEventListener('touchmove', handleTouchMove, touchOptions);
|
target.addEventListener('touchmove', handleTouchMove, touchOptions);
|
||||||
target.addEventListener('touchend', handleTouchEnd, touchOptions);
|
target.addEventListener('touchend', handleTouchEnd, touchOptions);
|
||||||
target.addEventListener('touchcancel', handleTouchCancel, touchOptions);
|
target.addEventListener('touchcancel', handleTouchCancel, touchOptions);
|
||||||
|
target.addEventListener('load', invalidateFrameContent);
|
||||||
|
target.addEventListener('loadedmetadata', invalidateFrameContent);
|
||||||
|
target.addEventListener('canplay', invalidateFrameContent);
|
||||||
|
|
||||||
// Mouse event handler
|
// Mouse event handler
|
||||||
function handleMouseEvent(event: MouseAbsoluteEvent) {
|
function handleMouseEvent(event: MouseAbsoluteEvent) {
|
||||||
@@ -423,23 +446,45 @@ export const Absolute = () => {
|
|||||||
offsetX = (rect.width - renderedWidth) / 2;
|
offsetX = (rect.width - renderedWidth) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentLeft = rect.left + offsetX;
|
const frameContent = getFrameContent(mediaSize);
|
||||||
const contentTop = rect.top + offsetY;
|
const frameScaleX = renderedWidth / mediaSize.width;
|
||||||
|
const frameScaleY = renderedHeight / mediaSize.height;
|
||||||
|
const contentLeft = rect.left + offsetX + frameContent.left * frameScaleX;
|
||||||
|
const contentTop = rect.top + offsetY + frameContent.top * frameScaleY;
|
||||||
|
const contentWidth = frameContent.width * frameScaleX;
|
||||||
|
const contentHeight = frameContent.height * frameScaleY;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
clientX < contentLeft ||
|
clientX < contentLeft ||
|
||||||
clientX > contentLeft + renderedWidth ||
|
clientX > contentLeft + contentWidth ||
|
||||||
clientY < contentTop ||
|
clientY < contentTop ||
|
||||||
clientY > contentTop + renderedHeight
|
clientY > contentTop + contentHeight
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const x = (clientX - contentLeft) / renderedWidth;
|
const x = (clientX - contentLeft) / contentWidth;
|
||||||
const y = (clientY - contentTop) / renderedHeight;
|
const y = (clientY - contentTop) / contentHeight;
|
||||||
|
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateFrameContent() {
|
||||||
|
frameContentCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFrameContent(mediaSize: MediaSize): FrameContent {
|
||||||
|
const key = `${mediaSize.width}x${mediaSize.height}`;
|
||||||
|
const now = performance.now();
|
||||||
|
if (frameContentCache?.key === key && now - frameContentCache.checkedAt < 1000) {
|
||||||
|
return frameContentCache.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = detectFrameContent(target, mediaSize);
|
||||||
|
frameContentCache = { key, checkedAt: now, content };
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
function queueMouseMove(x: number, y: number) {
|
function queueMouseMove(x: number, y: number) {
|
||||||
pendingMove = { x, y };
|
pendingMove = { x, y };
|
||||||
if (moveFrame !== null) {
|
if (moveFrame !== null) {
|
||||||
@@ -500,6 +545,9 @@ export const Absolute = () => {
|
|||||||
target.removeEventListener('touchmove', handleTouchMove, touchOptions.capture);
|
target.removeEventListener('touchmove', handleTouchMove, touchOptions.capture);
|
||||||
target.removeEventListener('touchend', handleTouchEnd, touchOptions.capture);
|
target.removeEventListener('touchend', handleTouchEnd, touchOptions.capture);
|
||||||
target.removeEventListener('touchcancel', handleTouchCancel, touchOptions.capture);
|
target.removeEventListener('touchcancel', handleTouchCancel, touchOptions.capture);
|
||||||
|
target.removeEventListener('load', invalidateFrameContent);
|
||||||
|
target.removeEventListener('loadedmetadata', invalidateFrameContent);
|
||||||
|
target.removeEventListener('canplay', invalidateFrameContent);
|
||||||
|
|
||||||
if (longPressTimerRef.current) {
|
if (longPressTimerRef.current) {
|
||||||
clearTimeout(longPressTimerRef.current);
|
clearTimeout(longPressTimerRef.current);
|
||||||
@@ -516,7 +564,102 @@ export const Absolute = () => {
|
|||||||
return <></>;
|
return <></>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getMediaSize(screen: Element) {
|
function detectFrameContent(screen: Element, mediaSize: MediaSize): FrameContent {
|
||||||
|
// Frame metadata includes letterbox pixels; infer stable side borders until the capture pipeline exposes an active rect.
|
||||||
|
// ponytail: replace pixel inference with active-content metadata when available.
|
||||||
|
const sampleScale = Math.min(1, 640 / mediaSize.width, 360 / mediaSize.height);
|
||||||
|
const sampleWidth = Math.max(1, Math.round(mediaSize.width * sampleScale));
|
||||||
|
const sampleHeight = Math.max(1, Math.round(mediaSize.height * sampleScale));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = sampleWidth;
|
||||||
|
canvas.height = sampleHeight;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!context || !drawMediaFrame(context, screen, sampleWidth, sampleHeight)) {
|
||||||
|
return fullFrameContent(mediaSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data;
|
||||||
|
const left = findBorderInset(pixels, sampleWidth, sampleHeight, true);
|
||||||
|
const right = findBorderInset(pixels, sampleWidth, sampleHeight, false);
|
||||||
|
const minHorizontalBorder = Math.max(4, Math.round(sampleWidth * 0.02));
|
||||||
|
const horizontalBorder =
|
||||||
|
left >= minHorizontalBorder &&
|
||||||
|
right >= minHorizontalBorder &&
|
||||||
|
left + right < sampleWidth * 0.4 &&
|
||||||
|
Math.abs(left - right) <= Math.max(4, Math.round(sampleWidth * 0.05));
|
||||||
|
const frameLeft = horizontalBorder ? (left / sampleWidth) * mediaSize.width : 0;
|
||||||
|
const frameRight = horizontalBorder ? (right / sampleWidth) * mediaSize.width : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: frameLeft,
|
||||||
|
top: 0,
|
||||||
|
width: mediaSize.width - frameLeft - frameRight,
|
||||||
|
height: mediaSize.height
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return fullFrameContent(mediaSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMediaFrame(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
screen: Element,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
screen instanceof HTMLVideoElement ||
|
||||||
|
screen instanceof HTMLImageElement ||
|
||||||
|
screen instanceof HTMLCanvasElement
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
context.drawImage(screen, 0, 0, width, height);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBorderInset(
|
||||||
|
pixels: Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
fromStart: boolean
|
||||||
|
) {
|
||||||
|
const lines = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9].map((ratio) =>
|
||||||
|
Math.round((height - 1) * ratio)
|
||||||
|
);
|
||||||
|
const limit = Math.floor(width * 0.45);
|
||||||
|
let inset = 0;
|
||||||
|
|
||||||
|
for (let offset = 0; offset < limit; offset++) {
|
||||||
|
const position = fromStart ? offset : width - offset - 1;
|
||||||
|
const samples = lines.map((line) => getPixel(pixels, width, position, line));
|
||||||
|
if (!samples.every(isBlackPixel)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
inset = offset + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPixel(pixels: Uint8ClampedArray, width: number, x: number, y: number) {
|
||||||
|
const offset = (y * width + x) * 4;
|
||||||
|
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlackPixel(pixel: number[]) {
|
||||||
|
return pixel[0] === 0 && pixel[1] === 0 && pixel[2] === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaSize(screen: Element): MediaSize | null {
|
||||||
if (screen instanceof HTMLVideoElement && screen.videoWidth > 0 && screen.videoHeight > 0) {
|
if (screen instanceof HTMLVideoElement && screen.videoWidth > 0 && screen.videoHeight > 0) {
|
||||||
return { width: screen.videoWidth, height: screen.videoHeight };
|
return { width: screen.videoWidth, height: screen.videoHeight };
|
||||||
}
|
}
|
||||||
@@ -525,8 +668,12 @@ function getMediaSize(screen: Element) {
|
|||||||
return { width: screen.naturalWidth, height: screen.naturalHeight };
|
return { width: screen.naturalWidth, height: screen.naturalHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (screen instanceof HTMLCanvasElement && screen.width > 0 && screen.height > 0) {
|
if (screen instanceof HTMLCanvasElement) {
|
||||||
return { width: screen.width, height: screen.height };
|
const width = Number(screen.dataset.mediaWidth);
|
||||||
|
const height = Number(screen.dataset.mediaHeight);
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ let stopped = false;
|
|||||||
let resyncRequested = false;
|
let resyncRequested = false;
|
||||||
let pendingAckTimestamp: number | null = null;
|
let pendingAckTimestamp: number | null = null;
|
||||||
let decodeBackpressured = false;
|
let decodeBackpressured = false;
|
||||||
|
let reportedFrameWidth = 0;
|
||||||
|
let reportedFrameHeight = 0;
|
||||||
|
|
||||||
const maxQueuedFrames = 1;
|
const maxQueuedFrames = 1;
|
||||||
const maxReconnectDelayMs = 5_000;
|
const maxReconnectDelayMs = 5_000;
|
||||||
@@ -45,6 +47,8 @@ self.onmessage = (event: MessageEvent<WorkerMessage>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas = offscreenCanvas;
|
canvas = offscreenCanvas;
|
||||||
|
reportedFrameWidth = 0;
|
||||||
|
reportedFrameHeight = 0;
|
||||||
ctx = canvas!.getContext('2d', {
|
ctx = canvas!.getContext('2d', {
|
||||||
alpha: false,
|
alpha: false,
|
||||||
desynchronized: true
|
desynchronized: true
|
||||||
@@ -309,6 +313,15 @@ function renderFrame(frame: VideoFrame) {
|
|||||||
canvas.width = frame.displayWidth;
|
canvas.width = frame.displayWidth;
|
||||||
canvas.height = frame.displayHeight;
|
canvas.height = frame.displayHeight;
|
||||||
}
|
}
|
||||||
|
if (reportedFrameWidth !== frame.displayWidth || reportedFrameHeight !== frame.displayHeight) {
|
||||||
|
reportedFrameWidth = frame.displayWidth;
|
||||||
|
reportedFrameHeight = frame.displayHeight;
|
||||||
|
self.postMessage({
|
||||||
|
type: 'frame-size',
|
||||||
|
width: reportedFrameWidth,
|
||||||
|
height: reportedFrameHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ export const H264Direct = () => {
|
|||||||
|
|
||||||
const offscreen = canvasRef.current.transferControlToOffscreen();
|
const offscreen = canvasRef.current.transferControlToOffscreen();
|
||||||
const url = `${getBaseUrl('ws')}/api/stream/h264/direct`;
|
const url = `${getBaseUrl('ws')}/api/stream/h264/direct`;
|
||||||
|
worker.onmessage = (
|
||||||
|
event: MessageEvent<{ type?: string; width?: number; height?: number }>
|
||||||
|
) => {
|
||||||
|
const { type, width, height } = event.data;
|
||||||
|
if (type !== 'frame-size' || !width || !height || !canvasRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.current.dataset.mediaWidth = String(width);
|
||||||
|
canvasRef.current.dataset.mediaHeight = String(height);
|
||||||
|
};
|
||||||
worker.postMessage({ type: 'h264', canvas: offscreen, url }, [offscreen]);
|
worker.postMessage({ type: 'h264', canvas: offscreen, url }, [offscreen]);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -50,7 +61,7 @@ export const H264Direct = () => {
|
|||||||
<canvas
|
<canvas
|
||||||
id="screen"
|
id="screen"
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={clsx('block select-none touch-none', mouseStyle)}
|
className={clsx('block touch-none select-none', mouseStyle)}
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${videoScale})`,
|
transform: `scale(${videoScale})`,
|
||||||
transformOrigin: 'center',
|
transformOrigin: 'center',
|
||||||
|
|||||||
Reference in New Issue
Block a user