Sangcho.log
dev

HyperFrames 탐구 4 — 커스텀 렌더러 없이 HTML을 영상으로 바꾸는 법

12 min read|

이번 편은 시리즈에서 가장 기술적인 질문을 다룹니다. HTML을 어떻게 영상 파일로 바꾸는가? 처음엔 이렇게 생각하기 쉽습니다 — "출력이 영상이니 HTML 파싱과 렌더링을 직접 구현해야 하지 않을까?" 그 직관이 왜 틀렸는지, 그리고 진짜 어려운 문제가 무엇이었는지가 이 글의 줄거리입니다.

한 줄 스포일러: HyperFrames는 커스텀 렌더러를 만들지 않았습니다. 렌더링은 진짜 크롬에게 맡기고, 대신 "시간을 프레임 단위로 멈춰 세우는" 결정론적 시간 제어 장치를 만들었습니다.

커스텀 파서/렌더러가 없는 이유

HTML/CSS/JS를 직접 파싱·렌더링하려면 CSS 레이아웃, flexbox, 폰트 렌더링, WebGL, GSAP, Three.js, Lottie를 픽셀 단위로 재현해야 합니다 — 사실상 브라우저 엔진을 다시 만드는 일이고, 만들어도 "브라우저에서 본 것과 영상이 다른" 문제가 남습니다. 그래서 선택은 정반대였습니다.

"세계 최고의 HTML 렌더러(크롬)가 이미 있는데 그걸 그대로 쓰자. 우리가 풀 진짜 문제는 렌더링이 아니라 시간 제어다."

왜 시간 제어가 문제일까요? 브라우저와 영상은 시간을 다루는 방식이 근본적으로 다릅니다.

구분 브라우저 (실시간) 영상 (프레임 기반)
시간 개념 "지금 몇 초 흘렀나"로 애니메이션 정지 프레임의 연속 — 30fps면 1초에 정확히 30장
느린 컴퓨터 화면도 늦게 그려짐 (프레임 드롭) 영상 속 시간은 정확히 1/30초씩만 흘러야 함

컴퓨터가 버벅이면 브라우저는 프레임을 건너뜁니다. 영상에서는 절대 안 되는 일입니다. 그래서 필요한 것이 결정론(determinism) — 몇 번을 돌리든, 어떤 컴퓨터에서든, 같은 입력 → 같은 픽셀.

등장인물 다섯 — 연극 한 편으로

이 시스템은 연극에 비유하면 단번에 정리됩니다.

이름 정체 역할 비유
크롬 (Chromium) 브라우저 엔진 HTML → 픽셀 대본을 연기하는 배우
window.__hf 페이지에 심은 JS "몇 초 시점을 그려라" 시간 지정 대본에 적힌 감독의 큐 사인
Puppeteer 크롬 자동화 라이브러리 코드로 크롬 조종 크롬을 조종하는
CDP Chrome DevTools Protocol 크롬이 공개한 명령 규약 크롬의 모국어
FFmpeg 영상 처리 도구 인코딩·오디오 믹싱 영상계 맥가이버칼

**window.__hf**가 HyperFrames가 실제로 만든 "커스텀"입니다. 정의는 허무할 만큼 단순합니다.

// packages/engine/src/types.ts
export interface HfProtocol {
  duration: number;            // 전체 길이(초)
  seek(time: number): void;    // "이 시점의 화면으로 점프해라"
}

seek(time)은 흐르는 시간을 무시하고 지정한 시점의 화면을 강제로 만들라는 명령입니다. 이게 가능하려면 페이지의 모든 애니메이션이 "시간 흐름"이 아니라 "지정된 시각"에 반응해야 하므로, GSAP·CSS·Three.js·Lottie마다 어댑터를 두고 각자의 점프 기능에 연결합니다.

// packages/core/src/adapters/gsap.ts
seekFrame: (frame) => {
  const targetSeconds = frame / fps;
  timeline.pause();
  timeline.seek(targetSeconds, false); // GSAP에게 "이 시각으로 점프"
}

중요한 오해 하나를 짚고 갑니다. window.__hf는 외부 도구가 아니라, 크롬이 HTML을 로드할 때 함께 실행된 JS가 페이지 내부에 만드는 객체입니다. 그리고 core 패키지의 htmlParser.ts 같은 "파서"도 HTML을 픽셀로 바꾸는 파서가 아니라 타이밍 메타데이터를 추출하는 파서입니다. 픽셀은 처음부터 끝까지 100% 크롬이 그립니다.

렌더 한 사이클은 이렇게 돕니다 — 엔진(카메라맨)이 seek(0.5)를 외치면, __hf(큐 사인)가 신호를 받아, 크롬(배우)이 0.5초 장면을 연기하고, 카메라맨이 찰칵. 이걸 660프레임이면 660번 반복합니다.

Puppeteer와 CDP — 명령이 크롬에 닿는 길

"엔진이 크롬에게 명령한다"의 실체가 Puppeteer입니다. 구글이 만든 공식 크롬 자동화 라이브러리로, 이 네 줄에 모든 것이 들어 있습니다.

const browser = await puppeteer.launch();          // 크롬 켜기 (사람 없이)
const page = await browser.newPage();               // 탭 열기
await page.goto('file://.../composition.html');     // HTML 열기
await page.evaluate(() => window.__hf.seek(0.5));   // 페이지 안에서 JS 실행
await page.screenshot();                             // 화면 찍기

Puppeteer의 아래층에 **CDP(Chrome DevTools Protocol)**가 있습니다. 크롬이 "나를 이렇게 조종해라"라고 공개해 둔 WebSocket + JSON 기반 명령 규약으로, F12 개발자 도구도 같은 통로로 크롬과 대화합니다.

{ "id": 1, "method": "Page.captureScreenshot", "params": { "format": "jpeg" } }
{ "id": 1, "result": { "data": "<base64 이미지>" } }

계층은 이렇습니다: 엔진 코드 → Puppeteer(읽기 좋은 함수) → CDP(크롬이 알아듣는 날것의 명령) → 크롬. Puppeteer가 통역사라면 CDP는 크롬의 모국어죠. 흥미로운 건 HyperFrames가 CDP를 직접 호출하는 지점이 있다는 겁니다 — 결정론의 핵심인 HeadlessExperimental.beginFrame은 Puppeteer가 함수로 감싸주지 않은 실험적 명령이라, CDP 세션을 직접 열어 보내는 수밖에 없습니다.

크롬은 물론 headless(창 없이 메모리에 렌더링)로 돕니다. 프레임마다 캡처하니 빠라야 하고, CI/Docker에는 모니터가 없고, 사람 간섭이 없어야 결정론적이니까요.

결정론의 승부처 — screenshot 모드 vs beginframe 모드

여기가 이 글의 핵심입니다. 브라우저 애니메이션은 requestAnimationFrame 위에서 도는데, rAF를 언제 부를지는 크롬 마음입니다. 한가하면 초당 60번, 바쁘면 40번, 백그라운드 탭이면 1번. "0.5초 시점"을 찍어야 하는데 크롬이 바빠서 0.48초 화면을 주면, 같은 영상을 두 번 렌더해도 결과가 달라집니다.

HyperFrames는 환경에 따라 두 캡처 모드를 자동 선택합니다.

// frameCapture.ts
const captureMode =
  headlessShell && isLinux && !forceScreenshot && !supersampling
    ? "beginframe"   // Linux + chrome-headless-shell
    : "screenshot";  // 그 외 (mac/Windows)
screenshot 모드 beginframe 모드
시계 주인 크롬 (rAF) 엔진 (가짜 시각 주입)
동작 "그려지길 기다렸다 → 따로 찍음" (2단계) "그려라 + 찍어라" (1개 원자 명령)
그리다 만 화면 나올 틈 있음 없음 (프레임 완성 보장)
결과 충분히 정확 완벽히 결정론적

screenshot 모드seek(0.5)로 애니메이션 값을 맞춘 뒤 "rAF 한 번 돌았으니 됐겠지" 하고 추정해서 찍습니다. 크롬이 실제로 다 그렸는지는 통제하지 못하므로, 무거운 WebGL 장면에서는 덜 그려진 화면이 찍힐 틈이 있습니다.

beginframe 모드HeadlessExperimental.beginFrame으로 크롬 합성기(compositor)에게 시간 주도권을 빼앗아 옵니다 — "내가 시계다. 내가 명령할 때만 딱 한 프레임, 레이아웃 → 페인트 → 합성까지 끝내고 결과를 내놔." 그리기와 찍기가 하나의 원자 명령이라 덜 그려진 화면이 존재할 수 없습니다.

3편의 "Golden은 반드시 Docker에서"라는 규칙의 뿌리가 바로 여기입니다. CI/Docker(Linux)는 beginframe, mac은 screenshot — 모드가 다르면 픽셀이 미세하게 다르므로, mac에서 만든 기준 영상은 코드가 멀쩡해도 CI에서 PSNR 검사에 실패합니다.

인코딩 — 그림 수백 장이 MP4가 되기까지

캡처가 끝나면 손에 쥔 건 그림 더미입니다(5초·30fps = 150장). FFmpeg가 코덱 압축(인접 프레임의 "달라진 부분만" 저장하는 H.264)과 컨테이너 포장(mp4)을 맡는데, 구현에 영리한 점이 둘 있습니다.

① 디스크를 안 거친다. 스크린샷 버퍼를 파일로 저장했다 다시 읽는 대신, 메모리에서 FFmpeg의 stdin으로 직접 흘려보냅니다.

ffmpeg -f image2pipe -vcodec mjpeg -framerate 30 -i - -c:v libx264 output.mp4
#       이미지를 파이프로 받아       stdin에서      H.264로 압축

② 재정렬 버퍼. 속도를 위해 프레임을 병렬 캡처하면 완료 순서가 뒤죽박죽이 됩니다(1번이 0번보다 먼저 끝남). FFmpeg는 들어온 순서가 곧 영상 순서이므로, 중간에 FrameReorderBuffer가 교통정리를 합니다 — "1번이 먼저 와도 0번이 올 때까지 붙잡아둔다." 병렬로 빠르게 캡처하되, 넣을 때만 엄격히 순서를 맞추는 겁니다.

오디오 — 따로 뽑아 따로 섞어 마지막에 입힌다

스크린샷에는 소리가 없으니 오디오는 완전히 별개 경로로 갑니다. 먼저 parseAudioElements가 HTML에서 정보를 추출하고,

<audio src="narration.mp3" data-start="0"   data-volume="1.0"></audio>
<audio src="bgm.mp3"       data-start="0"   data-volume="0.3"></audio>
<audio src="ding.mp3"      data-start="2.5" data-volume="0.8"></audio>

FFmpeg filter_complex가 트랙마다 4종 처리를 한 뒤 겹칩니다.

필터 하는 일
atrim 영상 길이만큼 소리 자름
volume 볼륨 조정 (BGM은 0.3배)
adelay data-start만큼 밀어 시작 시점 맞춤
apad 짧은 소리 뒤에 무음을 채워 길이 통일

amix로 전 트랙을 하나로 합치면 mixed.aac가 나오고, 마지막 mux 단계에서 무음 영상과 포장만 합니다.

ffmpeg -i silent.mp4 -i mixed.aac -c:v copy -c:a copy final.mp4

copy는 재인코딩 없이 한 컨테이너에 묶기만 하는 것이라 거의 순식간입니다.

                     HTML 컴포지션
          ┌───────────────┴───────────────┐
    [비디오 경로]                    [오디오 경로]
  크롬 + __hf.seek                 parseAudioElements
  (Puppeteer / CDP)               "뭘 / 언제 / 얼마 크기로"
          ▼                               ▼
  프레임마다 스크린샷              filter_complex
  (beginframe/screenshot)         atrim+volume+adelay+apad
          ▼                               ▼
  [재정렬 버퍼] 순서 보장            amix (겹쳐 합침)
          ▼                               ▼
  image2pipe → H.264                 mixed.aac
          │                               │
     silent.mp4 ──────────┬───────────────┘
                          ▼
                  mux (copy로 포장)
                          ▼
                    🎬 final.mp4

더 깊이: 같은 문제, 정반대의 답 — OpenCut과의 비교

이 블로그에서 함께 탐구한 OpenCut도 "브라우저 기술로 영상 만들기"라는 같은 문제를 풉니다. 그런데 렌더링 전략은 정반대입니다.

OpenCut HyperFrames
렌더 위치 사용자의 브라우저 안 (클라이언트) 헤드리스 크롬 (서버/CLI)
인코딩 WebCodecs (하드웨어 가속) + mediabunny 스크린샷 → FFmpeg (H.264)
소스 모델 IndexedDB의 프로젝트 데이터 → Canvas/WebGL로 자체 렌더 HTML 자체가 소스코드 → 크롬이 렌더
시간 처리 재생은 실시간(rAF), 내보내기는 프레임 순회 처음부터 seek 기반 프레임 순회
강점 설치·서버 없이 즉시, 미리보기 = 결과물 브라우저가 그릴 수 있는 모든 것(GSAP·Three.js·셰이더)이 소재

OpenCut은 "편집기"라서 실시간 미리보기가 일급 요구사항이고, 렌더 트리를 자체 관리하며 WebCodecs로 브라우저 안에서 인코딩까지 끝냅니다. HyperFrames는 "생성 파이프라인"이라서 실시간성 대신 결정론을 택했고, 렌더러를 만드는 대신 크롬을 통째로 재사용했습니다. 같은 웹 기술 스택에서 제품의 성격(대화형 편집 vs 배치 생성)이 아키텍처를 어떻게 갈라놓는지 보여주는 깔끔한 대조입니다.


한 줄 요약 — HyperFrames의 렌더 엔진은 "그리는 일은 크롬, 시간은 엔진"이라는 분업이다. window.__hf.seek으로 타임라인을 프레임 격자에 고정하고, beginframe 모드로 크롬의 시계까지 빼앗아 완벽한 결정론을 달성한 뒤, FFmpeg 스트리밍 파이프로 MP4를 조립한다.

스스로 점검할 질문

  1. "커스텀 렌더러를 만들지 않는다"는 선택이 가져온 이득과, 대신 떠안은 문제는 각각 무엇인가요?
  2. screenshot 모드와 beginframe 모드의 차이를 "시계의 주인"이라는 말로 설명할 수 있나요?
  3. GSAP 타임라인에 paused: true가 없거나 repeat: -1이 있으면 이 파이프라인에서 무엇이 깨질까요?

참고 링크