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
jwkim 2026-07-15 09:05:57 +09:00
parent 061d6b36b8
commit f24818860b
3 changed files with 243 additions and 69 deletions

View File

@ -35,23 +35,29 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import media as MU import media as MU
HERE = Path(__file__).parent HERE = Path(__file__).parent
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/ OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/
W, H = 1080, 1920 # 세로 쇼츠 W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림) BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함) NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
BRAND_TITLE = "썰록" # 상단 밴드 제목 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" IMG_MODEL = "gemini-2.5-flash-image"
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용 VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
TTS_VOICE = "Puck" TTS_VOICE = "Puck"
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, " TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
"능청스럽게 약간 오버해서 읽어줘:") "능청스럽게 약간 오버해서 읽어줘:")
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드 # 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf") 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" 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 TITLE_SIZE = 58
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다. # AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
@ -392,8 +398,27 @@ def _blossom(d, cx, cy, r, color):
fill=(255, 238, 175, 255)) # 꽃심 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): def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG. """상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃.""" 밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
from PIL import ImageDraw, ImageFont from PIL import ImageDraw, ImageFont
band_h = int(H * BAND_RATIO) 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.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) # 옅은 구분선 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_y = int(H * 0.012)
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
tf = ImageFont.truetype(title_fp, TITLE_SIZE) tf = ImageFont.truetype(title_fp, TITLE_SIZE)
cx = W / 2 cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
tw = d.textlength(title, font=tf) ink = (30, 30, 30, 255)
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma") sep = "×" # 곱셈기호(×) — 콜라보 표기
gap = 26
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙 w_title = d.textlength(title, font=tf)
gap = 44 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) rose = (228, 150, 168, 255)
_blossom(d, cx - tw / 2 - gap, by, 17, rose) bgap = 46
_blossom(d, cx + tw / 2 + gap, by, 17, rose) _blossom(d, group_left - bgap, cy, 17, rose)
_blossom(d, group_left + total + bgap, cy, 17, rose)
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명 rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4) d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬. # 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
top = rule_y + 22 top = rule_y + 22
avail = band_h - top - 24 avail = band_h - top - 24
f = ImageFont.truetype(font_path, NARR_SIZE) size = NARR_SIZE
lines = _wrap_lines(d, text, f, W - 150) while size > NARR_MIN_SIZE:
lh = int(NARR_SIZE * 1.4) 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) y = top + max(0, (avail - len(lines) * lh) // 2)
for ln in lines: for ln in lines:
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma") 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/ 폴더에 나눠 담는 지원): 해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 지원):
1) 파일이면 그대로 1) 파일이면 그대로
2) 폴더면 음악 무작위 1 (병맛 BGM 여러 넣어두고 매번 다르게) 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"): if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
return None return None
import random import random
p = Path(bgm_arg) 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: for c in cands:
if c.is_file(): if c.is_file():
return c return c
@ -550,9 +608,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이 # 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
band_h = int(H * BAND_RATIO) band_h = int(H * BAND_RATIO)
img_h = H - band_h 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];" 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"crop={bw}:{bh},"
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:" 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];" f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"

View File

@ -35,23 +35,29 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import media as MU import media as MU
HERE = Path(__file__).parent HERE = Path(__file__).parent
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/ OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/
W, H = 1080, 1920 # 세로 쇼츠 W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림) BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함) NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
BRAND_TITLE = "썰록" # 상단 밴드 제목 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 IMG_MODEL = "gemini-3-pro-image" # 최상위 이미지 모델(인체·화풍 품질 우선). 저렴하게: gemini-3.1-flash-image
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용 VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
TTS_VOICE = "Puck" TTS_VOICE = "Puck"
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, " TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
"능청스럽게 약간 오버해서 읽어줘:") "능청스럽게 약간 오버해서 읽어줘:")
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드 # 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf") 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" 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 TITLE_SIZE = 58
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다. # AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
@ -401,8 +407,27 @@ def _blossom(d, cx, cy, r, color):
fill=(255, 238, 175, 255)) # 꽃심 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): def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG. """상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃.""" 밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
from PIL import ImageDraw, ImageFont from PIL import ImageDraw, ImageFont
band_h = int(H * BAND_RATIO) 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.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) # 옅은 구분선 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_y = int(H * 0.012)
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
tf = ImageFont.truetype(title_fp, TITLE_SIZE) tf = ImageFont.truetype(title_fp, TITLE_SIZE)
cx = W / 2 cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
tw = d.textlength(title, font=tf) ink = (30, 30, 30, 255)
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma") sep = "×" # 곱셈기호(×) — 콜라보 표기
gap = 26
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙 w_title = d.textlength(title, font=tf)
gap = 44 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) rose = (228, 150, 168, 255)
_blossom(d, cx - tw / 2 - gap, by, 17, rose) bgap = 46
_blossom(d, cx + tw / 2 + gap, by, 17, rose) _blossom(d, group_left - bgap, cy, 17, rose)
_blossom(d, group_left + total + bgap, cy, 17, rose)
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명 rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4) d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬. # 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
top = rule_y + 22 top = rule_y + 22
avail = band_h - top - 24 avail = band_h - top - 24
f = ImageFont.truetype(font_path, NARR_SIZE) size = NARR_SIZE
lines = _wrap_lines(d, text, f, W - 150) while size > NARR_MIN_SIZE:
lh = int(NARR_SIZE * 1.4) 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) y = top + max(0, (avail - len(lines) * lh) // 2)
for ln in lines: for ln in lines:
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma") 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/ 폴더에 나눠 담는 지원): 해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 지원):
1) 파일이면 그대로 1) 파일이면 그대로
2) 폴더면 음악 무작위 1 (병맛 BGM 여러 넣어두고 매번 다르게) 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"): if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
return None return None
import random import random
p = Path(bgm_arg) 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: for c in cands:
if c.is_file(): if c.is_file():
return c return c
@ -559,9 +617,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이 # 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
band_h = int(H * BAND_RATIO) band_h = int(H * BAND_RATIO)
img_h = H - band_h 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];" 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"crop={bw}:{bh},"
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:" 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];" f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"

View File

@ -35,23 +35,29 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import media as MU import media as MU
HERE = Path(__file__).parent HERE = Path(__file__).parent
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
OUT_DIR = HERE.parent / "output" / "animation_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/ OUT_DIR = HERE.parent / "output" / "animation_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/
W, H = 1080, 1920 # 세로 쇼츠 W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림) BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함) NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
BRAND_TITLE = "썰록" # 상단 밴드 제목 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" IMG_MODEL = "gemini-2.5-flash-image"
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용 VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
TTS_VOICE = "Puck" TTS_VOICE = "Puck"
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, " TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
"능청스럽게 약간 오버해서 읽어줘:") "능청스럽게 약간 오버해서 읽어줘:")
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드 # 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf") 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" 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 TITLE_SIZE = 58
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다. # AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
@ -392,8 +398,27 @@ def _blossom(d, cx, cy, r, color):
fill=(255, 238, 175, 255)) # 꽃심 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): def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG. """상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃.""" 밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
from PIL import ImageDraw, ImageFont from PIL import ImageDraw, ImageFont
band_h = int(H * BAND_RATIO) 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.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) # 옅은 구분선 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_y = int(H * 0.012)
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
tf = ImageFont.truetype(title_fp, TITLE_SIZE) tf = ImageFont.truetype(title_fp, TITLE_SIZE)
cx = W / 2 cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
tw = d.textlength(title, font=tf) ink = (30, 30, 30, 255)
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma") sep = "×" # 곱셈기호(×) — 콜라보 표기
gap = 26
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙 w_title = d.textlength(title, font=tf)
gap = 44 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) rose = (228, 150, 168, 255)
_blossom(d, cx - tw / 2 - gap, by, 17, rose) bgap = 46
_blossom(d, cx + tw / 2 + gap, by, 17, rose) _blossom(d, group_left - bgap, cy, 17, rose)
_blossom(d, group_left + total + bgap, cy, 17, rose)
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명 rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4) d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬. # 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
top = rule_y + 22 top = rule_y + 22
avail = band_h - top - 24 avail = band_h - top - 24
f = ImageFont.truetype(font_path, NARR_SIZE) size = NARR_SIZE
lines = _wrap_lines(d, text, f, W - 150) while size > NARR_MIN_SIZE:
lh = int(NARR_SIZE * 1.4) 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) y = top + max(0, (avail - len(lines) * lh) // 2)
for ln in lines: for ln in lines:
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma") 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/ 폴더에 나눠 담는 지원): 해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 지원):
1) 파일이면 그대로 1) 파일이면 그대로
2) 폴더면 음악 무작위 1 (병맛 BGM 여러 넣어두고 매번 다르게) 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"): if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
return None return None
import random import random
p = Path(bgm_arg) 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: for c in cands:
if c.is_file(): if c.is_file():
return c return c
@ -550,9 +608,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이 # 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
band_h = int(H * BAND_RATIO) band_h = int(H * BAND_RATIO)
img_h = H - band_h 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];" 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"crop={bw}:{bh},"
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:" 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];" f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"