
ffmpeg의 filter_complex 지옥에서 벗어나, AI 뉴스 프로그램을 전체 React로 구현하기
요약
ffmpeg의 복잡한 filter_complex 체인을 React 기반 영상 프레임워크인 Remotion으로 전면 교체하여 AI 뉴스 제작 파이프라인을 개선한 사례입니다. 렌더링 성능 벤치마크를 통해 안정성을 검증하고, ffmpeg 시절의 복잡한 시간 계산 로직을 Remotion의 수학적 구조로 성공적으로 이식했습니다.
핵심 포인트
- ffmpeg의 복잡한 문자열 조립 대신 React 컴포넌트로 영상 연출 제어
- Remotion을 활용해 10분 분량 영상을 약 9분대에 렌더링하는 성능 확보
- ffmpeg의 '총 길이 보존 법칙'을 Remotion의 TransitionSeries 로직으로 이식
- Claude Code를 활용한 효율적인 개발 프로세스 경험
서론
매일 아침 6시 15분, AI가 알아서 뉴스 프로그램을 제작하여 YouTube에 업로드하는 파이프라인(지난번에는 아바타에 대해 썼습니다)이 있습니다. 이번에는 그 영상 렌더링을 ffmpeg의 filter_complex 체인에서, Remotion(React로 영상을 만드는 프레임워크)으로 전면 이관한 이야기입니다.
"도표 슬라이드만 Remotion화하는 하이브리드 구성"으로 며칠간 운용한 후, 나머지 전부—인트로·트랜지션·자막·아바타 합성·진행 바—를 React 컴포넌트로 교체했습니다. 작업은 거의 하루 만에 끝났으며, 평소와 마찬가지로 Claude Code와의 대화만으로 완주했습니다.
덧붙여 같은 날, 시청자 경험 측면의 세세한 개선(음량·심층 슬라이드·타이틀 콜)도 한꺼번에 적용했으므로 그 과정에서 얻은 배움도 함께 적겠습니다.
왜 전면 이관했는가: 「총 길이 보존 법칙」이라는 부채
하이브리드 구성 시점에서 영상은 세 가지 기술의 혼합이었습니다.
Pillow: 슬라이드 PNG·자막 바(telop bar) 그리기 -
ffmpeg filter_complex: xfade 트랜지션·아바타 합성·자막 입히기 -
Remotion: 도표 슬라이드의 애니메이션만
가장 큰 문제아는 xfade였습니다. 크로스페이드(crossfade)는 앞뒤 영상이 겹치는 만큼 총 길이가 줄어들기 때문에, 다음과 같은 규칙으로 슬라이드 이미지를 "길게" 생성해야 했습니다.
마지막 이외의 슬라이드: +D초 (D=트랜지션 길이)
인트로가 있는 경우의 슬라이드 0: +2D초
매우 짧은 슬라이드에서는 D를 자동 축퇴(degrade), 0.05초 미만은 concat으로 전환
저는 이를 「총 길이 보존 법칙」이라고 불렀는데, 요컨대 xfade의 사양이 만들어낸 복잡성이 코드 전체로 스며들어 있었던 것입니다. 트랜지션을 fade에서 다른 연출로 바꾸고 싶어도, filter_complex의 문자열 조립을 건드리는 것은 매우 부담스러운 일이었습니다.
관문: 렌더링 시간 벤치마크
이관의 최대 리스크는 렌더링 시간입니다. 브라우저(Chromium)에서 프레임을 한 장씩 그리는 Remotion은 원리적으로 ffmpeg보다 느립니다. 그래서 우선 "10분 분량의 프로그램 풀 구성을 30분 이내에 렌더링하지 못하면 중단한다"라는 관문을 설정하고, 실제 크기의 벤치마크용 컴포지션(인트로+슬라이드 14장+자막+아바타 영상+진행 바)을 만들었습니다.
결과는 17,844 프레임(9분 55초)을 9분 18초 만에 완료. 거의 실시간에 가깝게 렌더링되어 관문을 여유롭게 통과했습니다. 메모리 피크도 1.2GB였으므로, 이관을 진행(GO)하기로 했습니다.
총 길이 보존 법칙을 TransitionSeries의 수학으로 이식하기
Remotion의 @remotion/transitions는 시퀀스 간의 겹침을 선언적으로 작성할 수 있습니다. 여기서 흥미로웠던 점은, ffmpeg 시절의 총 길이 보존 법칙이 TransitionSeries의 overlap 계산에 그대로 사상(mapping)될 수 있었다는 것입니다.
TransitionSeries에서는 각 시퀀스의 길이에 전이(transition)의 겹침이 포함되므로, 유도하면 결국 다음과 같습니다.
인트로: 순수 길이
슬라이드 0: +2D (인트로가 있을 때)
중간 슬라이드: +D
...
……ffmpeg 시절의 규칙과 완전히 동형(isomorphic)이었습니다. 이식이라기보다 "같은 수학의 다른 표현"임을 깨달은 순간이 이번 작업의 하이라이트입니다. 경계 프레임은 누적 초(second)로부터의 반올림으로 결정함으로써, 소수점이 많은 나레이션 길이에서도 총 길이가 ±1 프레임 이내로 들어옵니다. 이 부분은 유닛 테스트를 통해 총 길이 보존을 불변 조건(invariant)으로 고정했습니다.
def test_segment_frames_rounding_no_drift():
durs = [7.37, 51.113, 44.71, 62.005, 38.99, 55.5, 60.107]
intro_f, segs, d_f = compute_segment_frames(durs, intro_sec=4.0, ...)
...
투명 아바타: 실시간 크로마키는 필요 없었다
아바타는 초록색 배경 영상을 ffmpeg의 chromakey로 추출하여 합성하고 있었습니다. Remotion에는 크로마키 기능이 없습니다. WebGL로 직접 구현해야 하나...? 라고 잠시 생각했지만, 답은 훨씬 단순했습니다.
사전에 ffmpeg로 키잉(keying)하여, 알파 채널이 포함된 VP9(webm)으로 만들어 두는 것뿐입니다.
ffmpeg -i avatar.mp4 \
-vf "crop=650:720:315:0,chromakey=0x00c800:0.15:0.07" \
-c:v libvpx-vp9 -pix_fmt yuva420p -deadline realtime -cpu-used 6 avatar.webm
Remotion 측은 <OffthreadVideo transparent>로 읽어들이기만 하면 슬라이드 위에 깔끔하게 투명 합성됩니다. VP9 알파 채널의 디코딩 비용(decoding cost)을 걱정했지만, 실제 측정 결과 오차 범위 수준이었습니다.
자막은 「ASS를 역으로 읽기」
자막은 TTS(Text-to-Speech)의 실제 타이밍으로부터 생성한 ASS 파일을 ffmpeg로 입혀(burn-in) 왔습니다. Remotion으로 전환하면서 동일한 타이밍 계산을 재구현하는 방법도 있었지만, 분할 로직(budoux를 이용한 문절 줄바꿈)이나 인트로 오프셋(intro offset)을 이중으로 구현하면 버그의 온상이 되기 때문에, 완성된 ASS를 파싱(parse)하여 props로 변환하는 방식을 택했습니다. 원본(source of truth)은 하나여야 한다는 것이 철칙입니다.
방송 사고를 구조적으로 방지하기
매일 아침 진행되는 완전 자동 방송이므로, "Remotion이 어떤 이유로 실패하여 영상이 나오지 않는 상황"은 허용되지 않습니다. 새로운 렌더러(renderer)는 실패할 경우 에러 로그를 남기고 **기존 ffmpeg 렌더러로 자동 위임(fallback)**합니다. 전환 설정은 단 한 줄이면 충분합니다. 이 보험이 있었기에 E2E(End-to-End) 검증 다음 날 아침부터 바로 실전에 투입할 수 있었습니다.
밟았던 지뢰들
1. git worktree로 인해 테스트가 「다른 코드」를 보고 있었던 문제
운영 중인 디렉토리를 보호하기 위해 git worktree로 개발했는데, conftest.py가 parents[1]로 고정되어 리포지토리 루트(repository root)를 해결하고 있었습니다. 이 때문에 worktree 내부에서는 메인 체크아웃(main checkout)을 가리키게 되어, worktree의 코드가 전혀 테스트되지 않는 상태가 되었습니다. 신규 모듈의 import 에러를 통해 알아차렸지만, 기존 테스트는 전부 "옆에 있는 코드"를 기준으로 통과(green)되고 있었기에 상당히 무서운 함정이었습니다. 루트 탐색은 마커 파일(lib/newscore의 존재 여부)을 확인하는 방식으로 수정했습니다.
2. Remotion의 Loop/Sequence는 absolute-fill로 그리기
컨테이너 div에 사이즈를 지정하지 않으면 높이가 0으로 찌그러져, 영상이 object-fit에 의해 극도로 작게 표시됩니다. "아바타가 사라졌다"의 정체는 바로 이것이었습니다.
3. ffmpeg의 기본 스트림 선택 문제
Remotion의 출력 mp4에는 무음 오디오 트랙이 붙는 경우가 있는데, 오디오를 별도 입력으로부터 mux(multiplex)할 때 -map을 명시하지 않으면 채널 수가 많은 무음 쪽이 선택됩니다. 무음 상태의 완성본이 만들어지고 나서야 이 사실을 깨달았습니다.
덤: 같은 날 적용한 시청자 경험(UX) 개선
음량이 다른 영상보다 작은 문제
사용자(제 파트너)의 "다른 YouTube 영상보다 소리가 작아서 이탈할 것 같다"는 지적에 따라 실측해 보니 -23 LUFS였습니다. YouTube의 기준은 약 -14 LUFS이며, 더욱이 YouTube는 큰 소리는 줄여주지만 작은 소리를 키워주지는 않습니다. 9 LUFS나 작은 상태로 매일 아침 방송을 해왔던 셈입니다. 2-pass loudnorm(linear=true)을 사용하여 -14 LUFS로 정규화(normalize)하여 해결했습니다. 음량 관련 불만이 들어온다면 우선 LUFS를 측정해야 했습니다.
「표 읽어주기」 중단하기
대본에 Markdown 표가 포함되면, TTS가 셀(cell)을 순서대로 읽어 내려가는 지옥이 펼쳐졌습니다. 나레이션에서는 표를 "자세한 내용은 화면의 표를 참고해 주세요"라는 한마디로 대체하고, 슬라이드 측에서 표를 그리드(grid)로 그리는 방식으로 분업화했습니다. 대본 생성 프롬프트에도 "표 직후에는 반드시 구어체로 요점을 덧붙일 것"이라는 규칙을 추가했습니다.
인트로에 타이틀 콜 추가
인트로는 징글(jingle)만 있었기에, 아바타가 말없이 몸짓만 하는 모습이 미묘하게 기괴했습니다. TTS로 "아크로파파의, AI 테크 뉴스!!"를 한 번만 합성하여 캐시(cache)한 뒤 징글 위에 겹쳐 재생합니다. 아바타의 구동 음성도 인트로를 포함하도록 변경했기에, 타이틀 콜 시 마스크의 LED가 빛나며 실제로 말하는 것처럼 보이게 되었습니다. 인트로 영상 자체도 Remotion으로 다시 제작하여(글리치 효과 구성 → 징글과 함께 전체 점등), 한 번 렌더링하여 재사용하므로 런닝 코스트(running cost)는 제로입니다.
요약
- ffmpeg filter_complex의 복잡성(총 길이 보존 법칙)은 TransitionSeries의 수학적 구조와 동형(isomorphic)인 상태로 이식할 수 있었다 - 「10분 프로그램을 30분 이내로」라는 관문을 처음에 설정함으로써, 망설임 없이 이행 판단을 내릴 수 있었다 (실측 9분 18초)
- 크로마키(Chroma key)는 「렌더링 시에 처리할」 필요가 없다. 사전 키잉(Pre-keying) + 알파 채널이 포함된 VP9로 충분하다 - 타이밍 계산의 정본(Single source of truth)은 하나로 통일한다. 완성품(ASS)을 역으로 읽는 것이 이중 구현보다 안전하다
- 전자동 운영으로의 전환은 **자동 폴백(Automatic fallback)**을 먼저 만들면 다음 날부터 바로 실전에 투입할 수 있다 - 음량은 감각이 아닌 LUFS로 측정한다. YouTube는 작은 소리를 도와주지 않는다
이로써 영상 파이프라인은 거의 전부 React가 되었습니다. 트랜지션 연출이나 인트로를 「코드만 작성하면」 바꿀 수 있는 기반이 마련되었으므로, 다음은 연출 측면의 완성도를 높일 것인지, 시청 데이터를 보며 개선할 것인지——계속해서 기록해 나가겠습니다.
(이 기사의 시스템이 매일 아침 제작하는 영상은, YouTube의 아크로파파 AI 테크 뉴스(@acropapa330)에서 공개하고 있습니다)
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기