# -*- 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