From f24818860b79f6fdded02dceca34bcccd3371cef Mon Sep 17 00:00:00 2001 From: jwkim Date: Wed, 15 Jul 2026 09:05:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(render):=20=EB=96=A8=EB=A6=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20+=20=EC=9E=90=EB=A7=89=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=B6=95=EC=86=8C=20+=20=EA=B3=B5=EC=9C=A0=ED=8F=B0=ED=8A=B8?= =?UTF-8?q?=20=EC=9D=B8=EC=8B=9D=20(shorts=5Fall=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/generator/animation/render.py | 104 ++++++++++++++---- .../generator/animation_greekroman/render.py | 104 ++++++++++++++---- .../generator/animation_samgukji/render.py | 104 ++++++++++++++---- 3 files changed, 243 insertions(+), 69 deletions(-) diff --git a/backend/generator/animation/render.py b/backend/generator/animation/render.py index d03baee..b81e156 100644 --- a/backend/generator/animation/render.py +++ b/backend/generator/animation/render.py @@ -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];" diff --git a/backend/generator/animation_greekroman/render.py b/backend/generator/animation_greekroman/render.py index 3909f77..e089132 100644 --- a/backend/generator/animation_greekroman/render.py +++ b/backend/generator/animation_greekroman/render.py @@ -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];" diff --git a/backend/generator/animation_samgukji/render.py b/backend/generator/animation_samgukji/render.py index 5242c5c..90254a2 100644 --- a/backend/generator/animation_samgukji/render.py +++ b/backend/generator/animation_samgukji/render.py @@ -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];"