컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
172 lines
6.1 KiB
Python
172 lines
6.1 KiB
Python
"""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
|