HyperFrames 탐구 4 — 커스텀 렌더러 없이 HTML을 영상으로 바꾸는 법
이번 편은 시리즈에서 가장 기술적인 질문을 다룹니다. 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를 조립한다.
스스로 점검할 질문
- "커스텀 렌더러를 만들지 않는다"는 선택이 가져온 이득과, 대신 떠안은 문제는 각각 무엇인가요?
- screenshot 모드와 beginframe 모드의 차이를 "시계의 주인"이라는 말로 설명할 수 있나요?
- GSAP 타임라인에
paused: true가 없거나repeat: -1이 있으면 이 파이프라인에서 무엇이 깨질까요?
참고 링크