ワンクリックで
webrtc-infrastructure
当用户要求实现 WebRTC 视频通话、设计 P2P 实时通信系统、搭建 WebRTC 信令基础设施、实现音视频传输或 DataChannel 数据传输时触发。提供从信令协议设计到移动端优化的完整架构指南。
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
当用户要求实现 WebRTC 视频通话、设计 P2P 实时通信系统、搭建 WebRTC 信令基础设施、实现音视频传输或 DataChannel 数据传输时触发。提供从信令协议设计到移动端优化的完整架构指南。
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Relay-v3 Lua 状态机接入指南。新增一个互联网房间业务(聊天、卡牌、白板、投票、协作…)时使用。描述后端元函数契约、前端闭环套路、Lua 脚本模板、state 转换规范、错误案例。识别"我要实现一个新业务""写一个 Lua 房间脚本""加一个 action 类型"时触发。
当用户提及 Relay/LAN/快照/action 流/net_p2p/net_engine 协议、讨论消息传输层(事件 vs 快照)、新建/重构 P2P 协议、排查"晚加入者错过事件""两端不同步""房间状态丢失""广播丢失"等问题时触发。包含 v1 action/事件驱动(已落地,遗留用法)与 v2 快照驱动(推荐新功能)。
Flutter 项目中"样式"相关工程的渐进式披露指南。当用户要做 UI 样式选型、视觉对齐复刻、画布/HTML mockup 与 Flutter 实现的双向对照、或在 Material 3 体系下选某一类样式(顶部 App Bar / Card / Button / NavigationBar / Modal 等)落地时触发。本 skill 是样式大类的总入口,所有方案的最终形态都登记在分类索引表里,按需加载对应方案文件。同时承载小豆子 FR 项目的 UI 设计原则与实战 bug 沉淀(border-emphasis 边框强调式、嵌套 sheet race condition、多风格 lottery 投票挑选、纯色按钮减负、左重右轻、装饰性 vs 功能性颜色决策)。
flutter的开发操作流程,在dart-flutter任何问题都需要优先加载这个SKILL
Flutter 通过 home_widget 把 1Hz 实时值(如倒计时)推到 Android 桌面 AppWidget 的端到端架构。当用户提到 home_widget 不同步、桌面小组件不刷新、appwidget 实时值、widget 显示卡死、widget 进程被杀场景、AppWidgetProvider 找不到时触发。
Comprehensive Rive animation platform skill covering scripting (Luau), runtime integration (React/Next.js), state machines, data binding, and the complete API. Use this skill when users need to create interactive animations with Rive, integrate Rive into React/Next.js applications, write Rive scripts (Node, Layout, Converter, PathEffect protocols), control animations via state machines, implement scroll-based animations, or work with Rive's drawing API (Path, Paint, Renderer). Triggers on: "rive", "rive animation", "rive script", "luau", "@rive-app/react-canvas", "state machine animation", "interactive animation", "scroll animation with rive". 本项目(xiaodouzi/fr,Flutter + rive ^0.14.5)特化:DataBind / ViewModel 双向数据绑定、lab demo 添加流程见 references/flutter-databind-0.14.md 与 references/flutter-project-workflow.md。
| name | webrtc-infrastructure |
| description | 当用户要求实现 WebRTC 视频通话、设计 P2P 实时通信系统、搭建 WebRTC 信令基础设施、实现音视频传输或 DataChannel 数据传输时触发。提供从信令协议设计到移动端优化的完整架构指南。 |
先信令后媒体,控制面与数据面分离。
WebRTC 的 P2P 连接依赖信令服务器完成初始握手,信令是"控制面",媒体/DataChannel 是"数据面"。设计时必须:
当用户说以下内容时触发:
定义核心消息类型(参考 flutter_webrtc_server/pkg/signaler/signaler.go):
| 消息类型 | 方向 | 用途 |
|---|---|---|
new / join | Client → Server | 注册 peer,获取在线列表 |
offer | Caller → Server → Callee | 转发 SDP offer |
answer | Callee → Server → Caller | 转发 SDP answer |
candidate | Both → Server → Peer | 转发 ICE candidate |
bye | Either → Server → Both | 结束会话 |
leave | Server → Client | Peer 离线通知 |
keepalive | Both | 心跳保活 |
信令消息格式:
{
"type": "offer",
"data": {
"from": "peer-id",
"to": "peer-id",
"session_id": "caller-callee",
"description": { "sdp": "...", "type": "offer" }
}
}
职责:Peer 注册、Session 跟踪、消息转发、TURN 凭证发放
// 核心数据结构
type Signaler struct {
peers map[string]Peer // peer-id → Peer
sessions map[string]Session // session-id → Session
turn *turn.TurnServer
}
// 消息处理逻辑
switch request.Type {
case New:
s.peers[info.ID] = Peer{conn, info}
s.NotifyPeersUpdate(s.peers)
case Offer, Answer, Candidate:
peer := s.peers[negotiation.To]
s.Send(peer.conn, request) // 直接转发
case Bye:
// 向会话双方发送 bye
}
TURN 凭证生成(REST API 规范):
turnUsername := fmt.Sprintf("%d:%s", timestamp, user)
hmac := hmac.New(sha1.New, []byte(sharedKey))
hmac.Write([]byte(turnUsername))
turnPassword := base64.StdEncoding.EncodeToString(hmac.Sum(nil))
// TTL 默认 86400s,存入 ExpiredMap 自动清理
使用事件总线解耦信令与业务:
// Dart (Flutter)
_socket?.onMessage = (message) {
onMessage(_decoder.convert(message));
};
void onMessage(mapData) {
switch (mapData['type']) {
case 'offer': _handleOffer(data); break;
case 'answer': _handleAnswer(data); break;
case 'candidate': _handleCandidate(data); break;
// ...
}
}
// TypeScript (Socket.io + EventBus)
this.socket.onAny((event, payload) => {
this.bus.emit(event, payload);
});
this.bus.on(SERVER_EVENT.SEND_OFFER, this.onReceiveOffer);
this.bus.on(SERVER_EVENT.SEND_ICE, this.onReceiveIce);
连接状态管理:
enum ConnectionState { READY, CONNECTING, CONNECTED }
// 使用 Promise 等待连接建立
private connectedPromise: PromiseWithResolve<void>;
public isConnected() { return this.connectedPromise; }
ICE Servers 配置:
const iceServers = [
{ urls: ['stun:stun.l.google.com:19302'] },
{
urls: ['turn:host:port'],
username: '...',
credential: '...'
}
];
呼叫方流程:
// 1. 创建 RTCPeerConnection
var pc = await createPeerConnection({...iceServers, 'sdpSemantics': 'unified-plan'});
// 2. 添加本地媒体流
_localStream.getTracks().forEach((track) async {
_senders.add(await pc.addTrack(track, _localStream));
});
// 3. 收集 ICE candidate 并立即发送
pc.onIceCandidate = (candidate) async {
_send('candidate', {'to': peerId, 'candidate': {...}});
};
// 4. 创建并发送 Offer
var offer = await pc.createOffer();
await pc.setLocalDescription(offer);
_send('offer', {'to': peerId, 'description': {'sdp': offer.sdp, 'type': offer.type}});
被叫方流程:
// 1. 收到 offer,设置 remote description
await pc.setRemoteDescription(RTCSessionDescription(sdp, 'offer'));
// 2. 创建并发送 Answer
var answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
_send('answer', {'to': peerId, 'description': {'sdp': answer.sdp, 'type': answer.type}});
ICE Candidate 缓冲(关键!):
// 如果 pc 尚未创建,先缓冲 candidate
if (session.pc != null) {
await session.pc?.addCandidate(candidate);
} else {
session.remoteCandidates.add(candidate);
}
// 待 pc 创建后,一次性添加缓冲的 candidates
session.remoteCandidates.forEach((c) async => await pc.addCandidate(c));
session.remoteCandidates.clear();
获取本地媒体:
final mediaConstraints = {
'audio': true,
'video': {
'mandatory': {'minWidth': '640', 'minHeight': '480', 'minFrameRate': '30'},
'facingMode': 'user',
}
};
var stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
屏幕共享:
var stream = await navigator.mediaDevices.getDisplayMedia({'video': true});
// 桌面端需要选择窗口/屏幕
final source = await showDialog<DesktopCapturerSource>(...);
stream = await navigator.mediaDevices.getDisplayMedia({
'video': {'deviceId': {'exact': source.id}}
});
Camera ↔ Screen 切换(replaceTrack,零中断):
_senders.forEach((sender) {
if (sender.track!.kind == 'video') {
sender.replaceTrack(newStream.getVideoTracks()[0]);
}
});
麦克风静音:
_localStream!.getAudioTracks()[0].enabled = !enabled;
SDP 兼容性修复:
// profile-level-id=640c1f → 42e032 解决某些编解码器不兼容
sdp = sdp.replaceAll('profile-level-id=640c1f', 'profile-level-id=42e032');
创建 DataChannel:
const channel = connection.createDataChannel("file-transfer", {
ordered: true, // 保证顺序
maxRetransmits: 50, // 最大重传次数
});
分片传输协议(参考 filetransfer_p2p):
| 字段 | 大小 | 说明 |
|---|---|---|
| ID | 12B | 文件唯一标识 |
| Sequence | 4B | 分片序号(大端) |
| Payload | 变长 | 实际数据 |
分片大小限制:
let maxSize = connection.sctp?.maxMessageSize || 64 * 1024;
maxSize = Math.min(maxSize, 256 * 1024); // 上限 256KB
// Firefox 可能返回 1GB,必须限制
背压控制(防止内存溢出):
while (this.tasks.length) {
const next = this.tasks.shift();
if (channel.bufferedAmount >= chunkSize) {
await new Promise(resolve => {
channel.onbufferedamountlow = () => resolve(0);
});
}
channel.send(buffer);
}
传输流程:
Sender Receiver
│ FILE_START (id, size, total) │
│ ─────────────────────────────> │
│ FILE_NEXT (id, series=0) │
│ <───────────────────────────── │
│ Binary Chunk [id|seq|data] │
│ ─────────────────────────────> │
│ FILE_NEXT (id, series=1) │
│ <───────────────────────────── │
│ ... │
│ FILE_FINISH (id) │
│ <───────────────────────────── │
后台恢复(关键!信令断开不重置 RTC):
this.signaling.socket.on("connect", () => {
// FIX: 移动端后台恢复时,信令重连但 RTC 仍保持
if (this.instance?.connection.connectionState === "connected") {
return; // 不创建新实例
}
// ...
});
会话去重(防止标签页复制导致 ID 冲突):
// LRU Session + performance.navigation 检测
const sessionId = getSessionId(); // 从 storage 读取或生成
// socket.io auth 携带 sessionId
io(wss, { transports: ["websocket"], auth: { sessionId } });
连接状态监听:
connection.onconnectionstatechange = () => {
if (connection.connectionState === "connected") {
atoms.set(stateAtom, CONNECTION_STATE.CONNECTED);
}
if (["disconnected", "failed", "closed"].includes(connection.connectionState)) {
atoms.set(stateAtom, CONNECTION_STATE.READY);
}
};
扫描信令相关代码:
Grep: offer | answer | candidate | signaling | RTCPeerConnection
Glob: **/signaling.{go,ts,dart}
读取信令服务器核心:
Read: pkg/signaler/signaler.go
Read: lib/src/call_sample/signaling.dart
读取客户端连接管理:
Read: packages/webrtc-im/client/service/webrtc.ts
Read: packages/webrtc-im/client/service/transfer.ts
✅ 一对一视频通话
✅ 多人会议(需扩展 SFU/MCU)
✅ P2P 文件传输(DataChannel)
✅ 屏幕共享
✅ 实时消息(DataChannel)
✅ NAT 穿透(STUN/TURN)
✅ 移动端音视频应用
❌ 大规模直播(需要 CDN/RTMP,非 WebRTC 强项)
❌ 纯服务器中转(用 WebSocket 即可,无需 WebRTC)
❌ 高并发信令(需考虑信令服务器水平扩展)
| 错误操作 | 实际后果 | 正确做法 |
|---|---|---|
| ICE candidate 到达时 pc 未创建,直接丢弃 | 部分 candidate 丢失,P2P 连接失败或延迟高 | 使用 remoteCandidates 缓冲列表,待 pc 创建后统一添加 |
| 等待所有 ICE candidate 收集完成再发送 SDP | 连接建立延迟 5-15 秒 | 使用 Trickle ICE,candidate 逐条实时发送 |
| DataChannel 发送不做背压控制 | 大文件传输时内存溢出,浏览器崩溃 | 检查 bufferedAmount,使用 onbufferedamountlow 等待 |
| 移动端切后台时重置 RTC 连接 | 用户返回后需要重新走完整信令流程,体验差 | 信令断开重连时检测 connectionState,connected 则保持 |
| 使用 Plan-B SDP 语义(已废弃) | 新浏览器不兼容,多轨传输异常 | 统一使用 unified-plan |
| 忽略 connectionState 变化 | 断连无法感知,UI 状态与实际脱节 | 监听 onconnectionstatechange,驱动 UI 状态 |
| 分片大小超过 sctp.maxMessageSize | 发送失败或数据截断 | 动态获取 maxMessageSize,上限 256KB |
| 不做 TURN 服务器配置 | 对称 NAT 环境下 30-40% 用户无法 P2P | 部署 TURN 中继,使用 HMAC 凭证验证 |
| 错误操作 | 实际后果 | 正确做法 |
|---|---|---|
| 未缓冲早期 ICE candidate | 连接建立时间从 2s 变为 15s | 始终实现 candidate 缓冲机制 |
| DataChannel 直接发送大文件 | 浏览器内存占用 2GB+ 后崩溃 | 实现分片 + 背压队列 |
| 忽略移动端后台行为 | 用户切回后通话中断 | 信令重连不重置已建立的 RTC |
常见坑点类型: