블로그 문제 해결

Remotion 영상 렌더 속도 높이기 — 인코더 튜닝 대신 구간별 스틸과 ffmpeg concat

브라우저로 프레임을 그리는 Remotion 렌더에서 x264 프리셋·GPU·동시성을 바꿔 본 실측값과, 정지 화면이 많은 영상을 구간별 스틸 1장과 ffmpeg concat으로 이어 붙여 15분 걸리던 렌더를 90.2초로 줄인 방법을 적었습니다.

웹페이지로 화면을 만들고 Remotion으로 영상을 뽑는 파이프라인을 집 서버(Intel i5-8500B Mac mini)에서 돌리고 있습니다. 5분짜리 영상 하나를 렌더하는 데 약 15분이 걸려서 56초짜리 쇼츠로 x264 프리셋과 GPU 경로, 동시성을 바꿔 가며 쟀는데, 가장 많이 줄인 경우가 11%였습니다. 시간은 인코딩이 아니라 브라우저가 프레임을 한 장씩 그리는 데서 나가고 있었습니다. 화면이 바뀌는 구간마다 스틸 이미지 1장만 뽑고 ffmpeg concat으로 이어 붙이는 초안 렌더를 따로 만들었더니, 같은 5분 영상이 90.2초에 끝났습니다.

이 글에는 옵션별 실측값, 병목을 확인한 방법, 스틸과 concat으로 영상을 만드는 순서, 길이가 어긋나지 않게 계산하는 법, 이 방식으로 안 되는 것을 적었습니다.

같은 56초 영상에서 렌더 옵션을 바꾼 시간은 84.5초에서 75.4초 사이였고, 5분 영상은 프레임별 렌더 약 15분에서 스틸 방식 90.2초로 줄었다

Remotion 렌더가 느린 이유: 매 프레임을 브라우저가 그린다

Remotion은 React로 만든 화면을 헤드리스 Chrome에 띄우고 프레임마다 스크린샷을 찍은 뒤, 그 이미지들을 ffmpeg로 인코딩해 영상을 만듭니다. 제 5분 영상은 9,000프레임이었으니 Chrome이 화면을 9,000번 그린 셈입니다.

그래서 렌더 시간은 프레임을 그리는 시간과 그 프레임을 압축하는 시간으로 나뉩니다. 느리다고 느끼면 대개 압축 쪽 옵션부터 손대게 되는데, 저도 그랬습니다.

x264 프리셋·GPU·동시성 옵션별 실측

렌더 스크립트에 --gl, --x264-preset, --concurrency 옵션을 열어 두고, 56초짜리 세로 쇼츠(1,685프레임, 1080×1920)로 조합을 바꿔 가며 쟀습니다. 기본값으로 같은 렌더를 반복했을 때 오차는 ±0.3초 안이었습니다.

설정렌더 시간파일 크기
기본 (medium, 동시성 3)84.5초9.85MB
gl=angle (GPU 경로)80.9초9.96MB
동시성 582.6초9.85MB
동시성 5 + veryfast79.4초8.85MB
동시성 5 + ultrafast75.4초28.8MB

가장 빠른 건 ultrafast였지만, 기준보다 약 11% 줄어드는 대신 파일이 3배 가까이 커져서 쓰지 않았습니다. 서버 기본값은 동시성 5 + veryfast로 정했고, 이 조합은 기준보다 약 6% 빠르고 파일도 조금 작습니다.

GPU 경로(gl=angle)는 4% 정도 빨라지는 데 그쳤습니다. 화면이 DOM과 CSS로 만든 카드와 글자라서 GPU가 맡을 3D 연산이나 셰이더가 거의 없었습니다. WebGL이나 캔버스 연산이 많은 화면이라면 결과가 다를 수 있습니다.

병목이 인코딩인지 확인하는 방법

옵션을 끝까지 밀어도 10% 근처에서 멈춘다면 지금 손대는 인코딩은 병목이 아닙니다. 프레임 수를 렌더 시간으로 나눠 보면 이게 보입니다.

56초 영상은 1,685프레임을 80초 안팎에 그렸으니 초당 약 20프레임이고, 인코더 프리셋을 가장 가볍게 바꿔도 이 값이 크게 움직이지 않았습니다. 압축을 거의 안 하는 설정에서도 처리량이 그대로라면 시간은 인코더 앞, Chrome이 프레임을 래스터화하는 데서 나가고 있다는 뜻입니다. 인코더를 아무리 바꿔도 그려야 할 9,000장은 그대로 9,000장입니다.

여기까지 오면 질문이 “프레임을 어떻게 더 빨리 그리나”에서 “9,000장을 다 그려야 하나”로 바뀝니다.

구간별 스틸 1장 + ffmpeg concat으로 초안 렌더 만들기

제 영상은 대부분이 정지된 카드 화면이고, 한 카드가 몇 초씩 떠 있습니다. 그 몇 초 동안 Chrome은 같은 그림을 수십 번 다시 그리고 있었습니다. 그래서 검수용 초안 렌더(--draft)를 따로 만들었습니다. 순서는 이렇습니다.

  1. 타임라인의 구간마다 중간 지점 프레임 하나를 Remotion renderStill()로 PNG 1장씩 뽑습니다. 등장 애니메이션이 구간 시작 직후에 끝나기 때문에 중간 프레임이 완성된 화면입니다. 브라우저는 매번 새로 띄우지 않고 재사용합니다.
  2. 각 PNG가 화면에 머물 시간을 계산해 concat 목록 파일을 씁니다.
  3. ffmpeg 한 번으로 이미지 목록과 음성 파일을 합쳐 인코딩합니다.

concat 목록 파일은 이런 모양입니다.

ffconcat version 1.0
file 'd-000.png'
duration 3.2
file 'd-001.png'
duration 2.8
...
file 'd-098.png'
duration 4.1
file 'd-098.png'

마지막 파일을 한 번 더 적은 건 마지막 항목의 duration이 반영되지 않는 문제를 피하려고 넣은 것입니다.

인코딩과 음성 합치기는 명령 하나로 합니다.

ffmpeg -y -f concat -safe 0 -i draft-stills/list.txt -i <음성파일> \
  -vf "fps=<fps>,scale=<W>:<H>:force_original_aspect_ratio=decrease,pad=<W>:<H>:(ow-iw)/2:(oh-ih)/2,format=yuv420p" \
  -c:v libx264 -preset veryfast -crf 21 \
  -c:a aac -b:a 192k \
  -t <영상 길이> \
  <출력파일>

5분 영상(1920×1080, 99구간)에서 그리는 장수가 9,000장에서 99장으로 줄었고, 렌더는 약 15분에서 90.2초가 됐습니다. 시간 대부분은 스틸을 뽑는 데 쓰이고 ffmpeg 조립은 몇 초면 끝납니다. M4 맥에서는 56초 영상이 프레임별 렌더 45초, 스틸 방식 6.6초였습니다.

concat 목록 형식과 duration 지시어 설명은 ffmpeg 공식 문서의 concat demuxer 항목에, renderStill()은 Remotion 문서에 있습니다.

영상 길이가 어긋나지 않게 duration 계산하기

ffmpeg concat은 앞 파일의 duration을 기준으로 다음 파일의 시작 시각을 정합니다. 그래서 구간 길이를 끝 - 시작으로 하나씩 반올림하면 오차가 쌓여 뒤로 갈수록 음성과 화면이 벌어집니다. 저는 구간 경계를 먼저 프레임 단위로 반올림하고 그 차이로 길이를 구했습니다.

const durFrames_i = Math.round(seg.end * fps) - Math.round(seg.start * fps); // round(end-start)가 아님
const durSec_i = durFrames_i / fps;

이렇게 하면 길이를 모두 더한 값이 전체 영상 길이와 프레임 단위로 맞습니다. 5분 영상 결과물 길이는 361.37초, 타임라인 길이는 361.35초로 차이가 0.02초였고, 비디오와 오디오 스트림 모두 정상이었습니다.

스틸 방식으로 안 되는 것

스틸 방식은 검수용입니다. 최종 업로드용 영상은 지금도 프레임별 렌더로 뽑습니다.

  • 등장 애니메이션은 완성된 모습만 남고 움직임은 사라집니다. 구간 안에서는 정지 화면입니다.
  • 단어마다 형광펜이 옮겨 가는 자막은 구간 중간 단어에 멈춘 채로 나옵니다. 프레임별 렌더와 가장 크게 달라 보이는 부분입니다.
  • 진행 막대처럼 영상 전체에 걸쳐 계속 변하는 요소는 구간마다 한 번씩 건너뜁니다. 99구간이면 한 번에 약 1%씩 움직입니다.
  • 실제 영상 클립이 들어간 구간도 정지 화면이 되고, 그 위 자막은 빠집니다. 이 구간은 Remotion 대신 ffmpeg로 원본 영상의 중간 프레임 한 장을 뽑습니다.
  • 구간 전환은 하드컷입니다. 원래 프레임별 렌더도 구간 전환이 하드컷이라 이 부분은 차이가 없습니다.

검수할 때 보는 건 글자와 카드 순서, 음성과 화면이 맞는지라서 움직임이 빠져도 검수에는 지장이 없었습니다.

검토했다가 버린 방법

  • 구간마다 renderMedia로 짧은 영상을 따로 뽑아 이어 붙이기: 호출마다 합성을 준비하는 비용이 커서 99번 부르면 스틸을 뽑는 쪽보다 느립니다.
  • 화면을 <Freeze>로 감싸 프레임별 렌더 그대로 돌리기: 그림 내용만 같아질 뿐 Chrome은 여전히 9,000장을 그립니다.
  • xfade 필터로 구간 사이 크로스페이드 넣기: 입력 99개짜리 필터 그래프는 깨지기 쉽고, 원래 렌더도 하드컷이라 넣을 이유가 없었습니다.

같은 파이프라인에서 LLM 호출을 잘게 쪼갰다가 오히려 느려진 일은 호출당 고정비 68초를 잰 글에, 렌더를 돌리는 서버 구성은 중고 Mac mini 2018을 회사 서버로 쓰는 글에 적었습니다.

자주 묻는 질문

ffmpeg 인코더 프리셋을 ultrafast로 바꾸면 렌더가 빨라지나요?

인코딩 자체가 병목일 때만 효과가 있습니다. 제 경우 ultrafast로 바꿔도 전체 시간은 11% 남짓 줄어드는 데 그쳤고, 대신 파일 크기가 9.85MB에서 28.8MB로 약 3배 커졌습니다. 병목이 인코딩이 아니라면 프리셋은 크기만 키우고 시간은 거의 못 줄입니다.

브라우저로 영상을 렌더할 때 GPU를 켜면 빨라지나요?

내용에 따라 다릅니다. DOM과 CSS 합성으로 화면을 그리는 경우 GPU가 실제로 할 연산이 거의 없어서, gl=angle 같은 GPU 백엔드를 켜도 체감 이득이 거의 없었습니다. 3D나 WebGL 캔버스를 많이 쓴다면 이야기가 달라집니다.

프레임별로 렌더하지 않고 영상 길이를 맞추는 방법이 있나요?

변화가 없는 구간은 스틸 이미지 1장으로 캡처하고, 각 스틸에 지속 시간을 지정해 ffmpeg concat으로 이어 붙이면 됩니다. 저는 9,000프레임을 99장으로 줄이고도 최종 영상 길이 오차를 ±0.02초로 맞출 수 있었습니다.

성능 튜닝이 한계에 부딪혔을 때 무엇을 먼저 의심해야 하나요?

파라미터를 아무리 조여도 개선폭이 한 자릿수 퍼센트에서 멈춘다면, 지금 튜닝하는 대상이 진짜 병목이 아닐 가능성이 큽니다. 프로파일을 다시 떠서 시간이 실제로 어디서 소모되는지, 측정 대상 자체를 의심하는 편이 빠릅니다.

#ffmpeg#영상 렌더링#성능 최적화#병목 분석#1인 개발