From 13d2ebc3c320e68d81154935ff713d02655fc668 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Sun, 25 Oct 2026 13:16:38 +0800 Subject: [PATCH 01/23] =?UTF-8?q?fix(scripts):=20=E4=BF=AE=E5=A4=8Dnative-?= =?UTF-8?q?fetch=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/fetch-native.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/scripts/fetch-native.mjs b/scripts/fetch-native.mjs index 43bcb6aa4..66f7b3b86 100644 --- a/scripts/fetch-native.mjs +++ b/scripts/fetch-native.mjs @@ -55,7 +55,7 @@ import https from 'node:https'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { Readable, Transform } from 'node:stream'; +import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { connect as tlsConnect } from 'node:tls'; import { fileURLToPath } from 'node:url'; @@ -379,11 +379,14 @@ async function readBody(res) { } async function download(url, dest, { label, size } = {}) { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok || !res.body) { - throw new Error(`下载失败 ${res.status} ${res.statusText}:${url}`); + // 必须走 httpGet 而不是内置 fetch:内置 fetch 不认 HTTP(S)_PROXY, + // 在只挂本地代理(如 Clash 7897)的环境里会直连超时,只报一句 `fetch failed`。 + const res = await httpGet(url); + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + throw new Error(`下载失败 ${res.statusCode} ${res.statusMessage ?? ''}:${url}`); } - const contentLength = Number(res.headers.get('content-length')) || 0; + const contentLength = Number(res.headers['content-length']) || 0; const total = size || contentLength; const progress = quiet ? undefined : new Progress(label ?? url); let done = 0; @@ -395,7 +398,7 @@ async function download(url, dest, { label, size } = {}) { }, }); try { - await pipeline(Readable.fromWeb(res.body), counter, createWriteStream(dest)); + await pipeline(res, counter, createWriteStream(dest)); } catch (err) { if (progress && IS_TTY) process.stdout.write('\n'); throw err; From c617deca5ea88888b654d4e9087a10ec2a0793ee Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 26 Oct 2026 02:40:04 +0800 Subject: [PATCH 02/23] =?UTF-8?q?fix(call):=20=E4=BF=AE=E5=A4=8D=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E9=80=9A=E8=AF=9D=E7=94=BB=E9=9D=A2=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E4=B8=8E=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fidx 不是 SVC 层号,而是 16 位帧序号的高字节:`((fidx - 1) << 8) | gidx`。 真机日志连续 600 帧验证(g=249/f=1 → g=43/f=2 → g=37/f=3,序号 249→299→549 全程连续)。据此修三处: - 主进程按「fidx === 1 才是基础层」过滤帧,第 256 帧(约 25 秒)之后 isBaseLayer 恒为 false 且 lastFrameWasBase 不再更新,画面永久冻结。 改为每帧都推。 - 发送侧 gidx 只写低 8 位、fidx 恒 1,第 256 帧包头从 g=255/f=1 跳回 g=0/f=1,帧序号倒退。改用 videoFrameIndex/videoFrameHeader 发 16 位序号。 - 摄像头帧走 tRPC 时被 superjson 展开成 1.2 MB 十进制文本(实测 30 ms/帧), 15 fps 下渲染层主线程被吃满。新增二进制 IPC 通道(结构化克隆 0.17 ms), web 构建无该桥时回退 tRPC。 顺带修 readHevcInfo:RBSP 起点少算 1 字节(应为 13 而非 12)且未剥 emulation prevention byte,导致永远返回 null、界面读不到对端分辨率。 补回归测试:帧序号拼接与逆运算、跨 256 帧连续性(收发两侧)、真实 SPS 黄金样本解析出 640×360。 --- CHANGELOG.md | 3 + apps/desktop/src/main/call.ts | 18 ++--- apps/desktop/src/main/call_video_ipc.ts | 46 ++++++++++++ apps/desktop/src/main/index.ts | 2 + apps/desktop/src/preload/index.d.ts | 2 + apps/desktop/src/preload/index.ts | 13 ++++ apps/desktop/src/renderer/src/env.d.ts | 7 ++ .../src/renderer/src/hooks/useCallCamera.ts | 9 +++ apps/desktop/src/shared/call_video_ipc.ts | 31 ++++++++ docs/develop/video-call-20261025.md | 57 +++++++++++++-- packages/protocol/src/call/index.ts | 2 + packages/protocol/src/call/video.ts | 67 ++++++++++++++--- packages/protocol/test/call_video.test.ts | 62 +++++++++++++++- packages/service/src/account/call_session.ts | 70 +++++++++++++++--- .../service/test/call_video_session.test.ts | 73 ++++++++++++++++++- 15 files changed, 419 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/main/call_video_ipc.ts create mode 100644 apps/desktop/src/shared/call_video_ipc.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 970d90d3d..a23ee4662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -590,3 +590,6 @@ - 消息气泡与字体支持独立开关,头像挂件统一控制;自己发送的消息先准备好字体再显示,回读前采用 QQ 账号气泡,修复装扮在发送与回读间反复跳动的问题。 - 调整隐私模式为按身份、私人内容和凭据遮罩,保留群人数、时间、seq、装扮、统计和操作状态,补齐独立预览与悬浮提示的遮罩。 - 修复 macOS 等桌面安装包中离线语音转写批量失败的问题,识别进程异常退出时提供具体诊断。 +- 修复视频通话画面播放二十来秒后永久卡住的问题:帧序号跨 256 帧后不再被误判为「非基础层」丢弃,本端上行也改为 16 位帧序号,两端画面持续更新。 +- 修复视频通话画面卡顿:摄像头帧改走二进制 IPC 通道,不再把每帧展开成 1.2 MB 文本经 JSON 传输,采集与编码不再互相拖慢。 +- 修复视频通话界面读不到对方分辨率的问题,关键帧参数集解析偏移已校正。 diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index 043d7900a..a959bd2c5 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -195,7 +195,7 @@ export interface StartCallParams { export type CallEvent = | { type: 'state'; call: ActiveCallInfo } | { type: 'audio'; pcm: Uint8Array } - /** 对端一帧视频(Annex B 裸流,只推基础层)。 */ + /** 对端一帧视频(Annex B 裸流)。 */ | { type: 'video'; hevc: Uint8Array; @@ -223,8 +223,6 @@ export class CallManager extends EventEmitter { /** 视频编码器(只有视频通话才非 null)。 */ private videoEncoder: NativeVideoEncoder | null = null; - /** 上一帧是否基础层(用于「基础层优先」的取舍)。 */ - private lastFrameWasBase = false; /** 最近收到的一个关键帧(新订阅者补发用)。 */ private lastKeyframe: VideoFramePayload | null = null; /** 当前这通电话的录音设置(发起时取一次,通话中改设置不影响进行中的录音)。 */ @@ -328,16 +326,14 @@ export class CallManager extends EventEmitter { this.emit('event', { type: 'audio', pcm } satisfies CallEvent); }); session.onVideo((frame) => { - // 只推基础层:增强层依赖基础层,单独解会花屏。 + // **每一帧都推,不要按 fidx 过滤。** // - // 但实测对端会在基础层之外再发 fidx=2 的增强层,而**基础层不是每帧都有**—— - // 一旦对端把画面切成纯增强层(长时间静止后重新编码),全丢就会永久黑屏。 - // 所以:基础层优先,没有基础层时退回增强层(解码器能解多少算多少, - // 花屏好过没画面)。 + // 早先这里按「fidx === 1 才是基础层」丢弃 fidx>=2 的帧,前提是错的:fidx + // 其实是**16 位帧序号的高字节**(每 256 帧 +1),不是 SVC 层号。于是通话 + // 进行到第 256 帧(10 fps 下约 25 秒)之后,`isBaseLayer` 恒为 false, + // 而标记又停在 true 且不再更新 —— 后续每一帧都被静默丢掉,画面永久冻结。 + // 真机日志里 `g=249/f=1 → g=43/f=2` 的序号连续性就是证据。 if (!frame.hevc) return; - // 增强层只在没有基础层可用时才推 —— 见上面的注释。 - if (!frame.isBaseLayer && this.lastFrameWasBase) return; - this.lastFrameWasBase = frame.isBaseLayer; // 缓存最近一个关键帧:渲染层的解码订阅是在「对端开始发视频」的状态推送 // **之后**才建立的,而首个关键帧往往已经过去 —— 后面的 delta 帧没有 // 关键帧起解,画面永远出不来。新订阅先用这份补一帧。 diff --git a/apps/desktop/src/main/call_video_ipc.ts b/apps/desktop/src/main/call_video_ipc.ts new file mode 100644 index 000000000..608c5701d --- /dev/null +++ b/apps/desktop/src/main/call_video_ipc.ts @@ -0,0 +1,46 @@ +/** + * 通话视频帧的二进制 IPC 接收端(主进程)。 + * + * 与 `shared/call_video_ipc.ts` 配对:渲染层用 `ipcRenderer.send` 把 I420 平面 + * 直接推过来(结构化克隆,不经 JSON),这里转交给当前通话的编码器。 + * + * 为什么不用 tRPC:见 `shared/call_video_ipc.ts` 的说明 —— 一帧 345 KB 的 + * `Uint8Array` 经 superjson 会变成 1.2 MB 的十进制文本,单帧开销 30 ms, + * 15 fps 下渲染层主线程被吃满,两端画面都卡。 + * + * 只注册一次;没有进行中的通话时直接丢弃(视频帧不需要排队,过期的画面没有意义)。 + */ + +import { ipcMain } from 'electron'; +import { getLogger } from '@weq/service'; +import { CALL_VIDEO_FRAME_CHANNEL, type CallVideoFrame } from '../shared/call_video_ipc'; +import { findCallManager } from './call_registry'; + +const logger = getLogger().child({ scope: 'call-video-ipc' }); + +/** 尺寸校验用:一帧 I420 的 Y 平面必须恰好 `w*h`。 */ +function asBytes(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) return value; + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return null; +} + +export function registerCallVideoIpc(): void { + ipcMain.on(CALL_VIDEO_FRAME_CHANNEL, (_event, raw: unknown) => { + if (!raw || typeof raw !== 'object') return; + const frame = raw as Partial; + const y = asBytes(frame.y); + const u = asBytes(frame.u); + const v = asBytes(frame.v); + if (!y || !u || !v) return; + + const manager = findCallManager(); + if (!manager) return; + // 主进程侧还会再校验一次尺寸(见 NativeVideoEncoder.pushFrame)。 + manager.pushVideoFrame(y, u, v); + }); + logger.debug('通话视频帧 IPC 已注册'); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e7a7f71d5..494616684 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -79,6 +79,7 @@ import { createRendererRecovery } from './renderer_recovery'; import { startRendererDiagnostics } from './renderer_diagnostics_runtime'; import { macApplicationMenu, nativeMenuLabels } from './application_menu'; import { registerEditUndoIpc, undoFocusedContent, redoFocusedContent } from './edit_undo'; +import { registerCallVideoIpc } from './call_video_ipc'; import { registerAttachHintIpc } from './attach_hint_ipc'; import { registerElevationIpc } from './elevation_ipc'; import { @@ -766,6 +767,7 @@ void app.whenReady().then(async () => { registerAttachHintIpc(); registerElevationIpc(); registerEditUndoIpc(); + registerCallVideoIpc(); app.on('browser-window-created', (_, win) => { optimizer.watchWindowShortcuts(win); diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index c9708a058..78296df03 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -56,6 +56,8 @@ declare global { capture: { window(): Promise<{ ok: boolean; error?: string }>; }; + /** 通话视频上行(二进制 IPC,不经 superjson)。 */ + callVideo: import('../shared/call_video_ipc').CallVideoBridge; analyticsShot: { /** 主进程在隐藏窗口里渲染同一张卡片、抓图、弹保存框(用户窗口全程不动)。 */ render(payload: AnalyticsExportPayload): Promise; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index de73dba60..fe0e61223 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -16,6 +16,7 @@ import { createRequire } from 'node:module'; import type { AnalyticsExportPayload, AnalyticsExportResult } from '../shared/analytics_export'; import { installRendererDiagnosticSampling } from './renderer_diagnostics'; import type { EditMenuState } from '../shared/edit_menu'; +import { CALL_VIDEO_FRAME_CHANNEL, type CallVideoFrame } from '../shared/call_video_ipc'; // Same CJS workaround as src/main/index.ts — electron-trpc 0.7's ESM // build statically imports `ipcMain` from 'electron', which is not @@ -170,6 +171,18 @@ const weqBridge = { claimPayload: () => ipcRenderer.invoke('analytics-shot:claim') as Promise, }, + callVideo: { + /** + * 推一帧 I420(通话视频上行)。 + * + * 走原生 IPC 而不是 tRPC:`Uint8Array` 在 `ipcRenderer.send` 里直接结构化 + * 克隆(0.17 ms),经 tRPC + superjson 会被展开成十进制数组(一帧 1.2 MB + * 文本、30 ms)。视频帧本来就该 fire-and-forget,不等回执。 + */ + pushFrame(frame: CallVideoFrame): void { + ipcRenderer.send(CALL_VIDEO_FRAME_CHANNEL, frame); + }, + }, }; if (process.contextIsolated) { diff --git a/apps/desktop/src/renderer/src/env.d.ts b/apps/desktop/src/renderer/src/env.d.ts index 6253d21c9..41dbb8796 100644 --- a/apps/desktop/src/renderer/src/env.d.ts +++ b/apps/desktop/src/renderer/src/env.d.ts @@ -53,6 +53,13 @@ interface Window { capture: { window(): Promise<{ ok: boolean; error?: string }>; }; + /** + * 通话视频上行(二进制 IPC)。 + * + * 只用于 Electron;web 构建下为 undefined,调用方要回退到 tRPC 的 + * `pushVideoFrame`。 + */ + callVideo?: import('../../shared/call_video_ipc').CallVideoBridge; analyticsShot: { /** 主进程在隐藏窗口里渲染同一张卡片、抓成长图、弹保存框(用户窗口全程不动)。 */ render( diff --git a/apps/desktop/src/renderer/src/hooks/useCallCamera.ts b/apps/desktop/src/renderer/src/hooks/useCallCamera.ts index 39f639c22..01018ac5d 100644 --- a/apps/desktop/src/renderer/src/hooks/useCallCamera.ts +++ b/apps/desktop/src/renderer/src/hooks/useCallCamera.ts @@ -156,6 +156,15 @@ export function useCallCamera(options: { enabled: boolean }): CallCameraState { const rgba = ctx.getImageData(0, 0, ENCODE_WIDTH, ENCODE_HEIGHT).data; const { y, u, v } = rgbaToI420(rgba, ENCODE_WIDTH, ENCODE_HEIGHT); pushCount.current++; + // Electron 走二进制 IPC(结构化克隆,一帧 ~0.2 ms);tRPC + superjson 会把 + // 345 KB 的 Uint8Array 展开成 1.2 MB 十进制文本(实测 30 ms/帧),15 fps + // 下渲染层主线程被吃满 —— 采集、解码、绘制全都跟着卡。 + // web 构建(apps/web 复用同一份 renderer)没有这个桥,回退到 tRPC。 + const bridge = window.weq?.callVideo; + if (bridge) { + bridge.pushFrame({ y, u, v }); + return; + } // 只在首批帧失败时报错:持续失败会刷屏,但完全静默又没法排查。 pushRef.current.mutate( { y, u, v }, diff --git a/apps/desktop/src/shared/call_video_ipc.ts b/apps/desktop/src/shared/call_video_ipc.ts new file mode 100644 index 000000000..3de9ac67a --- /dev/null +++ b/apps/desktop/src/shared/call_video_ipc.ts @@ -0,0 +1,31 @@ +/** + * 通话视频帧的**二进制 IPC 通道**。 + * + * 为什么不用 tRPC:视频帧是 640×360 的 I420,一帧 345 KB,15 fps 就是 5 MB/s。 + * tRPC 走 superjson,`Uint8Array` 会被展开成十进制数字数组(JSON 文本 1.2 MB), + * 实测序列化 15 ms、结构化克隆 10 ms、反序列化 5 ms —— 一帧 30 ms,15 fps 下 + * 渲染层主线程根本跑不满,采集、解码、绘制全被拖慢(表现:两端画面都极度卡顿)。 + * + * 走 `ipcRenderer.send` 的原生通道时,`Uint8Array` 直接走结构化克隆(实测 + * 0.17 ms),比 tRPC 快两个数量级,而且不用等往返 —— 视频帧本来就该是 + * fire-and-forget。 + * + * 只用于 **Electron**。web 构建(`apps/web` 复用同一份 renderer)没有 + * `window.weq`,调用方会回退到 tRPC 的 `pushVideoFrame`。 + */ + +/** 渲染层 → 主进程:一帧 I420。 */ +export const CALL_VIDEO_FRAME_CHANNEL = 'call:video-frame'; + +/** 一帧 I420 的三个平面(Y 全尺寸、U/V 各四分之一)。 */ +export interface CallVideoFrame { + y: Uint8Array; + u: Uint8Array; + v: Uint8Array; +} + +/** preload 暴露给渲染层的视频帧桥。 */ +export interface CallVideoBridge { + /** 推一帧 I420(不等待主进程,失败静默)。 */ + pushFrame(frame: CallVideoFrame): void; +} diff --git a/docs/develop/video-call-20261025.md b/docs/develop/video-call-20261025.md index 2feafc842..595b44d03 100644 --- a/docs/develop/video-call-20261025.md +++ b/docs/develop/video-call-20261025.md @@ -61,11 +61,40 @@ H.265 Annex B 以 00 00 00 01 开头,**明文** **分片规则**(2026-10-25 21 秒真实通话,451 帧全量统计): - 整帧**等分**,末片自然短。单片 payload 最大 1277 B,UDP 包最大 1303 B; -- `total_fec` 恒 0,`fidx` 恒 1(c2c 不启用增强层); -- `pkg_idx` 帧内从 0 递增,`total_pkg` 实测 1–14; +- `total_fec` 恒 0;`pkg_idx` 帧内从 0 递增,`total_pkg` 实测 1–14; - **`seq` 每片 +1**,整通连续(实测 0…717),不按帧重置; - **外层 `tick` 帧内恒定**,每帧换一次。 +### 3.2.0 `gidx` / `fidx`:16 位帧序号(2026-10-26 修正) + +**`fidx` 不是 SVC 层号,是帧序号的高字节。** `gidx` 是低字节,每帧 +1 并在 +256 处回绕;`fidx` 从 1 起,跟着回绕 +1: + +``` +frameIndex = ((fidx - 1) << 8) | gidx +``` + +真机日志(连续 600 帧,2026-10-25)逐段取样: + +| 日志 gidx / fidx | 拼出的帧序号 | +| --- | --- | +| g=0, f=1 | 0 | +| g=249, f=1 | 249 | +| g=43, f=2 | 299 | +| g=243, f=2 | 499 | +| g=37, f=3 | 549 | +| g=87, f=3 | 599 | + +每 50 帧一档,全程连续;中途关 / 开摄像头也不重置。同一时刻只有一个 `fidx` +值在发,**不存在「基础层 + 增强层」并行**。 + +> 早期结论「fidx=1 基础层 / fidx>=2 时域增强层」是错的,代价是一个真 bug:主进程 +> 据此丢弃 `fidx>=2` 的帧,而 `lastFrameWasBase` 停在 true 后不再更新 —— 通话到 +> 第 256 帧(10 fps 下约 25 秒)之后画面**永久冻结**。发送侧同样踩了这个坑: +> `gidx = frameIndex & 0xff` 且 `fidx` 恒 1,第 256 帧包头从 `g=255/f=1` 跳回 +> `g=0/f=1`,帧序号倒退。两侧都已改成 16 位帧序号(`videoFrameIndex` / +> `videoFrameHeader`)。 + **sub-header 20 字节**(`pkg_idx=0` 的那片才有,后续分片是裸 Annex B 续): | 偏移 | 含义 | @@ -168,6 +197,7 @@ body:`[0]=0x02`,`[4:8]` 是 tick 的小端副本,其余为 0。 | 信令档位 | `packages/protocol/src/call/signaling.ts`(`MEDIA_PROFILE`) | | 会话收发与状态 | `packages/service/src/account/call_session.ts` | | 本端采集与 t265 编码 | `apps/desktop/src/main/call_video_encoder.ts` | +| 上行帧的二进制 IPC | `apps/desktop/src/shared/call_video_ipc.ts` + `main/call_video_ipc.ts` | | 渲染层解码(WebCodecs) | `apps/desktop/src/renderer/src/hooks/useCallVideo.ts` | | 解码的纯逻辑(可单测) | `apps/desktop/src/renderer/src/lib/callVideo.ts` | @@ -177,6 +207,18 @@ body:`[0]=0x02`,`[4:8]` 是 tick 的小端副本,其余为 0。 - `CallSession.setCamera(enabled)` —— 开 / 关本端摄像头(发控制包); - `CallVideoEncoder` —— 主进程注入的编码器,`next()` 吐一帧裸流。 +### 5.1 上行帧走二进制 IPC,不走 tRPC(2026-10-26) + +采集在渲染层、编码在主进程,中间要传一帧 640×360 的 I420(345 KB)。**不要用 +tRPC 传它**:tRPC 的 superjson 会把 `Uint8Array` 展开成十进制数字数组,实测一帧 +序列化 15 ms + 结构化克隆 10 ms + 反序列化 5 ms = 30 ms,15 fps 下渲染层主线程被 +吃满,采集、解码、绘制一起卡(两端画面都卡)。 + +现在走 `window.weq.callVideo.pushFrame()` → `ipcRenderer.send` 的原生通道, +`Uint8Array` 直接结构化克隆(实测 0.17 ms,快两个数量级),且天然 fire-and-forget。 +web 构建(`apps/web` 复用同一份 renderer)没有这个桥,调用方回退到 tRPC 的 +`pushVideoFrame`。 + ## 6. 渲染层解码:参数集必须内联 Chromium 的 WebCodecs HEVC 解码器对 `VideoDecoderConfig.description` 的要求很挑 @@ -233,10 +275,15 @@ Chromium 的 WebCodecs HEVC 解码器对 `VideoDecoderConfig.description` 的要 | 字段 | 实测值 | 排查过的假设 | | --- | --- | --- | -| `[6:8]` | 每帧变化(6948 / 8746 / 8233…) | CRC16-CCITT / CRC16-IBM / 字节和 / 16 位字和 / 帧长 / 分片数,**全部对不上** | -| `[16:20]` | 小整数 0–5(偶尔更大) | 疑似分片数相关(`total_pkg-1` 只对上一部分),未确认 | +| `[6:8]` | 码率档 + 帧质量(2026-10-26 确认) | 见 §3.2 下方 `buildVideoSubHeader` 的说明 | +| `[16:20]` | 小整数 0–8,**与分片数无关** | `total_pkg-1` 只对上一部分,未确认 | + +`[16:20]` 逐帧实测(52 帧全量):`totalPkg` 为 4/4/4/3/6/5/6/6/7/9/8/10 时该字段是 +2/0/3/0/0/1/4/0/4/0/3/8 —— 既不是 `totalPkg - 1` 也不是帧内分片计数。当前实现填 +`totalPkg - 1` 只是占位。若对端仍不认我们发的画面,这里是第一顺位可疑字段。 -当前实现两个字段都填 0。若对端仍不认我们发的画面,优先回头试这两个。 +`[6:8]` 已确认来自 `GetVideoFrameQuality()`(`[6]`=码率档 16–25、`[7]`=帧质量 +31–38,关键帧整体低一档),对端只拿它做统计,不参与解码,按真实分布抽样即可。 ### 7.2 P2P 端口协商 diff --git a/packages/protocol/src/call/index.ts b/packages/protocol/src/call/index.ts index 61ee41bf9..1328ad017 100644 --- a/packages/protocol/src/call/index.ts +++ b/packages/protocol/src/call/index.ts @@ -68,6 +68,8 @@ export { buildVideoSubHeader, sampleVideoFrameQuality, videoPacketCount, + videoFrameIndex, + videoFrameHeader, } from './video'; export type { VideoFrameQuality, diff --git a/packages/protocol/src/call/video.ts b/packages/protocol/src/call/video.ts index 09ae2716b..40928cf9d 100644 --- a/packages/protocol/src/call/video.ts +++ b/packages/protocol/src/call/video.ts @@ -26,8 +26,15 @@ * 3. **sub-header `[2]` 是方向标识**(c2s=0x00 / s2c=0x03),且时间戳布局随之 * 变化。恒写 0x03 等于把自己伪装成对端方向。 * - * 帧的归属靠 `(gidx, fidx)`:`fidx=1` 是基础层(可独立解码),`fidx>=2` 是时域 - * 增强层(依赖基础层,跳过可避免花屏)。同一个 GOP 内 `gidx` 递增。 + * 帧的归属靠 `(gidx, fidx)`:它俩拼起来是**一个 16 位帧序号** —— + * `frameIndex = ((fidx - 1) << 8) | gidx`。`gidx` 是低字节,每帧 +1 并在 256 处 + * 回绕;`fidx` 从 1 起,跟着 `gidx` 的回绕 +1(实测连续 600 帧:g=249/f=1 → + * g=43/f=2 → g=87/f=3,拼出来的序号 249→299→343 全程连续)。 + * + * ⚠️ 早期把 `fidx` 当成「时域增强层」(fidx=1 基础层、fidx>=2 增强层),据此在 + * 主进程过滤掉 `fidx>=2` 的帧。真机日志证明那是错的:第 256 帧之后 fidx 变成 2, + * 于是**从第 256 帧起所有画面都被丢掉**,表现为「视频播二十来秒就永久冻结」。 + * 它也不是 SVC 的层号 —— 同一时刻只有一个 fidx 值在发。 * * `[9:13]` 两侧都是**每方向恒定的会话时钟**(实测 c2s/s2c 各差 1),不是每包 * 变化的时间戳。真正的帧时间戳在 sub-header 里。 @@ -94,7 +101,12 @@ export interface VideoRtpHeader { multiFrameFec: number; /** GOP 索引。 */ gidx: number; - /** GOP 内的帧索引:1 = 基础层,>=2 = 增强层。 */ + /** + * 帧序号高字节(`fidx`)。 + * + * **不是 SVC 层号**:`((fidx - 1) << 8) | gidx` 才是真正的 16 位帧序号, + * 见 {@link VideoFrame.frameIndex}。接收侧只把它当帧身份的一部分用。 + */ fidx: number; /** 扩展段解析结果。 */ extensions: VideoExtension[]; @@ -202,11 +214,16 @@ export function parseVideoRtpHeader(payload: Uint8Array): VideoRtpHeader | null export interface VideoFrame { gidx: number; fidx: number; + /** + * 16 位帧序号:`((fidx - 1) << 8) | gidx`。 + * + * 这是**帧的真正身份**(接收侧判连续性、丢弃迟到帧都用它),`gidx` 单独用会在 + * 每 256 帧回绕一次。见文件头关于 `fidx` 语义的说明。 + */ + frameIndex: number; /** 同一帧的各个分片共用(视频里就是 ssrc 的位置)。 */ tick: number; subType: number; - /** 基础层(`fidx === 1`)才能独立解码。 */ - isBaseLayer: boolean; /** * 是否为关键帧(IRAP:IDR / CRA / BLA)。 * @@ -312,6 +329,23 @@ export class VideoFrameAssembler { } } +/** + * `(gidx, fidx)` → 16 位帧序号。 + * + * 实测 2026-10-25 真机抓包 + 日志(连续 600 帧):`gidx` 是低字节、`fidx` 从 1 起 + * 是**高字节 + 1**,即 `((fidx - 1) << 8) | gidx`。拼接后帧序号全程连续, + * 包含中途关 / 开摄像头也不重置。 + */ +export function videoFrameIndex(gidx: number, fidx: number): number { + return (((fidx - 1) & 0xff) << 8) | (gidx & 0xff); +} + +/** 16 位帧序号 → 包头里的 `{ gidx, fidx }`。 */ +export function videoFrameHeader(index: number): { gidx: number; fidx: number } { + const wrapped = index & 0xffff; + return { gidx: wrapped & 0xff, fidx: ((wrapped >> 8) & 0xff) + 1 }; +} + function assemble(slot: PendingFrame): VideoFrame { let total = 0; for (let i = 0; i < slot.totalPkg; i++) total += slot.parts.get(i)?.length ?? 0; @@ -333,9 +367,9 @@ function assemble(slot: PendingFrame): VideoFrame { return { gidx: slot.gidx, fidx: slot.fidx, + frameIndex: videoFrameIndex(slot.gidx, slot.fidx), tick: slot.tick, subType: slot.subType, - isBaseLayer: slot.fidx === 1, // 关键帧按 NAL 类型判,不能按 gidx —— 见 `VideoFrame.isKeyframe` 的注释。 isKeyframe: containsIrap(frameData, subHeaderSize), subHeaderSize, @@ -542,11 +576,15 @@ export function videoPacketCount(frameBytes: number, maxPayload = 1200): number * **已含 sub-header** 的完整帧数据,与 `VideoFrame.frameData` 同形,这样收发 * 两侧可以共用组帧逻辑做回归比对。 * - * `fidx` 固定 1(基础层):实测 c2c 通话不启用时域增强层。 + * `frameIndex` 是 16 位帧序号,包头里的 `(gidx, fidx)` 由它拆出来 + * ({@link videoFrameHeader})—— 只写 `gidx` 会在第 256 帧回绕、帧序号倒退。 + * 调用方可以直接给 `gidx` / `fidx` 覆盖,供黄金样本比对用。 */ export function buildVideoPackets(params: { frameData: Uint8Array; - gidx: number; + /** 16 位帧序号;给了 `gidx`/`fidx` 时以它们为准。 */ + frameIndex?: number; + gidx?: number; fidx?: number; sessionTs: number; /** 本帧所有分片共用的 tick。 */ @@ -558,7 +596,9 @@ export function buildVideoPackets(params: { /** 覆盖单片的 tick(调试用;默认所有分片相同)。 */ tickForPacket?: (index: number) => number; }): Uint8Array[] { - const fidx = params.fidx ?? 1; + const header = videoFrameHeader(params.frameIndex ?? 0); + const gidx = params.gidx ?? header.gidx; + const fidx = params.fidx ?? header.fidx; const subType = params.subType ?? VideoSubType.max; const totalPkg = videoPacketCount(params.frameData.length, params.maxPayload); // 等分:末片自然短。真实客户端的末片不带 0 填充(填充语义未经证实,不补)。 @@ -584,7 +624,7 @@ export function buildVideoPackets(params: { out[AUDIO_HEADER_LEN + 1] = totalPkg; out[AUDIO_HEADER_LEN + 2] = 0; out[AUDIO_HEADER_LEN + 3] = 0x0b; - out[AUDIO_HEADER_LEN + 4] = params.gidx & 0xff; + out[AUDIO_HEADER_LEN + 4] = gidx & 0xff; out[AUDIO_HEADER_LEN + 5] = fidx & 0xff; // 那条扩展 `03 03 00`(len=3 含自身、type=3、data 1 字节)——必须完整写出来, @@ -630,9 +670,14 @@ export const VIDEO_SUBHEADER_LEN = 20; * [6:8] 帧质量 + 码率档 = GetVideoFrameQuality() 的 16 位返回值 * [8:12] 帧时间戳(见上表,随方向变) * [12:16] 会话基准 tick(c2s)/ 0(s2c) - * [16:20] 帧内分片序号相关(实测 0–4 的小整数,不随发送方变化) + * [16:20] 未还原(实测 0–8 的小整数,**与分片数无关**) * ``` * + * `[16:20]` 逐帧实测(2026-10-25 抓包,52 帧全量):`totalPkg` 分别是 + * 4/4/4/3/6/5/6/6/7/9/8/10 时,该字段是 2/0/3/0/0/1/4/0/4/0/3/8 —— 既不是 + * `totalPkg - 1`,也不是帧内分片计数。目前填 `totalPkg - 1` 只是占位,未证实 + * 对端会读它。真要排查「对端不认我们的画面」时,这里是第一顺位可疑字段。 + * * `[6:8]` 来自 `VideoPacketWriter::PackRTPEx`: * * ```c diff --git a/packages/protocol/test/call_video.test.ts b/packages/protocol/test/call_video.test.ts index 05e94d657..b68b3d251 100644 --- a/packages/protocol/test/call_video.test.ts +++ b/packages/protocol/test/call_video.test.ts @@ -28,6 +28,8 @@ import { parseVideoPacket, parseVideoRtpHeader, splitHevcNals, + videoFrameHeader, + videoFrameIndex, } from '../src/call'; function hex(s: string): Uint8Array { @@ -241,7 +243,7 @@ describe('VideoFrameAssembler', () => { expect(frame).not.toBeNull(); expect(frame!.gidx).toBe(0); expect(frame!.fidx).toBe(1); - expect(frame!.isBaseLayer).toBe(true); + expect(frame!.frameIndex).toBe(0); expect(frame!.isKeyframe).toBe(true); expect(frame!.subHeaderSize).toBe(0x14); }); @@ -280,11 +282,36 @@ describe('VideoFrameAssembler', () => { expect(assembler.pendingCount).toBe(1); }); - it('增强层(fidx=2)标记为非基础层', () => { + /** + * 回归:`fidx` 是**帧序号高字节**,不是 SVC 层号。 + * + * 2026-10-25 真机日志(连续 600 帧)实测:g=249/f=1 → g=43/f=2 → g=87/f=3, + * 拼出来的帧序号 249→299→599 全程连续(每 50 帧取样一次)。早先按 + * `fidx === 1` 判「基础层」,主进程据此丢掉 fidx>=2 的帧 —— 第 256 帧 + * (10 fps 下约 25 秒)之后画面永久冻结。 + */ + it('(gidx, fidx) 拼成 16 位帧序号,跨 256 帧连续', () => { + expect(videoFrameIndex(0, 1)).toBe(0); + expect(videoFrameIndex(249, 1)).toBe(249); + expect(videoFrameIndex(43, 2)).toBe(299); + expect(videoFrameIndex(37, 3)).toBe(549); + expect(videoFrameIndex(87, 3)).toBe(599); + // 回绕点前后必须连续,不能倒退。 + expect(videoFrameIndex(255, 1) + 1).toBe(videoFrameIndex(0, 2)); + }); + + it('videoFrameHeader 是 videoFrameIndex 的逆运算', () => { + for (const index of [0, 1, 249, 255, 256, 299, 65535]) { + const header = videoFrameHeader(index); + expect(videoFrameIndex(header.gidx, header.fidx)).toBe(index); + } + }); + + it('fidx=2 的帧照样是普通视频帧(不是增强层,不该被丢)', () => { const assembler = new VideoFrameAssembler(); const packets = packetsFor(3, 2, ['1400']); const frame = assembler.push(parseVideoPacket(packets[0]!)!, 1000); - expect(frame!.isBaseLayer).toBe(false); + expect(frame!.frameIndex).toBe(259); expect(frame!.isKeyframe).toBe(false); }); @@ -529,6 +556,35 @@ describe('buildVideoPackets', () => { expect(parsed.every((p) => p.tick === 100)).toBe(true); }); + /** + * 回归:帧序号必须用 16 位,`fidx` 要跟着 `gidx` 回绕 +1。 + * + * 只写 `gidx`(fidx 恒 1)时,第 256 帧的包头会从 `g=255/f=1` 跳回 `g=0/f=1`, + * 对端按 `(fidx<<8)|gidx` 判连续性会看到序号从 255 倒退到 0。 + */ + it('第 256 帧起 fidx 递增,帧序号不回退', () => { + const send = (frameIndex: number) => { + const packets = buildVideoPackets({ + frameData: new Uint8Array(8), + frameIndex, + sessionTs: 1, + tick: 100, + seq: 0, + maxPayload: 1000, + }); + return parseVideoPacket(packets[0]!)!.rtp; + }; + + expect(send(255)).toMatchObject({ gidx: 255, fidx: 1 }); + expect(send(256)).toMatchObject({ gidx: 0, fidx: 2 }); + expect(send(299)).toMatchObject({ gidx: 43, fidx: 2 }); + // 发出去的包头能被自己的解析器还原成同一个帧序号。 + for (const index of [0, 255, 256, 299, 1000]) { + const rtp = send(index); + expect(videoFrameIndex(rtp.gidx, rtp.fidx)).toBe(index); + } + }); + it('发出的包能被自己的解析器还原(收发同构)', () => { const original = hex('00000001 40010c01 ffffff'); const packets = buildVideoPackets({ diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index 8e488d76f..0ed3b203e 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -125,7 +125,7 @@ export interface CallSnapshot { videoSending: boolean; /** 对端摄像头是否开着(收到对端视频流)。 */ videoReceiving: boolean; - /** 收到的视频帧数(含增强层)。 */ + /** 收到的视频帧数。 */ receivedVideoFrames: number; /** 视频流的最近一次关键帧信息;没收到过为 null。 */ videoInfo: VideoStreamInfo | null; @@ -232,8 +232,13 @@ export type CallVideoListener = (frame: VideoFramePayload, at: number) => void; export interface VideoFramePayload { gidx: number; fidx: number; - /** 基础层才能独立解码。 */ - isBaseLayer: boolean; + /** + * 16 位帧序号 `((fidx - 1) << 8) | gidx`。 + * + * **不要再用 `fidx` 判层**:`fidx` 是帧序号高字节(每 256 帧 +1),不是 SVC + * 层号。早先按 `fidx === 1` 过滤「增强层」,导致第 256 帧之后画面永久冻结。 + */ + frameIndex: number; isKeyframe: boolean; /** 真实帧时间戳(毫秒)。 */ timestamp: number; @@ -360,18 +365,27 @@ function formatIpv6(raw: Uint8Array): string | null { * 只在关键帧上调:IDR 前面带 VPS + SPS + PPS,SPS 里有分辨率。实测视频流 * 640×360 Main。解析失败返回 null —— 规格只是给界面显示的,读不到不影响解码。 * - * SPS 的字段顺序(HEVC):`profile_tier_level`(12B) 之后是 - * `sps_seq_parameter_set_id`、`chroma_format_idc`(==3 时多一个 - * `separate_colour_plane_flag`)、`pic_width_in_luma_samples`、 - * `pic_height_in_luma_samples`,都是 Exp-Golomb。 + * SPS 的字段顺序(HEVC):NAL 头 2 字节之后是 + * `sps_video_parameter_set_id`(4b) + `sps_max_sub_layers_minus1`(3b) + + * `sps_temporal_id_nesting_flag`(1b) = 1 字节,再往后是 + * `profile_tier_level`(12B),然后才是 `sps_seq_parameter_set_id`、 + * `chroma_format_idc`(==3 时多一个 `separate_colour_plane_flag`)、 + * `pic_width_in_luma_samples`、`pic_height_in_luma_samples`,都是 Exp-Golomb。 + * + * ⚠️ 偏移是 **13 字节**不是 12:早先按 12 字节跳,等于把 + * `temporal_id_nesting_flag` 当成了 `sps_seq_parameter_set_id` 的首位,读出来的 + * 是 `chroma=0 / width=0 / height=1`,于是这个函数**永远返回 null**(界面拿不到 + * 分辨率)。另外必须先剥掉 emulation prevention byte(`00 00 03` → `00 00`), + * 否则一旦载荷里出现该序列,位流整体错位。 */ function readHevcInfo(annexb: Uint8Array): VideoStreamInfo | null { for (const nal of splitHevcNals(annexb)) { // SPS = 33。NAL 头 2 字节,之后是 RBSP。 - if (nal.type !== 33 || nal.data.length < 15) continue; + if (nal.type !== 33 || nal.data.length < 16) continue; const profile = nal.data[1]!; // profile_idc 在 NAL 头的第二个字节 - const rbsp = nal.data.subarray(2 + 12); - const bits = new BitReader(rbsp); + const rbsp = removeEmulationPrevention(nal.data.subarray(2)); + if (rbsp.length < 13) continue; + const bits = new BitReader(rbsp.subarray(13)); try { bits.ue(); // sps_seq_parameter_set_id const chroma = bits.ue(); @@ -388,6 +402,36 @@ function readHevcInfo(annexb: Uint8Array): VideoStreamInfo | null { return null; } +/** + * 剥掉 NAL 载荷里的 emulation prevention byte。 + * + * H.264/H.265 规定 RBSP 里出现 `00 00 00/01/02/03` 时,编码端在第二个 `00` + * 之后插一个 `03` 防止与起始码混淆。解析位流前必须先把这些 `03` 去掉。 + */ +function removeEmulationPrevention(data: Uint8Array): Uint8Array { + // 绝大多数 NAL 不含该序列,先扫一遍,没有就零拷贝返回。 + let has = false; + for (let i = 0; i + 2 < data.length; i++) { + if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3) { + has = true; + break; + } + } + if (!has) return data; + const out = new Uint8Array(data.length); + let n = 0; + for (let i = 0; i < data.length; i++) { + if (i + 2 < data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3) { + out[n++] = 0; + out[n++] = 0; + i += 2; // 跳过被插入的 03 + continue; + } + out[n++] = data[i]!; + } + return out.subarray(0, n); +} + /** 极简位读取器,只够解析 SPS 的前几个 Exp-Golomb 字段。 */ class BitReader { private pos = 0; @@ -1430,7 +1474,7 @@ export class CallSession { const payload: VideoFramePayload = { gidx: frame.gidx, fidx: frame.fidx, - isBaseLayer: frame.isBaseLayer, + frameIndex: frame.frameIndex, isKeyframe: frame.isKeyframe, timestamp: frame.timestamp, info: this.videoInfo, @@ -1641,7 +1685,9 @@ export class CallSession { const packets = buildVideoPackets({ frameData, - gidx: this.videoFrameIndex & 0xff, + // 16 位帧序号:包头里的 gidx/fidx 由它拆出。只写 gidx 会在第 256 帧回绕、 + // 帧序号倒退,对端按序号判连续性时会当成乱序而丢帧。 + frameIndex: this.videoFrameIndex, sessionTs: this.sessionTs, // tick 帧内恒定;seq 由 buildVideoPackets 按片递增。 tick: this.tick(), diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index e425fed96..641e1604c 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -36,6 +36,7 @@ import { parseVideoPacket, pbBytes, pbVarint, + videoFrameIndex, } from '@weq/protocol'; // ─────────────────────────── 测试替身 ─────────────────────────── @@ -298,6 +299,14 @@ function videoPacket(params: { return out; } +/** 十六进制串 → 字节。 */ +function hexBytes(s: string): Uint8Array { + const clean = s.replace(/\s+/g, ''); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16); + return out; +} + /** 把会话推进到 connected。 */ async function connect(ctx: ReturnType): Promise { await ctx.session.start(); @@ -348,7 +357,7 @@ describe('接收视频', () => { expect(frames).toHaveLength(1); const frame = frames[0]!; expect(frame.gidx).toBe(0); - expect(frame.isBaseLayer).toBe(true); + expect(frame.frameIndex).toBe(0); expect(frame.isKeyframe).toBe(true); expect(frame.hevc).not.toBeNull(); expect([...frame.hevc!]).toEqual([...body]); @@ -399,6 +408,37 @@ describe('接收视频', () => { expect(frames).toHaveLength(1); }); + /** + * 回归:关键帧要能读出分辨率。 + * + * 真实 SPS 来自 2026-10-25 抓包(Windows QQ 发的 640×360 Main)。早先 RBSP + * 起点按 12 字节跳(少算了 `sps_video_parameter_set_id` 那 1 字节),读出来是 + * `chroma=0 / w=0 / h=1`,`readHevcInfo` 永远返回 null —— 界面拿不到分辨率。 + */ + it('从真实关键帧的 SPS 读出 640×360', async () => { + const ctx = makeSession(); + await connect(ctx); + + // VPS + SPS + PPS + IDR_W_RADL,SPS 用的是抓包原文。 + const annexb = hexBytes( + '0000000140010c01' + + '00000001420101016000000300900000030000030096a005020169659aa4932e4020' + + '000000014401c172b0' + + '000000012601afe0', + ); + const whole = new Uint8Array(20 + annexb.length); + whole[0] = 20; + whole.set(annexb, 20); + + ctx.transport.deliver(videoPacket({ gidx: 0, pkgIdx: 0, totalPkg: 1, chunk: whole })); + expect(ctx.session.snapshot().videoInfo).toEqual({ + width: 640, + height: 360, + profile: 1, + codec: 'hevc', + }); + }); + it('控制包是周期公告,重复收到不翻转状态', async () => { // 2026-10-25 真机抓包:一方 14 秒内发了 316 个控制包(每秒 20+)。 // 早先按「收到即取反」实现,界面会每秒闪 20 次。 @@ -513,6 +553,37 @@ describe('发送视频', () => { expect([...frame!.frameData.subarray(24, 26)]).toEqual([0x40, 0x01]); }); + /** + * 回归:发送侧必须用 16 位帧序号。 + * + * 早先 `gidx = videoFrameIndex & 0xff`、`fidx` 恒 1,第 256 帧包头从 + * `g=255/f=1` 跳回 `g=0/f=1`,帧序号倒退 —— 对端按序号判连续性时会当成乱序。 + */ + it('跨 256 帧后 fidx 递增,帧序号不回退', async () => { + const encoder = new FakeVideoEncoder(300); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + ctx.session.setCamera(true); + await ctx.timers.advanceAsync(5000); + + // 只看每个视频帧的首个分片(pkgIdx === 0),按发送顺序取帧序号。 + const seen: Array<{ gidx: number; fidx: number }> = []; + for (const packet of ctx.transport.media(VideoSubType.max)) { + const parsed = parseVideoPacket(packet)!; + if (parsed.rtp.pkgIdx !== 0) continue; + seen.push({ gidx: parsed.rtp.gidx, fidx: parsed.rtp.fidx }); + } + expect(seen.length).toBeGreaterThan(256); + + const indexes = seen.map((s) => videoFrameIndex(s.gidx, s.fidx)); + for (let i = 1; i < indexes.length; i++) { + expect(indexes[i]!).toBe(indexes[i - 1]! + 1); + } + // 第 256 帧必须已经换到 fidx=2,而不是把 gidx 绕回 0。 + expect(seen[255]).toEqual({ gidx: 255, fidx: 1 }); + expect(seen[256]).toEqual({ gidx: 0, fidx: 2 }); + }); + it('sub-header 按 c2s 方向填([2]=0x00、[3]=0x06、PTS 递增)', async () => { const encoder = new FakeVideoEncoder(2); const ctx = makeSession({ media: 'video', videoEncoder: encoder }); From c950b14740b6af46d699dca248f7260b7ff91109 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 26 Oct 2026 03:13:56 +0800 Subject: [PATCH 03/23] =?UTF-8?q?fix(call):=20=E8=A1=A5=E4=B8=8A=20GCS=20?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=8F=82=E6=95=B0=E5=85=AC=E5=91=8A=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=80=E6=91=84=E5=83=8F=E5=A4=B4=E5=AF=B9?= =?UTF-8?q?=E7=AB=AF=E6=97=A0=E6=84=9F=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-10-26 QQ 本体抓包(docs 外的 weq-call-captures 存档)显示:本端开始发 视频后会在 GCS 上推 bt=77 的视频参数,之后每 2 秒重发一次。WeQ 只发 subType=4 控制包、从不发这条,对端因此不知道本端开了摄像头 —— 表现为手机和 电脑都看不到我们的画面。 - 新增 buildVideoParamPush(24 字节 body,逐字节对齐真实抓包)与 buildVideoStreamPush(13 字节流索引形态)。 - CallSession 在开摄像头 / 视频通话自动开摄像头时启动 2 秒重发的公告循环, 关摄像头时停止,teardown 清理定时器。 - VideoStreamInfo 增加 fps / bitrateKbps,编码器 info() 带上实际配置, 公告的分辨率与码率必须与编码器一致。 注意真实客户端的两个 protobuf 声明长度(37 / 30)比实际内容(22 / 15)大 15, 照抄实测值而不是"修正",否则会偏离黄金样本。 测试:协议层逐字节还原抓包的两种 body 形态;会话层验证接通即公告、2 秒重发、 关摄像头后停止。 --- CHANGELOG.md | 1 + apps/desktop/src/main/call_video_encoder.ts | 10 +- packages/protocol/src/call/gcs.ts | 108 ++++++++++++++++++ packages/protocol/src/call/index.ts | 2 + packages/protocol/test/call.test.ts | 74 ++++++++++++ packages/service/src/account/call_session.ts | 76 ++++++++++++ .../service/test/call_video_session.test.ts | 57 +++++++++ 7 files changed, 327 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a23ee4662..ecc659c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -593,3 +593,4 @@ - 修复视频通话画面播放二十来秒后永久卡住的问题:帧序号跨 256 帧后不再被误判为「非基础层」丢弃,本端上行也改为 16 位帧序号,两端画面持续更新。 - 修复视频通话画面卡顿:摄像头帧改走二进制 IPC 通道,不再把每帧展开成 1.2 MB 文本经 JSON 传输,采集与编码不再互相拖慢。 - 修复视频通话界面读不到对方分辨率的问题,关键帧参数集解析偏移已校正。 +- 修复视频通话开启摄像头后对端看不到画面的问题:补上 GCS 上的视频参数公告,手机与电脑都能收到本端已开摄像头。 diff --git a/apps/desktop/src/main/call_video_encoder.ts b/apps/desktop/src/main/call_video_encoder.ts index 57c52c9b1..6219b9f65 100644 --- a/apps/desktop/src/main/call_video_encoder.ts +++ b/apps/desktop/src/main/call_video_encoder.ts @@ -155,7 +155,15 @@ export class NativeVideoEncoder implements CallVideoEncoder { } info(): VideoStreamInfo { - return { width: this.width, height: this.height, profile: 1, codec: 'hevc' }; + return { + width: this.width, + height: this.height, + profile: 1, + codec: 'hevc', + // `bt=77` 视频参数公告要用:对端按这里声明的规格准备解码。 + fps: this.fps, + bitrateKbps: this.bitrateKbps, + }; } close(): void { diff --git a/packages/protocol/src/call/gcs.ts b/packages/protocol/src/call/gcs.ts index 4b39e3025..9d46a5dc2 100644 --- a/packages/protocol/src/call/gcs.ts +++ b/packages/protocol/src/call/gcs.ts @@ -202,6 +202,11 @@ export const GcsBodyType = { peerJoinAck: 28, mediaCtrlPush: 75, mediaCtrlAck: 76, + /** + * 视频流参数推送。两种 body 形态:52 字节(分辨率 / 码率)与 13 字节(流索引)。 + * 见 {@link buildVideoParamPush} / {@link buildVideoStreamPush}。 + */ + videoParamPush: 77, logout: 1003, } as const; @@ -386,6 +391,109 @@ export function buildGcsLogout(params: { return buildGcsPacket(head, pbVarint(1, params.reason ?? 2)); } +/** + * 视频流参数推送(bt=77,24 字节 body 形态)。 + * + * 实测(2026-10-26 QQ 本体抓包):本机开始发视频后约 3 秒,对端会在 GCS 上推 + * 这条,之后每 2 秒重发一次。它是「本端要发视频 / 视频参数是什么」的**房间级 + * 公告** —— 只发 `subType=4` 控制包、不发这条时,对端不知道本端开了摄像头 + * (WeQ 早先就是漏了它)。 + * + * 载荷结构(逐字节还原,总长 24 字节): + * + * ```text + * 12 25 f2,声明长度 37 + * 10 a2 02 .f2 = 290 + * 18 01 .f3 = 1 + * 22 1e .f4,声明长度 30 + * 08 0f ..f1 = 15 fps + * 10 01 ..f2 = 1 + * 18 80 05 ..f3 = 640 宽 + * 20 e0 03 ..f4 = 480 高 + * 28 80 28 ..f5 = 5120 码率 kbps + * 30 00 ..f6 = 0 + * ``` + * + * ⚠️ 两个声明长度(37 / 30)都比实际内容(22 / 15)大 15 —— 真实客户端就是这么 + * 发的,服务端与对端都不按这两个长度切字段。**照抄实测值**,别"修正"成实际长度: + * 那样会偏离黄金样本,也就失去了「和真实客户端逐字节一致」的验证手段。 + * + * `f2=290` 与 `f3=1` 的语义未还原,同样照抄。分辨率 / 码率必须与本端编码器 + * 一致,否则对端按错误的规格准备解码。 + */ +export function buildVideoParamPush(params: { + roomId: bigint; + selfUin: number; + sessionTs: number; + typeId: number; + width: number; + height: number; + fps: number; + /** 码率(kbps)。 */ + bitrateKbps: number; +}): Uint8Array { + const head = buildGcsHead({ + roomId: params.roomId, + selfUin: params.selfUin, + sessionTs: params.sessionTs, + typeId: params.typeId, + bodyType: GcsBodyType.videoParamPush, + }); + const videoParams = concat([ + pbVarint(1, params.fps), + pbVarint(2, 1), + pbVarint(3, params.width), + pbVarint(4, params.height), + pbVarint(5, params.bitrateKbps), + pbVarint(6, 0), + ]); + // f4 的声明长度固定写 30(实际 15),与真实客户端一致,见上方说明。 + const inner = concat([ + pbVarint(2, 290), + pbVarint(3, 1), + tag(4, 2), + encodeVarint(30), + videoParams, + ]); + // 外层 f2 的声明长度固定写 37(= 22 + 15),同上。 + const body = concat([tag(2, 2), encodeVarint(37), inner]); + return buildGcsPacket(head, body); +} + +/** + * 视频流索引推送(bt=77,13 字节形态)。 + * + * ```text + * 18 00 f3 = 流索引(0/1/2/3/4/5 递增) + * 3a 09 f7 bytes(9) + * 08 01 .f1 = 1 + * 10 91 83 bd 03 .f2 = 7291281 + * 20 01 .f4 = 1 + * ``` + * + * 实测在视频开始前/中随流建立推送,`f3` 每次 +1。与 52 字节那条是同一 bt 的 + * 两种 body 形态,靠 body 长度与字段区分。 + */ +export function buildVideoStreamPush(params: { + roomId: bigint; + selfUin: number; + sessionTs: number; + typeId: number; + /** 流索引,从 0 起递增。 */ + streamIndex: number; +}): Uint8Array { + const head = buildGcsHead({ + roomId: params.roomId, + selfUin: params.selfUin, + sessionTs: params.sessionTs, + typeId: params.typeId, + bodyType: GcsBodyType.videoParamPush, + }); + const streamInfo = concat([pbVarint(1, 1), pbVarint(2, 7291281), pbVarint(4, 1)]); + const body = concat([pbVarint(3, params.streamIndex), pbBytes(7, streamInfo)]); + return buildGcsPacket(head, body); +} + /** 房间事件推送(bt=3)里的事件类型。 */ export const CallEvent = { invited: 1, diff --git a/packages/protocol/src/call/index.ts b/packages/protocol/src/call/index.ts index 1328ad017..b8ddee4d8 100644 --- a/packages/protocol/src/call/index.ts +++ b/packages/protocol/src/call/index.ts @@ -93,6 +93,8 @@ export { buildGcsHeartbeat, buildGcsAck, buildGcsLogout, + buildVideoParamPush, + buildVideoStreamPush, CallEvent, parseCallEvent, buildStatusReport, diff --git a/packages/protocol/test/call.test.ts b/packages/protocol/test/call.test.ts index 621d70970..8d4a8696b 100644 --- a/packages/protocol/test/call.test.ts +++ b/packages/protocol/test/call.test.ts @@ -13,6 +13,8 @@ import { buildGcsAck, buildGcsAuth, buildGcsLogout, + buildVideoParamPush, + buildVideoStreamPush, buildQfbRequest, buildRelayGetIn, buildStatusReport, @@ -332,6 +334,78 @@ describe('质量反馈', () => { }); }); +// ─────────────── 视频参数公告(bt=77) ─────────────── + +describe('视频参数公告', () => { + /** + * 黄金样本来自 2026-10-26 QQ 本体抓包(docs/develop/call-captures 的 + * `20261026-qqnative-voice-then-video-both.pcap`): + * 对端在 GCS 上推的 52 字节 body,分辨率 640×480、15 fps、5120 kbps。 + */ + const REAL_PARAM_BODY = Uint8Array.from( + Buffer.from('122510a2021801221e080f100118800520e0032880283000', 'hex'), + ); + + it('逐字节还原真实抓包的 52 字节 body', () => { + const packet = buildVideoParamPush({ + roomId: 1n, + selfUin: 10001, + sessionTs: 1, + typeId: 1, + width: 640, + height: 480, + fps: 15, + bitrateKbps: 5120, + }); + const parsed = parseGcsPacket(packet)!; + expect(parsed.bodyType).toBe(GcsBodyType.videoParamPush); + expect([...parsed.body]).toEqual([...REAL_PARAM_BODY]); + }); + + it('分辨率 / 码率跟着参数走(对端按它准备解码)', () => { + const build = (width: number, height: number, fps: number, bitrateKbps: number) => { + const packet = buildVideoParamPush({ + roomId: 1n, + selfUin: 1, + sessionTs: 1, + typeId: 1, + width, + height, + fps, + bitrateKbps, + }); + return parseGcsPacket(packet)!.body; + }; + const a = build(640, 480, 15, 5120); + const b = build(480, 270, 10, 400); + expect([...a]).not.toEqual([...b]); + // 480 / 270 的 varint 分别是 0xe0 0x03 / 0x8e 0x02,必须出现在 body 里。 + expect([...b]).toContain(0x8e); + }); + + /** + * 13 字节形态:流索引递增。抓包里 f3 依次是 0/1/2/3/4/5,f7 恒定。 + */ + it('流索引形态逐字节还原真实抓包', () => { + const expected = [ + '18003a090801109183bd032001', + '18013a090801109183bd032001', + '18023a090801109183bd032001', + ]; + for (let i = 0; i < expected.length; i++) { + const packet = buildVideoStreamPush({ + roomId: 1n, + selfUin: 1, + sessionTs: 1, + typeId: 1, + streamIndex: i, + }); + const parsed = parseGcsPacket(packet)!; + expect(Buffer.from(parsed.body).toString('hex')).toBe(expected[i]); + } + }); +}); + // ─────────────── 协议层的公开契约 ─────────────── describe('不破坏既有机制', () => { diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index 0ed3b203e..c133625c1 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -48,6 +48,7 @@ import { buildStatusReport, buildVideoControl, buildVideoKeepalive, + buildVideoParamPush, buildVideoPackets, buildVideoSubHeader, extractHevc, @@ -139,6 +140,10 @@ export interface VideoStreamInfo { profile: number; /** 编码格式,目前只认 hevc。 */ codec: 'hevc'; + /** 编码帧率;缺省按 {@link DEFAULT_VIDEO_FPS}。 */ + fps?: number; + /** 目标码率(kbps);缺省按 {@link DEFAULT_VIDEO_BITRATE_KBPS}。 */ + bitrateKbps?: number; } // ─────────────────────────── 依赖注入 ─────────────────────────── @@ -287,6 +292,17 @@ const LOGOUT_REPEATS = 5; const FEC_GROUP = 4; /** 视频保活周期(实测 2 秒)。 */ const VIDEO_KEEPALIVE_MS = 2000; +/** + * 视频参数公告(`bt=77`)的重发周期。 + * + * 实测真实客户端每 2 秒重发一次 52 字节那条;它同时也是「本端还在发视频」的 + * 房间级心跳,停发会被对端当成关摄像头。 + */ +const VIDEO_PARAM_PUSH_MS = 2000; +/** 编码帧率缺省值(与 t265 默认配置一致)。 */ +const DEFAULT_VIDEO_FPS = 15; +/** 编码码率缺省值(kbps)。 */ +const DEFAULT_VIDEO_BITRATE_KBPS = 400; /** 同一拍里第二条保活包相对第一条的延迟(实测 40–90 ms)。 */ const VIDEO_KEEPALIVE_SECOND_MS = 60; /** 视频发送轮询间隔:比最慢的帧间隔(10 fps)小一个量级。 */ @@ -542,6 +558,8 @@ export class CallSession { private mediaBaseTick = 0; /** 视频保活定时器。 */ private videoKeepaliveTimer: unknown = null; + /** `bt=77` 视频参数公告的重发定时器。 */ + private videoParamTimer: unknown = null; /** 视频发送循环定时器。 */ private videoTimer: unknown = null; /** 视频接收看门狗。 */ @@ -654,8 +672,11 @@ export class CallSession { this.videoFrameIndex = 0; this.startVideoLoop(); this.startVideoKeepalive(); + // GCS 上的视频参数公告:不发这条,对端不知道本端开了摄像头。 + this.startVideoParamPush(); } else { this.stopVideoLoop(); + this.stopVideoParamPush(); } this.log?.info('摄像头状态', { enabled }); this.emit(); @@ -1319,6 +1340,7 @@ export class CallSession { this.videoFrameIndex = 0; this.startVideoLoop(); this.startVideoKeepalive(); + this.startVideoParamPush(); this.log?.info('视频通话接通,自动开启摄像头'); } this.emit(); @@ -1502,6 +1524,37 @@ export class CallSession { this.sendMedia(packet); } + /** + * 在 GCS 上公告本端视频参数(`bt=77`)。 + * + * **这条不能省。** 2026-10-26 QQ 本体抓包实测:对端开始发视频后会在 GCS 上 + * 推 52 字节的视频参数,之后每 2 秒重发一次;本端开摄像头时同样会推。 + * 只发 `subType=4` 控制包而不发这条,对端就不知道本端开了摄像头 —— WeQ 早先 + * 正是漏了它(表现为「我开了摄像头,手机和电脑都看不到」)。 + * + * 参数必须与编码器一致:对端按这里声明的分辨率 / 码率准备解码。 + */ + private sendVideoParamPush(): void { + if (!this.room) return; + const info = this.videoEncoder?.info(); + const width = info?.width ?? 640; + const height = info?.height ?? 360; + const fps = info?.fps ?? DEFAULT_VIDEO_FPS; + const bitrateKbps = info?.bitrateKbps ?? DEFAULT_VIDEO_BITRATE_KBPS; + const packet = buildVideoParamPush({ + roomId: this.room.roomId, + selfUin: Number(this.opts.selfUin), + sessionTs: this.sessionTs, + typeId: this.nextTypeId(), + width, + height, + fps, + bitrateKbps, + }); + this.send(packet); + this.log?.info('公告视频参数', { width, height, fps, bitrateKbps }); + } + /** * 媒体路径看门狗。 * @@ -1590,6 +1643,27 @@ export class CallSession { this.videoKeepaliveTimer = null; } + /** + * 视频参数公告循环:立刻发一次,之后每 2 秒重发。 + * + * 与 {@link startVideoKeepalive}(`0x5b subType=5`,走媒体路径)不同,这条走 + * **GCS 控制面** —— 它是房间级公告,对端据此才知道本端开了摄像头。 + */ + private startVideoParamPush(): void { + if (this.videoParamTimer !== null) return; + this.sendVideoParamPush(); + this.videoParamTimer = this.timers.setInterval( + () => this.sendVideoParamPush(), + VIDEO_PARAM_PUSH_MS, + ); + } + + private stopVideoParamPush(): void { + if (this.videoParamTimer === null) return; + this.timers.clearInterval(this.videoParamTimer); + this.videoParamTimer = null; + } + /** * 视频发送循环。 * @@ -1837,6 +1911,7 @@ export class CallSession { [this.connectTimer, 'clearTimeout'], [this.gcsRetryTimer, 'clearTimeout'], [this.videoKeepaliveTimer, 'clearInterval'], + [this.videoParamTimer, 'clearInterval'], [this.videoTimer, 'clearTimeout'], [this.videoWatchdogTimer, 'clearInterval'], [this.mediaWatchdogTimer, 'clearInterval'], @@ -1853,6 +1928,7 @@ export class CallSession { this.connectTimer = null; this.gcsRetryTimer = null; this.videoKeepaliveTimer = null; + this.videoParamTimer = null; this.videoTimer = null; this.videoWatchdogTimer = null; this.mediaWatchdogTimer = null; diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index 641e1604c..fe5125029 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -23,6 +23,7 @@ import { AUDIO_HEADER_LEN, AUDIO_STX, CallEvent, + GCS_STX, GcsBodyType, VIDEO_CTRL_SUB_TYPE, VIDEO_EXT_LEN, @@ -33,6 +34,7 @@ import { buildGcsPacket, buildVideoControl, buildVideoKeepalive, + parseGcsPacket, parseVideoPacket, pbBytes, pbVarint, @@ -516,6 +518,61 @@ describe('发送视频', () => { expect(ctx.session.snapshot().videoSending).toBe(false); }); + /** + * 回归:开摄像头必须在 GCS 上公告视频参数(`bt=77`)。 + * + * 2026-10-26 QQ 本体抓包实测:对端开始发视频后会在 GCS 推 24 字节的视频参数, + * 之后每 2 秒重发。只发 `subType=4` 控制包而不发这条时,对端不知道本端开了 + * 摄像头 —— 这正是「WeQ 开摄像头,手机和电脑都看不到」的成因。 + */ + it('视频通话接通时公告视频参数(bt=77),规格与编码器一致', async () => { + const encoder = new FakeVideoEncoder(1); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + + const pushes = ctx.transport.sent + .filter((s) => s.data[0] === GCS_STX) + .map((s) => parseGcsPacket(s.data)) + .filter((p) => p?.bodyType === GcsBodyType.videoParamPush); + expect(pushes.length).toBeGreaterThan(0); + // FakeVideoEncoder 声明 640×360 / 15 fps / 400 kbps: + // 宽 640 = 80 05,高 360 = e8 02,码率 400 = 90 03。 + expect([...pushes[0]!.body]).toEqual([ + 0x12, 0x25, 0x10, 0xa2, 0x02, 0x18, 0x01, 0x22, 0x1e, 0x08, 0x0f, 0x10, 0x01, 0x18, 0x80, + 0x05, 0x20, 0xe8, 0x02, 0x28, 0x90, 0x03, 0x30, 0x00, + ]); + }); + + it('公告每 2 秒重发一次(对端据此判断本端还在发视频)', async () => { + const encoder = new FakeVideoEncoder(1); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + const count = () => + ctx.transport.sent.filter( + (s) => + s.data[0] === GCS_STX && parseGcsPacket(s.data)?.bodyType === GcsBodyType.videoParamPush, + ).length; + const first = count(); + await ctx.timers.advanceAsync(2100); + expect(count()).toBeGreaterThan(first); + }); + + it('关摄像头后停止公告视频参数', async () => { + const encoder = new FakeVideoEncoder(1); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + ctx.session.setCamera(false); + ctx.transport.reset(); + + await ctx.timers.advanceAsync(5000); + + const pushes = ctx.transport.sent.filter( + (s) => + s.data[0] === GCS_STX && parseGcsPacket(s.data)?.bodyType === GcsBodyType.videoParamPush, + ); + expect(pushes).toHaveLength(0); + }); + it('快照里 videoSending 跟着切换', async () => { const ctx = makeSession({ media: 'video' }); await connect(ctx); From 1f503a1b0212e702752daf3a11564939ccdee528 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 26 Oct 2026 03:20:16 +0800 Subject: [PATCH 04/23] =?UTF-8?q?chore(web):=20=E7=BB=9F=E4=B8=80=20web=20?= =?UTF-8?q?=E4=B8=8E=20desktop=20=E7=9A=84=20Vite=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web 从 Vite 6 升到 8,@vitejs/plugin-react 同步升到 6.1.2(其 peer 要求 vite ^8)。此前 desktop 升级 Vite 8 后两包无法 dedupe,pnpm dev 会同时拉起 vite 6 与 vite 8 两份实例;升级后共用一个 8.3.4,lockfile 净减 421 行。 --- apps/web/package.json | 4 +- pnpm-lock.yaml | 424 +----------------------------------------- 2 files changed, 7 insertions(+), 421 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 2f71f413c..9c72903b9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,11 +37,11 @@ "@types/archiver": "^5.3.4", "@types/node": "^22.10.6", "@types/ws": "^8.5.13", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^6.1.2", "esbuild": "^0.28.1", "tailwindcss": "^4.0.0", "tsx": "^4.22.4", "typescript": "^5.7.3", - "vite": "^6.0.0" + "vite": "^8.3.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05e45c048..ec5c4573c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -449,7 +449,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)) + version: 4.3.0(vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) '@types/archiver': specifier: ^5.3.4 version: 5.3.4 @@ -460,8 +460,8 @@ importers: specifier: ^8.5.13 version: 8.18.1 '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(supports-color@7.2.0)(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)) + specifier: ^6.1.2 + version: 6.1.2(vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -475,8 +475,8 @@ importers: specifier: ^5.7.3 version: 5.9.3 vite: - specifier: ^6.0.0 - version: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) + specifier: ^8.3.4 + version: 8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4) packages/account: dependencies: @@ -753,18 +753,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -927,12 +915,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -945,12 +927,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} @@ -963,12 +939,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} @@ -981,12 +951,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} @@ -999,12 +963,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} @@ -1017,12 +975,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} @@ -1035,12 +987,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} @@ -1053,12 +999,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} @@ -1071,12 +1011,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} @@ -1089,12 +1023,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} @@ -1107,12 +1035,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} @@ -1125,12 +1047,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} @@ -1143,12 +1059,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} @@ -1161,12 +1071,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} @@ -1179,12 +1083,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} @@ -1197,12 +1095,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} @@ -1215,24 +1107,12 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} @@ -1245,24 +1125,12 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} @@ -1275,24 +1143,12 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} @@ -1305,12 +1161,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} @@ -1323,12 +1173,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} @@ -1341,12 +1185,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} @@ -1359,12 +1197,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -1822,9 +1654,6 @@ packages: cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -2146,18 +1975,6 @@ packages: '@types/archiver@5.3.4': resolution: {integrity: sha512-Lj7fLBIMwYFgViVVZHEdExZC3lVYsl+QL0VmdNdIzGZH544jHveYWij6qdnBgJQDnR7pMKliN9z2cPZFEbhyPw==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -2343,12 +2160,6 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitejs/plugin-react@6.1.2': resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3255,11 +3066,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -4584,10 +4390,6 @@ packages: peerDependencies: react: '*' - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -5236,46 +5038,6 @@ packages: terser: optional: true - vite@6.4.3: - resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@8.3.4: resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5599,16 +5361,6 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -5788,225 +5540,147 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true - '@esbuild/aix-ppc64@0.25.12': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true '@esbuild/android-arm64@0.21.5': optional: true - '@esbuild/android-arm64@0.25.12': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true '@esbuild/android-arm@0.21.5': optional: true - '@esbuild/android-arm@0.25.12': - optional: true - '@esbuild/android-arm@0.28.1': optional: true '@esbuild/android-x64@0.21.5': optional: true - '@esbuild/android-x64@0.25.12': - optional: true - '@esbuild/android-x64@0.28.1': optional: true '@esbuild/darwin-arm64@0.21.5': optional: true - '@esbuild/darwin-arm64@0.25.12': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/darwin-x64@0.25.12': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/freebsd-arm64@0.25.12': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/freebsd-x64@0.25.12': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true '@esbuild/linux-arm64@0.21.5': optional: true - '@esbuild/linux-arm64@0.25.12': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true '@esbuild/linux-arm@0.21.5': optional: true - '@esbuild/linux-arm@0.25.12': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true '@esbuild/linux-ia32@0.21.5': optional: true - '@esbuild/linux-ia32@0.25.12': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true '@esbuild/linux-loong64@0.21.5': optional: true - '@esbuild/linux-loong64@0.25.12': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true '@esbuild/linux-mips64el@0.21.5': optional: true - '@esbuild/linux-mips64el@0.25.12': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true '@esbuild/linux-ppc64@0.21.5': optional: true - '@esbuild/linux-ppc64@0.25.12': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true '@esbuild/linux-riscv64@0.21.5': optional: true - '@esbuild/linux-riscv64@0.25.12': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/linux-s390x@0.25.12': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/linux-x64@0.25.12': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.12': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true '@esbuild/netbsd-x64@0.21.5': optional: true - '@esbuild/netbsd-x64@0.25.12': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.12': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true '@esbuild/openbsd-x64@0.21.5': optional: true - '@esbuild/openbsd-x64@0.25.12': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.12': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true '@esbuild/sunos-x64@0.21.5': optional: true - '@esbuild/sunos-x64@0.25.12': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true '@esbuild/win32-arm64@0.21.5': optional: true - '@esbuild/win32-arm64@0.25.12': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true '@esbuild/win32-ia32@0.21.5': optional: true - '@esbuild/win32-ia32@0.25.12': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true '@esbuild/win32-x64@0.21.5': optional: true - '@esbuild/win32-x64@0.25.12': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true @@ -6365,8 +6039,6 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.14': optional: true - '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rolldown/pluginutils@1.0.1': {} '@rollup/rollup-android-arm-eabi@4.61.1': @@ -6558,13 +6230,6 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))': - dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 - vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) - '@tailwindcss/vite@4.3.0(vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))': dependencies: '@tailwindcss/node': 4.3.0 @@ -6602,27 +6267,6 @@ snapshots: dependencies: '@types/readdir-glob': 1.1.5 - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.7 - '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -6844,18 +6488,6 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -7906,35 +7538,6 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -9547,8 +9150,6 @@ snapshots: dependencies: react: 19.2.7 - react-refresh@0.17.0: {} - react@19.2.7: {} read-binary-file-arch@1.0.6(supports-color@7.2.0): @@ -10329,21 +9930,6 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.32.0 - vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.61.1 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 22.19.20 - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - tsx: 4.22.4 - vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4): dependencies: lightningcss: 1.33.0 From 53710902f77bd256818785291554c3e3d25885ba Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 26 Oct 2026 03:30:57 +0800 Subject: [PATCH 05/23] =?UTF-8?q?fix(call):=20=E5=AF=B9=E9=BD=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=20sub-header=20=E7=9A=84=E5=9F=BA=E5=87=86=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=B8=8E=E5=88=86=E7=89=87=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对 2026-10-26 QQ 本体抓包逐帧比对后发现两处与真实客户端不符: - [12:16] 是**对端视频流的时间基准**,不是本端接通的 tick。实测对端未发视频 时 239 帧里的前 128 帧全是 0;收到对端首帧([8:12]=1952894717)后才填这个 值,之后整通不变。改为记录对端首帧时间戳并在后续帧写入。 - [16:20] 实测是 0..totalPkg-1 里的一个值(totalPkg=1 时恒 0),不是 totalPkg-1 定值。语义未还原,改用随机值对齐分布。 顺带把 [6:8] 的文档结论从「未还原」更正为「码率档 / 帧质量」:实测值随分辨率 变化(480×270 时 28-33/36-41,640×360 时 36-44/40-48)。 另记录一处待验证差异:QQ 本体 19.8 秒只发 1 个关键帧,WeQ 的 keyint=30 在 15fps 下每 2 秒一个 I 帧,可能是「对端看 WeQ 画面卡」的原因之一。 测试:协议层验证 [12:16] 跟随对端基准(未收到时 0);会话层验证收到对端视频后 本端 [12:16] 换成对端时间戳。 --- CHANGELOG.md | 1 + docs/develop/video-call-20261025.md | 60 ++++++++++++++----- packages/protocol/src/call/video.ts | 23 +++++-- packages/protocol/test/call_video.test.ts | 41 ++++++++++--- packages/service/src/account/call_session.ts | 21 +++++-- .../service/test/call_video_session.test.ts | 36 ++++++++++- 6 files changed, 150 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc659c66..13afda63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -594,3 +594,4 @@ - 修复视频通话画面卡顿:摄像头帧改走二进制 IPC 通道,不再把每帧展开成 1.2 MB 文本经 JSON 传输,采集与编码不再互相拖慢。 - 修复视频通话界面读不到对方分辨率的问题,关键帧参数集解析偏移已校正。 - 修复视频通话开启摄像头后对端看不到画面的问题:补上 GCS 上的视频参数公告,手机与电脑都能收到本端已开摄像头。 +- 继续对齐视频通话的上行帧格式:对端未发视频时基准字段填 0、收到后改用对端时间基准,分片标记字段按实测分布填写。 diff --git a/docs/develop/video-call-20261025.md b/docs/develop/video-call-20261025.md index 595b44d03..fca21241e 100644 --- a/docs/develop/video-call-20261025.md +++ b/docs/develop/video-call-20261025.md @@ -104,18 +104,34 @@ frameIndex = ((fidx - 1) << 8) | gidx | `[2]` | **方向标识**:c2s=`0x00`、s2c=`0x03` | | `[3]` | 恒 `0x06`(**首帧也是**,早期「首帧 03」是误判) | | `[4:6]` | 恒 0 | -| `[6:8]` | **未还原**(每帧变化,对不上 CRC16 / 校验和 / 长度) | +| `[6:8]` | 码率档 / 帧质量(见下) | | `[8:12]` | 时间戳,布局随 `[2]` 变(见下表) | -| `[12:16]` | 会话基准 tick(c2s)/ 0(s2c) | -| `[16:20]` | **未还原**(实测小整数 0–5) | +| `[12:16]` | **对端视频流的时间基准**(见下)/ 0(s2c) | +| `[16:20]` | 未还原,实测是 `0..totalPkg-1` 里的一个值 | 时间戳布局: | 方向 | `[2]` | `[8:12]` | `[12:16]` | | --- | --- | --- | --- | -| c2s(QQ Linux 发) | `0x00` | 帧 PTS(0,100,200… ms,10 fps) | 会话基准 tick,整通恒定 | +| c2s(QQ Linux 发) | `0x00` | 帧 PTS(0,100,200… ms,10 fps) | 对端视频基准,见下 | | s2c(QQ Windows 发) | `0x03` | 发送方 tick(每帧变) | 0 | +### `[12:16]` 是对端视频流的时间基准(2026-10-26 修正) + +2026-10-26 QQ 本体抓包逐帧统计(239 帧):**对端还没开始发视频时,这个字段恒为 +0(前 128 帧)**;收到对端首个视频帧后(该帧 `[8:12]` = `1952894717`),从第 128 +帧起改填这个值,**之后整通不变**。 + +语义是「我跟对端的哪个时间基准对齐」,不是本端接通的 tick。早期实现从第一帧就填 +本端的 `mediaBaseTick`,与真实客户端不符 —— 这很可能是对端(尤其手机端)不认我们 +视频流的原因之一。 + +### `[6:8]` 是码率档 / 帧质量(2026-10-26 修正) + +来自 `GetVideoFrameQuality()`:`[6]` 是估算码率档、`[7]` 是帧质量。实测值随分辨率 +变化 —— 480×270 时 28–33 / 36–41,640×360 时 36–44 / 40–48。对端只拿它做统计, +不参与解码,按真实分布抽样即可。 + ⚠️ 这个差异踩过坑:早期实现把 `[2]` 恒写成 `0x03`,等于用对端方向的格式发自己的 上行数据。构造侧必须按方向分支。 @@ -271,19 +287,35 @@ Chromium 的 WebCodecs HEVC 解码器对 `VideoDecoderConfig.description` 的要 ## 7. 未完成 -### 7.1 sub-header 两个未解字段 +### 7.1 sub-header 未解字段:`[16:20]` -| 字段 | 实测值 | 排查过的假设 | -| --- | --- | --- | -| `[6:8]` | 码率档 + 帧质量(2026-10-26 确认) | 见 §3.2 下方 `buildVideoSubHeader` 的说明 | -| `[16:20]` | 小整数 0–8,**与分片数无关** | `total_pkg-1` 只对上一部分,未确认 | +2026-10-26 抓包逐帧统计(双向 335 帧): + +| `totalPkg` | `[16:20]` 取值 | +| --- | --- | +| 1 | 恒 0 | +| 2 | 0–1 | +| 3 | 0–2 | +| 4 | 0–3 | +| 5 | 0–4 | +| 6–8 | 0–total-1 | + +也就是说它是 `0..totalPkg-1` 里的一个值,**不是** `totalPkg - 1` 定值(早先这么 +填是错的),也不是帧内分片计数。看起来像「帧内第几个分片携带了某个标记」,具体 +规则未确认。当前实现用随机值对齐分布。 + +`[6:8]` 已确认来自 `GetVideoFrameQuality()`(`[6]`=码率档、`[7]`=帧质量,关键帧 +整体低一档),对端只拿它做统计,不参与解码,按真实分布抽样即可。 + +### 7.1.1 关键帧间隔:WeQ 比真实客户端密得多 -`[16:20]` 逐帧实测(52 帧全量):`totalPkg` 为 4/4/4/3/6/5/6/6/7/9/8/10 时该字段是 -2/0/3/0/0/1/4/0/4/0/3/8 —— 既不是 `totalPkg - 1` 也不是帧内分片计数。当前实现填 -`totalPkg - 1` 只是占位。若对端仍不认我们发的画面,这里是第一顺位可疑字段。 +实测 QQ 本体**一整通 19.8 秒只发 1 个关键帧**(首帧),对端同样只有 1 个。 +WeQ 的 `t265` 配置是 `keyint=30`,15 fps 下**每 2 秒一个关键帧**,码流里 I 帧 +占比高一个量级。 -`[6:8]` 已确认来自 `GetVideoFrameQuality()`(`[6]`=码率档 16–25、`[7]`=帧质量 -31–38,关键帧整体低一档),对端只拿它做统计,不参与解码,按真实分布抽样即可。 +这是「对端看 WeQ 画面卡」的可疑原因之一(I 帧大、瞬时码率尖峰大,弱网下更容易 +丢片 → 整帧丢弃 → 等下一个关键帧)。真实客户端显然用了很大的 keyint 或纯按需 +插帧,待抓 WeQ 包对比后确认要不要调大。 ### 7.2 P2P 端口协商 diff --git a/packages/protocol/src/call/video.ts b/packages/protocol/src/call/video.ts index 40928cf9d..5c0e72748 100644 --- a/packages/protocol/src/call/video.ts +++ b/packages/protocol/src/call/video.ts @@ -695,11 +695,26 @@ export function buildVideoSubHeader(params: { frameIndex: number; /** `[8:12]`:c2s 填帧 PTS(ms),s2c 填发送时的 tick。 */ timestampMs: number; - /** `[12:16]`:c2s 填会话基准 tick;s2c 恒 0。 */ - baseTick?: number; + /** + * `[12:16]`:**对端视频流的时间基准**(s2c 恒 0)。 + * + * 实测(2026-10-26 抓包):对端还没开始发视频时填 0;收到对端首个视频帧后, + * 填**该帧 `[8:12]` 的值**(本例 `1952894717`),之后整通不变。 + * + * 语义是「我这边在跟对端的哪个时间基准对齐」,不是本端的会话 tick —— 早期 + * 填 `mediaBaseTick`(本端接通的 tick)是错的。 + */ + peerVideoBase?: number; /** `[6:8]`:`{ hi, lo }` 分别写进 `[6]` 和 `[7]`。缺省时填 0。 */ quality?: VideoFrameQuality; - /** `[16:20]`:帧内分片序号(实测上限很小),缺省 0。 */ + /** + * `[16:20]`:语义未还原,实测是 `0 .. totalPkg-1` 里的一个值。 + * + * 逐帧统计(2026-10-26 抓包,双向 335 帧):`totalPkg=1` 时恒 0; + * `totalPkg=2/3/4/5` 时取值覆盖 `0..totalPkg-1`,且**不是** `totalPkg-1` + * (早先这么填是错的)。看起来像「帧内第几个分片携带了某个标记」,具体 + * 规则未确认。这里用随机值对齐分布,缺省 0。 + */ fragment?: number; }): Uint8Array { const out = new Uint8Array(VIDEO_SUBHEADER_LEN); @@ -711,7 +726,7 @@ export function buildVideoSubHeader(params: { out[6] = (params.quality?.rateTier ?? 0) & 0xff; out[7] = (params.quality?.frameQuality ?? 0) & 0xff; writeU32be(out, 8, params.timestampMs >>> 0); - if (params.direction === 'c2s') writeU32be(out, 12, (params.baseTick ?? 0) >>> 0); + if (params.direction === 'c2s') writeU32be(out, 12, (params.peerVideoBase ?? 0) >>> 0); // [16:20] 小端。 const frag = (params.fragment ?? 0) >>> 0; out[16] = frag & 0xff; diff --git a/packages/protocol/test/call_video.test.ts b/packages/protocol/test/call_video.test.ts index b68b3d251..9d1e8fd9e 100644 --- a/packages/protocol/test/call_video.test.ts +++ b/packages/protocol/test/call_video.test.ts @@ -111,7 +111,7 @@ describe('真实 c2s 包(2026-10-25 抓包黄金样本)', () => { direction: 'c2s', frameIndex: 0, timestampMs: 0, - baseTick: 0, + peerVideoBase: 0, }); const actual = REAL_C2S_PACKET.subarray(26, 46); // 真实的 [12:16] 不是 0(是会话基准 tick),其余字段应一致。 @@ -429,25 +429,50 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 0, timestampMs: 0, - baseTick: 0x70568b61, + peerVideoBase: 0x70568b61, }); expect(first[0]).toBe(20); expect(first[2]).toBe(0x00); expect(first[3]).toBe(0x06); - // [12:16] 会话基准 tick。 + // [12:16] 对端视频流的时间基准(对端还没发视频时是 0)。 expect([...first.subarray(12, 16)]).toEqual([0x70, 0x56, 0x8b, 0x61]); const next = buildVideoSubHeader({ direction: 'c2s', frameIndex: 1, timestampMs: 100, - baseTick: 0x70568b61, + peerVideoBase: 0x70568b61, }); expect(next[1]).toBe(1); expect(next[2]).toBe(0x00); expect(next[3]).toBe(0x06); }); + /** + * 回归:`[12:16]` 是**对端视频流的时间基准**,不是本端接通的 tick。 + * + * 2026-10-26 QQ 本体抓包实测:对端还没发视频时 128 帧全是 0;收到对端首个 + * 视频帧后(该帧 `[8:12]` = 1952894717),从第 128 帧起填这个值,整通不变。 + * 早期实现从第一帧就填本端 `mediaBaseTick`,与真实客户端不符。 + */ + it('c2s 的 [12:16] 跟着对端视频基准走(未收到时是 0)', () => { + const before = buildVideoSubHeader({ + direction: 'c2s', + frameIndex: 0, + timestampMs: 0, + }); + expect([...before.subarray(12, 16)]).toEqual([0, 0, 0, 0]); + + // 1952894717 = 0x7466cefd + const after = buildVideoSubHeader({ + direction: 'c2s', + frameIndex: 128, + timestampMs: 12800, + peerVideoBase: 1952894717, + }); + expect([...after.subarray(12, 16)]).toEqual([0x74, 0x66, 0xce, 0xfd]); + }); + it('s2c 方向:[2]=0x03,[12:16] 为 0', () => { const header = buildVideoSubHeader({ direction: 's2c', @@ -464,7 +489,7 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 0, timestampMs: 0x6f2f1bee, - baseTick: 1, + peerVideoBase: 1, }); expect([...header.subarray(8, 12)]).toEqual([0x6f, 0x2f, 0x1b, 0xee]); }); @@ -474,7 +499,7 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 3, timestampMs: 300, - baseTick: 9, + peerVideoBase: 9, quality: { rateTier: 0x19, frameQuality: 0x26 }, }); expect([...header.subarray(6, 8)]).toEqual([0x19, 0x26]); @@ -485,7 +510,7 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 3, timestampMs: 300, - baseTick: 9, + peerVideoBase: 9, fragment: 3, }); expect([...header.subarray(16, 20)]).toEqual([3, 0, 0, 0]); @@ -496,7 +521,7 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 3, timestampMs: 300, - baseTick: 9, + peerVideoBase: 9, }); expect([...header.subarray(6, 8)]).toEqual([0, 0]); expect([...header.subarray(16, 20)]).toEqual([0, 0, 0, 0]); diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index c133625c1..7f734eb82 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -551,10 +551,14 @@ export class CallSession { */ private videoFrameFragment = 0; /** - * 会话基准 tick,接通那一刻定下来,写进 sub-header 的 `[12:16]`。 + * 对端视频流的时间基准,写进本端 sub-header 的 `[12:16]`。 * - * 实测 c2s 方向这个字段整通恒定,s2c 方向是 0 —— 我们只发 c2s,所以填它。 + * 实测(2026-10-26 抓包):对端还没开始发视频时填 0;收到对端首个视频帧后, + * 填**该帧 `[8:12]` 的值**,之后整通不变。语义是「我跟对端的哪个时间基准 + * 对齐」,不是本端接通的 tick —— 早期填 `this.tick()` 是错的。 */ + private peerVideoBase = 0; + /** 本端接通的 tick(保留给日志 / 调试,不再写进 sub-header)。 */ private mediaBaseTick = 0; /** 视频保活定时器。 */ private videoKeepaliveTimer: unknown = null; @@ -1470,6 +1474,13 @@ export class CallSession { this.receivedVideoFrames++; this.lastVideoPacketAt = this.timers.now(); + // 记下对端视频流的时间基准:本端 sub-header 的 `[12:16]` 要填它。 + // 实测是「对端首个视频帧的 `[8:12]` 值」,收到后整通不变。 + if (this.peerVideoBase === 0 && frame.timestamp !== 0) { + this.peerVideoBase = frame.timestamp; + this.log?.info('记下对端视频时间基准', { peerVideoBase: frame.timestamp }); + } + const hevc = extractHevc(frame); // 前几帧把组帧结果打出来:拼不上 / 剥不出裸流是「收到包但没画面」的典型原因。 if (this.receivedVideoFrames <= 5 || this.receivedVideoFrames % 50 === 0) { @@ -1742,13 +1753,15 @@ export class CallSession { const now = this.timers.now(); const ptsMs = this.connectedAt === null ? 0 : now - this.connectedAt; // `[16:20]` 要提前知道片数,所以先按「sub-header + 裸流」的总长估一次。 + // 实测取值是 `0..totalPkg-1` 里的一个值(不是 `totalPkg-1` 定值),语义未 + // 还原,用随机值对齐分布。 const packetCount = videoPacketCount(VIDEO_SUBHEADER_LEN + hevc.length); - this.videoFrameFragment = Math.max(0, packetCount - 1); + this.videoFrameFragment = packetCount <= 1 ? 0 : Math.floor(Math.random() * packetCount); const subHeader = buildVideoSubHeader({ direction: 'c2s', frameIndex: this.videoFrameIndex, timestampMs: ptsMs, - baseTick: this.mediaBaseTick, + peerVideoBase: this.peerVideoBase, // `[6:8]`:对端只拿它做统计,按真实分布抽样即可(见 sampleVideoFrameQuality)。 quality: sampleVideoFrameQuality(splitHevcNals(hevc).some((nal) => isIrapNalType(nal.type))), fragment: this.videoFrameFragment, diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index fe5125029..d96373afa 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -658,9 +658,41 @@ describe('发送视频', () => { // [8:12] 是距接通的毫秒数(首帧可能为 0,至少不能是 Date.now() 量级)。 const pts = new DataView(sh.buffer, sh.byteOffset + 8, 4).getUint32(0); expect(pts).toBeLessThan(60_000); - // [12:16] 是接通时定下的会话基准 tick(非 0)。 + // [12:16] 是对端视频流的时间基准:还没收到对端视频时必须是 0 + // (实测真实客户端前 128 帧全 0)。 const base = new DataView(sh.buffer, sh.byteOffset + 12, 4).getUint32(0); - expect(base).toBeGreaterThan(0); + expect(base).toBe(0); + }); + + /** + * 回归:收到对端视频后,本端 `[12:16]` 要换成对端的时间基准。 + * + * 2026-10-26 抓包:对端首个视频帧的 `[8:12]` 是 1952894717,本端从下一帧起 + * 就把它写进 `[12:16]`,之后整通不变。 + */ + it('收到对端视频后,[12:16] 换成对端时间基准', async () => { + const encoder = new FakeVideoEncoder(100); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + ctx.session.setCamera(true); + + // 对端发一帧:sub-header [8:12] = 1952894717(0x7466cefd)。 + const sub = new Uint8Array(20); + sub[0] = 20; + sub.set([0x74, 0x66, 0xce, 0xfd], 8); + const body = new Uint8Array([0, 0, 0, 1, 0x26, 0x01, 0xaf, 0xe0]); + const whole = new Uint8Array(sub.length + body.length); + whole.set(sub, 0); + whole.set(body, sub.length); + ctx.transport.deliver(videoPacket({ gidx: 0, pkgIdx: 0, totalPkg: 1, chunk: whole })); + ctx.transport.reset(); + + await ctx.timers.advanceAsync(200); + + const videos = ctx.transport.media(VideoSubType.max); + expect(videos.length).toBeGreaterThan(0); + const sh = parseVideoPacket(videos[0]!)!.payload; + expect([...sh.subarray(12, 16)]).toEqual([0x74, 0x66, 0xce, 0xfd]); }); it('关摄像头后不再发视频包', async () => { From c10ac4f28eabcfeb61443613159cf0e0a4b712ee Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 26 Oct 2026 03:59:56 +0800 Subject: [PATCH 06/23] =?UTF-8?q?fix(call):=20=E4=BF=AE=E5=A4=8D=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E9=80=80=E5=8C=96=E6=88=90=E5=85=A8=E7=A8=8B=E4=B8=AD?= =?UTF-8?q?=E7=BB=A7=E5=AF=BC=E8=87=B4=E4=B8=A4=E7=AB=AF=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E6=9E=81=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 2026-10-26 两份抓包(QQ 本体直接视频走 P2P 1919 包;WeQ 同样流程却 22562 包全走中继),挖出三处叠加的问题: 1. pickServers 把 type=1 里的 8800 端口当 GCS 用。服务端下发的 type 字段 不可信 —— 历史 16 通里 5 通选到了 8800(中继)。连到中继后认证/心跳/bt=9 全发给中继节点,而中继从不给对端的 f7(IPv6),P2P 无从建立。 改为按端口判:8000/8001 优先当 GCS,其余(含 8800)只作兜底。 2. rememberPeerSource 用字符串比地址。socket 是 udp6 双栈,IPv4 来源在 rinfo.address 里是 ::ffff:a.b.c.d,服务器表里是 a.b.c.d,比较永远不等 —— 服务器自己的回包被学成「对端直连地址」,路径切到 direct 后其实仍发往同一台 服务器(同 IP 同端口)。加 sameHost() 做 IPv4-mapped 归一化,mediaTargets 的去重判断同样改用它。 3. 顺带确认:bt=9 的形态随服务器角色变化(真 GCS 给 f7+f8,中继只给 f1 且 没有 f7),所以「拿不到 f7」本身就是「连错服务器」的症状。 回归测试两条,都验证过在修复前会失败: - 服务器回包(IPv4 映射形态)不会被学成对端直连地址 - 服务器表里 type=1 混进 8800 时,仍优先选 8000/8001 抓包存档(仓库外):weq-call-captures/20261026-qqnative-direct-video-p2p.pcap 与 20261026-weq-direct-video-relay-only.pcap,README 里记了逐项对比。 --- CHANGELOG.md | 1 + docs/develop/video-call-20261025.md | 20 +++++ packages/service/src/account/call_session.ts | 66 ++++++++++++--- .../service/test/call_video_session.test.ts | 84 ++++++++++++++++++- 4 files changed, 157 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13afda63c..a262cd9ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -595,3 +595,4 @@ - 修复视频通话界面读不到对方分辨率的问题,关键帧参数集解析偏移已校正。 - 修复视频通话开启摄像头后对端看不到画面的问题:补上 GCS 上的视频参数公告,手机与电脑都能收到本端已开摄像头。 - 继续对齐视频通话的上行帧格式:对端未发视频时基准字段填 0、收到后改用对端时间基准,分片标记字段按实测分布填写。 +- 修复视频通话媒体退化成全程服务器中继、两端画面都极卡的问题:服务器选路不再选中继端口,对端直连地址识别不再被 IPv4 映射地址混淆。 diff --git a/docs/develop/video-call-20261025.md b/docs/develop/video-call-20261025.md index fca21241e..b6d8fa50d 100644 --- a/docs/develop/video-call-20261025.md +++ b/docs/develop/video-call-20261025.md @@ -317,6 +317,26 @@ WeQ 的 `t265` 配置是 `keyint=30`,15 fps 下**每 2 秒一个关键帧**, 丢片 → 整帧丢弃 → 等下一个关键帧)。真实客户端显然用了很大的 keyint 或纯按需 插帧,待抓 WeQ 包对比后确认要不要调大。 +### 7.1.2 三个让媒体退化成「全程中继」的坑(2026-10-26 实测) + +对照 QQ 本体抓包(同样直接拨视频,走 P2P `...:64927`,1919 包)与 WeQ 抓包 +(全程中继 `113.250.57.28:8800`,10842 包),挖出三处: + +1. **GCS 选到了中继端口。** 服务端下发的 `type` 字段不可信:16 通里 5 通把 + `type=1` 的候选选到了 8800(中继)。认证、心跳、`bt=9` 全发给中继节点, + 而中继**从不给对端的 `f7`(IPv6)**。判据改成按端口:8000/8001 是真 GCS。 +2. **服务器回包被学成「对端直连地址」。** socket 是 udp6 双栈,IPv4 来源在 + `rinfo.address` 里是 `::ffff:a.b.c.d`,服务器表里是 `a.b.c.d` —— 直接字符串 + 比较永远不等。于是服务器自己的回包把 `peerDirect` 改写成服务器地址,媒体路径 + 切到 `direct` 后其实仍发往同一台服务器(同 IP 同端口),「以为直连了,其实 + 全程中继」。必须做 IPv4-mapped 归一化再比。 +3. **`bt=9` 的形态随服务器角色变化。** 真 GCS 下发的是 `f7`(16 字节 IPv6)+ + `f8`(端口);中继下发的是 `f1`(fixed32 IPv4 + 端口)且**没有 f7**。所以 + 「拿不到 f7」本身就是「连错服务器」的症状,不是另一个独立问题。 + +三个坑叠加的结果:媒体包全部挤在一条服务端通道上(实测 2 万+ 包),丢包与排队 +严重 —— 表现为两端画面都极卡、前 20 秒花屏。 + ### 7.2 P2P 端口协商 真实客户端会在 GCS 上交换 P2P 端口(对端把包发到本机 55558,正是我们自己的 diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index 7f734eb82..9249ce115 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -331,6 +331,27 @@ export type MediaPath = 'server' | 'probing' | 'direct'; // ─────────────────────────── 会话 ─────────────────────────── +/** + * 两个地址是否指向同一台主机(忽略 IPv4 的 IPv6 映射前缀)。 + * + * udp6 双栈 socket 上,IPv4 来源在 `rinfo.address` 里是 `::ffff:a.b.c.d`,而 + * 服务器表里是 `a.b.c.d`。直接字符串比较会漏判 —— 见 `rememberPeerSource` 的 + * 注释,这个漏判会让服务器回包被当成对端直连地址,媒体退化成全程中继。 + */ +function sameHost(a: string, b: string): boolean { + if (a === b) return true; + return normalizeHost(a) === normalizeHost(b); +} + +/** 去掉 IPv4-mapped IPv6 前缀并转小写(IPv6 十六进制大小写不敏感)。 */ +function normalizeHost(address: string): string { + const lower = address.toLowerCase(); + const mapped = lower.startsWith('::ffff:') ? lower.slice('::ffff:'.length) : lower; + // `::ffff:1.2.3.4` 去掉前缀就是点分 IPv4;但 `::ffff:0102:0304` 形态要去前缀后 + // 再按 IPv6 比较 —— 这里只处理点分那种(Node 在双栈 socket 上就给点分)。 + return mapped; +} + /** * 16 字节 IPv6 → 标准文本形式。 * @@ -770,21 +791,36 @@ export class CallSession { * 分配的机器会变,但可达性通常一致,能省掉一次 3 秒的空等。 */ private pickServers(servers: CallServer[]): void { - const gcsCandidates = servers.filter((s) => s.type === 1); + // **端口比 type 可靠。** 实测服务端下发的 `type` 字段并不可信:2026-10-26 + // 抓包那次 16 通里有 5 通把 `type=1` 的候选选到了 8800 端口(中继), + // 后果是认证、心跳、`bt=9` 全发给中继节点 —— 中继只回中继地址、**从不给 + // 对端的 f7(IPv6)**,媒体于是全程走中继,两端画面都极卡。 + // + // 真实客户端连的 GCS 是 8000 / 8001 端口,8800 是中继。所以按端口再分一次: + // 8000/8001 优先当 GCS,其余(含 8800)只作兜底。 + const isGcsPort = (s: CallServer) => s.port === 8000 || s.port === 8001; + const typeOne = servers.filter((s) => s.type === 1); const relayCandidates = servers.filter((s) => s.type === 2); - this.gcsCandidates = [...gcsCandidates, ...relayCandidates]; - // 记住的 IP 优先在 **type=1(真 GCS)** 里找:同一个 IP 也可能出现在中继表 - // 里(不同端口),直接在整个候选表里 find 会选中 8800 那个中继端口 —— - // 音频还能通(服务端两个端口都收),但视频控制包发到中继就没人处理。 + const gcsCandidates = typeOne.filter(isGcsPort); + // 按端口过滤后一台都不剩(服务端换了端口方案)时退回原列表,不要空手。 + const preferred = gcsCandidates.length > 0 ? gcsCandidates : typeOne; + this.gcsCandidates = [ + ...preferred, + ...typeOne.filter((s) => !preferred.includes(s)), + ...relayCandidates, + ]; + // 记住的 IP 优先在 **真 GCS** 里找:同一个 IP 也可能出现在中继表里(不同 + // 端口),直接在整个候选表里 find 会选中 8800 那个中继端口。 this.gcs = - gcsCandidates.find((s) => s.ip === CallSession.goodGcsIp) ?? - gcsCandidates[0] ?? + preferred.find((s) => s.ip === CallSession.goodGcsIp) ?? + preferred[0] ?? this.gcsCandidates.find((s) => s.ip === CallSession.goodGcsIp) ?? this.gcsCandidates[0] ?? null; this.gcsIndex = Math.max(0, this.gcsCandidates.indexOf(this.gcs!)); this.log?.debug('服务器表', { gcs: gcsCandidates.length, + typeOne: typeOne.length, relay: relayCandidates.length, picked: this.gcs ? `${this.gcs.ip}:${this.gcs.port}` : 'none', }); @@ -841,8 +877,10 @@ export class CallSession { const wantPeer = hasPeer && this.mediaPath !== 'server'; if (wantServer) out.push({ address: this.gcs.ip, port: this.gcs.port }); if (wantPeer && peer) { - // 同一台机器(回环 / 服务端与对端同址)会撞上,避免重复发。 - if (peer.address !== this.gcs.ip || peer.port !== this.gcs.port) { + // 同一台机器(回环 / 服务端与对端同址)会撞上,避免重复发。地址比较要做 + // IPv4 映射归一化,否则 `::ffff:1.2.3.4` 与 `1.2.3.4` 会被当成两台机器, + // 同一份媒体包连发两遍。 + if (!sameHost(peer.address, this.gcs.ip) || peer.port !== this.gcs.port) { out.push({ address: peer.address, port: peer.port }); } } @@ -1227,7 +1265,15 @@ export class CallSession { private rememberPeerSource(from: { address: string; port: number }): void { // 判据是「来源 != 服务端」而不是「和 bt=9 一致」:bt=9 只给地址不给端口, // 而真正的回程端口只能从对端实际发包的来源学到。服务端地址要排除掉。 - const isServer = this.gcs && this.gcs.ip === from.address && this.gcs.port === from.port; + // + // ⚠️ **必须做地址归一化再比**。socket 是 udp6 双栈(`ipv6Only: false`), + // IPv4 来源在 `rinfo.address` 里是 `::ffff:113.250.57.28` 形态,而服务器表里 + // 是 `113.250.57.28` —— 直接字符串比较永远不相等。后果很严重:服务器自己的 + // 回包被当成「对端直连地址」,媒体路径切到 direct,实际仍发往服务器(同 IP + // 同端口),于是全程走中继、`bt=9` 也永远拿不到对端的 f7(IPv6),表现为 + // 两端画面都极卡。 + const isServer = + this.gcs !== null && sameHost(this.gcs.ip, from.address) && this.gcs.port === from.port; // 只有绕过服务端的包才刷新「直连还活着」的时间戳 —— 被中继的包说明直连不通。 if (!isServer) this.lastPeerMediaAt = this.timers.now(); if (this.lastPeerFrom?.address === from.address && this.lastPeerFrom?.port === from.port) diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index d96373afa..56d793b11 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -25,6 +25,7 @@ import { CallEvent, GCS_STX, GcsBodyType, + type CallServer, VIDEO_CTRL_SUB_TYPE, VIDEO_EXT_LEN, VIDEO_EXT_TYPE, @@ -207,7 +208,9 @@ class FakeVideoEncoder implements CallVideoEncoder { } } -function fakeSignaling(): CallSignaling & { startedWith: unknown[] } { +function fakeSignaling( + servers: CallServer[] = [{ ip: '113.250.57.28', port: 8000, type: 1 }], +): CallSignaling & { startedWith: unknown[] } { const startedWith: unknown[] = []; return { startedWith, @@ -219,7 +222,7 @@ function fakeSignaling(): CallSignaling & { startedWith: unknown[] } { sessionId: 88n, relationId: 15001, authKey: new Uint8Array(80).fill(1), - servers: [{ ip: '113.250.57.28', port: 8000, type: 1 }], + servers, }, authToken: 1234, }; @@ -228,10 +231,16 @@ function fakeSignaling(): CallSignaling & { startedWith: unknown[] } { }; } -function makeSession(options: { media?: 'audio' | 'video'; videoEncoder?: CallVideoEncoder } = {}) { +function makeSession( + options: { + media?: 'audio' | 'video'; + videoEncoder?: CallVideoEncoder; + servers?: CallServer[]; + } = {}, +) { const timers = new FakeTimers(); const transport = new FakeTransport(); - const signaling = fakeSignaling(); + const signaling = fakeSignaling(options.servers); const session = new CallSession({ selfUin: '1707889225', selfUid: 'u_self', @@ -822,6 +831,34 @@ describe('媒体路径(中继 / 直连)', () => { expect(ctx.transport.mediaTo(SERVER_IP, VideoSubType.max).length).toBeGreaterThan(0); }); + /** + * 回归:GCS 必须选 8000/8001 端口,不能选中继的 8800。 + * + * 2026-10-26 抓包实测:服务端下发的 `type` 字段不可信 —— 16 通里有 5 通把 + * `type=1` 的候选选到了 8800(中继)。中继只回中继地址、从不给对端的 f7 + * (IPv6),媒体于是全程走中继,两端画面都极卡。 + */ + it('服务器表里 type=1 混进 8800 时,仍优先选 8000/8001', async () => { + const ctx = makeSession({ + media: 'video', + videoEncoder: new FakeVideoEncoder(1000), + servers: [ + { ip: '113.250.57.28', port: 8800, type: 1 }, // 中继端口,但标了 type=1 + { ip: '113.250.58.160', port: 8000, type: 1 }, + { ip: '113.250.57.28', port: 8000, type: 2 }, + ], + }); + await connect(ctx); + ctx.transport.reset(); + await ctx.timers.advanceAsync(20); + + // 认证 / 媒体都必须发往 8000 那台,一个包都不能发到 8800。 + const to8800 = ctx.transport.sent.filter((s) => s.port === 8800); + expect(to8800).toHaveLength(0); + const to8000 = ctx.transport.sent.filter((s) => s.port === 8000); + expect(to8000.length).toBeGreaterThan(0); + }); + it('保活包每拍两条:0x02 与 0x03 变体', async () => { const ctx = makeSession({ media: 'video' }); await connect(ctx); @@ -834,4 +871,43 @@ describe('媒体路径(中继 / 直连)', () => { expect(bodies).toContain(0x02); expect(bodies).toContain(0x03); }); + + /** + * 回归:服务器自己的回包不能被当成「对端直连地址」。 + * + * socket 是 udp6 双栈,IPv4 来源在 `rinfo.address` 里是 `::ffff:a.b.c.d`,而 + * 服务器表里是 `a.b.c.d`。早先直接字符串比较,两者永远不等 —— 服务器回包被 + * 当成对端,媒体路径切到 direct 后其实仍发往同一台服务器(同 IP 同端口), + * 表现为「以为直连了,其实全程中继」,两端画面都极卡。 + */ + it('服务器回包(IPv4 映射形态)不会被学成对端直连地址', async () => { + const encoder = new FakeVideoEncoder(1000); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + + const whole = new Uint8Array(26); + whole[0] = 20; + whole.set([0, 0, 0, 1, 0x40, 0x01], 20); + const packet = (gidx: number) => videoPacket({ gidx, pkgIdx: 0, totalPkg: 1, chunk: whole }); + + // ① 对端先从真实 P2P 地址发一包 —— 学到端口,peerDirect 才有 port, + // 否则 mediaTargets 的 wantPeer 恒为 false,测不出问题。 + ctx.transport.deliverFrom(packet(0), { address: PEER_IP, port: 47708 }); + // ② 服务器以 `::ffff:SERVER_IP` 形态回包(真实 udp6 socket 的行为)。 + // 端口 8000 与服务器表一致,只有地址形态不同。**之后不再有对端包** —— + // 否则对端的下一包会把 peerDirect 改回去,掩盖问题。 + ctx.transport.deliverFrom(packet(1), { + address: `::ffff:${SERVER_IP}`, + port: 8000, + }); + ctx.transport.reset(); + await ctx.timers.advanceAsync(1500); + + // 服务器回包被误判成对端时,`peerDirect` 被改写成 + // `::ffff:113.250.57.28:8000`,媒体随后只发这个「伪对端」。 + const toFakePeer = ctx.transport.mediaTo(`::ffff:${SERVER_IP}`, VideoSubType.max); + expect(toFakePeer).toHaveLength(0); + // 真对端仍然收得到(直连没被服务器回包挤掉)。 + expect(ctx.transport.mediaTo(PEER_IP, VideoSubType.max).length).toBeGreaterThan(0); + }); }); From 6392738c32443b20a57438e3728f3d080b82ef89 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 26 Oct 2026 04:12:42 +0800 Subject: [PATCH 07/23] =?UTF-8?q?fix(call):=20GCS=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E9=9B=86=E6=8D=A2=E6=88=90=20QQ=20=E6=9C=AC?= =?UTF-8?q?=E4=BD=93=E5=8E=9F=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-10-26 抓包对比发现:即便 GCS 选路修好了(这次认证发往真 GCS 的 8001 端口),bt=9 里依然没有 f7(对端 IPv6),媒体还是全程中继。 根因在认证包 body —— 那是媒体能力协商,WeQ 一直发的是从语音通话抓来的旧模板: | 字段 | QQ 本体(语音/视频一致) | WeQ 旧模板 | | --- | --- | --- | | body 长度 | 618 B | 624 B | | f3 | 15 | 5 | | f6 | 10063 | 10058 | | f11 | 1812048064 | 26847424 | | 内层 f5/f6 | 0 / 0 | 2560 / 1440 | | 前两条能力 | 空 | f1=2(H.264 480×640@20) | | f21 | 2 | 1 | f3=15 / f6=10063 恰好等于 MEDIA_PROFILE.video 的 inviteF3/inviteF4 —— 能力集 与信令档位是配套的,而 WeQ 发的是语音档。服务端据此可能不把本端当视频/P2P 候选,于是不给 f7。 换成 QQ 本体原文(618 字节),已用抓包逐字节校验一致。 --- CHANGELOG.md | 1 + packages/protocol/src/call/gcs.ts | 19 +++++++++++++++++-- packages/protocol/test/call.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a262cd9ee..9968c2011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -596,3 +596,4 @@ - 修复视频通话开启摄像头后对端看不到画面的问题:补上 GCS 上的视频参数公告,手机与电脑都能收到本端已开摄像头。 - 继续对齐视频通话的上行帧格式:对端未发视频时基准字段填 0、收到后改用对端时间基准,分片标记字段按实测分布填写。 - 修复视频通话媒体退化成全程服务器中继、两端画面都极卡的问题:服务器选路不再选中继端口,对端直连地址识别不再被 IPv4 映射地址混淆。 +- 修复视频通话拿不到对端直连地址、媒体只能全程走服务器中继的问题:GCS 认证改为发送与 QQ 一致的音视频能力集。 diff --git a/packages/protocol/src/call/gcs.ts b/packages/protocol/src/call/gcs.ts index 9d46a5dc2..326f5d69e 100644 --- a/packages/protocol/src/call/gcs.ts +++ b/packages/protocol/src/call/gcs.ts @@ -281,13 +281,28 @@ export function parseGcsPacket(data: Uint8Array): GcsPacket | null { /** * 认证能力集。 * - * 一段固定的媒体能力协商描述:10 组编码能力 + 音频 / 视频 / 网络配置,描述 + * 一段固定的媒体能力协商描述:18 组编码能力 + 音频 / 视频 / 网络配置,描述 * 「本端能解哪些编码、带宽多少、要不要回声消除」。服务端拿它给房间选路, * 内容不必逐字段构造 —— 但**必须存在且能解析**,空 body 会被直接断开。 + * + * 2026-10-26 逐字节替换为 QQ 本体(Linux 桌面版)实际发送的原文。旧模板是从 + * 语音通话抓的,与视频通话有实质差异,服务端据此可能不把本端当视频/P2P 候选: + * + * | 字段 | QQ 本体(语音/视频一致) | 旧模板 | + * | --- | --- | --- | + * | `f3` | 15 | 5 | + * | `f6` | 10063 | 10058 | + * | `f11` | 1812048064 | 26847424 | + * | 内层 `f5`/`f6` | 0 / 0(由协商决定) | 2560 / 1440(硬编码) | + * | 能力条目 | 前 2 条为空 | 前 2 条是 `f1=2`(H.264 480×640@20) | + * | `f21` | 2 | 1 | + * + * 注意 `f3=15` / `f6=10063` 恰好等于 `MEDIA_PROFILE.video` 的 + * `inviteF3` / `inviteF4` —— 能力集与信令档位是配套的。 */ export const DEFAULT_CAPABILITIES: Uint8Array = Uint8Array.from( atob( - 'CtoDChkIAhABGOADIIAFKBQwgAg4wLsBQAFIAFABChkIAhACGOADIIAFKBQwgAg4wLsBQAFIAFABChkIAxABGLAJIIAPKA8wgCg42J4IQAFIAFABChkIAxACGLAJIIAPKB4wgCg4sL0QQAFIAFABChkIBBABGLAJIIAPKA8wgCg42J4IQAFIAFABChkIBBACGLAJIIAPKB4wgCg4sL0QQAFIAFABChkIBRABGLAJIIAPKA8wgCg42J4IQAFIAFABChkIBRACGLAJIIAPKB4wgCg4sL0QQAFIAFABChsIBhABGOADIIAFKA8wgCg40L2roAtAAUgAUAEKGwgGEAIY4AMggAUoDzCAKDi4vqugC0ABSABQAQoACgAKAAoACgAKAAoACgASXQgAEAAYACJSh2VDIQACACkDIA+gC7gAfgDAACsCWA+gBdwAfgDAAEAJYAyACWAAfgDAAEEJYBkACWAAfgDAAEIJYBkACWAAfgDAAFADIA+gC7gAfgDAEjRWeCoBABogCghTaGFycENhbRIICOADEOgCGB4SCAiABRDgAxgeGAEiFQi4FxABGAYgACgAMABKAnBjWgJwYyiAFDCgC0ABSAFQAFgCaAFwAXgBgAEBiAEHmAECqAEBEAEYBShkMMpOOAFAAUoAWMDR5gxqBAgDEAJyBggAEAAYAIIBVwoiMTAwMTNfYXVkaW9fY29uZmlnXzEwMDExX3Blbmd1aW5zXxIVMjA0X3ZpZGVvX2NvbmZpZ18yMDRfGhoxMDA1MV9jb21tb25fY29uZmlnXzEwMDUxX4oBDggAEgAiBGlvc18qADIAkAEAmAEA', + 'CtMDCgAKAAoZCAMQARiwCSCADygPMIAoONieCEABSABQAQoZCAMQAhiwCSCADygeMIAoOLC9EEABSABQAQoZCAQQARiwCSCADygPMIAoONieCEABSABQAQoZCAQQAhiwCSCADygeMIAoOLC9EEABSABQAQoZCAUQARiwCSCADygPMIAoONieCEABSABQAQoZCAUQAhiwCSCADygeMIAoOLC9EEABSABQAQoXCAYQARjgAyCABSgPMIAoOABAAUgAUAEKFwgGEAIY4AMggAUoDzCAKDgAQAFIAFABCgAKAAoACgAKAAoACgAKABJpCAAQABgAIl6HZUMhAAIAKQMgD6ALuAB+AMAAKwJYD6AF3AB+AMAALgMgD6AJxAB+AMAAQAlgDIAJYAB+AMAAQQlgGQAJYAB+AMAAQglgGQAJYAB+AMAAUAMgD6ALuAB+AMASNFZ4KgEAGgwKCFNoYXJwQ2FtGAEiDQi4FxABGAYgACgAMAAoADAAQAFIAVAAWAJiCwgGEPABGMACIJBOYgwIBhDoAhjgAyDwkwliDAgGEOADGIAFILCuFWIMCAYQ0AUYgAoggOowYgwIBhC4CBiADyCg4WdoAXABeAGAAQGIAQeYAQKoAQIQARgPKGQwz044AUABSgBYwNGG4AZqBAgDEAJyBggAEAAYAIIBVwoiMTAwMTNfYXVkaW9fY29uZmlnXzEwMDExX3Blbmd1aW5zXxIVMjA0X3ZpZGVvX2NvbmZpZ18yMDRfGhoxMDA1MV9jb21tb25fY29uZmlnXzEwMDUxX4oBDggAEgAiBGlvc18qADIAkAEAmAEA', ) .split('') .map((c) => c.charCodeAt(0)), diff --git a/packages/protocol/test/call.test.ts b/packages/protocol/test/call.test.ts index 8d4a8696b..1007ebb36 100644 --- a/packages/protocol/test/call.test.ts +++ b/packages/protocol/test/call.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { AUDIO_HEADER_LEN, CallEvent, + DEFAULT_CAPABILITIES, FEC_DATA_FRAMES, FEC_PARITY_ROWS, GcsBodyType, @@ -94,6 +95,27 @@ describe('GCS 控制面', () => { expect(parsed.body.length).toBeGreaterThan(100); }); + /** + * 回归:能力集必须与 QQ 本体逐字节一致。 + * + * 旧模板是从语音通话抓的,`f3=5 / f6=10058 / f11=26847424`,内层分辨率硬编码 + * 2560×1440、前两条能力是 `f1=2`。视频通话实测是 `f3=15 / f6=10063 / + * f11=1812048064`、内层 0/0、前两条为空 —— 两者差异可能让服务端不把本端当 + * 视频/P2P 候选(拿不到 `bt=9` 的 `f7`,媒体只能全程中继)。 + * + * 黄金样本:2026-10-26 QQ 本体直接视频抓包的认证 body(618 字节)。 + */ + it('能力集与 QQ 本体逐字节一致(618 字节)', () => { + expect(DEFAULT_CAPABILITIES).toHaveLength(618); + // 顶层 f3 = 15(视频档位)、f6 = 10063(与 MEDIA_PROFILE.video.inviteF4 一致)。 + expect(pbGetVarint(DEFAULT_CAPABILITIES, 3)).toBe(15); + expect(pbGetVarint(DEFAULT_CAPABILITIES, 6)).toBe(10063); + // 开头是 f1 能力列表:`0a d3 03`(field 1, len 467),之后两条空能力 + // `0a 00 0a 00` —— 不是旧模板的 `f1=2`(`08 02 10 01`)。 + expect([...DEFAULT_CAPABILITIES.subarray(0, 3)]).toEqual([0x0a, 0xd3, 0x03]); + expect([...DEFAULT_CAPABILITIES.subarray(3, 7)]).toEqual([0x0a, 0x00, 0x0a, 0x00]); + }); + it('登出包的 body 是 reason=2', () => { const pkt = buildGcsLogout({ ...base, authKey: new Uint8Array([1]) }); const parsed = parseGcsPacket(pkt)!; From bf5217a4b0bc9093c4c4135730b495a09f6f7815 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 26 Oct 2026 05:41:10 +0800 Subject: [PATCH 08/23] =?UTF-8?q?fix(call):=20=E5=BC=80=E6=88=BF=20/=20?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E8=AF=B7=E6=B1=82=E4=B8=8E=20QQ=20=E6=9C=AC?= =?UTF-8?q?=E4=BD=93=E9=80=90=E5=AD=97=E8=8A=82=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拿 2026-10-26 同一次通话的 OIDB 抓包逐字节比对,发现两处「语义等价、字节不同」 的差异: 1. 开房 `common.f1`(roomId)—— QQ 显式写出 `08 00`,WeQ 省略。给该字段加 `force`,roomId=0 时也写出。 2. 邀请 `common.f2`(sessionId)—— QQ **不写**,WeQ 多写了 `10 ` (开房回包虽带该字段,但不回写)。去掉。 顺带把 `ipStack` 改成本次抓包实测的 21547(0x542B,视频档)。此前用的 12656 是更早抓包的旧值。 对齐后开房请求逐字节一致(除每次不同的 relationId)。 注:`bt=9` 的 f7 下发条件仍未还原 —— 实测振铃阶段 body 只有 24 字节(无 f7), 真实客户端在「对端接听」时才收到 46 字节(带 f7/f8)。 --- CHANGELOG.md | 1 + packages/protocol/src/call/signaling.ts | 99 +++++++++++++-- packages/protocol/test/call.test.ts | 35 ++++-- .../test/fixtures/call-create-room-rsp.hex | 2 +- packages/service/src/account/call_session.ts | 113 ++++++++++-------- .../service/test/call_video_session.test.ts | 69 +++++++++-- 6 files changed, 231 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9968c2011..83b4e64ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -597,3 +597,4 @@ - 继续对齐视频通话的上行帧格式:对端未发视频时基准字段填 0、收到后改用对端时间基准,分片标记字段按实测分布填写。 - 修复视频通话媒体退化成全程服务器中继、两端画面都极卡的问题:服务器选路不再选中继端口,对端直连地址识别不再被 IPv4 映射地址混淆。 - 修复视频通话拿不到对端直连地址、媒体只能全程走服务器中继的问题:GCS 认证改为发送与 QQ 一致的音视频能力集。 +- 继续对齐视频通话信令:开房与邀请请求的字段与 QQ 本体逐字节一致。 diff --git a/packages/protocol/src/call/signaling.ts b/packages/protocol/src/call/signaling.ts index 20ddb8f49..b0dc23ed7 100644 --- a/packages/protocol/src/call/signaling.ts +++ b/packages/protocol/src/call/signaling.ts @@ -150,7 +150,8 @@ export const USER_WRAPPER: ProtoMessage = message([f('user', 1, USER_INFO)]); * 拿到回包后原样带回,服务端靠它把同一次通话的若干请求串起来。 */ export const C2C_MODE_COMMON: ProtoMessage = message([ - f('roomId', 1, 'uint64'), + // `force`:开房时 roomId=0 也要显式写出来(`08 00`),与真实客户端一致。 + f('roomId', 1, 'uint64', { force: true }), f('sessionId', 2, 'uint64'), f('relationId', 3, 'uint32', { force: true }), f('scene', 4, 'uint32', { force: true }), @@ -216,12 +217,34 @@ export const C2C_LOGOUT_REQ: ProtoMessage = message([ f('reason', 7, message([f('code', 1, 'uint32', { force: true })])), ]); -/** 中继服务器。`ip` / `altIp` 是 fixed32,按小端存 IPv4 网络序。 */ +/** + * 中继 / GCS 服务器。 + * + * - `ip` / `altIp`:fixed32,按小端存 IPv4 网络序; + * - `ipv6`(字段 5):**16 字节裸 IPv6**,wire type 2。 + * + * 2026-10-26 同一次通话的 OIDB + UDP 抓包对照(QQ 本体,P2P 正常): + * + * ```text + * f1(ipv4) | port | type | f4(altIp) | f5(ipv6) + * - | 8000 | 2 | 29.237.232.17 | 240e:930:c200:88::17 ← QQ 实际连这条 + * - | 8000 | 2 | 29.237.230.22 | 240e:930:c200:86::1c + * - | 8800 | 2 | 29.237.193.13 | 240e:930:c200:81::13 + * 113.250.57.133 | 8000 | 2 | 29.237.232.17 | - + * ... + * ``` + * + * 服务端下发的是**同一台机器的 IPv6 + IPv4 两种形态**,靠字段 5 区分。旧 schema + * 没有这个字段,IPv6 条目就退化成 `altIp` 的 IPv4 地址(`29.237.232.17`), + * 而 `29.237.*` 根本连不通 —— 表现为 GCS 握手靠后续 IPv4 条目兜底、`bt=9` + * 拿不到对端 `f7`、媒体全程走中继。 + */ export const RELAY_SERVER: ProtoMessage = message([ f('ip', 1, 'uint32'), f('port', 2, 'uint32'), f('type', 3, 'uint32'), f('altIp', 4, 'uint32'), + f('ipv6', 5, 'bytes'), ]); export const RSP_MEDIA_CONFIG: ProtoMessage = message([ @@ -300,10 +323,20 @@ export function nextRelationId(): number { return 15000 + Math.floor(Math.random() * 1000); } +/** + * 构造 `common`。 + * + * 2026-10-26 同一次通话的 OIDB 抓包逐字节比对(QQ 本体,P2P 正常): + * + * - **开房**:`common` 里 `f1=0` 是**显式写出**的(`08 00`),不是省略; + * - **邀请**:`common` 里**没有 `f2`(sessionId)** —— 回包虽然带了 + * `common.f2`(值等于 selfUin),但邀请时不能回写。 + * + * 这两处都是「语义等价、字节不同」的差异,服务端可能据此区分实现来源。 + */ function baseCommon(roomId: bigint, relationId: number, scene: number, selfUid: string) { return { roomId, - sessionId: 0n, relationId, scene, selfInfo: { user: { uid: selfUid, uidType: 2 } }, @@ -343,7 +376,7 @@ export interface MediaProfile { export const MEDIA_PROFILE: Record<'audio' | 'video', MediaProfile> = { audio: { f3: 1, ipStack: CALL_IP_STACK, f13: 1, inviteF3: 5, inviteF4: 10056 }, - video: { f3: 0, ipStack: 12656, f13: 3, inviteF3: 15, inviteF4: 10063 }, + video: { f3: 0, ipStack: 21547, f13: 3, inviteF3: 15, inviteF4: 10063 }, }; /** 开房 / 邀请请求里的媒体类型。 */ @@ -421,10 +454,13 @@ export const InviteUser: TrpcSpec< cmd: C2C_SSO_CMD.inviteUser, reqSchema: C2C_INVITE_USER_REQ, respSchema: INVITE_USER_RSP, - serialize: ({ selfUid, roomId, sessionId, relationId, media: kind = 'audio' }) => { + // 注意:`sessionId` 入参**不写进 common**。实测 QQ 的邀请请求里没有 + // `common.f2`(开房回包虽带了该字段,但不回写)。早期多写一个 `10 ` + // 是字节级差异。 + serialize: ({ selfUid, roomId, relationId, media: kind = 'audio' }) => { const profile = MEDIA_PROFILE[kind]; return { - common: { ...baseCommon(roomId, relationId, CALL_SCENE.invite, selfUid), sessionId }, + common: baseCommon(roomId, relationId, CALL_SCENE.invite, selfUid), params: { f3: profile.inviteF3, f4: profile.inviteF4, f5: 0, f12: 0, f13: 0 }, }; }, @@ -536,11 +572,15 @@ export function decodeCreateRoomRspBytes(bytes: Uint8Array): CallRoom { if (field.field !== 6 || field.wire !== 2 || !field.bytes) continue; const s = parsePb(field.bytes); const fixed = (n: number) => s.find((x) => x.field === n && x.wire === 5)?.fixed32; - // 只有 altIp 的记录是同一台机器的 IPv4 备用地址,ip 缺省时用它。 - const ip = ipv4FromFixed32(fixed(1)) ?? ipv4FromFixed32(fixed(4)); const port = Number(s.find((x) => x.field === 2 && x.wire === 0)?.value ?? 0n); const type = Number(s.find((x) => x.field === 3 && x.wire === 0)?.value ?? 0n); - if (!ip || !port) continue; + if (!port) continue; + // 字段 5 是 16 字节裸 IPv6;有它就说明这条是 IPv6 形态。 + const v6 = s.find((x) => x.field === 5 && x.wire === 2)?.bytes; + const ipv6 = v6 && v6.length === 16 ? formatIpv6Text(v6) : null; + // 只有 altIp 的记录是同一台机器的 IPv4 备用地址,ip 缺省时用它。 + const ip = ipv6 ?? ipv4FromFixed32(fixed(1)) ?? ipv4FromFixed32(fixed(4)); + if (!ip) continue; servers.push({ ip, port, type }); } } @@ -552,3 +592,44 @@ export function decodeCreateRoomRspBytes(bytes: Uint8Array): CallRoom { servers, }; } + +/** + * 16 字节裸 IPv6 → 标准文本(RFC 5952:最长连续零段压缩成 `::`)。 + * + * 服务器表字段 5 就是这种裸字节。用 `::` 压缩而不是逐段展开,是为了与抓包 / + * 日志里的形态一致,排查时不会误以为地址不同。 + */ +function formatIpv6Text(raw: Uint8Array): string { + const groups: number[] = []; + for (let i = 0; i < 16; i += 2) groups.push((raw[i]! << 8) | raw[i + 1]!); + + let bestStart = -1; + let bestLen = 0; + let runStart = -1; + let runLen = 0; + for (let i = 0; i < 8; i++) { + if (groups[i] === 0) { + if (runStart < 0) { + runStart = i; + runLen = 0; + } + runLen++; + if (runLen > bestLen) { + bestStart = runStart; + bestLen = runLen; + } + } else { + runStart = -1; + runLen = 0; + } + } + + const hex = (n: number) => n.toString(16); + if (bestLen < 2) return groups.map(hex).join(':'); + const head = groups.slice(0, bestStart).map(hex).join(':'); + const tail = groups + .slice(bestStart + bestLen) + .map(hex) + .join(':'); + return `${head}::${tail}`; +} diff --git a/packages/protocol/test/call.test.ts b/packages/protocol/test/call.test.ts index 1007ebb36..200ec3629 100644 --- a/packages/protocol/test/call.test.ts +++ b/packages/protocol/test/call.test.ts @@ -54,29 +54,38 @@ describe('通话信令', () => { expect(Buffer.from(bytes).toString('latin1')).toContain('u_self'); }); - it('开房回包解析出房间、密钥与服务器(ip 缺失时用 altIp 兜底)', () => { - // 真实回包(发往 2863253201 那次),原样粘过来当黄金样本。 + /** + * 黄金样本:2026-10-26 同一次通话的 OIDB 回包(QQ 本体,P2P 正常)。 + * + * 服务器表前三条带**字段 5(16 字节裸 IPv6)**,服务端下发的原始顺序即优先级; + * QQ 实际连的就是第一条 `240e:930:c200:88::17:8000`(UDP 抓包证实)。 + * + * 旧 schema 没有字段 5,这三条会退化成 `altIp` 的 IPv4(`29.237.*`,不可达), + * 导致 GCS 握手只能退到后面的 IPv4 条目、`bt=9` 拿不到对端 `f7`、媒体全程走 + * 中继 —— 这就是「两端都卡」的根因。 + */ + it('开房回包解析出房间、密钥与服务器(IPv6 优先)', () => { const hex = readFileSync( new URL('./fixtures/call-create-room-rsp.hex', import.meta.url), 'utf8', ).trim(); const room = decodeCreateRoomRsp(new Uint8Array(Buffer.from(hex, 'hex'))); - expect(room.roomId).toBe(6748173487929717840n); + expect(room.roomId).toBe(5697919981127284148n); expect(room.sessionId).toBe(1707889225n); - expect(room.relationId).toBe(15000); + expect(room.relationId).toBe(26136); expect(room.authKey).toHaveLength(80); - // 9 台:3 台只有 altIp(IPv6 记录的 IPv4 备用地址)+ 3 台 relay + 3 台 GCS + // 9 台:3 台 IPv6(字段 5)+ 3 台 IPv4 relay + 3 台 IPv4 GCS,顺序原样保留。 expect(room.servers).toHaveLength(9); expect(room.servers.map((s) => `${s.ip}:${s.port}/t${s.type}`)).toEqual([ - '29.237.193.13:8800/t2', - '29.237.234.22:8800/t2', - '29.237.209.13:8000/t2', + '240e:930:c200:88::17:8000/t2', + '240e:930:c200:86::1c:8000/t2', + '240e:930:c200:81::13:8800/t2', + '113.250.57.133:8000/t2', + '113.250.57.110:8000/t2', '113.250.57.28:8800/t2', - '113.250.57.166:8800/t2', - '113.250.58.106:8000/t2', - '113.250.57.28:8000/t1', - '113.250.57.166:8001/t1', - '113.250.58.106:8000/t1', + '113.250.57.133:8001/t1', + '113.250.57.110:8000/t1', + '113.250.57.28:8001/t1', ]); }); }); diff --git a/packages/protocol/test/fixtures/call-create-room-rsp.hex b/packages/protocol/test/fixtures/call-create-room-rsp.hex index ca67649a0..effbf5106 100644 --- a/packages/protocol/test/fixtures/call-create-room-rsp.hex +++ b/packages/protocol/test/fixtures/call-create-room-rsp.hex @@ -1 +1 @@ -0a3b08d0e889f68a8195d35d10c9a4b1ae061898752002ca011e0a1c1218755f6d47494254425737674634576f6377387a61706336771802e201020a001afc02080012001a507aee8e2baa3853ed710888c417219ff17ff5a4439610929d889f0e47b17340b90ef5ec555270632f5eedb1d3e2fdda77b1f9540b7088b934f5cedbfd34505a90403147cd1de2a36d330c3a64bd82b29a2000321c10e0441802251dedc10d2a10240e0930c20000810000000000000013321c10e0441802251dedea162a10240e0930c200008a000000000000001c321c10c03e1802251dedd10d2a10240e0930c20000910000000000000013320f0d71fa391c10e0441802251dedc10d320f0d71fa39a610e0441802251dedea16320f0d71fa3a6a10c03e1802251dedd10d320f0d71fa391c10c03e1801251dedc10d320f0d71fa39a610c13e1801251dedea16320f0d71fa3a6a10c03e1801251dedd10d42004a08080110a81418e807500162220a2008c9a4b1ae061218755f6d47494254425737674634576f6377387a61706336778201220a2008d18da7d50a1218755f4c4b7433416441494d502d4355666e3679647a447a778a01071802250b9371d0900101 +0a3c08b4ebc48d8b81c5894f10c9a4b1ae061898cc012002ca011e0a1c1218755f6d47494254425737674634576f6377387a61706336771802e201020a001afc02080012001a503606441801f5ec24a1d9c1682cfd7c3fca7d32e63488862c9f407f9f6336570e56b5c2d480b533203cb6aaa71c66475b38da0d8bb918704d0b92c9f09057e227a28099714c8be555f528e8bc5c11a27c2000321c10c03e1802251dede8112a10240e0930c20000880000000000000017321c10c03e1802251dede6162a10240e0930c2000086000000000000001c321c10e0441802251dedc10d2a10240e0930c20000810000000000000013320f0d71fa398510c03e1802251dede811320f0d71fa396e10c03e1802251dede616320f0d71fa391c10e0441802251dedc10d320f0d71fa398510c13e1801251dede811320f0d71fa396e10c03e1801251dede616320f0d71fa391c10c13e1801251dedc10d42004a08080610a81418f403500262220a2008c9a4b1ae061218755f6d47494254425737674634576f6377387a61706336778201220a2008d18da7d50a1218755f4c4b7433416441494d502d4355666e3679647a447a778a01071802251eae864b900101 \ No newline at end of file diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index 9249ce115..2e4b16db8 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -59,6 +59,7 @@ import { parseGcsPacket, parseQfb, pbGetBytes, + pbGetVarint, parseVideoPacket, rsEncode, sampleVideoFrameQuality, @@ -778,56 +779,43 @@ export class CallSession { // ── 服务器选择 ── /** - * 服务端给两张表:type=1 是 GCS(控制面 + 音频),type=2 是中继。 + * 选 GCS:**按服务端下发的原始顺序,取第一台**。 * - * 两张表里是**同一批机器**,只是端口不同(GCS 8000/8001,中继 8800)。 - * 所以「选哪个」不是查表的事 —— 得挑一个真的能收发的。这里的策略: + * 2026-10-26 同一次通话的 OIDB + UDP 抓包对照(QQ 本体,P2P 正常): * - * 1. 首次认证从 type=1 的第一台开始,失败(3 秒内没有认证回包)就换下一台; - * 2. 全部失败再退到 type=2; - * 3. 中继列表也去重,同一对 ip:port 只注册一次。 + * ```text + * 服务端下发的服务器表(顺序即优先级): + * 0: 240e:930:c200:88::17:8000 type=2 ← QQ 实际连的就是这条 + * 1: 240e:930:c200:86::1c:8000 type=2 + * 2: 240e:930:c200:81::13:8800 type=2 + * 3: 113.250.57.133:8000 type=2 + * ... + * 6: 113.250.57.133:8001 type=1 + * ``` * - * 试错结果记在 {@link goodGcs},同一进程内的后续通话直接复用 —— 服务端每次 - * 分配的机器会变,但可达性通常一致,能省掉一次 3 秒的空等。 + * 也就是说 **`type` 与「能不能当 GCS」无关**:QQ 连的第一条是 `type=2`, + * 端口 8000。旧实现按 `type === 1` 过滤,把前三条 IPv6 全滤掉,只能退到 + * 第 6 条的 IPv4 —— 于是 GCS 握手走 IPv4、`bt=9` 拿不到对端 `f7`、媒体全程 + * 走中继。 + * + * 失败(3 秒内没有认证回包)就按原顺序换下一台;试错结果记在 + * {@link goodGcs},同一进程内的后续通话直接复用。 */ private pickServers(servers: CallServer[]): void { - // **端口比 type 可靠。** 实测服务端下发的 `type` 字段并不可信:2026-10-26 - // 抓包那次 16 通里有 5 通把 `type=1` 的候选选到了 8800 端口(中继), - // 后果是认证、心跳、`bt=9` 全发给中继节点 —— 中继只回中继地址、**从不给 - // 对端的 f7(IPv6)**,媒体于是全程走中继,两端画面都极卡。 - // - // 真实客户端连的 GCS 是 8000 / 8001 端口,8800 是中继。所以按端口再分一次: - // 8000/8001 优先当 GCS,其余(含 8800)只作兜底。 - const isGcsPort = (s: CallServer) => s.port === 8000 || s.port === 8001; - const typeOne = servers.filter((s) => s.type === 1); - const relayCandidates = servers.filter((s) => s.type === 2); - const gcsCandidates = typeOne.filter(isGcsPort); - // 按端口过滤后一台都不剩(服务端换了端口方案)时退回原列表,不要空手。 - const preferred = gcsCandidates.length > 0 ? gcsCandidates : typeOne; - this.gcsCandidates = [ - ...preferred, - ...typeOne.filter((s) => !preferred.includes(s)), - ...relayCandidates, - ]; - // 记住的 IP 优先在 **真 GCS** 里找:同一个 IP 也可能出现在中继表里(不同 - // 端口),直接在整个候选表里 find 会选中 8800 那个中继端口。 - this.gcs = - preferred.find((s) => s.ip === CallSession.goodGcsIp) ?? - preferred[0] ?? - this.gcsCandidates.find((s) => s.ip === CallSession.goodGcsIp) ?? - this.gcsCandidates[0] ?? - null; + this.gcsCandidates = servers; + // 记住的 IP 只用来在**同序列表里**挑,不能越过原始顺序太多。 + this.gcs = servers.find((s) => s.ip === CallSession.goodGcsIp) ?? servers[0] ?? null; this.gcsIndex = Math.max(0, this.gcsCandidates.indexOf(this.gcs!)); this.log?.debug('服务器表', { - gcs: gcsCandidates.length, - typeOne: typeOne.length, - relay: relayCandidates.length, + total: servers.length, + ipv6: servers.filter((s) => s.ip.includes(':')).length, picked: this.gcs ? `${this.gcs.ip}:${this.gcs.port}` : 'none', }); + // 中继通道注册用前两台(按原始顺序,去重)。 const seen = new Set(); this.relays = []; - for (const server of [...relayCandidates, ...gcsCandidates]) { + for (const server of servers) { const key = `${server.ip}:${server.port}`; if (seen.has(key)) continue; seen.add(key); @@ -1229,31 +1217,52 @@ export class CallSession { } /** - * 解析 `bt=9` 里的对端直连地址。 + * 解析 `bt=9` 里的对端直连地址与端口。 + * + * body 结构(2026-10-26 同一次通话的 OIDB + UDP 抓包对照,QQ 本体 P2P 正常): + * + * ```text + * f1 bytes(10) 链路描述 + * f2 varint 对端 QQ 号 + * f3 varint 会话相关 + * f7 bytes(16) 对端 IPv6 地址 ★ + * f8 varint 对端 P2P 端口 ★ + * ``` * - * body 结构(实测): + * 实测(同一次通话): * * ```text - * f1 bytes 固定 8 字节描述(链路类型等) - * f2 varint 对端 QQ 号 - * f3 varint 端口相关(含义未定,实测不是监听端口) - * f7 bytes 16 字节 —— 对端 IPv6 地址 ★ - * f8 varint 端口相关 + * bt=9: f7 = 240e:399:f9d:3f10:7873:e70f:577e:60c5 + * f8 = 56082 + * 随后 QQ 直接向 240e:...:60c5:56089 发 0x02 relay 探测包(1696 个), + * 对端回包后 P2P 建立,媒体走 2493+1666 个包直连。 * ``` * - * 端口不在 `f3` / `f8` 里(实测对端 P2P 端口是 47652 / 38965,两者都对不上), - * 所以这里只取地址;端口用**实际收到对端包时的来源端口**(见 - * {@link rememberPeerSource})—— 对端一旦开始发,来源就是可用的回程地址。 + * **端口必须用 `f8` 初始化。** 早先这里把端口留 0、指望「收到对端包时从来源 + * 学到」,但 `mediaTargets` 要求 `port > 0` 才发对端 —— 不发就收不到回包, + * 永远学不到端口,形成死锁:媒体只能全程走服务器中继,两端画面都极卡。 + * + * `f8` 与实际监听端口可能差几个(实测 56082 vs 56089,同批临时端口), + * 但足以让第一包打过去;对端回包后 {@link rememberPeerSource} 会用真实来源 + * 端口纠正。 */ private handlePeerAddress(body: Uint8Array): void { const raw = pbGetBytes(body, 7); if (raw?.length !== 16) return; const address = formatIpv6(raw); if (!address) return; - if (this.peerDirect?.address === address) return; - // 端口先留 0:真正可用的是对端发包时的来源端口。 - this.peerDirect = { address, port: this.peerDirect?.port ?? 0 }; - this.log?.info('拿到对端直连地址', { address, port: this.peerDirect.port }); + // f8 是对端 P2P 端口;缺省时才退回已学到的端口。 + const announced = Number(pbGetVarint(body, 8) ?? 0); + const port = announced > 0 && announced < 65536 ? announced : (this.peerDirect?.port ?? 0); + if (this.peerDirect?.address === address && this.peerDirect.port === port) return; + this.peerDirect = { address, port }; + this.log?.info('拿到对端直连地址', { address, port, announced }); + // 学到端口即可开始双发探测:真实客户端也是拿到地址就主动打过去。 + if (port > 0 && this.mediaPath === 'server') { + this.mediaPath = 'probing'; + this.probeSince = this.timers.now(); + this.log?.info('媒体路径切换', { path: 'probing' }); + } } /** diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index 56d793b11..bd1e34835 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -282,6 +282,23 @@ function authRspPacket(sessionTs = 999): Uint8Array { return buildGcsPacket(head, new Uint8Array([0x08, 0x01])); } +/** + * `bt=9`(ctrlPush)——对端直连地址 + 端口。 + * + * 默认值取自 2026-10-26 同一次通话的抓包:`f7` = 对端 IPv6,`f8` = 56082。 + */ +function ctrlPushPacket(peerIpv6: Uint8Array, port: number): Uint8Array { + const head = buildGcsHead({ + roomId: 777n, + selfUin: 1707889225, + sessionTs: 555, + typeId: 0x6242, + bodyType: GcsBodyType.ctrlPush, + }); + const body = new Uint8Array([...pbBytes(7, peerIpv6), ...pbVarint(8, port)]); + return buildGcsPacket(head, body); +} + /** 拼一个视频分片(带 20 字节 sub-header + Annex B)。 */ function videoPacket(params: { gidx: number; @@ -832,31 +849,57 @@ describe('媒体路径(中继 / 直连)', () => { }); /** - * 回归:GCS 必须选 8000/8001 端口,不能选中继的 8800。 + * 回归:`bt=9` 的 `f8` 必须被当成对端端口,拿到就主动发包。 + * + * 2026-10-26 抓包:QQ 收到 `bt=9`(f7=对端 IPv6,f8=56082)后**立即**向 + * `240e:...:60c5:56089` 发 0x02 relay 探测包,对端回包后 P2P 建立。 + * + * 旧实现把端口硬编码 0,而 `mediaTargets` 要求 `port > 0` 才发对端 —— + * 不发就收不到回包,永远学不到端口,死锁在中继路径。 + */ + it('收到 bt=9 后按 f8 端口主动发往对端', async () => { + const encoder = new FakeVideoEncoder(1000); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + + const peerIpv6 = new Uint8Array([ + 0x24, 0x0e, 0x03, 0x99, 0x0f, 0x9d, 0x3f, 0x10, 0x78, 0x73, 0xe7, 0x0f, 0x57, 0x7e, 0x60, 0xc5, + ]); + ctx.transport.deliver(ctrlPushPacket(peerIpv6, 56082)); + ctx.transport.reset(); + await ctx.timers.advanceAsync(20); + + const peer = '240e:399:f9d:3f10:7873:e70f:577e:60c5'; + const toPeer = ctx.transport.sent.filter((s) => s.address === peer); + expect(toPeer.length).toBeGreaterThan(0); + expect(toPeer.every((s) => s.port === 56082)).toBe(true); + }); + + /** + * 回归:GCS 按服务端下发的**原始顺序**取第一台,不看 `type`。 * - * 2026-10-26 抓包实测:服务端下发的 `type` 字段不可信 —— 16 通里有 5 通把 - * `type=1` 的候选选到了 8800(中继)。中继只回中继地址、从不给对端的 f7 - * (IPv6),媒体于是全程走中继,两端画面都极卡。 + * 2026-10-26 同一次通话的 OIDB + UDP 抓包(QQ 本体,P2P 正常):服务器表第一条 + * 是 `240e:930:c200:88::17:8000 type=2`,QQ 连的就是它。旧实现按 `type === 1` + * 过滤,把前面的 IPv6 条目全滤掉,只能退到第 6 条的 IPv4。 */ - it('服务器表里 type=1 混进 8800 时,仍优先选 8000/8001', async () => { + it('按原始顺序取第一台,不按 type 过滤', async () => { const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(1000), servers: [ - { ip: '113.250.57.28', port: 8800, type: 1 }, // 中继端口,但标了 type=1 - { ip: '113.250.58.160', port: 8000, type: 1 }, - { ip: '113.250.57.28', port: 8000, type: 2 }, + { ip: '240e:930:c200:88::17', port: 8000, type: 2 }, + { ip: '240e:930:c200:86::1c', port: 8000, type: 2 }, + { ip: '113.250.57.133', port: 8001, type: 1 }, ], }); await connect(ctx); ctx.transport.reset(); await ctx.timers.advanceAsync(20); - // 认证 / 媒体都必须发往 8000 那台,一个包都不能发到 8800。 - const to8800 = ctx.transport.sent.filter((s) => s.port === 8800); - expect(to8800).toHaveLength(0); - const to8000 = ctx.transport.sent.filter((s) => s.port === 8000); - expect(to8000.length).toBeGreaterThan(0); + const toFirst = ctx.transport.sent.filter((s) => s.address === '240e:930:c200:88::17'); + expect(toFirst.length).toBeGreaterThan(0); + // 后面的条目一个包都不该收到。 + expect(ctx.transport.sent.filter((s) => s.address === '113.250.57.133')).toHaveLength(0); }); it('保活包每拍两条:0x02 与 0x03 变体', async () => { From bb995b0b820db7568164e7ebea0051ee14dee377 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Mon, 26 Oct 2026 07:54:33 +0800 Subject: [PATCH 09/23] =?UTF-8?q?fix(call):=20=E6=89=93=E9=80=9A=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E9=80=9A=E8=AF=9D=20P2P=20=E7=9B=B4=E8=BF=9E=E4=B8=8E?= =?UTF-8?q?=E4=B8=AD=E7=BB=A7=E5=8D=8F=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + apps/desktop/src/main/call.ts | 83 +++- packages/protocol/src/call/gcs.ts | 318 ++++++++++++-- packages/protocol/src/call/index.ts | 12 +- packages/protocol/test/call.test.ts | 205 ++++++++- packages/protocol/test/call_video.test.ts | 4 +- packages/service/src/account/call_session.ts | 397 +++++++++++++++--- packages/service/test/call_session.test.ts | 5 +- .../service/test/call_video_session.test.ts | 219 +++++++++- 9 files changed, 1117 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b4e64ef..619926e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -598,3 +598,4 @@ - 修复视频通话媒体退化成全程服务器中继、两端画面都极卡的问题:服务器选路不再选中继端口,对端直连地址识别不再被 IPv4 映射地址混淆。 - 修复视频通话拿不到对端直连地址、媒体只能全程走服务器中继的问题:GCS 认证改为发送与 QQ 一致的音视频能力集。 - 继续对齐视频通话信令:开房与邀请请求的字段与 QQ 本体逐字节一致。 +- 修复视频通话对端久等才看到画面、媒体全程走服务器中继的问题:补齐 P2P 端口交换与直连握手,媒体确认直连后不再全程走服务器中继;同时修正中继注册报文的序号与对端字段,服务器重新给予中继应答。 diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index a959bd2c5..b66f8bacb 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -53,29 +53,52 @@ const logger = getLogger().child({ scope: 'call' }); /** `node:dgram` 的 udp4 套接字适配成 `CallSession` 要的形状。 */ class NodeUdpTransport implements UdpTransport { private socket: dgram.Socket | null = null; + private p2pSocket: dgram.Socket | null = null; async bind(): Promise { - return new Promise((resolve, reject) => { - // 双栈套接字:视频通话的 P2P 直连地址是 **IPv6**(服务端下发的 bt=9 - // 里是 16 字节裸 IPv6),udp4 发不出去。`ipv6Only: false` 让它同时 - // 接受 IPv4 目标(Linux 上会自动走 ::ffff: 映射)。 - const socket = dgram.createSocket({ type: 'udp6', reuseAddr: false, ipv6Only: false }); - socket.once('error', reject); - socket.bind(0, () => { - socket.removeListener('error', reject); - this.socket = socket; - resolve(); + const open = (): Promise => + new Promise((resolve, reject) => { + const socket = dgram.createSocket({ type: 'udp6', reuseAddr: false, ipv6Only: false }); + socket.once('error', reject); + socket.bind(0, () => { + socket.removeListener('error', reject); + resolve(socket); + }); }); - }); + try { + const [control, p2p] = await Promise.all([open(), open()]); + this.socket = control; + this.p2pSocket = p2p; + } catch (error) { + this.close(); + throw error; + } + } + + /** + * 绑定后的本地端口。 + * + * `CallSession` 要用它填 `bt=25`(向服务端公告本端 P2P 端口)—— 公告的端口必须 + * 就是真正收发媒体这个 socket 的端口,否则对端打洞打不通。 + */ + localPort(): number { + if (!this.p2pSocket) return 0; + try { + return this.p2pSocket.address().port; + } catch { + // 已关闭 / 未绑定。 + return 0; + } } send(data: Uint8Array, port: number, address: string): void { - if (!this.socket) return; + const socket = [8000, 8001, 8800].includes(port) ? this.socket : this.p2pSocket; + if (!socket) return; // 直接拷一份:dgram 的回调可能在原缓冲被复写之后才跑。 const buffer = Buffer.from(data); // udp6 套接字发纯 IPv4 目标时,部分平台需要显式映射成 ::ffff: 形式。 const target = address.includes(':') ? address : `::ffff:${address}`; - this.socket.send(buffer, 0, buffer.length, port, target, (error) => { + socket.send(buffer, 0, buffer.length, port, target, (error) => { // 发送失败以前是静默吞掉的:地址不可达 / 套接字类型不匹配时 // 上层完全看不出「对方为什么收不到」。这里至少留一条日志。 if (error) { @@ -85,13 +108,17 @@ class NodeUdpTransport implements UdpTransport { } close(): void { - const socket = this.socket; + const sockets = [this.socket, this.p2pSocket].filter( + (socket): socket is dgram.Socket => !!socket, + ); this.socket = null; - if (!socket) return; - try { - socket.close(); - } catch { - // 已关闭 / 未绑定,忽略。 + this.p2pSocket = null; + for (const socket of sockets) { + try { + socket.close(); + } catch { + // 已关闭 / 未绑定,忽略。 + } } } @@ -113,14 +140,22 @@ class NodeUdpTransport implements UdpTransport { */ private pending: Array<(socket: dgram.Socket) => void> = []; private watch(register: (socket: dgram.Socket) => void): void { - if (this.socket) register(this.socket); - else this.pending.push(register); + if (!this.socket) { + this.pending.push(register); + return; + } + register(this.socket); + if (this.p2pSocket) register(this.p2pSocket); } /** 绑定完成后把排队的监听器挂上。 */ attach(): void { - if (!this.socket) return; - for (const register of this.pending) register(this.socket); + const sockets = [this.socket, this.p2pSocket].filter( + (socket): socket is dgram.Socket => !!socket, + ); + for (const register of this.pending) { + for (const socket of sockets) register(socket); + } this.pending = []; } } @@ -288,6 +323,8 @@ export class CallManager extends EventEmitter { await transport.bind(); transport.attach(); }, + // 必须透传:会话靠它填 bt=25 的 P2P 端口,漏掉这一行整条 P2P 通路就断了。 + localPort: () => transport.localPort(), send: (data, port, address) => transport.send(data, port, address), close: () => transport.close(), onMessage: (handler) => transport.onMessage(handler), diff --git a/packages/protocol/src/call/gcs.ts b/packages/protocol/src/call/gcs.ts index 326f5d69e..866a2830e 100644 --- a/packages/protocol/src/call/gcs.ts +++ b/packages/protocol/src/call/gcs.ts @@ -198,6 +198,15 @@ export const GcsBodyType = { statusReportAck: 16, heartbeat: 23, heartbeatAck: 24, + /** + * 本端 P2P 端点公告(client→server,24 字节)。见 {@link buildPeerJoinReport}。 + * + * **这是 P2P 能建立的开关。** 不发这条,服务端不知道我们的 P2P 端口,对端也就 + * 永远拿不到我们的端点 —— 表现就是「对端很久才收到我的画面」+「全程中继」。 + */ + peerJoinReport: 25, + peerJoinReportAck: 26, + /** 对端 P2P 端点推送(server→client)。见 {@link parsePeerJoin}。 */ peerJoin: 27, peerJoinAck: 28, mediaCtrlPush: 75, @@ -358,11 +367,8 @@ export function buildGcsAck(params: { authKey?: Uint8Array; }): Uint8Array { const isMediaCtrl = params.pushBodyType === GcsBodyType.mediaCtrlPush; - const errorCode = params.pushBodyType === GcsBodyType.ctrlPush ? 983041 : 0; let body: Uint8Array = new Uint8Array(0); - if (params.pushBodyType === GcsBodyType.ctrlPush) { - body = Uint8Array.from([0x08, 0x02]); - } else if (isMediaCtrl && params.pushBody) { + if (isMediaCtrl && params.pushBody) { const name = pbGetBytes(params.pushBody, 1); const type = pbGetVarint(params.pushBody, 2) ?? 1; if (name && name.length > 0) { @@ -376,7 +382,6 @@ export function buildGcsAck(params: { typeId: params.typeId, authKey: isMediaCtrl ? params.authKey : undefined, bodyType: params.pushBodyType + 1, - errorCode, }); return buildGcsPacket(head, body); } @@ -666,77 +671,312 @@ export function buildStatusReport(params: { // ═══════════════════════════ Relay (0x02) ═══════════════════════════ export const RELAY_STX = 0x02; -export const RELAY_HEADER_LEN = 37; + +/** + * `0x02` 的固定头长度(body 从 39 开始,最后一字节恒 `0x03`)。 + * + * 2026-10-26 QQ 本体抓包逐字节核对:`[35:39]` 是 4 字节 token(= 邀请回包的 + * authToken),它跨在「头 / 体」的老边界上。旧实现把头长写成 37,于是 token + * 只写进去 2 字节、剩 2 字节被当成 body 的开头 —— 中继因此从不回 GET_IN 应答。 + */ +export const RELAY_HEADER_LEN = 39; + +/** + * `0x02` 的两条通道。 + * + * - `server`(0):中继注册 / 保活 / 对端路径下发; + * - `peer`(1):P2P 打洞握手(见 {@link buildPeerProbe})。 + */ +export const RELAY_CHANNEL = { server: 0, peer: 1 } as const; export const RelayCmd = { getIn: 1, getInAck: 2, - peerPathInfo: 5, + peerPathInfo: 3, keepalive: 0x0b, } as const; -/** 中继通道号的起点;每个中继服务器一个通道,依次 +1。 */ -export const RELAY_CHANNEL_BASE = 5000; +/** + * 中继 GET_IN 尾部的能力摘要:`02 | capabilities.f3 | capabilities.f6(BE)`。 + * + * 实测 QQ 本体(视频档):`02 0f 27 4f` —— `0x0f`=15、`0x274f`=10063,正好是 + * {@link DEFAULT_CAPABILITIES} 的 `f3` / `f6`。旧实现照抄的是语音档的旧模板 + * `02 05 27 4a`(5 / 10058),与当前发出去的能力集自相矛盾。 + */ +export const RELAY_CAPABILITY_TRIPLE = Uint8Array.from([0x02, 0x0f, 0x27, 0x4f]); function buildRelayPacket(params: { roomId: bigint; selfUin: number; - channelId: number; - authToken: number; + /** 本端的控制包序号(中继通道内恒定,P2P 探测从它往后递增)。 */ + seq: number; + /** 对端 uin;真实客户端显式写出。 */ + peerUin: number; + /** 邀请回包里的 authToken,放在 `[35:39]`。 */ + token: number; + channel: number; cmd: number; body: Uint8Array; }): Uint8Array { - const out = new Uint8Array(RELAY_HEADER_LEN + params.body.length); + // 末尾固定一个 `0x03` 结束符,所以多留 1 字节(QQ 的 GET_IN 是 64 = 39 + 24 + 1)。 + const out = new Uint8Array(RELAY_HEADER_LEN + params.body.length + 1); out[0] = RELAY_STX; writeU16be(out, 1, out.length); - writeU16be(out, 3, 0); + out[3] = 0; + out[4] = params.channel; writeU16be(out, 5, params.cmd); writeU64be(out, 7, params.roomId); writeU32be(out, 19, params.selfUin); - writeU16be(out, 25, params.channelId); - // [31:35] 是保留的对端 uin,真实客户端也写 0。 - out[35] = (params.authToken >>> 24) & 0xff; - out[36] = (params.authToken >>> 16) & 0xff; + writeU32be(out, 23, params.seq); + writeU32be(out, 31, params.peerUin); + writeU32be(out, 35, params.token); out.set(params.body, RELAY_HEADER_LEN); + out[out.length - 1] = 0x03; return out; } /** - * 注册到中继(cmd=1)。 + * 注册到中继(ch=0, cmd=1)。 * - * `authToken` 来自邀请回包;`nonce` 是通道级的随机数,服务端原样带回。 - * 中继只做转发,同一个房间的两个客户端各注册一条通道,服务端把两边的音频 - * 互相投递。 + * 2026-10-26 抓包逐字段对齐(QQ 本体,P2P 正常): + * + * ```text + * [23:27] 本端序号 QQ 是 0x70ed 这样的递增计数,旧实现写死 5000 + * [31:35] 对端 uin QQ 显式填,旧实现写 0 + * [35:39] token QQ 填 authToken,旧实现只写 2 字节 + * [53:57] 02 0f 27 4f 能力摘要,见 RELAY_CAPABILITY_TRIPLE + * ``` + * + * 这四处不对,中继就不回 GET_IN 应答(cmd=2),也就不会下发对端路径(cmd=3)。 */ export function buildRelayGetIn(params: { roomId: bigint; selfUin: number; - channelId: number; + peerUin: number; + seq: number; authToken: number; nonce?: number; }): Uint8Array { - const body = new Uint8Array(27); - body[0] = (params.authToken >>> 8) & 0xff; - body[1] = params.authToken & 0xff; - writeU16be(body, 8, params.nonce ?? 0xfe8c); - // [10:18] 是固定的链路描述(IPv4 / UDP / 端口族),照抄。 - body[16] = 0x02; - body[17] = 0x05; - body[18] = 0x27; - body[19] = 0x4a; - body[26] = 0x03; - return buildRelayPacket({ ...params, cmd: RelayCmd.getIn, body }); -} - -/** 中继保活(cmd=0x0b),5 秒一次。 */ + // body(相对 39):[6:8] 通道随机数,[14:18] 能力摘要,其余 0。 + const body = new Uint8Array(24); + writeU16be(body, 6, params.nonce ?? 0xae4e); + body.set(RELAY_CAPABILITY_TRIPLE, 14); + return buildRelayPacket({ + roomId: params.roomId, + selfUin: params.selfUin, + peerUin: params.peerUin, + seq: params.seq, + token: params.authToken, + channel: RELAY_CHANNEL.server, + cmd: RelayCmd.getIn, + body, + }); +} + +/** 中继保活(ch=0, cmd=0x0b),5 秒一次;序号与 GET_IN 相同。 */ export function buildRelayKeepalive(params: { roomId: bigint; selfUin: number; - channelId: number; + peerUin: number; + seq: number; authToken: number; }): Uint8Array { - const body = Uint8Array.from([(params.authToken >>> 8) & 0xff, params.authToken & 0xff, 0x03]); - return buildRelayPacket({ ...params, cmd: RelayCmd.keepalive, body }); + return buildRelayPacket({ + roomId: params.roomId, + selfUin: params.selfUin, + peerUin: params.peerUin, + seq: params.seq, + token: params.authToken, + channel: RELAY_CHANNEL.server, + cmd: RelayCmd.keepalive, + body: new Uint8Array(0), + }); +} + +// ═══════════════════════════ P2P 端点(bt=25 / bt=27) ═══════════════════════════ + +/** + * `bt=25` / `bt=27` 里端点描述的固定前 6 字节。 + * + * 两个方向、两通不同的通话都是 `d1 07 00 00 00 00`,含义未还原(看起来像 + * 「IPv6 / UDP 族」之类的链路标识),照抄即可;**后两字节是大端端口**。 + */ +const PEER_ENDPOINT_PREFIX = Uint8Array.from([0xd1, 0x07, 0x00, 0x00, 0x00, 0x00]); + +/** 本端 P2P 端点公告(`bt=25`)。 */ +export function buildPeerJoinReport(params: { + roomId: bigint; + selfUin: number; + peerUin: number; + sessionTs: number; + typeId: number; + /** 本端 UDP 套接字的本地端口(就是 P2P 要用的端口)。 */ + port: number; +}): Uint8Array { + const endpoint = new Uint8Array(8); + endpoint.set(PEER_ENDPOINT_PREFIX, 0); + writeU16be(endpoint, 6, params.port); + const body = concat([ + pbVarint(1, params.selfUin), + pbVarint(2, params.peerUin), + // f3 恒 2(两通实测都是 2,语义未还原)。 + pbVarint(3, 2), + pbBytes(4, endpoint), + ]); + const head = buildGcsHead({ + roomId: params.roomId, + selfUin: params.selfUin, + sessionTs: params.sessionTs, + typeId: params.typeId, + bodyType: GcsBodyType.peerJoinReport, + }); + return buildGcsPacket(head, body); +} + +/** `bt=27` 解析结果。 */ +export interface PeerJoinPush { + /** 对端 uin。 */ + peerUin: number; + /** 对端真实 P2P 端口(`bt=9` 的 f8 只是近似值,这个才是权威)。 */ + port: number; +} + +/** + * 解析 `bt=27`(peerJoin)里对端的 P2P 端口。 + * + * 2026-10-26 抓包:`08 <对端uin> 10 <自己uin> 18 02 22 08 d10700000000 db19` —— + * `0xdb19` = 56089,正是对端发媒体用的端口(`bt=9` 的 f8 给的是 56082)。 + */ +export function parsePeerJoin(body: Uint8Array): PeerJoinPush | null { + const peerUin = Number(pbGetVarint(body, 1) ?? 0); + const endpoint = pbGetBytes(body, 4); + if (!endpoint || endpoint.length < 8) return null; + const port = u16be(endpoint, 6); + if (port === 0) return null; + return { peerUin, port }; +} + +// ═══════════════════════════ P2P 握手(0x02 ch=1) ═══════════════════════════ + +/** P2P 握手包定长 72 字节。 */ +export const PEER_PACKET_LEN = 72; + +/** + * P2P 通道的 `0x02` 子命令。 + * + * 实序是「四步握手 + 2 秒保活」(2026-10-26 QQ 本体抓包,23.883s 起): + * + * ```text + * A→B probe(0) B→A probeReply(1) ← 互相确认可达 + * B→A probe(0) A→B probeReply(1) + * A→B probeAck(2) B→A probeAckReply(3) + * 之后每 2 秒:probeAck(2) + probeAckReply(3) + * ``` + * + * 只有收到 1/3 才能确认「双向都通」,媒体才该切到直连。 + */ +export const PeerProbeCmd = { + probe: 0, + probeReply: 1, + probeAck: 2, + probeAckReply: 3, +} as const; + +/** 一条 P2P 握手包解析后的字段。 */ +export interface PeerProbe { + cmd: number; + /** 这条包「关于」哪个 uin:探测时是自己,应答时是发起方。 */ + uinAbout: number; + seq: number; + otherUin: number; + token: number; + /** `uinAbout` 的时钟(应答时原样回抄)。 */ + clock: number; + /** 发这条包的一方自己的时钟(探测时为 0)。 */ + peerClock: number; +} + +/** + * 构造 P2P 握手包(`0x02` ch=1)。 + * + * 逐字节布局(72 字节,2026-10-26 抓包核对): + * + * ```text + * [0:4] 02 00 48 00 magic + 总长(BE) + * [4] 01 通道:1 = P2P + * [5:7] cmd BE + * [7:15] roomId u64 BE + * [19:23] uinAbout 这条包「关于」的 uin + * [23:27] seq + * [31:35] otherUin + * [35:39] token + * [39:43] 00 00 00 02 常量 + * [47:51] clock(uinAbout 的) + * [51:55] peerClock(发信方自己的) + * [71] 03 结束符 + * ``` + */ +export function buildPeerProbe(params: { + roomId: bigint; + cmd: number; + uinAbout: number; + seq: number; + otherUin: number; + token: number; + clock: number; + peerClock: number; +}): Uint8Array { + const out = new Uint8Array(PEER_PACKET_LEN); + out[0] = RELAY_STX; + writeU16be(out, 1, out.length); + out[3] = 0; + out[4] = RELAY_CHANNEL.peer; + writeU16be(out, 5, params.cmd); + writeU64be(out, 7, params.roomId); + writeU32be(out, 19, params.uinAbout); + writeU32be(out, 23, params.seq); + writeU32be(out, 31, params.otherUin); + writeU32be(out, 35, params.token); + writeU32be(out, 39, 2); + writeU32be(out, 47, params.clock); + writeU32be(out, 51, params.peerClock); + out[out.length - 1] = 0x03; + return out; +} + +/** 解析 P2P 握手包;不是 P2P 通道的 `0x02` 返回 null。 */ +export function parsePeerProbe(data: Uint8Array): PeerProbe | null { + if (data.length < PEER_PACKET_LEN || data[0] !== RELAY_STX) return null; + if (data[4] !== RELAY_CHANNEL.peer) return null; + if (u16be(data, 1) !== data.length) return null; + return { + cmd: u16be(data, 5), + uinAbout: u32be(data, 19), + seq: u32be(data, 23), + otherUin: u32be(data, 31), + token: u32be(data, 35), + clock: u32be(data, 47), + peerClock: u32be(data, 51), + }; +} + +/** + * 构造应答(cmd+1)。 + * + * 实测应答包是「把收到的 [7:51] 原样回抄,再填上自己的时钟」:`uinAbout` / + * `seq` / `otherUin` / `token` / `clock` 全部照抄,只把 `peerClock` 换成自己的。 + */ +export function buildPeerProbeReply(probe: PeerProbe, params: { roomId: bigint; clock: number }) { + return buildPeerProbe({ + roomId: params.roomId, + cmd: probe.cmd + 1, + uinAbout: probe.uinAbout, + seq: probe.seq, + otherUin: probe.otherUin, + token: probe.token, + clock: probe.clock, + peerClock: params.clock, + }); } // ═══════════════════════════ Audio (0x5b) ═══════════════════════════ diff --git a/packages/protocol/src/call/index.ts b/packages/protocol/src/call/index.ts index b8ddee4d8..4c6581569 100644 --- a/packages/protocol/src/call/index.ts +++ b/packages/protocol/src/call/index.ts @@ -100,10 +100,18 @@ export { buildStatusReport, RELAY_STX, RELAY_HEADER_LEN, - RELAY_CHANNEL_BASE, + RELAY_CHANNEL, + RELAY_CAPABILITY_TRIPLE, RelayCmd, buildRelayGetIn, buildRelayKeepalive, + PEER_PACKET_LEN, + PeerProbeCmd, + buildPeerJoinReport, + parsePeerJoin, + buildPeerProbe, + buildPeerProbeReply, + parsePeerProbe, AUDIO_STX, AUDIO_HEADER_LEN, FRAME_SAMPLES, @@ -130,6 +138,8 @@ export type { AudioPacketHeader, AudioFrame, PbField, + PeerJoinPush, + PeerProbe, } from './gcs'; export { diff --git a/packages/protocol/test/call.test.ts b/packages/protocol/test/call.test.ts index 200ec3629..2c1cc7218 100644 --- a/packages/protocol/test/call.test.ts +++ b/packages/protocol/test/call.test.ts @@ -16,9 +16,16 @@ import { buildGcsLogout, buildVideoParamPush, buildVideoStreamPush, + buildPeerJoinReport, + buildPeerProbe, + buildPeerProbeReply, buildQfbRequest, buildRelayGetIn, + buildRelayKeepalive, buildStatusReport, + parsePeerJoin, + parsePeerProbe, + PeerProbeCmd, decodeCreateRoomRsp, encodeCreateRoomReq, parseAudioFrame, @@ -201,20 +208,196 @@ describe('GCS 控制面', () => { // ─────────────── Relay ─────────────── describe('中继', () => { - it('GET_IN 是 37 字节头 + 27 字节体', () => { + /** + * 黄金样本:2026-10-26 QQ 本体抓包里的 GET_IN(发往对端中继 `183.47.105.240:8000`, + * 64 字节)。 + * + * 旧实现与它逐字节相比有四处差异,导致中继从不回 GET_IN 应答(cmd=2): + * + * ```text + * [23:27] QQ 是递增序号 0x70ed,旧实现写死 channelId=5000 + * [31:35] QQ 填对端 uin,旧实现写 0 + * [35:39] QQ 填 4 字节 token,旧实现只写 2 字节(头长写成 37) + * [53:57] QQ 是 02 0f 27 4f(能力摘要),旧实现照抄语音档的 02 05 27 4a + * ``` + */ + it('GET_IN 与 QQ 本体逐字节一致', () => { const pkt = buildRelayGetIn({ + roomId: 5697919981127284148n, + selfUin: 1707889225, + peerUin: 2863253201, + seq: 0x70ed, + authToken: 0x6d7bd793, + nonce: 0xae4e, + }); + expect(Buffer.from(pkt).toString('hex')).toBe( + '02004000000001' + // [0:7] 长度 / 通道 0 / cmd=1 + '4f131408b1b135b4' + // [7:15] roomId + '00000000' + // [15:19] + '65cc5249' + // [19:23] selfUin + '000070ed' + // [23:27] seq + '00000000' + // [27:31] + 'aaa9c6d1' + // [31:35] peerUin + '6d7bd793' + // [35:39] token + '00'.repeat(6) + // [39:45] + 'ae4e' + // [45:47] 通道随机数 + '00'.repeat(6) + // [47:53] + '020f274f' + // [53:57] 能力摘要 + '00'.repeat(6) + // [57:63] + '03', + ); + }); + + it('保活是 39 字节头 + 结束符,序号与 GET_IN 相同', () => { + const pkt = buildRelayKeepalive({ + roomId: 5697919981127284148n, + selfUin: 1707889225, + peerUin: 2863253201, + seq: 0x70ed, + authToken: 0x6d7bd793, + }); + expect(Buffer.from(pkt).toString('hex')).toBe( + '02002800' + + '00000b' + + '4f131408b1b135b4' + + '00000000' + + '65cc5249' + + '000070ed' + + '00000000' + + 'aaa9c6d1' + + '6d7bd793' + + '03', + ); + }); +}); + +// ─────────────── P2P 端点 / 握手 ─────────────── + +describe('P2P 端点交换', () => { + /** + * `bt=25`:QQ 本体抓包 24 字节 —— 把自己真正的 UDP 端口(0xc894 = 51348)公告 + * 出去。不发这条,对端永远不知道往哪打洞,媒体只能全程中继。 + */ + it('bt=25 公告本端端口,与抓包逐字节一致', () => { + const pkt = buildPeerJoinReport({ roomId: 1n, - selfUin: 2, - channelId: 5000, - authToken: 0x11223344, + selfUin: 1707889225, + peerUin: 2863253201, + sessionTs: 0, + typeId: 1, + port: 51348, + }); + const parsed = parseGcsPacket(pkt)!; + expect(parsed.bodyType).toBe(GcsBodyType.peerJoinReport); + expect(Buffer.from(parsed.body).toString('hex')).toBe( + '08c9a4b1ae0610d18da7d50a18022208d10700000000c894', + ); + }); + + /** + * `bt=27`:服务端下发对端真实端口。抓包里 `0xdb19` = 56089,正是对端发媒体 + * 用的端口;`bt=9` 的 `f8` 给的是 56082(近似值)。 + */ + it('bt=27 解析出对端端口', () => { + const body = Uint8Array.from( + Buffer.from('08d18da7d50a10c9a4b1ae0618022208d10700000000db19', 'hex'), + ); + expect(parsePeerJoin(body)).toEqual({ peerUin: 2863253201, port: 56089 }); + expect(parsePeerJoin(new Uint8Array([0x08, 0x01]))).toBeNull(); + }); +}); + +describe('P2P 握手(0x02 ch=1)', () => { + /** + * 黄金样本:抓包 23.883s 的 `probe`。对端回包后 P2P 建立,媒体硬切直连 —— + * 这是「对端收不到 / 极卡」的最后一环。 + */ + it('probe 与抓包逐字节一致', () => { + const pkt = buildPeerProbe({ + roomId: 5697919981127284148n, + cmd: PeerProbeCmd.probe, + uinAbout: 1707889225, + seq: 0x70ee, + otherUin: 2863253201, + token: 0x6d7bd793, + clock: 0x74d7e43a, + peerClock: 0, + }); + expect(Buffer.from(pkt).toString('hex')).toBe( + '02004800010000' + // [0:7] 长度 / 通道 1 / cmd=0 + '4f131408b1b135b4' + // [7:15] roomId + '00000000' + // [15:19] + '65cc5249' + // [19:23] 本条「关于」的 uin(自己) + '000070ee' + // [23:27] seq + '00000000' + // [27:31] + 'aaa9c6d1' + // [31:35] 对端 uin + '6d7bd793' + // [35:39] token + '00000002' + // [39:43] 常量 + '00000000' + // [43:47] + '74d7e43a' + // [47:51] 自己的时钟 + '00'.repeat(20) + // [51:71] 还没收到对端的时钟 + '03', + ); + }); + + it('解析出的字段与构造一致,且只认 P2P 通道', () => { + const pkt = buildPeerProbe({ + roomId: 7n, + cmd: PeerProbeCmd.probeAck, + uinAbout: 1, + seq: 9, + otherUin: 2, + token: 3, + clock: 10, + peerClock: 11, + }); + expect(parsePeerProbe(pkt)).toEqual({ + cmd: PeerProbeCmd.probeAck, + uinAbout: 1, + seq: 9, + otherUin: 2, + token: 3, + clock: 10, + peerClock: 11, + }); + // 服务端通道([4]=0)不是握手包。 + const server = buildRelayKeepalive({ + roomId: 7n, + selfUin: 1, + peerUin: 2, + seq: 1, + authToken: 3, + }); + expect(parsePeerProbe(server)).toBeNull(); + }); + + /** + * 应答是「把收到的 [7:51] 原样回抄 + 自己的时钟」。抓包里 B 的 cmd1: + * uinAbout/seq/clock 全是 A 的,`peerClock` 才是 B 自己的。 + */ + it('应答回抄探测包的字段,只换上自己的时钟', () => { + const probe = parsePeerProbe( + buildPeerProbe({ + roomId: 5n, + cmd: PeerProbeCmd.probe, + uinAbout: 11, + seq: 22, + otherUin: 33, + token: 44, + clock: 0x74d7e43a, + peerClock: 0, + }), + )!; + const reply = parsePeerProbe(buildPeerProbeReply(probe, { roomId: 5n, clock: 0x5df445 }))!; + expect(reply).toEqual({ + cmd: PeerProbeCmd.probeReply, + uinAbout: 11, + seq: 22, + otherUin: 33, + token: 44, + clock: 0x74d7e43a, + peerClock: 0x5df445, }); - expect(pkt[0]).toBe(0x02); - expect(pkt.length).toBe(64); - expect((pkt[5]! << 8) | pkt[6]!).toBe(1); - // 通道号写在 [25:27] - expect(((pkt[25]! << 8) | pkt[26]!) >>> 0).toBe(5000); - // auth_token 的高 2 字节在尾部 - expect([pkt[35], pkt[36]]).toEqual([0x11, 0x22]); }); }); diff --git a/packages/protocol/test/call_video.test.ts b/packages/protocol/test/call_video.test.ts index 9d1e8fd9e..099b06a7b 100644 --- a/packages/protocol/test/call_video.test.ts +++ b/packages/protocol/test/call_video.test.ts @@ -673,7 +673,9 @@ describe('MEDIA_PROFILE', () => { }); expect(MEDIA_PROFILE.video).toMatchObject({ f3: 0, - ipStack: 12656, + // 2026-10-26 同一次通话的 OIDB 抓包逐字节比对:视频档是 0x542B。 + // 旧值 12656 来自更早的一通(抓包不同,不是同一档位)。 + ipStack: 21547, f13: 3, inviteF3: 15, inviteF4: 10063, diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index 2e4b16db8..d635d0a25 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -22,6 +22,7 @@ import { AUDIO_STX, AudioSubType, + RelayCmd, type AudioFrame, type CallRoom, CallEvent, @@ -32,8 +33,10 @@ import { GCS_TYPE_ID_BASE, GcsBodyType, OPUS_SILENCE, - RELAY_CHANNEL_BASE, + RELAY_CHANNEL, + RELAY_HEADER_LEN, RELAY_STX, + PeerProbeCmd, buildAudioFrame, buildCodecInitFrame, buildFecContainer, @@ -41,6 +44,9 @@ import { buildGcsAuth, buildGcsHeartbeat, buildGcsLogout, + buildPeerJoinReport, + buildPeerProbe, + buildPeerProbeReply, buildQfbRequest, buildQfbResponse, buildRelayGetIn, @@ -57,8 +63,10 @@ import { parseCallEvent, parseFecContainer, parseGcsPacket, + parsePb, + parsePeerJoin, + parsePeerProbe, parseQfb, - pbGetBytes, pbGetVarint, parseVideoPacket, rsEncode, @@ -153,6 +161,14 @@ export interface VideoStreamInfo { export interface UdpTransport { /** 绑定本地端口(0 = 随机)。 */ bind(): Promise; + /** + * 绑定后的本地端口。 + * + * `bt=25` 要向服务端公告**本端的 P2P 端口**,对端随后就打这个端口。拿不到时 + * (老实现 / 测试里的假 socket)跳过公告 —— 但那样 P2P 永远建不起来,所以真实 + * 实现必须给。 + */ + localPort?(): number; send(data: Uint8Array, port: number, address: string): void; close(): void; /** 收到一个包。 */ @@ -327,6 +343,9 @@ const MEDIA_PROBE_MS = 1000; /** 直连模式下多久没收到对端媒体包就回落服务端。 */ const MEDIA_DIRECT_TIMEOUT_MS = 2000; +/** P2P 握手(`0x02` ch=1)周期。实测真实客户端是 2 秒一拍。 */ +const PEER_PROBE_MS = 2000; + /** 媒体包的发送路径。 */ export type MediaPath = 'server' | 'probing' | 'direct'; @@ -397,6 +416,18 @@ function formatIpv6(raw: Uint8Array): string | null { return `${head}::${tail}`; } +/** + * fixed32 → 点分 IPv4。 + * + * `parsePb` 的 `fixed32` 是「按 wire 上的字节顺序读成的大端数」,所以直接按字节 + * 拆就是网络序的 IPv4:线上 `b7 2f 69 f0` → 183.47.105.240(`bt=9` 的 `f5` + * 与服务器表的 `ip` / `altIp` 都是这个约定)。 + */ +function formatIpv4Fixed(value: number): string { + const n = value >>> 0; + return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`; +} + /** * 从一帧 Annex B 里读 HEVC 规格(分辨率 / profile)。 * @@ -542,6 +573,29 @@ export class CallSession { private peerDirect: { address: string; port: number } | null = null; /** 最近一次收到对端包的来源(P2P 端口往往只能从实际流量里学到)。 */ private lastPeerFrom: { address: string; port: number } | null = null; + /** + * 服务端在 `bt=27` 里下发的对端 P2P 端口。 + * + * `bt=9` 的 `f8` 只是个近似值(实测 56082 vs 真实 56089),`bt=27` 才是权威。 + * 地址仍然要靠 `bt=9`(f7 IPv6 / f5 IPv4),两边拼起来才是完整端点。 + */ + private peerAnnouncedPort: number | null = null; + /** + * 本端 UDP 套接字的本地端口。 + * + * `bt=25` 要向服务端公告它,服务端再转给对端 —— 这是 P2P 能建立的开关。 + */ + private localPort = 0; + /** 已发出的 `bt=25` 条数(避免无限制重发)。 */ + private peerJoinReports = 0; + /** 本端控制包序号(中继 GET_IN 与 P2P 握手共用一条计数,实测如此)。 */ + private peerPacketSeq = 20_000 + Math.floor(Math.random() * 40_000); + /** 中继通道的序号(同一通内恒定,GET_IN 与保活共用)。 */ + private relaySeq = 0; + /** 对端回过我们的 P2P 握手应答(说明双向可达)。 */ + private peerProbeAnswered = false; + /** P2P 握手的定时器。 */ + private peerProbeTimer: unknown = null; /** * 媒体走哪条路。 * @@ -742,10 +796,11 @@ export class CallSession { this.opts.transport.onMessage((data, from) => this.handlePacket(data, from)); this.opts.transport.onError((error) => this.fail(error.message)); await this.opts.transport.bind(); + // P2P 端点公告要用这个端口(见 sendPeerJoinReport)。 + this.localPort = this.opts.transport.localPort?.() ?? 0; // 4. 握手 this.sendAuth(); - this.sendRelayGetIn(); this.startHeartbeat(); this.startStatusReport(); this.sendCodecInit(); @@ -779,36 +834,36 @@ export class CallSession { // ── 服务器选择 ── /** - * 选 GCS:**按服务端下发的原始顺序,取第一台**。 + * 选 GCS:**按端口优先,组内保持服务端下发的原始顺序**。 * - * 2026-10-26 同一次通话的 OIDB + UDP 抓包对照(QQ 本体,P2P 正常): + * `type` 不能用来区分 GCS:QQ 本体实际连接的 `8000` 也经常标成 `type=2`。 + * 真正可靠的是端口:`8000` / `8001` 是 GCS,`8800` 是中继。 * - * ```text - * 服务端下发的服务器表(顺序即优先级): - * 0: 240e:930:c200:88::17:8000 type=2 ← QQ 实际连的就是这条 - * 1: 240e:930:c200:86::1c:8000 type=2 - * 2: 240e:930:c200:81::13:8800 type=2 - * 3: 113.250.57.133:8000 type=2 - * ... - * 6: 113.250.57.133:8001 type=1 - * ``` + * 2026-10-26 关闭 TUN 后的对照抓包: * - * 也就是说 **`type` 与「能不能当 GCS」无关**:QQ 连的第一条是 `type=2`, - * 端口 8000。旧实现按 `type === 1` 过滤,把前三条 IPv6 全滤掉,只能退到 - * 第 6 条的 IPv4 —— 于是 GCS 握手走 IPv4、`bt=9` 拿不到对端 `f7`、媒体全程 - * 走中继。 + * - WeQ 按原始顺序选中 `240e:930:c200:8a::1c:8800`,`bt=9` 只给 IPv4 + * `f5/f6`;WeQ 向该端点打洞、发媒体,对端始终不回包; + * - QQ 本体连接 `8000`,`bt=9` 给出对端 IPv6 `f7/f8`,随后 P2P 正常。 * - * 失败(3 秒内没有认证回包)就按原顺序换下一台;试错结果记在 - * {@link goodGcs},同一进程内的后续通话直接复用。 + * 因此先试所有 `8000` / `8001`,全部失败再回退其余端口。组内仍保持服务端 + * 原始顺序,并优先复用上次认证成功的 GCS IP。 */ private pickServers(servers: CallServer[]): void { - this.gcsCandidates = servers; - // 记住的 IP 只用来在**同序列表里**挑,不能越过原始顺序太多。 - this.gcs = servers.find((s) => s.ip === CallSession.goodGcsIp) ?? servers[0] ?? null; + const isGcsPort = (server: CallServer) => server.port === 8000 || server.port === 8001; + const preferred = servers.filter(isGcsPort); + const fallback = servers.filter((server) => !isGcsPort(server)); + this.gcsCandidates = [...preferred, ...fallback]; + this.gcs = + preferred.find((s) => s.ip === CallSession.goodGcsIp) ?? + preferred[0] ?? + this.gcsCandidates.find((s) => s.ip === CallSession.goodGcsIp) ?? + this.gcsCandidates[0] ?? + null; this.gcsIndex = Math.max(0, this.gcsCandidates.indexOf(this.gcs!)); this.log?.debug('服务器表', { total: servers.length, ipv6: servers.filter((s) => s.ip.includes(':')).length, + gcsPorts: preferred.length, picked: this.gcs ? `${this.gcs.ip}:${this.gcs.port}` : 'none', }); @@ -956,12 +1011,15 @@ export class CallSession { private sendRelayGetIn(): void { if (!this.room) return; - for (const [index, server] of this.relays.entries()) { + // 序号在同一通内恒定(GET_IN 与保活共用),不能每次重发都换一个。 + if (this.relaySeq === 0) this.relaySeq = this.nextPeerSeq(); + for (const server of this.relays) { this.sendTo( buildRelayGetIn({ roomId: this.room.roomId, selfUin: Number(this.opts.selfUin), - channelId: RELAY_CHANNEL_BASE + index, + peerUin: Number(this.opts.peerUin), + seq: this.relaySeq, authToken: this.authToken, }), server, @@ -1055,12 +1113,13 @@ export class CallSession { private startRelayKeepalive(): void { this.relayTimer = this.timers.setInterval(() => { if (!this.room) return; - for (const [index, server] of this.relays.entries()) { + for (const server of this.relays) { this.sendTo( buildRelayKeepalive({ roomId: this.room.roomId, selfUin: Number(this.opts.selfUin), - channelId: RELAY_CHANNEL_BASE + index, + peerUin: Number(this.opts.peerUin), + seq: this.relaySeq, authToken: this.authToken, }), server, @@ -1183,7 +1242,7 @@ export class CallSession { this.rememberPeerSource(from); this.handleAudio(data); } else if (stx === GCS_STX) this.handleGcs(data); - else if (stx === RELAY_STX) this.handleRelay(data); + else if (stx === RELAY_STX) this.handleRelay(data, from); } private handleGcs(data: Uint8Array): void { @@ -1201,12 +1260,16 @@ export class CallSession { this.handleCallEvent(packet.body, packet.typeId); break; case GcsBodyType.peerJoin: + // bt=27:服务端告知对端真实 P2P 端口(地址在 bt=9 里)。 + this.handlePeerJoin(packet.body); this.ackPush(packet.typeId, packet.bodyType, packet.body); break; case GcsBodyType.ctrlPush: // bt=9 带对端直连地址 —— 视频通话的媒体与控制包主要走 P2P, // 只发服务端对端收不到。解析完照常 ACK。 this.handlePeerAddress(packet.body); + // 对端已经进房:把自己的 P2P 端口公告出去(晚于接通时的那次补一条)。 + this.sendPeerJoinReport(2); this.ackPush(packet.typeId, packet.bodyType, packet.body); break; default: @@ -1238,31 +1301,234 @@ export class CallSession { * 对端回包后 P2P 建立,媒体走 2493+1666 个包直连。 * ``` * - * **端口必须用 `f8` 初始化。** 早先这里把端口留 0、指望「收到对端包时从来源 - * 学到」,但 `mediaTargets` 要求 `port > 0` 才发对端 —— 不发就收不到回包, - * 永远学不到端口,形成死锁:媒体只能全程走服务器中继,两端画面都极卡。 + * 另一形态(同一通 20 秒后的第二条 `bt=9`,也是 WeQ 实际收到的那条): + * + * ```text + * f1 bytes(10) 对端中继(嵌套的 RELAY_SERVER) + * f4 bytes(10) 备选中继,可重复 + * f5 fixed32 对端 IPv4 地址 ★ + * f6 varint 对端 P2P 端口 ★ + * ``` + * + * 旧实现只认 `f7`,于是「服务端给的是 IPv4 候选」这一路被整个丢掉 —— 只要对端 + * 是 IPv4 出口(或服务端选的是 IPv4 候选),就永远停在纯中继。 * - * `f8` 与实际监听端口可能差几个(实测 56082 vs 56089,同批临时端口), - * 但足以让第一包打过去;对端回包后 {@link rememberPeerSource} 会用真实来源 - * 端口纠正。 + * **`f8` 只能当初始值。** 它是近似端口(实测 56082 vs 真实的 56089),权威值 + * 在 `bt=27`(见 {@link handlePeerJoin})。但 `mediaTargets` 要求 `port > 0` 才发 + * 对端 —— 不发第一包,`bt=27` 也永远不会被触发,所以这里先用近似值打开局面。 */ private handlePeerAddress(body: Uint8Array): void { - const raw = pbGetBytes(body, 7); - if (raw?.length !== 16) return; - const address = formatIpv6(raw); + const fields = parsePb(body); + const candidates: Array<{ address: string; port: number; ipv6: boolean }> = []; + const relayCandidates: CallServer[] = []; + + const v6 = fields.find((f) => f.field === 7 && f.wire === 2)?.bytes; + if (v6?.length === 16) { + const address = formatIpv6(v6); + if (address) + candidates.push({ address, port: Number(pbGetVarint(body, 8) ?? 0), ipv6: true }); + } + const v4 = fields.find((f) => f.field === 5 && f.wire === 5)?.fixed32; + if (v4 !== undefined) { + // f6 的端口在 IPv4 形态里是 protobuf fixed32,线上字节 `b9 22 00 00` + // 按小端解出来是 0x000022b9 = 8889。parsePb 为了兼容地址字段按大端存成 + // 0xb9220000,所以这里要把高 16 位再按字节序还原,不能直接取高 16 位。 + const fixed = fields.find((f) => f.field === 6 && f.wire === 5)?.fixed32; + const fixedPort = fixed === undefined ? 0 : fixed >>> 16; + const wirePort = ((fixedPort & 0xff) << 8) | (fixedPort >>> 8); + const port = Number(pbGetVarint(body, 6) ?? wirePort); + candidates.push({ address: formatIpv4Fixed(v4), port, ipv6: false }); + } + // `f3` 是本次通话的准入 token:它同时出现在中继 GET_IN 的 `[35:39]` 和每个 + // `0x02` 握手包里。信令那边解析失败时拿它兜底,否则中继永远不回我们。 + const pushToken = Number(pbGetVarint(body, 3) ?? 0); + if (pushToken > 0 && pushToken !== this.authToken) { + this.log?.info('bt=9 的准入 token 与信令不一致', { + pushToken, + signalingToken: this.authToken, + }); + if (this.authToken === 0) this.authToken = pushToken; + } + for (const field of fields) { + if ((field.field !== 1 && field.field !== 4) || field.wire !== 2 || !field.bytes) continue; + const relay = parsePb(field.bytes); + const ip = relay.find((item) => item.field === 1 && item.wire === 5)?.fixed32; + const port = Number(relay.find((item) => item.field === 2 && item.wire === 0)?.value ?? 0); + const type = Number(relay.find((item) => item.field === 3 && item.wire === 0)?.value ?? 0); + if (ip === undefined || port <= 0) continue; + relayCandidates.push({ ip: formatIpv4Fixed(ip), port, type }); + } + if (relayCandidates.length > 0 && this.authToken > 0) { + this.relays = relayCandidates; + this.sendRelayGetIn(); + if (!this.relayTimer) this.startRelayKeepalive(); + } + if (candidates.length === 0) return; + + // IPv6 优先(无 NAT,连通率最高)。 + const best = candidates[0]!; + // 端口优先级:真实流量里学到的 > bt=27 的权威值(只对 IPv6 成立)> bt=9 的近似值。 + // + // 为什么 bt=27 只套 IPv6:抓包里对端的 IPv6 socket 与 IPv4 socket 是两个端口 + // (56089 vs 18038),bt=27 下发的是它主端点(IPv6)的端口;IPv4 候选的端口 + // 就在 bt=9 的 f6 里,本来就是准的。 + const learned = + this.lastPeerFrom && sameHost(this.lastPeerFrom.address, best.address) + ? this.lastPeerFrom.port + : 0; + const announced = best.ipv6 ? this.peerAnnouncedPort : null; + const port = learned > 0 ? learned : (announced ?? best.port); + this.log?.info('拿到对端候选端点', { address: best.address, port, candidates }); + this.applyPeerEndpoint(best.address, port); + } + + /** + * 处理 `bt=27`:服务端下发对端的真实 P2P 端口。 + * + * 地址不在这条里,要和 `bt=9` 的 `f7` / `f5` 拼起来。端口到达后立即开始 + * `0x02` 打洞握手(真实客户端也是这个顺序)。 + */ + private handlePeerJoin(body: Uint8Array): void { + const push = parsePeerJoin(body); + if (!push) return; + this.peerAnnouncedPort = push.port; + this.log?.info('服务端下发对端 P2P 端口', { peerUin: push.peerUin, port: push.port }); + // 只有主端点(IPv6)用这个端口;IPv4 候选的端口在 bt=9 的 f6 里。 + const address = this.peerDirect?.address ?? this.lastPeerFrom?.address; if (!address) return; - // f8 是对端 P2P 端口;缺省时才退回已学到的端口。 - const announced = Number(pbGetVarint(body, 8) ?? 0); - const port = announced > 0 && announced < 65536 ? announced : (this.peerDirect?.port ?? 0); - if (this.peerDirect?.address === address && this.peerDirect.port === port) return; - this.peerDirect = { address, port }; - this.log?.info('拿到对端直连地址', { address, port, announced }); - // 学到端口即可开始双发探测:真实客户端也是拿到地址就主动打过去。 - if (port > 0 && this.mediaPath === 'server') { + const ipv4 = /^\d+\.\d+\.\d+\.\d+$/.test(address) || address.startsWith('::ffff:'); + if (!ipv4) this.applyPeerEndpoint(address, push.port); + } + + /** + * 确定对端端点并开始打洞。 + * + * 这是「知道对端在哪」到「真的把路打通」之间的一步:探索包一发,对端就会回 + * `0x02` 应答,两端几乎同时切到直连(实测 QQ 从拿到地址到媒体直连 <200 ms)。 + */ + private applyPeerEndpoint(address: string, port: number): void { + const normalizedPort = port > 0 && port < 65536 ? port : 0; + if (this.peerDirect?.address === address && this.peerDirect.port === normalizedPort) return; + this.peerDirect = { address, port: normalizedPort }; + if (normalizedPort === 0) return; + if (this.mediaPath === 'server') { this.mediaPath = 'probing'; this.probeSince = this.timers.now(); this.log?.info('媒体路径切换', { path: 'probing' }); } + this.startPeerProbeLoop(); + } + + /** + * 公告本端 P2P 端点(`bt=25`)。 + * + * 不发这条,服务端就不知道我们的 P2P 端口,对端也就永远拿不到我们的端点 —— + * 这正是「对端等很久才收到 WeQ 画面」+「全程中继」的根因(2026-10-26 抓包: + * QQ 发了 `bt=25`/`bt=27`,WeQ 一条都没有)。 + * + * 最多发 `max` 条:接通时一条,对端进房(收到 `bt=9`)后再补一条。 + */ + private sendPeerJoinReport(max = 1): void { + if (!this.room || !['calling', 'ringing', 'connecting', 'connected'].includes(this.state)) { + return; + } + if (this.peerJoinReports >= max) return; + if (this.localPort <= 0) { + if (this.peerJoinReports === 0) { + this.log?.warn('拿不到本地 UDP 端口,跳过 P2P 端点公告'); + this.peerJoinReports = max; + } + return; + } + this.peerJoinReports++; + this.send( + buildPeerJoinReport({ + roomId: this.room.roomId, + selfUin: Number(this.opts.selfUin), + peerUin: Number(this.opts.peerUin), + sessionTs: this.sessionTs, + typeId: this.nextTypeId(), + port: this.localPort, + }), + ); + this.log?.info('公告本端 P2P 端点', { port: this.localPort }); + } + + /** 下一个控制包序号(中继 GET_IN 与 P2P 握手共用)。 */ + private nextPeerSeq(): number { + return ++this.peerPacketSeq; + } + + /** + * 启动 P2P 握手循环(`0x02` ch=1,2 秒一拍)。 + * + * 第一拍是 `probe`;一旦对端回过应答就换成 `probeAck`(实测真机的稳态就是 + * 两秒一对 2/3)。 + */ + private startPeerProbeLoop(): void { + if (this.peerProbeTimer !== null) return; + const beat = () => this.sendPeerProbe(); + beat(); + this.peerProbeTimer = this.timers.setInterval(beat, PEER_PROBE_MS); + } + + private stopPeerProbeLoop(): void { + if (this.peerProbeTimer === null) return; + this.timers.clearInterval(this.peerProbeTimer); + this.peerProbeTimer = null; + } + + private sendPeerProbe(): void { + const peer = this.peerDirect; + if (!this.room || !peer || peer.port <= 0) return; + const packet = buildPeerProbe({ + roomId: this.room.roomId, + cmd: this.peerProbeAnswered ? PeerProbeCmd.probeAck : PeerProbeCmd.probe, + uinAbout: Number(this.opts.selfUin), + seq: this.nextPeerSeq(), + otherUin: Number(this.opts.peerUin), + token: this.authToken, + clock: this.tick(), + peerClock: 0, + }); + this.opts.transport.send(packet, peer.port, peer.address); + } + + /** + * P2P 握手包(`0x02` ch=1)。 + * + * 只有「对方发来的探测」需要应答:0→1、2→3。收到 1/3 说明我们发出去的探测 + * 到了对方,这条路双向可用,媒体就可以停掉中继那份了。 + */ + private handlePeerChannel(data: Uint8Array, from: { address: string; port: number }): void { + const probe = parsePeerProbe(data); + if (!probe || !this.room) return; + + // 打洞包本身就是最可靠的端点来源:它就是从这个地址:端口发过来的。 + this.rememberPeerSource(from); + if (probe.cmd === PeerProbeCmd.probe || probe.cmd === PeerProbeCmd.probeAck) { + const reply: Uint8Array = buildPeerProbeReply(probe, { + roomId: this.room.roomId, + clock: this.tick(), + }); + this.opts.transport.send(reply, from.port, from.address); + } + if (probe.cmd === PeerProbeCmd.probeReply || probe.cmd === PeerProbeCmd.probeAckReply) { + this.peerProbeAnswered = true; + this.confirmPeerPath(); + } + } + + /** 直连已双向确认:媒体立刻切到 P2P(不等探测期跑完)。 */ + private confirmPeerPath(): void { + this.lastPeerMediaAt = this.timers.now(); + if (this.mediaPath === 'direct') return; + if (!this.peerDirect || this.peerDirect.port <= 0) return; + this.mediaPath = 'direct'; + this.log?.info('P2P 直连已确认,媒体切到直连', { + address: this.peerDirect.address, + port: this.peerDirect.port, + }); } /** @@ -1297,14 +1563,19 @@ export class CallSession { this.probeSince = this.timers.now(); this.log?.info('媒体路径切换', { path: 'probing' }); } + // 对端已经在打我们了,我们也要跟上(否则对方收不到回程应答,不敢切直连)。 + this.startPeerProbeLoop(); } private handleAuthRsp(): void { this.authAcked = true; this.clearGcsRetry(); if (this.gcs) CallSession.goodGcsIp = this.gcs.ip; - // 认证成功即开始中继保活(中继注册先于认证发出去,服务端会缓存)。 - if (!this.relayTimer) this.startRelayKeepalive(); + // relay token 通常在后续 bt=9 才下发;拿到 token 后再注册并开始保活。 + if (this.authToken > 0) { + this.sendRelayGetIn(); + if (!this.relayTimer) this.startRelayKeepalive(); + } this.log?.debug('GCS 认证成功', { roomId: this.room?.roomId.toString() }); } @@ -1389,6 +1660,8 @@ export class CallSession { this.startAudioLoop(); this.startVideoWatchdog(); this.startMediaWatchdog(); + // 把自己的 P2P 端口公告出去 —— 不发这条,对端永远不知道往哪打洞。 + this.sendPeerJoinReport(); // QQ 的默认行为:**视频通话接通时双方摄像头自动打开**(对方可以选择 // 语音接听,但那不影响我们这边开摄像头)。语音通话则保持关闭。 // @@ -1840,11 +2113,36 @@ export class CallSession { this.videoFrameIndex++; } - private handleRelay(data: Uint8Array): void { + /** + * `0x02` 分流。 + * + * - `[4] = 1`:P2P 打洞握手(cmd 0–3),走 {@link handlePeerChannel}; + * - `[4] = 0`:服务端通道 —— GET_IN 应答(cmd=2)/ 对端路径下发(cmd=3)。 + * + * 旧实现只看 `[5:7]` 的 cmd 且只打日志,P2P 通道的包全被当成服务端包丢掉了: + * 对端的打洞探测没人应答,对方就一直不敢切直连。 + */ + private handleRelay(data: Uint8Array, from: { address: string; port: number }): void { if (data.length < 7) return; + if (data[4] === RELAY_CHANNEL.peer) { + this.handlePeerChannel(data, from); + return; + } const cmd = (data[5]! << 8) | data[6]!; // GET_IN 的应答(cmd=2)说明中继通道注册成功。 - if (cmd === 2) this.log?.debug('中继通道已注册'); + if (cmd === RelayCmd.getInAck) { + this.log?.debug('中继通道已注册'); + } else if (cmd === RelayCmd.peerPathInfo) { + const body = data.subarray(RELAY_HEADER_LEN); + if (body.length >= 24) { + const address = `${body[0]}.${body[1]}.${body[2]}.${body[3]}`; + const primaryPort = (body[6]! << 8) | body[7]!; + const directPort = (body[22]! << 8) | body[23]!; + const port = directPort > 0 ? directPort : primaryPort; + this.log?.info('中继下发对端路径', { address, primaryPort, directPort }); + if (port > 0) this.applyPeerEndpoint(address, port); + } + } } // ── 超时 ── @@ -1987,6 +2285,7 @@ export class CallSession { for (const [handle, kind] of pairs) { if (handle !== null) this.timers[kind](handle); } + this.stopPeerProbeLoop(); this.heartbeatTimer = null; this.statusTimer = null; this.qfbTimer = null; diff --git a/packages/service/test/call_session.test.ts b/packages/service/test/call_session.test.ts index 546b76aeb..6a59dc7f7 100644 --- a/packages/service/test/call_session.test.ts +++ b/packages/service/test/call_session.test.ts @@ -207,7 +207,7 @@ function authRspPacket(sessionTs = 999): Uint8Array { // ─────────────────────────── 用例 ─────────────────────────── describe('通话会话状态机', () => { - it('发起后先发认证、中继注册与编码器初始化', async () => { + it('发起后先发认证与编码器初始化,认证成功后再注册中继', async () => { const { session, transport } = makeSession(); await session.start(); @@ -215,6 +215,9 @@ describe('通话会话状态机', () => { expect(transport.sent.length).toBeGreaterThan(0); // 认证包发两次 expect(transport.gcs(GcsBodyType.authReq)).toHaveLength(2); + // 中继注册要等认证成功,不能抢跑 + expect(transport.sent.some((s) => s.data[0] === 0x02)).toBe(false); + transport.deliver(authRspPacket()); // 中继注册(首字节 0x02) expect(transport.sent.some((s) => s.data[0] === 0x02)).toBe(true); // 编码器初始化帧(0x5b, subType=1, seq=0) diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index bd1e34835..ee555719c 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -25,7 +25,11 @@ import { CallEvent, GCS_STX, GcsBodyType, + PeerProbeCmd, + RELAY_STX, type CallServer, + buildPeerProbe, + parsePeerProbe, VIDEO_CTRL_SUB_TYPE, VIDEO_EXT_LEN, VIDEO_EXT_TYPE, @@ -135,9 +139,14 @@ class FakeTimers implements CallTimers { class FakeTransport implements UdpTransport { readonly sent: Array<{ data: Uint8Array; port: number; address: string }> = []; closed = false; + /** 模拟本端 UDP 端口(`bt=25` 要公告它)。 */ + port = 51_348; private messageHandler?: (data: Uint8Array, from: { address: string; port: number }) => void; async bind(): Promise {} + localPort(): number { + return this.port; + } send(data: Uint8Array, port: number, address: string): void { this.sent.push({ data, port, address }); } @@ -288,6 +297,11 @@ function authRspPacket(sessionTs = 999): Uint8Array { * 默认值取自 2026-10-26 同一次通话的抓包:`f7` = 对端 IPv6,`f8` = 56082。 */ function ctrlPushPacket(peerIpv6: Uint8Array, port: number): Uint8Array { + return ctrlPushWithBody(new Uint8Array([...pbBytes(7, peerIpv6), ...pbVarint(8, port)])); +} + +/** 自定 body 的 `bt=9`(给「只有 IPv4 候选」这类形态用)。 */ +function ctrlPushWithBody(body: Uint8Array): Uint8Array { const head = buildGcsHead({ roomId: 777n, selfUin: 1707889225, @@ -295,7 +309,30 @@ function ctrlPushPacket(peerIpv6: Uint8Array, port: number): Uint8Array { typeId: 0x6242, bodyType: GcsBodyType.ctrlPush, }); - const body = new Uint8Array([...pbBytes(7, peerIpv6), ...pbVarint(8, port)]); + return buildGcsPacket(head, body); +} + +/** + * `bt=27`(peerJoin)—— 服务端告知对端的真实 P2P 端口。 + * + * 黄金样本:2026-10-26 抓包 `08 d18da7d50a 10 c9a4b1ae06 18 02 22 08 d10700000000 db19`。 + */ +function peerJoinPacket(port: number): Uint8Array { + const head = buildGcsHead({ + roomId: 777n, + selfUin: 1707889225, + sessionTs: 555, + typeId: 0x6243, + bodyType: GcsBodyType.peerJoin, + }); + const endpoint = new Uint8Array(8); + endpoint.set([0xd1, 0x07, 0, 0, 0, 0, (port >> 8) & 0xff, port & 0xff], 0); + const body = new Uint8Array([ + ...pbVarint(1, 2863253201), + ...pbVarint(2, 1707889225), + ...pbVarint(3, 2), + ...pbBytes(4, endpoint), + ]); return buildGcsPacket(head, body); } @@ -863,7 +900,8 @@ describe('媒体路径(中继 / 直连)', () => { await connect(ctx); const peerIpv6 = new Uint8Array([ - 0x24, 0x0e, 0x03, 0x99, 0x0f, 0x9d, 0x3f, 0x10, 0x78, 0x73, 0xe7, 0x0f, 0x57, 0x7e, 0x60, 0xc5, + 0x24, 0x0e, 0x03, 0x99, 0x0f, 0x9d, 0x3f, 0x10, 0x78, 0x73, 0xe7, 0x0f, 0x57, 0x7e, 0x60, + 0xc5, ]); ctx.transport.deliver(ctrlPushPacket(peerIpv6, 56082)); ctx.transport.reset(); @@ -954,3 +992,180 @@ describe('媒体路径(中继 / 直连)', () => { expect(ctx.transport.mediaTo(PEER_IP, VideoSubType.max).length).toBeGreaterThan(0); }); }); + +/** + * P2P 端点交换与打洞握手。 + * + * 2026-10-26 同一次通话的两份抓包(QQ 本体 P2P 正常 vs WeQ 全程中继)对比出来的 + * 三处缺口,全部在下面覆盖: + * + * 1. WeQ 从不发 `bt=25`(公告本端 P2P 端口)→ 对端永远不知道往哪打洞; + * 2. WeQ 不解析 `bt=27`(对端真实端口)→ 自己也不会打洞; + * 3. WeQ 不应答 `0x02 ch=1` 握手 → 对端不敢切直连,媒体只能全程中继。 + */ +describe('P2P 端点交换(bt=25 / bt=27)', () => { + const SERVER_IP = '113.250.57.28'; + const PEER_IP = '240e:399:f9d:3f10:7873:e70f:577e:60c5'; + const peerIpv6 = new Uint8Array([ + 0x24, 0x0e, 0x03, 0x99, 0x0f, 0x9d, 0x3f, 0x10, 0x78, 0x73, 0xe7, 0x0f, 0x57, 0x7e, 0x60, 0xc5, + ]); + + function joinReports(ctx: ReturnType): Uint8Array[] { + return ctx.transport.sent + .filter( + (s) => + s.data[0] === GCS_STX && parseGcsPacket(s.data)?.bodyType === GcsBodyType.peerJoinReport, + ) + .map((s) => s.data); + } + + it('接通后公告 bt=25,端口就是本端 UDP 端口', async () => { + const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(10) }); + await connect(ctx); + + const reports = joinReports(ctx); + expect(reports.length).toBeGreaterThanOrEqual(1); + const body = parseGcsPacket(reports[0]!)!.body; + // 固定前 6 字节 + 末两字节的大端端口(抓包:d10700000000 c894)。 + expect([...body.subarray(body.length - 8, body.length - 2)]).toEqual([0xd1, 0x07, 0, 0, 0, 0]); + expect((body[body.length - 2]! << 8) | body[body.length - 1]!).toBe(ctx.transport.port); + }); + + it('未接通时不会公告(房间还没就绪)', async () => { + const ctx = makeSession({ media: 'video' }); + await ctx.session.start(); + expect(joinReports(ctx)).toHaveLength(0); + }); + + it('bt=27 的端口优先于 bt=9 的近似 f8', async () => { + const ctx = makeSession({ + media: 'video', + videoEncoder: new FakeVideoEncoder(1000), + servers: [{ ip: SERVER_IP, port: 8000, type: 1 }], + }); + await connect(ctx); + + // bt=9 给近似端口 56082,bt=27 给权威端口 56089(实测值)。 + ctx.transport.deliver(ctrlPushPacket(peerIpv6, 56082)); + ctx.transport.deliver(peerJoinPacket(56089)); + ctx.transport.reset(); + await ctx.timers.advanceAsync(2100); + + const probes = ctx.transport.sent.filter( + (s) => s.data[0] === RELAY_STX && s.address === PEER_IP, + ); + expect(probes.length).toBeGreaterThan(0); + expect(probes.every((p) => p.port === 56089)).toBe(true); + }); + + it('只有 IPv4 候选(f5/f6)时也能学到对端', async () => { + const ctx = makeSession({ + media: 'video', + videoEncoder: new FakeVideoEncoder(1000), + servers: [{ ip: SERVER_IP, port: 8000, type: 1 }], + }); + await connect(ctx); + + // 抓包里 WeQ 实际收到的那种形态:只有 f5(fixed32 IPv4)+ f6(端口)。 + const body = new Uint8Array([(5 << 3) | 5, 0xcb, 0xf6, 0xb0, 0x50, ...pbVarint(6, 8889)]); + ctx.transport.deliver(ctrlPushWithBody(body)); + ctx.transport.reset(); + await ctx.timers.advanceAsync(20); + + const probes = ctx.transport.sent.filter((s) => s.address === '203.246.176.80'); + expect(probes.length).toBeGreaterThan(0); + expect(probes.every((p) => p.port === 8889)).toBe(true); + }); +}); + +describe('P2P 打洞握手(0x02 ch=1)', () => { + const PEER_IP = '240e:399:f9d:3f10:7873:e70f:577e:60c5'; + const SERVER_IP = '113.250.57.28'; + + function probeFromPeer(cmd: number, seq = 5): Uint8Array { + return buildPeerProbe({ + roomId: 777n, + cmd, + uinAbout: 2863253201, + seq, + otherUin: 1707889225, + token: 1234, + clock: 0x74d7e43a, + peerClock: 0, + }); + } + + it('收到对端 probe 后应答 cmd1(回抄字段、换上自己的时钟)', async () => { + const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(1000) }); + await connect(ctx); + ctx.transport.reset(); + + const packet = probeFromPeer(PeerProbeCmd.probe); + ctx.transport.deliverFrom(packet, { address: PEER_IP, port: 56089 }); + + // 除了应答,我们同时也会开始向对端打洞(probe),所以按 cmd 筛。 + const replies = ctx.transport.sent.filter( + (s) => + s.data[0] === RELAY_STX && + s.address === PEER_IP && + parsePeerProbe(s.data)?.cmd === PeerProbeCmd.probeReply, + ); + expect(replies).toHaveLength(1); + const parsed = parsePeerProbe(replies[0]!.data)!; + // 回抄的是「关于对端」的那一份 + expect(parsed.uinAbout).toBe(2863253201); + expect(parsed.seq).toBe(5); + expect(parsed.token).toBe(1234); + expect(parsed.clock).toBe(0x74d7e43a); + expect(replies[0]!.port).toBe(56089); + }); + + it('收到对端 probeAck(cmd2)后应答 cmd3', async () => { + const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(1000) }); + await connect(ctx); + ctx.transport.reset(); + + ctx.transport.deliverFrom(probeFromPeer(PeerProbeCmd.probeAck, 6), { + address: PEER_IP, + port: 56089, + }); + + const replies = ctx.transport.sent.filter( + (s) => + s.data[0] === RELAY_STX && + s.address === PEER_IP && + parsePeerProbe(s.data)?.cmd === PeerProbeCmd.probeAckReply, + ); + expect(replies).toHaveLength(1); + }); + + it('对端应答我们的 probe 后,媒体立即切直连(不再发服务器)', async () => { + const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(1000) }); + await connect(ctx); + ctx.transport.deliverFrom(probeFromPeer(PeerProbeCmd.probe), { + address: PEER_IP, + port: 56089, + }); + ctx.transport.reset(); + + // 对端回了我们 probe 的应答(cmd1)。 + ctx.transport.deliverFrom( + buildPeerProbe({ + roomId: 777n, + cmd: PeerProbeCmd.probeReply, + uinAbout: 1707889225, + seq: 7, + otherUin: 2863253201, + token: 1234, + clock: 1, + peerClock: 2, + }), + { address: PEER_IP, port: 56089 }, + ); + ctx.transport.reset(); + await ctx.timers.advanceAsync(50); + + expect(ctx.transport.mediaTo(SERVER_IP, VideoSubType.max)).toHaveLength(0); + expect(ctx.transport.mediaTo(PEER_IP, VideoSubType.max).length).toBeGreaterThan(0); + }); +}); From dc6b9e232d3174a8f8de290ffe1a14b88ea4ce6f Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Mon, 26 Oct 2026 16:39:47 +0800 Subject: [PATCH 10/23] =?UTF-8?q?fix(call):=20=E4=BF=AE=E5=A4=8D=E5=81=87?= =?UTF-8?q?=E7=9B=B4=E8=BF=9E=E4=B8=8E=E7=A0=81=E7=8E=87=E8=BF=87=E9=AB=98?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=20QQ=20=E6=9C=AC=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2P 路径(call_session.ts): - 中继 cmd=3 下发的地址命中节点自身时不再当作对端端点。旧实现直接 applyPeerEndpoint,把中继节点当对端,媒体离开 GCS 进入零回程黑洞 (实测 515+1087+125 个包全部无回包)。 - 补 cmd=4 应答(RelayCmd.peerPathAck)。真机收到 cmd=3 会立刻回 ACK, 我们从不回,中继每秒重发直到挂断。 - rememberPeerSource 排除中继节点(此前只排 GCS),否则中继回包刷新 「直连还活着」,看门狗永不回落。 - applyPeerEndpoint 新增 provisional 语义:候选地址只发打洞探测,不改 mediaPath。bt=9 的 IPv4 f5/f6 归为候选,等回程证据再切。 - 探测期满无回程时退回 server 并清端口,而不是无条件切 direct。 码率(call_session.ts / call_video_encoder.ts): - 400 → 30 kbps。2026-10-26 同一通通话实测:QQ 本体上行 6–26 kbps、 每帧 p50 73B、1.0 个分片;WeQ 是 351 kbps、3368B、3.4 个分片(最大 28 片),丢一片即整帧报废。 解码器(useCallVideo.ts): - 加 buildingDecoder 互斥。ensureDecoder 内有 await isConfigSupported, 期间后续帧 onData 并发进入会重复重建,第二次重建 close() 掉第一次刚 建好的解码器,表现为「只解出第一帧」。 未验证:色差根因(SPS 的 VUI 缺 video_signal_type,但注入 BT.709 后 离线解码无变化);「永远等待对方画面」待确认是否与渲染层代码加载有关。 --- apps/desktop/src/main/call_video_encoder.ts | 4 +- .../src/renderer/src/hooks/useCallVideo.ts | 28 ++- packages/protocol/src/call/gcs.ts | 10 +- packages/protocol/src/call/index.ts | 1 + packages/service/src/account/call_session.ts | 215 ++++++++++++++++-- 5 files changed, 232 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/call_video_encoder.ts b/apps/desktop/src/main/call_video_encoder.ts index 6219b9f65..be279aa01 100644 --- a/apps/desktop/src/main/call_video_encoder.ts +++ b/apps/desktop/src/main/call_video_encoder.ts @@ -68,7 +68,9 @@ export class NativeVideoEncoder implements CallVideoEncoder { this.width = options.width ?? 640; this.height = options.height ?? 360; this.fps = options.fps ?? 15; - this.bitrateKbps = options.bitrateKbps ?? 400; + // 30 kbps:与 QQ 本体对齐(实测静态 6 kbps、运动峰值 26 kbps)。 + // 旧的 400 kbps 会让每帧涨到 3KB+、拆成 3~28 个 UDP 包,丢一片整帧报废。 + this.bitrateKbps = options.bitrateKbps ?? 30; } private readonly fps: number; diff --git a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts index 37206fa36..6f2c9e268 100644 --- a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts +++ b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts @@ -105,6 +105,14 @@ export function useCallVideo(options: { const decodeFailures = useRef(0); /** 收到的帧数(只用于前几帧的排查日志)。 */ const frameCount = useRef(0); + /** + * 是否正在异步建解码器。 + * + * `ensureDecoder` 里有 `await VideoDecoder.isConfigSupported`,期间后续帧的 + * `onData` 会并发进来并重复触发重建 —— 第二次重建会 `close()` 掉第一次刚建 + * 好的解码器,导致「只解出第一帧」。见 `onData` 里的详细说明。 + */ + const buildingDecoder = useRef(false); /** 建 / 重建解码器。 */ const ensureDecoder = useCallback( @@ -218,7 +226,25 @@ export function useCallVideo(options: { const rebuilt = !existing || existing.state === 'closed'; let decoder = existing; if (rebuilt) { - decoder = await ensureDecoder(frame.info?.width ?? 640, frame.info?.height ?? 360); + // ⚠️ **必须互斥。** `onData` 是同步回调,但这里是 async 函数 —— 帧 N + // 在 `await ensureDecoder()` 期间,帧 N+1 的 `onData` 已经进来了, + // 它看到的 `decoderRef.current` 仍是 null,于是也走 `rebuilt` 分支, + // 再次 `await ensureDecoder()`。而 `ensureDecoder` 第一件事就是 + // `decoderRef.current?.close()` —— **它会把帧 N 刚建好的解码器关掉**。 + // + // 结果:帧 N 的 `decode()` 落到一个已关闭的解码器上(或被丢弃), + // 帧 N+1 建的新解码器又没收到关键帧(`configured=false` + 后续全是 + // delta 帧),此后每一帧都在 `if (!configured.current) return` 处被 + // 丢掉 —— 表现就是「解出第一帧,画面永久停住」。 + // + // 用一个在建标记挡住并发重建:谁先拿到谁建,其余帧直接返回等下一帧。 + if (buildingDecoder.current) return; + buildingDecoder.current = true; + try { + decoder = await ensureDecoder(frame.info?.width ?? 640, frame.info?.height ?? 360); + } finally { + buildingDecoder.current = false; + } if (!decoder) return; configured.current = false; // 一直挂到真的喂出一帧为止 —— 重建点落在 delta 帧上时,那帧会被下面 diff --git a/packages/protocol/src/call/gcs.ts b/packages/protocol/src/call/gcs.ts index 866a2830e..f02a27cd2 100644 --- a/packages/protocol/src/call/gcs.ts +++ b/packages/protocol/src/call/gcs.ts @@ -693,6 +693,14 @@ export const RelayCmd = { getIn: 1, getInAck: 2, peerPathInfo: 3, + /** + * `cmd=3`(对端路径下发)的应答。 + * + * 2026-10-26 QQ 本体抓包:收到 `cmd=3` 立刻回一条 `cmd=4`(56 字节, + * body 16 字节全 0)。WeQ 之前从不回,中继就把 `cmd=3` 每秒重发一遍, + * 一直到挂断 —— 表现为「中继协商永远没完成」。 + */ + peerPathAck: 4, keepalive: 0x0b, } as const; @@ -705,7 +713,7 @@ export const RelayCmd = { */ export const RELAY_CAPABILITY_TRIPLE = Uint8Array.from([0x02, 0x0f, 0x27, 0x4f]); -function buildRelayPacket(params: { +export function buildRelayPacket(params: { roomId: bigint; selfUin: number; /** 本端的控制包序号(中继通道内恒定,P2P 探测从它往后递增)。 */ diff --git a/packages/protocol/src/call/index.ts b/packages/protocol/src/call/index.ts index 4c6581569..a6f3476f4 100644 --- a/packages/protocol/src/call/index.ts +++ b/packages/protocol/src/call/index.ts @@ -104,6 +104,7 @@ export { RELAY_CAPABILITY_TRIPLE, RelayCmd, buildRelayGetIn, + buildRelayPacket, buildRelayKeepalive, PEER_PACKET_LEN, PeerProbeCmd, diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index d635d0a25..f99e3f4dc 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -51,6 +51,7 @@ import { buildQfbResponse, buildRelayGetIn, buildRelayKeepalive, + buildRelayPacket, buildStatusReport, buildVideoControl, buildVideoKeepalive, @@ -316,10 +317,33 @@ const VIDEO_KEEPALIVE_MS = 2000; * 房间级心跳,停发会被对端当成关摄像头。 */ const VIDEO_PARAM_PUSH_MS = 2000; -/** 编码帧率缺省值(与 t265 默认配置一致)。 */ +/** + * 编码帧率缺省值。 + * + * 2026-10-26 抓包实测 QQ 本体:上行 11.8 fps、下行 14.9 fps。取 15 与下行一致 + * (对端按这个节奏发,我们跟上就不会显得卡)。 + */ const DEFAULT_VIDEO_FPS = 15; -/** 编码码率缺省值(kbps)。 */ -const DEFAULT_VIDEO_BITRATE_KBPS = 400; +/** + * 编码码率缺省值(kbps)。 + * + * ⚠️ **这是「卡顿」和「对端收不到画面」的主因之一。** + * + * 2026-10-26 同一通通话对比(都是 320×184): + * + * | | QQ 本体 | WeQ(旧值 400) | + * | --- | --- | --- | + * | 上行码率 | 6–26 kbps(静态 6、运动峰值 26) | **351 kbps** | + * | 每帧字节 p50 | 73 B | **3368 B** | + * | 每帧分片 | 1.0 | 3.4(最大 28) | + * + * QQ 的实际策略是「低码率 + 按需涨」:静止画面 6 kbps,有运动才到 26 kbps。 + * 我们写死 400 kbps,编码器会把这个预算全部用掉 —— 一帧 3KB 拆成 3~28 个 UDP + * 包,任何一片丢失整帧报废,中继路径上尤其明显。 + * + * 取 30 kbps 对齐 QQ 的运动峰值;ABR 在静止画面下会自动降得更低。 + */ +const DEFAULT_VIDEO_BITRATE_KBPS = 30; /** 同一拍里第二条保活包相对第一条的延迟(实测 40–90 ms)。 */ const VIDEO_KEEPALIVE_SECOND_MS = 60; /** 视频发送轮询间隔:比最慢的帧间隔(10 fps)小一个量级。 */ @@ -600,14 +624,27 @@ export class CallSession { * 媒体走哪条路。 * * 真实客户端不是双发,而是**先纯中继、后硬切 P2P**(实测 0–7.9 秒只发服务端, - * 之后只发对端直连,服务端一个包都收不到)。我们沿用这个顺序,但把切换点 - * 放在「真的学到对端端口」之后,中间留一小段双发探测期兜底。 + * 之后只发对端直连,服务端一个包都收不到)。我们沿用这个顺序,但切换点必须 + * 由**回程证据**决定,不能由「拿到了一个地址」决定 —— 见 + * {@link confirmPeerPath} 与 {@link applyPeerEndpoint}。 */ private mediaPath: MediaPath = 'server'; /** 进入 `probing` 的时刻(毫秒)。 */ private probeSince = 0; /** 最近一次收到对端媒体包的时刻(毫秒),直连回落用。 */ private lastPeerMediaAt = 0; + /** 已见过的媒体子类型(每个只记一次日志,避免逐包刷屏)。 */ + private readonly seenMediaSubTypes = new Set(); + /** 视频参数是否已用 info 记过一次(后续周期公告降到 debug)。 */ + private videoParamAnnounced = false; + /** + * 进入 `probing` 的时刻,用于「探测期满仍无回程」的强制回落。 + * + * 与 {@link probeSince} 分开:`probeSince` 决定什么时候从 probing 推进, + * 这里只用来兜底判死。两者同源但语义不同,合在一起会让「重置探测期」 + * 把兜底计时也一起清掉。 + */ + private probeDeadlineAt = 0; /** 媒体路径看门狗。 */ private mediaWatchdogTimer: unknown = null; /** 已发出的视频帧数。 */ @@ -911,9 +948,41 @@ export class CallSession { // probing → direct 的推进放在这里惰性做:音频 20 ms 一帧、保活 2 秒一条都会 // 走到这条路径,摄像头关着也不会卡在 probing。 + // + // ⚠️ **探测期满不等于直连可用。** 旧实现到期就无条件切 `direct`,只要拿到 + // 一个「看起来像对端」的地址(中继下发的、bt=9 的 IPv4 近似值……)就切, + // 切完服务端一个包都收不到,而那个地址可能根本不回包 —— 2026-10-26 实测 + // 三个候选(中继:19341 / 11.149.189.80:8889 / 中继:19338)全是零回程, + // 对端于是完全收不到我们的流,报「对方网络质量不佳」。 + // + // 现在的判据是**回程证据**({@link confirmPeerPath} 置位): + // 探测期满仍无回程 → 直接放弃这一轮,退回 `server` 并清掉端口等重新学习。 if (this.mediaPath === 'probing' && this.timers.now() - this.probeSince >= MEDIA_PROBE_MS) { - this.mediaPath = hasPeer ? 'direct' : 'server'; - this.log?.info('媒体路径切换', { path: this.mediaPath }); + if (hasPeer && this.peerProbeAnswered) { + this.mediaPath = 'direct'; + this.log?.info('媒体路径切换', { path: this.mediaPath }); + } else { + this.log?.warn('探测期满无直连回程,保持中继', { + address: peer?.address, + port: peer?.port, + answered: this.peerProbeAnswered, + }); + this.mediaPath = 'server'; + this.probeDeadlineAt = 0; + // 端口归零、地址留着:等对端下次真的发包过来再重新学。 + if (this.peerDirect) this.peerDirect = { address: this.peerDirect.address, port: 0 }; + this.lastPeerFrom = null; + } + } + // 兜底:探测期被反复重置(对端零星发包)时,超过 3 倍探测期仍无回程也判死。 + if (this.mediaPath === 'probing' && this.probeDeadlineAt > 0) { + if (this.timers.now() - this.probeDeadlineAt >= MEDIA_PROBE_MS * 3 && !this.peerProbeAnswered) { + this.log?.warn('直连探测超时,放弃本轮 P2P', { address: peer?.address }); + this.mediaPath = 'server'; + this.probeDeadlineAt = 0; + if (this.peerDirect) this.peerDirect = { address: this.peerDirect.address, port: 0 }; + this.lastPeerFrom = null; + } } const wantServer = this.mediaPath !== 'direct'; @@ -1379,7 +1448,15 @@ export class CallSession { const announced = best.ipv6 ? this.peerAnnouncedPort : null; const port = learned > 0 ? learned : (announced ?? best.port); this.log?.info('拿到对端候选端点', { address: best.address, port, candidates }); - this.applyPeerEndpoint(best.address, port); + // 可信度分级: + // - 端口是从真实流量里学到的(learned)→ 对端确实从这儿发过包,可信; + // - IPv6 + bt=27 的权威端口 → 可信; + // - 其余(尤其 IPv4 的 f5/f6 近似值)→ 只是候选,必须等回程。 + // + // 2026-10-26 实测:IPv4 候选 `11.149.189.80:8889` 发了 515 个媒体包, + // **零回包**,旧实现却因为「拿到地址」就切了 direct,媒体从此离开 GCS。 + const trustworthy = learned > 0 || (best.ipv6 && announced !== null); + this.applyPeerEndpoint(best.address, port, { provisional: !trustworthy }); } /** @@ -1405,28 +1482,45 @@ export class CallSession { * * 这是「知道对端在哪」到「真的把路打通」之间的一步:探索包一发,对端就会回 * `0x02` 应答,两端几乎同时切到直连(实测 QQ 从拿到地址到媒体直连 <200 ms)。 + * + * **拿到地址 ≠ 直连可用。** 这里的两种来源可信度差很多: + * + * - `bt=27` 给的端口是服务端确认过的对端 P2P 端口,可以直接进 `probing`; + * - 中继路径下发(`cmd=3`)、`bt=9` 的 IPv4 `f5/f6` 只是「候选」, + * 2026-10-26 实测这些地址可能根本是黑洞(发出去 515 包、0 回包)。 + * + * 所以 `provisional` 的候选**不改变 `mediaPath`**,只用来发打洞探测包; + * 只有真收到回程({@link confirmPeerPath})或 `bt=27` 才允许进 `probing`。 */ - private applyPeerEndpoint(address: string, port: number): void { + private applyPeerEndpoint( + address: string, + port: number, + opts: { provisional?: boolean } = {}, + ): void { const normalizedPort = port > 0 && port < 65536 ? port : 0; if (this.peerDirect?.address === address && this.peerDirect.port === normalizedPort) return; this.peerDirect = { address, port: normalizedPort }; if (normalizedPort === 0) return; - if (this.mediaPath === 'server') { + if (!opts.provisional && this.mediaPath === 'server') { this.mediaPath = 'probing'; this.probeSince = this.timers.now(); + this.probeDeadlineAt = this.probeSince; + this.peerProbeAnswered = false; this.log?.info('媒体路径切换', { path: 'probing' }); } + // 无论可信与否,都去打洞探测 —— 这是拿到回程证据的唯一途径。 this.startPeerProbeLoop(); } /** * 公告本端 P2P 端点(`bt=25`)。 * - * 不发这条,服务端就不知道我们的 P2P 端口,对端也就永远拿不到我们的端点 —— - * 这正是「对端等很久才收到 WeQ 画面」+「全程中继」的根因(2026-10-26 抓包: - * QQ 发了 `bt=25`/`bt=27`,WeQ 一条都没有)。 - * * 最多发 `max` 条:接通时一条,对端进房(收到 `bt=9`)后再补一条。 + * + * ⚠️ **不要靠重发 `bt=25` 去催 `bt=27`。** 2026-10-26 Linux QQ 抓包实测: + * `bt=27` 在 `bt=25` 之前 2.7ms 就到了(服务端主动推),两者是同一次协商的 + * 两个方向,不是「发了 25 才有 27」。拿不到 `bt=27` 是网络/服务端判定问题, + * 重发只会制造噪音。 */ private sendPeerJoinReport(max = 1): void { if (!this.room || !['calling', 'ringing', 'connecting', 'connected'].includes(this.state)) { @@ -1454,6 +1548,7 @@ export class CallSession { this.log?.info('公告本端 P2P 端点', { port: this.localPort }); } + /** 下一个控制包序号(中继 GET_IN 与 P2P 握手共用)。 */ private nextPeerSeq(): number { return ++this.peerPacketSeq; @@ -1519,9 +1614,17 @@ export class CallSession { } } - /** 直连已双向确认:媒体立刻切到 P2P(不等探测期跑完)。 */ + /** + * 直连已双向确认:媒体立刻切到 P2P(不等探测期跑完)。 + * + * 这是**唯一**能让 `mediaPath` 变成 `direct` 的路径之一(另一处是 `bt=27` + * 给的权威端口 + 探测期)。没有这一步,任何「拿到地址就切」都会把媒体推进 + * 黑洞 —— 见 {@link mediaTargets} 的注释。 + */ private confirmPeerPath(): void { this.lastPeerMediaAt = this.timers.now(); + this.peerProbeAnswered = true; + this.probeDeadlineAt = 0; if (this.mediaPath === 'direct') return; if (!this.peerDirect || this.peerDirect.port <= 0) return; this.mediaPath = 'direct'; @@ -1547,9 +1650,16 @@ export class CallSession { // 回包被当成「对端直连地址」,媒体路径切到 direct,实际仍发往服务器(同 IP // 同端口),于是全程走中继、`bt=9` 也永远拿不到对端的 f7(IPv6),表现为 // 两端画面都极卡。 + // + // ⚠️ **中继节点也必须排除**。经中继转发的媒体包,源地址是中继自己,不是 + // 对端 —— 旧实现只排除了 GCS,于是中继的包既刷新了「直连还活着」的时间戳 + // (看门狗因此永远不回落),又把 `peerDirect` 改写成中继地址。2026-10-26 + // WeQ 抓包:整通 `sent 2478 / received 2389` 全在一条中继通道上,看门狗 + // 一次都没触发。 const isServer = - this.gcs !== null && sameHost(this.gcs.ip, from.address) && this.gcs.port === from.port; - // 只有绕过服务端的包才刷新「直连还活着」的时间戳 —— 被中继的包说明直连不通。 + (this.gcs !== null && sameHost(this.gcs.ip, from.address) && this.gcs.port === from.port) || + this.relays.some((r) => sameHost(r.ip, from.address) && r.port === from.port); + // 只有绕过服务端(和中继)的包才算「直连还活着」。 if (!isServer) this.lastPeerMediaAt = this.timers.now(); if (this.lastPeerFrom?.address === from.address && this.lastPeerFrom?.port === from.port) return; @@ -1558,9 +1668,12 @@ export class CallSession { this.peerDirect = { address: from.address, port: from.port }; this.log?.info('学到对端直连地址', { address: from.address, port: from.port }); // 首次学到端口:进入双发探测期。已经是 direct 时不动(回落由看门狗管)。 + // 这里的端口是**对端真的从这个地址发过包**得来的,可信,可以直接进 probing。 if (this.mediaPath === 'server') { this.mediaPath = 'probing'; this.probeSince = this.timers.now(); + this.probeDeadlineAt = this.probeSince; + this.peerProbeAnswered = false; this.log?.info('媒体路径切换', { path: 'probing' }); } // 对端已经在打我们了,我们也要跟上(否则对方收不到回程应答,不敢切直连)。 @@ -1693,9 +1806,11 @@ export class CallSession { const header = parseAudioHeader(data); if (!header) return; this.lastPeerTick = header.tick; - // 0x5b 的每个子类型都记一笔:排查「对端到底发没发」时这是唯一线索。 - if (header.subType !== AudioSubType.audio) { - this.log?.info('收到非音频媒体包', { + // 每个子类型**首次**出现时记一笔就够了:排查「对端到底发没发」时这是唯一 + // 线索,但逐包记录会刷屏(一通视频通话上千条,实测 1014 条/通)。 + if (header.subType !== AudioSubType.audio && !this.seenMediaSubTypes.has(header.subType)) { + this.seenMediaSubTypes.add(header.subType); + this.log?.info('收到媒体包子类型', { subType: `0x${header.subType.toString(16)}`, len: data.length, from: 'peer', @@ -1891,7 +2006,13 @@ export class CallSession { bitrateKbps, }); this.send(packet); - this.log?.info('公告视频参数', { width, height, fps, bitrateKbps }); + // 每 2 秒一条的周期心跳,只在第一次用 info 记下来,其余降到 debug。 + if (!this.videoParamAnnounced) { + this.videoParamAnnounced = true; + this.log?.info('公告视频参数', { width, height, fps, bitrateKbps }); + } else { + this.log?.debug('公告视频参数', { width, height, fps, bitrateKbps }); + } } /** @@ -1913,6 +2034,8 @@ export class CallSession { // 地址留着(bt=9 给的是对的),端口归零,等对端下次发包重新学。 if (this.peerDirect) this.peerDirect = { address: this.peerDirect.address, port: 0 }; this.lastPeerFrom = null; + this.peerProbeAnswered = false; + this.probeDeadlineAt = 0; }, 1000); } @@ -2139,12 +2262,58 @@ export class CallSession { const primaryPort = (body[6]! << 8) | body[7]!; const directPort = (body[22]! << 8) | body[23]!; const port = directPort > 0 ? directPort : primaryPort; - this.log?.info('中继下发对端路径', { address, primaryPort, directPort }); - if (port > 0) this.applyPeerEndpoint(address, port); + // 回 ACK(cmd=4)。真实客户端收到路径下发就应答,不回的话中继会每秒重发 + // 同一条(2026-10-26 WeQ 抓包:cmd=3 从 64 秒一直刷到挂断,cmd=4 一条 + // 都没有)。应答走「来的那个中继节点」原路返回。 + this.sendRelayPeerPathAck(from); + // ⚠️ **这条下发的是「对端在哪条中继通道上」,不是对端直连地址。** + // 旧实现直接 `applyPeerEndpoint(address, port)`,把中继节点自己当成了 + // 对端:媒体从 GCS 挪到中继、`mediaPath` 变成 `direct`,于是「以为直连 + // 了,其实还在中继」—— 和文档 §7.1.2 第 2 个坑同构,只是这次来源是 + // 中继推送而不是服务器回包。 + // + // 判据:地址若是中继节点自己(或就是 GCS),一律只记日志。 + this.log?.info('中继下发对端路径', { address, primaryPort, directPort, from: from.address }); + const isRelaySelf = + (this.gcs !== null && sameHost(this.gcs.ip, address) && this.gcs.port === port) || + this.relays.some((r) => sameHost(r.ip, address) && r.port === port) || + this.relays.some((r) => sameHost(r.ip, address) && r.port === primaryPort) || + sameHost(from.address, address); + if (isRelaySelf) { + this.log?.info('中继路径是节点自身,不当作对端端点', { address, port }); + return; + } + // 即便不是节点自身,中继下发的也只是「中继侧入口」,仍然要等回程证据 + // 才允许切直连 —— 所以这里只把它作为候选交给探测,不改变 mediaPath。 + if (port > 0) this.applyPeerEndpoint(address, port, { provisional: true }); } } } + /** + * 回 `cmd=4`:确认收到中继的对端路径下发。 + * + * body 全 0(16 字节),和 `cmd=3` 的 24 字节 body 不同 —— 见 2026-10-26 + * QQ 本体抓包:`cmd=3` 84 字节、`cmd=4` 56 字节,差值正好是 body 长度。 + */ + private sendRelayPeerPathAck(to: { address: string; port: number }): void { + if (!this.room) return; + this.opts.transport.send( + buildRelayPacket({ + roomId: this.room.roomId, + selfUin: Number(this.opts.selfUin), + peerUin: Number(this.opts.peerUin), + seq: this.relaySeq, + token: this.authToken, + channel: RELAY_CHANNEL.server, + cmd: RelayCmd.peerPathAck, + body: new Uint8Array(16), + }), + to.port, + to.address, + ); + } + // ── 超时 ── private armRingTimeout(): void { From 061a3ad31e037c0a57cbbdb1b10e4cb6f537b2c6 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Mon, 26 Oct 2026 17:38:41 +0800 Subject: [PATCH 11/23] =?UTF-8?q?feat(call):=20=E6=B8=B2=E6=9F=93=E5=B1=82?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E9=93=BE=E8=B7=AF=E5=8F=AF=E8=A7=82=E6=B5=8B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「一直等对方画面」这类问题此前只能盲猜:视频解码全在渲染层,而渲染层的 `console.*` 不会落盘,主进程日志里只能看到「对端包收到了、帧也组装了」, 断点究竟在 IPC、订阅还是解码器上完全无从判断。 - 新增渲染层日志桥 `weq.debugLog` → `logs:renderer` IPC → 同一个 `getLogger()`。只接受显式调用,不转发 console 全量(避免 React 警告等 噪声灌进日志)。 - `useCallVideo` 给每个静默 return 补上原因:拆不出 AU、未配置等关键帧、 解码器在建中丢弃、状态不是 configured、decode() 抛错、解码器错误、 订阅出错。里程碑记「解码器已建立」「解出画面」(第 1 帧 + 每 100 帧)。 - 主进程侧记「推送对端视频帧给渲染层」(前 5 帧 + 每 300 帧):这一行有、 渲染层没有 = 订阅或 IPC 的问题;两边都没有 = 收包/组帧的问题。 - 编码器记「编码帧统计」「编码会话汇总」:帧大小 / 关键帧数 / 均值最大值。 码率这类配置改了到底有没有被 native 采纳,只能看实际帧大小。 顺带修一处日志 bug:`output` 回调里 `frame.close()` 之后才读 `displayWidth/displayHeight`,close() 会把 VideoFrame 所有属性重置为 0, 日志里因此全是「解出画面 width:0」。尺寸改为 close 之前读出,绘制逻辑不变。 验证:2026-10-26 真机通话日志显示链路完整走通 —— 收到关键帧 → 解码器建立 (hvc1.1.6.L63.B0, 320x184)→ 喂入(params:3)→ 持续出帧(n=1/100/200)。 --- apps/desktop/src/main/call.ts | 14 +++ apps/desktop/src/main/call_video_encoder.ts | 79 +++++++++++- apps/desktop/src/main/index.ts | 26 ++++ apps/desktop/src/preload/index.ts | 13 ++ apps/desktop/src/renderer/src/env.d.ts | 2 + .../src/renderer/src/hooks/useCallVideo.ts | 118 +++++++++++++++--- 6 files changed, 235 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index b66f8bacb..99d1b3a9b 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -260,6 +260,8 @@ export class CallManager extends EventEmitter { private videoEncoder: NativeVideoEncoder | null = null; /** 最近收到的一个关键帧(新订阅者补发用)。 */ private lastKeyframe: VideoFramePayload | null = null; + /** 已推给渲染层的对端视频帧数(只用于前几帧的排查日志)。 */ + private videoPushCount = 0; /** 当前这通电话的录音设置(发起时取一次,通话中改设置不影响进行中的录音)。 */ private recordingConfig: CallRecordingConfig = { mode: 'mixed', systemAudio: false, dir: null }; private recorder: CallRecorder | null = null; @@ -375,6 +377,17 @@ export class CallManager extends EventEmitter { // **之后**才建立的,而首个关键帧往往已经过去 —— 后面的 delta 帧没有 // 关键帧起解,画面永远出不来。新订阅先用这份补一帧。 if (frame.isKeyframe) this.lastKeyframe = frame; + // 前几帧记一笔:主进程侧「收到了并推给渲染层」是排查「等待对方画面」的 + // 分界线 —— 这里没有、渲染层也没有,说明问题在收包/组帧;这里有、渲染层 + // 没有,说明订阅没建立或 IPC 序列化失败。 + this.videoPushCount++; + if (this.videoPushCount <= 5 || this.videoPushCount % 300 === 0) { + logger.info('推送对端视频帧给渲染层', { + n: this.videoPushCount, + bytes: frame.hevc.length, + keyframe: frame.isKeyframe, + }); + } this.emit('event', { type: 'video', hevc: frame.hevc, @@ -677,6 +690,7 @@ export class CallManager extends EventEmitter { this.videoEncoder?.close(); this.videoEncoder = null; this.lastKeyframe = null; + this.videoPushCount = 0; this.session = null; this.transport = null; this.info = null; diff --git a/apps/desktop/src/main/call_video_encoder.ts b/apps/desktop/src/main/call_video_encoder.ts index be279aa01..e08dd356a 100644 --- a/apps/desktop/src/main/call_video_encoder.ts +++ b/apps/desktop/src/main/call_video_encoder.ts @@ -24,6 +24,37 @@ import { getLogger } from '@weq/service'; const logger = getLogger().child({ scope: 'call-video-encoder' }); +/** + * 扫一遍 Annex B 裸流里每个 NAL 的类型(只看起始码后的首字节)。 + * + * 编码器侧只需要知道「这帧里有没有 IRAP」,不值得为此引协议层的完整解析器 —— + * 那边是值导入 `@weq/protocol`,主进程没问题,但这里二十行就够。 + */ +function splitAnnexBNalTypes(data: Uint8Array): number[] { + const types: number[] = []; + let i = 0; + let start = -1; + while (i < data.length) { + const four = + i + 4 <= data.length && + data[i] === 0 && + data[i + 1] === 0 && + data[i + 2] === 0 && + data[i + 3] === 1; + const three = + !four && i + 3 <= data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 1; + if (four || three) { + if (start >= 0 && i > start) types.push((data[start]! >> 1) & 0x3f); + start = i + (four ? 4 : 3); + i = start; + continue; + } + i++; + } + if (start >= 0 && start < data.length) types.push((data[start]! >> 1) & 0x3f); + return types; +} + /** * 调试开关:把送进编码器的 I420 和编码结果落盘。 * @@ -152,10 +183,44 @@ export class NativeVideoEncoder implements CallVideoEncoder { ); // 编码器还在 lookahead 里攒帧时是 null,下一拍再取。 if (!result) return null; - this.dumpAnnexB(new Uint8Array(result.data)); - return new Uint8Array(result.data); + const out = new Uint8Array(result.data); + this.dumpAnnexB(out); + this.noteEncoded(out); + return out; + } + + /** + * 记编码输出的尺寸分布(每 30 帧一条)。 + * + * 「把码率从 400 调到 30 kbps」这类改动到底生效没有,只能看实际帧大小 —— + * 配置值写对了但 native 侧没采纳,日志里仍然全是 3KB 帧。同时统计关键帧数: + * 30 kbps 下若关键帧仍然频繁,瞬时码率尖峰会把整帧拆成很多 UDP 片。 + */ + private noteEncoded(data: Uint8Array): void { + this.encodedFrames++; + const nals = splitAnnexBNalTypes(data); + const key = nals.some((t) => t >= 16 && t <= 21); + if (key) this.encodedKeyframes++; + this.encodedBytes += data.length; + this.encodedMax = Math.max(this.encodedMax, data.length); + if (this.encodedFrames <= 5 || this.encodedFrames % 30 === 0) { + logger.info('编码帧统计', { + n: this.encodedFrames, + bytes: data.length, + keyframes: this.encodedKeyframes, + avgBytes: Math.round(this.encodedBytes / this.encodedFrames), + maxBytes: this.encodedMax, + bitrateKbps: this.bitrateKbps, + fps: this.fps, + }); + } } + private encodedFrames = 0; + private encodedKeyframes = 0; + private encodedBytes = 0; + private encodedMax = 0; + info(): VideoStreamInfo { return { width: this.width, @@ -172,6 +237,16 @@ export class NativeVideoEncoder implements CallVideoEncoder { if (!this.opened) return; this.opened = false; this.queue.length = 0; + if (this.encodedFrames > 0) { + logger.info('编码会话汇总', { + frames: this.encodedFrames, + keyframes: this.encodedKeyframes, + avgBytes: Math.round(this.encodedBytes / this.encodedFrames), + maxBytes: this.encodedMax, + bitrateKbps: this.bitrateKbps, + fps: this.fps, + }); + } try { this.nt.closeVideoEncoder(); } catch (error) { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 494616684..a1a393a88 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -426,6 +426,32 @@ function registerLogIpc(): void { await electronHost.revealPath(dir); return true; }); + + /** + * 渲染层 → 主进程日志文件(`weq.debugLog`)。 + * + * 通话视频的解码全在渲染层,而渲染层的 `console.*` **不会**进日志文件 —— + * 「等待对方画面」这类问题因此一直只能盲猜。这个通道只接受结构化字段, + * 走同一个 `getLogger()`,落进同一天的日志里,便于和主进程的 `scope=call` + * 行按时间对齐。 + * + * 刻意**不**转发 `console` 全量:主进程 `console-message` 会把整个渲染层的 + * 噪声(React 警告、第三方库日志)都灌进文件。调用方显式调用才记。 + */ + ipcMain.on('logs:renderer', (_event, payload: unknown) => { + if (!payload || typeof payload !== 'object') return; + const record = payload as { level?: unknown; message?: unknown; context?: unknown }; + if (typeof record.message !== 'string' || record.message.length > 2000) return; + const level = + record.level === 'warn' || record.level === 'error' || record.level === 'debug' + ? record.level + : 'info'; + const context = + record.context && typeof record.context === 'object' + ? (record.context as Record) + : {}; + logger[level](record.message, { scope: 'renderer', ...context }); + }); } function registerSystemAuthIpc(): void { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index fe0e61223..bf2d26370 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -60,6 +60,19 @@ const weqBridge = { }, }, openLogDir: (): Promise => ipcRenderer.invoke('logs:open-dir') as Promise, + /** + * 往主进程日志文件里写一条结构化记录(fire-and-forget)。 + * + * 渲染层的 `console.*` 不会落盘,通话视频解码这类纯渲染层的链路出了问题就 + * 完全看不见。调用方显式调用才记,别拿来替代 `console`。 + */ + debugLog: (level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: object) => { + try { + ipcRenderer.send('logs:renderer', { level, message, context }); + } catch { + // 日志永远不能影响业务流程。 + } + }, /** * 用户选 / 拖 / 粘贴进来的文件的**本机绝对路径**(Electron 32+ 的 `webUtils`)。 * diff --git a/apps/desktop/src/renderer/src/env.d.ts b/apps/desktop/src/renderer/src/env.d.ts index 41dbb8796..a7853d37f 100644 --- a/apps/desktop/src/renderer/src/env.d.ts +++ b/apps/desktop/src/renderer/src/env.d.ts @@ -5,6 +5,8 @@ interface Window { weq: { editUndo?: import('../../shared/edit_menu').EditUndoBridge; openLogDir(): Promise; + /** 往主进程日志文件写一条结构化记录;web 构建下为 undefined。 */ + debugLog?(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: object): void; /** 用户选 / 拖 / 粘贴文件的绝对路径(Electron webUtils);拿不到返回 ''。 */ pathForFile?(file: File): string; channel: { diff --git a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts index 6f2c9e268..2f9f333f5 100644 --- a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts +++ b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts @@ -51,6 +51,29 @@ const HEVC_CODEC_CANDIDATES = [ 'hev1.1.6.L93.B0', ] as const; +/** + * 把渲染层的解码进度写进主进程日志文件。 + * + * 通话视频的解码全在这一层,而渲染层的 `console.*` 不落盘 —— 「一直等对方 + * 画面」这种问题因此完全不可观测。`weq.debugLog` 由 preload 提供;web 构建 + * 没有这个桥,退回 `console`(至少开发时能看到)。 + */ +function trace( + level: 'debug' | 'info' | 'warn' | 'error', + message: string, + context?: Record, +): void { + const bridge = window.weq?.debugLog; + if (bridge) { + bridge(level, message, { scope: 'call-video', ...context }); + return; + } + const line = `[call-video] ${message}`; + if (level === 'error') console.error(line, context); + else if (level === 'warn') console.warn(line, context); + else console.info(line, context); +} + export interface CallVideoState { /** 解码器可用。false 时界面应提示「当前环境不支持视频解码」。 */ supported: boolean; @@ -105,6 +128,8 @@ export function useCallVideo(options: { const decodeFailures = useRef(0); /** 收到的帧数(只用于前几帧的排查日志)。 */ const frameCount = useRef(0); + /** 解出的帧数(`output` 回调计数,只用于里程碑日志)。 */ + const decodedFrames = useRef(0); /** * 是否正在异步建解码器。 * @@ -150,7 +175,7 @@ export function useCallVideo(options: { } if (!config) { // 把试过的候选连同报错一起抛给界面:只说「不支持」没法排查。 - console.error('[call-video] 所有 HEVC 候选都不支持', tried, { width, height }); + trace('error', '所有 HEVC 候选都不支持', { tried, width, height }); setState((prev) => ({ ...prev, supported: false, @@ -158,36 +183,49 @@ export function useCallVideo(options: { })); return null; } + trace('info', '解码器配置就绪', { codec: config.codec, width, height, tried }); decoderRef.current?.close(); const decoder = new VideoDecoder({ output: (frame) => { + // ⚠️ **所有尺寸都要在 `frame.close()` 之前读。** close() 会把 VideoFrame + // 的所有属性重置为 0 —— 在它之后读 `displayWidth` 拿到的是 0,看起来像 + // 「解出画面但尺寸为 0」。日志和 setState 都依赖这两个值。 + const width = frame.displayWidth; + const height = frame.displayHeight; const canvas = canvasRef.current; if (canvas) { // 按帧尺寸调整画布,避免拉伸。 - if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) { - canvas.width = frame.displayWidth; - canvas.height = frame.displayHeight; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; } const ctx = canvas.getContext('2d'); ctx?.drawImage(frame, 0, 0); } frame.close(); decodeFailures.current = 0; - setState((prev) => - prev.active - ? prev - : { ...prev, active: true, width: frame.displayWidth, height: frame.displayHeight }, - ); + // 第一帧解出来是整条链路唯一的关键里程碑:有它才说明「等待对方画面」 + // 会消失。之后的帧只按 100 帧记一次,避免刷屏。 + decodedFrames.current++; + if (decodedFrames.current === 1 || decodedFrames.current % 100 === 0) { + trace('info', '解出画面', { n: decodedFrames.current, width, height }); + } + setState((prev) => (prev.active ? prev : { ...prev, active: true, width, height })); }, error: (error) => { - console.error('[call-video] 解码器错误', error); + trace('error', '解码器错误', { + error: String(error), + frames: frameCount.current, + decoded: decodedFrames.current, + }); setState((prev) => ({ ...prev, error: String(error) })); }, }); decoder.configure(config); decoderRef.current = decoder; configured.current = false; + trace('info', '解码器已建立', { codec: config.codec, width, height }); return decoder; }, [canvasRef], @@ -209,6 +247,16 @@ export function useCallVideo(options: { enabled, onData: (frame) => { frameCount.current++; + // 前几帧逐帧记:订阅建得晚 / 首帧不是关键帧 / 帧全被丢掉,这三种情况在 + // 界面上长得一模一样(都是「等待对方画面」),只有这里能区分。 + if (frameCount.current <= 5) { + trace('info', '收到待解码帧', { + n: frameCount.current, + bytes: frame.hevc?.length ?? null, + keyframe: frame.keyframe, + info: frame.info, + }); + } void (async () => { // 先拆 NAL 再判断关键帧。 // @@ -217,10 +265,24 @@ export function useCallVideo(options: { // 判断会导致 `!configured && !keyframe` 永远成立,解码器根本建不起来。 // 改用自己从 NAL 类型判出来的结果:只要这一帧里真的带了 IRAP 就能起解。 const au = toAccessUnit(frame.hevc); - if (!au.data) return; + if (!au.data) { + trace('warn', '拆不出 access unit,丢帧', { + n: frameCount.current, + bytes: frame.hevc?.length ?? null, + }); + return; + } // 见到参数集就记下来:解码器重建后要拿它补在最前面(见 paramSets 的注释)。 if (au.params.length > 0) paramSets.current = au.params; - if (!configured.current && !au.keyframe) return; + if (!configured.current && !au.keyframe) { + if (frameCount.current <= 5) { + trace('warn', '未配置且非关键帧,丢弃(等关键帧起解)', { + n: frameCount.current, + params: paramSets.current.length, + }); + } + return; + } const existing = decoderRef.current; const rebuilt = !existing || existing.state === 'closed'; @@ -238,7 +300,10 @@ export function useCallVideo(options: { // 丢掉 —— 表现就是「解出第一帧,画面永久停住」。 // // 用一个在建标记挡住并发重建:谁先拿到谁建,其余帧直接返回等下一帧。 - if (buildingDecoder.current) return; + if (buildingDecoder.current) { + trace('warn', '解码器正在建立,本帧丢弃', { n: frameCount.current }); + return; + } buildingDecoder.current = true; try { decoder = await ensureDecoder(frame.info?.width ?? 640, frame.info?.height ?? 360); @@ -251,7 +316,13 @@ export function useCallVideo(options: { // 的「未配置跳过 delta」丢掉,标记得留着给下一个关键帧用。 needsParamSets.current = true; } - if (decoder?.state !== 'configured') return; + if (decoder?.state !== 'configured') { + trace('warn', '解码器状态不是 configured,丢帧', { + n: frameCount.current, + state: decoder?.state ?? 'null', + }); + return; + } if (au.keyframe) configured.current = true; if (!configured.current) return; @@ -270,8 +341,21 @@ export function useCallVideo(options: { data: payload, }), ); + if (frameCount.current <= 5) { + trace('info', '已喂入解码器', { + n: frameCount.current, + key: au.keyframe, + bytes: payload.length, + params: paramSets.current.length, + }); + } } catch (error) { decodeFailures.current++; + trace('warn', 'decode() 抛错', { + n: frameCount.current, + failures: decodeFailures.current, + error: String(error), + }); setState((prev) => ({ ...prev, error: String(error) })); if (decodeFailures.current > 3) { decoder.close(); @@ -282,7 +366,11 @@ export function useCallVideo(options: { } })(); }, - onError: (error) => setState((prev) => ({ ...prev, error: String(error) })), + onError: (error) => { + trace('error', '视频订阅出错', { error: String(error) }); + setState((prev) => ({ ...prev, error: String(error) })); + }, + onStarted: () => trace('info', '视频订阅已建立'), }); useEffect(() => { From 3c65dca3970888224d6a4be55f987a17dee1e978 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Mon, 26 Oct 2026 17:58:14 +0800 Subject: [PATCH 12/23] =?UTF-8?q?feat(call):=20=E5=81=8F=E7=BB=BF=20bug=20?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E8=B7=AF=E5=8F=96=E8=AF=81=EF=BC=88colorSpac?= =?UTF-8?q?e=20/=20=E5=9D=87=E8=89=B2=E9=87=87=E6=A0=B7=20/=20=E5=AF=B9?= =?UTF-8?q?=E7=AB=AF=E7=A0=81=E6=B5=81=E8=90=BD=E7=9B=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目标:Windows 上通话前 15 秒画面严重偏绿、瞬间恢复、之后永远正常; Linux 永不复现。用户确认:内容正常只是偏色、只有 WeQ 看对端的主画面受影响。 已有事实(本轮先用真实 SPS 样本确证): - 码流 VUI 的 video_signal_type_present_flag = 0,完全未声明色彩空间 (tmp_hevc_vui.ts,解析真实抓包的 SPS)。 - 对端整通只有 1 个关键帧,15 秒 ≈ 225 帧(10 fps),不落在关键帧边界。 - native 只有 t265 编码器,解码全走 Chromium WebCodecs; VideoDecoderConfig 未设 colorSpace/hardwareAcceleration,也没有任何 GPU 开关 —— Windows 上 Chromium 自主选择 D3D11 硬解。 - tmp/ 里的上一轮离线探针(硬解重放 460 帧 colorSpace 恒 bt709 无跳变) 没有复现偏绿,且当时解码链路本身还是坏的,结论不可靠。 三路取证(复现时三路同时看): 1. useCallVideo 逐帧读 frame.colorSpace(close() 之前),与前帧比较, 变化即 warn「帧色彩空间发生变化」并带前后值 —— 直接证明解码器中途 换了 YUV→RGB 矩阵。 2. 画面中心 4x4~8x8 平均 RGB 采样,进「解出画面」日志;与上条采样差 ≥75 时 warn「画面均色突变」—— 元数据没变但画面跳变(或恢复)时 也能抓到,并给出精确帧号与色偏方向(偏绿 = G 显著高于 R/B)。 3. WEQ_CALL_PEER_DUMP= 把对端 Annex B 码流原样落盘 —— 配合 tmp_hevc_vui.ts 可验证偏绿时段的 SPS 是否与正常时段不同。 --- apps/desktop/src/main/call.ts | 51 ++- .../src/renderer/src/hooks/useCallVideo.ts | 102 ++++- tmp_hevc_vui.ts | 391 ++++++++++++++++++ 3 files changed, 540 insertions(+), 4 deletions(-) create mode 100644 tmp_hevc_vui.ts diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index 99d1b3a9b..c7092e0b4 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -17,6 +17,7 @@ */ import dgram from 'node:dgram'; +import { closeSync, openSync, writeSync } from 'node:fs'; import { join } from 'node:path'; import { EventEmitter } from 'node:events'; import type { NtHelperBinding } from '@weq/native'; @@ -258,10 +259,14 @@ export class CallManager extends EventEmitter { /** 视频编码器(只有视频通话才非 null)。 */ private videoEncoder: NativeVideoEncoder | null = null; - /** 最近收到的一个关键帧(新订阅者补发用)。 */ + /** 最近收到的一个关键帧(新订阅者补发时用)。 */ private lastKeyframe: VideoFramePayload | null = null; /** 已推给渲染层的对端视频帧数(只用于前几帧的排查日志)。 */ private videoPushCount = 0; + /** 对端码流落盘的输出句柄(只在 `WEQ_CALL_PEER_DUMP` 打开时非 null)。 */ + private peerDump: { write(chunk: Buffer): void; end(): void } | null = null; + /** 对端码流是否已尝试过打开(避免每帧都碰文件系统)。 */ + private peerDumpTried = false; /** 当前这通电话的录音设置(发起时取一次,通话中改设置不影响进行中的录音)。 */ private recordingConfig: CallRecordingConfig = { mode: 'mixed', systemAudio: false, dir: null }; private recorder: CallRecorder | null = null; @@ -377,6 +382,7 @@ export class CallManager extends EventEmitter { // **之后**才建立的,而首个关键帧往往已经过去 —— 后面的 delta 帧没有 // 关键帧起解,画面永远出不来。新订阅先用这份补一帧。 if (frame.isKeyframe) this.lastKeyframe = frame; + this.dumpPeerFrame(frame); // 前几帧记一笔:主进程侧「收到了并推给渲染层」是排查「等待对方画面」的 // 分界线 —— 这里没有、渲染层也没有,说明问题在收包/组帧;这里有、渲染层 // 没有,说明订阅没建立或 IPC 序列化失败。 @@ -691,11 +697,54 @@ export class CallManager extends EventEmitter { this.videoEncoder = null; this.lastKeyframe = null; this.videoPushCount = 0; + try { + this.peerDump?.end(); + } catch { + // 收尾失败不影响挂断。 + } + this.peerDump = null; + this.peerDumpTried = false; this.session = null; this.transport = null; this.info = null; } + /** + * 把对端码流原样追加到一个 Annex B 文件(排查色彩 / 花屏用)。 + * + * ```bash + * WEQ_CALL_PEER_DUMP=/tmp/peer.h265 pnpm --filter @weq/desktop dev + * # 通话后: + * ffplay /tmp/peer.h265 # 看对端到底发的是什么 + * npx tsx tmp_hevc_vui.ts /tmp/peer.h265 # 看 SPS/VUI 色彩声明 + * ``` + * + * **只有开了环境变量才落盘。** 15 fps 的裸流一通几十 MB,默认写盘会拖慢 + * 实时链路,也会在用户不知情时占用磁盘。 + */ + private dumpPeerFrame(frame: VideoFramePayload): void { + const dir = process.env.WEQ_CALL_PEER_DUMP; + if (!dir || !frame.hevc) return; + if (!this.peerDump && !this.peerDumpTried) { + this.peerDumpTried = true; + try { + const fd = openSync(dir, 'a'); + this.peerDump = { + write: (chunk) => writeSync(fd, chunk), + end: () => closeSync(fd), + }; + logger.info('对端码流开始落盘', { path: dir }); + } catch (error) { + logger.warn('对端码流落盘失败', { path: dir, error }); + } + } + try { + this.peerDump?.write(Buffer.from(frame.hevc)); + } catch (error) { + logger.warn('写对端码流失败', { error }); + } + } + /** * `AVSDKPlugin` 和 `wrapper.node` 同在 QQ 的 `resources/app` 下; * linux 的插件在它下面的 `avsdk/` 子目录,`nt_helper` 会自己找。 diff --git a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts index 2f9f333f5..b04d2756a 100644 --- a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts +++ b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts @@ -74,6 +74,40 @@ function trace( else console.info(line, context); } +/** + * 取画面中心区域的平均 RGB(0-255)。 + * + * 「偏绿」在数字上就是 G 通道显著高于 R/B —— 比肉眼描述精确得多。只采中心 + * 一小块(约 4×4 像素),一次 `getImageData` 开销可忽略;采样点固定,通话里 + * 对着镜头的人脸/背景变化不大时,数值有可比性。 + */ +function sampleCenterRgb( + ctx: CanvasRenderingContext2D, + width: number, + height: number, +): [number, number, number] | null { + try { + const w = Math.max(4, Math.min(8, Math.floor(width / 8))); + const h = Math.max(4, Math.min(8, Math.floor(height / 8))); + const x = Math.floor((width - w) / 2); + const y = Math.floor((height - h) / 2); + const data = ctx.getImageData(x, y, w, h).data; + let r = 0; + let g = 0; + let b = 0; + const count = w * h; + for (let i = 0; i < data.length; i += 4) { + r += data[i]!; + g += data[i + 1]!; + b += data[i + 2]!; + } + return [Math.round(r / count), Math.round(g / count), Math.round(b / count)]; + } catch { + // 画布被跨域污染等极端情况:采样失败不影响解码。 + return null; + } +} + export interface CallVideoState { /** 解码器可用。false 时界面应提示「当前环境不支持视频解码」。 */ supported: boolean; @@ -130,6 +164,10 @@ export function useCallVideo(options: { const frameCount = useRef(0); /** 解出的帧数(`output` 回调计数,只用于里程碑日志)。 */ const decodedFrames = useRef(0); + /** 上一帧的色彩空间,用于检测「解码中途色彩元数据变了」。 */ + const lastColorSpace = useRef(null); + /** 上一条采样的画面均色,用于检测「画面突然整体偏色 / 恢复」。 */ + const lastSampledRgb = useRef<[number, number, number] | null>(null); /** * 是否正在异步建解码器。 * @@ -193,7 +231,17 @@ export function useCallVideo(options: { // 「解出画面但尺寸为 0」。日志和 setState 都依赖这两个值。 const width = frame.displayWidth; const height = frame.displayHeight; + // 色彩空间也要在 close() 之前读。码流的 VUI 里没写色彩信息时,解码器 + // 会自己推一个默认值;Windows 上「前 15 秒偏绿、之后恢复」如果对应 + // 这里某个字段变化,就说明是色彩推断/切换的问题,而不是码流损坏。 + const colorSpace = frame.colorSpace; const canvas = canvasRef.current; + // 均色采样:在 close() 之前、drawImage 之后从画布取。 + // + // 「前 15 秒偏绿然后恢复」靠肉眼只能说出个大概时间;把画面中心的平均 + // RGB 记下来,日志里就能看到精确的切换帧号和色偏方向(绿 = G 通道 + // 显著高于 R/B)。center 采样避开画面四角的黑边 / UI 遮挡。 + let rgb: [number, number, number] | null = null; if (canvas) { // 按帧尺寸调整画布,避免拉伸。 if (canvas.width !== width || canvas.height !== height) { @@ -201,15 +249,61 @@ export function useCallVideo(options: { canvas.height = height; } const ctx = canvas.getContext('2d'); - ctx?.drawImage(frame, 0, 0); + if (ctx) { + ctx.drawImage(frame, 0, 0); + rgb = sampleCenterRgb(ctx, width, height); + } } frame.close(); decodeFailures.current = 0; // 第一帧解出来是整条链路唯一的关键里程碑:有它才说明「等待对方画面」 // 会消失。之后的帧只按 100 帧记一次,避免刷屏。 decodedFrames.current++; - if (decodedFrames.current === 1 || decodedFrames.current % 100 === 0) { - trace('info', '解出画面', { n: decodedFrames.current, width, height }); + const previous = lastColorSpace.current; + const changed = + previous !== null && + (previous.matrix !== colorSpace?.matrix || + previous.primaries !== colorSpace?.primaries || + previous.transfer !== colorSpace?.transfer || + previous.range !== colorSpace?.range); + lastColorSpace.current = colorSpace ?? null; + // RGB 均值突变检测:色彩空间元数据没变但画面突然整体偏色(或恢复), + // 说明问题在转换矩阵/合成层而不是元数据。阈值取 25 —— 偏绿恢复那种 + // 级别的跳变远超它,而正常画面运动抖动到不了。 + const lastRgb = lastSampledRgb.current; + let rgbJump: { from: [number, number, number] } | null = null; + if (rgb && lastRgb) { + const delta = + Math.abs(rgb[0] - lastRgb[0]) + + Math.abs(rgb[1] - lastRgb[1]) + + Math.abs(rgb[2] - lastRgb[2]); + if (delta >= 75) rgbJump = { from: lastRgb }; + } + if (rgb) lastSampledRgb.current = rgb; + if ( + decodedFrames.current === 1 || + decodedFrames.current % 100 === 0 || + changed || + rgbJump + ) { + const event = changed ? '帧色彩空间发生变化' : rgbJump ? '画面均色突变' : '解出画面'; + const level = changed || rgbJump ? 'warn' : 'info'; + trace(level, event, { + n: decodedFrames.current, + width, + height, + rgb, + ...(rgbJump ? { previousRgb: rgbJump.from } : {}), + colorSpace: colorSpace + ? { + matrix: colorSpace.matrix, + primaries: colorSpace.primaries, + transfer: colorSpace.transfer, + range: colorSpace.range, + } + : null, + ...(changed ? { previous } : {}), + }); } setState((prev) => (prev.active ? prev : { ...prev, active: true, width, height })); }, @@ -238,6 +332,8 @@ export function useCallVideo(options: { configured.current = false; paramSets.current = []; needsParamSets.current = false; + lastColorSpace.current = null; + lastSampledRgb.current = null; setState({ supported: true, active: false, width: 0, height: 0, error: null }); return; } diff --git a/tmp_hevc_vui.ts b/tmp_hevc_vui.ts new file mode 100644 index 000000000..3949d44ba --- /dev/null +++ b/tmp_hevc_vui.ts @@ -0,0 +1,391 @@ +/** + * 临时排查工具:解析 HEVC SPS 的 VUI 色彩字段。 + * + * 用途:验证「Windows 前 15 秒偏绿」是否与码流里声明/未声明色彩信息有关。 + * + * 用法: + * WEQ_CALL_VIDEO_DUMP=/tmp/vdump pnpm --filter @weq/desktop dev + * 通话后:npx tsx tmp_hevc_vui.ts /tmp/vdump/encoded.h265 + */ + +import { readFileSync } from 'node:fs'; + +interface Nal { + type: number; + data: Uint8Array; +} + +function splitHevcNals(data: Uint8Array): Nal[] { + const nals: Nal[] = []; + let i = 0; + let start = -1; + while (i < data.length) { + const four = + i + 4 <= data.length && + data[i] === 0 && + data[i + 1] === 0 && + data[i + 2] === 0 && + data[i + 3] === 1; + const three = + !four && i + 3 <= data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 1; + if (four || three) { + if (start >= 0) { + const body = data.subarray(start, i); + if (body.length >= 2) nals.push({ type: (body[0]! >> 1) & 0x3f, data: body }); + } + start = i + (four ? 4 : 3); + i = start; + continue; + } + i++; + } + if (start >= 0 && start < data.length) { + const body = data.subarray(start); + if (body.length >= 2) nals.push({ type: (body[0]! >> 1) & 0x3f, data: body }); + } + return nals; +} + +function removeEmulationPrevention(data: Uint8Array): Uint8Array { + const out = new Uint8Array(data.length); + let n = 0; + for (let i = 0; i < data.length; i++) { + if (i + 2 < data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3) { + out[n++] = 0; + out[n++] = 0; + i += 2; + continue; + } + out[n++] = data[i]!; + } + return out.subarray(0, n); +} + +class BitReader { + private pos = 0; + constructor(private readonly data: Uint8Array) {} + bit(): number { + if (this.pos >> 3 >= this.data.length) throw new Error('bit read past end'); + const b = (this.data[this.pos >> 3]! >> (7 - (this.pos & 7))) & 1; + this.pos++; + return b; + } + u(n: number): number { + let v = 0; + for (let i = 0; i < n; i++) v = (v << 1) | this.bit(); + return v; + } + ue(): number { + let zeros = 0; + while (this.bit() === 0) { + zeros++; + if (zeros > 32) throw new Error('ue too long'); + } + return (2 ** zeros - 1) + this.u(zeros); + } + se(): number { + const k = this.ue(); + return k & 1 ? (k + 1) / 2 : -(k / 2); + } + get bitsLeft(): number { + return this.data.length * 8 - this.pos; + } + skip(n: number): void { + this.pos += n; + } + get position(): number { + return this.pos; + } +} + +/** HEVC 的 profile_tier_level 解析,返回占用的位数。 */ +function parseProfileTierLevel(bits: BitReader, maxSubLayersMinus1: number): void { + bits.skip(2 + 1 + 5); // general_profile_space / tier_flag / profile_idc + bits.skip(32); // general_profile_compatibility_flags + bits.skip(48); // general_constraint_indicator_flags + bits.skip(8); // general_level_idc + const profilePresent: boolean[] = []; + const levelPresent: boolean[] = []; + for (let i = 0; i < maxSubLayersMinus1; i++) { + profilePresent.push(bits.bit() === 1); + levelPresent.push(bits.bit() === 1); + } + if (maxSubLayersMinus1 > 0) { + for (let i = maxSubLayersMinus1; i < 8; i++) bits.skip(2); // reserved_zero_2bits + } + for (let i = 0; i < maxSubLayersMinus1; i++) { + if (profilePresent[i]) bits.skip(2 + 1 + 5 + 32 + 48); + if (levelPresent[i]) bits.skip(8); + } +} + +const COLOR_PRIMARIES: Record = { + 1: 'BT.709', + 2: 'Unspecified', + 4: 'BT.470M', + 5: 'BT.470BG', + 6: 'SMPTE170M', + 7: 'SMPTE240M', + 8: 'Film', + 9: 'BT.2020', + 10: 'SMPTE428', + 11: 'SMPTE431', + 12: 'SMPTE432', + 22: 'EBU3213', +}; + +const TRANSFER: Record = { + 1: 'BT.709', + 2: 'Unspecified', + 4: 'Gamma 2.2', + 5: 'Gamma 2.8', + 6: 'SMPTE170M', + 7: 'SMPTE240M', + 8: 'Linear', + 9: 'Log100', + 10: 'Log316', + 11: 'IEC61966-2-4', + 12: 'BT.1361E', + 13: 'IEC61966-2-1 (sRGB)', + 14: 'BT.2020 10bit', + 15: 'BT.2020 12bit', + 16: 'SMPTE2084 (PQ)', + 17: 'SMPTE428', + 18: 'HLG', +}; + +const MATRIX: Record = { + 0: 'GBR (identity)', + 1: 'BT.709', + 2: 'Unspecified', + 4: 'FCC', + 5: 'BT.470BG', + 6: 'SMPTE170M', + 7: 'SMPTE240M', + 8: 'YCgCo', + 9: 'BT.2020 NCL', + 10: 'BT.2020 CL', + 11: 'SMPTE2085', + 12: 'Chroma-derived NCL', + 13: 'Chroma-derived CL', + 14: 'ICtCp', +}; + +interface VuiInfo { + present: boolean; + videoSignalTypePresent?: boolean; + videoFormat?: number; + videoFullRangeFlag?: number; + colourPrimaries?: number; + transferCharacteristics?: number; + matrixCoeffs?: number; + chromaLocInfoPresent?: boolean; + parsedBits?: number; +} + +function parseSpsVui(rbsp: Uint8Array): { + width: number; + height: number; + chromaFormat: number; + bitDepthLuma: number; + bitDepthChroma: number; + vui: VuiInfo; + error?: string; +} { + const bits = new BitReader(rbsp); + const result = { + width: 0, + height: 0, + chromaFormat: -1, + bitDepthLuma: -1, + bitDepthChroma: -1, + vui: { present: false } as VuiInfo, + }; + try { + bits.skip(4); // sps_video_parameter_set_id + const maxSubLayersMinus1 = bits.u(3); + bits.skip(1); // sps_temporal_id_nesting_flag + parseProfileTierLevel(bits, maxSubLayersMinus1); + + bits.ue(); // sps_seq_parameter_set_id + const chromaFormat = bits.ue(); + result.chromaFormat = chromaFormat; + if (chromaFormat === 3) bits.skip(1); // separate_colour_plane_flag + result.width = bits.ue(); + result.height = bits.ue(); + if (bits.bit() === 1) { + // conformance_window_flag + bits.ue(); + bits.ue(); + bits.ue(); + bits.ue(); + } + result.bitDepthLuma = bits.ue() + 8; + result.bitDepthChroma = bits.ue() + 8; + const log2MaxPoc = bits.ue() + 4; + const subLayerOrdering = bits.bit(); + if (subLayerOrdering) { + for (let i = 0; i <= maxSubLayersMinus1; i++) { + bits.ue(); + bits.ue(); + bits.ue(); + } + } + bits.ue(); // log2_min_luma_coding_block_size_minus3 + bits.ue(); // log2_diff_max_min_luma_coding_block_size + bits.ue(); // log2_min_luma_transform_block_size_minus2 + bits.ue(); // log2_diff_max_min_luma_transform_block_size + bits.ue(); // max_transform_hierarchy_depth_inter + bits.ue(); // max_transform_hierarchy_depth_intra + void log2MaxPoc; + + if (bits.bit() === 1) { + // scaling_list_enabled_flag + if (bits.bit() === 1) { + // sps_scaling_list_data_present_flag + for (let sizeId = 0; sizeId < 4; sizeId++) { + for (let matrixId = 0; matrixId < 6; matrixId += sizeId === 3 ? 3 : 1) { + if (bits.bit() === 0) { + bits.ue(); // scaling_list_pred_matrix_id_delta + } else { + const coefNum = Math.min(64, 1 << (4 + (sizeId << 1))); + if (sizeId > 1) bits.se(); // scaling_list_dc_coef_minus8 + for (let i = 0; i < coefNum; i++) bits.se(); + } + } + } + } + } + bits.skip(1); // amp_enabled_flag + bits.skip(1); // sample_adaptive_offset_enabled_flag + if (bits.bit() === 1) { + // pcm_enabled_flag + bits.skip(4 + 4); // pcm_sample_bit_depth_luma/chroma_minus1 + bits.ue(); // log2_min_pcm_luma_coding_block_size_minus3 + bits.ue(); // log2_diff_max_min_pcm_luma_coding_block_size + bits.skip(1); // pcm_loop_filter_disabled_flag + } + const numShortTermRps = bits.ue(); + if (numShortTermRps > 64) throw new Error(`sps short term rps 异常: ${numShortTermRps}`); + for (let i = 0; i < numShortTermRps; i++) { + const interRefPicSetPrediction = i !== 0 ? bits.bit() : 0; + if (interRefPicSetPrediction) throw new Error('inter_ref_pic_set_prediction 未实现'); + const numNegative = bits.ue(); + const numPositive = bits.ue(); + for (let j = 0; j < numNegative + numPositive; j++) { + bits.ue(); // delta_poc_sX_minus1 + bits.skip(1); // used_by_curr_pic_sX_flag + } + } + if (bits.bit() === 1) { + // long_term_ref_pics_present_flag + const numLongTerm = bits.ue(); + for (let i = 0; i < numLongTerm; i++) { + bits.skip(log2MaxPoc); + bits.skip(1); + } + } + bits.skip(1); // sps_temporal_mvp_enabled_flag + bits.skip(1); // strong_intra_smoothing_enabled_flag + const vuiPresent = bits.bit(); + result.vui.present = vuiPresent === 1; + if (vuiPresent !== 1) return result; + + const vui: VuiInfo = { present: true }; + if (bits.bit() === 1) { + // aspect_ratio_info_present_flag + const idc = bits.u(8); + if (idc === 255) bits.skip(32); + } + if (bits.bit() === 1) bits.skip(1); // overscan_info_present_flag + overscan_appropriate + if (bits.bit() === 1) { + // video_signal_type_present_flag + vui.videoSignalTypePresent = true; + vui.videoFormat = bits.u(3); + vui.videoFullRangeFlag = bits.bit(); + if (bits.bit() === 1) { + // colour_description_present_flag + vui.colourPrimaries = bits.u(8); + vui.transferCharacteristics = bits.u(8); + vui.matrixCoeffs = bits.u(8); + } + } else { + vui.videoSignalTypePresent = false; + } + if (bits.bit() === 1) { + // chroma_loc_info_present_flag + vui.chromaLocInfoPresent = true; + bits.ue(); + bits.ue(); + } + vui.parsedBits = bits.position; + result.vui = vui; + return result; + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + return result; + } +} + +function analyze(path: string): void { + const raw = new Uint8Array(readFileSync(path)); + const nals = splitHevcNals(raw); + console.log(`文件: ${path} 共 ${raw.length} 字节 / ${nals.length} 个 NAL\n`); + + const counts = new Map(); + for (const nal of nals) counts.set(nal.type, (counts.get(nal.type) ?? 0) + 1); + console.log('NAL 类型分布:'); + for (const [type, n] of [...counts.entries()].sort((a, b) => a[0] - b[0])) { + const name = + type === 32 ? 'VPS' : type === 33 ? 'SPS' : type === 34 ? 'PPS' : type <= 21 ? 'IRAP/VCL' : ''; + console.log(` type=${type}${name ? ` (${name})` : ''} × ${n}`); + } + + // 逐帧列出关键帧的位置和 SPS + let frameIndex = 0; + let seenSps = 0; + for (const nal of nals) { + if (nal.type === 33) { + seenSps++; + const rbsp = removeEmulationPrevention(nal.data.subarray(2)); + const sps = parseSpsVui(rbsp); + console.log(`\n=== 第 ${seenSps} 个 SPS(NAL 序号 ${frameIndex},${nal.data.length} 字节)===`); + if (sps.error) { + console.log(` 解析中断: ${sps.error}`); + } + console.log( + ` 分辨率 ${sps.width}x${sps.height} chroma=${sps.chromaFormat} bitDepth=${sps.bitDepthLuma}/${sps.bitDepthChroma}`, + ); + const v = sps.vui; + if (!v.present) { + console.log(' ❌ VUI 不存在 —— 码流未声明任何色彩信息'); + } else { + console.log(' VUI 存在'); + if (v.videoSignalTypePresent === false) { + console.log(' ❌ video_signal_type_present_flag = 0 —— 未声明色彩空间'); + } else if (v.videoSignalTypePresent) { + console.log(` video_format=${v.videoFormat} full_range=${v.videoFullRangeFlag}`); + if (v.colourPrimaries === undefined) { + console.log(' ❌ colour_description_present_flag = 0 —— 未声明 primaries/transfer/matrix'); + } else { + console.log( + ` colour_primaries=${v.colourPrimaries} (${COLOR_PRIMARIES[v.colourPrimaries] ?? '?'})`, + ); + console.log( + ` transfer_characteristics=${v.transferCharacteristics} (${TRANSFER[v.transferCharacteristics!] ?? '?'})`, + ); + console.log(` matrix_coeffs=${v.matrixCoeffs} (${MATRIX[v.matrixCoeffs!] ?? '?'})`); + } + } + } + } + if (nal.type <= 31) frameIndex++; + } +} + +const path = process.argv[2]; +if (!path) { + console.error('用法: npx tsx tmp_hevc_vui.ts '); + process.exit(1); +} +analyze(path); From 8d4a23faa70ce815a8ece70469caed3cf4b8ea16 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Mon, 26 Oct 2026 18:11:15 +0800 Subject: [PATCH 13/23] =?UTF-8?q?fix(call):=20=E4=BF=AE=E5=A4=8D=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=99=9A=E5=88=B0=E5=AF=BC=E8=87=B4=E7=9A=84=E8=B7=B3?= =?UTF-8?q?=E5=B8=A7=EF=BC=8C=E6=B6=88=E9=99=A4=E5=89=8D=2015=20=E7=A7=92?= =?UTF-8?q?=E6=95=B4=E5=B1=8F=E7=BA=AF=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 上视频通话开头约 15 秒画面严重偏绿、随后瞬间恢复正常,Linux 永不 复现。真机日志 + 对端码流 dump 对齐后定位到根因,与色彩空间无关。 ## 根因:补发关键帧后直接跳到实时帧 渲染层订阅比主进程收帧晚约 1.9 秒(`videoReceiving` 状态推送之后才订阅)。 订阅时补发缓存的关键帧(帧 0),但紧接着喂的是**当时的实时帧**(帧 29): 喂入顺序:0, 29, 30, 31, ... ← 帧 1~28 全部缺失 HEVC 的 P 帧依赖前序参考帧。Chromium 硬解拒绝这些无参考帧的 delta 之后, **内部状态被污染,继续输出错乱的 YUV**:实测中心 RGB `[0,88,0]`(R/B 恒为 0), 直到下一个关键帧(帧 257)才重新同步 —— 正好约 15 秒。 证据(2026-10-26 真机日志,帧号由码流 dump 的帧大小反查确认): 10:00:38.607 n=1 rgb:[16,16,16] ← 补发的关键帧 0 10:00:38.624 n=2 rgb:[0,77,0] ← 跳帧喂入,开始绿 10:00:45.155 n=100 rgb:[0,88,0] 10:00:53.821 n=230 rgb:[26,24,27] ← 第 257 帧(关键帧),恢复 `colorSpace` 全程 `bt709/bt709/bt709`,一次都没变 —— 所以不是色彩矩阵问题。 ## 离线复现与修复验证 `tmp/verify_fix.cjs` 用同一份对端码流重放: | 喂入方式 | 绿帧数 | | --- | --- | | 连续喂 0,1,2,… | 0 | | 跳帧喂 0,29,30,…(修复前) | 228(#1..#228),与真机日志逐帧吻合 | | 跳帧喂 + 本次修复 | 0(丢弃 228 帧,等关键帧) | ## 改动 - `lib/callVideo.ts` 新增纯函数 `acceptsFrame()`:关键帧永远放行;否则要求 16 位帧序号与上一帧连续(模回绕)。 - `useCallVideo` 在喂入前调用它;不连续就丢弃,直到下一个关键帧。代价是画面 「冻」到下一个关键帧才动,好过整屏绿 15 秒。 - `frameIndex` 沿 CallEvent → IPC 订阅透传到渲染层(此前只有主进程内部有)。 - 回归测试覆盖连续 / 跳跃 / 乱序 / 16 位回绕,并用真实绿屏序列断言 「丢弃 228 帧、恢复后喂入 44 帧」。 --- apps/desktop/src/main/call.ts | 9 +++ apps/desktop/src/main/ipc/routers/call.ts | 4 + .../src/renderer/src/hooks/useCallVideo.ts | 45 ++++++++++- .../desktop/src/renderer/src/lib/callVideo.ts | 35 +++++++++ apps/desktop/test/call_video_decode.test.ts | 75 +++++++++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index c7092e0b4..36faf6f0a 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -237,6 +237,14 @@ export type CallEvent = hevc: Uint8Array; timestamp: number; keyframe: boolean; + /** + * 16 位帧序号 `((fidx - 1) << 8) | gidx`。 + * + * 渲染层要靠它判断「喂给解码器的帧是否连续」—— HEVC 的 P 帧依赖前序参考 + * 帧,跳过若干帧后继续喂 delta 帧会让硬解输出错乱的 YUV(表现为整屏纯绿), + * 且要等到下一个关键帧才恢复。见 useCallVideo 的 continuity 检查。 + */ + frameIndex: number; info: VideoStreamInfo | null; } /** 录音收尾完成(含内部收尾:对端挂断 / 被踢 / 崩溃)。 */ @@ -399,6 +407,7 @@ export class CallManager extends EventEmitter { hevc: frame.hevc, timestamp: frame.timestamp, keyframe: frame.isKeyframe, + frameIndex: frame.frameIndex, info: frame.info, } satisfies CallEvent); }); diff --git a/apps/desktop/src/main/ipc/routers/call.ts b/apps/desktop/src/main/ipc/routers/call.ts index 29c2edd0d..9579e79bd 100644 --- a/apps/desktop/src/main/ipc/routers/call.ts +++ b/apps/desktop/src/main/ipc/routers/call.ts @@ -179,6 +179,8 @@ export const callRouter = router({ hevc: Uint8Array; timestamp: number; keyframe: boolean; + /** 16 位帧序号;渲染层据此判断喂入是否连续(见 useCallVideo)。 */ + frameIndex: number; info: VideoStreamInfo | null; }>((emit) => { const manager = findCallManager(); @@ -191,6 +193,7 @@ export const callRouter = router({ hevc: kf.hevc, timestamp: kf.timestamp, keyframe: true, + frameIndex: kf.frameIndex, info: kf.info, }); } @@ -200,6 +203,7 @@ export const callRouter = router({ hevc: event.hevc, timestamp: event.timestamp, keyframe: event.keyframe, + frameIndex: event.frameIndex, info: event.info, }); }; diff --git a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts index b04d2756a..cce8e48f5 100644 --- a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts +++ b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts @@ -28,7 +28,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { payloadForDecoder, toAccessUnit } from '../lib/callVideo'; +import { acceptsFrame, payloadForDecoder, toAccessUnit } from '../lib/callVideo'; import { trpc } from '../trpc/client'; /** HEVC 的 codec string:Main profile、level 1.5(实测 640×360@10fps 用这档)。 */ @@ -168,6 +168,15 @@ export function useCallVideo(options: { const lastColorSpace = useRef(null); /** 上一条采样的画面均色,用于检测「画面突然整体偏色 / 恢复」。 */ const lastSampledRgb = useRef<[number, number, number] | null>(null); + /** + * 上一个喂进解码器的帧序号。 + * + * HEVC 的 P 帧依赖前序参考帧:跳帧后继续喂 delta 会让硬解输出错乱(整屏纯绿), + * 要等到下一个关键帧才恢复。见 onData 里的连续性检查。 + */ + const lastFrameIndex = useRef(null); + /** 已经因为断帧而进入「等关键帧」状态(只用于日志去重)。 */ + const awaitingKeyframe = useRef(false); /** * 是否正在异步建解码器。 * @@ -334,6 +343,8 @@ export function useCallVideo(options: { needsParamSets.current = false; lastColorSpace.current = null; lastSampledRgb.current = null; + lastFrameIndex.current = null; + awaitingKeyframe.current = false; setState({ supported: true, active: false, width: 0, height: 0, error: null }); return; } @@ -380,6 +391,38 @@ export function useCallVideo(options: { return; } + // ⚠️ **连续性检查:关键帧之后,帧序号必须连续。** + // + // 2026-10-26 真机复现(Windows):渲染层订阅比主进程收帧晚约 1.9 秒。 + // 订阅时补发缓存的关键帧(gidx=0),但紧接着喂的是**实时帧**(帧 29), + // 中间 1~28 全部缺失。HEVC 的 P 帧依赖前序参考帧,Chromium 硬解在 + // 拒绝这些「无参考帧」的 delta 后内部状态被污染,**继续输出错乱的 YUV**: + // 整屏纯绿(实测 RGB [0,88,0],R/B 恒为 0),直到下一个关键帧 + // (第 257 帧,约 15 秒后)才重新同步。 + // + // 离线复现(tmp/repro_green_fix.cjs,同一份对端码流): + // 连续喂 → 绿帧 0 + // 跳帧喂 → 绿帧 228(#1..#228),与真机日志逐帧吻合 + // 丢弃到关键帧 → 绿帧 0 + // + // 所以:序号不连续且本帧不是关键帧 → 直接丢弃,等下一个关键帧。 + // 代价是画面「冻」到下一个关键帧才动,好过整屏绿 15 秒。 + const previousIndex = lastFrameIndex.current; + if (!acceptsFrame(previousIndex, frame.frameIndex, au.keyframe)) { + if (!awaitingKeyframe.current) { + awaitingKeyframe.current = true; + trace('warn', '帧序号不连续,丢弃直到下一个关键帧', { + n: frameCount.current, + previous: previousIndex, + current: frame.frameIndex, + gap: ((frame.frameIndex - (previousIndex ?? 0) + 65536) % 65536) >>> 0, + }); + } + return; + } + if (au.keyframe) awaitingKeyframe.current = false; + lastFrameIndex.current = frame.frameIndex; + const existing = decoderRef.current; const rebuilt = !existing || existing.state === 'closed'; let decoder = existing; diff --git a/apps/desktop/src/renderer/src/lib/callVideo.ts b/apps/desktop/src/renderer/src/lib/callVideo.ts index 3d59ba4e2..282f8813a 100644 --- a/apps/desktop/src/renderer/src/lib/callVideo.ts +++ b/apps/desktop/src/renderer/src/lib/callVideo.ts @@ -138,6 +138,41 @@ export function toAccessUnit(input: unknown): HevcAccessUnit { }; } +/** + * 判断这一帧能否喂给解码器:帧序号必须与上一帧连续,或者本帧是关键帧。 + * + * ## 为什么必须做这个检查 + * + * 2026-10-26 Windows 真机复现:渲染层订阅比主进程收帧晚约 1.9 秒。订阅时补发 + * 缓存的关键帧(帧 0),但紧接着喂的是**实时帧**(帧 29),中间 1~28 全部缺失。 + * + * HEVC 的 P 帧依赖前序参考帧。Chromium 硬解在拒绝这些「无参考帧」的 delta 后 + * **内部状态被污染**,继续输出错乱的 YUV —— 实测整屏纯绿(中心 RGB `[0,88,0]`, + * R/B 恒为 0),直到下一个关键帧(帧 257,约 15 秒后)才重新同步。 + * + * 离线复现(`tmp/verify_fix.cjs`,同一份对端码流): + * + * | 喂入方式 | 绿帧数 | + * | --- | --- | + * | 连续喂 0,1,2,… | 0 | + * | 跳帧喂 0,29,30,…(修复前) | 228(#1..#228) | + * | 跳帧喂 + 本检查(修复后) | 0(丢弃 228 帧,等关键帧) | + * + * 代价是画面「冻」到下一个关键帧才动 —— 好过整屏绿 15 秒。 + * + * @param previous 上一个**喂进解码器**的帧序号;null 表示还没有(刚起解) + * @param current 本帧的 16 位帧序号 + * @param keyframe 本帧是否含 IRAP + */ +export function acceptsFrame(previous: number | null, current: number, keyframe: boolean): boolean { + // 关键帧永远可以起解 —— 它是参考链的起点。 + if (keyframe) return true; + // 还没有基准:交给调用方的「未配置」逻辑处理。 + if (previous === null) return true; + // 16 位序号会回绕(65535 → 0)。先按模取「前进了多少」,再判断是否为 1。 + return (current - previous + 65536) % 65536 === 1; +} + /** * 给「新建 / 重建的解码器」准备首个 chunk 的载荷。 * diff --git a/apps/desktop/test/call_video_decode.test.ts b/apps/desktop/test/call_video_decode.test.ts index 5013e55d9..f7a6dfe11 100644 --- a/apps/desktop/test/call_video_decode.test.ts +++ b/apps/desktop/test/call_video_decode.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest'; import { + acceptsFrame, annexB, concatBytes, payloadForDecoder, @@ -165,3 +166,77 @@ describe('toBytes(tRPC 序列化形态)', () => { expect(toBytes({})).toBeNull(); }); }); + +/** + * 帧序号连续性检查 —— 「Windows 前 15 秒整屏纯绿」的修复。 + * + * 真实场景(2026-10-26 真机日志 + 码流 dump 对齐):订阅建立时补发缓存的关键帧 + * (帧 0),紧接着喂的是实时帧(帧 29),中间 1~28 缺失。Chromium 硬解在拒绝这些 + * 无参考帧的 delta 后内部状态被污染,输出错乱的 YUV(中心 RGB [0,88,0],R/B 恒 0), + * 直到下一个关键帧(帧 257)才恢复 —— 正好约 15 秒。 + * + * 离线验证(tmp/verify_fix.cjs,同一份对端码流):不检查时绿帧 228 帧, + * 加上本检查后 0 帧。 + */ +describe('acceptsFrame(帧序号连续性)', () => { + it('关键帧永远可以起解 —— 它是参考链的起点', () => { + expect(acceptsFrame(null, 0, true)).toBe(true); + expect(acceptsFrame(28, 257, true)).toBe(true); + expect(acceptsFrame(0, 100, true)).toBe(true); + }); + + it('还没有基准时放行,交给调用方的「未配置」逻辑判断', () => { + expect(acceptsFrame(null, 29, false)).toBe(true); + }); + + it('序号连续(+1)时接受', () => { + expect(acceptsFrame(0, 1, false)).toBe(true); + expect(acceptsFrame(28, 29, false)).toBe(true); + }); + + it('序号跳跃时拒绝 —— 这正是绿屏的成因', () => { + // 真实场景:关键帧 0 之后直接来帧 29 + expect(acceptsFrame(0, 29, false)).toBe(false); + expect(acceptsFrame(29, 33, false)).toBe(false); + // 乱序(倒退)同样拒绝 + expect(acceptsFrame(30, 10, false)).toBe(false); + }); + + it('16 位序号正常递增(255 → 256 → 257,不是回绕)', () => { + expect(acceptsFrame(254, 255, false)).toBe(true); + expect(acceptsFrame(255, 256, false)).toBe(true); + expect(acceptsFrame(256, 257, false)).toBe(true); + }); + + it('16 位序号真正回绕(65535 → 0)时仍然连续', () => { + expect(acceptsFrame(65534, 65535, false)).toBe(true); + expect(acceptsFrame(65535, 0, false)).toBe(true); + expect(acceptsFrame(0, 1, false)).toBe(true); + }); + + it('回绕边界上的跳跃照样拒绝', () => { + expect(acceptsFrame(65535, 2, false)).toBe(false); + // 255 → 0 是倒退,不是回绕 + expect(acceptsFrame(255, 0, false)).toBe(false); + }); + + it('真实绿屏序列:0 → 29 全部丢弃,直到关键帧 257 才恢复', () => { + // 复刻 tmp/verify_fix.cjs 的 feed:补发关键帧 0,然后从 29 开始 + const feed = [0, ...Array.from({ length: 271 }, (_, i) => 29 + i)]; + let last: number | null = null; + let fed = 0; + let dropped = 0; + for (const index of feed) { + const keyframe = index === 0 || index === 257; + if (!acceptsFrame(last, index, keyframe)) { + dropped++; + continue; + } + last = index; + fed++; + } + // 0(关键帧)→ 之后 29..256 全丢(228 帧)→ 257(关键帧)起恢复 + expect(dropped).toBe(228); + expect(fed).toBe(44); // 0 + 257..299 + }); +}); From ffe7a904feb5a04e563c3380b29c064866f758fc Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Mon, 26 Oct 2026 18:16:11 +0800 Subject: [PATCH 14/23] =?UTF-8?q?fix(call):=20=E8=AE=A2=E9=98=85=E6=99=9A?= =?UTF-8?q?=E5=88=B0=E6=97=B6=E6=95=B4=E6=AE=B5=E8=A1=A5=E5=8F=91=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E5=B8=A7=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=86=BB=E5=88=B0?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E4=B8=AA=E5=85=B3=E9=94=AE=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版修复(8d4a23fa)用「丢弃不连续帧、等下一个关键帧」消除了绿屏,但代价 是画面要冻到下一个关键帧才动 —— 对端关键帧间隔实测约 17 秒,等于把「绿 15 秒」 换成了「冻 15 秒」,比原 bug 更坏(绿屏至少还能看见对方在动)。 根因不在解码,在**补发策略**:订阅晚到约 1.9 秒时,主进程只补发了一个关键帧, 紧接着推的是当时的实时帧,中间整段缺失。补发整段连续帧即可,不需要丢任何东西。 改动: - `CallManager` 用 `replayBuffer` 替换单个 `lastKeyframe`:缓存「从最近关键帧 开始、帧序号连续」的一段帧(上限 600 帧,实测峰值约 255 帧 / 1.2 MB)。 遇到关键帧或断帧就重置;超过上限整段作废(关键帧之后必须连续才有意义)。 - `onCallVideo` 订阅时整段补发,而不是只补关键帧。 - 补发与实时流重叠的部分交给渲染层 `acceptsFrame` 统一处理,不在此处过滤。 离线验证(tmp/verify_replay.cjs,同一份真实对端码流): | 策略 | 解出帧 | 绿帧 | | --- | --- | --- | | 只补关键帧(原 bug) | 272 | 228(#1..#228) | | 只补关键帧 + 丢帧等关键帧(上一版) | 44 | 0(但冻 15 秒) | | **整段补发(本次)** | **300** | **0** | 整段补发的解出帧数与连续喂入相同,第 2 帧即正常画面,既不绿也不冻。 --- apps/desktop/src/main/call.ts | 74 +++++++++++++++++++---- apps/desktop/src/main/ipc/routers/call.ts | 27 ++++++--- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index 36faf6f0a..381e15deb 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -49,6 +49,15 @@ import { const logger = getLogger().child({ scope: 'call' }); +/** + * 补发缓存上限(帧)。 + * + * 实测对端关键帧间隔约 17 秒(15 fps ≈ 255 帧),所以 600 帧足够覆盖一整段。 + * 平均每帧 2 KB 时峰值约 1.2 MB —— 只为「订阅晚到」这一种情况准备,值得。 + * 超出说明码流异常(关键帧间隔极大),整段作废、等下一个关键帧。 + */ +const REPLAY_FRAMES = 600; + // ─────────────────────────── UDP ─────────────────────────── /** `node:dgram` 的 udp4 套接字适配成 `CallSession` 要的形状。 */ @@ -267,8 +276,19 @@ export class CallManager extends EventEmitter { /** 视频编码器(只有视频通话才非 null)。 */ private videoEncoder: NativeVideoEncoder | null = null; - /** 最近收到的一个关键帧(新订阅者补发时用)。 */ - private lastKeyframe: VideoFramePayload | null = null; + /** + * 从**最近一个关键帧**开始的一段连续帧(新订阅者补发用)。 + * + * ⚠️ **只补关键帧是不够的。** 2026-10-26 实测:渲染层订阅比收帧晚约 1.9 秒, + * 补发关键帧(帧 0)之后紧接着喂实时帧(帧 29),中间 1~28 缺失。HEVC 的 P 帧 + * 依赖前序参考帧,硬解拒绝这些 delta 后内部状态被污染,输出错乱的 YUV —— + * 整屏纯绿,直到下一个关键帧(约 15 秒)才恢复。 + * + * 所以这里缓存「关键帧 + 其后所有帧」的连续片段,订阅时整段补发,渲染层就能 + * 从头连续解码。上限 {@link REPLAY_FRAMES} 帧,超出就丢最旧的(那些帧的参考 + * 链已经断了,补发也没用)。 + */ + private replayBuffer: VideoFramePayload[] = []; /** 已推给渲染层的对端视频帧数(只用于前几帧的排查日志)。 */ private videoPushCount = 0; /** 对端码流落盘的输出句柄(只在 `WEQ_CALL_PEER_DUMP` 打开时非 null)。 */ @@ -301,14 +321,16 @@ export class CallManager extends EventEmitter { } /** - * 取最近的关键帧,供**新建立**的渲染层订阅补发。 + * 取一段**从关键帧开始、帧序号连续**的帧,供新建立的渲染层订阅补发。 * * 渲染层是在「对端开始发视频」的状态推送之后才订阅 `onCallVideo` 的,而首个 - * 关键帧通常已经过去。没有关键帧起解,后面的 delta 帧全解不出来 —— 所以 - * 订阅时先补这一帧。 + * 关键帧通常已经过去。**只补关键帧不够**:跳帧会让硬解输出错乱(整屏纯绿), + * 必须把关键帧之后的帧一并补上,让渲染层从头连续解码。详见 `replayBuffer`。 + * + * 返回空数组表示还没有可用的起点(还没收到过关键帧)。 */ - latestKeyframe(): VideoFramePayload | null { - return this.lastKeyframe; + replayFrames(): VideoFramePayload[] { + return this.replayBuffer; } /** 发起一路通话。已有通话在跑时直接报错,由界面提示先挂断。 */ @@ -386,10 +408,7 @@ export class CallManager extends EventEmitter { // 而标记又停在 true 且不再更新 —— 后续每一帧都被静默丢掉,画面永久冻结。 // 真机日志里 `g=249/f=1 → g=43/f=2` 的序号连续性就是证据。 if (!frame.hevc) return; - // 缓存最近一个关键帧:渲染层的解码订阅是在「对端开始发视频」的状态推送 - // **之后**才建立的,而首个关键帧往往已经过去 —— 后面的 delta 帧没有 - // 关键帧起解,画面永远出不来。新订阅先用这份补一帧。 - if (frame.isKeyframe) this.lastKeyframe = frame; + this.rememberForReplay(frame); this.dumpPeerFrame(frame); // 前几帧记一笔:主进程侧「收到了并推给渲染层」是排查「等待对方画面」的 // 分界线 —— 这里没有、渲染层也没有,说明问题在收包/组帧;这里有、渲染层 @@ -704,7 +723,7 @@ export class CallManager extends EventEmitter { this.transport?.close(); this.videoEncoder?.close(); this.videoEncoder = null; - this.lastKeyframe = null; + this.replayBuffer = []; this.videoPushCount = 0; try { this.peerDump?.end(); @@ -718,6 +737,37 @@ export class CallManager extends EventEmitter { this.info = null; } + /** + * 维护「从关键帧开始的一段连续帧」缓存,供新订阅补发。 + * + * 见 {@link replayBuffer} 的注释:只补关键帧会让渲染层跳帧喂入,硬解输出 + * 错乱(整屏纯绿)。所以: + * + * - 遇到关键帧 → 清空重来(这是新的参考链起点); + * - 帧序号不连续 → 同样清空(参考链已断,之前的帧补发也没用); + * - 其余帧追加,超过上限丢最旧的。 + */ + private rememberForReplay(frame: VideoFramePayload): void { + const last = this.replayBuffer[this.replayBuffer.length - 1]; + if (frame.isKeyframe) { + this.replayBuffer = [frame]; + return; + } + if (!last) return; // 还没见过关键帧,先不缓存 + // 16 位序号会回绕,按模判断是否正好 +1。 + const continuous = (frame.frameIndex - last.frameIndex + 65536) % 65536 === 1; + if (!continuous) { + this.replayBuffer = []; + return; + } + this.replayBuffer.push(frame); + // 超出上限时丢**最旧的 delta**、保留关键帧?不行 —— 关键帧之后的帧必须 + // 连续才有意义。这里改为整段作废等下一个关键帧:对端关键帧间隔实测约 + // 17 秒,缓存峰值 255 帧 × 平均 2 KB ≈ 500 KB,完全可以一直留着。 + // 上限只是防止异常码流(关键帧间隔极大)把内存吃爆。 + if (this.replayBuffer.length > REPLAY_FRAMES) this.replayBuffer = []; + } + /** * 把对端码流原样追加到一个 Annex B 文件(排查色彩 / 花屏用)。 * diff --git a/apps/desktop/src/main/ipc/routers/call.ts b/apps/desktop/src/main/ipc/routers/call.ts index 9579e79bd..25bd4ee10 100644 --- a/apps/desktop/src/main/ipc/routers/call.ts +++ b/apps/desktop/src/main/ipc/routers/call.ts @@ -185,18 +185,27 @@ export const callRouter = router({ }>((emit) => { const manager = findCallManager(); if (!manager) return () => {}; - // 先补最近的关键帧:订阅是在「对端开始发视频」之后才建立的,首个关键帧 - // 往往已经过去,没有它解码器起不来(后续 delta 帧会全被丢弃)。 - const kf = manager.latestKeyframe(); - if (kf?.hevc) { + // 补发「关键帧 + 其后的连续帧」:订阅是在「对端开始发视频」之后才建立的, + // 首个关键帧往往已经过去。 + // + // ⚠️ **不能只补关键帧。** 只补关键帧的话,渲染层喂完它之后紧接着收到的是 + // 实时帧,中间缺一大段;HEVC 的 P 帧依赖前序参考帧,硬解会输出错乱的 YUV + // (整屏纯绿,约 15 秒后才被下一个关键帧纠正)。整段补发让渲染层从头连续 + // 解码,代价是订阅瞬间多传几十帧。 + const replay = manager.replayFrames(); + for (const frame of replay) { + if (!frame.hevc) continue; emit.next({ - hevc: kf.hevc, - timestamp: kf.timestamp, - keyframe: true, - frameIndex: kf.frameIndex, - info: kf.info, + hevc: frame.hevc, + timestamp: frame.timestamp, + keyframe: frame.isKeyframe, + frameIndex: frame.frameIndex, + info: frame.info, }); } + // 补发期间实时帧可能已经往前走了,两者会重叠(补发到帧 N,实时流已从帧 M + // 开始)。重叠与跳帧由渲染层的 `acceptsFrame` 统一处理:连续的照常解, + // 不连续的就丢弃等关键帧 —— 这里不做额外过滤,避免两处逻辑各自为政。 const handler = (event: CallEvent) => { if (event.type !== 'video') return; emit.next({ From 25aa2cc814f1d4badcfea46ab5498507a29455c7 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Mon, 26 Oct 2026 18:22:47 +0800 Subject: [PATCH 15/23] =?UTF-8?q?chore(call):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=81=8F=E7=BB=BF=20bug=20=E6=8E=92=E6=9F=A5=E6=9C=9F=E7=9A=84?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7=E8=B0=83=E8=AF=95=E5=9F=8B=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留修复本体与真实诊断点,移除排查期间临时加的脚手架。 移除: - `tmp_hevc_vui.ts`(SPS/VUI 解析工具)—— 已确认码流 VUI 未声明色彩空间, 与本次根因无关。 - 对端码流落盘(`WEQ_CALL_PEER_DUMP`)—— 排查专用,会把整通裸流写进磁盘。 - 编码器「编码帧统计 / 编码会话汇总」—— 用于验证码率是否生效,已完成使命。 - 主进程「推送对端视频帧给渲染层」逐帧计数。 - 渲染层逐帧日志:收到待解码帧、已喂入解码器、解码器配置就绪、色彩空间变化、 画面均色采样(含 `sampleCenterRgb`)等。 保留: - `replayBuffer` / `rememberForReplay` / `acceptsFrame` —— 修复本体。 - 渲染层日志桥 `weq.debugLog`,但只留错误、断帧、首帧里程碑 —— 渲染层的 `console.*` 本来就不落盘,解码失败时日志里什么都没有,这是真实缺口。 - `frameIndex` 沿 CallEvent → IPC 透传 —— 连续性检查依赖它。 净删 681 行。 --- apps/desktop/src/main/call.ts | 63 --- apps/desktop/src/main/call_video_encoder.ts | 74 ---- .../src/renderer/src/hooks/useCallVideo.ts | 207 ++-------- tmp_hevc_vui.ts | 391 ------------------ 4 files changed, 27 insertions(+), 708 deletions(-) delete mode 100644 tmp_hevc_vui.ts diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index 381e15deb..2541fda96 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -17,7 +17,6 @@ */ import dgram from 'node:dgram'; -import { closeSync, openSync, writeSync } from 'node:fs'; import { join } from 'node:path'; import { EventEmitter } from 'node:events'; import type { NtHelperBinding } from '@weq/native'; @@ -289,12 +288,6 @@ export class CallManager extends EventEmitter { * 链已经断了,补发也没用)。 */ private replayBuffer: VideoFramePayload[] = []; - /** 已推给渲染层的对端视频帧数(只用于前几帧的排查日志)。 */ - private videoPushCount = 0; - /** 对端码流落盘的输出句柄(只在 `WEQ_CALL_PEER_DUMP` 打开时非 null)。 */ - private peerDump: { write(chunk: Buffer): void; end(): void } | null = null; - /** 对端码流是否已尝试过打开(避免每帧都碰文件系统)。 */ - private peerDumpTried = false; /** 当前这通电话的录音设置(发起时取一次,通话中改设置不影响进行中的录音)。 */ private recordingConfig: CallRecordingConfig = { mode: 'mixed', systemAudio: false, dir: null }; private recorder: CallRecorder | null = null; @@ -409,18 +402,6 @@ export class CallManager extends EventEmitter { // 真机日志里 `g=249/f=1 → g=43/f=2` 的序号连续性就是证据。 if (!frame.hevc) return; this.rememberForReplay(frame); - this.dumpPeerFrame(frame); - // 前几帧记一笔:主进程侧「收到了并推给渲染层」是排查「等待对方画面」的 - // 分界线 —— 这里没有、渲染层也没有,说明问题在收包/组帧;这里有、渲染层 - // 没有,说明订阅没建立或 IPC 序列化失败。 - this.videoPushCount++; - if (this.videoPushCount <= 5 || this.videoPushCount % 300 === 0) { - logger.info('推送对端视频帧给渲染层', { - n: this.videoPushCount, - bytes: frame.hevc.length, - keyframe: frame.isKeyframe, - }); - } this.emit('event', { type: 'video', hevc: frame.hevc, @@ -724,14 +705,6 @@ export class CallManager extends EventEmitter { this.videoEncoder?.close(); this.videoEncoder = null; this.replayBuffer = []; - this.videoPushCount = 0; - try { - this.peerDump?.end(); - } catch { - // 收尾失败不影响挂断。 - } - this.peerDump = null; - this.peerDumpTried = false; this.session = null; this.transport = null; this.info = null; @@ -768,42 +741,6 @@ export class CallManager extends EventEmitter { if (this.replayBuffer.length > REPLAY_FRAMES) this.replayBuffer = []; } - /** - * 把对端码流原样追加到一个 Annex B 文件(排查色彩 / 花屏用)。 - * - * ```bash - * WEQ_CALL_PEER_DUMP=/tmp/peer.h265 pnpm --filter @weq/desktop dev - * # 通话后: - * ffplay /tmp/peer.h265 # 看对端到底发的是什么 - * npx tsx tmp_hevc_vui.ts /tmp/peer.h265 # 看 SPS/VUI 色彩声明 - * ``` - * - * **只有开了环境变量才落盘。** 15 fps 的裸流一通几十 MB,默认写盘会拖慢 - * 实时链路,也会在用户不知情时占用磁盘。 - */ - private dumpPeerFrame(frame: VideoFramePayload): void { - const dir = process.env.WEQ_CALL_PEER_DUMP; - if (!dir || !frame.hevc) return; - if (!this.peerDump && !this.peerDumpTried) { - this.peerDumpTried = true; - try { - const fd = openSync(dir, 'a'); - this.peerDump = { - write: (chunk) => writeSync(fd, chunk), - end: () => closeSync(fd), - }; - logger.info('对端码流开始落盘', { path: dir }); - } catch (error) { - logger.warn('对端码流落盘失败', { path: dir, error }); - } - } - try { - this.peerDump?.write(Buffer.from(frame.hevc)); - } catch (error) { - logger.warn('写对端码流失败', { error }); - } - } - /** * `AVSDKPlugin` 和 `wrapper.node` 同在 QQ 的 `resources/app` 下; * linux 的插件在它下面的 `avsdk/` 子目录,`nt_helper` 会自己找。 diff --git a/apps/desktop/src/main/call_video_encoder.ts b/apps/desktop/src/main/call_video_encoder.ts index e08dd356a..59c7b10b9 100644 --- a/apps/desktop/src/main/call_video_encoder.ts +++ b/apps/desktop/src/main/call_video_encoder.ts @@ -24,37 +24,6 @@ import { getLogger } from '@weq/service'; const logger = getLogger().child({ scope: 'call-video-encoder' }); -/** - * 扫一遍 Annex B 裸流里每个 NAL 的类型(只看起始码后的首字节)。 - * - * 编码器侧只需要知道「这帧里有没有 IRAP」,不值得为此引协议层的完整解析器 —— - * 那边是值导入 `@weq/protocol`,主进程没问题,但这里二十行就够。 - */ -function splitAnnexBNalTypes(data: Uint8Array): number[] { - const types: number[] = []; - let i = 0; - let start = -1; - while (i < data.length) { - const four = - i + 4 <= data.length && - data[i] === 0 && - data[i + 1] === 0 && - data[i + 2] === 0 && - data[i + 3] === 1; - const three = - !four && i + 3 <= data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 1; - if (four || three) { - if (start >= 0 && i > start) types.push((data[start]! >> 1) & 0x3f); - start = i + (four ? 4 : 3); - i = start; - continue; - } - i++; - } - if (start >= 0 && start < data.length) types.push((data[start]! >> 1) & 0x3f); - return types; -} - /** * 调试开关:把送进编码器的 I420 和编码结果落盘。 * @@ -185,42 +154,9 @@ export class NativeVideoEncoder implements CallVideoEncoder { if (!result) return null; const out = new Uint8Array(result.data); this.dumpAnnexB(out); - this.noteEncoded(out); return out; } - /** - * 记编码输出的尺寸分布(每 30 帧一条)。 - * - * 「把码率从 400 调到 30 kbps」这类改动到底生效没有,只能看实际帧大小 —— - * 配置值写对了但 native 侧没采纳,日志里仍然全是 3KB 帧。同时统计关键帧数: - * 30 kbps 下若关键帧仍然频繁,瞬时码率尖峰会把整帧拆成很多 UDP 片。 - */ - private noteEncoded(data: Uint8Array): void { - this.encodedFrames++; - const nals = splitAnnexBNalTypes(data); - const key = nals.some((t) => t >= 16 && t <= 21); - if (key) this.encodedKeyframes++; - this.encodedBytes += data.length; - this.encodedMax = Math.max(this.encodedMax, data.length); - if (this.encodedFrames <= 5 || this.encodedFrames % 30 === 0) { - logger.info('编码帧统计', { - n: this.encodedFrames, - bytes: data.length, - keyframes: this.encodedKeyframes, - avgBytes: Math.round(this.encodedBytes / this.encodedFrames), - maxBytes: this.encodedMax, - bitrateKbps: this.bitrateKbps, - fps: this.fps, - }); - } - } - - private encodedFrames = 0; - private encodedKeyframes = 0; - private encodedBytes = 0; - private encodedMax = 0; - info(): VideoStreamInfo { return { width: this.width, @@ -237,16 +173,6 @@ export class NativeVideoEncoder implements CallVideoEncoder { if (!this.opened) return; this.opened = false; this.queue.length = 0; - if (this.encodedFrames > 0) { - logger.info('编码会话汇总', { - frames: this.encodedFrames, - keyframes: this.encodedKeyframes, - avgBytes: Math.round(this.encodedBytes / this.encodedFrames), - maxBytes: this.encodedMax, - bitrateKbps: this.bitrateKbps, - fps: this.fps, - }); - } try { this.nt.closeVideoEncoder(); } catch (error) { diff --git a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts index cce8e48f5..9aaa608d2 100644 --- a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts +++ b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts @@ -52,11 +52,13 @@ const HEVC_CODEC_CANDIDATES = [ ] as const; /** - * 把渲染层的解码进度写进主进程日志文件。 + * 把渲染层的解码**错误与关键状态**写进主进程日志文件。 * - * 通话视频的解码全在这一层,而渲染层的 `console.*` 不落盘 —— 「一直等对方 - * 画面」这种问题因此完全不可观测。`weq.debugLog` 由 preload 提供;web 构建 - * 没有这个桥,退回 `console`(至少开发时能看到)。 + * 通话视频的解码全在这一层,而渲染层的 `console.*` 不落盘 —— 解码失败时用户 + * 只看到「等待对方画面」,日志里却什么都没有。`weq.debugLog` 由 preload 提供; + * web 构建没有这个桥,退回 `console`。 + * + * 只记错误、断帧和首帧里程碑,不逐帧记录(一通通话上千帧,会淹没日志)。 */ function trace( level: 'debug' | 'info' | 'warn' | 'error', @@ -74,40 +76,6 @@ function trace( else console.info(line, context); } -/** - * 取画面中心区域的平均 RGB(0-255)。 - * - * 「偏绿」在数字上就是 G 通道显著高于 R/B —— 比肉眼描述精确得多。只采中心 - * 一小块(约 4×4 像素),一次 `getImageData` 开销可忽略;采样点固定,通话里 - * 对着镜头的人脸/背景变化不大时,数值有可比性。 - */ -function sampleCenterRgb( - ctx: CanvasRenderingContext2D, - width: number, - height: number, -): [number, number, number] | null { - try { - const w = Math.max(4, Math.min(8, Math.floor(width / 8))); - const h = Math.max(4, Math.min(8, Math.floor(height / 8))); - const x = Math.floor((width - w) / 2); - const y = Math.floor((height - h) / 2); - const data = ctx.getImageData(x, y, w, h).data; - let r = 0; - let g = 0; - let b = 0; - const count = w * h; - for (let i = 0; i < data.length; i += 4) { - r += data[i]!; - g += data[i + 1]!; - b += data[i + 2]!; - } - return [Math.round(r / count), Math.round(g / count), Math.round(b / count)]; - } catch { - // 画布被跨域污染等极端情况:采样失败不影响解码。 - return null; - } -} - export interface CallVideoState { /** 解码器可用。false 时界面应提示「当前环境不支持视频解码」。 */ supported: boolean; @@ -160,14 +128,8 @@ export function useCallVideo(options: { const needsParamSets = useRef(false); /** 丢帧计数:连续解不动时重建解码器。 */ const decodeFailures = useRef(0); - /** 收到的帧数(只用于前几帧的排查日志)。 */ - const frameCount = useRef(0); - /** 解出的帧数(`output` 回调计数,只用于里程碑日志)。 */ + /** 解出的帧数(只用于第一帧的里程碑日志)。 */ const decodedFrames = useRef(0); - /** 上一帧的色彩空间,用于检测「解码中途色彩元数据变了」。 */ - const lastColorSpace = useRef(null); - /** 上一条采样的画面均色,用于检测「画面突然整体偏色 / 恢复」。 */ - const lastSampledRgb = useRef<[number, number, number] | null>(null); /** * 上一个喂进解码器的帧序号。 * @@ -230,27 +192,14 @@ export function useCallVideo(options: { })); return null; } - trace('info', '解码器配置就绪', { codec: config.codec, width, height, tried }); - decoderRef.current?.close(); const decoder = new VideoDecoder({ output: (frame) => { - // ⚠️ **所有尺寸都要在 `frame.close()` 之前读。** close() 会把 VideoFrame - // 的所有属性重置为 0 —— 在它之后读 `displayWidth` 拿到的是 0,看起来像 - // 「解出画面但尺寸为 0」。日志和 setState 都依赖这两个值。 + // ⚠️ **尺寸要在 `frame.close()` 之前读。** close() 会把 VideoFrame 的 + // 所有属性重置为 0 —— 在它之后读 `displayWidth` 拿到的是 0。 const width = frame.displayWidth; const height = frame.displayHeight; - // 色彩空间也要在 close() 之前读。码流的 VUI 里没写色彩信息时,解码器 - // 会自己推一个默认值;Windows 上「前 15 秒偏绿、之后恢复」如果对应 - // 这里某个字段变化,就说明是色彩推断/切换的问题,而不是码流损坏。 - const colorSpace = frame.colorSpace; const canvas = canvasRef.current; - // 均色采样:在 close() 之前、drawImage 之后从画布取。 - // - // 「前 15 秒偏绿然后恢复」靠肉眼只能说出个大概时间;把画面中心的平均 - // RGB 记下来,日志里就能看到精确的切换帧号和色偏方向(绿 = G 通道 - // 显著高于 R/B)。center 采样避开画面四角的黑边 / UI 遮挡。 - let rgb: [number, number, number] | null = null; if (canvas) { // 按帧尺寸调整画布,避免拉伸。 if (canvas.width !== width || canvas.height !== height) { @@ -258,77 +207,26 @@ export function useCallVideo(options: { canvas.height = height; } const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.drawImage(frame, 0, 0); - rgb = sampleCenterRgb(ctx, width, height); - } + ctx?.drawImage(frame, 0, 0); } frame.close(); decodeFailures.current = 0; - // 第一帧解出来是整条链路唯一的关键里程碑:有它才说明「等待对方画面」 - // 会消失。之后的帧只按 100 帧记一次,避免刷屏。 decodedFrames.current++; - const previous = lastColorSpace.current; - const changed = - previous !== null && - (previous.matrix !== colorSpace?.matrix || - previous.primaries !== colorSpace?.primaries || - previous.transfer !== colorSpace?.transfer || - previous.range !== colorSpace?.range); - lastColorSpace.current = colorSpace ?? null; - // RGB 均值突变检测:色彩空间元数据没变但画面突然整体偏色(或恢复), - // 说明问题在转换矩阵/合成层而不是元数据。阈值取 25 —— 偏绿恢复那种 - // 级别的跳变远超它,而正常画面运动抖动到不了。 - const lastRgb = lastSampledRgb.current; - let rgbJump: { from: [number, number, number] } | null = null; - if (rgb && lastRgb) { - const delta = - Math.abs(rgb[0] - lastRgb[0]) + - Math.abs(rgb[1] - lastRgb[1]) + - Math.abs(rgb[2] - lastRgb[2]); - if (delta >= 75) rgbJump = { from: lastRgb }; - } - if (rgb) lastSampledRgb.current = rgb; - if ( - decodedFrames.current === 1 || - decodedFrames.current % 100 === 0 || - changed || - rgbJump - ) { - const event = changed ? '帧色彩空间发生变化' : rgbJump ? '画面均色突变' : '解出画面'; - const level = changed || rgbJump ? 'warn' : 'info'; - trace(level, event, { - n: decodedFrames.current, - width, - height, - rgb, - ...(rgbJump ? { previousRgb: rgbJump.from } : {}), - colorSpace: colorSpace - ? { - matrix: colorSpace.matrix, - primaries: colorSpace.primaries, - transfer: colorSpace.transfer, - range: colorSpace.range, - } - : null, - ...(changed ? { previous } : {}), - }); + // 第一帧解出来是整条链路唯一的关键里程碑(有它才说明「等待对方画面」 + // 会消失),只记这一次,不逐帧刷屏。 + if (decodedFrames.current === 1) { + trace('info', '解出画面', { width, height }); } setState((prev) => (prev.active ? prev : { ...prev, active: true, width, height })); }, error: (error) => { - trace('error', '解码器错误', { - error: String(error), - frames: frameCount.current, - decoded: decodedFrames.current, - }); + trace('error', '解码器错误', { error: String(error) }); setState((prev) => ({ ...prev, error: String(error) })); }, }); decoder.configure(config); decoderRef.current = decoder; configured.current = false; - trace('info', '解码器已建立', { codec: config.codec, width, height }); return decoder; }, [canvasRef], @@ -341,8 +239,6 @@ export function useCallVideo(options: { configured.current = false; paramSets.current = []; needsParamSets.current = false; - lastColorSpace.current = null; - lastSampledRgb.current = null; lastFrameIndex.current = null; awaitingKeyframe.current = false; setState({ supported: true, active: false, width: 0, height: 0, error: null }); @@ -353,17 +249,6 @@ export function useCallVideo(options: { trpc.call.onCallVideo.useSubscription(undefined, { enabled, onData: (frame) => { - frameCount.current++; - // 前几帧逐帧记:订阅建得晚 / 首帧不是关键帧 / 帧全被丢掉,这三种情况在 - // 界面上长得一模一样(都是「等待对方画面」),只有这里能区分。 - if (frameCount.current <= 5) { - trace('info', '收到待解码帧', { - n: frameCount.current, - bytes: frame.hevc?.length ?? null, - keyframe: frame.keyframe, - info: frame.info, - }); - } void (async () => { // 先拆 NAL 再判断关键帧。 // @@ -372,47 +257,28 @@ export function useCallVideo(options: { // 判断会导致 `!configured && !keyframe` 永远成立,解码器根本建不起来。 // 改用自己从 NAL 类型判出来的结果:只要这一帧里真的带了 IRAP 就能起解。 const au = toAccessUnit(frame.hevc); - if (!au.data) { - trace('warn', '拆不出 access unit,丢帧', { - n: frameCount.current, - bytes: frame.hevc?.length ?? null, - }); - return; - } + if (!au.data) return; // 见到参数集就记下来:解码器重建后要拿它补在最前面(见 paramSets 的注释)。 if (au.params.length > 0) paramSets.current = au.params; - if (!configured.current && !au.keyframe) { - if (frameCount.current <= 5) { - trace('warn', '未配置且非关键帧,丢弃(等关键帧起解)', { - n: frameCount.current, - params: paramSets.current.length, - }); - } - return; - } + if (!configured.current && !au.keyframe) return; // ⚠️ **连续性检查:关键帧之后,帧序号必须连续。** // - // 2026-10-26 真机复现(Windows):渲染层订阅比主进程收帧晚约 1.9 秒。 - // 订阅时补发缓存的关键帧(gidx=0),但紧接着喂的是**实时帧**(帧 29), - // 中间 1~28 全部缺失。HEVC 的 P 帧依赖前序参考帧,Chromium 硬解在 - // 拒绝这些「无参考帧」的 delta 后内部状态被污染,**继续输出错乱的 YUV**: - // 整屏纯绿(实测 RGB [0,88,0],R/B 恒为 0),直到下一个关键帧 - // (第 257 帧,约 15 秒后)才重新同步。 + // HEVC 的 P 帧依赖前序参考帧。跳帧后继续喂 delta,Chromium 硬解会拒绝 + // 这些「无参考帧」的帧,但**内部状态被污染**,继续输出错乱的 YUV —— + // 2026-10-26 实测整屏纯绿(RGB `[0,88,0]`,R/B 恒为 0),直到下一个 + // 关键帧(约 15 秒后)才重新同步。 // - // 离线复现(tmp/repro_green_fix.cjs,同一份对端码流): - // 连续喂 → 绿帧 0 - // 跳帧喂 → 绿帧 228(#1..#228),与真机日志逐帧吻合 - // 丢弃到关键帧 → 绿帧 0 + // 主进程侧已经改为「整段补发连续帧」(见 `CallManager.replayBuffer`), + // 正常情况下这里不会触发。它保留作为**安全网**:网络丢包、补发与实时流 + // 的衔接缝隙、对端中途重启编码,都可能造成序号断裂。 // - // 所以:序号不连续且本帧不是关键帧 → 直接丢弃,等下一个关键帧。 - // 代价是画面「冻」到下一个关键帧才动,好过整屏绿 15 秒。 + // 触发时丢弃本帧并等下一个关键帧 —— 会短暂冻画面,但好过整屏绿。 const previousIndex = lastFrameIndex.current; if (!acceptsFrame(previousIndex, frame.frameIndex, au.keyframe)) { if (!awaitingKeyframe.current) { awaitingKeyframe.current = true; trace('warn', '帧序号不连续,丢弃直到下一个关键帧', { - n: frameCount.current, previous: previousIndex, current: frame.frameIndex, gap: ((frame.frameIndex - (previousIndex ?? 0) + 65536) % 65536) >>> 0, @@ -439,10 +305,7 @@ export function useCallVideo(options: { // 丢掉 —— 表现就是「解出第一帧,画面永久停住」。 // // 用一个在建标记挡住并发重建:谁先拿到谁建,其余帧直接返回等下一帧。 - if (buildingDecoder.current) { - trace('warn', '解码器正在建立,本帧丢弃', { n: frameCount.current }); - return; - } + if (buildingDecoder.current) return; buildingDecoder.current = true; try { decoder = await ensureDecoder(frame.info?.width ?? 640, frame.info?.height ?? 360); @@ -455,13 +318,7 @@ export function useCallVideo(options: { // 的「未配置跳过 delta」丢掉,标记得留着给下一个关键帧用。 needsParamSets.current = true; } - if (decoder?.state !== 'configured') { - trace('warn', '解码器状态不是 configured,丢帧', { - n: frameCount.current, - state: decoder?.state ?? 'null', - }); - return; - } + if (decoder?.state !== 'configured') return; if (au.keyframe) configured.current = true; if (!configured.current) return; @@ -480,18 +337,9 @@ export function useCallVideo(options: { data: payload, }), ); - if (frameCount.current <= 5) { - trace('info', '已喂入解码器', { - n: frameCount.current, - key: au.keyframe, - bytes: payload.length, - params: paramSets.current.length, - }); - } } catch (error) { decodeFailures.current++; trace('warn', 'decode() 抛错', { - n: frameCount.current, failures: decodeFailures.current, error: String(error), }); @@ -509,7 +357,6 @@ export function useCallVideo(options: { trace('error', '视频订阅出错', { error: String(error) }); setState((prev) => ({ ...prev, error: String(error) })); }, - onStarted: () => trace('info', '视频订阅已建立'), }); useEffect(() => { diff --git a/tmp_hevc_vui.ts b/tmp_hevc_vui.ts deleted file mode 100644 index 3949d44ba..000000000 --- a/tmp_hevc_vui.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * 临时排查工具:解析 HEVC SPS 的 VUI 色彩字段。 - * - * 用途:验证「Windows 前 15 秒偏绿」是否与码流里声明/未声明色彩信息有关。 - * - * 用法: - * WEQ_CALL_VIDEO_DUMP=/tmp/vdump pnpm --filter @weq/desktop dev - * 通话后:npx tsx tmp_hevc_vui.ts /tmp/vdump/encoded.h265 - */ - -import { readFileSync } from 'node:fs'; - -interface Nal { - type: number; - data: Uint8Array; -} - -function splitHevcNals(data: Uint8Array): Nal[] { - const nals: Nal[] = []; - let i = 0; - let start = -1; - while (i < data.length) { - const four = - i + 4 <= data.length && - data[i] === 0 && - data[i + 1] === 0 && - data[i + 2] === 0 && - data[i + 3] === 1; - const three = - !four && i + 3 <= data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 1; - if (four || three) { - if (start >= 0) { - const body = data.subarray(start, i); - if (body.length >= 2) nals.push({ type: (body[0]! >> 1) & 0x3f, data: body }); - } - start = i + (four ? 4 : 3); - i = start; - continue; - } - i++; - } - if (start >= 0 && start < data.length) { - const body = data.subarray(start); - if (body.length >= 2) nals.push({ type: (body[0]! >> 1) & 0x3f, data: body }); - } - return nals; -} - -function removeEmulationPrevention(data: Uint8Array): Uint8Array { - const out = new Uint8Array(data.length); - let n = 0; - for (let i = 0; i < data.length; i++) { - if (i + 2 < data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3) { - out[n++] = 0; - out[n++] = 0; - i += 2; - continue; - } - out[n++] = data[i]!; - } - return out.subarray(0, n); -} - -class BitReader { - private pos = 0; - constructor(private readonly data: Uint8Array) {} - bit(): number { - if (this.pos >> 3 >= this.data.length) throw new Error('bit read past end'); - const b = (this.data[this.pos >> 3]! >> (7 - (this.pos & 7))) & 1; - this.pos++; - return b; - } - u(n: number): number { - let v = 0; - for (let i = 0; i < n; i++) v = (v << 1) | this.bit(); - return v; - } - ue(): number { - let zeros = 0; - while (this.bit() === 0) { - zeros++; - if (zeros > 32) throw new Error('ue too long'); - } - return (2 ** zeros - 1) + this.u(zeros); - } - se(): number { - const k = this.ue(); - return k & 1 ? (k + 1) / 2 : -(k / 2); - } - get bitsLeft(): number { - return this.data.length * 8 - this.pos; - } - skip(n: number): void { - this.pos += n; - } - get position(): number { - return this.pos; - } -} - -/** HEVC 的 profile_tier_level 解析,返回占用的位数。 */ -function parseProfileTierLevel(bits: BitReader, maxSubLayersMinus1: number): void { - bits.skip(2 + 1 + 5); // general_profile_space / tier_flag / profile_idc - bits.skip(32); // general_profile_compatibility_flags - bits.skip(48); // general_constraint_indicator_flags - bits.skip(8); // general_level_idc - const profilePresent: boolean[] = []; - const levelPresent: boolean[] = []; - for (let i = 0; i < maxSubLayersMinus1; i++) { - profilePresent.push(bits.bit() === 1); - levelPresent.push(bits.bit() === 1); - } - if (maxSubLayersMinus1 > 0) { - for (let i = maxSubLayersMinus1; i < 8; i++) bits.skip(2); // reserved_zero_2bits - } - for (let i = 0; i < maxSubLayersMinus1; i++) { - if (profilePresent[i]) bits.skip(2 + 1 + 5 + 32 + 48); - if (levelPresent[i]) bits.skip(8); - } -} - -const COLOR_PRIMARIES: Record = { - 1: 'BT.709', - 2: 'Unspecified', - 4: 'BT.470M', - 5: 'BT.470BG', - 6: 'SMPTE170M', - 7: 'SMPTE240M', - 8: 'Film', - 9: 'BT.2020', - 10: 'SMPTE428', - 11: 'SMPTE431', - 12: 'SMPTE432', - 22: 'EBU3213', -}; - -const TRANSFER: Record = { - 1: 'BT.709', - 2: 'Unspecified', - 4: 'Gamma 2.2', - 5: 'Gamma 2.8', - 6: 'SMPTE170M', - 7: 'SMPTE240M', - 8: 'Linear', - 9: 'Log100', - 10: 'Log316', - 11: 'IEC61966-2-4', - 12: 'BT.1361E', - 13: 'IEC61966-2-1 (sRGB)', - 14: 'BT.2020 10bit', - 15: 'BT.2020 12bit', - 16: 'SMPTE2084 (PQ)', - 17: 'SMPTE428', - 18: 'HLG', -}; - -const MATRIX: Record = { - 0: 'GBR (identity)', - 1: 'BT.709', - 2: 'Unspecified', - 4: 'FCC', - 5: 'BT.470BG', - 6: 'SMPTE170M', - 7: 'SMPTE240M', - 8: 'YCgCo', - 9: 'BT.2020 NCL', - 10: 'BT.2020 CL', - 11: 'SMPTE2085', - 12: 'Chroma-derived NCL', - 13: 'Chroma-derived CL', - 14: 'ICtCp', -}; - -interface VuiInfo { - present: boolean; - videoSignalTypePresent?: boolean; - videoFormat?: number; - videoFullRangeFlag?: number; - colourPrimaries?: number; - transferCharacteristics?: number; - matrixCoeffs?: number; - chromaLocInfoPresent?: boolean; - parsedBits?: number; -} - -function parseSpsVui(rbsp: Uint8Array): { - width: number; - height: number; - chromaFormat: number; - bitDepthLuma: number; - bitDepthChroma: number; - vui: VuiInfo; - error?: string; -} { - const bits = new BitReader(rbsp); - const result = { - width: 0, - height: 0, - chromaFormat: -1, - bitDepthLuma: -1, - bitDepthChroma: -1, - vui: { present: false } as VuiInfo, - }; - try { - bits.skip(4); // sps_video_parameter_set_id - const maxSubLayersMinus1 = bits.u(3); - bits.skip(1); // sps_temporal_id_nesting_flag - parseProfileTierLevel(bits, maxSubLayersMinus1); - - bits.ue(); // sps_seq_parameter_set_id - const chromaFormat = bits.ue(); - result.chromaFormat = chromaFormat; - if (chromaFormat === 3) bits.skip(1); // separate_colour_plane_flag - result.width = bits.ue(); - result.height = bits.ue(); - if (bits.bit() === 1) { - // conformance_window_flag - bits.ue(); - bits.ue(); - bits.ue(); - bits.ue(); - } - result.bitDepthLuma = bits.ue() + 8; - result.bitDepthChroma = bits.ue() + 8; - const log2MaxPoc = bits.ue() + 4; - const subLayerOrdering = bits.bit(); - if (subLayerOrdering) { - for (let i = 0; i <= maxSubLayersMinus1; i++) { - bits.ue(); - bits.ue(); - bits.ue(); - } - } - bits.ue(); // log2_min_luma_coding_block_size_minus3 - bits.ue(); // log2_diff_max_min_luma_coding_block_size - bits.ue(); // log2_min_luma_transform_block_size_minus2 - bits.ue(); // log2_diff_max_min_luma_transform_block_size - bits.ue(); // max_transform_hierarchy_depth_inter - bits.ue(); // max_transform_hierarchy_depth_intra - void log2MaxPoc; - - if (bits.bit() === 1) { - // scaling_list_enabled_flag - if (bits.bit() === 1) { - // sps_scaling_list_data_present_flag - for (let sizeId = 0; sizeId < 4; sizeId++) { - for (let matrixId = 0; matrixId < 6; matrixId += sizeId === 3 ? 3 : 1) { - if (bits.bit() === 0) { - bits.ue(); // scaling_list_pred_matrix_id_delta - } else { - const coefNum = Math.min(64, 1 << (4 + (sizeId << 1))); - if (sizeId > 1) bits.se(); // scaling_list_dc_coef_minus8 - for (let i = 0; i < coefNum; i++) bits.se(); - } - } - } - } - } - bits.skip(1); // amp_enabled_flag - bits.skip(1); // sample_adaptive_offset_enabled_flag - if (bits.bit() === 1) { - // pcm_enabled_flag - bits.skip(4 + 4); // pcm_sample_bit_depth_luma/chroma_minus1 - bits.ue(); // log2_min_pcm_luma_coding_block_size_minus3 - bits.ue(); // log2_diff_max_min_pcm_luma_coding_block_size - bits.skip(1); // pcm_loop_filter_disabled_flag - } - const numShortTermRps = bits.ue(); - if (numShortTermRps > 64) throw new Error(`sps short term rps 异常: ${numShortTermRps}`); - for (let i = 0; i < numShortTermRps; i++) { - const interRefPicSetPrediction = i !== 0 ? bits.bit() : 0; - if (interRefPicSetPrediction) throw new Error('inter_ref_pic_set_prediction 未实现'); - const numNegative = bits.ue(); - const numPositive = bits.ue(); - for (let j = 0; j < numNegative + numPositive; j++) { - bits.ue(); // delta_poc_sX_minus1 - bits.skip(1); // used_by_curr_pic_sX_flag - } - } - if (bits.bit() === 1) { - // long_term_ref_pics_present_flag - const numLongTerm = bits.ue(); - for (let i = 0; i < numLongTerm; i++) { - bits.skip(log2MaxPoc); - bits.skip(1); - } - } - bits.skip(1); // sps_temporal_mvp_enabled_flag - bits.skip(1); // strong_intra_smoothing_enabled_flag - const vuiPresent = bits.bit(); - result.vui.present = vuiPresent === 1; - if (vuiPresent !== 1) return result; - - const vui: VuiInfo = { present: true }; - if (bits.bit() === 1) { - // aspect_ratio_info_present_flag - const idc = bits.u(8); - if (idc === 255) bits.skip(32); - } - if (bits.bit() === 1) bits.skip(1); // overscan_info_present_flag + overscan_appropriate - if (bits.bit() === 1) { - // video_signal_type_present_flag - vui.videoSignalTypePresent = true; - vui.videoFormat = bits.u(3); - vui.videoFullRangeFlag = bits.bit(); - if (bits.bit() === 1) { - // colour_description_present_flag - vui.colourPrimaries = bits.u(8); - vui.transferCharacteristics = bits.u(8); - vui.matrixCoeffs = bits.u(8); - } - } else { - vui.videoSignalTypePresent = false; - } - if (bits.bit() === 1) { - // chroma_loc_info_present_flag - vui.chromaLocInfoPresent = true; - bits.ue(); - bits.ue(); - } - vui.parsedBits = bits.position; - result.vui = vui; - return result; - } catch (error) { - result.error = error instanceof Error ? error.message : String(error); - return result; - } -} - -function analyze(path: string): void { - const raw = new Uint8Array(readFileSync(path)); - const nals = splitHevcNals(raw); - console.log(`文件: ${path} 共 ${raw.length} 字节 / ${nals.length} 个 NAL\n`); - - const counts = new Map(); - for (const nal of nals) counts.set(nal.type, (counts.get(nal.type) ?? 0) + 1); - console.log('NAL 类型分布:'); - for (const [type, n] of [...counts.entries()].sort((a, b) => a[0] - b[0])) { - const name = - type === 32 ? 'VPS' : type === 33 ? 'SPS' : type === 34 ? 'PPS' : type <= 21 ? 'IRAP/VCL' : ''; - console.log(` type=${type}${name ? ` (${name})` : ''} × ${n}`); - } - - // 逐帧列出关键帧的位置和 SPS - let frameIndex = 0; - let seenSps = 0; - for (const nal of nals) { - if (nal.type === 33) { - seenSps++; - const rbsp = removeEmulationPrevention(nal.data.subarray(2)); - const sps = parseSpsVui(rbsp); - console.log(`\n=== 第 ${seenSps} 个 SPS(NAL 序号 ${frameIndex},${nal.data.length} 字节)===`); - if (sps.error) { - console.log(` 解析中断: ${sps.error}`); - } - console.log( - ` 分辨率 ${sps.width}x${sps.height} chroma=${sps.chromaFormat} bitDepth=${sps.bitDepthLuma}/${sps.bitDepthChroma}`, - ); - const v = sps.vui; - if (!v.present) { - console.log(' ❌ VUI 不存在 —— 码流未声明任何色彩信息'); - } else { - console.log(' VUI 存在'); - if (v.videoSignalTypePresent === false) { - console.log(' ❌ video_signal_type_present_flag = 0 —— 未声明色彩空间'); - } else if (v.videoSignalTypePresent) { - console.log(` video_format=${v.videoFormat} full_range=${v.videoFullRangeFlag}`); - if (v.colourPrimaries === undefined) { - console.log(' ❌ colour_description_present_flag = 0 —— 未声明 primaries/transfer/matrix'); - } else { - console.log( - ` colour_primaries=${v.colourPrimaries} (${COLOR_PRIMARIES[v.colourPrimaries] ?? '?'})`, - ); - console.log( - ` transfer_characteristics=${v.transferCharacteristics} (${TRANSFER[v.transferCharacteristics!] ?? '?'})`, - ); - console.log(` matrix_coeffs=${v.matrixCoeffs} (${MATRIX[v.matrixCoeffs!] ?? '?'})`); - } - } - } - } - if (nal.type <= 31) frameIndex++; - } -} - -const path = process.argv[2]; -if (!path) { - console.error('用法: npx tsx tmp_hevc_vui.ts '); - process.exit(1); -} -analyze(path); From 62d0d7a9adddf46879c7a5654cae4f029ff5ef22 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Mon, 26 Oct 2026 23:45:12 +0800 Subject: [PATCH 16/23] =?UTF-8?q?feat(msg):=20=E4=BD=8D=E7=BD=AE=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E5=8D=8F=E8=AE=AE=EF=BC=88=E5=8F=91=E8=B5=B7=20/=20?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=20/=20=E6=9F=A5=E8=AF=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QQLBSShareSvc 三条 trpc 命令,私聊与群聊共用,全部不签名: room_operation 发起(op=1,interval=2,经度/纬度 double) report_location 周期上报自己(响应带下次间隔,实测 2 秒) room_query 拉房间成员坐标(唯一能拿到别人位置的途径) 会话键两种场景不同,是最容易踩错的地方: room_operation / room_query 私聊 {1:2, 2:peerUin, 3:selfUin} 群聊 {1:1, 2:groupCode, 3:0} report_location 两种场景都是 {1:2, 2:会话号, 3:selfUin} 房间不由服务端分配 —— 私聊的房间就是「这两个人」,群聊的就是「这个群」, 后续命令靠调用方自己拼同一个会话键定位。 没有「退出共享」:房间靠上报续命,停上报约 60 秒后服务端把 status 置 2 (已结束)。真实客户端的结束动作走推送 ACK,拿不到,也不实现。 ## 消息卡片要自己发 真实客户端发起后由服务端 MsgPush 投递「发起了位置共享」消息,但 WeQ 的 SSO 是「发一包就断」,服务端判定发起方不在线就不投递。所以 sendMessage 自己发: commonElem serviceType=31 pbElem={1:"发起了位置共享"} businessType=1 私聊只此一条就能渲染成可点卡片;群聊必须额外带 generalFlags(37),否则 能显示但点不了(实测差异就在这一块)。 ## 顺带补的两处 - protobuf 增加 double / float 标量(wire type 1/5)。此前只支持 varint 与 len-delimited,经纬度编不出来。 - FONT_INFO / GENERAL_FLAGS 补齐观测到的 tag。encode() 只按 schema 的 field.name 取值,没声明的 tag 会被静默丢弃 —— 群聊卡片点不了就是这个原因。 --- packages/protocol/src/msg/index.ts | 22 + packages/protocol/src/msg/lbs-share.ts | 464 ++++++++++++++++++++++ packages/protocol/src/msg/schemas.ts | 49 +++ packages/protocol/src/protobuf.ts | 84 +++- packages/service/src/account/lbs_share.ts | 149 +++++++ packages/service/src/index.ts | 8 + packages/service/test/lbs_share.test.ts | 188 +++++++++ scripts/lbs_share.ts | 203 ++++++++++ 8 files changed, 1166 insertions(+), 1 deletion(-) create mode 100644 packages/protocol/src/msg/lbs-share.ts create mode 100644 packages/service/src/account/lbs_share.ts create mode 100644 packages/service/test/lbs_share.test.ts create mode 100644 scripts/lbs_share.ts diff --git a/packages/protocol/src/msg/index.ts b/packages/protocol/src/msg/index.ts index 7cffda9ec..65ca31104 100644 --- a/packages/protocol/src/msg/index.ts +++ b/packages/protocol/src/msg/index.ts @@ -134,6 +134,28 @@ export { export { RecallGroup, RecallPrivate } from './recall'; export type { RecallGroupParams, RecallPrivateParams } from './recall'; +export { + StartLocationShare, + ReportLocation, + QueryLocation, + buildShareLocationElements, + LBS_SCENE_GROUP, + LBS_SCENE_C2C, + LbsRoomStatus, + SHARE_LOCATION_TEXT, +} from './lbs-share'; +export type { + LbsScene, + LbsSession, + StartLocationShareParams, + LocationShareStartResult, + ReportLocationParams, + LocationReportResult, + QueryLocationParams, + LocationRoom, + LocationRoomMember, +} from './lbs-share'; + export { dumpProto, walkProto, extractPath, protoToJson } from './dump'; export type { DumpOptions, diff --git a/packages/protocol/src/msg/lbs-share.ts b/packages/protocol/src/msg/lbs-share.ts new file mode 100644 index 000000000..ae2ca719f --- /dev/null +++ b/packages/protocol/src/msg/lbs-share.ts @@ -0,0 +1,464 @@ +/** + * 位置共享(`QQLBSShareSvc`)—— 私聊 / 群聊两条场景共用一套命令。 + * + * 三条命令都是 **raw SSO**(不套 OIDB 信封),`needSign = false`: + * + * QQLBSShareSvc.room_operation 发起 / 进入共享 + * QQLBSShareSvc.report_location 周期上报自己的坐标 + * QQLBSShareSvc.query_location 拉取房间里所有成员的最新坐标(命令名见下) + * + * ## 会话键(三条命令的 field 1) + * + * `room_operation` 与查询用**场景键**: + * + * 私聊 `{ 1: 2(scene C2C), 2: peerUin, 3: selfUin }` + * 群聊 `{ 1: 1(scene 群), 2: groupCode, 3: 0 }` + * + * `report_location` 例外 —— 抓包里两种场景的 `1.1` 都是 `2`、`1.3` 都是 + * `selfUin`,只有 `1.2` 换成群号。所以它单独用 {@link buildReportKey}。 + * + * ## 房间不是服务端分配的 + * + * 服务端**不返回房间号**:私聊的房间就是「这两个人」,群聊的就是「这个群」。 + * `room_operation` 的响应只有 `{ code, message }`,后续所有命令靠调用方自己拼 + * 同一个会话键来定位。 + * + * ## 生命周期 + * + * 房间靠 `report_location` 心跳续命(响应里的 `interval` 告诉下次多久再报, + * 实测 2 秒)。停止上报约 60 秒后服务端把房间标记为已结束(查询响应 `status` + * 从 1 变 2),所以**「退出共享」不需要单独的命令** —— 不报即可。 + * + * ## 坐标 + * + * **GCJ-02**,**经度在前**,double。`report_location` 的 field 4 是每次调用都 + * 变的 double(实测 70~255),服务端原样存下并在查询响应的成员条目 field 5 回显; + * 语义未确认,疑似定位精度半径。给个稳定值即可,服务端不校验。 + * + * ## 消息卡片不在这里 + * + * 真实客户端发起后,服务端会用 `MsgPush` 投递一条「发起了位置共享」消息。但 + * WeQ 的 SSO 是「发一包就断」,服务端判定发起方不在线就不投递 —— 所以**卡片 + * 由调用方自己 `sendMessage` 发**,元素构造见 {@link buildShareLocationElements}。 + */ +import { message } from '../protobuf'; +import { invokeTrpc, type TrpcSpec } from '../oidb/invoke'; +import type { TrpcNative } from '../transport'; +import type { SendElement } from './send-elements'; + +// ───────────────────────── 公共 schema ───────────────────────── + +/** 会话键(`room_operation` / 查询用)。 */ +const SESSION_KEY = message([ + { name: 'scene', tag: 1, type: 'uint32' }, + { name: 'peer', tag: 2, type: 'uint64' }, + { name: 'selfUin', tag: 3, type: 'uint64' }, +]); + +/** 上报用的会话键(`1.1` 恒为 2,`1.3` 恒为 selfUin)。 */ +const REPORT_KEY = message([ + { name: 'scene', tag: 1, type: 'uint32' }, + { name: 'peer', tag: 2, type: 'uint64' }, + { name: 'selfUin', tag: 3, type: 'uint64' }, +]); + +/** 统一的 `{ code, message }` 结果块。 */ +const RESULT = message([ + { name: 'code', tag: 1, type: 'uint32' }, + { name: 'message', tag: 2, type: 'string' }, +]); + +/** `scene` 取值:群聊。 */ +export const LBS_SCENE_GROUP = 1; +/** `scene` 取值:私聊。 */ +export const LBS_SCENE_C2C = 2; + +/** 房间状态(查询响应 field 5)。 */ +export const LbsRoomStatus = { + /** 进行中。 */ + ACTIVE: 1, + /** 已结束(停止上报约 60 秒后服务端置为此值)。 */ + ENDED: 2, +} as const; + +/** 共享场景。 */ +export type LbsScene = 'c2c' | 'group'; + +/** + * 会话键参数。私聊给 `peerUin` + `selfUin`;群聊给 `groupId`(`selfUin` 不参与)。 + */ +export interface LbsSession { + scene: LbsScene; + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: number; + /** 私聊必填(自己 QQ 号);群聊忽略。 */ + selfUin?: number; +} + +/** 把 {@link LbsSession} 归一成 wire 上的会话键。 */ +function sessionKey(s: LbsSession): Record { + if (!Number.isSafeInteger(s.peer) || s.peer <= 0) { + throw new Error(`peer 必须是正整数,收到 ${String(s.peer)}`); + } + if (s.scene === 'group') { + return { scene: LBS_SCENE_GROUP, peer: s.peer, selfUin: 0 }; + } + if (!Number.isSafeInteger(s.selfUin) || (s.selfUin ?? 0) <= 0) { + throw new Error('私聊位置共享必须给 selfUin(自己的 QQ 号)'); + } + return { scene: LBS_SCENE_C2C, peer: s.peer, selfUin: s.selfUin }; +} + +/** 上报用的会话键:`1.1` 恒为 2、`1.3` 恒为自己。 */ +function reportKey(s: LbsSession): Record { + if (!Number.isSafeInteger(s.selfUin) || (s.selfUin ?? 0) <= 0) { + throw new Error('report_location 必须给 selfUin(自己的 QQ 号)'); + } + return { scene: LBS_SCENE_C2C, peer: s.peer, selfUin: s.selfUin }; +} + +// ───────────────────────── 发起 ───────────────────────── + +const ROOM_OPERATION_REQ = message([ + { name: 'session', tag: 1, type: SESSION_KEY }, + { name: 'op', tag: 2, type: 'uint32' }, + { name: 'interval', tag: 3, type: 'uint32' }, + { name: 'longitude', tag: 4, type: 'double' }, + { name: 'latitude', tag: 5, type: 'double' }, +]); +const ROOM_OPERATION_RESP = message([{ name: 'result', tag: 1, type: RESULT }]); + +export interface StartLocationShareParams extends LbsSession { + /** 经度(GCJ-02)。 */ + longitude: number; + /** 纬度(GCJ-02)。 */ + latitude: number; + /** 期望的上报间隔(秒)。抓包里固定 2。 */ + interval?: number; +} + +export interface LocationShareStartResult { + ok: boolean; + code: number; + message: string; +} + +/** + * 发起(或加入)一次位置共享。 + * + * 响应只有结果码 —— 房间不是服务端分配的,后续靠同一个会话键定位。 + */ +export namespace StartLocationShare { + export const cmd = 'QQLBSShareSvc.room_operation'; + export const needSign = false; + export const reqSchema = ROOM_OPERATION_REQ; + export const respSchema = ROOM_OPERATION_RESP; + + export type Params = StartLocationShareParams; + + export const serialize = (p: Params): Record => ({ + session: sessionKey(p), + op: 1, + interval: p.interval ?? 2, + longitude: requireCoord(p.longitude, 'longitude', 180), + latitude: requireCoord(p.latitude, 'latitude', 90), + }); + + export const deserialize = (body: Record): LocationShareStartResult => { + const result = (body.result ?? {}) as Record; + const code = Number(result.code ?? 0); + return { + ok: code === 0, + code, + message: typeof result.message === 'string' ? result.message : '', + }; + }; + + export const invoke = ( + nt: TrpcNative, + pid: number, + params: Params, + ): Promise => + invokeTrpc(nt, pid, StartLocationShare as TrpcSpec, params); +} + +// ───────────────────────── 上报 ───────────────────────── + +const REPORT_LOCATION_REQ = message([ + { name: 'session', tag: 1, type: REPORT_KEY }, + { name: 'longitude', tag: 2, type: 'double' }, + { name: 'latitude', tag: 3, type: 'double' }, + { name: 'extra', tag: 4, type: 'double' }, +]); +const REPORT_LOCATION_RESP = message([ + { name: 'result', tag: 1, type: RESULT }, + { name: 'interval', tag: 2, type: 'uint32' }, +]); + +export interface ReportLocationParams extends LbsSession { + /** 经度(GCJ-02)。 */ + longitude: number; + /** 纬度(GCJ-02)。 */ + latitude: number; + /** + * 语义未确认的 double(抓包实测每次调用都变,值域 70~255)。服务端原样存下 + * 并在查询响应的成员条目里回显,不参与校验。缺省给 0。 + */ + extra?: number; +} + +export interface LocationReportResult { + ok: boolean; + code: number; + message: string; + /** 服务端要求的下次上报间隔(秒);缺省 2。 */ + interval: number; +} + +/** + * 上报一次自己的坐标。**必须按服务端给的 `interval` 持续调用**,否则房间会超时结束。 + */ +export namespace ReportLocation { + export const cmd = 'QQLBSShareSvc.report_location'; + export const needSign = false; + export const reqSchema = REPORT_LOCATION_REQ; + export const respSchema = REPORT_LOCATION_RESP; + + export type Params = ReportLocationParams; + + export const serialize = (p: Params): Record => ({ + session: reportKey(p), + longitude: requireCoord(p.longitude, 'longitude', 180), + latitude: requireCoord(p.latitude, 'latitude', 90), + extra: p.extra ?? 0, + }); + + export const deserialize = (body: Record): LocationReportResult => { + const result = (body.result ?? {}) as Record; + const code = Number(result.code ?? 0); + return { + ok: code === 0, + code, + message: typeof result.message === 'string' ? result.message : '', + interval: Number(body.interval ?? 2) || 2, + }; + }; + + export const invoke = ( + nt: TrpcNative, + pid: number, + params: Params, + ): Promise => + invokeTrpc(nt, pid, ReportLocation as TrpcSpec, params); +} + +// ───────────────────────── 查询 ───────────────────────── + +/** 房间里的一个成员(查询响应 field 2,repeated)。 */ +const ROOM_MEMBER = message([ + { name: 'uin', tag: 1, type: 'uint64' }, + { name: 'joinedAt', tag: 2, type: 'uint32' }, + { name: 'longitude', tag: 3, type: 'double' }, + { name: 'latitude', tag: 4, type: 'double' }, + /** 回显该成员最近一次上报的 `extra`。 */ + { name: 'extra', tag: 5, type: 'double' }, + { name: 'reportedAt', tag: 6, type: 'uint32' }, +]); + +const QUERY_LOCATION_REQ = message([ + { name: 'session', tag: 1, type: SESSION_KEY }, + { name: 'mode', tag: 2, type: 'uint32' }, +]); +const QUERY_LOCATION_RESP = message([ + { name: 'result', tag: 1, type: RESULT }, + { name: 'members', tag: 2, type: ROOM_MEMBER, repeated: true }, + { name: 'selfUin', tag: 3, type: 'uint64' }, + { name: 'startedAt', tag: 4, type: 'uint32' }, + { name: 'status', tag: 5, type: 'uint32' }, + { name: 'field7', tag: 7, type: 'uint32' }, +]); + +export interface QueryLocationParams extends LbsSession { + /** + * 查询模式。`0` = 首次(还没上报过时成员条目坐标为 0),`1` = 轮询。 + * 抓包里客户端在发起后会并发发多条,这里按需调用即可。 + */ + mode?: 0 | 1; +} + +/** 房间成员。 */ +export interface LocationRoomMember { + /** 成员 QQ 号。 */ + uin: number; + /** 进入共享的时间(unix 秒)。 */ + joinedAt: number; + /** 经度(GCJ-02);没上报过时为 0。 */ + longitude: number; + /** 纬度(GCJ-02);没上报过时为 0。 */ + latitude: number; + /** 该成员最近一次上报的 `extra`(语义未确认)。 */ + extra: number; + /** 最近一次上报时间(unix 秒)。 */ + reportedAt: number; +} + +export interface LocationRoom { + ok: boolean; + code: number; + message: string; + /** 房间成员(含自己)。 */ + members: LocationRoomMember[]; + /** 自己的 QQ 号。 */ + selfUin: number; + /** 共享开始时间(unix 秒)。 */ + startedAt: number; + /** 房间状态,见 {@link LbsRoomStatus}。 */ + status: number; + /** 未知字段,抓包里恒为 5。 */ + field7: number; +} + +/** + * 查询房间里所有成员的最新坐标。这是**唯一**能拿到别人位置的方式 —— + * 位置不会通过消息推送过来。 + */ +export namespace QueryLocation { + export const cmd = 'QQLBSShareSvc.room_query'; + export const needSign = false; + export const reqSchema = QUERY_LOCATION_REQ; + export const respSchema = QUERY_LOCATION_RESP; + + export type Params = QueryLocationParams; + + export const serialize = (p: Params): Record => ({ + session: sessionKey(p), + mode: p.mode ?? 1, + }); + + export const deserialize = (body: Record): LocationRoom => { + const result = (body.result ?? {}) as Record; + const code = Number(result.code ?? 0); + const rawMembers = Array.isArray(body.members) ? body.members : []; + return { + ok: code === 0, + code, + message: typeof result.message === 'string' ? result.message : '', + members: rawMembers.map((raw) => { + const m = (raw ?? {}) as Record; + return { + uin: Number(m.uin ?? 0), + joinedAt: Number(m.joinedAt ?? 0), + longitude: Number(m.longitude ?? 0), + latitude: Number(m.latitude ?? 0), + extra: Number(m.extra ?? 0), + reportedAt: Number(m.reportedAt ?? 0), + }; + }), + selfUin: Number(body.selfUin ?? 0), + startedAt: Number(body.startedAt ?? 0), + status: Number(body.status ?? 0), + field7: Number(body.field7 ?? 0), + }; + }; + + export const invoke = (nt: TrpcNative, pid: number, params: Params): Promise => + invokeTrpc(nt, pid, QueryLocation as TrpcSpec, params); +} + +// ───────────────────────── 消息卡片元素 ───────────────────────── + +/** + * 「发起了位置共享」灰条的 `pbElem` 字节 = `{ 1: "发起了位置共享" }`。 + * + * 灰条本身不是独立协议,就是 `commonElem serviceType=31` —— 客户端按文案 + + * 当前会话的共享状态渲染成位置共享卡片。 + */ +const SHARE_LOCATION_TIP_PB = Uint8Array.from([ + 0x0a, 0x15, 0xe5, 0x8f, 0x91, 0xe8, 0xb5, 0xb7, 0xe4, 0xba, 0x86, 0xe4, 0xbd, 0x8d, 0xe7, 0xbd, + 0xae, 0xe5, 0x85, 0xb1, 0xe4, 0xba, 0xab, +]); + +/** 灰条文案。 */ +export const SHARE_LOCATION_TEXT = '发起了位置共享'; + +/** + * 构造「发起了位置共享」消息的元素。 + * + * **私聊**只要灰条 + 文本兜底就够(客户端会渲染成可点的卡片)。 + * + * **群聊必须额外带 `generalFlags(37)`**,否则能显示但**点不了** —— 实测差异就在 + * 这一块。里面 `font.token116` 对应消息头的 `1.2.32`(服务端下发时回填的同一个 + * 8 字节值),客户端编不出来;这里给 0 由服务端重新生成。 + * + * 注意 `encode()` 只按 schema 的 `field.name` 取值,`FONT_INFO` / `GENERAL_FLAGS` + * 里没声明的 tag 会被**静默丢弃** —— 所以这两个 schema 必须保留位置共享用到的那 + * 一批 tag(见 `./schemas`)。 + */ +export function buildShareLocationElements(scene: LbsScene): SendElement[] { + const elements: SendElement[] = [ + { + kind: 'raw', + elem: { + commonElem: { + serviceType: 31, + pbElem: SHARE_LOCATION_TIP_PB, + businessType: 1, + }, + }, + }, + ]; + + if (scene === 'group') { + elements.push({ + kind: 'raw', + elem: { + generalFlags: { + bubbleDiyTextId: 0, + groupFlagNew: 0, + flag10: 0, + flag12: 0, + flag13: 0, + widgetId: 104228, + font: { + flag1: 0, + flag4: 10315, + fontId2: 65536, + flag20: 36097, + flag25: 0, + flag30: 0, + flag31: 0, + flag34: 0, + flag51: 337, + flag52: 4, + flag54: 0, + fontId1: 0, + flag58: 0, + flag61: 0, + nested65: { flag2: 14 }, + flag66: 100664448, + flag71: 2, + flag72: 0, + nested73: { flag2: 0, flag6: 4 }, + flag79: 163904, + flag81: 16, + nested102: { inner: { a: 0, b: 0, c: 0, d: 0 } }, + flag107: 1100, + token116: 0n, + }, + }, + }, + }); + } + + elements.push({ kind: 'raw', elem: { text: { str: SHARE_LOCATION_TEXT } } }); + return elements; +} + +// ───────────────────────── 校验 ───────────────────────── + +function requireCoord(value: number, what: string, limit: number): number { + if (!Number.isFinite(value) || Math.abs(value) > limit) { + throw new Error(`${what} 必须是 ±${limit} 内的有限数,收到 ${String(value)}`); + } + return value; +} diff --git a/packages/protocol/src/msg/schemas.ts b/packages/protocol/src/msg/schemas.ts index f81dacdc2..1553eaec5 100644 --- a/packages/protocol/src/msg/schemas.ts +++ b/packages/protocol/src/msg/schemas.ts @@ -568,9 +568,55 @@ export const INLINE_KEYBOARD_GROUP: ProtoMessage = message([ /** commonElem(serviceType=46).pbElem 顶层。 */ export const INLINE_KEYBOARD_PB: ProtoMessage = message([f('group', 1, INLINE_KEYBOARD_GROUP)]); +/** + * generalFlags(37).font —— 名字沿用最早观测到的字体语义(15/56 确实是字体 id), + * 但**这一格实际还承载别的东西**:位置共享消息的 generalFlags.19 里塞的是一组 + * 业务参数(19.4 / 19.51 / 19.66 / 19.79 / 19.107 …),跟字体无关。 + * + * 这里把观测到的 tag 全部声明出来(加法,不影响既有解码路径)—— 发送侧 + * `encode()` 只按 `field.name` 取值,**没声明的 tag 会被静默丢弃**,群聊的 + * 位置共享卡片因此发不出去(实测:缺这些字段时卡片不可点)。 + */ export const FONT_INFO: ProtoMessage = message([ + f('flag1', 1, 'uint32'), + f('flag4', 4, 'uint32'), f('fontId2', 15, 'uint32'), + f('flag20', 20, 'uint32'), + f('flag25', 25, 'uint32'), + f('flag30', 30, 'uint32'), + f('flag31', 31, 'uint32'), + f('flag34', 34, 'uint32'), + f('flag51', 51, 'uint32'), + f('flag52', 52, 'uint32'), + f('flag54', 54, 'uint32'), f('fontId1', 56, 'uint32'), + f('flag58', 58, 'uint32'), + f('flag61', 61, 'uint32'), + f('nested65', 65, message([f('flag2', 2, 'uint32')])), + f('flag66', 66, 'uint64'), + f('flag71', 71, 'uint32'), + f('flag72', 72, 'uint32'), + f('nested73', 73, message([f('flag2', 2, 'uint32'), f('flag6', 6, 'uint32')])), + f('flag79', 79, 'uint32'), + f('flag81', 81, 'uint32'), + f( + 'nested102', + 102, + message([ + f( + 'inner', + 1, + message([ + f('a', 1, 'uint32'), + f('b', 2, 'uint32'), + f('c', 3, 'uint32'), + f('d', 4, 'uint32'), + ]), + ), + ]), + ), + f('flag107', 107, 'uint32'), + f('token116', 116, 'uint64'), ]); export const GENERAL_FLAGS: ProtoMessage = message([ @@ -579,6 +625,9 @@ export const GENERAL_FLAGS: ProtoMessage = message([ f('uin', 3, 'uint64'), f('longTextFlag', 6, 'int32'), f('longTextResId', 7, 'string'), + f('flag10', 10, 'int32'), + f('flag12', 12, 'int32'), + f('flag13', 13, 'int32'), f('widgetId', 17, 'uint32'), f('font', 19, FONT_INFO), ]); diff --git a/packages/protocol/src/protobuf.ts b/packages/protocol/src/protobuf.ts index c40cbbf75..57fedba43 100644 --- a/packages/protocol/src/protobuf.ts +++ b/packages/protocol/src/protobuf.ts @@ -28,7 +28,11 @@ export type ScalarType = | 'sint64' | 'bool' | 'string' - | 'bytes'; + | 'bytes' + /** IEEE-754 双精度,wire type 1(小端 8 字节)。位置共享的经纬度用它。 */ + | 'double' + /** IEEE-754 单精度,wire type 5(小端 4 字节)。 */ + | 'float'; /** A nested-message field type. Use {@link message} to build one. */ export interface ProtoMessage { @@ -58,11 +62,15 @@ function isMessage(type: ScalarType | ProtoMessage): type is ProtoMessage { } const WIRE_VARINT = 0; +const WIRE_64BIT = 1; const WIRE_LEN = 2; +const WIRE_32BIT = 5; /** Wire type a scalar/message encodes to. */ function wireOf(type: ScalarType | ProtoMessage): number { if (isMessage(type)) return WIRE_LEN; + if (type === 'double') return WIRE_64BIT; + if (type === 'float') return WIRE_32BIT; return type === 'string' || type === 'bytes' ? WIRE_LEN : WIRE_VARINT; } @@ -89,6 +97,16 @@ class Writer { for (let i = 0; i < bytes.length; i++) this.buf.push(bytes[i]!); } + /** 固定 8 字节,小端(wire type 1,protobuf 的 double/fixed64)。 */ + fixed64(value: bigint): void { + for (let i = 0n; i < 8n; i++) this.buf.push(Number((value >> (i * 8n)) & 0xffn)); + } + + /** 固定 4 字节,小端(wire type 5,protobuf 的 float/fixed32)。 */ + fixed32(value: number): void { + for (let i = 0; i < 4; i++) this.buf.push((value >>> (i * 8)) & 0xff); + } + finish(): Uint8Array { return Uint8Array.from(this.buf); } @@ -101,6 +119,34 @@ function toBigInt(v: number | bigint | string): bigint { return typeof v === 'bigint' ? v : BigInt(v); } +/** IEEE-754 double → 64 位无符号整数(按位,不做数值转换)。 */ +function doubleBits(d: number): bigint { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, d, true); + return view.getBigUint64(0, true); +} + +/** 64 位无符号整数 → IEEE-754 double。 */ +function bitsToDouble(bits: bigint): number { + const view = new DataView(new ArrayBuffer(8)); + view.setBigUint64(0, BigInt.asUintN(64, bits), true); + return view.getFloat64(0, true); +} + +/** IEEE-754 float → 32 位无符号整数。 */ +function floatBits(f: number): number { + const view = new DataView(new ArrayBuffer(4)); + view.setFloat32(0, f, true); + return view.getUint32(0, true); +} + +/** 32 位无符号整数 → IEEE-754 float。 */ +function bitsToFloat(bits: number): number { + const view = new DataView(new ArrayBuffer(4)); + view.setUint32(0, bits >>> 0, true); + return view.getFloat32(0, true); +} + /** zigzag-encode a signed bigint into its unsigned varint form. */ function zigzag(n: bigint): bigint { return BigInt.asUintN(64, (n << 1n) ^ (n >> 63n)); @@ -147,6 +193,24 @@ function writeScalar( } return; } + case 'double': { + const d = Number(value); + // proto3 默认省略 0;用 +0 比较让 -0 也算 0(IEEE 的 -0 在 wire 上有区别, + // 但业务上没人靠它区分,省一字节)。 + if (force || d !== 0) { + w.tag(tag, WIRE_64BIT); + w.fixed64(doubleBits(d)); + } + return; + } + case 'float': { + const f = Math.fround(Number(value)); + if (force || f !== 0) { + w.tag(tag, WIRE_32BIT); + w.fixed32(floatBits(f)); + } + return; + } default: { // int32 / uint32 / int64 / uint64 — two's-complement to 64 bits so that // negatives are sign-extended exactly like protobuf expects. @@ -222,6 +286,20 @@ class Reader { return out; } + /** 读固定 8 字节小端(wire type 1)。 */ + fixed64(): bigint { + const view = new DataView(this.data.buffer, this.data.byteOffset + this.pos, 8); + this.pos += 8; + return view.getBigUint64(0, true); + } + + /** 读固定 4 字节小端(wire type 5)。 */ + fixed32(): number { + const view = new DataView(this.data.buffer, this.data.byteOffset + this.pos, 4); + this.pos += 4; + return view.getUint32(0, true); + } + skip(wire: number): void { switch (wire) { case 0: @@ -267,6 +345,10 @@ function readScalar(r: Reader, type: ScalarType): unknown { return r.varint(); case 'sint64': return BigInt.asIntN(64, unzigzag(r.varint())); + case 'double': + return bitsToDouble(r.fixed64()); + case 'float': + return bitsToFloat(r.fixed32()); } } diff --git a/packages/service/src/account/lbs_share.ts b/packages/service/src/account/lbs_share.ts new file mode 100644 index 000000000..0b1a193e3 --- /dev/null +++ b/packages/service/src/account/lbs_share.ts @@ -0,0 +1,149 @@ +/** + * LocationShareService —— 位置共享(`QQLBSShareSvc`):发起 / 上报 / 查询。 + * + * 与 {@link GroupModerationService} 同构:只负责在在线 pid 上发包,离线 / 风控失败 + * 原样上抛。协议细节见 `@weq/protocol` 的 `msg/lbs-share.ts`。 + * + * ## 三条命令的分工 + * + * {@link start} 建房间(私聊 / 群聊同一套命令,只有会话键不同) + * {@link report} 周期上报自己的坐标 —— **必须持续调用**,否则房间超时结束 + * {@link query} 拉取房间里所有成员的坐标 —— 唯一能拿到别人位置的途径 + * + * ## 没有「退出共享」 + * + * 房间靠上报续命,停止上报约 60 秒后服务端自己把状态置为 + * {@link LbsRoomStatus.ENDED}。所以退出 = 停掉心跳,不需要额外命令。 + * + * ## 卡片要单独发 + * + * 真实客户端发起后,服务端会推一条「发起了位置共享」消息;但 WeQ 的 SSO 是 + * 「发一包就断」,服务端判定发起方不在线就不投递。所以这里额外提供 + * {@link sendShareCard},由调用方自己发消息卡片(元素见 + * `buildShareLocationElements`)。 + * + * ## 调用方注意 + * + * 本服务**不维护定时器、也不缓存房间状态** —— 心跳节拍由调用方自己驱动 + * (服务端在 `report` 的响应里给出 `interval`,实测 2 秒)。这样做的原因: + * 渲染层/主进程谁持有会话谁负责生命周期,服务里藏一个 setInterval 会让 + * 「切账号 / 关窗口」时的清理变得不可追踪。 + */ +import type { NtHelperBinding } from '@weq/native'; +import { + buildShareLocationElements, + QueryLocation, + ReportLocation, + StartLocationShare, + type LbsScene, + type LocationReportResult, + type LocationRoom, + type LocationShareStartResult, +} from '@weq/protocol'; + +/** 一次共享的会话定位。 */ +export interface LocationShareTarget { + /** c2c = 私聊,group = 群聊。 */ + scene: LbsScene; + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: number; + /** 自己的 QQ 号;私聊必填,群聊可省(上报时仍需要)。 */ + selfUin: number; +} + +/** 发起共享的参数。 */ +export interface StartLocationShareServiceParams extends LocationShareTarget { + /** 经度(GCJ-02,**经度在前**)。 */ + longitude: number; + /** 纬度(GCJ-02)。 */ + latitude: number; + /** 期望上报间隔(秒);缺省 2。 */ + interval?: number; +} + +/** 上报一次坐标的参数。 */ +export interface ReportLocationServiceParams extends LocationShareTarget { + /** 经度(GCJ-02)。 */ + longitude: number; + /** 纬度(GCJ-02)。 */ + latitude: number; + /** 语义未确认的 double,缺省 0。 */ + extra?: number; +} + +/** 查询房间的参数。 */ +export interface QueryLocationServiceParams extends LocationShareTarget { + /** `0` = 首次,`1` = 轮询(缺省)。 */ + mode?: 0 | 1; +} + +/** 发卡片的参数。 */ +export interface SendShareCardParams extends LocationShareTarget { + /** 私聊需要对方 uid(有就带上,新版客户端以 uid 为准)。 */ + peerUid?: string; +} + +export class LocationShareService { + constructor( + private readonly nt: Pick, + private readonly resolvePid: () => number, + ) {} + + /** + * 发起一次位置共享。返回服务端的结果码(`ok=false` 时看 `code` / `message`)。 + * + * 发起后**立刻开始按 `interval` 调 {@link report}**,否则房间会超时。 + */ + async start(params: StartLocationShareServiceParams): Promise { + return StartLocationShare.invoke(this.nt, this.resolvePid(), { + scene: params.scene, + peer: params.peer, + selfUin: params.selfUin, + longitude: params.longitude, + latitude: params.latitude, + ...(params.interval !== undefined ? { interval: params.interval } : {}), + }); + } + + /** + * 上报一次坐标。响应里的 `interval` 是服务端要求的下次间隔,调用方按它排下一次。 + */ + async report(params: ReportLocationServiceParams): Promise { + return ReportLocation.invoke(this.nt, this.resolvePid(), { + scene: params.scene, + peer: params.peer, + selfUin: params.selfUin, + longitude: params.longitude, + latitude: params.latitude, + ...(params.extra !== undefined ? { extra: params.extra } : {}), + }); + } + + /** + * 查询房间里所有成员的坐标。 + * + * `status === LbsRoomStatus.ENDED` 说明房间已经结束(停止上报超时), + * 此时 `members` 会变空。 + */ + async query(params: QueryLocationServiceParams): Promise { + return QueryLocation.invoke(this.nt, this.resolvePid(), { + scene: params.scene, + peer: params.peer, + selfUin: params.selfUin, + ...(params.mode !== undefined ? { mode: params.mode } : {}), + }); + } + + /** + * 发一条「发起了位置共享」消息卡片。 + * + * 私聊只需要灰条 + 文本;群聊必须额外带 `generalFlags(37)`,否则能显示但点不了。 + * 具体元素由 `buildShareLocationElements` 构造(见 `@weq/protocol`)。 + * + * 这里**不直接调 sendMessage** —— 发消息要走 `MessageSendService`(它管 uid + * 解析、字体、装扮)。本方法只负责给出元素,实际发送由调用方转交。 + */ + buildShareCardElements(scene: LbsScene) { + return buildShareLocationElements(scene); + } +} diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 083bd273a..2d9bcb06a 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -798,6 +798,14 @@ export type { SendGroupJoinRequestServiceParams, } from './account/interaction'; export { GroupModerationService } from './account/group_moderation'; +export { LocationShareService } from './account/lbs_share'; +export type { + LocationShareTarget, + StartLocationShareServiceParams, + ReportLocationServiceParams, + QueryLocationServiceParams, + SendShareCardParams, +} from './account/lbs_share'; export { AccountWriteService } from './account/account_write'; export type { AccountWriteIdentity, AccountWriteOutcome } from './account/account_write'; export type { diff --git a/packages/service/test/lbs_share.test.ts b/packages/service/test/lbs_share.test.ts new file mode 100644 index 000000000..c64aa9bde --- /dev/null +++ b/packages/service/test/lbs_share.test.ts @@ -0,0 +1,188 @@ +/** + * LocationShareService 的离线单测:断言三条命令真正上 wire 的 cmd / body, + * 并钉住私聊与群聊的会话键差异。不连任何网络。 + * + * 报文里的每个字节都对着真机抓包核对过(见 `@weq/protocol` 的 `msg/lbs-share`)。 + */ + +import { describe, expect, it } from 'vitest'; +import { LocationShareService } from '../src/account/lbs_share'; +import { + buildShareLocationElements, + decode, + QueryLocation, + ReportLocation, + StartLocationShare, +} from '@weq/protocol'; + +interface TrpcCall { + pid: number; + cmd: string; + body: Uint8Array; + needSign: boolean; +} + +function fakeNative() { + const trpc: TrpcCall[] = []; + return { + trpc, + sendPacket: async ( + pid: number, + cmd: string, + body: Buffer, + needSign: boolean, + ): Promise => { + trpc.push({ pid, cmd, body: new Uint8Array(body), needSign }); + return Buffer.alloc(0); + }, + }; +} + +const PID = 4321; +const SELF = 2863253201; +const PEER = 1707889225; +const GROUP = 673646675; +const LNG = 103.931543; +const LAT = 30.75535; + +function makeService(nt: ReturnType) { + return new LocationShareService(nt, () => PID); +} + +describe('LocationShareService', () => { + it('start(c2c) 走 room_operation,不签名,会话键 {scene:2, peer, selfUin}', async () => { + const nt = fakeNative(); + await makeService(nt).start({ + scene: 'c2c', + peer: PEER, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + }); + + expect(nt.trpc).toHaveLength(1); + const call = nt.trpc[0]!; + expect(call.pid).toBe(PID); + expect(call.cmd).toBe('QQLBSShareSvc.room_operation'); + expect(call.needSign).toBe(false); + + const body = decode(StartLocationShare.reqSchema, call.body); + expect(body.session).toEqual({ scene: 2, peer: BigInt(PEER), selfUin: BigInt(SELF) }); + expect(body.op).toBe(1); + expect(body.interval).toBe(2); + expect(body.longitude).toBeCloseTo(LNG, 6); + expect(body.latitude).toBeCloseTo(LAT, 6); + }); + + it('start(group) 会话键是 {scene:1, peer:群号, selfUin:0}', async () => { + const nt = fakeNative(); + await makeService(nt).start({ + scene: 'group', + peer: GROUP, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + }); + + const body = decode(StartLocationShare.reqSchema, nt.trpc[0]!.body); + expect(body.session).toEqual({ scene: 1, peer: BigInt(GROUP) }); + }); + + it('report(group) 的会话键仍是 {scene:2, peer:群号, selfUin}', async () => { + // 抓包里两种场景的 report_location 1.1 都是 2、1.3 都是自己 —— 不跟场景走。 + const nt = fakeNative(); + await makeService(nt).report({ + scene: 'group', + peer: GROUP, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + }); + + expect(nt.trpc[0]!.cmd).toBe('QQLBSShareSvc.report_location'); + const body = decode(ReportLocation.reqSchema, nt.trpc[0]!.body); + expect(body.session).toEqual({ scene: 2, peer: BigInt(GROUP), selfUin: BigInt(SELF) }); + // extra 缺省 0 → proto3 省略,解码回来自然没有这个字段。 + expect(body.extra).toBeUndefined(); + }); + + it('report(c2c) 带 extra', async () => { + const nt = fakeNative(); + await makeService(nt).report({ + scene: 'c2c', + peer: PEER, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + extra: 208.03, + }); + + const body = decode(ReportLocation.reqSchema, nt.trpc[0]!.body); + expect(body.extra).toBeCloseTo(208.03, 2); + }); + + it('query 默认 mode=1,群聊会话键同 room_operation', async () => { + const nt = fakeNative(); + await makeService(nt).query({ scene: 'group', peer: GROUP, selfUin: SELF }); + + expect(nt.trpc[0]!.cmd).toBe('QQLBSShareSvc.room_query'); + const body = decode(QueryLocation.reqSchema, nt.trpc[0]!.body); + expect(body.session).toEqual({ scene: 1, peer: BigInt(GROUP) }); + expect(body.mode).toBe(1); + }); + + it('私聊 start 缺 selfUin 直接报错,不发包', async () => { + const nt = fakeNative(); + await expect( + makeService(nt).start({ + scene: 'c2c', + peer: PEER, + selfUin: 0, + longitude: LNG, + latitude: LAT, + }), + ).rejects.toThrow(/selfUin/); + expect(nt.trpc).toHaveLength(0); + }); + + it('坐标越界直接报错', async () => { + const nt = fakeNative(); + await expect( + makeService(nt).start({ + scene: 'c2c', + peer: PEER, + selfUin: SELF, + longitude: 999, + latitude: LAT, + }), + ).rejects.toThrow(/longitude/); + expect(nt.trpc).toHaveLength(0); + }); +}); + +describe('buildShareLocationElements', () => { + it('私聊只有灰条 + 文本兜底', () => { + const elements = buildShareLocationElements('c2c'); + expect(elements).toHaveLength(2); + expect(elements[0]).toMatchObject({ kind: 'raw' }); + expect(elements[1]).toMatchObject({ kind: 'raw' }); + }); + + it('群聊额外带 generalFlags(37),否则卡片点不了', () => { + const elements = buildShareLocationElements('group'); + expect(elements).toHaveLength(3); + const general = elements[1] as { kind: 'raw'; elem: Record }; + expect(general.elem).toHaveProperty('generalFlags'); + }); + + it('灰条是 commonElem serviceType=31,文案「发起了位置共享」', () => { + const [tip] = buildShareLocationElements('c2c') as { + kind: 'raw'; + elem: { commonElem: { serviceType: number; pbElem: Uint8Array; businessType: number } }; + }[]; + expect(tip!.elem.commonElem.serviceType).toBe(31); + expect(tip!.elem.commonElem.businessType).toBe(1); + const text = new TextDecoder().decode(tip!.elem.commonElem.pbElem.subarray(2)); + expect(text).toBe('发起了位置共享'); + }); +}); diff --git a/scripts/lbs_share.ts b/scripts/lbs_share.ts new file mode 100644 index 000000000..c179dedf6 --- /dev/null +++ b/scripts/lbs_share.ts @@ -0,0 +1,203 @@ +/** + * 位置共享联调脚本:借同机在线 QQ 的凭据,发起一次私聊 / 群聊位置共享, + * 按服务端要求的心跳持续上报,并周期性查询房间成员。 + * + * 协议实现本身在 `@weq/protocol` 的 `msg/lbs-share` 与 `@weq/service` 的 + * `LocationShareService` 里 —— 本脚本只负责「拿到凭据 + 驱动心跳」, + * 不再自己拼 protobuf。 + * + * 用法: + * pnpm tsx scripts/lbs_share.ts --peer 1707889225 [--seconds 120] + * pnpm tsx scripts/lbs_share.ts --group 673646675 [--seconds 120] + * pnpm tsx scripts/lbs_share.ts --peer 1707889225 --lng 116.397 --lat 39.908 + * pnpm tsx scripts/lbs_share.ts --peer 1707889225 --no-card # 只建房间不发卡片 + * + * 权限:读别的进程内存 = ptrace attach。Windows 要管理员,Linux 要 root 或 + * `CAP_SYS_PTRACE`,macOS 要 root 且关 SIP。 + */ + +import { loadNative } from '../packages/native/src'; +import type { Platform } from '../packages/platform/src'; +import { + createDarwinPlatform, + createLinuxPlatform, + createWin32Platform, +} from '../packages/platform/src'; +import { + registerSsoSession, + resolveDeviceGuid, + resolveSubAppId, +} from '../packages/service/src/account/sso_session'; +import { readLoginAccounts } from '../packages/service/src/bootstrap/login_db'; +import { ensureAttached } from '../packages/testkit/src/attach'; +import { LocationShareService } from '../packages/service/src/account/lbs_share'; +import { LbsRoomStatus } from '../packages/protocol/src/msg/lbs-share'; +import { sendMessage } from '../packages/protocol/src/msg/send'; + +const LABEL = 'lbs-share'; + +function log(message: string): void { + console.log(`[${LABEL}] ${new Date().toISOString().slice(11, 23)} ${message}`); +} + +function fail(message: string): never { + console.error(`[${LABEL}] 失败: ${message}`); + process.exit(1); +} + +// ─────────────────────────── 命令行 ─────────────────────────── + +const argv = process.argv.slice(2); +const has = (flag: string): boolean => argv.includes(flag); +const opt = (name: string): string | undefined => { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +}; + +const peerText = opt('--peer'); +const groupText = opt('--group'); +if (!peerText && !groupText) fail('缺少 --peer <对端 QQ 号> 或 --group <群号>'); +if (peerText && groupText) fail('--peer 与 --group 只能给一个'); + +const scene = groupText !== undefined ? ('group' as const) : ('c2c' as const); +/** 会话号:私聊是 peerUin,群聊是 groupCode。 */ +const peer = Number(peerText ?? groupText); +if (!Number.isSafeInteger(peer) || peer <= 0) fail(`会话号不合法:${peerText ?? groupText}`); + +const lng = Number(opt('--lng') ?? '103.931543'); +const lat = Number(opt('--lat') ?? '30.75535'); +const totalSeconds = Number(opt('--seconds') ?? '120'); +const heartbeatMs = Number(opt('--interval') ?? '2000'); +const queryEvery = Number(opt('--query-every') ?? '5'); + +// ─────────────────────────── 平台 / 账号 ─────────────────────────── + +function createPlatform( + native: ReturnType, + getUidForUin: (uin: string) => string | null, +): Platform { + if (process.platform === 'linux') return createLinuxPlatform(native, () => null, getUidForUin); + if (process.platform === 'darwin') return createDarwinPlatform(native, () => null, getUidForUin); + return createWin32Platform( + native, + () => null, + () => null, + ); +} + +async function main(): Promise { + const native = loadNative(); + const nt = native.ntHelper; + const uidByUin = new Map(); + const platform = createPlatform(native, (uin) => uidByUin.get(uin) ?? null); + + const accounts = await readLoginAccounts(platform); + for (const account of accounts) uidByUin.set(account.uin, account.uid); + + let target: { uin: string; uid: string; pid: number } | null = null; + for (const account of accounts) { + const pid = platform.resolveQqPid(account.uin); + if (pid === null) continue; + target = { uin: account.uin, uid: account.uid, pid }; + break; + } + if (!target) fail('没有在线账号,先把 QQ 打开并登录'); + if (!target.uid) fail(`账号 ${target.uin} 的 uid 为空`); + + const selfUin = Number(target.uin); + const pid = target.pid; + log(`目标账号 uin=${selfUin} uid=${target.uid} pid=${pid}`); + log( + `${scene === 'group' ? '群聊 groupCode' : '私聊 peerUin'}=${peer} 坐标 lng=${lng} lat=${lat}`, + ); + + // ① 借凭据登记 SSO 会话(只登记,不建连)。 + const material = await ensureAttached(nt, pid, target.uin, { label: LABEL }); + const guid = resolveDeviceGuid(nt, platform); + if (!guid) fail('读不到设备 guid'); + const subAppId = resolveSubAppId(nt, platform, pid); + const registered = await registerSsoSession( + nt, + platform, + pid, + { uin: target.uin, uid: target.uid, guid, subAppId }, + material, + ); + if (!registered) fail('会话物料不齐,没登记上 SSO 会话'); + log('SSO 会话已登记'); + + const service = new LocationShareService(nt, () => pid); + + // ② 发起。 + const started = await service.start({ scene, peer, selfUin, longitude: lng, latitude: lat }); + log(`room_operation ok=${started.ok} code=${started.code} ${started.message}`); + if (!started.ok) fail('服务端拒绝发起位置共享'); + + // ③ 自己发「发起了位置共享」卡片。 + // + // 真实客户端这一步由服务端 MsgPush 完成,但 WeQ 的 SSO 是「发一包就断」, + // 服务端判定发起方不在线就不投递 —— 所以自己 SendMsg。 + if (!has('--no-card')) { + const elements = service.buildShareCardElements(scene); + const peerUid = uidByUin.get(String(peer)); + try { + const receipt = await sendMessage(nt, pid, { + ...(scene === 'group' ? { groupId: peer } : { userUin: peer }), + ...(scene === 'c2c' && peerUid ? { userUid: peerUid } : {}), + elements, + ...(scene === 'c2c' ? { msgFlag: Math.floor(Date.now() / 1000) } : {}), + }); + log(`卡片消息 ok=${receipt.ok} result=${receipt.result} ${receipt.errMsg || ''}`); + } catch (error) { + log(`卡片消息失败: ${error instanceof Error ? error.message : error}`); + } + } + + // ④ 心跳:上报自己 + 定期查询成员。停上报约 60 秒后服务端会把房间置为已结束, + // 所以「退出共享」不需要额外命令。 + const deadline = Date.now() + totalSeconds * 1000; + let round = 0; + let lastQuery = 0; + while (Date.now() < deadline) { + round++; + // extra 语义未确认(抓包里每次不同、服务端原样回显),给个稳定值即可。 + const extra = 208 + (round % 7) * 0.1; + try { + const reply = await service.report({ + scene, + peer, + selfUin, + longitude: lng, + latitude: lat, + extra, + }); + log(`#${round} report ok=${reply.ok} 下次间隔=${reply.interval}s`); + } catch (error) { + log(`#${round} report 失败: ${error instanceof Error ? error.message : error}`); + } + + if (Date.now() - lastQuery >= queryEvery * heartbeatMs) { + lastQuery = Date.now(); + try { + const room = await service.query({ scene, peer, selfUin, mode: 1 }); + const members = room.members + .map((m) => `${m.uin}@${m.longitude.toFixed(6)},${m.latitude.toFixed(6)}`) + .join(' '); + log( + `#${round} query status=${room.status === LbsRoomStatus.ACTIVE ? '进行中' : room.status} ` + + `成员(${room.members.length}): ${members || '<空>'}`, + ); + } catch (error) { + log(`#${round} query 失败: ${error instanceof Error ? error.message : error}`); + } + } + + await new Promise((resolve) => setTimeout(resolve, heartbeatMs)); + } + log(`跑满 ${totalSeconds} 秒,结束(停止上报,房间会自行超时关闭)`); +} + +main().catch((error: unknown) => { + console.error(`[${LABEL}] 失败:`, error instanceof Error ? error.message : error); + process.exit(1); +}); From e6c829d4bdd125a515ebfed023da8e72aed24868 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Tue, 27 Oct 2026 00:50:16 +0800 Subject: [PATCH 17/23] =?UTF-8?q?feat(ui):=20=E4=BD=8D=E7=BD=AE=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/context/app_context.ts | 5 + apps/desktop/src/main/ipc/routers/account.ts | 117 +++- .../src/renderer/src/im-template/styles.css | 1 + .../src/im-template/styles/location-share.css | 569 ++++++++++++++++++ .../src/im-template/template/chatPane.tsx | 152 ++++- .../src/im-template/template/index.ts | 1 + .../template/locationShareJoystick.tsx | 180 ++++++ .../im-template/template/locationShareMap.tsx | 334 ++++++++++ .../template/locationSharePanel.tsx | 541 +++++++++++++++++ apps/desktop/test/location_share_geo.test.ts | 90 +++ packages/protocol/src/msg/lbs-share.ts | 4 +- 11 files changed, 1990 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/renderer/src/im-template/styles/location-share.css create mode 100644 apps/desktop/src/renderer/src/im-template/template/locationShareJoystick.tsx create mode 100644 apps/desktop/src/renderer/src/im-template/template/locationShareMap.tsx create mode 100644 apps/desktop/src/renderer/src/im-template/template/locationSharePanel.tsx create mode 100644 apps/desktop/test/location_share_geo.test.ts diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index 599ca0209..a20f1c490 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -142,6 +142,7 @@ import { ChatImageOcrService, InteractionService, GroupModerationService, + LocationShareService, AccountWriteService, DbWatchService, checkAccountDatabaseHealth, @@ -856,6 +857,8 @@ export interface AccountServices { interaction: InteractionService; /** 会话治理:撤回 + 群管理(改群名片 / 踢人 / 禁言 / 设撤管理员,需在线 QQ 发包)。 */ groupModeration: GroupModerationService; + /** 位置共享(QQLBSShareSvc 发起 / 上报 / 查询,需在线 QQ 发包)。 */ + locationShare: LocationShareService; accountWrite: AccountWriteService; /** 闪传浏览 / 下载(匿名 HTTP2RPC,不需 QQ 在线)。 */ flashTransferFiles: FlashTransferFilesService; @@ -1969,6 +1972,7 @@ export function initAppContext(fileRoot?: string): AppContext { ), interaction: new InteractionService(platform.native.ntHelper, session, resolveOnlinePid), groupModeration: new GroupModerationService(platform.native.ntHelper, resolveOnlinePid), + locationShare: new LocationShareService(platform.native.ntHelper, resolveOnlinePid), accountWrite: new AccountWriteService( platform.native.ntHelper, { @@ -2713,6 +2717,7 @@ export function initAppContext(fileRoot?: string): AppContext { ), interaction: new InteractionService(platform.native.ntHelper, session, livePid), groupModeration: new GroupModerationService(platform.native.ntHelper, livePid), + locationShare: new LocationShareService(platform.native.ntHelper, livePid), accountWrite: new AccountWriteService( platform.native.ntHelper, { diff --git a/apps/desktop/src/main/ipc/routers/account.ts b/apps/desktop/src/main/ipc/routers/account.ts index 916c443d8..7b0827cb5 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -61,7 +61,7 @@ import type { SalvageLedgerEntry } from '@weq/db'; import { databasePerformance } from '@weq/db'; import { classifyChatType, ProtoMsg } from '@weq/codec'; import { WalletFlag48417Wire } from '@weq/codec/proto/msg/element'; -import { detectThumbType, type OcrBlock } from '@weq/protocol'; +import { buildShareLocationElements, detectThumbType, type OcrBlock } from '@weq/protocol'; /** 48417 的嵌套块解码器(红包定位:orderId + packetId)。 */ const walletFlag48417Wire = new ProtoMsg(WalletFlag48417Wire); @@ -5726,6 +5726,121 @@ export const accountRouter = router({ }), ), + // ---- 位置共享(QQLBSShareSvc) ---- + + /** + * 发起一次位置共享(私聊 / 群聊同一套命令,只有会话键不同)。 + * + * 房间**不由服务端分配** —— 私聊的房间就是「这两个人」,群聊的就是「这个群」。 + * 发起后必须按 {@link locationShareReport} 的响应间隔持续上报,否则约 60 秒后 + * 服务端会把房间标记为已结束。没有「退出共享」命令:停上报即可。 + */ + locationShareStart: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: z.number().int().safe().positive(), + /** 自己的 QQ 号(私聊会话键要用;上报也需要)。 */ + selfUin: z.number().int().safe().positive(), + longitude: z.number(), + latitude: z.number(), + interval: z.number().int().min(1).max(60).optional(), + }), + ) + .mutation(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.start({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + longitude: input.longitude, + latitude: input.latitude, + ...(input.interval !== undefined ? { interval: input.interval } : {}), + }); + }), + + /** 上报一次自己的坐标;响应里的 `interval` 是服务端要求的下次间隔(秒)。 */ + locationShareReport: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + peer: z.number().int().safe().positive(), + selfUin: z.number().int().safe().positive(), + longitude: z.number(), + latitude: z.number(), + extra: z.number().optional(), + }), + ) + .mutation(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.report({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + longitude: input.longitude, + latitude: input.latitude, + ...(input.extra !== undefined ? { extra: input.extra } : {}), + }); + }), + + /** + * 查询房间里所有成员的坐标 —— **唯一**能拿到别人位置的途径(位置不会推送)。 + * + * `status === 2` 表示房间已结束(停上报超时),此时 `members` 为空。 + */ + locationShareQuery: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + peer: z.number().int().safe().positive(), + selfUin: z.number().int().safe().positive(), + mode: z.union([z.literal(0), z.literal(1)]).optional(), + }), + ) + .query(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.query({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + ...(input.mode !== undefined ? { mode: input.mode } : {}), + }); + }), + + /** + * 发「发起了位置共享」消息卡片。 + * + * 真实客户端由服务端推送这条消息;WeQ 的 SSO 是「发一包就断」,服务端判定发起方 + * 不在线就不投递 —— 所以自己发。 + * + * **元素必须在主进程构造**:`buildShareLocationElements` 在 `@weq/protocol` 里, + * 而渲染层不能引那个包(远程 Web 端复用渲染层代码,protocol 里有 `node:crypto` + * 的红包签名,浏览器端打不出来)。所以这里收「场景 + 目标」,元素自己拼。 + */ + sendLocationShareCard: procedure + .input( + z.object({ + expectedAccountUin: z.string().min(1).optional(), + expectedAccountConfigId: z.string().min(1).optional(), + scene: z.enum(['c2c', 'group']), + /** 私聊:对端 QQ 号。群聊:群号。 */ + targetId: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const services = requireExpectedAccount( + input.expectedAccountUin, + input.expectedAccountConfigId, + ); + requireQqOnlineForAlbum(services); + return services.messageSend.sendElements({ + peerType: input.scene === 'group' ? 'group' : 'c2c', + targetId: input.targetId, + elements: buildShareLocationElements(input.scene) as never, + }); + }), + // ---- database decrypt ---- /** List encrypted `*.db` files under the open account's nt_db directory. */ diff --git a/apps/desktop/src/renderer/src/im-template/styles.css b/apps/desktop/src/renderer/src/im-template/styles.css index e047a672c..3f1050f33 100644 --- a/apps/desktop/src/renderer/src/im-template/styles.css +++ b/apps/desktop/src/renderer/src/im-template/styles.css @@ -10,3 +10,4 @@ @import './styles/composer-media.css'; @import './styles/ark-panel.css'; @import './styles/flash-composer.css'; +@import './styles/location-share.css'; diff --git a/apps/desktop/src/renderer/src/im-template/styles/location-share.css b/apps/desktop/src/renderer/src/im-template/styles/location-share.css new file mode 100644 index 000000000..496fe8d2f --- /dev/null +++ b/apps/desktop/src/renderer/src/im-template/styles/location-share.css @@ -0,0 +1,569 @@ +/** + * 位置共享面板(发消息框内的浮层)。 + * + * 形态与其余 composer 面板一致:贴在输入区上沿、左对齐、绝对定位;移动端改成 + * 内联(grid-row 3)。配色全部走主题变量,深色用 `html[data-theme='dark']` 覆盖。 + */ + +.lbs-panel { + position: absolute; + bottom: calc(100% - 1px); + left: 12px; + z-index: 30; + display: grid; + width: min(460px, calc(100% - 28px)); + max-height: min(560px, calc(100dvh - 180px)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 82%, transparent); + border-radius: 12px; + background: var(--popover, #ffffff); + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.16); + animation: lbs-in 150ms ease-out; +} + +@keyframes lbs-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +.lbs-panel.is-active { + grid-template-rows: auto minmax(0, 1fr) auto auto; +} + +/* ── 头部 ─────────────────────────────────────────────────────────────── */ + +.lbs-header { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px 9px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 74%, transparent); +} + +.lbs-back { + display: grid; + width: 26px; + height: 26px; + flex: none; + place-items: center; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--weq-fg-secondary, #666666); + cursor: pointer; +} + +.lbs-back:hover { + background: color-mix(in srgb, var(--weq-fg-primary, #111111) 7%, transparent); + color: var(--weq-fg-primary, #111111); +} + +.lbs-title { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + color: var(--weq-fg-primary, #111111); +} + +.lbs-title strong { + font-size: 13px; + font-weight: 600; +} + +.lbs-subtitle { + overflow: hidden; + min-width: 0; + color: var(--weq-fg-muted, #858585); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lbs-count { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; + padding: 2px 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--weq-accent-effective, #0099ff) 12%, transparent); + color: var(--weq-accent-effective, #0099ff); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +/* ── 未开始 ───────────────────────────────────────────────────────────── */ + +.lbs-idle { + display: grid; + justify-items: center; + gap: 12px; + padding: 28px 22px 24px; +} + +.lbs-idle-art { + position: relative; + display: grid; + width: 66px; + height: 66px; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--weq-accent-effective, #0099ff) 10%, transparent); + color: var(--weq-accent-effective, #0099ff); +} + +.lbs-idle-ping { + position: absolute; + inset: 0; + border: 1.5px solid color-mix(in srgb, var(--weq-accent-effective, #0099ff) 45%, transparent); + border-radius: 50%; + animation: lbs-ping 2.2s ease-out infinite; +} + +@keyframes lbs-ping { + 0% { + opacity: 0.75; + transform: scale(0.85); + } + + 70%, + 100% { + opacity: 0; + transform: scale(1.45); + } +} + +.lbs-idle-text { + margin: 0; + max-width: 260px; + color: var(--weq-fg-secondary, #666666); + font-size: 12.5px; + line-height: 1.5; + text-align: center; +} + +.lbs-primary { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + border: 0; + border-radius: 999px; + background: var(--weq-accent-solid, var(--weq-accent-effective, #0099ff)); + color: var(--weq-accent-solid-foreground, #ffffff); + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: filter 120ms ease; +} + +.lbs-primary:hover:not(:disabled) { + filter: brightness(1.06); +} + +.lbs-primary:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.lbs-hint, +.lbs-error { + max-width: 300px; + color: var(--weq-fg-muted, #858585); + font-size: 11.5px; + line-height: 1.45; + text-align: center; +} + +.lbs-error { + color: var(--weq-danger-solid-background, #a91727); +} + +/* ── 地图 ─────────────────────────────────────────────────────────────── */ + +.lbs-map-wrap { + position: relative; + min-height: 0; + padding: 8px 8px 0; +} + +.lbs-map { + position: relative; + height: 100%; + min-height: 190px; + overflow: hidden; + border-radius: 10px; + background: color-mix(in srgb, var(--weq-fg-muted, #858585) 8%, transparent); + cursor: grab; + touch-action: none; + user-select: none; +} + +.lbs-map:active { + cursor: grabbing; +} + +.lbs-map.is-pickable { + cursor: crosshair; + box-shadow: inset 0 0 0 2px var(--weq-accent-effective, #0099ff); +} + +.lbs-map-tiles { + position: absolute; + inset: 0; +} + +.lbs-map-tile { + position: absolute; + width: 256px; + height: 256px; + max-width: none; + pointer-events: none; +} + +/* 高德没有暗色栅格瓦片 —— 用滤镜把亮色底图翻成暗色(与选点地图同一套做法)。 */ +html[data-theme='dark'] .lbs-map-tile { + filter: invert(1) hue-rotate(180deg) brightness(0.86) contrast(0.92) saturate(0.72); +} + +html[data-theme='dark'] .lbs-map { + background: #1b1e22; +} + +/* ── 地图标记 ─────────────────────────────────────────────────────────── */ + +.lbs-map-member { + position: absolute; + z-index: 3; + display: grid; + place-items: center; + pointer-events: none; + transform: translate(-50%, -50%); +} + +.lbs-map-avatar { + display: grid; + width: 34px; + height: 34px; + overflow: hidden; + border: 2px solid var(--popover, #ffffff); + border-radius: 50%; + background: var(--weq-bg-surface, #ffffff); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.28); +} + +.lbs-map-avatar .avatar { + width: 100%; + height: 100%; + font-size: 12px; +} + +.lbs-map-member.is-self .lbs-map-avatar { + border-color: var(--weq-accent-effective, #0099ff); +} + +.lbs-map-member.is-stale .lbs-map-avatar { + opacity: 0.55; + filter: grayscale(0.5); +} + +.lbs-map-self-ring { + position: absolute; + width: 52px; + height: 52px; + border: 1.5px solid color-mix(in srgb, var(--weq-accent-effective, #0099ff) 55%, transparent); + border-radius: 50%; + animation: lbs-ping 2.4s ease-out infinite; +} + +.lbs-map-actions { + position: absolute; + top: 16px; + right: 16px; + z-index: 5; + display: grid; + gap: 4px; + padding: 3px; + border-radius: 9px; + background: color-mix(in srgb, var(--popover, #ffffff) 92%, transparent); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18); +} + +.lbs-map-action { + display: grid; + width: 26px; + height: 26px; + place-items: center; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--weq-fg-secondary, #666666); + cursor: pointer; +} + +.lbs-map-action:hover { + color: var(--weq-fg-primary, #111111); +} + +.lbs-map-action.is-on { + background: color-mix(in srgb, var(--weq-accent-effective, #0099ff) 16%, transparent); + color: var(--weq-accent-effective, #0099ff); +} + +.lbs-map-zoom { + position: absolute; + top: 8px; + left: 8px; + z-index: 5; + display: grid; + gap: 1px; + overflow: hidden; + border-radius: 8px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18); +} + +.lbs-map-zoom button { + display: grid; + width: 24px; + height: 24px; + place-items: center; + padding: 0; + border: 0; + background: color-mix(in srgb, var(--popover, #ffffff) 94%, transparent); + color: var(--weq-fg-secondary, #666666); + cursor: pointer; +} + +.lbs-map-zoom button:hover:not(:disabled) { + color: var(--weq-accent-effective, #0099ff); +} + +.lbs-map-zoom button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.lbs-map-credit { + position: absolute; + right: 0; + bottom: 0; + z-index: 4; + padding: 2px 6px; + border-top-left-radius: 6px; + background: color-mix(in srgb, var(--popover, #ffffff) 82%, transparent); + color: var(--weq-fg-muted, #858585); + font-size: 9.5px; + pointer-events: none; +} + +/* ── 摇杆 + 速度 ──────────────────────────────────────────────────────── */ + +.lbs-controls { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 14px 12px; +} + +.lbs-joystick { + position: relative; + display: grid; + width: 84px; + height: 84px; + flex: none; + place-items: center; + border: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 80%, transparent); + border-radius: 50%; + background: color-mix(in srgb, var(--weq-fg-muted, #858585) 7%, transparent); + cursor: grab; + touch-action: none; + outline: none; +} + +.lbs-joystick:active { + cursor: grabbing; +} + +.lbs-joystick:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--weq-accent-effective, #0099ff) 45%, transparent); +} + +.lbs-joystick.is-disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.lbs-joystick-ring { + position: absolute; + inset: 12px; + border: 1px dashed color-mix(in srgb, var(--weq-fg-muted, #858585) 32%, transparent); + border-radius: 50%; +} + +.lbs-joystick-thumb { + position: absolute; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--weq-accent-solid, var(--weq-accent-effective, #0099ff)); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.24); + transform: translate(-50%, -50%); + transition: left 60ms linear, top 60ms linear; +} + +.lbs-speed { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 6px; +} + +.lbs-speed-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 80%, transparent); + border-radius: 999px; + background: transparent; + color: var(--weq-fg-secondary, #666666); + font-family: inherit; + font-size: 11.5px; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.lbs-speed-chip:hover { + color: var(--weq-fg-primary, #111111); +} + +.lbs-speed-chip.is-on { + border-color: transparent; + background: color-mix(in srgb, var(--weq-accent-effective, #0099ff) 14%, transparent); + color: var(--weq-accent-effective, #0099ff); +} + +/* ── 底部 ─────────────────────────────────────────────────────────────── */ + +.lbs-footer { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px 12px; +} + +.lbs-status { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--weq-fg-muted, #858585); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.lbs-live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #2fbf71; + box-shadow: 0 0 0 0 rgba(47, 191, 113, 0.55); + animation: lbs-live 1.8s ease-out infinite; +} + +@keyframes lbs-live { + 70% { + box-shadow: 0 0 0 5px rgba(47, 191, 113, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(47, 191, 113, 0); + } +} + +.lbs-stop { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + padding: 5px 12px; + border: 1px solid color-mix(in srgb, var(--weq-danger-solid-background, #a91727) 32%, transparent); + border-radius: 999px; + background: transparent; + color: var(--weq-danger-solid-background, #a91727); + font-family: inherit; + font-size: 11.5px; + cursor: pointer; +} + +.lbs-stop:hover { + background: color-mix(in srgb, var(--weq-danger-solid-background, #a91727) 9%, transparent); +} + +/* ── 深色 ─────────────────────────────────────────────────────────────── */ + +html[data-theme='dark'] .lbs-panel { + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5); +} + +html[data-theme='dark'] .lbs-header { + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme='dark'] .lbs-map-avatar { + border-color: #1b1e22; +} + +html[data-theme='dark'] .lbs-joystick, +html[data-theme='dark'] .lbs-speed-chip { + border-color: rgba(255, 255, 255, 0.1); +} + +/* ── 移动端:内联进输入区(与其余面板同一套规则) ──────────────────────── */ + +@media (max-width: 760px) { + .lbs-panel { + position: static; + width: 100%; + max-height: none; + border: 0; + border-top: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 74%, transparent); + border-radius: 0; + background: #f8f8f9; + box-shadow: none; + animation: none; + } + + .lbs-panel.is-active { + grid-template-rows: auto minmax(0, 1fr) auto auto; + } + + .lbs-map-wrap { + padding: 6px 6px 0; + } + + .lbs-map { + min-height: 180px; + } + + .lbs-controls { + padding: 8px 12px 10px; + } + + .lbs-joystick { + width: 76px; + height: 76px; + } + + html[data-theme='dark'] .lbs-panel { + background: #1b1b1c; + border-top-color: rgba(255, 255, 255, 0.08); + } +} diff --git a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx index 2e00e682d..fc52873c0 100644 --- a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx @@ -53,6 +53,7 @@ import { Hand, Image as ImageIcon, Link2, + MapPin, ClipboardCopy, MessageSquareText, Mic, @@ -153,6 +154,7 @@ import type { ArkContactSource, ArkLocationProvider, ArkPayload } from './arkCar import { AiVoicePanel, aiVoiceToken, type AiVoiceDraft } from './aiVoicePanel'; import { BounceEmojiPanel, bounceEmojiToken, type BounceEmojiDraft } from './bounceEmojiPanel'; import { RedPacketPanel, type RedPacketDraft } from './redPacketPanel'; +import { LocationSharePanel } from './locationSharePanel'; import { PokeEmojiPanel, pokeEmojiToken, @@ -171,7 +173,12 @@ import { PROJECT_GROUP_IDS } from '../../../../shared/project_groups'; import { chatHeaderTitle, isBotConversation, resolveMessageSender } from './conversationDisplay'; import { createEmojiToken, parseMessageParts } from './emojiPacks'; import type { EmojiItem } from './emojiPacks'; -import { elementToToken, elementsToComposerText, tokenToElement } from './draftElements'; +import { + elementToToken, + elementsToComposerText, + toIpcElements, + tokenToElement, +} from './draftElements'; import { planNeedsLocalMedia, type LocalMediaRef } from './composerSend'; import { prepareReeditDraft } from './composerReedit'; import { @@ -243,7 +250,7 @@ import { dockStopLabel, dockStopTone } from './unreadDock'; import { displayGroupName, displayUserName } from './user'; import { OnlineStatus } from '../../components/OnlineStatus'; import { MessageDecorationCard } from '../../components/MessageDecorationCard'; -import { trpc } from '../../trpc/client'; +import { client, trpc } from '../../trpc/client'; import { useToast } from '../../components/Toast'; const composerHeightStorageKey = 'chat-template.layout.composerHeight'; @@ -724,6 +731,9 @@ export function ChatPane({ // 「红包」面板:群聊三档(普通 / 拼手气 / 口令),私聊两档(普通 / 拼手气); // 下单出码后由应用层弹二维码灯箱。 const [redPacketOpen, setRedPacketOpen] = useState(false); + // 「位置共享」面板:发起 / 心跳上报 / 看所有人在地图上的位置。房间靠上报续命, + // 停上报约 60 秒后服务端自动结束 —— 没有单独的退出命令。 + const [locationShareOpen, setLocationShareOpen] = useState(false); // 闪传文件框占着输入框正文那一行:别的面板一打开就把它收起来,避免两套东西打架。 useEffect(() => { @@ -967,6 +977,8 @@ export function ChatPane({ const redPacketButtonRef = useRef(null); const flashPanelRef = useRef(null); const flashButtonRef = useRef(null); + const locationSharePanelRef = useRef(null); + const locationShareButtonRef = useRef(null); const imageInputRef = useRef(null); const fileInputRef = useRef(null); const mentionMenuRef = useRef(null); @@ -1550,6 +1562,8 @@ export function ChatPane({ useEffect(() => { setEmojiOpen(false); setToolsOpen(false); + // 切会话就关掉位置共享 —— 面板卸载会停心跳,等价于「退出上一场共享」。 + setLocationShareOpen(false); setContextMenu(null); setMentionMenu(null); setClearMessagesConfirmOpen(false); @@ -2010,6 +2024,40 @@ export function ChatPane({ }; }, [tabActive, redPacketOpen]); + // 位置共享面板:点外面 / Esc 关闭。模态(灯箱等)开着时不抢 Esc。 + useLayoutEffect(() => { + if (!tabActive || !locationShareOpen) { + return; + } + + function closeLocationShareFromOutside(event: globalThis.MouseEvent) { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if ( + locationSharePanelRef.current?.contains(target) || + locationShareButtonRef.current?.contains(target) + ) { + return; + } + setLocationShareOpen(false); + } + + function closeLocationShareOnEscape(event: globalThis.KeyboardEvent) { + if (event.key === 'Escape' && !hasActiveModal()) { + setLocationShareOpen(false); + } + } + + document.addEventListener('mousedown', closeLocationShareFromOutside); + document.addEventListener('keydown', closeLocationShareOnEscape); + return () => { + document.removeEventListener('mousedown', closeLocationShareFromOutside); + document.removeEventListener('keydown', closeLocationShareOnEscape); + }; + }, [tabActive, locationShareOpen]); + // 附件跟着会话走:切会话时清掉上一条会话的待发送素材(预览地址一并释放)。 const mediaAttachmentRef = useRef(null); mediaAttachmentRef.current = mediaAttachment; @@ -3187,6 +3235,29 @@ export function ChatPane({ setRedPacketOpen(false); } + /** + * 位置共享的「发起了位置共享」卡片。 + * + * 真实客户端由服务端推送这条消息,但 WeQ 的 SSO 是「发一包就断」,服务端判定 + * 发起方不在线就不投递 —— 所以自己发。元素由协议层构造(私聊只需灰条 + 文本; + * 群聊必须额外带 `generalFlags(37)`,否则能显示但点不了)。 + * + * 卡片是独立一条消息,不带输入框里的文字 / 引用。 + */ + async function sendLocationShareCard(): Promise { + if (!conversation || conversation.type === 'merged') return; + // 元素在主进程构造:渲染层不能 import `@weq/protocol`(远程 Web 端复用这份 + // 代码,而 protocol 里有 `node:crypto` 的红包签名,浏览器端打不出来)。 + await client.account.sendLocationShareCard.mutate({ + expectedAccountUin: user.identityValue, + scene: conversation.type === 'group' ? 'group' : 'c2c', + targetId: + conversation.type === 'group' + ? conversation.group.id + : conversation.otherUser.identityValue, + }); + } + /** * AI 声聊面板「合成并发送」:这条语音**只能单独发** —— 不带输入框里的文字、 * 也不带挂着的引用(跟视频 / 文件那套「单独发」同一套互斥)。 @@ -4016,6 +4087,7 @@ export function ChatPane({ function toggleEmojiPanel() { setContextMenu(null); + setLocationShareOpen(false); setToolsOpen(false); setVoiceOpen(false); setArkOpen(false); @@ -4027,6 +4099,7 @@ export function ChatPane({ function toggleToolsPanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setVoiceOpen(false); setArkOpen(false); @@ -4038,6 +4111,7 @@ export function ChatPane({ function toggleVoicePanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setArkOpen(false); @@ -4049,6 +4123,7 @@ export function ChatPane({ function toggleArkPanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -4064,6 +4139,7 @@ export function ChatPane({ */ function toggleFlashPanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -4077,6 +4153,7 @@ export function ChatPane({ /** AI 声聊面板(仅群聊):和其余面板互斥,同一时刻只开一个。 */ function toggleAiVoicePanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -4089,6 +4166,7 @@ export function ChatPane({ /** 弹射表情面板:和其余面板互斥;「只做面板」,发射走既有元素通路。 */ function toggleBouncePanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -4101,6 +4179,7 @@ export function ChatPane({ /** 戳一戳面板:和其余面板互斥;「只做面板」,戳出去走既有元素通路。 */ function togglePokePanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -4113,6 +4192,7 @@ export function ChatPane({ /** 红包面板:和其余面板互斥;下单出码在应用层,成功后弹二维码灯箱。 */ function toggleRedPacketPanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -4123,6 +4203,25 @@ export function ChatPane({ setRedPacketOpen((open) => !open); } + /** + * 位置共享面板:和其余面板互斥。 + * + * 面板内部自己管心跳(打开才开始上报,关掉/切会话即停)—— 这里只负责显隐, + * 不把「是否在共享」提到 chatPane 层,避免关面板后还在后台偷偷上报位置。 + */ + function toggleLocationSharePanel() { + setContextMenu(null); + setEmojiOpen(false); + setToolsOpen(false); + setVoiceOpen(false); + setArkOpen(false); + setAiVoiceOpen(false); + setBounceOpen(false); + setPokeOpen(false); + setRedPacketOpen(false); + setLocationShareOpen((open) => !open); + } + const handleVoiceBusyChange = useCallback((busy: boolean) => { voiceBusyRef.current = busy; }, []); @@ -4561,6 +4660,17 @@ export function ChatPane({ const pokePanelActive = pokeOpen && !mobileComposerExpanded; // 红包面板:群聊 / 私聊都能发,浮层形态(不占正文那一行)。 const redPacketPanelActive = redPacketOpen && !mobileComposerExpanded; + // 位置共享面板:私聊 / 群聊都能发起,浮层形态。 + const locationSharePanelActive = locationShareOpen && !mobileComposerExpanded; + /** + * 位置共享的会话键:私聊是对端 QQ 号,群聊是群号。 + * 临时会话(来源群号)不参与 —— 位置共享协议没有 grpTmp 路由,直接按 c2c 走。 + */ + const locationSharePeer = + conversation.type === 'group' + ? Number(conversation.group.id) + : Number(conversation.otherUser.identityValue); + const locationShareSelfUin = Number(user.identityValue); const mediaSendDisabled = Boolean(sendGateReason); const repeatMessage = repeatTarget(messages, atLatest); function messageCanRepeat(message: Message) { @@ -5495,6 +5605,16 @@ export function ChatPane({ onClick={runComposerAction} /> ))} + {/* 位置共享(手机端):与桌面端同一个面板,只是工具栏样式不同。 */} + + {/* 位置共享:发起 / 看所有人在哪 / 摇杆挪位置。房间靠上报续命,停上报即退出。 */} + {composerActionRegistry.desktopToolbar.map((action) => ( setRedPacketOpen(false)} /> ) : null} + {locationSharePanelActive ? ( + setLocationShareOpen(false)} + /> + ) : null} void; + disabled?: boolean; +}) { + const baseRef = useRef(null); + const pointerRef = useRef(null); + /** 手柄当前偏移(-1..1 的归一化坐标,y 向下为正,与屏幕一致)。 */ + const [offset, setOffset] = useState({ x: 0, y: 0 }); + + const emit = useCallback( + (x: number, y: number) => { + setOffset({ x, y }); + onMove(x, y); + }, + [onMove], + ); + + const compute = useCallback((clientX: number, clientY: number) => { + const base = baseRef.current; + if (!base) return { x: 0, y: 0 }; + const rect = base.getBoundingClientRect(); + const radius = rect.width / 2; + // 相对底座中心的位移,归一化到 [-1, 1]。 + let dx = (clientX - (rect.left + radius)) / radius; + let dy = (clientY - (rect.top + radius)) / radius; + const len = Math.hypot(dx, dy); + if (len > 1) { + dx /= len; + dy /= len; + } + return { x: dx, y: dy }; + }, []); + + function handlePointerDown(event: ReactPointerEvent): void { + if (disabled) return; + pointerRef.current = event.pointerId; + event.currentTarget.setPointerCapture(event.pointerId); + const next = compute(event.clientX, event.clientY); + emit(next.x, next.y); + } + + function handlePointerMove(event: ReactPointerEvent): void { + if (pointerRef.current !== event.pointerId) return; + const next = compute(event.clientX, event.clientY); + emit(next.x, next.y); + } + + function handlePointerUp(event: ReactPointerEvent): void { + if (pointerRef.current !== event.pointerId) return; + pointerRef.current = null; + emit(0, 0); + } + + // 键盘:方向键按住持续移动,松开归零。上下左右可组合(同时按 = 斜向)。 + const keysRef = useRef(new Set()); + + const applyKeys = useCallback(() => { + const keys = keysRef.current; + let x = 0; + let y = 0; + if (keys.has('ArrowLeft')) x -= 1; + if (keys.has('ArrowRight')) x += 1; + if (keys.has('ArrowUp')) y -= 1; + if (keys.has('ArrowDown')) y += 1; + const len = Math.hypot(x, y); + emit(len > 1 ? x / len : x, len > 1 ? y / len : y); + }, [emit]); + + function handleKeyDown(event: ReactKeyboardEvent): void { + if (disabled) return; + if (!event.key.startsWith('Arrow')) return; + event.preventDefault(); + keysRef.current.add(event.key); + applyKeys(); + } + + function handleKeyUp(event: ReactKeyboardEvent): void { + if (!event.key.startsWith('Arrow')) return; + keysRef.current.delete(event.key); + applyKeys(); + } + + // 失焦时清空按键,避免「切窗口后自己一直走」。 + useEffect(() => { + const reset = (): void => { + if (!keysRef.current.size) return; + keysRef.current.clear(); + emit(0, 0); + }; + window.addEventListener('blur', reset); + return () => window.removeEventListener('blur', reset); + }, [emit]); + + const thumbLeft = `${50 + offset.x * THUMB_TRAVEL * 50}%`; + const thumbTop = `${50 + offset.y * THUMB_TRAVEL * 50}%`; + + return ( +
+ + +
+ ); +} + +/** 摇杆速度档位(米/秒)。越靠后越快,用于模拟步行 / 骑行 / 开车。 */ +export const JOYSTICK_SPEEDS = [ + { id: 'walk', label: '步行', metersPerSecond: 1.4 }, + { id: 'run', label: '跑步', metersPerSecond: 4 }, + { id: 'bike', label: '骑行', metersPerSecond: 8 }, + { id: 'car', label: '驾车', metersPerSecond: 22 }, +] as const; + +export type JoystickSpeedId = (typeof JOYSTICK_SPEEDS)[number]['id']; + +/** + * 经纬度按「米」偏移。 + * + * 纬度每度约 111320 米;经度随纬度收缩(乘 cos)。这是**球面近似**,在几公里的 + * 尺度上与真实距离的误差可以忽略,且不需要引入大地测量库。 + */ +export function offsetLatLng( + latitude: number, + longitude: number, + northMeters: number, + eastMeters: number, +): { latitude: number; longitude: number } { + const latRad = (latitude * Math.PI) / 180; + const nextLat = latitude + northMeters / 111320; + const cos = Math.cos(latRad); + // 极点附近 cos 趋近 0,经度会爆炸 —— 夹一个下限(约 ±89.99°)。 + const nextLng = longitude + eastMeters / (111320 * Math.max(0.0001, Math.abs(cos))); + return { + latitude: Math.max(-85, Math.min(85, nextLat)), + longitude: ((((nextLng + 180) % 360) + 360) % 360) - 180, + }; +} diff --git a/apps/desktop/src/renderer/src/im-template/template/locationShareMap.tsx b/apps/desktop/src/renderer/src/im-template/template/locationShareMap.tsx new file mode 100644 index 000000000..d6c657266 --- /dev/null +++ b/apps/desktop/src/renderer/src/im-template/template/locationShareMap.tsx @@ -0,0 +1,334 @@ +// @ts-nocheck +/** + * 位置共享地图 —— 多成员头像标记 + 可交互的 slippy map。 + * + * 与 `locationPicker.tsx` 同源(同一套高德栅格瓦片与 Web Mercator 投影),但那个 + * 组件是「选一个点」的受控输入框(固定 150px 高、视口不可控、单图钉),位置共享 + * 要的是「看一屋子人在哪 + 自己随时能挪」: + * + * - 视口(中心 / 缩放)由外部控制,便于「跟随我」; + * - 每个成员一个头像标记,自己高亮; + * - 点击地图 = 把自己挪过去(可选); + * - 拖拽平移、滚轮/按钮缩放。 + * + * CSP:瓦片是纯 ``,`img-src` 已放行 `https://*.is.autonavi.com`。 + * 坐标全程 GCJ-02 —— 瓦片与 QQ 的共享协议是同一套,不需要火星坐标转换。 + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; +import { Minus, Plus } from 'lucide-react'; +import { cn } from './classNames'; +import { Avatar } from './primitives'; +import { project, tileUrl, unproject } from './locationPicker'; + +const TILE_SIZE = 256; +const MIN_ZOOM = 3; +const MAX_ZOOM = 18; +/** Web Mercator 纬度上限;超出会让整块地图空掉。 */ +const MAX_LAT = 85.05112878; +/** 拖拽判定阈值(像素):超过就不算「点击落点」。 */ +const CLICK_SLOP = 5; + +export type MapView = { lng: number; lat: number; zoom: number }; + +/** 地图上的一个成员标记。 */ +export interface MapMember { + /** 稳定 key(uin 字符串)。 */ + id: string; + /** 显示名(昵称 / 群名片),用于头像兜底与 title。 */ + name: string; + avatarUrl?: string | null; + /** 头像兜底种子(通常给 uid 或 uin)。 */ + seed?: string; + longitude: number; + latitude: number; + /** 是不是自己 —— 自己用主题色描边 + 呼吸圈。 */ + isSelf?: boolean; + /** 最近一次上报距今多少秒;用于「位置陈旧」的视觉提示。 */ + ageSeconds?: number; +} + +function wrapLng(lng: number): number { + return ((((lng + 180) % 360) + 360) % 360) - 180; +} + +export function clampView(view: MapView): MapView { + const zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, view.zoom)); + const lat = Math.max(-MAX_LAT, Math.min(MAX_LAT, view.lat)); + const lng = wrapLng(view.lng); + // 三个分量都没变就原样返回,避免调用方拿到新对象触发多余重渲染。 + if (zoom === view.zoom && lat === view.lat && lng === view.lng) return view; + return { lng, lat, zoom }; +} + +/** + * 把一组坐标收进视野:算出让所有点都可见的中心与缩放。 + * + * `padding` 是给标记留的边距(像素),否则贴边的头像会被裁掉一半。 + */ +export function fitView( + points: Array<{ longitude: number; latitude: number }>, + size: { width: number; height: number }, + padding = 56, +): MapView | null { + if (!points.length || size.width <= 0 || size.height <= 0) return null; + if (points.length === 1) { + return clampView({ + lng: points[0].longitude, + lat: points[0].latitude, + zoom: 16, + }); + } + const usableW = Math.max(1, size.width - padding * 2); + const usableH = Math.max(1, size.height - padding * 2); + // 先在最细粒度上投影,再按「需要缩多少」反推 zoom。 + const baseZoom = 18; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const point of points) { + const px = project(point.longitude, point.latitude, baseZoom); + minX = Math.min(minX, px.x); + maxX = Math.max(maxX, px.x); + minY = Math.min(minY, px.y); + maxY = Math.max(maxY, px.y); + } + const spanX = Math.max(1, maxX - minX); + const spanY = Math.max(1, maxY - minY); + const zoomX = baseZoom + Math.log2(usableW / spanX); + const zoomY = baseZoom + Math.log2(usableH / spanY); + const zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.min(zoomX, zoomY))); + const center = unproject((minX + maxX) / 2, (minY + maxY) / 2, baseZoom); + return clampView({ lng: center.lng, lat: center.lat, zoom }); +} + +export function LocationShareMap({ + view, + onViewChange, + members, + onPick, + pickEnabled = false, + className, +}: { + view: MapView; + onViewChange: (next: MapView) => void; + members: MapMember[]; + /** 点地图落点(把自己挪过去);`pickEnabled` 为假时不触发。 */ + onPick?: (longitude: number, latitude: number) => void; + pickEnabled?: boolean; + className?: string; +}) { + const hostRef = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + // 原生滚轮监听里要读当前视野,但不想让 effect 跟着 view 重挂。 + const viewRef = useRef(view); + viewRef.current = view; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const measure = (): void => setSize({ width: host.clientWidth, height: host.clientHeight }); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(host); + return () => observer.disconnect(); + }, []); + + /** 屏幕坐标 → 地理坐标。 */ + const geoAt = useCallback((clientX: number, clientY: number) => { + const host = hostRef.current; + const current = viewRef.current; + if (!host) return { lng: current.lng, lat: current.lat }; + const rect = host.getBoundingClientRect(); + const centerPx = project(current.lng, current.lat, current.zoom); + return unproject( + centerPx.x + (clientX - rect.left) - rect.width / 2, + centerPx.y + (clientY - rect.top) - rect.height / 2, + current.zoom, + ); + }, []); + + /** 缩放;给了锚点就让锚点下的地理坐标停在屏幕上的同一处。 */ + const zoomBy = useCallback( + (delta: number, anchor?: { lng: number; lat: number }) => { + const current = viewRef.current; + const zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, current.zoom + delta)); + if (zoom === current.zoom) return; + if (!anchor) { + onViewChange(clampView({ ...current, zoom })); + return; + } + const before = project(anchor.lng, anchor.lat, current.zoom); + const after = project(anchor.lng, anchor.lat, zoom); + const centerPx = project(current.lng, current.lat, current.zoom); + const next = unproject( + centerPx.x + (after.x - before.x), + centerPx.y + (after.y - before.y), + zoom, + ); + onViewChange(clampView({ lng: next.lng, lat: next.lat, zoom })); + }, + [onViewChange], + ); + + // 滚轮缩放要 preventDefault,React 的合成 wheel 是 passive 的 —— 挂原生监听。 + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const onWheel = (event: WheelEvent): void => { + event.preventDefault(); + zoomBy(event.deltaY < 0 ? 1 : -1, geoAt(event.clientX, event.clientY)); + }; + host.addEventListener('wheel', onWheel, { passive: false }); + return () => host.removeEventListener('wheel', onWheel); + }, [zoomBy, geoAt]); + + const dragRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + lng: number; + lat: number; + moved: number; + } | null>(null); + + function handlePointerDown(event: ReactPointerEvent): void { + if (event.button !== 0) return; + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + lng: viewRef.current.lng, + lat: viewRef.current.lat, + moved: 0, + }; + event.currentTarget.setPointerCapture(event.pointerId); + } + + function handlePointerMove(event: ReactPointerEvent): void { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + drag.moved = Math.max(drag.moved, Math.hypot(dx, dy)); + const startPx = project(drag.lng, drag.lat, viewRef.current.zoom); + const next = unproject(startPx.x - dx, startPx.y - dy, viewRef.current.zoom); + onViewChange(clampView({ ...viewRef.current, lng: next.lng, lat: next.lat })); + } + + function handlePointerUp(event: ReactPointerEvent): void { + const drag = dragRef.current; + dragRef.current = null; + if (!drag || drag.pointerId !== event.pointerId) return; + // 拖动过就不是落点。 + if (drag.moved > CLICK_SLOP) return; + if (!pickEnabled || !onPick) return; + const geo = geoAt(event.clientX, event.clientY); + onPick(geo.lng, geo.lat); + } + + const centerPx = project(view.lng, view.lat, view.zoom); + const originX = centerPx.x - size.width / 2; + const originY = centerPx.y - size.height / 2; + const tileCount = 2 ** view.zoom; + + const tiles: Array<{ key: string; src: string; left: number; top: number }> = []; + if (size.width > 0 && size.height > 0) { + const startX = Math.floor(originX / TILE_SIZE); + const endX = Math.floor((originX + size.width) / TILE_SIZE); + const startY = Math.floor(originY / TILE_SIZE); + const endY = Math.floor((originY + size.height) / TILE_SIZE); + for (let tx = startX; tx <= endX; tx += 1) { + for (let ty = startY; ty <= endY; ty += 1) { + if (ty < 0 || ty >= tileCount) continue; + const wrapped = ((tx % tileCount) + tileCount) % tileCount; + tiles.push({ + key: `${view.zoom}/${tx}/${ty}`, + src: tileUrl(view.zoom, wrapped, ty), + left: tx * TILE_SIZE - originX, + top: ty * TILE_SIZE - originY, + }); + } + } + } + + return ( +
{ + dragRef.current = null; + }} + role="application" + aria-label="位置共享地图" + > +
+ {tiles.map((tile) => ( + + ))} +
+ + {members.map((member) => { + const px = project(member.longitude, member.latitude, view.zoom); + const left = px.x - originX; + const top = px.y - originY; + // 视野外的标记不渲染 —— 否则它们会以「贴在边缘」的假位置出现。 + if (left < -80 || top < -80 || left > size.width + 80 || top > size.height + 80) { + return null; + } + const stale = (member.ageSeconds ?? 0) > 60; + return ( + + {member.isSelf ? : null} + + + + + ); + })} + +
+ + +
+ + © 高德地图 +
+ ); +} diff --git a/apps/desktop/src/renderer/src/im-template/template/locationSharePanel.tsx b/apps/desktop/src/renderer/src/im-template/template/locationSharePanel.tsx new file mode 100644 index 000000000..1c32c2ae0 --- /dev/null +++ b/apps/desktop/src/renderer/src/im-template/template/locationSharePanel.tsx @@ -0,0 +1,541 @@ +// @ts-nocheck +/** + * 位置共享面板 —— 发起 / 维持 / 查看一场实时位置共享。 + * + * ## 生命周期 + * + * 房间靠**上报心跳**续命(服务端在每次 `report` 的响应里给出下次间隔,实测 2 秒); + * 停止上报约 60 秒后服务端把房间标记为已结束。所以「退出共享」= 停掉心跳, + * 没有单独的退出命令(真实客户端的结束动作走推送 ACK,第三方拿不到)。 + * + * ## 三条命令 + * + * locationShareStart 建房间(私聊 / 群聊同一套,只有会话键不同) + * locationShareReport 周期上报自己的坐标 + * locationShareQuery 拉所有成员坐标 —— **唯一**能拿到别人位置的途径 + * + * ## 卡片 + * + * 真实客户端发起后由服务端推一条「发起了位置共享」消息;WeQ 的 SSO 是「发一包 + * 就断」,服务端判定发起方不在线就不投递 —— 所以这里自己 `sendElements` 发卡片 + * (元素由 `buildShareLocationElements` 构造)。 + * + * ## 为什么心跳在渲染层 + * + * 主进程服务**不持有定时器**(见 `@weq/service` 的 `LocationShareService`):谁打开 + * 了面板谁负责生命周期。面板卸载 / 切会话 / 关窗口时定时器随之清掉,不会有 + * 「关了界面还在后台偷偷上报位置」这种事。 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowLeft, + Crosshair, + Footprints, + Gauge, + MapPin, + Play, + Radio, + Square, + Users, +} from 'lucide-react'; +import { cn } from './classNames'; +import { client } from '../../trpc/client'; +import { LocationShareMap, fitView, type MapMember, type MapView } from './locationShareMap'; +import { + JOYSTICK_SPEEDS, + LocationShareJoystick, + offsetLatLng, + type JoystickSpeedId, +} from './locationShareJoystick'; +import { DEFAULT_CENTER, DEFAULT_ZOOM } from './locationPicker'; + +/** 心跳兜底间隔(服务端没给 interval 时)。 */ +const DEFAULT_INTERVAL_SECONDS = 2; +/** 查询成员坐标的间隔(心跳的倍数)。位置不会推送,只能主动拉。 */ +const QUERY_EVERY_TICKS = 3; +/** 摇杆驱动的重绘节拍(毫秒):越小越顺滑,但上报仍然按服务端间隔。 */ +const JOYSTICK_TICK_MS = 100; +/** 自己的坐标推送节流:摇杆移动时最多每这么多毫秒上报一次。 */ +const REPORT_THROTTLE_MS = 900; + +export type LocationSharePhase = 'idle' | 'starting' | 'active'; + +interface LocationSharePanelProps { + /** 浮层根节点(外部点击判定用,与其余 composer 面板同构)。 */ + panelRef?: React.RefObject; + /** c2c = 私聊,group = 群聊。 */ + scene: 'c2c' | 'group'; + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: number; + /** 自己的 QQ 号。 */ + selfUin: number; + /** 自己的显示名 / 头像(地图上自己的标记)。 */ + selfName: string; + selfAvatarUrl?: string | null; + selfUid?: string; + /** 会话标题(面板头部)。 */ + conversationName: string; + /** 群成员(用来把 uin 映射成昵称 / 头像)。私聊为空。 */ + members?: Array<{ + identityValue: string; + displayName: string; + avatarUrl: string | null; + id: string; + }>; + /** 发送「发起了位置共享」卡片;由宿主转交 MessageSendService。 */ + onSendCard?: () => void | Promise; + /** 关闭面板(返回按钮)。 */ + onClose?: () => void; + disabled?: boolean; + disabledHint?: string; +} + +/** 面板内的一次会话状态。 */ +interface ShareSession { + /** 我自己当前的坐标(摇杆 / 点地图会改它)。 */ + self: { longitude: number; latitude: number }; + /** 服务端要求的下次上报间隔(秒)。 */ + interval: number; + /** 服务端返回的房间状态(1 进行中 / 2 已结束)。 */ + status: number; + /** 最近一次拉到的成员(含自己)。 */ + members: Array<{ + uin: number; + longitude: number; + latitude: number; + joinedAt: number; + reportedAt: number; + }>; + /** 最近一次成功上报的时间(毫秒),用于「上次上报」提示。 */ + lastReportAt: number; + /** 最近一次错误(展示用,成功一次就清)。 */ + error: string | null; +} + +const EMPTY_SESSION: ShareSession = { + self: { longitude: DEFAULT_CENTER.lng, latitude: DEFAULT_CENTER.lat }, + interval: DEFAULT_INTERVAL_SECONDS, + status: 1, + members: [], + lastReportAt: 0, + error: null, +}; + +/** uin → 展示信息(昵称 / 头像)。 */ +type MemberInfo = { name: string; avatarUrl: string | null; uid?: string }; + +export function LocationSharePanel({ + panelRef, + scene, + peer, + selfUin, + selfName, + selfAvatarUrl, + selfUid, + conversationName, + members, + onSendCard, + onClose, + disabled, + disabledHint, +}: LocationSharePanelProps) { + const [phase, setPhase] = useState('idle'); + const [session, setSession] = useState(EMPTY_SESSION); + const [speed, setSpeed] = useState('walk'); + const [view, setView] = useState({ + lng: DEFAULT_CENTER.lng, + lat: DEFAULT_CENTER.lat, + zoom: DEFAULT_ZOOM, + }); + const [follow, setFollow] = useState(true); + const [pickEnabled, setPickEnabled] = useState(false); + /** 摇杆当前方向(-1..1),`null` = 没在移动。 */ + const moveRef = useRef<{ x: number; y: number } | null>(null); + /** 每轮上报后的「下一次该报」计时。 */ + const nextReportRef = useRef(0); + const lastReportSentRef = useRef(0); + const tickRef = useRef | null>(null); + const sessionRef = useRef(session); + sessionRef.current = session; + const phaseRef = useRef(phase); + phaseRef.current = phase; + + /** uin → 昵称 / 头像。群成员表优先,自己单独覆盖。 */ + const memberInfoByUin = useMemo(() => { + const map = new Map(); + for (const member of members ?? []) { + const uin = Number(member.identityValue); + if (Number.isSafeInteger(uin) && uin > 0) { + map.set(uin, { + name: member.displayName, + avatarUrl: member.avatarUrl, + uid: member.id, + }); + } + } + map.set(selfUin, { name: selfName, avatarUrl: selfAvatarUrl ?? null, uid: selfUid }); + return map; + }, [members, selfUin, selfName, selfAvatarUrl, selfUid]); + + // ─────────────────────────── 启动 / 停止 ─────────────────────────── + + const stopTicking = useCallback(() => { + if (tickRef.current !== null) { + clearInterval(tickRef.current); + tickRef.current = null; + } + }, []); + + /** 上报一次(内部使用;会更新 session)。 */ + const doReport = useCallback( + async (position: { longitude: number; latitude: number }) => { + try { + const reply = await client.account.locationShareReport.mutate({ + scene, + peer, + selfUin, + longitude: position.longitude, + latitude: position.latitude, + }); + lastReportSentRef.current = Date.now(); + setSession((current) => ({ + ...current, + interval: reply.interval || DEFAULT_INTERVAL_SECONDS, + lastReportAt: Date.now(), + error: reply.ok ? null : reply.message || `上报失败(${reply.code})`, + })); + } catch (error) { + setSession((current) => ({ + ...current, + error: error instanceof Error ? error.message : String(error), + })); + } + }, + [scene, peer, selfUin], + ); + + /** 拉一次成员坐标。 */ + const doQuery = useCallback(async () => { + try { + const room = await client.account.locationShareQuery.query({ + scene, + peer, + selfUin, + mode: 1, + }); + setSession((current) => ({ + ...current, + status: room.status, + members: room.members, + })); + // 房间已结束(停上报超时)→ 回到 idle,别让用户以为还在共享。 + if (room.status === 2 && phaseRef.current === 'active') { + stopTicking(); + setPhase('idle'); + } + } catch (error) { + setSession((current) => ({ + ...current, + error: error instanceof Error ? error.message : String(error), + })); + } + }, [scene, peer, selfUin, stopTicking]); + + /** + * 心跳循环。 + * + * 用单个 100ms 的 tick 同时驱动「摇杆移动」与「按服务端间隔上报 / 定期查询」: + * 两个独立定时器会让「摇杆停下但心跳还在跑」这类状态很难对齐。 + */ + const startTicking = useCallback(() => { + stopTicking(); + let queryCountdown = 0; + tickRef.current = setInterval(() => { + if (phaseRef.current !== 'active') return; + const now = Date.now(); + const current = sessionRef.current; + + // ① 摇杆位移:按速度档位把「米/秒」换成经纬度增量。 + const move = moveRef.current; + if (move && (move.x !== 0 || move.y !== 0)) { + const speedSpec = JOYSTICK_SPEEDS.find((item) => item.id === speed) ?? JOYSTICK_SPEEDS[0]; + const seconds = JOYSTICK_TICK_MS / 1000; + const meters = speedSpec.metersPerSecond * seconds; + // 屏幕 y 向下为正,地理上「向上」= 北。 + const next = offsetLatLng( + current.self.latitude, + current.self.longitude, + -move.y * meters, + move.x * meters, + ); + setSession((prev) => ({ ...prev, self: next })); + if (follow) setView((v) => ({ ...v, lng: next.longitude, lat: next.latitude })); + } + + // ② 上报:到点才发,且摇杆移动时也不高于 REPORT_THROTTLE_MS。 + const intervalMs = Math.max(1000, current.interval * 1000); + if (now >= nextReportRef.current && now - lastReportSentRef.current >= REPORT_THROTTLE_MS) { + nextReportRef.current = now + intervalMs; + const position = sessionRef.current.self; + void doReport(position); + } + + // ③ 查询:每 N 次上报拉一次成员坐标。 + queryCountdown -= 1; + if (queryCountdown <= 0) { + queryCountdown = QUERY_EVERY_TICKS; + void doQuery(); + } + }, JOYSTICK_TICK_MS); + }, [speed, follow, doReport, doQuery, stopTicking]); + + // 摇杆 / 跟随 / 速度变了要重建 tick(闭包捕获了它们)。 + useEffect(() => { + if (phase !== 'active') return; + startTicking(); + return stopTicking; + }, [phase, startTicking, stopTicking]); + + // 面板卸载时停心跳(等价于「退出共享」)。 + useEffect(() => stopTicking, [stopTicking]); + + const handleStart = useCallback(async () => { + if (disabled) return; + setPhase('starting'); + setSession((current) => ({ ...current, error: null })); + const start = sessionRef.current.self; + try { + const reply = await client.account.locationShareStart.mutate({ + scene, + peer, + selfUin, + longitude: start.longitude, + latitude: start.latitude, + }); + if (!reply.ok) { + setSession((current) => ({ + ...current, + error: reply.message || `发起失败(${reply.code})`, + })); + setPhase('idle'); + return; + } + setPhase('active'); + nextReportRef.current = 0; + lastReportSentRef.current = 0; + setView((v) => ({ ...v, lng: start.longitude, lat: start.latitude })); + // 发卡片:失败不影响共享本身(房间已经建好了)。 + try { + await onSendCard?.(); + } catch { + /* 卡片失败静默 —— 房间已建立,对方仍可通过其他方式加入 */ + } + } catch (error) { + setSession((current) => ({ + ...current, + error: error instanceof Error ? error.message : String(error), + })); + setPhase('idle'); + } + }, [disabled, scene, peer, selfUin, onSendCard]); + + const handleStop = useCallback(() => { + stopTicking(); + setPhase('idle'); + moveRef.current = null; + }, [stopTicking]); + + // ─────────────────────────── 交互 ─────────────────────────── + + const handleJoystickMove = useCallback((dx: number, dy: number) => { + moveRef.current = dx === 0 && dy === 0 ? null : { x: dx, y: dy }; + }, []); + + const handleMapPick = useCallback((longitude: number, latitude: number) => { + setSession((current) => ({ ...current, self: { longitude, latitude } })); + setPickEnabled(false); + // 立刻上报一次,别等下一个心跳。 + lastReportSentRef.current = 0; + }, []); + + /** 地图标记:房间成员 + 自己(自己以本地坐标为准,更实时)。 */ + const mapMembers: MapMember[] = useMemo(() => { + const now = Date.now() / 1000; + const result: MapMember[] = []; + const selfPos = session.self; + const seen = new Set(); + for (const member of session.members) { + if (member.uin === selfUin) continue; + // 没上报过(坐标 0)的成员不画 —— 会落在几内亚湾。 + if (!member.longitude && !member.latitude) continue; + const info = memberInfoByUin.get(member.uin); + seen.add(member.uin); + result.push({ + id: String(member.uin), + name: info?.name ?? String(member.uin), + avatarUrl: info?.avatarUrl ?? null, + seed: info?.uid ?? String(member.uin), + longitude: member.longitude, + latitude: member.latitude, + ageSeconds: member.reportedAt ? Math.max(0, now - member.reportedAt) : undefined, + }); + } + // 自己永远在(即使服务端还没回显)。 + result.push({ + id: 'self', + name: selfName, + avatarUrl: selfAvatarUrl ?? null, + seed: selfUid ?? String(selfUin), + longitude: selfPos.longitude, + latitude: selfPos.latitude, + isSelf: true, + }); + return result; + }, [session.members, session.self, selfUin, memberInfoByUin, selfName, selfAvatarUrl, selfUid]); + + /** 「看全部」:把所有标记收进视野。 */ + const handleFitAll = useCallback(() => { + const host = document.querySelector('.lbs-map') as HTMLElement | null; + const size = host + ? { width: host.clientWidth, height: host.clientHeight } + : { width: 360, height: 260 }; + const fitted = fitView( + mapMembers.map((m) => ({ longitude: m.longitude, latitude: m.latitude })), + size, + ); + if (fitted) setView(fitted); + }, [mapMembers]); + + /** 「回到我」。 */ + const handleRecenter = useCallback(() => { + setFollow(true); + setView((v) => ({ ...v, lng: session.self.longitude, lat: session.self.latitude })); + }, [session.self]); + + const othersCount = session.members.filter( + (m) => m.uin !== selfUin && (m.longitude || m.latitude), + ).length; + const active = phase === 'active'; + + return ( +
+
+ +
+ + 位置共享 + {conversationName} +
+ + + {active ? othersCount + 1 : 0} + +
+ + {!active ? ( +
+
+ + +
+

+ {scene === 'group' ? '和群成员互相看到彼此的位置' : '和对方互相看到彼此的位置'} +

+ + {session.error ? {session.error} : null} + {disabled && disabledHint ? {disabledHint} : null} +
+ ) : ( + <> +
+ { + setView(next); + // 手动平移后不再自动跟随,避免和用户抢镜头。 + setFollow(false); + }} + members={mapMembers} + onPick={handleMapPick} + pickEnabled={pickEnabled} + /> +
+ + + +
+
+ +
+ +
+ {JOYSTICK_SPEEDS.map((item) => ( + + ))} +
+
+ +
+ + + {session.status === 2 ? '已结束' : '共享中'} + {session.lastReportAt + ? ` · ${Math.max(0, Math.round((Date.now() - session.lastReportAt) / 1000))}s 前上报` + : ''} + + +
+ {session.error ? {session.error} : null} + + )} +
+ ); +} diff --git a/apps/desktop/test/location_share_geo.test.ts b/apps/desktop/test/location_share_geo.test.ts new file mode 100644 index 000000000..134453e9b --- /dev/null +++ b/apps/desktop/test/location_share_geo.test.ts @@ -0,0 +1,90 @@ +/** + * 位置共享的地理换算单测。 + * + * 只测纯函数(摇杆 → 经纬度增量、多人视野拟合),不碰 React / DOM —— + * 这两个函数出错的表现是「摇杆方向反了」「看全部时有人跑出画面」, + * 在界面上很难一眼看出来,值得钉住。 + */ + +import { describe, expect, it } from 'vitest'; +import { offsetLatLng } from '../src/renderer/src/im-template/template/locationShareJoystick'; +import { fitView } from '../src/renderer/src/im-template/template/locationShareMap'; + +describe('offsetLatLng', () => { + it('向北 111.32 米约等于 +0.001 度纬度', () => { + const next = offsetLatLng(30, 104, 111.32, 0); + expect(next.latitude).toBeCloseTo(30.001, 5); + expect(next.longitude).toBeCloseTo(104, 6); + }); + + it('向东同样距离时,经度增量随纬度放大(高纬更「窄」)', () => { + const low = offsetLatLng(0, 0, 0, 1000); + const high = offsetLatLng(60, 0, 0, 1000); + // 纬度越高,同样的东向距离对应的经度变化越大。 + expect(high.longitude).toBeGreaterThan(low.longitude); + // 赤道上 1000 米 ≈ 0.00898 度。 + expect(low.longitude).toBeCloseTo(1000 / 111320, 6); + }); + + it('纬度被夹在 ±85 度内(Web Mercator 可渲染范围)', () => { + const next = offsetLatLng(84, 0, 1_000_000, 0); + expect(next.latitude).toBeLessThanOrEqual(85); + }); + + it('经度跨 180 度时回绕到 [-180, 180)', () => { + const next = offsetLatLng(0, 179.999, 0, 10_000); + expect(next.longitude).toBeGreaterThanOrEqual(-180); + expect(next.longitude).toBeLessThan(180); + }); +}); + +describe('fitView', () => { + const SIZE = { width: 400, height: 300 }; + + it('空列表返回 null', () => { + expect(fitView([], SIZE)).toBeNull(); + }); + + it('单个点给一个较近的缩放级别', () => { + const view = fitView([{ longitude: 104, latitude: 30 }], SIZE); + expect(view).not.toBeNull(); + expect(view!.lng).toBeCloseTo(104, 4); + expect(view!.lat).toBeCloseTo(30, 4); + expect(view!.zoom).toBe(16); + }); + + it('两个点都被收进视野(中心在两点中间)', () => { + const points = [ + { longitude: 104.0, latitude: 30.0 }, + { longitude: 104.02, latitude: 30.02 }, + ]; + const view = fitView(points, SIZE)!; + expect(view.lng).toBeCloseTo(104.01, 3); + expect(view.lat).toBeCloseTo(30.01, 3); + // 缩放要够大才能同时看到这两点(约 2km 跨度)。 + expect(view.zoom).toBeGreaterThan(10); + expect(view.zoom).toBeLessThanOrEqual(18); + }); + + it('点越分散,缩放越小', () => { + const tight = fitView( + [ + { longitude: 104, latitude: 30 }, + { longitude: 104.001, latitude: 30.001 }, + ], + SIZE, + )!; + const wide = fitView( + [ + { longitude: 104, latitude: 30 }, + { longitude: 105, latitude: 31 }, + ], + SIZE, + )!; + expect(wide.zoom).toBeLessThan(tight.zoom); + }); + + it('尺寸为 0 时返回 null(还没测量出容器)', () => { + expect(fitView([{ longitude: 104, latitude: 30 }], { width: 0, height: 0 })).toBeNull(); + }); +}); diff --git a/packages/protocol/src/msg/lbs-share.ts b/packages/protocol/src/msg/lbs-share.ts index ae2ca719f..cefdd774b 100644 --- a/packages/protocol/src/msg/lbs-share.ts +++ b/packages/protocol/src/msg/lbs-share.ts @@ -443,7 +443,9 @@ export function buildShareLocationElements(scene: LbsScene): SendElement[] { flag81: 16, nested102: { inner: { a: 0, b: 0, c: 0, d: 0 } }, flag107: 1100, - token116: 0n, + // 用 number 而不是 bigint:这组元素要经 IPC 从渲染层传到主进程, + // 而 IPC 的还原函数(`elementsFromEditable`)只还原 Buffer、不认 bigint。 + token116: 0, }, }, }, From 6ca014fbc822cb7efdaf67ed58500b3d7c2b5121 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Tue, 27 Oct 2026 01:02:37 +0800 Subject: [PATCH 18/23] =?UTF-8?q?fix(protocol):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E8=A7=86=E9=A2=91=E9=80=9A=E8=AF=9D=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E7=9A=84=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- docs/develop/video-call-20261025.md | 55 +++++- packages/protocol/src/call/gcs.ts | 140 ++++++++++---- packages/protocol/src/call/index.ts | 2 + packages/protocol/test/call.test.ts | 59 +++++- packages/service/src/account/call_session.ts | 180 +++++++----------- .../service/test/call_video_session.test.ts | 114 +++++++---- 7 files changed, 359 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 619926e2d..db44513fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -593,7 +593,7 @@ - 修复视频通话画面播放二十来秒后永久卡住的问题:帧序号跨 256 帧后不再被误判为「非基础层」丢弃,本端上行也改为 16 位帧序号,两端画面持续更新。 - 修复视频通话画面卡顿:摄像头帧改走二进制 IPC 通道,不再把每帧展开成 1.2 MB 文本经 JSON 传输,采集与编码不再互相拖慢。 - 修复视频通话界面读不到对方分辨率的问题,关键帧参数集解析偏移已校正。 -- 修复视频通话开启摄像头后对端看不到画面的问题:补上 GCS 上的视频参数公告,手机与电脑都能收到本端已开摄像头。 +- 视频通话补齐 GCS 视频流状态握手,并停止伪造仅由服务端下发的视频参数推送。 - 继续对齐视频通话的上行帧格式:对端未发视频时基准字段填 0、收到后改用对端时间基准,分片标记字段按实测分布填写。 - 修复视频通话媒体退化成全程服务器中继、两端画面都极卡的问题:服务器选路不再选中继端口,对端直连地址识别不再被 IPv4 映射地址混淆。 - 修复视频通话拿不到对端直连地址、媒体只能全程走服务器中继的问题:GCS 认证改为发送与 QQ 一致的音视频能力集。 diff --git a/docs/develop/video-call-20261025.md b/docs/develop/video-call-20261025.md index b6d8fa50d..092196eab 100644 --- a/docs/develop/video-call-20261025.md +++ b/docs/develop/video-call-20261025.md @@ -197,13 +197,58 @@ body:`[0]=0x02`,`[4:8]` 是 tick 的小端副本,其余为 0。 所以单看事件号分不出「开」还是「关」,得配合 `videoReceiving` 的当前值。实现 里没有依赖这个事件,只用来记日志。 -视频开始前还会出现两条未完全还原的控制面消息: +### 4.1 视频流状态握手(`bt=31`) -- `bt=31`:body.f1=4,一次性; -- `bt=77`:head.f5 带 88 字节密钥材料,body.f3 是流索引(0/1/2/3 递增)。 - 视频载荷是明文,这份材料没有用在载荷上。 +视频流建立依赖 `bt=31` 的四步状态交换,**不能当成普通未知推送只回 ACK**: -两者都只需 ACK,现有代码的「未知推送一律 ACK」已经覆盖。 +```text +event=4(振铃) + C→S bt31 body=0803 (带房间 authKey) + S→C bt32 body=0803 + S→C bt31 body=0806 + C→S bt32 body= + +S→C bt31 body=0804 + C→S bt32 body= 普通 ACK + C→S bt31 body=0805 状态应答,同样带 authKey + S→C bt32 body=0805 +``` + +QQ 电脑→电脑和电脑→手机的原生抓包中都能观察到这组状态交换。WeQ 旧实现把 +`bt=31` 当成普通未知推送,只回 ACK,缺少 `0803/0805` 这两个有效的状态值。 + +### 4.2 视频参数是服务端推送(`bt=77`) + +对照抓包(QQ 电脑→电脑、QQ 电脑→手机、WeQ→手机)确认: + +- `bt=77` **只由服务端下发**,两个原生客户端都是 client→server 0 条; +- head.f5 带 88 字节密钥材料; +- body 有两种形态:13 字节(流索引)和 52 字节(完整视频参数); +- body.f3 是流索引(0/1/2/3…),服务端会随会话推进持续更新; +- `f14/f15` 与目标端相关:电脑对端样本是 1920×1200,手机对端是 640×480; +- 收到 `bt=77` 后回普通空 `bt=78` ACK 即可,视频载荷仍是明文。 + +服务端在 `event=10` 接通前就会下发第一条 `bt=77`,说明它不是客户端摄像头 +开关公告。客户端不应伪造这条包。 + +### 4.3 接听前如何知道对端设备 + +`bt=1` 认证 body 在三份抓包里完全相同,不能用来判断对端。服务端在 +`bt=3/event=4` 的 `f2` 子消息里携带了对端媒体描述。实测值: + +| 字段 | 对端电脑 | 对端手机 | +| --- | ---: | ---: | +| `info.f6` | 5 | 4 | +| `info.f13` | 700 | 5000 | +| `info.f15` | 10063 | 10064 | +| `info.f16` | 94 | 62 | +| `info.f19` | 0 | 1 | + +这些值在接通事件前已经到达;随后服务端再按目标端下发对应的 `bt=77` +(尤其 `f14/f15`)。当前实现不根据本机平台猜测对端,直接按统一状态机应答。 + +`bt=15` 的 `f133/f134/f135` 在通话阶段会随视频状态变化,当前尚未把所有转移 +都还原完整;没有逐字段抓包证据时不要用本地 endpoint 或编码参数现场拼这些字段。 ## 5. 实现位置 diff --git a/packages/protocol/src/call/gcs.ts b/packages/protocol/src/call/gcs.ts index f02a27cd2..1405356c9 100644 --- a/packages/protocol/src/call/gcs.ts +++ b/packages/protocol/src/call/gcs.ts @@ -209,11 +209,21 @@ export const GcsBodyType = { /** 对端 P2P 端点推送(server→client)。见 {@link parsePeerJoin}。 */ peerJoin: 27, peerJoinAck: 28, + /** + * 视频流状态推送(server→client)。 + * + * 真机上 `f1=4` 表示服务端请求本端确认视频流;客户端还需另发一条同类型的 + * `f1=5`,并保留普通空 ACK(`bt=32`)。见 {@link buildVideoState}。 + */ + videoStatePush: 31, + videoStateAck: 32, mediaCtrlPush: 75, mediaCtrlAck: 76, /** - * 视频流参数推送。两种 body 形态:52 字节(分辨率 / 码率)与 13 字节(流索引)。 - * 见 {@link buildVideoParamPush} / {@link buildVideoStreamPush}。 + * 视频流参数推送(仅 server→client)。 + * + * 52 字节 body 包含分辨率 / 码率、目标端尺寸和流索引。真机客户端不会主动 + * 发送这条;收到后只需普通 ACK(`bt=78`)。见 {@link buildVideoParamPush}。 */ videoParamPush: 77, logout: 1003, @@ -412,34 +422,39 @@ export function buildGcsLogout(params: { } /** - * 视频流参数推送(bt=77,24 字节 body 形态)。 + * 视频流参数推送(`bt=77`,52 字节 body,**server→client**)。 * - * 实测(2026-10-26 QQ 本体抓包):本机开始发视频后约 3 秒,对端会在 GCS 上推 - * 这条,之后每 2 秒重发一次。它是「本端要发视频 / 视频参数是什么」的**房间级 - * 公告** —— 只发 `subType=4` 控制包、不发这条时,对端不知道本端开了摄像头 - * (WeQ 早先就是漏了它)。 + * 2026-10-26 三通对照抓包(QQ 电脑→电脑、QQ 电脑→手机、WeQ→手机)确认: + * 两侧原生客户端都不发这条,只由服务端在接听前下发并按流索引持续更新。 + * 它描述目标端准备接收的视频规格,不是「本端摄像头已开」的公告。 * - * 载荷结构(逐字节还原,总长 24 字节): + * 载荷结构(逐字节还原,总长 52 字节): * * ```text - * 12 25 f2,声明长度 37 + * 12 25 f2,37 字节 * 10 a2 02 .f2 = 290 * 18 01 .f3 = 1 - * 22 1e .f4,声明长度 30 - * 08 0f ..f1 = 15 fps + * 22 1e .f4,30 字节 + * 08 0f ..f1 = 15 fps * 10 01 ..f2 = 1 - * 18 80 05 ..f3 = 640 宽 - * 20 e0 03 ..f4 = 480 高 - * 28 80 28 ..f5 = 5120 码率 kbps + * 18 80 05 ..f3 = 640 宽 + * 20 e0 03 ..f4 = 480 高 + * 28 80 28 ..f5 = 5120 码率 kbps * 30 00 ..f6 = 0 + * 38 88 27 ..f7 = 5000 + * 50 80 05 ..f10 = 640 + * 58 e0 03 ..f11 = 480 + * 70 80 05 ..f14 = 640 目标端宽 + * 78 e0 03 ..f15 = 480 目标端高 + * 18 00 f3 = 流索引(0/1/2/3…) + * 3a 09 f7 = 流元数据 + * 08 01 .f1 = 1 + * 10 91 83 bd 03 .f2 = 7291281 + * 20 01 .f4 = 1 * ``` * - * ⚠️ 两个声明长度(37 / 30)都比实际内容(22 / 15)大 15 —— 真实客户端就是这么 - * 发的,服务端与对端都不按这两个长度切字段。**照抄实测值**,别"修正"成实际长度: - * 那样会偏离黄金样本,也就失去了「和真实客户端逐字节一致」的验证手段。 - * - * `f2=290` 与 `f3=1` 的语义未还原,同样照抄。分辨率 / 码率必须与本端编码器 - * 一致,否则对端按错误的规格准备解码。 + * 电脑对端样本的 `f14/f15` 是 1920×1200;手机对端是 640×480。 + * 该函数保留用于黄金样本与测试,**不要在客户端开摄像头时发送**。 */ export function buildVideoParamPush(params: { roomId: bigint; @@ -451,6 +466,11 @@ export function buildVideoParamPush(params: { fps: number; /** 码率(kbps)。 */ bitrateKbps: number; + /** 目标端展示尺寸;缺省与编码尺寸一致。 */ + displayWidth?: number; + displayHeight?: number; + /** 服务端流索引;缺省 0。 */ + streamIndex?: number; }): Uint8Array { const head = buildGcsHead({ roomId: params.roomId, @@ -466,17 +486,19 @@ export function buildVideoParamPush(params: { pbVarint(4, params.height), pbVarint(5, params.bitrateKbps), pbVarint(6, 0), + pbVarint(7, 5000), + pbVarint(10, params.width), + pbVarint(11, params.height), + pbVarint(14, params.displayWidth ?? params.width), + pbVarint(15, params.displayHeight ?? params.height), ]); - // f4 的声明长度固定写 30(实际 15),与真实客户端一致,见上方说明。 - const inner = concat([ - pbVarint(2, 290), - pbVarint(3, 1), - tag(4, 2), - encodeVarint(30), - videoParams, + const inner = concat([pbVarint(2, 290), pbVarint(3, 1), pbBytes(4, videoParams)]); + const streamInfo = concat([pbVarint(1, 1), pbVarint(2, 7291281), pbVarint(4, 1)]); + const body = concat([ + pbBytes(2, inner), + pbVarint(3, params.streamIndex ?? 0), + pbBytes(7, streamInfo), ]); - // 外层 f2 的声明长度固定写 37(= 22 + 15),同上。 - const body = concat([tag(2, 2), encodeVarint(37), inner]); return buildGcsPacket(head, body); } @@ -514,6 +536,44 @@ export function buildVideoStreamPush(params: { return buildGcsPacket(head, body); } +/** + * 视频流状态消息(`bt=31`,client↔server)。 + * + * 三通原生抓包中,视频通话的 caller 在收到 `event=4` 后发送 `f1=3`;服务端 + * 下发 `f1=4` 时,客户端除普通空 ACK 外还必须回一条 `f1=5`。客户端发出的 + * 两条都带 88 字节房间 authKey。当前只实现主动发起,因此只构造 3 / 5。 + */ +export function buildVideoState(params: { + roomId: bigint; + selfUin: number; + sessionTs: number; + typeId: number; + authKey: Uint8Array; + state: number; +}): Uint8Array { + const head = buildGcsHead({ + roomId: params.roomId, + selfUin: params.selfUin, + sessionTs: params.sessionTs, + typeId: params.typeId, + authKey: params.authKey, + bodyType: GcsBodyType.videoStatePush, + }); + return buildGcsPacket(head, pbVarint(1, params.state)); +} + +/** `bt=31` 的线上状态值。 */ +export const VideoStateCode = { + /** C→S:客户端在振铃阶段发起视频流状态协商。 */ + start: 3, + /** S→C:服务端请求本端确认视频流。 */ + offer: 4, + /** C→S:确认服务端的 offer。 */ + accept: 5, + /** S→C:服务端确认客户端的 start。 */ + ready: 6, +} as const; + /** 房间事件推送(bt=3)里的事件类型。 */ export const CallEvent = { invited: 1, @@ -574,7 +634,10 @@ export function buildStatusReport(params: { sendBytes: number; /** 本次通话内的第几次上报,从 1 起。 */ seq: number; + /** 当前是否已在发视频;只切换抓包中已确认会变化的字段。 */ + media?: 'audio' | 'video'; }): Uint8Array { + const videoCall = params.media === 'video'; const tick = Date.now() >>> 0; const parts: Uint8Array[] = []; parts.push(pbBytes(1, concat([encodeVarint(tick), encodeVarint((tick + 2000) >>> 0)]))); @@ -590,9 +653,10 @@ export function buildStatusReport(params: { parts.push(pbVarint(13, params.seq === 1 ? 1 : 0)); parts.push(pbVarint(14, params.sendBytes)); parts.push(pbVarint(15, 0)); - parts.push(pbVarint(16, 3)); // 网络类型:3 = 有线 + // 原生视频样本固定为 1;旧语音模板的 3 会让服务端把它当成另一种终端。 + parts.push(pbVarint(16, videoCall ? 1 : 3)); parts.push(pbVarint(17, 1)); - // f18:网络描述(34 字节),运营商 / 链路类型 / 带宽,照抄真实客户端。 + // f18:网络描述,沿用已通过真机校验的固定样本。 parts.push( pbBytes( 18, @@ -609,8 +673,9 @@ export function buildStatusReport(params: { parts.push(pbVarint(22, 100)); // 质量分 for (let i = 23; i <= 33; i++) parts.push(pbVarint(i, 0)); parts.push(pbVarint(36, 0)); - for (let i = 38; i <= 40; i++) parts.push(pbVarint(i, 0)); - parts.push(pbVarint(42, 26280)); // 发送码率 + for (let i = 38; i <= 39; i++) parts.push(pbVarint(i, 0)); + parts.push(pbVarint(40, videoCall ? 2 : 0)); + parts.push(pbVarint(42, videoCall ? 45000 : 0)); // 收发带宽预算 for (let i = 43; i <= 45; i++) parts.push(pbVarint(i, 0)); for (let i = 46; i <= 53; i++) parts.push(pbVarint(i, 0xffff)); parts.push(pbVarint(56, 0)); @@ -629,8 +694,13 @@ export function buildStatusReport(params: { parts.push(pbVarint(105, 0)); parts.push(pbVarint(110, 0)); parts.push(pbVarint(111, params.seq)); - for (let i = 112; i <= 115; i++) parts.push(pbVarint(i, 0)); - parts.push(pbVarint(120, 26847424)); + parts.push(pbVarint(112, params.seq >= 3 ? 1 : 0)); + parts.push(pbVarint(113, 0)); + parts.push(pbVarint(114, params.seq >= 3 ? (videoCall ? 6553600 : 2621440) : 0)); + parts.push(pbVarint(115, 0)); + // 与 DEFAULT_CAPABILITIES.f11 一致。原生语音通话也声明视频能力, + // 否则之后动态打开摄像头时服务端不会补发完整的 bt=77 视频流参数。 + parts.push(pbVarint(120, 1812048064)); parts.push(pbVarint(123, 0)); for (let i = 125; i <= 130; i++) parts.push(pbVarint(i, 0)); parts.push(pbVarint(132, 0)); diff --git a/packages/protocol/src/call/index.ts b/packages/protocol/src/call/index.ts index a6f3476f4..1c45e134c 100644 --- a/packages/protocol/src/call/index.ts +++ b/packages/protocol/src/call/index.ts @@ -93,6 +93,8 @@ export { buildGcsHeartbeat, buildGcsAck, buildGcsLogout, + buildVideoState, + VideoStateCode, buildVideoParamPush, buildVideoStreamPush, CallEvent, diff --git a/packages/protocol/test/call.test.ts b/packages/protocol/test/call.test.ts index 2c1cc7218..215b78907 100644 --- a/packages/protocol/test/call.test.ts +++ b/packages/protocol/test/call.test.ts @@ -14,6 +14,7 @@ import { buildGcsAck, buildGcsAuth, buildGcsLogout, + buildVideoState, buildVideoParamPush, buildVideoStreamPush, buildPeerJoinReport, @@ -32,8 +33,11 @@ import { parseCallEvent, parseFecContainer, parseGcsPacket, + parsePb, parseQfb, + pbGetBytes, pbGetVarint, + VideoStateCode, rsDecode, rsEncode, } from '../src/call'; @@ -548,16 +552,39 @@ describe('质量反馈', () => { }); }); -// ─────────────── 视频参数公告(bt=77) ─────────────── +// ─────────────── 视频流状态(bt=31) ─────────────── -describe('视频参数公告', () => { +describe('视频流状态协商', () => { + it('客户端状态包带 authKey,body 是 f1=', () => { + const authKey = new Uint8Array(88).fill(0x5a); + const packet = buildVideoState({ + roomId: 1n, + selfUin: 10001, + sessionTs: 2, + typeId: 3, + authKey, + state: VideoStateCode.start, + }); + const parsed = parseGcsPacket(packet)!; + expect(parsed.bodyType).toBe(GcsBodyType.videoStatePush); + expect([...parsed.body]).toEqual([0x08, VideoStateCode.start]); + expect(pbGetBytes(parsed.head, 5)).toEqual(authKey); + }); +}); + +// ─────────────── 服务端视频参数推送(bt=77) ─────────────── + +describe('视频参数推送', () => { /** - * 黄金样本来自 2026-10-26 QQ 本体抓包(docs/develop/call-captures 的 - * `20261026-qqnative-voice-then-video-both.pcap`): - * 对端在 GCS 上推的 52 字节 body,分辨率 640×480、15 fps、5120 kbps。 + * 黄金样本来自 2026-10-26 QQ 电脑→手机抓包: + * 服务端在接通前推的 52 字节 body,分辨率 640×480、15 fps、5120 kbps。 */ const REAL_PARAM_BODY = Uint8Array.from( - Buffer.from('122510a2021801221e080f100118800520e0032880283000', 'hex'), + Buffer.from( + '122510a2021801221e080f100118800520e003288028300038882750800558e00370800578e003' + + '18003a090801109183bd032001', + 'hex', + ), ); it('逐字节还原真实抓包的 52 字节 body', () => { @@ -576,7 +603,7 @@ describe('视频参数公告', () => { expect([...parsed.body]).toEqual([...REAL_PARAM_BODY]); }); - it('分辨率 / 码率跟着参数走(对端按它准备解码)', () => { + it('分辨率、码率与目标端尺寸跟着参数走', () => { const build = (width: number, height: number, fps: number, bitrateKbps: number) => { const packet = buildVideoParamPush({ roomId: 1n, @@ -595,6 +622,24 @@ describe('视频参数公告', () => { expect([...a]).not.toEqual([...b]); // 480 / 270 的 varint 分别是 0xe0 0x03 / 0x8e 0x02,必须出现在 body 里。 expect([...b]).toContain(0x8e); + + const pcPacket = buildVideoParamPush({ + roomId: 1n, + selfUin: 1, + sessionTs: 1, + typeId: 1, + width: 640, + height: 480, + fps: 15, + bitrateKbps: 5120, + displayWidth: 1920, + displayHeight: 1200, + }); + const outer = parsePb(parseGcsPacket(pcPacket)!.body); + const inner = parsePb(outer.find((field) => field.field === 2)!.bytes!); + const video = parsePb(inner.find((field) => field.field === 4)!.bytes!); + expect(video.find((field) => field.field === 14)?.value).toBe(1920n); + expect(video.find((field) => field.field === 15)?.value).toBe(1200n); }); /** diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index f99e3f4dc..710698da5 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -55,8 +55,8 @@ import { buildStatusReport, buildVideoControl, buildVideoKeepalive, - buildVideoParamPush, buildVideoPackets, + buildVideoState, buildVideoSubHeader, extractHevc, parseAudioFrame, @@ -76,6 +76,7 @@ import { isIrapNalType, VIDEO_SUBHEADER_LEN, VIDEO_CTRL_SUB_TYPE, + VideoStateCode, videoPacketCount, VIDEO_KEEPALIVE_SUB_TYPE, VideoFrameAssembler, @@ -150,9 +151,9 @@ export interface VideoStreamInfo { profile: number; /** 编码格式,目前只认 hevc。 */ codec: 'hevc'; - /** 编码帧率;缺省按 {@link DEFAULT_VIDEO_FPS}。 */ + /** 编码帧率(可选)。 */ fps?: number; - /** 目标码率(kbps);缺省按 {@link DEFAULT_VIDEO_BITRATE_KBPS}。 */ + /** 目标码率(kbps,可选)。 */ bitrateKbps?: number; } @@ -310,40 +311,6 @@ const LOGOUT_REPEATS = 5; const FEC_GROUP = 4; /** 视频保活周期(实测 2 秒)。 */ const VIDEO_KEEPALIVE_MS = 2000; -/** - * 视频参数公告(`bt=77`)的重发周期。 - * - * 实测真实客户端每 2 秒重发一次 52 字节那条;它同时也是「本端还在发视频」的 - * 房间级心跳,停发会被对端当成关摄像头。 - */ -const VIDEO_PARAM_PUSH_MS = 2000; -/** - * 编码帧率缺省值。 - * - * 2026-10-26 抓包实测 QQ 本体:上行 11.8 fps、下行 14.9 fps。取 15 与下行一致 - * (对端按这个节奏发,我们跟上就不会显得卡)。 - */ -const DEFAULT_VIDEO_FPS = 15; -/** - * 编码码率缺省值(kbps)。 - * - * ⚠️ **这是「卡顿」和「对端收不到画面」的主因之一。** - * - * 2026-10-26 同一通通话对比(都是 320×184): - * - * | | QQ 本体 | WeQ(旧值 400) | - * | --- | --- | --- | - * | 上行码率 | 6–26 kbps(静态 6、运动峰值 26) | **351 kbps** | - * | 每帧字节 p50 | 73 B | **3368 B** | - * | 每帧分片 | 1.0 | 3.4(最大 28) | - * - * QQ 的实际策略是「低码率 + 按需涨」:静止画面 6 kbps,有运动才到 26 kbps。 - * 我们写死 400 kbps,编码器会把这个预算全部用掉 —— 一帧 3KB 拆成 3~28 个 UDP - * 包,任何一片丢失整帧报废,中继路径上尤其明显。 - * - * 取 30 kbps 对齐 QQ 的运动峰值;ABR 在静止画面下会自动降得更低。 - */ -const DEFAULT_VIDEO_BITRATE_KBPS = 30; /** 同一拍里第二条保活包相对第一条的延迟(实测 40–90 ms)。 */ const VIDEO_KEEPALIVE_SECOND_MS = 60; /** 视频发送轮询间隔:比最慢的帧间隔(10 fps)小一个量级。 */ @@ -635,8 +602,8 @@ export class CallSession { private lastPeerMediaAt = 0; /** 已见过的媒体子类型(每个只记一次日志,避免逐包刷屏)。 */ private readonly seenMediaSubTypes = new Set(); - /** 视频参数是否已用 info 记过一次(后续周期公告降到 debug)。 */ - private videoParamAnnounced = false; + /** 是否已发过视频流状态协商首包(`bt=31/f1=3`)。 */ + private videoStateStarted = false; /** * 进入 `probing` 的时刻,用于「探测期满仍无回程」的强制回落。 * @@ -675,8 +642,6 @@ export class CallSession { private mediaBaseTick = 0; /** 视频保活定时器。 */ private videoKeepaliveTimer: unknown = null; - /** `bt=77` 视频参数公告的重发定时器。 */ - private videoParamTimer: unknown = null; /** 视频发送循环定时器。 */ private videoTimer: unknown = null; /** 视频接收看门狗。 */ @@ -784,16 +749,14 @@ export class CallSession { return; } this.videoSending = enabled; + if (enabled) this.startVideoState(); this.sendVideoControl(); if (enabled) { this.videoFrameIndex = 0; this.startVideoLoop(); this.startVideoKeepalive(); - // GCS 上的视频参数公告:不发这条,对端不知道本端开了摄像头。 - this.startVideoParamPush(); } else { this.stopVideoLoop(); - this.stopVideoParamPush(); } this.log?.info('摄像头状态', { enabled }); this.emit(); @@ -976,7 +939,10 @@ export class CallSession { } // 兜底:探测期被反复重置(对端零星发包)时,超过 3 倍探测期仍无回程也判死。 if (this.mediaPath === 'probing' && this.probeDeadlineAt > 0) { - if (this.timers.now() - this.probeDeadlineAt >= MEDIA_PROBE_MS * 3 && !this.peerProbeAnswered) { + if ( + this.timers.now() - this.probeDeadlineAt >= MEDIA_PROBE_MS * 3 && + !this.peerProbeAnswered + ) { this.log?.warn('直连探测超时,放弃本轮 P2P', { address: peer?.address }); this.mediaPath = 'server'; this.probeDeadlineAt = 0; @@ -1148,6 +1114,7 @@ export class CallSession { this.statusTimer = this.timers.setInterval(() => { if (!this.room) return; this.statusReportSeq++; + const videoCall = this.opts.media === 'video' || this.videoSending; this.send( buildStatusReport({ roomId: this.room.roomId, @@ -1158,6 +1125,7 @@ export class CallSession { recvPackets: this.receivedFrames, sendBytes: this.sentFrames * 80, seq: this.statusReportSeq, + media: videoCall ? 'video' : 'audio', }), ); }, STATUS_REPORT_MS); @@ -1341,6 +1309,9 @@ export class CallSession { this.sendPeerJoinReport(2); this.ackPush(packet.typeId, packet.bodyType, packet.body); break; + case GcsBodyType.videoStatePush: + this.handleVideoStatePush(packet); + break; default: // 其它推送类型一律回 ACK:不回的话服务端会重推。 if (packet.bodyType % 2 === 1) this.ackPush(packet.typeId, packet.bodyType, packet.body); @@ -1548,7 +1519,6 @@ export class CallSession { this.log?.info('公告本端 P2P 端点', { port: this.localPort }); } - /** 下一个控制包序号(中继 GET_IN 与 P2P 握手共用)。 */ private nextPeerSeq(): number { return ++this.peerPacketSeq; @@ -1724,6 +1694,7 @@ export class CallSession { switch (event.event) { case CallEvent.ringing: + this.startVideoState(); if (this.state === 'calling') { this.state = 'ringing'; this.emit(); @@ -1758,6 +1729,55 @@ export class CallSession { } } + /** + * 振铃后发起视频流状态协商。 + * + * 2026-10-26 三通原生抓包(电脑→电脑 / 电脑→手机 / WeQ→手机)对照: + * QQ 收到 `event=4` 后约 40–160 ms 发送 `bt=31 body=0803`(带 authKey)。 + * 服务端随后回普通 `bt=32 body=0803` 和 `bt=31 body=0806`。 + */ + private startVideoState(): void { + if (this.videoStateStarted || !this.room) return; + if (this.opts.media !== 'video' && !this.videoSending) return; + this.videoStateStarted = true; + this.sendVideoState(VideoStateCode.start); + } + + /** + * 处理 `bt=31` 视频流状态推送。 + * + * 服务端下发 `f1=4` 时,原生客户端会同时发空 `bt=32` ACK 和 + * `bt=31 body=0805`。后者不是普通 ACK,不能由「未知奇数推送一律 ACK」代替; + * 漏掉它时手机不会创建对端视频画面。 + */ + private handleVideoStatePush(packet: { + body: Uint8Array; + bodyType: number; + typeId: number; + }): void { + this.ackPush(packet.typeId, packet.bodyType, packet.body); + const state = pbGetVarint(packet.body, 1); + this.log?.info('视频流状态推送', { state }); + if ((this.opts.media === 'video' || this.videoSending) && state === VideoStateCode.offer) { + this.sendVideoState(VideoStateCode.accept); + } + } + + private sendVideoState(state: number): void { + if (!this.room) return; + this.send( + buildVideoState({ + roomId: this.room.roomId, + selfUin: Number(this.opts.selfUin), + sessionTs: this.sessionTs, + typeId: this.nextTypeId(), + authKey: this.room.authKey, + state, + }), + ); + this.log?.info('发送视频流状态', { state }); + } + private markConnected(): void { // 异步挂断期间的迟到 dataChange 不能复活通话。 if (this.state === 'connected' || this.state === 'ending') return; @@ -1785,7 +1805,6 @@ export class CallSession { this.videoFrameIndex = 0; this.startVideoLoop(); this.startVideoKeepalive(); - this.startVideoParamPush(); this.log?.info('视频通话接通,自动开启摄像头'); } this.emit(); @@ -1978,43 +1997,6 @@ export class CallSession { this.sendMedia(packet); } - /** - * 在 GCS 上公告本端视频参数(`bt=77`)。 - * - * **这条不能省。** 2026-10-26 QQ 本体抓包实测:对端开始发视频后会在 GCS 上 - * 推 52 字节的视频参数,之后每 2 秒重发一次;本端开摄像头时同样会推。 - * 只发 `subType=4` 控制包而不发这条,对端就不知道本端开了摄像头 —— WeQ 早先 - * 正是漏了它(表现为「我开了摄像头,手机和电脑都看不到」)。 - * - * 参数必须与编码器一致:对端按这里声明的分辨率 / 码率准备解码。 - */ - private sendVideoParamPush(): void { - if (!this.room) return; - const info = this.videoEncoder?.info(); - const width = info?.width ?? 640; - const height = info?.height ?? 360; - const fps = info?.fps ?? DEFAULT_VIDEO_FPS; - const bitrateKbps = info?.bitrateKbps ?? DEFAULT_VIDEO_BITRATE_KBPS; - const packet = buildVideoParamPush({ - roomId: this.room.roomId, - selfUin: Number(this.opts.selfUin), - sessionTs: this.sessionTs, - typeId: this.nextTypeId(), - width, - height, - fps, - bitrateKbps, - }); - this.send(packet); - // 每 2 秒一条的周期心跳,只在第一次用 info 记下来,其余降到 debug。 - if (!this.videoParamAnnounced) { - this.videoParamAnnounced = true; - this.log?.info('公告视频参数', { width, height, fps, bitrateKbps }); - } else { - this.log?.debug('公告视频参数', { width, height, fps, bitrateKbps }); - } - } - /** * 媒体路径看门狗。 * @@ -2105,27 +2087,6 @@ export class CallSession { this.videoKeepaliveTimer = null; } - /** - * 视频参数公告循环:立刻发一次,之后每 2 秒重发。 - * - * 与 {@link startVideoKeepalive}(`0x5b subType=5`,走媒体路径)不同,这条走 - * **GCS 控制面** —— 它是房间级公告,对端据此才知道本端开了摄像头。 - */ - private startVideoParamPush(): void { - if (this.videoParamTimer !== null) return; - this.sendVideoParamPush(); - this.videoParamTimer = this.timers.setInterval( - () => this.sendVideoParamPush(), - VIDEO_PARAM_PUSH_MS, - ); - } - - private stopVideoParamPush(): void { - if (this.videoParamTimer === null) return; - this.timers.clearInterval(this.videoParamTimer); - this.videoParamTimer = null; - } - /** * 视频发送循环。 * @@ -2273,7 +2234,12 @@ export class CallSession { // 中继推送而不是服务器回包。 // // 判据:地址若是中继节点自己(或就是 GCS),一律只记日志。 - this.log?.info('中继下发对端路径', { address, primaryPort, directPort, from: from.address }); + this.log?.info('中继下发对端路径', { + address, + primaryPort, + directPort, + from: from.address, + }); const isRelaySelf = (this.gcs !== null && sameHost(this.gcs.ip, address) && this.gcs.port === port) || this.relays.some((r) => sameHost(r.ip, address) && r.port === port) || @@ -2446,7 +2412,6 @@ export class CallSession { [this.connectTimer, 'clearTimeout'], [this.gcsRetryTimer, 'clearTimeout'], [this.videoKeepaliveTimer, 'clearInterval'], - [this.videoParamTimer, 'clearInterval'], [this.videoTimer, 'clearTimeout'], [this.videoWatchdogTimer, 'clearInterval'], [this.mediaWatchdogTimer, 'clearInterval'], @@ -2464,7 +2429,6 @@ export class CallSession { this.connectTimer = null; this.gcsRetryTimer = null; this.videoKeepaliveTimer = null; - this.videoParamTimer = null; this.videoTimer = null; this.videoWatchdogTimer = null; this.mediaWatchdogTimer = null; diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index ee555719c..cc4b65e1c 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -40,10 +40,12 @@ import { buildVideoControl, buildVideoKeepalive, parseGcsPacket, + pbGetBytes, parseVideoPacket, pbBytes, pbVarint, videoFrameIndex, + VideoStateCode, } from '@weq/protocol'; // ─────────────────────────── 测试替身 ─────────────────────────── @@ -336,6 +338,18 @@ function peerJoinPacket(port: number): Uint8Array { return buildGcsPacket(head, body); } +/** 服务端下发的 `bt=31` 视频流状态。 */ +function videoStatePacket(state: number): Uint8Array { + const head = buildGcsHead({ + roomId: 777n, + selfUin: 1707889225, + sessionTs: 555, + typeId: 0x6244, + bodyType: GcsBodyType.videoStatePush, + }); + return buildGcsPacket(head, pbVarint(1, state)); +} + /** 拼一个视频分片(带 20 字节 sub-header + Annex B)。 */ function videoPacket(params: { gidx: number; @@ -582,56 +596,82 @@ describe('发送视频', () => { }); /** - * 回归:开摄像头必须在 GCS 上公告视频参数(`bt=77`)。 - * - * 2026-10-26 QQ 本体抓包实测:对端开始发视频后会在 GCS 推 24 字节的视频参数, - * 之后每 2 秒重发。只发 `subType=4` 控制包而不发这条时,对端不知道本端开了 - * 摄像头 —— 这正是「WeQ 开摄像头,手机和电脑都看不到」的成因。 + * 2026-10-26 三通原生抓包:视频通话收到 `event=4` 后发送 + * `bt=31/f1=3`,开启后续 f4/f5 建流握手。 */ - it('视频通话接通时公告视频参数(bt=77),规格与编码器一致', async () => { - const encoder = new FakeVideoEncoder(1); - const ctx = makeSession({ media: 'video', videoEncoder: encoder }); - await connect(ctx); + it('视频通话振铃时发送一次 bt31/f1=3,并带 authKey', async () => { + const ctx = makeSession({ media: 'video' }); + await ctx.session.start(); + ctx.transport.deliver(authRspPacket()); + ctx.transport.reset(); - const pushes = ctx.transport.sent - .filter((s) => s.data[0] === GCS_STX) - .map((s) => parseGcsPacket(s.data)) - .filter((p) => p?.bodyType === GcsBodyType.videoParamPush); - expect(pushes.length).toBeGreaterThan(0); - // FakeVideoEncoder 声明 640×360 / 15 fps / 400 kbps: - // 宽 640 = 80 05,高 360 = e8 02,码率 400 = 90 03。 - expect([...pushes[0]!.body]).toEqual([ - 0x12, 0x25, 0x10, 0xa2, 0x02, 0x18, 0x01, 0x22, 0x1e, 0x08, 0x0f, 0x10, 0x01, 0x18, 0x80, - 0x05, 0x20, 0xe8, 0x02, 0x28, 0x90, 0x03, 0x30, 0x00, - ]); + ctx.transport.deliver(notifyPacket(CallEvent.ringing)); + ctx.transport.deliver(notifyPacket(CallEvent.ringing)); + + const states = ctx.transport.sent + .filter((entry) => entry.data[0] === GCS_STX) + .map((entry) => parseGcsPacket(entry.data)) + .filter((packet) => packet?.bodyType === GcsBodyType.videoStatePush); + expect(states).toHaveLength(1); + expect([...states[0]!.body]).toEqual([0x08, VideoStateCode.start]); + expect(pbGetBytes(states[0]!.head, 5)).toEqual(new Uint8Array(80).fill(1)); }); - it('公告每 2 秒重发一次(对端据此判断本端还在发视频)', async () => { - const encoder = new FakeVideoEncoder(1); - const ctx = makeSession({ media: 'video', videoEncoder: encoder }); - await connect(ctx); - const count = () => + it('语音通话振铃不发送视频流状态', async () => { + const ctx = makeSession(); + await ctx.session.start(); + ctx.transport.deliver(authRspPacket()); + ctx.transport.reset(); + + ctx.transport.deliver(notifyPacket(CallEvent.ringing)); + + expect( ctx.transport.sent.filter( - (s) => - s.data[0] === GCS_STX && parseGcsPacket(s.data)?.bodyType === GcsBodyType.videoParamPush, - ).length; - const first = count(); - await ctx.timers.advanceAsync(2100); - expect(count()).toBeGreaterThan(first); + (entry) => + entry.data[0] === GCS_STX && + parseGcsPacket(entry.data)?.bodyType === GcsBodyType.videoStatePush, + ), + ).toHaveLength(0); }); - it('关摄像头后停止公告视频参数', async () => { + it('收到 bt31/f1=4 时回空 bt32 ACK 和 bt31/f1=5', async () => { + const ctx = makeSession({ media: 'video' }); + await ctx.session.start(); + ctx.transport.deliver(authRspPacket()); + ctx.transport.reset(); + + ctx.transport.deliver(videoStatePacket(VideoStateCode.offer)); + + const parsed = ctx.transport.sent + .filter((entry) => entry.data[0] === GCS_STX) + .map((entry) => parseGcsPacket(entry.data)!); + const answers = parsed.filter((packet) => packet.bodyType === GcsBodyType.videoStatePush); + expect(answers).toHaveLength(1); + expect([...answers[0]!.body]).toEqual([0x08, VideoStateCode.accept]); + expect(pbGetBytes(answers[0]!.head, 5)).toEqual(new Uint8Array(80).fill(1)); + + const acks = parsed.filter((packet) => packet.bodyType === GcsBodyType.videoStateAck); + expect(acks).toHaveLength(1); + expect(acks[0]!.body).toHaveLength(0); + }); + + /** + * `bt=77` 是服务端推送。原生 QQ 在电脑→电脑和电脑→手机两通中都是 + * client→server 0 条,WeQ 不能自行伪造。 + */ + it('开摄像头和接通后都不发送客户端 bt=77', async () => { const encoder = new FakeVideoEncoder(1); const ctx = makeSession({ media: 'video', videoEncoder: encoder }); await connect(ctx); - ctx.session.setCamera(false); ctx.transport.reset(); - - await ctx.timers.advanceAsync(5000); + ctx.session.setCamera(false); + ctx.session.setCamera(true); + await ctx.timers.advanceAsync(2500); const pushes = ctx.transport.sent.filter( - (s) => - s.data[0] === GCS_STX && parseGcsPacket(s.data)?.bodyType === GcsBodyType.videoParamPush, + (entry) => + entry.data[0] === GCS_STX && + parseGcsPacket(entry.data)?.bodyType === GcsBodyType.videoParamPush, ); expect(pushes).toHaveLength(0); }); From 114d32c50afa9c6e0a3dfc4cd56a1866364138b2 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Tue, 27 Oct 2026 02:17:25 +0800 Subject: [PATCH 19/23] =?UTF-8?q?fix(protocol):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E5=85=B1=E4=BA=AB=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + packages/protocol/src/msg/lbs-share.ts | 69 ++++++++++++++++++------- packages/service/test/lbs_share.test.ts | 39 ++++++++++++-- 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2779247f8..31ef6edd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -582,3 +582,4 @@ - 修复视频通话拿不到对端直连地址、媒体只能全程走服务器中继的问题:GCS 认证改为发送与 QQ 一致的音视频能力集。 - 继续对齐视频通话信令:开房与邀请请求的字段与 QQ 本体逐字节一致。 - 修复视频通话对端久等才看到画面、媒体全程走服务器中继的问题:补齐 P2P 端口交换与直连握手,媒体确认直连后不再全程走服务器中继;同时修正中继注册报文的序号与对端字段,服务器重新给予中继应答。 +- 修复私聊位置共享发起报 `param error`:会话键改为按 uin 升序排列两个参与者,两个方向的发起现在都能成功。 diff --git a/packages/protocol/src/msg/lbs-share.ts b/packages/protocol/src/msg/lbs-share.ts index cefdd774b..e4d804ea3 100644 --- a/packages/protocol/src/msg/lbs-share.ts +++ b/packages/protocol/src/msg/lbs-share.ts @@ -11,11 +11,16 @@ * * `room_operation` 与查询用**场景键**: * - * 私聊 `{ 1: 2(scene C2C), 2: peerUin, 3: selfUin }` + * 私聊 `{ 1: 2(scene C2C), 2: 小号, 3: 大号 }` ← 两个参与者按 uin 升序,不是「对端/自己」 * 群聊 `{ 1: 1(scene 群), 2: groupCode, 3: 0 }` * - * `report_location` 例外 —— 抓包里两种场景的 `1.1` 都是 `2`、`1.3` 都是 - * `selfUin`,只有 `1.2` 换成群号。所以它单独用 {@link buildReportKey}。 + * ⚠️ 私聊这一对**必须按规范顺序**(见 {@link canonicalPair}):服务端不认语义 + * 位置,同一对号无论谁发包,`1.2` / `1.3` 都得是小号在前、大号在后。写反了 + * 服务端直接回 `10001 param error`(实测 1707889225↔2863253201 双向验证)。 + * 早期实现按「peer / selfUin」写,只有在「自己恰好是大号」时才碰巧正确。 + * + * `report_location` 的 `1.1` 恒为 `2`;私聊同样按规范顺序,群聊 `1.2` 换成群号。 + * 所以它单独用 {@link reportKey}。 * * ## 房间不是服务端分配的 * @@ -48,18 +53,23 @@ import type { SendElement } from './send-elements'; // ───────────────────────── 公共 schema ───────────────────────── -/** 会话键(`room_operation` / 查询用)。 */ +/** + * 会话键(`room_operation` / 查询用)。 + * + * ⚠️ 私聊的 tag 2 / tag 3 **不是「对端 / 自己」**,而是两个参与者的**规范顺序** + * (小号在前,见 {@link canonicalPair})。群聊的 tag 2 是群号、tag 3 恒 0。 + */ const SESSION_KEY = message([ { name: 'scene', tag: 1, type: 'uint32' }, - { name: 'peer', tag: 2, type: 'uint64' }, - { name: 'selfUin', tag: 3, type: 'uint64' }, + { name: 'uinA', tag: 2, type: 'uint64' }, + { name: 'uinB', tag: 3, type: 'uint64' }, ]); -/** 上报用的会话键(`1.1` 恒为 2,`1.3` 恒为 selfUin)。 */ +/** 上报用的会话键(`1.1` 恒为 2;私聊同样按规范顺序,群聊 `1.2` 是群号)。 */ const REPORT_KEY = message([ { name: 'scene', tag: 1, type: 'uint32' }, - { name: 'peer', tag: 2, type: 'uint64' }, - { name: 'selfUin', tag: 3, type: 'uint64' }, + { name: 'uinA', tag: 2, type: 'uint64' }, + { name: 'uinB', tag: 3, type: 'uint64' }, ]); /** 统一的 `{ code, message }` 结果块。 */ @@ -95,26 +105,49 @@ export interface LbsSession { selfUin?: number; } +/** + * 私聊会话键的两个参与者:**小号在前**。 + * + * 服务端不认「对端 / 自己」的语义顺序,只认这个规范顺序 —— 同一对号的两个方向 + * 发出的包必须逐字节相同。实测(两账号分别发包 + 安卓抓包三方互证): + * + * field2 = 1707889225, field3 = 2863253201 → ok + * field2 = 2863253201, field3 = 1707889225 → `10001 param error` + * + * 与「谁发包」无关,所以绝不能按 `peer` / `selfUin` 的位置直接写 wire。 + */ +function canonicalPair(a: number, b: number): { uinA: number; uinB: number } { + return a < b ? { uinA: a, uinB: b } : { uinA: b, uinB: a }; +} + +/** 校验并收窄 `selfUin`(`LbsSession.selfUin` 是 optional,`Number.isSafeInteger` 不收窄)。 */ +function requireSelfUin(s: LbsSession, what: string): number { + const selfUin = s.selfUin; + if (!Number.isSafeInteger(selfUin) || (selfUin ?? 0) <= 0) { + throw new Error(`${what} 必须给 selfUin(自己的 QQ 号)`); + } + return selfUin as number; +} + /** 把 {@link LbsSession} 归一成 wire 上的会话键。 */ function sessionKey(s: LbsSession): Record { if (!Number.isSafeInteger(s.peer) || s.peer <= 0) { throw new Error(`peer 必须是正整数,收到 ${String(s.peer)}`); } if (s.scene === 'group') { - return { scene: LBS_SCENE_GROUP, peer: s.peer, selfUin: 0 }; + return { scene: LBS_SCENE_GROUP, uinA: s.peer, uinB: 0 }; } - if (!Number.isSafeInteger(s.selfUin) || (s.selfUin ?? 0) <= 0) { - throw new Error('私聊位置共享必须给 selfUin(自己的 QQ 号)'); - } - return { scene: LBS_SCENE_C2C, peer: s.peer, selfUin: s.selfUin }; + const selfUin = requireSelfUin(s, '私聊位置共享'); + return { scene: LBS_SCENE_C2C, ...canonicalPair(selfUin, s.peer) }; } -/** 上报用的会话键:`1.1` 恒为 2、`1.3` 恒为自己。 */ +/** 上报用的会话键:`1.1` 恒为 2;私聊同样按规范顺序,群聊 `1.2` 是群号。 */ function reportKey(s: LbsSession): Record { - if (!Number.isSafeInteger(s.selfUin) || (s.selfUin ?? 0) <= 0) { - throw new Error('report_location 必须给 selfUin(自己的 QQ 号)'); + const selfUin = requireSelfUin(s, 'report_location'); + if (s.scene === 'group') { + return { scene: LBS_SCENE_C2C, uinA: s.peer, uinB: selfUin }; } - return { scene: LBS_SCENE_C2C, peer: s.peer, selfUin: s.selfUin }; + return { scene: LBS_SCENE_C2C, ...canonicalPair(selfUin, s.peer) }; } // ───────────────────────── 发起 ───────────────────────── diff --git a/packages/service/test/lbs_share.test.ts b/packages/service/test/lbs_share.test.ts index c64aa9bde..264726b32 100644 --- a/packages/service/test/lbs_share.test.ts +++ b/packages/service/test/lbs_share.test.ts @@ -50,7 +50,7 @@ function makeService(nt: ReturnType) { } describe('LocationShareService', () => { - it('start(c2c) 走 room_operation,不签名,会话键 {scene:2, peer, selfUin}', async () => { + it('start(c2c) 走 room_operation,不签名,会话键按 uin 升序', async () => { const nt = fakeNative(); await makeService(nt).start({ scene: 'c2c', @@ -67,13 +67,42 @@ describe('LocationShareService', () => { expect(call.needSign).toBe(false); const body = decode(StartLocationShare.reqSchema, call.body); - expect(body.session).toEqual({ scene: 2, peer: BigInt(PEER), selfUin: BigInt(SELF) }); + // 私聊会话键按 uin 升序(小号在前),不是「对端/自己」。 + expect(body.session).toEqual({ scene: 2, uinA: BigInt(PEER), uinB: BigInt(SELF) }); expect(body.op).toBe(1); expect(body.interval).toBe(2); expect(body.longitude).toBeCloseTo(LNG, 6); expect(body.latitude).toBeCloseTo(LAT, 6); }); + it('c2c 反方向发包产生完全相同的 wire(服务端只认规范顺序)', async () => { + // 回归:早期实现把「对端」写 field2、「自己」写 field3,于是「自己是大号」 + // 时碰巧正确、「自己是小号」时服务端回 10001 param error。同一对号的两个方向 + // 必须逐字节相同 —— 安卓抓包里两个账号各自发包,请求体完全一致。 + const forward = fakeNative(); + await makeService(forward).start({ + scene: 'c2c', + peer: PEER, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + }); + const backward = fakeNative(); + await makeService(backward).start({ + scene: 'c2c', + peer: SELF, + selfUin: PEER, + longitude: LNG, + latitude: LAT, + }); + + expect(Buffer.from(backward.trpc[0]!.body).equals(Buffer.from(forward.trpc[0]!.body))).toBe( + true, + ); + const body = decode(StartLocationShare.reqSchema, backward.trpc[0]!.body); + expect(body.session).toEqual({ scene: 2, uinA: BigInt(PEER), uinB: BigInt(SELF) }); + }); + it('start(group) 会话键是 {scene:1, peer:群号, selfUin:0}', async () => { const nt = fakeNative(); await makeService(nt).start({ @@ -85,7 +114,7 @@ describe('LocationShareService', () => { }); const body = decode(StartLocationShare.reqSchema, nt.trpc[0]!.body); - expect(body.session).toEqual({ scene: 1, peer: BigInt(GROUP) }); + expect(body.session).toEqual({ scene: 1, uinA: BigInt(GROUP) }); }); it('report(group) 的会话键仍是 {scene:2, peer:群号, selfUin}', async () => { @@ -101,7 +130,7 @@ describe('LocationShareService', () => { expect(nt.trpc[0]!.cmd).toBe('QQLBSShareSvc.report_location'); const body = decode(ReportLocation.reqSchema, nt.trpc[0]!.body); - expect(body.session).toEqual({ scene: 2, peer: BigInt(GROUP), selfUin: BigInt(SELF) }); + expect(body.session).toEqual({ scene: 2, uinA: BigInt(GROUP), uinB: BigInt(SELF) }); // extra 缺省 0 → proto3 省略,解码回来自然没有这个字段。 expect(body.extra).toBeUndefined(); }); @@ -127,7 +156,7 @@ describe('LocationShareService', () => { expect(nt.trpc[0]!.cmd).toBe('QQLBSShareSvc.room_query'); const body = decode(QueryLocation.reqSchema, nt.trpc[0]!.body); - expect(body.session).toEqual({ scene: 1, peer: BigInt(GROUP) }); + expect(body.session).toEqual({ scene: 1, uinA: BigInt(GROUP) }); expect(body.mode).toBe(1); }); From e18879d298534d805bfa199f41e4a782899bd5f1 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Tue, 27 Oct 2026 03:27:36 +0800 Subject: [PATCH 20/23] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E4=B8=80?= =?UTF-8?q?=E4=BA=9Bui=E8=BE=B9=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ apps/desktop/src/renderer/src/styles/cache.css | 4 ++++ .../src/renderer/src/styles/settings-database.css | 11 ++++++++--- resources/redpack/skins.json | 7 +++++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5502bf06..de5333a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -593,3 +593,5 @@ - 修复已创建克隆体在会话列表和新建克隆群聊中消失的问题,后台创建完成后自动刷新列表 - 好友克隆支持使用最新聊天记录更新画像,保留原有配置、聊天历史、记忆和群聊关系,更新失败不覆盖旧画像 - 补齐主页、存储管理、聊天消息等设置页标题缺失的图标 +- 修复资源浏览页右侧内容区没有撑满窗口、右边留出大片空白的问题,所有资源分类一并生效 +- 修复数据库管理页标题上方留白明显大于同级设置页的问题,标题与内容边距已对齐 diff --git a/apps/desktop/src/renderer/src/styles/cache.css b/apps/desktop/src/renderer/src/styles/cache.css index c8234948b..dd1566bfb 100644 --- a/apps/desktop/src/renderer/src/styles/cache.css +++ b/apps/desktop/src/renderer/src/styles/cache.css @@ -11,6 +11,10 @@ grid-template-columns: 200px minmax(0, 1fr); gap: 14px; height: 100%; + /* 外层可能是 flex 容器(资源管理 / 数据与维护),不 grow 就只会按内容宽度 + * 收缩,右侧内容面板会留下大片空白。min-width 清零防止宽内容顶穿。 */ + flex: 1 1 auto; + min-width: 0; min-height: 0; padding: 14px; /* 透明底:跟随所在页面(维护工具)的界面背景。 */ diff --git a/apps/desktop/src/renderer/src/styles/settings-database.css b/apps/desktop/src/renderer/src/styles/settings-database.css index be4e2f24e..48b51f771 100644 --- a/apps/desktop/src/renderer/src/styles/settings-database.css +++ b/apps/desktop/src/renderer/src/styles/settings-database.css @@ -1,4 +1,8 @@ -.weq-settings-page.weq-settings-body[data-settings-page='database-management'] { +/* 本页内容(DbExplorer)自己撑满高度,不需要外层内边距;留白由页头负责, + * 与普通设置页的 20px 对齐。必须带 html[data-theme] 才压得过 + * page-chrome.css 里 `html[data-theme] .weq-workspace-page .weq-settings-body` + * 的 padding(那一条权重更高,否则这里会被打回 20px,顶部留白翻倍)。 */ +html[data-theme] .weq-settings-page.weq-settings-body[data-settings-page='database-management'] { display: flex; padding: 0; overflow: hidden; @@ -13,11 +17,12 @@ } .weq-settings-database > .weq-set-head { flex-shrink: 0; - padding: 20px 24px 12px; + /* 外层内边距已清零,留白全在这里给;与普通设置页的 20px 对齐。 */ + padding: 20px 20px 12px; } .weq-settings-database > .weq-settings-query-error { flex-shrink: 0; - margin: 0 24px 12px; + margin: 0 20px 12px; } .weq-settings-database > .weq-cache-db { min-width: 0; diff --git a/resources/redpack/skins.json b/resources/redpack/skins.json index 0e9f2502a..e0d35b338 100644 --- a/resources/redpack/skins.json +++ b/resources/redpack/skins.json @@ -1,7 +1,10 @@ { - "generatedAt": 1792374450, + "generatedAt": 1793042730, "count": 836, - "range": [1039, 2683], + "range": [ + 1039, + 2683 + ], "skins": [ { "id": 1039, From eac52028db5d11fc5ff21262fc753ef3ab601f48 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Tue, 27 Oct 2026 03:29:28 +0800 Subject: [PATCH 21/23] chore: update resources --- resources/emoji/market.csv | 37 ++++++++++++++++++++++++++++++++ scripts/fetch_dress_resources.ts | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/resources/emoji/market.csv b/resources/emoji/market.csv index 91c5fca21..f9f0afec2 100644 --- a/resources/emoji/market.csv +++ b/resources/emoji/market.csv @@ -27039,3 +27039,40 @@ Some are born to endless night.",1 249242,猫悠-Mew1,猫悠-Mew,1 249243,可可小宅天使篇1,B站关注可可小宅谢谢喵~,1 249244,水小宝,节约用水、低碳生活、爱护自然,1 +249245,流桑桑桑2,这里是流桑,一只破画画的赤狐,1 +249246,暖萌治愈日常,软乎乎的企鹅与小浣熊的暖冬治愈日,1 +249247,欧武欧武,欧武表情第一期,1 +249248,抽象糕列巴2,小糕再次在神秘力量下变抽象了,1 +249249,狐兰兹_01,可爱狐兰兹表情包,1 +249250,皮卡少个丘w2,虚拟主播皮卡少个丘w,1 +249251,咖咪02.1,恶魔猫小咖,B咖啡咪KoFFee,1 +249252,咖咪02.2,恶魔猫小咖B站咖啡咪KoFFee,1 +249253,咖咪02.4,恶魔猫小咖B站咖啡咪KoFFee,1 +249254,萧浩的狐兔开心宝1,一起来玩吧,1 +249255,空雨,(^ω^) 绘:盐水毛小豆,1 +249256,傻傻cookiya系列1,傻傻克琪亚!,1 +249257,日月熜熜,熜喵、甜妹和笔架。,1 +249258,小明扬呀,关注音符明扬,画师小连翘,1 +249259,喵千绘,是一只魔法猫猫喵~,1 +249260,契和他的朋友们Dx,但为君故,沉吟至今。,1 +249261,赛鹿鹿,小魔女日常表情包,软萌治愈,1 +249262,影之颂歌角色篇,一定是个阳光开朗的故事喵~,1 +249263,蜂蜜菠萝喵,祝你好运喵(作者:甘乐能),1 +249264,吸血姬凌泠依,世界上最强大的吸血鬼!!!,1 +249265,芒果糯咪团,小猫你可以请假(作者:甘乐能),1 +249266,糯米天师_01,米需米制作的糯米天师,1 +249267,糯米天师_02,米需米制作的糯米天师,1 +249268,布萨沃夫,神秘白色小狼 画师b站夜卡,1 +249269,年糕狐V2,喜欢和酒狐贴贴的年糕狐,1 +249270,水水和南南1,水水和南南的日常,1 +249271,Morsd小巫师,小巫师,今天也在认真搞怪。,1 +249272,冰盐大虎的日常1,冰盐大虎的日常1,1 +249273,小蜘蛛表情包第七弹jpg版,小蜘蛛表情包第七弹jpg版,1 +249274,小蜘蛛表情包第六弹jpg版,小蜘蛛表情包第六弹jpg版,1 +249275,小蜘蛛表情包第四弹jpg版,小蜘蛛表情包第四弹jpg版,1 +249276,泉户猫丸K,B站泉户猫丸,画师riri哒,1 +249277,时间向前,干员们的日常生活,1 +249278,猪猪们的实习生活,金牌实习生来了!,1 +249279,柚小冈,适合日常聊天、推荐家乡蜜柚,1 +249280,星塔日常篇,诺瓦大陆可爱的日常,1 +249281,闪闪鱼碎碎念,闪闪鱼表情包,猫猫鱼的多样情绪,1 diff --git a/scripts/fetch_dress_resources.ts b/scripts/fetch_dress_resources.ts index e54d78a89..c4a447ef2 100644 --- a/scripts/fetch_dress_resources.ts +++ b/scripts/fetch_dress_resources.ts @@ -697,5 +697,5 @@ main().catch((e) => { * pnpm tsx scripts/fetch_dress_resources.ts --bubble --font --widget --extend * 它会从各段真实最大 id 往后扫,连续 200 个空 id 自动收手,并至少越过旧边界 +200。 * - * 2026-10-24 + * 2026-10-27 * */ From 57f557075215571ba0a750134877b6184ec90817 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Tue, 27 Oct 2026 05:38:41 +0800 Subject: [PATCH 22/23] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8Dci=20test?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + apps/desktop/test/package_size.test.ts | 12 +++- packages/service/src/account/call_session.ts | 8 ++- .../service/test/call_video_session.test.ts | 56 +++++++++++++++++-- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de5333a2d..5add1eb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -595,3 +595,4 @@ - 补齐主页、存储管理、聊天消息等设置页标题缺失的图标 - 修复资源浏览页右侧内容区没有撑满窗口、右边留出大片空白的问题,所有资源分类一并生效 - 修复数据库管理页标题上方留白明显大于同级设置页的问题,标题与内容边距已对齐 +- 修复视频通话拿到服务端下发的对端 P2P 端口后仍打不通直连的问题:权威端口到达时立即补发打洞探测,不再等下一拍而被探测超时清掉。 diff --git a/apps/desktop/test/package_size.test.ts b/apps/desktop/test/package_size.test.ts index c9a9dc448..004605d27 100644 --- a/apps/desktop/test/package_size.test.ts +++ b/apps/desktop/test/package_size.test.ts @@ -11,7 +11,17 @@ import { } from 'node:fs'; import { join, resolve } from 'node:path'; import ts from 'typescript'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +// `trimDesktopPackage` 在签名前会校验包内的 remote-network 辅助程序(发布期 +// 门禁)。这个用例只关心 resvg Darwin 绑定的裁剪,临时目录里没有那个二进制, +// 所以把校验桩掉 —— 它自己的行为由 `scripts/package-remote-network.test.mjs` +// 和 `authorization_package_hooks.test.ts` 覆盖。 +vi.mock('../../../scripts/package-remote-network.mjs', async (importOriginal) => ({ + ...(await importOriginal()), + verifyPackagedRemoteNetwork: vi.fn(), +})); + import { trimDesktopPackage } from '../../../scripts/trim-desktop-package.mjs'; const desktop = resolve(__dirname, '..'); diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index 710698da5..2b6431b8f 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -1480,7 +1480,13 @@ export class CallSession { this.log?.info('媒体路径切换', { path: 'probing' }); } // 无论可信与否,都去打洞探测 —— 这是拿到回程证据的唯一途径。 - this.startPeerProbeLoop(); + // + // ⚠️ 循环已经在跑时也要**立刻补一拍**。`bt=27` 的权威端口常常晚于 `bt=9` + // 到达,而 `bt=9` 已经启动了探测循环;只依赖 `startPeerProbeLoop()` 的话, + // 新端口要等 2 秒后的下一拍才会用上,可探测期 1 秒就到期把端口清零了 —— + // 权威端口于是永远发不出去,P2P 握手死锁在中继上。 + if (this.peerProbeTimer === null) this.startPeerProbeLoop(); + else this.sendPeerProbe(); } /** diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index cc4b65e1c..878b22522 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -871,7 +871,7 @@ describe('媒体路径(中继 / 直连)', () => { expect(ctx.transport.mediaTo(PEER_IP, VideoSubType.max).length).toBeGreaterThan(0); }); - it('探测期结束后只发 P2P', async () => { + it('探测得到回程后只发 P2P', async () => { const encoder = new FakeVideoEncoder(10000); const ctx = makeSession({ media: 'video', videoEncoder: encoder }); await connect(ctx); @@ -890,6 +890,21 @@ describe('媒体路径(中继 / 直连)', () => { { address: PEER_IP, port: 47708 }, ); } + // 只有媒体包还不够:探测期满仍无 `0x02` 回程会被判成黑洞并退回中继, + // 所以这里补一条对端对我们探测的应答(见 `confirmPeerPath`)。 + ctx.transport.deliverFrom( + buildPeerProbe({ + roomId: 777n, + cmd: PeerProbeCmd.probeReply, + uinAbout: 1707889225, + seq: 7, + otherUin: 2863253201, + token: 1234, + clock: 1, + peerClock: 2, + }), + { address: PEER_IP, port: 47708 }, + ); ctx.transport.reset(); await ctx.timers.advanceAsync(50); @@ -943,8 +958,8 @@ describe('媒体路径(中继 / 直连)', () => { 0x24, 0x0e, 0x03, 0x99, 0x0f, 0x9d, 0x3f, 0x10, 0x78, 0x73, 0xe7, 0x0f, 0x57, 0x7e, 0x60, 0xc5, ]); - ctx.transport.deliver(ctrlPushPacket(peerIpv6, 56082)); ctx.transport.reset(); + ctx.transport.deliver(ctrlPushPacket(peerIpv6, 56082)); await ctx.timers.advanceAsync(20); const peer = '240e:399:f9d:3f10:7873:e70f:577e:60c5'; @@ -1086,10 +1101,43 @@ describe('P2P 端点交换(bt=25 / bt=27)', () => { await connect(ctx); // bt=9 给近似端口 56082,bt=27 给权威端口 56089(实测值)。 + ctx.transport.reset(); ctx.transport.deliver(ctrlPushPacket(peerIpv6, 56082)); ctx.transport.deliver(peerJoinPacket(56089)); + // bt=27 一到就应该立刻向权威端口补一拍探测(不能等 2 秒的下一拍, + // 那时探测期早已到期把端口清零)。 + const firstRound = ctx.transport.sent.filter( + (s) => s.data[0] === RELAY_STX && s.address === PEER_IP, + ); + expect(firstRound.length).toBeGreaterThan(0); + expect(firstRound[firstRound.length - 1]!.port).toBe(56089); + + // 对端从权威端口回包确认直连,之后持续有媒体包(否则直连静默看门狗 + // 会在 2 秒后回落中继、把端口清零)。 + ctx.transport.deliverFrom( + buildPeerProbe({ + roomId: 777n, + cmd: PeerProbeCmd.probeReply, + uinAbout: 1707889225, + seq: 7, + otherUin: 2863253201, + token: 1234, + clock: 1, + peerClock: 2, + }), + { address: PEER_IP, port: 56089 }, + ); ctx.transport.reset(); - await ctx.timers.advanceAsync(2100); + const whole = new Uint8Array(26); + whole[0] = 20; + whole.set([0, 0, 0, 1, 0x40, 0x01], 20); + for (let i = 0; i < 21; i++) { + await ctx.timers.advanceAsync(100); + ctx.transport.deliverFrom( + videoPacket({ gidx: i + 1, pkgIdx: 0, totalPkg: 1, chunk: whole }), + { address: PEER_IP, port: 56089 }, + ); + } const probes = ctx.transport.sent.filter( (s) => s.data[0] === RELAY_STX && s.address === PEER_IP, @@ -1108,8 +1156,8 @@ describe('P2P 端点交换(bt=25 / bt=27)', () => { // 抓包里 WeQ 实际收到的那种形态:只有 f5(fixed32 IPv4)+ f6(端口)。 const body = new Uint8Array([(5 << 3) | 5, 0xcb, 0xf6, 0xb0, 0x50, ...pbVarint(6, 8889)]); - ctx.transport.deliver(ctrlPushWithBody(body)); ctx.transport.reset(); + ctx.transport.deliver(ctrlPushWithBody(body)); await ctx.timers.advanceAsync(20); const probes = ctx.transport.sent.filter((s) => s.address === '203.246.176.80'); From 1c063cd243739712cd2909cd133273cb65c7b42e Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Tue, 27 Oct 2026 05:39:58 +0800 Subject: [PATCH 23/23] chore: update native pinned version --- .../renderer/src/im-template/styles/location-share.css | 9 +++++++-- .../src/renderer/src/im-template/template/chatPane.tsx | 1 - native/pinned.json | 8 ++++---- resources/redpack/skins.json | 5 +---- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/src/im-template/styles/location-share.css b/apps/desktop/src/renderer/src/im-template/styles/location-share.css index 496fe8d2f..989927515 100644 --- a/apps/desktop/src/renderer/src/im-template/styles/location-share.css +++ b/apps/desktop/src/renderer/src/im-template/styles/location-share.css @@ -415,7 +415,9 @@ html[data-theme='dark'] .lbs-map { background: var(--weq-accent-solid, var(--weq-accent-effective, #0099ff)); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.24); transform: translate(-50%, -50%); - transition: left 60ms linear, top 60ms linear; + transition: + left 60ms linear, + top 60ms linear; } .lbs-speed { @@ -437,7 +439,10 @@ html[data-theme='dark'] .lbs-map { font-family: inherit; font-size: 11.5px; cursor: pointer; - transition: background 120ms ease, color 120ms ease, border-color 120ms ease; + transition: + background 120ms ease, + color 120ms ease, + border-color 120ms ease; } .lbs-speed-chip:hover { diff --git a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx index 3092229bb..ca76c5eda 100644 --- a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx @@ -172,7 +172,6 @@ import type { EmojiItem } from './emojiPacks'; import { elementToToken, elementsToComposerText, - toIpcElements, tokenToElement, } from './draftElements'; import { planNeedsLocalMedia, type LocalMediaRef } from './composerSend'; diff --git a/native/pinned.json b/native/pinned.json index 8960c94d0..6397f65c5 100644 --- a/native/pinned.json +++ b/native/pinned.json @@ -1,8 +1,8 @@ { - "tag": "nt-helper-20261026-bb174b2", - "commit": "bb174b25b88b5de0c572a88e6a83c9d451f4688d", - "builtAt": "2026-10-26T09:33:51.000Z", - "expiresAt": "2026-11-25T09:33:51.000Z", + "tag": "nt-helper-20261026-8e7cf66", + "commit": "8e7cf669a5a59e6fbe99cddf35948efd558a6580", + "builtAt": "2026-10-26T20:13:18.000Z", + "expiresAt": "2026-11-25T20:13:18.000Z", "pinnedAt": "2026-10-26", "note": "WeQ 认这一份 native 构建(pnpm native:fetch 默认取它)。升级:pnpm native:fetch --latest --verify 先验证新构建,再 pnpm native:fetch --latest --write-pin 写回这里并提交。" } diff --git a/resources/redpack/skins.json b/resources/redpack/skins.json index e0d35b338..212a028d4 100644 --- a/resources/redpack/skins.json +++ b/resources/redpack/skins.json @@ -1,10 +1,7 @@ { "generatedAt": 1793042730, "count": 836, - "range": [ - 1039, - 2683 - ], + "range": [1039, 2683], "skins": [ { "id": 1039,