Deck to Video (슬라이드 + 내레이션 → 자동 재생 MP4)
개요
N개의 슬라이드 이미지(또는 PDF)와 N개의 슬라이드별 오디오 클립을 하나의 MP4 동영상으로 변환합니다. 각 슬라이드는 해당 내레이션 길이와 짧은 일시 정지 시간 동안 표시된 후, 다음 슬라이드로 바로 전환됩니다. 비디오에는 다음과 같은 오버레이가 포함됩니다: 채워지는 진행률 표시줄 + 남은 시간(초) 카운트다운(오른쪽 아래) 그리고 'N / M' 슬라이드 카운터(오른쪽 위).
핵심 명령어 (스크립트는 이 스킬의 scripts/ 디렉토리 안에 있으며, 전체 경로로 호출하세요):
python3 <이-스킬-디렉토리>/scripts/build_video.py SLIDES AUDIO_DIR out.mp4
# SLIDES = slides.pdf (200dpi로 래스터화됨) 또는 슬라이드별 이미지 디렉토리
# (숫자 인식 정렬: s2가 s10보다 앞에 옴; 그래도 0으로 채운 이름 권장)
# AUDIO_DIR = slide01.mp3, slide02.mp3, … — 개수는 슬라이드 개수와 반드시 일치해야 함
# 옵션: --size 1920x1080 · --pad 0.7 · --no-bar · --no-counter · --bar-color HEX · --crf 20
# --highlights hl.json · --anim-fps 30 (아래 '요소 하이라이트' 참조;
# scripts/make_highlights.py가 hl.json을 생성함)
성공 시 각 슬라이드당 한 줄을 출력합니다. 각 슬라이드 길이는 이미 pad를 포함하고 있으며, 마지막에 duration matches segment sum으로 끝납니다 (그렇지 않으면 0이 아닌 종료 코드로 종료).
입력 준비하기
- 슬라이드: pptx에서 시작하는 경우, LibreOffice가 아닌 실제 PowerPoint에서 래스터화하세요. pptx를 실제 PowerPoint를 통해 PDF로 내보낸 다음(office-render 스킬이 macOS에서 이 작업을 수행함), PDF를 스크립트에 전달하세요. LibreOffice는 글꼴을 대체하고 레이아웃을 다시 흐르게 하므로 비디오가 데크 작성자가 의도한 것과 일치하지 않습니다. 200dpi는 1080p에서 밀도 높은 슬라이드 텍스트를 선명하게 유지합니다. (이미 슬라이드별 이미지가 있습니까? 직접 사용하세요. 이 단계를 건너뜁니다.)
- 오디오: 슬라이드당 하나의 클립,
slideNN.mp3형식으로 이름을 지정하세요. 클립이 elevenlabs-tts의generate_tts.py(narrating-pptx에서 사용)에서 생성된 경우 이미 이 형식과 정확히 일치하므로 재사용하여 pptx와 비디오의 사운드가 동일하게 유지되도록 하세요. - 내레이션 속도가 조정된 경우(예:
ffmpeg -filter:a atempo=1.1), 조정된 파일로 비디오를 제작하세요.
오버레이가 ffmpeg 대신 PIL로 구현된 이유 (피 흘린 경험)
t기반 폭 표현식을 사용하는drawbox는 프레임 0부터 전체 막대를 렌더링합니다 — 여러 ffmpeg 빌드에서 표현식이 프레임당 한 번이 아니라 초기화 시 한 번만 평가되기 때문입니다. 실패는 묵시적입니다: 비디오는 정상적으로 인코딩되지만 막대는 절대 움직이지 않습니다. 동일한 빌드에서drawtext표현식은 프레임당 업데이트되므로, 작동하는 카운트다운 옆에 있는 고장난 막대를 놓치기 쉽습니다.- Homebrew의 ffmpeg 병(2026년 이후)은 libfreetype 없이 빌드되었습니다 —
No such filter: 'drawtext'오류가 발생하며, keg 버전(ffmpeg@7,ffmpeg@8)도 동일합니다. 설치 주기를 낭비하지 마세요; 다른 목적으로 전체 빌드가 필요하다면uv tool install static-ffmpeg가 하나를 제공합니다(스크립트는 자동으로 이를 대체 사용합니다).
따라서 스크립트는 PIL로 프레임을 생성하고(막대 채우기, 카운트다운, 카운터) -r 30으로 인코딩된 이미지 시퀀스를 ffmpeg에 공급합니다. 결정적이며, 글꼴이 제어되고, 30fps 스트림을 필터링하는 것보다 저렴하게 인코딩됩니다. 슬라이드에서 아무것도 움직이지 않을 때는 초당 1프레임이 기본값이며 적절합니다 — 초 단위 업데이트는 카운트다운에 자연스럽게 읽힙니다. --highlights를 사용하는 슬라이드는 다음 섹션의 이유로 30fps로 생성됩니다.
스크립트가 이미 만드는 인코딩 선택 (되돌리지 마세요)
| 선택 | 이유 |
|---|---|
apad=pad_dur=PAD + -t seg_dur | 정확한 끝 부분 패딩; 단순한 -shortest는 말이 끝난 후의 일시 중지를 자름 |
yuv420p + 짝수 치수 | 그렇지 않으면 QuickTime/WhatsApp/브라우저에서 재생 실패 |
-tune stillimage, CRF 20 | 정적 슬라이드는 텍스트 깜빡임 없이 잘 압축됨 |
concat demuxer + -c copy | 동일하게 인코딩된 세그먼트의 무손실 결합 |
-movflags +faststart | 다운로드가 완료되기 전에 비디오 스트리밍 시작 |
검증 — 프레임이 증명할 때까지 오버레이가 고장났다고 가정하세요
스크립트는 최종 지속 시간 ≈ 세그먼트 합계를 확인합니다. 오버레이는 눈으로 확인해야 합니다:
ffmpeg -y -ss T1 -i out.mp4 -frames:v 1 a.jpg # 어떤 슬라이드의 초반
ffmpeg -y -ss T2 -i out.mp4 -frames:v 1 b.jpg # 같은 슬라이드의 후반
T1/T2를 스크립트가 출력한 각 슬라이드 길이에서 선택합니다 (슬라이드 경계를 위해 누적 합산하고 pad가 포함되어 있음을 기억하세요). 두 이미지를 모두 확인하세요. 막대가 b.jpg에서 눈에 띄게 더 채워져 있고 카운트다운은 낮아야 하며, 각 새 슬라이드에서 재설정되어야 합니다. 이른 타임스탬프에서 막대가 가득 차 보이는 경우 = 정적 막대 버그 — 이 정확한 확인이 원래 문제를 발견한 방법입니다. 오디오를 헤드리스로 확인할 수 없습니다. 그렇게 말하고, 절대 들었다고 주장하지 마세요.
내레이션과 함께 요소 하이라이트
"왼쪽에… 중간에… 오른쪽에"라고 말하는 내레이션은 오디오 설명 트랙처럼 읽힙니다(자동 발표 데크 참조). 해결책은 언어적 지시어를 제거하고 대신 요소에 글로우(glow) 효과를 주는 것입니다. 단, 타이밍이 오디오에서 나올 때만 가능합니다.
오디오가 마스터이고, 애니메이션은 이를 따릅니다. 절대 마크를 맞추기 위해 내레이션을 작성하지 말고, 구절이 언제 나타날지 추정하지 마세요. 클립을 정상적으로 생성한 다음, ElevenLabs 강제 정렬(POST /v1/forced-alignment, 오디오 + 대본 → 문자별 start/end; elevenlabs-tts의 scripts/align_narration.py로 실행)을 사용하여 각 항목이 실제로 언제 말해졌는지 확인하고, 그로부터 윈도우를 도출하세요. 나중에 클립을 다시 녹음하고 재정렬하면 자동으로 수정됩니다.
- 키워드가 아닌 스크립트의 위치에 기준을 두세요. 단일 키워드는 두 번 나타나는 순간 깨집니다("tags"는 한 슬라이드에 네 번 나타납니다). 내레이션이 각 요소로 전환되는 지점을 스크립트에서 복사한 짧은 구절로 표시하고, 말해진 순서대로 표시하며, 이전 일치 이후에만 각각을 검색하세요. 이것이
make_highlights.py가 하는 일입니다. 스크립트 앞부분에 있는 반복된 단어가 이를 포착할 수 없습니다. (결합된 스크립트에 요소별 세그먼트 오프셋을 유지하는 것도 작동합니다.) - 정렬하기 전에 오디오 태그를 모두 제거하세요.
[confident]는 수행되고 말해지는 것이 아닙니다. 정렬기에 보내면 오디오에서 해당 단어를 찾고 이후의 모든 오프셋을 끌어당깁니다. Eleven v4 태그는 자유 형식([Pause, dry amusement])이므로 괄호 안의 내용을 문자와 공백뿐만 아니라 모두 제거하세요(elevenlabs-tts의 정렬기가 수행함). 동일한 제거된 문자열에 대해 오프셋을 계산하세요. - 반환된 문자 수가 텍스트 길이와 같은지 확인하고,
loss를 주시하세요(건강한 17클립 영어 데크에서 0.04-0.10, 건강한 26클립 히브리어 데크에서 0.59-0.77; 데크 내 명백한 이상값이 신호이며, 절대 임계값이 아닙니다). 불일치는 해당 슬라이드의 모든 윈도우가 잘못되었음을 의미합니다.
--highlights JSON은 scripts/make_highlights.py로 빌드하세요. 세 가지 입력이 필요합니다: 마커(슬라이드별, 내레이션이 해당 요소로 전환되는 구절이 있는 각 영역), 박스(슬라이드별, 영역 → [x, y, w, h]) 및 alignment/ 파일:
python3 <이-스킬-디렉토리>/scripts/make_highlights.py markers.json boxes.json narration/alignment/ hl.json \
--size 1920x1080 [--inches 13.333] # --inches: 박스가 슬라이드 인치 단위이고, 페이지가 이 너비일 때
# markers.json: {"09": [["chart", "Last quarter"], ["callout", "The real problem"], ["_end", "Now, to"]],
# "12": {"marks": [["a", "First"], ["b", "Second"]], "dim": "black", "colour": "#E07A5F"}}
영역의 윈도우는 해당 구절부터 다음 마커의 구절까지 실행됩니다(_end는 다른 것을 열지 않고 하나를 닫고, 그렇지 않으면 마지막 구절은 말하기 끝까지 실행됨). 0.12초 일찍 시작합니다. 1.4초 미만의 윈도우는 경고와 함께 건너뜁니다. 0.5초의 페이드 인/아웃은 볼 것이 거의 남기지 않기 때문입니다. 구절이 스크립트에 없거나, 영역에 박스가 없거나, 박스가 프레임을 벗어나거나, 정렬 파일이 해당 스크립트와 일치하지 않으면 아무것도 쓰지 않고 중지합니다. 박스가 가장자리에서 19px 이내에 있어 글로우가 잘릴 때 경고합니다.
결과 JSON은 0으로 채워진 슬라이드 번호로 키가 지정되며, 박스는 출력 픽셀 단위입니다:
{"09": [{"card": "chart", "box": [87, 468, 549, 374], "colour": "#B45309",
"dim": "white", "start": 13.6, "end": 22.9}]}
box는 데크 자체의 레이아웃 코드에서 가져오세요(인치 → 픽셀). 절대 렌더링을 눈으로 확인하여 가져오지 마세요. 카드의 기하학은 이미 그것을 그린 빌더에서 정확합니다. dim은 밝은 슬라이드에서는 white, 어두운 슬라이드에서는 black입니다. 어두운 슬라이드에서도 글로우 색상을 확인하세요(짙은 자두색 글로우가 거의 검은색 마무리 슬라이드에서 사라졌습니다). 영역은 한 슬라이드에서 여러 번 하이라이트될 수 있습니다. 파일에 없는 슬라이드는 평소처럼 1fps로 생성됩니다.
정렬되면 자막은 공짜로 따라옵니다. 동일한 단어 타이밍으로 두 번째 전사 과정 없이 .srt 파일을 만듭니다:
python3 <이-스킬-디렉토리>/scripts/make_srt.py narration/alignment/ narration/audio/ out.srt --pad 0.7
--pad는 비디오가 빌드된 pad와 일치해야 합니다, 그렇지 않으면 큐가 슬라이드마다 더 멀어집니다.
자막을 제공하는 세 가지 방법과 목적지가 결정합니다.
| 형태 | 방법 | 시청자가 얻는 것 |
|---|---|---|
사이드카 .srt | mp4 옆에 파일 제공 | 실제 플레이어에서 둘 다 열어야만 자막 표시 |
| 소프트 트랙 | -c copy -c:s mov_text -disposition:s:0 default — 무손실 먹싱, ~12 KB, 몇 초 | 플레이어 의존적; CC 제어가 필요함 |
| 하드코딩(번인) | libass를 통해 재인코딩 | 항상 표시, 모든 곳에서, 끌 수 없음 |
사이드카 또는 소프트 트랙을 기본으로 사용하세요. 둘 다 되돌릴 수 있고 그림에 영향을 주지 않습니다. 비디오가 자막 제어가 없는 곳에서 재생될 때만 하드코딩하세요. Slack 또는 Teams 스레드의 인라인 플레이어가 강제하는 경우입니다: 그냥 재생을 누르는 시청자는 다른 두 형태에서 아무것도 얻지 못합니다.
자막 하드코딩하기
ffmpeg -i deck.srt /tmp/subs.ass # 먼저 변환; force_style을 전달하지 마세요
# … /tmp/subs.ass의 Style: 줄을 편집하고 (아래 참조), 다음 실행:
<static-ffmpeg> -i master.mp4 -vf "ass=/tmp/subs.ass" \
-c:v libx264 -crf 20 -preset medium -tune stillimage -pix_fmt yuv420p \
-c:a copy -sn -movflags +faststart out.mp4
각각 한 주기를 소모하는 네 가지 사항:
- Homebrew의 ffmpeg에는 libass가 없습니다 —
drawtext/libfreetype과 같은 격차이며, 오류 메시지는 오해의 소지가 있습니다.No option name near '<filename>'라는 필터그래프 파싱 오류가 발생하며No such filter가 아니므로 인용 버그로 읽히고 이미 올바른 이스케이프를 다시 작성하게 만듭니다.ffmpeg -filters | grep -E ' (ass|subtitles) '로 확인하고 static-ffmpeg 빌드를 사용하세요. .ass파일의 스타일을 정의하고, 필터 문자열에force_style을 절대 우겨넣지 마세요. 그 안의 쉼표는 필터 구분자로 읽히며, 백슬래시 이스케이프는 충분히 취약해서 작동하기 전에 두 번 실패합니다.- ASS 스타일 필드는
Format:헤더를 기준으로 매핑하고, 위치를 세어 절대 매핑하지 마세요. 하나 차이가 나면 Alignment=0(유효하지 않음)을 쓰고 MarginV를 설정하지 않으며, libass는 불평 없이 렌더링합니다. - 하드코딩 전의 마스터에서 하드코딩하고 그 마스터를 보관하세요. 이미 하드코딩된 파일에서 스타일을 변경하면 두 번째 세대가 겹쳐지고 이전 텍스트가 영구적으로 굽혀집니다.
배치 — 밴드 또는 오버레이. 취향이 아니라 데크가 결정합니다.
| 방법 | 비용 | |
|---|---|---|
| 슬라이드 아래 밴드 | pad=1920:1200:0:0:black 후 ass=, 밴드 내부에 MarginV 설정 | 슬라이드에 아무것도 가리지 않음; 출력이 16:9가 아님 |
| 오버레이 (익숙한 VLC 스타일) | ass= 단독; 하단 중앙, 1080p에서 굵게 ~48px, 두꺼운 검은색 외곽선, MarginV ~40 | 고전적이고 자체 포함되지만, 데크가 하단 스트립에 배치하는 모든 것을 가릴 것임 |
오버레이는 대부분의 사람들이 "자막"이라고 생각하는 방식입니다. 텍스트가 빽빽한 데크에서는 프레임 하나를 추출하여 적용하기 전에 확인하세요: 하단에 있는 각주, 범례 및 진행률 표시줄 위에 표시됩니다.
1fps는 동기화 한계일 뿐만 아니라 부드러움의 한계이기도 합니다. 기본 생성은 초당 1프레임이므로 하이라이트는 정수 초에서만 변경될 수 있습니다. 가리키는 단어보다 최대 1초까지 차이가 나며, 이는 깨진 것으로 보입니다. 애니메이션 슬라이드에는 ~30fps가 필요합니다.
30fps는 프레임을 캐시하면 저렴합니다. 60초 슬라이드는 1,800프레임이지만, 거의 모두 픽셀 단위로 동일합니다. 글로우는 페이드 중에만 움직입니다. 각 프레임을 시각적 상태로 키를 지정하고 중복은 하드 링크하세요:
key = (active_element, round(fade_alpha, 2), int(t)) # int(t) = 막대의 스텝
if key in cache: os.link(cache[key], path); continue
슬라이드당 ~1,800개 대신 120-200개의 실제 합성물이며 추가 디스크가 없습니다. 하지만 진행률 표시줄을 먼저 정수 초로 양자화하세요 — 지속적으로 움직이는 막대는 모든 프레임을 고유하게 만들어 캐시를 완전히 무력화합니다. (1fps에서는 이미 그렇게 작동했으므로 잃는 것은 없습니다.)
어두운 슬라이드에서는 색조(dim)를 반전시키세요. 밝은 슬라이드에서 비활성 요소를 반투명 흰색으로 씻어내면 뒤로 밀려나고, 어두운 슬라이드에서는 밝아집니다. 색조 색상을 데크별이 아닌 슬라이드별로 전달하세요.
너무 빨리 말해지는 것은 애니메이션으로 만들지 마세요. 적용하기 전에 말해지는 시간을 측정하세요: ~1.2초에 걸쳐 서너 개의 목록 항목을 빠르게 말하면 0.5초 페이드 인/아웃 후 약 0.2초의 글로우만 남고 깜빡입니다. 각 항목에 내레이션에서 고유한 비트를 부여하거나(보통 개선됨, 한 호흡 목록은 모든 데크에서 가장 빽빽한 것임) 슬라이드를 정적으로 두세요.
일반적인 실수
| 실수 | 결과 | 수정 |
|---|---|---|
ffmpeg drawbox가 t 표현식으로 애니메이션됨 | 진행률 표시줄이 프레임 0에서 가득 차 보이고 절대 움직이지 않음 (여러 빌드에서) | 대신 PIL 기반 프레임 생성을 사용하세요. 이 스크립트는 이미 이렇게 합니다. |
| 출력이 QuickTime/WhatsApp/브라우저에서 재생되지 않음 | 잘못된 픽셀 형식 또는 홀수 치수 | -pix_fmt yuv420p를 강제하고 치수를 짝수로 만드세요. 스크립트가 자동으로 수행합니다. |
| 자막이 Slack/Teams 인라인 플레이어에 표시되지 않음 | 사이드카 .srt 또는 소프트 트랙은 인라인 플레이어에서 로드되지 않음 | 해당 플레이어의 경우 자막을 하드코딩(번인)하세요. |
-c copy로 먹싱한 후 비디오 끝이 잘림 | 오디오 패딩이 없는 -shortest가 오디오 완료 시 비디오를 자름 | apad를 사용하고 명시적 -t로 정확한 지속 시간을 설정하세요. 스크립트가 이 작업을 수행합니다. |
ffmpeg 오류 No option name near '<filename>' | Homebrew ffmpeg에 libass 없음; 잘못된 파싱 오류 | static-ffmpeg 빌드(uv tool install static-ffmpeg)를 사용하세요. |
--highlights가 빈 JSON을 생성함(아무것도 쓰지 않고 중지) | 스크립트에 구절이 없거나, 영역에 박스가 없거나, 박스가 프레임 밖에 있거나, 정렬 파일 불일치 | 이러한 각 조건에 대한 경고를 확인하세요. |
문제 해결 및 디버깅
- 오버레이가 잘못된 경우: 스크립트가 생성한 시각적 프레임(
.jpg파일)을 검사하세요. 진행률 표시줄과 카운트다운이 예상대로 움직이는지 확인하세요. - 자막 동기화가 맞지 않는 경우:
.srt파일이 생성될 때--pad값이 비디오를 빌드할 때 사용된 pad 값과 정확히 일치하는지 확인하세요. 그렇지 않으면 각 슬라이드에서 점점 더 어긋납니다. - 하이라이트가 깜빡이거나 표시되지 않는 경우:
make_highlights.py의 경고를 확인하세요. 하이라이트 기간이 너무 짧거나(1.4초 미만) 박스가 가장자리에 너무 가까울 수 있습니다. - ffmpeg 관련 오류:
uv tool install static-ffmpeg로 설치된 static-ffmpeg 빌드를 사용하고 있는지 확인하세요. Homebrew의 ffmpeg는drawtext또는ass필터가 없을 수 있습니다.
종속성
- Python 3 (테스트된 버전: 3.10+)
- Pillow (PIL) — 프레임 생성용
- ffmpeg 또는 static-ffmpeg — 비디오 인코딩 및 먹싱용
- poppler (선택 사항, PDF를 이미지로 변환하는 경우) —
pdftoppm또는 유사 도구 - elevenlabs-tts (선택 사항, 강제 정렬 또는 TTS 생성의 경우)
스크립트는 scripts/ 디렉토리 내에 있으며 종속성을 확인하고 필요한 경우 안내합니다.