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
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];"

View File

@ -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];"

View File

@ -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];"