commit 9ab618db10d0f22463eb501ef75b9f434168008a Author: jwkim Date: Wed Jul 8 14:55:47 2026 +0900 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b95b272 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..52e5bfc --- /dev/null +++ b/README.md @@ -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로 확장 가능. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cf388b7 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..cdb838c --- /dev/null +++ b/backend/app/config.py @@ -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") diff --git a/backend/app/content_sync.py b/backend/app/content_sync.py new file mode 100644 index 0000000..8a0ea88 --- /dev/null +++ b/backend/app/content_sync.py @@ -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() diff --git a/backend/app/credit_service.py b/backend/app/credit_service.py new file mode 100644 index 0000000..19c3cc5 --- /dev/null +++ b/backend/app/credit_service.py @@ -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) diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..399ac57 --- /dev/null +++ b/backend/app/database.py @@ -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}") diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..7056c4a --- /dev/null +++ b/backend/app/deps.py @@ -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) diff --git a/backend/app/jobs.py b/backend/app/jobs.py new file mode 100644 index 0000000..a1faf50 --- /dev/null +++ b/backend/app/jobs.py @@ -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() diff --git a/backend/app/kakao_client.py b/backend/app/kakao_client.py new file mode 100644 index 0000000..c91430d --- /dev/null +++ b/backend/app/kakao_client.py @@ -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"), + } diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..08e148f --- /dev/null +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..ea8c1e5 --- /dev/null +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/place_service.py b/backend/app/place_service.py new file mode 100644 index 0000000..8df6662 --- /dev/null +++ b/backend/app/place_service.py @@ -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 [] diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..7b85378 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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) diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py new file mode 100644 index 0000000..fb5dd9e --- /dev/null +++ b/backend/app/routers/comments.py @@ -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} diff --git a/backend/app/routers/content.py b/backend/app/routers/content.py new file mode 100644 index 0000000..3deb85d --- /dev/null +++ b/backend/app/routers/content.py @@ -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} diff --git a/backend/app/routers/credit.py b/backend/app/routers/credit.py new file mode 100644 index 0000000..f54f04f --- /dev/null +++ b/backend/app/routers/credit.py @@ -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"]} diff --git a/backend/app/routers/sns.py b/backend/app/routers/sns.py new file mode 100644 index 0000000..298cd7b --- /dev/null +++ b/backend/app/routers/sns.py @@ -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} diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..ed53a4f --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..42ea375 --- /dev/null +++ b/backend/app/security.py @@ -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 diff --git a/backend/app/youtube_client.py b/backend/app/youtube_client.py new file mode 100644 index 0000000..5ec8adf --- /dev/null +++ b/backend/app/youtube_client.py @@ -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}"} diff --git a/backend/generator/animation/character/README.txt b/backend/generator/animation/character/README.txt new file mode 100644 index 0000000..f115674 --- /dev/null +++ b/backend/generator/animation/character/README.txt @@ -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") diff --git a/backend/generator/animation/character/image.png b/backend/generator/animation/character/image.png new file mode 100644 index 0000000..6861589 Binary files /dev/null and b/backend/generator/animation/character/image.png differ diff --git a/backend/generator/animation/data/joseon_keywords_20260601_092737.csv b/backend/generator/animation/data/joseon_keywords_20260601_092737.csv new file mode 100644 index 0000000..02f5c09 --- /dev/null +++ b/backend/generator/animation/data/joseon_keywords_20260601_092737.csv @@ -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/순종(대한제국) diff --git a/backend/generator/animation/gen.py b/backend/generator/animation/gen.py new file mode 100644 index 0000000..558f43a --- /dev/null +++ b/backend/generator/animation/gen.py @@ -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 diff --git a/backend/generator/animation/main.py b/backend/generator/animation/main.py new file mode 100644 index 0000000..71e2baf --- /dev/null +++ b/backend/generator/animation/main.py @@ -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() diff --git a/backend/generator/animation/render.py b/backend/generator/animation/render.py new file mode 100644 index 0000000..d03baee --- /dev/null +++ b/backend/generator/animation/render.py @@ -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 diff --git a/backend/generator/animation/scraper.py b/backend/generator/animation/scraper.py new file mode 100644 index 0000000..f5d2d99 --- /dev/null +++ b/backend/generator/animation/scraper.py @@ -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()) diff --git a/backend/generator/animation_greekroman/character/README.txt b/backend/generator/animation_greekroman/character/README.txt new file mode 100644 index 0000000..e4ff5fd --- /dev/null +++ b/backend/generator/animation_greekroman/character/README.txt @@ -0,0 +1,13 @@ +[캐릭터 공식 레퍼런스 폴더 — greekroman 은 기본 '비움'] + +greekroman(그리스 로마 신화) 버전은 고양이 의인화가 아니라 '실제 신의 모습' +(근육질 남신 / 아름답고 늘씬한 여신, 한국 신화만화풍)으로 그리므로, +조선/삼국지에서 쓰던 고양이 눈 레퍼런스 이미지를 넣지 않습니다. + +- 폴더가 비어 있으면: 각 영상의 '첫 컷'이 자동 앵커가 되어 + 나머지 컷들이 그 캐릭터 디자인/화풍을 그대로 따라갑니다(영상 내 일관성 유지). +- 특정 그림체를 전 영상에 걸쳐 고정하고 싶으면 기준 이미지를 1장 넣으세요 + (폴더 안 '첫 번째' 이미지 파일 사용. png/jpg/jpeg/webp). + 단 이 경우 '눈만 고정' 모드라 눈/표정만 통일되고 복장은 신별로 다르게 나옵니다. + +연결 코드: render.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes") diff --git a/backend/generator/animation_greekroman/data/greek_keywords_20260707_100130.csv b/backend/generator/animation_greekroman/data/greek_keywords_20260707_100130.csv new file mode 100644 index 0000000..3fd23e6 --- /dev/null +++ b/backend/generator/animation_greekroman/data/greek_keywords_20260707_100130.csv @@ -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/이카로스 diff --git a/backend/generator/animation_greekroman/data/greek_keywords_20260707_100130.json b/backend/generator/animation_greekroman/data/greek_keywords_20260707_100130.json new file mode 100644 index 0000000..8833d54 --- /dev/null +++ b/backend/generator/animation_greekroman/data/greek_keywords_20260707_100130.json @@ -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": "이카로스" + } +] \ No newline at end of file diff --git a/backend/generator/animation_greekroman/gen.py b/backend/generator/animation_greekroman/gen.py new file mode 100644 index 0000000..8cd3fdc --- /dev/null +++ b/backend/generator/animation_greekroman/gen.py @@ -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 diff --git a/backend/generator/animation_greekroman/main.py b/backend/generator/animation_greekroman/main.py new file mode 100644 index 0000000..8a7234c --- /dev/null +++ b/backend/generator/animation_greekroman/main.py @@ -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() diff --git a/backend/generator/animation_greekroman/render.py b/backend/generator/animation_greekroman/render.py new file mode 100644 index 0000000..3909f77 --- /dev/null +++ b/backend/generator/animation_greekroman/render.py @@ -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 diff --git a/backend/generator/animation_greekroman/scraper.py b/backend/generator/animation_greekroman/scraper.py new file mode 100644 index 0000000..05ee830 --- /dev/null +++ b/backend/generator/animation_greekroman/scraper.py @@ -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()) diff --git a/backend/generator/animation_samgukji/character/README.txt b/backend/generator/animation_samgukji/character/README.txt new file mode 100644 index 0000000..f115674 --- /dev/null +++ b/backend/generator/animation_samgukji/character/README.txt @@ -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") diff --git a/backend/generator/animation_samgukji/character/image.png b/backend/generator/animation_samgukji/character/image.png new file mode 100644 index 0000000..6861589 Binary files /dev/null and b/backend/generator/animation_samgukji/character/image.png differ diff --git a/backend/generator/animation_samgukji/data/samguk_keywords_20260706_141831.csv b/backend/generator/animation_samgukji/data/samguk_keywords_20260706_141831.csv new file mode 100644 index 0000000..cd684dd --- /dev/null +++ b/backend/generator/animation_samgukji/data/samguk_keywords_20260706_141831.csv @@ -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/원술 diff --git a/backend/generator/animation_samgukji/data/samguk_keywords_20260706_141831.json b/backend/generator/animation_samgukji/data/samguk_keywords_20260706_141831.json new file mode 100644 index 0000000..2be3785 --- /dev/null +++ b/backend/generator/animation_samgukji/data/samguk_keywords_20260706_141831.json @@ -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": "원술" + } +] \ No newline at end of file diff --git a/backend/generator/animation_samgukji/gen.py b/backend/generator/animation_samgukji/gen.py new file mode 100644 index 0000000..b4576cb --- /dev/null +++ b/backend/generator/animation_samgukji/gen.py @@ -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 diff --git a/backend/generator/animation_samgukji/main.py b/backend/generator/animation_samgukji/main.py new file mode 100644 index 0000000..78de314 --- /dev/null +++ b/backend/generator/animation_samgukji/main.py @@ -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() diff --git a/backend/generator/animation_samgukji/render.py b/backend/generator/animation_samgukji/render.py new file mode 100644 index 0000000..5242c5c --- /dev/null +++ b/backend/generator/animation_samgukji/render.py @@ -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 diff --git a/backend/generator/animation_samgukji/scraper.py b/backend/generator/animation_samgukji/scraper.py new file mode 100644 index 0000000..ad69920 --- /dev/null +++ b/backend/generator/animation_samgukji/scraper.py @@ -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()) diff --git a/backend/generator/fonts/Jua-Regular.ttf b/backend/generator/fonts/Jua-Regular.ttf new file mode 100644 index 0000000..b2ac41e Binary files /dev/null and b/backend/generator/fonts/Jua-Regular.ttf differ diff --git a/backend/generator/fonts/SongMyung-Regular.ttf b/backend/generator/fonts/SongMyung-Regular.ttf new file mode 100644 index 0000000..b0732bf Binary files /dev/null and b/backend/generator/fonts/SongMyung-Regular.ttf differ diff --git a/backend/generator/media.py b/backend/generator/media.py new file mode 100644 index 0000000..ac06605 --- /dev/null +++ b/backend/generator/media.py @@ -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)]) diff --git a/backend/generator/naver.py b/backend/generator/naver.py new file mode 100644 index 0000000..9bd0204 --- /dev/null +++ b/backend/generator/naver.py @@ -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 브리핑' 불릿 문장만 뽑는 브라우저측 스크립트. +# [핵심] 브리핑 본문 문장은 출처번호(1…)를 '자식'으로 품은 노드 안에 있어서 +# 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 + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f2fc458 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e3e8f8d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 썰박스 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4611ffc --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2402 @@ +{ + "name": "o2o-ssulbox-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "o2o-ssulbox-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c2eb089 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..d2cf6d3 --- /dev/null +++ b/frontend/src/App.tsx @@ -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('home') + const [data, setData] = useState(SAMPLE) + const [me, setMe] = useState(null) + const [authReady, setAuthReady] = useState(false) + const [busy, setBusy] = useState(false) + const [viewerId, setViewerId] = useState(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 ( +
+