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
commit
9ab618db10
|
|
@ -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/
|
||||
|
|
@ -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로 확장 가능.
|
||||
|
|
@ -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,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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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"]}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"}
|
||||
|
|
@ -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 |
|
|
@ -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/순종(대한제국)
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
[캐릭터 공식 레퍼런스 폴더 — greekroman 은 기본 '비움']
|
||||
|
||||
greekroman(그리스 로마 신화) 버전은 고양이 의인화가 아니라 '실제 신의 모습'
|
||||
(근육질 남신 / 아름답고 늘씬한 여신, 한국 신화만화풍)으로 그리므로,
|
||||
조선/삼국지에서 쓰던 고양이 눈 레퍼런스 이미지를 넣지 않습니다.
|
||||
|
||||
- 폴더가 비어 있으면: 각 영상의 '첫 컷'이 자동 앵커가 되어
|
||||
나머지 컷들이 그 캐릭터 디자인/화풍을 그대로 따라갑니다(영상 내 일관성 유지).
|
||||
- 특정 그림체를 전 영상에 걸쳐 고정하고 싶으면 기준 이미지를 1장 넣으세요
|
||||
(폴더 안 '첫 번째' 이미지 파일 사용. png/jpg/jpeg/webp).
|
||||
단 이 경우 '눈만 고정' 모드라 눈/표정만 통일되고 복장은 신별로 다르게 나옵니다.
|
||||
|
||||
연결 코드: render.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||
|
|
@ -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/이카로스
|
||||
|
|
|
@ -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": "이카로스"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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 |
|
|
@ -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/원술
|
||||
|
|
|
@ -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": "원술"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
|
@ -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)])
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>· 시나리오 <b>{s.name}</b></div>
|
||||
<div>· 가게 <b>{selected ? selected.title : input}</b></div>
|
||||
{selected && <div>· 위치 <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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 },
|
||||
]
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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
|
|
@ -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"}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue