"""ffmpeg 미디어 변환 — 레터박스(9:16 검은 띠) 굽기. 핵심: 캡컷 scale/transform 의미 추측(함정)을 피하려 영상을 미리 정확한 캔버스 비율로 구워둔다. 결과 mp4 는 이미 9:16 → 캡컷에서 scale 1.0 으로 정확히 일치. 부수효과: AV1/webm → h264 재인코딩으로 캡컷 미리보기 문제도 해소. """ from __future__ import annotations import os import subprocess from typing import Tuple def make_frame(out_png: str, *, top_bar: int, bottom_top: int, band_color: Tuple[int, int, int] = (0, 0, 0), canvas: Tuple[int, int] = (1080, 1920)) -> str: """명시적 좌표로 프레임 생성: 상단 띠(0~top_bar) + 투명 가운데 + 하단 띠(bottom_top~H). 하단 띠를 크게(bottom_top 낮게) 잡으면 영상이 위로 올라가고 하단에 댓글 캡쳐용 빈 공간이 생긴다. 띠는 band_color 불투명, 가운데는 투명(영상이 비침). 배경.png 흰밴드 자동감지(make_transparent_frame) 대신 이걸 쓰면 레이아웃을 코드 상수로 정확히 통제할 수 있다. """ from PIL import Image # 지연 import W, H = canvas out = Image.new("RGBA", (W, H), (0, 0, 0, 0)) band = (*band_color, 255) px = out.load() for y in range(H): if y < top_bar or y >= bottom_top: for x in range(W): px[x, y] = band out.save(out_png) return out_png def extract_audio(src: str, out_path: str) -> str: """오디오만 압축 mp3로 추출 (Gemini 받아쓰기 전송용, mono 16k 48k).""" subprocess.run( ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-b:a", "48k", out_path], check=True, encoding="utf-8", errors="replace", ) return out_path def detect_white_band(template_png: str) -> Tuple[int, int]: """검정-흰색-검정 템플릿에서 흰 밴드(영상 영역) [top, bottom] 픽셀 반환.""" from PIL import Image # 지연 import im = Image.open(template_png).convert("RGB") W, H = im.size x = W // 2 whites = [y for y in range(H) if min(im.getpixel((x, y))) > 200] if not whites: return 0, H return whites[0], whites[-1] + 1 def make_transparent_frame(template_png: str, out_png: str, band_color: Tuple[int, int, int] = (0, 0, 0)) -> str: """검정-흰색-검정 배경 템플릿 → 상하 띠는 band_color 로, 가운데(흰색)는 투명으로. 영상 위에 '프레임'으로 올려 상하 띠로 가리고 가운데는 영상이 비치게 한다. band_color=(0,0,0) 검은 띠(기본), (255,255,255) 흰 띠. 흰색 밴드 경계를 자동 검출(중앙 컬럼 스캔). """ from PIL import Image # 지연 import im = Image.open(template_png).convert("RGB") W, H = im.size x = W // 2 def is_white(y: int) -> bool: r, g, b = im.getpixel((x, y)) return r > 200 and g > 200 and b > 200 whites = [y for y in range(H) if is_white(y)] if whites: top, bot = whites[0], whites[-1] + 1 # 흰색 밴드 = 투명 처리 구간 else: top, bot = 0, H r, g, b = band_color out = Image.new("RGBA", (W, H), (0, 0, 0, 0)) px = out.load() for y in range(H): opaque = (y < top) or (y >= bot) if not opaque: continue for xx in range(W): px[xx, y] = (r, g, b, 255) out.save(out_png) return out_png def make_solid(out_png: str, color: Tuple[int, int, int] = (255, 255, 255), canvas: Tuple[int, int] = (1080, 1920)) -> str: """단색 전체 캔버스 PNG 생성(흰 배경 레이어 등). 가운데 빈 곳까지 그 색으로.""" from PIL import Image # 지연 import W, H = canvas Image.new("RGB", (W, H), color).save(out_png) return out_png # ── 템플릿 레이아웃 (1080×1920) ── # 상단 제목 띠 | 영상(좌우 살짝 크롭, 크게) | 하단 채널 띠 TPL_W, TPL_H = 1080, 1920 TPL_TOP_BAR = 410 # 상단 검은 띠(제목) TPL_VIDEO_H = 1000 # 영상 영역 높이 (410~1410) # 하단 띠 = 1920 - 410 - 1000 = 510 (채널/출처) TPL_VIDEO_TOP = TPL_TOP_BAR TPL_VIDEO_BOTTOM = TPL_TOP_BAR + TPL_VIDEO_H def to_template( src: str, out_path: str, *, crf: int = 20, ) -> str: """영상을 템플릿 비디오 영역(1080×1000, 좌우 cover 크롭)에 채우고 상하 검은 띠로 1080×1920 합성. 영상 틀만 굽고(제목/자막/채널은 캡컷 텍스트로 별도), scale 함정 회피. """ vf = ( f"scale={TPL_W}:{TPL_VIDEO_H}:force_original_aspect_ratio=increase," f"crop={TPL_W}:{TPL_VIDEO_H}," f"pad={TPL_W}:{TPL_H}:0:{TPL_VIDEO_TOP}:color=black," f"setsar=1" ) cmd = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", src, "-vf", vf, "-c:v", "libx264", "-preset", "veryfast", "-crf", str(crf), "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100", out_path, ] subprocess.run(cmd, check=True, encoding="utf-8", errors="replace") return out_path def to_letterbox( src: str, out_path: str, *, canvas: Tuple[int, int] = (1080, 1920), crf: int = 20, ) -> str: """가로 영상을 canvas(기본 9:16) 가운데 두고 위아래 검은 띠로 채운 mp4 생성. scale=...:force_original_aspect_ratio=decrease 로 캔버스 안에 비율 유지하며 축소, pad 로 가운데 정렬 + 나머지 검정. 전체 클립을 그대로(타임라인 보존) 재인코딩. """ cw, ch = canvas vf = ( f"scale={cw}:{ch}:force_original_aspect_ratio=decrease," f"pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2:color=black," f"setsar=1" ) cmd = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", src, "-vf", vf, "-c:v", "libx264", "-preset", "veryfast", "-crf", str(crf), "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "44100", out_path, ] subprocess.run(cmd, check=True, encoding="utf-8", errors="replace") return out_path