255 lines
14 KiB
Python
255 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
키워드 → 병맛 삼국지 마케팅 쇼츠 (완전 자동, 원커맨드)
|
|
==========================================================
|
|
|
|
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
|
|
1) 랜덤으로 삼국지 인물 '썰' 매칭
|
|
2) Gemini 로 병맛 마케팅 나레이션 생성
|
|
3) 장면 분할(스토리보드)
|
|
4) 장면별 이미지 + 음성 생성
|
|
5) Ken Burns + 자막(맨 위) + 음성 합성 → 세로 mp4
|
|
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
|
|
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
|
|
|
|
선행: scraper.py 로 만든 data/samguk_keywords_*.csv 가 있어야 함.
|
|
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
|
|
|
|
★ 실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
|
|
python animation_samgukji\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
|
|
python animation_samgukji\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
|
|
python animation_samgukji\\main.py https://naver.me/xxxx
|
|
|
|
옵션:
|
|
--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_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/
|
|
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:
|
|
sys.exit(f"[!] '{brief_store or '이 가게'}'에 AI 브리핑이 없어요. "
|
|
"키워드를 직접 주거나, 브리핑 있는 가게 링크를 쓰세요.")
|
|
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()
|