init: 썰박스(SSULBOX) 마케팅 쇼츠 플랫폼

- frontend: React+Vite+TS+Tailwind (모바일 우선 인스타형 피드/뷰어/채널)
- backend: FastAPI + SQLAlchemy async + SQLite, 카카오 로그인/유튜브 연동/크레딧
- generator: 네이버 크롤 기반 AI 쇼츠 생성기(애니메이션 3종)
- 시크릿(.env)·DB·node_modules·output·bgm 은 커밋 제외

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
jwkim 2026-07-08 14:55:47 +09:00
commit 9ab618db10
64 changed files with 14967 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# node
frontend/node_modules/
frontend/dist/
# python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# env (실 키는 커밋하지 않음 — .env.example 만 커밋)
.env
.env.*
!.env.example
# 개발용 DB
backend/ssulbox.db
# 생성 산출물(영상)·크롤 캐시 — 커밋하지 않음
backend/generator/output/
backend/generator/**/load_image/
# 공유 BGM 에셋(용량 큼) — git 대신 별도 배포
backend/generator/bgm/
# misc
.DS_Store
_s/

62
README.md Normal file
View File

@ -0,0 +1,62 @@
# o2o-ssulbox — 썰박스
병맛 역사 **썰툰(썰박스)** 마케팅 쇼츠 플랫폼. 네이버 링크/업장명만 넣으면
조선왕·삼국지·그리스로마신화 시나리오로 AI가 대본→그림→목소리→영상을 자동 생성한다.
인스타 느낌의 모바일 전용 UI(웹은 좌측 사이드바) + 계정/좋아요/댓글/크레딧 소셜 레이어.
```
o2o-ssulbox/
├── frontend/ React + Vite + TypeScript + Tailwind (모바일 전용 반응형)
│ └── src/ App · components · api(백엔드 /api 연동) · data · icons
└── backend/ FastAPI + SQLAlchemy(async) — API 전용
├── app/
│ ├── main.py 앱·라우터·lifespan(테이블 생성 + output 동기화)
│ ├── config.py 설정(개발 DB=SQLite, JWT, NAVER, 크레딧)
│ ├── database.py async 세션 + Base + TimestampMixin
│ ├── models.py User·Content·Like·Comment·CreditTransaction·SocialAccount
│ ├── deps.py 현재 사용자(필수/선택) + 페이지네이션
│ ├── security.py JWT 발급/검증
│ ├── credit_service.py 크레딧 충전/차감(원장)
│ ├── content_sync.py output 스캔 → content 반영
│ ├── jobs.py 생성 잡(subprocess → 내장 generator)
│ └── routers/ auth · content(피드/생성/좋아요/자동완성) · comments · credit · sns
└── generator/ ★생성 엔진 내장(vendored, 외부 shorts_all 참조 없음)
├── naver.py media.py 공유 크롤러/툴박스(1벌)
├── fonts/ bgm/ 공용 폰트·BGM(1벌 — 3엔진 공유)
├── animation/ 조선왕 (character/ data/ = 시나리오별)
├── animation_samgukji/ 삼국지
└── animation_greekroman/ 그리스로마신화
```
## 기능 (castad 백엔드에서 포팅)
- **인증** — JWT(액세스/리프레시). 개발용 `dev-login`(카카오 없이). 카카오는 키 설정 후 활성.
- **피드** — output 기반, 정렬(최신/좋아요) + 페이지네이션.
- **좋아요 / 2-depth 댓글** — 로그인 사용자 기준.
- **크레딧** — 가입 지급 + 영상 1편당 차감(원장 기록). 채널의 "남은 크레딧"과 연동.
- **업장명 자동완성** — 네이버 지역검색(키 설정 시).
- **SNS 연동** — 유튜브/인스타 계정 연결·업로드(구조/엔드포인트, OAuth 키 설정 후 활성).
## 실행
### 1) 백엔드 (FastAPI, :8000)
```bash
cd backend
pip install -r requirements.txt # sqlalchemy, aiosqlite, python-jose, ...
# GEMINI_API_KEY 는 환경변수 또는 backend/.env (예시: .env.example)
python -m uvicorn app.main:app --reload --port 8000
```
DB는 개발 기본 **SQLite**(`backend/ssulbox.db`, 자동 생성). 첫 실행 시 output 폴더를 스캔해 피드를 채운다.
### 2) 프론트 (Vite dev, :5173)
```bash
cd frontend
npm install
npm run dev # /api·/videos 는 :8000 으로 프록시
```
브라우저 `http://localhost:5173`. 데스크톱은 좌측 사이드바, 모바일 폭에선 하단 탭바로 전환.
## 남은 설정(키 필요)
- **카카오 로그인**: `KAKAO_*` — 지금은 dev-login 사용.
- **네이버 자동완성**: `NAVER_CLIENT_ID/SECRET`.
- **SNS 업로드**: 유튜브(Google OAuth)·인스타(Graph API) 앱 키.
- **운영 DB**: `DATABASE_URL` 로 MySQL 지정(패턴은 castad와 동일해 전환 용이). 좋아요는 필요 시 Redis write-behind로 확장 가능.

35
backend/.env.example Normal file
View File

@ -0,0 +1,35 @@
# ===== o2o-ssulbox 백엔드 환경변수 =====
# 실제 값은 .env 에 넣고 커밋하지 않습니다 (.env 는 .gitignore).
# 아래 키들은 전부 "선택" — 넣으면 해당 기능이 자동으로 켜집니다.
# ---- 생성 (필수) ----
# Gemini API 키 — 썰박스 생성(대본/이미지/음성)에 필요.
GEMINI_API_KEY=
# 동시에 돌릴 최대 생성 잡 수 (1 권장 — 편당 시간/과금 큼).
MAX_CONCURRENT_JOBS=1
# 생성 엔진은 저장소 내장(backend/generator/)이라 외부 경로 설정이 필요 없습니다.
# ---- 인증 ----
# 운영에선 반드시 교체. 개발은 기본값으로 동작.
JWT_SECRET=dev-secret-change-me
# 카카오 로그인 (미설정 시 개발용 dev-login 사용). developers.kakao.com
KAKAO_CLIENT_ID=
KAKAO_CLIENT_SECRET=
KAKAO_REDIRECT_URI=http://localhost:8000/user/auth/kakao/callback
# ---- 네이버 업장명 자동완성 ----
# developers.naver.com → 검색 API(지역). 미설정 시 만들기 폼 자동완성 비활성.
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# ---- SNS 연동/업로드 ----
# 유튜브: Google Cloud Console → OAuth + YouTube Data API v3
YOUTUBE_CLIENT_ID=
YOUTUBE_CLIENT_SECRET=
# OAuth 콜백 베이스 (예: https://ssulbox.example.com)
SNS_REDIRECT_BASE=http://localhost:8000
# ---- 운영 DB (선택) ----
# 미설정 시 개발용 SQLite(backend/ssulbox.db). 운영은 MySQL 권장.
# DATABASE_URL=mysql+aiomysql://user:pass@host:3306/ssulbox

0
backend/app/__init__.py Normal file
View File

83
backend/app/config.py Normal file
View File

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""환경·경로 설정 (pydantic-settings).
castad 패턴을 따르되, 개발이 바로 되도록 DB 기본값은 SQLite.
생성 엔진은 저장소 내장(backend/generator/).
"""
import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
BACKEND_DIR = Path(__file__).resolve().parents[1] # backend/
REPO_ROOT = BACKEND_DIR.parent # o2o-ssulbox/
GENERATOR_DIR = BACKEND_DIR / "generator" # 내장 생성 엔진
OUTPUT_DIR = GENERATOR_DIR / "output" # 완성 mp4
# 프론트 시나리오 코드 ↔ 엔진 폴더
SCENARIO_ENGINE = {
"joseon": "animation",
"samgukji": "animation_samgukji",
"greek": "animation_greekroman",
}
ENGINE_SCENARIO = {v: k for k, v in SCENARIO_ENGINE.items()}
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=str(BACKEND_DIR / ".env"), env_ignore_empty=True, extra="ignore"
)
DEBUG: bool = True
# DB — 개발 기본 SQLite. 운영은 DATABASE_URL 로 MySQL 지정 가능.
DATABASE_URL: str = f"sqlite+aiosqlite:///{(BACKEND_DIR / 'ssulbox.db').as_posix()}"
# JWT
JWT_SECRET: str = "dev-secret-change-me"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# 크레딧
SIGNUP_CREDITS: int = 10 # 신규 가입 지급
CREDITS_PER_VIDEO: int = 1 # 영상 1편 생성 소모
# 생성(Gemini)
GEMINI_API_KEY: str | None = None
MAX_CONCURRENT_JOBS: int = 1
# 네이버 업장명 자동완성(선택 — 키 없으면 비활성)
NAVER_CLIENT_ID: str | None = None
NAVER_CLIENT_SECRET: str | None = None
NAVER_LOCAL_API_URL: str = "https://openapi.naver.com/v1/search/local.json"
# 카카오 로그인(선택 — 키 없으면 dev-login 사용)
KAKAO_CLIENT_ID: str | None = None
KAKAO_CLIENT_SECRET: str | None = None
# castad 카카오 앱에 이미 등록된 경로에 맞춤(콘솔 수정 불필요). 자체 앱 쓰면 아무 경로나 등록 후 변경.
KAKAO_REDIRECT_URI: str = "http://localhost:8000/user/auth/kakao/callback"
# 카카오 콜백이 토큰을 붙여 되돌려보낼 프론트 주소
FRONTEND_URL: str = "http://localhost:5173"
@property
def kakao_enabled(self) -> bool:
return bool(self.KAKAO_CLIENT_ID)
# SNS 연동/업로드 — 유튜브(Google OAuth + YouTube Data API v3)만 지원
YOUTUBE_CLIENT_ID: str | None = None
YOUTUBE_CLIENT_SECRET: str | None = None
# castad Google OAuth 앱에 등록된 콜백 경로에 맞춤(콘솔 수정 불필요)
YOUTUBE_REDIRECT_URI: str = "http://localhost:8000/social/oauth/youtube/callback"
SNS_REDIRECT_BASE: str | None = None # OAuth 콜백 베이스 URL
@property
def social_enabled(self) -> dict[str, bool]:
"""플랫폼별 키 설정 여부. 프론트/라우터에서 활성 판단에 사용."""
return {"youtube": bool(self.YOUTUBE_CLIENT_ID and self.YOUTUBE_CLIENT_SECRET)}
settings = Settings()
# OS 환경변수 GEMINI_API_KEY/GOOGLE_API_KEY 폴백
GEMINI_API_KEY = settings.GEMINI_API_KEY or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")

View File

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""generator/output 스캔 → content 테이블 upsert.
디스크의 완성 mp4 DB 잇는다. 시작 + 완료 호출.
"""
import re
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .config import OUTPUT_DIR, ENGINE_SCENARIO
from .database import SessionLocal
from .models import Content
_TITLE_RE = re.compile(r"\[제목\]\s*(.+)")
def _title(job_dir) -> str:
txt = job_dir / "narration.txt"
if txt.exists():
for line in txt.read_text(encoding="utf-8", errors="ignore").splitlines():
m = _TITLE_RE.match(line.strip())
if m:
return m.group(1).strip()
return job_dir.name
def _scan() -> list[dict]:
rows = []
if not OUTPUT_DIR.is_dir():
return rows
for engine, sc in ENGINE_SCENARIO.items():
d = OUTPUT_DIR / engine
if not d.is_dir():
continue
for job in d.iterdir():
if not job.is_dir():
continue
mp4s = sorted(job.glob("*.mp4"))
if not mp4s:
continue
rel = mp4s[0].relative_to(OUTPUT_DIR).as_posix()
rows.append({"id": job.name, "sc": sc, "title": _title(job), "video": f"/videos/{rel}"})
return rows
async def sync_output(session: AsyncSession | None = None, owner_uuid: str | None = None) -> int:
"""새 output 폴더를 content 로 삽입. 삽입 개수 반환."""
own = session is None
session = session or SessionLocal()
try:
existing = set((await session.execute(select(Content.id))).scalars().all())
added = 0
for r in _scan():
if r["id"] in existing:
continue
session.add(Content(
id=r["id"], scenario=r["sc"], title=r["title"], video=r["video"],
owner_uuid=owner_uuid,
))
added += 1
if added:
await session.commit()
return added
finally:
if own:
await session.close()

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""크레딧 충전/차감 (원장 기록). castad credit_service 패턴 축약."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .models import CreditTransaction, User
class InsufficientCreditError(Exception):
pass
async def _record(session, user_uuid, amount, balance_after, type_, reason):
tx = CreditTransaction(
user_uuid=user_uuid, amount=amount, balance_after=balance_after, type=type_, reason=reason,
)
session.add(tx)
return tx
async def charge(session: AsyncSession, user_uuid: str, amount: int, type_: str = "charge", reason: str | None = None):
user = (await session.execute(select(User).where(User.user_uuid == user_uuid).with_for_update())).scalar_one_or_none()
if user is None:
raise ValueError("user not found")
user.credits += amount
await session.flush()
return await _record(session, user_uuid, amount, user.credits, type_, reason)
async def deduct(session: AsyncSession, user_uuid: str, amount: int, type_: str = "consume", reason: str | None = None):
user = (await session.execute(select(User).where(User.user_uuid == user_uuid).with_for_update())).scalar_one_or_none()
if user is None:
raise ValueError("user not found")
if user.credits < amount:
raise InsufficientCreditError()
user.credits -= amount
await session.flush()
return await _record(session, user_uuid, -amount, user.credits, type_, reason)

63
backend/app/database.py Normal file
View File

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""비동기 DB 세션 + Base + 공통 타임스탬프 믹스인.
castad 공통 base 없어 컬럼을 매번 반복하지만, 여기선 TimestampMixin 으로 정리.
"""
from collections.abc import AsyncGenerator
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from .config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False, future=True)
SessionLocal = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False, autoflush=False)
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
async def create_tables() -> None:
from . import models # noqa: F401 (테이블 메타데이터 등록)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await _lightweight_migrate(conn)
# create_all 은 기존 테이블에 새 컬럼을 추가하지 않는다. 개발용 SQLite 를 위한 최소 마이그레이션.
# (운영 MySQL 은 Alembic 등 정식 마이그레이션 사용 권장)
_ADD_COLUMNS = {
"user": [
("profile_image_url", "TEXT"),
("bio", "VARCHAR(200)"),
],
}
async def _lightweight_migrate(conn) -> None:
from sqlalchemy import text
dialect = conn.dialect.name
if dialect != "sqlite":
return # 운영 DB 는 정식 마이그레이션으로 처리
for table, cols in _ADD_COLUMNS.items():
rows = (await conn.exec_driver_sql(f"PRAGMA table_info({table})")).fetchall()
existing = {r[1] for r in rows}
for name, ddl in cols:
if name not in existing:
await conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")

64
backend/app/deps.py Normal file
View File

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""공통 FastAPI 의존성: 현재 사용자(필수/선택) + 페이지네이션."""
from dataclasses import dataclass
from fastapi import Depends, HTTPException, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .database import get_session
from .models import User
from .security import decode_token
_bearer = HTTPBearer(auto_error=False)
async def _user_from_token(creds: HTTPAuthorizationCredentials | None, session: AsyncSession) -> User | None:
if creds is None:
return None
payload = decode_token(creds.credentials)
if not payload or payload.get("type") != "access":
return None
user = (
await session.execute(
select(User).where(User.user_uuid == payload.get("sub"), User.is_deleted == False) # noqa: E712
)
).scalar_one_or_none()
if user is None or not user.is_active:
return None
return user
async def get_current_user(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User:
user = await _user_from_token(creds, session)
if user is None:
raise HTTPException(401, "로그인이 필요합니다.")
return user
async def get_current_user_optional(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User | None:
return await _user_from_token(creds, session)
@dataclass
class Pagination:
page: int
page_size: int
@property
def offset(self) -> int:
return (self.page - 1) * self.page_size
def get_pagination(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> Pagination:
return Pagination(page=page, page_size=page_size)

101
backend/app/jobs.py Normal file
View File

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""썰박스 생성 잡 매니저 (subprocess → 내장 generator 엔진).
편당 2~4·과금이 크므로 동시 실행 제한. stdout 진행 마커([1/4], "완성: ...mp4") 파싱.
DB 작업은 하지 않는다 완료 처리(content 삽입·크레딧 차감) 상태 조회 엔드포인트가 1 수행.
"""
import os
import re
import subprocess
import sys
import threading
import uuid
from collections import deque
from datetime import datetime
from .config import GENERATOR_DIR, SCENARIO_ENGINE, GEMINI_API_KEY, settings
_jobs: dict[str, dict] = {}
_queue: deque[str] = deque()
_lock = threading.Lock()
_running = 0
_STEP_RE = re.compile(r"\[(\d)\s*/\s*4\]")
_DONE_RE = re.compile(r"완성[:]\s*(.+\.mp4)")
STEP_NAMES = ["대본 생성", "스토리보드", "이미지·음성", "영상 합성"]
def create_job(scenario: str, input_text: str, scenes: int, seconds: int, owner_uuid: str | None) -> dict:
jid = uuid.uuid4().hex[:12]
_jobs[jid] = {
"id": jid, "scenario": scenario, "input": input_text,
"scenes": scenes, "seconds": seconds, "owner_uuid": owner_uuid,
"status": "queued", "step": 0, "log": [], "output": None, "error": None,
"finalized": False, "created": datetime.now().isoformat(timespec="seconds"),
}
with _lock:
_queue.append(jid)
_pump()
return _jobs[jid]
def get_job(jid: str) -> dict | None:
return _jobs.get(jid)
def _pump():
global _running
with _lock:
while _queue and _running < settings.MAX_CONCURRENT_JOBS:
jid = _queue.popleft()
_running += 1
threading.Thread(target=_run, args=(jid,), daemon=True).start()
def _run(jid: str):
global _running
job = _jobs[jid]
try:
engine = SCENARIO_ENGINE[job["scenario"]]
engine_dir = GENERATOR_DIR / engine
main_py = engine_dir / "main.py"
if not main_py.exists():
raise FileNotFoundError(f"generator not found: {main_py}")
cmd = [sys.executable, "-u", str(main_py), job["input"],
"--scenes", str(job["scenes"]), "--seconds", str(job["seconds"])]
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
if GEMINI_API_KEY:
env["GEMINI_API_KEY"] = GEMINI_API_KEY
job["status"] = "running"
p = subprocess.Popen(
cmd, cwd=str(engine_dir), env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1,
)
for line in p.stdout:
line = line.rstrip()
if not line:
continue
job["log"].append(line)
if len(job["log"]) > 300:
del job["log"][:-300]
m = _STEP_RE.search(line)
if m:
job["step"] = int(m.group(1))
d = _DONE_RE.search(line)
if d:
job["output"] = d.group(1).strip()
code = p.wait()
if code == 0:
job["status"] = "done"; job["step"] = 4
else:
job["status"] = "error"; job["error"] = f"생성 프로세스 종료코드 {code}"
except Exception as e:
job["status"] = "error"; job["error"] = f"{type(e).__name__}: {e}"
finally:
with _lock:
_running -= 1
_pump()

View File

@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""카카오 OAuth 클라이언트 (castad app/user/services/kakao.py 이식·축약).
흐름: get_authorization_url() 사용자 카카오 로그인 인가코드(code)
get_access_token(code) get_user_info(token) (id, nickname, email)
"""
from fastapi import HTTPException
import aiohttp
from .config import settings
AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
TOKEN_URL = "https://kauth.kakao.com/oauth/token"
USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
def get_authorization_url() -> str:
if not settings.KAKAO_CLIENT_ID:
raise HTTPException(501, "카카오 로그인 미설정 — KAKAO_CLIENT_ID 필요")
return (
f"{AUTH_URL}?client_id={settings.KAKAO_CLIENT_ID}"
f"&redirect_uri={settings.KAKAO_REDIRECT_URI}&response_type=code"
)
async def get_access_token(code: str) -> str:
data = {
"grant_type": "authorization_code",
"client_id": settings.KAKAO_CLIENT_ID,
"redirect_uri": settings.KAKAO_REDIRECT_URI,
"code": code,
}
if settings.KAKAO_CLIENT_SECRET:
data["client_secret"] = settings.KAKAO_CLIENT_SECRET
async with aiohttp.ClientSession() as s:
async with s.post(TOKEN_URL, data=data) as r:
res = await r.json()
if "error" in res:
raise HTTPException(400, f"카카오 토큰 발급 실패: {res.get('error_description', res.get('error'))}")
return res["access_token"]
async def get_user_info(access_token: str) -> dict:
"""카카오 사용자 → {kakao_id:int, nickname:str|None, email:str|None, profile_image:str|None}."""
async with aiohttp.ClientSession() as s:
async with s.get(USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"}) as r:
if r.status != 200:
raise HTTPException(500, "카카오 사용자 정보 조회 실패")
info = await r.json()
acc = info.get("kakao_account") or {}
prof = acc.get("profile") or {}
return {
"kakao_id": int(info["id"]),
"nickname": prof.get("nickname"),
"email": acc.get("email"),
"profile_image": prof.get("profile_image_url"),
}

55
backend/app/main.py Normal file
View File

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""o2o-ssulbox 백엔드 (FastAPI, API 전용).
인증(dev/카카오) · 피드/생성 · 좋아요 · 댓글 · 크레딧 · 업장명 자동완성 · SNS 연동.
DB는 개발 기본 SQLite. 생성 엔진은 저장소 내장(backend/generator/).
프론트(React/Vite) 별도 실행하며 dev 프록시(/api,/videos) 붙는다.
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .config import GEMINI_API_KEY, GENERATOR_DIR, OUTPUT_DIR, SCENARIO_ENGINE, settings
from .content_sync import sync_output
from .database import create_tables
from .routers import auth, comments, content, credit, sns
@asynccontextmanager
async def lifespan(app: FastAPI):
await create_tables() # SQLite: 없으면 생성
await sync_output() # 기존 output → content 반영
yield
app = FastAPI(title="o2o-ssulbox API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], allow_credentials=True,
)
app.include_router(auth.router)
app.include_router(auth.compat_router) # /user/auth/kakao/callback (카카오 앱 등록 경로)
app.include_router(content.router)
app.include_router(comments.router)
app.include_router(credit.router)
app.include_router(sns.router)
app.include_router(sns.compat_router) # /social/oauth/youtube/callback (Google 앱 등록 경로)
@app.get("/api/health")
def health():
return {
"ok": True,
"gemini_key": bool(GEMINI_API_KEY),
"generator_dir": str(GENERATOR_DIR),
"output_exists": OUTPUT_DIR.is_dir(),
"db": settings.DATABASE_URL.split("://")[0],
"scenarios": list(SCENARIO_ENGINE),
}
# 완성 mp4 정적 서빙
if OUTPUT_DIR.is_dir():
app.mount("/videos", StaticFiles(directory=str(OUTPUT_DIR)), name="videos")

91
backend/app/models.py Normal file
View File

@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""SQLAlchemy 모델. 사용자·콘텐츠(썰박스)·좋아요·댓글·크레딧 원장.
castad 컨벤션대로 소셜/크레딧 FK 숫자 PK 아니라 user_uuid(String36) 기준.
콘텐츠 id 생성 폴더명(문자열) 그대로 PK 쓴다.
"""
from datetime import datetime
from sqlalchemy import (
BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text,
UniqueConstraint, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base, TimestampMixin
# SQLite 에서 BIGINT 는 rowid 별칭이 안 돼 autoincrement 가 동작하지 않는다 → sqlite 는 INTEGER 로.
BIGPK = BigInteger().with_variant(Integer, "sqlite")
class User(Base, TimestampMixin):
__tablename__ = "user"
id: Mapped[int] = mapped_column(BIGPK, primary_key=True, autoincrement=True)
user_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
nickname: Mapped[str] = mapped_column(String(50), default="사용자")
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
profile_image_url: Mapped[str | None] = mapped_column(Text, nullable=True) # data URL 또는 외부 URL
bio: Mapped[str | None] = mapped_column(String(200), nullable=True) # 한 줄 소개
kakao_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
credits: Mapped[int] = mapped_column(Integer, default=10)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Content(Base, TimestampMixin):
"""생성된 썰박스 1편. id = output 잡 폴더명."""
__tablename__ = "content"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
scenario: Mapped[str] = mapped_column(String(20), index=True) # joseon|samgukji|greek
title: Mapped[str] = mapped_column(String(200), default="")
video: Mapped[str | None] = mapped_column(String(300), nullable=True) # /videos/... url
owner_uuid: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
views: Mapped[int] = mapped_column(Integer, default=0)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
class Like(Base, TimestampMixin):
__tablename__ = "content_like"
__table_args__ = (UniqueConstraint("content_id", "user_uuid", name="uq_like_user_content"),)
id: Mapped[int] = mapped_column(BIGPK, primary_key=True, autoincrement=True)
content_id: Mapped[str] = mapped_column(String(120), ForeignKey("content.id"), index=True)
user_uuid: Mapped[str] = mapped_column(String(36), index=True)
class Comment(Base, TimestampMixin):
"""2-depth 댓글(댓글+대댓글). 익명 정책 — user_uuid 는 저장만, 응답에 노출 X."""
__tablename__ = "comment"
id: Mapped[int] = mapped_column(BIGPK, primary_key=True, autoincrement=True)
content_id: Mapped[str] = mapped_column(String(120), ForeignKey("content.id"), index=True)
user_uuid: Mapped[str] = mapped_column(String(36), index=True)
parent_id: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("comment.id"), nullable=True, index=True)
nickname: Mapped[str] = mapped_column(String(50), default="익명")
body: Mapped[str] = mapped_column(String(300))
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
parent = relationship("Comment", remote_side=[id], backref="replies")
class CreditTransaction(Base, TimestampMixin):
"""크레딧 원장. amount +충전/-소모, balance_after 스냅샷."""
__tablename__ = "credit_transaction"
id: Mapped[int] = mapped_column(BIGPK, primary_key=True, autoincrement=True)
user_uuid: Mapped[str] = mapped_column(String(36), index=True)
amount: Mapped[int] = mapped_column(Integer)
balance_after: Mapped[int] = mapped_column(Integer)
type: Mapped[str] = mapped_column(String(20)) # charge|consume|refund|signup
reason: Mapped[str | None] = mapped_column(String(200), nullable=True)
class SocialAccount(Base, TimestampMixin):
"""SNS 연동 계정 (유튜브/인스타 등). OAuth 토큰은 실제 연동 시 채운다."""
__tablename__ = "social_account"
__table_args__ = (UniqueConstraint("user_uuid", "platform", name="uq_social_user_platform"),)
id: Mapped[int] = mapped_column(BIGPK, primary_key=True, autoincrement=True)
user_uuid: Mapped[str] = mapped_column(String(36), index=True)
platform: Mapped[str] = mapped_column(String(20)) # youtube|instagram|tiktok|facebook
account_name: Mapped[str] = mapped_column(String(120), default="")
access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
"""네이버 지도 업장 검색 (키 불필요, Playwright).
`map.naver.com/p/search/{query}` 검색 리스트 iframe(`pcmap.place.naver.com/place/list`)
안에 있는 `__APOLLO_STATE__` 파싱해, 동명 업장들을 주소로 구분한 후보 목록을 만든다.
후보는 `place_url`(map.naver.com/p/entry/place/{id}) 가지므로, 사용자가 하나를 고르면
URL 그대로 생성 파이프라인(generator/naver.py) 넘겨 정확히 가게를 크롤링한다.
castad NvMapPwScraper 같은 목적이지만, NAVER 검색 API 없이 지도만으로 후보+place_id
동시에 얻는다(castad 후보=NAVER API, place_id 해석=Playwright 2단계).
"""
import asyncio
import json
import re
import sys
from urllib.parse import quote
from playwright.async_api import async_playwright
DESKTOP_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
)
def _extract_apollo_state(html: str) -> dict | None:
"""HTML 안의 `__APOLLO_STATE__ = { ... };` 객체를 중괄호 균형으로 안전하게 추출."""
i = html.find("__APOLLO_STATE__")
if i < 0:
return None
j = html.find("{", i)
if j < 0:
return None
depth = 0
in_str = False
esc = False
for k in range(j, len(html)):
c = html[k]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
else:
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
try:
return json.loads(html[j : k + 1])
except Exception:
return None
return None
def _candidates_from_state(state: dict, limit: int) -> list[dict]:
out: list[dict] = []
for key, obj in state.items():
if not key.startswith("PlaceListBusinessesItem:"):
continue
if not isinstance(obj, dict):
continue
name = obj.get("name")
pid = obj.get("id")
if not (name and pid and str(pid).isdigit()):
continue
out.append(
{
"title": name,
"category": obj.get("category") or "",
# 표시용 주소는 '서울 성동구 금호동3가' 같은 commonAddress 를 우선(동명 구분에 최적)
"address": obj.get("commonAddress") or obj.get("fullAddress") or "",
"roadAddress": obj.get("fullAddress") or obj.get("roadAddress") or "",
"place_url": f"https://map.naver.com/p/entry/place/{pid}",
}
)
if len(out) >= limit:
break
return out
async def _search(query: str, limit: int) -> list[dict]:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
)
try:
ctx = await browser.new_context(
user_agent=DESKTOP_UA, locale="ko-KR", timezone_id="Asia/Seoul",
viewport={"width": 1280, "height": 800},
extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"},
)
page = await ctx.new_page()
await page.goto(
f"https://map.naver.com/p/search/{quote(query)}",
wait_until="domcontentloaded", timeout=40000,
)
# 검색 리스트 iframe 이 뜰 때까지 대기(최대 ~12초)
frame = None
for _ in range(24):
for f in page.frames:
if "pcmap.place.naver.com/place/list" in f.url:
frame = f
break
if frame:
break
# 단일 결과면 곧바로 place 상세로 리다이렉트됨 → 후보 1개로 처리
if "/place/" in page.url and "/search/" not in page.url:
return [{"title": query, "category": "", "address": "",
"roadAddress": "", "place_url": page.url}]
await page.wait_for_timeout(500)
if not frame:
return []
# apollo state 가 채워질 시간을 조금 더 준다
html = await frame.content()
state = _extract_apollo_state(html)
for _ in range(6):
if state and any(k.startswith("PlaceListBusinessesItem:") for k in state):
break
await page.wait_for_timeout(600)
html = await frame.content()
state = _extract_apollo_state(html)
if not state:
return []
return _candidates_from_state(state, limit)
finally:
await browser.close()
def _search_blocking(query: str, limit: int) -> list[dict]:
"""별도 스레드에서 자체 이벤트 루프로 Playwright 실행.
Windows 웹서버 루프(Selector) 서브프로세스를 띄워 Playwright 즉시 실패한다.
함수는 워커 스레드에서 Proactor 루프(윈도우) 새로 만들어 위에서 돌린다.
"""
if sys.platform == "win32":
loop = asyncio.ProactorEventLoop()
else:
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(_search(query, limit))
finally:
loop.close()
async def search_places(query: str, limit: int = 8, timeout: float = 35.0) -> list[dict]:
"""업장명으로 네이버 지도 후보 목록을 반환. 실패/타임아웃이면 빈 리스트."""
query = (query or "").strip()
if len(query) < 2:
return []
try:
return await asyncio.wait_for(asyncio.to_thread(_search_blocking, query, limit), timeout=timeout)
except Exception:
return []

View File

172
backend/app/routers/auth.py Normal file
View File

@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
"""인증 라우터. 카카오 로그인(실연동) + 개발용 dev-login + me + refresh."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .. import credit_service, kakao_client
from ..config import settings
from ..database import get_session
from ..deps import get_current_user
from ..models import User
from ..schemas import ProfileUpdate, TokenResponse, UserOut
from ..security import create_access_token, create_refresh_token, decode_token, new_uuid
router = APIRouter(prefix="/api/auth", tags=["Auth"])
# 카카오 앱에 등록된 redirect 경로(prefix 밖)를 그대로 받기 위한 보조 라우터.
compat_router = APIRouter(tags=["Auth"])
async def _get_or_create_kakao_user(info: dict, session: AsyncSession) -> tuple[User, bool]:
"""카카오 사용자 정보로 사용자 조회/생성. (User, 신규여부) 반환. 신규엔 가입 크레딧 지급."""
user = (
await session.execute(select(User).where(User.kakao_id == info["kakao_id"]))
).scalar_one_or_none()
if user is not None:
if info.get("nickname"):
user.nickname = info["nickname"]
if info.get("email"):
user.email = info["email"]
# 기본 아바타 = 카카오 프로필 사진. 단, 사용자가 직접 바꾼 이미지는 덮어쓰지 않음.
if info.get("profile_image") and not user.profile_image_url:
user.profile_image_url = info["profile_image"]
user.last_login_at = datetime.now(timezone.utc)
return user, False
user = User(
user_uuid=new_uuid(), kakao_id=info["kakao_id"],
nickname=info.get("nickname") or "사용자", email=info.get("email"),
profile_image_url=info.get("profile_image"), # 기본 아바타 = 카카오 프로필 사진
credits=0, last_login_at=datetime.now(timezone.utc),
)
session.add(user)
await session.flush()
await credit_service.charge(session, user.user_uuid, settings.SIGNUP_CREDITS, type_="signup", reason="가입 지급")
return user, True
async def _kakao_login(code: str, session: AsyncSession) -> tuple[str, str, bool]:
"""인가코드 → (access, refresh, 신규여부). 카카오 토큰교환+사용자조회/생성."""
access = await kakao_client.get_access_token(code)
info = await kakao_client.get_user_info(access)
user, is_new = await _get_or_create_kakao_user(info, session)
if not user.is_active:
raise HTTPException(403, "비활성화된 계정입니다.")
tokens = (create_access_token(user.user_uuid), create_refresh_token(user.user_uuid))
await session.commit()
return tokens[0], tokens[1], is_new
class DevLoginReq(BaseModel):
nickname: str | None = None
@router.post("/dev-login", response_model=TokenResponse)
async def dev_login(body: DevLoginReq, session: AsyncSession = Depends(get_session)):
"""개발용 로그인: 닉네임으로 사용자 재사용/생성 + 가입 크레딧. (DEBUG 전용)"""
if not settings.DEBUG:
raise HTTPException(403, "DEBUG 모드에서만 사용 가능합니다.")
nickname = (body.nickname or "테스터").strip() or "테스터"
user = (
await session.execute(
select(User).where(User.nickname == nickname, User.kakao_id.is_(None))
)
).scalars().first()
if user is None:
user = User(user_uuid=new_uuid(), nickname=nickname, credits=0)
session.add(user)
await session.flush()
await credit_service.charge(session, user.user_uuid, settings.SIGNUP_CREDITS, type_="signup", reason="가입 지급")
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
return TokenResponse(
access_token=create_access_token(user.user_uuid),
refresh_token=create_refresh_token(user.user_uuid),
)
def _user_out(user: User) -> UserOut:
return UserOut(user_uuid=user.user_uuid, nickname=user.nickname, email=user.email,
profile_image_url=user.profile_image_url, bio=user.bio,
credits=user.credits, is_admin=user.is_admin)
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return _user_out(user)
@router.patch("/me", response_model=UserOut)
async def update_me(body: ProfileUpdate, user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
"""프로필 편집 — 닉네임·프로필 이미지·한 줄 소개."""
if body.nickname is not None:
nn = body.nickname.strip()
if not (1 <= len(nn) <= 20):
raise HTTPException(400, "닉네임은 1~20자여야 합니다.")
user.nickname = nn
if body.bio is not None:
user.bio = body.bio.strip()[:200] or None
if body.profile_image_url is not None:
img = body.profile_image_url.strip()
if img and not (img.startswith("data:image/") or img.startswith("http")):
raise HTTPException(400, "이미지 형식이 올바르지 않습니다.")
if len(img) > 3_000_000: # data URL 과대 방지(~3MB)
raise HTTPException(400, "이미지가 너무 큽니다. 더 작은 사진을 사용해주세요.")
user.profile_image_url = img or None
await session.commit()
return _user_out(user)
class RefreshReq(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshReq, session: AsyncSession = Depends(get_session)):
payload = decode_token(body.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(401, "유효하지 않은 refresh 토큰입니다.")
uuid_ = payload.get("sub")
user = (await session.execute(select(User).where(User.user_uuid == uuid_))).scalar_one_or_none()
if user is None:
raise HTTPException(401, "사용자를 찾을 수 없습니다.")
return TokenResponse(access_token=create_access_token(uuid_), refresh_token=create_refresh_token(uuid_))
@router.get("/kakao/login")
async def kakao_login():
"""카카오 인증 페이지 URL 반환. 프론트가 이 URL로 이동시킨다."""
return {"auth_url": kakao_client.get_authorization_url()}
async def _kakao_callback(code: str, session: AsyncSession):
"""카카오 콜백 공통: 코드 → 로그인 → 토큰을 프론트 URL 쿼리로 붙여 리다이렉트."""
access, refresh, _is_new = await _kakao_login(code, session)
url = f"{settings.FRONTEND_URL}/?access_token={access}&refresh_token={refresh}"
return RedirectResponse(url=url, status_code=302)
@router.get("/kakao/callback")
async def kakao_callback(code: str, session: AsyncSession = Depends(get_session)):
return await _kakao_callback(code, session)
@compat_router.get("/user/auth/kakao/callback")
async def kakao_callback_compat(code: str, session: AsyncSession = Depends(get_session)):
"""카카오 앱에 등록된 경로(/user/auth/kakao/callback)로 오는 콜백."""
return await _kakao_callback(code, session)
class KakaoVerifyReq(BaseModel):
code: str
@router.post("/kakao/verify", response_model=TokenResponse)
async def kakao_verify(body: KakaoVerifyReq, session: AsyncSession = Depends(get_session)):
"""SPA용: 프론트가 받은 인가코드를 검증해 JSON 토큰으로 발급."""
access, refresh, _is_new = await _kakao_login(body.code, session)
return TokenResponse(access_token=access, refresh_token=refresh)

View File

@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""댓글 라우터 (2-depth: 댓글+대댓글, 익명, 소프트 삭제)."""
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_session
from ..deps import Pagination, get_current_user, get_current_user_optional, get_pagination
from ..models import Comment, Content, User
from ..schemas import CommentItem, CommentReq, PaginatedResponse, ReplyItem
router = APIRouter(prefix="/api/comment", tags=["Comment"])
@router.post("/content/{cid}")
async def create_comment(cid: str, body: CommentReq, user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
if not body.body.strip():
raise HTTPException(400, "내용을 입력해주세요.")
content = await session.get(Content, cid)
if content is None or content.is_deleted:
raise HTTPException(404, "콘텐츠를 찾을 수 없습니다.")
if body.parent_id is not None:
parent = await session.get(Comment, body.parent_id)
if parent is None or parent.content_id != cid:
raise HTTPException(400, "원 댓글을 찾을 수 없습니다.")
if parent.parent_id is not None:
raise HTTPException(400, "대댓글에는 답글을 달 수 없습니다 (최대 2-depth).")
c = Comment(
content_id=cid, user_uuid=user.user_uuid, parent_id=body.parent_id,
nickname=(body.nickname or user.nickname or "익명")[:50], body=body.body.strip()[:300],
)
session.add(c)
await session.commit()
return {"id": c.id}
@router.get("/content/{cid}", response_model=PaginatedResponse[CommentItem])
async def list_comments(cid: str, pg: Pagination = Depends(get_pagination),
user: User | None = Depends(get_current_user_optional),
session: AsyncSession = Depends(get_session)):
me = user.user_uuid if user else None
top_where = [Comment.content_id == cid, Comment.parent_id.is_(None)]
total = (await session.execute(select(func.count(Comment.id)).where(*top_where))).scalar() or 0
tops = (
await session.execute(
select(Comment).where(*top_where).order_by(Comment.created_at.desc())
.offset(pg.offset).limit(pg.page_size)
)
).scalars().all()
parent_ids = [c.id for c in tops]
replies_by_parent: dict[int, list[Comment]] = defaultdict(list)
if parent_ids:
reps = (
await session.execute(
select(Comment).where(Comment.parent_id.in_(parent_ids)).order_by(Comment.created_at.asc())
)
).scalars().all()
for r in reps:
replies_by_parent[r.parent_id].append(r)
def out(c: Comment):
return dict(
id=c.id, body=(None if c.is_deleted else c.body),
nickname=c.nickname, is_mine=(c.user_uuid == me), created_at=c.created_at,
)
items = [
CommentItem(**out(c), replies=[ReplyItem(**out(r)) for r in replies_by_parent.get(c.id, [])])
for c in tops
]
return PaginatedResponse.create(items, total, pg.page, pg.page_size)
@router.delete("/{comment_id}")
async def delete_comment(comment_id: int, user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
c = await session.get(Comment, comment_id)
if c is None or c.is_deleted:
raise HTTPException(404, "댓글을 찾을 수 없습니다.")
if c.user_uuid != user.user_uuid:
raise HTTPException(403, "본인 댓글만 삭제할 수 있습니다.")
c.is_deleted = True
await session.commit()
return {"ok": True}

View File

@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""콘텐츠(피드)·생성·좋아요·업장명 자동완성 라우터."""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from .. import credit_service, jobs
from ..config import GEMINI_API_KEY, SCENARIO_ENGINE, settings
from ..content_sync import sync_output
from ..database import get_session
from ..deps import Pagination, get_current_user, get_current_user_optional, get_pagination
from ..models import Comment, Content, Like, User
from ..schemas import CreateReq, FeedItem, PaginatedResponse
router = APIRouter(prefix="/api", tags=["Content"])
@router.get("/feed", response_model=PaginatedResponse[FeedItem])
async def feed(
sort: str = Query("latest", pattern="^(latest|likes)$"),
owner: str | None = None, # "me" → 내 채널
pg: Pagination = Depends(get_pagination),
user: User | None = Depends(get_current_user_optional),
session: AsyncSession = Depends(get_session),
):
like_ct = (
select(func.count(Like.id)).where(Like.content_id == Content.id).correlate(Content).scalar_subquery()
)
cmt_ct = (
select(func.count(Comment.id))
.where(Comment.content_id == Content.id, Comment.is_deleted == False) # noqa: E712
.correlate(Content).scalar_subquery()
)
where = [Content.is_deleted == False] # noqa: E712
if owner == "me":
if user is None:
return PaginatedResponse.create([], 0, pg.page, pg.page_size)
where.append(Content.owner_uuid == user.user_uuid)
total = (await session.execute(select(func.count(Content.id)).where(*where))).scalar() or 0
order = like_ct.desc() if sort == "likes" else Content.created_at.desc()
rows = (
await session.execute(
select(Content, like_ct.label("likes"), cmt_ct.label("comments"))
.where(*where).order_by(order).offset(pg.offset).limit(pg.page_size)
)
).all()
ids = [r[0].id for r in rows]
liked = set()
if user and ids:
liked = set(
(await session.execute(
select(Like.content_id).where(Like.user_uuid == user.user_uuid, Like.content_id.in_(ids))
)).scalars().all()
)
items = [
FeedItem(
id=c.id, sc=c.scenario, title=c.title, video=c.video, views=c.views,
likes=likes, comments=comments, liked=(c.id in liked),
mine=(user is not None and c.owner_uuid == user.user_uuid), createdAt=c.created_at,
)
for (c, likes, comments) in rows
]
return PaginatedResponse.create(items, total, pg.page, pg.page_size)
@router.post("/create")
async def create(body: CreateReq, user: User = Depends(get_current_user)):
if body.scenario not in SCENARIO_ENGINE:
raise HTTPException(400, f"알 수 없는 시나리오: {body.scenario}")
if not body.input.strip():
raise HTTPException(400, "링크 또는 업장명이 필요합니다.")
if not GEMINI_API_KEY:
raise HTTPException(400, "GEMINI_API_KEY 가 설정되지 않았습니다.")
if user.credits < settings.CREDITS_PER_VIDEO:
raise HTTPException(402, "크레딧이 부족합니다. 충전 후 이용해주세요.")
job = jobs.create_job(
body.scenario, body.input.strip(),
max(4, min(20, body.scenes)), max(20, min(90, body.seconds)),
owner_uuid=user.user_uuid,
)
return {"id": job["id"], "status": job["status"]}
@router.get("/jobs/{jid}")
async def job_status(jid: str, session: AsyncSession = Depends(get_session)):
job = jobs.get_job(jid)
if not job:
raise HTTPException(404, "잡을 찾을 수 없습니다.")
# 완료 최초 관측 시 1회: 새 output → content 삽입 + 크레딧 차감
if job["status"] == "done" and not job["finalized"]:
job["finalized"] = True
try:
await sync_output(session, owner_uuid=job["owner_uuid"])
if job["owner_uuid"]:
await credit_service.deduct(session, job["owner_uuid"], settings.CREDITS_PER_VIDEO, reason="영상 생성")
await session.commit()
except Exception:
await session.rollback()
return {
"id": job["id"], "status": job["status"], "step": job["step"],
"stepName": jobs.STEP_NAMES[job["step"] - 1] if job["step"] else "대기 중",
"output": job["output"], "error": job["error"], "log": job["log"][-15:],
}
@router.post("/content/{cid}/like")
async def toggle_like(cid: str, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session)):
c = await session.get(Content, cid)
if c is None or c.is_deleted:
raise HTTPException(404, "콘텐츠를 찾을 수 없습니다.")
existing = (
await session.execute(select(Like).where(Like.content_id == cid, Like.user_uuid == user.user_uuid))
).scalar_one_or_none()
if existing:
await session.delete(existing)
liked = False
else:
session.add(Like(content_id=cid, user_uuid=user.user_uuid))
liked = True
await session.commit()
count = (await session.execute(select(func.count(Like.id)).where(Like.content_id == cid))).scalar() or 0
return {"liked": liked, "likes": count}
@router.get("/search/place", tags=["Search"])
async def search_place(query: str = Query(..., min_length=2)):
"""업장명 → 네이버 지도 후보 목록(동명 업장 주소로 구분). 키 불필요(Playwright).
후보는 place_url 가지므로, 프론트에서 하나를 고르면 URL 정확히 크롤링한다.
"""
from .. import place_service
items = await place_service.search_places(query, limit=8)
return {"query": query, "count": len(items), "items": items}

View File

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""크레딧 라우터: 잔액·원장 조회 + (개발용) 충전. 실서비스 충전은 관리자 승인 워크플로 필요."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from .. import credit_service
from ..config import settings
from ..database import get_session
from ..deps import Pagination, get_current_user, get_pagination
from ..models import CreditTransaction, User
from ..schemas import CreditTx, PaginatedResponse
router = APIRouter(prefix="/api/credits", tags=["Credit"])
@router.get("/balance")
async def balance(user: User = Depends(get_current_user)):
return {"credits": user.credits}
@router.get("/transactions", response_model=PaginatedResponse[CreditTx])
async def transactions(pg: Pagination = Depends(get_pagination), user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
where = [CreditTransaction.user_uuid == user.user_uuid]
total = (await session.execute(select(func.count(CreditTransaction.id)).where(*where))).scalar() or 0
rows = (
await session.execute(
select(CreditTransaction).where(*where).order_by(CreditTransaction.created_at.desc())
.offset(pg.offset).limit(pg.page_size)
)
).scalars().all()
items = [CreditTx(amount=r.amount, balance_after=r.balance_after, type=r.type, reason=r.reason,
created_at=r.created_at) for r in rows]
return PaginatedResponse.create(items, total, pg.page, pg.page_size)
class ChargeReq(BaseModel):
amount: int = 10
@router.post("/charge")
async def charge(body: ChargeReq, user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
"""개발용 즉시 충전. 실서비스에선 결제/관리자 승인으로 대체."""
if not settings.DEBUG:
raise HTTPException(403, "충전은 결제 연동 후 제공됩니다.")
if body.amount <= 0 or body.amount > 1000:
raise HTTPException(400, "충전 금액이 올바르지 않습니다.")
await credit_service.charge(session, user.user_uuid, body.amount, type_="charge", reason="개발 충전")
await session.commit()
return {"credits": user.credits}
# ── 크레딧 충전 상품 ─────────────────────────────────────────────
# 편당 1크레딧 소모. bonus 는 덤으로 주는 크레딧.
PRODUCTS = [
{"id": "p10", "label": "스타터", "credits": 10, "bonus": 0, "price": 3900, "best": False},
{"id": "p30", "label": "베이직", "credits": 30, "bonus": 5, "price": 9900, "best": True},
{"id": "p100", "label": "프로", "credits": 100, "bonus": 25, "price": 29900, "best": False},
]
_PRODUCTS_BY_ID = {p["id"]: p for p in PRODUCTS}
@router.get("/products")
async def products():
"""충전 상품 목록. price 는 원(₩)."""
return {"items": PRODUCTS}
class PurchaseReq(BaseModel):
product_id: str
@router.post("/purchase")
async def purchase(body: PurchaseReq, user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
"""상품 구매 → 크레딧 지급. 결제(PG) 미연동이라 개발 모드에서 즉시 지급."""
p = _PRODUCTS_BY_ID.get(body.product_id)
if not p:
raise HTTPException(404, "존재하지 않는 상품입니다.")
if not settings.DEBUG:
raise HTTPException(501, "결제 연동 준비 중입니다.") # 실서비스: PG 결제 검증 후 지급
total = p["credits"] + p["bonus"]
await credit_service.charge(session, user.user_uuid, total, type_="purchase",
reason=f"{p['label']} 상품({p['credits']}+{p['bonus']})")
await session.commit()
return {"credits": user.credits, "added": total, "product": p["label"]}

142
backend/app/routers/sns.py Normal file
View File

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""SNS 연동 라우터 — 유튜브(Google OAuth + YouTube Data API v3) 실연동.
- 연결: /youtube/connect Google 동의 URL 콜백에서 토큰 저장(채널명 포함)
- 업로드: 완성 mp4 연동 채널에 resumable 업로드
(castad app/social 패턴 이식)
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .. import youtube_client
from ..config import OUTPUT_DIR, settings
from ..database import get_session
from ..deps import get_current_user
from ..models import Content, SocialAccount, User
from ..security import create_access_token, decode_token
router = APIRouter(prefix="/api/social", tags=["Social"])
# Google OAuth 앱에 등록된 콜백 경로(prefix 밖)를 그대로 받기 위한 보조 라우터.
compat_router = APIRouter(tags=["Social"])
SUPPORTED = {"youtube"}
@router.get("/config")
async def social_config():
"""플랫폼별 연동 활성(키 설정) 여부. 프론트가 '곧 지원' 배지 판단에 사용."""
return settings.social_enabled
@router.get("/accounts")
async def list_accounts(user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(
select(SocialAccount).where(SocialAccount.user_uuid == user.user_uuid, SocialAccount.is_active == True) # noqa: E712
)
).scalars().all()
return [{"id": a.id, "platform": a.platform, "account_name": a.account_name} for a in rows]
@router.get("/youtube/connect")
async def youtube_connect(user: User = Depends(get_current_user)):
"""Google OAuth 동의 URL 반환. state 에 로그인 사용자 토큰을 실어 콜백에서 식별."""
state = create_access_token(user.user_uuid) # 짧은 수명 서명 토큰을 state 로 사용
return {"auth_url": youtube_client.get_authorization_url(state)}
@compat_router.get("/social/oauth/youtube/callback")
async def youtube_callback(code: str, state: str, session: AsyncSession = Depends(get_session)):
"""Google 콜백: state→사용자 식별, 코드→토큰 교환, 채널명 조회 후 계정 upsert."""
payload = decode_token(state)
if not payload or not payload.get("sub"):
raise HTTPException(400, "유효하지 않은 state")
user_uuid = payload["sub"]
tokens = await youtube_client.exchange_code(code)
channel = await youtube_client.get_channel_info(tokens["access_token"])
acc = (
await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user_uuid, SocialAccount.platform == "youtube"
)
)
).scalar_one_or_none()
if acc is None:
acc = SocialAccount(user_uuid=user_uuid, platform="youtube")
session.add(acc)
acc.account_name = channel.get("title") or "내 채널"
acc.access_token = tokens.get("access_token")
if tokens.get("refresh_token"): # Google 은 재동의 때만 refresh 재발급
acc.refresh_token = tokens["refresh_token"]
acc.is_active = True
await session.commit()
return RedirectResponse(url=f"{settings.FRONTEND_URL}/?tab=channel&connected=youtube", status_code=302)
@router.delete("/{platform}/disconnect")
async def disconnect(platform: str, user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
acc = (
await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user.user_uuid, SocialAccount.platform == platform
)
)
).scalar_one_or_none()
if acc is None:
raise HTTPException(404, "연동된 계정이 없습니다.")
acc.is_active = False
await session.commit()
return {"ok": True}
def _video_file_path(content: Content):
"""Content.video(/videos/...) → 디스크 실제 경로(OUTPUT_DIR 기준)."""
if not content.video:
return None
rel = content.video.removeprefix("/videos/")
return OUTPUT_DIR / rel
@router.post("/upload/{platform}/{content_id}")
async def upload(platform: str, content_id: str, user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
if platform != "youtube":
raise HTTPException(400, f"지원하지 않는 플랫폼: {platform}")
content = await session.get(Content, content_id)
if content is None or not content.video:
raise HTTPException(404, "업로드할 영상을 찾을 수 없습니다.")
acc = (
await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user.user_uuid, SocialAccount.platform == "youtube",
SocialAccount.is_active == True, # noqa: E712
)
)
).scalar_one_or_none()
if acc is None:
raise HTTPException(400, "유튜브 계정을 먼저 연동해주세요.")
path = _video_file_path(content)
if path is None or not path.exists():
raise HTTPException(404, "영상 파일을 찾을 수 없습니다.")
access_token = acc.access_token
desc = f"{content.title}\n\n#썰박스 로 만든 병맛 역사 홍보 쇼츠"
try:
result = await youtube_client.upload_video(str(path), access_token, content.title, desc)
except HTTPException as e:
# 401 이면 refresh 로 재시도
if e.status_code in (401, 403) and acc.refresh_token:
access_token = await youtube_client.refresh_access_token(acc.refresh_token)
acc.access_token = access_token
await session.commit()
result = await youtube_client.upload_video(str(path), access_token, content.title, desc)
else:
raise
return {"ok": True, "platform": "youtube", **result}

109
backend/app/schemas.py Normal file
View File

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""Pydantic 스키마 + 공통 페이지네이션 응답."""
from datetime import datetime
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
total_pages: int
has_next: bool
has_prev: bool
@classmethod
def create(cls, items: list[T], total: int, page: int, page_size: int) -> "PaginatedResponse[T]":
total_pages = (total + page_size - 1) // page_size if page_size else 0
return cls(
items=items, total=total, page=page, page_size=page_size, total_pages=total_pages,
has_next=page < total_pages, has_prev=page > 1,
)
# ---- auth ----
class UserOut(BaseModel):
user_uuid: str
nickname: str
email: str | None = None
profile_image_url: str | None = None
bio: str | None = None
credits: int
is_admin: bool = False
class ProfileUpdate(BaseModel):
nickname: str | None = None
profile_image_url: str | None = None # data URL 또는 외부 URL, ""=기본으로 되돌림
bio: str | None = None
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# ---- content / feed ----
class FeedItem(BaseModel):
id: str
sc: str
title: str
video: str | None
views: int
likes: int
liked: bool = False
comments: int = 0
mine: bool = False
createdAt: datetime | None = None
# ---- create ----
class CreateReq(BaseModel):
scenario: str
input: str
scenes: int = 12
seconds: int = 45
# ---- comment ----
class CommentReq(BaseModel):
body: str
parent_id: int | None = None
nickname: str | None = None
class ReplyItem(BaseModel):
id: int
body: str | None
nickname: str
is_mine: bool
created_at: datetime
class CommentItem(BaseModel):
id: int
body: str | None
nickname: str
is_mine: bool
created_at: datetime
replies: list[ReplyItem] = []
# ---- credit ----
class CreditTx(BaseModel):
amount: int
balance_after: int
type: str
reason: str | None
created_at: datetime
class ChargeReq(BaseModel):
amount: int
message: str | None = None

39
backend/app/security.py Normal file
View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""JWT 발급·검증 + uuid. castad app/user/services/jwt.py 패턴 축약."""
import uuid
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from .config import settings
def new_uuid() -> str:
return str(uuid.uuid4())
def _now() -> datetime:
return datetime.now(timezone.utc)
def create_access_token(user_uuid: str) -> str:
payload = {
"sub": user_uuid, "type": "access",
"exp": _now() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(user_uuid: str) -> str:
payload = {
"sub": user_uuid, "type": "refresh",
"exp": _now() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
except JWTError:
return None

View File

@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""유튜브(Google OAuth + YouTube Data API v3) 클라이언트.
castad app/social/oauth/youtube.py + uploader/youtube.py 이식·축약.
- OAuth: 인증 URL 생성 코드 교환(access/refresh) 리프레시 채널 정보
- 업로드: resumable upload 세션 시작 파일 PUT video_id
"""
from urllib.parse import urlencode
import aiohttp
from fastapi import HTTPException
from .config import settings
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
SCOPES = [
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.readonly",
]
def get_authorization_url(state: str) -> str:
if not (settings.YOUTUBE_CLIENT_ID and settings.YOUTUBE_CLIENT_SECRET):
raise HTTPException(501, "유튜브 연동 미설정 — YOUTUBE_CLIENT_ID/SECRET 필요")
params = {
"client_id": settings.YOUTUBE_CLIENT_ID,
"redirect_uri": settings.YOUTUBE_REDIRECT_URI,
"response_type": "code",
"scope": " ".join(SCOPES),
"access_type": "offline", # refresh_token 발급
"prompt": "consent", # 매번 동의 → refresh_token 보장
"state": state,
}
return f"{AUTHORIZATION_URL}?{urlencode(params)}"
async def exchange_code(code: str) -> dict:
"""인가코드 → {access_token, refresh_token, expires_in}."""
data = {
"client_id": settings.YOUTUBE_CLIENT_ID,
"client_secret": settings.YOUTUBE_CLIENT_SECRET,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": settings.YOUTUBE_REDIRECT_URI,
}
async with aiohttp.ClientSession() as s:
async with s.post(TOKEN_URL, data=data) as r:
res = await r.json()
if "error" in res:
raise HTTPException(400, f"유튜브 토큰 교환 실패: {res.get('error_description', res.get('error'))}")
return res
async def refresh_access_token(refresh_token: str) -> str:
data = {
"client_id": settings.YOUTUBE_CLIENT_ID,
"client_secret": settings.YOUTUBE_CLIENT_SECRET,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
}
async with aiohttp.ClientSession() as s:
async with s.post(TOKEN_URL, data=data) as r:
res = await r.json()
if "error" in res:
raise HTTPException(401, f"유튜브 토큰 갱신 실패: {res.get('error_description', res.get('error'))}")
return res["access_token"]
async def get_channel_info(access_token: str) -> dict:
"""내 유튜브 채널 → {id, title}."""
headers = {"Authorization": f"Bearer {access_token}"}
params = {"part": "snippet", "mine": "true"}
async with aiohttp.ClientSession() as s:
async with s.get(CHANNELS_URL, headers=headers, params=params) as r:
data = await r.json()
items = data.get("items") or []
if not items:
return {"id": "", "title": "내 채널"}
return {"id": items[0]["id"], "title": items[0]["snippet"]["title"]}
async def upload_video(video_path: str, access_token: str, title: str,
description: str = "", tags: list[str] | None = None,
privacy: str = "public") -> dict:
"""영상 업로드(resumable). → {video_id, url}."""
import os
if not os.path.exists(video_path):
raise HTTPException(404, f"업로드할 파일이 없습니다: {video_path}")
file_size = os.path.getsize(video_path)
body = {
"snippet": {
"title": title[:100] or "썰박스",
"description": (description or "").replace("<", "").replace(">", ""),
"tags": tags or ["썰박스", "쇼츠", "홍보"],
"categoryId": "22",
},
"status": {"privacyStatus": privacy, "selfDeclaredMadeForKids": False},
}
init_headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json; charset=utf-8",
"X-Upload-Content-Type": "video/*",
"X-Upload-Content-Length": str(file_size),
}
async with aiohttp.ClientSession() as s:
# 1) resumable 세션 시작
async with s.post(UPLOAD_URL, params={"uploadType": "resumable", "part": "snippet,status"},
headers=init_headers, json=body) as r:
if r.status != 200:
err = await r.json()
raise HTTPException(502, f"유튜브 업로드 세션 실패: {err.get('error', {}).get('message', r.status)}")
upload_url = r.headers.get("Location")
if not upload_url:
raise HTTPException(502, "유튜브 업로드 URL을 받지 못했습니다.")
# 2) 파일 업로드(단일 PUT — 쇼츠는 소용량)
with open(video_path, "rb") as f:
content = f.read()
put_headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "video/*",
"Content-Length": str(file_size),
}
async with s.put(upload_url, headers=put_headers, data=content) as r:
res = await r.json()
vid = res.get("id")
if not vid:
raise HTTPException(502, f"유튜브 업로드 실패: {res.get('error', {}).get('message', '알 수 없는 오류')}")
return {"video_id": vid, "url": f"https://www.youtube.com/watch?v={vid}"}

View File

@ -0,0 +1,15 @@
[캐릭터 공식 레퍼런스 폴더 — '눈만 고정']
이 폴더에 고양이 왕 기준 이미지를 1장 넣어두면(예: king_ref.png),
모든 쇼츠 영상의 고양이 왕이 '그 눈과 표정'을 그대로 따라갑니다.
- 고정되는 것 : 눈 모양 / 눈동자 / 표정 (귀여운 순한 눈으로 통일)
- 고정 안 됨 : 곤룡포 색, 관모, 포즈, 배경 → 왕별 character_sheet / 장면 프롬프트대로
(즉 광해군·순종 등 왕마다 복장은 다르게 나옴)
- 파일명은 자유 (폴더 안 '첫 번째' 이미지 파일을 사용. png/jpg/jpeg/webp)
- 한 장만 두는 걸 권장 (여러 장이면 정렬상 첫 파일만 사용)
- 빼고 싶으면 이미지를 폴더 밖으로 빼거나 폴더를 비우면 됨
→ 그럼 예전처럼 영상마다 캐릭터를 새로 그림(눈은 EYE_STYLE 텍스트 지시로 귀엽게 유지)
연결 코드: make_short.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

@ -0,0 +1,394 @@
대수,왕,문서제목,썰후보여부,키워드,URL
1,태조,태조(조선),,태조(조선),https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,개요,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,생애,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),Y,이름,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,묘호(廟號)와 시호(諡號),https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,휘(諱),https://namu.wiki/w/태조(조선)
1,태조,태조(조선),Y,가족 관계,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,사용한 무구,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,어궁구,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,전어도,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,화살보다 빠른 말,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,팔준마,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,직접 쓴 글과 시,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),Y,평가,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,어진,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),Y,여담,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),Y,대중매체,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,관련 문서,https://namu.wiki/w/태조(조선)
1,태조,태조(조선),,둘러보기,https://namu.wiki/w/태조(조선)
2,정종,정종(조선),,정종(조선),https://namu.wiki/w/정종(조선)
2,정종,정종(조선),,개요,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),,생애,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),Y,가족 관계,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),,선대 가계,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),,배우자 / 자녀,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),Y,평가,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),Y,여담,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),,정종 무인정사 배후설?,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),Y,대중매체,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),,관련 문서,https://namu.wiki/w/정종(조선)
2,정종,정종(조선),,둘러보기,https://namu.wiki/w/정종(조선)
3,태종,태종(조선),,태종(조선),https://namu.wiki/w/태종(조선)
3,태종,태종(조선),,개요,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),,묘호와 시호,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),Y,이름과 작위,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),,생애,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),Y,평가,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),Y,일화,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),,직접 쓴 글과 시,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),Y,기타,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),Y,가족 관계,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),Y,대중매체,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),,관련 문서,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),,외부 링크,https://namu.wiki/w/태종(조선)
3,태종,태종(조선),,둘러보기,https://namu.wiki/w/태종(조선)
4,세종,세종(조선),,세종(조선),https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,개요,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,생애,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),Y,업적,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),Y,비판과 반론,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),Y,특이한 기록들,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,가계,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,조상,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,배우자 / 자녀,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,영릉,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,어진,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),Y,대중매체,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,직접 쓴 글과 시,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),Y,여담,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),Y,어록,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,관련 단체,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,관련 문서,https://namu.wiki/w/세종(조선)
4,세종,세종(조선),,둘러보기,https://namu.wiki/w/세종(조선)
5,문종,문종(조선),,문종(조선),https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,개요,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,생애,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),Y,평가,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,가계,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,조상,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,배우자 / 자녀,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,직접 쓴 글과 시,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),Y,기타,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),Y,대중매체,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,드라마,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,영화,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,소설,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,게임,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,관련 문서,https://namu.wiki/w/문종(조선)
5,문종,문종(조선),,둘러보기,https://namu.wiki/w/문종(조선)
6,단종,단종(조선),,단종(조선),https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,개요,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,휘,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,생애,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),Y,평가,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),Y,가족관계,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,조상,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,형제자매,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,배우자,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,직접 쓴 글과 시,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,어진,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),Y,대중매체,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,만화,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,연극,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,영화,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,드라마,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,다큐멘터리,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,TV조선왕조실록,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,고운님 여의옵고,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,EBS 특집 다큐멘터리,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,MBC 다큐프라임,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,역사스페셜 시간여행자,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,소설,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),Y,여담,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,관련 문서,https://namu.wiki/w/단종(조선)
6,단종,단종(조선),,둘러보기,https://namu.wiki/w/단종(조선)
7,세조,세조(조선),,세조(조선),https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,개요,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,생애,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),Y,평가,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,어진,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,어필,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,수결(서명),https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,직접 쓴 글과 시,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,가계,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,조상,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,배우자 / 자녀,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),Y,대중매체,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),Y,여담,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,관련 유물,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,관련 문서,https://namu.wiki/w/세조(조선)
7,세조,세조(조선),,둘러보기,https://namu.wiki/w/세조(조선)
8,예종,예종(조선),,예종(조선),https://namu.wiki/w/예종(조선)
8,예종,예종(조선),,개요,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),,생애,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),,직접 쓴 글과 시,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),Y,평가,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),,가계,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),,조상,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),,배우자 / 자녀,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),Y,기타,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),Y,대중매체,https://namu.wiki/w/예종(조선)
8,예종,예종(조선),,둘러보기,https://namu.wiki/w/예종(조선)
9,성종,성종(조선),,성종(조선),https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,개요,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,생애,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,가계,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,조상,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,배우자 / 자녀,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),Y,평가,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),Y,기타,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),Y,대중매체,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,영화,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,드라마,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,관련 문서,https://namu.wiki/w/성종(조선)
9,성종,성종(조선),,둘러보기,https://namu.wiki/w/성종(조선)
10,연산군,연산군,,연산군,https://namu.wiki/w/연산군
10,연산군,연산군,,개요,https://namu.wiki/w/연산군
10,연산군,연산군,,생애,https://namu.wiki/w/연산군
10,연산군,연산군,Y,여담,https://namu.wiki/w/연산군
10,연산군,연산군,,가계,https://namu.wiki/w/연산군
10,연산군,연산군,,조상,https://namu.wiki/w/연산군
10,연산군,연산군,,배우자/자녀,https://namu.wiki/w/연산군
10,연산군,연산군,Y,평가,https://namu.wiki/w/연산군
10,연산군,연산군,Y,대중매체,https://namu.wiki/w/연산군
10,연산군,연산군,,관련 문서,https://namu.wiki/w/연산군
10,연산군,연산군,,둘러보기,https://namu.wiki/w/연산군
11,중종,중종(조선),,중종(조선),https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,개요,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,생애,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),Y,평가,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),Y,기타,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,가계,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,조상,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,배우자 / 자녀,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),Y,대중매체,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,드라마,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,영화,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,관련 문서,https://namu.wiki/w/중종(조선)
11,중종,중종(조선),,둘러보기,https://namu.wiki/w/중종(조선)
12,인종,인종(조선),,인종(조선),https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,개요,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,생애,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,가계,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,친가(전주 이씨),https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,조상,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,외가(파평 윤씨),https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,처가(반남 박씨),https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,배우자 / 자녀,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),Y,기타,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),Y,대중매체,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,만화,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,영화,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,드라마,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,소설,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,관련 문서,https://namu.wiki/w/인종(조선)
12,인종,인종(조선),,둘러보기,https://namu.wiki/w/인종(조선)
13,명종,명종(조선),,명종(조선),https://namu.wiki/w/명종(조선)
13,명종,명종(조선),,개요,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),,생애,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),,가계,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),,조상,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),,배우자 / 자녀,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),Y,평가,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),Y,기타,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),Y,대중매체,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),,관련 문서,https://namu.wiki/w/명종(조선)
13,명종,명종(조선),,둘러보기,https://namu.wiki/w/명종(조선)
14,선조,선조(조선),,선조(조선),https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,개요,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,생애,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),Y,평가,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),Y,가족 관계,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,조상,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,배우자/자녀,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),Y,기타,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,어진(御眞),https://namu.wiki/w/선조(조선)
14,선조,선조(조선),Y,대중매체,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,소설,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,만화,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,게임,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,영화,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,드라마,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,교양·다큐멘터리,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,관련 문서,https://namu.wiki/w/선조(조선)
14,선조,선조(조선),,둘러보기,https://namu.wiki/w/선조(조선)
15,광해군,광해군,,광해군,https://namu.wiki/w/광해군
15,광해군,광해군,,개요,https://namu.wiki/w/광해군
15,광해군,광해군,,생애,https://namu.wiki/w/광해군
15,광해군,광해군,Y,평가,https://namu.wiki/w/광해군
15,광해군,광해군,Y,여담,https://namu.wiki/w/광해군
15,광해군,광해군,Y,가족 관계,https://namu.wiki/w/광해군
15,광해군,광해군,,조상,https://namu.wiki/w/광해군
15,광해군,광해군,,배우자/자녀,https://namu.wiki/w/광해군
15,광해군,광해군,,광해군묘,https://namu.wiki/w/광해군
15,광해군,광해군,Y,대중매체,https://namu.wiki/w/광해군
15,광해군,광해군,,관련 다큐,https://namu.wiki/w/광해군
15,광해군,광해군,,한국사 傳,https://namu.wiki/w/광해군
15,광해군,광해군,,역사스페셜,https://namu.wiki/w/광해군
15,광해군,광해군,,역사스페셜 시간여행자,https://namu.wiki/w/광해군
15,광해군,광해군,,같이 보기,https://namu.wiki/w/광해군
15,광해군,광해군,,둘러보기,https://namu.wiki/w/광해군
17,효종,효종(조선),,효종(조선),https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,개요,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,생애,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,즉위 과정,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,북벌 준비와 군비 확장,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),Y,사망 징조와 죽음,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,후일담,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),Y,평가,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,북벌론,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,정통성에 대한 태도,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),Y,기타,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),Y,가족,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,조상,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,배우자/자녀,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),Y,대중매체,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,드라마,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,영화,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,관련 문서,https://namu.wiki/w/효종(조선)
17,효종,효종(조선),,둘러보기,https://namu.wiki/w/효종(조선)
18,현종,현종(조선),,현종(조선),https://namu.wiki/w/현종(조선)
18,현종,현종(조선),,개요,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),,생애,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),Y,평가,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),Y,기타,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),,가계,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),,조상,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),,배우자 / 자녀,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),Y,대중매체,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),,관련 문서,https://namu.wiki/w/현종(조선)
18,현종,현종(조선),,둘러보기,https://namu.wiki/w/현종(조선)
19,숙종,숙종(조선),,숙종(조선),https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,개요,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,생애,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,가계,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,조상,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,배우자 / 자녀,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),Y,여담,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,"금덕, 금손, 애묘인",https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,어진,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,직접 쓴 글과 시,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),Y,평가,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),Y,긍정적 평가,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),Y,부정적 평가,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),Y,대중매체,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,영화,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,드라마,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,소설,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,관련 문서,https://namu.wiki/w/숙종(조선)
19,숙종,숙종(조선),,둘러보기,https://namu.wiki/w/숙종(조선)
20,경종,경종(조선),,경종(조선),https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,개요,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,생애,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,조상,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),Y,여담,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,불임 의혹,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,이복동생 연잉군과의 관계,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),Y,대중매체,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,영화,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,드라마,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,소설,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,뮤지컬,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),Y,기타,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,관련 문서,https://namu.wiki/w/경종(조선)
20,경종,경종(조선),,둘러보기,https://namu.wiki/w/경종(조선)
22,정조,정조(조선),,정조(조선),https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,개요,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,생애,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),Y,업적과 정책,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,"묘호, 시호, 휘",https://namu.wiki/w/정조(조선)
22,정조,정조(조선),Y,평가,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,가계,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,조상,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,어진,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),Y,기타,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,엄친아,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),Y,유성한 사건,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),Y,의빈 성씨와의 일화,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,술과 담배 사랑,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,정조의 비밀 편지들,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,과인은 사도세자의 아들이다?,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),Y,대중매체,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,관련 문서,https://namu.wiki/w/정조(조선)
22,정조,정조(조선),,둘러보기,https://namu.wiki/w/정조(조선)
23,순조,순조,,순조,https://namu.wiki/w/순조
23,순조,순조,,개요,https://namu.wiki/w/순조
23,순조,순조,,생애,https://namu.wiki/w/순조
23,순조,순조,Y,평가,https://namu.wiki/w/순조
23,순조,순조,,묘호와 시호,https://namu.wiki/w/순조
23,순조,순조,,어진,https://namu.wiki/w/순조
23,순조,순조,Y,기타,https://namu.wiki/w/순조
23,순조,순조,,가계,https://namu.wiki/w/순조
23,순조,순조,,조상,https://namu.wiki/w/순조
23,순조,순조,,형제자매,https://namu.wiki/w/순조
23,순조,순조,,배우자/자녀,https://namu.wiki/w/순조
23,순조,순조,Y,대중매체,https://namu.wiki/w/순조
23,순조,순조,,관련 문서,https://namu.wiki/w/순조
23,순조,순조,,둘러보기,https://namu.wiki/w/순조
24,헌종,헌종(조선),,헌종(조선),https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,개요,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,생애,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),Y,평가,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,가계,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,조상,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,친가(전주 이씨),https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,외가(풍양 조씨),https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,처가,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,안동 김씨,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,남양 홍씨,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,배우자 / 자녀,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,어진,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),Y,여담,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),Y,대중매체,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,관련 문서,https://namu.wiki/w/헌종(조선)
24,헌종,헌종(조선),,둘러보기,https://namu.wiki/w/헌종(조선)
25,철종,철종(조선),,철종(조선),https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,개요,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,생애,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),Y,평가,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,어진,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,외부 링크,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,보물 (舊 보물 제1492호),https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,가계,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,조상,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,친가(전주 이씨),https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,외가(용담 염씨),https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,처가(안동 김씨),https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,배우자 / 자녀,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),Y,여담,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),Y,대중매체,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,소설,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,영화,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,드라마,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,만화,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,게임,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,관련 문서,https://namu.wiki/w/철종(조선)
25,철종,철종(조선),,둘러보기,https://namu.wiki/w/철종(조선)
26,고종,고종(대한제국),,고종(대한제국),https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,개요,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,호칭,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,즉위 배경,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,생애,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),Y,평가,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,청과의 영토 분쟁,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),Y,가족 관계,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,조상,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),Y,"사진과 어진, 기타 그림",https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,사진,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,어진,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),Y,기타 그림,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),Y,여담,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),Y,대중매체,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,관련 문서,https://namu.wiki/w/고종(대한제국)
26,고종,고종(대한제국),,둘러보기,https://namu.wiki/w/고종(대한제국)
27,순종,순종(대한제국),,순종(대한제국),https://namu.wiki/w/순종(대한제국)
27,순종,순종(대한제국),,개요,https://namu.wiki/w/순종(대한제국)
27,순종,순종(대한제국),,생애,https://namu.wiki/w/순종(대한제국)
27,순종,순종(대한제국),Y,평가,https://namu.wiki/w/순종(대한제국)
27,순종,순종(대한제국),,어진,https://namu.wiki/w/순종(대한제국)
27,순종,순종(대한제국),Y,기타,https://namu.wiki/w/순종(대한제국)
27,순종,순종(대한제국),Y,대중매체,https://namu.wiki/w/순종(대한제국)
27,순종,순종(대한제국),,관련 문서,https://namu.wiki/w/순종(대한제국)
27,순종,순종(대한제국),,둘러보기,https://namu.wiki/w/순종(대한제국)
1 대수 문서제목 썰후보여부 키워드 URL
2 1 태조 태조(조선) 태조(조선) https://namu.wiki/w/태조(조선)
3 1 태조 태조(조선) 개요 https://namu.wiki/w/태조(조선)
4 1 태조 태조(조선) 생애 https://namu.wiki/w/태조(조선)
5 1 태조 태조(조선) Y 이름 https://namu.wiki/w/태조(조선)
6 1 태조 태조(조선) 묘호(廟號)와 시호(諡號) https://namu.wiki/w/태조(조선)
7 1 태조 태조(조선) 휘(諱) https://namu.wiki/w/태조(조선)
8 1 태조 태조(조선) Y 가족 관계 https://namu.wiki/w/태조(조선)
9 1 태조 태조(조선) 사용한 무구 https://namu.wiki/w/태조(조선)
10 1 태조 태조(조선) 어궁구 https://namu.wiki/w/태조(조선)
11 1 태조 태조(조선) 전어도 https://namu.wiki/w/태조(조선)
12 1 태조 태조(조선) 화살보다 빠른 말 https://namu.wiki/w/태조(조선)
13 1 태조 태조(조선) 팔준마 https://namu.wiki/w/태조(조선)
14 1 태조 태조(조선) 직접 쓴 글과 시 https://namu.wiki/w/태조(조선)
15 1 태조 태조(조선) Y 평가 https://namu.wiki/w/태조(조선)
16 1 태조 태조(조선) 어진 https://namu.wiki/w/태조(조선)
17 1 태조 태조(조선) Y 여담 https://namu.wiki/w/태조(조선)
18 1 태조 태조(조선) Y 대중매체 https://namu.wiki/w/태조(조선)
19 1 태조 태조(조선) 관련 문서 https://namu.wiki/w/태조(조선)
20 1 태조 태조(조선) 둘러보기 https://namu.wiki/w/태조(조선)
21 2 정종 정종(조선) 정종(조선) https://namu.wiki/w/정종(조선)
22 2 정종 정종(조선) 개요 https://namu.wiki/w/정종(조선)
23 2 정종 정종(조선) 생애 https://namu.wiki/w/정종(조선)
24 2 정종 정종(조선) Y 가족 관계 https://namu.wiki/w/정종(조선)
25 2 정종 정종(조선) 선대 가계 https://namu.wiki/w/정종(조선)
26 2 정종 정종(조선) 배우자 / 자녀 https://namu.wiki/w/정종(조선)
27 2 정종 정종(조선) Y 평가 https://namu.wiki/w/정종(조선)
28 2 정종 정종(조선) Y 여담 https://namu.wiki/w/정종(조선)
29 2 정종 정종(조선) 정종 무인정사 배후설? https://namu.wiki/w/정종(조선)
30 2 정종 정종(조선) Y 대중매체 https://namu.wiki/w/정종(조선)
31 2 정종 정종(조선) 관련 문서 https://namu.wiki/w/정종(조선)
32 2 정종 정종(조선) 둘러보기 https://namu.wiki/w/정종(조선)
33 3 태종 태종(조선) 태종(조선) https://namu.wiki/w/태종(조선)
34 3 태종 태종(조선) 개요 https://namu.wiki/w/태종(조선)
35 3 태종 태종(조선) 묘호와 시호 https://namu.wiki/w/태종(조선)
36 3 태종 태종(조선) Y 이름과 작위 https://namu.wiki/w/태종(조선)
37 3 태종 태종(조선) 생애 https://namu.wiki/w/태종(조선)
38 3 태종 태종(조선) Y 평가 https://namu.wiki/w/태종(조선)
39 3 태종 태종(조선) Y 일화 https://namu.wiki/w/태종(조선)
40 3 태종 태종(조선) 직접 쓴 글과 시 https://namu.wiki/w/태종(조선)
41 3 태종 태종(조선) Y 기타 https://namu.wiki/w/태종(조선)
42 3 태종 태종(조선) Y 가족 관계 https://namu.wiki/w/태종(조선)
43 3 태종 태종(조선) Y 대중매체 https://namu.wiki/w/태종(조선)
44 3 태종 태종(조선) 관련 문서 https://namu.wiki/w/태종(조선)
45 3 태종 태종(조선) 외부 링크 https://namu.wiki/w/태종(조선)
46 3 태종 태종(조선) 둘러보기 https://namu.wiki/w/태종(조선)
47 4 세종 세종(조선) 세종(조선) https://namu.wiki/w/세종(조선)
48 4 세종 세종(조선) 개요 https://namu.wiki/w/세종(조선)
49 4 세종 세종(조선) 생애 https://namu.wiki/w/세종(조선)
50 4 세종 세종(조선) Y 업적 https://namu.wiki/w/세종(조선)
51 4 세종 세종(조선) Y 비판과 반론 https://namu.wiki/w/세종(조선)
52 4 세종 세종(조선) Y 특이한 기록들 https://namu.wiki/w/세종(조선)
53 4 세종 세종(조선) 가계 https://namu.wiki/w/세종(조선)
54 4 세종 세종(조선) 조상 https://namu.wiki/w/세종(조선)
55 4 세종 세종(조선) 배우자 / 자녀 https://namu.wiki/w/세종(조선)
56 4 세종 세종(조선) 영릉 https://namu.wiki/w/세종(조선)
57 4 세종 세종(조선) 어진 https://namu.wiki/w/세종(조선)
58 4 세종 세종(조선) Y 대중매체 https://namu.wiki/w/세종(조선)
59 4 세종 세종(조선) 직접 쓴 글과 시 https://namu.wiki/w/세종(조선)
60 4 세종 세종(조선) Y 여담 https://namu.wiki/w/세종(조선)
61 4 세종 세종(조선) Y 어록 https://namu.wiki/w/세종(조선)
62 4 세종 세종(조선) 관련 단체 https://namu.wiki/w/세종(조선)
63 4 세종 세종(조선) 관련 문서 https://namu.wiki/w/세종(조선)
64 4 세종 세종(조선) 둘러보기 https://namu.wiki/w/세종(조선)
65 5 문종 문종(조선) 문종(조선) https://namu.wiki/w/문종(조선)
66 5 문종 문종(조선) 개요 https://namu.wiki/w/문종(조선)
67 5 문종 문종(조선) 생애 https://namu.wiki/w/문종(조선)
68 5 문종 문종(조선) Y 평가 https://namu.wiki/w/문종(조선)
69 5 문종 문종(조선) 가계 https://namu.wiki/w/문종(조선)
70 5 문종 문종(조선) 조상 https://namu.wiki/w/문종(조선)
71 5 문종 문종(조선) 배우자 / 자녀 https://namu.wiki/w/문종(조선)
72 5 문종 문종(조선) 직접 쓴 글과 시 https://namu.wiki/w/문종(조선)
73 5 문종 문종(조선) Y 기타 https://namu.wiki/w/문종(조선)
74 5 문종 문종(조선) Y 대중매체 https://namu.wiki/w/문종(조선)
75 5 문종 문종(조선) 드라마 https://namu.wiki/w/문종(조선)
76 5 문종 문종(조선) 영화 https://namu.wiki/w/문종(조선)
77 5 문종 문종(조선) 소설 https://namu.wiki/w/문종(조선)
78 5 문종 문종(조선) 게임 https://namu.wiki/w/문종(조선)
79 5 문종 문종(조선) 관련 문서 https://namu.wiki/w/문종(조선)
80 5 문종 문종(조선) 둘러보기 https://namu.wiki/w/문종(조선)
81 6 단종 단종(조선) 단종(조선) https://namu.wiki/w/단종(조선)
82 6 단종 단종(조선) 개요 https://namu.wiki/w/단종(조선)
83 6 단종 단종(조선) https://namu.wiki/w/단종(조선)
84 6 단종 단종(조선) 생애 https://namu.wiki/w/단종(조선)
85 6 단종 단종(조선) Y 평가 https://namu.wiki/w/단종(조선)
86 6 단종 단종(조선) Y 가족관계 https://namu.wiki/w/단종(조선)
87 6 단종 단종(조선) 조상 https://namu.wiki/w/단종(조선)
88 6 단종 단종(조선) 형제자매 https://namu.wiki/w/단종(조선)
89 6 단종 단종(조선) 배우자 https://namu.wiki/w/단종(조선)
90 6 단종 단종(조선) 직접 쓴 글과 시 https://namu.wiki/w/단종(조선)
91 6 단종 단종(조선) 어진 https://namu.wiki/w/단종(조선)
92 6 단종 단종(조선) Y 대중매체 https://namu.wiki/w/단종(조선)
93 6 단종 단종(조선) 만화 https://namu.wiki/w/단종(조선)
94 6 단종 단종(조선) 연극 https://namu.wiki/w/단종(조선)
95 6 단종 단종(조선) 영화 https://namu.wiki/w/단종(조선)
96 6 단종 단종(조선) 드라마 https://namu.wiki/w/단종(조선)
97 6 단종 단종(조선) 다큐멘터리 https://namu.wiki/w/단종(조선)
98 6 단종 단종(조선) TV조선왕조실록 https://namu.wiki/w/단종(조선)
99 6 단종 단종(조선) 고운님 여의옵고 https://namu.wiki/w/단종(조선)
100 6 단종 단종(조선) EBS 특집 다큐멘터리 https://namu.wiki/w/단종(조선)
101 6 단종 단종(조선) MBC 다큐프라임 https://namu.wiki/w/단종(조선)
102 6 단종 단종(조선) 역사스페셜 시간여행자 https://namu.wiki/w/단종(조선)
103 6 단종 단종(조선) 소설 https://namu.wiki/w/단종(조선)
104 6 단종 단종(조선) Y 여담 https://namu.wiki/w/단종(조선)
105 6 단종 단종(조선) 관련 문서 https://namu.wiki/w/단종(조선)
106 6 단종 단종(조선) 둘러보기 https://namu.wiki/w/단종(조선)
107 7 세조 세조(조선) 세조(조선) https://namu.wiki/w/세조(조선)
108 7 세조 세조(조선) 개요 https://namu.wiki/w/세조(조선)
109 7 세조 세조(조선) 생애 https://namu.wiki/w/세조(조선)
110 7 세조 세조(조선) Y 평가 https://namu.wiki/w/세조(조선)
111 7 세조 세조(조선) 어진 https://namu.wiki/w/세조(조선)
112 7 세조 세조(조선) 어필 https://namu.wiki/w/세조(조선)
113 7 세조 세조(조선) 수결(서명) https://namu.wiki/w/세조(조선)
114 7 세조 세조(조선) 직접 쓴 글과 시 https://namu.wiki/w/세조(조선)
115 7 세조 세조(조선) 가계 https://namu.wiki/w/세조(조선)
116 7 세조 세조(조선) 조상 https://namu.wiki/w/세조(조선)
117 7 세조 세조(조선) 배우자 / 자녀 https://namu.wiki/w/세조(조선)
118 7 세조 세조(조선) Y 대중매체 https://namu.wiki/w/세조(조선)
119 7 세조 세조(조선) Y 여담 https://namu.wiki/w/세조(조선)
120 7 세조 세조(조선) 관련 유물 https://namu.wiki/w/세조(조선)
121 7 세조 세조(조선) 관련 문서 https://namu.wiki/w/세조(조선)
122 7 세조 세조(조선) 둘러보기 https://namu.wiki/w/세조(조선)
123 8 예종 예종(조선) 예종(조선) https://namu.wiki/w/예종(조선)
124 8 예종 예종(조선) 개요 https://namu.wiki/w/예종(조선)
125 8 예종 예종(조선) 생애 https://namu.wiki/w/예종(조선)
126 8 예종 예종(조선) 직접 쓴 글과 시 https://namu.wiki/w/예종(조선)
127 8 예종 예종(조선) Y 평가 https://namu.wiki/w/예종(조선)
128 8 예종 예종(조선) 가계 https://namu.wiki/w/예종(조선)
129 8 예종 예종(조선) 조상 https://namu.wiki/w/예종(조선)
130 8 예종 예종(조선) 배우자 / 자녀 https://namu.wiki/w/예종(조선)
131 8 예종 예종(조선) Y 기타 https://namu.wiki/w/예종(조선)
132 8 예종 예종(조선) Y 대중매체 https://namu.wiki/w/예종(조선)
133 8 예종 예종(조선) 둘러보기 https://namu.wiki/w/예종(조선)
134 9 성종 성종(조선) 성종(조선) https://namu.wiki/w/성종(조선)
135 9 성종 성종(조선) 개요 https://namu.wiki/w/성종(조선)
136 9 성종 성종(조선) 생애 https://namu.wiki/w/성종(조선)
137 9 성종 성종(조선) 가계 https://namu.wiki/w/성종(조선)
138 9 성종 성종(조선) 조상 https://namu.wiki/w/성종(조선)
139 9 성종 성종(조선) 배우자 / 자녀 https://namu.wiki/w/성종(조선)
140 9 성종 성종(조선) Y 평가 https://namu.wiki/w/성종(조선)
141 9 성종 성종(조선) Y 기타 https://namu.wiki/w/성종(조선)
142 9 성종 성종(조선) Y 대중매체 https://namu.wiki/w/성종(조선)
143 9 성종 성종(조선) 영화 https://namu.wiki/w/성종(조선)
144 9 성종 성종(조선) 드라마 https://namu.wiki/w/성종(조선)
145 9 성종 성종(조선) 관련 문서 https://namu.wiki/w/성종(조선)
146 9 성종 성종(조선) 둘러보기 https://namu.wiki/w/성종(조선)
147 10 연산군 연산군 연산군 https://namu.wiki/w/연산군
148 10 연산군 연산군 개요 https://namu.wiki/w/연산군
149 10 연산군 연산군 생애 https://namu.wiki/w/연산군
150 10 연산군 연산군 Y 여담 https://namu.wiki/w/연산군
151 10 연산군 연산군 가계 https://namu.wiki/w/연산군
152 10 연산군 연산군 조상 https://namu.wiki/w/연산군
153 10 연산군 연산군 배우자/자녀 https://namu.wiki/w/연산군
154 10 연산군 연산군 Y 평가 https://namu.wiki/w/연산군
155 10 연산군 연산군 Y 대중매체 https://namu.wiki/w/연산군
156 10 연산군 연산군 관련 문서 https://namu.wiki/w/연산군
157 10 연산군 연산군 둘러보기 https://namu.wiki/w/연산군
158 11 중종 중종(조선) 중종(조선) https://namu.wiki/w/중종(조선)
159 11 중종 중종(조선) 개요 https://namu.wiki/w/중종(조선)
160 11 중종 중종(조선) 생애 https://namu.wiki/w/중종(조선)
161 11 중종 중종(조선) Y 평가 https://namu.wiki/w/중종(조선)
162 11 중종 중종(조선) Y 기타 https://namu.wiki/w/중종(조선)
163 11 중종 중종(조선) 가계 https://namu.wiki/w/중종(조선)
164 11 중종 중종(조선) 조상 https://namu.wiki/w/중종(조선)
165 11 중종 중종(조선) 배우자 / 자녀 https://namu.wiki/w/중종(조선)
166 11 중종 중종(조선) Y 대중매체 https://namu.wiki/w/중종(조선)
167 11 중종 중종(조선) 드라마 https://namu.wiki/w/중종(조선)
168 11 중종 중종(조선) 영화 https://namu.wiki/w/중종(조선)
169 11 중종 중종(조선) 관련 문서 https://namu.wiki/w/중종(조선)
170 11 중종 중종(조선) 둘러보기 https://namu.wiki/w/중종(조선)
171 12 인종 인종(조선) 인종(조선) https://namu.wiki/w/인종(조선)
172 12 인종 인종(조선) 개요 https://namu.wiki/w/인종(조선)
173 12 인종 인종(조선) 생애 https://namu.wiki/w/인종(조선)
174 12 인종 인종(조선) 가계 https://namu.wiki/w/인종(조선)
175 12 인종 인종(조선) 친가(전주 이씨) https://namu.wiki/w/인종(조선)
176 12 인종 인종(조선) 조상 https://namu.wiki/w/인종(조선)
177 12 인종 인종(조선) 외가(파평 윤씨) https://namu.wiki/w/인종(조선)
178 12 인종 인종(조선) 처가(반남 박씨) https://namu.wiki/w/인종(조선)
179 12 인종 인종(조선) 배우자 / 자녀 https://namu.wiki/w/인종(조선)
180 12 인종 인종(조선) Y 기타 https://namu.wiki/w/인종(조선)
181 12 인종 인종(조선) Y 대중매체 https://namu.wiki/w/인종(조선)
182 12 인종 인종(조선) 만화 https://namu.wiki/w/인종(조선)
183 12 인종 인종(조선) 영화 https://namu.wiki/w/인종(조선)
184 12 인종 인종(조선) 드라마 https://namu.wiki/w/인종(조선)
185 12 인종 인종(조선) 소설 https://namu.wiki/w/인종(조선)
186 12 인종 인종(조선) 관련 문서 https://namu.wiki/w/인종(조선)
187 12 인종 인종(조선) 둘러보기 https://namu.wiki/w/인종(조선)
188 13 명종 명종(조선) 명종(조선) https://namu.wiki/w/명종(조선)
189 13 명종 명종(조선) 개요 https://namu.wiki/w/명종(조선)
190 13 명종 명종(조선) 생애 https://namu.wiki/w/명종(조선)
191 13 명종 명종(조선) 가계 https://namu.wiki/w/명종(조선)
192 13 명종 명종(조선) 조상 https://namu.wiki/w/명종(조선)
193 13 명종 명종(조선) 배우자 / 자녀 https://namu.wiki/w/명종(조선)
194 13 명종 명종(조선) Y 평가 https://namu.wiki/w/명종(조선)
195 13 명종 명종(조선) Y 기타 https://namu.wiki/w/명종(조선)
196 13 명종 명종(조선) Y 대중매체 https://namu.wiki/w/명종(조선)
197 13 명종 명종(조선) 관련 문서 https://namu.wiki/w/명종(조선)
198 13 명종 명종(조선) 둘러보기 https://namu.wiki/w/명종(조선)
199 14 선조 선조(조선) 선조(조선) https://namu.wiki/w/선조(조선)
200 14 선조 선조(조선) 개요 https://namu.wiki/w/선조(조선)
201 14 선조 선조(조선) 생애 https://namu.wiki/w/선조(조선)
202 14 선조 선조(조선) Y 평가 https://namu.wiki/w/선조(조선)
203 14 선조 선조(조선) Y 가족 관계 https://namu.wiki/w/선조(조선)
204 14 선조 선조(조선) 조상 https://namu.wiki/w/선조(조선)
205 14 선조 선조(조선) 배우자/자녀 https://namu.wiki/w/선조(조선)
206 14 선조 선조(조선) Y 기타 https://namu.wiki/w/선조(조선)
207 14 선조 선조(조선) 어진(御眞) https://namu.wiki/w/선조(조선)
208 14 선조 선조(조선) Y 대중매체 https://namu.wiki/w/선조(조선)
209 14 선조 선조(조선) 소설 https://namu.wiki/w/선조(조선)
210 14 선조 선조(조선) 만화 https://namu.wiki/w/선조(조선)
211 14 선조 선조(조선) 게임 https://namu.wiki/w/선조(조선)
212 14 선조 선조(조선) 영화 https://namu.wiki/w/선조(조선)
213 14 선조 선조(조선) 드라마 https://namu.wiki/w/선조(조선)
214 14 선조 선조(조선) 교양·다큐멘터리 https://namu.wiki/w/선조(조선)
215 14 선조 선조(조선) 관련 문서 https://namu.wiki/w/선조(조선)
216 14 선조 선조(조선) 둘러보기 https://namu.wiki/w/선조(조선)
217 15 광해군 광해군 광해군 https://namu.wiki/w/광해군
218 15 광해군 광해군 개요 https://namu.wiki/w/광해군
219 15 광해군 광해군 생애 https://namu.wiki/w/광해군
220 15 광해군 광해군 Y 평가 https://namu.wiki/w/광해군
221 15 광해군 광해군 Y 여담 https://namu.wiki/w/광해군
222 15 광해군 광해군 Y 가족 관계 https://namu.wiki/w/광해군
223 15 광해군 광해군 조상 https://namu.wiki/w/광해군
224 15 광해군 광해군 배우자/자녀 https://namu.wiki/w/광해군
225 15 광해군 광해군 광해군묘 https://namu.wiki/w/광해군
226 15 광해군 광해군 Y 대중매체 https://namu.wiki/w/광해군
227 15 광해군 광해군 관련 다큐 https://namu.wiki/w/광해군
228 15 광해군 광해군 한국사 傳 https://namu.wiki/w/광해군
229 15 광해군 광해군 역사스페셜 https://namu.wiki/w/광해군
230 15 광해군 광해군 역사스페셜 시간여행자 https://namu.wiki/w/광해군
231 15 광해군 광해군 같이 보기 https://namu.wiki/w/광해군
232 15 광해군 광해군 둘러보기 https://namu.wiki/w/광해군
233 17 효종 효종(조선) 효종(조선) https://namu.wiki/w/효종(조선)
234 17 효종 효종(조선) 개요 https://namu.wiki/w/효종(조선)
235 17 효종 효종(조선) 생애 https://namu.wiki/w/효종(조선)
236 17 효종 효종(조선) 즉위 과정 https://namu.wiki/w/효종(조선)
237 17 효종 효종(조선) 북벌 준비와 군비 확장 https://namu.wiki/w/효종(조선)
238 17 효종 효종(조선) Y 사망 징조와 죽음 https://namu.wiki/w/효종(조선)
239 17 효종 효종(조선) 후일담 https://namu.wiki/w/효종(조선)
240 17 효종 효종(조선) Y 평가 https://namu.wiki/w/효종(조선)
241 17 효종 효종(조선) 북벌론 https://namu.wiki/w/효종(조선)
242 17 효종 효종(조선) 정통성에 대한 태도 https://namu.wiki/w/효종(조선)
243 17 효종 효종(조선) Y 기타 https://namu.wiki/w/효종(조선)
244 17 효종 효종(조선) Y 가족 https://namu.wiki/w/효종(조선)
245 17 효종 효종(조선) 조상 https://namu.wiki/w/효종(조선)
246 17 효종 효종(조선) 배우자/자녀 https://namu.wiki/w/효종(조선)
247 17 효종 효종(조선) Y 대중매체 https://namu.wiki/w/효종(조선)
248 17 효종 효종(조선) 드라마 https://namu.wiki/w/효종(조선)
249 17 효종 효종(조선) 영화 https://namu.wiki/w/효종(조선)
250 17 효종 효종(조선) 관련 문서 https://namu.wiki/w/효종(조선)
251 17 효종 효종(조선) 둘러보기 https://namu.wiki/w/효종(조선)
252 18 현종 현종(조선) 현종(조선) https://namu.wiki/w/현종(조선)
253 18 현종 현종(조선) 개요 https://namu.wiki/w/현종(조선)
254 18 현종 현종(조선) 생애 https://namu.wiki/w/현종(조선)
255 18 현종 현종(조선) Y 평가 https://namu.wiki/w/현종(조선)
256 18 현종 현종(조선) Y 기타 https://namu.wiki/w/현종(조선)
257 18 현종 현종(조선) 가계 https://namu.wiki/w/현종(조선)
258 18 현종 현종(조선) 조상 https://namu.wiki/w/현종(조선)
259 18 현종 현종(조선) 배우자 / 자녀 https://namu.wiki/w/현종(조선)
260 18 현종 현종(조선) Y 대중매체 https://namu.wiki/w/현종(조선)
261 18 현종 현종(조선) 관련 문서 https://namu.wiki/w/현종(조선)
262 18 현종 현종(조선) 둘러보기 https://namu.wiki/w/현종(조선)
263 19 숙종 숙종(조선) 숙종(조선) https://namu.wiki/w/숙종(조선)
264 19 숙종 숙종(조선) 개요 https://namu.wiki/w/숙종(조선)
265 19 숙종 숙종(조선) 생애 https://namu.wiki/w/숙종(조선)
266 19 숙종 숙종(조선) 가계 https://namu.wiki/w/숙종(조선)
267 19 숙종 숙종(조선) 조상 https://namu.wiki/w/숙종(조선)
268 19 숙종 숙종(조선) 배우자 / 자녀 https://namu.wiki/w/숙종(조선)
269 19 숙종 숙종(조선) Y 여담 https://namu.wiki/w/숙종(조선)
270 19 숙종 숙종(조선) 금덕, 금손, 애묘인 https://namu.wiki/w/숙종(조선)
271 19 숙종 숙종(조선) 어진 https://namu.wiki/w/숙종(조선)
272 19 숙종 숙종(조선) 직접 쓴 글과 시 https://namu.wiki/w/숙종(조선)
273 19 숙종 숙종(조선) Y 평가 https://namu.wiki/w/숙종(조선)
274 19 숙종 숙종(조선) Y 긍정적 평가 https://namu.wiki/w/숙종(조선)
275 19 숙종 숙종(조선) Y 부정적 평가 https://namu.wiki/w/숙종(조선)
276 19 숙종 숙종(조선) Y 대중매체 https://namu.wiki/w/숙종(조선)
277 19 숙종 숙종(조선) 영화 https://namu.wiki/w/숙종(조선)
278 19 숙종 숙종(조선) 드라마 https://namu.wiki/w/숙종(조선)
279 19 숙종 숙종(조선) 소설 https://namu.wiki/w/숙종(조선)
280 19 숙종 숙종(조선) 관련 문서 https://namu.wiki/w/숙종(조선)
281 19 숙종 숙종(조선) 둘러보기 https://namu.wiki/w/숙종(조선)
282 20 경종 경종(조선) 경종(조선) https://namu.wiki/w/경종(조선)
283 20 경종 경종(조선) 개요 https://namu.wiki/w/경종(조선)
284 20 경종 경종(조선) 생애 https://namu.wiki/w/경종(조선)
285 20 경종 경종(조선) 조상 https://namu.wiki/w/경종(조선)
286 20 경종 경종(조선) Y 여담 https://namu.wiki/w/경종(조선)
287 20 경종 경종(조선) 불임 의혹 https://namu.wiki/w/경종(조선)
288 20 경종 경종(조선) 이복동생 연잉군과의 관계 https://namu.wiki/w/경종(조선)
289 20 경종 경종(조선) Y 대중매체 https://namu.wiki/w/경종(조선)
290 20 경종 경종(조선) 영화 https://namu.wiki/w/경종(조선)
291 20 경종 경종(조선) 드라마 https://namu.wiki/w/경종(조선)
292 20 경종 경종(조선) 소설 https://namu.wiki/w/경종(조선)
293 20 경종 경종(조선) 뮤지컬 https://namu.wiki/w/경종(조선)
294 20 경종 경종(조선) Y 기타 https://namu.wiki/w/경종(조선)
295 20 경종 경종(조선) 관련 문서 https://namu.wiki/w/경종(조선)
296 20 경종 경종(조선) 둘러보기 https://namu.wiki/w/경종(조선)
297 22 정조 정조(조선) 정조(조선) https://namu.wiki/w/정조(조선)
298 22 정조 정조(조선) 개요 https://namu.wiki/w/정조(조선)
299 22 정조 정조(조선) 생애 https://namu.wiki/w/정조(조선)
300 22 정조 정조(조선) Y 업적과 정책 https://namu.wiki/w/정조(조선)
301 22 정조 정조(조선) 묘호, 시호, 휘 https://namu.wiki/w/정조(조선)
302 22 정조 정조(조선) Y 평가 https://namu.wiki/w/정조(조선)
303 22 정조 정조(조선) 가계 https://namu.wiki/w/정조(조선)
304 22 정조 정조(조선) 조상 https://namu.wiki/w/정조(조선)
305 22 정조 정조(조선) 어진 https://namu.wiki/w/정조(조선)
306 22 정조 정조(조선) Y 기타 https://namu.wiki/w/정조(조선)
307 22 정조 정조(조선) 엄친아 https://namu.wiki/w/정조(조선)
308 22 정조 정조(조선) Y 유성한 사건 https://namu.wiki/w/정조(조선)
309 22 정조 정조(조선) Y 의빈 성씨와의 일화 https://namu.wiki/w/정조(조선)
310 22 정조 정조(조선) 술과 담배 사랑 https://namu.wiki/w/정조(조선)
311 22 정조 정조(조선) 정조의 비밀 편지들 https://namu.wiki/w/정조(조선)
312 22 정조 정조(조선) 과인은 사도세자의 아들이다? https://namu.wiki/w/정조(조선)
313 22 정조 정조(조선) Y 대중매체 https://namu.wiki/w/정조(조선)
314 22 정조 정조(조선) 관련 문서 https://namu.wiki/w/정조(조선)
315 22 정조 정조(조선) 둘러보기 https://namu.wiki/w/정조(조선)
316 23 순조 순조 순조 https://namu.wiki/w/순조
317 23 순조 순조 개요 https://namu.wiki/w/순조
318 23 순조 순조 생애 https://namu.wiki/w/순조
319 23 순조 순조 Y 평가 https://namu.wiki/w/순조
320 23 순조 순조 묘호와 시호 https://namu.wiki/w/순조
321 23 순조 순조 어진 https://namu.wiki/w/순조
322 23 순조 순조 Y 기타 https://namu.wiki/w/순조
323 23 순조 순조 가계 https://namu.wiki/w/순조
324 23 순조 순조 조상 https://namu.wiki/w/순조
325 23 순조 순조 형제자매 https://namu.wiki/w/순조
326 23 순조 순조 배우자/자녀 https://namu.wiki/w/순조
327 23 순조 순조 Y 대중매체 https://namu.wiki/w/순조
328 23 순조 순조 관련 문서 https://namu.wiki/w/순조
329 23 순조 순조 둘러보기 https://namu.wiki/w/순조
330 24 헌종 헌종(조선) 헌종(조선) https://namu.wiki/w/헌종(조선)
331 24 헌종 헌종(조선) 개요 https://namu.wiki/w/헌종(조선)
332 24 헌종 헌종(조선) 생애 https://namu.wiki/w/헌종(조선)
333 24 헌종 헌종(조선) Y 평가 https://namu.wiki/w/헌종(조선)
334 24 헌종 헌종(조선) 가계 https://namu.wiki/w/헌종(조선)
335 24 헌종 헌종(조선) 조상 https://namu.wiki/w/헌종(조선)
336 24 헌종 헌종(조선) 친가(전주 이씨) https://namu.wiki/w/헌종(조선)
337 24 헌종 헌종(조선) 외가(풍양 조씨) https://namu.wiki/w/헌종(조선)
338 24 헌종 헌종(조선) 처가 https://namu.wiki/w/헌종(조선)
339 24 헌종 헌종(조선) 안동 김씨 https://namu.wiki/w/헌종(조선)
340 24 헌종 헌종(조선) 남양 홍씨 https://namu.wiki/w/헌종(조선)
341 24 헌종 헌종(조선) 배우자 / 자녀 https://namu.wiki/w/헌종(조선)
342 24 헌종 헌종(조선) 어진 https://namu.wiki/w/헌종(조선)
343 24 헌종 헌종(조선) Y 여담 https://namu.wiki/w/헌종(조선)
344 24 헌종 헌종(조선) Y 대중매체 https://namu.wiki/w/헌종(조선)
345 24 헌종 헌종(조선) 관련 문서 https://namu.wiki/w/헌종(조선)
346 24 헌종 헌종(조선) 둘러보기 https://namu.wiki/w/헌종(조선)
347 25 철종 철종(조선) 철종(조선) https://namu.wiki/w/철종(조선)
348 25 철종 철종(조선) 개요 https://namu.wiki/w/철종(조선)
349 25 철종 철종(조선) 생애 https://namu.wiki/w/철종(조선)
350 25 철종 철종(조선) Y 평가 https://namu.wiki/w/철종(조선)
351 25 철종 철종(조선) 어진 https://namu.wiki/w/철종(조선)
352 25 철종 철종(조선) 외부 링크 https://namu.wiki/w/철종(조선)
353 25 철종 철종(조선) 보물 (舊 보물 제1492호) https://namu.wiki/w/철종(조선)
354 25 철종 철종(조선) 가계 https://namu.wiki/w/철종(조선)
355 25 철종 철종(조선) 조상 https://namu.wiki/w/철종(조선)
356 25 철종 철종(조선) 친가(전주 이씨) https://namu.wiki/w/철종(조선)
357 25 철종 철종(조선) 외가(용담 염씨) https://namu.wiki/w/철종(조선)
358 25 철종 철종(조선) 처가(안동 김씨) https://namu.wiki/w/철종(조선)
359 25 철종 철종(조선) 배우자 / 자녀 https://namu.wiki/w/철종(조선)
360 25 철종 철종(조선) Y 여담 https://namu.wiki/w/철종(조선)
361 25 철종 철종(조선) Y 대중매체 https://namu.wiki/w/철종(조선)
362 25 철종 철종(조선) 소설 https://namu.wiki/w/철종(조선)
363 25 철종 철종(조선) 영화 https://namu.wiki/w/철종(조선)
364 25 철종 철종(조선) 드라마 https://namu.wiki/w/철종(조선)
365 25 철종 철종(조선) 만화 https://namu.wiki/w/철종(조선)
366 25 철종 철종(조선) 게임 https://namu.wiki/w/철종(조선)
367 25 철종 철종(조선) 관련 문서 https://namu.wiki/w/철종(조선)
368 25 철종 철종(조선) 둘러보기 https://namu.wiki/w/철종(조선)
369 26 고종 고종(대한제국) 고종(대한제국) https://namu.wiki/w/고종(대한제국)
370 26 고종 고종(대한제국) 개요 https://namu.wiki/w/고종(대한제국)
371 26 고종 고종(대한제국) 호칭 https://namu.wiki/w/고종(대한제국)
372 26 고종 고종(대한제국) 즉위 배경 https://namu.wiki/w/고종(대한제국)
373 26 고종 고종(대한제국) 생애 https://namu.wiki/w/고종(대한제국)
374 26 고종 고종(대한제국) Y 평가 https://namu.wiki/w/고종(대한제국)
375 26 고종 고종(대한제국) 청과의 영토 분쟁 https://namu.wiki/w/고종(대한제국)
376 26 고종 고종(대한제국) Y 가족 관계 https://namu.wiki/w/고종(대한제국)
377 26 고종 고종(대한제국) 조상 https://namu.wiki/w/고종(대한제국)
378 26 고종 고종(대한제국) Y 사진과 어진, 기타 그림 https://namu.wiki/w/고종(대한제국)
379 26 고종 고종(대한제국) 사진 https://namu.wiki/w/고종(대한제국)
380 26 고종 고종(대한제국) 어진 https://namu.wiki/w/고종(대한제국)
381 26 고종 고종(대한제국) Y 기타 그림 https://namu.wiki/w/고종(대한제국)
382 26 고종 고종(대한제국) Y 여담 https://namu.wiki/w/고종(대한제국)
383 26 고종 고종(대한제국) Y 대중매체 https://namu.wiki/w/고종(대한제국)
384 26 고종 고종(대한제국) 관련 문서 https://namu.wiki/w/고종(대한제국)
385 26 고종 고종(대한제국) 둘러보기 https://namu.wiki/w/고종(대한제국)
386 27 순종 순종(대한제국) 순종(대한제국) https://namu.wiki/w/순종(대한제국)
387 27 순종 순종(대한제국) 개요 https://namu.wiki/w/순종(대한제국)
388 27 순종 순종(대한제국) 생애 https://namu.wiki/w/순종(대한제국)
389 27 순종 순종(대한제국) Y 평가 https://namu.wiki/w/순종(대한제국)
390 27 순종 순종(대한제국) 어진 https://namu.wiki/w/순종(대한제국)
391 27 순종 순종(대한제국) Y 기타 https://namu.wiki/w/순종(대한제국)
392 27 순종 순종(대한제국) Y 대중매체 https://namu.wiki/w/순종(대한제국)
393 27 순종 순종(대한제국) 관련 문서 https://namu.wiki/w/순종(대한제국)
394 27 순종 순종(대한제국) 둘러보기 https://namu.wiki/w/순종(대한제국)

View File

@ -0,0 +1,278 @@
# -*- coding: utf-8 -*-
"""
animation 생성 단계 (나레이션 + 스토리보드) 병합본
==================================================================
조선왕 '' CSV 병맛 마케팅 나레이션 장면 분할 스토리보드 파일에 모음.
(이전 gen_narration.py + gen_storyboard.py main.py 모듈로 쓰도록 합침.)
scraper.py 만든 joseon_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("joseon_keywords_*.csv"))
if not files:
sys.exit("[!] animation/data/ 에 joseon_keywords_*.csv 가 없음. 먼저 'python scraper.py' 실행.")
return files[-1]
def load_rows(csv_path: Path):
"""CSV → [{order, king, doc, is_anecdote, keyword, url}, ...]"""
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 king sweating while supervising palace construction)
절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트' 그리도록 요구하지 마라.
(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 Joseon King Gwanghaegun, wearing red royal gonryongpo robe and black ikseongwan hat, round face)
그림체: 눈을 떴을 때는 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

View File

@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
키워드 병맛 한국사 마케팅 쇼츠 (완전 자동, 원커맨드)
==========================================================
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
1) 랜덤으로 조선왕 '' 매칭
2) Gemini 병맛 마케팅 나레이션 생성
3) 장면 분할(스토리보드)
4) 장면별 이미지 + 음성 생성
5) Ken Burns + 자막( ) + 음성 합성 세로 mp4
6) 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)
모두 output/<작업폴더>/ 안에만 저장. 완성 중간 파일(assets) 자동 삭제.
선행: namu_joseon_scraper.py 만든 output/joseon_keywords_*.csv 있어야 .
API : GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
실행 (Shorts 폴더 안에서, 키워드는 자유롭게):
python auto_short.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1 12만원
python auto_short.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
python auto_short.py 배달앱 왕의밥상 --label 왕의밥상
옵션:
--label 왕의휴식 결과 폴더 이름에 라벨 (기본: 키워드 앞부분 자동)
--king 광해군 특정 고정 (기본: 랜덤)
--scenes 12 장면 =이미지 (기본 12)
--seconds 45 목표 길이 (기본 45)
--voice Puck TTS 목소리
--keep 중간 파일(assets) 삭제하지 않고 보존
"""
import argparse
import json
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
from google import genai
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 공유 모듈(naver.py, media.py)
import gen # 나레이션 + 스토리보드 생성(병합: 이전 gen_narration + gen_storyboard)
import render as MS # animation 영상합성 엔진(이전 make_short.py)
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
HERE = Path(__file__).parent
OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/
TEXT_MODEL = "gemini-2.5-flash"
def safe_name(s: str) -> str:
s = re.sub(r"[^\w가-힣]+", "_", s).strip("_")
return s[:30] or "short"
def main():
ap = argparse.ArgumentParser(
description="키워드 또는 네이버 링크 → 병맛 한국사 쇼츠 (완전 자동)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
)
# 마케팅 대상: 자유 키워드(여러 단어/문장) 또는 네이버 링크(섞어 써도 됨)
ap.add_argument("keywords", nargs="*",
help="마케팅 대상 키워드/문장, 또는 네이버 링크 (--link 없이 링크만 줘도 됨)")
# 결과 폴더 라벨
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
# 생성 옵션
ap.add_argument("--king", help="특정 왕 고정 (기본: 랜덤)")
ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
ap.add_argument("--keep", action="store_true", help="중간 파일(assets) 보존")
ap.add_argument("--csv", help="썰 CSV 경로 (기본: output/ 최신)")
# 실제 사진(가게/메뉴판) 섞기 + 마지막 링크 카드
ap.add_argument("--images", default="load_image",
help="실제 사진 폴더. 가게 이름만 줘도 load_image/<가게>/ 를 찾음 "
"(예: --images 연산골막국수). 비었으면 전부 AI")
ap.add_argument("--max-real", type=int, default=0,
help="실제 사진으로 채울 최대 장면 수 (기본 0=폴더 사진 전부, 장면 수 한도 내)")
ap.add_argument("--real-scenes",
help="실제 사진 깔 장면 번호 1-based, 콤마구분 (예: 5,6,7). 주면 내용매칭 대신 수동")
ap.add_argument("--no-match", action="store_true",
help="실제 사진 내용 자동매칭(Gemini 비전) 끄고 위치 기반(뒤쪽 장면)으로")
ap.add_argument("--photos", type=int, default=4,
help="네이버 사진탭에서 자동 선별할 공식사진 수 (기본 4, --link 줄 때)")
ap.add_argument("--no-photos", action="store_true",
help="네이버 사진 자동 수집 끄기 (전부 AI 그림으로)")
ap.add_argument("--link", help="맨 마지막 화면에 띄울 링크/문구 (예: naver.me/xxxx)")
ap.add_argument("--link-seconds", type=float, default=3.0, help="링크 카드 길이(초)")
ap.add_argument("--store", help="링크 카드에 함께 띄울 가게 이름 (기본: 키워드 첫 부분 자동)")
ap.add_argument("--cta", default=MS.LINK_CTA, help=f"링크 카드 안내문구 (기본: '{MS.LINK_CTA}')")
ap.add_argument("--img-model", default=MS.IMG_MODEL,
help=f"이미지 생성 모델 (기본 {MS.IMG_MODEL}). 글자 또렷하게: gemini-3.1-flash-image / gemini-3-pro-image")
ap.add_argument("--allow-text", action="store_true",
help="그림 안 글자 금지 해제 (신형 모델로 간판에 진짜 이름 넣고 싶을 때)")
# 컷편집 느낌 (말 빠르게 + 컷 사이 침묵 제거 → 분량 ↓)
ap.add_argument("--speed", type=float, default=1.3,
help="말 속도 배율 (기본 1.3=30%% 빠르게, 음정 유지). 1.0=원본")
ap.add_argument("--no-trim", action="store_true",
help="컷 앞뒤 침묵 제거 끄기 (기본은 제거해 컷을 타이트하게)")
ap.add_argument("--tail", type=float, default=0.0,
help="각 컷 말 끝난 뒤 여운(초). 기본 0=말 끝나자마자 다음 컷. 여유 주려면 0.1~0.2")
ap.add_argument("--workers", type=int, default=4,
help="이미지·음성 동시 생성 수 (기본 4). 높이면 빠르지만 API 속도제한 주의")
ap.add_argument("--bgm",
help="배경음악(병맛 BGM) 파일 또는 폴더. 폴더면 무작위 1곡. "
"기본: bgm/ 폴더에 곡이 있으면 자동으로 무작위 사용. "
"특정 곡 고정하려면 --bgm 파일명. BGM 끄려면 --bgm none")
ap.add_argument("--bgm-volume", type=float, default=0.3,
help="BGM 볼륨 0~1 (기본 0.3). 나레이션 안 묻히게 조절")
ap.add_argument("--api-key")
args = ap.parse_args()
# --link 안 써도 됨: 위치 인자에 네이버 링크가 섞여 있으면 그걸 링크로 빼낸다
# (narration/kakao 처럼 `python main.py https://naver.me/xxxx` 만으로 실행 가능)
if not args.link:
link_tok = next((k for k in args.keywords if NV.looks_like_naver_place(k)), None)
if link_tok:
args.link = link_tok
args.keywords = [k for k in args.keywords if k != link_tok]
product = " ".join(args.keywords).strip()
# 키워드를 안 줬는데 --link 가 네이버 플레이스면 → AI 브리핑을 긁어와 마케팅 키워드로 사용
brief_store = None
if not product and NV.looks_like_naver_place(args.link or ""):
print("■ 키워드 없음 → 네이버 AI 브리핑 자동 수집...")
try:
brief_store, bullets, _cat = NV.fetch_briefing(args.link) # 업종(cat)은 animation 미사용
except Exception as e:
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
if not bullets:
sys.exit(f"[!] '{brief_store or '이 가게'}'에 AI 브리핑이 없어요. "
"키워드를 직접 주거나, 브리핑 있는 가게 링크를 쓰세요.")
product = " ".join(bullets)
print(f"■ 가게: {brief_store or '?'} / 브리핑 {len(bullets)}줄 → 키워드로 사용")
for b in bullets:
print(f"{b}")
if not product:
sys.exit("[!] 마케팅 키워드가 필요해. 예: python auto_short.py 감성 펜션 ... "
"또는 --link 에 네이버 가게 링크(AI 브리핑 자동 수집).")
api_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not api_key:
sys.exit("[!] API 키 없음. setx GEMINI_API_KEY \"\" 후 새 터미널, 또는 --api-key.")
csv_path = Path(args.csv) if args.csv else gen.latest_csv()
rows = gen.load_rows(csv_path)
client = genai.Client(api_key=api_key)
OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
# 네이버 링크 주고 사진 폴더를 직접 안 줬으면 → 사진탭 공식사진을 자동 수집·선별
if (not args.no_photos and NV.looks_like_naver_place(args.link or "")
and args.images == "load_image"):
store_for_dir = (brief_store or args.store or args.label
or " ".join(args.keywords[:2]) or "naver_store")
photo_dir = HERE / "load_image" / safe_name(store_for_dir)
existing = [p for p in photo_dir.glob("*.jpg")] if photo_dir.exists() else []
if existing:
print(f"■ 사진 캐시 재사용: {photo_dir} ({len(existing)}장)")
args.images = safe_name(store_for_dir)
else:
print(f"■ 네이버 사진탭 → 공식사진 {args.photos}장 자동 수집·선별...")
try:
_, finals = NV.fetch_and_select(args.link, photo_dir,
n=args.photos, client=client)
if finals:
args.images = safe_name(store_for_dir)
print(f"■ 사진 {len(finals)}장 준비 → {photo_dir}")
else:
print("■ 공식사진을 못 받았어요 → 전부 AI 그림으로 진행")
except Exception as e:
print(f"■ 사진 수집 실패({type(e).__name__}) → 전부 AI 그림으로 진행")
# 이 작업 전용 폴더 (모든 산출물은 여기 안에만)
label = args.label or " ".join(args.keywords[:3]) or brief_store or product[:20]
stem = f"{safe_name(label)}_{ts}"
job_dir = OUT_DIR / stem
job_dir.mkdir(parents=True, exist_ok=True)
# 1) 랜덤(또는 지정) 왕 썰 매칭
seed = gen.pick_seed(rows, king=args.king)
print(f"■ 작업 폴더 : {job_dir}")
print(f"■ 마케팅 대상 : {product}")
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
# 2) 병맛 나레이션
print("[1/4] 나레이션 생성...")
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds)
(job_dir / "narration.json").write_text(
json.dumps(narr, ensure_ascii=False, indent=2), encoding="utf-8")
# 사람이 바로 읽는 나레이션 '가사' 텍스트 (제목 + 본문 + 해시태그)
lyric = (f"[제목] {narr.get('title','')}\n"
f"[소재] {seed['king']} / \"{seed['keyword']}\"\n"
f"[마케팅] {product}\n"
f"{'-'*40}\n"
f"{narr.get('full_narration','')}\n")
if narr.get("hashtags"):
lyric += f"\n{' '.join(narr['hashtags'])}\n"
(job_dir / "narration.txt").write_text(lyric, encoding="utf-8")
print(f" \"{narr['title']}\" ({len(narr['full_narration'])}자)")
# 3) 스토리보드(장면 분할)
print("[2/4] 스토리보드 분할...")
sb = gen.make_storyboard(client, TEXT_MODEL, narr["full_narration"],
product, seed["king"], scenes=args.scenes)
(job_dir / "storyboard.json").write_text(
json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" 장면 {len(sb['scenes'])}")
# 4) 이미지 + 음성 + 영상 합성 (assets·mp4 모두 job_dir 안)
# 실제 사진(가게/메뉴판)을 내용 맞는 장면에 AI 배경과 합성, 마지막에 링크 카드 추가
real_images, real_scenes = MS.resolve_real_images(args.images, args.real_scenes)
if not real_images:
print(f"■ 실제 사진 : 없음 ('{args.images}' 폴더 비었거나 없음) → 전부 AI")
# --bgm 안 줘도 bgm/ 폴더에 곡이 있으면 자동으로 무작위 1곡 사용
bgm = MS.resolve_bgm(args.bgm or "bgm")
_off = str(args.bgm or "").strip().lower() in ("none", "off", "no")
if args.bgm and not _off and not bgm:
print(f"■ BGM '{args.bgm}' 를 못 찾음 → BGM 없이 진행")
elif bgm:
print(f"■ BGM : {bgm.name} (bgm/ 무작위)")
print("[3/4] 이미지·음성 생성 + [4/4] 영상 합성...")
store = args.store or brief_store or product.split(",")[0].strip() or None
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
real_images=real_images, real_scenes=real_scenes,
max_real=args.max_real, match_content=not args.no_match,
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
img_model=args.img_model, allow_text=args.allow_text,
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
shutil.rmtree(job_dir / "assets", ignore_errors=True)
for junk in ("narration.json", "storyboard.json"):
(job_dir / junk).unlink(missing_ok=True)
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
print(f"\n✅ 완성: {out_mp4}")
print(f" {seed['king']} 썰 · {product}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,874 @@
# -*- coding: utf-8 -*-
"""
스토리보드 완성 쇼츠 영상(mp4) 조립 [A방식: 정지 일러스트 + 모션]
=====================================================================
gen_storyboard.py 만든 스토리보드를 받아 장면별로:
1) Gemini 이미지로 일러스트 생성 (캐릭터 일관성 = 1 컷을 레퍼런스로 물림)
2) Gemini TTS 장면 나레이션 음성 생성 (장면 길이 = 음성 길이)
3) ffmpeg Ken Burns(zoompan ) + 자막(PNG 오버레이) + 음성 합성 세로 1080x1920 mp4
(Intel QSV 하드웨어 인코딩 자동 사용, 없으면 libx264 ultrafast)
이미지/음성은 output/ 장면별로 캐싱한다. 재실행 이미 있으면 재사용(재과금 X).
다시 그리고 싶으면 --force.
설치: pip install google-genai pillow imageio-ffmpeg (moviepy 불필요 ffmpeg 직접 렌더)
실행:
python make_short.py # 최신 *_storyboard.json
python make_short.py --storyboard output/xxx_storyboard.json --voice Puck
python make_short.py --force # 이미지/음성 새로 생성
"""
import json
import re
import shutil
import subprocess
import sys
import time
import wave
from pathlib import Path
from google.genai import types
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import media as MU
HERE = Path(__file__).parent
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/
W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함)
BRAND_TITLE = "썰록" # 상단 밴드 제목
IMG_MODEL = "gemini-2.5-flash-image"
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
TTS_VOICE = "Puck"
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
"능청스럽게 약간 오버해서 읽어줘:")
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf")
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
TITLE_FONT = str(SHARED / "fonts" / "SongMyung-Regular.ttf")
TITLE_SIZE = 58
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
# → 모든 이미지 생성에서 '글자 자체를 그리지 말라'고 강하게 지시. (가게 이름은 자막/카드로 표기)
NO_TEXT = (" CRITICAL: Render NO text of any kind in the image — no letters, words, numbers, "
"Korean hangul, logos, or signage text. Keep every sign, banner, board and menu "
"completely BLANK with no writing on them.")
# ---------------------------------------------------------------- 데이터
IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
def resolve_real_images(images_dir, real_scenes_arg):
"""--images 값 → 정렬된 실제 사진 경로 리스트, --real-scenes → 정수 리스트.
images_dir 해석 순서 (가게마다 load_image/<가게>/ 나눠 담는 지원):
1) 절대경로면 그대로
2) 상대경로/이름이면 HERE/<> (: load_image/연산골막국수)
3) 그래도 없으면 HERE/load_image/<> (: --images 연산골막국수)
가장 먼저 '이미지가 들어있는' 폴더를 채택. 없으면 리스트.
"""
real_images, used = [], None
if images_dir:
d = Path(images_dir)
candidates = [d] if d.is_absolute() else [HERE / d, HERE / "load_image" / d]
for c in candidates:
if c.exists() and c.is_dir():
imgs = sorted(p for p in c.iterdir() if p.suffix.lower() in IMG_EXTS)
if imgs:
real_images, used = imgs, c
break
if used:
print(f"■ 실제 사진 폴더: {used} ({len(real_images)}장)")
real_scenes = None
if real_scenes_arg:
real_scenes = [int(x) for x in re.split(r"[,\s]+", str(real_scenes_arg).strip()) if x]
return real_images, real_scenes
# 실제 사진 카드를 합성하는 장면에서, AI 배경이 카드 자리를 비워두도록 주는 포즈 힌트
PRESENT_POSES = [
"Place the cat king in the UPPER part of the frame, smiling and pointing down toward the "
"lower-center, leaving the lower-center area visually simple (a photo will be placed there).",
"Put the cat king on the LEFT side presenting with one paw to the right-center, keeping the "
"right-center area simple and uncluttered (a photo will be placed there).",
"Show the cat king at the BOTTOM looking up with sparkling eyes toward the upper-center, "
"keeping the upper-center area simple (a photo will be placed there).",
]
# PRESENT_POSES 순서에 맞춰 실제 사진 카드를 놓을 (가로,세로) 중심 비율
PRESENT_SPOTS = [(0.50, 0.62), (0.62, 0.50), (0.50, 0.40)]
# 고양이 왕의 '눈' 그림체를 귀엽게 유지하되, 눈을 '떴을 때'의 모양만 고정한다.
# (모든 장면을 동그란 뜬눈으로 강제하면 감김/실눈/윙크 같은 표정 다양성이 사라짐)
EYE_STYLE = (
"When the cat's eyes are open, they are the same cute friendly eyes: large round eyes "
"with big round solid black pupils and a small soft white highlight, gentle innocent "
"look. Keep this cute rounded eye style; never sharp, angry, fierce, glaring or "
"realistic predatory eyes."
)
# 눈을 감거나(감김/실눈/윙크/졸림/웃음·울음으로 눈이 변하는) 장면용: 표정은 장면대로 두되 화풍만 유지.
EYE_FREE = (
"In this scene the cat's eye EXPRESSION follows the moment — the eyes may be gently "
"closed, squinting, blinking, winking, teary or sleepy half-closed as appropriate. "
"Keep the same cute, soft, rounded drawing style (never sharp, angry or realistic), "
"but do NOT force wide-open round eyes here."
)
# image_prompt(영어)에 아래 표현이 있으면 '눈 표정 장면'으로 보고 뜬눈 고정/레퍼런스를 푼다.
_EYE_EXPR_KW = (
"closed eye", "eyes closed", "close its eye", "close his eye", "shut eye",
"shut its eye", "squint", "narrowed eye", "wink", "half-closed", "half closed",
"sleepy", "sleeping", "asleep", "dozing", "doze", "napping", "yawn", "drowsy",
"crying", "sob", "weeping", "tears", "teary", "laughing", "giggl", "chuckl",
"wince", "grimac", "blink",
)
def scene_needs_eye_expression(sc) -> bool:
"""장면이 눈 감김/실눈/윙크 등 '뜬눈 아닌' 표정을 필요로 하면 True."""
text = ((sc.get("image_prompt") or "") + " " + (sc.get("caption") or "")).lower()
return any(k in text for k in _EYE_EXPR_KW)
# 캐릭터 공식 레퍼런스: 이 폴더에 이미지를 넣어두면(예: king_ref.png),
# 모든 영상의 모든 컷이 그 고양이 왕의 얼굴/눈/복장/화풍을 그대로 따라간다.
CHAR_REF_DIR = HERE / "character"
def load_char_ref():
"""Shorts/character/ 안의 첫 이미지 바이트 반환(없으면 None). 모든 영상의 캐릭터 앵커로 쓰인다."""
if not CHAR_REF_DIR.is_dir():
return None
for p in sorted(CHAR_REF_DIR.iterdir()):
if p.suffix.lower() in IMG_EXTS:
return p.read_bytes()
return None
# ---------------------------------------------------------------- 이미지
def gen_image(client, prompt, ref_bytes=None, model=None, no_text=True,
ref_mode="full") -> bytes:
"""Gemini 이미지 생성. ref_bytes 주면 캐릭터 일관성 위해 레퍼런스로 사용.
model: 사용할 이미지 모델 (없으면 IMG_MODEL). 신형일수록 글자 렌더링 우수.
no_text: True '그림에 글자 금지' 지시를 붙인다(글자 깨짐 방지).
ref_mode: 'full' = 레퍼런스의 캐릭터/복장/화풍을 통째로 유지.
'eyes' = 레퍼런스에선 '눈/표정만' 복제하고 복장·장면은 프롬프트대로.
"""
parts = []
if ref_bytes:
parts.append(types.Part.from_bytes(data=ref_bytes, mime_type="image/png"))
if ref_mode == "eyes":
prompt = ("Use the reference image ONLY to copy the cat character's EYES and "
"facial expression exactly: the same large round eyes with big round "
"solid black pupils and a small soft white highlight, gentle innocent "
"look. Do NOT copy the outfit, hat, robe color, pose, framing or "
"background from the reference — those follow the scene description. "
"New scene: " + prompt)
else:
prompt = ("Keep the EXACT same character design, outfit and art style as the "
"reference image. New scene: " + prompt)
parts.append(types.Part.from_text(text=prompt + (NO_TEXT if no_text else "")))
resp = client.models.generate_content(
model=model or IMG_MODEL,
contents=parts,
config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
)
for p in resp.candidates[0].content.parts:
if getattr(p, "inline_data", None):
return p.inline_data.data
raise RuntimeError("이미지 파트 없음")
def composite_card(base_path, photo_path, out_path,
rel_w=0.60, center=(0.5, 0.60), angle=4.0, border=22):
"""AI 배경(base) 위에 '실제 사진'을 작은 폴라로이드 카드로 얹어 합성 저장.
사진은 원본 그대로(왜곡 X) 축소만 하고, 테두리+그림자+살짝 기울기로 자연스럽게 올린다.
rel_w: 카드 사진 가로 = 화면폭 * rel_w. center: 카드 중심 (가로,세로) 비율. angle: 기울기().
"""
from PIL import Image, ImageFilter
base = Image.open(base_path).convert("RGBA")
photo = Image.open(photo_path).convert("RGB")
cw = int(W * rel_w)
ph = int(photo.height * (cw / photo.width))
max_h = int(H * 0.40) # 너무 길면 높이 기준으로 제한
if ph > max_h:
ph, cw = max_h, int(photo.width * (max_h / photo.height))
photo = photo.resize((cw, ph), Image.LANCZOS)
# 폴라로이드 흰 카드 (아래쪽 여백 좀 더)
card = Image.new("RGBA", (cw + 2 * border, ph + 2 * border + int(border * 1.4)),
(255, 255, 255, 255))
card.paste(photo, (border, border))
card = card.rotate(angle, expand=True, resample=Image.BICUBIC)
cx, cy = int(W * center[0]), int(H * center[1])
x, y = cx - card.width // 2, cy - card.height // 2
# 그림자
alpha = card.split()[3]
shadow = Image.new("RGBA", card.size, (0, 0, 0, 0))
shadow.paste(Image.new("RGBA", card.size, (0, 0, 0, 150)), (0, 0), alpha)
shadow = shadow.filter(ImageFilter.GaussianBlur(18))
base.alpha_composite(shadow, (x + 10, y + 16))
base.alpha_composite(card, (x, y))
base.convert("RGB").save(out_path)
def cover_crop(src: Path, dst: Path):
"""이미지를 1080x1920 꽉 차게 cover-crop 해서 저장."""
im = Image.open(src).convert("RGB")
scale = max(W / im.width, H / im.height)
nw, nh = int(im.width * scale + 0.5), int(im.height * scale + 0.5)
im = im.resize((nw, nh), Image.LANCZOS)
left, top = (nw - W) // 2, (nh - H) // 2
im.crop((left, top, left + W, top + H)).save(dst)
# ---------------------------------------------------------------- 음성
def _extract_audio(resp):
"""TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호)."""
cands = getattr(resp, "candidates", None) or []
for c in cands:
content = getattr(c, "content", None)
parts = getattr(content, "parts", None) or [] if content else []
for p in parts:
inline = getattr(p, "inline_data", None)
if inline and inline.data:
m = re.search(r"rate=(\d+)", inline.mime_type or "")
return inline.data, (int(m.group(1)) if m else 24000)
return None
def gen_tts(client, text, voice, retries=4) -> bytes:
"""Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다."""
last = ""
for attempt in range(1, retries + 1):
try:
resp = client.models.generate_content(
model=TTS_MODEL,
contents=f"{TTS_STYLE}\n\n{text}",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
)
),
),
)
got = _extract_audio(resp)
if got:
return got
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
fr = ""
try:
fr = str(getattr(resp.candidates[0], "finish_reason", "") or "")
except Exception:
fr = "no-candidates"
last = f"빈 응답(finish_reason={fr or '?'})"
except Exception as e:
last = f"{type(e).__name__}: {e}"
if attempt < retries:
wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프
print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
end="", flush=True)
time.sleep(wait)
raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}")
def pcm_to_wav(pcm, path, rate):
with wave.open(str(path), "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
w.writeframes(pcm)
def wav_seconds(path: Path) -> float:
with wave.open(str(path), "rb") as w:
return w.getnframes() / w.getframerate()
def _atempo_chain(speed: float) -> list:
"""atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝."""
chain, s = [], speed
while s > 2.0:
chain.append("atempo=2.0"); s /= 2.0
while s < 0.5:
chain.append("atempo=0.5"); s *= 2.0
chain.append(f"atempo={s:.4f}")
return chain
def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True,
threshold="-38dB", keep=0.06):
"""컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기.
- trim_silence: / 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지).
silenceremove 앞을 자르고, areverse 뒤집어 다시 (=원래 ) 자른 복원.
keep: 잘라낸 끝에 남길 여유(). 0 이면 너무 칼같이 잘려 어색할 있어 약간 남긴다.
- speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다.
가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백.
"""
af = []
if trim_silence:
one = (f"silenceremove=start_periods=1:start_silence={keep}:"
f"start_threshold={threshold}:detection=peak")
af += [one, "areverse", one, "areverse"]
if speed and abs(speed - 1.0) > 1e-3:
af += _atempo_chain(speed)
if not af:
shutil.copyfile(src_wav, dst_wav); return
try:
_run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af),
"-ar", "24000", "-ac", "1", str(dst_wav)])
if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백
raise RuntimeError("가공 후 음성이 너무 짧음")
except (RuntimeError, OSError):
shutil.copyfile(src_wav, dst_wav)
# ---------------------------------------------------------------- 조립 (ffmpeg 직접 렌더)
FPS = 30
def _ffmpeg() -> str:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def _run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode != 0:
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
return r
def pick_encoder(exe) -> list:
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
test = subprocess.run(
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
"-c:v", "h264_qsv", "-f", "null", "-"],
capture_output=True, text=True, errors="replace")
if test.returncode == 0:
print(" 인코더: h264_qsv (Intel 하드웨어)")
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
print(" 인코더: libx264 (ultrafast)")
return ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"]
def _wrap_lines(draw, text, font, max_w):
"""글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈."""
lines, cur = [], ""
for ch in text:
if ch == "\n":
lines.append(cur); cur = ""; continue
if draw.textlength(cur + ch, font=font) <= max_w:
cur += ch
else:
lines.append(cur); cur = ch
if cur:
lines.append(cur)
return lines or [""]
def _blossom(d, cx, cy, r, color):
"""간단한 5장 꽃잎 벚꽃 장식(제목 양옆 무늬)."""
import math
pr = r * 0.62 # 꽃잎 반지름
for k in range(5):
a = math.radians(-90 + k * 72)
px, py = cx + r * 0.55 * math.cos(a), cy + r * 0.55 * math.sin(a)
d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color)
d.ellipse([cx - r * 0.32, cy - r * 0.32, cx + r * 0.32, cy + r * 0.32],
fill=(255, 238, 175, 255)) # 꽃심
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
from PIL import ImageDraw, ImageFont
band_h = int(H * BAND_RATIO)
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
# ── 제목: 조선 느낌 폰트(TITLE_FONT) + 양옆 벚꽃 + 아래 구분선(끝까지·불투명) ──
title_y = int(H * 0.012)
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
cx = W / 2
tw = d.textlength(title, font=tf)
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma")
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙
gap = 44
rose = (228, 150, 168, 255)
_blossom(d, cx - tw / 2 - gap, by, 17, rose)
_blossom(d, cx + tw / 2 + gap, by, 17, rose)
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬.
top = rule_y + 22
avail = band_h - top - 24
f = ImageFont.truetype(font_path, NARR_SIZE)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(NARR_SIZE * 1.4)
y = top + max(0, (avail - len(lines) * lh) // 2)
for ln in lines:
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
y += lh
img.save(out_path)
LINK_CTA = "링크는 고정댓글 확인" # ▼ 등 특수문자는 자막 폰트(주아체)에 없어 □로 깨짐 → 한글만
def render_link_card_png(base_img_path, link, out_path, font_path, store=None, cta=None):
"""배경 사진을 어둡게 깔고 가게 이름 + 안내문구 + 링크를 큼직하게 그린 1080x1920 PNG."""
from PIL import ImageDraw, ImageFont
base = Image.open(base_img_path).convert("RGB")
base = Image.blend(base, Image.new("RGB", base.size, (0, 0, 0)), 0.55)
d = ImageDraw.Draw(base)
def centered(text, y, size, fill, fit_one_line=False):
if fit_one_line: # 한 줄에 들어갈 때까지 폰트 축소
while size > 40 and d.textlength(text, font=ImageFont.truetype(font_path, size)) > W - 140:
size -= 4
f = ImageFont.truetype(font_path, size)
for ln in _wrap_lines(d, text, f, W - 140):
w = d.textlength(ln, font=f)
d.text(((W - w) / 2, y), ln, font=f, fill=fill,
stroke_width=5, stroke_fill="black")
y += int(size * 1.25)
if store:
centered(store, int(H * 0.33), 88, "white", fit_one_line=True)
centered(cta or LINK_CTA, int(H * 0.43), 58, "#FFFFFF", fit_one_line=True)
centered(link, int(H * 0.51), 72, "#FFE94A", fit_one_line=True)
base.save(out_path)
# 모든 세그먼트를 같은 규격으로 만들어 concat copy 가 되게 한다.
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
# ---------------------------------------------------------------- BGM
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")
def resolve_bgm(bgm_arg):
"""--bgm 값 → 실제 음악 파일 경로(없으면 None).
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 지원):
1) 파일이면 그대로
2) 폴더면 음악 무작위 1 (병맛 BGM 여러 넣어두고 매번 다르게)
3) 이름만 주면 HERE/<> HERE/bgm/<> 공용 generator/<> generator/bgm/<> 탐색
"""
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
return None
import random
p = Path(bgm_arg)
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p, SHARED / p, SHARED / "bgm" / p]
for c in cands:
if c.is_file():
return c
if c.is_dir():
files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS]
if files:
return random.choice(files)
return None
def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0):
"""완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장.
- BGM 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림.
- 나레이션[0:a] + BGM[1:a]*volume amix(duration=first) 섞어 영상 길이에 맞춘다.
- 영상은 재인코딩하지 않는다(-c:v copy) 빠름. 시작에 짧은 페이드인.
"""
fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];"
f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]")
_run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path),
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
"-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart",
str(out_path)])
# Shared low-level media utilities. Keep local render.py for per-feature
# layout, prompts, local fonts, BGM folder resolution, and produce().
_ffmpeg = MU._ffmpeg
_run = MU._run
pick_encoder = MU.pick_encoder
pcm_to_wav = MU.pcm_to_wav
wav_seconds = MU.wav_seconds
process_audio = MU.process_audio
_wrap_lines = MU._wrap_lines
mix_bgm = MU.mix_bgm
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
bgm=None, bgm_volume=0.3):
"""장면별 ffmpeg 세그먼트(Ken Burns 줌 + 자막 오버레이 + 음성) → concat 으로 최종 mp4.
speed/trim_silence: 컷편집 느낌 음성을 빠르게 + 앞뒤 침묵 제거( 컷이 타이트).
tail: 음성 끝에 붙일 정적(). 0 이면 끝나자마자 바로 다음 .
bgm: 배경음악 파일 경로. 주면 concat BGM 작은 볼륨으로 깔아 믹스.
bgm_volume: BGM 볼륨(0~1). 기본 0.12 = 나레이션 묻히게 12%.
"""
exe = _ffmpeg()
enc = pick_encoder(exe)
font = FONT if Path(FONT).exists() else FONT_FALLBACK
out_path = Path(out_path)
tmp = out_path.parent / "_render"
tmp.mkdir(exist_ok=True)
segs = []
total = 0.0
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
proc_ap = tmp / f"aud{idx:02d}.wav"
process_audio(exe, ap, proc_ap, speed=speed, trim_silence=trim_silence)
speech = wav_seconds(proc_ap)
dur = speech + max(0.0, tail) # 말 끝나고 약간의 여운만
frames = max(1, round(dur * FPS))
sub_png = tmp / f"sub{idx:02d}.png"
# 화면 자막 = 그 장면에서 '말하는 문장'(나레이션) 그대로 → 무음 시청자도 내용 전달
render_band_png(sc.get("narration") or sc.get("caption", ""), sub_png, font)
seg = tmp / f"seg{idx:02d}.mp4"
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
band_h = int(H * BAND_RATIO)
img_h = H - band_h
bw, bh = int(W * 1.2), int(img_h * 1.2) # 줌 여유용 1.2x
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase,"
f"crop={bw}:{bh},"
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:"
f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"
f"[bg][img]overlay=0:{band_h}[base];"
f"[base][1:v]overlay=0:0[v]")
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
_run([exe, "-y", "-i", str(ip), "-i", str(sub_png), "-i", str(proc_ap),
"-filter_complex", fc, "-map", "[v]", "-map", "2:a",
"-af", "apad", "-t", f"{dur:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
"-pix_fmt", "yuv420p", str(seg)])
segs.append(seg)
total += dur
print(f"[{idx:2d}] 세그먼트 (말 {speech:.1f}초 → 컷 {dur:.1f}초)")
print(f" ▶ 본편 합계 {total:.1f}" + (f" + 링크 {link_seconds:.1f}" if link else ""))
# 링크 카드 (무음)
if link and img_paths:
card_png = tmp / "linkcard.png"
render_link_card_png(img_paths[-1], link, card_png, font, store=store, cta=cta)
seg = tmp / "seg_link.mp4"
_run([exe, "-y", "-loop", "1", "-i", str(card_png),
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
"-t", f"{link_seconds:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
"-pix_fmt", "yuv420p", str(seg)])
segs.append(seg)
print(f"[링크] 카드 세그먼트 ({link_seconds:.1f}초)")
# concat (재인코딩 없이 복사 → 즉시). 실패하면 재인코딩으로 폴백.
# BGM 이 있으면 일단 임시 본편으로 이어붙이고, 그 위에 BGM 을 믹스해 최종본을 만든다.
listf = tmp / "concat.txt"
# concat 목록은 절대경로로 (ffmpeg 가 목록파일 위치 기준으로 상대경로를 또 해석하는 문제 방지)
listf.write_text("".join(f"file '{s.resolve().as_posix()}'\n" for s in segs), encoding="utf-8")
concat_out = (tmp / "_body.mp4") if bgm else out_path
try:
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
"-c", "copy", "-movflags", "+faststart", str(concat_out)])
except RuntimeError:
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
*enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", "-movflags", "+faststart",
str(concat_out)])
if bgm:
mix_bgm(exe, concat_out, bgm, out_path, volume=bgm_volume)
print(f" ♪ BGM 믹스: {Path(bgm).name} (볼륨 {bgm_volume})")
shutil.rmtree(tmp, ignore_errors=True)
def _mime_of(path) -> str:
return "image/jpeg" if Path(path).suffix.lower() in (".jpg", ".jpeg") else "image/png"
def analyze_photo(client, photo_path) -> str:
"""실제 사진 1장을 Gemini 비전으로 보고 짧은 한국어 라벨 반환 (예: '수육 한 접시')."""
data = Path(photo_path).read_bytes()
resp = client.models.generate_content(
model=VISION_MODEL,
contents=[
types.Part.from_bytes(data=data, mime_type=_mime_of(photo_path)),
types.Part.from_text(text=(
"이 사진의 핵심 대상을 한국어 명사구 12자 이내로만 답해. "
"음식이면 메뉴명(예: '수육', '메밀국수'), 가게면 '가게 외관'/'매장 내부', "
"메뉴판이면 '메뉴판'. 설명·문장 금지, 라벨만.")),
],
)
return (resp.text or "").strip().splitlines()[0][:20] if resp.text else ""
_MATCH_SCHEMA = {
"type": "object",
"properties": {
"assignments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"photo_index": {"type": "integer"},
"scene_number": {"type": "integer"},
},
"required": ["photo_index", "scene_number"],
},
}
},
"required": ["assignments"],
}
def match_real_scenes(client, scenes, real_images, max_real=3) -> dict:
"""각 실제 사진을 비전 분석 → 내용이 가장 맞는 장면에 배치한 {장면번호: 사진경로} 반환.
실패하면 위치 기반(plan_real_scenes)으로 폴백한다.
"""
real_images = list(real_images or [])
if not real_images:
return {}
# 1) 사진별 내용 라벨
labels = []
for p in real_images:
try:
lab = analyze_photo(client, p)
except Exception:
lab = ""
labels.append(lab)
print(f" · {Path(p).name}'{lab or '?'}'")
# 2) 라벨 ↔ 장면 매칭 (구조화 출력 1회)
scene_lines = "\n".join(
f"{i+1}. [{s.get('caption','')}] {s.get('narration','')}"
for i, s in enumerate(scenes))
photo_lines = "\n".join(f"{i}. {labels[i] or '내용불명'}" for i in range(len(real_images)))
prompt = (
"아래는 영상의 '장면 목록'과, 영상에 넣을 '실제 사진'들의 내용 라벨이다.\n"
"각 사진을 그 내용이 가장 잘 어울리는 장면에 배치하라.\n"
"[규칙]\n"
"- 한 장면엔 사진 하나, 한 사진은 한 장면에만.\n"
"- 그 음식/메뉴/가게를 직접 언급·소개하는 장면에 우선 배치.\n"
"- 비슷하면 마케팅/소개가 나오는 뒤쪽 장면을 선호.\n"
f"- 최대 {max_real}장까지만 배치(나머지는 제외).\n\n"
f"[장면 목록]\n{scene_lines}\n\n[사진 라벨]\n{photo_lines}\n")
try:
resp = client.models.generate_content(
model=VISION_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
temperature=0.2,
response_mime_type="application/json",
response_schema=_MATCH_SCHEMA,
),
)
assigns = json.loads(resp.text).get("assignments", [])
except Exception as e:
print(f" (매칭 실패 → 위치 기반으로 폴백: {type(e).__name__})")
return plan_real_scenes(scenes, real_images, None, max_real)
real_map, used_photo = {}, set()
for a in assigns:
pi, sn = a.get("photo_index"), a.get("scene_number")
if pi is None or sn is None:
continue
if 0 <= pi < len(real_images) and 1 <= sn <= len(scenes) \
and pi not in used_photo and sn not in real_map:
real_map[sn] = Path(real_images[pi])
used_photo.add(pi)
if len(real_map) >= max_real:
break
return real_map or plan_real_scenes(scenes, real_images, None, max_real)
def plan_real_scenes(scenes, real_images, real_scenes=None, max_real=3) -> dict:
"""어느 장면 번호(1-based)에 어떤 실제 사진을 깔지 매핑 dict 로 반환.
- real_scenes 주어지면 장면 번호들에 사진을 순서대로 매핑.
- 없으면 '뒤쪽(마케팅 전환부) 장면들' 사진 개수만큼(최대 max_real) 자동 배치.
"""
real_images = list(real_images or [])
real_map = {}
if not real_images:
return real_map
if real_scenes:
for no, img in zip(real_scenes, real_images):
if 1 <= no <= len(scenes):
real_map[no] = Path(img)
else:
n = min(len(real_images), max_real, len(scenes))
start = len(scenes) - n + 1 # 뒤에서 n개
for k in range(n):
real_map[start + k] = Path(real_images[k])
return real_map
def produce(client, sb, stem, workdir=None, voice=TTS_VOICE, force=False,
real_images=None, real_scenes=None, max_real=3, match_content=True,
link=None, link_seconds=3.0, store=None, cta=None,
img_model=None, allow_text=False,
speed=1.0, trim_silence=True, tail=0.15, workers=4,
bgm=None, bgm_volume=0.3) -> Path:
"""스토리보드 dict → 장면별 이미지/음성 생성 후 mp4 합성. 완성 경로 반환.
workdir: 작업의 결과/중간파일을 담을 폴더. 이미지·음성은 workdir/assets/ ,
완성 mp4 workdir/{stem}.mp4 저장한다. (기본: output/{stem})
real_images: 실제 사진(가게/메뉴판 ) 경로 리스트. 뒤쪽 장면에 깔아 AI 컷과 섞는다.
real_scenes: 실제 사진을 장면 번호(1-based) 리스트. 없으면 뒤쪽 자동.
max_real: 자동 배치 실제 사진으로 채울 최대 장면 .
link: 주면 마지막에 링크 카드 1장을 붙인다.
"""
scenes = sb["scenes"]
style = sb.get("style_guide", "")
char = sb.get("character_sheet", "")
workdir = Path(workdir) if workdir else (OUT_DIR / stem)
workdir.mkdir(parents=True, exist_ok=True)
assets = workdir / "assets"
assets.mkdir(exist_ok=True)
# 실제 사진 → 장면 배치 결정
# · real_scenes 수동지정이 최우선
# · 아니면 내용 매칭(비전). 단, 새로 생성할 장면이 있을 때만(전부 캐시면 비전 호출 낭비라 생략)
real_images = list(real_images or [])
# max_real 이 None/0/음수면 '폴더 사진 전부 사용'(장면 수 한도 내)
eff_max = max_real if (max_real and max_real > 0) else len(real_images)
need_gen = force or any(not (assets / f"scene{i:02d}.png").exists()
for i in range(1, len(scenes) + 1))
if not real_images:
real_map = {}
elif real_scenes:
real_map = plan_real_scenes(scenes, real_images, real_scenes, eff_max)
elif match_content and client is not None and need_gen:
print(f"■ 실제 사진 내용 분석(Gemini 비전) → 장면 매칭 (최대 {eff_max}장)")
real_map = match_real_scenes(client, scenes, real_images, eff_max)
else:
real_map = plan_real_scenes(scenes, real_images, None, eff_max)
real_order = {no: k for k, no in enumerate(sorted(real_map))} # 포즈/위치 번갈아 쓰기용
if real_map:
picks = ", ".join(f"{no}{real_map[no].name}" for no in sorted(real_map))
print(f"■ 실제 사진 합성 장면: {picks}")
from concurrent.futures import ThreadPoolExecutor, as_completed
img_paths = [assets / f"scene{i:02d}.png" for i in range(1, len(scenes) + 1)]
audio_paths = [assets / f"scene{i:02d}.wav" for i in range(1, len(scenes) + 1)]
def make_image(i, ref, ref_mode="full"):
"""장면 i 이미지 1장 생성(필요시 실사진 합성). ref=캐릭터 앵커 바이트."""
sc = scenes[i - 1]
raw_png = assets / f"scene{i:02d}_raw.png"
img_png = assets / f"scene{i:02d}.png"
# 눈 표정 장면(감김/실눈/윙크/졸림/웃음·울음 등)은 뜬눈 고정을 풀고 표정대로 그린다.
# · '눈만 고정(eyes)' 레퍼런스도 이런 장면에선 빼야 눈이 안 떠짐.
free_eyes = scene_needs_eye_expression(sc)
eye = EYE_FREE if free_eyes else EYE_STYLE
use_ref = None if (free_eyes and ref_mode == "eyes") else ref
if i in real_map:
k = real_order[i]
pose = PRESENT_POSES[k % len(PRESENT_POSES)]
spot = PRESENT_SPOTS[k % len(PRESENT_SPOTS)]
prompt = (f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. {pose} "
f"Vertical 9:16 composition.")
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
no_text=not allow_text, ref_mode=ref_mode)
raw_png.write_bytes(data)
cover_crop(raw_png, img_png) # 1) AI 배경
composite_card(img_png, real_map[i], img_png, center=spot) # 2) 실사진 카드
else:
prompt = f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. Vertical 9:16 composition."
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
no_text=not allow_text, ref_mode=ref_mode)
raw_png.write_bytes(data)
cover_crop(raw_png, img_png)
# --- 이미지: 캐릭터 앵커(1컷) 먼저, 나머지는 병렬 ---
# 앵커 의존성: 모든 컷이 1번 컷(캐릭터)을 ref 로 참조해야 일관성이 유지된다.
# → 앵커 1장만 순차로 만든 뒤, 나머지는 동시에 생성(서로 독립).
need_img = [i for i in range(1, len(scenes) + 1)
if force or not img_paths[i - 1].exists()]
cached_img = [i for i in range(1, len(scenes) + 1) if i not in need_img]
for i in cached_img:
print(f"[{i:2d}] 이미지 캐시 사용")
# 공식 캐릭터 레퍼런스가 있으면 '눈만 고정' 모드: 모든 컷이 레퍼런스에서 눈/표정만 복제.
# 곤룡포·관모 등 복장은 복제하지 않고 왕별 character_sheet/장면 프롬프트대로 그려진다.
char_ref = load_char_ref()
if char_ref is not None:
print("■ 캐릭터 레퍼런스(눈만 고정) 사용: Shorts/character/ "
"→ 눈/표정은 통일, 복장은 왕별로 다르게")
ref_bytes, ref_mode, anchor = char_ref, "eyes", None
else:
ref_mode = "full"
ref_bytes = None
for i in range(1, len(scenes) + 1): # 캐시된 raw 가 있으면 앵커로 재사용
raw = assets / f"scene{i:02d}_raw.png"
if raw.exists() and not force:
ref_bytes = raw.read_bytes(); break
anchor = None
if ref_bytes is None:
anchor = next((i for i in need_img if i not in real_map), None)
if anchor is not None:
print(f"[{anchor:2d}] 캐릭터 앵커 이미지 생성...", flush=True)
make_image(anchor, None)
ref_bytes = (assets / f"scene{anchor:02d}_raw.png").read_bytes()
rest = [i for i in need_img if i != anchor]
if rest:
print(f"■ 이미지 {len(rest)}장 병렬 생성 (워커 {workers})...", flush=True)
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(make_image, i, ref_bytes, ref_mode): i for i in rest}
for fut in as_completed(futs):
i = futs[fut]; fut.result()
tag = f"실사합성({real_map[i].name})" if i in real_map else "이미지"
print(f"[{i:2d}] {tag} done", flush=True)
# --- 음성: 전부 독립 → 병렬 ---
def make_tts(i):
text = (scenes[i - 1].get("narration") or "").strip()
if not text:
# 빈 나레이션 방어: TTS 가 빈 텍스트에 빈 응답을 반복하다 죽으므로 0.3초 무음으로 대체.
# (정상 경로에선 gen_storyboard 가 빈 장면을 미리 거르지만, 만약을 대비한 이중 안전장치)
pcm_to_wav(b"\x00\x00" * int(24000 * 0.3), audio_paths[i - 1], 24000)
return
pcm, rate = gen_tts(client, text, voice)
pcm_to_wav(pcm, audio_paths[i - 1], rate)
need_wav = [i for i in range(1, len(scenes) + 1)
if force or not audio_paths[i - 1].exists()]
for i in (i for i in range(1, len(scenes) + 1) if i not in need_wav):
print(f" [{i:2d}] 음성 캐시 사용 ({wav_seconds(audio_paths[i - 1]):.1f}초)")
if need_wav:
print(f"■ 음성 {len(need_wav)}개 병렬 생성 (워커 {workers})...", flush=True)
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(make_tts, i): i for i in need_wav}
for fut in as_completed(futs):
i = futs[fut]; fut.result()
print(f" [{i:2d}] 음성 {wav_seconds(audio_paths[i - 1]):.1f}", flush=True)
out = workdir / f"{stem}.mp4"
print(f"\n영상 합성 → {out.name}")
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
bgm=bgm, bgm_volume=bgm_volume)
print(f"완성: {out}")
return out

View File

@ -0,0 +1,270 @@
# -*- coding: utf-8 -*-
"""
나무위키 조선 '썰 키워드' 스크래퍼
====================================
조선 27 왕의 나무위키 문서에서 목차(섹션 제목) 긁어와
숏폼 소재가 만한 '썰/일화 키워드' 추출해 JSON / CSV 저장한다.
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
Playwright(Chromium) 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
설치:
pip install playwright beautifulsoup4
playwright install chromium # (이미 설치돼 있으면 생략)
실행:
python namu_joseon_scraper.py
옵션:
python namu_joseon_scraper.py --headful # 브라우저 창 보이게
python namu_joseon_scraper.py --only 세종 광해군 # 특정 왕만
"""
import argparse
import asyncio
import csv
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from playwright.async_api import async_playwright
# ----------------------------------------------------------------------------
# 설정
# ----------------------------------------------------------------------------
BASE = "https://namu.wiki/w/"
OUT_DIR = Path(__file__).parent / "data" # 조선왕 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
# 조선 27대 왕: (대수, 묘호/군호, 나무위키 문서 제목)
# 군주는 보통 "묘호(조선)" 형태, 폐위된 임금(연산군/광해군)은 군호 단독.
JOSEON_KINGS = [
(1, "태조", "태조(조선)"),
(2, "정종", "정종(조선)"),
(3, "태종", "태종(조선)"),
(4, "세종", "세종(조선)"),
(5, "문종", "문종(조선)"),
(6, "단종", "단종(조선)"),
(7, "세조", "세조(조선)"),
(8, "예종", "예종(조선)"),
(9, "성종", "성종(조선)"),
(10, "연산군", "연산군"),
(11, "중종", "중종(조선)"),
(12, "인종", "인종(조선)"),
(13, "명종", "명종(조선)"),
(14, "선조", "선조(조선)"),
(15, "광해군", "광해군"),
(16, "인조", "인조(조선)"),
(17, "효종", "효종(조선)"),
(18, "현종", "현종(조선)"),
(19, "숙종", "숙종(조선)"),
(20, "경종", "경종(조선)"),
(21, "영조", "영조(조선)"),
(22, "정조", "정조(조선)"),
(23, "순조", "순조"),
(24, "헌종", "헌종(조선)"),
(25, "철종", "철종(조선)"),
(26, "고종", "고종(대한제국)"),
(27, "순종", "순종(대한제국)"),
]
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
ANECDOTE_HINTS = [
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
"독살", "암살", "치세", "업적", "가족", "후궁", "이름", "별명",
]
def clean_heading(text: str) -> str:
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
t = text.replace("[편집]", "").strip()
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
t = re.sub(r"^\s*[\d.]+\s*", "", t)
return t.strip()
def is_anecdote(keyword: str) -> bool:
return any(h in keyword for h in ANECDOTE_HINTS)
async def scrape_king(page, title: str):
"""한 왕 문서를 열고 목차 키워드 목록을 반환."""
url = BASE + title
result = {"title": title, "url": url, "page_title": None,
"keywords": [], "anecdote_keywords": [], "error": None}
try:
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
try:
await page.wait_for_selector(
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
timeout=8000,
)
except Exception:
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
await page.wait_for_timeout(800)
# 문서 실제 제목
try:
result["page_title"] = (await page.title()).split(" - ")[0].strip()
except Exception:
pass
if resp and resp.status >= 400:
result["error"] = f"HTTP {resp.status}"
return result
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
keywords = await page.evaluate(
"""
() => {
const clean = s => s.replace('[편집]', '').trim();
let items = [];
// 1순위: 목차(table of contents)
const tocLinks = document.querySelectorAll(
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
);
if (tocLinks.length) {
items = Array.from(tocLinks).map(a => clean(a.textContent));
}
// 2순위(목차가 없으면): 본문 헤딩 태그
if (items.length === 0) {
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
items = Array.from(hs).map(h => clean(h.textContent));
}
return items.filter(Boolean);
}
"""
)
# 중복 제거 + 번호 정리
seen, cleaned = set(), []
for raw in keywords:
kw = clean_heading(raw)
if kw and kw not in seen and len(kw) <= 60:
seen.add(kw)
cleaned.append(kw)
result["keywords"] = cleaned
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
except Exception as e:
result["error"] = f"{type(e).__name__}: {e}"
return result
async def main():
parser = argparse.ArgumentParser()
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
parser.add_argument("--only", nargs="*", help="특정 왕만 (묘호/군호로 지정)")
parser.add_argument("--concurrency", type=int, default=4,
help="동시에 열 페이지 수 (기본 4)")
args = parser.parse_args()
kings = JOSEON_KINGS
if args.only:
wanted = set(args.only)
kings = [k for k in JOSEON_KINGS if k[1] in wanted]
if not kings:
print(f"[!] 매칭되는 왕 없음: {args.only}")
print(" 가능한 값:", ", ".join(k[1] for k in JOSEON_KINGS))
return
OUT_DIR.mkdir(parents=True, exist_ok=True)
all_results = []
total = len(kings)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=not args.headful)
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
locale="ko-KR",
)
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
async def _block(route):
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
await route.abort()
else:
await route.continue_()
await context.route("**/*", _block)
sem = asyncio.Semaphore(max(1, args.concurrency))
done = 0
async def worker(idx, name, title):
nonlocal done
async with sem:
page = await context.new_page()
try:
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
# 실패하면 백오프하며 최대 3회까지 재시도
r = await scrape_king(page, title)
for attempt in range(3):
if not r["error"]:
break
await page.wait_for_timeout(1500 * (attempt + 1))
r = await scrape_king(page, title)
finally:
await page.close()
r["order"] = idx
r["name"] = name
done += 1
if r["error"]:
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
else:
print(f"[{done:2d}/{total}] {name} ({title}) "
f"키워드 {len(r['keywords'])}"
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
return r
all_results = await asyncio.gather(
*(worker(idx, name, title) for idx, name, title in kings)
)
all_results = sorted(all_results, key=lambda r: r["order"])
await browser.close()
# 저장 ---------------------------------------------------------------
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
json_path = OUT_DIR / f"joseon_keywords_{ts}.json"
json_path.write_text(
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
)
csv_path = OUT_DIR / f"joseon_keywords_{ts}.csv"
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["대수", "", "문서제목", "썰후보여부", "키워드", "URL"])
for r in all_results:
anec = set(r["anecdote_keywords"])
for kw in r["keywords"]:
w.writerow([r["order"], r["name"], r["title"],
"Y" if kw in anec else "", kw, r["url"]])
total_kw = sum(len(r["keywords"]) for r in all_results)
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
fails = [r["name"] for r in all_results if r["error"]]
print("\n" + "=" * 50)
print(f"완료: 왕 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}")
if fails:
print(f"수집 실패: {', '.join(fails)}")
print(f"JSON -> {json_path}")
print(f"CSV -> {csv_path}")
if __name__ == "__main__":
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
asyncio.run(main())

View File

@ -0,0 +1,13 @@
[캐릭터 공식 레퍼런스 폴더 — greekroman 은 기본 '비움']
greekroman(그리스 로마 신화) 버전은 고양이 의인화가 아니라 '실제 신의 모습'
(근육질 남신 / 아름답고 늘씬한 여신, 한국 신화만화풍)으로 그리므로,
조선/삼국지에서 쓰던 고양이 눈 레퍼런스 이미지를 넣지 않습니다.
- 폴더가 비어 있으면: 각 영상의 '첫 컷'이 자동 앵커가 되어
나머지 컷들이 그 캐릭터 디자인/화풍을 그대로 따라갑니다(영상 내 일관성 유지).
- 특정 그림체를 전 영상에 걸쳐 고정하고 싶으면 기준 이미지를 1장 넣으세요
(폴더 안 '첫 번째' 이미지 파일 사용. png/jpg/jpeg/webp).
단 이 경우 '눈만 고정' 모드라 눈/표정만 통일되고 복장은 신별로 다르게 나옵니다.
연결 코드: render.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")

View File

@ -0,0 +1,466 @@
번호,인물,문서제목,썰후보여부,키워드,URL
1,제우스,제우스,,제우스,https://namu.wiki/w/제우스
1,제우스,제우스,,개요,https://namu.wiki/w/제우스
1,제우스,제우스,,특징,https://namu.wiki/w/제우스
1,제우스,제우스,,명칭,https://namu.wiki/w/제우스
1,제우스,제우스,,기원과 발전,https://namu.wiki/w/제우스
1,제우스,제우스,,행적,https://namu.wiki/w/제우스
1,제우스,제우스,,탄생,https://namu.wiki/w/제우스
1,제우스,제우스,,신들의 왕이 되다: 티타노마키아,https://namu.wiki/w/제우스
1,제우스,제우스,,티포노마키아,https://namu.wiki/w/제우스
1,제우스,제우스,,기간토마키아,https://namu.wiki/w/제우스
1,제우스,제우스,,트로이 전쟁,https://namu.wiki/w/제우스
1,제우스,제우스,Y,로마에서의 숭배,https://namu.wiki/w/제우스
1,제우스,제우스,,기나긴 통치의 끝,https://namu.wiki/w/제우스
1,제우스,제우스,,그리스도교와 제우스,https://namu.wiki/w/제우스
1,제우스,제우스,Y,신화의 해석,https://namu.wiki/w/제우스
1,제우스,제우스,,강간범 및 난봉꾼,https://namu.wiki/w/제우스
1,제우스,제우스,,"제우스의 구애 및 사통, 강간 목록",https://namu.wiki/w/제우스
1,제우스,제우스,,능력,https://namu.wiki/w/제우스
1,제우스,제우스,,권능 / 전투력,https://namu.wiki/w/제우스
1,제우스,제우스,,무기,https://namu.wiki/w/제우스
1,제우스,제우스,Y,기타,https://namu.wiki/w/제우스
1,제우스,제우스,,매체에서의 등장,https://namu.wiki/w/제우스
2,헤라,헤라,,헤라,https://namu.wiki/w/헤라
2,헤라,헤라,,개요,https://namu.wiki/w/헤라
2,헤라,헤라,,신격의 기원,https://namu.wiki/w/헤라
2,헤라,헤라,,행적,https://namu.wiki/w/헤라
2,헤라,헤라,,특징,https://namu.wiki/w/헤라
2,헤라,헤라,,외모,https://namu.wiki/w/헤라
2,헤라,헤라,,자식들,https://namu.wiki/w/헤라
2,헤라,헤라,Y,기타,https://namu.wiki/w/헤라
2,헤라,헤라,Y,대중매체,https://namu.wiki/w/헤라
2,헤라,헤라,,외부 링크,https://namu.wiki/w/헤라
3,포세이돈,포세이돈,,포세이돈,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,개요,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,신격의 기원,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,어원,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,특징,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,외모,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,가계도,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,포세이돈의 구애 목록,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,위상,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,Y,창작물에서의 포세이돈,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,Y,"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,Y,만화로 읽는 초등 인문학 그리스 로마 신화,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,에이지 오브 미쏠로지,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,수학도둑,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,최강 시리즈,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,외부 링크,https://namu.wiki/w/포세이돈
3,포세이돈,포세이돈,,넵튠,https://namu.wiki/w/포세이돈
4,데메테르,데메테르,,데메테르,https://namu.wiki/w/데메테르
4,데메테르,데메테르,,개요,https://namu.wiki/w/데메테르
4,데메테르,데메테르,,신격의 기원,https://namu.wiki/w/데메테르
4,데메테르,데메테르,Y,가족 관계,https://namu.wiki/w/데메테르
4,데메테르,데메테르,,특징,https://namu.wiki/w/데메테르
4,데메테르,데메테르,,납치된 딸 페르세포네를 찾아서,https://namu.wiki/w/데메테르
4,데메테르,데메테르,Y,엘레우시스 설화,https://namu.wiki/w/데메테르
4,데메테르,데메테르,,도마뱀이 된 아스칼라보스,https://namu.wiki/w/데메테르
4,데메테르,데메테르,,엘레우시스 비교(秘敎),https://namu.wiki/w/데메테르
4,데메테르,데메테르,,아리온과 데스포이나,https://namu.wiki/w/데메테르
4,데메테르,데메테르,Y,에뤼시크톤 이야기,https://namu.wiki/w/데메테르
4,데메테르,데메테르,Y,이아시온과의 사랑,https://namu.wiki/w/데메테르
4,데메테르,데메테르,,에리뉘스 테르포우시아,https://namu.wiki/w/데메테르
4,데메테르,데메테르,Y,대중매체,https://namu.wiki/w/데메테르
4,데메테르,데메테르,,외부 링크,https://namu.wiki/w/데메테르
5,아테나,아테나,,아테나,https://namu.wiki/w/아테나
5,아테나,아테나,,개요,https://namu.wiki/w/아테나
5,아테나,아테나,,신격의 기원,https://namu.wiki/w/아테나
5,아테나,아테나,,특징,https://namu.wiki/w/아테나
5,아테나,아테나,,다른 신들과의 관계,https://namu.wiki/w/아테나
5,아테나,아테나,,헤라,https://namu.wiki/w/아테나
5,아테나,아테나,,아레스,https://namu.wiki/w/아테나
5,아테나,아테나,,아프로디테,https://namu.wiki/w/아테나
5,아테나,아테나,,제우스,https://namu.wiki/w/아테나
5,아테나,아테나,,포세이돈,https://namu.wiki/w/아테나
5,아테나,아테나,,아르테미스,https://namu.wiki/w/아테나
5,아테나,아테나,,헤르메스,https://namu.wiki/w/아테나
5,아테나,아테나,,다른 신들,https://namu.wiki/w/아테나
5,아테나,아테나,Y,그 외 신화 속 행적,https://namu.wiki/w/아테나
5,아테나,아테나,Y,대중매체,https://namu.wiki/w/아테나
5,아테나,아테나,,외부 링크,https://namu.wiki/w/아테나
5,아테나,아테나,,한국어,https://namu.wiki/w/아테나
5,아테나,아테나,,영어,https://namu.wiki/w/아테나
5,아테나,아테나,,관련 문서,https://namu.wiki/w/아테나
6,아폴론,아폴론,,아폴론,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,개요,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,신격의 기원,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,특징,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,태양신으로서,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,별칭,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,연애사,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,아폴론의 구애 목록,https://namu.wiki/w/아폴론
6,아폴론,아폴론,Y,기타,https://namu.wiki/w/아폴론
6,아폴론,아폴론,Y,대중매체,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,외부 링크,https://namu.wiki/w/아폴론
6,아폴론,아폴론,,관련 문서,https://namu.wiki/w/아폴론
7,아르테미스,아르테미스,,아르테미스,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,개요,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,신격의 기원,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,특징,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,Y,탄생 신화,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,신격,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,달의 여신,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,사냥과 궁술의 여신,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,순결한 처녀신,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,남성과의 관계,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,미모,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,Y,그리스 로마 신화에서의 행적들,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,악타이온,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,오리온,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,키오네,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,힙폴뤼토스,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,칼리돈의 멧돼지 사냥,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,헤라클레스의 12과업,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,기간토마키아,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,파리스의 심판,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,트로이 전쟁,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,아트레이드의 비극,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,오디세이아,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,아이네이스,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,오토스와 에피알테스,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,니오베의 자식 학살,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,아레스의 재판,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,브리토마르티스,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,티포노마키아,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,Y,기타 신앙,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,소아시아의 아르테미스 신앙,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,,별칭,https://namu.wiki/w/아르테미스
7,아르테미스,아르테미스,Y,대중매체,https://namu.wiki/w/아르테미스
8,아레스,아레스,,아레스,https://namu.wiki/w/아레스
8,아레스,아레스,,개요,https://namu.wiki/w/아레스
8,아레스,아레스,,신격의 기원,https://namu.wiki/w/아레스
8,아레스,아레스,,위상,https://namu.wiki/w/아레스
8,아레스,아레스,,고대 그리스,https://namu.wiki/w/아레스
8,아레스,아레스,,아테나와의 비교,https://namu.wiki/w/아레스
8,아레스,아레스,,헬레니즘 시대,https://namu.wiki/w/아레스
8,아레스,아레스,,고대 로마,https://namu.wiki/w/아레스
8,아레스,아레스,Y,여러 전승,https://namu.wiki/w/아레스
8,아레스,아레스,,여자들과 자녀,https://namu.wiki/w/아레스
8,아레스,아레스,,외모와 연애사,https://namu.wiki/w/아레스
8,아레스,아레스,Y,아프로디테 설화,https://namu.wiki/w/아레스
8,아레스,아레스,,아버지로서,https://namu.wiki/w/아레스
8,아레스,아레스,Y,알킵페와 아레오파고스(Areopagus) 설화,https://namu.wiki/w/아레스
8,아레스,아레스,Y,그 외의 설화,https://namu.wiki/w/아레스
8,아레스,아레스,Y,알로아다이 설화,https://namu.wiki/w/아레스
8,아레스,아레스,Y,현대의 재평가?,https://namu.wiki/w/아레스
8,아레스,아레스,Y,기타,https://namu.wiki/w/아레스
8,아레스,아레스,Y,대중매체,https://namu.wiki/w/아레스
8,아레스,아레스,,외부 링크,https://namu.wiki/w/아레스
8,아레스,아레스,,영어,https://namu.wiki/w/아레스
8,아레스,아레스,,한국어,https://namu.wiki/w/아레스
8,아레스,아레스,,관련 문서,https://namu.wiki/w/아레스
9,아프로디테,아프로디테,,아프로디테,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,,개요,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,신화에서의 묘사,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,,기원 및 해석,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,탄생 신화,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,,아레스와의 관계,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,,헤르메스와의 관계,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,퓌그말리온 신화,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,,이피스와 아낙사레테,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,탄호이저 전설,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,프쉬케 이야기,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,아도니스 이야기,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,,아탈란테와 힙포메네스,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,,아이네이스,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,네리테스 이야기,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,,남자들과 자녀,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,여담,https://namu.wiki/w/아프로디테
9,아프로디테,아프로디테,Y,창작물,https://namu.wiki/w/아프로디테
10,헤파이스토스,헤파이스토스,,헤파이스토스,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,개요,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,신격의 기원,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,특징,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,헤파이스토스의 부인들,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,능력,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,기술자,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,전투력,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,Y,기타,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,매체에서,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,Y,"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,망치하르방,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,Y,그리스 로마 신화 - 신들의 사생활,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,소울 칼리버 시리즈,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,외부 링크,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,한국어,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,영어,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,일본어,https://namu.wiki/w/헤파이스토스
10,헤파이스토스,헤파이스토스,,중국어,https://namu.wiki/w/헤파이스토스
11,헤르메스,헤르메스,,헤르메스,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,개요,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,신격의 기원,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,특징,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,성장 과정,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,Y,신화에서,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,현실에서,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,Y,창작물,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,Y,해당 신에게서 이름을 딴 것들,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,해당 신이 모델이 된 인물들,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,신을 죽이는 방법,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,쿠키런 시리즈,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,쿠키런: 킹덤,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,Y,해당 신이 등장하는 창작물,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,Y,"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,원더우먼,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,하데스타운,https://namu.wiki/w/헤르메스
11,헤르메스,헤르메스,,관련 문서,https://namu.wiki/w/헤르메스
12,디오니소스,디오니소스,,디오니소스,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,개요,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,신격의 기원,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,상세,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,Y,신화 속 외모 묘사,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,탄생과 성장,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,제우스와 세멜레,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,제우스와 페르세포네,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,어린 시절,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,Y,숭배,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,Y,신화 속 디오니소스,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,디오니소스의 별칭들,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,대중 매체에서,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,Y,홍은영의 그리스 로마 신화,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,Y,만화로 읽는 초등 인문학 그리스 로마 신화,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,올림포스 가디언,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,로어 올림푸스,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,던전에서 만남을 추구하면 안 되는 걸까,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,트랜스포머 시리즈,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,2024 파리 올림픽,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,,디지몬 시리즈,https://namu.wiki/w/디오니소스
12,디오니소스,디오니소스,Y,기타,https://namu.wiki/w/디오니소스
13,하데스,하데스,,하데스,https://namu.wiki/w/하데스
13,하데스,하데스,,개요,https://namu.wiki/w/하데스
13,하데스,하데스,,신격의 기원,https://namu.wiki/w/하데스
13,하데스,하데스,,특징,https://namu.wiki/w/하데스
13,하데스,하데스,,가계도,https://namu.wiki/w/하데스
13,하데스,하데스,Y,페르세포네 납치와 사랑,https://namu.wiki/w/하데스
13,하데스,하데스,Y,기타,https://namu.wiki/w/하데스
13,하데스,하데스,,대중문화 속의 하데스,https://namu.wiki/w/하데스
13,하데스,하데스,,목록,https://namu.wiki/w/하데스
13,하데스,하데스,Y,"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",https://namu.wiki/w/하데스
13,하데스,하데스,,에이지 오브 미쏠로지,https://namu.wiki/w/하데스
13,하데스,하데스,,삼국지 천명 시리즈에서의 하데스,https://namu.wiki/w/하데스
13,하데스,하데스,,가수 강수지의 노래,https://namu.wiki/w/하데스
13,하데스,하데스,,드라마 유령에 등장하는 천재 해커,https://namu.wiki/w/하데스
13,하데스,하데스,,관련 문서,https://namu.wiki/w/하데스
13,하데스,하데스,,외부 링크,https://namu.wiki/w/하데스
13,하데스,하데스,,한국어,https://namu.wiki/w/하데스
13,하데스,하데스,,영어,https://namu.wiki/w/하데스
13,하데스,하데스,,일본어,https://namu.wiki/w/하데스
13,하데스,하데스,,중국어,https://namu.wiki/w/하데스
14,헤스티아,헤스티아,,헤스티아,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,,개요,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,,신격의 기원,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,,특징,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,,지위와 역할,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,,최고의 선신,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,,올림포스 최초의 처녀신,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,,너무 적은 비중,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,Y,대중매체,https://namu.wiki/w/헤스티아
14,헤스티아,헤스티아,,관련 문서,https://namu.wiki/w/헤스티아
15,프로메테우스,프로메테우스,,프로메테우스,https://namu.wiki/w/프로메테우스
15,프로메테우스,프로메테우스,,개요,https://namu.wiki/w/프로메테우스
15,프로메테우스,프로메테우스,,특징,https://namu.wiki/w/프로메테우스
15,프로메테우스,프로메테우스,,행적,https://namu.wiki/w/프로메테우스
15,프로메테우스,프로메테우스,,고전,https://namu.wiki/w/프로메테우스
15,프로메테우스,프로메테우스,,매체,https://namu.wiki/w/프로메테우스
15,프로메테우스,프로메테우스,,올림포스 가디언,https://namu.wiki/w/프로메테우스
15,프로메테우스,프로메테우스,Y,장난꾸러기 신 이야기 코로코로 포론,https://namu.wiki/w/프로메테우스
15,프로메테우스,프로메테우스,,관련 문서,https://namu.wiki/w/프로메테우스
16,크로노스,크로노스,,크로노스,https://namu.wiki/w/크로노스
16,크로노스,크로노스,,개요,https://namu.wiki/w/크로노스
16,크로노스,크로노스,,농경의 신,https://namu.wiki/w/크로노스
16,크로노스,크로노스,,매체에서의 모습,https://namu.wiki/w/크로노스
16,크로노스,크로노스,,시간의 신,https://namu.wiki/w/크로노스
17,에로스,에로스,,에로스,https://namu.wiki/w/에로스
17,에로스,에로스,,개요,https://namu.wiki/w/에로스
17,에로스,에로스,Y,전승,https://namu.wiki/w/에로스
17,에로스,에로스,,카오스와 동시대의 신으로 묘사되는 경우,https://namu.wiki/w/에로스
17,에로스,에로스,,아프로디테의 아들로 묘사되는 경우,https://namu.wiki/w/에로스
17,에로스,에로스,Y,프시케 설화,https://namu.wiki/w/에로스
17,에로스,에로스,Y,기타,https://namu.wiki/w/에로스
17,에로스,에로스,Y,대중매체,https://namu.wiki/w/에로스
18,헤라클레스,헤라클레스,,헤라클레스,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,개요,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,특징,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,이름,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,주요 전승들,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,탄생,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,성장기,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,헤라클레스의 선택,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,모험의 시작,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,12과업,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,네메아의 사자 처치,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,히드라 처치,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,황금뿔 사슴 타이게테 생포,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,에리만토스의 멧돼지 생포,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,아우게이아스 왕의 우리 청소,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,스팀팔로스의 새 사냥,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,크레타의 황소 생포,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,디오메데스 왕의 식인 말 생포,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,히폴리테의 허리띠 구해오기,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,게리온의 소를 데려오기,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,황금사과를 구해오기,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,케르베로스 생포,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,옴팔레 여왕의 노예,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,그 이후,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,최후,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,불사의 신이 되다,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,다양한 전승,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,스파르타의 전승,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,아르고 호 원정,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,VS 신,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,테세우스 구출,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,트로이 침공,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,해학,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,헤라클레스의 기둥,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,헤라클레스의 대제단,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,기타 전승 및 언급,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,알레고리적 해석,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,,현대인의 관점,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,가족관계,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,대중매체에서,https://namu.wiki/w/헤라클레스
18,헤라클레스,헤라클레스,Y,여담,https://namu.wiki/w/헤라클레스
19,페르세우스,페르세우스,,페르세우스,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,개요,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,생애,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,탄생,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,메두사를 처단하다,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,안드로메다와의 만남,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,어머니의 원수인 폴리덱테스를 처단하다,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,결국 이루어진 신탁,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,아르고스를 떠나다,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,미케네의 초대 왕,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,Y,최후,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,Y,평가,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,가계 및 혈통,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,Y,기타,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,Y,대중매체,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,관련 문서,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,동음이의어,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,마케도니아의 군주,https://namu.wiki/w/페르세우스
19,페르세우스,페르세우스,,소련의 스파이,https://namu.wiki/w/페르세우스
20,테세우스,테세우스,,테세우스,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,개요,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,일대기,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,아버지를 찾아,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,미노타우로스 퇴치,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,오이디푸스와의 만남,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,몰락,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,해설,https://namu.wiki/w/테세우스
20,테세우스,테세우스,Y,평가,https://namu.wiki/w/테세우스
20,테세우스,테세우스,Y,기타,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,대중 문화에서,https://namu.wiki/w/테세우스
20,테세우스,테세우스,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,그 외,https://namu.wiki/w/테세우스
20,테세우스,테세우스,,관련 문서,https://namu.wiki/w/테세우스
21,오디세우스,오디세우스,,오디세우스,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,개요,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,어원,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,특징,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,일대기,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,트로이 전쟁,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,병역기피 시도,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,결국은 참전하다,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,전쟁 이후,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,《오디세이아》,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,키클롭스의 동굴,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,아이올로스의 섬,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,키르케의 섬,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,헬리오스의 소,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,칼립소의 섬,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,파이에케스 족(族),https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,구혼자들 처단,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,말년,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,《텔레고네이아》,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,《비블리오테케》,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,그 외,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,가계도,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,Y,평가,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,Y,긍정적 평가,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,Y,부정적 평가,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,,총평,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,Y,대중매체에서의 오디세우스,https://namu.wiki/w/오디세우스
21,오디세우스,오디세우스,Y,기타,https://namu.wiki/w/오디세우스
22,아킬레우스,아킬레우스,,아킬레우스,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,개요,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,생애,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,《일리아스》,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,아가멤논과의 불화,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,Y,파트로클로스의 죽음,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,헥토르와의 결투,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,《아이디오피스》,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,Y,사후 전승,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,인간 관계,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,파트로클로스,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,그 외,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,가계도,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,Y,평가,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,인품,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,Y,긍정적 평가,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,Y,부정적 평가,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,현대인의 관점에서,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,옹호,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,총평,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,다른 영웅들과의 비교,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,Y,여담,https://namu.wiki/w/아킬레우스
22,아킬레우스,아킬레우스,,관련 문서,https://namu.wiki/w/아킬레우스
23,오르페우스,오르페우스,,오르페우스,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,,개요,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,Y,신화에서의 행적,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,Y,평가,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,,오르페우스 밀교,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,Y,유사한 신화,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,Y,미디어,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,,올림포스 가디언,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,,그 외,https://namu.wiki/w/오르페우스
23,오르페우스,오르페우스,,모티브로 삼은 작품,https://namu.wiki/w/오르페우스
24,이아손,이아손,,이아손,https://namu.wiki/w/이아손
24,이아손,이아손,,개요,https://namu.wiki/w/이아손
24,이아손,이아손,,일대기,https://namu.wiki/w/이아손
24,이아손,이아손,Y,기타,https://namu.wiki/w/이아손
24,이아손,이아손,Y,이름에 대하여,https://namu.wiki/w/이아손
24,이아손,이아손,,각종 매체에서의 이아손,https://namu.wiki/w/이아손
24,이아손,이아손,,아르고 황금 대탐험,https://namu.wiki/w/이아손
24,이아손,이아손,,신곡 지옥편,https://namu.wiki/w/이아손
24,이아손,이아손,,갓 오브 워 2,https://namu.wiki/w/이아손
24,이아손,이아손,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/이아손
24,이아손,이아손,,올림포스 가디언,https://namu.wiki/w/이아손
24,이아손,이아손,,Fate 시리즈,https://namu.wiki/w/이아손
24,이아손,이아손,,Rise of the Argonauts,https://namu.wiki/w/이아손
24,이아손,이아손,,하루만 네가 되고 싶어,https://namu.wiki/w/이아손
24,이아손,이아손,Y,초등 인문학 그리스 로마 신화,https://namu.wiki/w/이아손
24,이아손,이아손,Y,신화 속 양치기 노예가 되었다,https://namu.wiki/w/이아손
24,이아손,이아손,,R-TYPE 시리즈,https://namu.wiki/w/이아손
25,메두사,메두사,,메두사,https://namu.wiki/w/메두사
25,메두사,메두사,,개요,https://namu.wiki/w/메두사
25,메두사,메두사,Y,전승,https://namu.wiki/w/메두사
25,메두사,메두사,Y,대중매체,https://namu.wiki/w/메두사
25,메두사,메두사,Y,기타,https://namu.wiki/w/메두사
25,메두사,메두사,,외부 링크,https://namu.wiki/w/메두사
26,미다스,미다스,,미다스,https://namu.wiki/w/미다스
26,미다스,미다스,,개요,https://namu.wiki/w/미다스
26,미다스,미다스,,행적,https://namu.wiki/w/미다스
26,미다스,미다스,Y,여담,https://namu.wiki/w/미다스
26,미다스,미다스,Y,대중매체에서,https://namu.wiki/w/미다스
26,미다스,미다스,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/미다스
26,미다스,미다스,,올림포스 가디언,https://namu.wiki/w/미다스
26,미다스,미다스,,갓 오브 워: 고스트 오브 스파르타,https://namu.wiki/w/미다스
26,미다스,미다스,Y,그리스 로마 신화 : 전설의 수호자들,https://namu.wiki/w/미다스
27,시시포스,시시포스,,시시포스,https://namu.wiki/w/시시포스
27,시시포스,시시포스,,개요,https://namu.wiki/w/시시포스
27,시시포스,시시포스,Y,가족관계,https://namu.wiki/w/시시포스
27,시시포스,시시포스,,일대기,https://namu.wiki/w/시시포스
27,시시포스,시시포스,,아우톨리코스와의 대결,https://namu.wiki/w/시시포스
27,시시포스,시시포스,,에뤼시크톤과의 악연,https://namu.wiki/w/시시포스
27,시시포스,시시포스,,영원한 형벌,https://namu.wiki/w/시시포스
27,시시포스,시시포스,Y,대중매체,https://namu.wiki/w/시시포스
27,시시포스,시시포스,Y,여담,https://namu.wiki/w/시시포스
27,시시포스,시시포스,,관련 문서,https://namu.wiki/w/시시포스
28,이카로스,이카로스,,이카로스,https://namu.wiki/w/이카로스
28,이카로스,이카로스,,개요,https://namu.wiki/w/이카로스
28,이카로스,이카로스,Y,이름을 딴 것들,https://namu.wiki/w/이카로스
28,이카로스,이카로스,Y,대중매체,https://namu.wiki/w/이카로스
28,이카로스,이카로스,,서방 프로젝트의 등장인물,https://namu.wiki/w/이카로스
28,이카로스,이카로스,,파이브 스타 스토리의 등장인물 발란셰 파티마,https://namu.wiki/w/이카로스
1 번호 인물 문서제목 썰후보여부 키워드 URL
2 1 제우스 제우스 제우스 https://namu.wiki/w/제우스
3 1 제우스 제우스 개요 https://namu.wiki/w/제우스
4 1 제우스 제우스 특징 https://namu.wiki/w/제우스
5 1 제우스 제우스 명칭 https://namu.wiki/w/제우스
6 1 제우스 제우스 기원과 발전 https://namu.wiki/w/제우스
7 1 제우스 제우스 행적 https://namu.wiki/w/제우스
8 1 제우스 제우스 탄생 https://namu.wiki/w/제우스
9 1 제우스 제우스 신들의 왕이 되다: 티타노마키아 https://namu.wiki/w/제우스
10 1 제우스 제우스 티포노마키아 https://namu.wiki/w/제우스
11 1 제우스 제우스 기간토마키아 https://namu.wiki/w/제우스
12 1 제우스 제우스 트로이 전쟁 https://namu.wiki/w/제우스
13 1 제우스 제우스 Y 로마에서의 숭배 https://namu.wiki/w/제우스
14 1 제우스 제우스 기나긴 통치의 끝 https://namu.wiki/w/제우스
15 1 제우스 제우스 그리스도교와 제우스 https://namu.wiki/w/제우스
16 1 제우스 제우스 Y 신화의 해석 https://namu.wiki/w/제우스
17 1 제우스 제우스 강간범 및 난봉꾼 https://namu.wiki/w/제우스
18 1 제우스 제우스 제우스의 구애 및 사통, 강간 목록 https://namu.wiki/w/제우스
19 1 제우스 제우스 능력 https://namu.wiki/w/제우스
20 1 제우스 제우스 권능 / 전투력 https://namu.wiki/w/제우스
21 1 제우스 제우스 무기 https://namu.wiki/w/제우스
22 1 제우스 제우스 Y 기타 https://namu.wiki/w/제우스
23 1 제우스 제우스 매체에서의 등장 https://namu.wiki/w/제우스
24 2 헤라 헤라 헤라 https://namu.wiki/w/헤라
25 2 헤라 헤라 개요 https://namu.wiki/w/헤라
26 2 헤라 헤라 신격의 기원 https://namu.wiki/w/헤라
27 2 헤라 헤라 행적 https://namu.wiki/w/헤라
28 2 헤라 헤라 특징 https://namu.wiki/w/헤라
29 2 헤라 헤라 외모 https://namu.wiki/w/헤라
30 2 헤라 헤라 자식들 https://namu.wiki/w/헤라
31 2 헤라 헤라 Y 기타 https://namu.wiki/w/헤라
32 2 헤라 헤라 Y 대중매체 https://namu.wiki/w/헤라
33 2 헤라 헤라 외부 링크 https://namu.wiki/w/헤라
34 3 포세이돈 포세이돈 포세이돈 https://namu.wiki/w/포세이돈
35 3 포세이돈 포세이돈 개요 https://namu.wiki/w/포세이돈
36 3 포세이돈 포세이돈 신격의 기원 https://namu.wiki/w/포세이돈
37 3 포세이돈 포세이돈 어원 https://namu.wiki/w/포세이돈
38 3 포세이돈 포세이돈 특징 https://namu.wiki/w/포세이돈
39 3 포세이돈 포세이돈 외모 https://namu.wiki/w/포세이돈
40 3 포세이돈 포세이돈 가계도 https://namu.wiki/w/포세이돈
41 3 포세이돈 포세이돈 포세이돈의 구애 목록 https://namu.wiki/w/포세이돈
42 3 포세이돈 포세이돈 위상 https://namu.wiki/w/포세이돈
43 3 포세이돈 포세이돈 Y 창작물에서의 포세이돈 https://namu.wiki/w/포세이돈
44 3 포세이돈 포세이돈 Y 만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화 https://namu.wiki/w/포세이돈
45 3 포세이돈 포세이돈 Y 만화로 읽는 초등 인문학 그리스 로마 신화 https://namu.wiki/w/포세이돈
46 3 포세이돈 포세이돈 에이지 오브 미쏠로지 https://namu.wiki/w/포세이돈
47 3 포세이돈 포세이돈 수학도둑 https://namu.wiki/w/포세이돈
48 3 포세이돈 포세이돈 최강 시리즈 https://namu.wiki/w/포세이돈
49 3 포세이돈 포세이돈 외부 링크 https://namu.wiki/w/포세이돈
50 3 포세이돈 포세이돈 넵튠 https://namu.wiki/w/포세이돈
51 4 데메테르 데메테르 데메테르 https://namu.wiki/w/데메테르
52 4 데메테르 데메테르 개요 https://namu.wiki/w/데메테르
53 4 데메테르 데메테르 신격의 기원 https://namu.wiki/w/데메테르
54 4 데메테르 데메테르 Y 가족 관계 https://namu.wiki/w/데메테르
55 4 데메테르 데메테르 특징 https://namu.wiki/w/데메테르
56 4 데메테르 데메테르 납치된 딸 페르세포네를 찾아서 https://namu.wiki/w/데메테르
57 4 데메테르 데메테르 Y 엘레우시스 설화 https://namu.wiki/w/데메테르
58 4 데메테르 데메테르 도마뱀이 된 아스칼라보스 https://namu.wiki/w/데메테르
59 4 데메테르 데메테르 엘레우시스 비교(秘敎) https://namu.wiki/w/데메테르
60 4 데메테르 데메테르 아리온과 데스포이나 https://namu.wiki/w/데메테르
61 4 데메테르 데메테르 Y 에뤼시크톤 이야기 https://namu.wiki/w/데메테르
62 4 데메테르 데메테르 Y 이아시온과의 사랑 https://namu.wiki/w/데메테르
63 4 데메테르 데메테르 에리뉘스 테르포우시아 https://namu.wiki/w/데메테르
64 4 데메테르 데메테르 Y 대중매체 https://namu.wiki/w/데메테르
65 4 데메테르 데메테르 외부 링크 https://namu.wiki/w/데메테르
66 5 아테나 아테나 아테나 https://namu.wiki/w/아테나
67 5 아테나 아테나 개요 https://namu.wiki/w/아테나
68 5 아테나 아테나 신격의 기원 https://namu.wiki/w/아테나
69 5 아테나 아테나 특징 https://namu.wiki/w/아테나
70 5 아테나 아테나 다른 신들과의 관계 https://namu.wiki/w/아테나
71 5 아테나 아테나 헤라 https://namu.wiki/w/아테나
72 5 아테나 아테나 아레스 https://namu.wiki/w/아테나
73 5 아테나 아테나 아프로디테 https://namu.wiki/w/아테나
74 5 아테나 아테나 제우스 https://namu.wiki/w/아테나
75 5 아테나 아테나 포세이돈 https://namu.wiki/w/아테나
76 5 아테나 아테나 아르테미스 https://namu.wiki/w/아테나
77 5 아테나 아테나 헤르메스 https://namu.wiki/w/아테나
78 5 아테나 아테나 다른 신들 https://namu.wiki/w/아테나
79 5 아테나 아테나 Y 그 외 신화 속 행적 https://namu.wiki/w/아테나
80 5 아테나 아테나 Y 대중매체 https://namu.wiki/w/아테나
81 5 아테나 아테나 외부 링크 https://namu.wiki/w/아테나
82 5 아테나 아테나 한국어 https://namu.wiki/w/아테나
83 5 아테나 아테나 영어 https://namu.wiki/w/아테나
84 5 아테나 아테나 관련 문서 https://namu.wiki/w/아테나
85 6 아폴론 아폴론 아폴론 https://namu.wiki/w/아폴론
86 6 아폴론 아폴론 개요 https://namu.wiki/w/아폴론
87 6 아폴론 아폴론 신격의 기원 https://namu.wiki/w/아폴론
88 6 아폴론 아폴론 특징 https://namu.wiki/w/아폴론
89 6 아폴론 아폴론 태양신으로서 https://namu.wiki/w/아폴론
90 6 아폴론 아폴론 별칭 https://namu.wiki/w/아폴론
91 6 아폴론 아폴론 연애사 https://namu.wiki/w/아폴론
92 6 아폴론 아폴론 아폴론의 구애 목록 https://namu.wiki/w/아폴론
93 6 아폴론 아폴론 Y 기타 https://namu.wiki/w/아폴론
94 6 아폴론 아폴론 Y 대중매체 https://namu.wiki/w/아폴론
95 6 아폴론 아폴론 외부 링크 https://namu.wiki/w/아폴론
96 6 아폴론 아폴론 관련 문서 https://namu.wiki/w/아폴론
97 7 아르테미스 아르테미스 아르테미스 https://namu.wiki/w/아르테미스
98 7 아르테미스 아르테미스 개요 https://namu.wiki/w/아르테미스
99 7 아르테미스 아르테미스 신격의 기원 https://namu.wiki/w/아르테미스
100 7 아르테미스 아르테미스 특징 https://namu.wiki/w/아르테미스
101 7 아르테미스 아르테미스 Y 탄생 신화 https://namu.wiki/w/아르테미스
102 7 아르테미스 아르테미스 신격 https://namu.wiki/w/아르테미스
103 7 아르테미스 아르테미스 달의 여신 https://namu.wiki/w/아르테미스
104 7 아르테미스 아르테미스 사냥과 궁술의 여신 https://namu.wiki/w/아르테미스
105 7 아르테미스 아르테미스 순결한 처녀신 https://namu.wiki/w/아르테미스
106 7 아르테미스 아르테미스 남성과의 관계 https://namu.wiki/w/아르테미스
107 7 아르테미스 아르테미스 미모 https://namu.wiki/w/아르테미스
108 7 아르테미스 아르테미스 Y 그리스 로마 신화에서의 행적들 https://namu.wiki/w/아르테미스
109 7 아르테미스 아르테미스 악타이온 https://namu.wiki/w/아르테미스
110 7 아르테미스 아르테미스 오리온 https://namu.wiki/w/아르테미스
111 7 아르테미스 아르테미스 키오네 https://namu.wiki/w/아르테미스
112 7 아르테미스 아르테미스 힙폴뤼토스 https://namu.wiki/w/아르테미스
113 7 아르테미스 아르테미스 칼리돈의 멧돼지 사냥 https://namu.wiki/w/아르테미스
114 7 아르테미스 아르테미스 헤라클레스의 12과업 https://namu.wiki/w/아르테미스
115 7 아르테미스 아르테미스 기간토마키아 https://namu.wiki/w/아르테미스
116 7 아르테미스 아르테미스 파리스의 심판 https://namu.wiki/w/아르테미스
117 7 아르테미스 아르테미스 트로이 전쟁 https://namu.wiki/w/아르테미스
118 7 아르테미스 아르테미스 아트레이드의 비극 https://namu.wiki/w/아르테미스
119 7 아르테미스 아르테미스 오디세이아 https://namu.wiki/w/아르테미스
120 7 아르테미스 아르테미스 아이네이스 https://namu.wiki/w/아르테미스
121 7 아르테미스 아르테미스 오토스와 에피알테스 https://namu.wiki/w/아르테미스
122 7 아르테미스 아르테미스 니오베의 자식 학살 https://namu.wiki/w/아르테미스
123 7 아르테미스 아르테미스 아레스의 재판 https://namu.wiki/w/아르테미스
124 7 아르테미스 아르테미스 브리토마르티스 https://namu.wiki/w/아르테미스
125 7 아르테미스 아르테미스 티포노마키아 https://namu.wiki/w/아르테미스
126 7 아르테미스 아르테미스 Y 기타 신앙 https://namu.wiki/w/아르테미스
127 7 아르테미스 아르테미스 소아시아의 아르테미스 신앙 https://namu.wiki/w/아르테미스
128 7 아르테미스 아르테미스 별칭 https://namu.wiki/w/아르테미스
129 7 아르테미스 아르테미스 Y 대중매체 https://namu.wiki/w/아르테미스
130 8 아레스 아레스 아레스 https://namu.wiki/w/아레스
131 8 아레스 아레스 개요 https://namu.wiki/w/아레스
132 8 아레스 아레스 신격의 기원 https://namu.wiki/w/아레스
133 8 아레스 아레스 위상 https://namu.wiki/w/아레스
134 8 아레스 아레스 고대 그리스 https://namu.wiki/w/아레스
135 8 아레스 아레스 아테나와의 비교 https://namu.wiki/w/아레스
136 8 아레스 아레스 헬레니즘 시대 https://namu.wiki/w/아레스
137 8 아레스 아레스 고대 로마 https://namu.wiki/w/아레스
138 8 아레스 아레스 Y 여러 전승 https://namu.wiki/w/아레스
139 8 아레스 아레스 여자들과 자녀 https://namu.wiki/w/아레스
140 8 아레스 아레스 외모와 연애사 https://namu.wiki/w/아레스
141 8 아레스 아레스 Y 아프로디테 설화 https://namu.wiki/w/아레스
142 8 아레스 아레스 아버지로서 https://namu.wiki/w/아레스
143 8 아레스 아레스 Y 알킵페와 아레오파고스(Areopagus) 설화 https://namu.wiki/w/아레스
144 8 아레스 아레스 Y 그 외의 설화 https://namu.wiki/w/아레스
145 8 아레스 아레스 Y 알로아다이 설화 https://namu.wiki/w/아레스
146 8 아레스 아레스 Y 현대의 재평가? https://namu.wiki/w/아레스
147 8 아레스 아레스 Y 기타 https://namu.wiki/w/아레스
148 8 아레스 아레스 Y 대중매체 https://namu.wiki/w/아레스
149 8 아레스 아레스 외부 링크 https://namu.wiki/w/아레스
150 8 아레스 아레스 영어 https://namu.wiki/w/아레스
151 8 아레스 아레스 한국어 https://namu.wiki/w/아레스
152 8 아레스 아레스 관련 문서 https://namu.wiki/w/아레스
153 9 아프로디테 아프로디테 아프로디테 https://namu.wiki/w/아프로디테
154 9 아프로디테 아프로디테 개요 https://namu.wiki/w/아프로디테
155 9 아프로디테 아프로디테 Y 신화에서의 묘사 https://namu.wiki/w/아프로디테
156 9 아프로디테 아프로디테 기원 및 해석 https://namu.wiki/w/아프로디테
157 9 아프로디테 아프로디테 Y 탄생 신화 https://namu.wiki/w/아프로디테
158 9 아프로디테 아프로디테 아레스와의 관계 https://namu.wiki/w/아프로디테
159 9 아프로디테 아프로디테 헤르메스와의 관계 https://namu.wiki/w/아프로디테
160 9 아프로디테 아프로디테 Y 퓌그말리온 신화 https://namu.wiki/w/아프로디테
161 9 아프로디테 아프로디테 이피스와 아낙사레테 https://namu.wiki/w/아프로디테
162 9 아프로디테 아프로디테 Y 탄호이저 전설 https://namu.wiki/w/아프로디테
163 9 아프로디테 아프로디테 Y 프쉬케 이야기 https://namu.wiki/w/아프로디테
164 9 아프로디테 아프로디테 Y 아도니스 이야기 https://namu.wiki/w/아프로디테
165 9 아프로디테 아프로디테 아탈란테와 힙포메네스 https://namu.wiki/w/아프로디테
166 9 아프로디테 아프로디테 아이네이스 https://namu.wiki/w/아프로디테
167 9 아프로디테 아프로디테 Y 네리테스 이야기 https://namu.wiki/w/아프로디테
168 9 아프로디테 아프로디테 남자들과 자녀 https://namu.wiki/w/아프로디테
169 9 아프로디테 아프로디테 Y 여담 https://namu.wiki/w/아프로디테
170 9 아프로디테 아프로디테 Y 창작물 https://namu.wiki/w/아프로디테
171 10 헤파이스토스 헤파이스토스 헤파이스토스 https://namu.wiki/w/헤파이스토스
172 10 헤파이스토스 헤파이스토스 개요 https://namu.wiki/w/헤파이스토스
173 10 헤파이스토스 헤파이스토스 신격의 기원 https://namu.wiki/w/헤파이스토스
174 10 헤파이스토스 헤파이스토스 특징 https://namu.wiki/w/헤파이스토스
175 10 헤파이스토스 헤파이스토스 헤파이스토스의 부인들 https://namu.wiki/w/헤파이스토스
176 10 헤파이스토스 헤파이스토스 능력 https://namu.wiki/w/헤파이스토스
177 10 헤파이스토스 헤파이스토스 기술자 https://namu.wiki/w/헤파이스토스
178 10 헤파이스토스 헤파이스토스 전투력 https://namu.wiki/w/헤파이스토스
179 10 헤파이스토스 헤파이스토스 Y 기타 https://namu.wiki/w/헤파이스토스
180 10 헤파이스토스 헤파이스토스 매체에서 https://namu.wiki/w/헤파이스토스
181 10 헤파이스토스 헤파이스토스 Y 만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화 https://namu.wiki/w/헤파이스토스
182 10 헤파이스토스 헤파이스토스 망치하르방 https://namu.wiki/w/헤파이스토스
183 10 헤파이스토스 헤파이스토스 Y 그리스 로마 신화 - 신들의 사생활 https://namu.wiki/w/헤파이스토스
184 10 헤파이스토스 헤파이스토스 소울 칼리버 시리즈 https://namu.wiki/w/헤파이스토스
185 10 헤파이스토스 헤파이스토스 외부 링크 https://namu.wiki/w/헤파이스토스
186 10 헤파이스토스 헤파이스토스 한국어 https://namu.wiki/w/헤파이스토스
187 10 헤파이스토스 헤파이스토스 영어 https://namu.wiki/w/헤파이스토스
188 10 헤파이스토스 헤파이스토스 일본어 https://namu.wiki/w/헤파이스토스
189 10 헤파이스토스 헤파이스토스 중국어 https://namu.wiki/w/헤파이스토스
190 11 헤르메스 헤르메스 헤르메스 https://namu.wiki/w/헤르메스
191 11 헤르메스 헤르메스 개요 https://namu.wiki/w/헤르메스
192 11 헤르메스 헤르메스 신격의 기원 https://namu.wiki/w/헤르메스
193 11 헤르메스 헤르메스 특징 https://namu.wiki/w/헤르메스
194 11 헤르메스 헤르메스 성장 과정 https://namu.wiki/w/헤르메스
195 11 헤르메스 헤르메스 Y 신화에서 https://namu.wiki/w/헤르메스
196 11 헤르메스 헤르메스 현실에서 https://namu.wiki/w/헤르메스
197 11 헤르메스 헤르메스 Y 창작물 https://namu.wiki/w/헤르메스
198 11 헤르메스 헤르메스 Y 해당 신에게서 이름을 딴 것들 https://namu.wiki/w/헤르메스
199 11 헤르메스 헤르메스 해당 신이 모델이 된 인물들 https://namu.wiki/w/헤르메스
200 11 헤르메스 헤르메스 신을 죽이는 방법 https://namu.wiki/w/헤르메스
201 11 헤르메스 헤르메스 쿠키런 시리즈 https://namu.wiki/w/헤르메스
202 11 헤르메스 헤르메스 쿠키런: 킹덤 https://namu.wiki/w/헤르메스
203 11 헤르메스 헤르메스 Y 해당 신이 등장하는 창작물 https://namu.wiki/w/헤르메스
204 11 헤르메스 헤르메스 Y 만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화 https://namu.wiki/w/헤르메스
205 11 헤르메스 헤르메스 원더우먼 https://namu.wiki/w/헤르메스
206 11 헤르메스 헤르메스 하데스타운 https://namu.wiki/w/헤르메스
207 11 헤르메스 헤르메스 관련 문서 https://namu.wiki/w/헤르메스
208 12 디오니소스 디오니소스 디오니소스 https://namu.wiki/w/디오니소스
209 12 디오니소스 디오니소스 개요 https://namu.wiki/w/디오니소스
210 12 디오니소스 디오니소스 신격의 기원 https://namu.wiki/w/디오니소스
211 12 디오니소스 디오니소스 상세 https://namu.wiki/w/디오니소스
212 12 디오니소스 디오니소스 Y 신화 속 외모 묘사 https://namu.wiki/w/디오니소스
213 12 디오니소스 디오니소스 탄생과 성장 https://namu.wiki/w/디오니소스
214 12 디오니소스 디오니소스 제우스와 세멜레 https://namu.wiki/w/디오니소스
215 12 디오니소스 디오니소스 제우스와 페르세포네 https://namu.wiki/w/디오니소스
216 12 디오니소스 디오니소스 어린 시절 https://namu.wiki/w/디오니소스
217 12 디오니소스 디오니소스 Y 숭배 https://namu.wiki/w/디오니소스
218 12 디오니소스 디오니소스 Y 신화 속 디오니소스 https://namu.wiki/w/디오니소스
219 12 디오니소스 디오니소스 디오니소스의 별칭들 https://namu.wiki/w/디오니소스
220 12 디오니소스 디오니소스 대중 매체에서 https://namu.wiki/w/디오니소스
221 12 디오니소스 디오니소스 Y 만화로 보는 그리스 로마 신화 https://namu.wiki/w/디오니소스
222 12 디오니소스 디오니소스 Y 홍은영의 그리스 로마 신화 https://namu.wiki/w/디오니소스
223 12 디오니소스 디오니소스 Y 만화로 읽는 초등 인문학 그리스 로마 신화 https://namu.wiki/w/디오니소스
224 12 디오니소스 디오니소스 올림포스 가디언 https://namu.wiki/w/디오니소스
225 12 디오니소스 디오니소스 로어 올림푸스 https://namu.wiki/w/디오니소스
226 12 디오니소스 디오니소스 던전에서 만남을 추구하면 안 되는 걸까 https://namu.wiki/w/디오니소스
227 12 디오니소스 디오니소스 트랜스포머 시리즈 https://namu.wiki/w/디오니소스
228 12 디오니소스 디오니소스 2024 파리 올림픽 https://namu.wiki/w/디오니소스
229 12 디오니소스 디오니소스 디지몬 시리즈 https://namu.wiki/w/디오니소스
230 12 디오니소스 디오니소스 Y 기타 https://namu.wiki/w/디오니소스
231 13 하데스 하데스 하데스 https://namu.wiki/w/하데스
232 13 하데스 하데스 개요 https://namu.wiki/w/하데스
233 13 하데스 하데스 신격의 기원 https://namu.wiki/w/하데스
234 13 하데스 하데스 특징 https://namu.wiki/w/하데스
235 13 하데스 하데스 가계도 https://namu.wiki/w/하데스
236 13 하데스 하데스 Y 페르세포네 납치와 사랑 https://namu.wiki/w/하데스
237 13 하데스 하데스 Y 기타 https://namu.wiki/w/하데스
238 13 하데스 하데스 대중문화 속의 하데스 https://namu.wiki/w/하데스
239 13 하데스 하데스 목록 https://namu.wiki/w/하데스
240 13 하데스 하데스 Y 만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화 https://namu.wiki/w/하데스
241 13 하데스 하데스 에이지 오브 미쏠로지 https://namu.wiki/w/하데스
242 13 하데스 하데스 삼국지 천명 시리즈에서의 하데스 https://namu.wiki/w/하데스
243 13 하데스 하데스 가수 강수지의 노래 https://namu.wiki/w/하데스
244 13 하데스 하데스 드라마 유령에 등장하는 천재 해커 https://namu.wiki/w/하데스
245 13 하데스 하데스 관련 문서 https://namu.wiki/w/하데스
246 13 하데스 하데스 외부 링크 https://namu.wiki/w/하데스
247 13 하데스 하데스 한국어 https://namu.wiki/w/하데스
248 13 하데스 하데스 영어 https://namu.wiki/w/하데스
249 13 하데스 하데스 일본어 https://namu.wiki/w/하데스
250 13 하데스 하데스 중국어 https://namu.wiki/w/하데스
251 14 헤스티아 헤스티아 헤스티아 https://namu.wiki/w/헤스티아
252 14 헤스티아 헤스티아 개요 https://namu.wiki/w/헤스티아
253 14 헤스티아 헤스티아 신격의 기원 https://namu.wiki/w/헤스티아
254 14 헤스티아 헤스티아 특징 https://namu.wiki/w/헤스티아
255 14 헤스티아 헤스티아 지위와 역할 https://namu.wiki/w/헤스티아
256 14 헤스티아 헤스티아 최고의 선신 https://namu.wiki/w/헤스티아
257 14 헤스티아 헤스티아 올림포스 최초의 처녀신 https://namu.wiki/w/헤스티아
258 14 헤스티아 헤스티아 너무 적은 비중 https://namu.wiki/w/헤스티아
259 14 헤스티아 헤스티아 Y 대중매체 https://namu.wiki/w/헤스티아
260 14 헤스티아 헤스티아 관련 문서 https://namu.wiki/w/헤스티아
261 15 프로메테우스 프로메테우스 프로메테우스 https://namu.wiki/w/프로메테우스
262 15 프로메테우스 프로메테우스 개요 https://namu.wiki/w/프로메테우스
263 15 프로메테우스 프로메테우스 특징 https://namu.wiki/w/프로메테우스
264 15 프로메테우스 프로메테우스 행적 https://namu.wiki/w/프로메테우스
265 15 프로메테우스 프로메테우스 고전 https://namu.wiki/w/프로메테우스
266 15 프로메테우스 프로메테우스 매체 https://namu.wiki/w/프로메테우스
267 15 프로메테우스 프로메테우스 올림포스 가디언 https://namu.wiki/w/프로메테우스
268 15 프로메테우스 프로메테우스 Y 장난꾸러기 신 이야기 코로코로 포론 https://namu.wiki/w/프로메테우스
269 15 프로메테우스 프로메테우스 관련 문서 https://namu.wiki/w/프로메테우스
270 16 크로노스 크로노스 크로노스 https://namu.wiki/w/크로노스
271 16 크로노스 크로노스 개요 https://namu.wiki/w/크로노스
272 16 크로노스 크로노스 농경의 신 https://namu.wiki/w/크로노스
273 16 크로노스 크로노스 매체에서의 모습 https://namu.wiki/w/크로노스
274 16 크로노스 크로노스 시간의 신 https://namu.wiki/w/크로노스
275 17 에로스 에로스 에로스 https://namu.wiki/w/에로스
276 17 에로스 에로스 개요 https://namu.wiki/w/에로스
277 17 에로스 에로스 Y 전승 https://namu.wiki/w/에로스
278 17 에로스 에로스 카오스와 동시대의 신으로 묘사되는 경우 https://namu.wiki/w/에로스
279 17 에로스 에로스 아프로디테의 아들로 묘사되는 경우 https://namu.wiki/w/에로스
280 17 에로스 에로스 Y 프시케 설화 https://namu.wiki/w/에로스
281 17 에로스 에로스 Y 기타 https://namu.wiki/w/에로스
282 17 에로스 에로스 Y 대중매체 https://namu.wiki/w/에로스
283 18 헤라클레스 헤라클레스 헤라클레스 https://namu.wiki/w/헤라클레스
284 18 헤라클레스 헤라클레스 개요 https://namu.wiki/w/헤라클레스
285 18 헤라클레스 헤라클레스 특징 https://namu.wiki/w/헤라클레스
286 18 헤라클레스 헤라클레스 Y 이름 https://namu.wiki/w/헤라클레스
287 18 헤라클레스 헤라클레스 Y 주요 전승들 https://namu.wiki/w/헤라클레스
288 18 헤라클레스 헤라클레스 탄생 https://namu.wiki/w/헤라클레스
289 18 헤라클레스 헤라클레스 성장기 https://namu.wiki/w/헤라클레스
290 18 헤라클레스 헤라클레스 헤라클레스의 선택 https://namu.wiki/w/헤라클레스
291 18 헤라클레스 헤라클레스 모험의 시작 https://namu.wiki/w/헤라클레스
292 18 헤라클레스 헤라클레스 12과업 https://namu.wiki/w/헤라클레스
293 18 헤라클레스 헤라클레스 네메아의 사자 처치 https://namu.wiki/w/헤라클레스
294 18 헤라클레스 헤라클레스 히드라 처치 https://namu.wiki/w/헤라클레스
295 18 헤라클레스 헤라클레스 황금뿔 사슴 타이게테 생포 https://namu.wiki/w/헤라클레스
296 18 헤라클레스 헤라클레스 에리만토스의 멧돼지 생포 https://namu.wiki/w/헤라클레스
297 18 헤라클레스 헤라클레스 아우게이아스 왕의 우리 청소 https://namu.wiki/w/헤라클레스
298 18 헤라클레스 헤라클레스 스팀팔로스의 새 사냥 https://namu.wiki/w/헤라클레스
299 18 헤라클레스 헤라클레스 크레타의 황소 생포 https://namu.wiki/w/헤라클레스
300 18 헤라클레스 헤라클레스 디오메데스 왕의 식인 말 생포 https://namu.wiki/w/헤라클레스
301 18 헤라클레스 헤라클레스 히폴리테의 허리띠 구해오기 https://namu.wiki/w/헤라클레스
302 18 헤라클레스 헤라클레스 게리온의 소를 데려오기 https://namu.wiki/w/헤라클레스
303 18 헤라클레스 헤라클레스 황금사과를 구해오기 https://namu.wiki/w/헤라클레스
304 18 헤라클레스 헤라클레스 케르베로스 생포 https://namu.wiki/w/헤라클레스
305 18 헤라클레스 헤라클레스 옴팔레 여왕의 노예 https://namu.wiki/w/헤라클레스
306 18 헤라클레스 헤라클레스 그 이후 https://namu.wiki/w/헤라클레스
307 18 헤라클레스 헤라클레스 Y 최후 https://namu.wiki/w/헤라클레스
308 18 헤라클레스 헤라클레스 불사의 신이 되다 https://namu.wiki/w/헤라클레스
309 18 헤라클레스 헤라클레스 Y 다양한 전승 https://namu.wiki/w/헤라클레스
310 18 헤라클레스 헤라클레스 Y 스파르타의 전승 https://namu.wiki/w/헤라클레스
311 18 헤라클레스 헤라클레스 아르고 호 원정 https://namu.wiki/w/헤라클레스
312 18 헤라클레스 헤라클레스 VS 신 https://namu.wiki/w/헤라클레스
313 18 헤라클레스 헤라클레스 테세우스 구출 https://namu.wiki/w/헤라클레스
314 18 헤라클레스 헤라클레스 트로이 침공 https://namu.wiki/w/헤라클레스
315 18 헤라클레스 헤라클레스 해학 https://namu.wiki/w/헤라클레스
316 18 헤라클레스 헤라클레스 헤라클레스의 기둥 https://namu.wiki/w/헤라클레스
317 18 헤라클레스 헤라클레스 헤라클레스의 대제단 https://namu.wiki/w/헤라클레스
318 18 헤라클레스 헤라클레스 Y 기타 전승 및 언급 https://namu.wiki/w/헤라클레스
319 18 헤라클레스 헤라클레스 알레고리적 해석 https://namu.wiki/w/헤라클레스
320 18 헤라클레스 헤라클레스 현대인의 관점 https://namu.wiki/w/헤라클레스
321 18 헤라클레스 헤라클레스 Y 가족관계 https://namu.wiki/w/헤라클레스
322 18 헤라클레스 헤라클레스 Y 대중매체에서 https://namu.wiki/w/헤라클레스
323 18 헤라클레스 헤라클레스 Y 여담 https://namu.wiki/w/헤라클레스
324 19 페르세우스 페르세우스 페르세우스 https://namu.wiki/w/페르세우스
325 19 페르세우스 페르세우스 개요 https://namu.wiki/w/페르세우스
326 19 페르세우스 페르세우스 생애 https://namu.wiki/w/페르세우스
327 19 페르세우스 페르세우스 탄생 https://namu.wiki/w/페르세우스
328 19 페르세우스 페르세우스 메두사를 처단하다 https://namu.wiki/w/페르세우스
329 19 페르세우스 페르세우스 안드로메다와의 만남 https://namu.wiki/w/페르세우스
330 19 페르세우스 페르세우스 어머니의 원수인 폴리덱테스를 처단하다 https://namu.wiki/w/페르세우스
331 19 페르세우스 페르세우스 결국 이루어진 신탁 https://namu.wiki/w/페르세우스
332 19 페르세우스 페르세우스 아르고스를 떠나다 https://namu.wiki/w/페르세우스
333 19 페르세우스 페르세우스 미케네의 초대 왕 https://namu.wiki/w/페르세우스
334 19 페르세우스 페르세우스 Y 최후 https://namu.wiki/w/페르세우스
335 19 페르세우스 페르세우스 Y 평가 https://namu.wiki/w/페르세우스
336 19 페르세우스 페르세우스 가계 및 혈통 https://namu.wiki/w/페르세우스
337 19 페르세우스 페르세우스 Y 기타 https://namu.wiki/w/페르세우스
338 19 페르세우스 페르세우스 Y 대중매체 https://namu.wiki/w/페르세우스
339 19 페르세우스 페르세우스 관련 문서 https://namu.wiki/w/페르세우스
340 19 페르세우스 페르세우스 동음이의어 https://namu.wiki/w/페르세우스
341 19 페르세우스 페르세우스 마케도니아의 군주 https://namu.wiki/w/페르세우스
342 19 페르세우스 페르세우스 소련의 스파이 https://namu.wiki/w/페르세우스
343 20 테세우스 테세우스 테세우스 https://namu.wiki/w/테세우스
344 20 테세우스 테세우스 개요 https://namu.wiki/w/테세우스
345 20 테세우스 테세우스 일대기 https://namu.wiki/w/테세우스
346 20 테세우스 테세우스 아버지를 찾아 https://namu.wiki/w/테세우스
347 20 테세우스 테세우스 미노타우로스 퇴치 https://namu.wiki/w/테세우스
348 20 테세우스 테세우스 오이디푸스와의 만남 https://namu.wiki/w/테세우스
349 20 테세우스 테세우스 몰락 https://namu.wiki/w/테세우스
350 20 테세우스 테세우스 해설 https://namu.wiki/w/테세우스
351 20 테세우스 테세우스 Y 평가 https://namu.wiki/w/테세우스
352 20 테세우스 테세우스 Y 기타 https://namu.wiki/w/테세우스
353 20 테세우스 테세우스 대중 문화에서 https://namu.wiki/w/테세우스
354 20 테세우스 테세우스 Y 만화로 보는 그리스 로마 신화 https://namu.wiki/w/테세우스
355 20 테세우스 테세우스 그 외 https://namu.wiki/w/테세우스
356 20 테세우스 테세우스 관련 문서 https://namu.wiki/w/테세우스
357 21 오디세우스 오디세우스 오디세우스 https://namu.wiki/w/오디세우스
358 21 오디세우스 오디세우스 개요 https://namu.wiki/w/오디세우스
359 21 오디세우스 오디세우스 어원 https://namu.wiki/w/오디세우스
360 21 오디세우스 오디세우스 특징 https://namu.wiki/w/오디세우스
361 21 오디세우스 오디세우스 일대기 https://namu.wiki/w/오디세우스
362 21 오디세우스 오디세우스 트로이 전쟁 https://namu.wiki/w/오디세우스
363 21 오디세우스 오디세우스 병역기피 시도 https://namu.wiki/w/오디세우스
364 21 오디세우스 오디세우스 결국은 참전하다 https://namu.wiki/w/오디세우스
365 21 오디세우스 오디세우스 전쟁 이후 https://namu.wiki/w/오디세우스
366 21 오디세우스 오디세우스 《오디세이아》 https://namu.wiki/w/오디세우스
367 21 오디세우스 오디세우스 키클롭스의 동굴 https://namu.wiki/w/오디세우스
368 21 오디세우스 오디세우스 아이올로스의 섬 https://namu.wiki/w/오디세우스
369 21 오디세우스 오디세우스 키르케의 섬 https://namu.wiki/w/오디세우스
370 21 오디세우스 오디세우스 헬리오스의 소 https://namu.wiki/w/오디세우스
371 21 오디세우스 오디세우스 칼립소의 섬 https://namu.wiki/w/오디세우스
372 21 오디세우스 오디세우스 파이에케스 족(族) https://namu.wiki/w/오디세우스
373 21 오디세우스 오디세우스 구혼자들 처단 https://namu.wiki/w/오디세우스
374 21 오디세우스 오디세우스 말년 https://namu.wiki/w/오디세우스
375 21 오디세우스 오디세우스 《텔레고네이아》 https://namu.wiki/w/오디세우스
376 21 오디세우스 오디세우스 《비블리오테케》 https://namu.wiki/w/오디세우스
377 21 오디세우스 오디세우스 그 외 https://namu.wiki/w/오디세우스
378 21 오디세우스 오디세우스 가계도 https://namu.wiki/w/오디세우스
379 21 오디세우스 오디세우스 Y 평가 https://namu.wiki/w/오디세우스
380 21 오디세우스 오디세우스 Y 긍정적 평가 https://namu.wiki/w/오디세우스
381 21 오디세우스 오디세우스 Y 부정적 평가 https://namu.wiki/w/오디세우스
382 21 오디세우스 오디세우스 총평 https://namu.wiki/w/오디세우스
383 21 오디세우스 오디세우스 Y 대중매체에서의 오디세우스 https://namu.wiki/w/오디세우스
384 21 오디세우스 오디세우스 Y 기타 https://namu.wiki/w/오디세우스
385 22 아킬레우스 아킬레우스 아킬레우스 https://namu.wiki/w/아킬레우스
386 22 아킬레우스 아킬레우스 개요 https://namu.wiki/w/아킬레우스
387 22 아킬레우스 아킬레우스 생애 https://namu.wiki/w/아킬레우스
388 22 아킬레우스 아킬레우스 《일리아스》 https://namu.wiki/w/아킬레우스
389 22 아킬레우스 아킬레우스 아가멤논과의 불화 https://namu.wiki/w/아킬레우스
390 22 아킬레우스 아킬레우스 Y 파트로클로스의 죽음 https://namu.wiki/w/아킬레우스
391 22 아킬레우스 아킬레우스 헥토르와의 결투 https://namu.wiki/w/아킬레우스
392 22 아킬레우스 아킬레우스 《아이디오피스》 https://namu.wiki/w/아킬레우스
393 22 아킬레우스 아킬레우스 Y 사후 전승 https://namu.wiki/w/아킬레우스
394 22 아킬레우스 아킬레우스 인간 관계 https://namu.wiki/w/아킬레우스
395 22 아킬레우스 아킬레우스 파트로클로스 https://namu.wiki/w/아킬레우스
396 22 아킬레우스 아킬레우스 그 외 https://namu.wiki/w/아킬레우스
397 22 아킬레우스 아킬레우스 가계도 https://namu.wiki/w/아킬레우스
398 22 아킬레우스 아킬레우스 Y 평가 https://namu.wiki/w/아킬레우스
399 22 아킬레우스 아킬레우스 인품 https://namu.wiki/w/아킬레우스
400 22 아킬레우스 아킬레우스 Y 긍정적 평가 https://namu.wiki/w/아킬레우스
401 22 아킬레우스 아킬레우스 Y 부정적 평가 https://namu.wiki/w/아킬레우스
402 22 아킬레우스 아킬레우스 현대인의 관점에서 https://namu.wiki/w/아킬레우스
403 22 아킬레우스 아킬레우스 옹호 https://namu.wiki/w/아킬레우스
404 22 아킬레우스 아킬레우스 총평 https://namu.wiki/w/아킬레우스
405 22 아킬레우스 아킬레우스 다른 영웅들과의 비교 https://namu.wiki/w/아킬레우스
406 22 아킬레우스 아킬레우스 Y 여담 https://namu.wiki/w/아킬레우스
407 22 아킬레우스 아킬레우스 관련 문서 https://namu.wiki/w/아킬레우스
408 23 오르페우스 오르페우스 오르페우스 https://namu.wiki/w/오르페우스
409 23 오르페우스 오르페우스 개요 https://namu.wiki/w/오르페우스
410 23 오르페우스 오르페우스 Y 신화에서의 행적 https://namu.wiki/w/오르페우스
411 23 오르페우스 오르페우스 Y 평가 https://namu.wiki/w/오르페우스
412 23 오르페우스 오르페우스 오르페우스 밀교 https://namu.wiki/w/오르페우스
413 23 오르페우스 오르페우스 Y 유사한 신화 https://namu.wiki/w/오르페우스
414 23 오르페우스 오르페우스 Y 미디어 https://namu.wiki/w/오르페우스
415 23 오르페우스 오르페우스 Y 만화로 보는 그리스 로마 신화 https://namu.wiki/w/오르페우스
416 23 오르페우스 오르페우스 올림포스 가디언 https://namu.wiki/w/오르페우스
417 23 오르페우스 오르페우스 그 외 https://namu.wiki/w/오르페우스
418 23 오르페우스 오르페우스 모티브로 삼은 작품 https://namu.wiki/w/오르페우스
419 24 이아손 이아손 이아손 https://namu.wiki/w/이아손
420 24 이아손 이아손 개요 https://namu.wiki/w/이아손
421 24 이아손 이아손 일대기 https://namu.wiki/w/이아손
422 24 이아손 이아손 Y 기타 https://namu.wiki/w/이아손
423 24 이아손 이아손 Y 이름에 대하여 https://namu.wiki/w/이아손
424 24 이아손 이아손 각종 매체에서의 이아손 https://namu.wiki/w/이아손
425 24 이아손 이아손 아르고 황금 대탐험 https://namu.wiki/w/이아손
426 24 이아손 이아손 신곡 지옥편 https://namu.wiki/w/이아손
427 24 이아손 이아손 갓 오브 워 2 https://namu.wiki/w/이아손
428 24 이아손 이아손 Y 만화로 보는 그리스 로마 신화 https://namu.wiki/w/이아손
429 24 이아손 이아손 올림포스 가디언 https://namu.wiki/w/이아손
430 24 이아손 이아손 Fate 시리즈 https://namu.wiki/w/이아손
431 24 이아손 이아손 Rise of the Argonauts https://namu.wiki/w/이아손
432 24 이아손 이아손 하루만 네가 되고 싶어 https://namu.wiki/w/이아손
433 24 이아손 이아손 Y 초등 인문학 그리스 로마 신화 https://namu.wiki/w/이아손
434 24 이아손 이아손 Y 신화 속 양치기 노예가 되었다 https://namu.wiki/w/이아손
435 24 이아손 이아손 R-TYPE 시리즈 https://namu.wiki/w/이아손
436 25 메두사 메두사 메두사 https://namu.wiki/w/메두사
437 25 메두사 메두사 개요 https://namu.wiki/w/메두사
438 25 메두사 메두사 Y 전승 https://namu.wiki/w/메두사
439 25 메두사 메두사 Y 대중매체 https://namu.wiki/w/메두사
440 25 메두사 메두사 Y 기타 https://namu.wiki/w/메두사
441 25 메두사 메두사 외부 링크 https://namu.wiki/w/메두사
442 26 미다스 미다스 미다스 https://namu.wiki/w/미다스
443 26 미다스 미다스 개요 https://namu.wiki/w/미다스
444 26 미다스 미다스 행적 https://namu.wiki/w/미다스
445 26 미다스 미다스 Y 여담 https://namu.wiki/w/미다스
446 26 미다스 미다스 Y 대중매체에서 https://namu.wiki/w/미다스
447 26 미다스 미다스 Y 만화로 보는 그리스 로마 신화 https://namu.wiki/w/미다스
448 26 미다스 미다스 올림포스 가디언 https://namu.wiki/w/미다스
449 26 미다스 미다스 갓 오브 워: 고스트 오브 스파르타 https://namu.wiki/w/미다스
450 26 미다스 미다스 Y 그리스 로마 신화 : 전설의 수호자들 https://namu.wiki/w/미다스
451 27 시시포스 시시포스 시시포스 https://namu.wiki/w/시시포스
452 27 시시포스 시시포스 개요 https://namu.wiki/w/시시포스
453 27 시시포스 시시포스 Y 가족관계 https://namu.wiki/w/시시포스
454 27 시시포스 시시포스 일대기 https://namu.wiki/w/시시포스
455 27 시시포스 시시포스 아우톨리코스와의 대결 https://namu.wiki/w/시시포스
456 27 시시포스 시시포스 에뤼시크톤과의 악연 https://namu.wiki/w/시시포스
457 27 시시포스 시시포스 영원한 형벌 https://namu.wiki/w/시시포스
458 27 시시포스 시시포스 Y 대중매체 https://namu.wiki/w/시시포스
459 27 시시포스 시시포스 Y 여담 https://namu.wiki/w/시시포스
460 27 시시포스 시시포스 관련 문서 https://namu.wiki/w/시시포스
461 28 이카로스 이카로스 이카로스 https://namu.wiki/w/이카로스
462 28 이카로스 이카로스 개요 https://namu.wiki/w/이카로스
463 28 이카로스 이카로스 Y 이름을 딴 것들 https://namu.wiki/w/이카로스
464 28 이카로스 이카로스 Y 대중매체 https://namu.wiki/w/이카로스
465 28 이카로스 이카로스 서방 프로젝트의 등장인물 https://namu.wiki/w/이카로스
466 28 이카로스 이카로스 파이브 스타 스토리의 등장인물 발란셰 파티마 https://namu.wiki/w/이카로스

View File

@ -0,0 +1,913 @@
[
{
"title": "제우스",
"url": "https://namu.wiki/w/제우스",
"page_title": "제우스",
"keywords": [
"제우스",
"개요",
"특징",
"명칭",
"기원과 발전",
"행적",
"탄생",
"신들의 왕이 되다: 티타노마키아",
"티포노마키아",
"기간토마키아",
"트로이 전쟁",
"로마에서의 숭배",
"기나긴 통치의 끝",
"그리스도교와 제우스",
"신화의 해석",
"강간범 및 난봉꾼",
"제우스의 구애 및 사통, 강간 목록",
"능력",
"권능 / 전투력",
"무기",
"기타",
"매체에서의 등장"
],
"anecdote_keywords": [
"로마에서의 숭배",
"신화의 해석",
"기타"
],
"error": null,
"order": 1,
"name": "제우스"
},
{
"title": "헤라",
"url": "https://namu.wiki/w/헤라",
"page_title": "헤라",
"keywords": [
"헤라",
"개요",
"신격의 기원",
"행적",
"특징",
"외모",
"자식들",
"기타",
"대중매체",
"외부 링크"
],
"anecdote_keywords": [
"기타",
"대중매체"
],
"error": null,
"order": 2,
"name": "헤라"
},
{
"title": "포세이돈",
"url": "https://namu.wiki/w/포세이돈",
"page_title": "포세이돈",
"keywords": [
"포세이돈",
"개요",
"신격의 기원",
"어원",
"특징",
"외모",
"가계도",
"포세이돈의 구애 목록",
"위상",
"창작물에서의 포세이돈",
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
"만화로 읽는 초등 인문학 그리스 로마 신화",
"에이지 오브 미쏠로지",
"수학도둑",
"최강 시리즈",
"외부 링크",
"넵튠"
],
"anecdote_keywords": [
"창작물에서의 포세이돈",
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
"만화로 읽는 초등 인문학 그리스 로마 신화"
],
"error": null,
"order": 3,
"name": "포세이돈"
},
{
"title": "데메테르",
"url": "https://namu.wiki/w/데메테르",
"page_title": "데메테르",
"keywords": [
"데메테르",
"개요",
"신격의 기원",
"가족 관계",
"특징",
"납치된 딸 페르세포네를 찾아서",
"엘레우시스 설화",
"도마뱀이 된 아스칼라보스",
"엘레우시스 비교(秘敎)",
"아리온과 데스포이나",
"에뤼시크톤 이야기",
"이아시온과의 사랑",
"에리뉘스 테르포우시아",
"대중매체",
"외부 링크"
],
"anecdote_keywords": [
"가족 관계",
"엘레우시스 설화",
"에뤼시크톤 이야기",
"이아시온과의 사랑",
"대중매체"
],
"error": null,
"order": 4,
"name": "데메테르"
},
{
"title": "아테나",
"url": "https://namu.wiki/w/아테나",
"page_title": "아테나",
"keywords": [
"아테나",
"개요",
"신격의 기원",
"특징",
"다른 신들과의 관계",
"헤라",
"아레스",
"아프로디테",
"제우스",
"포세이돈",
"아르테미스",
"헤르메스",
"다른 신들",
"그 외 신화 속 행적",
"대중매체",
"외부 링크",
"한국어",
"영어",
"관련 문서"
],
"anecdote_keywords": [
"그 외 신화 속 행적",
"대중매체"
],
"error": null,
"order": 5,
"name": "아테나"
},
{
"title": "아폴론",
"url": "https://namu.wiki/w/아폴론",
"page_title": "아폴론",
"keywords": [
"아폴론",
"개요",
"신격의 기원",
"특징",
"태양신으로서",
"별칭",
"연애사",
"아폴론의 구애 목록",
"기타",
"대중매체",
"외부 링크",
"관련 문서"
],
"anecdote_keywords": [
"기타",
"대중매체"
],
"error": null,
"order": 6,
"name": "아폴론"
},
{
"title": "아르테미스",
"url": "https://namu.wiki/w/아르테미스",
"page_title": "아르테미스",
"keywords": [
"아르테미스",
"개요",
"신격의 기원",
"특징",
"탄생 신화",
"신격",
"달의 여신",
"사냥과 궁술의 여신",
"순결한 처녀신",
"남성과의 관계",
"미모",
"그리스 로마 신화에서의 행적들",
"악타이온",
"오리온",
"키오네",
"힙폴뤼토스",
"칼리돈의 멧돼지 사냥",
"헤라클레스의 12과업",
"기간토마키아",
"파리스의 심판",
"트로이 전쟁",
"아트레이드의 비극",
"오디세이아",
"아이네이스",
"오토스와 에피알테스",
"니오베의 자식 학살",
"아레스의 재판",
"브리토마르티스",
"티포노마키아",
"기타 신앙",
"소아시아의 아르테미스 신앙",
"별칭",
"대중매체"
],
"anecdote_keywords": [
"탄생 신화",
"그리스 로마 신화에서의 행적들",
"기타 신앙",
"대중매체"
],
"error": null,
"order": 7,
"name": "아르테미스"
},
{
"title": "아레스",
"url": "https://namu.wiki/w/아레스",
"page_title": "아레스",
"keywords": [
"아레스",
"개요",
"신격의 기원",
"위상",
"고대 그리스",
"아테나와의 비교",
"헬레니즘 시대",
"고대 로마",
"여러 전승",
"여자들과 자녀",
"외모와 연애사",
"아프로디테 설화",
"아버지로서",
"알킵페와 아레오파고스(Areopagus) 설화",
"그 외의 설화",
"알로아다이 설화",
"현대의 재평가?",
"기타",
"대중매체",
"외부 링크",
"영어",
"한국어",
"관련 문서"
],
"anecdote_keywords": [
"여러 전승",
"아프로디테 설화",
"알킵페와 아레오파고스(Areopagus) 설화",
"그 외의 설화",
"알로아다이 설화",
"현대의 재평가?",
"기타",
"대중매체"
],
"error": null,
"order": 8,
"name": "아레스"
},
{
"title": "아프로디테",
"url": "https://namu.wiki/w/아프로디테",
"page_title": "아프로디테",
"keywords": [
"아프로디테",
"개요",
"신화에서의 묘사",
"기원 및 해석",
"탄생 신화",
"아레스와의 관계",
"헤르메스와의 관계",
"퓌그말리온 신화",
"이피스와 아낙사레테",
"탄호이저 전설",
"프쉬케 이야기",
"아도니스 이야기",
"아탈란테와 힙포메네스",
"아이네이스",
"네리테스 이야기",
"남자들과 자녀",
"여담",
"창작물"
],
"anecdote_keywords": [
"신화에서의 묘사",
"탄생 신화",
"퓌그말리온 신화",
"탄호이저 전설",
"프쉬케 이야기",
"아도니스 이야기",
"네리테스 이야기",
"여담",
"창작물"
],
"error": null,
"order": 9,
"name": "아프로디테"
},
{
"title": "헤파이스토스",
"url": "https://namu.wiki/w/헤파이스토스",
"page_title": "헤파이스토스",
"keywords": [
"헤파이스토스",
"개요",
"신격의 기원",
"특징",
"헤파이스토스의 부인들",
"능력",
"기술자",
"전투력",
"기타",
"매체에서",
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
"망치하르방",
"그리스 로마 신화 - 신들의 사생활",
"소울 칼리버 시리즈",
"외부 링크",
"한국어",
"영어",
"일본어",
"중국어"
],
"anecdote_keywords": [
"기타",
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
"그리스 로마 신화 - 신들의 사생활"
],
"error": null,
"order": 10,
"name": "헤파이스토스"
},
{
"title": "헤르메스",
"url": "https://namu.wiki/w/헤르메스",
"page_title": "헤르메스",
"keywords": [
"헤르메스",
"개요",
"신격의 기원",
"특징",
"성장 과정",
"신화에서",
"현실에서",
"창작물",
"해당 신에게서 이름을 딴 것들",
"해당 신이 모델이 된 인물들",
"신을 죽이는 방법",
"쿠키런 시리즈",
"쿠키런: 킹덤",
"해당 신이 등장하는 창작물",
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
"원더우먼",
"하데스타운",
"관련 문서"
],
"anecdote_keywords": [
"신화에서",
"창작물",
"해당 신에게서 이름을 딴 것들",
"해당 신이 등장하는 창작물",
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화"
],
"error": null,
"order": 11,
"name": "헤르메스"
},
{
"title": "디오니소스",
"url": "https://namu.wiki/w/디오니소스",
"page_title": "디오니소스",
"keywords": [
"디오니소스",
"개요",
"신격의 기원",
"상세",
"신화 속 외모 묘사",
"탄생과 성장",
"제우스와 세멜레",
"제우스와 페르세포네",
"어린 시절",
"숭배",
"신화 속 디오니소스",
"디오니소스의 별칭들",
"대중 매체에서",
"만화로 보는 그리스 로마 신화",
"홍은영의 그리스 로마 신화",
"만화로 읽는 초등 인문학 그리스 로마 신화",
"올림포스 가디언",
"로어 올림푸스",
"던전에서 만남을 추구하면 안 되는 걸까",
"트랜스포머 시리즈",
"2024 파리 올림픽",
"디지몬 시리즈",
"기타"
],
"anecdote_keywords": [
"신화 속 외모 묘사",
"숭배",
"신화 속 디오니소스",
"만화로 보는 그리스 로마 신화",
"홍은영의 그리스 로마 신화",
"만화로 읽는 초등 인문학 그리스 로마 신화",
"기타"
],
"error": null,
"order": 12,
"name": "디오니소스"
},
{
"title": "하데스",
"url": "https://namu.wiki/w/하데스",
"page_title": "하데스",
"keywords": [
"하데스",
"개요",
"신격의 기원",
"특징",
"가계도",
"페르세포네 납치와 사랑",
"기타",
"대중문화 속의 하데스",
"목록",
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
"에이지 오브 미쏠로지",
"삼국지 천명 시리즈에서의 하데스",
"가수 강수지의 노래",
"드라마 유령에 등장하는 천재 해커",
"관련 문서",
"외부 링크",
"한국어",
"영어",
"일본어",
"중국어"
],
"anecdote_keywords": [
"페르세포네 납치와 사랑",
"기타",
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화"
],
"error": null,
"order": 13,
"name": "하데스"
},
{
"title": "헤스티아",
"url": "https://namu.wiki/w/헤스티아",
"page_title": "헤스티아",
"keywords": [
"헤스티아",
"개요",
"신격의 기원",
"특징",
"지위와 역할",
"최고의 선신",
"올림포스 최초의 처녀신",
"너무 적은 비중",
"대중매체",
"관련 문서"
],
"anecdote_keywords": [
"대중매체"
],
"error": null,
"order": 14,
"name": "헤스티아"
},
{
"title": "프로메테우스",
"url": "https://namu.wiki/w/프로메테우스",
"page_title": "프로메테우스",
"keywords": [
"프로메테우스",
"개요",
"특징",
"행적",
"고전",
"매체",
"올림포스 가디언",
"장난꾸러기 신 이야기 코로코로 포론",
"관련 문서"
],
"anecdote_keywords": [
"장난꾸러기 신 이야기 코로코로 포론"
],
"error": null,
"order": 15,
"name": "프로메테우스"
},
{
"title": "크로노스",
"url": "https://namu.wiki/w/크로노스",
"page_title": "크로노스",
"keywords": [
"크로노스",
"개요",
"농경의 신",
"매체에서의 모습",
"시간의 신"
],
"anecdote_keywords": [],
"error": null,
"order": 16,
"name": "크로노스"
},
{
"title": "에로스",
"url": "https://namu.wiki/w/에로스",
"page_title": "에로스",
"keywords": [
"에로스",
"개요",
"전승",
"카오스와 동시대의 신으로 묘사되는 경우",
"아프로디테의 아들로 묘사되는 경우",
"프시케 설화",
"기타",
"대중매체"
],
"anecdote_keywords": [
"전승",
"프시케 설화",
"기타",
"대중매체"
],
"error": null,
"order": 17,
"name": "에로스"
},
{
"title": "헤라클레스",
"url": "https://namu.wiki/w/헤라클레스",
"page_title": "헤라클레스",
"keywords": [
"헤라클레스",
"개요",
"특징",
"이름",
"주요 전승들",
"탄생",
"성장기",
"헤라클레스의 선택",
"모험의 시작",
"12과업",
"네메아의 사자 처치",
"히드라 처치",
"황금뿔 사슴 타이게테 생포",
"에리만토스의 멧돼지 생포",
"아우게이아스 왕의 우리 청소",
"스팀팔로스의 새 사냥",
"크레타의 황소 생포",
"디오메데스 왕의 식인 말 생포",
"히폴리테의 허리띠 구해오기",
"게리온의 소를 데려오기",
"황금사과를 구해오기",
"케르베로스 생포",
"옴팔레 여왕의 노예",
"그 이후",
"최후",
"불사의 신이 되다",
"다양한 전승",
"스파르타의 전승",
"아르고 호 원정",
"VS 신",
"테세우스 구출",
"트로이 침공",
"해학",
"헤라클레스의 기둥",
"헤라클레스의 대제단",
"기타 전승 및 언급",
"알레고리적 해석",
"현대인의 관점",
"가족관계",
"대중매체에서",
"여담"
],
"anecdote_keywords": [
"이름",
"주요 전승들",
"최후",
"다양한 전승",
"스파르타의 전승",
"기타 전승 및 언급",
"가족관계",
"대중매체에서",
"여담"
],
"error": null,
"order": 18,
"name": "헤라클레스"
},
{
"title": "페르세우스",
"url": "https://namu.wiki/w/페르세우스",
"page_title": "페르세우스",
"keywords": [
"페르세우스",
"개요",
"생애",
"탄생",
"메두사를 처단하다",
"안드로메다와의 만남",
"어머니의 원수인 폴리덱테스를 처단하다",
"결국 이루어진 신탁",
"아르고스를 떠나다",
"미케네의 초대 왕",
"최후",
"평가",
"가계 및 혈통",
"기타",
"대중매체",
"관련 문서",
"동음이의어",
"마케도니아의 군주",
"소련의 스파이"
],
"anecdote_keywords": [
"최후",
"평가",
"기타",
"대중매체"
],
"error": null,
"order": 19,
"name": "페르세우스"
},
{
"title": "테세우스",
"url": "https://namu.wiki/w/테세우스",
"page_title": "테세우스",
"keywords": [
"테세우스",
"개요",
"일대기",
"아버지를 찾아",
"미노타우로스 퇴치",
"오이디푸스와의 만남",
"몰락",
"해설",
"평가",
"기타",
"대중 문화에서",
"만화로 보는 그리스 로마 신화",
"그 외",
"관련 문서"
],
"anecdote_keywords": [
"평가",
"기타",
"만화로 보는 그리스 로마 신화"
],
"error": null,
"order": 20,
"name": "테세우스"
},
{
"title": "오디세우스",
"url": "https://namu.wiki/w/오디세우스",
"page_title": "오디세우스",
"keywords": [
"오디세우스",
"개요",
"어원",
"특징",
"일대기",
"트로이 전쟁",
"병역기피 시도",
"결국은 참전하다",
"전쟁 이후",
"《오디세이아》",
"키클롭스의 동굴",
"아이올로스의 섬",
"키르케의 섬",
"헬리오스의 소",
"칼립소의 섬",
"파이에케스 족(族)",
"구혼자들 처단",
"말년",
"《텔레고네이아》",
"《비블리오테케》",
"그 외",
"가계도",
"평가",
"긍정적 평가",
"부정적 평가",
"총평",
"대중매체에서의 오디세우스",
"기타"
],
"anecdote_keywords": [
"평가",
"긍정적 평가",
"부정적 평가",
"대중매체에서의 오디세우스",
"기타"
],
"error": null,
"order": 21,
"name": "오디세우스"
},
{
"title": "아킬레우스",
"url": "https://namu.wiki/w/아킬레우스",
"page_title": "아킬레우스",
"keywords": [
"아킬레우스",
"개요",
"생애",
"《일리아스》",
"아가멤논과의 불화",
"파트로클로스의 죽음",
"헥토르와의 결투",
"《아이디오피스》",
"사후 전승",
"인간 관계",
"파트로클로스",
"그 외",
"가계도",
"평가",
"인품",
"긍정적 평가",
"부정적 평가",
"현대인의 관점에서",
"옹호",
"총평",
"다른 영웅들과의 비교",
"여담",
"관련 문서"
],
"anecdote_keywords": [
"파트로클로스의 죽음",
"사후 전승",
"평가",
"긍정적 평가",
"부정적 평가",
"여담"
],
"error": null,
"order": 22,
"name": "아킬레우스"
},
{
"title": "오르페우스",
"url": "https://namu.wiki/w/오르페우스",
"page_title": "오르페우스",
"keywords": [
"오르페우스",
"개요",
"신화에서의 행적",
"평가",
"오르페우스 밀교",
"유사한 신화",
"미디어",
"만화로 보는 그리스 로마 신화",
"올림포스 가디언",
"그 외",
"모티브로 삼은 작품"
],
"anecdote_keywords": [
"신화에서의 행적",
"평가",
"유사한 신화",
"미디어",
"만화로 보는 그리스 로마 신화"
],
"error": null,
"order": 23,
"name": "오르페우스"
},
{
"title": "이아손",
"url": "https://namu.wiki/w/이아손",
"page_title": "이아손",
"keywords": [
"이아손",
"개요",
"일대기",
"기타",
"이름에 대하여",
"각종 매체에서의 이아손",
"아르고 황금 대탐험",
"신곡 지옥편",
"갓 오브 워 2",
"만화로 보는 그리스 로마 신화",
"올림포스 가디언",
"Fate 시리즈",
"Rise of the Argonauts",
"하루만 네가 되고 싶어",
"초등 인문학 그리스 로마 신화",
"신화 속 양치기 노예가 되었다",
"R-TYPE 시리즈"
],
"anecdote_keywords": [
"기타",
"이름에 대하여",
"만화로 보는 그리스 로마 신화",
"초등 인문학 그리스 로마 신화",
"신화 속 양치기 노예가 되었다"
],
"error": null,
"order": 24,
"name": "이아손"
},
{
"title": "메두사",
"url": "https://namu.wiki/w/메두사",
"page_title": "메두사",
"keywords": [
"메두사",
"개요",
"전승",
"대중매체",
"기타",
"외부 링크"
],
"anecdote_keywords": [
"전승",
"대중매체",
"기타"
],
"error": null,
"order": 25,
"name": "메두사"
},
{
"title": "미다스",
"url": "https://namu.wiki/w/미다스",
"page_title": "미다스",
"keywords": [
"미다스",
"개요",
"행적",
"여담",
"대중매체에서",
"만화로 보는 그리스 로마 신화",
"올림포스 가디언",
"갓 오브 워: 고스트 오브 스파르타",
"그리스 로마 신화 : 전설의 수호자들"
],
"anecdote_keywords": [
"여담",
"대중매체에서",
"만화로 보는 그리스 로마 신화",
"그리스 로마 신화 : 전설의 수호자들"
],
"error": null,
"order": 26,
"name": "미다스"
},
{
"title": "시시포스",
"url": "https://namu.wiki/w/시시포스",
"page_title": "시시포스",
"keywords": [
"시시포스",
"개요",
"가족관계",
"일대기",
"아우톨리코스와의 대결",
"에뤼시크톤과의 악연",
"영원한 형벌",
"대중매체",
"여담",
"관련 문서"
],
"anecdote_keywords": [
"가족관계",
"대중매체",
"여담"
],
"error": null,
"order": 27,
"name": "시시포스"
},
{
"title": "이카로스",
"url": "https://namu.wiki/w/이카로스",
"page_title": "이카로스",
"keywords": [
"이카로스",
"개요",
"이름을 딴 것들",
"대중매체",
"서방 프로젝트의 등장인물",
"파이브 스타 스토리의 등장인물 발란셰 파티마"
],
"anecdote_keywords": [
"이름을 딴 것들",
"대중매체"
],
"error": null,
"order": 28,
"name": "이카로스"
}
]

View File

@ -0,0 +1,292 @@
# -*- 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

View File

@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
키워드 병맛 그리스 로마 신화 마케팅 쇼츠 (완전 자동, 원커맨드)
==========================================================
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
1) 랜덤으로 그리스 로마 신화 ·영웅 '' 매칭
2) Gemini 병맛 마케팅 나레이션 생성
3) 장면 분할(스토리보드)
4) 장면별 이미지 + 음성 생성
5) Ken Burns + 자막( ) + 음성 합성 세로 mp4
6) 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)
모두 output/<작업폴더>/ 안에만 저장. 완성 중간 파일(assets) 자동 삭제.
선행: scraper.py 만든 data/greek_keywords_*.csv 있어야 .
API : GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
python animation_greekroman\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1 12만원
python animation_greekroman\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
python animation_greekroman\\main.py https://naver.me/xxxx
옵션:
--label 왕의휴식 결과 폴더 이름에 라벨 (기본: 키워드 앞부분 자동)
--king 제우스 특정 /영웅 고정 (기본: 랜덤)
--scenes 12 장면 =이미지 (기본 12)
--seconds 45 목표 길이 (기본 45)
--voice Puck TTS 목소리
--keep 중간 파일(assets) 삭제하지 않고 보존
"""
import argparse
import json
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
from google import genai
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 공유 모듈(naver.py, media.py)
import gen # 나레이션 + 스토리보드 생성(병합: 이전 gen_narration + gen_storyboard)
import render as MS # animation 영상합성 엔진(이전 make_short.py)
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
HERE = Path(__file__).parent
OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/
TEXT_MODEL = "gemini-2.5-flash"
def safe_name(s: str) -> str:
s = re.sub(r"[^\w가-힣]+", "_", s).strip("_")
return s[:30] or "short"
def main():
ap = argparse.ArgumentParser(
description="키워드 또는 네이버 링크 → 병맛 그리스 로마 신화 쇼츠 (완전 자동)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
)
# 마케팅 대상: 자유 키워드(여러 단어/문장) 또는 네이버 링크(섞어 써도 됨)
ap.add_argument("keywords", nargs="*",
help="마케팅 대상 키워드/문장, 또는 네이버 링크 (--link 없이 링크만 줘도 됨)")
# 결과 폴더 라벨
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
# 생성 옵션
ap.add_argument("--king", help="특정 신/영웅 고정 (예: 제우스, 기본: 랜덤)")
ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
ap.add_argument("--keep", action="store_true", help="중간 파일(assets) 보존")
ap.add_argument("--csv", help="썰 CSV 경로 (기본: output/ 최신)")
# 실제 사진(가게/메뉴판) 섞기 + 마지막 링크 카드
ap.add_argument("--images", default="load_image",
help="실제 사진 폴더. 가게 이름만 줘도 load_image/<가게>/ 를 찾음 "
"(예: --images 연산골막국수). 비었으면 전부 AI")
ap.add_argument("--max-real", type=int, default=0,
help="실제 사진으로 채울 최대 장면 수 (기본 0=폴더 사진 전부, 장면 수 한도 내)")
ap.add_argument("--real-scenes",
help="실제 사진 깔 장면 번호 1-based, 콤마구분 (예: 5,6,7). 주면 내용매칭 대신 수동")
ap.add_argument("--no-match", action="store_true",
help="실제 사진 내용 자동매칭(Gemini 비전) 끄고 위치 기반(뒤쪽 장면)으로")
ap.add_argument("--photos", type=int, default=4,
help="네이버 사진탭에서 자동 선별할 공식사진 수 (기본 4, --link 줄 때)")
ap.add_argument("--no-photos", action="store_true",
help="네이버 사진 자동 수집 끄기 (전부 AI 그림으로)")
ap.add_argument("--link", help="맨 마지막 화면에 띄울 링크/문구 (예: naver.me/xxxx)")
ap.add_argument("--link-seconds", type=float, default=3.0, help="링크 카드 길이(초)")
ap.add_argument("--store", help="링크 카드에 함께 띄울 가게 이름 (기본: 키워드 첫 부분 자동)")
ap.add_argument("--cta", default=MS.LINK_CTA, help=f"링크 카드 안내문구 (기본: '{MS.LINK_CTA}')")
ap.add_argument("--img-model", default=MS.IMG_MODEL,
help=f"이미지 생성 모델 (기본 {MS.IMG_MODEL}). 글자 또렷하게: gemini-3.1-flash-image / gemini-3-pro-image")
ap.add_argument("--allow-text", action="store_true",
help="그림 안 글자 금지 해제 (신형 모델로 간판에 진짜 이름 넣고 싶을 때)")
# 컷편집 느낌 (말 빠르게 + 컷 사이 침묵 제거 → 분량 ↓)
ap.add_argument("--speed", type=float, default=1.3,
help="말 속도 배율 (기본 1.3=30%% 빠르게, 음정 유지). 1.0=원본")
ap.add_argument("--no-trim", action="store_true",
help="컷 앞뒤 침묵 제거 끄기 (기본은 제거해 컷을 타이트하게)")
ap.add_argument("--tail", type=float, default=0.0,
help="각 컷 말 끝난 뒤 여운(초). 기본 0=말 끝나자마자 다음 컷. 여유 주려면 0.1~0.2")
ap.add_argument("--workers", type=int, default=4,
help="이미지·음성 동시 생성 수 (기본 4). 높이면 빠르지만 API 속도제한 주의")
ap.add_argument("--bgm",
help="배경음악(병맛 BGM) 파일 또는 폴더. 폴더면 무작위 1곡. "
"기본: bgm/ 폴더에 곡이 있으면 자동으로 무작위 사용. "
"특정 곡 고정하려면 --bgm 파일명. BGM 끄려면 --bgm none")
ap.add_argument("--bgm-volume", type=float, default=0.3,
help="BGM 볼륨 0~1 (기본 0.3). 나레이션 안 묻히게 조절")
ap.add_argument("--api-key")
args = ap.parse_args()
# --link 안 써도 됨: 위치 인자에 네이버 링크가 섞여 있으면 그걸 링크로 빼낸다
# (narration/kakao 처럼 `python main.py https://naver.me/xxxx` 만으로 실행 가능)
if not args.link:
link_tok = next((k for k in args.keywords if NV.looks_like_naver_place(k)), None)
if link_tok:
args.link = link_tok
args.keywords = [k for k in args.keywords if k != link_tok]
product = " ".join(args.keywords).strip()
# 키워드를 안 줬는데 --link 가 네이버 플레이스면 → AI 브리핑을 긁어와 마케팅 키워드로 사용
brief_store = None
if not product and NV.looks_like_naver_place(args.link or ""):
print("■ 키워드 없음 → 네이버 AI 브리핑 자동 수집...")
try:
brief_store, bullets, _cat = NV.fetch_briefing(args.link) # 업종(cat)은 animation 미사용
except Exception as e:
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
if not bullets:
sys.exit(f"[!] '{brief_store or '이 가게'}'에 AI 브리핑이 없어요. "
"키워드를 직접 주거나, 브리핑 있는 가게 링크를 쓰세요.")
product = " ".join(bullets)
print(f"■ 가게: {brief_store or '?'} / 브리핑 {len(bullets)}줄 → 키워드로 사용")
for b in bullets:
print(f"{b}")
if not product:
sys.exit("[!] 마케팅 키워드가 필요해. 예: python auto_short.py 감성 펜션 ... "
"또는 --link 에 네이버 가게 링크(AI 브리핑 자동 수집).")
api_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not api_key:
sys.exit("[!] API 키 없음. setx GEMINI_API_KEY \"\" 후 새 터미널, 또는 --api-key.")
csv_path = Path(args.csv) if args.csv else gen.latest_csv()
rows = gen.load_rows(csv_path)
client = genai.Client(api_key=api_key)
OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
# 네이버 링크 주고 사진 폴더를 직접 안 줬으면 → 사진탭 공식사진을 자동 수집·선별
if (not args.no_photos and NV.looks_like_naver_place(args.link or "")
and args.images == "load_image"):
store_for_dir = (brief_store or args.store or args.label
or " ".join(args.keywords[:2]) or "naver_store")
photo_dir = HERE / "load_image" / safe_name(store_for_dir)
existing = [p for p in photo_dir.glob("*.jpg")] if photo_dir.exists() else []
if existing:
print(f"■ 사진 캐시 재사용: {photo_dir} ({len(existing)}장)")
args.images = safe_name(store_for_dir)
else:
print(f"■ 네이버 사진탭 → 공식사진 {args.photos}장 자동 수집·선별...")
try:
_, finals = NV.fetch_and_select(args.link, photo_dir,
n=args.photos, client=client)
if finals:
args.images = safe_name(store_for_dir)
print(f"■ 사진 {len(finals)}장 준비 → {photo_dir}")
else:
print("■ 공식사진을 못 받았어요 → 전부 AI 그림으로 진행")
except Exception as e:
print(f"■ 사진 수집 실패({type(e).__name__}) → 전부 AI 그림으로 진행")
# 이 작업 전용 폴더 (모든 산출물은 여기 안에만)
label = args.label or " ".join(args.keywords[:3]) or brief_store or product[:20]
stem = f"{safe_name(label)}_{ts}"
job_dir = OUT_DIR / stem
job_dir.mkdir(parents=True, exist_ok=True)
# 1) 랜덤(또는 지정) 인물 썰 매칭
seed = gen.pick_seed(rows, king=args.king)
print(f"■ 작업 폴더 : {job_dir}")
print(f"■ 마케팅 대상 : {product}")
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
# 2) 병맛 나레이션
print("[1/4] 나레이션 생성...")
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds)
(job_dir / "narration.json").write_text(
json.dumps(narr, ensure_ascii=False, indent=2), encoding="utf-8")
# 사람이 바로 읽는 나레이션 '가사' 텍스트 (제목 + 본문 + 해시태그)
lyric = (f"[제목] {narr.get('title','')}\n"
f"[소재] {seed['king']} / \"{seed['keyword']}\"\n"
f"[마케팅] {product}\n"
f"{'-'*40}\n"
f"{narr.get('full_narration','')}\n")
if narr.get("hashtags"):
lyric += f"\n{' '.join(narr['hashtags'])}\n"
(job_dir / "narration.txt").write_text(lyric, encoding="utf-8")
print(f" \"{narr['title']}\" ({len(narr['full_narration'])}자)")
# 3) 스토리보드(장면 분할)
print("[2/4] 스토리보드 분할...")
sb = gen.make_storyboard(client, TEXT_MODEL, narr["full_narration"],
product, seed["king"], scenes=args.scenes)
(job_dir / "storyboard.json").write_text(
json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" 장면 {len(sb['scenes'])}")
# 4) 이미지 + 음성 + 영상 합성 (assets·mp4 모두 job_dir 안)
# 실제 사진(가게/메뉴판)을 내용 맞는 장면에 AI 배경과 합성, 마지막에 링크 카드 추가
real_images, real_scenes = MS.resolve_real_images(args.images, args.real_scenes)
if not real_images:
print(f"■ 실제 사진 : 없음 ('{args.images}' 폴더 비었거나 없음) → 전부 AI")
# --bgm 안 줘도 bgm/ 폴더에 곡이 있으면 자동으로 무작위 1곡 사용
bgm = MS.resolve_bgm(args.bgm or "bgm")
_off = str(args.bgm or "").strip().lower() in ("none", "off", "no")
if args.bgm and not _off and not bgm:
print(f"■ BGM '{args.bgm}' 를 못 찾음 → BGM 없이 진행")
elif bgm:
print(f"■ BGM : {bgm.name} (bgm/ 무작위)")
print("[3/4] 이미지·음성 생성 + [4/4] 영상 합성...")
store = args.store or brief_store or product.split(",")[0].strip() or None
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
real_images=real_images, real_scenes=real_scenes,
max_real=args.max_real, match_content=not args.no_match,
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
img_model=args.img_model, allow_text=args.allow_text,
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
shutil.rmtree(job_dir / "assets", ignore_errors=True)
for junk in ("narration.json", "storyboard.json"):
(job_dir / junk).unlink(missing_ok=True)
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
print(f"\n✅ 완성: {out_mp4}")
print(f" {seed['king']} 썰 · {product}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,884 @@
# -*- coding: utf-8 -*-
"""
스토리보드 완성 쇼츠 영상(mp4) 조립 [A방식: 정지 일러스트 + 모션]
=====================================================================
gen_storyboard.py 만든 스토리보드를 받아 장면별로:
1) Gemini 이미지로 일러스트 생성 (캐릭터 일관성 = 1 컷을 레퍼런스로 물림)
2) Gemini TTS 장면 나레이션 음성 생성 (장면 길이 = 음성 길이)
3) ffmpeg Ken Burns(zoompan ) + 자막(PNG 오버레이) + 음성 합성 세로 1080x1920 mp4
(Intel QSV 하드웨어 인코딩 자동 사용, 없으면 libx264 ultrafast)
이미지/음성은 output/ 장면별로 캐싱한다. 재실행 이미 있으면 재사용(재과금 X).
다시 그리고 싶으면 --force.
설치: pip install google-genai pillow imageio-ffmpeg (moviepy 불필요 ffmpeg 직접 렌더)
실행:
python make_short.py # 최신 *_storyboard.json
python make_short.py --storyboard output/xxx_storyboard.json --voice Puck
python make_short.py --force # 이미지/음성 새로 생성
"""
import json
import re
import shutil
import subprocess
import sys
import time
import wave
from pathlib import Path
from google.genai import types
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import media as MU
HERE = Path(__file__).parent
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/
W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함)
BRAND_TITLE = "썰록" # 상단 밴드 제목
IMG_MODEL = "gemini-3-pro-image" # 최상위 이미지 모델(인체·화풍 품질 우선). 저렴하게: gemini-3.1-flash-image
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
TTS_VOICE = "Puck"
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
"능청스럽게 약간 오버해서 읽어줘:")
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf")
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
TITLE_FONT = str(SHARED / "fonts" / "SongMyung-Regular.ttf")
TITLE_SIZE = 58
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
# → 모든 이미지 생성에서 '글자 자체를 그리지 말라'고 강하게 지시. (가게 이름은 자막/카드로 표기)
NO_TEXT = (" CRITICAL: Render NO text of any kind in the image — no letters, words, numbers, "
"Korean hangul, logos, or signage text. Keep every sign, banner, board and menu "
"completely BLANK with no writing on them.")
# ---------------------------------------------------------------- 데이터
IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
def resolve_real_images(images_dir, real_scenes_arg):
"""--images 값 → 정렬된 실제 사진 경로 리스트, --real-scenes → 정수 리스트.
images_dir 해석 순서 (가게마다 load_image/<가게>/ 나눠 담는 지원):
1) 절대경로면 그대로
2) 상대경로/이름이면 HERE/<> (: load_image/연산골막국수)
3) 그래도 없으면 HERE/load_image/<> (: --images 연산골막국수)
가장 먼저 '이미지가 들어있는' 폴더를 채택. 없으면 리스트.
"""
real_images, used = [], None
if images_dir:
d = Path(images_dir)
candidates = [d] if d.is_absolute() else [HERE / d, HERE / "load_image" / d]
for c in candidates:
if c.exists() and c.is_dir():
imgs = sorted(p for p in c.iterdir() if p.suffix.lower() in IMG_EXTS)
if imgs:
real_images, used = imgs, c
break
if used:
print(f"■ 실제 사진 폴더: {used} ({len(real_images)}장)")
real_scenes = None
if real_scenes_arg:
real_scenes = [int(x) for x in re.split(r"[,\s]+", str(real_scenes_arg).strip()) if x]
return real_images, real_scenes
# 실제 사진 카드를 합성하는 장면에서, AI 배경이 카드 자리를 비워두도록 주는 포즈 힌트
PRESENT_POSES = [
"Place the god character in the UPPER part of the frame, smiling and pointing down toward the "
"lower-center, leaving the lower-center area visually simple (a photo will be placed there).",
"Put the god character on the LEFT side presenting with one hand to the right-center, keeping the "
"right-center area simple and uncluttered (a photo will be placed there).",
"Show the god character at the BOTTOM looking up with sparkling eyes toward the upper-center, "
"keeping the upper-center area simple (a photo will be placed there).",
]
# PRESENT_POSES 순서에 맞춰 실제 사진 카드를 놓을 (가로,세로) 중심 비율
PRESENT_SPOTS = [(0.50, 0.62), (0.62, 0.50), (0.50, 0.40)]
# 신화만화풍 '눈' 그림체를 유지하되, 눈을 '떴을 때'의 모양만 고정한다.
# (모든 장면을 부릅뜬 눈으로 강제하면 감김/실눈/윙크 같은 표정 다양성이 사라짐)
EYE_STYLE = (
"When the character's eyes are open, they are the same beautiful expressive manhwa "
"eyes: large sparkling eyes with detailed irises and bright light reflections, in the "
"style of classic Korean mythology comics. Keep this consistent stylized eye art."
)
# 인체 해부학 가드: 레퍼런스 오염/인체 오류로 팔·손·소품이 중복 생성되는 것 방지.
ANATOMY_GUARD = (
"The main character must have correct human anatomy: exactly two arms and two hands, "
"one head, no extra or duplicated limbs, no duplicated props or instruments."
)
# 눈을 감거나(감김/실눈/윙크/졸림/웃음·울음으로 눈이 변하는) 장면용: 표정은 장면대로 두되 화풍만 유지.
EYE_FREE = (
"In this scene the character's eye EXPRESSION follows the moment — the eyes may be "
"gently closed, squinting, blinking, winking, teary, sleepy half-closed or dramatically "
"widened as appropriate. Keep the same sparkling manhwa drawing style, but do NOT "
"force wide-open eyes here."
)
# image_prompt(영어)에 아래 표현이 있으면 '눈 표정 장면'으로 보고 뜬눈 고정/레퍼런스를 푼다.
_EYE_EXPR_KW = (
"closed eye", "eyes closed", "close its eye", "close his eye", "shut eye",
"shut its eye", "squint", "narrowed eye", "wink", "half-closed", "half closed",
"sleepy", "sleeping", "asleep", "dozing", "doze", "napping", "yawn", "drowsy",
"crying", "sob", "weeping", "tears", "teary", "laughing", "giggl", "chuckl",
"wince", "grimac", "blink",
)
def scene_needs_eye_expression(sc) -> bool:
"""장면이 눈 감김/실눈/윙크 등 '뜬눈 아닌' 표정을 필요로 하면 True."""
text = ((sc.get("image_prompt") or "") + " " + (sc.get("caption") or "")).lower()
return any(k in text for k in _EYE_EXPR_KW)
# 캐릭터 공식 레퍼런스: 이 폴더에 이미지를 넣어두면(예: god_ref.png),
# 모든 영상의 모든 컷이 그 캐릭터의 얼굴/눈/화풍을 그대로 따라간다.
# ★ greekroman 은 '실제 신 모습'이라 조선/삼국지의 고양이 레퍼런스를 쓰지 않는다(폴더 비움).
# 비어 있으면 각 영상의 첫 컷이 자동 앵커가 되어 컷 간 일관성을 유지한다.
CHAR_REF_DIR = HERE / "character"
def load_char_ref():
"""Shorts/character/ 안의 첫 이미지 바이트 반환(없으면 None). 모든 영상의 캐릭터 앵커로 쓰인다."""
if not CHAR_REF_DIR.is_dir():
return None
for p in sorted(CHAR_REF_DIR.iterdir()):
if p.suffix.lower() in IMG_EXTS:
return p.read_bytes()
return None
# ---------------------------------------------------------------- 이미지
def gen_image(client, prompt, ref_bytes=None, model=None, no_text=True,
ref_mode="full") -> bytes:
"""Gemini 이미지 생성. ref_bytes 주면 캐릭터 일관성 위해 레퍼런스로 사용.
model: 사용할 이미지 모델 (없으면 IMG_MODEL). 신형일수록 글자 렌더링 우수.
no_text: True '그림에 글자 금지' 지시를 붙인다(글자 깨짐 방지).
ref_mode: 'full' = 레퍼런스의 캐릭터/복장/화풍을 통째로 유지.
'eyes' = 레퍼런스에선 '눈/표정만' 복제하고 복장·장면은 프롬프트대로.
"""
parts = []
if ref_bytes:
parts.append(types.Part.from_bytes(data=ref_bytes, mime_type="image/png"))
if ref_mode == "eyes":
prompt = ("Use the reference image ONLY to copy the character's EYES and "
"facial expression exactly: the same large sparkling expressive eyes "
"with detailed irises and light reflections. Do NOT copy the outfit, "
"hair, pose, framing or background from the reference — those follow "
"the scene description. New scene: " + prompt)
else:
# ★ '디자인만' 복사 — 포즈/소품까지 복사하면 팔·악기가 중복돼 그려진다(다지증 컷 원인)
prompt = ("Use the reference image ONLY for the character's IDENTITY: the same "
"face, hair, outfit design, colors and art style. Do NOT copy the "
"pose, arms, hands, props, objects or composition from the reference "
"image — draw the new scene completely fresh. New scene: " + prompt)
parts.append(types.Part.from_text(text=prompt + (NO_TEXT if no_text else "")))
resp = client.models.generate_content(
model=model or IMG_MODEL,
contents=parts,
config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
)
for p in resp.candidates[0].content.parts:
if getattr(p, "inline_data", None):
return p.inline_data.data
raise RuntimeError("이미지 파트 없음")
def composite_card(base_path, photo_path, out_path,
rel_w=0.60, center=(0.5, 0.60), angle=4.0, border=22):
"""AI 배경(base) 위에 '실제 사진'을 작은 폴라로이드 카드로 얹어 합성 저장.
사진은 원본 그대로(왜곡 X) 축소만 하고, 테두리+그림자+살짝 기울기로 자연스럽게 올린다.
rel_w: 카드 사진 가로 = 화면폭 * rel_w. center: 카드 중심 (가로,세로) 비율. angle: 기울기().
"""
from PIL import Image, ImageFilter
base = Image.open(base_path).convert("RGBA")
photo = Image.open(photo_path).convert("RGB")
cw = int(W * rel_w)
ph = int(photo.height * (cw / photo.width))
max_h = int(H * 0.40) # 너무 길면 높이 기준으로 제한
if ph > max_h:
ph, cw = max_h, int(photo.width * (max_h / photo.height))
photo = photo.resize((cw, ph), Image.LANCZOS)
# 폴라로이드 흰 카드 (아래쪽 여백 좀 더)
card = Image.new("RGBA", (cw + 2 * border, ph + 2 * border + int(border * 1.4)),
(255, 255, 255, 255))
card.paste(photo, (border, border))
card = card.rotate(angle, expand=True, resample=Image.BICUBIC)
cx, cy = int(W * center[0]), int(H * center[1])
x, y = cx - card.width // 2, cy - card.height // 2
# 그림자
alpha = card.split()[3]
shadow = Image.new("RGBA", card.size, (0, 0, 0, 0))
shadow.paste(Image.new("RGBA", card.size, (0, 0, 0, 150)), (0, 0), alpha)
shadow = shadow.filter(ImageFilter.GaussianBlur(18))
base.alpha_composite(shadow, (x + 10, y + 16))
base.alpha_composite(card, (x, y))
base.convert("RGB").save(out_path)
def cover_crop(src: Path, dst: Path):
"""이미지를 1080x1920 꽉 차게 cover-crop 해서 저장."""
im = Image.open(src).convert("RGB")
scale = max(W / im.width, H / im.height)
nw, nh = int(im.width * scale + 0.5), int(im.height * scale + 0.5)
im = im.resize((nw, nh), Image.LANCZOS)
left, top = (nw - W) // 2, (nh - H) // 2
im.crop((left, top, left + W, top + H)).save(dst)
# ---------------------------------------------------------------- 음성
def _extract_audio(resp):
"""TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호)."""
cands = getattr(resp, "candidates", None) or []
for c in cands:
content = getattr(c, "content", None)
parts = getattr(content, "parts", None) or [] if content else []
for p in parts:
inline = getattr(p, "inline_data", None)
if inline and inline.data:
m = re.search(r"rate=(\d+)", inline.mime_type or "")
return inline.data, (int(m.group(1)) if m else 24000)
return None
def gen_tts(client, text, voice, retries=4) -> bytes:
"""Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다."""
last = ""
for attempt in range(1, retries + 1):
try:
resp = client.models.generate_content(
model=TTS_MODEL,
contents=f"{TTS_STYLE}\n\n{text}",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
)
),
),
)
got = _extract_audio(resp)
if got:
return got
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
fr = ""
try:
fr = str(getattr(resp.candidates[0], "finish_reason", "") or "")
except Exception:
fr = "no-candidates"
last = f"빈 응답(finish_reason={fr or '?'})"
except Exception as e:
last = f"{type(e).__name__}: {e}"
if attempt < retries:
wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프
print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
end="", flush=True)
time.sleep(wait)
raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}")
def pcm_to_wav(pcm, path, rate):
with wave.open(str(path), "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
w.writeframes(pcm)
def wav_seconds(path: Path) -> float:
with wave.open(str(path), "rb") as w:
return w.getnframes() / w.getframerate()
def _atempo_chain(speed: float) -> list:
"""atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝."""
chain, s = [], speed
while s > 2.0:
chain.append("atempo=2.0"); s /= 2.0
while s < 0.5:
chain.append("atempo=0.5"); s *= 2.0
chain.append(f"atempo={s:.4f}")
return chain
def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True,
threshold="-38dB", keep=0.06):
"""컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기.
- trim_silence: / 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지).
silenceremove 앞을 자르고, areverse 뒤집어 다시 (=원래 ) 자른 복원.
keep: 잘라낸 끝에 남길 여유(). 0 이면 너무 칼같이 잘려 어색할 있어 약간 남긴다.
- speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다.
가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백.
"""
af = []
if trim_silence:
one = (f"silenceremove=start_periods=1:start_silence={keep}:"
f"start_threshold={threshold}:detection=peak")
af += [one, "areverse", one, "areverse"]
if speed and abs(speed - 1.0) > 1e-3:
af += _atempo_chain(speed)
if not af:
shutil.copyfile(src_wav, dst_wav); return
try:
_run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af),
"-ar", "24000", "-ac", "1", str(dst_wav)])
if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백
raise RuntimeError("가공 후 음성이 너무 짧음")
except (RuntimeError, OSError):
shutil.copyfile(src_wav, dst_wav)
# ---------------------------------------------------------------- 조립 (ffmpeg 직접 렌더)
FPS = 30
def _ffmpeg() -> str:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def _run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode != 0:
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
return r
def pick_encoder(exe) -> list:
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
test = subprocess.run(
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
"-c:v", "h264_qsv", "-f", "null", "-"],
capture_output=True, text=True, errors="replace")
if test.returncode == 0:
print(" 인코더: h264_qsv (Intel 하드웨어)")
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
print(" 인코더: libx264 (ultrafast)")
return ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"]
def _wrap_lines(draw, text, font, max_w):
"""글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈."""
lines, cur = [], ""
for ch in text:
if ch == "\n":
lines.append(cur); cur = ""; continue
if draw.textlength(cur + ch, font=font) <= max_w:
cur += ch
else:
lines.append(cur); cur = ch
if cur:
lines.append(cur)
return lines or [""]
def _blossom(d, cx, cy, r, color):
"""간단한 5장 꽃잎 벚꽃 장식(제목 양옆 무늬)."""
import math
pr = r * 0.62 # 꽃잎 반지름
for k in range(5):
a = math.radians(-90 + k * 72)
px, py = cx + r * 0.55 * math.cos(a), cy + r * 0.55 * math.sin(a)
d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color)
d.ellipse([cx - r * 0.32, cy - r * 0.32, cx + r * 0.32, cy + r * 0.32],
fill=(255, 238, 175, 255)) # 꽃심
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
from PIL import ImageDraw, ImageFont
band_h = int(H * BAND_RATIO)
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
# ── 제목: 조선 느낌 폰트(TITLE_FONT) + 양옆 벚꽃 + 아래 구분선(끝까지·불투명) ──
title_y = int(H * 0.012)
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
cx = W / 2
tw = d.textlength(title, font=tf)
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma")
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙
gap = 44
rose = (228, 150, 168, 255)
_blossom(d, cx - tw / 2 - gap, by, 17, rose)
_blossom(d, cx + tw / 2 + gap, by, 17, rose)
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬.
top = rule_y + 22
avail = band_h - top - 24
f = ImageFont.truetype(font_path, NARR_SIZE)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(NARR_SIZE * 1.4)
y = top + max(0, (avail - len(lines) * lh) // 2)
for ln in lines:
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
y += lh
img.save(out_path)
LINK_CTA = "링크는 고정댓글 확인" # ▼ 등 특수문자는 자막 폰트(주아체)에 없어 □로 깨짐 → 한글만
def render_link_card_png(base_img_path, link, out_path, font_path, store=None, cta=None):
"""배경 사진을 어둡게 깔고 가게 이름 + 안내문구 + 링크를 큼직하게 그린 1080x1920 PNG."""
from PIL import ImageDraw, ImageFont
base = Image.open(base_img_path).convert("RGB")
base = Image.blend(base, Image.new("RGB", base.size, (0, 0, 0)), 0.55)
d = ImageDraw.Draw(base)
def centered(text, y, size, fill, fit_one_line=False):
if fit_one_line: # 한 줄에 들어갈 때까지 폰트 축소
while size > 40 and d.textlength(text, font=ImageFont.truetype(font_path, size)) > W - 140:
size -= 4
f = ImageFont.truetype(font_path, size)
for ln in _wrap_lines(d, text, f, W - 140):
w = d.textlength(ln, font=f)
d.text(((W - w) / 2, y), ln, font=f, fill=fill,
stroke_width=5, stroke_fill="black")
y += int(size * 1.25)
if store:
centered(store, int(H * 0.33), 88, "white", fit_one_line=True)
centered(cta or LINK_CTA, int(H * 0.43), 58, "#FFFFFF", fit_one_line=True)
centered(link, int(H * 0.51), 72, "#FFE94A", fit_one_line=True)
base.save(out_path)
# 모든 세그먼트를 같은 규격으로 만들어 concat copy 가 되게 한다.
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
# ---------------------------------------------------------------- BGM
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")
def resolve_bgm(bgm_arg):
"""--bgm 값 → 실제 음악 파일 경로(없으면 None).
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 지원):
1) 파일이면 그대로
2) 폴더면 음악 무작위 1 (병맛 BGM 여러 넣어두고 매번 다르게)
3) 이름만 주면 HERE/<> HERE/bgm/<> 공용 generator/<> generator/bgm/<> 탐색
"""
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
return None
import random
p = Path(bgm_arg)
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p, SHARED / p, SHARED / "bgm" / p]
for c in cands:
if c.is_file():
return c
if c.is_dir():
files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS]
if files:
return random.choice(files)
return None
def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0):
"""완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장.
- BGM 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림.
- 나레이션[0:a] + BGM[1:a]*volume amix(duration=first) 섞어 영상 길이에 맞춘다.
- 영상은 재인코딩하지 않는다(-c:v copy) 빠름. 시작에 짧은 페이드인.
"""
fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];"
f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]")
_run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path),
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
"-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart",
str(out_path)])
# Shared low-level media utilities. Keep local render.py for per-feature
# layout, prompts, local fonts, BGM folder resolution, and produce().
_ffmpeg = MU._ffmpeg
_run = MU._run
pick_encoder = MU.pick_encoder
pcm_to_wav = MU.pcm_to_wav
wav_seconds = MU.wav_seconds
process_audio = MU.process_audio
_wrap_lines = MU._wrap_lines
mix_bgm = MU.mix_bgm
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
bgm=None, bgm_volume=0.3):
"""장면별 ffmpeg 세그먼트(Ken Burns 줌 + 자막 오버레이 + 음성) → concat 으로 최종 mp4.
speed/trim_silence: 컷편집 느낌 음성을 빠르게 + 앞뒤 침묵 제거( 컷이 타이트).
tail: 음성 끝에 붙일 정적(). 0 이면 끝나자마자 바로 다음 .
bgm: 배경음악 파일 경로. 주면 concat BGM 작은 볼륨으로 깔아 믹스.
bgm_volume: BGM 볼륨(0~1). 기본 0.12 = 나레이션 묻히게 12%.
"""
exe = _ffmpeg()
enc = pick_encoder(exe)
font = FONT if Path(FONT).exists() else FONT_FALLBACK
out_path = Path(out_path)
tmp = out_path.parent / "_render"
tmp.mkdir(exist_ok=True)
segs = []
total = 0.0
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
proc_ap = tmp / f"aud{idx:02d}.wav"
process_audio(exe, ap, proc_ap, speed=speed, trim_silence=trim_silence)
speech = wav_seconds(proc_ap)
dur = speech + max(0.0, tail) # 말 끝나고 약간의 여운만
frames = max(1, round(dur * FPS))
sub_png = tmp / f"sub{idx:02d}.png"
# 화면 자막 = 그 장면에서 '말하는 문장'(나레이션) 그대로 → 무음 시청자도 내용 전달
render_band_png(sc.get("narration") or sc.get("caption", ""), sub_png, font)
seg = tmp / f"seg{idx:02d}.mp4"
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
band_h = int(H * BAND_RATIO)
img_h = H - band_h
bw, bh = int(W * 1.2), int(img_h * 1.2) # 줌 여유용 1.2x
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase,"
f"crop={bw}:{bh},"
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:"
f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"
f"[bg][img]overlay=0:{band_h}[base];"
f"[base][1:v]overlay=0:0[v]")
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
_run([exe, "-y", "-i", str(ip), "-i", str(sub_png), "-i", str(proc_ap),
"-filter_complex", fc, "-map", "[v]", "-map", "2:a",
"-af", "apad", "-t", f"{dur:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
"-pix_fmt", "yuv420p", str(seg)])
segs.append(seg)
total += dur
print(f"[{idx:2d}] 세그먼트 (말 {speech:.1f}초 → 컷 {dur:.1f}초)")
print(f" ▶ 본편 합계 {total:.1f}" + (f" + 링크 {link_seconds:.1f}" if link else ""))
# 링크 카드 (무음)
if link and img_paths:
card_png = tmp / "linkcard.png"
render_link_card_png(img_paths[-1], link, card_png, font, store=store, cta=cta)
seg = tmp / "seg_link.mp4"
_run([exe, "-y", "-loop", "1", "-i", str(card_png),
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
"-t", f"{link_seconds:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
"-pix_fmt", "yuv420p", str(seg)])
segs.append(seg)
print(f"[링크] 카드 세그먼트 ({link_seconds:.1f}초)")
# concat (재인코딩 없이 복사 → 즉시). 실패하면 재인코딩으로 폴백.
# BGM 이 있으면 일단 임시 본편으로 이어붙이고, 그 위에 BGM 을 믹스해 최종본을 만든다.
listf = tmp / "concat.txt"
# concat 목록은 절대경로로 (ffmpeg 가 목록파일 위치 기준으로 상대경로를 또 해석하는 문제 방지)
listf.write_text("".join(f"file '{s.resolve().as_posix()}'\n" for s in segs), encoding="utf-8")
concat_out = (tmp / "_body.mp4") if bgm else out_path
try:
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
"-c", "copy", "-movflags", "+faststart", str(concat_out)])
except RuntimeError:
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
*enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", "-movflags", "+faststart",
str(concat_out)])
if bgm:
mix_bgm(exe, concat_out, bgm, out_path, volume=bgm_volume)
print(f" ♪ BGM 믹스: {Path(bgm).name} (볼륨 {bgm_volume})")
shutil.rmtree(tmp, ignore_errors=True)
def _mime_of(path) -> str:
return "image/jpeg" if Path(path).suffix.lower() in (".jpg", ".jpeg") else "image/png"
def analyze_photo(client, photo_path) -> str:
"""실제 사진 1장을 Gemini 비전으로 보고 짧은 한국어 라벨 반환 (예: '수육 한 접시')."""
data = Path(photo_path).read_bytes()
resp = client.models.generate_content(
model=VISION_MODEL,
contents=[
types.Part.from_bytes(data=data, mime_type=_mime_of(photo_path)),
types.Part.from_text(text=(
"이 사진의 핵심 대상을 한국어 명사구 12자 이내로만 답해. "
"음식이면 메뉴명(예: '수육', '메밀국수'), 가게면 '가게 외관'/'매장 내부', "
"메뉴판이면 '메뉴판'. 설명·문장 금지, 라벨만.")),
],
)
return (resp.text or "").strip().splitlines()[0][:20] if resp.text else ""
_MATCH_SCHEMA = {
"type": "object",
"properties": {
"assignments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"photo_index": {"type": "integer"},
"scene_number": {"type": "integer"},
},
"required": ["photo_index", "scene_number"],
},
}
},
"required": ["assignments"],
}
def match_real_scenes(client, scenes, real_images, max_real=3) -> dict:
"""각 실제 사진을 비전 분석 → 내용이 가장 맞는 장면에 배치한 {장면번호: 사진경로} 반환.
실패하면 위치 기반(plan_real_scenes)으로 폴백한다.
"""
real_images = list(real_images or [])
if not real_images:
return {}
# 1) 사진별 내용 라벨
labels = []
for p in real_images:
try:
lab = analyze_photo(client, p)
except Exception:
lab = ""
labels.append(lab)
print(f" · {Path(p).name}'{lab or '?'}'")
# 2) 라벨 ↔ 장면 매칭 (구조화 출력 1회)
scene_lines = "\n".join(
f"{i+1}. [{s.get('caption','')}] {s.get('narration','')}"
for i, s in enumerate(scenes))
photo_lines = "\n".join(f"{i}. {labels[i] or '내용불명'}" for i in range(len(real_images)))
prompt = (
"아래는 영상의 '장면 목록'과, 영상에 넣을 '실제 사진'들의 내용 라벨이다.\n"
"각 사진을 그 내용이 가장 잘 어울리는 장면에 배치하라.\n"
"[규칙]\n"
"- 한 장면엔 사진 하나, 한 사진은 한 장면에만.\n"
"- 그 음식/메뉴/가게를 직접 언급·소개하는 장면에 우선 배치.\n"
"- 비슷하면 마케팅/소개가 나오는 뒤쪽 장면을 선호.\n"
f"- 최대 {max_real}장까지만 배치(나머지는 제외).\n\n"
f"[장면 목록]\n{scene_lines}\n\n[사진 라벨]\n{photo_lines}\n")
try:
resp = client.models.generate_content(
model=VISION_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
temperature=0.2,
response_mime_type="application/json",
response_schema=_MATCH_SCHEMA,
),
)
assigns = json.loads(resp.text).get("assignments", [])
except Exception as e:
print(f" (매칭 실패 → 위치 기반으로 폴백: {type(e).__name__})")
return plan_real_scenes(scenes, real_images, None, max_real)
real_map, used_photo = {}, set()
for a in assigns:
pi, sn = a.get("photo_index"), a.get("scene_number")
if pi is None or sn is None:
continue
if 0 <= pi < len(real_images) and 1 <= sn <= len(scenes) \
and pi not in used_photo and sn not in real_map:
real_map[sn] = Path(real_images[pi])
used_photo.add(pi)
if len(real_map) >= max_real:
break
return real_map or plan_real_scenes(scenes, real_images, None, max_real)
def plan_real_scenes(scenes, real_images, real_scenes=None, max_real=3) -> dict:
"""어느 장면 번호(1-based)에 어떤 실제 사진을 깔지 매핑 dict 로 반환.
- real_scenes 주어지면 장면 번호들에 사진을 순서대로 매핑.
- 없으면 '뒤쪽(마케팅 전환부) 장면들' 사진 개수만큼(최대 max_real) 자동 배치.
"""
real_images = list(real_images or [])
real_map = {}
if not real_images:
return real_map
if real_scenes:
for no, img in zip(real_scenes, real_images):
if 1 <= no <= len(scenes):
real_map[no] = Path(img)
else:
n = min(len(real_images), max_real, len(scenes))
start = len(scenes) - n + 1 # 뒤에서 n개
for k in range(n):
real_map[start + k] = Path(real_images[k])
return real_map
def produce(client, sb, stem, workdir=None, voice=TTS_VOICE, force=False,
real_images=None, real_scenes=None, max_real=3, match_content=True,
link=None, link_seconds=3.0, store=None, cta=None,
img_model=None, allow_text=False,
speed=1.0, trim_silence=True, tail=0.15, workers=4,
bgm=None, bgm_volume=0.3) -> Path:
"""스토리보드 dict → 장면별 이미지/음성 생성 후 mp4 합성. 완성 경로 반환.
workdir: 작업의 결과/중간파일을 담을 폴더. 이미지·음성은 workdir/assets/ ,
완성 mp4 workdir/{stem}.mp4 저장한다. (기본: output/{stem})
real_images: 실제 사진(가게/메뉴판 ) 경로 리스트. 뒤쪽 장면에 깔아 AI 컷과 섞는다.
real_scenes: 실제 사진을 장면 번호(1-based) 리스트. 없으면 뒤쪽 자동.
max_real: 자동 배치 실제 사진으로 채울 최대 장면 .
link: 주면 마지막에 링크 카드 1장을 붙인다.
"""
scenes = sb["scenes"]
style = sb.get("style_guide", "")
char = sb.get("character_sheet", "")
workdir = Path(workdir) if workdir else (OUT_DIR / stem)
workdir.mkdir(parents=True, exist_ok=True)
assets = workdir / "assets"
assets.mkdir(exist_ok=True)
# 실제 사진 → 장면 배치 결정
# · real_scenes 수동지정이 최우선
# · 아니면 내용 매칭(비전). 단, 새로 생성할 장면이 있을 때만(전부 캐시면 비전 호출 낭비라 생략)
real_images = list(real_images or [])
# max_real 이 None/0/음수면 '폴더 사진 전부 사용'(장면 수 한도 내)
eff_max = max_real if (max_real and max_real > 0) else len(real_images)
need_gen = force or any(not (assets / f"scene{i:02d}.png").exists()
for i in range(1, len(scenes) + 1))
if not real_images:
real_map = {}
elif real_scenes:
real_map = plan_real_scenes(scenes, real_images, real_scenes, eff_max)
elif match_content and client is not None and need_gen:
print(f"■ 실제 사진 내용 분석(Gemini 비전) → 장면 매칭 (최대 {eff_max}장)")
real_map = match_real_scenes(client, scenes, real_images, eff_max)
else:
real_map = plan_real_scenes(scenes, real_images, None, eff_max)
real_order = {no: k for k, no in enumerate(sorted(real_map))} # 포즈/위치 번갈아 쓰기용
if real_map:
picks = ", ".join(f"{no}{real_map[no].name}" for no in sorted(real_map))
print(f"■ 실제 사진 합성 장면: {picks}")
from concurrent.futures import ThreadPoolExecutor, as_completed
img_paths = [assets / f"scene{i:02d}.png" for i in range(1, len(scenes) + 1)]
audio_paths = [assets / f"scene{i:02d}.wav" for i in range(1, len(scenes) + 1)]
def make_image(i, ref, ref_mode="full"):
"""장면 i 이미지 1장 생성(필요시 실사진 합성). ref=캐릭터 앵커 바이트."""
sc = scenes[i - 1]
raw_png = assets / f"scene{i:02d}_raw.png"
img_png = assets / f"scene{i:02d}.png"
# 눈 표정 장면(감김/실눈/윙크/졸림/웃음·울음 등)은 뜬눈 고정을 풀고 표정대로 그린다.
# · '눈만 고정(eyes)' 레퍼런스도 이런 장면에선 빼야 눈이 안 떠짐.
free_eyes = scene_needs_eye_expression(sc)
eye = EYE_FREE if free_eyes else EYE_STYLE
use_ref = None if (free_eyes and ref_mode == "eyes") else ref
if i in real_map:
k = real_order[i]
pose = PRESENT_POSES[k % len(PRESENT_POSES)]
spot = PRESENT_SPOTS[k % len(PRESENT_SPOTS)]
prompt = (f"{style}. {char}. {eye} {ANATOMY_GUARD} Scene: {sc['image_prompt']}. {pose} "
f"Vertical 9:16 composition.")
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
no_text=not allow_text, ref_mode=ref_mode)
raw_png.write_bytes(data)
cover_crop(raw_png, img_png) # 1) AI 배경
composite_card(img_png, real_map[i], img_png, center=spot) # 2) 실사진 카드
else:
prompt = (f"{style}. {char}. {eye} {ANATOMY_GUARD} "
f"Scene: {sc['image_prompt']}. Vertical 9:16 composition.")
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
no_text=not allow_text, ref_mode=ref_mode)
raw_png.write_bytes(data)
cover_crop(raw_png, img_png)
# --- 이미지: 캐릭터 앵커(1컷) 먼저, 나머지는 병렬 ---
# 앵커 의존성: 모든 컷이 1번 컷(캐릭터)을 ref 로 참조해야 일관성이 유지된다.
# → 앵커 1장만 순차로 만든 뒤, 나머지는 동시에 생성(서로 독립).
need_img = [i for i in range(1, len(scenes) + 1)
if force or not img_paths[i - 1].exists()]
cached_img = [i for i in range(1, len(scenes) + 1) if i not in need_img]
for i in cached_img:
print(f"[{i:2d}] 이미지 캐시 사용")
# 공식 캐릭터 레퍼런스가 있으면 '눈만 고정' 모드: 모든 컷이 레퍼런스에서 눈/표정만 복제.
# 토가·월계관 등 복장은 복제하지 않고 인물별 character_sheet/장면 프롬프트대로 그려진다.
char_ref = load_char_ref()
if char_ref is not None:
print("■ 캐릭터 레퍼런스(눈만 고정) 사용: character/ "
"→ 눈/표정은 통일, 복장은 인물별로 다르게")
ref_bytes, ref_mode, anchor = char_ref, "eyes", None
else:
ref_mode = "full"
ref_bytes = None
for i in range(1, len(scenes) + 1): # 캐시된 raw 가 있으면 앵커로 재사용
raw = assets / f"scene{i:02d}_raw.png"
if raw.exists() and not force:
ref_bytes = raw.read_bytes(); break
anchor = None
if ref_bytes is None:
anchor = next((i for i in need_img if i not in real_map), None)
if anchor is not None:
print(f"[{anchor:2d}] 캐릭터 앵커 이미지 생성...", flush=True)
make_image(anchor, None)
ref_bytes = (assets / f"scene{anchor:02d}_raw.png").read_bytes()
rest = [i for i in need_img if i != anchor]
if rest:
print(f"■ 이미지 {len(rest)}장 병렬 생성 (워커 {workers})...", flush=True)
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(make_image, i, ref_bytes, ref_mode): i for i in rest}
for fut in as_completed(futs):
i = futs[fut]; fut.result()
tag = f"실사합성({real_map[i].name})" if i in real_map else "이미지"
print(f"[{i:2d}] {tag} done", flush=True)
# --- 음성: 전부 독립 → 병렬 ---
def make_tts(i):
text = (scenes[i - 1].get("narration") or "").strip()
if not text:
# 빈 나레이션 방어: TTS 가 빈 텍스트에 빈 응답을 반복하다 죽으므로 0.3초 무음으로 대체.
# (정상 경로에선 gen_storyboard 가 빈 장면을 미리 거르지만, 만약을 대비한 이중 안전장치)
pcm_to_wav(b"\x00\x00" * int(24000 * 0.3), audio_paths[i - 1], 24000)
return
pcm, rate = gen_tts(client, text, voice)
pcm_to_wav(pcm, audio_paths[i - 1], rate)
need_wav = [i for i in range(1, len(scenes) + 1)
if force or not audio_paths[i - 1].exists()]
for i in (i for i in range(1, len(scenes) + 1) if i not in need_wav):
print(f" [{i:2d}] 음성 캐시 사용 ({wav_seconds(audio_paths[i - 1]):.1f}초)")
if need_wav:
print(f"■ 음성 {len(need_wav)}개 병렬 생성 (워커 {workers})...", flush=True)
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(make_tts, i): i for i in need_wav}
for fut in as_completed(futs):
i = futs[fut]; fut.result()
print(f" [{i:2d}] 음성 {wav_seconds(audio_paths[i - 1]):.1f}", flush=True)
out = workdir / f"{stem}.mp4"
print(f"\n영상 합성 → {out.name}")
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
bgm=bgm, bgm_volume=bgm_volume)
print(f"완성: {out}")
return out

View File

@ -0,0 +1,287 @@
# -*- coding: utf-8 -*-
"""
나무위키 그리스 로마 신화 ·영웅 '썰 키워드' 스크래퍼
====================================================
그리스 로마 신화의 주요 ·영웅 28명의 나무위키 문서에서 목차(섹션 제목) 긁어와
숏폼 소재가 만한 '썰/일화 키워드' 추출해 JSON / CSV 저장한다.
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
Playwright(Chromium) 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
설치:
pip install playwright beautifulsoup4
playwright install chromium # (이미 설치돼 있으면 생략)
실행:
python scraper.py
옵션:
python scraper.py --headful # 브라우저 창 보이게
python scraper.py --only 제우스 헤라클레스 # 특정 인물만
"""
import argparse
import asyncio
import csv
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from playwright.async_api import async_playwright
# ----------------------------------------------------------------------------
# 설정
# ----------------------------------------------------------------------------
BASE = "https://namu.wiki/w/"
OUT_DIR = Path(__file__).parent / "data" # 신화 인물 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
# 그리스 로마 신화 주요 신·영웅 28명: (번호, 이름, 나무위키 문서 제목)
# 동음이의어 문서는 worker 가 "이름" ↔ "이름(그리스 로마 신화)" ↔ "이름(그리스 신화)" 를 자동 재시도한다.
GREEK_FIGURES = [
# 올림포스 12신
(1, "제우스", "제우스"),
(2, "헤라", "헤라"),
(3, "포세이돈", "포세이돈"),
(4, "데메테르", "데메테르"),
(5, "아테나", "아테나"),
(6, "아폴론", "아폴론"),
(7, "아르테미스", "아르테미스"),
(8, "아레스", "아레스"),
(9, "아프로디테", "아프로디테"),
(10, "헤파이스토스", "헤파이스토스"),
(11, "헤르메스", "헤르메스"),
(12, "디오니소스", "디오니소스"),
# 그 외 주요 신
(13, "하데스", "하데스"),
(14, "헤스티아", "헤스티아"),
(15, "프로메테우스", "프로메테우스"),
(16, "크로노스", "크로노스(그리스 로마 신화)"),
(17, "에로스", "에로스"),
# 영웅·인간
(18, "헤라클레스", "헤라클레스"),
(19, "페르세우스", "페르세우스"),
(20, "테세우스", "테세우스"),
(21, "오디세우스", "오디세우스"),
(22, "아킬레우스", "아킬레우스"),
(23, "오르페우스", "오르페우스"),
(24, "이아손", "이아손"),
# 유명 괴물·일화 인물
(25, "메두사", "메두사"),
(26, "미다스", "미다스"),
(27, "시시포스", "시시포스"),
(28, "이카로스", "이카로스"),
]
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
ANECDOTE_HINTS = [
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
"업적", "가족", "이름", "별명",
"신화", "전승", "전설", "설화", "사랑", "연인", "상징", "숭배", "창작물", "최후",
]
def clean_heading(text: str) -> str:
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
t = text.replace("[편집]", "").strip()
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
t = re.sub(r"^\s*[\d.]+\s*", "", t)
return t.strip()
def is_anecdote(keyword: str) -> bool:
return any(h in keyword for h in ANECDOTE_HINTS)
async def scrape_king(page, title: str):
"""한 인물 문서를 열고 목차 키워드 목록을 반환."""
url = BASE + title
result = {"title": title, "url": url, "page_title": None,
"keywords": [], "anecdote_keywords": [], "error": None}
try:
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
try:
await page.wait_for_selector(
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
timeout=8000,
)
except Exception:
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
await page.wait_for_timeout(800)
# 문서 실제 제목
try:
result["page_title"] = (await page.title()).split(" - ")[0].strip()
except Exception:
pass
if resp and resp.status >= 400:
result["error"] = f"HTTP {resp.status}"
return result
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
keywords = await page.evaluate(
"""
() => {
const clean = s => s.replace('[편집]', '').trim();
let items = [];
// 1순위: 목차(table of contents)
const tocLinks = document.querySelectorAll(
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
);
if (tocLinks.length) {
items = Array.from(tocLinks).map(a => clean(a.textContent));
}
// 2순위(목차가 없으면): 본문 헤딩 태그
if (items.length === 0) {
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
items = Array.from(hs).map(h => clean(h.textContent));
}
return items.filter(Boolean);
}
"""
)
# 중복 제거 + 번호 정리
seen, cleaned = set(), []
for raw in keywords:
kw = clean_heading(raw)
if kw and kw not in seen and len(kw) <= 60:
seen.add(kw)
cleaned.append(kw)
result["keywords"] = cleaned
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
except Exception as e:
result["error"] = f"{type(e).__name__}: {e}"
return result
async def main():
parser = argparse.ArgumentParser()
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
parser.add_argument("--only", nargs="*", help="특정 인물만 (이름으로 지정)")
parser.add_argument("--concurrency", type=int, default=4,
help="동시에 열 페이지 수 (기본 4)")
args = parser.parse_args()
kings = GREEK_FIGURES
if args.only:
wanted = set(args.only)
kings = [k for k in GREEK_FIGURES if k[1] in wanted]
if not kings:
print(f"[!] 매칭되는 인물 없음: {args.only}")
print(" 가능한 값:", ", ".join(k[1] for k in GREEK_FIGURES))
return
OUT_DIR.mkdir(parents=True, exist_ok=True)
all_results = []
total = len(kings)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=not args.headful)
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
locale="ko-KR",
)
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
async def _block(route):
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
await route.abort()
else:
await route.continue_()
await context.route("**/*", _block)
sem = asyncio.Semaphore(max(1, args.concurrency))
done = 0
async def worker(idx, name, title):
nonlocal done
# 문서 제목 후보: 지정 제목 → "이름" → "이름(그리스 로마 신화)" → "이름(그리스 신화)" 순으로 자동 재시도
# (나무위키 동음이의어 처리 방식이 인물마다 달라 제목을 못 맞출 수 있어서)
titles = [title]
for alt in (name, f"{name}(그리스 로마 신화)", f"{name}(그리스 신화)"):
if alt not in titles:
titles.append(alt)
async with sem:
page = await context.new_page()
try:
r = None
for t in titles:
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
# 실패하면 백오프하며 최대 3회까지 재시도
r = await scrape_king(page, t)
for attempt in range(3):
if not r["error"]:
break
await page.wait_for_timeout(1500 * (attempt + 1))
r = await scrape_king(page, t)
# 성공했고 목차도 나왔으면 이 제목으로 확정
if not r["error"] and r["keywords"]:
break
finally:
await page.close()
r["order"] = idx
r["name"] = name
done += 1
if r["error"]:
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
else:
print(f"[{done:2d}/{total}] {name} ({title}) "
f"키워드 {len(r['keywords'])}"
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
return r
all_results = await asyncio.gather(
*(worker(idx, name, title) for idx, name, title in kings)
)
all_results = sorted(all_results, key=lambda r: r["order"])
await browser.close()
# 저장 ---------------------------------------------------------------
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
json_path = OUT_DIR / f"greek_keywords_{ts}.json"
json_path.write_text(
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
)
csv_path = OUT_DIR / f"greek_keywords_{ts}.csv"
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["번호", "인물", "문서제목", "썰후보여부", "키워드", "URL"])
for r in all_results:
anec = set(r["anecdote_keywords"])
for kw in r["keywords"]:
w.writerow([r["order"], r["name"], r["title"],
"Y" if kw in anec else "", kw, r["url"]])
total_kw = sum(len(r["keywords"]) for r in all_results)
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
fails = [r["name"] for r in all_results if r["error"]]
print("\n" + "=" * 50)
print(f"완료: 인물 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}")
if fails:
print(f"수집 실패: {', '.join(fails)}")
print(f"JSON -> {json_path}")
print(f"CSV -> {csv_path}")
if __name__ == "__main__":
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
asyncio.run(main())

View File

@ -0,0 +1,15 @@
[캐릭터 공식 레퍼런스 폴더 — '눈만 고정']
이 폴더에 고양이 왕 기준 이미지를 1장 넣어두면(예: king_ref.png),
모든 쇼츠 영상의 고양이 왕이 '그 눈과 표정'을 그대로 따라갑니다.
- 고정되는 것 : 눈 모양 / 눈동자 / 표정 (귀여운 순한 눈으로 통일)
- 고정 안 됨 : 곤룡포 색, 관모, 포즈, 배경 → 왕별 character_sheet / 장면 프롬프트대로
(즉 광해군·순종 등 왕마다 복장은 다르게 나옴)
- 파일명은 자유 (폴더 안 '첫 번째' 이미지 파일을 사용. png/jpg/jpeg/webp)
- 한 장만 두는 걸 권장 (여러 장이면 정렬상 첫 파일만 사용)
- 빼고 싶으면 이미지를 폴더 밖으로 빼거나 폴더를 비우면 됨
→ 그럼 예전처럼 영상마다 캐릭터를 새로 그림(눈은 EYE_STYLE 텍스트 지시로 귀엽게 유지)
연결 코드: make_short.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

@ -0,0 +1,408 @@
번호,인물,문서제목,썰후보여부,키워드,URL
1,조조,조조,,조조,https://namu.wiki/w/조조
1,조조,조조,,개요,https://namu.wiki/w/조조
1,조조,조조,Y,이름,https://namu.wiki/w/조조
1,조조,조조,,생애,https://namu.wiki/w/조조
1,조조,조조,Y,가족 관계,https://namu.wiki/w/조조
1,조조,조조,,무덤,https://namu.wiki/w/조조
1,조조,조조,Y,평가,https://namu.wiki/w/조조
1,조조,조조,Y,업적과 능력,https://namu.wiki/w/조조
1,조조,조조,,vs 유비,https://namu.wiki/w/조조
1,조조,조조,Y,어록,https://namu.wiki/w/조조
1,조조,조조,Y,기타,https://namu.wiki/w/조조
1,조조,조조,Y,기타 창작물,https://namu.wiki/w/조조
1,조조,조조,,둘러보기,https://namu.wiki/w/조조
2,사마의,사마의,,사마의,https://namu.wiki/w/사마의
2,사마의,사마의,,개요,https://namu.wiki/w/사마의
2,사마의,사마의,Y,일생(정사),https://namu.wiki/w/사마의
2,사마의,사마의,Y,연의,https://namu.wiki/w/사마의
2,사마의,사마의,Y,가족 관계,https://namu.wiki/w/사마의
2,사마의,사마의,Y,평가,https://namu.wiki/w/사마의
2,사마의,사마의,Y,기타,https://namu.wiki/w/사마의
2,사마의,사마의,Y,미디어 믹스,https://namu.wiki/w/사마의
2,사마의,사마의,,둘러보기,https://namu.wiki/w/사마의
3,하후돈,하후돈,,하후돈,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,개요,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,정사 삼국지,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,초기 생애,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,동탁 토벌전,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,복양 전투,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,서주에서,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,조조 휘하,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,관도대전,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,박망파 전투,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,하북 평정,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,장로 정벌,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,216년~217년 유수구 전투,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,번성 공방전,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,죽음,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,사후 (위나라 건국),https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,삼국지연의,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,평가,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,친족 우대의 수혜자,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,긍정적 평가,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,연의에 기반한 오해 및 재평가,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,기타,https://namu.wiki/w/하후돈
3,하후돈,하후돈,Y,미디어 믹스,https://namu.wiki/w/하후돈
3,하후돈,하후돈,,관련 문서,https://namu.wiki/w/하후돈
4,장료,장료,,장료,https://namu.wiki/w/장료
4,장료,장료,,개요,https://namu.wiki/w/장료
4,장료,장료,Y,정사 삼국지,https://namu.wiki/w/장료
4,장료,장료,,초기 생애,https://namu.wiki/w/장료
4,장료,장료,,여포 휘하,https://namu.wiki/w/장료
4,장료,장료,,관도대전,https://namu.wiki/w/장료
4,장료,장료,,창희 토벌,https://namu.wiki/w/장료
4,장료,장료,,하북 평정,https://namu.wiki/w/장료
4,장료,장료,,진란 토벌,https://namu.wiki/w/장료
4,장료,장료,,형주 정벌,https://namu.wiki/w/장료
4,장료,장료,Y,환성 전투,https://namu.wiki/w/장료
4,장료,장료,,2차 합비 공방전[7],https://namu.wiki/w/장료
4,장료,장료,,조조 휘하,https://namu.wiki/w/장료
4,장료,장료,Y,유수구 전투,https://namu.wiki/w/장료
4,장료,장료,,번성 공방전,https://namu.wiki/w/장료
4,장료,장료,,조비 휘하,https://namu.wiki/w/장료
4,장료,장료,Y,동구 전투,https://namu.wiki/w/장료
4,장료,장료,Y,죽음,https://namu.wiki/w/장료
4,장료,장료,Y,삼국지연의,https://namu.wiki/w/장료
4,장료,장료,Y,평가,https://namu.wiki/w/장료
4,장료,장료,,군사적 능력,https://namu.wiki/w/장료
4,장료,장료,,인품과 성품,https://namu.wiki/w/장료
4,장료,장료,Y,기타,https://namu.wiki/w/장료
4,장료,장료,Y,미디어 믹스,https://namu.wiki/w/장료
4,장료,장료,,둘러보기,https://namu.wiki/w/장료
4,장료,장료,,관련 문서,https://namu.wiki/w/장료
5,허저,허저,,허저,https://namu.wiki/w/허저
5,허저,허저,,개요,https://namu.wiki/w/허저
5,허저,허저,Y,정사 삼국지,https://namu.wiki/w/허저
5,허저,허저,,초기 생애,https://namu.wiki/w/허저
5,허저,허저,Y,양 전투,https://namu.wiki/w/허저
5,허저,허저,,관도대전,https://namu.wiki/w/허저
5,허저,허저,,하북 평정,https://namu.wiki/w/허저
5,허저,허저,,관중 평정,https://namu.wiki/w/허저
5,허저,허저,,장로 정벌,https://namu.wiki/w/허저
5,허저,허저,,조조 휘하,https://namu.wiki/w/허저
5,허저,허저,Y,죽음,https://namu.wiki/w/허저
5,허저,허저,Y,삼국지연의,https://namu.wiki/w/허저
5,허저,허저,Y,평가,https://namu.wiki/w/허저
5,허저,허저,Y,여담,https://namu.wiki/w/허저
5,허저,허저,Y,대중매체에서,https://namu.wiki/w/허저
6,곽가,곽가,,곽가,https://namu.wiki/w/곽가
6,곽가,곽가,,개요,https://namu.wiki/w/곽가
6,곽가,곽가,,생애,https://namu.wiki/w/곽가
6,곽가,곽가,,초기 생애,https://namu.wiki/w/곽가
6,곽가,곽가,,조조에게 임관,https://namu.wiki/w/곽가
6,곽가,곽가,,서주에서,https://namu.wiki/w/곽가
6,곽가,곽가,,관도대전,https://namu.wiki/w/곽가
6,곽가,곽가,,하북 평정,https://namu.wiki/w/곽가
6,곽가,곽가,Y,죽음,https://namu.wiki/w/곽가
6,곽가,곽가,,사후,https://namu.wiki/w/곽가
6,곽가,곽가,Y,평가,https://namu.wiki/w/곽가
6,곽가,곽가,Y,긍정적 평가,https://namu.wiki/w/곽가
6,곽가,곽가,Y,부정적 평가: 연의의 수혜자,https://namu.wiki/w/곽가
6,곽가,곽가,,별 볼일 없는 전공,https://namu.wiki/w/곽가
6,곽가,곽가,,적벽대전에서 곽가만 있었다면 이겼다?,https://namu.wiki/w/곽가
6,곽가,곽가,,사생활,https://namu.wiki/w/곽가
6,곽가,곽가,Y,여담,https://namu.wiki/w/곽가
6,곽가,곽가,Y,미디어 믹스,https://namu.wiki/w/곽가
7,순욱,순욱,,순욱,https://namu.wiki/w/순욱
7,순욱,순욱,,개요,https://namu.wiki/w/순욱
7,순욱,순욱,Y,생애(정사),https://namu.wiki/w/순욱
7,순욱,순욱,Y,연의,https://namu.wiki/w/순욱
7,순욱,순욱,Y,평가,https://namu.wiki/w/순욱
7,순욱,순욱,Y,가족 관계,https://namu.wiki/w/순욱
7,순욱,순욱,Y,미디어 믹스,https://namu.wiki/w/순욱
7,순욱,순욱,Y,기타,https://namu.wiki/w/순욱
7,순욱,순욱,,순욱/빈 찬합 밈,https://namu.wiki/w/순욱
7,순욱,순욱,,둘러보기,https://namu.wiki/w/순욱
8,가후,가후,,가후,https://namu.wiki/w/가후
8,가후,가후,,개요,https://namu.wiki/w/가후
8,가후,가후,Y,정사 삼국지,https://namu.wiki/w/가후
8,가후,가후,,초기 생애,https://namu.wiki/w/가후
8,가후,가후,,동탁 휘하,https://namu.wiki/w/가후
8,가후,가후,,이각 휘하,https://namu.wiki/w/가후
8,가후,가후,,단외 휘하,https://namu.wiki/w/가후
8,가후,가후,,장수 휘하,https://namu.wiki/w/가후
8,가후,가후,Y,완 전투,https://namu.wiki/w/가후
8,가후,가후,Y,양 전투,https://namu.wiki/w/가후
8,가후,가후,,관도대전,https://namu.wiki/w/가후
8,가후,가후,,적벽대전,https://namu.wiki/w/가후
8,가후,가후,,관중 평정,https://namu.wiki/w/가후
8,가후,가후,,조비에게 조언,https://namu.wiki/w/가후
8,가후,가후,,조비 휘하 (위나라 건국),https://namu.wiki/w/가후
8,가후,가후,Y,죽음,https://namu.wiki/w/가후
8,가후,가후,Y,삼국지연의,https://namu.wiki/w/가후
8,가후,가후,Y,평가,https://namu.wiki/w/가후
8,가후,가후,Y,긍정적 평가,https://namu.wiki/w/가후
8,가후,가후,Y,부정적 평가,https://namu.wiki/w/가후
8,가후,가후,Y,기타,https://namu.wiki/w/가후
8,가후,가후,Y,미디어 믹스,https://namu.wiki/w/가후
8,가후,가후,,관련 문서,https://namu.wiki/w/가후
8,가후,가후,,둘러보기,https://namu.wiki/w/가후
9,유비,유비,,유비,https://namu.wiki/w/유비
9,유비,유비,,개요,https://namu.wiki/w/유비
9,유비,유비,Y,이름과 칭호,https://namu.wiki/w/유비
9,유비,유비,,생애,https://namu.wiki/w/유비
9,유비,유비,Y,평가,https://namu.wiki/w/유비
9,유비,유비,,vs 조조,https://namu.wiki/w/유비
9,유비,유비,,혈통,https://namu.wiki/w/유비
9,유비,유비,Y,가족 관계,https://namu.wiki/w/유비
9,유비,유비,,무덤,https://namu.wiki/w/유비
9,유비,유비,Y,기타,https://namu.wiki/w/유비
9,유비,유비,Y,대중매체에서,https://namu.wiki/w/유비
9,유비,유비,,둘러보기,https://namu.wiki/w/유비
10,관우,관우,,관우,https://namu.wiki/w/관우
10,관우,관우,,개요,https://namu.wiki/w/관우
10,관우,관우,Y,생애(정사),https://namu.wiki/w/관우
10,관우,관우,Y,연의,https://namu.wiki/w/관우
10,관우,관우,,역대 봉호,https://namu.wiki/w/관우
10,관우,관우,,중화 왕조,https://namu.wiki/w/관우
10,관우,관우,,조선,https://namu.wiki/w/관우
10,관우,관우,Y,가족 관계,https://namu.wiki/w/관우
10,관우,관우,Y,평가,https://namu.wiki/w/관우
10,관우,관우,Y,시호 논란,https://namu.wiki/w/관우
10,관우,관우,,악시설,https://namu.wiki/w/관우
10,관우,관우,,선시설,https://namu.wiki/w/관우
10,관우,관우,,신격화,https://namu.wiki/w/관우
10,관우,관우,,말말말,https://namu.wiki/w/관우
10,관우,관우,Y,대중매체에서,https://namu.wiki/w/관우
10,관우,관우,,관련 문서,https://namu.wiki/w/관우
10,관우,관우,,둘러보기,https://namu.wiki/w/관우
11,장비,장비,,장비,https://namu.wiki/w/장비
11,장비,장비,,개요,https://namu.wiki/w/장비
11,장비,장비,Y,생애(정사),https://namu.wiki/w/장비
11,장비,장비,Y,연의,https://namu.wiki/w/장비
11,장비,장비,Y,연의의 피해자?,https://namu.wiki/w/장비
11,장비,장비,Y,가족 관계,https://namu.wiki/w/장비
11,장비,장비,Y,평가,https://namu.wiki/w/장비
11,장비,장비,Y,기타,https://namu.wiki/w/장비
11,장비,장비,Y,기타 창작물,https://namu.wiki/w/장비
11,장비,장비,,관련 항목,https://namu.wiki/w/장비
11,장비,장비,,둘러보기,https://namu.wiki/w/장비
12,제갈량,제갈량,,제갈량,https://namu.wiki/w/제갈량
12,제갈량,제갈량,,개요,https://namu.wiki/w/제갈량
12,제갈량,제갈량,Y,생애(정사),https://namu.wiki/w/제갈량
12,제갈량,제갈량,Y,연의,https://namu.wiki/w/제갈량
12,제갈량,제갈량,,인간관계,https://namu.wiki/w/제갈량
12,제갈량,제갈량,Y,업적,https://namu.wiki/w/제갈량
12,제갈량,제갈량,Y,평가,https://namu.wiki/w/제갈량
12,제갈량,제갈량,Y,논란,https://namu.wiki/w/제갈량
12,제갈량,제갈량,Y,관련 일화들,https://namu.wiki/w/제갈량
12,제갈량,제갈량,,사상,https://namu.wiki/w/제갈량
12,제갈량,제갈량,Y,여담,https://namu.wiki/w/제갈량
12,제갈량,제갈량,Y,미디어 믹스,https://namu.wiki/w/제갈량
12,제갈량,제갈량,,관련 문서,https://namu.wiki/w/제갈량
12,제갈량,제갈량,,둘러보기,https://namu.wiki/w/제갈량
13,조운,조운,,조운,https://namu.wiki/w/조운
13,조운,조운,,개요,https://namu.wiki/w/조운
13,조운,조운,,소개,https://namu.wiki/w/조운
13,조운,조운,Y,정사,https://namu.wiki/w/조운
13,조운,조운,Y,평가,https://namu.wiki/w/조운
13,조운,조운,Y,연의,https://namu.wiki/w/조운
13,조운,조운,Y,가족 관계,https://namu.wiki/w/조운
13,조운,조운,Y,여담,https://namu.wiki/w/조운
13,조운,조운,Y,미디어 믹스,https://namu.wiki/w/조운
13,조운,조운,,관련 문서,https://namu.wiki/w/조운
14,마초,마초,,마초,https://namu.wiki/w/마초
14,마초,마초,,개요,https://namu.wiki/w/마초
14,마초,마초,Y,정사,https://namu.wiki/w/마초
14,마초,마초,Y,평가,https://namu.wiki/w/마초
14,마초,마초,Y,연의,https://namu.wiki/w/마초
14,마초,마초,Y,가족 관계,https://namu.wiki/w/마초
14,마초,마초,Y,여담,https://namu.wiki/w/마초
14,마초,마초,Y,대중매체에서,https://namu.wiki/w/마초
14,마초,마초,,둘러보기,https://namu.wiki/w/마초
15,황충,황충,,황충,https://namu.wiki/w/황충
15,황충,황충,,개요,https://namu.wiki/w/황충
15,황충,황충,Y,생애(정사),https://namu.wiki/w/황충
15,황충,황충,Y,연의,https://namu.wiki/w/황충
15,황충,황충,,유비군 합류,https://namu.wiki/w/황충
15,황충,황충,,한중 공방전,https://namu.wiki/w/황충
15,황충,황충,Y,사망,https://namu.wiki/w/황충
15,황충,황충,Y,평가,https://namu.wiki/w/황충
15,황충,황충,,노장이었는가?,https://namu.wiki/w/황충
15,황충,황충,,노장이 아니다.,https://namu.wiki/w/황충
15,황충,황충,,노장이 맞다.,https://namu.wiki/w/황충
15,황충,황충,Y,여담,https://namu.wiki/w/황충
15,황충,황충,Y,대중매체에서,https://namu.wiki/w/황충
15,황충,황충,,둘러보기,https://namu.wiki/w/황충
16,강유,강유,,강유,https://namu.wiki/w/강유
16,강유,강유,,개요,https://namu.wiki/w/강유
16,강유,강유,Y,생애(정사),https://namu.wiki/w/강유
16,강유,강유,Y,연의,https://namu.wiki/w/강유
16,강유,강유,Y,평가,https://namu.wiki/w/강유
16,강유,강유,Y,여담,https://namu.wiki/w/강유
16,강유,강유,Y,대중매체에서,https://namu.wiki/w/강유
17,방통,방통,,방통,https://namu.wiki/w/방통
17,방통,방통,,개요,https://namu.wiki/w/방통
17,방통,방통,Y,생애(정사),https://namu.wiki/w/방통
17,방통,방통,Y,연의,https://namu.wiki/w/방통
17,방통,방통,Y,평가,https://namu.wiki/w/방통
17,방통,방통,Y,여담,https://namu.wiki/w/방통
17,방통,방통,Y,대중매체에서,https://namu.wiki/w/방통
18,손권,손권,,손권,https://namu.wiki/w/손권
18,손권,손권,,개요,https://namu.wiki/w/손권
18,손권,손권,,시호,https://namu.wiki/w/손권
18,손권,손권,,생애,https://namu.wiki/w/손권
18,손권,손권,Y,평가,https://namu.wiki/w/손권
18,손권,손권,Y,가족 관계,https://namu.wiki/w/손권
18,손권,손권,Y,기타,https://namu.wiki/w/손권
18,손권,손권,Y,대중매체에서,https://namu.wiki/w/손권
18,손권,손권,,둘러보기,https://namu.wiki/w/손권
19,손책,손책,,손책,https://namu.wiki/w/손책
19,손책,손책,,개요,https://namu.wiki/w/손책
19,손책,손책,,생애,https://namu.wiki/w/손책
19,손책,손책,,초기 생애,https://namu.wiki/w/손책
19,손책,손책,,원술 휘하에서,https://namu.wiki/w/손책
19,손책,손책,,유요를 격파하다,https://namu.wiki/w/손책
19,손책,손책,,왕랑을 격파하다,https://namu.wiki/w/손책
19,손책,손책,,엄백호를 격파하다,https://namu.wiki/w/손책
19,손책,손책,,원술로부터 독립,https://namu.wiki/w/손책
19,손책,손책,,유훈을 격파하다,https://namu.wiki/w/손책
19,손책,손책,,조조와의 관계,https://namu.wiki/w/손책
19,손책,손책,,고대를 죽이다,https://namu.wiki/w/손책
19,손책,손책,,허공을 죽이다,https://namu.wiki/w/손책
19,손책,손책,,피습을 당하다,https://namu.wiki/w/손책
19,손책,손책,,우길을 죽이다,https://namu.wiki/w/손책
19,손책,손책,Y,죽음,https://namu.wiki/w/손책
19,손책,손책,,사후,https://namu.wiki/w/손책
19,손책,손책,Y,평가,https://namu.wiki/w/손책
19,손책,손책,Y,가족 관계,https://namu.wiki/w/손책
19,손책,손책,Y,기타,https://namu.wiki/w/손책
19,손책,손책,Y,대중매체에서,https://namu.wiki/w/손책
19,손책,손책,,둘러보기,https://namu.wiki/w/손책
20,주유,주유(삼국지),,주유(삼국지),https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),,개요,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),Y,정사 삼국지,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),,초기 생애,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),,원술 휘하에서,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),,손책 휘하에서,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),,손권 휘하에서,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),,적벽대전,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),,남군 공방전,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),Y,죽음,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),Y,삼국지연의,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),Y,평가,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),Y,가족 및 후손,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),Y,기타,https://namu.wiki/w/주유(삼국지)
20,주유,주유(삼국지),Y,대중매체에서,https://namu.wiki/w/주유(삼국지)
21,육손,육손,,육손,https://namu.wiki/w/육손
21,육손,육손,,개요,https://namu.wiki/w/육손
21,육손,육손,Y,정사 삼국지,https://namu.wiki/w/육손
21,육손,육손,,초기 생애,https://namu.wiki/w/육손
21,육손,육손,,손가와의 화친,https://namu.wiki/w/육손
21,육손,육손,,번성 공방전,https://namu.wiki/w/육손
21,육손,육손,,"700리 촉영을 불사르다, 이릉대전",https://namu.wiki/w/육손
21,육손,육손,,외교와 내정,https://namu.wiki/w/육손
21,육손,육손,Y,석정 전투,https://namu.wiki/w/육손
21,육손,육손,,건업에서(오나라 건국),https://namu.wiki/w/육손
21,육손,육손,,4차 합비 공방전,https://namu.wiki/w/육손
21,육손,육손,,형주에서 보여준 지략,https://namu.wiki/w/육손
21,육손,육손,,손권과의 말년,https://namu.wiki/w/육손
21,육손,육손,Y,이궁지쟁 사건과 최후,https://namu.wiki/w/육손
21,육손,육손,,사후,https://namu.wiki/w/육손
21,육손,육손,Y,삼국지연의,https://namu.wiki/w/육손
21,육손,육손,Y,평가,https://namu.wiki/w/육손
21,육손,육손,Y,기타,https://namu.wiki/w/육손
21,육손,육손,Y,미디어 믹스,https://namu.wiki/w/육손
21,육손,육손,,둘러보기,https://namu.wiki/w/육손
22,노숙,노숙(삼국지),,노숙(삼국지),https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),,개요,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),Y,정사 삼국지,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),,초기 생애,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),,손권에게 임관,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),,적벽대전,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),,익양대치,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),Y,죽음,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),,사후,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),Y,삼국지연의,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),Y,평가,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),Y,기타,https://namu.wiki/w/노숙(삼국지)
22,노숙,노숙(삼국지),Y,기타 창작물,https://namu.wiki/w/노숙(삼국지)
23,감녕,감녕,,감녕,https://namu.wiki/w/감녕
23,감녕,감녕,,개요,https://namu.wiki/w/감녕
23,감녕,감녕,Y,정사 삼국지,https://namu.wiki/w/감녕
23,감녕,감녕,,익주의 토호,https://namu.wiki/w/감녕
23,감녕,감녕,,익주를 떠나다,https://namu.wiki/w/감녕
23,감녕,감녕,,황조의 푸대접,https://namu.wiki/w/감녕
23,감녕,감녕,,손권에게 투항,https://namu.wiki/w/감녕
23,감녕,감녕,,남군 공방전,https://namu.wiki/w/감녕
23,감녕,감녕,Y,환성 전투,https://namu.wiki/w/감녕
23,감녕,감녕,,익양대치,https://namu.wiki/w/감녕
23,감녕,감녕,,2차 합비 공방전,https://namu.wiki/w/감녕
23,감녕,감녕,Y,유수구 전투,https://namu.wiki/w/감녕
23,감녕,감녕,Y,그의 성격을 보여주는 일화,https://namu.wiki/w/감녕
23,감녕,감녕,Y,죽음,https://namu.wiki/w/감녕
23,감녕,감녕,Y,삼국지연의,https://namu.wiki/w/감녕
23,감녕,감녕,,나이,https://namu.wiki/w/감녕
23,감녕,감녕,,감녕전의 순서 문제,https://namu.wiki/w/감녕
23,감녕,감녕,Y,기타,https://namu.wiki/w/감녕
23,감녕,감녕,Y,미디어 믹스,https://namu.wiki/w/감녕
24,태사자,태사자,,태사자,https://namu.wiki/w/태사자
24,태사자,태사자,,개요,https://namu.wiki/w/태사자
24,태사자,태사자,,생애,https://namu.wiki/w/태사자
24,태사자,태사자,,요동으로 피신하다,https://namu.wiki/w/태사자
24,태사자,태사자,,공융을 구원하다,https://namu.wiki/w/태사자
24,태사자,태사자,,손책을 따르다,https://namu.wiki/w/태사자
24,태사자,태사자,,예장을 정찰하다,https://namu.wiki/w/태사자
24,태사자,태사자,,동오의 명장,https://namu.wiki/w/태사자
24,태사자,태사자,Y,죽음,https://namu.wiki/w/태사자
24,태사자,태사자,Y,기타,https://namu.wiki/w/태사자
24,태사자,태사자,Y,기타 창작물,https://namu.wiki/w/태사자
25,여포,여포,,여포,https://namu.wiki/w/여포
25,여포,여포,,개요,https://namu.wiki/w/여포
25,여포,여포,Y,정사 삼국지,https://namu.wiki/w/여포
25,여포,여포,,정원 휘하,https://namu.wiki/w/여포
25,여포,여포,,동탁 휘하,https://namu.wiki/w/여포
25,여포,여포,,왕윤과의 갈등,https://namu.wiki/w/여포
25,여포,여포,,방랑,https://namu.wiki/w/여포
25,여포,여포,Y,여포의 최후,https://namu.wiki/w/여포
25,여포,여포,Y,삼국지연의,https://namu.wiki/w/여포
25,여포,여포,Y,평가,https://namu.wiki/w/여포
25,여포,여포,,무력,https://namu.wiki/w/여포
25,여포,여포,,통솔,https://namu.wiki/w/여포
25,여포,여포,,지력,https://namu.wiki/w/여포
25,여포,여포,,정치,https://namu.wiki/w/여포
25,여포,여포,,인격,https://namu.wiki/w/여포
25,여포,여포,,배신,https://namu.wiki/w/여포
25,여포,여포,,색욕,https://namu.wiki/w/여포
25,여포,여포,,번외: 항우와의 비교,https://namu.wiki/w/여포
25,여포,여포,,유비와의 관계,https://namu.wiki/w/여포
25,여포,여포,,이민족 떡밥,https://namu.wiki/w/여포
25,여포,여포,Y,여담,https://namu.wiki/w/여포
25,여포,여포,Y,대중매체에서,https://namu.wiki/w/여포
26,동탁,동탁,,동탁,https://namu.wiki/w/동탁
26,동탁,동탁,,개요,https://namu.wiki/w/동탁
26,동탁,동탁,,생애,https://namu.wiki/w/동탁
26,동탁,동탁,Y,평가,https://namu.wiki/w/동탁
26,동탁,동탁,Y,재평가?,https://namu.wiki/w/동탁
26,동탁,동탁,,반론,https://namu.wiki/w/동탁
26,동탁,동탁,Y,가족 관계,https://namu.wiki/w/동탁
26,동탁,동탁,Y,기타 창작물,https://namu.wiki/w/동탁
26,동탁,동탁,Y,여담,https://namu.wiki/w/동탁
26,동탁,동탁,,둘러 보기,https://namu.wiki/w/동탁
27,원소,원소(삼국지),,원소(삼국지),https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,개요,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,생애,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),Y,평가,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,정치적 능력,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,행정적 능력,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,군사적 능력,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,용인술,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,도덕성,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,외모와 쇼맨십,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),Y,여담,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,조조에게 열등감을 가졌었다?,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,후계자 선정에서 잘못을 했나?,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,우유부단했는가?,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,투구 대신에 모자와 정장 차림,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),Y,가족 관계,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),Y,대중매체에서,https://namu.wiki/w/원소(삼국지)
27,원소,원소(삼국지),,둘러보기,https://namu.wiki/w/원소(삼국지)
28,원술,원술,,원술,https://namu.wiki/w/원술
28,원술,원술,,개요,https://namu.wiki/w/원술
28,원술,원술,,생애,https://namu.wiki/w/원술
28,원술,원술,Y,평가,https://namu.wiki/w/원술
28,원술,원술,Y,가족 관계,https://namu.wiki/w/원술
28,원술,원술,Y,여담,https://namu.wiki/w/원술
28,원술,원술,Y,대중매체에서,https://namu.wiki/w/원술
28,원술,원술,,둘러보기(계보),https://namu.wiki/w/원술
1 번호 인물 문서제목 썰후보여부 키워드 URL
2 1 조조 조조 조조 https://namu.wiki/w/조조
3 1 조조 조조 개요 https://namu.wiki/w/조조
4 1 조조 조조 Y 이름 https://namu.wiki/w/조조
5 1 조조 조조 생애 https://namu.wiki/w/조조
6 1 조조 조조 Y 가족 관계 https://namu.wiki/w/조조
7 1 조조 조조 무덤 https://namu.wiki/w/조조
8 1 조조 조조 Y 평가 https://namu.wiki/w/조조
9 1 조조 조조 Y 업적과 능력 https://namu.wiki/w/조조
10 1 조조 조조 vs 유비 https://namu.wiki/w/조조
11 1 조조 조조 Y 어록 https://namu.wiki/w/조조
12 1 조조 조조 Y 기타 https://namu.wiki/w/조조
13 1 조조 조조 Y 기타 창작물 https://namu.wiki/w/조조
14 1 조조 조조 둘러보기 https://namu.wiki/w/조조
15 2 사마의 사마의 사마의 https://namu.wiki/w/사마의
16 2 사마의 사마의 개요 https://namu.wiki/w/사마의
17 2 사마의 사마의 Y 일생(정사) https://namu.wiki/w/사마의
18 2 사마의 사마의 Y 연의 https://namu.wiki/w/사마의
19 2 사마의 사마의 Y 가족 관계 https://namu.wiki/w/사마의
20 2 사마의 사마의 Y 평가 https://namu.wiki/w/사마의
21 2 사마의 사마의 Y 기타 https://namu.wiki/w/사마의
22 2 사마의 사마의 Y 미디어 믹스 https://namu.wiki/w/사마의
23 2 사마의 사마의 둘러보기 https://namu.wiki/w/사마의
24 3 하후돈 하후돈 하후돈 https://namu.wiki/w/하후돈
25 3 하후돈 하후돈 개요 https://namu.wiki/w/하후돈
26 3 하후돈 하후돈 Y 정사 삼국지 https://namu.wiki/w/하후돈
27 3 하후돈 하후돈 초기 생애 https://namu.wiki/w/하후돈
28 3 하후돈 하후돈 동탁 토벌전 https://namu.wiki/w/하후돈
29 3 하후돈 하후돈 Y 복양 전투 https://namu.wiki/w/하후돈
30 3 하후돈 하후돈 서주에서 https://namu.wiki/w/하후돈
31 3 하후돈 하후돈 조조 휘하 https://namu.wiki/w/하후돈
32 3 하후돈 하후돈 관도대전 https://namu.wiki/w/하후돈
33 3 하후돈 하후돈 Y 박망파 전투 https://namu.wiki/w/하후돈
34 3 하후돈 하후돈 하북 평정 https://namu.wiki/w/하후돈
35 3 하후돈 하후돈 장로 정벌 https://namu.wiki/w/하후돈
36 3 하후돈 하후돈 Y 216년~217년 유수구 전투 https://namu.wiki/w/하후돈
37 3 하후돈 하후돈 번성 공방전 https://namu.wiki/w/하후돈
38 3 하후돈 하후돈 Y 죽음 https://namu.wiki/w/하후돈
39 3 하후돈 하후돈 사후 (위나라 건국) https://namu.wiki/w/하후돈
40 3 하후돈 하후돈 Y 삼국지연의 https://namu.wiki/w/하후돈
41 3 하후돈 하후돈 Y 평가 https://namu.wiki/w/하후돈
42 3 하후돈 하후돈 친족 우대의 수혜자 https://namu.wiki/w/하후돈
43 3 하후돈 하후돈 Y 긍정적 평가 https://namu.wiki/w/하후돈
44 3 하후돈 하후돈 Y 연의에 기반한 오해 및 재평가 https://namu.wiki/w/하후돈
45 3 하후돈 하후돈 Y 기타 https://namu.wiki/w/하후돈
46 3 하후돈 하후돈 Y 미디어 믹스 https://namu.wiki/w/하후돈
47 3 하후돈 하후돈 관련 문서 https://namu.wiki/w/하후돈
48 4 장료 장료 장료 https://namu.wiki/w/장료
49 4 장료 장료 개요 https://namu.wiki/w/장료
50 4 장료 장료 Y 정사 삼국지 https://namu.wiki/w/장료
51 4 장료 장료 초기 생애 https://namu.wiki/w/장료
52 4 장료 장료 여포 휘하 https://namu.wiki/w/장료
53 4 장료 장료 관도대전 https://namu.wiki/w/장료
54 4 장료 장료 창희 토벌 https://namu.wiki/w/장료
55 4 장료 장료 하북 평정 https://namu.wiki/w/장료
56 4 장료 장료 진란 토벌 https://namu.wiki/w/장료
57 4 장료 장료 형주 정벌 https://namu.wiki/w/장료
58 4 장료 장료 Y 환성 전투 https://namu.wiki/w/장료
59 4 장료 장료 2차 합비 공방전[7] https://namu.wiki/w/장료
60 4 장료 장료 조조 휘하 https://namu.wiki/w/장료
61 4 장료 장료 Y 유수구 전투 https://namu.wiki/w/장료
62 4 장료 장료 번성 공방전 https://namu.wiki/w/장료
63 4 장료 장료 조비 휘하 https://namu.wiki/w/장료
64 4 장료 장료 Y 동구 전투 https://namu.wiki/w/장료
65 4 장료 장료 Y 죽음 https://namu.wiki/w/장료
66 4 장료 장료 Y 삼국지연의 https://namu.wiki/w/장료
67 4 장료 장료 Y 평가 https://namu.wiki/w/장료
68 4 장료 장료 군사적 능력 https://namu.wiki/w/장료
69 4 장료 장료 인품과 성품 https://namu.wiki/w/장료
70 4 장료 장료 Y 기타 https://namu.wiki/w/장료
71 4 장료 장료 Y 미디어 믹스 https://namu.wiki/w/장료
72 4 장료 장료 둘러보기 https://namu.wiki/w/장료
73 4 장료 장료 관련 문서 https://namu.wiki/w/장료
74 5 허저 허저 허저 https://namu.wiki/w/허저
75 5 허저 허저 개요 https://namu.wiki/w/허저
76 5 허저 허저 Y 정사 삼국지 https://namu.wiki/w/허저
77 5 허저 허저 초기 생애 https://namu.wiki/w/허저
78 5 허저 허저 Y 양 전투 https://namu.wiki/w/허저
79 5 허저 허저 관도대전 https://namu.wiki/w/허저
80 5 허저 허저 하북 평정 https://namu.wiki/w/허저
81 5 허저 허저 관중 평정 https://namu.wiki/w/허저
82 5 허저 허저 장로 정벌 https://namu.wiki/w/허저
83 5 허저 허저 조조 휘하 https://namu.wiki/w/허저
84 5 허저 허저 Y 죽음 https://namu.wiki/w/허저
85 5 허저 허저 Y 삼국지연의 https://namu.wiki/w/허저
86 5 허저 허저 Y 평가 https://namu.wiki/w/허저
87 5 허저 허저 Y 여담 https://namu.wiki/w/허저
88 5 허저 허저 Y 대중매체에서 https://namu.wiki/w/허저
89 6 곽가 곽가 곽가 https://namu.wiki/w/곽가
90 6 곽가 곽가 개요 https://namu.wiki/w/곽가
91 6 곽가 곽가 생애 https://namu.wiki/w/곽가
92 6 곽가 곽가 초기 생애 https://namu.wiki/w/곽가
93 6 곽가 곽가 조조에게 임관 https://namu.wiki/w/곽가
94 6 곽가 곽가 서주에서 https://namu.wiki/w/곽가
95 6 곽가 곽가 관도대전 https://namu.wiki/w/곽가
96 6 곽가 곽가 하북 평정 https://namu.wiki/w/곽가
97 6 곽가 곽가 Y 죽음 https://namu.wiki/w/곽가
98 6 곽가 곽가 사후 https://namu.wiki/w/곽가
99 6 곽가 곽가 Y 평가 https://namu.wiki/w/곽가
100 6 곽가 곽가 Y 긍정적 평가 https://namu.wiki/w/곽가
101 6 곽가 곽가 Y 부정적 평가: 연의의 수혜자 https://namu.wiki/w/곽가
102 6 곽가 곽가 별 볼일 없는 전공 https://namu.wiki/w/곽가
103 6 곽가 곽가 적벽대전에서 곽가만 있었다면 이겼다? https://namu.wiki/w/곽가
104 6 곽가 곽가 사생활 https://namu.wiki/w/곽가
105 6 곽가 곽가 Y 여담 https://namu.wiki/w/곽가
106 6 곽가 곽가 Y 미디어 믹스 https://namu.wiki/w/곽가
107 7 순욱 순욱 순욱 https://namu.wiki/w/순욱
108 7 순욱 순욱 개요 https://namu.wiki/w/순욱
109 7 순욱 순욱 Y 생애(정사) https://namu.wiki/w/순욱
110 7 순욱 순욱 Y 연의 https://namu.wiki/w/순욱
111 7 순욱 순욱 Y 평가 https://namu.wiki/w/순욱
112 7 순욱 순욱 Y 가족 관계 https://namu.wiki/w/순욱
113 7 순욱 순욱 Y 미디어 믹스 https://namu.wiki/w/순욱
114 7 순욱 순욱 Y 기타 https://namu.wiki/w/순욱
115 7 순욱 순욱 순욱/빈 찬합 밈 https://namu.wiki/w/순욱
116 7 순욱 순욱 둘러보기 https://namu.wiki/w/순욱
117 8 가후 가후 가후 https://namu.wiki/w/가후
118 8 가후 가후 개요 https://namu.wiki/w/가후
119 8 가후 가후 Y 정사 삼국지 https://namu.wiki/w/가후
120 8 가후 가후 초기 생애 https://namu.wiki/w/가후
121 8 가후 가후 동탁 휘하 https://namu.wiki/w/가후
122 8 가후 가후 이각 휘하 https://namu.wiki/w/가후
123 8 가후 가후 단외 휘하 https://namu.wiki/w/가후
124 8 가후 가후 장수 휘하 https://namu.wiki/w/가후
125 8 가후 가후 Y 완 전투 https://namu.wiki/w/가후
126 8 가후 가후 Y 양 전투 https://namu.wiki/w/가후
127 8 가후 가후 관도대전 https://namu.wiki/w/가후
128 8 가후 가후 적벽대전 https://namu.wiki/w/가후
129 8 가후 가후 관중 평정 https://namu.wiki/w/가후
130 8 가후 가후 조비에게 조언 https://namu.wiki/w/가후
131 8 가후 가후 조비 휘하 (위나라 건국) https://namu.wiki/w/가후
132 8 가후 가후 Y 죽음 https://namu.wiki/w/가후
133 8 가후 가후 Y 삼국지연의 https://namu.wiki/w/가후
134 8 가후 가후 Y 평가 https://namu.wiki/w/가후
135 8 가후 가후 Y 긍정적 평가 https://namu.wiki/w/가후
136 8 가후 가후 Y 부정적 평가 https://namu.wiki/w/가후
137 8 가후 가후 Y 기타 https://namu.wiki/w/가후
138 8 가후 가후 Y 미디어 믹스 https://namu.wiki/w/가후
139 8 가후 가후 관련 문서 https://namu.wiki/w/가후
140 8 가후 가후 둘러보기 https://namu.wiki/w/가후
141 9 유비 유비 유비 https://namu.wiki/w/유비
142 9 유비 유비 개요 https://namu.wiki/w/유비
143 9 유비 유비 Y 이름과 칭호 https://namu.wiki/w/유비
144 9 유비 유비 생애 https://namu.wiki/w/유비
145 9 유비 유비 Y 평가 https://namu.wiki/w/유비
146 9 유비 유비 vs 조조 https://namu.wiki/w/유비
147 9 유비 유비 혈통 https://namu.wiki/w/유비
148 9 유비 유비 Y 가족 관계 https://namu.wiki/w/유비
149 9 유비 유비 무덤 https://namu.wiki/w/유비
150 9 유비 유비 Y 기타 https://namu.wiki/w/유비
151 9 유비 유비 Y 대중매체에서 https://namu.wiki/w/유비
152 9 유비 유비 둘러보기 https://namu.wiki/w/유비
153 10 관우 관우 관우 https://namu.wiki/w/관우
154 10 관우 관우 개요 https://namu.wiki/w/관우
155 10 관우 관우 Y 생애(정사) https://namu.wiki/w/관우
156 10 관우 관우 Y 연의 https://namu.wiki/w/관우
157 10 관우 관우 역대 봉호 https://namu.wiki/w/관우
158 10 관우 관우 중화 왕조 https://namu.wiki/w/관우
159 10 관우 관우 조선 https://namu.wiki/w/관우
160 10 관우 관우 Y 가족 관계 https://namu.wiki/w/관우
161 10 관우 관우 Y 평가 https://namu.wiki/w/관우
162 10 관우 관우 Y 시호 논란 https://namu.wiki/w/관우
163 10 관우 관우 악시설 https://namu.wiki/w/관우
164 10 관우 관우 선시설 https://namu.wiki/w/관우
165 10 관우 관우 신격화 https://namu.wiki/w/관우
166 10 관우 관우 말말말 https://namu.wiki/w/관우
167 10 관우 관우 Y 대중매체에서 https://namu.wiki/w/관우
168 10 관우 관우 관련 문서 https://namu.wiki/w/관우
169 10 관우 관우 둘러보기 https://namu.wiki/w/관우
170 11 장비 장비 장비 https://namu.wiki/w/장비
171 11 장비 장비 개요 https://namu.wiki/w/장비
172 11 장비 장비 Y 생애(정사) https://namu.wiki/w/장비
173 11 장비 장비 Y 연의 https://namu.wiki/w/장비
174 11 장비 장비 Y 연의의 피해자? https://namu.wiki/w/장비
175 11 장비 장비 Y 가족 관계 https://namu.wiki/w/장비
176 11 장비 장비 Y 평가 https://namu.wiki/w/장비
177 11 장비 장비 Y 기타 https://namu.wiki/w/장비
178 11 장비 장비 Y 기타 창작물 https://namu.wiki/w/장비
179 11 장비 장비 관련 항목 https://namu.wiki/w/장비
180 11 장비 장비 둘러보기 https://namu.wiki/w/장비
181 12 제갈량 제갈량 제갈량 https://namu.wiki/w/제갈량
182 12 제갈량 제갈량 개요 https://namu.wiki/w/제갈량
183 12 제갈량 제갈량 Y 생애(정사) https://namu.wiki/w/제갈량
184 12 제갈량 제갈량 Y 연의 https://namu.wiki/w/제갈량
185 12 제갈량 제갈량 인간관계 https://namu.wiki/w/제갈량
186 12 제갈량 제갈량 Y 업적 https://namu.wiki/w/제갈량
187 12 제갈량 제갈량 Y 평가 https://namu.wiki/w/제갈량
188 12 제갈량 제갈량 Y 논란 https://namu.wiki/w/제갈량
189 12 제갈량 제갈량 Y 관련 일화들 https://namu.wiki/w/제갈량
190 12 제갈량 제갈량 사상 https://namu.wiki/w/제갈량
191 12 제갈량 제갈량 Y 여담 https://namu.wiki/w/제갈량
192 12 제갈량 제갈량 Y 미디어 믹스 https://namu.wiki/w/제갈량
193 12 제갈량 제갈량 관련 문서 https://namu.wiki/w/제갈량
194 12 제갈량 제갈량 둘러보기 https://namu.wiki/w/제갈량
195 13 조운 조운 조운 https://namu.wiki/w/조운
196 13 조운 조운 개요 https://namu.wiki/w/조운
197 13 조운 조운 소개 https://namu.wiki/w/조운
198 13 조운 조운 Y 정사 https://namu.wiki/w/조운
199 13 조운 조운 Y 평가 https://namu.wiki/w/조운
200 13 조운 조운 Y 연의 https://namu.wiki/w/조운
201 13 조운 조운 Y 가족 관계 https://namu.wiki/w/조운
202 13 조운 조운 Y 여담 https://namu.wiki/w/조운
203 13 조운 조운 Y 미디어 믹스 https://namu.wiki/w/조운
204 13 조운 조운 관련 문서 https://namu.wiki/w/조운
205 14 마초 마초 마초 https://namu.wiki/w/마초
206 14 마초 마초 개요 https://namu.wiki/w/마초
207 14 마초 마초 Y 정사 https://namu.wiki/w/마초
208 14 마초 마초 Y 평가 https://namu.wiki/w/마초
209 14 마초 마초 Y 연의 https://namu.wiki/w/마초
210 14 마초 마초 Y 가족 관계 https://namu.wiki/w/마초
211 14 마초 마초 Y 여담 https://namu.wiki/w/마초
212 14 마초 마초 Y 대중매체에서 https://namu.wiki/w/마초
213 14 마초 마초 둘러보기 https://namu.wiki/w/마초
214 15 황충 황충 황충 https://namu.wiki/w/황충
215 15 황충 황충 개요 https://namu.wiki/w/황충
216 15 황충 황충 Y 생애(정사) https://namu.wiki/w/황충
217 15 황충 황충 Y 연의 https://namu.wiki/w/황충
218 15 황충 황충 유비군 합류 https://namu.wiki/w/황충
219 15 황충 황충 한중 공방전 https://namu.wiki/w/황충
220 15 황충 황충 Y 사망 https://namu.wiki/w/황충
221 15 황충 황충 Y 평가 https://namu.wiki/w/황충
222 15 황충 황충 노장이었는가? https://namu.wiki/w/황충
223 15 황충 황충 노장이 아니다. https://namu.wiki/w/황충
224 15 황충 황충 노장이 맞다. https://namu.wiki/w/황충
225 15 황충 황충 Y 여담 https://namu.wiki/w/황충
226 15 황충 황충 Y 대중매체에서 https://namu.wiki/w/황충
227 15 황충 황충 둘러보기 https://namu.wiki/w/황충
228 16 강유 강유 강유 https://namu.wiki/w/강유
229 16 강유 강유 개요 https://namu.wiki/w/강유
230 16 강유 강유 Y 생애(정사) https://namu.wiki/w/강유
231 16 강유 강유 Y 연의 https://namu.wiki/w/강유
232 16 강유 강유 Y 평가 https://namu.wiki/w/강유
233 16 강유 강유 Y 여담 https://namu.wiki/w/강유
234 16 강유 강유 Y 대중매체에서 https://namu.wiki/w/강유
235 17 방통 방통 방통 https://namu.wiki/w/방통
236 17 방통 방통 개요 https://namu.wiki/w/방통
237 17 방통 방통 Y 생애(정사) https://namu.wiki/w/방통
238 17 방통 방통 Y 연의 https://namu.wiki/w/방통
239 17 방통 방통 Y 평가 https://namu.wiki/w/방통
240 17 방통 방통 Y 여담 https://namu.wiki/w/방통
241 17 방통 방통 Y 대중매체에서 https://namu.wiki/w/방통
242 18 손권 손권 손권 https://namu.wiki/w/손권
243 18 손권 손권 개요 https://namu.wiki/w/손권
244 18 손권 손권 시호 https://namu.wiki/w/손권
245 18 손권 손권 생애 https://namu.wiki/w/손권
246 18 손권 손권 Y 평가 https://namu.wiki/w/손권
247 18 손권 손권 Y 가족 관계 https://namu.wiki/w/손권
248 18 손권 손권 Y 기타 https://namu.wiki/w/손권
249 18 손권 손권 Y 대중매체에서 https://namu.wiki/w/손권
250 18 손권 손권 둘러보기 https://namu.wiki/w/손권
251 19 손책 손책 손책 https://namu.wiki/w/손책
252 19 손책 손책 개요 https://namu.wiki/w/손책
253 19 손책 손책 생애 https://namu.wiki/w/손책
254 19 손책 손책 초기 생애 https://namu.wiki/w/손책
255 19 손책 손책 원술 휘하에서 https://namu.wiki/w/손책
256 19 손책 손책 유요를 격파하다 https://namu.wiki/w/손책
257 19 손책 손책 왕랑을 격파하다 https://namu.wiki/w/손책
258 19 손책 손책 엄백호를 격파하다 https://namu.wiki/w/손책
259 19 손책 손책 원술로부터 독립 https://namu.wiki/w/손책
260 19 손책 손책 유훈을 격파하다 https://namu.wiki/w/손책
261 19 손책 손책 조조와의 관계 https://namu.wiki/w/손책
262 19 손책 손책 고대를 죽이다 https://namu.wiki/w/손책
263 19 손책 손책 허공을 죽이다 https://namu.wiki/w/손책
264 19 손책 손책 피습을 당하다 https://namu.wiki/w/손책
265 19 손책 손책 우길을 죽이다 https://namu.wiki/w/손책
266 19 손책 손책 Y 죽음 https://namu.wiki/w/손책
267 19 손책 손책 사후 https://namu.wiki/w/손책
268 19 손책 손책 Y 평가 https://namu.wiki/w/손책
269 19 손책 손책 Y 가족 관계 https://namu.wiki/w/손책
270 19 손책 손책 Y 기타 https://namu.wiki/w/손책
271 19 손책 손책 Y 대중매체에서 https://namu.wiki/w/손책
272 19 손책 손책 둘러보기 https://namu.wiki/w/손책
273 20 주유 주유(삼국지) 주유(삼국지) https://namu.wiki/w/주유(삼국지)
274 20 주유 주유(삼국지) 개요 https://namu.wiki/w/주유(삼국지)
275 20 주유 주유(삼국지) Y 정사 삼국지 https://namu.wiki/w/주유(삼국지)
276 20 주유 주유(삼국지) 초기 생애 https://namu.wiki/w/주유(삼국지)
277 20 주유 주유(삼국지) 원술 휘하에서 https://namu.wiki/w/주유(삼국지)
278 20 주유 주유(삼국지) 손책 휘하에서 https://namu.wiki/w/주유(삼국지)
279 20 주유 주유(삼국지) 손권 휘하에서 https://namu.wiki/w/주유(삼국지)
280 20 주유 주유(삼국지) 적벽대전 https://namu.wiki/w/주유(삼국지)
281 20 주유 주유(삼국지) 남군 공방전 https://namu.wiki/w/주유(삼국지)
282 20 주유 주유(삼국지) Y 죽음 https://namu.wiki/w/주유(삼국지)
283 20 주유 주유(삼국지) Y 삼국지연의 https://namu.wiki/w/주유(삼국지)
284 20 주유 주유(삼국지) Y 평가 https://namu.wiki/w/주유(삼국지)
285 20 주유 주유(삼국지) Y 가족 및 후손 https://namu.wiki/w/주유(삼국지)
286 20 주유 주유(삼국지) Y 기타 https://namu.wiki/w/주유(삼국지)
287 20 주유 주유(삼국지) Y 대중매체에서 https://namu.wiki/w/주유(삼국지)
288 21 육손 육손 육손 https://namu.wiki/w/육손
289 21 육손 육손 개요 https://namu.wiki/w/육손
290 21 육손 육손 Y 정사 삼국지 https://namu.wiki/w/육손
291 21 육손 육손 초기 생애 https://namu.wiki/w/육손
292 21 육손 육손 손가와의 화친 https://namu.wiki/w/육손
293 21 육손 육손 번성 공방전 https://namu.wiki/w/육손
294 21 육손 육손 700리 촉영을 불사르다, 이릉대전 https://namu.wiki/w/육손
295 21 육손 육손 외교와 내정 https://namu.wiki/w/육손
296 21 육손 육손 Y 석정 전투 https://namu.wiki/w/육손
297 21 육손 육손 건업에서(오나라 건국) https://namu.wiki/w/육손
298 21 육손 육손 4차 합비 공방전 https://namu.wiki/w/육손
299 21 육손 육손 형주에서 보여준 지략 https://namu.wiki/w/육손
300 21 육손 육손 손권과의 말년 https://namu.wiki/w/육손
301 21 육손 육손 Y 이궁지쟁 사건과 최후 https://namu.wiki/w/육손
302 21 육손 육손 사후 https://namu.wiki/w/육손
303 21 육손 육손 Y 삼국지연의 https://namu.wiki/w/육손
304 21 육손 육손 Y 평가 https://namu.wiki/w/육손
305 21 육손 육손 Y 기타 https://namu.wiki/w/육손
306 21 육손 육손 Y 미디어 믹스 https://namu.wiki/w/육손
307 21 육손 육손 둘러보기 https://namu.wiki/w/육손
308 22 노숙 노숙(삼국지) 노숙(삼국지) https://namu.wiki/w/노숙(삼국지)
309 22 노숙 노숙(삼국지) 개요 https://namu.wiki/w/노숙(삼국지)
310 22 노숙 노숙(삼국지) Y 정사 삼국지 https://namu.wiki/w/노숙(삼국지)
311 22 노숙 노숙(삼국지) 초기 생애 https://namu.wiki/w/노숙(삼국지)
312 22 노숙 노숙(삼국지) 손권에게 임관 https://namu.wiki/w/노숙(삼국지)
313 22 노숙 노숙(삼국지) 적벽대전 https://namu.wiki/w/노숙(삼국지)
314 22 노숙 노숙(삼국지) 익양대치 https://namu.wiki/w/노숙(삼국지)
315 22 노숙 노숙(삼국지) Y 죽음 https://namu.wiki/w/노숙(삼국지)
316 22 노숙 노숙(삼국지) 사후 https://namu.wiki/w/노숙(삼국지)
317 22 노숙 노숙(삼국지) Y 삼국지연의 https://namu.wiki/w/노숙(삼국지)
318 22 노숙 노숙(삼국지) Y 평가 https://namu.wiki/w/노숙(삼국지)
319 22 노숙 노숙(삼국지) Y 기타 https://namu.wiki/w/노숙(삼국지)
320 22 노숙 노숙(삼국지) Y 기타 창작물 https://namu.wiki/w/노숙(삼국지)
321 23 감녕 감녕 감녕 https://namu.wiki/w/감녕
322 23 감녕 감녕 개요 https://namu.wiki/w/감녕
323 23 감녕 감녕 Y 정사 삼국지 https://namu.wiki/w/감녕
324 23 감녕 감녕 익주의 토호 https://namu.wiki/w/감녕
325 23 감녕 감녕 익주를 떠나다 https://namu.wiki/w/감녕
326 23 감녕 감녕 황조의 푸대접 https://namu.wiki/w/감녕
327 23 감녕 감녕 손권에게 투항 https://namu.wiki/w/감녕
328 23 감녕 감녕 남군 공방전 https://namu.wiki/w/감녕
329 23 감녕 감녕 Y 환성 전투 https://namu.wiki/w/감녕
330 23 감녕 감녕 익양대치 https://namu.wiki/w/감녕
331 23 감녕 감녕 2차 합비 공방전 https://namu.wiki/w/감녕
332 23 감녕 감녕 Y 유수구 전투 https://namu.wiki/w/감녕
333 23 감녕 감녕 Y 그의 성격을 보여주는 일화 https://namu.wiki/w/감녕
334 23 감녕 감녕 Y 죽음 https://namu.wiki/w/감녕
335 23 감녕 감녕 Y 삼국지연의 https://namu.wiki/w/감녕
336 23 감녕 감녕 나이 https://namu.wiki/w/감녕
337 23 감녕 감녕 감녕전의 순서 문제 https://namu.wiki/w/감녕
338 23 감녕 감녕 Y 기타 https://namu.wiki/w/감녕
339 23 감녕 감녕 Y 미디어 믹스 https://namu.wiki/w/감녕
340 24 태사자 태사자 태사자 https://namu.wiki/w/태사자
341 24 태사자 태사자 개요 https://namu.wiki/w/태사자
342 24 태사자 태사자 생애 https://namu.wiki/w/태사자
343 24 태사자 태사자 요동으로 피신하다 https://namu.wiki/w/태사자
344 24 태사자 태사자 공융을 구원하다 https://namu.wiki/w/태사자
345 24 태사자 태사자 손책을 따르다 https://namu.wiki/w/태사자
346 24 태사자 태사자 예장을 정찰하다 https://namu.wiki/w/태사자
347 24 태사자 태사자 동오의 명장 https://namu.wiki/w/태사자
348 24 태사자 태사자 Y 죽음 https://namu.wiki/w/태사자
349 24 태사자 태사자 Y 기타 https://namu.wiki/w/태사자
350 24 태사자 태사자 Y 기타 창작물 https://namu.wiki/w/태사자
351 25 여포 여포 여포 https://namu.wiki/w/여포
352 25 여포 여포 개요 https://namu.wiki/w/여포
353 25 여포 여포 Y 정사 삼국지 https://namu.wiki/w/여포
354 25 여포 여포 정원 휘하 https://namu.wiki/w/여포
355 25 여포 여포 동탁 휘하 https://namu.wiki/w/여포
356 25 여포 여포 왕윤과의 갈등 https://namu.wiki/w/여포
357 25 여포 여포 방랑 https://namu.wiki/w/여포
358 25 여포 여포 Y 여포의 최후 https://namu.wiki/w/여포
359 25 여포 여포 Y 삼국지연의 https://namu.wiki/w/여포
360 25 여포 여포 Y 평가 https://namu.wiki/w/여포
361 25 여포 여포 무력 https://namu.wiki/w/여포
362 25 여포 여포 통솔 https://namu.wiki/w/여포
363 25 여포 여포 지력 https://namu.wiki/w/여포
364 25 여포 여포 정치 https://namu.wiki/w/여포
365 25 여포 여포 인격 https://namu.wiki/w/여포
366 25 여포 여포 배신 https://namu.wiki/w/여포
367 25 여포 여포 색욕 https://namu.wiki/w/여포
368 25 여포 여포 번외: 항우와의 비교 https://namu.wiki/w/여포
369 25 여포 여포 유비와의 관계 https://namu.wiki/w/여포
370 25 여포 여포 이민족 떡밥 https://namu.wiki/w/여포
371 25 여포 여포 Y 여담 https://namu.wiki/w/여포
372 25 여포 여포 Y 대중매체에서 https://namu.wiki/w/여포
373 26 동탁 동탁 동탁 https://namu.wiki/w/동탁
374 26 동탁 동탁 개요 https://namu.wiki/w/동탁
375 26 동탁 동탁 생애 https://namu.wiki/w/동탁
376 26 동탁 동탁 Y 평가 https://namu.wiki/w/동탁
377 26 동탁 동탁 Y 재평가? https://namu.wiki/w/동탁
378 26 동탁 동탁 반론 https://namu.wiki/w/동탁
379 26 동탁 동탁 Y 가족 관계 https://namu.wiki/w/동탁
380 26 동탁 동탁 Y 기타 창작물 https://namu.wiki/w/동탁
381 26 동탁 동탁 Y 여담 https://namu.wiki/w/동탁
382 26 동탁 동탁 둘러 보기 https://namu.wiki/w/동탁
383 27 원소 원소(삼국지) 원소(삼국지) https://namu.wiki/w/원소(삼국지)
384 27 원소 원소(삼국지) 개요 https://namu.wiki/w/원소(삼국지)
385 27 원소 원소(삼국지) 생애 https://namu.wiki/w/원소(삼국지)
386 27 원소 원소(삼국지) Y 평가 https://namu.wiki/w/원소(삼국지)
387 27 원소 원소(삼국지) 정치적 능력 https://namu.wiki/w/원소(삼국지)
388 27 원소 원소(삼국지) 행정적 능력 https://namu.wiki/w/원소(삼국지)
389 27 원소 원소(삼국지) 군사적 능력 https://namu.wiki/w/원소(삼국지)
390 27 원소 원소(삼국지) 용인술 https://namu.wiki/w/원소(삼국지)
391 27 원소 원소(삼국지) 도덕성 https://namu.wiki/w/원소(삼국지)
392 27 원소 원소(삼국지) 외모와 쇼맨십 https://namu.wiki/w/원소(삼국지)
393 27 원소 원소(삼국지) Y 여담 https://namu.wiki/w/원소(삼국지)
394 27 원소 원소(삼국지) 조조에게 열등감을 가졌었다? https://namu.wiki/w/원소(삼국지)
395 27 원소 원소(삼국지) 후계자 선정에서 잘못을 했나? https://namu.wiki/w/원소(삼국지)
396 27 원소 원소(삼국지) 우유부단했는가? https://namu.wiki/w/원소(삼국지)
397 27 원소 원소(삼국지) 투구 대신에 모자와 정장 차림 https://namu.wiki/w/원소(삼국지)
398 27 원소 원소(삼국지) Y 가족 관계 https://namu.wiki/w/원소(삼국지)
399 27 원소 원소(삼국지) Y 대중매체에서 https://namu.wiki/w/원소(삼국지)
400 27 원소 원소(삼국지) 둘러보기 https://namu.wiki/w/원소(삼국지)
401 28 원술 원술 원술 https://namu.wiki/w/원술
402 28 원술 원술 개요 https://namu.wiki/w/원술
403 28 원술 원술 생애 https://namu.wiki/w/원술
404 28 원술 원술 Y 평가 https://namu.wiki/w/원술
405 28 원술 원술 Y 가족 관계 https://namu.wiki/w/원술
406 28 원술 원술 Y 여담 https://namu.wiki/w/원술
407 28 원술 원술 Y 대중매체에서 https://namu.wiki/w/원술
408 28 원술 원술 둘러보기(계보) https://namu.wiki/w/원술

View File

@ -0,0 +1,920 @@
[
{
"title": "조조",
"url": "https://namu.wiki/w/조조",
"page_title": "조조",
"keywords": [
"조조",
"개요",
"이름",
"생애",
"가족 관계",
"무덤",
"평가",
"업적과 능력",
"vs 유비",
"어록",
"기타",
"기타 창작물",
"둘러보기"
],
"anecdote_keywords": [
"이름",
"가족 관계",
"평가",
"업적과 능력",
"어록",
"기타",
"기타 창작물"
],
"error": null,
"order": 1,
"name": "조조"
},
{
"title": "사마의",
"url": "https://namu.wiki/w/사마의",
"page_title": "사마의",
"keywords": [
"사마의",
"개요",
"일생(정사)",
"연의",
"가족 관계",
"평가",
"기타",
"미디어 믹스",
"둘러보기"
],
"anecdote_keywords": [
"일생(정사)",
"연의",
"가족 관계",
"평가",
"기타",
"미디어 믹스"
],
"error": null,
"order": 2,
"name": "사마의"
},
{
"title": "하후돈",
"url": "https://namu.wiki/w/하후돈",
"page_title": "하후돈",
"keywords": [
"하후돈",
"개요",
"정사 삼국지",
"초기 생애",
"동탁 토벌전",
"복양 전투",
"서주에서",
"조조 휘하",
"관도대전",
"박망파 전투",
"하북 평정",
"장로 정벌",
"216년~217년 유수구 전투",
"번성 공방전",
"죽음",
"사후 (위나라 건국)",
"삼국지연의",
"평가",
"친족 우대의 수혜자",
"긍정적 평가",
"연의에 기반한 오해 및 재평가",
"기타",
"미디어 믹스",
"관련 문서"
],
"anecdote_keywords": [
"정사 삼국지",
"복양 전투",
"박망파 전투",
"216년~217년 유수구 전투",
"죽음",
"삼국지연의",
"평가",
"긍정적 평가",
"연의에 기반한 오해 및 재평가",
"기타",
"미디어 믹스"
],
"error": null,
"order": 3,
"name": "하후돈"
},
{
"title": "장료",
"url": "https://namu.wiki/w/장료",
"page_title": "장료",
"keywords": [
"장료",
"개요",
"정사 삼국지",
"초기 생애",
"여포 휘하",
"관도대전",
"창희 토벌",
"하북 평정",
"진란 토벌",
"형주 정벌",
"환성 전투",
"2차 합비 공방전[7]",
"조조 휘하",
"유수구 전투",
"번성 공방전",
"조비 휘하",
"동구 전투",
"죽음",
"삼국지연의",
"평가",
"군사적 능력",
"인품과 성품",
"기타",
"미디어 믹스",
"둘러보기",
"관련 문서"
],
"anecdote_keywords": [
"정사 삼국지",
"환성 전투",
"유수구 전투",
"동구 전투",
"죽음",
"삼국지연의",
"평가",
"기타",
"미디어 믹스"
],
"error": null,
"order": 4,
"name": "장료"
},
{
"title": "허저",
"url": "https://namu.wiki/w/허저",
"page_title": "허저",
"keywords": [
"허저",
"개요",
"정사 삼국지",
"초기 생애",
"양 전투",
"관도대전",
"하북 평정",
"관중 평정",
"장로 정벌",
"조조 휘하",
"죽음",
"삼국지연의",
"평가",
"여담",
"대중매체에서"
],
"anecdote_keywords": [
"정사 삼국지",
"양 전투",
"죽음",
"삼국지연의",
"평가",
"여담",
"대중매체에서"
],
"error": null,
"order": 5,
"name": "허저"
},
{
"title": "곽가",
"url": "https://namu.wiki/w/곽가",
"page_title": "곽가",
"keywords": [
"곽가",
"개요",
"생애",
"초기 생애",
"조조에게 임관",
"서주에서",
"관도대전",
"하북 평정",
"죽음",
"사후",
"평가",
"긍정적 평가",
"부정적 평가: 연의의 수혜자",
"별 볼일 없는 전공",
"적벽대전에서 곽가만 있었다면 이겼다?",
"사생활",
"여담",
"미디어 믹스"
],
"anecdote_keywords": [
"죽음",
"평가",
"긍정적 평가",
"부정적 평가: 연의의 수혜자",
"여담",
"미디어 믹스"
],
"error": null,
"order": 6,
"name": "곽가"
},
{
"title": "순욱",
"url": "https://namu.wiki/w/순욱",
"page_title": "순욱",
"keywords": [
"순욱",
"개요",
"생애(정사)",
"연의",
"평가",
"가족 관계",
"미디어 믹스",
"기타",
"순욱/빈 찬합 밈",
"둘러보기"
],
"anecdote_keywords": [
"생애(정사)",
"연의",
"평가",
"가족 관계",
"미디어 믹스",
"기타"
],
"error": null,
"order": 7,
"name": "순욱"
},
{
"title": "가후",
"url": "https://namu.wiki/w/가후",
"page_title": "가후",
"keywords": [
"가후",
"개요",
"정사 삼국지",
"초기 생애",
"동탁 휘하",
"이각 휘하",
"단외 휘하",
"장수 휘하",
"완 전투",
"양 전투",
"관도대전",
"적벽대전",
"관중 평정",
"조비에게 조언",
"조비 휘하 (위나라 건국)",
"죽음",
"삼국지연의",
"평가",
"긍정적 평가",
"부정적 평가",
"기타",
"미디어 믹스",
"관련 문서",
"둘러보기"
],
"anecdote_keywords": [
"정사 삼국지",
"완 전투",
"양 전투",
"죽음",
"삼국지연의",
"평가",
"긍정적 평가",
"부정적 평가",
"기타",
"미디어 믹스"
],
"error": null,
"order": 8,
"name": "가후"
},
{
"title": "유비",
"url": "https://namu.wiki/w/유비",
"page_title": "유비",
"keywords": [
"유비",
"개요",
"이름과 칭호",
"생애",
"평가",
"vs 조조",
"혈통",
"가족 관계",
"무덤",
"기타",
"대중매체에서",
"둘러보기"
],
"anecdote_keywords": [
"이름과 칭호",
"평가",
"가족 관계",
"기타",
"대중매체에서"
],
"error": null,
"order": 9,
"name": "유비"
},
{
"title": "관우",
"url": "https://namu.wiki/w/관우",
"page_title": "관우",
"keywords": [
"관우",
"개요",
"생애(정사)",
"연의",
"역대 봉호",
"중화 왕조",
"조선",
"가족 관계",
"평가",
"시호 논란",
"악시설",
"선시설",
"신격화",
"말말말",
"대중매체에서",
"관련 문서",
"둘러보기"
],
"anecdote_keywords": [
"생애(정사)",
"연의",
"가족 관계",
"평가",
"시호 논란",
"대중매체에서"
],
"error": null,
"order": 10,
"name": "관우"
},
{
"title": "장비",
"url": "https://namu.wiki/w/장비",
"page_title": "장비",
"keywords": [
"장비",
"개요",
"생애(정사)",
"연의",
"연의의 피해자?",
"가족 관계",
"평가",
"기타",
"기타 창작물",
"관련 항목",
"둘러보기"
],
"anecdote_keywords": [
"생애(정사)",
"연의",
"연의의 피해자?",
"가족 관계",
"평가",
"기타",
"기타 창작물"
],
"error": null,
"order": 11,
"name": "장비"
},
{
"title": "제갈량",
"url": "https://namu.wiki/w/제갈량",
"page_title": "제갈량",
"keywords": [
"제갈량",
"개요",
"생애(정사)",
"연의",
"인간관계",
"업적",
"평가",
"논란",
"관련 일화들",
"사상",
"여담",
"미디어 믹스",
"관련 문서",
"둘러보기"
],
"anecdote_keywords": [
"생애(정사)",
"연의",
"업적",
"평가",
"논란",
"관련 일화들",
"여담",
"미디어 믹스"
],
"error": null,
"order": 12,
"name": "제갈량"
},
{
"title": "조운",
"url": "https://namu.wiki/w/조운",
"page_title": "조운",
"keywords": [
"조운",
"개요",
"소개",
"정사",
"평가",
"연의",
"가족 관계",
"여담",
"미디어 믹스",
"관련 문서"
],
"anecdote_keywords": [
"정사",
"평가",
"연의",
"가족 관계",
"여담",
"미디어 믹스"
],
"error": null,
"order": 13,
"name": "조운"
},
{
"title": "마초",
"url": "https://namu.wiki/w/마초",
"page_title": "마초",
"keywords": [
"마초",
"개요",
"정사",
"평가",
"연의",
"가족 관계",
"여담",
"대중매체에서",
"둘러보기"
],
"anecdote_keywords": [
"정사",
"평가",
"연의",
"가족 관계",
"여담",
"대중매체에서"
],
"error": null,
"order": 14,
"name": "마초"
},
{
"title": "황충",
"url": "https://namu.wiki/w/황충",
"page_title": "황충",
"keywords": [
"황충",
"개요",
"생애(정사)",
"연의",
"유비군 합류",
"한중 공방전",
"사망",
"평가",
"노장이었는가?",
"노장이 아니다.",
"노장이 맞다.",
"여담",
"대중매체에서",
"둘러보기"
],
"anecdote_keywords": [
"생애(정사)",
"연의",
"사망",
"평가",
"여담",
"대중매체에서"
],
"error": null,
"order": 15,
"name": "황충"
},
{
"title": "강유",
"url": "https://namu.wiki/w/강유",
"page_title": "강유",
"keywords": [
"강유",
"개요",
"생애(정사)",
"연의",
"평가",
"여담",
"대중매체에서"
],
"anecdote_keywords": [
"생애(정사)",
"연의",
"평가",
"여담",
"대중매체에서"
],
"error": null,
"order": 16,
"name": "강유"
},
{
"title": "방통",
"url": "https://namu.wiki/w/방통",
"page_title": "방통",
"keywords": [
"방통",
"개요",
"생애(정사)",
"연의",
"평가",
"여담",
"대중매체에서"
],
"anecdote_keywords": [
"생애(정사)",
"연의",
"평가",
"여담",
"대중매체에서"
],
"error": null,
"order": 17,
"name": "방통"
},
{
"title": "손권",
"url": "https://namu.wiki/w/손권",
"page_title": "손권",
"keywords": [
"손권",
"개요",
"시호",
"생애",
"평가",
"가족 관계",
"기타",
"대중매체에서",
"둘러보기"
],
"anecdote_keywords": [
"평가",
"가족 관계",
"기타",
"대중매체에서"
],
"error": null,
"order": 18,
"name": "손권"
},
{
"title": "손책",
"url": "https://namu.wiki/w/손책",
"page_title": "손책",
"keywords": [
"손책",
"개요",
"생애",
"초기 생애",
"원술 휘하에서",
"유요를 격파하다",
"왕랑을 격파하다",
"엄백호를 격파하다",
"원술로부터 독립",
"유훈을 격파하다",
"조조와의 관계",
"고대를 죽이다",
"허공을 죽이다",
"피습을 당하다",
"우길을 죽이다",
"죽음",
"사후",
"평가",
"가족 관계",
"기타",
"대중매체에서",
"둘러보기"
],
"anecdote_keywords": [
"죽음",
"평가",
"가족 관계",
"기타",
"대중매체에서"
],
"error": null,
"order": 19,
"name": "손책"
},
{
"title": "주유(삼국지)",
"url": "https://namu.wiki/w/주유(삼국지)",
"page_title": "주유(삼국지)",
"keywords": [
"주유(삼국지)",
"개요",
"정사 삼국지",
"초기 생애",
"원술 휘하에서",
"손책 휘하에서",
"손권 휘하에서",
"적벽대전",
"남군 공방전",
"죽음",
"삼국지연의",
"평가",
"가족 및 후손",
"기타",
"대중매체에서"
],
"anecdote_keywords": [
"정사 삼국지",
"죽음",
"삼국지연의",
"평가",
"가족 및 후손",
"기타",
"대중매체에서"
],
"error": null,
"order": 20,
"name": "주유"
},
{
"title": "육손",
"url": "https://namu.wiki/w/육손",
"page_title": "육손",
"keywords": [
"육손",
"개요",
"정사 삼국지",
"초기 생애",
"손가와의 화친",
"번성 공방전",
"700리 촉영을 불사르다, 이릉대전",
"외교와 내정",
"석정 전투",
"건업에서(오나라 건국)",
"4차 합비 공방전",
"형주에서 보여준 지략",
"손권과의 말년",
"이궁지쟁 사건과 최후",
"사후",
"삼국지연의",
"평가",
"기타",
"미디어 믹스",
"둘러보기"
],
"anecdote_keywords": [
"정사 삼국지",
"석정 전투",
"이궁지쟁 사건과 최후",
"삼국지연의",
"평가",
"기타",
"미디어 믹스"
],
"error": null,
"order": 21,
"name": "육손"
},
{
"title": "노숙(삼국지)",
"url": "https://namu.wiki/w/노숙(삼국지)",
"page_title": "노숙(삼국지)",
"keywords": [
"노숙(삼국지)",
"개요",
"정사 삼국지",
"초기 생애",
"손권에게 임관",
"적벽대전",
"익양대치",
"죽음",
"사후",
"삼국지연의",
"평가",
"기타",
"기타 창작물"
],
"anecdote_keywords": [
"정사 삼국지",
"죽음",
"삼국지연의",
"평가",
"기타",
"기타 창작물"
],
"error": null,
"order": 22,
"name": "노숙"
},
{
"title": "감녕",
"url": "https://namu.wiki/w/감녕",
"page_title": "감녕",
"keywords": [
"감녕",
"개요",
"정사 삼국지",
"익주의 토호",
"익주를 떠나다",
"황조의 푸대접",
"손권에게 투항",
"남군 공방전",
"환성 전투",
"익양대치",
"2차 합비 공방전",
"유수구 전투",
"그의 성격을 보여주는 일화",
"죽음",
"삼국지연의",
"나이",
"감녕전의 순서 문제",
"기타",
"미디어 믹스"
],
"anecdote_keywords": [
"정사 삼국지",
"환성 전투",
"유수구 전투",
"그의 성격을 보여주는 일화",
"죽음",
"삼국지연의",
"기타",
"미디어 믹스"
],
"error": null,
"order": 23,
"name": "감녕"
},
{
"title": "태사자",
"url": "https://namu.wiki/w/태사자",
"page_title": "태사자",
"keywords": [
"태사자",
"개요",
"생애",
"요동으로 피신하다",
"공융을 구원하다",
"손책을 따르다",
"예장을 정찰하다",
"동오의 명장",
"죽음",
"기타",
"기타 창작물"
],
"anecdote_keywords": [
"죽음",
"기타",
"기타 창작물"
],
"error": null,
"order": 24,
"name": "태사자"
},
{
"title": "여포",
"url": "https://namu.wiki/w/여포",
"page_title": "여포",
"keywords": [
"여포",
"개요",
"정사 삼국지",
"정원 휘하",
"동탁 휘하",
"왕윤과의 갈등",
"방랑",
"여포의 최후",
"삼국지연의",
"평가",
"무력",
"통솔",
"지력",
"정치",
"인격",
"배신",
"색욕",
"번외: 항우와의 비교",
"유비와의 관계",
"이민족 떡밥",
"여담",
"대중매체에서"
],
"anecdote_keywords": [
"정사 삼국지",
"여포의 최후",
"삼국지연의",
"평가",
"여담",
"대중매체에서"
],
"error": null,
"order": 25,
"name": "여포"
},
{
"title": "동탁",
"url": "https://namu.wiki/w/동탁",
"page_title": "동탁",
"keywords": [
"동탁",
"개요",
"생애",
"평가",
"재평가?",
"반론",
"가족 관계",
"기타 창작물",
"여담",
"둘러 보기"
],
"anecdote_keywords": [
"평가",
"재평가?",
"가족 관계",
"기타 창작물",
"여담"
],
"error": null,
"order": 26,
"name": "동탁"
},
{
"title": "원소(삼국지)",
"url": "https://namu.wiki/w/원소(삼국지)",
"page_title": "원소(삼국지)",
"keywords": [
"원소(삼국지)",
"개요",
"생애",
"평가",
"정치적 능력",
"행정적 능력",
"군사적 능력",
"용인술",
"도덕성",
"외모와 쇼맨십",
"여담",
"조조에게 열등감을 가졌었다?",
"후계자 선정에서 잘못을 했나?",
"우유부단했는가?",
"투구 대신에 모자와 정장 차림",
"가족 관계",
"대중매체에서",
"둘러보기"
],
"anecdote_keywords": [
"평가",
"여담",
"가족 관계",
"대중매체에서"
],
"error": null,
"order": 27,
"name": "원소"
},
{
"title": "원술",
"url": "https://namu.wiki/w/원술",
"page_title": "원술",
"keywords": [
"원술",
"개요",
"생애",
"평가",
"가족 관계",
"여담",
"대중매체에서",
"둘러보기(계보)"
],
"anecdote_keywords": [
"평가",
"가족 관계",
"여담",
"대중매체에서"
],
"error": null,
"order": 28,
"name": "원술"
}
]

View File

@ -0,0 +1,282 @@
# -*- 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

View File

@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
키워드 병맛 삼국지 마케팅 쇼츠 (완전 자동, 원커맨드)
==========================================================
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
1) 랜덤으로 삼국지 인물 '' 매칭
2) Gemini 병맛 마케팅 나레이션 생성
3) 장면 분할(스토리보드)
4) 장면별 이미지 + 음성 생성
5) Ken Burns + 자막( ) + 음성 합성 세로 mp4
6) 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)
모두 output/<작업폴더>/ 안에만 저장. 완성 중간 파일(assets) 자동 삭제.
선행: scraper.py 만든 data/samguk_keywords_*.csv 있어야 .
API : GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
python animation_samgukji\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1 12만원
python animation_samgukji\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
python animation_samgukji\\main.py https://naver.me/xxxx
옵션:
--label 왕의휴식 결과 폴더 이름에 라벨 (기본: 키워드 앞부분 자동)
--king 조조 특정 인물 고정 (기본: 랜덤)
--scenes 12 장면 =이미지 (기본 12)
--seconds 45 목표 길이 (기본 45)
--voice Puck TTS 목소리
--keep 중간 파일(assets) 삭제하지 않고 보존
"""
import argparse
import json
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
from google import genai
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 공유 모듈(naver.py, media.py)
import gen # 나레이션 + 스토리보드 생성(병합: 이전 gen_narration + gen_storyboard)
import render as MS # animation 영상합성 엔진(이전 make_short.py)
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
HERE = Path(__file__).parent
OUT_DIR = HERE.parent / "output" / "animation_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/
TEXT_MODEL = "gemini-2.5-flash"
def safe_name(s: str) -> str:
s = re.sub(r"[^\w가-힣]+", "_", s).strip("_")
return s[:30] or "short"
def main():
ap = argparse.ArgumentParser(
description="키워드 또는 네이버 링크 → 병맛 삼국지 쇼츠 (완전 자동)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
)
# 마케팅 대상: 자유 키워드(여러 단어/문장) 또는 네이버 링크(섞어 써도 됨)
ap.add_argument("keywords", nargs="*",
help="마케팅 대상 키워드/문장, 또는 네이버 링크 (--link 없이 링크만 줘도 됨)")
# 결과 폴더 라벨
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
# 생성 옵션
ap.add_argument("--king", help="특정 인물 고정 (예: 조조, 기본: 랜덤)")
ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
ap.add_argument("--keep", action="store_true", help="중간 파일(assets) 보존")
ap.add_argument("--csv", help="썰 CSV 경로 (기본: output/ 최신)")
# 실제 사진(가게/메뉴판) 섞기 + 마지막 링크 카드
ap.add_argument("--images", default="load_image",
help="실제 사진 폴더. 가게 이름만 줘도 load_image/<가게>/ 를 찾음 "
"(예: --images 연산골막국수). 비었으면 전부 AI")
ap.add_argument("--max-real", type=int, default=0,
help="실제 사진으로 채울 최대 장면 수 (기본 0=폴더 사진 전부, 장면 수 한도 내)")
ap.add_argument("--real-scenes",
help="실제 사진 깔 장면 번호 1-based, 콤마구분 (예: 5,6,7). 주면 내용매칭 대신 수동")
ap.add_argument("--no-match", action="store_true",
help="실제 사진 내용 자동매칭(Gemini 비전) 끄고 위치 기반(뒤쪽 장면)으로")
ap.add_argument("--photos", type=int, default=4,
help="네이버 사진탭에서 자동 선별할 공식사진 수 (기본 4, --link 줄 때)")
ap.add_argument("--no-photos", action="store_true",
help="네이버 사진 자동 수집 끄기 (전부 AI 그림으로)")
ap.add_argument("--link", help="맨 마지막 화면에 띄울 링크/문구 (예: naver.me/xxxx)")
ap.add_argument("--link-seconds", type=float, default=3.0, help="링크 카드 길이(초)")
ap.add_argument("--store", help="링크 카드에 함께 띄울 가게 이름 (기본: 키워드 첫 부분 자동)")
ap.add_argument("--cta", default=MS.LINK_CTA, help=f"링크 카드 안내문구 (기본: '{MS.LINK_CTA}')")
ap.add_argument("--img-model", default=MS.IMG_MODEL,
help=f"이미지 생성 모델 (기본 {MS.IMG_MODEL}). 글자 또렷하게: gemini-3.1-flash-image / gemini-3-pro-image")
ap.add_argument("--allow-text", action="store_true",
help="그림 안 글자 금지 해제 (신형 모델로 간판에 진짜 이름 넣고 싶을 때)")
# 컷편집 느낌 (말 빠르게 + 컷 사이 침묵 제거 → 분량 ↓)
ap.add_argument("--speed", type=float, default=1.3,
help="말 속도 배율 (기본 1.3=30%% 빠르게, 음정 유지). 1.0=원본")
ap.add_argument("--no-trim", action="store_true",
help="컷 앞뒤 침묵 제거 끄기 (기본은 제거해 컷을 타이트하게)")
ap.add_argument("--tail", type=float, default=0.0,
help="각 컷 말 끝난 뒤 여운(초). 기본 0=말 끝나자마자 다음 컷. 여유 주려면 0.1~0.2")
ap.add_argument("--workers", type=int, default=4,
help="이미지·음성 동시 생성 수 (기본 4). 높이면 빠르지만 API 속도제한 주의")
ap.add_argument("--bgm",
help="배경음악(병맛 BGM) 파일 또는 폴더. 폴더면 무작위 1곡. "
"기본: bgm/ 폴더에 곡이 있으면 자동으로 무작위 사용. "
"특정 곡 고정하려면 --bgm 파일명. BGM 끄려면 --bgm none")
ap.add_argument("--bgm-volume", type=float, default=0.3,
help="BGM 볼륨 0~1 (기본 0.3). 나레이션 안 묻히게 조절")
ap.add_argument("--api-key")
args = ap.parse_args()
# --link 안 써도 됨: 위치 인자에 네이버 링크가 섞여 있으면 그걸 링크로 빼낸다
# (narration/kakao 처럼 `python main.py https://naver.me/xxxx` 만으로 실행 가능)
if not args.link:
link_tok = next((k for k in args.keywords if NV.looks_like_naver_place(k)), None)
if link_tok:
args.link = link_tok
args.keywords = [k for k in args.keywords if k != link_tok]
product = " ".join(args.keywords).strip()
# 키워드를 안 줬는데 --link 가 네이버 플레이스면 → AI 브리핑을 긁어와 마케팅 키워드로 사용
brief_store = None
if not product and NV.looks_like_naver_place(args.link or ""):
print("■ 키워드 없음 → 네이버 AI 브리핑 자동 수집...")
try:
brief_store, bullets, _cat = NV.fetch_briefing(args.link) # 업종(cat)은 animation 미사용
except Exception as e:
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
if not bullets:
sys.exit(f"[!] '{brief_store or '이 가게'}'에 AI 브리핑이 없어요. "
"키워드를 직접 주거나, 브리핑 있는 가게 링크를 쓰세요.")
product = " ".join(bullets)
print(f"■ 가게: {brief_store or '?'} / 브리핑 {len(bullets)}줄 → 키워드로 사용")
for b in bullets:
print(f"{b}")
if not product:
sys.exit("[!] 마케팅 키워드가 필요해. 예: python auto_short.py 감성 펜션 ... "
"또는 --link 에 네이버 가게 링크(AI 브리핑 자동 수집).")
api_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not api_key:
sys.exit("[!] API 키 없음. setx GEMINI_API_KEY \"\" 후 새 터미널, 또는 --api-key.")
csv_path = Path(args.csv) if args.csv else gen.latest_csv()
rows = gen.load_rows(csv_path)
client = genai.Client(api_key=api_key)
OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
# 네이버 링크 주고 사진 폴더를 직접 안 줬으면 → 사진탭 공식사진을 자동 수집·선별
if (not args.no_photos and NV.looks_like_naver_place(args.link or "")
and args.images == "load_image"):
store_for_dir = (brief_store or args.store or args.label
or " ".join(args.keywords[:2]) or "naver_store")
photo_dir = HERE / "load_image" / safe_name(store_for_dir)
existing = [p for p in photo_dir.glob("*.jpg")] if photo_dir.exists() else []
if existing:
print(f"■ 사진 캐시 재사용: {photo_dir} ({len(existing)}장)")
args.images = safe_name(store_for_dir)
else:
print(f"■ 네이버 사진탭 → 공식사진 {args.photos}장 자동 수집·선별...")
try:
_, finals = NV.fetch_and_select(args.link, photo_dir,
n=args.photos, client=client)
if finals:
args.images = safe_name(store_for_dir)
print(f"■ 사진 {len(finals)}장 준비 → {photo_dir}")
else:
print("■ 공식사진을 못 받았어요 → 전부 AI 그림으로 진행")
except Exception as e:
print(f"■ 사진 수집 실패({type(e).__name__}) → 전부 AI 그림으로 진행")
# 이 작업 전용 폴더 (모든 산출물은 여기 안에만)
label = args.label or " ".join(args.keywords[:3]) or brief_store or product[:20]
stem = f"{safe_name(label)}_{ts}"
job_dir = OUT_DIR / stem
job_dir.mkdir(parents=True, exist_ok=True)
# 1) 랜덤(또는 지정) 인물 썰 매칭
seed = gen.pick_seed(rows, king=args.king)
print(f"■ 작업 폴더 : {job_dir}")
print(f"■ 마케팅 대상 : {product}")
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
# 2) 병맛 나레이션
print("[1/4] 나레이션 생성...")
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds)
(job_dir / "narration.json").write_text(
json.dumps(narr, ensure_ascii=False, indent=2), encoding="utf-8")
# 사람이 바로 읽는 나레이션 '가사' 텍스트 (제목 + 본문 + 해시태그)
lyric = (f"[제목] {narr.get('title','')}\n"
f"[소재] {seed['king']} / \"{seed['keyword']}\"\n"
f"[마케팅] {product}\n"
f"{'-'*40}\n"
f"{narr.get('full_narration','')}\n")
if narr.get("hashtags"):
lyric += f"\n{' '.join(narr['hashtags'])}\n"
(job_dir / "narration.txt").write_text(lyric, encoding="utf-8")
print(f" \"{narr['title']}\" ({len(narr['full_narration'])}자)")
# 3) 스토리보드(장면 분할)
print("[2/4] 스토리보드 분할...")
sb = gen.make_storyboard(client, TEXT_MODEL, narr["full_narration"],
product, seed["king"], scenes=args.scenes)
(job_dir / "storyboard.json").write_text(
json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" 장면 {len(sb['scenes'])}")
# 4) 이미지 + 음성 + 영상 합성 (assets·mp4 모두 job_dir 안)
# 실제 사진(가게/메뉴판)을 내용 맞는 장면에 AI 배경과 합성, 마지막에 링크 카드 추가
real_images, real_scenes = MS.resolve_real_images(args.images, args.real_scenes)
if not real_images:
print(f"■ 실제 사진 : 없음 ('{args.images}' 폴더 비었거나 없음) → 전부 AI")
# --bgm 안 줘도 bgm/ 폴더에 곡이 있으면 자동으로 무작위 1곡 사용
bgm = MS.resolve_bgm(args.bgm or "bgm")
_off = str(args.bgm or "").strip().lower() in ("none", "off", "no")
if args.bgm and not _off and not bgm:
print(f"■ BGM '{args.bgm}' 를 못 찾음 → BGM 없이 진행")
elif bgm:
print(f"■ BGM : {bgm.name} (bgm/ 무작위)")
print("[3/4] 이미지·음성 생성 + [4/4] 영상 합성...")
store = args.store or brief_store or product.split(",")[0].strip() or None
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
real_images=real_images, real_scenes=real_scenes,
max_real=args.max_real, match_content=not args.no_match,
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
img_model=args.img_model, allow_text=args.allow_text,
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
shutil.rmtree(job_dir / "assets", ignore_errors=True)
for junk in ("narration.json", "storyboard.json"):
(job_dir / junk).unlink(missing_ok=True)
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
print(f"\n✅ 완성: {out_mp4}")
print(f" {seed['king']} 썰 · {product}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,874 @@
# -*- coding: utf-8 -*-
"""
스토리보드 완성 쇼츠 영상(mp4) 조립 [A방식: 정지 일러스트 + 모션]
=====================================================================
gen_storyboard.py 만든 스토리보드를 받아 장면별로:
1) Gemini 이미지로 일러스트 생성 (캐릭터 일관성 = 1 컷을 레퍼런스로 물림)
2) Gemini TTS 장면 나레이션 음성 생성 (장면 길이 = 음성 길이)
3) ffmpeg Ken Burns(zoompan ) + 자막(PNG 오버레이) + 음성 합성 세로 1080x1920 mp4
(Intel QSV 하드웨어 인코딩 자동 사용, 없으면 libx264 ultrafast)
이미지/음성은 output/ 장면별로 캐싱한다. 재실행 이미 있으면 재사용(재과금 X).
다시 그리고 싶으면 --force.
설치: pip install google-genai pillow imageio-ffmpeg (moviepy 불필요 ffmpeg 직접 렌더)
실행:
python make_short.py # 최신 *_storyboard.json
python make_short.py --storyboard output/xxx_storyboard.json --voice Puck
python make_short.py --force # 이미지/음성 새로 생성
"""
import json
import re
import shutil
import subprocess
import sys
import time
import wave
from pathlib import Path
from google.genai import types
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import media as MU
HERE = Path(__file__).parent
SHARED = HERE.parent # generator/ (fonts·bgm 공용 자산)
OUT_DIR = HERE.parent / "output" / "animation_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/
W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 글자 크기(고정 — 장면마다 안 변함)
BRAND_TITLE = "썰록" # 상단 밴드 제목
IMG_MODEL = "gemini-2.5-flash-image"
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
TTS_VOICE = "Puck"
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
"능청스럽게 약간 오버해서 읽어줘:")
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'(fonts/ 에 동봉), 없으면 맑은고딕 볼드
FONT = str(SHARED / "fonts" / "Jua-Regular.ttf")
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
TITLE_FONT = str(SHARED / "fonts" / "SongMyung-Regular.ttf")
TITLE_SIZE = 58
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
# → 모든 이미지 생성에서 '글자 자체를 그리지 말라'고 강하게 지시. (가게 이름은 자막/카드로 표기)
NO_TEXT = (" CRITICAL: Render NO text of any kind in the image — no letters, words, numbers, "
"Korean hangul, logos, or signage text. Keep every sign, banner, board and menu "
"completely BLANK with no writing on them.")
# ---------------------------------------------------------------- 데이터
IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
def resolve_real_images(images_dir, real_scenes_arg):
"""--images 값 → 정렬된 실제 사진 경로 리스트, --real-scenes → 정수 리스트.
images_dir 해석 순서 (가게마다 load_image/<가게>/ 나눠 담는 지원):
1) 절대경로면 그대로
2) 상대경로/이름이면 HERE/<> (: load_image/연산골막국수)
3) 그래도 없으면 HERE/load_image/<> (: --images 연산골막국수)
가장 먼저 '이미지가 들어있는' 폴더를 채택. 없으면 리스트.
"""
real_images, used = [], None
if images_dir:
d = Path(images_dir)
candidates = [d] if d.is_absolute() else [HERE / d, HERE / "load_image" / d]
for c in candidates:
if c.exists() and c.is_dir():
imgs = sorted(p for p in c.iterdir() if p.suffix.lower() in IMG_EXTS)
if imgs:
real_images, used = imgs, c
break
if used:
print(f"■ 실제 사진 폴더: {used} ({len(real_images)}장)")
real_scenes = None
if real_scenes_arg:
real_scenes = [int(x) for x in re.split(r"[,\s]+", str(real_scenes_arg).strip()) if x]
return real_images, real_scenes
# 실제 사진 카드를 합성하는 장면에서, AI 배경이 카드 자리를 비워두도록 주는 포즈 힌트
PRESENT_POSES = [
"Place the cat king in the UPPER part of the frame, smiling and pointing down toward the "
"lower-center, leaving the lower-center area visually simple (a photo will be placed there).",
"Put the cat king on the LEFT side presenting with one paw to the right-center, keeping the "
"right-center area simple and uncluttered (a photo will be placed there).",
"Show the cat king at the BOTTOM looking up with sparkling eyes toward the upper-center, "
"keeping the upper-center area simple (a photo will be placed there).",
]
# PRESENT_POSES 순서에 맞춰 실제 사진 카드를 놓을 (가로,세로) 중심 비율
PRESENT_SPOTS = [(0.50, 0.62), (0.62, 0.50), (0.50, 0.40)]
# 고양이 왕의 '눈' 그림체를 귀엽게 유지하되, 눈을 '떴을 때'의 모양만 고정한다.
# (모든 장면을 동그란 뜬눈으로 강제하면 감김/실눈/윙크 같은 표정 다양성이 사라짐)
EYE_STYLE = (
"When the cat's eyes are open, they are the same cute friendly eyes: large round eyes "
"with big round solid black pupils and a small soft white highlight, gentle innocent "
"look. Keep this cute rounded eye style; never sharp, angry, fierce, glaring or "
"realistic predatory eyes."
)
# 눈을 감거나(감김/실눈/윙크/졸림/웃음·울음으로 눈이 변하는) 장면용: 표정은 장면대로 두되 화풍만 유지.
EYE_FREE = (
"In this scene the cat's eye EXPRESSION follows the moment — the eyes may be gently "
"closed, squinting, blinking, winking, teary or sleepy half-closed as appropriate. "
"Keep the same cute, soft, rounded drawing style (never sharp, angry or realistic), "
"but do NOT force wide-open round eyes here."
)
# image_prompt(영어)에 아래 표현이 있으면 '눈 표정 장면'으로 보고 뜬눈 고정/레퍼런스를 푼다.
_EYE_EXPR_KW = (
"closed eye", "eyes closed", "close its eye", "close his eye", "shut eye",
"shut its eye", "squint", "narrowed eye", "wink", "half-closed", "half closed",
"sleepy", "sleeping", "asleep", "dozing", "doze", "napping", "yawn", "drowsy",
"crying", "sob", "weeping", "tears", "teary", "laughing", "giggl", "chuckl",
"wince", "grimac", "blink",
)
def scene_needs_eye_expression(sc) -> bool:
"""장면이 눈 감김/실눈/윙크 등 '뜬눈 아닌' 표정을 필요로 하면 True."""
text = ((sc.get("image_prompt") or "") + " " + (sc.get("caption") or "")).lower()
return any(k in text for k in _EYE_EXPR_KW)
# 캐릭터 공식 레퍼런스: 이 폴더에 이미지를 넣어두면(예: king_ref.png),
# 모든 영상의 모든 컷이 그 고양이 왕의 얼굴/눈/복장/화풍을 그대로 따라간다.
CHAR_REF_DIR = HERE / "character"
def load_char_ref():
"""Shorts/character/ 안의 첫 이미지 바이트 반환(없으면 None). 모든 영상의 캐릭터 앵커로 쓰인다."""
if not CHAR_REF_DIR.is_dir():
return None
for p in sorted(CHAR_REF_DIR.iterdir()):
if p.suffix.lower() in IMG_EXTS:
return p.read_bytes()
return None
# ---------------------------------------------------------------- 이미지
def gen_image(client, prompt, ref_bytes=None, model=None, no_text=True,
ref_mode="full") -> bytes:
"""Gemini 이미지 생성. ref_bytes 주면 캐릭터 일관성 위해 레퍼런스로 사용.
model: 사용할 이미지 모델 (없으면 IMG_MODEL). 신형일수록 글자 렌더링 우수.
no_text: True '그림에 글자 금지' 지시를 붙인다(글자 깨짐 방지).
ref_mode: 'full' = 레퍼런스의 캐릭터/복장/화풍을 통째로 유지.
'eyes' = 레퍼런스에선 '눈/표정만' 복제하고 복장·장면은 프롬프트대로.
"""
parts = []
if ref_bytes:
parts.append(types.Part.from_bytes(data=ref_bytes, mime_type="image/png"))
if ref_mode == "eyes":
prompt = ("Use the reference image ONLY to copy the cat character's EYES and "
"facial expression exactly: the same large round eyes with big round "
"solid black pupils and a small soft white highlight, gentle innocent "
"look. Do NOT copy the outfit, hat, robe color, pose, framing or "
"background from the reference — those follow the scene description. "
"New scene: " + prompt)
else:
prompt = ("Keep the EXACT same character design, outfit and art style as the "
"reference image. New scene: " + prompt)
parts.append(types.Part.from_text(text=prompt + (NO_TEXT if no_text else "")))
resp = client.models.generate_content(
model=model or IMG_MODEL,
contents=parts,
config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
)
for p in resp.candidates[0].content.parts:
if getattr(p, "inline_data", None):
return p.inline_data.data
raise RuntimeError("이미지 파트 없음")
def composite_card(base_path, photo_path, out_path,
rel_w=0.60, center=(0.5, 0.60), angle=4.0, border=22):
"""AI 배경(base) 위에 '실제 사진'을 작은 폴라로이드 카드로 얹어 합성 저장.
사진은 원본 그대로(왜곡 X) 축소만 하고, 테두리+그림자+살짝 기울기로 자연스럽게 올린다.
rel_w: 카드 사진 가로 = 화면폭 * rel_w. center: 카드 중심 (가로,세로) 비율. angle: 기울기().
"""
from PIL import Image, ImageFilter
base = Image.open(base_path).convert("RGBA")
photo = Image.open(photo_path).convert("RGB")
cw = int(W * rel_w)
ph = int(photo.height * (cw / photo.width))
max_h = int(H * 0.40) # 너무 길면 높이 기준으로 제한
if ph > max_h:
ph, cw = max_h, int(photo.width * (max_h / photo.height))
photo = photo.resize((cw, ph), Image.LANCZOS)
# 폴라로이드 흰 카드 (아래쪽 여백 좀 더)
card = Image.new("RGBA", (cw + 2 * border, ph + 2 * border + int(border * 1.4)),
(255, 255, 255, 255))
card.paste(photo, (border, border))
card = card.rotate(angle, expand=True, resample=Image.BICUBIC)
cx, cy = int(W * center[0]), int(H * center[1])
x, y = cx - card.width // 2, cy - card.height // 2
# 그림자
alpha = card.split()[3]
shadow = Image.new("RGBA", card.size, (0, 0, 0, 0))
shadow.paste(Image.new("RGBA", card.size, (0, 0, 0, 150)), (0, 0), alpha)
shadow = shadow.filter(ImageFilter.GaussianBlur(18))
base.alpha_composite(shadow, (x + 10, y + 16))
base.alpha_composite(card, (x, y))
base.convert("RGB").save(out_path)
def cover_crop(src: Path, dst: Path):
"""이미지를 1080x1920 꽉 차게 cover-crop 해서 저장."""
im = Image.open(src).convert("RGB")
scale = max(W / im.width, H / im.height)
nw, nh = int(im.width * scale + 0.5), int(im.height * scale + 0.5)
im = im.resize((nw, nh), Image.LANCZOS)
left, top = (nw - W) // 2, (nh - H) // 2
im.crop((left, top, left + W, top + H)).save(dst)
# ---------------------------------------------------------------- 음성
def _extract_audio(resp):
"""TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호)."""
cands = getattr(resp, "candidates", None) or []
for c in cands:
content = getattr(c, "content", None)
parts = getattr(content, "parts", None) or [] if content else []
for p in parts:
inline = getattr(p, "inline_data", None)
if inline and inline.data:
m = re.search(r"rate=(\d+)", inline.mime_type or "")
return inline.data, (int(m.group(1)) if m else 24000)
return None
def gen_tts(client, text, voice, retries=4) -> bytes:
"""Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다."""
last = ""
for attempt in range(1, retries + 1):
try:
resp = client.models.generate_content(
model=TTS_MODEL,
contents=f"{TTS_STYLE}\n\n{text}",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
)
),
),
)
got = _extract_audio(resp)
if got:
return got
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
fr = ""
try:
fr = str(getattr(resp.candidates[0], "finish_reason", "") or "")
except Exception:
fr = "no-candidates"
last = f"빈 응답(finish_reason={fr or '?'})"
except Exception as e:
last = f"{type(e).__name__}: {e}"
if attempt < retries:
wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프
print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
end="", flush=True)
time.sleep(wait)
raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}")
def pcm_to_wav(pcm, path, rate):
with wave.open(str(path), "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
w.writeframes(pcm)
def wav_seconds(path: Path) -> float:
with wave.open(str(path), "rb") as w:
return w.getnframes() / w.getframerate()
def _atempo_chain(speed: float) -> list:
"""atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝."""
chain, s = [], speed
while s > 2.0:
chain.append("atempo=2.0"); s /= 2.0
while s < 0.5:
chain.append("atempo=0.5"); s *= 2.0
chain.append(f"atempo={s:.4f}")
return chain
def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True,
threshold="-38dB", keep=0.06):
"""컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기.
- trim_silence: / 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지).
silenceremove 앞을 자르고, areverse 뒤집어 다시 (=원래 ) 자른 복원.
keep: 잘라낸 끝에 남길 여유(). 0 이면 너무 칼같이 잘려 어색할 있어 약간 남긴다.
- speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다.
가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백.
"""
af = []
if trim_silence:
one = (f"silenceremove=start_periods=1:start_silence={keep}:"
f"start_threshold={threshold}:detection=peak")
af += [one, "areverse", one, "areverse"]
if speed and abs(speed - 1.0) > 1e-3:
af += _atempo_chain(speed)
if not af:
shutil.copyfile(src_wav, dst_wav); return
try:
_run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af),
"-ar", "24000", "-ac", "1", str(dst_wav)])
if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백
raise RuntimeError("가공 후 음성이 너무 짧음")
except (RuntimeError, OSError):
shutil.copyfile(src_wav, dst_wav)
# ---------------------------------------------------------------- 조립 (ffmpeg 직접 렌더)
FPS = 30
def _ffmpeg() -> str:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def _run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode != 0:
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
return r
def pick_encoder(exe) -> list:
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
test = subprocess.run(
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
"-c:v", "h264_qsv", "-f", "null", "-"],
capture_output=True, text=True, errors="replace")
if test.returncode == 0:
print(" 인코더: h264_qsv (Intel 하드웨어)")
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
print(" 인코더: libx264 (ultrafast)")
return ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"]
def _wrap_lines(draw, text, font, max_w):
"""글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈."""
lines, cur = [], ""
for ch in text:
if ch == "\n":
lines.append(cur); cur = ""; continue
if draw.textlength(cur + ch, font=font) <= max_w:
cur += ch
else:
lines.append(cur); cur = ch
if cur:
lines.append(cur)
return lines or [""]
def _blossom(d, cx, cy, r, color):
"""간단한 5장 꽃잎 벚꽃 장식(제목 양옆 무늬)."""
import math
pr = r * 0.62 # 꽃잎 반지름
for k in range(5):
a = math.radians(-90 + k * 72)
px, py = cx + r * 0.55 * math.cos(a), cy + r * 0.55 * math.sin(a)
d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color)
d.ellipse([cx - r * 0.32, cy - r * 0.32, cx + r * 0.32, cy + r * 0.32],
fill=(255, 238, 175, 255)) # 꽃심
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목 + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
밴드 아래는 투명 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
from PIL import ImageDraw, ImageFont
band_h = int(H * BAND_RATIO)
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
# ── 제목: 조선 느낌 폰트(TITLE_FONT) + 양옆 벚꽃 + 아래 구분선(끝까지·불투명) ──
title_y = int(H * 0.012)
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
cx = W / 2
tw = d.textlength(title, font=tf)
d.text((cx, title_y), title, font=tf, fill=(50, 50, 50, 255), anchor="ma")
by = title_y + TITLE_SIZE * 0.55 # 꽃 세로 중심 ≒ 글자 중앙
gap = 44
rose = (228, 150, 168, 255)
_blossom(d, cx - tw / 2 - gap, by, 17, rose)
_blossom(d, cx + tw / 2 + gap, by, 17, rose)
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
# 나레이션: 글자 크기는 항상 고정(NARR_SIZE), 길면 줄바꿈만. 세로 가운데 정렬.
top = rule_y + 22
avail = band_h - top - 24
f = ImageFont.truetype(font_path, NARR_SIZE)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(NARR_SIZE * 1.4)
y = top + max(0, (avail - len(lines) * lh) // 2)
for ln in lines:
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
y += lh
img.save(out_path)
LINK_CTA = "링크는 고정댓글 확인" # ▼ 등 특수문자는 자막 폰트(주아체)에 없어 □로 깨짐 → 한글만
def render_link_card_png(base_img_path, link, out_path, font_path, store=None, cta=None):
"""배경 사진을 어둡게 깔고 가게 이름 + 안내문구 + 링크를 큼직하게 그린 1080x1920 PNG."""
from PIL import ImageDraw, ImageFont
base = Image.open(base_img_path).convert("RGB")
base = Image.blend(base, Image.new("RGB", base.size, (0, 0, 0)), 0.55)
d = ImageDraw.Draw(base)
def centered(text, y, size, fill, fit_one_line=False):
if fit_one_line: # 한 줄에 들어갈 때까지 폰트 축소
while size > 40 and d.textlength(text, font=ImageFont.truetype(font_path, size)) > W - 140:
size -= 4
f = ImageFont.truetype(font_path, size)
for ln in _wrap_lines(d, text, f, W - 140):
w = d.textlength(ln, font=f)
d.text(((W - w) / 2, y), ln, font=f, fill=fill,
stroke_width=5, stroke_fill="black")
y += int(size * 1.25)
if store:
centered(store, int(H * 0.33), 88, "white", fit_one_line=True)
centered(cta or LINK_CTA, int(H * 0.43), 58, "#FFFFFF", fit_one_line=True)
centered(link, int(H * 0.51), 72, "#FFE94A", fit_one_line=True)
base.save(out_path)
# 모든 세그먼트를 같은 규격으로 만들어 concat copy 가 되게 한다.
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
# ---------------------------------------------------------------- BGM
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")
def resolve_bgm(bgm_arg):
"""--bgm 값 → 실제 음악 파일 경로(없으면 None).
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 지원):
1) 파일이면 그대로
2) 폴더면 음악 무작위 1 (병맛 BGM 여러 넣어두고 매번 다르게)
3) 이름만 주면 HERE/<> HERE/bgm/<> 공용 generator/<> generator/bgm/<> 탐색
"""
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
return None
import random
p = Path(bgm_arg)
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p, SHARED / p, SHARED / "bgm" / p]
for c in cands:
if c.is_file():
return c
if c.is_dir():
files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS]
if files:
return random.choice(files)
return None
def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0):
"""완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장.
- BGM 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림.
- 나레이션[0:a] + BGM[1:a]*volume amix(duration=first) 섞어 영상 길이에 맞춘다.
- 영상은 재인코딩하지 않는다(-c:v copy) 빠름. 시작에 짧은 페이드인.
"""
fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];"
f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]")
_run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path),
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
"-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart",
str(out_path)])
# Shared low-level media utilities. Keep local render.py for per-feature
# layout, prompts, local fonts, BGM folder resolution, and produce().
_ffmpeg = MU._ffmpeg
_run = MU._run
pick_encoder = MU.pick_encoder
pcm_to_wav = MU.pcm_to_wav
wav_seconds = MU.wav_seconds
process_audio = MU.process_audio
_wrap_lines = MU._wrap_lines
mix_bgm = MU.mix_bgm
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
bgm=None, bgm_volume=0.3):
"""장면별 ffmpeg 세그먼트(Ken Burns 줌 + 자막 오버레이 + 음성) → concat 으로 최종 mp4.
speed/trim_silence: 컷편집 느낌 음성을 빠르게 + 앞뒤 침묵 제거( 컷이 타이트).
tail: 음성 끝에 붙일 정적(). 0 이면 끝나자마자 바로 다음 .
bgm: 배경음악 파일 경로. 주면 concat BGM 작은 볼륨으로 깔아 믹스.
bgm_volume: BGM 볼륨(0~1). 기본 0.12 = 나레이션 묻히게 12%.
"""
exe = _ffmpeg()
enc = pick_encoder(exe)
font = FONT if Path(FONT).exists() else FONT_FALLBACK
out_path = Path(out_path)
tmp = out_path.parent / "_render"
tmp.mkdir(exist_ok=True)
segs = []
total = 0.0
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
proc_ap = tmp / f"aud{idx:02d}.wav"
process_audio(exe, ap, proc_ap, speed=speed, trim_silence=trim_silence)
speech = wav_seconds(proc_ap)
dur = speech + max(0.0, tail) # 말 끝나고 약간의 여운만
frames = max(1, round(dur * FPS))
sub_png = tmp / f"sub{idx:02d}.png"
# 화면 자막 = 그 장면에서 '말하는 문장'(나레이션) 그대로 → 무음 시청자도 내용 전달
render_band_png(sc.get("narration") or sc.get("caption", ""), sub_png, font)
seg = tmp / f"seg{idx:02d}.mp4"
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
band_h = int(H * BAND_RATIO)
img_h = H - band_h
bw, bh = int(W * 1.2), int(img_h * 1.2) # 줌 여유용 1.2x
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase,"
f"crop={bw}:{bh},"
f"zoompan=z='min(zoom+0.0007,1.12)':d={frames}:"
f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{img_h}:fps={FPS}[img];"
f"[bg][img]overlay=0:{band_h}[base];"
f"[base][1:v]overlay=0:0[v]")
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
_run([exe, "-y", "-i", str(ip), "-i", str(sub_png), "-i", str(proc_ap),
"-filter_complex", fc, "-map", "[v]", "-map", "2:a",
"-af", "apad", "-t", f"{dur:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
"-pix_fmt", "yuv420p", str(seg)])
segs.append(seg)
total += dur
print(f"[{idx:2d}] 세그먼트 (말 {speech:.1f}초 → 컷 {dur:.1f}초)")
print(f" ▶ 본편 합계 {total:.1f}" + (f" + 링크 {link_seconds:.1f}" if link else ""))
# 링크 카드 (무음)
if link and img_paths:
card_png = tmp / "linkcard.png"
render_link_card_png(img_paths[-1], link, card_png, font, store=store, cta=cta)
seg = tmp / "seg_link.mp4"
_run([exe, "-y", "-loop", "1", "-i", str(card_png),
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
"-t", f"{link_seconds:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
"-pix_fmt", "yuv420p", str(seg)])
segs.append(seg)
print(f"[링크] 카드 세그먼트 ({link_seconds:.1f}초)")
# concat (재인코딩 없이 복사 → 즉시). 실패하면 재인코딩으로 폴백.
# BGM 이 있으면 일단 임시 본편으로 이어붙이고, 그 위에 BGM 을 믹스해 최종본을 만든다.
listf = tmp / "concat.txt"
# concat 목록은 절대경로로 (ffmpeg 가 목록파일 위치 기준으로 상대경로를 또 해석하는 문제 방지)
listf.write_text("".join(f"file '{s.resolve().as_posix()}'\n" for s in segs), encoding="utf-8")
concat_out = (tmp / "_body.mp4") if bgm else out_path
try:
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
"-c", "copy", "-movflags", "+faststart", str(concat_out)])
except RuntimeError:
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
*enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", "-movflags", "+faststart",
str(concat_out)])
if bgm:
mix_bgm(exe, concat_out, bgm, out_path, volume=bgm_volume)
print(f" ♪ BGM 믹스: {Path(bgm).name} (볼륨 {bgm_volume})")
shutil.rmtree(tmp, ignore_errors=True)
def _mime_of(path) -> str:
return "image/jpeg" if Path(path).suffix.lower() in (".jpg", ".jpeg") else "image/png"
def analyze_photo(client, photo_path) -> str:
"""실제 사진 1장을 Gemini 비전으로 보고 짧은 한국어 라벨 반환 (예: '수육 한 접시')."""
data = Path(photo_path).read_bytes()
resp = client.models.generate_content(
model=VISION_MODEL,
contents=[
types.Part.from_bytes(data=data, mime_type=_mime_of(photo_path)),
types.Part.from_text(text=(
"이 사진의 핵심 대상을 한국어 명사구 12자 이내로만 답해. "
"음식이면 메뉴명(예: '수육', '메밀국수'), 가게면 '가게 외관'/'매장 내부', "
"메뉴판이면 '메뉴판'. 설명·문장 금지, 라벨만.")),
],
)
return (resp.text or "").strip().splitlines()[0][:20] if resp.text else ""
_MATCH_SCHEMA = {
"type": "object",
"properties": {
"assignments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"photo_index": {"type": "integer"},
"scene_number": {"type": "integer"},
},
"required": ["photo_index", "scene_number"],
},
}
},
"required": ["assignments"],
}
def match_real_scenes(client, scenes, real_images, max_real=3) -> dict:
"""각 실제 사진을 비전 분석 → 내용이 가장 맞는 장면에 배치한 {장면번호: 사진경로} 반환.
실패하면 위치 기반(plan_real_scenes)으로 폴백한다.
"""
real_images = list(real_images or [])
if not real_images:
return {}
# 1) 사진별 내용 라벨
labels = []
for p in real_images:
try:
lab = analyze_photo(client, p)
except Exception:
lab = ""
labels.append(lab)
print(f" · {Path(p).name}'{lab or '?'}'")
# 2) 라벨 ↔ 장면 매칭 (구조화 출력 1회)
scene_lines = "\n".join(
f"{i+1}. [{s.get('caption','')}] {s.get('narration','')}"
for i, s in enumerate(scenes))
photo_lines = "\n".join(f"{i}. {labels[i] or '내용불명'}" for i in range(len(real_images)))
prompt = (
"아래는 영상의 '장면 목록'과, 영상에 넣을 '실제 사진'들의 내용 라벨이다.\n"
"각 사진을 그 내용이 가장 잘 어울리는 장면에 배치하라.\n"
"[규칙]\n"
"- 한 장면엔 사진 하나, 한 사진은 한 장면에만.\n"
"- 그 음식/메뉴/가게를 직접 언급·소개하는 장면에 우선 배치.\n"
"- 비슷하면 마케팅/소개가 나오는 뒤쪽 장면을 선호.\n"
f"- 최대 {max_real}장까지만 배치(나머지는 제외).\n\n"
f"[장면 목록]\n{scene_lines}\n\n[사진 라벨]\n{photo_lines}\n")
try:
resp = client.models.generate_content(
model=VISION_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
temperature=0.2,
response_mime_type="application/json",
response_schema=_MATCH_SCHEMA,
),
)
assigns = json.loads(resp.text).get("assignments", [])
except Exception as e:
print(f" (매칭 실패 → 위치 기반으로 폴백: {type(e).__name__})")
return plan_real_scenes(scenes, real_images, None, max_real)
real_map, used_photo = {}, set()
for a in assigns:
pi, sn = a.get("photo_index"), a.get("scene_number")
if pi is None or sn is None:
continue
if 0 <= pi < len(real_images) and 1 <= sn <= len(scenes) \
and pi not in used_photo and sn not in real_map:
real_map[sn] = Path(real_images[pi])
used_photo.add(pi)
if len(real_map) >= max_real:
break
return real_map or plan_real_scenes(scenes, real_images, None, max_real)
def plan_real_scenes(scenes, real_images, real_scenes=None, max_real=3) -> dict:
"""어느 장면 번호(1-based)에 어떤 실제 사진을 깔지 매핑 dict 로 반환.
- real_scenes 주어지면 장면 번호들에 사진을 순서대로 매핑.
- 없으면 '뒤쪽(마케팅 전환부) 장면들' 사진 개수만큼(최대 max_real) 자동 배치.
"""
real_images = list(real_images or [])
real_map = {}
if not real_images:
return real_map
if real_scenes:
for no, img in zip(real_scenes, real_images):
if 1 <= no <= len(scenes):
real_map[no] = Path(img)
else:
n = min(len(real_images), max_real, len(scenes))
start = len(scenes) - n + 1 # 뒤에서 n개
for k in range(n):
real_map[start + k] = Path(real_images[k])
return real_map
def produce(client, sb, stem, workdir=None, voice=TTS_VOICE, force=False,
real_images=None, real_scenes=None, max_real=3, match_content=True,
link=None, link_seconds=3.0, store=None, cta=None,
img_model=None, allow_text=False,
speed=1.0, trim_silence=True, tail=0.15, workers=4,
bgm=None, bgm_volume=0.3) -> Path:
"""스토리보드 dict → 장면별 이미지/음성 생성 후 mp4 합성. 완성 경로 반환.
workdir: 작업의 결과/중간파일을 담을 폴더. 이미지·음성은 workdir/assets/ ,
완성 mp4 workdir/{stem}.mp4 저장한다. (기본: output/{stem})
real_images: 실제 사진(가게/메뉴판 ) 경로 리스트. 뒤쪽 장면에 깔아 AI 컷과 섞는다.
real_scenes: 실제 사진을 장면 번호(1-based) 리스트. 없으면 뒤쪽 자동.
max_real: 자동 배치 실제 사진으로 채울 최대 장면 .
link: 주면 마지막에 링크 카드 1장을 붙인다.
"""
scenes = sb["scenes"]
style = sb.get("style_guide", "")
char = sb.get("character_sheet", "")
workdir = Path(workdir) if workdir else (OUT_DIR / stem)
workdir.mkdir(parents=True, exist_ok=True)
assets = workdir / "assets"
assets.mkdir(exist_ok=True)
# 실제 사진 → 장면 배치 결정
# · real_scenes 수동지정이 최우선
# · 아니면 내용 매칭(비전). 단, 새로 생성할 장면이 있을 때만(전부 캐시면 비전 호출 낭비라 생략)
real_images = list(real_images or [])
# max_real 이 None/0/음수면 '폴더 사진 전부 사용'(장면 수 한도 내)
eff_max = max_real if (max_real and max_real > 0) else len(real_images)
need_gen = force or any(not (assets / f"scene{i:02d}.png").exists()
for i in range(1, len(scenes) + 1))
if not real_images:
real_map = {}
elif real_scenes:
real_map = plan_real_scenes(scenes, real_images, real_scenes, eff_max)
elif match_content and client is not None and need_gen:
print(f"■ 실제 사진 내용 분석(Gemini 비전) → 장면 매칭 (최대 {eff_max}장)")
real_map = match_real_scenes(client, scenes, real_images, eff_max)
else:
real_map = plan_real_scenes(scenes, real_images, None, eff_max)
real_order = {no: k for k, no in enumerate(sorted(real_map))} # 포즈/위치 번갈아 쓰기용
if real_map:
picks = ", ".join(f"{no}{real_map[no].name}" for no in sorted(real_map))
print(f"■ 실제 사진 합성 장면: {picks}")
from concurrent.futures import ThreadPoolExecutor, as_completed
img_paths = [assets / f"scene{i:02d}.png" for i in range(1, len(scenes) + 1)]
audio_paths = [assets / f"scene{i:02d}.wav" for i in range(1, len(scenes) + 1)]
def make_image(i, ref, ref_mode="full"):
"""장면 i 이미지 1장 생성(필요시 실사진 합성). ref=캐릭터 앵커 바이트."""
sc = scenes[i - 1]
raw_png = assets / f"scene{i:02d}_raw.png"
img_png = assets / f"scene{i:02d}.png"
# 눈 표정 장면(감김/실눈/윙크/졸림/웃음·울음 등)은 뜬눈 고정을 풀고 표정대로 그린다.
# · '눈만 고정(eyes)' 레퍼런스도 이런 장면에선 빼야 눈이 안 떠짐.
free_eyes = scene_needs_eye_expression(sc)
eye = EYE_FREE if free_eyes else EYE_STYLE
use_ref = None if (free_eyes and ref_mode == "eyes") else ref
if i in real_map:
k = real_order[i]
pose = PRESENT_POSES[k % len(PRESENT_POSES)]
spot = PRESENT_SPOTS[k % len(PRESENT_SPOTS)]
prompt = (f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. {pose} "
f"Vertical 9:16 composition.")
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
no_text=not allow_text, ref_mode=ref_mode)
raw_png.write_bytes(data)
cover_crop(raw_png, img_png) # 1) AI 배경
composite_card(img_png, real_map[i], img_png, center=spot) # 2) 실사진 카드
else:
prompt = f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. Vertical 9:16 composition."
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
no_text=not allow_text, ref_mode=ref_mode)
raw_png.write_bytes(data)
cover_crop(raw_png, img_png)
# --- 이미지: 캐릭터 앵커(1컷) 먼저, 나머지는 병렬 ---
# 앵커 의존성: 모든 컷이 1번 컷(캐릭터)을 ref 로 참조해야 일관성이 유지된다.
# → 앵커 1장만 순차로 만든 뒤, 나머지는 동시에 생성(서로 독립).
need_img = [i for i in range(1, len(scenes) + 1)
if force or not img_paths[i - 1].exists()]
cached_img = [i for i in range(1, len(scenes) + 1) if i not in need_img]
for i in cached_img:
print(f"[{i:2d}] 이미지 캐시 사용")
# 공식 캐릭터 레퍼런스가 있으면 '눈만 고정' 모드: 모든 컷이 레퍼런스에서 눈/표정만 복제.
# 갑옷·한푸 등 복장은 복제하지 않고 인물별 character_sheet/장면 프롬프트대로 그려진다.
char_ref = load_char_ref()
if char_ref is not None:
print("■ 캐릭터 레퍼런스(눈만 고정) 사용: character/ "
"→ 눈/표정은 통일, 복장은 인물별로 다르게")
ref_bytes, ref_mode, anchor = char_ref, "eyes", None
else:
ref_mode = "full"
ref_bytes = None
for i in range(1, len(scenes) + 1): # 캐시된 raw 가 있으면 앵커로 재사용
raw = assets / f"scene{i:02d}_raw.png"
if raw.exists() and not force:
ref_bytes = raw.read_bytes(); break
anchor = None
if ref_bytes is None:
anchor = next((i for i in need_img if i not in real_map), None)
if anchor is not None:
print(f"[{anchor:2d}] 캐릭터 앵커 이미지 생성...", flush=True)
make_image(anchor, None)
ref_bytes = (assets / f"scene{anchor:02d}_raw.png").read_bytes()
rest = [i for i in need_img if i != anchor]
if rest:
print(f"■ 이미지 {len(rest)}장 병렬 생성 (워커 {workers})...", flush=True)
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(make_image, i, ref_bytes, ref_mode): i for i in rest}
for fut in as_completed(futs):
i = futs[fut]; fut.result()
tag = f"실사합성({real_map[i].name})" if i in real_map else "이미지"
print(f"[{i:2d}] {tag} done", flush=True)
# --- 음성: 전부 독립 → 병렬 ---
def make_tts(i):
text = (scenes[i - 1].get("narration") or "").strip()
if not text:
# 빈 나레이션 방어: TTS 가 빈 텍스트에 빈 응답을 반복하다 죽으므로 0.3초 무음으로 대체.
# (정상 경로에선 gen_storyboard 가 빈 장면을 미리 거르지만, 만약을 대비한 이중 안전장치)
pcm_to_wav(b"\x00\x00" * int(24000 * 0.3), audio_paths[i - 1], 24000)
return
pcm, rate = gen_tts(client, text, voice)
pcm_to_wav(pcm, audio_paths[i - 1], rate)
need_wav = [i for i in range(1, len(scenes) + 1)
if force or not audio_paths[i - 1].exists()]
for i in (i for i in range(1, len(scenes) + 1) if i not in need_wav):
print(f" [{i:2d}] 음성 캐시 사용 ({wav_seconds(audio_paths[i - 1]):.1f}초)")
if need_wav:
print(f"■ 음성 {len(need_wav)}개 병렬 생성 (워커 {workers})...", flush=True)
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(make_tts, i): i for i in need_wav}
for fut in as_completed(futs):
i = futs[fut]; fut.result()
print(f" [{i:2d}] 음성 {wav_seconds(audio_paths[i - 1]):.1f}", flush=True)
out = workdir / f"{stem}.mp4"
print(f"\n영상 합성 → {out.name}")
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
bgm=bgm, bgm_volume=bgm_volume)
print(f"완성: {out}")
return out

View File

@ -0,0 +1,288 @@
# -*- coding: utf-8 -*-
"""
나무위키 삼국지 인물 '썰 키워드' 스크래퍼
========================================
삼국지(후한 ~삼국시대) 주요 인물 28명의 나무위키 문서에서 목차(섹션 제목) 긁어와
숏폼 소재가 만한 '썰/일화 키워드' 추출해 JSON / CSV 저장한다.
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
Playwright(Chromium) 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
설치:
pip install playwright beautifulsoup4
playwright install chromium # (이미 설치돼 있으면 생략)
실행:
python scraper.py
옵션:
python scraper.py --headful # 브라우저 창 보이게
python scraper.py --only 조조 여포 # 특정 인물만
"""
import argparse
import asyncio
import csv
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from playwright.async_api import async_playwright
# ----------------------------------------------------------------------------
# 설정
# ----------------------------------------------------------------------------
BASE = "https://namu.wiki/w/"
OUT_DIR = Path(__file__).parent / "data" # 삼국지 인물 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
# 삼국지 주요 인물 28명: (번호, 이름, 나무위키 문서 제목)
# 동음이의어가 있는 인물(장비/마초/원소 등)은 "(삼국지)" 를 붙인 문서 제목을 쓴다.
# 제목이 틀려도 worker 가 "이름" ↔ "이름(삼국지)" 를 자동으로 재시도한다.
SAMGUK_HEROES = [
# 위(魏)
(1, "조조", "조조"),
(2, "사마의", "사마의"),
(3, "하후돈", "하후돈"),
(4, "장료", "장료"),
(5, "허저", "허저"),
(6, "곽가", "곽가"),
(7, "순욱", "순욱"),
(8, "가후", "가후"),
# 촉(蜀)
(9, "유비", "유비"),
(10, "관우", "관우"),
(11, "장비", "장비(삼국지)"),
(12, "제갈량", "제갈량"),
(13, "조운", "조운(삼국지)"),
(14, "마초", "마초(삼국지)"),
(15, "황충", "황충(삼국지)"),
(16, "강유", "강유(삼국지)"),
(17, "방통", "방통(삼국지)"),
# 오(吳)
(18, "손권", "손권"),
(19, "손책", "손책"),
(20, "주유", "주유(삼국지)"),
(21, "육손", "육손(삼국지)"),
(22, "노숙", "노숙(삼국지)"),
(23, "감녕", "감녕"),
(24, "태사자", "태사자"),
# 군웅(群雄)
(25, "여포", "여포"),
(26, "동탁", "동탁"),
(27, "원소", "원소(삼국지)"),
(28, "원술", "원술"),
]
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
ANECDOTE_HINTS = [
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
"독살", "암살", "치세", "업적", "가족", "이름", "별명",
"연의", "정사", "전투", "최후", "민간", "창작물",
]
def clean_heading(text: str) -> str:
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
t = text.replace("[편집]", "").strip()
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
t = re.sub(r"^\s*[\d.]+\s*", "", t)
return t.strip()
def is_anecdote(keyword: str) -> bool:
return any(h in keyword for h in ANECDOTE_HINTS)
async def scrape_king(page, title: str):
"""한 인물 문서를 열고 목차 키워드 목록을 반환."""
url = BASE + title
result = {"title": title, "url": url, "page_title": None,
"keywords": [], "anecdote_keywords": [], "error": None}
try:
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
try:
await page.wait_for_selector(
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
timeout=8000,
)
except Exception:
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
await page.wait_for_timeout(800)
# 문서 실제 제목
try:
result["page_title"] = (await page.title()).split(" - ")[0].strip()
except Exception:
pass
if resp and resp.status >= 400:
result["error"] = f"HTTP {resp.status}"
return result
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
keywords = await page.evaluate(
"""
() => {
const clean = s => s.replace('[편집]', '').trim();
let items = [];
// 1순위: 목차(table of contents)
const tocLinks = document.querySelectorAll(
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
);
if (tocLinks.length) {
items = Array.from(tocLinks).map(a => clean(a.textContent));
}
// 2순위(목차가 없으면): 본문 헤딩 태그
if (items.length === 0) {
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
items = Array.from(hs).map(h => clean(h.textContent));
}
return items.filter(Boolean);
}
"""
)
# 중복 제거 + 번호 정리
seen, cleaned = set(), []
for raw in keywords:
kw = clean_heading(raw)
if kw and kw not in seen and len(kw) <= 60:
seen.add(kw)
cleaned.append(kw)
result["keywords"] = cleaned
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
except Exception as e:
result["error"] = f"{type(e).__name__}: {e}"
return result
async def main():
parser = argparse.ArgumentParser()
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
parser.add_argument("--only", nargs="*", help="특정 인물만 (이름으로 지정)")
parser.add_argument("--concurrency", type=int, default=4,
help="동시에 열 페이지 수 (기본 4)")
args = parser.parse_args()
kings = SAMGUK_HEROES
if args.only:
wanted = set(args.only)
kings = [k for k in SAMGUK_HEROES if k[1] in wanted]
if not kings:
print(f"[!] 매칭되는 인물 없음: {args.only}")
print(" 가능한 값:", ", ".join(k[1] for k in SAMGUK_HEROES))
return
OUT_DIR.mkdir(parents=True, exist_ok=True)
all_results = []
total = len(kings)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=not args.headful)
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
locale="ko-KR",
)
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
async def _block(route):
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
await route.abort()
else:
await route.continue_()
await context.route("**/*", _block)
sem = asyncio.Semaphore(max(1, args.concurrency))
done = 0
async def worker(idx, name, title):
nonlocal done
# 문서 제목 후보: 지정 제목 → "이름" → "이름(삼국지)" 순으로 자동 재시도
# (나무위키 동음이의어 처리 방식이 인물마다 달라 제목을 못 맞출 수 있어서)
titles = [title]
for alt in (name, f"{name}(삼국지)"):
if alt not in titles:
titles.append(alt)
async with sem:
page = await context.new_page()
try:
r = None
for t in titles:
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
# 실패하면 백오프하며 최대 3회까지 재시도
r = await scrape_king(page, t)
for attempt in range(3):
if not r["error"]:
break
await page.wait_for_timeout(1500 * (attempt + 1))
r = await scrape_king(page, t)
# 성공했고 목차도 나왔으면 이 제목으로 확정
if not r["error"] and r["keywords"]:
break
finally:
await page.close()
r["order"] = idx
r["name"] = name
done += 1
if r["error"]:
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
else:
print(f"[{done:2d}/{total}] {name} ({title}) "
f"키워드 {len(r['keywords'])}"
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
return r
all_results = await asyncio.gather(
*(worker(idx, name, title) for idx, name, title in kings)
)
all_results = sorted(all_results, key=lambda r: r["order"])
await browser.close()
# 저장 ---------------------------------------------------------------
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
json_path = OUT_DIR / f"samguk_keywords_{ts}.json"
json_path.write_text(
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
)
csv_path = OUT_DIR / f"samguk_keywords_{ts}.csv"
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["번호", "인물", "문서제목", "썰후보여부", "키워드", "URL"])
for r in all_results:
anec = set(r["anecdote_keywords"])
for kw in r["keywords"]:
w.writerow([r["order"], r["name"], r["title"],
"Y" if kw in anec else "", kw, r["url"]])
total_kw = sum(len(r["keywords"]) for r in all_results)
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
fails = [r["name"] for r in all_results if r["error"]]
print("\n" + "=" * 50)
print(f"완료: 인물 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}")
if fails:
print(f"수집 실패: {', '.join(fails)}")
print(f"JSON -> {json_path}")
print(f"CSV -> {csv_path}")
if __name__ == "__main__":
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
asyncio.run(main())

Binary file not shown.

Binary file not shown.

285
backend/generator/media.py Normal file
View File

@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
"""
Best3 저수준 도구함 (TTS · 비전 · ffmpeg · BGM · 폰트)
======================================================
one_short / render 공통으로 쓰는 유틸 모음. 단독 실행용 아님.
· 비전: analyze_photos(사진/클립프레임 라벨·점수), extract_frame
· 음성: gen_tts(Gemini TTS), pcm_to_wav, wav_seconds, process_audio(무음제거·속도)
· ffmpeg: _ffmpeg, _run, pick_encoder(QSV/libx264), _AUDIO_ARGS
· 자막: _wrap_lines(픽셀폭 줄바꿈)
· BGM: resolve_bgm(폴더 랜덤 1), mix_bgm
설치: pip install google-genai pillow imageio-ffmpeg
"""
import json
import re
import shutil
import subprocess
import time
import wave
from pathlib import Path
from google.genai import types
HERE = Path(__file__).parent
W, H = 1080, 1920 # 세로 쇼츠
FPS = 30
VISION_MODEL = "gemini-2.5-flash" # 사진/클립 비전 분석용
TTS_MODEL = "gemini-2.5-flash-preview-tts"
TTS_VOICE = "Aoede" # 산뜻하고 가벼운 보이스. 대안: Charon(차분) / Sulafat(따뜻) / Laomedeia(경쾌)
TTS_STYLE = ("다음 문장을 밝고 경쾌하게, 적당히 빠른 속도로 활기차고 신나는 톤으로 읽어줘. "
"지루하지 않게 텐션을 올리되, 과한 병맛·오버는 아니고 세련되게:")
LINK_CTA = "소개한 곳 링크는 고정댓글에서 확인하세요" # info 카드 링크 안내
# 한글 자막용 폰트 — 주아체(fonts/ 동봉), 없으면 맑은고딕 볼드. 제목/정보용은 송명체.
FONT = str(HERE / "fonts" / "Jua-Regular.ttf")
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
TITLE_FONT = str(HERE / "fonts" / "SongMyung-Regular.ttf")
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
# 수동 입력(링크 없이 직접 업로드)용 — 로컬 폴더의 이미지/영상 파일 목록
_LOCAL_IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".bmp")
_LOCAL_VID_EXTS = (".mp4", ".mov", ".webm", ".m4v")
def list_media(folder, kind="image"):
"""로컬 폴더의 이미지(kind='image') 또는 영상(kind='video') 파일을 정렬해 [Path...] 반환.
링크 없이 직접 사진/클립을 올릴 사용. 폴더가 없으면 리스트."""
exts = _LOCAL_VID_EXTS if kind == "video" else _LOCAL_IMG_EXTS
d = Path(folder) if folder else None
if not d or not d.is_dir():
return []
return sorted(p for p in d.iterdir() if p.suffix.lower() in exts)
# ---------------------------------------------------------------- ffmpeg
def _ffmpeg() -> str:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def _run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode != 0:
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
return r
def pick_encoder(exe) -> list:
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
test = subprocess.run(
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
"-c:v", "h264_qsv", "-f", "null", "-"],
capture_output=True, text=True, errors="replace")
if test.returncode == 0:
print(" 인코더: h264_qsv (Intel 하드웨어)")
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
print(" 인코더: libx264 (ultrafast)")
return ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"]
# ---------------------------------------------------------------- 비전
def analyze_photos(client, photo_paths, model=None):
"""사진/클립프레임을 비전으로 한 번에 분석.
반환 rows: 입력 순서대로 [{label, char_fit, has_text}].
label = 보이는 핵심 한국어 (나레이션 매칭용)
char_fit = 컷이 매력적인 정도 1~10(클립·사진 순위용)
has_text = 글자/자막/캡션이 박혀 보이면 True
실패 라벨/0/False."""
n = len(photo_paths)
parts = [types.Part.from_text(text=(
"여러 이미지(가게 사진 또는 영상 캡처)다. 각 이미지를 분석해라.\n"
"- label: 보이는 핵심을 짧은 한국어 한 줄로(예: '대게 한 상', '바다 노을 뷰', '야외 수영장', "
"'아늑한 객실', '외관/간판'). 메뉴판이면 '메뉴판'.\n"
"- char_fit: 광고 컷으로 매력적인 정도 1~10 "
"(음식·음료·수영장·아늑한 실내=높게 / 메뉴판·로고·밋밋한 외관·사람 위주=낮게).\n"
"- has_text: 이미지에 '글자/자막/캡션/큰 로고 텍스트'가 박혀 보이면 true, 깨끗하면 false.\n"
"각 이미지 앞 [이미지 N] 의 N 을 index 로 써라."))]
for i, p in enumerate(photo_paths):
parts.append(types.Part.from_text(text=f"[이미지 {i}]"))
parts.append(types.Part.from_bytes(data=Path(p).read_bytes(), mime_type="image/jpeg"))
schema = {"type": "object", "properties": {"photos": {"type": "array", "items": {
"type": "object", "properties": {
"index": {"type": "integer"}, "label": {"type": "string"},
"char_fit": {"type": "integer"}, "has_text": {"type": "boolean"}},
"required": ["index", "label", "char_fit", "has_text"]}}}, "required": ["photos"]}
resp = client.models.generate_content(
model=model or VISION_MODEL, contents=parts,
config=types.GenerateContentConfig(
temperature=0.2, response_mime_type="application/json", response_schema=schema))
by = {r["index"]: r for r in json.loads(resp.text).get("photos", [])
if 0 <= r.get("index", -1) < n}
return [{"label": (by.get(i, {}).get("label") or "").strip(),
"char_fit": by.get(i, {}).get("char_fit", 0),
"has_text": bool(by.get(i, {}).get("has_text", False))} for i in range(n)]
def extract_frame(video_path, out_jpg, at=1.0):
"""영상에서 프레임 1장 추출(비전 분석/썸네일용)."""
_run([_ffmpeg(), "-y", "-ss", str(at), "-i", str(video_path),
"-frames:v", "1", str(out_jpg)])
return out_jpg
# ---------------------------------------------------------------- 음성
def _extract_audio(resp):
"""TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호)."""
cands = getattr(resp, "candidates", None) or []
for c in cands:
content = getattr(c, "content", None)
parts = getattr(content, "parts", None) or [] if content else []
for p in parts:
inline = getattr(p, "inline_data", None)
if inline and inline.data:
m = re.search(r"rate=(\d+)", inline.mime_type or "")
return inline.data, (int(m.group(1)) if m else 24000)
return None
def gen_tts(client, text, voice, retries=4) -> bytes:
"""Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다."""
last = ""
for attempt in range(1, retries + 1):
try:
resp = client.models.generate_content(
model=TTS_MODEL,
contents=f"{TTS_STYLE}\n\n{text}",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
)
),
),
)
got = _extract_audio(resp)
if got:
return got
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
fr = ""
try:
fr = str(getattr(resp.candidates[0], "finish_reason", "") or "")
except Exception:
fr = "no-candidates"
last = f"빈 응답(finish_reason={fr or '?'})"
except Exception as e:
last = f"{type(e).__name__}: {e}"
if attempt < retries:
wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프
print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
end="", flush=True)
time.sleep(wait)
raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}")
def pcm_to_wav(pcm, path, rate):
with wave.open(str(path), "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
w.writeframes(pcm)
def wav_seconds(path: Path) -> float:
with wave.open(str(path), "rb") as w:
return w.getnframes() / w.getframerate()
def _atempo_chain(speed: float) -> list:
"""atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝."""
chain, s = [], speed
while s > 2.0:
chain.append("atempo=2.0"); s /= 2.0
while s < 0.5:
chain.append("atempo=0.5"); s *= 2.0
chain.append(f"atempo={s:.4f}")
return chain
def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True,
threshold="-38dB", keep=0.06):
"""컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기.
- trim_silence: / 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지).
silenceremove 앞을 자르고, areverse 뒤집어 다시 (=원래 ) 자른 복원.
keep: 잘라낸 끝에 남길 여유(). 작을수록 마디가 바짝 붙는다.
- speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다.
가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백.
"""
af = []
if trim_silence:
one = (f"silenceremove=start_periods=1:start_silence={keep}:"
f"start_threshold={threshold}:detection=peak")
af += [one, "areverse", one, "areverse"]
if speed and abs(speed - 1.0) > 1e-3:
af += _atempo_chain(speed)
if not af:
shutil.copyfile(src_wav, dst_wav); return
try:
_run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af),
"-ar", "24000", "-ac", "1", str(dst_wav)])
if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백
raise RuntimeError("가공 후 음성이 너무 짧음")
except (RuntimeError, OSError):
shutil.copyfile(src_wav, dst_wav)
# ---------------------------------------------------------------- 자막 유틸
def _wrap_lines(draw, text, font, max_w):
"""글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈."""
lines, cur = [], ""
for ch in text:
if ch == "\n":
lines.append(cur); cur = ""; continue
if draw.textlength(cur + ch, font=font) <= max_w:
cur += ch
else:
lines.append(cur); cur = ch
if cur:
lines.append(cur)
return lines or [""]
# ---------------------------------------------------------------- BGM
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")
def resolve_bgm(bgm_arg):
"""--bgm 값 → 실제 음악 파일 경로(없으면 None).
해석 순서(분위기별로 bgm/ 폴더에 나눠 담는 지원):
1) 파일이면 그대로
2) 폴더면 음악 무작위 1
3) 이름만 주면 HERE/<> HERE/bgm/<> 순으로 파일/폴더 탐색
"""
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
return None
import random
p = Path(bgm_arg)
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p]
for c in cands:
if c.is_file():
return c
if c.is_dir():
files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS]
if files:
return random.choice(files)
return None
def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0):
"""완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장.
- BGM 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림.
- 나레이션[0:a] + BGM[1:a]*volume amix(duration=first) 섞어 영상 길이에 맞춘다.
- 영상은 재인코딩하지 않는다(-c:v copy) 빠름. 시작에 짧은 페이드인.
"""
fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];"
f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]")
_run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path),
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
"-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart",
str(out_path)])

541
backend/generator/naver.py Normal file
View File

@ -0,0 +1,541 @@
# -*- coding: utf-8 -*-
"""
네이버 플레이스 크롤링 레이어 (AI브리핑 · 사진) animation 3(조선/삼국지/그리스로마) 공유본
====================================================================================
animation 계열이 공유하는 '링크 1개' 네이버 크롤러. 파일 하나로 자립 동작.
· fetch_briefing 가게이름 + 업종타입(cat) + AI 브리핑(불릿)
· fetch_and_select 공식 사진 크롤 + Gemini 비전 선별(인물 항상 제외)
공유 헬퍼(MOBILE_UA / STEALTH / extract_place) 번만 정의.
클립(fetch_clips)·지도(fetch_map) narration/kakao 폴더 삭제와 함께 제거됨(animation 미사용).
[정리한 ]
- 방문자 후기(fetch_reviews) 제거: AI요약(브리핑) 나레이션 근거로 쓰므로 불필요.
- fetch_briefing 항상 3-tuple (store, bullets, cat) 반환. cat 쓰는 형식은 그냥 무시.
- 사진 선별은 항상 '인물 사진 제외'(초상권). 끄고 싶으면 fetch_and_select(..., exclude_person=False).
[ 차단 회피] 자동화 흔적 줄인 스텔스 컨텍스트 + 모바일 UA 접근.
설치: pip install playwright google-genai pillow httpx ; playwright install chromium
"""
import asyncio
import json
import re
import shutil
from pathlib import Path
from urllib.parse import unquote, urlparse
import httpx
from google.genai import types
from PIL import Image
from playwright.async_api import async_playwright
# ============================== 공유 헬퍼 (UA · 스텔스 · place id) ==============================
MOBILE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1")
# 자동화 탐지 우회용 init 스크립트
STEALTH = ("Object.defineProperty(navigator,'webdriver',{get:()=>undefined});"
"Object.defineProperty(navigator,'languages',{get:()=>['ko-KR','ko']});"
"window.chrome={runtime:{}};")
VISION_MODEL = "gemini-2.5-flash"
def extract_place(final_url: str):
"""리다이렉트된 최종 URL → (업종타입, place id). 예: ('restaurant', '1234567')."""
m = re.search(r"place\.naver\.com/([a-z]+)/(\d+)", final_url)
if m:
return m.group(1), m.group(2)
m2 = re.search(r"(\d{7,})", final_url)
return "restaurant", (m2.group(1) if m2 else None)
def looks_like_naver_place(s: str) -> bool:
return bool(s) and ("naver.me/" in s or "place.naver.com" in s or "map.naver.com" in s)
async def _new_page(p, headful=False, viewport=None, device_scale_factor=None):
"""공통 브라우저 컨텍스트(스텔스 + 모바일 UA) → (browser, ctx, page)."""
browser = await p.chromium.launch(
headless=not headful,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"])
kwargs = dict(user_agent=MOBILE_UA, locale="ko-KR",
viewport=viewport or {"width": 414, "height": 896},
extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"})
if device_scale_factor:
kwargs["device_scale_factor"] = device_scale_factor
ctx = await browser.new_context(**kwargs)
await ctx.add_init_script(STEALTH)
page = await ctx.new_page()
return browser, ctx, page
# ============================== 브리핑 (이름 · 업종타입 · AI요약) ==============================
# 홈에서 'AI 브리핑' 불릿 문장만 뽑는 브라우저측 스크립트.
# [핵심] 브리핑 본문 문장은 출처번호(<a>1</a>…)를 '자식'으로 품은 노드 안에 있어서
# leaf(자식없는) 추출로는 절대 안 잡힌다(라벨/번호만 leaf로 남는다).
# → 인트로 + 본문을 모두 품은 컨테이너의 innerText 를 통째로 떠서 줄 단위로 판다.
# → 본문 불릿과 '출처 리뷰 인용문'을 가르는 구조 신호:
# 브리핑 불릿 문장 = 뒤에 출처번호만 따라옴.
# 리뷰 인용문 = 문장 뒤 몇 줄 안에 '날짜(26.05.13.)'가 붙는다.
# → 문장 뒤 3줄 안에 날짜가 나오면 거기서부터 리뷰 → 그 직전까지가 브리핑.
# 이 방식은 라벨(객실/시설/위치/서비스…)이 뭐로 바뀌든 안 흔들린다.
_EXTRACT_JS = r"""() => {
const hasHeader = [...document.querySelectorAll('body *')]
.some(e => e.children.length === 0 && /^AI\s*브리핑/.test((e.innerText||'').trim()));
// 인트로 + 본문을 함께 품은 '가장 안쪽' 컨테이너
const INTRO = /(리뷰[를에]?\s*(바탕|기반|활용))|요약(|||하면)|정리한 (정보|내용)|다음과 같습니다/;
const cands = [...document.querySelectorAll('body *')]
.filter(e => { const t = e.innerText || '';
return INTRO.test(t) && t.length > 40 && /[-]{6,}/.test(t); });
cands.sort((a, b) => (a.innerText||'').length - (b.innerText||'').length);
if (!cands.length) return {found: hasHeader, bullets: []};
const block = cands[0].innerText || '';
const lines = block.split('\n').map(s => s.trim()).filter(Boolean);
const isNum = s => /^\d+$/.test(s);
const isDate = s => /^\d{2}\.\d{2}\.\d{2}\.?$/.test(s) || /^\d{4}\s*/.test(s);
const isSent = s => s.length >= 12 && /[-]/.test(s) && !isNum(s) && !INTRO.test(s);
// 인트로 다음부터, 리뷰 인용(문장 3 날짜) 시작 전까지의 문장 = 브리핑 불릿
let start = lines.findIndex(t => INTRO.test(t));
start = start < 0 ? 0 : start + 1;
const bullets = [];
for (let i = start; i < lines.length; i++) {
if (!isSent(lines[i])) continue;
if (lines.slice(i + 1, i + 4).some(isDate)) break; // 리뷰 인용 시작 종료
bullets.push(lines[i].replace(/^[-힣A-Za-z ]{1,12}:\s*/, '')); // '객실 정보: ' 라벨 제거
}
return {found: hasHeader || bullets.length > 0, bullets};
}"""
async def _fetch(url, headful=False):
async with async_playwright() as p:
browser, ctx, page = await _new_page(p, headful=headful)
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(2500)
cat, pid = extract_place(page.url)
if not pid:
await browser.close()
raise RuntimeError("place id 를 못 찾음. URL 확인 또는 headful=True 로 화면 확인.")
# 항상 표준 홈 URL 로 이동(리다이렉트 직후엔 섹션이 덜 렌더됨)
await page.goto(f"https://m.place.naver.com/{cat}/{pid}/home",
wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(2000)
try:
await page.wait_for_selector("text=AI 브리핑", timeout=8000)
except Exception:
for _ in range(3):
await page.mouse.wheel(0, 1500)
await page.wait_for_timeout(600)
await page.wait_for_timeout(800)
store = ""
for sel in ["span.GHAhO", "h2", "title"]:
try:
t = await page.locator(sel).first.inner_text(timeout=1500)
if t and t.strip():
store = t.strip().splitlines()[0]
break
except Exception:
pass
res = await page.evaluate(_EXTRACT_JS)
await browser.close()
if not res.get("found"):
return store, None, cat
seen, bullets = set(), []
for t in res.get("bullets", []):
t = re.sub(r"\s+", " ", t).strip()
if t and t not in seen:
seen.add(t); bullets.append(t)
return store, bullets, cat
def fetch_briefing(url, headful=False):
"""동기 진입점. (가게이름, [불릿...], 업종타입) 반환.
업종타입 = 링크의 플레이스 타입(restaurant/accommodation/hairshop). 브리핑 없으면 불릿이 None.
animation 처럼 업종이 필요 없는 형식은 3번째 값을 그냥 무시하면 된다."""
return asyncio.run(_fetch(url, headful=headful))
# ============================== 사진 (크롤 + Gemini 비전 선별, 인물 제외) ==============================
# 공식 대표사진 CDN(업체 등록)만 후보로 사용.
# 손님 리뷰 사진(pup-review-*)은 저작권/초상권이 '리뷰 작성자 개인'에게 있어
# 가게 동의만으론 광고 영상에 쓸 수 없으므로 제외한다.
# 공식 대표사진이 오는 호스트(둘 다 '업체가 직접 등록'한 사진이라 광고 사용 가능):
# · ldb-phinf : 음식점/일반 업체가 등록한 공식 대표사진
# · naverbooking-phinf : 네이버 '예약'형 숙소(펜션·게하·글램핑)의 공식 객실 사진
# → 예약 숙소는 ldb 대신 이쪽으로 온다 (이게 'accommodation 사진 0장'의 원인이었음)
# 손님 리뷰 사진(pup-review-*)은 초상권 문제로 의도적 제외.
OFFICIAL_HOST = "ldb-phinf.pstatic.net"
BOOKING_HOST = "naverbooking-phinf.pstatic.net"
REVIEW_HOST = "pup-review-phinf.pstatic.net" # (참고: 의도적으로 후보에서 제외)
CAND_HOSTS = (OFFICIAL_HOST, BOOKING_HOST)
def _underlying(url: str) -> str:
"""search.pstatic.net/common?...&src=<진짜URL> 프록시면 src 를 풀어서 반환."""
m = re.search(r"[?&]src=([^&]+)", url)
if m:
return unquote(m.group(1))
return url
async def crawl_and_download(url, dst_dir, max_candidates=14, headful=False):
"""사진 탭 → 공식 대표사진을 dst_dir 에 내려받고 (가게이름, [경로]) 반환."""
dst_dir = Path(dst_dir)
dst_dir.mkdir(parents=True, exist_ok=True)
seen, seen_tab, store = [], [], ""
cur = {"label": "업체"} # 현재 보고 있는 사진탭 카테고리(응답에 태깅)
async with async_playwright() as p:
browser, ctx, page = await _new_page(p, headful=headful)
def on_resp(resp):
u = resp.url
if "phinf.pstatic.net" in u or "search.pstatic.net/common" in u:
seen.append(u); seen_tab.append(cur["label"])
page.on("response", on_resp)
# 링크 → place id → 사진 탭
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(2000)
cat, pid = extract_place(page.url)
if not pid:
await browser.close()
raise RuntimeError("place id 를 못 찾음.")
try:
store = (await page.locator("span.GHAhO, h2").first.inner_text(timeout=1500)).strip()
store = store.splitlines()[0]
except Exception:
store = ""
await page.goto(f"https://m.place.naver.com/{cat}/{pid}/photo",
wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(2000)
# [핵심] 사진탭 상단 카테고리 탭(업체/내부/음식·음료/외부/풍경...)을 돌며 로드.
# 기본 '업체' 탭엔 대표사진 몇 장뿐 → 다른 탭을 눌러야 음식·음료(와인 등)·내부가 나온다.
# (세션이 길면 헤드리스 브라우저가 불안정해지므로 탭 수·스크롤을 적당히 제한)
try:
chips = [c.strip() for c in await page.locator("a.sbkBy").all_inner_texts()]
except Exception:
chips = []
targets = [c for c in chips if c and c != "클립"][:6] or [None] # 클립(영상)은 제외
for label in targets:
cur["label"] = label or "업체" # 이 탭에서 로드되는 사진을 이 라벨로 태깅
try:
if label is not None:
await page.locator("a.sbkBy", has_text=label).first.click(timeout=3000)
await page.wait_for_timeout(900)
for _ in range(5): # 각 탭에서 스크롤하며 lazy 로드 유발
await page.mouse.wheel(0, 2500)
await page.wait_for_timeout(300)
except Exception:
break # 연결 끊기면 모은 만큼으로 진행
await page.wait_for_timeout(300)
# 후보 = 탭(업체/내부/음식·음료/외부…)을 '고루 섞어' 뽑는다.
# 앞에서 N장만 자르면 먼저 로드된 업체(food) 탭이 다 차지해 내부/외부가 잘린다
# → 탭별로 모은 뒤 라운드로빈으로 채워 다양성을 강제한다. 각 탭 안에선 공식 먼저.
tab_imgs, bases = {}, set() # tab -> [(host, base)…]
official_n = 0
for u, tab in zip(seen, seen_tab):
real = _underlying(u)
host = urlparse(real).netloc
if host not in CAND_HOSTS: # 공식 대표사진(ldb-phinf)만 통과
continue
if "icon" in real or "profile" in real:
continue
base = real.split("?")[0]
if base in bases:
continue
bases.add(base)
tab_imgs.setdefault(tab, []).append((host, base))
official_n += 1
# 메뉴판·동영상 탭은 광고컷으로 약하므로 뒤로 미룬다
def _tab_rank(t):
return 1 if (t and ("메뉴" in t or "동영상" in t)) else 0
order = sorted(tab_imgs.keys(), key=_tab_rank)
candidates, ptr = [], {t: 0 for t in order}
while len(candidates) < max_candidates and any(ptr[t] < len(tab_imgs[t]) for t in order):
for t in order:
if ptr[t] < len(tab_imgs[t]):
candidates.append(tab_imgs[t][ptr[t]][1]); ptr[t] += 1
if len(candidates) >= max_candidates:
break
tabsum = ", ".join(f"{t}:{len(v)}" for t, v in tab_imgs.items())
print(f"■ 사진 후보 {len(candidates)}장 (공식 {official_n}, "
f"전체 응답 {len(seen)}) / 탭별 [{tabsum}]")
# [진단] 후보 0인데 응답은 많을 때, 실제로 어떤 CDN 호스트에서 왔는지 확인용.
# 여기에 CAND_HOSTS 가 아닌 호스트가 잡히면 (리뷰/블로그 아닌 한) 추가하면 된다.
if official_n == 0 and seen:
from collections import Counter
hosts = Counter(urlparse(_underlying(u)).netloc for u in seen)
print(" [진단] 응답 호스트별: "
+ ", ".join(f"{h}:{c}" for h, c in hosts.most_common()))
# 1차: 브라우저 컨텍스트로 다운로드(쿠키/헤더 유지). 연결 끊기면 남은 건 httpx 폴백.
paths, got = [], set()
for i, u in enumerate(candidates, 1):
try:
r = await ctx.request.get(u, timeout=15000)
if not r.ok:
continue
data = await r.body()
if len(data) < 3000: # 너무 작으면 깨진/아이콘
continue
fp = dst_dir / f"cand{i:02d}.jpg"
fp.write_bytes(data)
paths.append(fp); got.add(i)
except Exception:
break # 연결이 죽었을 가능성 → 폴백으로
try:
await browser.close()
except Exception:
pass
# 2차(폴백): 1차에서 못 받은 후보를 httpx 로 직접 다운로드 (공개 CDN, Referer 헤더로 차단 회피)
missing = [(i, u) for i, u in enumerate(candidates, 1) if i not in got]
if missing:
hdr = {"User-Agent": MOBILE_UA, "Referer": "https://m.place.naver.com/"}
with httpx.Client(headers=hdr, timeout=15, follow_redirects=True) as hc:
for i, u in missing:
try:
rr = hc.get(u)
if rr.status_code == 200 and len(rr.content) >= 3000:
fp = dst_dir / f"cand{i:02d}.jpg"
fp.write_bytes(rr.content); paths.append(fp)
except Exception:
continue
paths.sort()
print(f"■ 다운로드 {len(paths)}장 → {dst_dir}")
return store, paths
# 비전 채점 스키마 — has_person 포함(인물 제외 판정용).
_SCORE_SCHEMA = {
"type": "object",
"properties": {
"scores": {
"type": "array",
"items": {
"type": "object",
"properties": {
"index": {"type": "integer"},
"category": {"type": "string",
"enum": ["food", "interior", "exterior", "menu", "drink", "other"]},
"subject": {"type": "string"}, # 핵심 피사체 한두 단어(다양성 판단용)
"quality": {"type": "integer"}, # 1~10
"good_for_ad": {"type": "boolean"},
"has_person": {"type": "boolean"}, # 사람(전신/일부/뒷모습 포함)이 보이면 true
},
"required": ["index", "category", "subject", "quality", "good_for_ad", "has_person"],
},
}
},
"required": ["scores"],
}
def vision_score(client, paths, cap=16):
"""각 사진을 Gemini 비전으로 채점. [{index,category,subject,quality,good_for_ad,has_person}] 반환."""
paths = paths[:cap]
parts = [types.Part.from_text(text=(
"다음 사진들은 한 음식점의 사진이다. 각 사진을 평가하라.\n"
"- category: food(음식)/drink(음료)/interior(내부)/exterior(외부/간판)/menu(메뉴판)/other\n"
"- subject: 핵심 피사체를 한두 단어로(예: 구운고기, 랍스터, 스테이크, 와인, 파스타, 수영장야경, 실내홀, 외관).\n"
" ★ 거의 같은 피사체면 같은 단어로 통일하라(예: 그릴에 구운 고기 사진 여러 장은 전부 '구운고기').\n"
" 이 값으로 비슷한 사진을 묶어 다양하게 고른다.\n"
"- quality: 광고 영상에 쓸 만큼 잘 나왔는지 1~10 (선명·밝기·구도·먹음직스러움). 흐리거나 어둡거나 잘리면 낮게.\n"
"- has_person: 사진 안에 사람이 조금이라도 보이면 true (얼굴·전신·일부·뒷모습·손 등 무엇이든).\n"
" 사람이 전혀 없는 빈 공간/음식/사물 사진만 false.\n"
"- good_for_ad: 가게 홍보 컷으로 바로 써도 좋은가(true/false).\n"
" ★ 다음은 무조건 category='other', good_for_ad=false, quality 1~3 으로:\n"
" · 로고/브랜드 글자/간판 텍스트만 큰 이미지\n"
" · 메뉴판·가격표·영수증 등 글자 위주 이미지\n"
" · 사람이 보이는 사진(초상권 문제 — 사람 없는 컷이 많으니 사람 있으면 제외), 텍스트 그래픽/포스터만 있는 이미지\n"
"'음식·음료·매장 공간'이 또렷하게 보이고 사람이 없는 사진만 good_for_ad=true.\n"
"각 사진 바로 앞에 [사진 N] 표시가 있다. 그 N 을 index 로.")), ]
for i, p in enumerate(paths):
parts.append(types.Part.from_text(text=f"[사진 {i}]"))
parts.append(types.Part.from_bytes(data=Path(p).read_bytes(), mime_type="image/jpeg"))
resp = client.models.generate_content(
model=VISION_MODEL, contents=parts,
config=types.GenerateContentConfig(
temperature=0.2, response_mime_type="application/json",
response_schema=_SCORE_SCHEMA),
)
return json.loads(resp.text).get("scores", [])
BAD_CATS = {"menu", "other"}
def _dhash(path, size=8):
"""차이 해시(dHash, 64bit). 시각적으로 비슷한 사진끼리는 해밍거리가 작다.
(같은 그릴/같은 수영장뷰처럼 '거의 같은 그림' 잡아내 중복 선택을 막는 용도.)"""
try:
img = Image.open(path).convert("L").resize((size + 1, size), Image.LANCZOS)
px = list(img.getdata())
bits = 0
for r in range(size):
row = px[r * (size + 1):(r + 1) * (size + 1)]
for c in range(size):
bits = (bits << 1) | (1 if row[c] < row[c + 1] else 0)
return bits
except Exception:
return None
def _ham(a, b):
if a is None or b is None:
return 999
return bin(a ^ b).count("1")
def too_similar(path, others, thresh=8):
"""path 의 차이해시(dHash)가 others(이미지 경로 리스트) 중 어느 것과든 해밍거리 <= thresh 면 True.
= 시각적으로 '거의 같은 그림'. 사진/클립이 서로 너무 비슷하게 뽑히는 막는 쓴다(중복 방지)."""
h = _dhash(path)
if h is None:
return False
return any(_ham(h, _dhash(o)) <= thresh for o in others)
def _subj(s):
"""다양성 판단 키: 피사체(subject) 우선, 없으면 종류(category)."""
return (s.get("subject") or s.get("category") or "other").strip().lower()
def _ngrams(s, k=2):
s = s.replace(" ", "")
return {s[i:i + k] for i in range(len(s) - k + 1)} or {s}
def _sim_subj(a, b):
"""두 피사체가 사실상 같은 종류인가(예: '구운고기' vs '구운고기새우' → True)."""
if not a or not b:
return False
if a in b or b in a:
return True
return bool(_ngrams(a) & _ngrams(b)) # 2글자 조각이 하나라도 겹치면 비슷
def pick_best(paths, scores, n=4, per_cat=2, dup_thresh=8, exclude_person=True):
"""채점 결과로 '종류·피사체 안 겹치게 잘 나온' N장 선택 → 경로 리스트.
비전 모델이 피사체를 너무 잘게 쪼개는 경우가 있어(구운고기/구운고기새우/문어새우
전부 사실상 '그릴 음식') 단순 피사체 분산만으론 비슷한 그림이 몰린다. 그래서:
1) 종류(category) 우선 분산 + 피사체 비유사 최대한 다른 종류·다른 피사체로.
2) 같은 종류 2번째 허용하되 '비유사 피사체'(그릴 다음엔 디저트 같은 ).
3) 그래도 모자라면 피사체 유사 허용(동일사진 hash 제외).
4) 최후: 동일사진만 피해 채움.
로고/메뉴판/텍스트(other·menu, good_for_ad=false) 후보에서 제외.
exclude_person=True(기본): 사람이 보이는 사진은 초상권 문제로 끝까지 제외.
"""
by_idx = {s["index"]: s for s in scores if 0 <= s.get("index", -1) < len(paths)}
ranked = sorted(by_idx.values(),
key=lambda s: (s.get("good_for_ad", False), s.get("quality", 0)),
reverse=True)
usable = [s for s in ranked
if s.get("good_for_ad") and s.get("category") not in BAD_CATS
and not (exclude_person and s.get("has_person"))] # 인물 제외(초상권)
hashes = {paths[s["index"]]: _dhash(paths[s["index"]]) for s in ranked}
chosen, chosen_h, chosen_subj, cat_cnt = [], [], [], {}
def is_dup(p):
h = hashes.get(p)
return any(_ham(h, ch) <= dup_thresh for ch in chosen_h)
def take(s):
p = paths[s["index"]]
chosen.append(p); chosen_h.append(hashes.get(p)); chosen_subj.append(_subj(s))
c = s.get("category", "other"); cat_cnt[c] = cat_cnt.get(c, 0) + 1
def fits(s, new_cat, distinct_subj, respect_cap):
p = paths[s["index"]]; c = s.get("category", "other")
if p in chosen or is_dup(p):
return False
if respect_cap and cat_cnt.get(c, 0) >= per_cat:
return False
if new_cat and cat_cnt.get(c, 0) >= 1:
return False
if distinct_subj and any(_sim_subj(_subj(s), cs) for cs in chosen_subj):
return False
return True
# (카테고리상한, 새카테고리우선, 피사체비유사) 단계적 완화.
# 3단계: 한 카테고리로 쏠린 가게(전부 food 등)는 상한을 풀되 '피사체 다양성'은 끝까지 지킨다.
# → 한정식집처럼 다 음식이어도 돌솥밥/불고기/생선구이/전… 서로 다른 걸 고른다.
for new_cat, distinct, cap in [
(True, True, True), (False, True, True),
(False, True, False), (False, False, False)]:
for s in usable:
if len(chosen) >= n:
break
if fits(s, new_cat, distinct, cap):
take(s)
if len(chosen) >= n:
break
# 최후: 동일사진만 피해 나머지로 채움 (단, exclude_person 이면 사람 있는 사진은 끝까지 제외)
if len(chosen) < n:
for s in ranked:
if len(chosen) >= n:
break
if exclude_person and s.get("has_person"):
continue
p = paths[s["index"]]
if p not in chosen and not is_dup(p):
chosen.append(p); chosen_h.append(hashes.get(p)); chosen_subj.append(_subj(s))
if chosen_subj:
print(f" 선별 피사체: {', '.join(chosen_subj)}")
return chosen
def fetch_and_select(url, out_dir, n=4, client=None, max_candidates=16,
headful=False, exclude_person=True):
"""전체 파이프라인: 크롤링 → (있으면)비전 선별 → out_dir 에 photoNN.jpg 저장.
반환 (가게이름, [최종 사진 경로]). client 없으면 비전 없이 앞에서 N장.
exclude_person=True(기본): 인물 사진 제외(초상권). (animation/narration/kakao) 기본 적용.
"""
out_dir = Path(out_dir)
tmp = out_dir / "_cand"
if tmp.exists():
shutil.rmtree(tmp, ignore_errors=True)
store, cand = asyncio.run(crawl_and_download(url, tmp, max_candidates, headful))
if not cand:
shutil.rmtree(tmp, ignore_errors=True)
return store, []
if client is not None and len(cand) > n:
try:
scores = vision_score(client, cand)
chosen = pick_best(cand, scores, n=n, exclude_person=exclude_person)
print(f"■ 비전 선별: {len(cand)}장 → {len(chosen)}")
except Exception as e:
print(f" (비전 선별 실패 → 앞에서 {n}장: {type(e).__name__})")
chosen = cand[:n]
else:
chosen = cand[:n]
out_dir.mkdir(parents=True, exist_ok=True)
finals = []
for i, src in enumerate(chosen, 1):
dst = out_dir / f"photo{i:02d}.jpg"
shutil.copyfile(src, dst)
finals.append(dst)
shutil.rmtree(tmp, ignore_errors=True)
return store, finals

8
backend/requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi[standard]>=0.110
uvicorn[standard]>=0.27
sqlalchemy[asyncio]>=2.0
aiosqlite>=0.19
python-jose[cryptography]>=3.3
pydantic-settings>=2.0
python-dotenv>=1.0
aiohttp>=3.9

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<title>썰박스</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2402
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "o2o-ssulbox-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.4",
"vite": "^5.4.8"
}
}

125
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,125 @@
import { useCallback, useEffect, useState } from 'react'
import type { FeedItem, Tab } from './data'
import { SAMPLE } from './data'
import {
getFeed, getMe, toggleLike,
captureTokensFromUrl, getKakaoAuthUrl, startKakaoLogin, logout as apiLogout,
} from './api'
import type { Me } from './api'
import { Nav, TopBar, Home, Search, Channel, CreateSheet, Viewer, Login, ChargeSheet, ProfileEdit } from './components'
export default function App() {
const [tab, setTab] = useState<Tab>('home')
const [data, setData] = useState<FeedItem[]>(SAMPLE)
const [me, setMe] = useState<Me | null>(null)
const [authReady, setAuthReady] = useState(false)
const [busy, setBusy] = useState(false)
const [viewerId, setViewerId] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [loginOpen, setLoginOpen] = useState(false)
const [chargeOpen, setChargeOpen] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
const [toast, setToast] = useState('')
const notify = useCallback((m: string) => {
setToast(m)
window.setTimeout(() => setToast((t) => (t === m ? '' : t)), 2600)
}, [])
const loadFeed = useCallback(async () => {
const f = await getFeed()
if (f) setData(f.length ? f : []) // 백엔드 연결 시 실제 output(빈 배열이면 빈 상태)
}, [])
useEffect(() => {
(async () => {
captureTokensFromUrl() // 카카오 콜백 토큰 수신
// 유튜브 연동 콜백 복귀 처리(?connected=youtube&tab=channel)
const q = new URLSearchParams(window.location.search)
if (q.get('connected') === 'youtube') { setTab('channel'); notify('유튜브 채널이 연동됐어요!') }
if (q.get('connected') || q.get('tab')) window.history.replaceState({}, '', window.location.pathname)
setMe(await getMe())
setAuthReady(true)
await loadFeed()
})()
}, [loadFeed])
const onKakao = async () => {
setBusy(true)
const url = await getKakaoAuthUrl()
if (url) startKakaoLogin(url)
else { notify('카카오 로그인이 아직 설정되지 않았어요.'); setBusy(false) }
}
const onLogout = () => { apiLogout(); setMe(null); setTab('home') }
const requireLogin = () => { setLoginOpen(true); return false }
const onTab = (t: Tab | 'create') => {
if (t === 'create') { if (!me) return void requireLogin(); setSheetOpen(true); return }
if (t === 'channel' && !me) return void requireLogin()
setTab(t)
}
const onLike = async (id: string) => {
if (!me) return void requireLogin()
try {
const r = await toggleLike(id)
setData((d) => d.map((it) => (it.id === id ? { ...it, liked: r.liked, likes: r.likes } : it)))
} catch { /* 네트워크 — 무시 */ }
}
const viewer = data.find((x) => x.id === viewerId) ?? null
void authReady
return (
<div id="app">
<Nav tab={tab} onTab={onTab} nickname={me?.nickname ?? null} avatar={me?.profile_image_url ?? null}
onLogin={() => setLoginOpen(true)} onLogout={onLogout} />
<div className="main">
<TopBar tab={tab} nickname={me?.nickname ?? null} onLogin={() => setLoginOpen(true)} />
<main className="screen">
{tab === 'home' && <Home data={data} onOpen={setViewerId} />}
{tab === 'search' && <Search data={data} onOpen={setViewerId} />}
{tab === 'channel' && (
<Channel
data={me ? data.filter((d) => d.mine) : data}
credits={me?.credits ?? 0}
nickname={me?.nickname ?? '게스트'}
avatar={me?.profile_image_url ?? null}
bio={me?.bio ?? null}
onOpen={setViewerId}
toast={notify}
onLogout={onLogout}
onCharge={() => setChargeOpen(true)}
onEditProfile={() => setProfileOpen(true)}
/>
)}
</main>
</div>
<div className={'scrim' + (sheetOpen || chargeOpen || profileOpen ? ' on' : '')}
onClick={() => { setSheetOpen(false); setChargeOpen(false); setProfileOpen(false) }} />
<ChargeSheet
open={chargeOpen}
onClose={() => setChargeOpen(false)}
onDone={(credits) => setMe((m) => (m ? { ...m, credits } : m))}
toast={notify}
/>
<ProfileEdit
open={profileOpen}
onClose={() => setProfileOpen(false)}
me={me}
onSaved={(updated) => setMe(updated)}
toast={notify}
/>
<CreateSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onCreated={async () => { await loadFeed(); setMe(await getMe()); setTab('home') }}
/>
<Viewer item={viewer} onClose={() => setViewerId(null)} onLike={onLike} toast={notify} />
<Login open={loginOpen} onClose={() => setLoginOpen(false)} onKakao={onKakao} busy={busy} />
<div className={'toast' + (toast ? ' on' : '')} role="status">{toast}</div>
</div>
)
}

216
frontend/src/api.ts Normal file
View File

@ -0,0 +1,216 @@
import type { FeedItem, Scen } from './data'
const TOKEN_KEY = 'ssulbox_token'
export const getToken = () => localStorage.getItem(TOKEN_KEY)
const authH = (): Record<string, string> => {
const t = getToken()
return t ? { Authorization: 'Bearer ' + t } : {}
}
const REFRESH_KEY = 'ssulbox_refresh'
export interface Me {
user_uuid: string; nickname: string; email: string | null
profile_image_url: string | null; bio: string | null
credits: number; is_admin: boolean
}
export interface ProfileBody { nickname?: string; profile_image_url?: string; bio?: string }
export async function updateProfile(body: ProfileBody): Promise<{ ok: boolean; me?: Me; message: string }> {
try {
const r = await fetch('/api/auth/me', {
method: 'PATCH', headers: { 'Content-Type': 'application/json', ...authH() },
body: JSON.stringify(body),
})
if (r.ok) return { ok: true, me: await r.json(), message: '저장했어요.' }
const detail = await r.json().then((j) => j.detail).catch(() => '')
return { ok: false, message: detail || '저장에 실패했어요.' }
} catch { return { ok: false, message: '네트워크 오류로 저장에 실패했어요.' } }
}
/** 카카오 콜백이 ?access_token=&refresh_token= 로 되돌려준 토큰을 저장하고 URL을 정리. */
export function captureTokensFromUrl(): boolean {
const q = new URLSearchParams(window.location.search)
const at = q.get('access_token')
if (!at) return false
localStorage.setItem(TOKEN_KEY, at)
const rt = q.get('refresh_token')
if (rt) localStorage.setItem(REFRESH_KEY, rt)
window.history.replaceState({}, '', window.location.pathname)
return true
}
/** 카카오 로그인 시작: auth_url 있으면 이동. 미설정(501)이면 null. */
export async function getKakaoAuthUrl(): Promise<string | null> {
try {
const r = await fetch('/api/auth/kakao/login')
if (!r.ok) return null
return (await r.json()).auth_url ?? null
} catch { return null }
}
export function startKakaoLogin(url: string) { window.location.href = url }
export function logout() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
}
export async function devLogin(nickname = '진우'): Promise<boolean> {
try {
const r = await fetch('/api/auth/dev-login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nickname }),
})
if (!r.ok) return false
localStorage.setItem(TOKEN_KEY, (await r.json()).access_token)
return true
} catch { return false }
}
export async function getMe(): Promise<Me | null> {
try {
const r = await fetch('/api/auth/me', { headers: authH() })
return r.ok ? r.json() : null
} catch { return null }
}
/** 토큰이 없거나 만료면 개발용 로그인. 백엔드 없으면 null. */
export async function ensureLogin(): Promise<Me | null> {
let me = await getMe()
if (!me) { await devLogin(); me = await getMe() }
return me
}
export async function getFeed(opts?: { sort?: string; owner?: string }): Promise<FeedItem[] | null> {
try {
const q = new URLSearchParams({ page_size: '60' })
if (opts?.sort) q.set('sort', opts.sort)
if (opts?.owner) q.set('owner', opts.owner)
const r = await fetch('/api/feed?' + q, { headers: authH() })
if (!r.ok) return null
const j = await r.json()
return Array.isArray(j) ? j : (j.items ?? null) // 페이지네이션 {items} 언랩
} catch { return null }
}
export async function toggleLike(id: string): Promise<{ liked: boolean; likes: number }> {
const r = await fetch(`/api/content/${encodeURIComponent(id)}/like`, { method: 'POST', headers: authH() })
if (!r.ok) throw new Error('좋아요 실패')
return r.json()
}
export interface CommentNode {
id: number; body: string | null; nickname: string; is_mine: boolean
created_at: string; replies?: CommentNode[]
}
export async function listComments(id: string): Promise<CommentNode[]> {
try {
const r = await fetch(`/api/comment/content/${encodeURIComponent(id)}`, { headers: authH() })
if (!r.ok) return []
return (await r.json()).items ?? []
} catch { return [] }
}
export async function addComment(id: string, body: string, parent_id?: number): Promise<void> {
const r = await fetch(`/api/comment/content/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authH() },
body: JSON.stringify({ body, parent_id }),
})
if (!r.ok) throw new Error('댓글 실패')
}
/* ============================ 업장 검색 (동명 업장 구분 → place_url) ============================ */
export interface PlaceItem {
title: string; category: string; address: string; roadAddress: string; place_url: string
}
export async function searchPlace(query: string): Promise<PlaceItem[]> {
try {
const r = await fetch('/api/search/place?query=' + encodeURIComponent(query), { headers: authH() })
if (!r.ok) return []
return (await r.json()).items ?? []
} catch { return [] }
}
/* ============================ SNS 연동 ============================ */
export type Platform = 'youtube'
export interface SocialAccount { id: number; platform: Platform; account_name: string }
/** 플랫폼별 키 설정 여부(연동 가능 여부). 미설정이면 '곧 지원' 안내. */
export async function socialConfig(): Promise<Partial<Record<Platform, boolean>>> {
try {
const r = await fetch('/api/social/config')
return r.ok ? r.json() : {}
} catch { return {} }
}
export async function listSocial(): Promise<SocialAccount[]> {
try {
const r = await fetch('/api/social/accounts', { headers: authH() })
return r.ok ? r.json() : []
} catch { return [] }
}
/** 연동 시작: Google OAuth 동의 URL 반환(있으면 프론트가 그리로 이동). 미설정이면 message. */
export async function connectSocial(platform: Platform): Promise<{ authUrl?: string; message: string }> {
try {
const r = await fetch(`/api/social/${platform}/connect`, { headers: authH() })
if (r.ok) { const j = await r.json(); return { authUrl: j.auth_url, message: '' } }
const detail = await r.json().then((j) => j.detail).catch(() => '')
return { message: detail || '연동에 실패했어요.' }
} catch { return { message: '네트워크 오류로 연동에 실패했어요.' } }
}
export async function disconnectSocial(platform: Platform): Promise<boolean> {
try {
const r = await fetch(`/api/social/${platform}/disconnect`, { method: 'DELETE', headers: authH() })
return r.ok
} catch { return false }
}
export async function uploadToSocial(platform: Platform, contentId: string): Promise<{ ok: boolean; pending?: boolean; message: string }> {
try {
const r = await fetch(`/api/social/upload/${platform}/${encodeURIComponent(contentId)}`, { method: 'POST', headers: authH() })
if (r.ok) return { ok: true, message: '업로드했어요.' }
const detail = await r.json().then((j) => j.detail).catch(() => '')
return { ok: false, pending: r.status === 501, message: detail || '업로드에 실패했어요.' }
} catch { return { ok: false, message: '네트워크 오류로 업로드에 실패했어요.' } }
}
/* ============================ 크레딧 충전 상품 ============================ */
export interface Product { id: string; label: string; credits: number; bonus: number; price: number; best: boolean }
export async function getProducts(): Promise<Product[]> {
try {
const r = await fetch('/api/credits/products', { headers: authH() })
return r.ok ? (await r.json()).items ?? [] : []
} catch { return [] }
}
export async function purchaseProduct(productId: string): Promise<{ ok: boolean; credits?: number; message: string }> {
try {
const r = await fetch('/api/credits/purchase', {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authH() },
body: JSON.stringify({ product_id: productId }),
})
if (r.ok) { const j = await r.json(); return { ok: true, credits: j.credits, message: `${j.product} · ${j.added}크레딧 충전 완료!` } }
const detail = await r.json().then((j) => j.detail).catch(() => '')
return { ok: false, message: detail || '충전에 실패했어요.' }
} catch { return { ok: false, message: '네트워크 오류로 충전에 실패했어요.' } }
}
export interface CreateBody { scenario: Scen; input: string }
export async function createJob(body: CreateBody): Promise<{ id: string }> {
const r = await fetch('/api/create', {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authH() },
body: JSON.stringify(body),
})
if (!r.ok) {
let detail = '생성 요청 실패'
try { detail = (await r.json()).detail || detail } catch { /* noop */ }
throw new Error(detail)
}
return r.json()
}
export interface JobStatus {
id: string; status: 'queued' | 'running' | 'done' | 'error'
step: number; stepName: string; output: string | null; error: string | null; log: string[]
}
export async function getJob(id: string): Promise<JobStatus> {
const r = await fetch('/api/jobs/' + id, { cache: 'no-store', headers: authH() })
if (!r.ok) throw new Error('잡 조회 실패')
return r.json()
}

700
frontend/src/components.tsx Normal file
View File

@ -0,0 +1,700 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, ChangeEvent } from 'react'
import { SCEN, SCEN_KEYS, scGrad, fmt } from './data'
import type { FeedItem, Scen, Tab } from './data'
import {
createJob, getJob, listComments, addComment,
searchPlace, listSocial, socialConfig, connectSocial, disconnectSocial, uploadToSocial,
getProducts, purchaseProduct, updateProfile,
} from './api'
import type { CommentNode, PlaceItem, SocialAccount, Platform, Product, Me, ProfileBody } from './api'
/** 파일 → 256px 정사각 JPEG data URL (서버 스토리지 없이 프로필 이미지 저장) */
function fileToAvatar(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image()
const url = URL.createObjectURL(file)
img.onload = () => {
const size = 256
const c = document.createElement('canvas')
c.width = size; c.height = size
const ctx = c.getContext('2d')!
const s = Math.min(img.width, img.height)
ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size)
URL.revokeObjectURL(url)
resolve(c.toDataURL('image/jpeg', 0.85))
}
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('image load fail')) }
img.src = url
})
}
/** 프로필 아바타 — 이미지 있으면 사진, 없으면 이니셜 */
export function Avatar({ url, name, className = '' }: { url: string | null; name: string; className?: string }) {
return (
<span className={'avatar' + (className ? ' ' + className : '')}>
{url ? <img src={url} alt="" /> : (name.slice(0, 1) || 'ㅅ')}
</span>
)
}
import {
IcHome, IcSearch, IcUser, IcPlus, IcPlay, IcHeart, IcHeartLine,
IcComment, IcShare, IcLink, IcGrid, IcBack,
IcYoutube, IcInstagram, IcTiktok, IcUpload,
} from './icons'
const sc = (v: string): CSSProperties => ({ ['--sc']: v } as CSSProperties)
/* SNS 플랫폼 메타 (표시용) — 유튜브만 지원 */
export const PLATFORMS: { key: Platform; name: string; Icon: () => JSX.Element; hex: string }[] = [
{ key: 'youtube', name: '유튜브', Icon: IcYoutube, hex: '#ff0033' },
]
/* ============================ 로그인 모달 (카카오 전용) ============================ */
export function Login({ open, onClose, onKakao, busy }: {
open: boolean; onClose: () => void; onKakao: () => void; busy: boolean
}) {
if (!open) return null
return (
<div className="login" onClick={onClose}>
<div className="login-card" onClick={(e) => e.stopPropagation()}>
<button className="login-x" aria-label="닫기" onClick={onClose}></button>
<div className="login-brand"><b></b><span>SSULBOX</span></div>
<p className="login-tag"> <br />· </p>
<div className="login-actions">
<button className="kakao" disabled={busy} onClick={onKakao}>
<span className="ki"></span>{busy ? '잠시만요…' : '카카오톡으로 로그인'}
</button>
</div>
</div>
</div>
)
}
/* ============================ 좌측 사이드바 / 하단 탭바 ============================ */
export function Nav({ tab, onTab, nickname, avatar, onLogin, onLogout }: {
tab: Tab; onTab: (t: Tab | 'create') => void
nickname: string | null; avatar: string | null; onLogin: () => void; onLogout: () => void
}) {
return (
<nav className="nav">
<div className="brand"><b></b><span>SSULBOX</span></div>
<button data-tab="home" aria-current={tab === 'home'} onClick={() => onTab('home')}>
<span className="i"><IcHome /></span><span className="lbl"></span>
</button>
<button data-tab="search" aria-current={tab === 'search'} onClick={() => onTab('search')}>
<span className="i"><IcSearch /></span><span className="lbl"></span>
</button>
<button className="create" onClick={() => onTab('create')}>
<span className="plus"><IcPlus /></span><span className="lbl"></span>
</button>
<button data-tab="channel" aria-current={tab === 'channel'} onClick={() => onTab('channel')}>
<span className="i"><IcUser /></span><span className="lbl"> </span>
</button>
<div className="nav-foot">
{nickname ? (
<div className="nav-me">
<span className="nav-av">{avatar ? <img src={avatar} alt="" /> : nickname.slice(0, 1)}</span>
<span className="nav-nm">{nickname}</span>
<button className="nav-logout" onClick={onLogout}></button>
</div>
) : (
<button className="nav-login" onClick={onLogin}></button>
)}
</div>
</nav>
)
}
export function TopBar({ tab, nickname, onLogin }: {
tab: Tab; nickname: string | null; onLogin: () => void
}) {
if (tab === 'home')
return (
<header className="topbar">
<div className="wordmark"><b></b><span className="dot">SSULBOX</span></div>
<div className="actions">
{nickname
? <button aria-label="알림"><IcHeartLine /></button>
: <button className="tb-login" onClick={onLogin}></button>}
</div>
</header>
)
if (tab === 'search') return <header className="topbar"><h2></h2></header>
return (
<header className="topbar">
<h2>@{nickname ?? '게스트'}</h2>
<div className="actions">
{nickname
? <button aria-label="설정"></button>
: <button className="tb-login" onClick={onLogin}></button>}
</div>
</header>
)
}
/* ============================ 카드 / 피드 ============================ */
export function Card({ it, onOpen }: { it: FeedItem; onOpen: (id: string) => void }) {
const s = SCEN[it.sc]
return (
<button className="card" style={{ background: scGrad(it.sc) }} onClick={() => onOpen(it.id)}>
<div className="cover">
{it.video
? <video className="cov-vid" src={it.video + '#t=0.1'} muted playsInline preload="metadata" />
: <span className="emoji">{s.emoji}</span>}
</div>
<span className="badge"><i style={{ background: s.hex }} />{s.name}</span>
<span className="play-fab"><IcPlay /></span>
<div className="shade" />
<div className="meta">
<div className="title">{it.title}</div>
<div className="sub"><IcPlay />{fmt(it.views)} · {fmt(it.likes)}</div>
</div>
</button>
)
}
export function Feed({ items, onOpen }: { items: FeedItem[]; onOpen: (id: string) => void }) {
if (!items.length)
return (
<div className="empty">
.<br /> <b> </b> .
</div>
)
return <div className="grid">{items.map((it) => <Card key={it.id} it={it} onOpen={onOpen} />)}</div>
}
/* ============================ 화면: 홈 / 검색 / 채널 ============================ */
export function Home({ data, onOpen }: { data: FeedItem[]; onOpen: (id: string) => void }) {
return (
<>
<div className="sec-h"><h3></h3></div>
<Feed items={data} onOpen={onOpen} />
</>
)
}
export function Search({ data, onOpen }: { data: FeedItem[]; onOpen: (id: string) => void }) {
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<'all' | Scen>('all')
const chips: [('all' | Scen), string][] = [['all', '전체'], ['joseon', '조선'], ['samgukji', '삼국지'], ['greek', '그리스']]
let items = data.filter((it) => filter === 'all' || it.sc === filter)
if (query.trim()) items = items.filter((it) => it.title.includes(query.trim()))
return (
<>
<div className="searchbar">
<div className="searchbox">
<IcSearch />
<input placeholder="가게·인물·키워드 검색" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
</div>
<div className="chips">
{chips.map(([k, l]) => (
<button key={k} className="chip" aria-pressed={filter === k} onClick={() => setFilter(k)}>{l}</button>
))}
</div>
<Feed items={items} onOpen={onOpen} />
</>
)
}
/* ============================ 프로필 편집 시트 ============================ */
export function ProfileEdit({ open, onClose, me, onSaved, toast }: {
open: boolean; onClose: () => void; me: Me | null
onSaved: (me: Me) => void; toast: (m: string) => void
}) {
const [nickname, setNickname] = useState('')
const [bio, setBio] = useState('')
const [avatar, setAvatar] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open && me) { setNickname(me.nickname); setBio(me.bio ?? ''); setAvatar(me.profile_image_url) }
}, [open, me])
async function pick(e: ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
try { setAvatar(await fileToAvatar(f)) } catch { toast('이미지를 불러오지 못했어요.') }
}
async function save() {
if (!me) return
setBusy(true)
const body: ProfileBody = { nickname, bio }
if (avatar !== me.profile_image_url) body.profile_image_url = avatar ?? ''
const r = await updateProfile(body)
toast(r.message)
if (r.ok && r.me) { onSaved(r.me); onClose() }
setBusy(false)
}
return (
<section className={'sheet' + (open ? ' on' : '')} aria-hidden={!open}>
<div className="grab" />
<div className="sheet-h"><h3> </h3><button aria-label="닫기" onClick={onClose}></button></div>
<div className="sheet-body">
<div className="pe-avatar">
<Avatar url={avatar} name={nickname} className="lg" />
<div className="pe-av-btns">
<button onClick={() => fileRef.current?.click()}> </button>
{avatar && <button className="rm" onClick={() => setAvatar(null)}></button>}
</div>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={pick} />
</div>
<div className="field">
<label> <span className="lbl-sub">( )</span></label>
<div className="input"><input value={nickname} maxLength={20}
onChange={(e) => setNickname(e.target.value)} placeholder="채널 이름" /></div>
</div>
<div className="field">
<label> </label>
<textarea className="pe-bio" value={bio} maxLength={200} rows={3}
onChange={(e) => setBio(e.target.value)} placeholder="내 채널을 소개해보세요 (예: 성수동 골목 냉면집 사장 🍜)" />
<div className="hint" style={{ textAlign: 'right' }}>{bio.length}/200</div>
</div>
</div>
<div className="sheet-foot">
<button className="cta" disabled={busy || !nickname.trim()} onClick={save}>{busy ? '저장 중…' : '저장'}</button>
</div>
</section>
)
}
/* ============================ 크레딧 충전 시트 ============================ */
export function ChargeSheet({ open, onClose, onDone, toast }: {
open: boolean; onClose: () => void; onDone: (credits: number) => void; toast: (m: string) => void
}) {
const [products, setProducts] = useState<Product[]>([])
const [busy, setBusy] = useState<string | null>(null)
useEffect(() => { if (open) getProducts().then(setProducts) }, [open])
async function buy(p: Product) {
setBusy(p.id)
try {
const r = await purchaseProduct(p.id)
toast(r.message)
if (r.ok) { if (typeof r.credits === 'number') onDone(r.credits); onClose() }
} finally { setBusy(null) }
}
return (
<section className={'sheet' + (open ? ' on' : '')} aria-hidden={!open}>
<div className="grab" />
<div className="sheet-h"><h3> </h3><button aria-label="닫기" onClick={onClose}></button></div>
<div className="sheet-body">
<div className="step-label"> <b>1 = 1</b> · </div>
<div className="prods">
{products.map((p) => (
<button key={p.id} className={'prod' + (p.best ? ' best' : '')} disabled={busy === p.id} onClick={() => buy(p)}>
{p.best && <span className="prod-tag"></span>}
<span className="prod-cr"><b>{p.credits + p.bonus}</b></span>
{p.bonus > 0 && <span className="prod-bonus"> {p.credits} + {p.bonus}</span>}
<span className="prod-name">{p.label}</span>
<span className="prod-price">{busy === p.id ? '처리 중…' : '₩' + p.price.toLocaleString()}</span>
</button>
))}
</div>
<div className="hint">(PG) .</div>
</div>
</section>
)
}
/* ============================ SNS 연동 패널 ============================ */
export function SnsPanel({ toast }: { toast: (m: string) => void }) {
const [accounts, setAccounts] = useState<SocialAccount[]>([])
const [enabled, setEnabled] = useState<Partial<Record<Platform, boolean>>>({})
const [busy, setBusy] = useState<Platform | null>(null)
const connected = new Set(accounts.map((a) => a.platform))
const refresh = () => listSocial().then(setAccounts)
useEffect(() => { refresh(); socialConfig().then(setEnabled) }, [])
async function toggle(p: Platform) {
setBusy(p)
try {
if (connected.has(p)) {
if (await disconnectSocial(p)) { toast('연동을 해제했어요.'); await refresh() }
else toast('해제에 실패했어요.')
} else {
const r = await connectSocial(p)
if (r.authUrl) { window.location.href = r.authUrl; return } // Google 동의 페이지로 이동
toast(r.message)
}
} finally { setBusy(null) }
}
return (
<div className="sns-card">
<div className="sns-h"><span>SNS </span><em> </em></div>
<div className="sns-list">
{PLATFORMS.map(({ key, name, Icon, hex }) => {
const on = connected.has(key)
const ready = enabled[key] ?? false
return (
<div className="sns-row" key={key}>
<span className="sns-ic" style={sc(hex)}><Icon /></span>
<span className="sns-name">{name}{on && <i className="sns-dot" />}
{!ready && !on && <em className="sns-soon"> </em>}
</span>
<button className={'sns-btn' + (on ? ' on' : '')} disabled={busy === key} onClick={() => toggle(key)}>
{busy === key ? '…' : on ? '연동됨' : '연동'}
</button>
</div>
)
})}
</div>
</div>
)
}
export function Channel({ data, credits, nickname, avatar, bio, onOpen, toast, onLogout, onCharge, onEditProfile }: {
data: FeedItem[]; credits: number; nickname: string; avatar: string | null; bio: string | null
onOpen: (id: string) => void; toast: (m: string) => void; onLogout: () => void
onCharge: () => void; onEditProfile: () => void
}) {
const perVideo = 1
const canMake = Math.floor(credits / perVideo)
return (
<div className="ch-wrap">
<div className="profile">
<div className="row">
<Avatar url={avatar} name={nickname} />
<div className="stats">
<div><b>{data.length}</b><span> </span></div>
<div><b>{fmt(credits)}</b><span> </span></div>
<div><b>{data.length}</b><span> </span></div>
</div>
</div>
<div className="bio">
<div className="name">{nickname}</div>
{bio ? <div className="bio-txt">{bio}</div> : <div className="bio-txt muted"> </div>}
</div>
<div className="token-card">
<div className="tk-left">
<span> </span>
<b>{fmt(credits)}</b>
<em> {canMake} ( {perVideo})</em>
</div>
<button className="tk-btn" onClick={onCharge}></button>
</div>
<div className="pf-actions"><button onClick={onEditProfile}> </button><button onClick={onLogout}></button></div>
<SnsPanel toast={toast} />
<div className="pf-tabs"><button aria-selected="true"><IcGrid /></button></div>
</div>
<Feed items={data} onOpen={onOpen} />
</div>
)
}
/* ============================ 만들기 시트 ============================ */
type Phase = 'pick' | 'form' | 'making'
export function CreateSheet({ open, onClose, onCreated }: {
open: boolean
onClose: () => void
onCreated: () => Promise<void> | void
}) {
const [phase, setPhase] = useState<Phase>('pick')
const [scn, setScn] = useState<Scen | null>(null)
const [input, setInput] = useState('')
const [status, setStatus] = useState('')
const [log, setLog] = useState<string[]>([])
const [err, setErr] = useState('')
const [results, setResults] = useState<PlaceItem[]>([])
const [selected, setSelected] = useState<PlaceItem | null>(null)
const [searching, setSearching] = useState(false)
const [searched, setSearched] = useState(false)
const timer = useRef<number | undefined>(undefined)
useEffect(() => {
if (open) {
setPhase('pick'); setScn(null); setInput('')
setStatus(''); setLog([]); setErr('')
setResults([]); setSelected(null); setSearching(false); setSearched(false)
}
return () => { if (timer.current) window.clearTimeout(timer.current) }
}, [open])
const isUrl = (s: string) => /naver\.me|map\.naver|place\.naver|^https?:\/\//i.test(s.trim())
async function runSearch() {
const q = input.trim()
if (q.length < 2 || isUrl(q)) return
setSearching(true); setSearched(false); setResults([]); setSelected(null)
try {
const items = await searchPlace(q)
setResults(items); setSearched(true)
} catch { setResults([]); setSearched(true) } finally { setSearching(false) }
}
// 링크(place_url)가 확정됐거나(=선택) URL을 직접 붙여넣으면 생성 가능
const canSubmit = !!selected || isUrl(input)
const s = scn ? SCEN[scn] : null
async function poll(id: string) {
try {
const j = await getJob(id)
setLog(j.log || [])
if (j.status === 'done') { setStatus('완성!'); await onCreated(); onClose(); return }
if (j.status === 'error') { setErr(j.error || '생성 중 오류가 발생했어요.'); return }
const names = ['대본 생성', '스토리보드', '이미지·음성', '영상 합성']
setStatus(j.status === 'queued' ? '대기 중…' : `${names[Math.max(0, (j.step || 1) - 1)]}… (${j.step || 1}/4)`)
} catch {
setStatus('상태 확인 중…')
}
timer.current = window.setTimeout(() => poll(id), 3000)
}
async function submit() {
if (!scn) return
// 선택된 가게가 있으면 그 place_url 로 정확히 크롤링. 아니면 붙여넣은 URL 그대로.
const payload = selected ? selected.place_url : input.trim()
setPhase('making'); setErr(''); setStatus('생성 준비 중…')
try {
const res = await createJob({ scenario: scn, input: payload })
poll(res.id)
} catch (e) {
setErr(e instanceof Error ? e.message : '생성 요청에 실패했어요.')
}
}
return (
<section className={'sheet' + (open ? ' on' : '')} aria-hidden={!open}>
<div className="grab" />
<div className="sheet-h">
<h3> </h3>
<button aria-label="닫기" onClick={onClose}></button>
</div>
<div className="sheet-body">
{phase === 'pick' && (
<>
<div className="step-label"><b>1</b> · ?</div>
<div className="scn-list">
{SCEN_KEYS.map((k) => {
const si = SCEN[k]
return (
<button key={k} className="scn" aria-pressed={scn === k} style={sc(si.hex)} onClick={() => setScn(k)}>
<span className="ic" style={sc(si.hex)}>{si.emoji}</span>
<span className="tx"><span className="t">{si.name}</span><span className="d">{si.desc}</span></span>
<span className="ck" />
</button>
)
})}
</div>
</>
)}
{phase === 'form' && s && (
<>
<div className="step-label"><b>2</b> · <span style={{ color: s.hex }}>{s.emoji} {s.name}</span> </div>
<div className="field">
<label> <span className="lbl-sub">( )</span></label>
<div className="input search">
<IcSearch />
<input
placeholder="가게 이름 (예: 골목냉면) · 또는 네이버 링크 붙여넣기" value={input} autoFocus
onChange={(e) => { setInput(e.target.value); if (selected) { setSelected(null) } }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch() } }}
/>
<button type="button" className="srch-btn" disabled={searching || input.trim().length < 2 || isUrl(input)}
onClick={runSearch}>{searching ? '검색 중' : '검색'}</button>
</div>
{selected ? (
<div className="picked">
<div className="pk-body">
<b>{selected.title}</b>
<span>{selected.category ? selected.category + ' · ' : ''}{selected.address || selected.roadAddress}</span>
</div>
<button type="button" className="pk-change" onClick={() => { setSelected(null); runSearch() }}></button>
</div>
) : isUrl(input) ? (
<div className="hint ok"> .</div>
) : searching ? (
<div className="place-loading"><div className="spin sm" /> ( )</div>
) : results.length > 0 ? (
<ul className="places">
{results.map((p, i) => (
<li key={i}>
<button type="button" onClick={() => setSelected(p)}>
<b>{p.title}</b>
<span>{p.category ? p.category + ' · ' : ''}{p.address || p.roadAddress}</span>
</button>
</li>
))}
</ul>
) : searched ? (
<div className="hint warn"> . .</div>
) : (
<div className="hint"> AI · .</div>
)}
</div>
</>
)}
{phase === 'making' && s && (
<div className="making" style={sc(s.hex)}>
{!err ? (
<>
<div className="spin" />
<h4>{s.emoji} {s.name} </h4>
<p>{status}<br />AI . 2~4 .</p>
<div className="job">
<div>· &nbsp;<b>{s.name}</b></div>
<div>· &nbsp;<b>{selected ? selected.title : input}</b></div>
{selected && <div>· &nbsp;<b>{selected.address || selected.roadAddress}</b></div>}
{log.length > 0 && <div style={{ marginTop: 8, whiteSpace: 'pre-wrap' }}>{log.slice(-4).join('\n')}</div>}
</div>
</>
) : (
<>
<h4> </h4>
<p>{err}</p>
</>
)}
</div>
)}
</div>
<div className="sheet-foot">
{phase === 'pick' && <button className="cta" disabled={!scn} onClick={() => setPhase('form')}></button>}
{phase === 'form' && <button className="cta" disabled={!canSubmit} onClick={submit}> </button>}
{phase === 'making' && <button className="cta" disabled={!err} onClick={onClose}>{err ? '닫기' : '생성 중…'}</button>}
</div>
</section>
)
}
/* ============================ 릴스형 뷰어 (좋아요·댓글 연동) ============================ */
export function Viewer({ item, onClose, onLike, toast }: {
item: FeedItem | null; onClose: () => void; onLike: (id: string) => void; toast: (m: string) => void
}) {
const s = item ? SCEN[item.sc] : null
const [comments, setComments] = useState<CommentNode[]>([])
const [open, setOpen] = useState(false)
const [text, setText] = useState('')
const [shareOpen, setShareOpen] = useState(false)
const [accounts, setAccounts] = useState<SocialAccount[]>([])
const [upBusy, setUpBusy] = useState<Platform | null>(null)
useEffect(() => {
setOpen(false); setText(''); setShareOpen(false)
if (item) listComments(item.id).then(setComments)
else setComments([])
}, [item?.id])
function openShare() {
setShareOpen(true)
listSocial().then(setAccounts)
}
async function upload(p: Platform) {
if (!item) return
setUpBusy(p)
try {
const r = await uploadToSocial(p, item.id)
toast(r.pending ? '🔑 곧 지원돼요 — 플랫폼 키 설정 후 자동 활성화됩니다.' : r.message)
if (r.ok) setShareOpen(false)
} finally { setUpBusy(null) }
}
async function send() {
if (!item || !text.trim()) return
await addComment(item.id, text.trim())
setText('')
setComments(await listComments(item.id))
}
const commentCount = comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
return (
<div className={'viewer' + (item ? ' on' : '')} onClick={onClose}>
{item && s && (
<>
<div className="vframe" onClick={(e) => e.stopPropagation()}>
<div className="vtop">
<button aria-label="뒤로가기" onClick={onClose}><IcBack /></button>
<h2></h2>
</div>
<div className="vstage" style={{ background: scGrad(item.sc) }}>
{item.video ? (
<video src={item.video} playsInline controls autoPlay />
) : (
<>
<div className="cover"><span className="emoji">{s.emoji}</span></div>
<button className="vbig" aria-label="재생"><IcPlay /></button>
</>
)}
</div>
<div className="vrail">
<button className={item.liked ? 'liked' : ''} onClick={() => onLike(item.id)}>
<IcHeart /><span>{fmt(item.likes)}</span>
</button>
<button onClick={() => setOpen(true)}><IcComment /><span>{fmt(item.comments ?? commentCount)}</span></button>
<button className={shareOpen ? 'liked' : ''} onClick={openShare}><IcUpload /><span></span></button>
</div>
<div className="vbottom">
<span className="badge vbadge"><i style={{ background: s.hex }} />{s.name}</span>
<div className="vt">{item.title}</div>
<div className="vn">{s.desc} · </div>
</div>
{open && (
<div className="cmts">
<div className="cmts-h"><span> {commentCount}</span><button aria-label="닫기" onClick={() => setOpen(false)}></button></div>
<div className="cmts-list">
{comments.length === 0 && <div className="cmts-empty"> .</div>}
{comments.map((c) => (
<div className="cmt" key={c.id}>
<div className="who">{c.nickname}{c.is_mine ? ' · 나' : ''}</div>
<div className="bd">{c.body ?? '삭제된 댓글'}</div>
{c.replies?.map((r) => (
<div className="rep" key={r.id}>
<div className="who">{r.nickname}{r.is_mine ? ' · 나' : ''}</div>
<div className="bd">{r.body ?? '삭제된 댓글'}</div>
</div>
))}
</div>
))}
</div>
<div className="cmts-in">
<input
placeholder="댓글 달기…" value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') send() }}
/>
<button disabled={!text.trim()} onClick={send}></button>
</div>
</div>
)}
{shareOpen && (
<div className="cmts share">
<div className="cmts-h"><span>SNS </span><button aria-label="닫기" onClick={() => setShareOpen(false)}></button></div>
<div className="share-list">
{PLATFORMS.map(({ key, name, Icon, hex }) => {
const linked = accounts.some((a) => a.platform === key)
return (
<button key={key} className="share-row" disabled={upBusy === key} onClick={() => upload(key)}>
<span className="sns-ic" style={sc(hex)}><Icon /></span>
<span className="share-name">{name}</span>
<span className="share-state">{upBusy === key ? '업로드 중…' : linked ? '업로드' : '연동 필요'}</span>
</button>
)
})}
</div>
<div className="share-hint"> . <b> SNS </b>.</div>
</div>
)}
</div>
</>
)}
</div>
)
}

53
frontend/src/data.ts Normal file
View File

@ -0,0 +1,53 @@
export type Scen = 'joseon' | 'samgukji' | 'greek'
export type Tab = 'home' | 'search' | 'channel'
export interface FeedItem {
id: string
sc: Scen
title: string
views: number
likes: number
video: string | null
liked?: boolean
comments?: number
mine?: boolean
}
export interface ScenInfo {
name: string
emoji: string
hex: string
desc: string
g: [string, string]
}
export const SCEN: Record<Scen, ScenInfo> = {
joseon: { name: '조선왕', emoji: '👑', hex: '#3b4cc0', desc: '조선 27대 왕들의 실화 썰', g: ['#4054d6', '#8a3ffb'] },
samgukji: { name: '삼국지', emoji: '⚔️', hex: '#d63a2f', desc: '위·촉·오 영웅 28인의 야사', g: ['#e0402f', '#f0872b'] },
greek: { name: '그리스·로마신화', emoji: '🏛️', hex: '#0a8f6b', desc: '올림포스 신·영웅들의 전설', g: ['#0a9e78', '#3fb6d6'] },
}
export const SCEN_KEYS = Object.keys(SCEN) as Scen[]
export const scGrad = (s: Scen) => `linear-gradient(150deg,${SCEN[s].g[0]} 0%,${SCEN[s].g[1]} 100%)`
export const fmt = (n: number) =>
n >= 10000 ? (n / 10000).toFixed(1).replace(/\.0$/, '') + '만'
: n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + '천'
: '' + n
// 백엔드 미연결 시 폴백용 목업
export const SAMPLE: FeedItem[] = [
{ id: 'j1', sc: 'joseon', title: '세종대왕도 극찬한 그 집 평양냉면', views: 128400, likes: 9210, video: null },
{ id: 's1', sc: 'samgukji', title: '조조가 천하통일보다 원했던 족발', views: 88700, likes: 7640, video: null },
{ id: 'g1', sc: 'greek', title: '제우스가 벼락 맞은 사연: 노천탕 후기', views: 203100, likes: 15400, video: null },
{ id: 'j2', sc: 'joseon', title: '광해군의 심야 야식 습격 사건', views: 45200, likes: 3110, video: null },
{ id: 'g2', sc: 'greek', title: '아프로디테 인생 미용실 등극썰', views: 67800, likes: 5980, video: null },
{ id: 's2', sc: 'samgukji', title: '제갈량의 갓생 아침 루틴 (feat.카페)', views: 51900, likes: 4020, video: null },
{ id: 'j3', sc: 'joseon', title: '정조의 1일 1치킨 실록 기록', views: 39400, likes: 2870, video: null },
{ id: 's3', sc: 'samgukji', title: '여포도 줄 서서 먹는다는 그 국밥', views: 73300, likes: 6100, video: null },
{ id: 'g3', sc: 'greek', title: '헤라클레스 12번째 과업은 배달이었다', views: 94600, likes: 8250, video: null },
{ id: 'j4', sc: 'joseon', title: '태종의 부동산 보는 눈 (평창 펜션편)', views: 28800, likes: 1990, video: null },
{ id: 'g4', sc: 'greek', title: '하데스가 지상에서 딱 하나 그리워한 것', views: 61200, likes: 5340, video: null },
{ id: 's4', sc: 'samgukji', title: '손권이 강동에서 제일 아낀 디저트', views: 33500, likes: 2410, video: null },
]

47
frontend/src/icons.tsx Normal file
View File

@ -0,0 +1,47 @@
// 프로토타입과 동일한 SVG. 색/굵기는 대부분 CSS(컨텍스트)로 제어된다.
export const IcHome = () => (
<svg viewBox="0 0 24 24"><path d="M3 10.5 12 3l9 7.5V21a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" /></svg>
)
export const IcSearch = () => (
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.2-3.2" /></svg>
)
export const IcUser = () => (
<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7" /></svg>
)
export const IcPlus = () => (
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
)
export const IcPlay = () => (
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
)
export const IcHeart = () => (
<svg viewBox="0 0 24 24"><path d="M12 21s-8-5.3-8-11a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 20 10c0 5.7-8 11-8 11z" /></svg>
)
export const IcHeartLine = IcHeart
export const IcComment = () => (
<svg viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-5.2A8 8 0 1 1 21 12z" fill="none" stroke="#fff" strokeWidth="1.8" /></svg>
)
export const IcShare = () => (
<svg viewBox="0 0 24 24"><path d="M22 3 11 14M22 3l-7 19-4-8-8-4z" fill="none" stroke="#fff" strokeWidth="1.8" /></svg>
)
export const IcLink = () => (
<svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1m0 8a5 5 0 0 1-7 0 5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 0" fill="none" stroke="currentColor" strokeWidth="1.7" /></svg>
)
export const IcGrid = () => (
<svg viewBox="0 0 24 24"><path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z" /></svg>
)
export const IcBack = () => (
<svg viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7" fill="none" stroke="#fff" strokeWidth="2" /></svg>
)
export const IcYoutube = () => (
<svg viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="4" /><path d="M10 9l5 3-5 3z" fill="#fff" /></svg>
)
export const IcInstagram = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="3" y="3" width="18" height="18" rx="5" /><circle cx="12" cy="12" r="4" /><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" /></svg>
)
export const IcTiktok = () => (
<svg viewBox="0 0 24 24"><path d="M14 3c.3 2.3 1.8 4 4 4.3V10c-1.5 0-2.9-.5-4-1.3V15a5 5 0 1 1-5-5v2.6a2.4 2.4 0 1 0 2.4 2.4V3z" /></svg>
)
export const IcUpload = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8"><path d="M12 15V4M8 8l4-4 4 4M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3" /></svg>
)

457
frontend/src/index.css Normal file
View File

@ -0,0 +1,457 @@
@import "tailwindcss";
/* ============================ 디자인 변수 ============================ */
:root{
--bg:#ffffff;
--surface:#ffffff;
--surface-2:#f6f4f1;
--fg:#17140f;
--muted:#8b857c;
--faint:#b9b2a8;
--border:#e9e4dc;
--elev:0 2px 14px rgba(30,24,14,.10);
--joseon:#3b4cc0;
--samgukji:#d63a2f;
--greek:#0a8f6b;
--greek-2:#e0a91b;
--ig:linear-gradient(45deg,#f9a03f 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%);
--nav-h:58px;
--top-h:52px;
--r:14px;
font-family:"Apple SD Gothic Neo","Pretendard","Malgun Gothic","Noto Sans KR",system-ui,-apple-system,sans-serif;
}
@media (prefers-color-scheme:dark){
:root{
--bg:#000000; --surface:#0c0b0a; --surface-2:#161412;
--fg:#f4f1ec; --muted:#9a938a; --faint:#5c564e; --border:#262320;
--elev:0 2px 16px rgba(0,0,0,.6);
}
}
:root[data-theme="dark"]{
--bg:#000000; --surface:#0c0b0a; --surface-2:#161412;
--fg:#f4f1ec; --muted:#9a938a; --faint:#5c564e; --border:#262320;
--elev:0 2px 16px rgba(0,0,0,.6);
}
:root[data-theme="light"]{
--bg:#ffffff; --surface:#ffffff; --surface-2:#f6f4f1;
--fg:#17140f; --muted:#8b857c; --faint:#b9b2a8; --border:#e9e4dc;
--elev:0 2px 14px rgba(30,24,14,.10);
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{
background:var(--bg);
color:var(--fg);
overscroll-behavior:none;
}
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer}
img{display:block;max-width:100%}
ul{list-style:none}
/* ============================ 앱 셸 (반응형) ============================ */
#app{
position:relative;
width:100%;
height:100dvh;
background:var(--bg);
display:flex;flex-direction:column;
overflow:hidden;
}
.main{flex:1 1 auto;display:flex;flex-direction:column;min-height:0;min-width:0;position:relative}
.brand{display:none}
/* ============================ 상단바 ============================ */
.topbar{
height:var(--top-h);flex:0 0 auto;
display:flex;align-items:center;justify-content:space-between;
padding:0 16px;
border-bottom:1px solid var(--border);
background:color-mix(in srgb,var(--bg) 88%,transparent);
backdrop-filter:saturate(1.4) blur(8px);
position:relative;z-index:5;
}
.wordmark{font-size:24px;font-weight:800;letter-spacing:-.02em;display:flex;align-items:center;gap:6px}
.wordmark b{background:var(--ig);-webkit-background-clip:text;background-clip:text;color:transparent}
.wordmark .dot{font-size:13px;color:var(--muted);font-weight:600;letter-spacing:0}
.topbar .actions{display:flex;gap:18px;align-items:center}
.topbar .actions button{color:var(--fg);display:grid;place-items:center}
.topbar .actions svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.8}
.topbar h2{font-size:16px;font-weight:700}
/* ============================ 스크린 ============================ */
.screen{
flex:1 1 auto;overflow-y:auto;overflow-x:hidden;
-webkit-overflow-scrolling:touch;
padding-bottom:calc(var(--nav-h) + env(safe-area-inset-bottom) + 8px);
}
.screen::-webkit-scrollbar{width:0}
/* ------ 피드 그리드 (모바일 2열) ------ */
.grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px}
.card{
position:relative;aspect-ratio:9/16;border-radius:var(--r);overflow:hidden;
background:#222;isolation:isolate;transition:transform .12s ease;
}
.card:active{transform:scale(.975)}
.card .cover{position:absolute;inset:0;display:grid;place-items:center}
.card .cov-vid{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.card .emoji{font-size:58px;filter:drop-shadow(0 3px 8px rgba(0,0,0,.35));opacity:.92}
.card .shade{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 42%,rgba(0,0,0,.72) 100%)}
.card .meta{position:absolute;left:9px;right:9px;bottom:8px;z-index:2}
.card .title{
color:#fff;font-size:12.5px;font-weight:700;line-height:1.28;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
text-shadow:0 1px 3px rgba(0,0,0,.5);
}
.card .sub{display:flex;align-items:center;gap:4px;margin-top:5px;color:rgba(255,255,255,.9);font-size:11px;font-variant-numeric:tabular-nums}
.card .sub svg{width:12px;height:12px;fill:currentColor}
.badge{
position:absolute;top:8px;left:8px;z-index:2;
display:inline-flex;align-items:center;gap:4px;
padding:3px 8px;border-radius:999px;
font-size:10.5px;font-weight:700;color:#fff;
background:rgba(0,0,0,.32);backdrop-filter:blur(3px);
}
.badge i{width:7px;height:7px;border-radius:50%;display:inline-block}
.play-fab{position:absolute;top:8px;right:8px;z-index:2;width:24px;height:24px;border-radius:50%;
background:rgba(0,0,0,.35);backdrop-filter:blur(3px);display:grid;place-items:center}
.play-fab svg{width:12px;height:12px;fill:#fff;margin-left:1px}
/* ------ 섹션 헤더 ------ */
.sec-h{display:flex;align-items:baseline;justify-content:space-between;padding:16px 14px 4px}
.sec-h h3{font-size:16px;font-weight:800;letter-spacing:-.01em}
.sec-h span{font-size:12px;color:var(--muted)}
/* ------ 검색 ------ */
.searchbar{position:sticky;top:0;z-index:4;background:var(--bg);padding:10px 12px;border-bottom:1px solid var(--border)}
.searchbox{display:flex;align-items:center;gap:8px;background:var(--surface-2);border-radius:11px;padding:9px 12px}
.searchbox svg{width:16px;height:16px;fill:none;stroke:var(--muted);stroke-width:2;flex:0 0 auto}
.searchbox input{flex:1;background:none;border:none;outline:none;color:var(--fg);font-size:14px}
.searchbox input::placeholder{color:var(--faint)}
.chips{display:flex;gap:8px;overflow-x:auto;padding:10px 12px;scrollbar-width:none}
.chips::-webkit-scrollbar{display:none}
.chip{flex:0 0 auto;padding:7px 13px;border-radius:999px;background:var(--surface-2);
font-size:13px;font-weight:600;color:var(--muted);border:1px solid transparent;transition:.15s}
.chip[aria-pressed="true"]{background:var(--fg);color:var(--bg)}
.empty{padding:60px 24px;text-align:center;color:var(--muted);font-size:14px;line-height:1.6}
/* ------ 내 채널 ------ */
.profile{padding:20px 16px 6px}
.profile .row{display:flex;align-items:center;gap:18px}
.avatar{width:74px;height:74px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;overflow:hidden;
color:#fff;font-size:30px;font-weight:800;background:var(--ig)}
.avatar img{width:100%;height:100%;object-fit:cover;display:block}
.avatar.lg{width:92px;height:92px;font-size:38px}
.nav-av{overflow:hidden}
.nav-av img{width:100%;height:100%;object-fit:cover;display:block}
.stats{flex:1;display:flex;justify-content:space-around;text-align:center}
.stats b{display:block;font-size:18px;font-weight:800;font-variant-numeric:tabular-nums}
.stats span{font-size:12px;color:var(--muted)}
.bio{margin-top:12px}
.bio .name{font-weight:700;font-size:14px}
.bio .bio-txt{font-size:13px;color:var(--fg);margin-top:3px;line-height:1.45;white-space:pre-wrap}
.bio .bio-txt.muted{color:var(--faint)}
/* 프로필 편집 시트 */
.pe-avatar{display:flex;flex-direction:column;align-items:center;gap:10px;padding:6px 0 14px}
.pe-av-btns{display:flex;gap:8px}
.pe-av-btns button{font-size:13px;font-weight:700;color:var(--samgukji);padding:6px 12px;border-radius:9px;background:var(--surface-2)}
.pe-av-btns button.rm{color:var(--muted)}
.pe-bio{width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:12px;padding:11px 13px;
color:var(--fg);outline:none;font-size:14px;font-family:inherit;resize:none;line-height:1.5}
.pe-bio:focus{border-color:var(--samgukji)}
.token-card{display:flex;align-items:center;justify-content:space-between;gap:12px;
margin:14px 0 2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border);
background:linear-gradient(135deg,rgba(220,39,67,.06),rgba(188,24,136,.06))}
.token-card .tk-left{display:flex;flex-direction:column;gap:1px;min-width:0}
.token-card .tk-left span{font-size:11.5px;color:var(--muted);font-weight:600}
.token-card .tk-left b{font-size:23px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.15}
.token-card .tk-left em{font-size:11px;color:var(--muted);font-style:normal;margin-top:2px}
.token-card .tk-btn{flex:0 0 auto;padding:11px 20px;border-radius:11px;color:#fff;font-weight:800;font-size:13px;
background:var(--ig);box-shadow:0 4px 12px rgba(220,39,67,.28)}
.pf-actions{display:flex;gap:8px;margin:12px 0 4px}
.pf-actions button{flex:1;padding:8px;border-radius:9px;background:var(--surface-2);font-size:13px;font-weight:700}
.pf-tabs{display:flex;border-top:1px solid var(--border);margin-top:12px}
.pf-tabs button{flex:1;padding:11px;display:grid;place-items:center;color:var(--faint);border-bottom:2px solid transparent}
.pf-tabs button[aria-selected="true"]{color:var(--fg);border-bottom-color:var(--fg)}
.pf-tabs svg{width:22px;height:22px;fill:currentColor}
/* ============================ 하단 탭바 ============================ */
.nav{
position:absolute;left:0;right:0;bottom:0;z-index:20;
height:calc(var(--nav-h) + env(safe-area-inset-bottom));
padding-bottom:env(safe-area-inset-bottom);
display:flex;align-items:stretch;
background:color-mix(in srgb,var(--bg) 92%,transparent);
backdrop-filter:saturate(1.4) blur(10px);
border-top:1px solid var(--border);
}
.nav button{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:var(--muted)}
.nav button .lbl{font-size:10px;font-weight:600}
.nav button svg{width:25px;height:25px;fill:none;stroke:currentColor;stroke-width:1.9}
.nav button[aria-current="true"]{color:var(--fg)}
.nav button[aria-current="true"] svg{fill:currentColor;stroke:currentColor}
.nav .create{flex:0 0 64px;position:relative}
.nav .create .plus{
width:44px;height:32px;border-radius:11px;background:var(--ig);
display:grid;place-items:center;box-shadow:0 4px 12px rgba(220,39,67,.35);
}
.nav .create .plus svg{width:22px;height:22px;stroke:#fff;stroke-width:2.4;fill:none}
.nav .create .lbl{color:var(--fg)}
/* ============================ 만들기 바텀시트 ============================ */
.scrim{position:absolute;inset:0;z-index:40;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .22s}
.scrim.on{opacity:1;pointer-events:auto}
.sheet{
position:absolute;left:0;right:0;bottom:0;z-index:50;
background:var(--surface);border-radius:22px 22px 0 0;
transform:translateY(101%);transition:transform .28s cubic-bezier(.22,1,.36,1);
max-height:92%;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom);
box-shadow:0 -8px 40px rgba(0,0,0,.35);
pointer-events:none; /* 닫혔을 땐 뒤 콘텐츠 클릭을 가로막지 않음 */
}
.sheet.on{transform:translateY(0);pointer-events:auto}
.sheet .grab{width:38px;height:4px;border-radius:2px;background:var(--faint);margin:9px auto 4px}
.sheet-h{display:flex;align-items:center;justify-content:space-between;padding:6px 16px 10px;border-bottom:1px solid var(--border)}
.sheet-h h3{font-size:17px;font-weight:800}
.sheet-h button{color:var(--muted);font-size:22px;line-height:1;padding:4px}
.sheet-body{overflow-y:auto;padding:16px}
.step-label{font-size:13px;font-weight:700;color:var(--muted);margin:2px 2px 10px}
.step-label b{color:var(--fg)}
.scn-list{display:flex;flex-direction:column;gap:10px}
.scn{
display:flex;align-items:center;gap:14px;padding:14px;border-radius:16px;
border:1.5px solid var(--border);background:var(--surface);text-align:left;transition:.14s;width:100%;
}
.scn:active{transform:scale(.99)}
.scn[aria-pressed="true"]{border-color:var(--sc);background:color-mix(in srgb,var(--sc) 8%,var(--surface))}
.scn .ic{width:48px;height:48px;border-radius:13px;display:grid;place-items:center;font-size:26px;flex:0 0 auto;
background:color-mix(in srgb,var(--sc) 15%,var(--surface))}
.scn .tx{flex:1}
.scn .tx .t{font-weight:800;font-size:15px}
.scn .tx .d{font-size:12.5px;color:var(--muted);margin-top:2px;line-height:1.4}
.scn .ck{width:22px;height:22px;border-radius:50%;border:2px solid var(--border);flex:0 0 auto;display:grid;place-items:center}
.scn[aria-pressed="true"] .ck{background:var(--sc);border-color:var(--sc)}
.scn[aria-pressed="true"] .ck::after{content:"";width:7px;height:11px;border:2.5px solid #fff;border-top:0;border-left:0;transform:rotate(42deg) translateY(-1px)}
.field{margin-top:20px}
.field > label{display:block;font-size:13px;font-weight:700;margin-bottom:8px}
.input{display:flex;align-items:center;gap:8px;background:var(--surface-2);border-radius:12px;padding:12px 14px;border:1.5px solid transparent}
.input:focus-within{border-color:var(--fg)}
.input svg{width:17px;height:17px;color:var(--muted);flex:0 0 auto}
.input input{flex:1;background:none;border:none;outline:none;color:var(--fg);font-size:15px}
.input input::placeholder{color:var(--faint)}
.hint{font-size:11.5px;color:var(--muted);margin-top:7px;line-height:1.5;padding:0 2px}
.sheet-foot{padding:12px 16px calc(14px + env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--surface)}
.cta{width:100%;padding:15px;border-radius:14px;font-size:15px;font-weight:800;color:#fff;background:var(--ig);
transition:.15s;box-shadow:0 6px 18px rgba(220,39,67,.32)}
.cta:disabled{background:var(--surface-2);color:var(--faint);box-shadow:none;cursor:not-allowed}
/* 생성중 상태 */
.making{padding:30px 20px;text-align:center}
.making .spin{width:52px;height:52px;border-radius:50%;border:4px solid var(--surface-2);border-top-color:var(--sc,#dc2743);margin:0 auto 18px;animation:sp 1s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
.making h4{font-size:17px;font-weight:800}
.making p{font-size:13px;color:var(--muted);margin-top:8px;line-height:1.6}
.making .job{margin-top:16px;background:var(--surface-2);border-radius:12px;padding:12px 14px;font-size:12.5px;text-align:left;color:var(--muted);line-height:1.7}
.making .job b{color:var(--fg)}
/* ============================ 릴스형 뷰어 ============================ */
.viewer{position:absolute;inset:0;z-index:60;background:#000;display:none;flex-direction:column}
.viewer.on{display:flex}
.viewer .vtop{position:absolute;top:0;left:0;right:0;z-index:3;display:flex;align-items:center;gap:14px;
padding:14px 16px;padding-top:calc(14px + env(safe-area-inset-top));
background:linear-gradient(180deg,rgba(0,0,0,.5),transparent)}
.viewer .vtop button{color:#fff;display:flex;align-items:center;justify-content:center;
width:38px;height:38px;border-radius:50%;background:rgba(0,0,0,.4);flex:0 0 auto}
.viewer .vtop button svg{width:22px;height:22px}
.viewer .vtop h2{color:#fff;font-size:16px;font-weight:700}
.vframe{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
.vstage{flex:1;position:relative;display:grid;place-items:center;overflow:hidden}
.vstage .cover{position:absolute;inset:0;display:grid;place-items:center}
.vstage .cover .emoji{font-size:120px;opacity:.9;filter:drop-shadow(0 6px 20px rgba(0,0,0,.5))}
.vstage video{width:100%;height:100%;object-fit:contain;background:#000}
.vbig{position:relative;z-index:2;width:74px;height:74px;border-radius:50%;background:rgba(255,255,255,.14);
backdrop-filter:blur(4px);display:grid;place-items:center;border:1.5px solid rgba(255,255,255,.5)}
.vbig svg{width:30px;height:30px;fill:#fff;margin-left:4px}
.vrail{position:absolute;right:10px;bottom:120px;z-index:3;display:flex;flex-direction:column;gap:20px;align-items:center}
.vrail button{color:#fff;display:flex;flex-direction:column;align-items:center;gap:4px;font-size:11px;font-weight:600;font-variant-numeric:tabular-nums}
.vrail svg{width:29px;height:29px;fill:#fff}
.vbottom{position:absolute;left:0;right:64px;bottom:0;z-index:3;padding:16px;
padding-bottom:calc(20px + env(safe-area-inset-bottom));
background:linear-gradient(0deg,rgba(0,0,0,.6),transparent)}
.vbottom .vt{color:#fff;font-weight:800;font-size:17px;line-height:1.3}
.vbottom .vn{color:rgba(255,255,255,.86);font-size:13px;margin-top:8px;line-height:1.5;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.vbottom .vbadge{margin-bottom:10px}
.note-pill{display:inline-block;margin-top:12px;font-size:11px;color:rgba(255,255,255,.7);
background:rgba(255,255,255,.12);padding:5px 10px;border-radius:8px}
/* ============================ 데스크톱(웹): 좌측 사이드바 + 4열 피드 ============================ */
@media (min-width:860px){
#app{flex-direction:row}
.nav{
position:static;flex:0 0 244px;height:100%;
flex-direction:column;align-items:stretch;justify-content:flex-start;gap:4px;
padding:18px 14px;border-top:none;border-right:1px solid var(--border);
background:var(--bg);backdrop-filter:none;
}
.brand{display:flex;align-items:baseline;gap:7px;padding:8px 12px 22px;font-size:25px;font-weight:800;letter-spacing:-.02em}
.brand b{background:var(--ig);-webkit-background-clip:text;background-clip:text;color:transparent}
.brand span{font-size:11px;color:var(--muted);font-weight:600}
.nav button{flex:0 0 auto;flex-direction:row;justify-content:flex-start;gap:16px;padding:12px 12px;border-radius:12px;transition:background .15s}
.nav button:hover{background:var(--surface-2)}
.nav button .lbl{font-size:15px;font-weight:600}
.nav button svg{width:24px;height:24px}
.nav button[aria-current="true"]{background:var(--surface-2)}
.nav button[aria-current="true"] .lbl{font-weight:800}
.nav .create{flex:0 0 auto;margin-top:4px}
.nav .create .plus{width:30px;height:30px;border-radius:9px}
.nav .create .plus svg{width:19px}
.nav-foot{display:block;margin-top:auto;padding-top:12px;border-top:1px solid var(--border)}
.nav-login{width:100%;padding:12px;border-radius:12px;background:var(--samgukji);color:#fff;font-size:15px;font-weight:800}
.nav-me{display:flex;align-items:center;gap:10px;padding:6px 4px}
.nav-av{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--ig);color:#fff;font-weight:800;font-size:14px;flex:0 0 auto}
.nav-nm{flex:1;font-size:14px;font-weight:700;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.nav-logout{font-size:12px;font-weight:700;color:var(--muted);flex:0 0 auto}
.topbar{display:none}
.screen{padding-bottom:24px}
.grid{grid-template-columns:repeat(4,1fr);gap:18px;padding:22px 26px;max-width:1240px;margin:0 auto}
.sec-h{padding:24px 26px 2px;max-width:1240px;margin:0 auto}
.sec-h h3{font-size:22px}
.searchbar{padding:16px 26px;max-width:1240px;margin:0 auto}
.chips{padding:6px 26px 14px;max-width:1240px;margin:0 auto}
.ch-wrap{max-width:1240px;margin:0 auto}
.sheet{left:50%;right:auto;bottom:auto;top:50%;width:min(460px,92vw);max-height:86vh;
border-radius:22px;transform:translate(-50%,-46%);opacity:0;
transition:transform .22s cubic-bezier(.22,1,.36,1),opacity .22s}
.sheet.on{transform:translate(-50%,-50%);opacity:1}
.sheet .grab{display:none}
.viewer.on{align-items:center;justify-content:center}
.vframe{position:relative;inset:auto;width:min(430px,26vw);height:min(90vh,780px);
border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.6)}
}
/* 좋아요 눌림 상태 */
.vrail button.liked svg{fill:#ff3b5c}
/* 댓글 패널 (뷰어 하단 시트) */
.cmts{position:absolute;left:0;right:0;bottom:0;z-index:5;max-height:64%;display:flex;flex-direction:column;
background:var(--surface);border-radius:18px 18px 0 0;color:var(--fg);box-shadow:0 -8px 30px rgba(0,0,0,.4)}
.cmts-h{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:800;font-size:14px}
.cmts-h button{color:var(--muted);font-size:20px;line-height:1}
.cmts-list{flex:1;overflow-y:auto;padding:6px 16px}
.cmts-empty{color:var(--muted);font-size:13px;text-align:center;padding:30px 0}
.cmt{padding:9px 0;border-bottom:1px solid var(--border)}
.cmt .who{font-size:12px;color:var(--muted);font-weight:700}
.cmt .bd{font-size:14px;margin-top:2px;line-height:1.4}
.cmt .del{font-size:11px;color:var(--faint);float:right}
.cmt .rep{margin:8px 0 0 16px;padding-left:10px;border-left:2px solid var(--border)}
.cmts-in{display:flex;gap:8px;padding:10px 12px;border-top:1px solid var(--border);align-items:center}
.cmts-in input{flex:1;background:var(--surface-2);border:none;border-radius:20px;padding:10px 14px;color:var(--fg);outline:none;font-size:14px}
.cmts-in button{color:var(--samgukji);font-weight:800;padding:0 8px;font-size:14px}
.cmts-in button:disabled{color:var(--faint)}
/* 업장 검색 → 후보 선택 */
.lbl-sub{font-weight:500;color:var(--muted);font-size:11px}
.input.search{align-items:center}
.srch-btn{flex:0 0 auto;padding:8px 14px;border-radius:9px;background:var(--fg);color:var(--bg);font-size:13px;font-weight:800}
.srch-btn:disabled{opacity:.4}
.places{margin:8px 0 0;list-style:none;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--surface);max-height:230px;overflow-y:auto}
.places li+li{border-top:1px solid var(--border)}
.places button{display:flex;flex-direction:column;gap:2px;width:100%;text-align:left;padding:11px 13px;background:none}
.places button:hover{background:var(--surface-2)}
.places b{font-size:14px;font-weight:700;color:var(--fg)}
.places span{font-size:12px;color:var(--muted)}
.picked{margin:8px 0 0;display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:12px;
border:1.5px solid color-mix(in srgb,var(--sc,#22c55e) 55%,var(--border));background:color-mix(in srgb,var(--sc,#22c55e) 8%,var(--surface))}
.picked .pk-body{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0}
.picked .pk-body b{font-size:15px;font-weight:800;color:var(--fg)}
.picked .pk-body b::before{content:"✓ ";color:#22c55e}
.picked .pk-body span{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pk-change{flex:0 0 auto;font-size:12.5px;font-weight:800;color:var(--muted);padding:6px 10px;border-radius:8px;background:var(--surface-2)}
.place-loading{margin:10px 2px 0;display:flex;align-items:center;gap:9px;font-size:13px;color:var(--muted)}
.spin.sm{width:16px;height:16px;border-width:2px;margin:0}
.hint.ok{color:#16a34a}
.hint.warn{color:#d97706}
/* SNS 연동 카드 (내 채널) */
.sns-card{margin:14px 16px 4px;border:1px solid var(--border);border-radius:16px;background:var(--surface);overflow:hidden}
.sns-h{display:flex;align-items:baseline;gap:8px;padding:13px 16px 9px;border-bottom:1px solid var(--border)}
.sns-h span{font-size:14px;font-weight:800}
.sns-h em{font-style:normal;font-size:11.5px;color:var(--muted)}
.sns-row{display:flex;align-items:center;gap:11px;padding:11px 16px}
.sns-row+.sns-row{border-top:1px solid var(--border)}
.sns-ic{display:grid;place-items:center;width:34px;height:34px;border-radius:9px;background:color-mix(in srgb,var(--sc) 16%,transparent)}
.sns-ic svg{width:20px;height:20px;fill:var(--sc);stroke:var(--sc)}
.sns-name{flex:1;font-size:14px;font-weight:600;display:flex;align-items:center;gap:6px}
.sns-dot{width:7px;height:7px;border-radius:50%;background:#22c55e;display:inline-block}
.sns-soon{font-style:normal;font-size:10.5px;font-weight:800;color:var(--muted);background:var(--surface-2);padding:2px 7px;border-radius:10px}
.sns-btn{padding:7px 15px;border-radius:20px;font-size:13px;font-weight:800;background:var(--fg);color:var(--bg)}
.sns-btn.on{background:var(--surface-2);color:var(--muted)}
.sns-btn:disabled{opacity:.5}
/* SNS 업로드 시트 (뷰어) — .cmts 레이아웃 재사용 */
.cmts.share .share-list{flex:1;overflow-y:auto;padding:8px 12px}
.share-row{display:flex;align-items:center;gap:12px;width:100%;padding:13px 8px;background:none}
.share-row+.share-row{border-top:1px solid var(--border)}
.share-name{flex:1;text-align:left;font-size:15px;font-weight:700;color:var(--fg)}
.share-state{font-size:12.5px;font-weight:800;color:var(--samgukji)}
.share-row:disabled .share-state{color:var(--faint)}
.share-hint{padding:10px 16px 14px;font-size:12px;color:var(--muted);line-height:1.5}
.share-hint b{color:var(--fg)}
/* 토스트 */
.toast{position:fixed;left:50%;bottom:86px;z-index:60;transform:translate(-50%,14px);opacity:0;pointer-events:none;
max-width:min(88vw,420px);padding:12px 18px;border-radius:14px;background:rgba(20,18,16,.94);color:#fff;
font-size:13.5px;font-weight:600;line-height:1.45;text-align:center;box-shadow:0 10px 34px rgba(0,0,0,.4);
transition:opacity .2s,transform .2s}
.toast.on{opacity:1;transform:translate(-50%,0)}
@media (min-width:860px){.toast{bottom:32px}}
/* 로그인 모달 */
.login{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.5)}
.login-card{position:relative;width:100%;max-width:340px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:16px;
background:var(--surface);border-radius:20px;padding:34px 26px 26px;box-shadow:0 24px 70px rgba(0,0,0,.45)}
.login-x{position:absolute;top:12px;right:14px;color:var(--muted);font-size:19px;line-height:1}
.login-brand{display:flex;align-items:baseline;gap:9px}
.login-brand b{font-size:34px;font-weight:800;letter-spacing:-.02em;color:var(--fg)}
.login-brand span{font-size:12px;font-weight:700;color:var(--muted)}
.login-tag{font-size:14px;line-height:1.6;color:var(--muted)}
.login-actions{width:100%;display:flex;flex-direction:column;gap:10px;margin-top:4px}
.login-actions button{width:100%;padding:14px;border-radius:13px;font-size:15px;font-weight:800;display:flex;align-items:center;justify-content:center;gap:8px}
.login-actions .kakao{background:#FEE500;color:#191600}
.login-actions .kakao .ki{width:18px;height:18px;border-radius:50%;background:#191600;position:relative}
.login-actions .kakao .ki::after{content:"";position:absolute;left:4px;top:5px;width:10px;height:7px;border-radius:50%;background:#FEE500}
.login-actions .guest{background:var(--surface-2);color:var(--fg)}
.login-actions button:disabled{opacity:.6}
.login-note{font-size:11.5px;color:var(--faint)}
/* 크레딧 충전 상품 */
.prods{display:flex;flex-direction:column;gap:10px;margin-top:6px}
.prod{position:relative;display:grid;grid-template-columns:1fr auto;grid-template-areas:"cr price" "bonus price" "name price";
align-items:center;gap:2px 12px;text-align:left;padding:14px 16px;border-radius:14px;border:1.5px solid var(--border);background:var(--surface)}
.prod:hover{border-color:var(--samgukji)}
.prod.best{border-color:var(--samgukji);background:color-mix(in srgb,var(--samgukji) 7%,var(--surface))}
.prod:disabled{opacity:.55}
.prod-tag{position:absolute;top:-9px;left:14px;background:var(--samgukji);color:#fff;font-size:10.5px;font-weight:800;padding:2px 8px;border-radius:10px}
.prod-cr{grid-area:cr;font-size:15px;color:var(--muted);font-weight:600}
.prod-cr b{font-size:22px;color:var(--fg);font-weight:800;margin-right:3px}
.prod-bonus{grid-area:bonus;font-size:11.5px;color:var(--samgukji);font-weight:700}
.prod-name{grid-area:name;font-size:12px;color:var(--faint)}
.prod-price{grid-area:price;font-size:16px;font-weight:800;color:var(--fg);white-space:nowrap}
/* 탑바 로그인 버튼(모바일) */
.tb-login{background:var(--samgukji);color:#fff;font-size:13px;font-weight:800;padding:8px 15px;border-radius:20px}
/* 사이드바 하단 로그인/프로필 — 모바일 바텀바에선 숨김(데스크톱 규칙과 충돌 없게 max-width로) */
@media (max-width:859px){.nav-foot{display:none}}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
:focus-visible{outline:2px solid var(--samgukji);outline-offset:2px}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

21
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/api.ts","./src/components.tsx","./src/data.ts","./src/icons.tsx","./src/main.tsx"],"errors":true,"version":"5.9.3"}

15
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// dev 서버: /api·/videos 를 백엔드(FastAPI :8000)로 프록시
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
'/videos': 'http://localhost:8000',
},
},
})