🔄

Remotion 코드 분석 — renderMedia()와 프레임 렌더링 루프

Puppeteer로 각 프레임을 스크린샷하고 FFmpeg로 합치는 렌더링 핵심 루프를 소스 코드로 분석

GitHub: remotion-dev/remotion/packages/renderer

번들링이 끝나면 실제 렌더링이 시작됩니다. renderMedia()가 모든 것을 조율하는 오케스트레이터입니다.


renderMedia() — 오케스트레이터

// renderer/src/render-media.ts (간략화)
export const renderMedia = async (options: RenderMediaOptions) => {
  // 1. 번들 열기
  const serveUrl = await prepareServer(options.serveUrl);

  // 2. Composition 메타데이터 가져오기
  const composition = await selectComposition({
    serveUrl,
    id: options.composition,  // 렌더링할 Composition ID
  });

  // 3. 프레임 렌더링 (PNG 시퀀스 생성)
  const { assetsInfo } = await renderFrames({
    config: composition,
    concurrency: options.concurrency,
    onFrameUpdate: options.onProgress,
  });

  // 4. FFmpeg로 비디오 인코딩
  await stitchFramesToVideo({
    fps: composition.fps,
    codec: options.codec,
    audioBitrate: options.audioBitrate,
  });
};

renderFrames() — 프레임 루프의 핵심

// renderer/src/render-frames.ts (핵심 로직)
export const renderFrames = async (options) => {
  // concurrency 수만큼 Chrome 페이지를 열어 병렬 처리
  const pages = await openPages(options.concurrency);

  // 프레임을 풀에 분배
  const framePool = createFramePool(totalFrames, pages.length);

  // 각 페이지가 프레임을 하나씩 처리
  await Promise.all(pages.map(async (page, pageIndex) => {
    for (const frame of framePool[pageIndex]) {
      // currentFrame을 브라우저에 주입
      await page.evaluate((f) => {
        window.remotion_setFrame(f);
      }, frame);

      // delayRender가 있으면 완료될 때까지 대기
      await waitForDelayRender(page);

      // 스크린샷 → PNG 파일로 저장
      await page.screenshot({
        path: `frame-${frame}.png`,
        type: 'png',
      });
    }
  }));
};

핵심 메커니즘:

  • window.remotion_setFrame(n): 브라우저 컨텍스트에서 전역 프레임 번호를 변경

  • React가 이를 감지하고 리렌더링 → 새로운 프레임 상태

  • waitForDelayRender(): continueRender()가 호출될 때까지 page.waitForFunction()으로 대기

delayRender / continueRender 구현

// core/src/delay-render.ts
let delayRenderCount = 0;

export const delayRender = (label?: string): number => {
  const id = ++delayRenderCount;
  // window.__REMOTION_DELAY_RENDER에 등록
  window.__REMOTION_DELAY_RENDER.push({ id, label });
  return id;
};

export const continueRender = (id: number): void => {
  // 해당 ID를 배열에서 제거
  window.__REMOTION_DELAY_RENDER =
    window.__REMOTION_DELAY_RENDER.filter(d => d.id !== id);
};

// 렌더러가 확인하는 조건:
// window.__REMOTION_DELAY_RENDER.length === 0 이면 스크린샷 진행

설계 포인트: delayRender는 단순한 카운터입니다. 여러 비동기 작업이 각각 delayRender()를 호출하고, 모든 continueRender()가 호출되어 배열이 비워져야 스크린샷이 진행됩니다.

stitchFramesToVideo() — FFmpeg 인코딩

// renderer/src/stitch-frames-to-video.ts (간략화)
export const stitchFramesToVideo = async (options) => {
  const ffmpegArgs = [
    '-r', String(options.fps),           // 프레임레이트
    '-i', 'frame-%d.png',                // 이미지 시퀀스 입력
    '-c:v', 'libx264',                   // H.264 코덱
    '-pix_fmt', 'yuva420p',              // 픽셀 포맷
    '-y', options.outputPath,             // 출력 파일
  ];

  // 오디오가 있으면 -i audio.mp3 추가
  if (options.audioFile) {
    ffmpegArgs.push('-i', options.audioFile, '-c:a', 'aac');
  }

  await execa('ffmpeg', ffmpegArgs);
};

PNG 시퀀스를 FFmpeg의 이미지 시퀀스 입력으로 넣어 H.264(MP4) 또는 VP9(WebM)으로 인코딩합니다.

동작 흐름

1

renderMedia()가 번들 서버를 열고 selectComposition()으로 Composition 메타데이터 조회

2

renderFrames()가 concurrency 수만큼 Chrome 페이지를 열고 프레임을 분배

3

각 페이지에서 window.remotion_setFrame(n) → React 리렌더 → waitForDelayRender() → screenshot()

4

delayRender 배열이 비어질 때까지(= 모든 비동기 완료) 스크린샷 대기

5

stitchFramesToVideo()가 FFmpeg로 PNG 시퀀스 → MP4/WebM 인코딩 + 오디오 mux

장점

  • remotion_setFrame → React 리렌더 패턴: 기존 React 생태계를 그대로 활용하는 우아한 설계
  • delayRender가 단순 카운터 → 복잡한 비동기 시나리오도 안전하게 처리

단점

  • Chrome 프로세스 관리 복잡: 페이지 크래시, 메모리 누수 등 브라우저 특유 문제

사용 사례

렌더링 병목 디버깅: 어느 단계(번들/프레임/인코딩)에서 시간이 걸리는지 파악 delayRender 타임아웃 디버깅: continueRender가 호출되지 않는 원인 추적