HyperFrames 아키텍처 분석: 실시간으로 흘러가는 웹 페이지를 어떻게 프레임 단위로 찍을까요?
HyperFrames는 HTML 페이지를 MP4로 렌더링하는 HeyGen의 오픈소스 프레임워크입니다. 페이지를 재생하지 않고, 프레임마다 페이지의 시계와 모든 애니메이션을 그 시각으로 옮긴 뒤 찍습니다. 코드를 따라가고, 직접 렌더링해서 worker 수별 속도와 결과가 정말 같은지 측정했습니다.
1. 왜 HyperFrames인가요?
이번 주 GitHub 트렌딩에 올라온 저장소입니다. 2026년 3월 10일에 첫 커밋이 올라왔고 7개월 만에 별 58,000개를 받았습니다. 그동안 쌓인 커밋은 5,336개로, 하루 평균 25개꼴입니다. 오늘(10월 7일) 나온 릴리스가 v0.8.140입니다.
README의 첫 줄은 이렇습니다.
Write HTML. Render video. Built for agents.
HTML, CSS, 애니메이션으로 만든 페이지를 MP4로 바꿔 줍니다. 그런데 웹 페이지는 실시간으로 흘러갑니다. requestAnimationFrame은 모니터 주사율에 맞춰 호출되고 CSS 애니메이션은 실제 시간을 따라 진행됩니다. Date.now()는 지금 시각을 돌려줍니다. 화면을 녹화해서 영상을 만들면 무거운 장면에서 프레임이 빠지고 녹화할 때마다 결과가 조금씩 달라집니다.
영상은 그렇게 만들 수 없습니다. 30fps 6초짜리 영상이라면 정확히 1/30초 간격의 프레임 180장이 필요합니다. 어떤 프레임도 빠지거나 밀리면 안 됩니다. 실시간으로 흘러가는 페이지에서 어떻게 정확한 시각의 프레임을 한 장씩 얻을 수 있을까요?
HyperFrames는 페이지를 재생하지 않습니다. 프레임마다 시각을 먼저 정하고, 페이지 안의 시간을 전부 그 시각에 맞춘 다음 찍습니다.
2. 한 문장으로 이해하기
HyperFrames는 data-* 속성으로 시간 정보를 적은 HTML 페이지를 headless Chrome에 띄우고 프레임마다 페이지 전체를 그 시각으로 seek한 뒤 픽셀을 찍어 FFmpeg로 보내는 HTML → 영상 렌더러입니다.
| 질문 | HyperFrames의 답 |
|---|---|
| 무엇으로 영상을 만드나요? | HTML, CSS, JS입니다. React나 빌드 단계가 없습니다. |
| 애니메이션은 어떻게 맞추나요? | 일시정지한 GSAP 타임라인을 window.__timelines에 등록합니다. 렌더러가 매 프레임 이 타임라인을 seek합니다. |
| GSAP 말고 다른 것도 되나요? | CSS 애니메이션, WAAPI, Anime.js, Lottie, Three.js, TypeGPU를 어댑터로 지원합니다. |
Date.now()를 쓰면요? | 렌더링 중에는 페이지의 Date.now()와 performance.now()가 가상 시각을 돌려줍니다. 그래도 lint는 오류로 막습니다. |
<video>는요? | 재생하지 않습니다. FFmpeg로 프레임을 미리 뽑아 두고, 매 프레임 해당 이미지를 <img>로 끼워 넣습니다. |
| 속도는요? | Chrome 프로세스를 여러 개 띄워 프레임 구간을 나눠 찍습니다. 클라우드에서는 AWS Lambda나 Cloud Run으로 240프레임씩 나눠 렌더링합니다. |
| "Built for agents"는 무슨 뜻인가요? | 스킬 21개와 결과를 JSON으로 출력하는 CLI(lint, check, keyframes, doctor)를 함께 배포합니다. 주로 코딩 에이전트가 영상을 만들 때 씁니다. |
| 라이선스는요? | Apache 2.0입니다. |
3. 기존 글들과 어떤 관계인가요?
이 블로그에서 headless 브라우저를 다룬 글은 대부분 "브라우저를 어떻게 조종하나요?"를 물었습니다. HyperFrames는 브라우저를 카메라로 씁니다.
| 대상 | 브라우저를 쓰는 방식 | HyperFrames와의 관계 |
|---|---|---|
| Playwright | 테스트를 위해 페이지를 조작하고 검사합니다 | 둘 다 CDP 위에서 동작합니다. Playwright는 페이지가 "준비될 때까지" 기다리고, HyperFrames는 페이지의 시간 자체를 정해 줍니다. |
| Lightpanda | 렌더링을 빼서 가볍게 만든 브라우저입니다 | 정반대입니다. HyperFrames는 픽셀이 결과물이라서 Chrome의 렌더링 파이프라인 전체가 필요합니다. |
| browser-use | LLM 에이전트가 브라우저를 씁니다 | HyperFrames도 에이전트용이지만 방향이 반대입니다. 에이전트가 페이지를 만들고, 렌더러가 찍습니다. |
4. 기술 스택과 규모
Bun 워크스페이스 모노레포이고 패키지는 14개입니다. 코드 줄 수는 테스트를 빼고 TypeScript·JavaScript만 셌습니다.
| 패키지 | 코드 줄 수 | 테스트 파일 | 역할 |
|---|---|---|---|
packages/studio | 172,923 | 680 | 브라우저 기반 편집기 UI |
packages/cli | 87,059 | 303 | hyperframes CLI (init, lint, check, render, snapshot 등) |
packages/core | 52,450 | 192 | 페이지 안에서 도는 런타임, 애니메이션 어댑터, 타입 |
packages/producer | 40,114 | 120 | 렌더링 파이프라인 전체 (컴파일 → 캡처 → 인코딩 → 오디오 → 합치기) |
packages/engine | 30,341 | 89 | Puppeteer로 프레임을 찍고 FFmpeg로 인코딩하는 엔진 |
packages/parsers | 16,482 | 43 | 컴포지션 HTML 파서 |
packages/studio-server | 16,247 | 68 | Studio 백엔드 |
packages/lint | 10,013 | 20 | 컴포지션 lint 규칙 97개 |
packages/player | 9,265 | 15 | 웹에 넣을 수 있는 <hyperframes-player> |
packages/sdk | 7,911 | 22 | 프로그래밍 방식으로 렌더링을 호출하는 SDK |
packages/aws-lambda | 4,469 | 13 | Lambda 분산 렌더링 |
packages/shader-transitions | 3,678 | 6 | WebGL 셰이더 전환 효과 |
packages/gcp-cloud-run | 3,049 | 11 | Cloud Run 분산 렌더링 |
packages/sdk-playground | 1,617 | 3 | SDK 예제 |
전부 합치면 약 45만 6천 줄이고 그중 38%가 Studio입니다. 렌더링의 핵심은 engine, producer, core 세 패키지에 있고 이 셋도 12만 줄이 넘습니다. 5,000줄이 넘는 파일도 세 개 있습니다. 렌더링 단계를 조율하는 renderOrchestrator.ts(5,414줄), 페이지 런타임의 init.ts(5,170줄), 프레임 캡처를 맡는 frameCapture.ts(5,031줄)입니다.
외부 의존성은 적습니다. 엔진은 puppeteer, hono(로컬 파일 서버), linkedom(서버 쪽 DOM 파싱)만 씁니다. FFmpeg와 Chrome은 시스템에 설치된 것을 찾아서 씁니다.
5. 직접 렌더링해 보았습니다
구조를 보기 전에 직접 영상을 하나 만들었습니다. 제목, 부제, 진행 막대, CSS로 회전하는 사각형, 0에서 1000까지 올라가는 숫자가 있는 6초짜리 컴포지션입니다.
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="6"
>
<h1 class="title clip" id="t" data-start="0" data-duration="6" data-track-index="1">
HTML로 만든 영상
</h1>
<p class="sub clip" id="s" data-start="1" data-duration="5" data-track-index="2">
frame-by-frame, deterministic
</p>
<div class="bar clip" id="b" data-start="0" data-duration="6" data-track-index="3"></div>
<div class="spin clip" id="sp" data-start="0" data-duration="6" data-track-index="4"></div>
<div class="counter clip" id="c" data-start="0" data-duration="6" data-track-index="5">0</div>
<script>
const tl = gsap.timeline({ paused: true })
tl.from('#t', { x: -200, opacity: 0, duration: 1, ease: 'power3.out' }, 0)
tl.from('#s', { y: 40, opacity: 0, duration: 0.8 }, 1)
tl.fromTo('#b', { scaleX: 0 }, { scaleX: 1, duration: 6, ease: 'none' }, 0)
const n = { v: 0 }
tl.to(
n,
{
v: 1000,
duration: 5,
ease: 'none',
onUpdate: () => {
document.getElementById('c').textContent = Math.round(n.v)
},
},
0
)
window.__timelines = window.__timelines || {}
window.__timelines['main'] = tl
</script>
</div>
요소마다 data-start와 data-duration을 적고 GSAP 타임라인을 { paused: true }로 만들어 window.__timelines["main"]에 등록합니다. 규칙은 이 세 가지뿐입니다. play()는 부르지 않습니다. 재생 위치는 렌더러가 정합니다.
hyperframes check를 실행하면 headless Chrome에서 컴포지션을 검사합니다.
Lint ◇ 0 errors, 0 warnings
Runtime ◇ 0 errors, 0 warnings
Layout ◇ 0 issues across 9 sample(s)
Motion ◇ 0 errors, 0 warnings
Contrast ◇ 14/14 text checks pass WCAG AA
◇ Check passed
hyperframes render -w 1로 렌더링한 결과입니다.
634.2 KB · 6.0s video · rendered in 8.8s
screenshot capture · hardware gpu · compile 0.3s · extract 0.0s · audio 0.0s ·
probe 0.0s · setup 0.2s · capture 8.2s · encode (during capture) 8.2s · assemble 0.0s

90번째 프레임입니다. 시각은 89/30 = 2.967초이고 숫자는 1000 × 2.967 / 5 = 593입니다. 시간 대부분이 capture에 들어갔습니다. 180프레임을 8.2초에 찍었으니 초당 22프레임 정도입니다. 렌더링 로그에는 이런 줄도 있었습니다.
[engine] fast capture: falling back to screenshot capture — css-animation detected
(drawElementImage cannot reproduce it; see fast-capture-limitations.md)
회전하는 사각형에 쓴 CSS 애니메이션 때문에 빠른 캡처 방식을 쓰지 못했습니다(7.6 참고).
6. 렌더링은 어떤 단계로 진행되나요?
hyperframes render는 먼저 lint를 실행합니다(cli/src/commands/render/execute.ts:96). lint를 통과하면 producer의 executeRenderJob(producer/src/services/renderOrchestrator.ts:2863)을 호출합니다. 이 파일 맨 위 주석에 단계가 정리되어 있습니다.
| 단계 | 이름 | 하는 일 |
|---|---|---|
| 1 | compile | 하위 컴포지션(data-composition-src)을 합치고, data-start + data-duration으로 data-end를 계산합니다. |
| 1b | probe | 브라우저를 띄워 실제 길이를 확인하고, 미디어 길이를 ffprobe로 맞춥니다. |
| 2 | extract videos | <video>마다 FFmpeg로 프레임을 이미지로 뽑아 둡니다. |
| 3 | audio | 모든 <audio>, <video>의 소리를 하나의 audio.m4a로 섞습니다. |
| 4 | capture | headless Chrome에서 프레임을 한 장씩 찍습니다. |
| 5 | encode | 찍은 프레임을 H.264로 인코딩합니다. 캡처와 동시에 인코딩했으면 건너뜁니다. |
| 6 | assemble | 영상과 오디오를 합치고 faststart를 적용합니다. |
오디오는 영상과 완전히 따로 처리합니다. 트랙마다 FFmpeg 필터(atrim, volume, adelay)를 거친 뒤 amix 하나로 섞습니다(engine/src/services/audioMixer.ts). 이퀄라이저, 컴프레서, 리버브 같은 효과는 FFmpeg가 아니라 **같은 headless Chrome 안의 OfflineAudioContext**에서 처리합니다. Studio 미리보기가 Web Audio로 효과를 내므로 렌더링에서도 같은 그래프 코드를 써야 소리가 같아집니다. audioFxRender.ts의 주석에 따르면 컴프레서와 모듈레이션 딜레이 등 네 가지는 똑같이 동작하는 FFmpeg 필터가 없습니다.
7. 프레임 N은 어떻게 찍히나요?
핵심은 4단계 capture입니다. 프레임 하나는 이 순서로 찍힙니다.
- 시각을 계산합니다.
t = N × fps.den / fps.num이고 프레임 격자에 맞춰 반올림합니다. - 페이지에서
window.__hf.seek(t)를 호출합니다. - 페이지 런타임이 모든 애니메이션 어댑터를
t로 seek하고 각 클립을 보이거나 숨깁니다. - 가상 시계를
t로 옮기고 쌓여 있던requestAnimationFrame콜백을 실행합니다. <video>자리에 그 시각의 프레임 이미지를 끼워 넣습니다.- 픽셀을 가져옵니다.
- 픽셀을 FFmpeg의 stdin으로 보냅니다.
7.1 시각은 정수 계산으로 정합니다
export function quantizeSeekTime(
timeSeconds: number,
fps: number,
subFrameDivisions?: number
): number {
const divisions = Number.isInteger(subFrameDivisions) ? (subFrameDivisions as number) : 1
if (divisions <= 1) return quantizeTimeToFrame(timeSeconds, fps)
const grid = safeFps(fps) * divisions
return Math.round(safeTime(timeSeconds) * grid) / grid
}
core/src/inline-scripts/parityContract.ts:63입니다. 렌더러와 Studio 미리보기, check가 모두 이 함수로 시각을 맞춥니다. 29.97fps 같은 NTSC 프레임레이트를 30000/1001 분수로 받으므로 부동소수점 오차가 쌓여 프레임이 밀리는 일이 없습니다.
7.2 페이지의 시계를 바꿔치기합니다
렌더링할 때 producer는 로컬 파일 서버로 컴포지션을 서빙하면서 작성자의 스크립트보다 먼저 실행되도록 <head> 맨 앞에 스크립트를 끼워 넣습니다(producer/src/services/fileServer.ts:219). 이 스크립트가 페이지의 시계를 가상 시계로 바꿉니다. 아래는 일부를 줄인 것입니다.
var virtualNowMs = 0
// Date.now(), performance.now()는 virtualNowMs를 돌려줍니다
window.requestAnimationFrame = function (callback) {
var entry = { id: rafId++, callback: callback, cancelled: false }
rafQueue.push(entry) // 바로 실행하지 않고 쌓아 둡니다
return entry.id
}
window.__HF_VIRTUAL_TIME__ = {
seekToTime: function (nextTimeMs) {
virtualNowMs = Math.max(0, Number(nextTimeMs) || 0)
flushAnimationFrame() // seek할 때만 rAF 콜백을 실행합니다
return virtualNowMs
},
}
이 shim이 들어간 페이지에서는 시간이 저절로 흐르지 않습니다. 렌더러가 seekToTime을 부를 때만 시계가 움직이고 그때만 requestAnimationFrame 콜백이 한 번 실행됩니다. 3초 시점을 찍는 데 1초가 걸리든 10초가 걸리든, 페이지는 3초라고 믿습니다.
다만 shim의 함수 주석에는 "rAF/setTimeout 파이프라인을 얼린다"고 적혀 있지만 실제 코드는 setTimeout과 setInterval의 원본을 저장만 하고 바꾸지는 않습니다. Math.random도 기본 설정(seedRandomFromFrame: false)에서는 그대로 둡니다. 시드는 분산 렌더링의 청크 워커에서만 넣습니다(distributed/renderChunk.ts:711). 나머지는 lint로 막습니다.
7.3 seek 하나가 어댑터 13개로 전달됩니다
__hf.seek도 producer가 끼워 넣는 연결 스크립트에 있습니다(fileServer.ts:658, 일부 줄임).
hf.seek = function (t, options) {
p.renderSeek(t, options) // 페이지 런타임
var nextTimeMs = Math.max(0, Number(t) || 0) * 1000
window.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs) // 가상 시계
seekSameOriginChildFrames(window, nextTimeMs) // iframe까지
}
renderSeek(core/src/runtime/init.ts:3921)은 시계를 멈추고 등록된 어댑터를 모두 t로 seek한 뒤 다시 멈춥니다. 어댑터는 애니메이션 라이브러리마다 하나씩 있습니다.
| 어댑터 | seek 방법 |
|---|---|
| gsap | timeline.totalTime(t) 뒤에 한 번 더 그립니다(아래 설명) |
| css | element.getAnimations()의 각 애니메이션에 currentTime = t; pause() |
| waapi | animation.currentTime = t; animation.pause() |
| animejs | instance.seek(t) |
| lottie | goToAndStop(frame, true) |
| three | window.__hfThreeTime에 시각을 쓰고 hf-seek 이벤트를 보냅니다 |
| typegpu | window.__hfTypegpuTime에 시각을 쓰고 hf-seek 이벤트를 보냅니다 |
| frame-source | 등록된 프레임 소스를 클립이 보일 때만 seek합니다 |
| mapbox, maplibre, leaflet, google-maps, d3 | seek하지 않습니다. 타일·데이터가 다 로드될 때까지만 기다립니다 |
Three.js와 TypeGPU는 라이브러리의 시계를 바꾸지 않습니다. 작성자가 __hfThreeTime을 읽거나 hf-seek 이벤트를 받아서 장면을 직접 다시 그려야 합니다.
GSAP 어댑터는 totalTime(t) 한 번으로 끝나지 않습니다. rerenderGsapTimelineAt(core/src/runtime/adapters/gsap.ts:11)은 t - 0.001로 먼저 이동하고 정확히 t에 시작하는 트윈을 미리 준비한 다음 t로 이동합니다. 같은 시각에 set과 from이 겹쳐 있을 때 작성한 순서대로 적용하기 위해서입니다. 영상은 프레임 하나하나가 정지 화면이라서 실시간 재생에서는 눈에 띄지 않는 경계값 문제가 그대로 드러납니다.
7.4 클립 표시 구간은 반열린 구간입니다
data-start와 data-duration이 있는 요소가 시각 t에 보일지는 아래 함수가 정합니다(core/src/runtime/clipWindow.ts:6).
/** Half-open: two back-to-back clips never both hold the shared boundary instant */
export const isInClipWindow = (time, start, end) =>
hasClipStarted(time, start) && time < end && !sameInstant(time, end)
export const isClipVisibleAt = (time, start, end, compositionDuration) =>
isInClipWindow(time, start, end) ||
(hasClipStarted(time, start) &&
compositionDuration > 0 &&
end >= compositionDuration - TERMINAL_EPSILON_SECONDS)
[start, end) 구간이라서 0~3초 클립과 3~6초 클립이 3초 정각에 동시에 보이지 않습니다. 다만 영상 끝까지 이어지는 클립은 마지막 순간까지 보입니다. 이 예외가 없으면 마지막 프레임이 빈 화면이 됩니다.
7.5 <video>는 재생하지 않습니다
페이지 안의 <video>를 seek하고 seeked 이벤트를 기다리는 방식은 느리고 디코더에 따라 정확한 프레임이 나오지 않을 수 있습니다.
그래서 HyperFrames는 2단계(extract videos)에서 FFmpeg로 영상마다 프레임을 이미지 파일로 뽑아 둡니다(engine/src/services/videoFrameExtractor.ts). 가변 프레임레이트 영상은 fps=F:start_time=0:round=up 필터로 고정 프레임레이트로 바꿉니다. 캡처할 때는 <video> 옆에 숨겨 둔 <img>에 그 시각의 프레임을 data URI로 넣고 <video>의 크기·위치·스타일을 <img>에 복사한 다음 <video>를 숨깁니다(screenshotService.ts:641). 화면에는 매 프레임 바뀌는 이미지가 보입니다.
7.6 픽셀을 가져오는 방법은 세 가지입니다
| 방식 | 조건 | 방법 |
|---|---|---|
beginframe | Linux + chrome-headless-shell | HeadlessExperimental.beginFrame으로 Chrome의 컴포지터를 직접 한 프레임씩 진행하고, 같은 호출로 스크린샷을 받습니다 |
drawelement | 기본값. CSS 애니메이션·blur·blend 등이 없을 때 | 페이지를 <canvas layoutsubtree>로 감싸고 canvas.drawElementImage()로 DOM의 페인트 기록을 캔버스에 바로 그립니다 |
screenshot | 위 두 방식을 쓸 수 없을 때 | Page.captureScreenshot |
문서는 "beginFrame으로 프레임 단위 seek를 한다"고 설명하지만 이 방식은 Linux에서만 씁니다. macOS와 Windows에서는 beginFrame이 없고 시간은 7.2의 가상 시계와 7.3의 seek가 정합니다. beginFrame은 여기에 더해 Chrome의 컴포지터까지 같은 시각으로 맞춥니다.
drawElement는 Chrome의 실험 기능(--enable-features=CanvasDrawElement)입니다. 컴포지터를 거치지 않으니 빠르지만 컴포지터가 처리하는 효과(CSS 애니메이션, backdrop-filter, blur, mix-blend-mode)는 재현하지 못합니다. 그래서 렌더링 전에 계산된 스타일과 스타일시트를 훑어서 이런 효과가 있으면 screenshot으로 바꿉니다(engine/src/services/threeDProjection.ts:957). 5장의 로그가 이 경우였습니다.
회전하는 사각형의 CSS 애니메이션을 GSAP 회전으로 바꾸면 drawElement를 쓸 수 있습니다. 얼마나 빠른지 측정하려고 같은 컴포지션을 두 방식으로 3번씩 렌더링했습니다.
| 캡처 방식 | 컴파일·브라우저 준비 | 프레임 캡처 | 인코딩 | 합계 |
|---|---|---|---|---|
| drawElement | 0.5 | 4.2 | 0.0 | 4.7 |
| screenshot | 0.5 | 8.3 | 0.0 | 8.9 |
| 캡처 방식 | 렌더링 시간 | 캡처 시간 |
|---|---|---|
| drawElement | 4.7초 | 4.2초 |
| screenshot | 8.9초 | 8.3초 |
캡처 시간이 49% 줄었습니다. drawElementService.ts의 주석이 주장하는 "로컬 GPU에서 screenshot보다 약 46% 빠르다"와 거의 같습니다.
7.7 FFmpeg에는 파이프로 넘깁니다
기본 설정에서는 찍은 프레임을 디스크에 저장하지 않고 FFmpeg의 stdin으로 바로 보내 인코딩합니다(streamingEncoder.ts:264). FFmpeg는 -f image2pipe -vcodec mjpeg -i -로 JPEG(품질 80)를 받아 libx264로 인코딩합니다. 품질 프리셋은 draft(ultrafast, CRF 28), standard(medium, CRF 18), high(slow, CRF 15)입니다. 모든 프레임이 정지 화면이므로 B-프레임을 끄고(-bf 0), 색 공간을 BT.709로 고정합니다.
8. worker를 늘리면 얼마나 빨라지나요?
worker 하나는 Chrome 프로세스 하나입니다(--workers 도움말에 따르면 하나에 약 256MB를 씁니다). 프레임은 연속 구간으로 나눕니다. 180프레임을 4개로 나누면 0~44, 45~89, 90~134, 135~179입니다(parallelCoordinator.ts:422). worker들이 각자 찍은 프레임은 FrameReorderBuffer가 순서대로 줄을 세워 FFmpeg 하나로 보냅니다.
worker 수를 바꿔 가며 같은 컴포지션을 3번씩 렌더링했습니다. Apple M5 Pro(15코어), 하드웨어 GPU입니다.
| worker 수 | 컴파일·브라우저 준비 | 프레임 캡처 | 인코딩 | 합계 |
|---|---|---|---|---|
| worker 1개 | 0.5 | 8.1 | 0.0 | 8.7 |
| worker 2개 | 0.5 | 4.3 | 0.4 | 5.2 |
| worker 4개 | 0.5 | 2.7 | 0.4 | 3.6 |
| auto (5개) | 1.2 | 2.3 | 0.4 | 4.0 |
| worker 8개 | 0.5 | 2.3 | 0.4 | 3.3 |
| worker 수 | 렌더링 시간 | 캡처 시간 | 1개 대비 |
|---|---|---|---|
| 1 | 8.7초 | 8.1초 | 1.0배 |
| 2 | 5.2초 | 4.3초 | 1.7배 |
| 4 | 3.6초 | 2.7초 | 2.4배 |
| auto (5) | 4.0초 | 2.3초 | 2.2배 |
| 8 | 3.3초 | 2.3초 | 2.6배 |
2개까지는 거의 절반으로 줄지만 4개를 넘기면 캡처 시간이 거의 줄지 않습니다. 영상이 6초로 짧아서 worker 8개면 하나가 23프레임만 맡습니다. worker마다 페이지를 열고 준비가 끝나기를 기다리는 고정 시간이 있는데 그 비중이 상대적으로 커지기 때문으로 보입니다(캡처 단계 안의 세부 시간은 따로 재지 않았습니다). auto는 5개를 골랐는데 브라우저 준비(setup)가 0.9초로 늘어서 4개보다 느렸습니다. 또 worker가 1개일 때만 캡처와 인코딩을 동시에 하고(encode (during capture)), 여러 개일 때는 캡처가 끝난 뒤 0.4초를 인코딩에 씁니다.
클라우드에서는 같은 방식으로 프레임 구간을 여러 기계에 나눕니다. producer/src/distributed.ts가 순수 함수 세 개를 제공합니다.
const planResult = await planV2(projectDir, config, planV2Dir) // 컨트롤러
const chunk = await renderChunkV2(planV2Dir, chunkIndex, outputChunkPath) // 워커
await assembleV2(planV2Dir, chunkPaths, outputPath) // 컨트롤러
plan은 컴파일, 영상 프레임 추출, 오디오 믹스까지 끝내고 결과를 디렉터리 하나에 고정합니다. 오디오는 여기서 한 번만 섞습니다.- 프레임 범위는 240프레임(30fps에서 8초) 단위 청크로 나눕니다. 주석은 "Lambda의 15분 제한에 맞춘 크기"라고 설명합니다(
distributed/plan.ts:361). - 청크 워커는 GPU 대신 소프트웨어 렌더러(SwiftShader)를 강제하고
Math.random에 프레임별 시드를 넣습니다. GOP 크기를 청크 길이와 같게 해서 모든 청크가 키프레임으로 시작합니다. assemble은ffmpeg -f concat -c copy로 청크를 다시 인코딩하지 않고 이어 붙입니다.
AWS에서는 Step Functions의 Map이, GCP에서는 Cloud Workflows가 청크마다 워커를 띄웁니다(GCP는 동시 실행 20개가 상한입니다). 두 어댑터 모두 함수 하나가 plan, renderChunk, assemble 역할을 모두 맡는 얇은 래퍼입니다.
9. 같은 입력이면 정말 같은 영상이 나오나요?
문서(docs/concepts/determinism.mdx)는 결정적 렌더링을 이렇게 설명합니다.
Rendering never plays your video. It asks for one frame at a time, and the answer to "what does frame 90 look like?" depends on exactly one thing that changes: the number 90.
프레임 어댑터 규약에도 "seek는 어떤 순서로 해도 동작해야 한다"는 조건이 있습니다. 확인해 보았습니다. 8장에서 렌더링한 MP4 21개를 ffmpeg -f framemd5로 디코딩해서 프레임마다 해시를 구했습니다.
| 비교 | 결과 |
|---|---|
| 같은 설정으로 3번 렌더링 | 7가지 설정 모두 3번이 비트 단위로 같음 |
| worker 1개와 4개 | 다름 |
| worker 5개(auto)와 8개 | 다름 |
같은 설정이면 결과가 정확히 같았습니다. 그런데 worker 수를 바꾸면 해시가 일치하지 않았습니다. 인코딩 차이인지 확인하려고 PNG 시퀀스(--format png-sequence)로 다시 렌더링해 픽셀을 직접 비교했습니다. worker 1개와 4개를 비교하면 180프레임 중 46번부터 180번까지 135프레임이 달랐습니다. 앞의 45프레임은 4-worker 렌더링에서 첫 번째 worker가 찍은 구간입니다. 두 번째 worker부터 찍은 프레임이 모두 여기에 해당합니다.
90번째 프레임에서 다른 픽셀은 143개였고 모두 제목의 '로' 한 글자 안에 있었습니다.

획의 가장자리가 한 픽셀씩 옆으로 밀려 있습니다. 원인을 좁히려고 GPU 설정과 제목 트윈, 두 가지를 바꿔 다시 비교했습니다.
| 실험 | worker 1개와 4개의 차이 |
|---|---|
GPU를 끄고 소프트웨어 렌더링(--no-browser-gpu) | 135프레임 다름 |
제목의 x: -200 이동 트윈만 제거 | 0프레임 다름 |
GPU와는 관계가 없었습니다. 제목이 왼쪽에서 미끄러져 들어오는 1초짜리 트윈을 빼자 두 결과가 완전히 같아졌습니다. 1초 이후 제목의 x는 어느 worker에서나 0입니다. 달라진 것은 0에 이르기까지의 과정입니다. 첫 번째 worker는 0초부터 이동 애니메이션을 한 프레임씩 거쳐 왔고 두 번째 worker는 1.5초로 바로 seek했습니다. DOM 값이 같아도 Chrome이 글자를 래스터화하는 위치가 이전 프레임의 영향을 받은 것으로 보입니다(Chrome 내부까지 확인하지는 못했으므로 추정입니다).
HyperFrames의 결정성은 같은 입력, 같은 worker 분할, 같은 기계에서 성립합니다. 프레임 하나의 픽셀이 프레임 번호만으로 정해지지는 않고 그 Chrome 프로세스가 앞서 어떤 프레임을 거쳐 왔는지에도 영향을 받습니다. 사람 눈에는 보이지 않는 차이지만 렌더링 결과를 해시로 비교하는 회귀 테스트나 청크를 다시 렌더링하는 재시도에서는 문제가 됩니다. 저장소에서도 gsap.utils.random()을 막는 lint 규칙의 안내문이 "각 렌더 worker가 독립적으로 초기화되므로 무작위 값이 청크마다 달라진다"고 설명합니다. 문서는 기계 간 차이를 없애려면 --docker 렌더링으로 Chromium·폰트·FFmpeg를 고정하라고 권합니다.
10. "Built for agents"는 무엇을 뜻하나요?
코드와 별도로 에이전트용 문서도 큰 비중을 차지합니다. skills/에는 스킬 21개가 있고 마크다운 파일만 330개, 42,392줄입니다. 스킬의 진입점인 SKILL.md 21개만 5,349줄입니다.
/hyperframes 스킬이 라우터입니다. "영상 만들어 줘" 요청이 오면 프로젝트 상태를 먼저 보고(Remotion 이전 작업인지, 기존 편집인지, BRIEF.md가 있는지), 그다음 우선순위 표에 따라 작업 유형 10개(/product-launch-video, /pr-to-video, /music-to-video, /slideshow 등) 중 하나로 보냅니다. 작업 유형 스킬은 처음부터 설치되지 않고 라우터가 고른 뒤에 npx hyperframes skills update <workflow>로 설치합니다.
CLI는 에이전트가 결과를 읽을 수 있도록 만들어졌습니다.
| 명령 | 에이전트가 받는 것 |
|---|---|
lint --json | {code, severity, message, file, line, column, selector, elementId, fixHint, snippet} 형태의 문제 목록 |
check --json | lint, 런타임 오류, 레이아웃(글자 넘침·겹침), 모션 검사, WCAG 대비. 문제마다 selector, bbox, time, fixHint가 붙습니다 |
snapshot | 지정한 시각의 PNG와 contact-sheet.jpg(여러 프레임을 한 장에 모은 이미지). 코드 주석은 "AI 리뷰용 그리드"라고 적어 둡니다 |
keyframes --json | GSAP 트윈, CSS @keyframes, Anime.js 키프레임을 절대 시각으로 펼친 JSON. --shot은 움직임 경로를 겹쳐 그린 PNG를 만듭니다 |
doctor --json | Node, FFmpeg, Chrome, 메모리, /dev/shm 등 환경 점검. 종료 코드가 항상 0이므로 jq -e '.ok'로 판단하라고 스킬이 안내합니다 |
check는 재생하지 않고 seek한다는 점에서 렌더러와 같습니다(checkBrowser.ts:278의 주석이 "check seeks, it never plays"입니다). 9개 시각으로 seek해서 레이아웃을 검사합니다. 대비는 최대 5개 시각에서 글자를 숨긴 화면을 한 장 찍어 실제 배경색을 읽은 다음 계산합니다. 5장에서 본 "14/14 text checks pass WCAG AA"가 이 검사의 결과입니다.
lint 규칙은 97개이고 안내문은 에이전트가 고칠 수 있도록 이유를 함께 적습니다. Date.now()에는 "벽시계 대신 GSAP 타임라인 위치를 쓰라", crypto.getRandomValues()에는 "mulberry32 같은 시드 있는 PRNG를 쓰라"고 안내합니다. 스킬에는 "검사를 통과했다는 이유만으로 렌더링하지 말고, 마지막 미리보기에서 멈춰라" 같은 작업 규칙도 있습니다.
11. Remotion과 무엇이 다른가요?
비교 대상은 React로 영상을 만드는 Remotion입니다. 저장소에 비교 문서(docs/guides/hyperframes-vs-remotion.mdx)가 있고 Remotion 프로젝트를 옮겨 주는 /remotion-to-hyperframes 스킬도 있습니다.
| HyperFrames | Remotion | |
|---|---|---|
| 작성 언어 | HTML, CSS, JS | React, TypeScript |
| 시간을 다루는 방식 | 렌더러가 애니메이션을 멈추고 프레임마다 seek | 컴포넌트가 useCurrentFrame()으로 프레임 번호를 읽고 값을 계산 |
| 기존 웹 애니메이션 | GSAP, Lottie, CSS를 거의 그대로 사용 | React 컴포넌트로 다시 작성 |
| 라이선스 | Apache 2.0 | 3인 이하 회사까지 무료, 그 이상은 유료 |
두 방식은 시간을 누가 정하느냐에서 갈립니다. Remotion은 컴포넌트가 프레임 번호의 순수 함수라서 시간에 따른 값을 작성자가 직접 계산합니다. HyperFrames는 작성자가 평범한 웹 애니메이션을 쓰고 렌더러가 그 애니메이션의 시간을 대신 정합니다. 7장의 가상 시계, 어댑터 13개, <video> 바꿔치기도 이 선택 때문에 필요합니다.
비교 문서는 Remotion이 나은 점도 숨기지 않습니다. Remotion이 더 오래되었고 Lambda 렌더링이 더 성숙했다고 적고 다음 한계도 인정합니다.
HyperFrames asks you to follow rules — paused timeline, no wall clocks, no unseeded randomness — and breaks quietly if you don't.
12. 코드 읽는 추천 순서
docs/concepts/determinism.mdx,docs/guides/hyperframes-vs-remotion.mdx: 설계 의도를 가장 짧게 설명합니다.packages/cli/src/templates/blank/index.html: 가장 작은 컴포지션입니다.packages/producer/src/services/fileServer.ts의buildVirtualTimeShim과bridge: 시계를 바꾸는 부분과__hf.seek가 여기 있습니다. 이 저장소에서 한 파일만 읽는다면 이 파일을 권합니다.packages/core/src/runtime/clipWindow.ts: 25줄짜리 클립 표시 규칙입니다.packages/core/src/runtime/adapters/:waapi.ts,css.ts,gsap.ts순서로 읽으면 seek가 라이브러리마다 어떻게 다른지 보입니다.packages/producer/src/services/renderOrchestrator.ts의 머리 주석: 6단계 목록입니다. 본문 5,414줄은 나중에 읽어도 됩니다.packages/engine/src/services/frameCapture.ts의captureFrameCore: 프레임 하나를 찍는 함수입니다.packages/producer/src/services/distributed/plan.ts: 청크를 나누는 방법과 그 이유가 주석에 있습니다.skills/hyperframes/SKILL.md: 154줄짜리 라우터입니다.
13. 인상적인 설계 포인트
1. 시간을 바깥에서 정합니다. 렌더러가 Date.now(), requestAnimationFrame, 애니메이션 라이브러리 13종의 시간을 모두 바꿔치기합니다. 덕분에 GSAP나 Lottie로 만든 기존 웹 애니메이션을 거의 그대로 영상으로 바꿀 수 있습니다.
2. 미리보기, 검사, 렌더링이 같은 seek 함수를 씁니다. Studio 미리보기, check, snapshot, 렌더러가 모두 같은 런타임과 같은 quantizeSeekTime을 거칩니다. 그래서 미리보기에서 본 프레임과 렌더링한 프레임이 같습니다.
3. 빠른 경로가 실패하면 안전한 경로로 돌아갑니다. drawElement 캡처는 쓸 수 없는 CSS 효과를 렌더링 전에 찾아서 screenshot으로 바꾸고 그 이유를 로그에 남깁니다. beginFrame도 시작할 때 시험해 보고 실패하면 Chrome을 일반 플래그로 다시 띄웁니다.
4. 분산 렌더링을 순수 함수 세 개로 나눴습니다. plan, renderChunk, assemble에는 네트워크 코드가 없습니다. Lambda와 Cloud Run 어댑터는 이 세 함수를 부르는 얇은 래퍼라서 다른 클라우드로 옮기기 쉽습니다.
5. 에이전트가 읽을 출력을 따로 설계했습니다. lint, check, keyframes, doctor가 --json을 지원하고 문제마다 CSS 선택자, 시각, 고치는 방법이 붙습니다. 에이전트가 사람처럼 영상을 보지 않아도 무엇이 잘못되었는지 알 수 있습니다.
14. 주의해서 볼 지점
1. 결정성의 범위가 문서보다 좁습니다. 9장에서 측정했듯이 worker 수가 바뀌면 픽셀이 달라질 수 있습니다. 같은 영상을 다시 렌더링해서 해시로 비교한다면 worker 수까지 고정해야 합니다.
2. 가상 시계에 빈틈이 있습니다. setTimeout과 setInterval은 가상화하지 않고 Math.random은 로컬 렌더링에서 시드가 없습니다. 렌더링 중 네트워크 요청도 막지 않습니다. 이런 코드는 lint로만 막습니다. lint는 컴포지션에 적힌 스크립트를 정규식으로 검사하므로, 외부에서 불러온 라이브러리 안의 Math.random 호출은 검사 대상이 아닙니다.
3. 규칙을 어기면 조용히 깨집니다. 타임라인을 paused: true 없이 만들거나 무한 반복 애니메이션을 쓰면 오류 없이 이상한 영상이 나옵니다. 비교 문서도 이 점을 인정합니다. check가 이 문제를 상당 부분 잡지만 결국 작성자(대부분 에이전트)가 규약을 지켜야 합니다.
4. 플랫폼마다 캡처 경로가 다릅니다. beginFrame은 Linux에서만, drawElement는 특정 조건에서만 씁니다. 같은 컴포지션이 macOS 노트북과 Linux 서버에서 다른 경로로 찍힙니다. 이 차이를 없애는 공식 방법은 Docker 렌더링입니다.
5. 코드가 매우 빠르게 바뀝니다. 7개월 동안 커밋 5,336개, 릴리스 태그 518개가 쌓였습니다. 코드 곳곳에 텔레메트리 분석 결과를 반영한 분기(예: drawElement를 쓰지 않은 이유를 분류하는 코드)가 있습니다. 상위 기여자 한 명의 커밋이 2,339개로 전체의 44%입니다.
6. 텔레메트리가 기본으로 켜져 있습니다. 렌더링마다 익명 텔레메트리를 보내고 --skill 옵션으로 어떤 스킬이 렌더링을 시작했는지도 기록합니다. hyperframes telemetry disable이나 HYPERFRAMES_NO_TELEMETRY, DO_NOT_TRACK 환경 변수로 끌 수 있습니다.
15. 결론
HyperFrames는 페이지를 재생하지 않고 프레임마다 페이지의 시계와 모든 애니메이션을 그 시각으로 옮긴 뒤 찍어서 실시간으로 흘러가는 웹 페이지에서도 정확한 프레임을 얻습니다. 가상 시계 shim, 애니메이션 어댑터 13개, 미리 뽑아 둔 영상 프레임, 반열린 클립 구간은 모두 "지금 몇 초인가?"를 렌더러가 정하게 만드는 장치입니다.
직접 측정한 결과도 이 설계와 맞았습니다. 같은 설정으로 렌더링한 영상은 매번 비트 단위로 같았습니다. 프레임 구간을 나눠 Chrome 여러 개로 찍으면 6초 영상 기준 2.6배까지 빨라졌습니다. 다만 같은 프레임이라도 그 앞에 어떤 프레임을 찍었는지에 따라 글자 래스터화가 달라질 수 있어서, worker 수를 바꾸면 사람 눈에 보이지 않는 픽셀 차이가 생깁니다.
분석 일자: 2026-10-07 대상 버전:
hyperframesv0.8.140 (Apache 2.0) 대상 커밋:5c7f6316d(main브랜치, 2026-10-07) 저장소: https://github.com/heygen-com/hyperframes 측정 환경: Apple M5 Pro(15코어), macOS, Node 24.15, FFmpeg 8.1,hyperframes@0.8.140CLI, 1920×1080 30fps 6초(180프레임), 설정마다 3회 렌더링 후 중앙값
Claude Code와 함께 작성됨