ssulbox/backend/generator/animation/main.py

269 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""
키워드 → 병맛 한국사 마케팅 쇼츠 (완전 자동, 원커맨드)
==========================================================
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
1) 랜덤으로 조선왕 '' 매칭
2) Gemini 로 병맛 마케팅 나레이션 생성
3) 장면 분할(스토리보드)
4) 장면별 이미지 + 음성 생성
5) Ken Burns + 자막(맨 위) + 음성 합성 → 세로 mp4
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
선행: namu_joseon_scraper.py 로 만든 output/joseon_keywords_*.csv 가 있어야 함.
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
★ 실행 (Shorts 폴더 안에서, 키워드는 자유롭게):
python auto_short.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
python auto_short.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
python auto_short.py 배달앱 왕의밥상 --label 왕의밥상
옵션:
--label 왕의휴식 결과 폴더 이름에 쓸 라벨 (기본: 키워드 앞부분 자동)
--king 광해군 특정 왕 고정 (기본: 랜덤)
--scenes 12 장면 수=이미지 수 (기본 12)
--seconds 45 목표 길이 초 (기본 45)
--voice Puck TTS 목소리
--keep 중간 파일(assets) 삭제하지 않고 보존
"""
import argparse
import json
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
from google import genai
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 공유 모듈(naver.py, media.py)
import gen # 나레이션 + 스토리보드 생성(병합: 이전 gen_narration + gen_storyboard)
import render as MS # animation 영상합성 엔진(이전 make_short.py)
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
HERE = Path(__file__).parent
OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/
TEXT_MODEL = "gemini-2.5-flash"
def safe_name(s: str) -> str:
s = re.sub(r"[^\w가-힣]+", "_", s).strip("_")
return s[:30] or "short"
def main():
ap = argparse.ArgumentParser(
description="키워드 또는 네이버 링크 → 병맛 한국사 쇼츠 (완전 자동)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
)
# 마케팅 대상: 자유 키워드(여러 단어/문장) 또는 네이버 링크(섞어 써도 됨)
ap.add_argument("keywords", nargs="*",
help="마케팅 대상 키워드/문장, 또는 네이버 링크 (--link 없이 링크만 줘도 됨)")
# 결과 폴더 라벨
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
# 생성 옵션
ap.add_argument("--king", help="특정 왕 고정 (기본: 랜덤)")
ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
ap.add_argument("--keep", action="store_true", help="중간 파일(assets) 보존")
ap.add_argument("--csv", help="썰 CSV 경로 (기본: output/ 최신)")
# 실제 사진(가게/메뉴판) 섞기 + 마지막 링크 카드
ap.add_argument("--images", default="load_image",
help="실제 사진 폴더. 가게 이름만 줘도 load_image/<가게>/ 를 찾음 "
"(예: --images 연산골막국수). 비었으면 전부 AI")
ap.add_argument("--max-real", type=int, default=0,
help="실제 사진으로 채울 최대 장면 수 (기본 0=폴더 사진 전부, 장면 수 한도 내)")
ap.add_argument("--real-scenes",
help="실제 사진 깔 장면 번호 1-based, 콤마구분 (예: 5,6,7). 주면 내용매칭 대신 수동")
ap.add_argument("--no-match", action="store_true",
help="실제 사진 내용 자동매칭(Gemini 비전) 끄고 위치 기반(뒤쪽 장면)으로")
ap.add_argument("--photos", type=int, default=4,
help="네이버 사진탭에서 자동 선별할 공식사진 수 (기본 4, --link 줄 때)")
ap.add_argument("--no-photos", action="store_true",
help="네이버 사진 자동 수집 끄기 (전부 AI 그림으로)")
ap.add_argument("--link", help="맨 마지막 화면에 띄울 링크/문구 (예: naver.me/xxxx)")
ap.add_argument("--link-seconds", type=float, default=3.0, help="링크 카드 길이(초)")
ap.add_argument("--store", help="링크 카드에 함께 띄울 가게 이름 (기본: 키워드 첫 부분 자동)")
ap.add_argument("--cta", default=MS.LINK_CTA, help=f"링크 카드 안내문구 (기본: '{MS.LINK_CTA}')")
ap.add_argument("--img-model", default=MS.IMG_MODEL,
help=f"이미지 생성 모델 (기본 {MS.IMG_MODEL}). 글자 또렷하게: gemini-3.1-flash-image / gemini-3-pro-image")
ap.add_argument("--allow-text", action="store_true",
help="그림 안 글자 금지 해제 (신형 모델로 간판에 진짜 이름 넣고 싶을 때)")
# 컷편집 느낌 (말 빠르게 + 컷 사이 침묵 제거 → 분량 ↓)
ap.add_argument("--speed", type=float, default=1.3,
help="말 속도 배율 (기본 1.3=30%% 빠르게, 음정 유지). 1.0=원본")
ap.add_argument("--no-trim", action="store_true",
help="컷 앞뒤 침묵 제거 끄기 (기본은 제거해 컷을 타이트하게)")
ap.add_argument("--tail", type=float, default=0.0,
help="각 컷 말 끝난 뒤 여운(초). 기본 0=말 끝나자마자 다음 컷. 여유 주려면 0.1~0.2")
ap.add_argument("--workers", type=int, default=4,
help="이미지·음성 동시 생성 수 (기본 4). 높이면 빠르지만 API 속도제한 주의")
ap.add_argument("--bgm",
help="배경음악(병맛 BGM) 파일 또는 폴더. 폴더면 무작위 1곡. "
"기본: bgm/ 폴더에 곡이 있으면 자동으로 무작위 사용. "
"특정 곡 고정하려면 --bgm 파일명. BGM 끄려면 --bgm none")
ap.add_argument("--bgm-volume", type=float, default=0.3,
help="BGM 볼륨 0~1 (기본 0.3). 나레이션 안 묻히게 조절")
ap.add_argument("--api-key")
args = ap.parse_args()
# --link 안 써도 됨: 위치 인자에 네이버 링크가 섞여 있으면 그걸 링크로 빼낸다
# (narration/kakao 처럼 `python main.py https://naver.me/xxxx` 만으로 실행 가능)
if not args.link:
link_tok = next((k for k in args.keywords if NV.looks_like_naver_place(k)), None)
if link_tok:
args.link = link_tok
args.keywords = [k for k in args.keywords if k != link_tok]
product = " ".join(args.keywords).strip()
# 키워드를 안 줬는데 --link 가 네이버 플레이스면 → AI 브리핑을 긁어와 마케팅 키워드로 사용
brief_store = None
if not product and NV.looks_like_naver_place(args.link or ""):
print("■ 키워드 없음 → 네이버 AI 브리핑 자동 수집...")
try:
brief_store, bullets, _cat = NV.fetch_briefing(args.link) # 업종(cat)은 animation 미사용
except Exception as e:
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
if not bullets:
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
print(f"'{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not _key:
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"\" 후 새 터미널, 또는 --api-key.")
try:
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
except Exception as e:
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
if rstore and not brief_store:
brief_store = rstore
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
if not bullets:
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
"키워드를 직접 주거나 다른 링크를 쓰세요.")
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
product = " ".join(bullets)
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
for b in bullets:
print(f"{b}")
if not product:
sys.exit("[!] 마케팅 키워드가 필요해. 예: python auto_short.py 감성 펜션 ... "
"또는 --link 에 네이버 가게 링크(AI 브리핑 자동 수집).")
api_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not api_key:
sys.exit("[!] API 키 없음. setx GEMINI_API_KEY \"\" 후 새 터미널, 또는 --api-key.")
csv_path = Path(args.csv) if args.csv else gen.latest_csv()
rows = gen.load_rows(csv_path)
client = genai.Client(api_key=api_key)
OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
# 네이버 링크 주고 사진 폴더를 직접 안 줬으면 → 사진탭 공식사진을 자동 수집·선별
if (not args.no_photos and NV.looks_like_naver_place(args.link or "")
and args.images == "load_image"):
store_for_dir = (brief_store or args.store or args.label
or " ".join(args.keywords[:2]) or "naver_store")
photo_dir = HERE / "load_image" / safe_name(store_for_dir)
existing = [p for p in photo_dir.glob("*.jpg")] if photo_dir.exists() else []
if existing:
print(f"■ 사진 캐시 재사용: {photo_dir} ({len(existing)}장)")
args.images = safe_name(store_for_dir)
else:
print(f"■ 네이버 사진탭 → 공식사진 {args.photos}장 자동 수집·선별...")
try:
_, finals = NV.fetch_and_select(args.link, photo_dir,
n=args.photos, client=client)
if finals:
args.images = safe_name(store_for_dir)
print(f"■ 사진 {len(finals)}장 준비 → {photo_dir}")
else:
print("■ 공식사진을 못 받았어요 → 전부 AI 그림으로 진행")
except Exception as e:
print(f"■ 사진 수집 실패({type(e).__name__}) → 전부 AI 그림으로 진행")
# 이 작업 전용 폴더 (모든 산출물은 여기 안에만)
label = args.label or " ".join(args.keywords[:3]) or brief_store or product[:20]
stem = f"{safe_name(label)}_{ts}"
job_dir = OUT_DIR / stem
job_dir.mkdir(parents=True, exist_ok=True)
# 1) 랜덤(또는 지정) 왕 썰 매칭
seed = gen.pick_seed(rows, king=args.king)
print(f"■ 작업 폴더 : {job_dir}")
print(f"■ 마케팅 대상 : {product}")
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
# 2) 병맛 나레이션
print("[1/4] 나레이션 생성...")
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds)
(job_dir / "narration.json").write_text(
json.dumps(narr, ensure_ascii=False, indent=2), encoding="utf-8")
# 사람이 바로 읽는 나레이션 '가사' 텍스트 (제목 + 본문 + 해시태그)
lyric = (f"[제목] {narr.get('title','')}\n"
f"[소재] {seed['king']} / \"{seed['keyword']}\"\n"
f"[마케팅] {product}\n"
f"{'-'*40}\n"
f"{narr.get('full_narration','')}\n")
if narr.get("hashtags"):
lyric += f"\n{' '.join(narr['hashtags'])}\n"
(job_dir / "narration.txt").write_text(lyric, encoding="utf-8")
print(f" \"{narr['title']}\" ({len(narr['full_narration'])}자)")
# 3) 스토리보드(장면 분할)
print("[2/4] 스토리보드 분할...")
sb = gen.make_storyboard(client, TEXT_MODEL, narr["full_narration"],
product, seed["king"], scenes=args.scenes)
(job_dir / "storyboard.json").write_text(
json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" 장면 {len(sb['scenes'])}")
# 4) 이미지 + 음성 + 영상 합성 (assets·mp4 모두 job_dir 안)
# 실제 사진(가게/메뉴판)을 내용 맞는 장면에 AI 배경과 합성, 마지막에 링크 카드 추가
real_images, real_scenes = MS.resolve_real_images(args.images, args.real_scenes)
if not real_images:
print(f"■ 실제 사진 : 없음 ('{args.images}' 폴더 비었거나 없음) → 전부 AI")
# --bgm 안 줘도 bgm/ 폴더에 곡이 있으면 자동으로 무작위 1곡 사용
bgm = MS.resolve_bgm(args.bgm or "bgm")
_off = str(args.bgm or "").strip().lower() in ("none", "off", "no")
if args.bgm and not _off and not bgm:
print(f"■ BGM '{args.bgm}' 를 못 찾음 → BGM 없이 진행")
elif bgm:
print(f"■ BGM : {bgm.name} (bgm/ 무작위)")
print("[3/4] 이미지·음성 생성 + [4/4] 영상 합성...")
store = args.store or brief_store or product.split(",")[0].strip() or None
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
real_images=real_images, real_scenes=real_scenes,
max_real=args.max_real, match_content=not args.no_match,
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
img_model=args.img_model, allow_text=args.allow_text,
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
shutil.rmtree(job_dir / "assets", ignore_errors=True)
for junk in ("narration.json", "storyboard.json"):
(job_dir / junk).unlink(missing_ok=True)
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
print(f"\n✅ 완성: {out_mp4}")
print(f" {seed['king']} 썰 · {product}")
if __name__ == "__main__":
main()