# -*- coding: utf-8 -*- """ animation_odyssey 생성 단계 (나레이션 + 스토리보드) — 병합본 ================================================================== 오디세이아 '썰' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음. (animation_greekroman 폴더와 동일 구조 — 소재만 '그리스 로마 신화 신·영웅' → '오디세우스의 귀향 대모험'.) scraper.py 가 만든 odyssey_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("odyssey_keywords_*.csv")) if not files: sys.exit("[!] animation_odyssey/data/ 에 odyssey_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 # 오디세이아(귀향 대모험) 핵심 사건·인물 신호 — 이런 키워드를 '썰 씨앗'으로 최우선. ODYSSEY_HINTS = [ "오디세이아", "오디세우스", "트로이", "목마", "섬", "동굴", "키클롭스", "폴리페모스", "키르케", "칼립소", "세이렌", "스킬라", "카립디스", "아이올로스", "헬리오스", "구혼자", "귀향", "파이에케스", "로토파고이", "수의", "표류", "항해", "저승", "페넬로페", "텔레마코스", "안티노오스", "나우시카", "이타카", "아무도", "난파", "변장", "활 시합", ] # 오디세이아와 무관하거나 내용이 빈약한 위키 섹션 — 씨앗에서 제외(뽑히면 '그리스로마신화'로 샌다). SEED_DROP = [ "개요", "어원", "특징", "총평", "평가", "긍정적", "부정적", "기타", "그 외", "가계도", "가족", "대중매체", "창작물", "미디어", "만화", "숭배", "해석", "신화 속 행적", "신화에서", "전승", "이름", "별명", "어록", "상징", "비블리오테케", "텔레고네이아", # 오디세이아 외 전거(다른 신화 출처) ] def _seed_pool(rows): """범용·무관 섹션을 걸러 '오디세이아에 실질적으로 쓸 만한' 씨앗만 남긴다.""" pool = [] for r in rows: kw = r["keyword"].strip() if not kw or kw == r["king"]: # 최상단 표제(= 인물명) 제외 continue if any(bad in kw for bad in SEED_DROP): # 범용/무관 섹션 제외 continue pool.append(r) return pool def pick_seed(rows, king=None, keyword=None): """대본의 씨앗이 될 (인물, 썰키워드) 한 건 선택. ★원 소스는 항상 오디세이아. ※ CSV 의 is_anecdote(Y) 는 '평가/기타/대중매체' 같은 범용 섹션에 붙어 오히려 오디세이아를 벗어나므로 쓰지 않는다.""" 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) # ① 오디세이아 핵심 사건 키워드를 최우선(항해·괴물·귀향 등). 인물명도 함께 검사 — # 키워드 문구에 힌트가 없어도 인물 자체가 오디세이아 전용(세이렌·키르케 등)이면 씨앗으로 유효. odyssey = [r for r in cand if any(h in r["keyword"] or h in r["king"] for h in ODYSSEY_HINTS)] if odyssey: return random.choice(odyssey) # ② 없으면 범용/무관 섹션을 걸러낸 실질 씨앗 풀에서 pool = _seed_pool(cand) if pool: return random.choice(pool) # ③ 최후 폴백: 빈 풀 방지 return random.choice(cand) NARR_SYSTEM = """\ 너는 한국 숏폼(쇼츠/릴스) 전문 카피라이터다. '병맛(어이없고 황당하지만 묘하게 중독성 있는)' 유머 톤으로, 오디세이아 속 오디세우스와 그 일행·신들의 '썰'을 풀다가 마지막에 자연스러운 척 억지로 광고로 갈아타는 {seconds}초 나레이션 대본을 쓴다. [소재 원칙] (가장 중요) - 원 소스는 반드시 서사시 '오디세이아'(오디세우스의 10년 귀향 대모험)다. 모든 썰은 이 항해 이야기 안에서 풀어라. - 인물이 신(포세이돈·아테나·제우스 등)이라도 '오디세이아 속 그 신의 역할'로만 다뤄라 (포세이돈=귀향을 방해한 바다신, 아테나=오디세우스를 편애해 도운 여신 식). 오디세이아 밖 일반 그리스·로마 신화 일화(제우스의 바람기, 신들의 탄생·계보, 헤라클레스 과업 등)로 새지 마라 — 그건 다른 장르다. [톤 규칙] - 구어체 반말 나레이션. TTS(AI 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게. - 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환. - 오디세이아 내용(인물 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다. 유명 장면(트로이 목마, 외눈박이 폴리페모스 "아무도 아니다" 사기, 세이렌의 노래, 키르케의 돼지 마법, 칼립소의 섬, 스킬라와 카립디스, 페넬로페의 수의 짜기, 활쏘기 시합과 구혼자 응징 등)을 드립 소재로 적극 활용해도 된다. 포세이돈의 분노, 아테나의 편애 같은 유명 밈은 써도 되지만 선정적으로 묘사하진 마라. 단, 시청자가 '진짜 이야기'와 '드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지). - 2026년 영화화(각색판)에서 원작과 달라진 지점(예: 트로이 목마를 신의 법도를 어긴 죄로 재해석, 칼립소와의 동거 삭제, 왕위를 아들에게 넘기고 다시 떠나는 결말 등)도 "요즘은 영화로도 나왔는데 각색이 다르다더라" 식 드립으로 언급해도 된다. 단, 실제 배우·감독 등 실존 인물 이름은 절대 언급하지 마라 — 오직 각색 내용(줄거리 차이)만 다뤄라. - 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선. [구조] (반드시 이 4단) 1) hook : 3초 안에 스크롤 멈추게 하는 한 방. 질문/충격/궁금증. 2) story : 썰 본문. 역사로 시작해 점점 병맛으로 과열. 광고로 넘어갈 '연결고리'를 은근히 심어둔다. 3) pivot : "근데 알고 보니/그래서/요즘 같으면…" 식으로 광고로 억지 전환. 이 억지스러움 자체가 개그. 4) cta : 광고 대상의 핵심 셀링포인트 + 행동유도 한 줄. [분량] - 한국어 나레이션 기준 약 {seconds}초 분량 = {lo}~{hi}자. full_narration 은 반드시 이 글자수 범위로. - 이 범위를 넘기지 마라. 너무 길면 TTS가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게. [출력] - 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지. - full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션. - sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만). """ NARR_USER = """\ [이번 쇼츠 소재] - 인물: {king} ({doc}) - 썰 키워드: "{keyword}" - 참고 출처: {url} [마케팅 대상 (마지막에 이걸로 갈아타기)] - 가게명: {store} - 소개/키워드: {product} 위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는 {seconds}초 쇼츠 나레이션을 만들어라. 썰 키워드가 빈약하면, 그 인물이 '오디세이아(오디세우스의 귀향 대모험)'에서 맡은 역할·사건 (항해·괴물·표류·귀향·구혼자 응징 등)으로 살을 붙여라. 오디세이아와 무관한 다른 신화 일화로 새지 마라. """ NARR_SCHEMA = { "type": "object", "properties": { "title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."}, "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"}}, "sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"}, }, "required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"], } def generate(client, model, seed, product, seconds, store=""): """신화 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini). store = 가게명(SNS 캡션용).""" # 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 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, store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), 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). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다. → 그래야 컷마다 눈 표정이 다양해진다. ★ 잔혹 장면(눈 찌르기, 학살, 잡아먹힘 등)은 이미지 안전필터에 걸려 생성이 거부된다. 폭력을 직접 묘사하지 말고 만화적으로 순화해 적어라 — 유혈/찌르는 순간/시체 금지. (예: 눈 찌르기 → the cyclops comically covering his eye with cartoon stars around his head, 학살 → suitors comically fleeing in panic with exaggerated cartoon expressions) - camera: 그 장면에 어울리는 카메라 움직임을 아래 중 하나로 고른다. 장면의 '감정'에 맞춰라. reveal : 상황 전체가 드러남 (첫 컷 기본) punch_in : 반전·폭로·핵심 한 방 (강하다. 영상당 1~2번만) slow_in : 몰입이 쌓이는 서술, 마지막 CTA slow_out : 상황이 점점 보이는 서술 pan_left / pan_right : 이동·전개·시간 흐름 tilt_up : 올려다봄 — 권위·거대함·감탄 tilt_down : 내려다봄 — 추락·좌절·한심함 shake : 충격·당황·사고 (강하다. 영상당 1번이면 충분) hold : 정적인 대사·여운. 강한 컷 앞뒤에 넣으면 대비가 산다 ★ 같은 값을 3연속으로 쓰지 마라. 강한 효과(punch_in/shake)를 연달아 쓰면 산만해진다. [전역 설정] - 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/애기얼굴 금지. → 단 과한 노출·선정적 묘사는 금지. 토가/키톤/갑옷 등 신화 복식은 제대로 입힌다. 복식/색/머리/상징물(투구·활·삼지창·베일 등 그 인물의 아이콘)을 못박아 컷마다 동일하게. (예: Odysseus as a tall rugged hero with a heroic muscular greek-statue physique, short dark curly hair and a trimmed beard, wearing a worn bronze breastplate and a red cloak, holding a bow and a spear / 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"}, # 값은 media.CAMERA_KINDS 와 반드시 일치해야 한다. # 모르는 값이 오면 렌더가 조용히 기본 순환으로 떨어진다. "camera": { "type": "string", "enum": [ "reveal", "punch_in", "slow_in", "slow_out", "pan_left", "pan_right", "tilt_up", "tilt_down", "shake", "hold", ], }, }, "required": ["narration", "caption", "image_prompt", "camera"], }, }, }, "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