Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
13d2ebc
fix(scripts): 修复native-fetch脚本
H3CoF6 Oct 25, 2026
4e778bb
Merge remote-tracking branch 'origin/dev' into feat/qqcall
H3CoF6 Oct 25, 2026
c617dec
fix(call): 修复视频通话画面冻结与卡顿
H3CoF6 Oct 25, 2026
c950b14
fix(call): 补上 GCS 视频参数公告,修复开摄像头对端无感知
H3CoF6 Oct 25, 2026
1f503a1
chore(web): 统一 web 与 desktop 的 Vite 8
H3CoF6 Oct 25, 2026
5371090
fix(call): 对齐视频 sub-header 的基准字段与分片标记
H3CoF6 Oct 25, 2026
c10ac4f
fix(call): 修复媒体退化成全程中继导致两端画面极卡
H3CoF6 Oct 25, 2026
6392738
fix(call): GCS 认证能力集换成 QQ 本体原文
H3CoF6 Oct 25, 2026
bf5217a
fix(call): 开房 / 邀请请求与 QQ 本体逐字节对齐
H3CoF6 Oct 25, 2026
bb995b0
fix(call): 打通视频通话 P2P 直连与中继协商
H3CoF6 Oct 25, 2026
dc6b9e2
fix(call): 修复假直连与码率过高,对齐 QQ 本体
H3CoF6 Oct 26, 2026
061a3ad
feat(call): 渲染层解码链路可观测化
H3CoF6 Oct 26, 2026
3c65dca
feat(call): 偏绿 bug 的三路取证(colorSpace / 均色采样 / 对端码流落盘)
H3CoF6 Oct 26, 2026
8d4a23f
fix(call): 修复订阅晚到导致的跳帧,消除前 15 秒整屏纯绿
H3CoF6 Oct 26, 2026
ffe7a90
fix(call): 订阅晚到时整段补发连续帧,不再冻到下一个关键帧
H3CoF6 Oct 26, 2026
25aa2cc
chore(call): 清理偏绿 bug 排查期的一次性调试埋点
H3CoF6 Oct 26, 2026
62d0d7a
feat(msg): 位置共享协议(发起 / 上报 / 查询)
H3CoF6 Oct 26, 2026
e6c829d
feat(ui): 位置共享前端
H3CoF6 Oct 26, 2026
2f18128
Merge remote-tracking branch 'origin/dev' into dev
H3CoF6 Oct 26, 2026
96c6e8f
fix: merge conflict
H3CoF6 Oct 26, 2026
6ca014f
fix(protocol): 对齐更多视频通话不一致的实现
H3CoF6 Oct 26, 2026
a84db6e
Merge branch 'dev' into feat/qqcall
H3CoF6 Oct 26, 2026
ab8a976
Merge remote-tracking branch 'origin/feat/qqcall' into feat/qqcall
H3CoF6 Oct 26, 2026
114d32c
fix(protocol): 修复位置共享协议
H3CoF6 Oct 26, 2026
bd6a0cd
Merge remote-tracking branch 'origin/dev' into feat/qqcall
H3CoF6 Oct 26, 2026
c6b00c4
Merge remote-tracking branch 'origin/dev' into feat/qqcall
H3CoF6 Oct 26, 2026
e18879d
fix(ui): 修复一些ui边距
H3CoF6 Oct 26, 2026
eac5202
chore: update resources
H3CoF6 Oct 26, 2026
57f5570
fix(ci): 修复ci test错误
H3CoF6 Oct 26, 2026
1c063cd
chore: update native pinned version
H3CoF6 Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -576,10 +576,23 @@
- 修复恢复记录保存失败后仍删除消息的问题,保留写库失败时的恢复信息
- 修复确认框被其他提示替换后操作悬挂,消息编辑器补齐名称与键盘焦点支持
- 修复桌面打包缺少 Remote 网络辅助程序,构建时核对目标平台与包内文件
- 修复视频通话画面播放二十来秒后永久卡住的问题:帧序号跨 256 帧后不再被误判为「非基础层」丢弃,本端上行也改为 16 位帧序号,两端画面持续更新。
- 修复视频通话画面卡顿:摄像头帧改走二进制 IPC 通道,不再把每帧展开成 1.2 MB 文本经 JSON 传输,采集与编码不再互相拖慢。
- 修复视频通话界面读不到对方分辨率的问题,关键帧参数集解析偏移已校正。
- 视频通话补齐 GCS 视频流状态握手,并停止伪造仅由服务端下发的视频参数推送。
- 继续对齐视频通话的上行帧格式:对端未发视频时基准字段填 0、收到后改用对端时间基准,分片标记字段按实测分布填写。
- 修复视频通话媒体退化成全程服务器中继、两端画面都极卡的问题:服务器选路不再选中继端口,对端直连地址识别不再被 IPv4 映射地址混淆。
- 修复视频通话拿不到对端直连地址、媒体只能全程走服务器中继的问题:GCS 认证改为发送与 QQ 一致的音视频能力集。
- 继续对齐视频通话信令:开房与邀请请求的字段与 QQ 本体逐字节一致。
- 修复视频通话对端久等才看到画面、媒体全程走服务器中继的问题:补齐 P2P 端口交换与直连握手,媒体确认直连后不再全程走服务器中继;同时修正中继注册报文的序号与对端字段,服务器重新给予中继应答。
- 修复私聊位置共享发起报 `param error`:会话键改为按 uin 升序排列两个参与者,两个方向的发起现在都能成功。
- 弹射表情和互动表情直接纳入表情面板,录音与文字转语音合并到输入框内的语音面板
- 优化闪传文件浏览界面,移除独立下载任务列表;一次下载作为一项常规任务,集中查看文件进度和取消下载
- 减少在线凭据的重复获取,修复 QQ 重启后复用旧票据的问题;邮箱明确要求安全验证时暂停后台重试,完成验证后可手动重连
- 修复联系人未选择好友或群聊时显示占位页的问题,默认展示群交集首页
- 修复已创建克隆体在会话列表和新建克隆群聊中消失的问题,后台创建完成后自动刷新列表
- 好友克隆支持使用最新聊天记录更新画像,保留原有配置、聊天历史、记忆和群聊关系,更新失败不覆盖旧画像
- 补齐主页、存储管理、聊天消息等设置页标题缺失的图标
- 修复资源浏览页右侧内容区没有撑满窗口、右边留出大片空白的问题,所有资源分类一并生效
- 修复数据库管理页标题上方留白明显大于同级设置页的问题,标题与内容边距已对齐
- 修复视频通话拿到服务端下发的对端 P2P 端口后仍打不通直连的问题:权威端口到达时立即补发打洞探测,不再等下一拍而被探测超时清掉。
184 changes: 138 additions & 46 deletions apps/desktop/src/main/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,34 +48,66 @@ 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` 要的形状。 */
class NodeUdpTransport implements UdpTransport {
private socket: dgram.Socket | null = null;
private p2pSocket: dgram.Socket | null = null;

async bind(): Promise<void> {
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<dgram.Socket> =>
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) {
Expand All @@ -85,13 +117,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 {
// 已关闭 / 未绑定,忽略。
}
}
}

Expand All @@ -113,14 +149,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 = [];
}
}
Expand Down Expand Up @@ -195,12 +239,20 @@ export interface StartCallParams {
export type CallEvent =
| { type: 'state'; call: ActiveCallInfo }
| { type: 'audio'; pcm: Uint8Array }
/** 对端一帧视频(Annex B 裸流,只推基础层)。 */
/** 对端一帧视频(Annex B 裸流)。 */
| {
type: 'video';
hevc: Uint8Array;
timestamp: number;
keyframe: boolean;
/**
* 16 位帧序号 `((fidx - 1) << 8) | gidx`。
*
* 渲染层要靠它判断「喂给解码器的帧是否连续」—— HEVC 的 P 帧依赖前序参考
* 帧,跳过若干帧后继续喂 delta 帧会让硬解输出错乱的 YUV(表现为整屏纯绿),
* 且要等到下一个关键帧才恢复。见 useCallVideo 的 continuity 检查。
*/
frameIndex: number;
info: VideoStreamInfo | null;
}
/** 录音收尾完成(含内部收尾:对端挂断 / 被踢 / 崩溃)。 */
Expand All @@ -223,10 +275,19 @@ export class CallManager extends EventEmitter {

/** 视频编码器(只有视频通话才非 null)。 */
private videoEncoder: NativeVideoEncoder | null = null;
/** 上一帧是否基础层(用于「基础层优先」的取舍)。 */
private lastFrameWasBase = false;
/** 最近收到的一个关键帧(新订阅者补发用)。 */
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 recordingConfig: CallRecordingConfig = { mode: 'mixed', systemAudio: false, dir: null };
private recorder: CallRecorder | null = null;
Expand All @@ -253,14 +314,16 @@ export class CallManager extends EventEmitter {
}

/**
* 取最近的关键帧,供**新建立**的渲染层订阅补发。
* 取一段**从关键帧开始、帧序号连续**的帧,供新建立的渲染层订阅补发。
*
* 渲染层是在「对端开始发视频」的状态推送之后才订阅 `onCallVideo` 的,而首个
* 关键帧通常已经过去。没有关键帧起解,后面的 delta 帧全解不出来 —— 所以
* 订阅时先补这一帧。
* 关键帧通常已经过去。**只补关键帧不够**:跳帧会让硬解输出错乱(整屏纯绿),
* 必须把关键帧之后的帧一并补上,让渲染层从头连续解码。详见 `replayBuffer`。
*
* 返回空数组表示还没有可用的起点(还没收到过关键帧)。
*/
latestKeyframe(): VideoFramePayload | null {
return this.lastKeyframe;
replayFrames(): VideoFramePayload[] {
return this.replayBuffer;
}

/** 发起一路通话。已有通话在跑时直接报错,由界面提示先挂断。 */
Expand Down Expand Up @@ -290,6 +353,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),
Expand Down Expand Up @@ -328,25 +393,21 @@ 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 帧没有
// 关键帧起解,画面永远出不来。新订阅先用这份补一帧。
if (frame.isKeyframe) this.lastKeyframe = frame;
this.rememberForReplay(frame);
this.emit('event', {
type: 'video',
hevc: frame.hevc,
timestamp: frame.timestamp,
keyframe: frame.isKeyframe,
frameIndex: frame.frameIndex,
info: frame.info,
} satisfies CallEvent);
});
Expand Down Expand Up @@ -643,12 +704,43 @@ export class CallManager extends EventEmitter {
this.transport?.close();
this.videoEncoder?.close();
this.videoEncoder = null;
this.lastKeyframe = null;
this.replayBuffer = [];
this.session = null;
this.transport = null;
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 = [];
}

/**
* `AVSDKPlugin` 和 `wrapper.node` 同在 QQ 的 `resources/app` 下;
* linux 的插件在它下面的 `avsdk/` 子目录,`nt_helper` 会自己找。
Expand Down
19 changes: 15 additions & 4 deletions apps/desktop/src/main/call_video_encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -150,12 +152,21 @@ 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);
return out;
}

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 {
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/main/call_video_ipc.ts
Original file line number Diff line number Diff line change
@@ -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<CallVideoFrame>;
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 已注册');
}
Loading
Loading