# -*- coding: utf-8 -*- """ 스토리보드 → 완성 쇼츠 영상(mp4) 조립 [A방식: 정지 일러스트 + 모션] ===================================================================== gen_storyboard.py 가 만든 스토리보드를 받아 장면별로: 1) Gemini 이미지로 일러스트 생성 (캐릭터 일관성 = 1번 컷을 레퍼런스로 물림) 2) Gemini TTS 로 그 장면 나레이션 음성 생성 (장면 길이 = 음성 길이) 3) ffmpeg 로 Ken Burns(zoompan 줌) + 자막(PNG 오버레이) + 음성 합성 → 세로 1080x1920 mp4 (Intel QSV 하드웨어 인코딩 자동 사용, 없으면 libx264 ultrafast) 이미지/음성은 output/ 에 장면별로 캐싱한다. 재실행 시 이미 있으면 재사용(재과금 X). 다시 그리고 싶으면 --force. 설치: pip install google-genai pillow imageio-ffmpeg (moviepy 불필요 — ffmpeg 직접 렌더) 실행: python make_short.py # 최신 *_storyboard.json python make_short.py --storyboard output/xxx_storyboard.json --voice Puck python make_short.py --force # 이미지/음성 새로 생성 """ import json import re import shutil import subprocess import sys import time import wave from pathlib import Path from google.genai import types from PIL import Image 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 = "썰록" # 상단 밴드 제목 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") FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf" # 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게. TITLE_FONT = str(SHARED / "fonts" / "SongMyung-Regular.ttf") TITLE_SIZE = 58 # AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다. # → 모든 이미지 생성에서 '글자 자체를 그리지 말라'고 강하게 지시. (가게 이름은 자막/카드로 표기) NO_TEXT = (" CRITICAL: Render NO text of any kind in the image — no letters, words, numbers, " "Korean hangul, logos, or signage text. Keep every sign, banner, board and menu " "completely BLANK with no writing on them.") # ---------------------------------------------------------------- 데이터 IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp") def resolve_real_images(images_dir, real_scenes_arg): """--images 값 → 정렬된 실제 사진 경로 리스트, --real-scenes → 정수 리스트. images_dir 해석 순서 (가게마다 load_image/<가게>/ 로 나눠 담는 걸 지원): 1) 절대경로면 그대로 2) 상대경로/이름이면 HERE/<값> (예: load_image/연산골막국수) 3) 그래도 없으면 HERE/load_image/<값> (예: --images 연산골막국수) 가장 먼저 '이미지가 들어있는' 폴더를 채택. 없으면 빈 리스트. """ real_images, used = [], None if images_dir: d = Path(images_dir) candidates = [d] if d.is_absolute() else [HERE / d, HERE / "load_image" / d] for c in candidates: if c.exists() and c.is_dir(): imgs = sorted(p for p in c.iterdir() if p.suffix.lower() in IMG_EXTS) if imgs: real_images, used = imgs, c break if used: print(f"■ 실제 사진 폴더: {used} ({len(real_images)}장)") real_scenes = None if real_scenes_arg: real_scenes = [int(x) for x in re.split(r"[,\s]+", str(real_scenes_arg).strip()) if x] return real_images, real_scenes # 실제 사진 카드를 합성하는 장면에서, AI 배경이 카드 자리를 비워두도록 주는 포즈 힌트 PRESENT_POSES = [ "Place the cat king in the UPPER part of the frame, smiling and pointing down toward the " "lower-center, leaving the lower-center area visually simple (a photo will be placed there).", "Put the cat king on the LEFT side presenting with one paw to the right-center, keeping the " "right-center area simple and uncluttered (a photo will be placed there).", "Show the cat king at the BOTTOM looking up with sparkling eyes toward the upper-center, " "keeping the upper-center area simple (a photo will be placed there).", ] # PRESENT_POSES 순서에 맞춰 실제 사진 카드를 놓을 (가로,세로) 중심 비율 PRESENT_SPOTS = [(0.50, 0.62), (0.62, 0.50), (0.50, 0.40)] # 고양이 왕의 '눈' 그림체를 귀엽게 유지하되, 눈을 '떴을 때'의 모양만 고정한다. # (모든 장면을 동그란 뜬눈으로 강제하면 감김/실눈/윙크 같은 표정 다양성이 사라짐) EYE_STYLE = ( "When the cat's eyes are open, they are the same cute friendly eyes: large round eyes " "with big round solid black pupils and a small soft white highlight, gentle innocent " "look. Keep this cute rounded eye style; never sharp, angry, fierce, glaring or " "realistic predatory eyes." ) # 눈을 감거나(감김/실눈/윙크/졸림/웃음·울음으로 눈이 변하는) 장면용: 표정은 장면대로 두되 화풍만 유지. EYE_FREE = ( "In this scene the cat's eye EXPRESSION follows the moment — the eyes may be gently " "closed, squinting, blinking, winking, teary or sleepy half-closed as appropriate. " "Keep the same cute, soft, rounded drawing style (never sharp, angry or realistic), " "but do NOT force wide-open round eyes here." ) # image_prompt(영어)에 아래 표현이 있으면 '눈 표정 장면'으로 보고 뜬눈 고정/레퍼런스를 푼다. _EYE_EXPR_KW = ( "closed eye", "eyes closed", "close its eye", "close his eye", "shut eye", "shut its eye", "squint", "narrowed eye", "wink", "half-closed", "half closed", "sleepy", "sleeping", "asleep", "dozing", "doze", "napping", "yawn", "drowsy", "crying", "sob", "weeping", "tears", "teary", "laughing", "giggl", "chuckl", "wince", "grimac", "blink", ) def scene_needs_eye_expression(sc) -> bool: """장면이 눈 감김/실눈/윙크 등 '뜬눈 아닌' 표정을 필요로 하면 True.""" text = ((sc.get("image_prompt") or "") + " " + (sc.get("caption") or "")).lower() return any(k in text for k in _EYE_EXPR_KW) # 캐릭터 공식 레퍼런스: 이 폴더에 이미지를 넣어두면(예: king_ref.png), # 모든 영상의 모든 컷이 그 고양이 왕의 얼굴/눈/복장/화풍을 그대로 따라간다. CHAR_REF_DIR = HERE / "character" def load_char_ref(): """Shorts/character/ 안의 첫 이미지 바이트 반환(없으면 None). 모든 영상의 캐릭터 앵커로 쓰인다.""" if not CHAR_REF_DIR.is_dir(): return None for p in sorted(CHAR_REF_DIR.iterdir()): if p.suffix.lower() in IMG_EXTS: return p.read_bytes() return None # ---------------------------------------------------------------- 이미지 def gen_image(client, prompt, ref_bytes=None, model=None, no_text=True, ref_mode="full") -> bytes: """Gemini 이미지 생성. ref_bytes 주면 캐릭터 일관성 위해 레퍼런스로 사용. model: 사용할 이미지 모델 (없으면 IMG_MODEL). 신형일수록 글자 렌더링 우수. no_text: True 면 '그림에 글자 금지' 지시를 붙인다(글자 깨짐 방지). ref_mode: 'full' = 레퍼런스의 캐릭터/복장/화풍을 통째로 유지. 'eyes' = 레퍼런스에선 '눈/표정만' 복제하고 복장·장면은 프롬프트대로. """ parts = [] if ref_bytes: parts.append(types.Part.from_bytes(data=ref_bytes, mime_type="image/png")) if ref_mode == "eyes": prompt = ("Use the reference image ONLY to copy the cat character's EYES and " "facial expression exactly: the same large round eyes with big round " "solid black pupils and a small soft white highlight, gentle innocent " "look. Do NOT copy the outfit, hat, robe color, pose, framing or " "background from the reference — those follow the scene description. " "New scene: " + prompt) else: prompt = ("Keep the EXACT same character design, outfit and art style as the " "reference image. New scene: " + prompt) parts.append(types.Part.from_text(text=prompt + (NO_TEXT if no_text else ""))) resp = client.models.generate_content( model=model or IMG_MODEL, contents=parts, config=types.GenerateContentConfig(response_modalities=["IMAGE"]), ) for p in resp.candidates[0].content.parts: if getattr(p, "inline_data", None): return p.inline_data.data raise RuntimeError("이미지 파트 없음") def composite_card(base_path, photo_path, out_path, rel_w=0.60, center=(0.5, 0.60), angle=4.0, border=22): """AI 배경(base) 위에 '실제 사진'을 작은 폴라로이드 카드로 얹어 합성 저장. 사진은 원본 그대로(왜곡 X) 축소만 하고, 흰 테두리+그림자+살짝 기울기로 자연스럽게 올린다. rel_w: 카드 사진 가로 = 화면폭 * rel_w. center: 카드 중심 (가로,세로) 비율. angle: 기울기(도). """ from PIL import Image, ImageFilter base = Image.open(base_path).convert("RGBA") photo = Image.open(photo_path).convert("RGB") cw = int(W * rel_w) ph = int(photo.height * (cw / photo.width)) max_h = int(H * 0.40) # 너무 길면 높이 기준으로 제한 if ph > max_h: ph, cw = max_h, int(photo.width * (max_h / photo.height)) photo = photo.resize((cw, ph), Image.LANCZOS) # 폴라로이드 흰 카드 (아래쪽 여백 좀 더) card = Image.new("RGBA", (cw + 2 * border, ph + 2 * border + int(border * 1.4)), (255, 255, 255, 255)) card.paste(photo, (border, border)) card = card.rotate(angle, expand=True, resample=Image.BICUBIC) cx, cy = int(W * center[0]), int(H * center[1]) x, y = cx - card.width // 2, cy - card.height // 2 # 그림자 alpha = card.split()[3] shadow = Image.new("RGBA", card.size, (0, 0, 0, 0)) shadow.paste(Image.new("RGBA", card.size, (0, 0, 0, 150)), (0, 0), alpha) shadow = shadow.filter(ImageFilter.GaussianBlur(18)) base.alpha_composite(shadow, (x + 10, y + 16)) base.alpha_composite(card, (x, y)) base.convert("RGB").save(out_path) def cover_crop(src: Path, dst: Path): """이미지를 1080x1920 꽉 차게 cover-crop 해서 저장.""" im = Image.open(src).convert("RGB") scale = max(W / im.width, H / im.height) nw, nh = int(im.width * scale + 0.5), int(im.height * scale + 0.5) im = im.resize((nw, nh), Image.LANCZOS) left, top = (nw - W) // 2, (nh - H) // 2 im.crop((left, top, left + W, top + H)).save(dst) # ---------------------------------------------------------------- 음성 def _extract_audio(resp): """TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호).""" cands = getattr(resp, "candidates", None) or [] for c in cands: content = getattr(c, "content", None) parts = getattr(content, "parts", None) or [] if content else [] for p in parts: inline = getattr(p, "inline_data", None) if inline and inline.data: m = re.search(r"rate=(\d+)", inline.mime_type or "") return inline.data, (int(m.group(1)) if m else 24000) return None def gen_tts(client, text, voice, retries=4) -> bytes: """Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다.""" last = "" for attempt in range(1, retries + 1): try: resp = client.models.generate_content( model=TTS_MODEL, contents=f"{TTS_STYLE}\n\n{text}", config=types.GenerateContentConfig( response_modalities=["AUDIO"], speech_config=types.SpeechConfig( voice_config=types.VoiceConfig( prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice) ) ), ), ) got = _extract_audio(resp) if got: return got # 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도 fr = "" try: fr = str(getattr(resp.candidates[0], "finish_reason", "") or "") except Exception: fr = "no-candidates" last = f"빈 응답(finish_reason={fr or '?'})" except Exception as e: last = f"{type(e).__name__}: {e}" if attempt < retries: wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프 print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후", end="", flush=True) time.sleep(wait) raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}…") def pcm_to_wav(pcm, path, rate): with wave.open(str(path), "wb") as w: w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate) w.writeframes(pcm) def wav_seconds(path: Path) -> float: with wave.open(str(path), "rb") as w: return w.getnframes() / w.getframerate() def _atempo_chain(speed: float) -> list: """atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝.""" chain, s = [], speed while s > 2.0: chain.append("atempo=2.0"); s /= 2.0 while s < 0.5: chain.append("atempo=0.5"); s *= 2.0 chain.append(f"atempo={s:.4f}") return chain def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True, threshold="-38dB", keep=0.06): """컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기. - trim_silence: 앞/뒤 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지). silenceremove 로 앞을 자르고, areverse 로 뒤집어 다시 앞(=원래 뒤)을 자른 뒤 복원. keep: 잘라낸 끝에 남길 여유(초). 0 이면 너무 칼같이 잘려 어색할 수 있어 약간 남긴다. - speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다. 가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백. """ af = [] if trim_silence: one = (f"silenceremove=start_periods=1:start_silence={keep}:" f"start_threshold={threshold}:detection=peak") af += [one, "areverse", one, "areverse"] if speed and abs(speed - 1.0) > 1e-3: af += _atempo_chain(speed) if not af: shutil.copyfile(src_wav, dst_wav); return try: _run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af), "-ar", "24000", "-ac", "1", str(dst_wav)]) if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백 raise RuntimeError("가공 후 음성이 너무 짧음") except (RuntimeError, OSError): shutil.copyfile(src_wav, dst_wav) # ---------------------------------------------------------------- 조립 (ffmpeg 직접 렌더) FPS = 30 def _ffmpeg() -> str: import imageio_ffmpeg return imageio_ffmpeg.get_ffmpeg_exe() def _run(cmd): r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode != 0: raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:]) return r def pick_encoder(exe) -> list: """Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast.""" test = subprocess.run( [exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1", "-c:v", "h264_qsv", "-f", "null", "-"], capture_output=True, text=True, errors="replace") if test.returncode == 0: print(" 인코더: h264_qsv (Intel 하드웨어)") return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"] print(" 인코더: libx264 (ultrafast)") return ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"] def _wrap_lines(draw, text, font, max_w): """글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈.""" lines, cur = [], "" for ch in text: if ch == "\n": lines.append(cur); cur = ""; continue if draw.textlength(cur + ch, font=font) <= max_w: cur += ch else: lines.append(cur); cur = ch if cur: lines.append(cur) return lines or [""] def _blossom(d, cx, cy, r, color): """간단한 5장 꽃잎 벚꽃 장식(제목 양옆 무늬).""" import math pr = r * 0.62 # 꽃잎 반지름 for k in range(5): a = math.radians(-90 + k * 72) px, py = cx + r * 0.55 * math.cos(a), cy + r * 0.55 * math.sin(a) d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color) d.ellipse([cx - r * 0.32, cy - r * 0.32, cx + r * 0.32, cy + r * 0.32], fill=(255, 238, 175, 255)) # 꽃심 def render_band_png(text, out_path, font_path, title=BRAND_TITLE): """상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG. 밴드 아래는 투명 → 그 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃.""" from PIL import ImageDraw, ImageFont band_h = int(H * BAND_RATIO) img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) d = ImageDraw.Draw(img) 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) + 양옆 벚꽃 + 아래 구분선(끝까지·불투명) ── 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") by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙 gap = 44 rose = (228, 150, 168, 255) _blossom(d, cx - tw / 2 - gap, by, 17, rose) _blossom(d, cx + tw / 2 + gap, by, 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), 길면 줄바꿈만. 세로 가운데 정렬. 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) 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") y += lh img.save(out_path) LINK_CTA = "링크는 고정댓글 확인" # ▼ 등 특수문자는 자막 폰트(주아체)에 없어 □로 깨짐 → 한글만 def render_link_card_png(base_img_path, link, out_path, font_path, store=None, cta=None): """배경 사진을 어둡게 깔고 가게 이름 + 안내문구 + 링크를 큼직하게 그린 1080x1920 PNG.""" from PIL import ImageDraw, ImageFont base = Image.open(base_img_path).convert("RGB") base = Image.blend(base, Image.new("RGB", base.size, (0, 0, 0)), 0.55) d = ImageDraw.Draw(base) def centered(text, y, size, fill, fit_one_line=False): if fit_one_line: # 한 줄에 들어갈 때까지 폰트 축소 while size > 40 and d.textlength(text, font=ImageFont.truetype(font_path, size)) > W - 140: size -= 4 f = ImageFont.truetype(font_path, size) for ln in _wrap_lines(d, text, f, W - 140): w = d.textlength(ln, font=f) d.text(((W - w) / 2, y), ln, font=f, fill=fill, stroke_width=5, stroke_fill="black") y += int(size * 1.25) if store: centered(store, int(H * 0.33), 88, "white", fit_one_line=True) centered(cta or LINK_CTA, int(H * 0.43), 58, "#FFFFFF", fit_one_line=True) centered(link, int(H * 0.51), 72, "#FFE94A", fit_one_line=True) base.save(out_path) # 모든 세그먼트를 같은 규격으로 만들어 concat copy 가 되게 한다. _AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"] # ---------------------------------------------------------------- BGM AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus") def resolve_bgm(bgm_arg): """--bgm 값 → 실제 음악 파일 경로(없으면 None). 해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 걸 지원): 1) 파일이면 그대로 2) 폴더면 그 안 음악 중 무작위 1곡 (병맛 BGM 여러 개 넣어두고 매번 다르게) 3) 이름만 주면 HERE/<값> → HERE/bgm/<값> → 공용 generator/<값> → generator/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] for c in cands: if c.is_file(): return c if c.is_dir(): files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS] if files: return random.choice(files) return None def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0): """완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장. - BGM 이 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림. - 나레이션[0:a] + BGM[1:a]*volume 를 amix(duration=first)로 섞어 영상 길이에 맞춘다. - 영상은 재인코딩하지 않는다(-c:v copy) → 빠름. 시작에 짧은 페이드인. """ fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];" f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]") _run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path), "-filter_complex", fc, "-map", "0:v", "-map", "[a]", "-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart", str(out_path)]) # Shared low-level media utilities. Keep local render.py for per-feature # layout, prompts, local fonts, BGM folder resolution, and produce(). _ffmpeg = MU._ffmpeg _run = MU._run pick_encoder = MU.pick_encoder pcm_to_wav = MU.pcm_to_wav wav_seconds = MU.wav_seconds process_audio = MU.process_audio _wrap_lines = MU._wrap_lines mix_bgm = MU.mix_bgm def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0, store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15, bgm=None, bgm_volume=0.3): """장면별 ffmpeg 세그먼트(Ken Burns 줌 + 자막 오버레이 + 음성) → concat 으로 최종 mp4. speed/trim_silence: 컷편집 느낌 — 음성을 빠르게 + 앞뒤 침묵 제거(→ 컷이 타이트). tail: 각 컷 음성 끝에 붙일 정적(초). 0 이면 말 끝나자마자 바로 다음 컷. bgm: 배경음악 파일 경로. 주면 concat 후 BGM 을 작은 볼륨으로 깔아 한 번 더 믹스. bgm_volume: BGM 볼륨(0~1). 기본 0.12 = 나레이션 안 묻히게 12%. """ exe = _ffmpeg() enc = pick_encoder(exe) font = FONT if Path(FONT).exists() else FONT_FALLBACK out_path = Path(out_path) tmp = out_path.parent / "_render" tmp.mkdir(exist_ok=True) segs = [] total = 0.0 for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1): # 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용 proc_ap = tmp / f"aud{idx:02d}.wav" process_audio(exe, ap, proc_ap, speed=speed, trim_silence=trim_silence) speech = wav_seconds(proc_ap) dur = speech + max(0.0, tail) # 말 끝나고 약간의 여운만 frames = max(1, round(dur * FPS)) sub_png = tmp / f"sub{idx:02d}.png" # 화면 자막 = 그 장면에서 '말하는 문장'(나레이션) 그대로 → 무음 시청자도 내용 전달 render_band_png(sc.get("narration") or sc.get("caption", ""), sub_png, font) seg = tmp / f"seg{idx:02d}.mp4" # 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 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 fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];" f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase," 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];" f"[bg][img]overlay=0:{band_h}[base];" f"[base][1:v]overlay=0:0[v]") # 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷) _run([exe, "-y", "-i", str(ip), "-i", str(sub_png), "-i", str(proc_ap), "-filter_complex", fc, "-map", "[v]", "-map", "2:a", "-af", "apad", "-t", f"{dur:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", str(seg)]) segs.append(seg) total += dur print(f"[{idx:2d}] 세그먼트 (말 {speech:.1f}초 → 컷 {dur:.1f}초)") print(f" ▶ 본편 합계 {total:.1f}초" + (f" + 링크 {link_seconds:.1f}초" if link else "")) # 링크 카드 (무음) if link and img_paths: card_png = tmp / "linkcard.png" render_link_card_png(img_paths[-1], link, card_png, font, store=store, cta=cta) seg = tmp / "seg_link.mp4" _run([exe, "-y", "-loop", "1", "-i", str(card_png), "-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo", "-t", f"{link_seconds:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", str(seg)]) segs.append(seg) print(f"[링크] 카드 세그먼트 ({link_seconds:.1f}초)") # concat (재인코딩 없이 복사 → 즉시). 실패하면 재인코딩으로 폴백. # BGM 이 있으면 일단 임시 본편으로 이어붙이고, 그 위에 BGM 을 믹스해 최종본을 만든다. listf = tmp / "concat.txt" # concat 목록은 절대경로로 (ffmpeg 가 목록파일 위치 기준으로 상대경로를 또 해석하는 문제 방지) listf.write_text("".join(f"file '{s.resolve().as_posix()}'\n" for s in segs), encoding="utf-8") concat_out = (tmp / "_body.mp4") if bgm else out_path try: _run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf), "-c", "copy", "-movflags", "+faststart", str(concat_out)]) except RuntimeError: _run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf), *enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(concat_out)]) if bgm: mix_bgm(exe, concat_out, bgm, out_path, volume=bgm_volume) print(f" ♪ BGM 믹스: {Path(bgm).name} (볼륨 {bgm_volume})") shutil.rmtree(tmp, ignore_errors=True) def _mime_of(path) -> str: return "image/jpeg" if Path(path).suffix.lower() in (".jpg", ".jpeg") else "image/png" def analyze_photo(client, photo_path) -> str: """실제 사진 1장을 Gemini 비전으로 보고 짧은 한국어 라벨 반환 (예: '수육 한 접시').""" data = Path(photo_path).read_bytes() resp = client.models.generate_content( model=VISION_MODEL, contents=[ types.Part.from_bytes(data=data, mime_type=_mime_of(photo_path)), types.Part.from_text(text=( "이 사진의 핵심 대상을 한국어 명사구 12자 이내로만 답해. " "음식이면 메뉴명(예: '수육', '메밀국수'), 가게면 '가게 외관'/'매장 내부', " "메뉴판이면 '메뉴판'. 설명·문장 금지, 라벨만.")), ], ) return (resp.text or "").strip().splitlines()[0][:20] if resp.text else "" _MATCH_SCHEMA = { "type": "object", "properties": { "assignments": { "type": "array", "items": { "type": "object", "properties": { "photo_index": {"type": "integer"}, "scene_number": {"type": "integer"}, }, "required": ["photo_index", "scene_number"], }, } }, "required": ["assignments"], } def match_real_scenes(client, scenes, real_images, max_real=3) -> dict: """각 실제 사진을 비전 분석 → 내용이 가장 맞는 장면에 배치한 {장면번호: 사진경로} 반환. 실패하면 위치 기반(plan_real_scenes)으로 폴백한다. """ real_images = list(real_images or []) if not real_images: return {} # 1) 사진별 내용 라벨 labels = [] for p in real_images: try: lab = analyze_photo(client, p) except Exception: lab = "" labels.append(lab) print(f" · {Path(p).name} → '{lab or '?'}'") # 2) 라벨 ↔ 장면 매칭 (구조화 출력 1회) scene_lines = "\n".join( f"{i+1}. [{s.get('caption','')}] {s.get('narration','')}" for i, s in enumerate(scenes)) photo_lines = "\n".join(f"{i}. {labels[i] or '내용불명'}" for i in range(len(real_images))) prompt = ( "아래는 영상의 '장면 목록'과, 영상에 넣을 '실제 사진'들의 내용 라벨이다.\n" "각 사진을 그 내용이 가장 잘 어울리는 장면에 배치하라.\n" "[규칙]\n" "- 한 장면엔 사진 하나, 한 사진은 한 장면에만.\n" "- 그 음식/메뉴/가게를 직접 언급·소개하는 장면에 우선 배치.\n" "- 비슷하면 마케팅/소개가 나오는 뒤쪽 장면을 선호.\n" f"- 최대 {max_real}장까지만 배치(나머지는 제외).\n\n" f"[장면 목록]\n{scene_lines}\n\n[사진 라벨]\n{photo_lines}\n") try: resp = client.models.generate_content( model=VISION_MODEL, contents=prompt, config=types.GenerateContentConfig( temperature=0.2, response_mime_type="application/json", response_schema=_MATCH_SCHEMA, ), ) assigns = json.loads(resp.text).get("assignments", []) except Exception as e: print(f" (매칭 실패 → 위치 기반으로 폴백: {type(e).__name__})") return plan_real_scenes(scenes, real_images, None, max_real) real_map, used_photo = {}, set() for a in assigns: pi, sn = a.get("photo_index"), a.get("scene_number") if pi is None or sn is None: continue if 0 <= pi < len(real_images) and 1 <= sn <= len(scenes) \ and pi not in used_photo and sn not in real_map: real_map[sn] = Path(real_images[pi]) used_photo.add(pi) if len(real_map) >= max_real: break return real_map or plan_real_scenes(scenes, real_images, None, max_real) def plan_real_scenes(scenes, real_images, real_scenes=None, max_real=3) -> dict: """어느 장면 번호(1-based)에 어떤 실제 사진을 깔지 매핑 dict 로 반환. - real_scenes 가 주어지면 그 장면 번호들에 사진을 순서대로 매핑. - 없으면 '뒤쪽(마케팅 전환부) 장면들'에 사진 개수만큼(최대 max_real) 자동 배치. """ real_images = list(real_images or []) real_map = {} if not real_images: return real_map if real_scenes: for no, img in zip(real_scenes, real_images): if 1 <= no <= len(scenes): real_map[no] = Path(img) else: n = min(len(real_images), max_real, len(scenes)) start = len(scenes) - n + 1 # 뒤에서 n개 for k in range(n): real_map[start + k] = Path(real_images[k]) return real_map def produce(client, sb, stem, workdir=None, voice=TTS_VOICE, force=False, real_images=None, real_scenes=None, max_real=3, match_content=True, link=None, link_seconds=3.0, store=None, cta=None, img_model=None, allow_text=False, speed=1.0, trim_silence=True, tail=0.15, workers=4, bgm=None, bgm_volume=0.3) -> Path: """스토리보드 dict → 장면별 이미지/음성 생성 후 mp4 합성. 완성 경로 반환. workdir: 이 작업의 결과/중간파일을 담을 폴더. 이미지·음성은 workdir/assets/ 에, 완성 mp4 는 workdir/{stem}.mp4 로 저장한다. (기본: output/{stem}) real_images: 실제 사진(가게/메뉴판 등) 경로 리스트. 뒤쪽 장면에 깔아 AI 컷과 섞는다. real_scenes: 실제 사진을 깔 장면 번호(1-based) 리스트. 없으면 뒤쪽 자동. max_real: 자동 배치 시 실제 사진으로 채울 최대 장면 수. link: 주면 맨 마지막에 링크 카드 1장을 붙인다. """ scenes = sb["scenes"] style = sb.get("style_guide", "") char = sb.get("character_sheet", "") workdir = Path(workdir) if workdir else (OUT_DIR / stem) workdir.mkdir(parents=True, exist_ok=True) assets = workdir / "assets" assets.mkdir(exist_ok=True) # 실제 사진 → 장면 배치 결정 # · real_scenes 수동지정이 최우선 # · 아니면 내용 매칭(비전). 단, 새로 생성할 장면이 있을 때만(전부 캐시면 비전 호출 낭비라 생략) real_images = list(real_images or []) # max_real 이 None/0/음수면 '폴더 사진 전부 사용'(장면 수 한도 내) eff_max = max_real if (max_real and max_real > 0) else len(real_images) need_gen = force or any(not (assets / f"scene{i:02d}.png").exists() for i in range(1, len(scenes) + 1)) if not real_images: real_map = {} elif real_scenes: real_map = plan_real_scenes(scenes, real_images, real_scenes, eff_max) elif match_content and client is not None and need_gen: print(f"■ 실제 사진 내용 분석(Gemini 비전) → 장면 매칭 (최대 {eff_max}장)") real_map = match_real_scenes(client, scenes, real_images, eff_max) else: real_map = plan_real_scenes(scenes, real_images, None, eff_max) real_order = {no: k for k, no in enumerate(sorted(real_map))} # 포즈/위치 번갈아 쓰기용 if real_map: picks = ", ".join(f"{no}←{real_map[no].name}" for no in sorted(real_map)) print(f"■ 실제 사진 합성 장면: {picks}") from concurrent.futures import ThreadPoolExecutor, as_completed img_paths = [assets / f"scene{i:02d}.png" for i in range(1, len(scenes) + 1)] audio_paths = [assets / f"scene{i:02d}.wav" for i in range(1, len(scenes) + 1)] def make_image(i, ref, ref_mode="full"): """장면 i 이미지 1장 생성(필요시 실사진 합성). ref=캐릭터 앵커 바이트.""" sc = scenes[i - 1] raw_png = assets / f"scene{i:02d}_raw.png" img_png = assets / f"scene{i:02d}.png" # 눈 표정 장면(감김/실눈/윙크/졸림/웃음·울음 등)은 뜬눈 고정을 풀고 표정대로 그린다. # · '눈만 고정(eyes)' 레퍼런스도 이런 장면에선 빼야 눈이 안 떠짐. free_eyes = scene_needs_eye_expression(sc) eye = EYE_FREE if free_eyes else EYE_STYLE use_ref = None if (free_eyes and ref_mode == "eyes") else ref if i in real_map: k = real_order[i] pose = PRESENT_POSES[k % len(PRESENT_POSES)] spot = PRESENT_SPOTS[k % len(PRESENT_SPOTS)] prompt = (f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. {pose} " f"Vertical 9:16 composition.") data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model, no_text=not allow_text, ref_mode=ref_mode) raw_png.write_bytes(data) cover_crop(raw_png, img_png) # 1) AI 배경 composite_card(img_png, real_map[i], img_png, center=spot) # 2) 실사진 카드 else: prompt = f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. Vertical 9:16 composition." data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model, no_text=not allow_text, ref_mode=ref_mode) raw_png.write_bytes(data) cover_crop(raw_png, img_png) # --- 이미지: 캐릭터 앵커(1컷) 먼저, 나머지는 병렬 --- # 앵커 의존성: 모든 컷이 1번 컷(캐릭터)을 ref 로 참조해야 일관성이 유지된다. # → 앵커 1장만 순차로 만든 뒤, 나머지는 동시에 생성(서로 독립). need_img = [i for i in range(1, len(scenes) + 1) if force or not img_paths[i - 1].exists()] cached_img = [i for i in range(1, len(scenes) + 1) if i not in need_img] for i in cached_img: print(f"[{i:2d}] 이미지 캐시 사용") # 공식 캐릭터 레퍼런스가 있으면 '눈만 고정' 모드: 모든 컷이 레퍼런스에서 눈/표정만 복제. # 갑옷·한푸 등 복장은 복제하지 않고 인물별 character_sheet/장면 프롬프트대로 그려진다. char_ref = load_char_ref() if char_ref is not None: print("■ 캐릭터 레퍼런스(눈만 고정) 사용: character/ " "→ 눈/표정은 통일, 복장은 인물별로 다르게") ref_bytes, ref_mode, anchor = char_ref, "eyes", None else: ref_mode = "full" ref_bytes = None for i in range(1, len(scenes) + 1): # 캐시된 raw 가 있으면 앵커로 재사용 raw = assets / f"scene{i:02d}_raw.png" if raw.exists() and not force: ref_bytes = raw.read_bytes(); break anchor = None if ref_bytes is None: anchor = next((i for i in need_img if i not in real_map), None) if anchor is not None: print(f"[{anchor:2d}] 캐릭터 앵커 이미지 생성...", flush=True) make_image(anchor, None) ref_bytes = (assets / f"scene{anchor:02d}_raw.png").read_bytes() rest = [i for i in need_img if i != anchor] if rest: print(f"■ 이미지 {len(rest)}장 병렬 생성 (워커 {workers})...", flush=True) with ThreadPoolExecutor(max_workers=workers) as ex: futs = {ex.submit(make_image, i, ref_bytes, ref_mode): i for i in rest} for fut in as_completed(futs): i = futs[fut]; fut.result() tag = f"실사합성({real_map[i].name})" if i in real_map else "이미지" print(f"[{i:2d}] {tag} done", flush=True) # --- 음성: 전부 독립 → 병렬 --- def make_tts(i): text = (scenes[i - 1].get("narration") or "").strip() if not text: # 빈 나레이션 방어: TTS 가 빈 텍스트에 빈 응답을 반복하다 죽으므로 0.3초 무음으로 대체. # (정상 경로에선 gen_storyboard 가 빈 장면을 미리 거르지만, 만약을 대비한 이중 안전장치) pcm_to_wav(b"\x00\x00" * int(24000 * 0.3), audio_paths[i - 1], 24000) return pcm, rate = gen_tts(client, text, voice) pcm_to_wav(pcm, audio_paths[i - 1], rate) need_wav = [i for i in range(1, len(scenes) + 1) if force or not audio_paths[i - 1].exists()] for i in (i for i in range(1, len(scenes) + 1) if i not in need_wav): print(f" [{i:2d}] 음성 캐시 사용 ({wav_seconds(audio_paths[i - 1]):.1f}초)") if need_wav: print(f"■ 음성 {len(need_wav)}개 병렬 생성 (워커 {workers})...", flush=True) with ThreadPoolExecutor(max_workers=workers) as ex: futs = {ex.submit(make_tts, i): i for i in need_wav} for fut in as_completed(futs): i = futs[fut]; fut.result() print(f" [{i:2d}] 음성 {wav_seconds(audio_paths[i - 1]):.1f}초", flush=True) out = workdir / f"{stem}.mp4" print(f"\n영상 합성 → {out.name}") build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds, store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail, bgm=bgm, bgm_volume=bgm_volume) print(f"완성: {out}") return out