47 KiB
屏幕共享 WebRTC PoC 方案设计
2026-08-18 14:45
状态:首个 PoC 基线及其已接受扩展的当前设计。769de201f7cc 是仍在运行的旧 production P2P/TURN 基线;下文明确标注的 repository candidate 是隔离验证中的 STUN-only ordinary ICE、exact-room peer-assisted 与 LiveKit SFU/UDP,二者不是同进程兼容路径。
目标与边界
产品面向一名分享者和小范围可信朋友的低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;每条媒体边独立 ICE。旧 production 能直连时走 P2P、失败边使用 coturn;repository candidate 的普通边只接收自托管 STUN,peer 恢复耗尽后由 LiveKit ICE/UDP virtual parent 服务一至两个必要 roots,再失败则有界明确结束。当前房间默认最多八名观看者、可配置范围为 1 至 16;自动分布式拓扑通过 20 人门禁后,目标 release 才把默认值和上限原子改为 20。容量配置只是接入边界,不是性能声明。
本阶段必须形成完整的最小纵向链路:
- 分享者主动选择屏幕、窗口或标签页,并请求可用音频;
- 创建数字房间,并用可选全站密码统一保护分享和观看入口;受保护部署可选择最小 SQLite 持久化,使房间和链接在停止分享及进程重启后仍有效;
- 每名观看者拥有独立的
RTCPeerConnection; - 当前仓库候选要求 STUN,普通 peer ICE 只发 STUN servers;SFU candidate 只开放 LiveKit ICE/UDP;
- 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据;
- 超过部署配置上限的观看者被明确拒绝,不静默超载;
- 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。
首个基线不实现账号数据库、通用业务数据库、Redis、simulcast/SVC、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。唯一持久化例外是可选的 node:sqlite 房间身份表。后来接受的 peer-assisted/SFU candidate 只通过 ADR-0004/0005 的现有窄 owner 扩展,不引入第二套路由器或通用优化框架。
技术栈决策
| 层 | 选择 | 原因 |
|---|---|---|
| Web 客户端 | React 19、TypeScript、Vite 8 | 同一套响应式界面覆盖分享者与桌面/移动观看者;官方模板成熟 |
| 媒体 | 浏览器原生 WebRTC 与 Screen Capture API | 不引入 PeerJS/simple-peer,保留对 ICE、sender 参数和 stats 的直接控制 |
| 应用服务 | Node.js 24 LTS、TypeScript、原生 HTTP、ws |
服务只负责小流量控制面;前后端可共享协议类型和校验 |
| 可选房间持久化 | Node.js 内置 node:sqlite |
单文件、无额外依赖;只保存恢复持久房间所需的 ID 与 token 摘要 |
| 运行时校验 | Zod | WebSocket 和 HTTP 输入不可信,需要与 TypeScript 类型一致的运行时边界 |
| 静态文件 | sirv |
生产环境由同一 Node 进程提供前端构建结果,避免引入完整 Web 框架 |
| 地址发现 | coturn 4.17.2 或更新补丁版本的 stun-only 模式 |
自托管 UDP STUN,不启用 relay、credential 或媒体 TCP |
| 测试 | Vitest | 覆盖纯逻辑和 Node 集成测试;真实媒体能力另做浏览器/网络矩阵 |
| TLS 入口 | Caddy 或 nginx,由部署者选择 | 统一 HTTPS/WSS;不把证书管理写进应用进程 |
不选 Go 的原因是 Node 不承载正常情况下的媒体流量,换语言不会减少分享者上行或 TURN 中继成本。不选 C++ 的原因是浏览器能力尚未被实测证明不足;只有捕获、应用音频或硬件编码出现明确瓶颈时才增加原生 helper。
系统结构
flowchart LR
H[分享者 Web] -->|HTTPS: 建房| A[Node 应用服务]
H <-->|WSS: 鉴权与信令| A
V[观看者 Web 1..N] <-->|WSS: 鉴权与信令| A
H <-->|ordinary direct/peer DTLS-SRTP UDP| V
H -.peer 路径耗尽.-> S[LiveKit SFU/UDP virtual parent]
S -.一至两个必要 roots.-> V
H -.地址发现.-> T[coturn STUN-only]
V -.地址发现.-> T
每个观看者的候选检查和最终路径互不影响。同一 candidate 房间可同时存在健康 direct/peer 子树与必要的 SFU roots;Node 房间服务不复制、解码或转发媒体,LiveKit 只在有界 root 路径上转发。
代码布局
src/
client/ React 页面、捕获、PeerConnection、stats 和样式
server/ HTTP/WS 入口、配置、房间和静态 STUN ICE snapshot
shared/ 信令 schema 与跨端类型
tests/ RoomStore、ICE 配置、协议和 WebSocket 集成测试
保持单个 npm package。出现第二个可独立发布的程序前,不建立 monorepo 或 workspace。
房间与访问控制
ACCESS_PASSWORD 是可选的全站开关。为空时网站公开;非空值必须包含 1 至 128 个可见 ASCII 字符(0x21 至 0x7e)。配置时,分享者访问根页面、观看者访问 /join 或 /r/{code} 都先进入同一个登录 gate。POST /api/session 校验密码后签发 12 小时的无状态 cookie:值只包含版本、到期时间和 HMAC-SHA256 签名,不创建账号、JWT 或服务端 session Map,也不提供 logout 流程。cookie 使用 HttpOnly、SameSite=Strict、Path=/ 与有限 Max-Age;HTTPS 生产环境再使用 Secure 和 __Host- 前缀。WebSocket 异常断开后会用 /api/session 检查一次当前 cookie;仅在确认失效时停止重连并返回 gate,网络故障仍走原重连流程。
建房 POST /api/rooms 和 WebSocket upgrade 在启用密码时都只验证该 cookie。密码只以 Authorization: Bearer 提交给登录接口,建房 API 不接受相同的 Bearer 旁路。密码模式下 HTTP 建房还要求精确 Origin;公开模式也会拒绝不在 allowlist 中的已提供 Origin。WebSocket 在握手完成前同时校验 Origin、容量和 access cookie。
ROOM_DATABASE_PATH 是可选 SQLite 文件路径,且配置它时必须同时配置 ACCESS_PASSWORD,否则启动失败。由此形成三个明确配置:
ACCESS_PASSWORD为空、数据库路径为空:公开临时模式,生成随机数字roomId,使用ROOM_TTL_SECONDS过期,随机性降低匿名枚举风险;- 只配置
ACCESS_PASSWORD:受保护临时模式,房间仍使用随机 ID 和 TTL; - 同时配置
ACCESS_PASSWORD与ROOM_DATABASE_PATH:受保护持久模式,SQLiteINTEGER PRIMARY KEY AUTOINCREMENT从1开始分配十进制roomId,房间与邀请链接不设过期时间。
所有模式都为新房间生成 256-bit 随机 hostToken,只把 SHA-256 摘要留在服务端。host 浏览器把建房响应保存在同源 localStorage,用于刷新后重新取得发布权;清除站点数据或换设备不会从摘要恢复 token。持久模式的 SQLite 表只有作为 rowid 的房间 ID 与 32-byte host token 摘要两个应用字段;不保存全站密码、token 明文、access cookie、SDP、ICE candidate、IP 地址、观看者状态、媒体或 TURN 凭据。node:sqlite 由单个 Node 进程直接使用,不增加 ORM、迁移框架、Redis 或多节点同步。生产 systemd 示例通过 StateDirectory=screener 提供 /var/lib/screener,当前实例使用 /var/lib/screener/rooms.sqlite。
观看者没有独立 token:邀请为 /r/{roomId},也可访问 /join 并只输入房间码。在受保护模式中,两种入口都必须先通过全站 gate。自增 ID 可被已登录用户枚举,这是“一个全站密码保护一组可信朋友”的已接受边界;它绝不能用于公开模式。账号、逐人邀请、逐人撤销、逐房间密码和持久 access session 不在当前需求内。
房间规则:
- 一名在线分享者;当前默认最多八名在线观看者、配置只接受 1 至 16,目标路由 release 通过门禁后统一改为 20;
- 分享者明确停止或捕获轨结束时只停止当前发布并通知观看者进入等待,不删除房间或使链接失效;host 信令短断仅更新在线状态并触发恢复,不能主动关闭仍健康的 P2P 媒体;
- 临时房间到达 TTL 后才拒绝新连接、通知现有参与者并从内存清理;持久房间没有 TTL,进程启动时从 SQLite 恢复房间身份;
- 设置全局房间上限防止内存耗尽;持久房间没有自动删除策略,运维方必须保护和备份数据库文件;
- 运行时仍只在内存保存当前 host/viewer 连接与信令状态,任何模式都不记录 SDP、ICE candidate、IP 地址、媒体或 TURN 密钥。
客户端用 sessionStorage 保存随机的 128-bit clientId,作为当前标签页内稳定的参与者 key。同一 role/clientId 的新 WebSocket 会原子替换旧 socket;观看者断线后保留五秒 grace,期间不发送 peer-left 且 peerId 不变,重新鉴权时重发幂等的 peer-joined,超时才释放名额。host 鉴权或重连后,authenticated 携带包含 grace 成员的权威 viewer ID 列表供客户端删除已离开的 peer,服务端再重放当前在线观看者的 peer-joined,因此观看者可以先于 host 打开邀请,host 离线期间的离开也不会遗留幽灵连接。
显示名是独立的本地偏好:一个同源 localStorage 值供 Host 与 Viewer 共用,未设置时使用角色默认标签。客户端鉴权时发送经同一严格 schema 校验的纯文本名称,服务端只在当前内存会话中保存;每次加入、离开或会话替换后向房间内所有参与者广播不超过一名 Host 加配置观看上限的完整权威 roster,而不增加增量事件状态机。房间标题取当前 Host 显示名;名称允许重复、只按文本渲染,并且不替代 role、host token、peer/session ID 或任何授权判断。SQLite schema 保持不变。
信令协议
WebSocket 使用同源 /signal。启用全站密码时,upgrade 必须先携带有效 access cookie;连接后五秒内第一条消息仍必须是房间级 authenticate。host 消息携带内部 host token,viewer 只携带房间码、role 与 clientId,不存在 viewer token,也不把凭据放 query string。服务端设置 64 KiB maxPayload、关闭 perMessageDeflate,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 WebSocket upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket;这只是 PoC 的资源边界,不是容量承诺。
主要消息:
| 方向 | 消息 | 作用 |
|---|---|---|
| client -> server | authenticate |
host 以 room、role、内部 host token 和 clientId 建立会话;viewer 只使用 room、role 和 clientId |
| server -> client | authenticated |
返回角色、peerId、可为空的房间期限、容量、host 状态、该 viewer 的历史 connectionId、host 的权威 viewer 列表和 ICE 配置 |
| server -> host | peer-joined / peer-left |
建立或关闭该观看者的独立连接 |
| host -> viewer | offer / candidate |
带 peerId 与 connectionId 的定向信令 |
| viewer -> host | answer / candidate |
只能回给本房间分享者 |
| viewer -> host | restart-request |
以 rebuild 标志请求 ICE restart 或完整重建该 peer |
| host -> server | stop-sharing |
结束当前发布、清理本轮连接并让观看者等待;不删除房间或使邀请失效 |
Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。
不兼容迁移使用一个精确的当前 release/protocol fingerprint,而不是版本协商。旧页面连接新服务端时收到专用 fatal mismatch,停止自动重连并提示刷新;服务端终止旧 session。客户端和服务端不解析旧 wire、不双写、不维护多版本 handler,部署切换完成后删除旧 schema 与测试。
WebRTC 协商
分享者是唯一 offerer。每名观看者对应一个 RTCPeerConnection,以独立 connectionId 区分重建代次:
- 分享者在开始按钮的同一用户手势内先成功调用
getDisplayMedia()。 - 只有捕获 track 已存在时才请求创建房间、连接 host WebSocket 并显示邀请;若用户在建房响应返回前取消,迟到结果不得覆盖后续分享会话。尚未被客户端认领的迟到结果通过内部
abandon-room清理;已经认领的房间不因正常停止捕获而删除。持久模式使用AUTOINCREMENT,被清理的编号不会复用。 - 观看者认证后,服务端向在线分享者发送
peer-joined;host 晚上线或重连时收到完整观看者快照。 - 分享者创建连接、加入当前视频 track,并以现有音轨或空 sender 预留 audio transceiver,设置 sender 参数,然后创建 offer。
- 观看者收到 offer 后创建连接、设置 remote description、创建 answer。
- 双方持续通过信令服务交换 trickle ICE candidates。
- remote description 尚未设置时,客户端仅缓存有界数量的 candidates;旧
connectionId消息直接丢弃。
这种单一 offerer 模式满足当前单向媒体需求,不引入 perfect-negotiation 状态机。同一页面中的不健康连接发送 rebuild: false,host 先尝试 restartIce(),非稳定信令状态或恢复失败时重建该 peer。新标签页没有旧 RTCPeerConnection,或页面 peer 与 authenticated.connectionId 代次不一致时,viewer 发送 rebuild: true,host 直接换代。健康且代次一致的媒体在单纯信令重连时保持不变;所有异步启动结果以 host 会话 generation 隔离,viewer 的异步 answer、candidate 和 stats 结果也必须同时匹配当前 connection 对象与 connectionId,旧代次不得改写新状态或发出信令。
信令 WebSocket 暂时中断时,不立即关闭仍健康的 P2P 媒体;客户端指数退避恢复控制通道。明确不可重连的会话替换会释放旧页面的 peer、统计定时器和媒体引用,host 也停止旧捕获。首版只保证同一页面生命周期内恢复,不承诺浏览器进程被系统挂起后的连续播放。
捕获、编码与质量
捕获只能由按钮产生的用户手势调用 getDisplayMedia()。三档目标为:
| 档位 | 约束目标 | 初始码率上限 |
|---|---|---|
| 1080p60 | 1920x1080、60 fps | 8 Mbps/peer |
| 1080p30 | 1920x1080、30 fps | 5 Mbps/peer |
| 720p30 | 1280x720、30 fps | 3 Mbps/peer |
浏览器可返回更低设置,界面以 track.getSettings() 显示实际捕获结果。生产 release 769de201f7cc 已部署有界手动质量控制:视频 track 保留 contentHint = "motion",默认使用清晰优先的 degradationPreference = "maintain-resolution",并允许显式选择 balanced 或 fluid。折叠高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 maintain-resolution、balanced、maintain-framerate 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。
每次 setParameters() 都必须从当次 getParameters() 派生更新,然后读回 requested/applied maxBitrate、maxFramerate、scaleResolutionDownBy 和 degradation preference。拒绝或浏览器改写必须进入可见状态,不能只写 console.warn。线上已是 clarity-first bounded controls,但用户报告的严重降级尚未完成受控分类,ADR-0007 自动质量控制也尚未实现;不得据此宣称画质提升。下一次真实质量验收仍需在同一高动态场景采样 1、2、3 名观看者和 TURN 路径。不得只提高 ceiling、强制 codec 或修改 SDP。
PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商 codec、encoderImplementation 和 powerEfficientEncoder,再依据目标机器测量决定是否调整 H.264、VP8、VP9 或 AV1 优先级。getCapabilities() 只证明可协商,Media Capabilities 的 supported/smooth/power-efficient 只提供候选证据;最终必须同时检查实际 outbound codec、区间 encode time、游戏 FPS、CPU/GPU encode 引擎和 sender 数量。AV1 压缩效率不能证明目标机器具有 WebRTC 硬件编码,强制后可能落入更重的软件路径。
audio: true 只是请求。若 getAudioTracks() 为空,分享者界面必须显示“当前来源没有可共享音频”。systemAudio、windowAudio 和 audioSelection 只影响选源器偏好,浏览器可以忽略且没有对应的标准音频来源类型读回,因此不能显示为系统、窗口或所选游戏音频保证。纯 Web 首版接受浏览器实际提供的标签页或系统音频,不承诺仅捕获某个游戏进程;系统音频可能把语音软件中观众自己的声音再次采入,这属于源内容泄漏而非 Host 页面回放,AEC 或 Web Audio 无法从已混合 track 恢复进程身份。Web 端必须保留 video-only 选择并明确风险,不能声称已隔离语音。
后续 Windows sender 以所选游戏 PID 为本地边界,优先使用 WASAPI process loopback 的 include-target-process-tree 捕获游戏及其子进程;“全部系统声音”仅为显式选择。目标进程无渲染流、退出或捕获失败时显示无声/未知,不回退为整机音频,也不上传 PID、路径、窗口标题、设备标识或原始音频。该 API 的最低 build 20348 不存在于普通 Windows 10 22H2(19045)产品矩阵,因此当前候选只覆盖 Windows 11;Windows 10 game-only 音频保持未解决或明确不支持,直到独立候选通过。
直播中切换来源仍由明确的按钮手势重新调用 getDisplayMedia(),不改变房间、host generation 或媒体拓扑。选源取消或失败时继续发送旧流;成功后为每个现有 sender 调用 replaceTrack(),健康连接不重新协商。初始 offer 预留 send-only audio transceiver,使音频可以在 null -> track -> null 间切换。若某个 peer 的替换或回滚失败,只重建该 peer;新流提交后才停止旧流,并以当前 stream 身份保护旧 track 的迟到 ended 事件。统计码率累加器在换源后重置,避免把不同 track 的计数器混算。
直播中切换质量不重新打开选源器:对当前视频 track 调用 applyConstraints(),再更新所有现有 sender 的参数;新加入、重建的 relay sender 和启用的 SFU publisher 使用同一权威设置。失败时保留当前分享并给出状态,不以重建整个房间补偿。临时暂停只禁用当前视频 track,使远端收到黑画面;音频、房间和连接保持不变,恢复时重新启用同一 track。
Host 本地预览直接绑定现有 capture stream,不创建 viewer、RTCPeerConnection 或额外媒体边,也不产生 TURN/SFU/应用服务器出口。预览仍可能有本地合成、缩放和 GPU 显示成本;在同一动态场景对预览开/关采集 Host CPU、GPU、encode time 和实际 FPS。只有稳定收益且捕获、编码和远端画面不变时,才把直播后的默认动态预览改为静止缩略图或隐藏并允许按需展开;隐藏预览绝不停止 capture track 或 sender encode。
部署版 769de201f7cc 的模糊、游戏低帧、持续 bandwidth 提示和 Host 刷新恢复都只作为同一 release 的用户报告。首轮复现必须在相同机器、Chrome/Edge、驱动、游戏场景和单个有线 direct Viewer 上对照该 release 与最新 main,并分别执行只重建受影响 HostPeer、只替换 capture、完整 Host 重建和预览开/关;任何一次只改变一个变量。若实际 codec/encoder 不变而 BWE/发送恢复,则下调 codec 根因优先级;只有编码成本异常时才进入受控 codec spike。
当前 Web 高级区不暴露音频质量控件。Screen Capture 规范未把 channelCount、sampleRate 列为 display-audio 可控属性;track.getSettings() 中偶尔出现的值只能作为捕获观测。标准 RTCRtpSender.encodings[].maxBitrate 可设置和读回,但只降低发送 ceiling,既不能要求编码器提高质量,也不等于 Opus maxaveragebitrate。Opus stereo、DTX 和 in-band FEC 属于协商的 codec/fmtp 行为,没有跨主流浏览器的标准 sender setter;opus/48000/2 也只是 RTP 信令约定,不能证明 48 kHz 源或实际 stereo。设计不为这些控件新增应用层 SDP munging,也不把 SDK 私有设置包装成浏览器保证。详细证据与复核门槛见 docs/research/browser-screen-audio-quality.md。
frameRate 约束和 sender 的 maxFramerate 都是目标或上限;浏览器仍控制实际捕获、重复帧处理、编码和拥塞降级。静态画面可能由浏览器减少实际编码数据,但标准没有保证每个实现以同一种方式节省采集或 GPU 开销。本 PoC 不添加画面差分、定时改约束或自定义动态 FPS 状态机,先用 framesEncoded、实际发送码率、totalEncodeTime、质量受限原因和主机资源占用测量真实问题。
自动质量控制采用 ADR-0007 的按需双表示,而不是全房间按最差链路降档。正常状态只运行一套 active HIGH representation;第一条被真实统计持续证明无法承受的观看路径出现时,必须启动唯一一套共享 LOW 并迁移所有已验证弱路径,健康路径继续接收 HIGH。最后一条弱路径经过更长的稳定恢复窗口回到 HIGH 后关闭 LOW。active representation 数量硬限制为 HIGH + at most one on-demand LOW <= 2,不能按 viewer 增长为 N 套。标准浏览器不保证跨 PeerConnection 共用一个物理编码器,inactive 层也不保证释放 GPU;实例数、CPU/GPU 与释放行为必须实测。若没有合格 hardware/power-efficient 路径或启动 LOW 实测破坏游戏预算,则当次 fail closed,保留 HIGH 并明确显示弱路径降级或失败;这只是异常止损。supported sender cohort 若不能可靠按需启动 LOW,则自动质量控制验收失败,必须优化编码路径或明确标记不支持,不能长期忽略弱路径。
每条观看路径只维护 HIGH/FALLBACK 两态。HIGH -> FALLBACK 需要多个连续采样窗口中的带宽不足、冻结或解码跟不上;FALLBACK -> HIGH 使用不同门槛和更长的稳定窗口,避免来回抖动。不把 RTT、CPU、码率、设备信息等压成一个综合分数。具体窗口和阈值必须由真实质量矩阵确定,而不是先写死一个猜测。
状态决策必须关联同一时间区间和媒体代次的三段证据:
| 阶段 | 必要证据 |
|---|---|
| A. Host capture | track.getSettings() 的实际宽、高、FPS |
| B. Host outbound | 实际宽高/FPS/码率、可用或目标码率、区间 encode time、qualityLimitationReason、RTT、丢包、重传、direct/TURN 路径,以及派生后的实际协商视频 codec/profile/parameters 和适用时的 scalabilityMode |
| C. Viewer inbound | 实际宽高/FPS/码率、丢包、jitter、区间 decoded/dropped frame 和 freeze,以及对应派生 codec/profile/parameters、适用时的 scalabilityMode 和实际解码表现 |
判断顺序固定为:Capture 低归为采集或约束;Capture 高而 outbound 低且 reason=cpu 归为编码资源;Capture 高而 outbound 低且 reason=bandwidth 归为 GCC、上行或 TURN/路径;outbound 正常而 inbound 低归为传输或接收;inbound 指标正常但肉眼模糊归为码率/量化、协商 codec/profile/parameters、适用的 scalable layer 或显示缩放。B/C 必须关联同一派生协商结果与实际解码表现。累计统计只计算相邻、不重叠区间的 delta;缺字段、stats ID/媒体代次变化或计数器回退时重新建立基线,不能把未知当零。
探针按最小顺序落地:先在 host 同一个采样 tick 内关联 A+B,并携带明确 window、媒体/stats identity 和有效 delta;验证可靠后才增加只包含两态判定所需字段的受限、已鉴权 C 上报。不建设通用遥测 schema,只传派生后的协商字段,绝不上传 raw stats、raw SDP、candidate 地址或原始设备/网络标识;鉴权与关联只使用服务端签发的不透明 path/connection generation ID。
Viewer 可通过已鉴权、绑定当前房间/路径代次的控制面请求 LOW,但请求只是一条观测输入,必须限频和去重,并与 sender 的 Transport-CC/GCC、实际码率、RTT、丢包、重传及编码限制、派生协商 codec/profile/parameters(适用时含 scalabilityMode)和 viewer 的实际解码/丢帧/冻结表现交叉验证。UA、平台和设备型号不参与质量决策;现有 mobile/iPad 检测只用于保守设置 relay capacity。
SVC 是 simulcast 与 LiveKit/Dynacast 均未满足后的第三个标准候选;若它能独立选层、读回实际 codec/scalabilityMode、确认目标 hardware/power-efficient 路径并通过游戏性能和亚秒延迟门槛,即采用并停止自研。浏览器的硬件偏好不是保证,不能静默回退为软件 SVC;它不是预选默认方案,未来严格单输出要求只会加强而不是创建采用理由。普通非分层编码包不能只靠转发生成另一档画质,实现路径差异只能用第二表示、SVC 或 relay/SFU 转码。
质量控制不放宽媒体拓扑预算:host 仍最多两条下游媒体边;native relay 只转发选定表示的编码包,不解码重编码。后续双树或条带化可把 host 上传从约两份完整码率推向一份加必要冗余,但不阻塞按需双表示的实现。
标准表示能力排在任何自定义双表示媒体实现之前,并可与 ADR-0006 native sender 复核并行。静态 API 或 pinned 协议不满足可在完整 A+B/C 前短路否决;只有 runtime media/resource gate 等待可信 A+B/C。序列依次为同 sender simulcast、最多两个 LiveKit root 的 Dynacast 和 SVC;前两项已静态 no-go,SVC 是下一候选,首个满足语义与资源门槛的路径立即结束序列。具体门槛由 ADR-0007 和实时质量调研持有,任何方案都不得复用 PR #28 的 minimum-of-two 或绕过 stock WebRTC congestion control。
媒体传输配置
当前仓库候选已经删除旧全房 TURN contract。ServerConfig 只接受
STUN_URLS;已删除的 TURN_URLS、TURN_SHARED_SECRET 或
TURN_CREDENTIAL_TTL_SECONDS 任一存在(包括空值)都会启动失败,避免部署
误以为旧兼容配置仍生效。生产缺失 STUN 也启动失败。authenticated.iceConfig 是静态
STUN snapshot,只含 iceServers,不含 TURN URL、username、credential、
expiry 或 relayAvailable。信令协议也不再接受 refresh-ice 或发送
ice-config;WebSocket 重连后的新 authenticated 自然取得当前 snapshot。
普通 direct/peer RTCPeerConnection 与 LiveKit participant 是两个明确的
ICE 域。普通边只消费 authenticated.iceConfig;SFU publisher/subscriber
只消费 revision-bound sfu-config {url, token},再由 LiveKit SDK 与服务端
协商自己的 candidates。tracked LiveKit 配置只开放 UDP mux,显式使用
tcp_port: 0、allow_tcp_fallback: false 和部署自托管 stun_servers,不配置
external TURN 或 embedded TURN。Node 应用不能替代部署
验证来证明该 UDP listener 从公网可达;自托管 stun_servers 也避免 pinned
LiveKit 1.13.5 继承默认公共 Google STUN。
当前可执行梯级是 direct/peer UDP -> SFU ICE/UDP -> failure。Viewer 的
现有两次有界 ICE restart/rebuild 后报告 route failure;router 先尝试局部
reparent,再在 root budget 内准备 SFU,最终没有可行 route 时返回明确的
PEER_NOT_FOUND,不长时间维持伪连接。HTTPS/WSS 始终使用 TLS/TCP,不
属于媒体 TCP;clarity/balanced/fluid 只改变编码策略。
旧 production release 769de201f7cc 仍需要 coturn TURN/UDP+TCP。它作为
独立旧实例保留到新 candidate canary 通过;新代码不根据 room allowlist
保留旧 release TURN 兼容分支。PEER_ASSISTED_ROOM_IDS 只灰度 topology/SFU route
wire,进程内所有房间的 ordinary ICE 都是 STUN-only。因此验证与回滚必须
使用隔离 candidate/旧实例,不能假设同进程未命中房间仍有旧 transport。
optional selected-edge TURN 仍是未来候选,不是当前能力。只有真实受限网络 证据证明需要时,才以单独 PR 一次性加入配置校验、短期鉴权、assignment/ connection generation grant、限频、客户端消费、部署和负向测试。该 PR 不得恢复房间鉴权时广播的全房 credentials。LiveKit participant-wide TURN 也不能冒充这项 edge grant。
SFU 正常只作为 virtual parent 向一至两个必要 roots 发布;可靠 root 收到
媒体后继续服务 sticky descendants。host/native root 最多两条下游媒体边,
browser root/relay 最多一个 child。无法挂到健康 root 的极端 viewers 只能
通过独立 E/central-egress cap 准入。普通 SFU 终止两侧 DTLS-SRTP;没有
应用层 E2EE 时 operator 可接触媒体,这一产品取舍必须保持可见。所有物理
hop 按低服务器成本媒体路由逐项计量。
可观测性
每两秒在本地采集一次 getStats()。首版不上传原始统计。折叠详情可在产生该连接的本机显示 selected local/remote candidate 的 address/port,但浏览器未暴露时保持未知;地址不得进入信令、Viewer C、日志、诊断导出、持久化或路由/质量决策。在 peer 边中它只是当前连接端点,在 SFU 边中通常是 SFU 端点,不能标成参与者的权威公网 IP。
质量诊断必须能按顺序区分:捕获源设置、sender 实际参数、CPU 或带宽 qualityLimitationReason、selected candidate/TURN 路径、outbound 与 inbound 差异,以及接收端 dropped/freeze。当前有界实现显示 sender 参数的 requested/applied 读回;同一个非 none 原生 qualityLimitationReason 连续出现三个采样周期后显示单条原因提示,原因变化、缺失或恢复为 none 即重新计数。这只是去抖后的观察,不组合评分、不触发调档或迁移。完整跨路径分类仍由后续真实矩阵验收;首轮使用浏览器 WebRTC internals 导出和一份脱敏 run manifest,不新增服务端遥测。
分享者逐观看者显示:
connectionState/iceConnectionState;- 选中候选对的 local/remote candidate type 与 ICE
protocol,任一端为relay即标记中继; - 本地 relay candidate 的
relayProtocol;规范禁止远端 candidate 暴露该字段,因此不推断远端使用 TURN/UDP、TCP 还是 TLS; - 后续 exact-room SFU/UDP 与可选兼容 transport 只按低服务器成本媒体路由的 generation-baselined、privacy-safe ICE manifest 记录;先以本地脱敏
webrtc-internals建立 ground truth,不新增后台遥测; currentRoundTripTime、发送码率和availableOutgoingBitrate;- 实际帧率、分辨率和相邻有效采样窗的 RTP 丢包率;
- codec、编码实现、最近采样区间内的每帧编解码耗时和
qualityLimitationReason;同一连接不并发采样,首个样本、无新帧、统计对象换代或计数器重置时显示未知并重新建立基线,不用连接生命周期均值掩盖后续过载。
观看者显示接收码率、帧率、分辨率、RTP 丢包率、jitter、解码/丢帧和当前路径。丢包率只在同一 RTP generation 的相邻样本中以 lostDelta / (receivedDelta + lostDelta) 计算;首样本、对象/计数器重置、负 delta、零分母或字段缺失时显示“未知”,不把累计 packetsLost 伪装成百分比。RTT 只标为网络往返时间,不能冒充玻璃到玻璃延迟。
界面结构
首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题;浏览器 favicon 复用页头 MonitorUp 的深绿底、绿色显示器和上行箭头语言。
- 全站 gate:仅在配置
ACCESS_PASSWORD且 cookie 无效时显示一个密码输入窗口;验证后返回原目标路径。 - 分享者根页面:顶部显示“{分享者显示名} 的房间”和房间状态,并在标题旁显示可单独复制的紧凑房间号;主区域保持稳定的 16:9 舞台。未发布、已停止或可恢复错误状态时,舞台内以同等尺寸和权重显示“开始分享”和“加入房间”,中间只用“或”与分隔线表达发送/接收;前者直接开始现有分享流程,后者直接进入
/join,不增加首页、向导或拓扑选择。直播时舞台显示本地预览,底部保留停止、暂停、质量档位和复制完整邀请链接。 /join:只输入正整数形式的数字房间码,成功后进入/r/{code}。/r/{code}观看者:主区域最大化远端视频;标题旁显示可复制的紧凑房间号,顶部保留连接状态;提供本地音量、静音、全屏和重试控制,不要求额外邀请码或 viewer token。- 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。
两端默认显示紧凑的在线 roster;Host 与 Viewer 均可修改本机显示名,更新只影响之后由服务端广播的纯文本 roster。一个不持久化的“显示连接详情”复选框按页展开信令、ICE/TURN、浏览器实际暴露的本地/远端候选地址、codec、RTT、RTP 丢包率、编解码耗时及逐边统计;真实连接失败、音频缺失、持续画质受限和需要用户动作的告警始终可见,不能被该开关隐藏。分享者默认观看人数必须来自房间 roster,而不是只统计当前由 host 直连的媒体 child。
用户可见的非直播状态只使用“已停止分享”(分享端已结束当前发布)、“等待开始分享”(观看端等待发布)、“正在连接”与“正在恢复连接”。room-closed 显示“房间已关闭”或“房间已过期”,不得误写成“分享已结束”。房间寿命、媒体代次、换父和恢复等内部状态不得作为额外产品文案暴露。
按钮使用 lucide-react 图标并提供可访问名称和 tooltip。动态状态区域固定最小尺寸,避免连接状态变化导致布局跳动。320、375 与 390 CSS px 下两个首屏动作仍须同排等宽、文字不溢出且触控高度至少 44 px;展开详情不遮挡视频或引入横向滚动。
观看端使用原生 range 展示 0% 至 100%,并把归一化值直接写入当前 <video>.volume;它不进入 WebRTC sender、信令、房间状态、SQLite 或 localStorage。React 页面状态跨 srcObject 替换、换父和 ICE 恢复保留;页面刷新后恢复默认值。静音按钮只切换本地 muted,并保留上次非零音量;拖到零自动静音,从零调高或直接取消静音时恢复可听状态。该控件不作为音频码率、声道、采样率或来源隔离设置。
安全与故障边界
- 生产仅允许 HTTPS/WSS,并校验 WebSocket
Originallowlist。 - 全站密码以定长摘要配合常量时间比较;access cookie 由服务端 HMAC 签名且不含账号或可查询的 session ID。host token 只保存摘要并以常量时间验证;认证和建房响应设置
Cache-Control: no-store。 - HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。
- 应用日志不得主动记录 IP;反向代理、STUN 和 LiveKit 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。
- 自托管 coturn 必须启用
stun-only及no-tcp/no-tls,不得配置 relay port range、TURN auth secret、证书或匿名 TURN;coturn 4.17.2 已弃用no-dtls,tracked 配置不再使用它。未来 selected-edge TURN 由独立设计取代这份 STUN-only 配置,而不是在当前 wire 上加回全房 credential。 - 分享者直连会向可信观看者暴露网络地址。当前 candidate 不保留 force-relay 产品分支;未来 selected-edge TURN 若被证据接受,配置、授权、UI 和测试必须原子引入。
验证计划
自动测试:
- AccessSession 的密码开关、12 小时签名 cookie、过期、篡改、cookie 属性和无服务端 session 状态;
- RoomStore 的随机临时房间、TTL、host token 摘要验证、停止分享等待、当前默认八人/1 至 16 边界、目标 20 人 gate 和超额拒绝;
- SQLite 房间库从
1自增、重启恢复、持久房间无过期时间、数据最小化、数据库路径无全站密码时启动失败; - STUN URL 严格校验、生产 STUN 必需、ordinary ICE 不含 username/credential、旧
refresh-ice/ice-config被协议拒绝,以及 LiveKitsfu-config独立边界; - 单一
screener-v1信令 literal、旧页面/新服务或新页面/旧服务 mismatch 的一次性刷新终态,以及协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; - 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、已配置 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示;
- HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和停止分享后的等待状态;
- 客户端策略与 stats 解析:会话替换不重连,本地 TURN protocol 与 ICE protocol 分离,远端 TURN transport 保持未知;
- TypeScript 类型检查、Vite production build 和仓库 hygiene。
人工矩阵:
- 同机双标签页与同 LAN direct;
- 真实跨网络、同房间 direct/peer UDP + SFU/UDP root;
- CGNAT、双 NAT、移动热点和普通家宽下使用隔离 candidate,LiveKit 显式关闭 TCP fallback 且不配置 TURN,验证 host 与 roots 使用 SFU/UDP;人为封锁 UDP 时必须有界明确失败。旧 production release 留在独立实例作为回滚,不在同进程保留旧 transport;
- Windows Chrome/Edge 的 tab/window/monitor 音轨有/无、同时语音通话和 video-only;Windows 11 原生候选另测 game process tree、通知、无渲染流与进程重启,Windows 10 记录明确不支持/未解决;
- Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换;
- 一名分享者、八名异构观看者连续 30 分钟,同时记录较低人数基线、实际上行、编码负载和首帧时间。真实 1:8 未完成前不得把默认上限写成性能承诺。
- 真实桌面 Chrome 对 balanced 与 clarity 做控制传播和连接身份 smoke;该项只证明直播中应用与不重建,不代替清晰度、负载或公网矩阵。
真实矩阵未完成前,状态文档必须把这些列为待验证,而不是把设计目标写成产品能力。
实施顺序
- 建立单 package 工程、共享 schema、配置与 CI。
- 完成 RoomStore、建房 API、WebSocket 鉴权和定向信令。
- 完成屏幕捕获、逐观看者 PeerConnection、观看播放和手动质量档。
- 完成连接恢复和 stats 面板;随后以 STUN-only snapshot 替换旧全房 TURN credential/refresh wire。
- 完成 automatic peer/SFU controller 与 exact-room canary 边界。
- 用有界网络矩阵验收 direct/peer UDP、SFU/UDP 和明确失败;selected-edge 兼容 transport 只有出现真实缺口后才另立完整 PR。
任何一步只实现当前消费者需要的接口。不得为未来账号、第三种媒体拓扑或原生 sender 预建抽象层。
2026-08-19 Peer-Assisted 实验边界
状态:Proposed / Experiment Only。该扩展不改变上面的已部署 PoC,也不表示正式采用。
部署设置 PEER_ASSISTED_MEDIA=true 时必须同时提供非空的
PEER_ASSISTED_ROOM_IDS 精确限制实验房间。该值只接受逗号分隔、符合现有
room code 规则且不重复的 ID;缺失、空值或非法配置都启动失败,不存在全房间
fail-open。未命中房间不得进入
HybridMediaRouter、房间质量同步或 LiveKit 兜底,route 字段、定向信令、停止、
重连和删除保持普通 P2P 行为;ordinary STUN-only ICE snapshot 是进程级契约,
不为未命中房间保留旧 TURN wire。该边界不增加 UI、用户选择或百分比框架。
产品硬目标是任一非服务器节点任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署 SFU 中央兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。默认媒体 transport 只使用 UDP;SFU 作为 virtual parent 正常只送一至两个 roots,roots 之后仍优先沿受限 peer descendants 扩散;多个无法挂到健康 root 的极端 viewers 只可使用另设上限的中央 edge。当前仓库已把普通 ICE 收敛为 STUN-only 并删除全房 TURN wire;default-off、failure-only 跨模式控制器仍需 exact-room 公网验收,生产旧 release 仍使用 coturn relay。未来可选 TURN 只能按需给异常 assignment/connection generation,由单独完整 PR 实现。ADR-0004/0005 的运行时验收尚未完成。
实验只实现一个由服务端确定的广度优先拓扑:host 最多两个 child,每名 viewer 最多一个 child,因此形成两条按加入顺序平衡的 sticky 链。父节点只按深度和 serverJoinSequence 选择第一个空 slot。新 viewer 加入时不搬动已有节点;parent 离开时只把失去 parent 的 subtree root 重新分配到第一个空 slot,其 descendants 保持连接。该规则不采集或组合 RTT、带宽、CPU、地理位置与质量评分,也不主动重平衡。没有 parent 时明确等待或拒绝,不能创建分享端第三条边。
该 one-child 浏览器实验在深度四时只能覆盖八名观看者,不能证明 20 人能力。目标 20 人需要资源门禁通过的 relay downstreamCapacity=2(两棵深度四二叉树理论可容纳 30 人,但这只是可达性)或受独立中央 egress cap 约束的 SFU 例外;必须实测逐跳重编码、上行、延迟和故障半径。未通过时保持当前默认八人,不增加深度或 Host fanout 来凑容量。
所有边都继续使用标准 WebRTC。每名 viewer 播放收到的 remote MediaStream,并把其中的 remote tracks 加入给唯一 child 的新 RTCPeerConnection。该路径会逐跳解码和重编码;实验必须记录每层 encode time、CPU/GPU、qualityLimitationReason、帧率、分辨率和延迟,不能称为 zero-copy 或 shared encode。
Peer-assisted 信令只接受一个严格对象:resolution 为 720p | 1080p | 1440p,maxFramerate 为 15 至 60 的整数,maxBitrate 为 2,000,000 至 12,000,000 的整数,degradationPreference 为 maintain-resolution | balanced | maintain-framerate,缺字段、多余字段和越界值都拒绝。服务端用受房间容量限制的内存 Map 保存完整设置,默认 1080p60、8 Mbps、清晰优先,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。只有 peer-assisted host 可更新,鉴权快照和在线广播携带同一完整设置;普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送同步消息都会被拒绝。启用 SFU 时 publisher 也使用这一设置,不另建一套质量协议。
Viewer 在处理 peer-assisted 鉴权快照时先更新 ViewerRelay 的 desired settings,再应用拓扑分配;同一个串行队列协调 remote stream、当前 child 更新和以后重建的 child,因此它们读取同一最新目标。每个 HostPeer 也把初始 sender 配置、replaceStream() 和 updateProfile() 放进同一 sender-mutation queue;desired settings 在入队前同步更新,已排队操作执行时读取最新值,使快速连续修改保持 last-wins,而不增加版本号或确认协议。这里同步的是浏览器 sender 的目标上限,不是实际分辨率、帧率、码率或 shared encoding 的性能保证。
spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面 Chrome/Edge relay、按测试顺序位于链尾的当前 Android Chrome 与 iOS Safari leaf、最多八名 viewer;捕获存在时沿同一标准 WebRTC 路径带上音频。Encoded Transform/DataChannel/WebCodecs 媒体、SVC/simulcast、多树、转码、codec ladder、综合评分、移动端后台 relay 和跨模式 SFU 迁移不在本次 spike 内实现。该范围限制不能解释为拒绝产品所需的自动兜底;验收与失败即放弃条件以 ADR-0004 为准,自动路由由后续独立 ADR 定义。
浏览器不保证多个 RTCPeerConnection 共享一次编码。实验允许 host 对一至两个 seed 各自编码。后续 native sender 必须让每个 active representation 只编码一次并跨 direct、peer 与 SFU transport 复用;每条连接仍拥有独立 packetizer、SSRC、pacing、加密、反馈和上传。健康时只有共享 HIGH,弱路径期间最多增加一份共享 LOW。该计划不依赖本次浏览器 relay 实验成败,也不在本 spike 中提前抽象或实现。
详细证据见 docs/research/peer-assisted-media.md,提案见 docs/adr/0004-peer-assisted-media-experiment.md。
官方与参考资料
访问日期为 2026-08-18 至 2026-08-19:
- Vite Getting Started
- React: Build a React app from Scratch
- Node.js release status
- ws official README
- Screen Capture specification
- WebRTC specification
- Media Capture Content Hints
- RTCRtpSender.replaceTrack
- WebRTC Stats
- TURN, RFC 8656
- WebRTC SVC
- Media Capabilities
- WebCodecs
- WebRTC official samples
- coturn TURN server documentation
- coturn example configuration
- TURN REST API draft implemented by coturn
- Cookies: HTTP State Management Mechanism draft (RFC6265bis)
- Node.js
crypto.createHmac()andcrypto.timingSafeEqual() - W3C WAI: Labeling Controls
- W3C WCAG 2.2: Target Size (Enhanced)
MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。Tailchat Meeting(Apache-2.0)用于捕获生命周期对照,但它采用 mediasoup/SFU,不作为本项目拓扑或依赖底座。项目许可证确定前不复制 GPL/AGPL 代码。