293 lines
15 KiB
Python
293 lines
15 KiB
Python
# -*- 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
|