쇼츠 타이틀 자동 합성: ffmpeg 오버레이와 쇼츠 탭 3:4 크롭 검사를 스크립트 하나로 묶은 기록
쇼츠 타이틀을 편집본 위에 자동으로 합성하고, 쇼츠 탭 3:4 크롭에서 타이틀이 보이는지 검사하는 것까지 스크립트 하나로 끝낸 기록입니다.
목차
쇼츠를 만들 때마다 편집 앱을 열어 타이틀 텍스트를 얹고, 위치를 미세 조정하고, 다시 내보내는 작업을 반복하고 있었습니다. 영상 편집 자체는 손으로 하는 게 맞다고 결론을 냈지만, 타이틀 합성과 QC는 매번 똑같은 작업입니다. 똑같은 작업이면 스크립트가 해야 합니다. 그래서 ffmpeg + Python(Pillow) 조합으로 타이틀 오버레이 파이프라인을 만들었고, 지금은 타이틀 문구 두 줄만 바꾸면 빌드부터 쇼츠 탭 미리보기 QC까지 한 번에 돌아갑니다.
파이프라인 전체 구조#
소스는 항상 사용자 편집본입니다. AI가 클립을 이어 붙인 결과물을 최종 소스로 쓰지 않는다는 건 이전 실험에서 확정한 원칙입니다 (그 시행착오는 별도 글로 정리했습니다). 이 글은 그 다음 단계, 편집이 끝난 mp4에 타이틀을 얹는 자동화만 다룹니다.
흐름은 이렇습니다.
사용자 편집본.mp4
→ Python: title_head.png 생성 (검은 헤더에 1줄+2줄 타이틀)
→ ffmpeg: 본문 crop → pad로 아래로 시프트
→ ffmpeg: 하단 바 overlay
→ ffmpeg: title_head overlay (y=0)
→ 최종 Shorts.mp4
→ 자동 QC: 3:4 크롭 미리보기 PNG (2줄 VISIBLE 체크)빌드 스크립트 하나(build_shorts.sh)가 이 전부를 순서대로 실행합니다. 새 소재가 생기면 스크립트를 복제해서 상단의 소스 경로와 타이틀 문구만 바꿉니다.
상하단 바를 먼저 측정합니다. 하드코딩하면 다음 영상에서 깨집니다#
세로 쇼츠(1080×1920)에서 제 편집본은 상단 바 | 본문 | 하단 바 3단 구조입니다. 테스트 영상 기준으로 측정하면 이렇습니다.
TOP_BAR = 352px (y 0..351)
CONTENT = 1216px (y 352..1567)
BOTTOM_BAR = 352px (y 1568..1919)여기서 얻은 교훈 하나. 352는 이 영상만의 값입니다. 처음엔 상수로 박아두려 했는데, 편집 템플릿이 조금만 달라져도 crop 좌표가 전부 틀어집니다. 그래서 스크립트에 "새 소스마다 프레임을 추출해 바 높이를 재측정한다"를 체크리스트 첫 항목으로 못 박았습니다. ffmpeg로 프레임 하나 뽑아서 확인하면 됩니다.
drawtext 대신 Pillow로 타이틀 PNG를 만든 이유#
ffmpeg에는 drawtext 필터가 있어서 텍스트를 영상에 바로 얹을 수 있습니다. 하지만 이 파이프라인에서는 Pillow로 타이틀 이미지를 먼저 렌더링하고 overlay 필터로 얹는 방식을 택했습니다. 이유는 세 가지입니다.
- 한글 폰트 함정. 사용하는
GmarketSansBold.ttf에는 공백(U+0020) 글리프가 없습니다. 띄어쓰기가 그대로 사라집니다. 그래서 문구를['야호', '해보라고', '했더니']처럼 단어 배열로 쪼개고 단어 사이 간격을 직접 계산해 배치합니다. 이런 로직은 drawtext로는 무리입니다. - 다층 렌더링. 2줄 타이틀은 빨간 글자 + 검은 외곽선 + 은은한 그림자 조합입니다. 외곽선은 8방향 오프셋으로, 그림자는 2겹 레이어로 그립니다. Pillow에서는 몇 줄이면 되는 일입니다.
- 레이아웃 계산의 부산물. 타이틀을 그리면서 각 줄의 정확한 Y 좌표(
line1_bottom_y,line2_top_y등)를title_layout.txt로 남깁니다. 이 값이 뒤의 QC 단계에서 그대로 쓰입니다.
폰트 크기는 고정하지 않고, 바 너비의 80%(FILL=0.80)를 채우도록 40부터 키워가며 맞는 크기를 탐색합니다. 문구가 길면 자동으로 작아집니다.
ffmpeg filter_complex로 crop, pad, overlay 조합하기#
핵심 합성은 ffmpeg 한 번의 호출로 끝납니다. 본문 영상을 잘라서 아래로 내리고, 하단 바를 다시 붙이고, 타이틀 헤더 PNG를 맨 위에 얹습니다.
ffmpeg -y -i "$SOURCE" -loop 1 -i "$TITLE_HEAD_PNG" \
-filter_complex "\
[0:v]fps=${FPS}[base];\
[base]crop=${W}:${CONTENT_H}:0:${CONTENT_Y},crop=${W}:${CONTENT_DISPLAY_H}:0:0[content];\
[base]crop=${W}:${BAR_H}:0:${BOTTOM_Y}[bottom];\
[content]pad=${W}:${H}:0:${CONTENT_Y_NEW}:black[with_content];\
[with_content][bottom]overlay=0:${BOTTOM_Y}:shortest=1[with_bars];\
[1:v]scale=${W}:${CONTENT_Y_NEW},fps=${FPS},format=rgba[head];\
[with_bars][head]overlay=0:0:format=auto,format=yuv420p,fps=${FPS}[v]" \
-map "[v]" -map 0:a? \
-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p \
-c:a aac -b:a 192k -ar 48000 -shortest \
"$OUTPUT"포인트를 짚으면 이렇습니다.
crop두 번: 먼저 본문 영역(1216px)을 잘라내고, 시프트한 만큼 하단을 다시 잘라냅니다. 스케일 다운이 아니라 crop입니다. 영상을 축소하면 화질과 프레이밍이 같이 무너집니다.pad: 잘라낸 본문을content_y_new위치에 배치합니다. 타이틀 헤더가 차지할 공간만큼 영상이 아래로 밀립니다. 테스트 영상에서는 +98px이었습니다.- 타이틀을 영상 위에 투명 오버레이로 올리는 게 아니라, 검은 헤더 영역에 타이틀을 고정하고 영상 자체를 내리는 구조입니다. 타이틀이 얼굴이나 제스처를 가리는 문제가 원천적으로 사라집니다.
- 여담으로, 처음엔 2줄에 글로우 효과를 줬다가 눈으로 보고 바로 뺐습니다. 지금의 외곽선 + 약한 그림자 조합이 훨씬 낫습니다.
쇼츠 탭 3:4 크롭 QC를 빌드마다 자동으로#
YouTube 앱의 쇼츠 탭 카드는 16:9도 9:16도 아닌 약 3:4입니다. 9:16 영상의 중앙만 잘라서 보여주기 때문에, 1080×1920 기준으로 위아래 240px씩 날아갑니다. 상단 바에 있는 1줄 타이틀은 탭에서 안 보입니다. 탭에서 시청자를 멈추게 하는 훅은 2줄입니다.
그래서 2줄이 3:4 크롭 안에 들어오는지가 배포 전 필수 체크이고, 이걸 빌드마다 자동으로 확인합니다.
crop_h = int(vw * 4 / 3) # 1080 → 1440
y0 = (vh - crop_h) // 2 # 1920 → 240
# line2_top_y ~ line2_bottom_y 가 [y0, y0+crop_h] 안이면 VISIBLE앞 단계에서 남긴 title_layout.txt의 2줄 좌표를 읽어서, 5초 지점 프레임을 3:4로 크롭한 미리보기 PNG에 2줄 위치를 박스로 표시하고 VISIBLE / PARTIAL/CUT 판정을 찍습니다. 판정이 VISIBLE이 아니면 배포 전에 걸러집니다. 실패하면 VIDEO_GAP을 키우거나 2줄 문구·폰트를 줄이는 식으로 조정합니다.
튜닝 파라미터와 남은 것#
수치 조정이 필요한 값은 스크립트 상단에 모아뒀습니다.
LINE_GAP=10: 1줄과 2줄 사이 간격. 벌어지면 한 타이틀처럼 안 보입니다.VIDEO_GAP=16: 2줄 아래 검은 여백 후 본문 시작.STROKE_W=3,SHADOW_ALPHA=110: 외곽선 두께와 그림자 강도.
QC에서 어색한 부분이 나오면 이 값만 만지고 다시 빌드합니다. 편집 앱을 다시 열 일이 없다는 게 이 파이프라인의 핵심 가치입니다. 타이틀 위치를 1px 단위로 손으로 맞추고, 내보내고, 앱에서 어떻게 잘리는지 눈대중으로 확인하던 반복이 스크립트 실행 한 번으로 줄었습니다.
미해결도 남아 있습니다. 탭에서 잘리는 1줄까지 노출하려면 업로드용 정지 썸네일을 따로 만들어야 하는데, 이건 아직 파이프라인에 없습니다. 정지 썸네일 대신 움직이는 미리보기가 필요하면, 영상을 올리지 않고 브라우저에서 GIF를 만드는 gif-moments로 후보 장면을 뽑아 볼 수 있습니다. 소스에 박힌 자막과 타이틀이 겹치는 경우도 소스 레벨 문제라 스크립트로는 건드리지 않기로 했습니다. 다음 소재를 돌리면서 하나씩 채울 예정입니다.