Skip to content

Feat/qqcall - #114

Merged
H3CoF6 merged 30 commits into
devfrom
feat/qqcall
Oct 26, 2026
Merged

H3CoF6 merged 30 commits into
devfrom
feat/qqcall

Conversation

@H3CoF6

@H3CoF6 H3CoF6 commented Oct 26, 2026

Copy link
Copy Markdown
Member

No description provided.

H3CoF6 and others added 29 commits October 25, 2026 13:16
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。
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 秒重发、
关摄像头后停止。
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 行。
对 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] 换成对端时间戳。
对照 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 里记了逐项对比。
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 字节),已用抓包逐字节校验一致。
拿 2026-10-26 同一次通话的 OIDB 抓包逐字节比对,发现两处「语义等价、字节不同」
的差异:

1. 开房 `common.f1`(roomId)—— QQ 显式写出 `08 00`,WeQ 省略。给该字段加
   `force`,roomId=0 时也写出。
2. 邀请 `common.f2`(sessionId)—— QQ **不写**,WeQ 多写了 `10 <sessionId>`
   (开房回包虽带该字段,但不回写)。去掉。

顺带把 `ipStack` 改成本次抓包实测的 21547(0x542B,视频档)。此前用的
12656 是更早抓包的旧值。

对齐后开房请求逐字节一致(除每次不同的 relationId)。

注:`bt=9` 的 f7 下发条件仍未还原 —— 实测振铃阶段 body 只有 24 字节(无 f7),
真实客户端在「对端接听」时才收到 46 字节(带 f7/f8)。
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 后
离线解码无变化);「永远等待对方画面」待确认是否与渲染层代码加载有关。
「一直等对方画面」这类问题此前只能盲猜:视频解码全在渲染层,而渲染层的
`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)。
目标: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=<path> 把对端 Annex B 码流原样落盘 —— 配合
   tmp_hevc_vui.ts 可验证偏绿时段的 SPS 是否与正常时段不同。
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 帧」。
上一版修复(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 帧即正常画面,既不绿也不冻。
保留修复本体与真实诊断点,移除排查期间临时加的脚手架。

移除:
- `tmp_hevc_vui.ts`(SPS/VUI 解析工具)—— 已确认码流 VUI 未声明色彩空间,
  与本次根因无关。
- 对端码流落盘(`WEQ_CALL_PEER_DUMP`)—— 排查专用,会把整通裸流写进磁盘。
- 编码器「编码帧统计 / 编码会话汇总」—— 用于验证码率是否生效,已完成使命。
- 主进程「推送对端视频帧给渲染层」逐帧计数。
- 渲染层逐帧日志:收到待解码帧、已喂入解码器、解码器配置就绪、色彩空间变化、
  画面均色采样(含 `sampleCenterRgb`)等。

保留:
- `replayBuffer` / `rememberForReplay` / `acceptsFrame` —— 修复本体。
- 渲染层日志桥 `weq.debugLog`,但只留错误、断帧、首帧里程碑 —— 渲染层的
  `console.*` 本来就不落盘,解码失败时日志里什么都没有,这是真实缺口。
- `frameIndex` 沿 CallEvent → IPC 透传 —— 连续性检查依赖它。

净删 681 行。
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 会被静默丢弃 —— 群聊卡片点不了就是这个原因。
# Conflicts:
#	CHANGELOG.md
#	apps/desktop/src/renderer/src/im-template/template/chatPane.tsx
@H3CoF6
H3CoF6 merged commit ee3971f into dev Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant