ssulbox/backend/generator/animation_samgukji/gen.py

283 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -*- coding: utf-8 -*-
"""
animation_samgukji 생성 단계 (나레이션 + 스토리보드) — 병합본
==================================================================
삼국지 인물 '' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음.
(animation(조선왕) 폴더와 동일 구조 — 소재만 '조선왕''삼국지 인물'.)
scraper.py 가 만든 samguk_keywords_*.csv 를 입력으로 한다.
· 나레이션: latest_csv / load_rows / pick_seed / generate
· 스토리보드: clamp_scenes / make_storyboard
구조: 훅 → 썰 전개(역사 + 병맛 과장) → 어거지 전환 → 마케팅 + CTA → 장면별 분할(자막+그림 프롬프트).
"""
import csv
import json
import random
import re
import sys
from pathlib import Path
from google.genai import types
HERE = Path(__file__).parent
DATA_DIR = HERE / "data" # 삼국지 인물 일화 CSV(선행 데이터). ★ output/ 이 아님 — 거긴 출력물(mp4)만.
DEFAULT_MODEL = "gemini-2.5-flash"
# 자막 폰트(주아/송명/맑은고딕)에 없는 이모지·기호(😊▼★♥ 등)는 화면에서 □ 로 깨진다 → 제거.
_TYPO = {"": "...", "": "'", "": "'", "": '"',
"": '"', "·": " ", "": "~", "": "~"}
# 폰트(주아/송명/맑은고딕)에 확실히 있는 것 = 한글 음절·자모(ㅋㅋ ㅠㅠ) + ASCII 인쇄문자. 그 외(이모지·…·특수기호)는 제거.
_KEEP_RE = re.compile(r"[^가-힣ㄱ-ㅣ\x20-\x7E]")
def _no_emoji(s):
s = s or ""
for _k, _v in _TYPO.items():
s = s.replace(_k, _v)
return re.sub(r" +", " ", _KEEP_RE.sub("", s)).strip()
# ============================== ① 나레이션 (삼국지 썰 → 병맛 대본) ==============================
def latest_csv() -> Path:
files = sorted(DATA_DIR.glob("samguk_keywords_*.csv"))
if not files:
sys.exit("[!] animation_samgukji/data/ 에 samguk_keywords_*.csv 가 없음. 먼저 'python scraper.py' 실행.")
return files[-1]
def load_rows(csv_path: Path):
"""CSV → [{order, king, doc, is_anecdote, keyword, url}, ...] (king = 삼국지 인물 이름)"""
rows = []
with csv_path.open(encoding="utf-8-sig", newline="") as f:
r = csv.reader(f)
next(r, None) # 헤더 스킵
for line in r:
if len(line) < 6:
continue
order, king, doc, anec, kw, url = line[:6]
rows.append({
"order": int(order) if order.isdigit() else 0,
"king": king, "doc": doc,
"is_anecdote": anec.strip().upper() == "Y",
"keyword": kw, "url": url,
})
return rows
def pick_seed(rows, king=None, keyword=None, prefer_anecdote=True):
"""대본의 씨앗이 될 (인물, 썰키워드) 한 건 선택."""
cand = rows
if king:
cand = [r for r in cand if r["king"] == king]
if not cand:
kings = sorted({r["king"] for r in rows})
sys.exit(f"[!] '{king}' 없음. 가능: {', '.join(kings)}")
if keyword:
kw_match = [r for r in cand if keyword in r["keyword"]]
if not kw_match:
sys.exit(f"[!] '{keyword}' 키워드가 {king or '전체'}에 없음.")
return random.choice(kw_match)
if prefer_anecdote:
anec = [r for r in cand if r["is_anecdote"]]
if anec:
cand = anec
return random.choice(cand)
NARR_SYSTEM = """\
너는 한국 숏폼(쇼츠/릴스) 전문 카피라이터다. '병맛(어이없고 황당하지만 묘하게 중독성 있는)' 유머 톤으로,
삼국지(중국 후한 말~삼국시대) 인물들의 ''을 풀다가 마지막에 자연스러운 척 억지로 광고로 갈아타는 {seconds}초 나레이션 대본을 쓴다.
[톤 규칙]
- 구어체 반말 나레이션. TTS(AI 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게.
- 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환.
- 역사적 사실(인물 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다.
삼국지연의(소설) 속 유명 장면(도원결의, 삼고초려, 적벽대전, 장판파 등)을 드립 소재로 적극 활용해도 된다.
단, 시청자가 '진짜 역사''드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
[구조] (반드시 이 4단)
1) hook : 3초 안에 스크롤 멈추게 하는 한 방. 질문/충격/궁금증.
2) story : 썰 본문. 역사로 시작해 점점 병맛으로 과열. 광고로 넘어갈 '연결고리'를 은근히 심어둔다.
3) pivot : "근데 알고 보니/그래서/요즘 같으면…" 식으로 광고로 억지 전환. 이 억지스러움 자체가 개그.
4) cta : 광고 대상의 핵심 셀링포인트 + 행동유도 한 줄.
[분량]
- 한국어 나레이션 기준 약 {seconds}초 분량 = {lo}~{hi}자. full_narration 은 반드시 이 글자수 범위로.
- 이 범위를 넘기지 마라. 너무 길면 TTS가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
[출력]
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
"""
NARR_USER = """\
[이번 쇼츠 소재]
- 인물: {king} ({doc})
- 썰 키워드: "{keyword}"
- 참고 출처: {url}
[마케팅 대상 (마지막에 이걸로 갈아타기)]
{product}
'썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
{seconds}초 쇼츠 나레이션을 만들어라. 썰 키워드가 빈약하면 그 인물의 유명한 일화(연의 속 명장면 포함)로 살을 붙여도 된다.
"""
NARR_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적)"},
"hook": {"type": "string"},
"story": {"type": "string"},
"pivot": {"type": "string"},
"cta": {"type": "string"},
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
"estimated_seconds": {"type": "integer"},
"hashtags": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "hook", "story", "pivot", "cta", "full_narration"],
}
def generate(client, model, seed, product, seconds):
"""삼국지 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini)."""
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
user = NARR_USER.format(
king=seed["king"], doc=seed["doc"],
keyword=seed["keyword"], url=seed["url"], product=product, seconds=seconds,
)
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
resp = client.models.generate_content(
model=model,
contents=user,
config=types.GenerateContentConfig(
system_instruction=sys_prompt,
temperature=1.1, # 병맛 = 약간 높은 창의성
response_mime_type="application/json",
response_schema=NARR_SCHEMA,
),
)
data = json.loads(resp.text)
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
data["_product"] = product
return data
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
SB_SYSTEM = """\
너는 숏폼 영상 디렉터다. 주어진 '병맛 삼국지 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
[핵심 규칙]
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the cat general sweating while defending a castle gate alone)
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
→ 그래야 컷마다 눈 표정이 다양해진다.
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의. 삼국지 인물을 '귀여운 통통한 고양이'로 의인화.
복식/색/특징(갑옷·한푸·무기·수염 등 그 인물의 상징)을 못박아 컷마다 동일하게.
(예: a chubby cute cat as Guan Yu from Three Kingdoms, wearing green hanfu robe and golden armor,
long flowing black beard, holding a tiny green dragon crescent blade, round face /
a chubby cute cat as Zhuge Liang, wearing white-and-teal hanfu scholar robe, holding a white feather fan)
★ 눈 그림체: 눈을 떴을 때는 large round eyes with big round solid black pupils and a small
soft white highlight, gentle innocent look (귀엽고 둥근 눈)으로 통일. 단 이 '그림체'만 유지하고,
장면 감정에 따라 눈을 감거나(eyes closed) 실눈(squint)·윙크·졸린 눈 등 표정은 바꿔도 된다.
절대 날카롭거나 찢어진/화난/매서운/사실적인 맹수 눈은 금지.
- style_guide: 그림 스타일을 영어로. 한국 웹툰풍 플랫 일러스트, 굵은 외곽선, 따뜻한 색, 단순 배경(고대 중국 삼국시대 분위기), 세로 9:16.
[출력] 지정된 JSON 스키마로만.
"""
SB_USER = """\
[마케팅 대상] {product}
[소재 인물] {king}
[목표 장면 수] 약 {scenes}
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
{narration}
"""
SB_SCHEMA = {
"type": "object",
"properties": {
"character_sheet": {"type": "string"},
"style_guide": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"narration": {"type": "string"},
"caption": {"type": "string"},
"image_prompt": {"type": "string"},
},
"required": ["narration", "caption", "image_prompt"],
},
},
},
"required": ["character_sheet", "style_guide", "scenes"],
}
def clamp_scenes(scene_list, target):
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
def clen(s):
return len(s.get("narration", "").strip())
while len(scene_list) > target:
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
i = min(range(len(scene_list) - 1),
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
base["narration"] = (a + " " + b).strip()
scene_list[i:i + 2] = [base]
return scene_list
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
"""나레이션 → 스토리보드 dict (Gemini)."""
resp = client.models.generate_content(
model=model,
contents=SB_USER.format(product=product, king=king,
scenes=scenes, narration=narration),
config=types.GenerateContentConfig(
system_instruction=SB_SYSTEM,
temperature=0.8,
response_mime_type="application/json",
response_schema=SB_SCHEMA,
),
)
sb = json.loads(resp.text)
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
raw = sb.get("scenes", [])
kept = [s for s in raw if s.get("narration", "").strip()]
if len(kept) < len(raw):
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
sc["narration"] = _no_emoji(sc.get("narration", ""))
sc["caption"] = _no_emoji(sc.get("caption", ""))
sb["_product"] = product
sb["_king"] = king
return sb