우리는 H.264 스트리밍을 JPEG 스크린샷으로 교체했으며(그 결과 더 나아졌습니다)

발행: (2025년 12월 24일 오전 03:00 GMT+9)
15 min read

Source: Hacker News

Part 2 of our video streaming saga.
Read Part 1: How we replaced WebRTC with WebSockets →

우리가 3개월 동안 화려하고 하드웨어 가속을 이용한 WebCodecs 기반, 60 fps H.264 스트리밍 파이프라인을 WebSockets 위에 구축한 이야기를 들려드리겠습니다…

…그리고 Wi‑Fi가 조금 불안정해지자 grim | curl 로 교체했던 이야기까지.

농담이라면 좋겠지만, 사실입니다.

우리는 Helix 를 만들고 있습니다. 이 AI 플랫폼에서는 자율 코딩 에이전트가 클라우드 샌드박스에서 작업합니다. 사용자는 AI 어시스턴트가 작업하는 모습을 볼 필요가 있습니다. “스크린 공유”와 비슷하지만, 공유되는 대상은 코드를 작성하는 로봇이라는 점이 다릅니다.

지난 주에 우리는 WebRTC를 맞춤형 WebSocket 스트리밍 파이프라인으로 교체한 과정을 설명했습니다. 이번 주에는 왜 그것만으로는 충분하지 않았는지 이야기합니다.

모든 것을 망친 제약

엔터프라이즈 네트워크에서 작동해야 합니다.

엔터프라이즈 네트워크가 좋아하는 것이 뭔지 아세요?

  • HTTP / HTTPS – 포트 443. 그게 전부입니다.

엔터프라이즈 네트워크가 싫어하는 것이 뭔지 아세요?

  • UDP – 차단, 우선순위 낮춤, 삭제. “보안 위험.”
  • WebRTC – TURN 서버가 필요하고, TURN 서버는 UDP를 필요로 하는데, UDP가 차단됩니다.
  • 맞춤 포트 – 방화벽이 아니오라고 답합니다.
  • STUN/ICE – NAT 트래버설? 내 회사 네트워크에서는 절대 안 됩니다.
  • 재미있는 모든 것 – 정책에 의해 거부됩니다.

우리는 먼저 WebRTC를 시도했습니다. 개발 환경에서는, 클라우드에서도, 심지어 엔터프라이즈 고객에서도 잘 작동했지만…

“비디오가 연결되지 않아요.”
네트워크 확인 — 아웃바운드 UDP 차단. TURN 서버에 접근 불가. ICE 협상 실패.

우리는 이것과 싸울 수도 있었겠죠 (TURN 서버를 구축하고, 프록시를 설정하고, IT와 협업) 또는 현실을 받아들일 수도 있었습니다: 모든 것은 HTTPS 포트 443을 통해서만 이루어져야 합니다.

순수 WebSocket 비디오 파이프라인

  • H.264 인코딩 via GStreamer + VA‑API (하드웨어 가속)
  • 바이너리 프레임을 WebSocket으로 전송 (L7만 사용, 모든 프록시를 통해 작동)
  • 브라우저에서 하드웨어 디코딩을 위한 WebCodecs API
  • 60 fps, 40 Mbps 및 100 ms 미만 지연

우리는 자부심을 느꼈다. 우리는 Rust, TypeScript, 자체 바이너리 프로토콜을 작성했고, 모든 것을 마이크로초 단위로 측정했다.

커피숍 악몽

“동영상이 멈췄어요.”
“와이파이가 안 좋네요.”
“아니에요, 동영상이 확실히 멈췄어요. 그리고 지금 키보드도 안 돼요.”

동영상을 확인해 보니 – AI가 30 초 전에 했던 일을 보여주고, 지연이 점점 커지고 있다.

알고 보니, 40 Mbps 스트림은 200 ms 이상의 지연을 견디지 못한다. 누가 알았겠는가.

네트워크가 혼잡해지면:

  1. 프레임이 TCP/WebSocket 계층에서 버퍼에 쌓인다.
  2. 프레임은 순서대로 도착한다 (TCP 덕분!) 하지만 점점 지연된다.
  3. 영상은 실시간보다 더 뒤처진다.
  4. 45 초 전 AI가 코드를 입력하는 모습을 보고 있다.
  5. 버그를 발견할 때쯤이면 AI는 이미 그 코드를 메인에 커밋해버렸다.
  6. 모든 것이 영원히 끔찍해진다.

“그냥 비트레이트를 낮춰,” 라고 말한다.
좋은 생각이다. 이제 10 Mbps의 블록 같은 쓰레기가 여전히 30 초 뒤처진다.

“키프레임만 전송한다면 어떨까요?”

우리의 대박 아이디어: H.264 키프레임(IDR 프레임)은 자체적으로 완전합니다. P‑프레임을 모두 버리고 키프레임만 전송 → ~1 fps의 깨끗한 영상, 저대역폭 대체용으로 완벽합니다.

keyframes_only 플래그를 추가하고, 디코더가 FrameType::Idr를 확인하도록 수정했으며, GOP = 60(60 fps에서 1초당 한 키프레임)으로 설정하고 테스트했습니다.

결과: 정확히 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 프로토콜(엔비디아 GameStream을 역공학한 것) 위에 놓여 있습니다. 그 스택 어딘가에서 무언가가 P‑프레임을 소비하지 않으면 더 이상 프레임을 받을 준비가 되지 않았다고 판단합니다. 끝.

몇 시간 동안 파헤쳐 보았지만 Moonlight 내부를 깊이 파고들지 못하면 해결할 수 없었습니다. 프로토콜은 모든 프레임을 원하거나 전혀 원하지 않았습니다.

“우리가 적절한 혼잡 제어를 구현한다면 어떨까?”

TCP 혼잡‑control 문헌을 살펴본다
탭을 닫는다

“우리가 그냥… 안 좋은 와이‑파이가 없다고 하면 어떨까?”

모든 것을 제한하고 있는 엔터프라이즈 방화벽을 바라본다

스크린샷 깨달음

한밤중에, 멈춰버린 스트림을 디버깅하던 중 우리는 스크린샷 엔드포인트를 열어봤습니다:

GET /api/v1/external-agents/abc123/screenshot?format=jpeg&quality=70

이미지는 즉시 로드되었습니다 – 원격 데스크톱의 깨끗한 150 KB JPEG, 결점 없이, 키프레임을 기다릴 필요도 없고, 디코더 상태도 없습니다. 단순히 픽셀일 뿐이죠.

새로 고침했습니다. 또다시 즉시 이미지가 나타났습니다. F5를 마구 눌렀습니다. 완벽한 스크린샷이 5 fps로 연속해서 나오고 있었습니다.

아름다운 WebCodecs 파이프라인을 바라보았습니다. JPEG들을 살펴보았습니다. 파이프라인을 다시 살펴보았습니다.

아니요. 우리는 이렇게 하지 않습니다.

우리는 전문가입니다. 올바른 비디오 코덱을 구현합니다. 2009년식처럼 개별 프레임을 위해 HTTP 요청을 스팸처럼 보내지 않습니다.

// 가능한 한 빨리 스크린샷을 폴링 (최대 10 FPS 제한)
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를 전송하고 있었습니다.

그리고 알다시피? 완벽하게 동작합니다.

빠른 비교

속성H.264 스트림JPEG 스팸
대역폭 (고정)~40 Mbps100‑500 Kbps

그래서, 우리 고급 H.264 파이프라인이 대용량이고 지연에 민감한 파이프를 요구했던 반면, 겸손한 “스크린샷만 보내기” 접근 방식은 낮은 대역폭, 낮은 지연, 기업 친화적인 솔루션을 제공했습니다. 🎉

Video Streaming Trade‑offs

항목Stateful (손상 = 종료)Stateless (각 프레임 독립)
지연 민감도매우 높음관계 없음
패킷 손실 복구키프레임 대기 (초)다음 프레임 (≈ 100 ms)
구현 복잡도Rust 3개월 (fetch() 루프)

JPEG 스크린샷은 자체 포함

  • JPEG는 완전하게 도착하거나 전혀 도착하지 않습니다.
  • “부분 디코드”, “다음 키프레임 대기”, “디코더 상태 손상” 같은 상황이 없습니다.

네트워크가 불량할 때는 단순히 JPEG가 적게 도착합니다 – 도착한 것들은 완벽합니다.

크기 비교

  • 1080p 데스크톱의 70 % 품질 JPEG: 100‑150 KB
  • 단일 H.264 키프레임: 200‑500 KB

따라서 프레임당 데이터 양이 적게 전송되고 신뢰성이 향상됩니다.

Adaptive switching strategy

우리는 H.264 파이프라인을 버린 것이 아니라, 폴백을 추가했을 뿐입니다.

  1. Good connection (RTT < 150 ms) → H.264 비디오 사용.
  2. Bad connection (RTT ≥ 150 ms) → JPEG 스크린샷으로 전환.

Key insight: 입력을 위해서는 여전히 WebSocket이 필요합니다.

Keyboard and mouse events are tiny (≈ 10 bytes each) and travel flawlessly even on a poor connection. We only needed to stop sending massive video frames.

Control message

{"set_video_enabled": false}

서버는 이를 수신하고 비디오 프레임 전송을 중단하며, 클라이언트는 입력은 계속 흐르는 동안 스크린샷을 폴링하기 시작합니다.

Rust snippet

if !video_enabled.load(Ordering::Relaxed) {
    continue; // skip frame, it's screenshot time
}

The oscillation bug

비디오 프레임이 중단되면 WebSocket은 거의 비어 있게 됩니다(작은 입력 이벤트와 가끔 있는 ping만 남음).
Latency drops dramatically, so the adaptive logic thinks the connection has recovered and switches back to video.

Result:

  1. Video resumes → 40 Mbps flood → latency spikes → switch to screenshots.
  2. Screenshots → latency drops → switch back to video.

이 루프는 every ~2 seconds마다 반복됩니다.

Fix

Lock the mode to screenshots until the user explicitly clicks Retry.

setAdaptiveLockedToScreenshots(true); // no more oscillation

우리는 다음 메시지와 함께 호박색 아이콘을 표시합니다:

“Video paused to save bandwidth. Click to retry.”“대역폭 절약을 위해 비디오가 일시 중지되었습니다. 클릭하여 재시도하세요.”

Now the user is in control and the infinite loop is gone.

Ubuntu는 grim에 JPEG 지원을 제공하지 않습니다 (당연히 그렇죠)

오, 우리가 끝났다고 생각했나요? 귀엽네요.

grim은 Wayland 스크린샷 도구로, 우리의 요구에 딱 맞습니다. JPEG 출력을 지원해 파일 크기를 줄일 수 있습니다.

문제점

Ubuntu는 grimlibjpeg 지원 없이 컴파일합니다:

$ grim -t jpeg screenshot.jpg
error: jpeg support disabled

놀랍군요.

해결책

Dockerfile에 빌드 단계를 추가하여 JPEG 지원이 활성화된 상태로 소스에서 grim을 컴파일합니다.

# Dockerfile
FROM ubuntu:25.04 AS grim-build

# Build dependencies 설치
RUN apt-get update && \
    apt-get install -y \
        meson \
        ninja-build \
        libjpeg-turbo8-dev \
        git \
        build-essential \
        pkg-config

# JPEG 지원을 포함해 grim 클론 및 빌드
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.26460 fps낮은 지연
나쁜 연결 (RTT ≥ 150 ms)JPEG 스크린샷5‑10 fps대역폭 친화적
  • 스크린샷으로 전환할 때, 과부하를 방지하기 위해 가져오기 속도를 10 FPS로 제한합니다.
  • 프레임을 가져오는 데 100 ms 이상 걸리면 해당 프레임을 건너뜁니다.

유용하다고 생각하시면 별표를 눌러 주세요!

Back to Blog

관련 글

더 보기 »

X For You 피드 알고리즘

https://x.com/XEng/status/2013471689087086804 댓글 URL: https://news.ycombinator.com/item?id=46688173 Points: 60 Comments: 29...