edge-tts와 ffmpeg를 사용하여 스크립트 기반의 2인 대화형 비디오 파이프라인을 구축하며 배운 점
요약
edge-tts와 ffmpeg를 활용하여 GitHub Actions 환경에서 자동화 가능한 2인 대화형 비디오 생성 파이프라인 구축 방법을 소개합니다. JSON 명세를 기반으로 자연스러운 대화 리듬과 슬라이드 렌더링을 구현하는 설계 노하우를 다룹니다.
핵심 포인트
- edge-tts를 활용한 비용 없는 고품질 신경망 음성 합성
- GitHub Actions 내에서 결정론적인 MP4 생성을 위한 CI 우선 설계
- 자연스러운 대화 흐름을 위한 짧은 텍스트 세그먼트 구성
- Pillow를 이용한 경량화된 슬라이드 이미지 렌더링 방식
이전 글에서 작성했던 YouTube Shorts 파이프라인에서 계속 마주쳤던 한 가지 한계는 바로 형식(format)이었습니다. Shorts는 세로형의 60초 콘텐츠로 형식이 제한됩니다. 누군가에게 실제로 도움이 될 만큼 충분한 깊이로 "NocoDB vs Baserow vs Teable, 당신의 사용 사례에는 어떤 것이 적합할까"와 같은 내용을 다루는 더 길고 설명적인 콘텐츠를 위해서는, 16:9 비율과 8~12분의 길이, 그리고 로봇이 목록을 소리 내어 읽는 것처럼 느껴지지 않는 형식이 필요했습니다.
2인 대화(two-host dialogue)는 그러한 느낌을 만들어내는 가장 단순한 형식입니다. 두 목소리가 번갈아 가며 말하고, 사소한 부분에서 의견을 달리하며, 자연스럽게 대화를 주고받습니다. 이러한 리듬은 단일 목소리의 내레이션이 할 수 없는 방식으로 10분 동안 주의를 집중시킵니다. 저는 비디오 도구를 구매하는 대신 처음부터 직접 구축했는데, CI 우선(CI-first) 제약 조건 때문에 대부분의 옵션이 제외되었기 때문입니다. 즉, 렌더링(render)은 GitHub Actions 작업 내에서 사람의 개입 없이, 커밋(commit)으로부터 결정론적인(deterministic) MP4 파일을 생성해야 합니다.
스펙 JSON 형식
모든 것은 비디오 메타데이터와 segments 배열이 포함된 JSON 파일로부터 흐릅니다.
{
"title": "NocoDB vs Baserow vs Teable: 어떤 Airtable 대안을 선택할 것인가",
"description": "우리는 세 가지 오픈 소스 Airtable 대안을 비교합니다...",
...
해당 구조에서의 몇 가지 설계 선택 사항은 다음과 같습니다:
슬라이드(Slides)는 세그먼트(segment)별로 선택 사항입니다. 만약 세그먼트에서 slide 키를 생략하면, 렌더러(renderer)는 이전 슬라이드를 그대로 유지합니다. 이를 통해 모든 문장마다 새로운 시각 자료를 요구하지 않고도 대화로 밀도 높은 스크립트를 구성할 수 있습니다. 전환(Transitions)은 문장이 바뀔 때가 아니라 주제가 바뀔 때 일어납니다.
화자(Speaker)는 A 또는 B입니다. 파이프라인은 A를 en-US-AndrewNeural로, B를 en-US-AvaNeural로 매핑합니다. 둘 다 edge-tts 신경망 음성(neural voices)입니다. 이는 오픈 소스 edge-tts Python 패키지를 통해 접근하는 Microsoft의 Text to Speech API이며, 이 패키지는 Edge 브라우저가 몰입형 읽기(Immersive Reader)를 위해 사용하는 것과 동일한 엔드포인트를 호출하므로 API 키도 필요 없고 비용도 들지 않습니다.
텍스트 세그먼트는 짧은 대화형 문장입니다. 긴 단락은 합성되었을 때 부자연스럽게 들립니다. 각 세그먼트를 25단어 미만으로 유지하면 문장 경계에서의 적절한 휴지(pause)를 포함하여 더 자연스러운 말하기 리듬을 생성할 수 있습니다. 저는 Claude를 사용하여 명세(spec)를 생성합니다. 이는 제가 세 개의 디렉토리 사이트의 ETL 콘텐츠를 위해 사용하는 것과 동일한 공유 Haiku 클라이언트이며, 연속된 산문을 A/B 대화로 자연스럽게 나누는 까다로운 작업을 처리합니다.
Pillow를 사용한 슬라이드 렌더링
슬라이드 렌더러는 Pillow를 사용하여 JSON 슬라이드 명세로부터 1920x1080 PNG 파일을 생성합니다. 브라우저도, Playwright도, Headless Chrome도 필요하지 않습니다. Playwright 기반의 스크린샷 렌더링은 OG 이미지 생성에는 유용하지만, 브라우저 바이너리 다운로드 및 실행으로 인해 CI 시작 시 30~60초의 시간이 추가됩니다. 40개 이상의 슬라이드 이미지를 생성하는 렌더링 작업에서 이러한 오버헤드는 정당화되지 않습니다.
슬라이드 명세는 다섯 가지 kind 값을 지원합니다:
| kind | 레이아웃 (Layout) | 사용 사례 (Use case) |
|---|---|---|
title | 중앙 정렬 제목 + 부제목 | 섹션 전환, 도입부, 종결부 |
| ... |
모든 슬라이드는 동일한 브랜드 크롬(brand chrome)을 그립니다: 상단의 10px 강조 바, 채널 워크마크(wordmark), 사이트 URL이 포함된 푸터(footer), 그리고 선택 사항인 페이지 번호입니다. 크롬 함수는 슬라이드별 콘텐츠가 생성되기 전에 실행되므로, 브랜드 일관성이 자동으로 유지되며 새로운 슬라이드 종류(kind)를 만들 때 실수로 누락될 염려가 없습니다.
폰트 해결(Font resolution) 문제는 제가 예상하지 못했던 CI 대 로컬(CI-vs-local) 환경의 문제입니다. Ubuntu(GitHub Actions)에서는 DejaVu Sans가 /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf에 있습니다. macOS 로컬 개발 환경에서는 /System/Library/Fonts/Supplemental/Arial Bold.ttf에 있습니다. 리졸버(resolver)는 각 굵기(weight)에 대해 후보 목록을 반복 탐색하며, 아무것도 찾지 못할 경우 명확한 에러를 발생시킵니다. CI 설정 단계에서 sudo apt-get install -y fonts-dejavu-core를 실행하면 Ubuntu 케이스를 깔끔하게 해결할 수 있습니다.
_wrap() 헬퍼 함수에 대해서도 언급할 가치가 있습니다. Pillow의 draw.textlength() 메서드는 문자 수가 아니라 렌더링된 픽셀 너비를 측정합니다. 단어 줄 바꿈 (word-wrap) 알고리즘은 이 값을 사용하여 슬라이드 여백 내에 머물면서 단어 경계에서 텍스트를 분할합니다. 이는 가변 폭 글꼴 (proportional fonts)에서 중요합니다. 동일한 글꼴 크기에서 "WWW"는 "iii"보다 훨씬 넓으며, 단순한 문자 수 계산을 사용하면 오른쪽 슬라이드 영역을 벗어나는 줄이 생성될 수 있습니다.
edge-tts를 사용한 대화 합성
각 텍스트 세그먼트는 독립적으로 합성됩니다:
def tts(text: str, speaker: str, out_mp3: str):
for voice in (VOICE.get(speaker), VOICE_FALLBACK.get(speaker)):
r = subprocess.run(
...
여기서 두 가지 사실이 놀라웠습니다.
1000바이트 파일 크기 체크. edge-tts는 요청한 음성이 해당 날짜에 Microsoft 엔드포인트에서 사용 불가능할 경우, 종료 코드(exit code) 0을 반환하면서도 거의 비어 있는 MP3(유효한 MP3 헤더는 있지만 실제 오디오는 없는 상태)를 작성할 수 있습니다. 크기 체크가 없다면, ffmpeg는 지속 시간이 0.001초인 클립을 조용히 생성할 것입니다. 이 체크를 통해 ffmpeg가 실행되기 전에 해당 문제를 잡아낼 수 있습니다.
음성 폴백 (voice fallback). en-US-AndrewNeural과 en-US-AvaNeural이 기본 쌍이며, en-US-GuyNeural과 en-US-AriaNeural이 폴백입니다. Microsoft는 때때로 edge-tts 패키지 버전 간에 신경망 음성 (neural voice) 가용성을 순환시킵니다. 한 쌍의 두 음성은 톤과 리듬이 충분히 유사하여, 한 번의 호출에서 폴백 음성을 사용하더라도 오디오가 어색하게 들리지 않습니다.
파이프라인은 Python API를 직접 사용하는 대신 edge-tts를 서브프로세스 (subprocess)로 호출합니다. 그 이유는 CLI 버전이 내부적으로 음성 협상 (voice negotiation)을 처리하기 때문입니다. Python API를 호출했을 때는 Pillow 렌더링 코드와 비동기 이벤트 루프 (async event loop) 충돌이 발생했는데, 이를 추적할 가치가 없을 정도로 번거로웠습니다.
ffmpeg를 사용하여 클립을 비디오로 조립하기
각 세그먼트는 두 가지 결과물(artifacts)을 생성합니다: PNG 슬라이드 이미지와 MP3 오디오 파일입니다. 이들은 하나의 .ts 전송 스트림 (transport stream) 클립으로 결합됩니다:
run(["ffmpeg", "-y",
"-loop", "1", "-i", slide_img,
"-i", audio_mp3,
...
-loop 1은 오디오가 재생되는 동안 ffmpeg가 정지 이미지(still image)를 유지하도록 만듭니다. -tune stillimage는 x264의 stillimage 프리셋을 사용하는데, 이는 프레임 간 움직임(inter-frame motion)에 비트를 할당하지 않습니다. 클립 내에서 이미지가 변하지 않으므로 이는 올바른 설정입니다. -shortest는 이미지 스트림의 지속 시간이 암시적으로 무한하기 때문에, 오디오가 끝나면 클립을 종료하도록 합니다.
모든 클립의 렌더링이 완료되면, ffmpeg -f concat을 사용하여 이들을 연결(concatenate)합니다:
with open(clips_list, "w") as f:
for p in clip_paths:
f.write(f"file '{os.path.abspath(p)}'\n")
...
-c copy는 연결(concat) 단계에서 재인코딩(re-encode)을 하지 않음을 의미합니다. 이는 인코딩된 데이터에 손을 대지 않고 전송 스트림(transport streams)을 하나로 꿰매는 방식으로, 60개의 클립이 있는 비디오라도 매우 빠르게 처리됩니다. 제가 클립 경로를 인자(arguments)로 전달하는 대신 텍스트 파일에 쓰는 이유는 썸네일 파이프라인이 매니페스트(manifest)를 사용하는 것과 같은 이유 때문입니다. 즉, 80개의 경로를 인자로 넘기면 셸 인자 길이 제한(shell argument length limits)을 초과하게 됩니다.
80개의 세그먼트로 구성된 10분 길이의 비디오를 CI에서 렌더링하는 데 걸리는 총 시간은 4~5분입니다. 대략 3분은 Microsoft 엔드포인트로의 TTS 네트워크 왕복 시간(round trips)이며, 1분은 Pillow 렌더링 및 ffmpeg 작업 시간입니다.
CI 워크플로우 (The CI workflow)
GitHub Actions 워크플로우는 content/yt-longform-queue/*.json 내의 파일을 수정하는 main 브랜치로의 모든 푸시(push)에 대해 트리거됩니다. 경로 필터(path filter)가 핵심입니다. 이는 공유 CI의 다른 파이프라인들—기사 발행, ETL 크론 잡(cron jobs), Bluesky 큐 배출(queue drains)—이 실수로 5분짜리 비디오 렌더링을 트리거하지 않음을 의미합니다.
성공적으로 렌더링 및 업로드가 완료되면, 워크플로(workflow)는 JSON 명세(spec)를 큐(queue) 디렉토리에서 uploaded/ 하위 디렉토리로 이동시키고, 해당 이동 사항을 main 브랜치에 커밋(commit)합니다. 커밋 메시지에는 [skip yt-longform]이 포함되며, 워크플로의 if: 조건문은 자신의 커밋에 의해 재트리거(re-triggering)되는 것을 방지하기 위해 이를 확인합니다. 이 처리가 없다면, 워크플로는 자신의 커밋에 의해 실행되어 새로운 큐 파일을 찾지 못하고 정상 종료되겠지만, 매번 작업 시작(job startup) 비용을 낭비하게 됩니다.
이 패턴 — 큐 디렉토리 → 렌더링 → uploaded로 이동 → skip 토큰과 함께 커밋 — 은 제가 Bluesky 이미지 업로드 파이프라인에서 사용하는 것과 동일합니다. 한 곳에서 이를 안정적으로 작동하게 만든 후, 비디오 큐에 재사용하는 데는 약 20분 정도의 YAML 편집 시간만 소요되었습니다.
업로드 단계에서는 YouTube Data API v3를 사용합니다. 이는 GitHub Actions 시크릿(YT_SERVICE_ACCOUNT_JSON)에서 자격 증명(credentials)을 읽어오는데, 이는 제가 Google 서비스 계정 관련 문서에서 더 자세히 다루었던 JSON 서비스 계정 키입니다. 롱폼(long-form) 파이프라인은 가공되지 않은 JWT 대신 google-api-python-client를 사용하는데, 이는 대용량 MP4 파일을 위한 재개 가능한 업로드(resumable upload) API가 충분히 복잡하여 라이브러리를 사용하는 가치가 있기 때문입니다.
다르게 했을 부분
TTS 합성(synthesis)이 병목 구간(bottleneck)이며, 순차적(sequential)으로 진행됩니다. 각 세그먼트(segment)가 Microsoft 엔드포인트로 왕복하며, 약 2초씩 걸리는 80개의 세그먼트는 순수하게 네트워크 대기 시간만 약 160초가 소요됩니다. asyncio.gather()와 동시 요청 수를 제한하는 세마포어(semaphore)를 사용하여 병렬화(parallelizing)한다면 이를 20~30초로 단축할 수 있을 것입니다. 디버깅을 단순화하기 위해(세그먼트가 실패했을 때 오류 출력이 명확함) 순차적 버전을 유지했지만, 세그먼트가 120개를 넘어 확장하기 전에는 비동기(async) 방식으로 전환할 것입니다.
세그먼트 간의 슬라이드 인계(hand-off)가 명세(spec)에 보이지 않습니다. slide를 생략한 세그먼트는 이전 세그먼트의 설정을 조용히 상속받습니다. 순차적으로 작성할 때는 괜찮지만, 순서를 변경할 때는 혼란을 줄 수 있습니다. 명시적인 `
사양 검증(spec validation)을 먼저 추가했어야 했습니다. 제가 작성한 첫 세 개의 사양에는 slide.kind에 오타가 있었고, 렌더러는 이를 빈 슬라이드를 출력하는 방식으로 처리했습니다. 즉, 조용한 실패(silent failure)였습니다. 구조화된 데이터에 사용하는 JSON-LD 감사 패턴이 저에게 하나의 템플릿을 제공합니다: 값비싼 렌더링 작업 전에 스키마 레벨에서 검증하는 것입니다. 다음 반복(iteration)에서는 JSON 스키마 검증기(validator)를 사전 점검 단계(pre-flight step)로 추가할 예정입니다.
스크립트 생성 품질은 다릅니다. Claude는 합리적인 대화 사양을 작성하지만, A/B 음성 분할이 때때로 한 화자가 특정 부분을 지배하는 쪽으로 치우치는 경향이 있습니다. 프롬프트에
현재 방식으로는 불가능합니다. 이 파이프라인은 정지 이미지로 유지되는 정적 PNG를 생성합니다. 부드러운 전환 (smooth transitions)을 구현하려면 개별 프레임을 렌더링하거나 (매우 느림), 클립 세그먼트 사이에 ffmpeg xfade 필터 그래프 (filter graphs)를 사용해야 합니다. xfade 방식은 가능하지만, 연결 (concat) 단계의 복잡성을 증가시킵니다. 교육용 토킹 헤드 (talking-heads) 형식에서는 슬라이드 간의 갑작스러운 컷 (abrupt cuts)도 괜찮습니다.
60개 세그먼트로 구성된 사양 (spec)을 처리하는 도중 edge-tts가 실패하면 어떻게 되나요?
스크립트는 세그먼트 인덱스와 실패한 텍스트 스니펫을 포함한 명확한 오류 메시지와 함께 즉시 종료됩니다. output.mp4는 작성되지 않으며, ffmpeg는 실행되지 않습니다. 파이프라인이 모든 것을 처음부터 다시 생성하기 때문에 재실행은 안전합니다. 정리해야 할 부분적인 상태 (partial state)가 없습니다.
CI로 푸시하기 전에 로컬에서 사양 (spec)을 어떻게 테스트하나요?
python3 scripts/yt-longform/build_longform.py my-spec.json \
--workdir /tmp/lf --outdir /tmp/lf/slides
open /tmp/lf/output.mp4
로컬 유일한 요구 사항은 ffmpeg와 edge-tts (pip install edge-tts)입니다. 렌더링 결과는 CI와 동일합니다. 파이프라인은 렌더링 중에 GitHub 전용 환경 변수를 사용하지 않으며, 오직 업로드 단계에서만 사용합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기