ssulbox/backend/generator/animation_greekroman/gen.py

293 lines
15 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_greekroman 생성 단계 (나레이션 + 스토리보드) — 병합본
==================================================================
그리스 로마 신화 '' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음.
(animation(조선왕) 폴더와 동일 구조 — 소재만 '조선왕''그리스 로마 신화 신·영웅'.)
scraper.py 가 만든 greek_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("greek_keywords_*.csv"))
if not files:
sys.exit("[!] animation_greekroman/data/ 에 greek_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 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게.
- 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환.
- 신화 내용(인물 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다.
유명 신화 장면(판도라의 상자, 트로이 전쟁, 12과업, 미다스의 손, 시시포스의 바위 등)을 드립 소재로 적극 활용해도 된다.
제우스의 바람기, 헤라의 질투 같은 유명 밈은 써도 되지만 선정적으로 묘사하진 마라.
단, 시청자가 '진짜 역사''드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
[구조] (반드시 이 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 muscular god dramatically throwing a lightning bolt from a cloud, sweating)
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
→ 그래야 컷마다 눈 표정이 다양해진다.
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
★ 한국의 '그리스 로마 신화 학습만화'처럼, 신을 '진짜 신의 모습'으로 그린다 — 의인화 동물 금지.
· 남신/남자 영웅: 조각상 같은 근육질의 멋진 체격(broad shoulders, heroic muscular greek-statue
physique), 잘생긴 얼굴. · 여신: 아름답고 늘씬한 몸매(beautiful, slender, graceful), 긴 머리.
· 반드시 '성인 8등신 비율'로: tall adult heroic proportions, 8-heads-tall elegant body,
NOT chibi, NOT super-deformed, NOT cute mascot — 치비/SD/애기얼굴 금지.
→ 단 과한 노출·선정적 묘사는 금지. 토가/키톤/갑옷 등 신화 복식은 제대로 입힌다.
복식/색/머리/상징물(번개·투구·부엉이·삼지창·월계관 등 그 신의 아이콘)을 못박아 컷마다 동일하게.
(예: Zeus as a tall handsome god with a heroic muscular greek-statue physique, long white hair and
majestic white beard, wearing a white toga with gold trim and a laurel wreath, holding a glowing
golden lightning bolt / Athena as a beautiful slender goddess with long dark hair, wearing a bronze
helmet and flowing white chiton with a golden owl emblem, holding a spear and round shield)
★ 눈 그림체: 순정만화/한국 신화만화풍의 크고 또렷한 눈(large expressive sparkling manhwa eyes,
detailed irises with light reflections)으로 통일. 이 '그림체'만 유지하고, 장면 감정에 따라
눈을 감거나(eyes closed) 실눈(squint)·윙크·부릅뜸(wide dramatic glare) 등 표정은 바꿔도 된다.
- style_guide: 그림 스타일을 영어로. 한국 그리스로마신화 학습만화풍(classic Korean mythology manhwa
style) — 깔끔하고 또렷한 외곽선, 화사하고 선명한 색, 반짝이는 하이라이트, 극적인 연출,
'성인 비율의 캐릭터(tall adult proportions, never chibi or super-deformed)',
단순 배경(고대 그리스 신전·올림포스·구름·대리석 기둥 분위기), 세로 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