Appearance
从视频帧一致到屏幕观感一致:一次 RTC 推流预览色差问题的定位与修复
更新: 8/8/2026 字数: 0 字 时长: 0 分钟
一、问题背景:屏幕取色差了一点点
在流光实时视频生成链路中,页面同时展示三路画面:本地原始画面、RTC 回拉的推流画面,以及模型处理后的远端回显画面。
排查色差时先发现一个现象:用 macOS 数码测色计观察,本地原始画面与推流画面的屏幕 RGB 存在轻微差异。例如同一个深红色块:
| 画面 | 屏幕 RGB |
|---|---|
| 本地原始画面 | 约 66, 1, 16 |
| 推流画面 | 约 76, 0, 14 |

这个现象容易让人误判为"前端推流过程发生了色彩空间错误"——尤其是此前镜像链路里确实出现过 VideoFrame(canvas) 导致的 metadata 变化:原始摄像头帧是 NV12 / bt709 / limited range,镜像后中间帧可能变成 BGRA / rgb / fullRange。
于是核心疑问是:色差到底来自前端推流链路、浏览器显示层,还是后端 streamx/model 链路?
二、思考:把问题拆成三个证据层
关键决策是不直接根据屏幕取色下结论,而是把问题拆成三个层级,分别取证:

Track / VideoFrame 元数据层 用
trackColorSpaceInspector读取format、matrix、primaries、transfer、fullRange,确认每条流在不同阶段的色彩空间 metadata。浏览器可绘制视频帧层 新增页面内 JS 采样:对
<video>执行drawImage(video),再用getImageData()采同一组 2×4 色块中心点,比较localRaw、pushPreview、remote的 RGB。屏幕最终显示层 macOS 数码测色计读到的是经过浏览器 compositor、CSS 缩放、透明层、系统 ICC profile、显示器色彩管理后的最终屏幕像素——它和视频帧本身不是同一层证据。
关键结论:帧数据完全一致
页面内 JS 采样显示,localRaw 与 pushPreview 的 8 个采样点 RGB 完全一致:
text
localRaw r2c1: 66, 1, 16 pushPreview r2c1: 66, 1, 16
localRaw r3c2: 190, 255, 240 pushPreview r3c2: 190, 255, 240这说明下面这条链路在浏览器可绘制帧层面是 RGB 保真的:

text
本地原始 video frame → 前端镜像/推流 → RTC 上行
→ genx_preview 回拉 → 推流画面 video frame即前端推流链路本身没有造成数据层面的色差。 但屏幕取色仍有差异——差异更可能发生在 <video> 直接播放时的显示合成路径:本地 MediaStream video layer 与 RTC remote decoded video layer 可能被浏览器/系统走了不同的 compositor 或色彩管理路径。

三、解决思路:统一显示层的渲染路径
既然帧数据已经一致,继续改 RTC 推流、BT.601/BT.709 转换或模型链路都不是正确的修复点。真正要修的是:让本地原始画面和推流回显画面在页面显示层走同一条渲染路径。
原来的显示方式:
text
localRaw: 本地 MediaStream → <video> 直接显示
pushPreview: RTC remote MediaStream → <video> 直接显示二者虽然都是 <video>,但底层来源不同,浏览器可能走不同的硬件 overlay、YUV→RGB 转换、compositor 合成路径,最终屏幕像素就可能轻微不一致。
修复方案是把两者统一为——用隐藏 <video> 负责解码,用可见 <canvas> 负责展示:

text
MediaStream → hidden <video> → visible <canvas>这样 localRaw 和 pushPreview 都通过 Canvas 2D 的 drawImage 绘制到屏幕,显示路径一致,避免两个 video layer 在最终合成阶段出现差异。
四、方案执行:三个职责清晰的模块
实现上拆成三个独立模块,避免把诊断逻辑继续堆在主组件里:

1. CanvasVideoView.tsx — 统一 PiP 显示路径
MediaStream → hidden video → visible canvas,负责:
- 把传入的
MediaStream绑定到隐藏<video>; - 用
requestVideoFrameCallback驱动绘制,不支持时退回requestAnimationFrame; - 把 video 当前帧绘制到可见
<canvas>; - 手动实现
contain/cover,与原 video 的object-fit视觉一致; - 按
devicePixelRatio设置 canvas backing store,避免高分屏下 PiP 变糊。
它只影响 PiP 显示,不参与 RTC 推流,不会把 canvas 再 captureStream() 给 RTC,因此不改变推流数据链路。
2. use-video-rgb-sampler.ts — 保留页面内诊断采样
对 localRaw / pushPreview / remote 三路做同一组采样:
ts
ctx.drawImage(video, 0, 0)
ctx.getImageData(x, y, 1, 1)定位明确:它采的是"浏览器可绘制的视频帧",不是 VideoFrame metadata,也不是 macOS 屏幕最终取色,用于判断帧数据是否一致(输出 🎨 页面视频RGB采样首帧 / 🎨 ...10秒后)。
3. VideoDisplay.tsx — 只保留接线
tsx
<CanvasVideoView stream={localStream} videoRef={localVideoPipRef}
fit={template === 'vertical' ? 'contain' : 'cover'} />
<CanvasVideoView stream={pushPreviewStream} videoRef={pushPreviewVideoRef}
fit={template === 'vertical' ? 'contain' : 'cover'} />ts
useVideoRgbSampler({ enabled: taskStarted, localVideoRef: localVideoPipRef,
pushPreviewVideoRef, remoteVideoRef, ... })职责就此拆清:CanvasVideoView 管显示一致性,use-video-rgb-sampler 管诊断采样,VideoDisplay 管页面组合。
五、结果与边界
核心结论:
text
帧数据一致,但 video 直接播放有色差
⇒ 不是继续修 RTC 推流
⇒ 而是统一页面显示路径使用 CanvasVideoView 后,localRaw 与 pushPreview 两个 PiP 都经过同一个 Canvas 2D 显示链路,最大程度消除由不同 video layer 合成路径带来的屏幕观感差异。
边界同样清晰:

- 不修复
remote模型回显的色差——那发生在 streamx/model 输出链路; - 不改变 RTC 推流内容;
- 可能让 PiP 在后台标签页降低刷新频率,但不影响真正的 RTC 推流帧率;
- 适合小 PiP 预览,不适合未经评估就扩展到主画面大尺寸渲染。
六、经验总结
这次排查的关键不是某一个 API,而是把"帧数据"和"屏幕显示"拆开看:
- 屏幕取色 ≠ 帧数据。macOS 测色计读到的是 compositor + CSS + ICC + 显示器色彩管理之后的结果,不能直接用来判断推流链路是否出错。
- 先取证,再定位修复点。JS 采样证明推流帧无色差,屏幕取色证明显示层有差异——方案自然落在"统一显示层",而非继续改推流链路。
- 改错层 = 白改。帧数据已一致时去改 BT.601/BT.709 转换或模型链路,不但无效,还会引入新风险。
- 诊断逻辑要与显示逻辑分离,让每个模块的定位和边界都可解释、可回归。
配合上一篇的镜像推流修复,可以形成一套完整的经验:视频色差要分层定位——元数据层看 VideoFrame,帧数据层看 JS 采样,观感层看屏幕取色,每一层用对应证据,再决定在哪一层修。