Skip to content

从视频帧一致到屏幕观感一致:一次 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 链路?

二、思考:把问题拆成三个证据层

关键决策是不直接根据屏幕取色下结论,而是把问题拆成三个层级,分别取证:

三个证据层级

  1. Track / VideoFrame 元数据层trackColorSpaceInspector 读取 formatmatrixprimariestransferfullRange,确认每条流在不同阶段的色彩空间 metadata。

  2. 浏览器可绘制视频帧层 新增页面内 JS 采样:对 <video> 执行 drawImage(video),再用 getImageData() 采同一组 2×4 色块中心点,比较 localRawpushPreviewremote 的 RGB。

  3. 屏幕最终显示层 macOS 数码测色计读到的是经过浏览器 compositor、CSS 缩放、透明层、系统 ICC profile、显示器色彩管理后的最终屏幕像素——它和视频帧本身不是同一层证据

关键结论:帧数据完全一致

页面内 JS 采样显示,localRawpushPreview 的 8 个采样点 RGB 完全一致:

text
localRaw   r2c1:  66, 1, 16      pushPreview r2c1:  66, 1, 16
localRaw   r3c2:  190, 255, 240  pushPreview r3c2:  190, 255, 240

这说明下面这条链路在浏览器可绘制帧层面是 RGB 保真的:

推流链路 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> 负责展示:

统一为 canvas 显示路径

text
MediaStream → hidden <video> → visible <canvas>

这样 localRawpushPreview 都通过 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 后,localRawpushPreview 两个 PiP 都经过同一个 Canvas 2D 显示链路,最大程度消除由不同 video layer 合成路径带来的屏幕观感差异。

边界同样清晰:

方案边界

  • 不修复 remote 模型回显的色差——那发生在 streamx/model 输出链路;
  • 不改变 RTC 推流内容;
  • 可能让 PiP 在后台标签页降低刷新频率,但不影响真正的 RTC 推流帧率;
  • 适合小 PiP 预览,不适合未经评估就扩展到主画面大尺寸渲染

六、经验总结

这次排查的关键不是某一个 API,而是把"帧数据"和"屏幕显示"拆开看:

  1. 屏幕取色 ≠ 帧数据。macOS 测色计读到的是 compositor + CSS + ICC + 显示器色彩管理之后的结果,不能直接用来判断推流链路是否出错。
  2. 先取证,再定位修复点。JS 采样证明推流帧无色差,屏幕取色证明显示层有差异——方案自然落在"统一显示层",而非继续改推流链路。
  3. 改错层 = 白改。帧数据已一致时去改 BT.601/BT.709 转换或模型链路,不但无效,还会引入新风险。
  4. 诊断逻辑要与显示逻辑分离,让每个模块的定位和边界都可解释、可回归。

配合上一篇的镜像推流修复,可以形成一套完整的经验:视频色差要分层定位——元数据层看 VideoFrame,帧数据层看 JS 采样,观感层看屏幕取色,每一层用对应证据,再决定在哪一层修。