fix(render): 떨림 제거 + 자막 자동축소 + 공유폰트 인식 (shorts_all 동기화)
- Ken Burns 떨림 제거: zoompan 입력 업스케일 1.2→4배 + flags=lanczos. 정수픽셀 반올림 흔들림 소멸(측정: 세로 튐 47→0회/60f). - 자막 자동 축소: 나레이션이 밴드 높이를 넘치면 폰트를 NARR_MIN_SIZE(30)까지 단계 축소 후 흰 밴드 안에만 렌더(아래 그림 침범 방지). - 폰트 경로 견고화(_find_font): per-engine fonts/ 우선, 없으면 상위 공유 generator/fonts/ 사용 → ssulbox 공유폰트 레이아웃에서도 주아체 정상 로드. (shorts_all/ssulbox render.py 3종 동일 유지) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
061d6b36b8
commit
f24818860b
|
|
@ -35,23 +35,29 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||
import media as MU
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
|
||||
OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/
|
||||
W, H = 1080, 1920 # 세로 쇼츠
|
||||
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
|
||||
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함)
|
||||
BRAND_TITLE = "썰록" # 상단 밴드 제목
|
||||
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
|
||||
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
|
||||
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
|
||||
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
|
||||
IMG_MODEL = "gemini-2.5-flash-image"
|
||||
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
|
||||
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
|
||||
TTS_VOICE = "Puck"
|
||||
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
|
||||
"능청스럽게 약간 오버해서 읽어줘:")
|
||||
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드
|
||||
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf")
|
||||
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
|
||||
def _find_font(name):
|
||||
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
|
||||
if c.exists():
|
||||
return str(c)
|
||||
return str(HERE / "fonts" / name)
|
||||
FONT = _find_font("Jua-Regular.ttf")
|
||||
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
|
||||
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
|
||||
TITLE_FONT = str(SHARED / "fonts" / "SongMyung-Regular.ttf")
|
||||
TITLE_FONT = _find_font("SongMyung-Regular.ttf")
|
||||
TITLE_SIZE = 58
|
||||
|
||||
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
|
||||
|
|
@ -392,8 +398,27 @@ def _blossom(d, cx, cy, r, color):
|
|||
fill=(255, 238, 175, 255)) # 꽃심
|
||||
|
||||
|
||||
_LOGO_RGBA = None
|
||||
|
||||
|
||||
def _brand_logo():
|
||||
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
|
||||
global _LOGO_RGBA
|
||||
if _LOGO_RGBA is not None:
|
||||
return _LOGO_RGBA
|
||||
if not Path(BRAND_LOGO).exists():
|
||||
return None
|
||||
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
|
||||
g = Image.open(BRAND_LOGO).convert("L")
|
||||
alpha = g.point(lambda p: 255 - p)
|
||||
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
|
||||
logo.putalpha(alpha)
|
||||
_LOGO_RGBA = logo
|
||||
return logo
|
||||
|
||||
|
||||
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
||||
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||
밴드 아래는 투명 → 그 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
band_h = int(H * BAND_RATIO)
|
||||
|
|
@ -402,29 +427,62 @@ def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
|||
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
|
||||
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
|
||||
|
||||
# ── 제목: 조선 느낌 폰트(TITLE_FONT) + 양옆 벚꽃 + 아래 구분선(끝까지·불투명) ──
|
||||
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
|
||||
title_y = int(H * 0.012)
|
||||
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
|
||||
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
|
||||
cx = W / 2
|
||||
tw = d.textlength(title, font=tf)
|
||||
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma")
|
||||
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
|
||||
ink = (30, 30, 30, 255)
|
||||
sep = "×" # 곱셈기호(×) — 콜라보 표기
|
||||
gap = 26
|
||||
|
||||
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙
|
||||
gap = 44
|
||||
w_title = d.textlength(title, font=tf)
|
||||
w_sep = d.textlength(sep, font=tf)
|
||||
|
||||
logo = _brand_logo()
|
||||
logo_h = 0
|
||||
if logo is not None:
|
||||
logo_h = int(TITLE_SIZE * 1.05)
|
||||
logo_w = max(1, int(logo.width * logo_h / logo.height))
|
||||
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
|
||||
else:
|
||||
logo_w = 0
|
||||
|
||||
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
|
||||
group_left = (W - total) / 2
|
||||
x = group_left
|
||||
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
|
||||
x += w_title + gap
|
||||
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
|
||||
x += w_sep + gap
|
||||
if logo is not None:
|
||||
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
|
||||
|
||||
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
|
||||
rose = (228, 150, 168, 255)
|
||||
_blossom(d, cx - tw / 2 - gap, by, 17, rose)
|
||||
_blossom(d, cx + tw / 2 + gap, by, 17, rose)
|
||||
bgap = 46
|
||||
_blossom(d, group_left - bgap, cy, 17, rose)
|
||||
_blossom(d, group_left + total + bgap, cy, 17, rose)
|
||||
|
||||
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
|
||||
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
|
||||
|
||||
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬.
|
||||
# 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
|
||||
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
|
||||
top = rule_y + 22
|
||||
avail = band_h - top - 24
|
||||
f = ImageFont.truetype(font_path, NARR_SIZE)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(NARR_SIZE * 1.4)
|
||||
size = NARR_SIZE
|
||||
while size > NARR_MIN_SIZE:
|
||||
f = ImageFont.truetype(font_path, size)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(size * 1.4)
|
||||
if len(lines) * lh <= avail:
|
||||
break
|
||||
size -= 2
|
||||
else:
|
||||
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(NARR_MIN_SIZE * 1.4)
|
||||
y = top + max(0, (avail - len(lines) * lh) // 2)
|
||||
for ln in lines:
|
||||
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
|
||||
|
|
@ -473,13 +531,13 @@ def resolve_bgm(bgm_arg):
|
|||
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 걸 지원):
|
||||
1) 파일이면 그대로
|
||||
2) 폴더면 그 안 음악 중 무작위 1곡 (병맛 BGM 여러 개 넣어두고 매번 다르게)
|
||||
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> → 공용 generator/<값> → generator/bgm/<값> 순 탐색
|
||||
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> 순으로 파일/폴더 탐색
|
||||
"""
|
||||
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
|
||||
return None
|
||||
import random
|
||||
p = Path(bgm_arg)
|
||||
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p, SHARED / p, SHARED / "bgm" / p]
|
||||
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p]
|
||||
for c in cands:
|
||||
if c.is_file():
|
||||
return c
|
||||
|
|
@ -550,9 +608,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
|
|||
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
|
||||
band_h = int(H * BAND_RATIO)
|
||||
img_h = H - band_h
|
||||
bw, bh = int(W * 1.2), int(img_h * 1.2) # 줌 여유용 1.2x
|
||||
bw, bh = int(W * 4), int(img_h * 4) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(1.2→4)
|
||||
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
|
||||
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase,"
|
||||
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
|
||||
f"crop={bw}:{bh},"
|
||||
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:"
|
||||
f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"
|
||||
|
|
|
|||
|
|
@ -35,23 +35,29 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||
import media as MU
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
|
||||
OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/
|
||||
W, H = 1080, 1920 # 세로 쇼츠
|
||||
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
|
||||
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함)
|
||||
BRAND_TITLE = "썰록" # 상단 밴드 제목
|
||||
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
|
||||
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
|
||||
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
|
||||
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
|
||||
IMG_MODEL = "gemini-3-pro-image" # 최상위 이미지 모델(인체·화풍 품질 우선). 저렴하게: gemini-3.1-flash-image
|
||||
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
|
||||
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
|
||||
TTS_VOICE = "Puck"
|
||||
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
|
||||
"능청스럽게 약간 오버해서 읽어줘:")
|
||||
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드
|
||||
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf")
|
||||
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
|
||||
def _find_font(name):
|
||||
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
|
||||
if c.exists():
|
||||
return str(c)
|
||||
return str(HERE / "fonts" / name)
|
||||
FONT = _find_font("Jua-Regular.ttf")
|
||||
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
|
||||
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
|
||||
TITLE_FONT = str(SHARED / "fonts" / "SongMyung-Regular.ttf")
|
||||
TITLE_FONT = _find_font("SongMyung-Regular.ttf")
|
||||
TITLE_SIZE = 58
|
||||
|
||||
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
|
||||
|
|
@ -401,8 +407,27 @@ def _blossom(d, cx, cy, r, color):
|
|||
fill=(255, 238, 175, 255)) # 꽃심
|
||||
|
||||
|
||||
_LOGO_RGBA = None
|
||||
|
||||
|
||||
def _brand_logo():
|
||||
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
|
||||
global _LOGO_RGBA
|
||||
if _LOGO_RGBA is not None:
|
||||
return _LOGO_RGBA
|
||||
if not Path(BRAND_LOGO).exists():
|
||||
return None
|
||||
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
|
||||
g = Image.open(BRAND_LOGO).convert("L")
|
||||
alpha = g.point(lambda p: 255 - p)
|
||||
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
|
||||
logo.putalpha(alpha)
|
||||
_LOGO_RGBA = logo
|
||||
return logo
|
||||
|
||||
|
||||
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
||||
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||
밴드 아래는 투명 → 그 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
band_h = int(H * BAND_RATIO)
|
||||
|
|
@ -411,29 +436,62 @@ def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
|||
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
|
||||
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
|
||||
|
||||
# ── 제목: 조선 느낌 폰트(TITLE_FONT) + 양옆 벚꽃 + 아래 구분선(끝까지·불투명) ──
|
||||
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
|
||||
title_y = int(H * 0.012)
|
||||
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
|
||||
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
|
||||
cx = W / 2
|
||||
tw = d.textlength(title, font=tf)
|
||||
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma")
|
||||
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
|
||||
ink = (30, 30, 30, 255)
|
||||
sep = "×" # 곱셈기호(×) — 콜라보 표기
|
||||
gap = 26
|
||||
|
||||
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙
|
||||
gap = 44
|
||||
w_title = d.textlength(title, font=tf)
|
||||
w_sep = d.textlength(sep, font=tf)
|
||||
|
||||
logo = _brand_logo()
|
||||
logo_h = 0
|
||||
if logo is not None:
|
||||
logo_h = int(TITLE_SIZE * 1.05)
|
||||
logo_w = max(1, int(logo.width * logo_h / logo.height))
|
||||
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
|
||||
else:
|
||||
logo_w = 0
|
||||
|
||||
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
|
||||
group_left = (W - total) / 2
|
||||
x = group_left
|
||||
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
|
||||
x += w_title + gap
|
||||
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
|
||||
x += w_sep + gap
|
||||
if logo is not None:
|
||||
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
|
||||
|
||||
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
|
||||
rose = (228, 150, 168, 255)
|
||||
_blossom(d, cx - tw / 2 - gap, by, 17, rose)
|
||||
_blossom(d, cx + tw / 2 + gap, by, 17, rose)
|
||||
bgap = 46
|
||||
_blossom(d, group_left - bgap, cy, 17, rose)
|
||||
_blossom(d, group_left + total + bgap, cy, 17, rose)
|
||||
|
||||
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
|
||||
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
|
||||
|
||||
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬.
|
||||
# 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
|
||||
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
|
||||
top = rule_y + 22
|
||||
avail = band_h - top - 24
|
||||
f = ImageFont.truetype(font_path, NARR_SIZE)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(NARR_SIZE * 1.4)
|
||||
size = NARR_SIZE
|
||||
while size > NARR_MIN_SIZE:
|
||||
f = ImageFont.truetype(font_path, size)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(size * 1.4)
|
||||
if len(lines) * lh <= avail:
|
||||
break
|
||||
size -= 2
|
||||
else:
|
||||
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(NARR_MIN_SIZE * 1.4)
|
||||
y = top + max(0, (avail - len(lines) * lh) // 2)
|
||||
for ln in lines:
|
||||
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
|
||||
|
|
@ -482,13 +540,13 @@ def resolve_bgm(bgm_arg):
|
|||
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 걸 지원):
|
||||
1) 파일이면 그대로
|
||||
2) 폴더면 그 안 음악 중 무작위 1곡 (병맛 BGM 여러 개 넣어두고 매번 다르게)
|
||||
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> → 공용 generator/<값> → generator/bgm/<값> 순 탐색
|
||||
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> 순으로 파일/폴더 탐색
|
||||
"""
|
||||
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
|
||||
return None
|
||||
import random
|
||||
p = Path(bgm_arg)
|
||||
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p, SHARED / p, SHARED / "bgm" / p]
|
||||
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p]
|
||||
for c in cands:
|
||||
if c.is_file():
|
||||
return c
|
||||
|
|
@ -559,9 +617,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
|
|||
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
|
||||
band_h = int(H * BAND_RATIO)
|
||||
img_h = H - band_h
|
||||
bw, bh = int(W * 1.2), int(img_h * 1.2) # 줌 여유용 1.2x
|
||||
bw, bh = int(W * 4), int(img_h * 4) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(1.2→4)
|
||||
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
|
||||
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase,"
|
||||
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
|
||||
f"crop={bw}:{bh},"
|
||||
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:"
|
||||
f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"
|
||||
|
|
|
|||
|
|
@ -35,23 +35,29 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||
import media as MU
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
|
||||
OUT_DIR = HERE.parent / "output" / "animation_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/
|
||||
W, H = 1080, 1920 # 세로 쇼츠
|
||||
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
|
||||
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함)
|
||||
BRAND_TITLE = "썰록" # 상단 밴드 제목
|
||||
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
|
||||
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
|
||||
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
|
||||
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
|
||||
IMG_MODEL = "gemini-2.5-flash-image"
|
||||
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
|
||||
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
|
||||
TTS_VOICE = "Puck"
|
||||
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
|
||||
"능청스럽게 약간 오버해서 읽어줘:")
|
||||
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드
|
||||
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf")
|
||||
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
|
||||
def _find_font(name):
|
||||
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
|
||||
if c.exists():
|
||||
return str(c)
|
||||
return str(HERE / "fonts" / name)
|
||||
FONT = _find_font("Jua-Regular.ttf")
|
||||
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
|
||||
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
|
||||
TITLE_FONT = str(SHARED / "fonts" / "SongMyung-Regular.ttf")
|
||||
TITLE_FONT = _find_font("SongMyung-Regular.ttf")
|
||||
TITLE_SIZE = 58
|
||||
|
||||
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
|
||||
|
|
@ -392,8 +398,27 @@ def _blossom(d, cx, cy, r, color):
|
|||
fill=(255, 238, 175, 255)) # 꽃심
|
||||
|
||||
|
||||
_LOGO_RGBA = None
|
||||
|
||||
|
||||
def _brand_logo():
|
||||
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
|
||||
global _LOGO_RGBA
|
||||
if _LOGO_RGBA is not None:
|
||||
return _LOGO_RGBA
|
||||
if not Path(BRAND_LOGO).exists():
|
||||
return None
|
||||
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
|
||||
g = Image.open(BRAND_LOGO).convert("L")
|
||||
alpha = g.point(lambda p: 255 - p)
|
||||
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
|
||||
logo.putalpha(alpha)
|
||||
_LOGO_RGBA = logo
|
||||
return logo
|
||||
|
||||
|
||||
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
||||
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||
밴드 아래는 투명 → 그 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
band_h = int(H * BAND_RATIO)
|
||||
|
|
@ -402,29 +427,62 @@ def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
|||
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
|
||||
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
|
||||
|
||||
# ── 제목: 조선 느낌 폰트(TITLE_FONT) + 양옆 벚꽃 + 아래 구분선(끝까지·불투명) ──
|
||||
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
|
||||
title_y = int(H * 0.012)
|
||||
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
|
||||
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
|
||||
cx = W / 2
|
||||
tw = d.textlength(title, font=tf)
|
||||
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma")
|
||||
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
|
||||
ink = (30, 30, 30, 255)
|
||||
sep = "×" # 곱셈기호(×) — 콜라보 표기
|
||||
gap = 26
|
||||
|
||||
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙
|
||||
gap = 44
|
||||
w_title = d.textlength(title, font=tf)
|
||||
w_sep = d.textlength(sep, font=tf)
|
||||
|
||||
logo = _brand_logo()
|
||||
logo_h = 0
|
||||
if logo is not None:
|
||||
logo_h = int(TITLE_SIZE * 1.05)
|
||||
logo_w = max(1, int(logo.width * logo_h / logo.height))
|
||||
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
|
||||
else:
|
||||
logo_w = 0
|
||||
|
||||
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
|
||||
group_left = (W - total) / 2
|
||||
x = group_left
|
||||
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
|
||||
x += w_title + gap
|
||||
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
|
||||
x += w_sep + gap
|
||||
if logo is not None:
|
||||
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
|
||||
|
||||
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
|
||||
rose = (228, 150, 168, 255)
|
||||
_blossom(d, cx - tw / 2 - gap, by, 17, rose)
|
||||
_blossom(d, cx + tw / 2 + gap, by, 17, rose)
|
||||
bgap = 46
|
||||
_blossom(d, group_left - bgap, cy, 17, rose)
|
||||
_blossom(d, group_left + total + bgap, cy, 17, rose)
|
||||
|
||||
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
|
||||
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
|
||||
|
||||
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬.
|
||||
# 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
|
||||
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
|
||||
top = rule_y + 22
|
||||
avail = band_h - top - 24
|
||||
f = ImageFont.truetype(font_path, NARR_SIZE)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(NARR_SIZE * 1.4)
|
||||
size = NARR_SIZE
|
||||
while size > NARR_MIN_SIZE:
|
||||
f = ImageFont.truetype(font_path, size)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(size * 1.4)
|
||||
if len(lines) * lh <= avail:
|
||||
break
|
||||
size -= 2
|
||||
else:
|
||||
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
|
||||
lines = _wrap_lines(d, text, f, W - 150)
|
||||
lh = int(NARR_MIN_SIZE * 1.4)
|
||||
y = top + max(0, (avail - len(lines) * lh) // 2)
|
||||
for ln in lines:
|
||||
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
|
||||
|
|
@ -473,13 +531,13 @@ def resolve_bgm(bgm_arg):
|
|||
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 걸 지원):
|
||||
1) 파일이면 그대로
|
||||
2) 폴더면 그 안 음악 중 무작위 1곡 (병맛 BGM 여러 개 넣어두고 매번 다르게)
|
||||
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> → 공용 generator/<값> → generator/bgm/<값> 순 탐색
|
||||
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> 순으로 파일/폴더 탐색
|
||||
"""
|
||||
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
|
||||
return None
|
||||
import random
|
||||
p = Path(bgm_arg)
|
||||
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p, SHARED / p, SHARED / "bgm" / p]
|
||||
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p]
|
||||
for c in cands:
|
||||
if c.is_file():
|
||||
return c
|
||||
|
|
@ -550,9 +608,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
|
|||
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
|
||||
band_h = int(H * BAND_RATIO)
|
||||
img_h = H - band_h
|
||||
bw, bh = int(W * 1.2), int(img_h * 1.2) # 줌 여유용 1.2x
|
||||
bw, bh = int(W * 4), int(img_h * 4) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(1.2→4)
|
||||
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
|
||||
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase,"
|
||||
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
|
||||
f"crop={bw}:{bh},"
|
||||
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:"
|
||||
f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"
|
||||
|
|
|
|||
Loading…
Reference in New Issue