desktop: 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端
界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。 用户端: - 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0; - 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟, 像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效; - 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中: 第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血"); - 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。 裁判端: - 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放; - 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位; - 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。 密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的 解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。 其它: - 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名, 原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做); - 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来; - 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证); - CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明 JS 加载、IPC、WebSocket、React effect 四件事。 删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了 (槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名 同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。 测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* 仅开发用的浏览器预览。
|
||||
*
|
||||
* # 它解决什么问题
|
||||
*
|
||||
* Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建;而且截图要靠 macOS 的
|
||||
* 屏幕录制权限。预览模式让同一套 React 组件在浏览器里跑起来:状态由本文件合成,画面用
|
||||
* `createImageBitmap` 从一块 canvas 上取——`VideoSurface`、血条、瞄准器、命中反馈全都是
|
||||
* 真代码,只有"后端"是假的。
|
||||
*
|
||||
* # 它绝不可能进到正式构建里
|
||||
*
|
||||
* 两重门:
|
||||
*
|
||||
* 1. `import.meta.env.DEV` —— 只在 `vite dev` 下为真;`vite build` 时是 false,
|
||||
* 2. 并且页面里没有 Tauri 注入的 `__TAURI_INTERNALS__`。
|
||||
*
|
||||
* 调用点还用 `await import(...)`:条件为假时整个模块不会被打进产物,而不是打进去再靠
|
||||
* 运行时不走。
|
||||
*
|
||||
* # 用法
|
||||
*
|
||||
* ```sh
|
||||
* pnpm dev # 用户端
|
||||
* open 'http://127.0.0.1:1420/?preview=referee' # 裁判端
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { StreamClient } from "./stream";
|
||||
import type {
|
||||
ConfigView,
|
||||
HudView,
|
||||
Snapshot,
|
||||
WireMsg,
|
||||
} from "./types";
|
||||
|
||||
/** 页面里有没有 Tauri 注入。 */
|
||||
function hasTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
/** 现在是不是在预览模式。 */
|
||||
export function isPreview(): boolean {
|
||||
return import.meta.env.DEV && !hasTauri();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(typeof location === "undefined" ? "" : location.search);
|
||||
const startAsReferee = params.get("preview") === "referee";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 可变状态:设置面板里的改动会落到这里,界面因此是可交互的
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_HUD: HudView = {
|
||||
health: {
|
||||
visible: true,
|
||||
showNumbers: true,
|
||||
showTeamName: true,
|
||||
lowThreshold: 30,
|
||||
pulse: true,
|
||||
width: 0.42,
|
||||
},
|
||||
crosshair: {
|
||||
visible: true,
|
||||
style: "cross-dot",
|
||||
size: 0.045,
|
||||
thickness: 0.004,
|
||||
gap: 0.012,
|
||||
color: "#3dff88",
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
grid: { visible: false, style: "thirds", divisions: 6, opacity: 0.22 },
|
||||
hitFeedback: { flash: true, shake: true, marker: true, intensity: 1 },
|
||||
showZoom: true,
|
||||
showStats: true,
|
||||
};
|
||||
|
||||
const CAMERA_A = 0x0de0_0001;
|
||||
const CAMERA_B = 0x0de0_0002;
|
||||
const ARMOR_A = 0x0a20_0001;
|
||||
const ARMOR_B = 0x0a20_0002;
|
||||
|
||||
interface PreviewState {
|
||||
role: "team" | "referee";
|
||||
hud: HudView;
|
||||
cameraId: number | null;
|
||||
armorId: number | null;
|
||||
team: number;
|
||||
mirror: boolean;
|
||||
unlocked: boolean;
|
||||
password: string | null;
|
||||
calibrating: boolean;
|
||||
recordings: { camera: number; frames: number; startedMs: number }[];
|
||||
hp: [number, number];
|
||||
}
|
||||
|
||||
const state: PreviewState = {
|
||||
role: startAsReferee ? "referee" : "team",
|
||||
hud: structuredClone(DEFAULT_HUD),
|
||||
cameraId: startAsReferee ? null : CAMERA_A,
|
||||
armorId: startAsReferee ? null : ARMOR_A,
|
||||
team: 0,
|
||||
mirror: true,
|
||||
// 预览里直接是已解锁状态,免得每次刷新都要输密码。
|
||||
unlocked: false,
|
||||
password: "1234",
|
||||
calibrating: false,
|
||||
recordings: [],
|
||||
hp: [100, 100],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 假的命令实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 供 `ipc.ts` 在有 Tauri 时不会走到的分支上用。 */
|
||||
export async function previewInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const value = handle(command, args ?? {});
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function handle(command: string, args: Record<string, unknown>): unknown {
|
||||
switch (command) {
|
||||
case "stream_info":
|
||||
return { url: "preview://", port: 0, clients: 1 };
|
||||
case "get_snapshot":
|
||||
return snapshot();
|
||||
case "get_config":
|
||||
return configView();
|
||||
case "list_media_files":
|
||||
return [];
|
||||
case "config_path":
|
||||
return "/预览模式/没有真实配置文件";
|
||||
case "unlock":
|
||||
state.unlocked = String(args.password) === state.password;
|
||||
return state.unlocked;
|
||||
case "lock":
|
||||
state.unlocked = false;
|
||||
return undefined;
|
||||
case "set_password":
|
||||
state.password = String(args.fresh);
|
||||
state.unlocked = true;
|
||||
return configView();
|
||||
case "set_discovery_open":
|
||||
return undefined;
|
||||
case "bind_camera":
|
||||
guard();
|
||||
state.cameraId = (args.cameraId as number | null) ?? null;
|
||||
return configView();
|
||||
case "bind_armor":
|
||||
guard();
|
||||
state.armorId = (args.armorId as number | null) ?? null;
|
||||
return configView();
|
||||
case "set_team":
|
||||
guard();
|
||||
state.team = Number(args.team);
|
||||
return configView();
|
||||
case "set_role":
|
||||
state.role = args.role as "team" | "referee";
|
||||
return configView();
|
||||
case "set_mirror":
|
||||
state.mirror = Boolean(args.mirror);
|
||||
return configView();
|
||||
case "save_hud":
|
||||
state.hud = args.hud as HudView;
|
||||
return configView();
|
||||
case "set_referee_slots":
|
||||
return configView();
|
||||
case "set_recording_options":
|
||||
return configView();
|
||||
case "screenshot":
|
||||
return "/预览模式/没有截图";
|
||||
case "start_recording": {
|
||||
const cameras = (args.cameras as number[]) ?? [];
|
||||
const startedMs = Date.now();
|
||||
for (const camera of cameras) {
|
||||
if (!state.recordings.some((r) => r.camera === camera)) {
|
||||
state.recordings.push({ camera, frames: 0, startedMs });
|
||||
}
|
||||
}
|
||||
return recordings();
|
||||
}
|
||||
case "stop_recording": {
|
||||
const cameras = (args.cameras as number[]) ?? [];
|
||||
state.recordings = cameras.length
|
||||
? state.recordings.filter((r) => !cameras.includes(r.camera))
|
||||
: [];
|
||||
return recordings();
|
||||
}
|
||||
case "refresh":
|
||||
case "reveal":
|
||||
case "open_file":
|
||||
return undefined;
|
||||
case "clear_config":
|
||||
return "预览模式:没有真实配置可清空";
|
||||
case "quit":
|
||||
return undefined;
|
||||
default:
|
||||
throw new Error(`预览模式没有实现命令 ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 预览里模拟后端的密码闸门,好让解锁流程也能被看到。 */
|
||||
function guard() {
|
||||
if (state.role === "team" && state.password !== null && !state.unlocked) {
|
||||
throw new Error("这个改动需要密码。先解锁(预览模式里密码是 1234)。");
|
||||
}
|
||||
}
|
||||
|
||||
function configView(): ConfigView {
|
||||
return {
|
||||
role: state.role,
|
||||
roleChosen: true,
|
||||
team: state.team,
|
||||
cameraId: state.cameraId,
|
||||
cameraIdText: state.cameraId === null ? null : hex(state.cameraId),
|
||||
armorId: state.armorId,
|
||||
armorIdText: state.armorId === null ? null : hex(state.armorId),
|
||||
mirror: state.mirror,
|
||||
passwordSet: state.password !== null,
|
||||
hud: structuredClone(state.hud),
|
||||
recordingDir: "/预览模式/recordings",
|
||||
ffmpeg: "ffmpeg",
|
||||
};
|
||||
}
|
||||
|
||||
function recordings() {
|
||||
return state.recordings.map((recording) => ({
|
||||
camera: recording.camera,
|
||||
cameraText: hex(recording.camera),
|
||||
active: true,
|
||||
path: `/预览模式/rec-${recording.camera.toString(16)}.mp4`,
|
||||
inputFps: 20,
|
||||
framesWritten: recording.frames,
|
||||
framesDropped: 0,
|
||||
startedMs: recording.startedMs,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function hex(id: number): string {
|
||||
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
function snapshot(): Snapshot {
|
||||
const cameras = [
|
||||
{ id: CAMERA_A, team: 0 },
|
||||
{ id: CAMERA_B, team: 1 },
|
||||
].map(({ id, team }) => ({
|
||||
id,
|
||||
idText: hex(id),
|
||||
addr: team === 0 ? "192.168.1.41" : "192.168.1.42",
|
||||
online: true,
|
||||
silenceMs: 120,
|
||||
firmware: 3,
|
||||
}));
|
||||
|
||||
const boards = [
|
||||
{ id: ARMOR_A, team: 0 },
|
||||
{ id: ARMOR_B, team: 1 },
|
||||
].map(({ id, team }, index) => ({
|
||||
id,
|
||||
idText: hex(id),
|
||||
addr: team === 0 ? "192.168.1.51" : "192.168.1.52",
|
||||
online: true,
|
||||
team,
|
||||
hp: state.hp[index],
|
||||
platesSeen: 4,
|
||||
reportsAllPlates: true,
|
||||
silenceMs: 90,
|
||||
}));
|
||||
|
||||
const stats = cameras.map((camera) => ({
|
||||
camera: camera.id,
|
||||
cameraText: camera.idText,
|
||||
online: true,
|
||||
status: camera.idText,
|
||||
hasFrames: true,
|
||||
fps: 20,
|
||||
width: 640,
|
||||
height: 480,
|
||||
framesShown: 1000,
|
||||
framesDropped: 3,
|
||||
lossPercent: 0.3,
|
||||
datagrams: 17000,
|
||||
lastFrameBytes: 22000,
|
||||
gapMs: 50,
|
||||
}));
|
||||
|
||||
const healthFor = (team: number) => {
|
||||
const hp = state.hp[team];
|
||||
const board = team === 0 ? ARMOR_A : ARMOR_B;
|
||||
|
||||
return {
|
||||
team,
|
||||
name: team === 0 ? "队伍一" : "队伍二",
|
||||
available: true,
|
||||
placeholder: "",
|
||||
hp,
|
||||
hps: [hp, hp, hp, hp],
|
||||
board,
|
||||
boardText: hex(board),
|
||||
critical: hp === 0 || hp <= state.hud.health.lowThreshold,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
role: state.role,
|
||||
team: state.team,
|
||||
mirrored: state.mirror,
|
||||
demo: true,
|
||||
diagnostics: true,
|
||||
unlocked: state.unlocked || state.role === "referee",
|
||||
discoveryOpen: true,
|
||||
uptimeMs: performance.now(),
|
||||
clients: 1,
|
||||
config: configView(),
|
||||
cameras,
|
||||
armorBoards: boards,
|
||||
stats,
|
||||
slots: {
|
||||
cameraA: state.role === "referee" ? CAMERA_A : state.cameraId,
|
||||
cameraB: state.role === "referee" ? CAMERA_B : null,
|
||||
armorA: state.role === "referee" ? ARMOR_A : state.armorId,
|
||||
armorB: state.role === "referee" ? ARMOR_B : null,
|
||||
manual: false,
|
||||
},
|
||||
health: state.role === "referee" ? [healthFor(0), healthFor(1)] : [healthFor(state.team)],
|
||||
recordings: recordings(),
|
||||
notes: ["这是浏览器预览:状态与画面都是本文件合成的,不是真实链路。"],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 假的画面与心跳
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 启动预览的数据源:每 200ms 一份快照,每 50ms 一帧画面。 */
|
||||
export function startPreview(client: StreamClient, emit: (msg: WireMsg) => void) {
|
||||
let frameId = 0;
|
||||
const started = performance.now();
|
||||
|
||||
// 快照。
|
||||
const snapshotTimer = window.setInterval(() => {
|
||||
emit({ t: "snapshot", ...snapshot() } as WireMsg);
|
||||
}, 200);
|
||||
|
||||
// 两路画面,各 20fps。
|
||||
const canvases = new Map<number, HTMLCanvasElement>();
|
||||
const render = async (camera: number, team: number) => {
|
||||
const canvas = canvases.get(camera) ?? makeCanvas();
|
||||
canvases.set(camera, canvas);
|
||||
draw(canvas, team, frameId);
|
||||
|
||||
const bitmap = await createImageBitmap(canvas);
|
||||
client.injectFrame(camera, frameId, canvas.width, canvas.height, bitmap);
|
||||
};
|
||||
|
||||
const frameTimer = window.setInterval(() => {
|
||||
frameId += 1;
|
||||
|
||||
const cameras =
|
||||
state.role === "referee"
|
||||
? [
|
||||
[CAMERA_A, 0],
|
||||
[CAMERA_B, 1],
|
||||
]
|
||||
: [[state.cameraId ?? CAMERA_A, state.team]];
|
||||
|
||||
for (const [camera, team] of cameras as [number, number][]) {
|
||||
void render(camera, team);
|
||||
}
|
||||
|
||||
for (const recording of state.recordings) recording.frames += 1;
|
||||
}, 50);
|
||||
|
||||
// 掉血:每 3 秒一次,轮流打两块板子,顺便把命中反馈也验证了。
|
||||
let tick = 0;
|
||||
const hitTimer = window.setInterval(() => {
|
||||
tick += 1;
|
||||
const team = tick % 2;
|
||||
const damage = 3 + ((tick * 7) % 20);
|
||||
const from = state.hp[team];
|
||||
const to = from === 0 ? 100 : Math.max(0, from - damage);
|
||||
|
||||
// 打空之后重置成满血:界面上"已击毁"的状态要真的能看到。
|
||||
state.hp[team] = to;
|
||||
|
||||
if (to >= from) return;
|
||||
|
||||
emit({
|
||||
t: "hit",
|
||||
board: team === 0 ? ARMOR_A : ARMOR_B,
|
||||
team,
|
||||
index: 0,
|
||||
from,
|
||||
to,
|
||||
damage: from - to,
|
||||
atMs: Date.now(),
|
||||
} as WireMsg);
|
||||
}, 3000);
|
||||
|
||||
// 第一帧不必等 200ms。
|
||||
emit({ t: "snapshot", ...snapshot() } as WireMsg);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(snapshotTimer);
|
||||
window.clearInterval(frameTimer);
|
||||
window.clearInterval(hitTimer);
|
||||
void started;
|
||||
};
|
||||
}
|
||||
|
||||
function makeCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 480;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** 与 `src-tauri/src/demo.rs` 画的是同一类东西:底色区分两路、中心靶心、二进制帧号。 */
|
||||
function draw(canvas: HTMLCanvasElement, team: number, frameId: number) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { width, height } = canvas;
|
||||
const tint = team === 0 ? [0, 46, 40] : [58, 30, 0];
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, `rgb(${18 + tint[0] / 3}, ${18 + tint[1] / 3}, ${22 + tint[2] / 3})`);
|
||||
gradient.addColorStop(1, `rgb(${78 + tint[0] / 3}, ${78 + tint[1] / 3}, ${82 + tint[2] / 3})`);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.07)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x < width; x += 40) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 0.5, 0);
|
||||
ctx.lineTo(x + 0.5, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < height; y += 40) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y + 0.5);
|
||||
ctx.lineTo(width, y + 0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
ctx.strokeStyle = "#c8c8d2";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 60, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "#96969f";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 28, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = "#78788a";
|
||||
for (const [dx, dy] of [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
]) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + dx * 40, cy + dy * 40);
|
||||
ctx.lineTo(cx + dx * 90, cy + dy * 90);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let bit = 0; bit < 16; bit += 1) {
|
||||
const on = ((frameId >> bit) & 1) === 1;
|
||||
ctx.fillStyle = on ? "#78ffa0" : "#34343c";
|
||||
ctx.fillRect(12 + bit * 14, 12, 10, 10);
|
||||
}
|
||||
|
||||
const t = frameId / 20;
|
||||
const phase = team === 0 ? 0 : 1.7;
|
||||
ctx.fillStyle = "#ffd25a";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + 250 * Math.sin(t * 0.9 + phase), cy + 150 * Math.cos(t * 1.3 + phase), 26, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#5ac8ff";
|
||||
ctx.fillRect(((t * 60) % (width - 80)) | 0, height - 24, 60, 8);
|
||||
|
||||
ctx.font = "600 20px ui-monospace, monospace";
|
||||
ctx.fillStyle = "#e8e8ec";
|
||||
ctx.fillText(team === 0 ? "PREVIEW · TEAM 1" : "PREVIEW · TEAM 2", 14, height - 40);
|
||||
ctx.font = "500 16px ui-monospace, monospace";
|
||||
ctx.fillStyle = "#8a8a95";
|
||||
ctx.fillText(`frame ${frameId}`, 14, height - 16);
|
||||
}
|
||||
+12
-1
@@ -23,8 +23,18 @@ import type {
|
||||
StreamInfo,
|
||||
} from "./types";
|
||||
|
||||
/** 把后端的字符串错误变成真正的 `Error`。 */
|
||||
/**
|
||||
* 把后端的字符串错误变成真正的 `Error`。
|
||||
*
|
||||
* 在没有 Tauri 的 `vite dev` 会话里(`?preview=` 那种),这条走预览实现——它用
|
||||
* `await import()` 引入,所以正式构建里那段代码根本不会被打进产物(见 `dev-preview.ts`)。
|
||||
*/
|
||||
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (import.meta.env.DEV && !("__TAURI_INTERNALS__" in window)) {
|
||||
const preview = await import("./dev-preview");
|
||||
return preview.previewInvoke<T>(command, args);
|
||||
}
|
||||
|
||||
try {
|
||||
return await invoke<T>(command, args);
|
||||
} catch (raw) {
|
||||
@@ -77,6 +87,7 @@ export const api = {
|
||||
startRecording: (cameras: number[]) => call<RecordingDto[]>("start_recording", { cameras }),
|
||||
stopRecording: (cameras: number[]) => call<RecordingDto[]>("stop_recording", { cameras }),
|
||||
reveal: (path: string) => call<void>("reveal", { path }),
|
||||
openFile: (path: string) => call<void>("open_file", { path }),
|
||||
|
||||
// ---- 退出与重置 ----
|
||||
clearConfig: (password?: string) =>
|
||||
|
||||
@@ -111,6 +111,16 @@ export function startStream(url: string): StreamClient {
|
||||
}),
|
||||
});
|
||||
|
||||
// 浏览器预览:没有 WebSocket,状态与画面由 dev-preview.ts 合成。
|
||||
// 动态引入让这段代码在正式构建里被完全剔掉。
|
||||
if (import.meta.env.DEV && !("__TAURI_INTERNALS__" in window)) {
|
||||
emit({ connected: true });
|
||||
|
||||
const current = client;
|
||||
void import("./dev-preview").then((preview) => preview.startPreview(current, handleMessage));
|
||||
return client;
|
||||
}
|
||||
|
||||
client.connect(url);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -117,11 +117,6 @@ export class StreamClient {
|
||||
|
||||
this.counters.frames += 1;
|
||||
|
||||
const previous = this.frames.get(camera);
|
||||
|
||||
// 乱序完成时丢弃更旧的那一帧。用带符号差处理 u32 回绕。
|
||||
if (previous && ((frameId - previous.frameId) | 0) <= 0) return;
|
||||
|
||||
const jpeg = new Uint8Array(buffer, FRAME_HEADER_LEN, length);
|
||||
let bitmap: ImageBitmap;
|
||||
|
||||
@@ -132,8 +127,28 @@ export class StreamClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// 解码期间可能已经来了更新的帧:那就把刚解出来的这张丢掉。
|
||||
this.store(camera, frameId, width, height, bitmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 放进一帧。
|
||||
*
|
||||
* 解码是异步的,所以这里要再判一次"更新的一帧有没有先到":`createImageBitmap` 的
|
||||
* 完成顺序不保证与发起顺序一致,不判的话画面会偶发地往回跳一帧。
|
||||
*
|
||||
* 预览模式(`dev-preview.ts`)直接调它,绕过 WebSocket——两条路径共用同一套存储逻辑,
|
||||
* 所以预览里看到的画面行为与真机一致。
|
||||
*/
|
||||
private store(
|
||||
camera: number,
|
||||
frameId: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bitmap: ImageBitmap,
|
||||
) {
|
||||
const current = this.frames.get(camera);
|
||||
|
||||
// 带符号差处理 u32 回绕。
|
||||
if (current && ((frameId - current.frameId) | 0) <= 0) {
|
||||
bitmap.close();
|
||||
return;
|
||||
@@ -151,6 +166,18 @@ export class StreamClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** 给预览模式用的入口:直接塞一帧。 */
|
||||
injectFrame(
|
||||
camera: number,
|
||||
frameId: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bitmap: ImageBitmap,
|
||||
) {
|
||||
this.counters.frames += 1;
|
||||
this.store(camera, frameId, width, height, bitmap);
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closed || this.retryTimer !== null) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user