flowoodz.
← blog
11분 읽기글 flowoodz

Whisper 자막 생성: yt-dlp와 whisper.cpp로 영상에 한글 자막을 자동으로 입힌 파이프라인

Whisper 자막 생성부터 번역, burn-in까지 영상 URL 하나로 도는 파이프라인입니다. 자막 싱크, 폰트, 줄바꿈에서 넘어졌던 지점과 고친 방법을 적었습니다.

목차
  1. 다운로드가 막힐 때가 있다
  2. 전사는 오디오 준비가 반이다
  3. 번역할 때 자막 싱크는 "큐 불변"이 전부다
  4. burn-in에서 만난 폰트와 따옴표 지옥
  5. 마지막 검증에서 "됐겠지"는 금물
  6. 다섯 단계에서 남긴 체크리스트

해외 기술 영상을 볼 때마다 "이거 한글 자막으로 편하게 보고 싶다"는 생각을 했습니다. 유튜브 자동 번역 자막은 품질이 들쑥날쑥하고, X(트위터)에 올라온 영상은 그마저도 없습니다. 그래서 영상 URL 하나만 주면 다운로드 → 음성 전사 → 한글 번역 → SRT 생성 → 영상에 자막을 굽는(burn-in) 것까지 자동으로 도는 파이프라인을 만들었습니다.

도구는 전부 무료 로컬 도구입니다. yt-dlp(다운로드), whisper.cpp(전사), ffmpeg(오디오 추출·burn-in). 전체 흐름은 이렇습니다.

영상 URL
  ↓ yt-dlp          original.mp4
  ↓ ffmpeg          audio.wav (16kHz mono)
  ↓ whisper-cli     transcript.srt (원어)
  ↓ 번역            korean.srt
  ↓ ffmpeg + libass final_ko.mp4 (자막 burn-in)

흐름 자체는 단순한데, 실제로 돌려보니 단계마다 함정이 하나씩 있었습니다. 다운로드와 전사가 핵심처럼 보이지만, 시간을 잡아먹은 건 번역 후 싱크 유지와 burn-in 폰트 지정이었습니다. 그 함정과 해결책이 이 글의 본론입니다.

다운로드가 막힐 때가 있다#

영상 자막 파이프라인 5단계와 각 단계에서 막혔던 지점. 다운로드, 전사, 번역, burn-in, 검증 순서로 진행됩니다

기본은 한 줄입니다.

yt-dlp -o "original.%(ext)s" "<영상 URL>"

유튜브는 거의 문제없는데, X(트위터) 영상에서 HLS fragment 오류로 다운로드가 중간에 죽는 경우가 있었습니다. 이때는 X의 syndication API로 mp4 직통 URL을 뽑아 curl로 받는 우회로가 있습니다. https://cdn.syndication.twimg.com/tweet-result?id=<트윗ID>&token=a를 조회하면 응답 JSON의 video.variants에 해상도별 mp4 URL이 그대로 들어 있습니다. 그중 최고 해상도를 골라 curl로 받으면 끝입니다. 덤으로 트윗 본문 텍스트도 같이 얻을 수 있어서 나중에 요약할 때 유용했습니다.

받은 뒤에는 ffprobe로 duration과 파일 크기를 확인하는 습관을 들였습니다. 다운로드가 조용히 반쯤 끊긴 파일로 뒷단계를 돌리면 시간만 날립니다.

전사는 오디오 준비가 반이다#

whisper.cpp(brew whisper-cpp)를 씁니다. 영상을 바로 넣는 게 아니라 16kHz mono wav를 먼저 뽑아야 합니다.

ffmpeg -y -v error -i original.mp4 -ar 16000 -ac 1 -c:a pcm_s16le audio.wav
 
whisper-cli -m ~/.cache/whisper-models/ggml-small.en.bin -f audio.wav \
  --output-srt --output-file transcript -l en -t 8

모델은 영어 영상 기준 ggml-small.en.bin(약 466MB)을 한 번 받아 캐시에 두고 재사용합니다. 다국어 영상이면 .en이 아닌 small 모델을 씁니다.

여기서 첫 번째 체감 함정. 30분 넘는 영상은 전사가 오래 걸립니다. 터미널을 붙잡고 기다리면 파이프라인 전체가 직렬로 늘어집니다. 그래서 긴 전사는 백그라운드로 던져 두고, 그 사이에 burn-in용 ffmpeg 환경 점검 같은 다음 단계 준비를 합니다. 이 습관 하나로 내 맥북(M 시리즈) 기준 40분짜리 1080p 영상도 전체 파이프라인이 15~20분 안에 끝났습니다.

전사가 끝나면 transcript.srt의 큐 개수를 세어 둡니다. 이 숫자가 이후 모든 검증의 기준선이 됩니다.

번역할 때 자막 싱크는 "큐 불변"이 전부다#

번역 자체는 LLM에게 시키면 되는데, 순진하게 SRT 전체를 던지면 두 가지가 깨집니다. 긴 영상이면 출력이 잘리고, 모델이 친절하게 큐를 합치거나 타임스탬프를 "정리"해 버립니다. 그 순간 자막 싱크는 끝장입니다.

해결책은 규칙을 계약으로 박는 것이었습니다.

  • SRT를 여러 청크로 분할해 병렬 번역 (4분할 기준 번역이 몇 분 안에 끝납니다)
  • 프롬프트에 큐 번호·타임스탬프 절대 불변, 큐 개수 동일을 개수까지 명시
  • 영상 주제를 한 줄 같이 넘깁니다. 전사 오인식 교정에 효과가 컸습니다. 예를 들어 whisper가 "Cloud Code"로 받아 적은 걸 문맥을 알면 Claude Code로 바로잡습니다
  • 기술 용어는 영어 원형 유지 + 한국어 조사, 문체는 "~합니다"로 통일
  • 한 줄 25자 내외, 줄바꿈 최대 1회: 이걸 안 정하면 자막이 화면을 3줄로 덮습니다

병합에서도 한 번 넘어졌습니다. 청크를 cat으로 그냥 이어붙이면 파일 경계에서 빈 줄이 빠져 두 큐가 한 덩어리로 붙어 버립니다. 병합 스크립트를 따로 만들고, 병합 후에는 원본 SRT와 큐 개수·타임스탬프가 100% 일치하는지 기계적으로 검증합니다. 이 검증이 통과하면 싱크는 원본 전사와 동일하다고 보장할 수 있습니다.

burn-in에서 만난 폰트와 따옴표 지옥#

가장 삽질이 많았던 단계입니다.

함정 1: homebrew 기본 ffmpeg에 libass가 없을 수 있습니다. 자막 필터가 아예 없는 빌드라 subtitles= 필터가 조용히 실패합니다. 필터 유무를 먼저 확인하고, 없으면 ffmpeg-full을 씁니다.

ffmpeg -hide_banner -filters 2>/dev/null | grep -cE "subtitles|\bass\b"
# 0이면: brew install ffmpeg-full 후 그쪽 바이너리 사용

함정 2: subtitles=korean.srt:force_style='...' 방식은 따옴표 파싱이 잘 깨집니다. 폰트나 크기를 바꾸려고 force_style을 쓰다가 셸 이스케이프 지옥에 빠졌습니다. 결론은 우회였습니다. SRT를 ASS 포맷으로 변환한 뒤 스타일 라인을 파일에 직접 박고 ass= 필터를 쓰는 쪽이 훨씬 안정적입니다.

ffmpeg -y -v error -i korean.srt korean.ass
# korean.ass의 "Style: Default,..." 라인을 원하는 폰트·크기·외곽선으로 교체
# 예: Apple SD Gothic Neo, 크기 17, 흰 글자 + 반투명 검정 외곽선, 하단 마진 25
ffmpeg -y -v error -i original.mp4 -vf "ass=korean.ass" \
  -c:v libx264 -preset fast -crf 21 -c:a copy final_ko.mp4

한글 폰트는 macOS 기본 Apple SD Gothic Neo를 지정했습니다. 폰트를 안 박으면 환경에 따라 한글이 깨지거나 이상한 폴백 폰트로 나옵니다. 원본 영상에 이미 하드코딩 자막이 있으면 하단 마진(MarginV)을 키워 우리 자막을 위로 올립니다.

마지막 검증에서 "됐겠지"는 금물#

burn-in이 끝나면 서로 다른 두 지점(초반 + 중반)의 프레임을 캡처해서 자막이 실제로 렌더링됐는지 눈으로 확인합니다. 인코딩은 성공했는데 자막 필터만 조용히 안 먹은 케이스를 한 번 겪은 뒤 생긴 절차입니다.

ffmpeg -y -v error -ss 00:00:05 -i final_ko.mp4 -frames:v 1 check1.jpg
ffmpeg -y -v error -ss 00:20:00 -i final_ko.mp4 -frames:v 1 check2.jpg

확인한 장면을 공유할 때는 정지 화면보다 짧은 GIF가 맥락을 더 잘 보여 줍니다. gif-moments는 가입 없이 브라우저에서 영상을 GIF로 자릅니다.

macOS 특유의 마무리도 하나 있습니다. 샌드박스 셸에서 만든 파일에는 quarantine 속성이 붙어서 IINA 같은 서드파티 플레이어가 Gatekeeper 경고를 띄웁니다. xattr -d com.apple.quarantine final_ko.mp4 한 줄로 정리합니다. 공증(notarization)을 받지 않은 앱도 quarantine을 지워야 열립니다. 예를 들어 Loupecast는 설치 뒤 xattr -dr com.apple.quarantine /Applications/Loupecast.app을 실행하라고 안내합니다.

다섯 단계에서 남긴 체크리스트#

막힌 지점 해결
X 영상 HLS fragment 오류 syndication API로 mp4 직통 URL 확보
긴 영상 전사 대기 백그라운드 실행 + 그 사이 다음 단계 준비
번역 후 자막 싱크 붕괴 청크 분할 + 큐 개수·타임스탬프 불변 계약 + 기계 검증
cat 병합 시 큐 붙음 전용 병합 스크립트 + 큐 개수 재검증
brew ffmpeg에 libass 없음 필터 확인 후 ffmpeg-full
force_style 따옴표 파싱 깨짐 SRT→ASS 변환 후 스타일을 파일에 직접 지정
한글 폰트 깨짐 Apple SD Gothic Neo 명시
자막 3줄 덮임 한 줄 25자·줄바꿈 1회 규칙

핵심은 도구가 아니라 단계 사이의 검증이었습니다. 다운로드 후 ffprobe, 번역 후 큐 개수 대조, burn-in 후 프레임 캡처. 각 단계가 조용히 실패할 수 있다는 걸 인정하고 나서야 파이프라인이 믿을 만해졌습니다. 한 번 만들어 두니 이제 영상 URL을 던지는 것으로 제 일은 끝납니다.