我们把 H.264 流媒体换成了 JPEG 截图(效果更好)
Source: Hacker News
Part 2 of our video streaming saga.
Read Part 1: How we replaced WebRTC with WebSockets →
让我告诉你我们花了三个月时间构建一个华丽的、硬件加速的、基于 WebCodecs、60 fps H.264 流媒体管道,使用 WebSockets…
…但当 Wi‑Fi 有点不稳时,我们把它换成了 grim | curl。
我真希望这只是玩笑。
我们正在构建 Helix,一个 AI 平台,自治的编码代理在云沙箱中工作。用户需要观看他们的 AI 助手的工作过程。可以把它想象成“屏幕共享,但共享的对象是一台写代码的机器人”。
上周我们解释了如何用自定义的 WebSocket 流媒体管道取代 WebRTC。本周:为什么这仍然不够。
破坏一切的约束
它必须在企业网络上工作。
你知道企业网络喜欢什么吗?
- HTTP / HTTPS – 端口 443。仅此而已。
你知道企业网络讨厌什么吗?
- UDP – 被阻止、降级、丢弃。“安全风险。”
- WebRTC – 需要 TURN 服务器,而 TURN 服务器需要 UDP,而 UDP 被阻止。
- 自定义端口 – 防火墙说 不。
- STUN/ICE – NAT 穿透?在 我的 企业网络里?绝对不行。
- 字面上任何有趣的东西 – 被政策拒绝。
我们先尝试了 WebRTC。它在开发环境、云端以及甚至在企业客户那里都运行良好……直到:
“视频无法连接。”
检查网络 — 出站 UDP 被阻止。TURN 服务器不可达。ICE 协商失败。
我们本可以去解决这个问题(部署 TURN 服务器、配置代理、与 IT 合作),或者我们可以接受现实:所有流量必须通过端口 443 的 HTTPS。
我们的纯 WebSocket 视频管道
- H.264 编码 通过 GStreamer + VA‑API(硬件加速)
- 二进制帧 通过 WebSocket(仅 L7,适用于任何代理)
- WebCodecs API 用于浏览器中的硬件解码
- 60 fps,40 Mbps,延迟低于 100 ms
我们感到自豪。我们编写了 Rust、TypeScript、自己的二进制协议,并以微秒为单位测量了一切。
咖啡店噩梦
“视频卡住了。”
“你的 Wi‑Fi 信号不好。”
“不,视频绝对是卡住了。而且我的键盘也不工作了。”
检查视频 —— 它显示的是 AI 30 秒前 的操作,且延迟还在不断增加。
原来,40 Mbps 的流媒体根本容不下 200 ms 以上的延迟。谁能想到。
当网络拥塞时:
- 帧在 TCP/WebSocket 层被缓存。
- 它们 按顺序 到达(感谢 TCP!),但延迟越来越大。
- 视频越来越落后于实时。
- 你看到的是 AI 45 秒前敲的代码。
- 当你发现 bug 时,AI 已经把它提交到主分支了。
- 一切永远糟糕下去。
“把比特率调低一点,”你说。
好主意。现在是 10 Mbps 的块状垃圾,仍然 落后 30 秒。
“如果我们只发送关键帧会怎样?”
我们的大脑闪光时刻:H.264 关键帧(IDR 帧)是自包含的。丢弃所有 P 帧,只发送关键帧 → 大约 1 fps 的干净视频,完美适用于低带宽回退。
我们添加了 keyframes_only 标志,修改了解码器以检查 FrameType::Idr,将 GOP = 60(在 60 fps 时每秒一个关键帧),并进行测试。
结果:恰好只有 ONE 帧。
[WebSocket] Keyframe received (frame 121), sending
[WebSocket] ...
[WebSocket] ...
[WebSocket] It's been 14 seconds why is nothing else coming
[WebSocket] Failed to send audio frame: Closed
检查 Wolf 日志 — 编码器仍在运行
检查 GStreamer 管道 — 正在生成帧
检查 Moonlight 协议层 — 没有任何帧通过
我们使用的是 Wolf,一个优秀的开源游戏流媒体服务器。我们的 WebSocket 层位于 Moonlight 协议之上(从 NVIDIA GameStream 逆向工程而来)。在这套栈的某个地方,某些东西 决定如果你不消费 P 帧,就说明你还没有准备好接收更多帧。就这么简单。
我们花了几个小时四处查找,但在没有深入 Moonlight 内部实现的情况下无法解决。该协议要么需要 所有 帧,要么什么也不接受。
“如果我们实现适当的拥塞控制会怎样?”
查看 TCP 拥塞控制文献
关闭标签页
“如果我们根本没有糟糕的 Wi‑Fi 会怎样?”
盯着正在限制一切的企业防火墙
截图的灵感
某个深夜,在调试一个卡住的流时,我打开了我们的截图接口:
GET /api/v1/external-agents/abc123/screenshot?format=jpeg&quality=70
图像瞬间加载——一张 150 KB 的 JPEG,远程桌面画面清晰如新,没有伪影,不需要等待关键帧,也没有解码器状态。只有像素。
我刷新了一下。又是一张瞬间出现的图像。我像个疯子一样疯狂点 F5。5 fps 的完美截图。
我查看了我那漂亮的 WebCodecs 管道。我查看了 JPEG。又重新审视了一遍管道。
不。我们不该这么做。
我们是专业人士。我们实现的是合适的视频编解码器。我们不会像 2009 年那样对单帧进行 HTTP 请求轰炸。
// Poll screenshots as fast as possible (capped at 10 FPS max)
const fetchScreenshot = async () => {
const response = await fetch(
`/api/v1/external-agents/${sessionId}/screenshot`
)
const blob = await response.blob()
screenshotImg.src = URL.createObjectURL(blob)
setTimeout(fetchScreenshot, 100) // yolo
}
我们已经这么做了。我们在发送 JPEG。
而且你知道吗?它运行得非常完美。
快速比较
| Property | H.264 Stream | JPEG Spam |
|---|---|---|
| Bandwidth (constant) | ~40 Mbps | 100‑500 Kbps |
所以,虽然我们花哨的 H.264 流水线需要一个巨大的、对延迟敏感的管道,但朴素的“只发送截图”方法为我们提供了低带宽、低延迟、企业友好的解决方案。 🎉
视频流的权衡
| 方面 | 有状态(损坏即死亡) | 无状态(每帧独立) |
|---|---|---|
| 延迟敏感性 | 非常高 | 不在乎 |
| 丢包恢复 | 等待关键帧(秒) | 下一帧(≈ 100 ms) |
| 实现复杂度 | 3 个月的 Rust(fetch() 循环) | – |
JPEG 截图是 自包含 的
- JPEG 要么完整到达,要么根本不出现。
- 没有 “部分解码”、没有 “等待下一个关键帧”、没有 “解码器状态损坏”。
当网络状况不佳时,你只会得到 更少的 JPEG —— 那些成功到达的都是完好的。
尺寸对比
- 1080p 桌面 70 % 质量的 JPEG:100‑150 KB
- 单个 H.264 关键帧:200‑500 KB
因此我们每帧发送 更少的数据,并获得 更好的可靠性。
Source: …
自适应切换策略
我们并没有放弃 H.264 流程;只是添加了一个后备方案。
- 良好连接(
RTT < 150 ms)→ 使用 H.264 视频。 - 差劲连接(
RTT ≥ 150 ms)→ 切换为 JPEG 截图。
关键洞察: 我们仍然需要 WebSocket 来传输输入。
键盘和鼠标事件非常小(≈ 10 字节每个),即使在网络不佳的情况下也能毫无问题地传输。我们只需要停止发送庞大的视频帧。
控制消息
{"set_video_enabled": false}
服务器收到后停止发送视频帧,客户端开始轮询截图,同时输入仍然保持流畅。
Rust 代码片段
if !video_enabled.load(Ordering::Relaxed) {
continue; // skip frame, it's screenshot time
}
振荡 bug
当视频帧停止后,WebSocket 几乎为空(只有微小的输入事件和偶尔的 ping)。
延迟急剧下降,于是自适应逻辑误以为连接已恢复,切换回视频。
结果:
- 视频恢复 → 40 Mbps 的洪流 → 延迟飙升 → 切换到截图。
- 截图 → 延迟下降 → 再次切换回视频。
这个循环大约每 2 秒 重复一次。
修复方案
将模式锁定为截图,直到用户显式点击 Retry(重试)。
setAdaptiveLockedToScreenshots(true); // no more oscillation
我们显示一个琥珀色图标并附上提示:
“已暂停视频以节省带宽。点击重试。”
现在由用户自行控制,无限循环问题已消除。
Ubuntu 在 grim 中不提供 JPEG 支持(因为它本来就不提供)
哦,你以为我们已经结束了吗?真可爱。
grim 是一个 Wayland 截图工具——完全符合我们的需求。它支持 JPEG 输出,以生成更小的文件。
问题
Ubuntu 编译的 grim 没有 libjpeg 支持:
$ grim -t jpeg screenshot.jpg
error: jpeg support disabled
不可思议。
解决方案
在 Dockerfile 中添加一个构建阶段,从源码编译 grim 并启用 JPEG 支持。
# Dockerfile
FROM ubuntu:25.04 AS grim-build
# Install build dependencies
RUN apt-get update && \
apt-get install -y \
meson \
ninja-build \
libjpeg-turbo8-dev \
git \
build-essential \
pkg-config
# Clone and build grim with JPEG support
RUN git clone https://git.sr.ht/~emersion/grim /opt/grim && \
cd /opt/grim && \
meson setup build -Djpeg=enabled && \
ninja -C build
现在我们可以从源码构建截图工具,并在 2025 年发送 JPEG 图像。这种方法运行得非常完美。
最终架构
┌─────────────────────────────────────────────────────────────┐
│ User's Browser │
├─────────────────────────────────────────────────────────────┤
│ WebSocket (always connected) │
│ ├─ Video frames (H.264) ─────── when RTT < 150 ms │
│ └─ GET /screenshot?quality=70 │
└─────────────────────────────────────────────────────────────┘
连接质量
| 条件 | 视频模式 | 帧率 | 备注 |
|---|---|---|---|
| 良好连接 (RTT < 150 ms) | H.264 | 60 fps | 低延迟 |
| 较差连接 (RTT ≥ 150 ms) | JPEG 截图 | 5‑10 fps | 节省带宽 |
- 当切换到截图时,我们将获取速率上限设为 10 FPS,以避免过载。
- 如果获取一帧所需时间超过 100 ms,则会跳过该帧。
如果觉得有用,请给我们加星!