372 lines
21 KiB
Python
372 lines
21 KiB
Python
# -*- 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
|