feat(ssulbox): 썰박스 생성 파이프라인 통합 및 콘텐츠 목록 UNION

This commit is contained in:
김성경 2026-08-11 11:03:03 +09:00
parent 8096837b13
commit 666c3bf6d1
77 changed files with 15913 additions and 350 deletions

3
.gitignore vendored
View File

@ -54,3 +54,6 @@ Dockerfile
zzz/ zzz/
credentials/service_account.json credentials/service_account.json
o2o-castad-scheduler/ o2o-castad-scheduler/
generator/output/
generator/*/load_image/

View File

@ -16,10 +16,9 @@ from app.user.dependencies.auth import get_current_user
from app.user.models import User from app.user.models import User
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.comment.models import Comment from app.video.models import Video
from app.database.like_cache import get_like_counts, mset_like_counts
from app.video.models import Video, VideoReaction
from app.video.schemas.video_schema import VideoListItem from app.video.schemas.video_schema import VideoListItem
from app.video.services import unified_list
logger = get_logger(__name__) logger = get_logger(__name__)
@ -79,89 +78,27 @@ async def get_videos(
try: try:
offset = (pagination.page - 1) * pagination.page_size offset = (pagination.page - 1) * pagination.page_size
# 서브쿼리: task_id별 최신 Video ID 추출 items, total = await unified_list.fetch_my_contents(
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치 session,
latest_video_ids = ( user_uuid=current_user.user_uuid,
select(func.max(Video.id).label("latest_id")) offset=offset,
.join(Project, Video.project_id == Project.id) limit=pagination.page_size,
.where(
Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
Video.is_deleted == False, # noqa: E712
Project.is_deleted == False, # noqa: E712
) )
.group_by(Video.task_id)
.subquery()
)
# 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만)
count_query = select(func.count(Video.id)).where(
Video.id.in_(select(latest_video_ids.c.latest_id))
)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# 쿼리 2: Video + Project + comment_count 조회 (like_count는 Redis에서)
comment_count_subq = (
select(func.count(Comment.id))
.where(
Comment.video_id == Video.id,
Comment.is_deleted == False, # noqa: E712
)
.correlate(Video)
.scalar_subquery()
)
data_query = (
select(
Video,
Project,
comment_count_subq.label("comment_count"),
)
.join(Project, Video.project_id == Project.id)
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
.order_by(Video.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
result = await session.execute(data_query)
rows = result.all()
# Redis mget으로 like_count 일괄 조회
video_ids = [video.id for video, project, _ in rows]
like_count_map = await get_like_counts(video_ids)
# 캐시 미스(None)인 video_id만 DB에서 보정
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
if missing_ids:
db_counts = (await session.execute(
select(VideoReaction.video_id, func.count(VideoReaction.id))
.where(VideoReaction.video_id.in_(missing_ids))
.group_by(VideoReaction.video_id)
)).all()
db_found_ids = set()
batch = {}
for vid, cnt in db_counts:
batch[vid] = cnt
like_count_map[vid] = cnt
db_found_ids.add(vid)
await mset_like_counts(batch)
for vid in missing_ids:
if vid not in db_found_ids:
like_count_map[vid] = 0
# VideoListItem으로 변환
items = [ items = [
VideoListItem( VideoListItem(
video_id=video.id, type=it.ctype,
store_name=project.store_name, video_id=it.id,
region=project.region, store_name=it.store_name,
task_id=video.task_id, region=it.region,
result_movie_url=video.result_movie_url, # 썰박스는 task_id 개념이 없어 빈 문자열이다.
created_at=video.created_at, # 프론트는 반드시 (type, video_id) 쌍으로 식별할 것.
like_count=like_count_map.get(video.id) or 0, task_id=it.task_id,
comment_count=comment_count or 0, result_movie_url=it.movie_url,
created_at=it.created_at,
like_count=it.like_count,
comment_count=it.comment_count,
) )
for video, project, comment_count in rows for it in items
] ]
response = PaginatedResponse.create( response = PaginatedResponse.create(

View File

@ -36,7 +36,7 @@ async def soft_delete_by_task_id(task_id: str) -> dict:
dict: 각 테이블별 업데이트된 레코드 수 dict: 각 테이블별 업데이트된 레코드 수
""" """
logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}") logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}")
logger.debug(f"[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작") logger.debug("[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
result = { result = {
"task_id": task_id, "task_id": task_id,

View File

@ -1,12 +1,23 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from app.ssulbox.models import SsulContent
from app.user.models import User from app.user.models import User
from app.video.models import Video from app.video.models import Video
@ -18,11 +29,22 @@ class Comment(Base):
2-depth 구조 (최상위 댓글 + 대댓글 1단계). 2-depth 구조 (최상위 댓글 + 대댓글 1단계).
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글. parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책). 작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책).
**ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id`
중 **정확히 하나**만 채워지며, 이를 DB `CHECK` 로 강제한다. MySQL 은 하나의 FK 가
두 테이블을 조건부로 가리키게 할 수 없어 컬럼을 나눠 둔 것이다.
별도의 종류 컬럼은 두지 않는다 — 어느 FK 가 채워졌는지가 곧 종류이고,
컬럼을 하나 더 두면 둘이 어긋날 수 있다.
""" """
__tablename__ = "comment" __tablename__ = "comment"
__table_args__ = ( __table_args__ = (
CheckConstraint(
"(video_id IS NULL) <> (content_id IS NULL)",
name="ck_comment_one_target",
),
Index("idx_comment_video_id", "video_id"), Index("idx_comment_video_id", "video_id"),
Index("idx_comment_content_id", "content_id"),
Index("idx_comment_user_uuid", "user_uuid"), Index("idx_comment_user_uuid", "user_uuid"),
Index("idx_comment_parent_id", "parent_id"), Index("idx_comment_parent_id", "parent_id"),
Index("idx_comment_is_deleted", "is_deleted"), Index("idx_comment_is_deleted", "is_deleted"),
@ -36,11 +58,19 @@ class Comment(Base):
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True, comment="고유 식별자" Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
) )
video_id: Mapped[int] = mapped_column( # 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_comment_one_target).
video_id: Mapped[Optional[int]] = mapped_column(
Integer, Integer,
ForeignKey("video.id", ondelete="CASCADE"), ForeignKey("video.id", ondelete="CASCADE"),
nullable=False, nullable=True,
comment="연결된 Video의 id", comment="ADO2 영상 id (썰박스 댓글이면 NULL)",
)
content_id: Mapped[Optional[int]] = mapped_column(
# ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780).
BigInteger,
ForeignKey("ssul_content.id", ondelete="CASCADE"),
nullable=True,
comment="썰박스 콘텐츠 id (ADO2 댓글이면 NULL)",
) )
user_uuid: Mapped[str] = mapped_column( user_uuid: Mapped[str] = mapped_column(
ForeignKey("user.user_uuid", ondelete="CASCADE"), ForeignKey("user.user_uuid", ondelete="CASCADE"),
@ -69,7 +99,13 @@ class Comment(Base):
comment="작성 일시", comment="작성 일시",
) )
video: Mapped["Video"] = relationship("Video", back_populates="comments") # 썰박스 댓글이면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
video: Mapped[Optional["Video"]] = relationship(
"Video", foreign_keys=[video_id], back_populates="comments"
)
content_ref: Mapped[Optional["SsulContent"]] = relationship(
"SsulContent", foreign_keys=[content_id], lazy="noload"
)
user: Mapped["User"] = relationship("User", back_populates="comments") user: Mapped["User"] = relationship("User", back_populates="comments")
parent: Mapped[Optional["Comment"]] = relationship( parent: Mapped[Optional["Comment"]] = relationship(
"Comment", remote_side=[id], back_populates="replies" "Comment", remote_side=[id], back_populates="replies"

View File

@ -6,6 +6,8 @@ from fastapi import FastAPI
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.nvMapPwScraper import NvMapPwScraper from app.utils.nvMapPwScraper import NvMapPwScraper
from config import ssulbox_settings
logger = get_logger("core") logger = get_logger("core")
@ -29,6 +31,37 @@ async def lifespan(app: FastAPI):
from app.dashboard.migration import init_dashboard_table from app.dashboard.migration import init_dashboard_table
await init_dashboard_table() await init_dashboard_table()
# 썰박스 스키마 보장 (모든 환경 - Alembic 부재 + create_db_tables 는 DEBUG 전용)
# 실패해도 앱 기동은 막지 않는다. 썰박스 스키마 문제로 castad 전체가 죽으면 안 된다.
if ssulbox_settings.SSULBOX_ENABLED:
try:
from app.ssulbox.migration import ensure_ssulbox_schema
await ensure_ssulbox_schema()
except Exception as e:
logger.error(
f"[ssulbox] 스키마 보장 실패 (다음 기동 시 재시도): "
f"{type(e).__name__}: {e}"
)
# 고아 잡 스윕 — 이전 프로세스가 죽으며 남긴 queued/running 을 환불·정리.
# 기동 직후 인메모리 잡은 0개이므로 비터미널 잡은 전부 고아다.
# ⚠️ 이 불변식은 단일 워커 전제다(--workers 를 늘리면 정상 잡을 오판한다).
try:
from app.database.session import BackgroundSessionLocal
from app.ssulbox.services import task_service
async with BackgroundSessionLocal() as session:
swept = await task_service.sweep_orphans(session)
await session.commit()
if swept:
logger.info(f"[ssulbox] 고아 잡 {swept}건 환불·정리")
except Exception as e:
logger.error(
f"[ssulbox] 고아 스윕 실패 (다음 기동 시 재시도): "
f"{type(e).__name__}: {e}"
)
await NvMapPwScraper.initiate_scraper() await NvMapPwScraper.initiate_scraper()
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error("Database initialization timed out") logger.error("Database initialization timed out")
@ -44,6 +77,16 @@ async def lifespan(app: FastAPI):
# Shutdown - 애플리케이션 종료 시 # Shutdown - 애플리케이션 종료 시
logger.info("Shutting down...") logger.info("Shutting down...")
# 썰박스 신규 잡 큐잉 차단. dispose_engine() 전에 해야 워커 스레드가
# 죽은 커넥션에 접근하지 않는다. 진행 중이던 잡은 다음 기동의 스윕이 환불한다.
if ssulbox_settings.SSULBOX_ENABLED:
try:
from app.ssulbox.worker import job_manager
job_manager.shutdown()
except Exception as e:
logger.warning(f"[ssulbox] 종료 처리 실패: {e}")
# 공유 HTTP 클라이언트 종료 # 공유 HTTP 클라이언트 종료
from app.utils.creatomate import close_shared_client from app.utils.creatomate import close_shared_client
from app.utils.upload_blob_as_request import close_shared_blob_client from app.utils.upload_blob_as_request import close_shared_blob_client

View File

@ -326,6 +326,25 @@ def add_exception_handlers(app: FastAPI):
}, },
) )
# SsulboxException 핸들러 추가
# (FastShipError 를 상속하지 않으므로 위 자동 등록에 잡히지 않는다 —
# DashboardException 과 같은 (message, status_code, code) 형태를 쓴다)
from app.ssulbox.exceptions import SsulboxException
@app.exception_handler(SsulboxException)
def ssulbox_exception_handler(request: Request, exc: SsulboxException) -> Response:
if exc.status_code < 500:
logger.warning(f"Handled SsulboxException: {exc.__class__.__name__} - {exc.message}")
else:
logger.error(f"Handled SsulboxException: {exc.__class__.__name__} - {exc.message}")
return JSONResponse(
status_code=exc.status_code,
content={
"detail": exc.message,
"code": exc.code,
},
)
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) @app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
def internal_server_error_handler(request, exception): def internal_server_error_handler(request, exception):
# 에러 메시지 로깅 (한글 포함 가능) # 에러 메시지 로깅 (한글 포함 가능)

View File

@ -6,7 +6,9 @@ from app.core.exceptions import FastShipError
class InsufficientCreditError(FastShipError): class InsufficientCreditError(FastShipError):
"""크레딧이 부족합니다.""" """크레딧이 부족합니다."""
status = status.HTTP_400_BAD_REQUEST # 402 Payment Required. 사전차감 전환 전에는 라우터로 노출된 적이 없어(내부에서만
# raise/catch) 400 이었다. 프론트가 "충전 화면으로 유도"를 402 로 분기한다.
status = status.HTTP_402_PAYMENT_REQUIRED
class InvalidRequestStateError(FastShipError): class InvalidRequestStateError(FastShipError):

View File

@ -2,7 +2,16 @@ from datetime import datetime
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, func from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -146,6 +155,10 @@ class CreditTransaction(Base):
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"), Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
Index("idx_credit_tx_type", "type"), Index("idx_credit_tx_type", "type"),
Index("idx_credit_tx_related_request", "related_request_id"), Index("idx_credit_tx_related_request", "related_request_id"),
# 작업 1건당 consume 1행 / refund 1행을 DB 레벨에서 보장하는 멱등 키.
# MySQL 은 NULL 을 서로 다르게 취급하므로 job_type 이 NULL 인 기존
# charge/admin_adjust 행은 이 제약의 영향을 받지 않는다.
UniqueConstraint("job_type", "job_ref", "type", name="uq_credit_job"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
@ -206,6 +219,25 @@ class CreditTransaction(Base):
comment="연관 충전 요청 ID", comment="연관 충전 요청 ID",
) )
# ==========================================================================
# 작업 기반 멱등 키 — (job_type, job_ref, type) 유니크
# ==========================================================================
# 사전차감 정책의 핵심. 생성 작업을 시작할 때 차감하고 실패 시 환불하는데,
# 재시도·중복 요청·크래시 후 재처리에도 "작업 1건당 정확히 1번"을 보장해야 한다.
# FK 를 걸지 않는 이유: 썰박스(ssul_task.id 숫자)와 영상(video.task_id UUID7 문자열)
# 이라는 이질적인 두 작업 테이블을 하나의 컬럼이 가리키기 때문이다.
job_type: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
comment="차감 유발 작업 종류 (ssul/video). 충전·관리자 조정은 NULL",
)
job_ref: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="작업 식별자 (ssul_task.id 문자열 또는 video.task_id)",
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,

View File

@ -32,6 +32,8 @@ async def record_transaction(
reason: Optional[str] = None, reason: Optional[str] = None,
admin_id: Optional[int] = None, admin_id: Optional[int] = None,
related_request_id: Optional[int] = None, related_request_id: Optional[int] = None,
job_type: Optional[str] = None,
job_ref: Optional[str] = None,
) -> CreditTransaction: ) -> CreditTransaction:
tx = CreditTransaction( tx = CreditTransaction(
user_uuid=user_uuid, user_uuid=user_uuid,
@ -41,6 +43,8 @@ async def record_transaction(
reason=reason, reason=reason,
admin_id=admin_id, admin_id=admin_id,
related_request_id=related_request_id, related_request_id=related_request_id,
job_type=job_type,
job_ref=job_ref,
) )
session.add(tx) session.add(tx)
await session.flush() await session.flush()
@ -123,6 +127,189 @@ async def deduct_credit(
return tx return tx
# =============================================================================
# 작업 기반 차감/환불 (사전차감 정책)
# =============================================================================
# 위의 charge_credit / deduct_credit 은 멱등성이 없어 재시도 시 중복 반영된다.
# 생성 작업처럼 "시작할 때 차감하고 실패하면 환불"하는 경로에서는 아래 두 함수를 쓴다.
#
# 멱등 보장 방식은 2중이다:
# 1) 먼저 (job_type, job_ref, type) 로 기존 행을 조회해 있으면 그대로 반환
# 2) 경합으로 1)을 통과한 두 요청이 동시에 INSERT 하면 DB 유니크 제약이 막는다
# 잔액 자체는 User 행을 with_for_update() 로 잠근 뒤 읽기→갱신하므로 경합에 안전하다.
#
# 두 함수 모두 **자체 commit 하지 않는다.** 호출부가 트랜잭션을 소유하고,
# 작업 행 생성과 차감을 한 트랜잭션으로 묶어 마지막에 한 번만 커밋해야 한다.
async def _find_job_transaction(
session: AsyncSession,
job_type: str,
job_ref: str,
type: CreditTransactionType,
) -> Optional[CreditTransaction]:
"""(job_type, job_ref, type) 에 해당하는 기존 원장 행 조회"""
result = await session.execute(
select(CreditTransaction).where(
CreditTransaction.job_type == job_type,
CreditTransaction.job_ref == job_ref,
CreditTransaction.type == type,
)
)
return result.scalar_one_or_none()
async def deduct_credit_for_job(
*,
session: AsyncSession,
user_uuid: str,
amount: int,
job_type: str,
job_ref: str,
reason: Optional[str] = None,
) -> CreditTransaction:
"""작업 시작 시점에 크레딧을 선차감한다. (job_type, job_ref) 기준 멱등.
Args:
session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다.
user_uuid: 사용자 UUID
amount: 차감할 크레딧 (양수)
job_type: 작업 종류 ("video" | "ssul")
job_ref: 작업 식별자 (video.task_id 또는 str(ssul_task.id))
reason: 원장에 남길 사유
Returns:
새로 만든 차감 원장 행. 이미 차감된 작업이면 기존 행을 그대로 반환한다.
Raises:
InsufficientCreditError: 잔액 부족 (호출부에서 402 로 변환할 것)
UserNotFoundError: 사용자 없음
"""
from app.user.models import User
existing = await _find_job_transaction(
session, job_type, job_ref, CreditTransactionType.CONSUME
)
if existing is not None:
logger.info(
f"[CREDIT] deduct skipped (already charged) "
f"job={job_type}:{job_ref} tx_id={existing.id}"
)
return existing
result = await session.execute(
select(User).where(User.user_uuid == user_uuid).with_for_update()
)
user = result.scalar_one_or_none()
if user is None:
from app.user.services.auth import UserNotFoundError
raise UserNotFoundError()
if user.credits < amount:
logger.warning(
f"[CREDIT] insufficient credits user_uuid={user_uuid} "
f"credits={user.credits} requested={amount} job={job_type}:{job_ref}"
)
raise InsufficientCreditError()
user.credits = user.credits - amount
await session.flush()
tx = await record_transaction(
session=session,
user_uuid=user_uuid,
amount=-amount,
balance_after=user.credits,
type=CreditTransactionType.CONSUME,
reason=reason,
job_type=job_type,
job_ref=job_ref,
)
logger.info(
f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} "
f"balance_after={user.credits} job={job_type}:{job_ref}"
)
return tx
async def refund_credit_for_job(
*,
session: AsyncSession,
user_uuid: str,
amount: int,
job_type: str,
job_ref: str,
reason: Optional[str] = None,
) -> Optional[CreditTransaction]:
"""작업 실패 시 선차감한 크레딧을 환불한다. (job_type, job_ref) 기준 멱등.
차감 기록이 없으면 환불하지 않는다 — 애초에 차감되지 않은 작업(예: 정책 전환
이전에 시작된 in-flight 작업)에 환불을 얹으면 크레딧이 늘어나기 때문이다.
Args:
session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다.
user_uuid: 사용자 UUID
amount: 환불할 크레딧 (양수)
job_type: 작업 종류 ("video" | "ssul")
job_ref: 작업 식별자
reason: 원장에 남길 사유
Returns:
새로 만든 환불 원장 행. 이미 환불했거나 차감 기록이 없으면 None.
"""
from app.user.models import User
already = await _find_job_transaction(
session, job_type, job_ref, CreditTransactionType.REFUND
)
if already is not None:
logger.info(
f"[CREDIT] refund skipped (already refunded) "
f"job={job_type}:{job_ref} tx_id={already.id}"
)
return None
consumed = await _find_job_transaction(
session, job_type, job_ref, CreditTransactionType.CONSUME
)
if consumed is None:
logger.info(
f"[CREDIT] refund skipped (never charged) job={job_type}:{job_ref}"
)
return None
result = await session.execute(
select(User).where(User.user_uuid == user_uuid).with_for_update()
)
user = result.scalar_one_or_none()
if user is None:
logger.warning(
f"[CREDIT] refund skipped (user not found) "
f"user_uuid={user_uuid} job={job_type}:{job_ref}"
)
return None
user.credits = user.credits + amount
await session.flush()
tx = await record_transaction(
session=session,
user_uuid=user_uuid,
amount=amount,
balance_after=user.credits,
type=CreditTransactionType.REFUND,
reason=reason,
job_type=job_type,
job_ref=job_ref,
)
logger.info(
f"[CREDIT] refund user_uuid={user_uuid} amount=+{amount} "
f"balance_after={user.credits} job={job_type}:{job_ref}"
)
return tx
async def approve_charge_request( async def approve_charge_request(
*, *,
session: AsyncSession, session: AsyncSession,

View File

@ -5,12 +5,20 @@ Write-Behind 패턴 적용:
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시 - 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write - 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
**콘텐츠 종류(ctype)** 를 받아 ADO2 영상과 썰박스 콘텐츠 양쪽에 같은 로직을 쓴다.
`ctype` 기본값이 "video" 라서 기존 castad 호출부는 수정 없이 그대로 동작한다.
검증된 Lua 원자 토글은 키를 인자로 받으므로 변경하지 않았다.
Key 패턴: Key 패턴:
- video:like:count:{video_id} INT — 좋아요 카운트 - {ctype}:like:count:{content_id} INT — 좋아요 카운트
- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록 - {ctype}:like:users:{content_id} SET — 좋아요 누른 user_uuid 목록
- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}" - video:reaction:dirty SET — DB 동기화 대기 "{ctype}:{content_id}:{user_uuid}"
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용) - video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
ctype="video" 일 때 카운트/유저 키가 기존과 **완전히 동일**하므로 캐시 이관이 필요 없다.
dirty SET 키 이름도 "video:" 접두를 유지한다 — 배포 순간 큐에 남아 있는 항목을
잃지 않기 위해서다(이름을 바꾸면 그 항목들이 영구 미반영된다).
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다. 캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
""" """
@ -44,6 +52,17 @@ end
_DIRTY_KEY = "video:reaction:dirty" _DIRTY_KEY = "video:reaction:dirty"
_DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing" _DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing"
# ──────────────────────────────────────────────
# 콘텐츠 종류
# ──────────────────────────────────────────────
#: ADO2 영상 (video 테이블 · video_reaction)
CT_VIDEO = "video"
#: 썰박스 콘텐츠 (ssul_content 테이블 · ssul_like)
CT_SSUL = "ssul"
#: dirty 항목 파싱 시 "종류 접두인지" 판별하는 데 쓴다
CONTENT_TYPES: tuple[str, ...] = (CT_VIDEO, CT_SSUL)
def get_like_cache() -> aioredis.Redis: def get_like_cache() -> aioredis.Redis:
global _client global _client
@ -68,61 +87,71 @@ async def close_like_cache() -> None:
# Key 헬퍼 # Key 헬퍼
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
def _key(video_id: int) -> str: def _key(content_id: int, ctype: str = CT_VIDEO) -> str:
return f"video:like:count:{video_id}" return f"{ctype}:like:count:{content_id}"
def _user_key(video_id: int) -> str: def _user_key(content_id: int, ctype: str = CT_VIDEO) -> str:
return f"video:like:users:{video_id}" return f"{ctype}:like:users:{content_id}"
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 카운트 (기존 API 유지) # 카운트 (기존 API 유지)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
async def get_like_count(video_id: int) -> int | None: async def get_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int | None:
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환.""" """Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
val = await get_like_cache().get(_key(video_id)) val = await get_like_cache().get(_key(content_id, ctype))
if val is None: if val is None:
return None return None
return max(int(val), 0) return max(int(val), 0)
async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]: async def get_like_counts(
"""여러 영상의 like_count를 한 번에 조회 (mget). content_ids: list[int], *, ctype: str = CT_VIDEO
캐시 미스인 video_id는 None으로 반환.""" ) -> dict[int, int | None]:
if not video_ids: """여러 콘텐츠의 like_count를 한 번에 조회 (mget).
캐시 미스인 content_id는 None으로 반환."""
if not content_ids:
return {} return {}
keys = [_key(vid) for vid in video_ids] keys = [_key(cid, ctype) for cid in content_ids]
values = await get_like_cache().mget(*keys) values = await get_like_cache().mget(*keys)
return { return {
vid: max(int(v), 0) if v is not None else None cid: max(int(v), 0) if v is not None else None
for vid, v in zip(video_ids, values) for cid, v in zip(content_ids, values)
} }
async def set_like_count(video_id: int, count: int) -> None: async def set_like_count(
content_id: int, count: int, *, ctype: str = CT_VIDEO
) -> None:
"""like_count를 Redis에 저장 (음수 방지).""" """like_count를 Redis에 저장 (음수 방지)."""
await get_like_cache().set(_key(video_id), max(count, 0)) await get_like_cache().set(_key(content_id, ctype), max(count, 0))
async def mset_like_counts(counts: dict[int, int]) -> None: async def mset_like_counts(
"""여러 영상의 like_count를 한 번에 저장 (mset).""" counts: dict[int, int], *, ctype: str = CT_VIDEO
) -> None:
"""여러 콘텐츠의 like_count를 한 번에 저장 (mset)."""
if not counts: if not counts:
return return
await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()}) await get_like_cache().mset(
{_key(cid, ctype): max(cnt, 0) for cid, cnt in counts.items()}
)
async def incr_like_count(video_id: int) -> int: async def incr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
"""like_count를 1 증가 후 반환.""" """like_count를 1 증가 후 반환."""
return max(int(await get_like_cache().incr(_key(video_id))), 0) return max(int(await get_like_cache().incr(_key(content_id, ctype))), 0)
async def decr_like_count(video_id: int) -> int: async def decr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
"""like_count를 1 감소 후 반환 (음수 방지).""" """like_count를 1 감소 후 반환 (음수 방지)."""
count = int(await get_like_cache().decr(_key(video_id))) client = get_like_cache()
key = _key(content_id, ctype)
count = int(await client.decr(key))
if count < 0: if count < 0:
await get_like_cache().set(_key(video_id), 0) await client.set(key, 0)
return 0 return 0
return count return count
@ -131,7 +160,9 @@ async def decr_like_count(video_id: int) -> int:
# 유저 SET (is_liked_by_me source of truth) # 유저 SET (is_liked_by_me source of truth)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]: async def toggle_like_atomic(
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
) -> tuple[bool, int]:
"""Lua 스크립트로 원자적 좋아요 토글. """Lua 스크립트로 원자적 좋아요 토글.
Returns: Returns:
@ -140,14 +171,16 @@ async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
result = await get_like_cache().eval( result = await get_like_cache().eval(
_TOGGLE_LIKE_SCRIPT, _TOGGLE_LIKE_SCRIPT,
2, 2,
_user_key(video_id), _user_key(content_id, ctype),
_key(video_id), _key(content_id, ctype),
user_uuid, user_uuid,
) )
return bool(result[0]), int(result[1]) return bool(result[0]), int(result[1])
async def is_user_liked(video_id: int, user_uuid: str) -> bool | None: async def is_user_liked(
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
) -> bool | None:
"""Redis user-set에서 좋아요 여부 조회. """Redis user-set에서 좋아요 여부 조회.
Returns: Returns:
@ -155,59 +188,101 @@ async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
None: user-set 키가 없음 (cold-start backfill 필요 신호) None: user-set 키가 없음 (cold-start backfill 필요 신호)
""" """
client = get_like_cache() client = get_like_cache()
key = _user_key(video_id) key = _user_key(content_id, ctype)
if not await client.exists(key): if not await client.exists(key):
return None return None
return bool(await client.sismember(key, user_uuid)) return bool(await client.sismember(key, user_uuid))
async def is_user_set_exists(video_id: int) -> bool: async def is_user_set_exists(content_id: int, *, ctype: str = CT_VIDEO) -> bool:
"""Redis user-set 키 존재 여부 확인.""" """Redis user-set 키 존재 여부 확인."""
return bool(await get_like_cache().exists(_user_key(video_id))) return bool(await get_like_cache().exists(_user_key(content_id, ctype)))
async def bulk_is_user_liked( async def bulk_is_user_liked(
video_ids: list[int], user_uuid: str content_ids: list[int], user_uuid: str, *, ctype: str = CT_VIDEO
) -> dict[int, bool | None]: ) -> dict[int, bool | None]:
"""여러 영상의 is_liked 여부를 한 번에 조회 (pipeline). """여러 콘텐츠의 is_liked 여부를 한 번에 조회 (pipeline).
통합 목록처럼 두 종류가 섞인 경우에는 **종류별로 나눠 각각 호출한다** —
반환 키가 content_id 하나여서 종류가 다른 같은 id 를 구분할 수 없다.
Returns: Returns:
{video_id: True/False} — user-set 키가 없는 영상은 None {content_id: True/False} — user-set 키가 없는 항목은 None
""" """
if not video_ids: if not content_ids:
return {} return {}
client = get_like_cache() client = get_like_cache()
async with client.pipeline(transaction=False) as pipe: async with client.pipeline(transaction=False) as pipe:
for vid in video_ids: for cid in content_ids:
pipe.exists(_user_key(vid)) pipe.exists(_user_key(cid, ctype))
pipe.sismember(_user_key(vid), user_uuid) pipe.sismember(_user_key(cid, ctype), user_uuid)
responses = await pipe.execute() responses = await pipe.execute()
return { return {
vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None) cid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
for i, vid in enumerate(video_ids) for i, cid in enumerate(content_ids)
} }
async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None: async def backfill_user_set(
content_id: int, user_uuids: list[str], *, ctype: str = CT_VIDEO
) -> None:
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재.""" """DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
if user_uuids: if user_uuids:
await get_like_cache().sadd(_user_key(video_id), *user_uuids) await get_like_cache().sadd(_user_key(content_id, ctype), *user_uuids)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Dirty SET (Write-Behind 큐) # Dirty SET (Write-Behind 큐)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
async def mark_dirty(video_id: int, user_uuid: str) -> None: async def mark_dirty(
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
) -> None:
"""DB 동기화 대기 목록에 추가.""" """DB 동기화 대기 목록에 추가."""
await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}") await get_like_cache().sadd(_DIRTY_KEY, f"{ctype}:{content_id}:{user_uuid}")
async def drain_dirty() -> list[tuple[int, str]]: def _parse_dirty(member: str) -> tuple[str, int, str] | None:
"""dirty 항목 문자열 → (ctype, content_id, user_uuid).
두 형식을 모두 받는다:
- 현재: "{ctype}:{content_id}:{user_uuid}"
- 구형: "{content_id}:{user_uuid}" ← 종류 도입 전에 큐에 들어간 항목
**구형 관용이 필요한 이유**: 배포 순간 dirty SET 에 구형 항목이 남아 있다.
새 파서가 이를 못 읽으면 그 좋아요는 DB 에 영구 미반영된다.
구형은 ADO2 영상뿐이었으므로 CT_VIDEO 로 해석한다.
형식이 깨진 항목은 None 을 돌려 호출부가 건너뛰게 한다 — 하나 때문에
플러시 전체가 죽으면 큐가 무한히 쌓인다.
"""
parts = member.split(":", 2)
# 길이가 아니라 **첫 토큰이 알려진 종류인지**로 판정한다.
# user_uuid 에 콜론이 있어도 구형이 3조각으로 보일 수 있다.
if len(parts) == 3 and parts[0] in CONTENT_TYPES:
ctype, id_str, user_uuid = parts
else:
ctype = CT_VIDEO
legacy = member.split(":", 1)
if len(legacy) != 2:
return None
id_str, user_uuid = legacy
if not id_str.isdigit() or not user_uuid:
return None
return ctype, int(id_str), user_uuid
async def drain_dirty() -> list[tuple[str, int, str]]:
"""dirty SET을 processing으로 RENAME 후 전체 반환. """dirty SET을 processing으로 RENAME 후 전체 반환.
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지. 이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
Returns:
[(ctype, content_id, user_uuid), ...]
""" """
client = get_like_cache() client = get_like_cache()
@ -223,10 +298,12 @@ async def drain_dirty() -> list[tuple[int, str]]:
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY) await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
members = await client.smembers(_DIRTY_PROCESSING_KEY) members = await client.smembers(_DIRTY_PROCESSING_KEY)
result = [] result: list[tuple[str, int, str]] = []
for member in members: for member in members:
vid_str, user_uuid = member.split(":", 1) parsed = _parse_dirty(member)
result.append((int(vid_str), user_uuid)) if parsed is None:
continue
result.append(parsed)
return result return result

View File

@ -86,6 +86,10 @@ async def create_db_tables():
from app.dashboard.models import Dashboard # noqa: F401 from app.dashboard.models import Dashboard # noqa: F401
from app.backoffice.admin.models import Admin # noqa: F401 from app.backoffice.admin.models import Admin # noqa: F401
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401 from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
from app.ssulbox.models import ( # noqa: F401
SsulContent,
SsulSocialUpload,
)
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저) # 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
tables_to_create = [ tables_to_create = [
@ -106,6 +110,9 @@ async def create_db_tables():
Admin.__table__, Admin.__table__,
CreditChargeRequest.__table__, CreditChargeRequest.__table__,
CreditTransaction.__table__, CreditTransaction.__table__,
# 썰박스 (FK 순서: ssul_content 를 나머지가 참조)
SsulContent.__table__,
SsulSocialUpload.__table__,
] ]
logger.info("Creating database tables...") logger.info("Creating database tables...")

9
app/ssulbox/__init__.py Normal file
View File

@ -0,0 +1,9 @@
"""썰박스(ssulbox) 모듈.
네이버 지도 링크나 업장명을 입력하면 4개 시나리오(조선왕·삼국지·그리스로마신화·오디세이)
중 하나로 대본 → 그림 → 목소리 → 영상을 자동 생성하는 병맛 역사 썰툰 쇼츠 파이프라인.
별도 저장소(o2o-ssulbox)로 개발되던 것을 castad 백엔드로 이식했다.
계정·크레딧·소셜 계정은 castad 것을 그대로 쓰고(User / CreditTransaction / SocialAccount),
썰박스 고유 도메인만 `ssul_` 접두 테이블로 신설한다.
"""

View File

View File

View File

View File

@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""썰박스 API — 장소 검색 · 생성 요청 · 진행 폴링.
castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `/ssul` 로 노출한다.
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from app.credit.exceptions import InsufficientCreditError
from app.database.session import AsyncSessionLocal, get_session
from app.ssulbox.constants import ORPHAN_STATUSES, is_generation_available
from app.ssulbox.exceptions import GenerationUnavailableError, TaskNotFoundError
from app.ssulbox.models import SsulContent
from app.ssulbox.schemas.ssulbox_schema import (
SsulActiveTasksResponse,
SsulCreateRequest,
SsulCreateResponse,
SsulPlaceSearchResponse,
SsulTaskStatus,
)
from app.ssulbox.services import place_service, task_service
from app.ssulbox.worker import job_manager
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from config import ssulbox_settings
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("ssulbox")
router = APIRouter(prefix="/ssul", tags=["Ssulbox"])
@router.get(
"/search/place",
response_model=SsulPlaceSearchResponse,
summary="업장 검색 (네이버 지도)",
description="""
업장명으로 네이버 지도 후보를 검색합니다.
castad `/search/accommodation`(네이버 **검색 API**)과 달리 **`place_url` 을 포함**합니다.
생성 파이프라인이 네이버 지도 place 페이지를 크롤링하므로 이 URL 이 필요합니다.
- 동명 업장은 주소로 구분됩니다.
- 검색 실패·타임아웃 시 빈 목록을 반환합니다(사용자는 네이버 링크를 직접 붙여넣을 수 있습니다).
- Playwright 크롤링이라 수 초 걸립니다.
""",
responses={
200: {"description": "검색 성공 (결과 없음도 200)"},
401: {"description": "인증 실패"},
},
)
async def search_place(
query: str = Query(..., min_length=2, description="업장명"),
limit: int = Query(default=8, ge=1, le=20),
current_user: User = Depends(get_current_user),
) -> SsulPlaceSearchResponse:
items = await place_service.search_places(query, limit=limit)
return SsulPlaceSearchResponse(query=query, count=len(items), items=items)
@router.post(
"/create",
response_model=SsulCreateResponse,
summary="썰박스 생성 요청",
description="""
썰박스 생성을 요청합니다.
## 크레딧
- **요청 시점에 크레딧이 선차감됩니다.** 완료 시점이 아닙니다.
- 생성이 실패하면 자동으로 환불됩니다.
- 잔액이 부족하면 402 를 반환하며 잡도 생성되지 않습니다.
## 진행 확인
응답의 `id` 로 `GET /ssul/tasks/{id}` 를 폴링하세요.
권장 간격은 응답의 `poll_interval_seconds` 입니다.
""",
responses={
200: {"description": "요청 접수"},
401: {"description": "인증 실패"},
402: {"description": "크레딧 부족"},
503: {"description": "생성 기능 비활성 (Gemini API 키 미설정)"},
},
)
async def create_ssul(
body: SsulCreateRequest,
current_user: User = Depends(get_current_user),
) -> SsulCreateResponse:
if not is_generation_available():
raise GenerationUnavailableError("Gemini API 키가 설정되지 않았습니다.")
# 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다.
# 외부 API 호출이 없으므로 Depends(get_session) 대신 짧게 열고 닫는다.
async with AsyncSessionLocal() as session:
try:
row = await task_service.create_task(
session,
user_uuid=current_user.user_uuid,
scenario=body.scenario,
input_text=body.input,
scenes=body.scenes,
seconds=body.seconds,
store_name=body.store_name,
road_address=body.road_address,
address=body.address,
)
await session.commit()
content_id = row.id
except InsufficientCreditError:
await session.rollback()
logger.info(
f"[create_ssul] INSUFFICIENT CREDIT user={current_user.user_uuid}"
)
raise
except Exception:
await session.rollback()
raise
# 커밋이 끝난 뒤에 큐잉한다 — 워커가 아직 없는 행을 조회하면 안 된다.
# create_job 은 앱 이벤트 루프를 캡처하므로 반드시 요청 핸들러에서 호출한다.
job_manager.create_job(
content_id, body.scenario, body.input, body.scenes, body.seconds
)
return SsulCreateResponse(
id=content_id,
status="queued",
poll_interval_seconds=ssulbox_settings.SSULBOX_POLL_HINT_SECONDS,
)
@router.get(
"/tasks/active",
response_model=SsulActiveTasksResponse,
summary="진행 중인 내 생성 잡",
description="""
새로고침·새 탭 진입 시 진행 상태를 복구하는 데 씁니다.
클라이언트의 localStorage 는 새 탭에서 초기화되므로 **서버가 권위**입니다.
""",
)
async def get_active_tasks(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SsulActiveTasksResponse:
rows = (
(
await session.execute(
select(SsulContent)
.where(
SsulContent.user_uuid == current_user.user_uuid,
SsulContent.status.in_(ORPHAN_STATUSES),
SsulContent.is_deleted.is_(False),
)
.order_by(SsulContent.created_at.desc())
)
)
.scalars()
.all()
)
return SsulActiveTasksResponse(
items=[SsulTaskStatus.model_validate(r) for r in rows]
)
@router.get(
"/tasks/{content_id}",
response_model=SsulTaskStatus,
summary="생성 진행 상태 (폴링)",
description="""
생성 진행 상태를 반환합니다. 프론트가 3초마다 폴링합니다.
- `step` 은 완료한 단계 수(0~4)입니다. 0=준비, 4=영상 합성 완료.
- `video_url` 은 `status=done` 일 때만 채워집니다.
- 남의 잡은 404 로 처리합니다(존재 여부를 노출하지 않습니다).
""",
responses={
200: {"description": "조회 성공"},
401: {"description": "인증 실패"},
404: {"description": "잡을 찾을 수 없음"},
},
)
async def get_task(
content_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SsulTaskStatus:
row = await session.get(SsulContent, content_id)
# 남의 잡이면 존재 여부를 알리지 않고 동일하게 404
if row is None or row.user_uuid != current_user.user_uuid:
raise TaskNotFoundError()
return SsulTaskStatus.model_validate(row)

110
app/ssulbox/constants.py Normal file
View File

@ -0,0 +1,110 @@
"""썰박스 상수.
설정(SsulboxSettings)이 아니라 코드와 함께 고정되는 값들. 시나리오 ↔ 엔진 폴더 매핑,
생성 잡 stdout 마커 파싱 규칙, 태스크 상태 enum이 여기 있다.
"""
import re
from enum import Enum
from typing import Final
from config import apikey_settings
# =============================================================================
# 시나리오 ↔ 생성 엔진 폴더
# =============================================================================
# 프론트가 보내는 시나리오 코드를 generator/ 하위 엔진 디렉터리명으로 옮긴다.
# 엔진을 추가하면 여기와 프론트 ssulData.ts 양쪽을 함께 고쳐야 한다.
SCENARIO_ENGINE: Final[dict[str, str]] = {
"joseon": "animation",
"samgukji": "animation_samgukji",
"greek": "animation_greekroman",
"odyssey": "animation_odyssey",
}
ENGINE_SCENARIO: Final[dict[str, str]] = {v: k for k, v in SCENARIO_ENGINE.items()}
SCENARIOS: Final[tuple[str, ...]] = tuple(SCENARIO_ENGINE)
# =============================================================================
# 생성 잡 상태
# =============================================================================
class SsulTaskStatus(str, Enum):
"""생성 잡의 권위 상태. 인메모리 진행률(step)과 달리 DB가 진실 원천이다."""
QUEUED = "queued"
RUNNING = "running"
DONE = "done"
ERROR = "error"
#: 프로세스 기동 시 고아로 판정해 환불·정리할 상태들
ORPHAN_STATUSES: Final[tuple[str, ...]] = (
SsulTaskStatus.QUEUED.value,
SsulTaskStatus.RUNNING.value,
)
# =============================================================================
# 생성 엔진 stdout 파싱
# =============================================================================
#: 엔진이 뱉는 진행 마커. 예) "[2/4] 스토리보드 생성"
STEP_RE: Final[re.Pattern[str]] = re.compile(r"\[(\d)\s*/\s*4\]")
#: 완료 마커. 예) "완성: output/animation/xxx/final.mp4"
DONE_RE: Final[re.Pattern[str]] = re.compile(r"완성[::]\s*(.+\.mp4)")
#: 업장명 마커. 예) "■ 가게: 골목냉면 / 소재 5줄 → 키워드로 사용"
#: place URL 을 직접 붙여넣어 검색을 거치지 않은 경우, 업장명을 얻을 수 있는
#: 유일한 경로다(엔진이 네이버 브리핑에서 뽑아 찍는다).
#: 주소는 찍지 않으므로 이 경로에서는 region 을 채울 수 없다.
STORE_RE: Final[re.Pattern[str]] = re.compile(r"가게\s*[::]\s*([^/\n]+?)\s*(?:/|$)")
#: 작업 폴더 마커. 예) "■ 작업 폴더 : C:\...\output\animation\골목냉면20260729"
#: 실패 시 정리할 대상을 알아내는 유일한 수단이다 — 실패하면 완료 마커가
#: 없어 mp4 경로로 폴더를 역산할 수 없다.
JOB_DIR_RE: Final[re.Pattern[str]] = re.compile(r"작업\s*폴더\s*[::]\s*(.+)")
#: step 1~4에 대응하는 사람이 읽는 단계명 (프론트 진행 표시용)
STEP_NAMES: Final[tuple[str, ...]] = ("대본 생성", "스토리보드", "이미지·음성", "영상 합성")
TOTAL_STEPS: Final[int] = len(STEP_NAMES)
# =============================================================================
# 크레딧 원장 멱등 키
# =============================================================================
#: credit_transaction.job_type 값. (job_type, job_ref, type) 유니크로 중복 차감을 막는다.
JOB_TYPE_SSUL: Final[str] = "ssul"
JOB_TYPE_VIDEO: Final[str] = "video"
# =============================================================================
# Gemini API 키 판정
# =============================================================================
#: castad config 의 GEMINI_API_KEY 기본값이 플레이스홀더 문자열이라
#: `if not key` 형태의 가드가 항상 통과해버린다. 반드시 gemini_key() 로 판정할 것.
_PLACEHOLDER_KEYS: Final[frozenset[str]] = frozenset(
{
"",
"your-gemeni-api-key", # config.py 기본값 (오타 그대로)
"your-gemini-api-key",
"none",
}
)
def gemini_key() -> str | None:
"""실제로 사용 가능한 Gemini API 키를 반환한다. 미설정이면 None.
Returns:
설정된 키. 플레이스홀더이거나 비어 있으면 None.
"""
key = (apikey_settings.GEMINI_API_KEY or "").strip()
return None if key.lower() in _PLACEHOLDER_KEYS else key
def is_generation_available() -> bool:
"""생성 파이프라인을 돌릴 수 있는 상태인지 여부."""
return gemini_key() is not None

146
app/ssulbox/exceptions.py Normal file
View File

@ -0,0 +1,146 @@
"""썰박스 예외.
app/dashboard/exceptions.py 와 동일한 (message, status_code, code) 형태를 따른다.
전역 핸들러가 code 를 그대로 응답 본문에 실어 보내므로, 프론트가 분기에 쓰는
문자열이다 — 이미 나간 code 값은 함부로 바꾸지 말 것.
"""
from fastapi import status
class SsulboxException(Exception):
"""썰박스 기본 예외"""
def __init__(
self,
message: str,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
code: str = "SSULBOX_ERROR",
):
self.message = message
self.status_code = status_code
self.code = code
super().__init__(self.message)
# =============================================================================
# 생성 요청 관련
# =============================================================================
class InvalidScenarioError(SsulboxException):
"""지원하지 않는 시나리오 코드"""
def __init__(self, scenario: str = ""):
message = "지원하지 않는 시나리오입니다."
if scenario:
message += f" ({scenario})"
super().__init__(
message=message,
status_code=status.HTTP_400_BAD_REQUEST,
code="SSUL_INVALID_SCENARIO",
)
class GenerationUnavailableError(SsulboxException):
"""생성 엔진 사용 불가 (Gemini API 키 미설정 등)"""
def __init__(self, detail: str = ""):
message = "썰박스 생성 기능이 현재 비활성화되어 있습니다."
if detail:
message += f" ({detail})"
super().__init__(
message=message,
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
code="SSUL_GENERATION_UNAVAILABLE",
)
class TaskAlreadyRunningError(SsulboxException):
"""이미 진행 중인 생성 잡이 있음"""
def __init__(self):
super().__init__(
message="이미 생성 중인 썰박스가 있습니다. 완료 후 다시 시도해주세요.",
status_code=status.HTTP_409_CONFLICT,
code="SSUL_TASK_ALREADY_RUNNING",
)
# =============================================================================
# 조회 관련
# =============================================================================
class TaskNotFoundError(SsulboxException):
"""생성 잡을 찾을 수 없음 (없거나 남의 것)"""
def __init__(self):
super().__init__(
message="생성 요청을 찾을 수 없습니다.",
status_code=status.HTTP_404_NOT_FOUND,
code="SSUL_TASK_NOT_FOUND",
)
class ContentNotFoundError(SsulboxException):
"""콘텐츠를 찾을 수 없음"""
def __init__(self):
super().__init__(
message="썰박스 콘텐츠를 찾을 수 없습니다.",
status_code=status.HTTP_404_NOT_FOUND,
code="SSUL_CONTENT_NOT_FOUND",
)
class CommentNotFoundError(SsulboxException):
"""댓글을 찾을 수 없음"""
def __init__(self):
super().__init__(
message="댓글을 찾을 수 없습니다.",
status_code=status.HTTP_404_NOT_FOUND,
code="SSUL_COMMENT_NOT_FOUND",
)
class CommentDepthExceededError(SsulboxException):
"""댓글은 2-depth(댓글 + 대댓글)까지만 허용"""
def __init__(self):
super().__init__(
message="대댓글에는 답글을 달 수 없습니다.",
status_code=status.HTTP_400_BAD_REQUEST,
code="SSUL_COMMENT_DEPTH_EXCEEDED",
)
# =============================================================================
# 생성 실행 관련
# =============================================================================
class GenerationFailedError(SsulboxException):
"""생성 엔진 실행 실패"""
def __init__(self, detail: str = ""):
message = "썰박스 생성에 실패했습니다."
if detail:
message += f" ({detail})"
super().__init__(
message=message,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="SSUL_GENERATION_FAILED",
)
class GenerationTimeoutError(SsulboxException):
"""생성 엔진 실행 시간 초과"""
def __init__(self):
super().__init__(
message="썰박스 생성 시간이 초과되었습니다. 크레딧은 환불됩니다.",
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
code="SSUL_GENERATION_TIMEOUT",
)

419
app/ssulbox/migration.py Normal file
View File

@ -0,0 +1,419 @@
"""썰박스 스키마 보장.
**이 프로젝트에는 Alembic 이 없다.** 게다가 `create_db_tables()` 는
`prj_settings.DEBUG` 일 때만 호출되므로(app/core/common.py), 운영 환경에서는
신규 테이블이 조용히 만들어지지 않는다. 그래서 이 모듈이 DEBUG 여부와 무관하게
스키마를 책임진다.
두 가지 일을 한다:
1. **기존 테이블 ALTER** — `user.bio`, `credit_transaction.job_type/job_ref` +
`(job_type, job_ref, type)` 유니크. `create_all` 은 기존 테이블의 컬럼 변경을
하지 못하므로 information_schema 로 존재를 확인한 뒤 직접 DDL 을 친다.
2. **ssul_* 테이블 생성** — 신규 테이블이라 `create_all(checkfirst=True)` 로 안전하다.
모든 단계가 멱등이다. 두 번 연속 기동해도 두 번째에는 아무 DDL 도 실행되지 않는다.
app/dashboard/migration.py 의 information_schema 확인 패턴을 그대로 따른다.
"""
import logging
from sqlalchemy import text
from app.database.session import Base, engine
logger = logging.getLogger(__name__)
LOG_PREFIX = "[SSULBOX_MIGRATE]"
# =============================================================================
# information_schema 조회 헬퍼
# =============================================================================
async def _table_exists(conn, table: str) -> bool:
result = await conn.execute(
text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = :t"
),
{"t": table},
)
return (result.scalar() or 0) > 0
async def _column_exists(conn, table: str, column: str) -> bool:
result = await conn.execute(
text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() "
"AND table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
)
return (result.scalar() or 0) > 0
async def _column_def(conn, table: str, column: str) -> tuple[str, str] | None:
"""(column_type, is_nullable) 또는 없으면 None."""
result = await conn.execute(
text(
"SELECT column_type, is_nullable FROM information_schema.columns "
"WHERE table_schema = DATABASE() "
"AND table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
)
row = result.first()
return (row[0], row[1]) if row else None
async def _check_exists(conn, table: str, name: str) -> bool:
"""CHECK 제약 존재 여부. MySQL 8.0.16+ 의 information_schema 를 본다."""
result = await conn.execute(
text(
"SELECT COUNT(*) FROM information_schema.table_constraints "
"WHERE table_schema = DATABASE() AND table_name = :t "
"AND constraint_name = :c AND constraint_type = 'CHECK'"
),
{"t": table, "c": name},
)
return (result.scalar() or 0) > 0
async def _index_exists(conn, table: str, index: str) -> bool:
result = await conn.execute(
text(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = DATABASE() "
"AND table_name = :t AND index_name = :i"
),
{"t": table, "i": index},
)
return (result.scalar() or 0) > 0
# =============================================================================
# 1) 기존 테이블 ALTER
# =============================================================================
async def _ensure_user_bio(conn) -> bool:
"""user.bio 컬럼 보장 (썰박스 프로필 한 줄 소개)"""
if not await _table_exists(conn, "user"):
logger.warning(f"{LOG_PREFIX} user 테이블 없음 - bio 추가 건너뜀")
return False
if await _column_exists(conn, "user", "bio"):
return False
await conn.execute(
text(
"ALTER TABLE `user` "
"ADD COLUMN `bio` VARCHAR(200) NULL COMMENT '한 줄 소개 (썰박스 프로필)'"
)
)
logger.info(f"{LOG_PREFIX} user.bio 컬럼 추가")
return True
async def _ensure_credit_job_keys(conn) -> bool:
"""credit_transaction 에 크레딧 멱등 키 컬럼 + 유니크 제약 보장.
(job_type, job_ref, type) 유니크가 "작업 1건당 consume 1행 / refund 1행"을
DB 레벨에서 보장한다. MySQL 은 NULL 을 서로 다르게 취급하므로 기존
charge/admin_adjust 행(job_type NULL)은 이 제약의 영향을 받지 않는다.
job_ref 가 문자열인 이유: 썰박스는 ssul_content.id(숫자), castad 영상은
video.task_id(UUID7 문자열)를 앵커로 쓰기 때문에 하나로 담으려면 문자열이어야 한다.
"""
table = "credit_transaction"
if not await _table_exists(conn, table):
logger.warning(f"{LOG_PREFIX} {table} 테이블 없음 - 멱등 키 추가 건너뜀")
return False
changed = False
if not await _column_exists(conn, table, "job_type"):
await conn.execute(
text(
f"ALTER TABLE `{table}` "
"ADD COLUMN `job_type` VARCHAR(20) NULL "
"COMMENT '차감 유발 작업 종류 (ssul/video)'"
)
)
logger.info(f"{LOG_PREFIX} {table}.job_type 컬럼 추가")
changed = True
if not await _column_exists(conn, table, "job_ref"):
await conn.execute(
text(
f"ALTER TABLE `{table}` "
"ADD COLUMN `job_ref` VARCHAR(64) NULL "
"COMMENT '작업 식별자 (ssul_content.id 문자열 또는 video.task_id)'"
)
)
logger.info(f"{LOG_PREFIX} {table}.job_ref 컬럼 추가")
changed = True
if not await _index_exists(conn, table, "uq_credit_job"):
await conn.execute(
text(
f"ALTER TABLE `{table}` "
"ADD UNIQUE KEY `uq_credit_job` (`job_type`, `job_ref`, `type`)"
)
)
logger.info(f"{LOG_PREFIX} {table}.uq_credit_job 유니크 제약 추가")
changed = True
return changed
# =============================================================================
# 2) ssul_* 테이블 생성
# =============================================================================
def _ssul_tables() -> list:
"""생성할 ssul_* 테이블 목록 (FK 순서: 참조 대상 먼저)
ssul_* 는 `user.user_uuid` 와 `social_account.id` 를 참조한다. create_all 은 FK 를
해석할 때 **참조 대상 테이블이 Base.metadata 에 등록되어 있어야** 하므로,
생성 목록에 넣지 않더라도 app.user.models 를 함께 import 해야 한다.
(없으면 NoReferencedTableError 로 실패한다.)
"""
from app.user.models import SocialAccount, User # noqa: F401 # 메타데이터 등록용
from app.ssulbox.models import SsulContent, SsulSocialUpload
# 좋아요·댓글은 castad `video_reaction` / `comment` 에 합쳤으므로
# ssul_like / ssul_comment 는 만들지 않는다(_drop_legacy_... 가 정리한다).
# ssul_content 를 나머지가 참조하므로 먼저 만든다.
return [
SsulContent.__table__,
SsulSocialUpload.__table__,
]
# =============================================================================
# 진입점
# =============================================================================
async def _ensure_ssul_detail_region(conn) -> bool:
"""ssul_content 에 detail_region_info 컬럼 보장.
`create_all` 은 **없는 테이블만** 만들고 기존 테이블에 컬럼을 붙이지 않으므로,
이미 만들어진 환경을 위해 별도 ALTER 가 필요하다.
castad `project.detail_region_info` 와 같은 TEXT NULL 이다. 통합 목록의 지역
필터가 `region` 만 보지 않고 상세 주소를 별칭으로 부분 일치 검색하기 때문에,
이 컬럼이 없으면 썰박스 콘텐츠만 필터 결과가 달라진다.
"""
table = "ssul_content"
# 테이블이 아직 없으면 create_all 이 컬럼까지 포함해 만든다 → 여기서 할 일 없음
if not await _table_exists(conn, table):
return False
if await _column_exists(conn, table, "detail_region_info"):
return False
await conn.execute(
text(
f"ALTER TABLE `{table}` "
"ADD COLUMN `detail_region_info` TEXT NULL "
"COMMENT '상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용'"
)
)
logger.info(f"{LOG_PREFIX} {table}.detail_region_info 컬럼 추가")
return True
async def _ensure_ssul_store_name(conn) -> bool:
"""ssul_content.store_name 을 castad `project.store_name` 과 같은 정의로 맞춘다.
목표: `VARCHAR(255) NOT NULL DEFAULT ''`
(초기 이식본은 `VARCHAR(200) NULL` 이었다)
통합 목록이 이 컬럼을 UNION 하므로 폭·널 허용이 어긋나면 정렬·비교가 미묘하게
달라진다. NOT NULL 로 바꾸면 UNION 결과에 NULL 이 섞이지 않아 응답 매핑에서
COALESCE 도 필요 없다.
**NULL 행을 먼저 빈 문자열로 바꾼다.** 그러지 않으면 MySQL 이 strict 모드에서
MODIFY 를 거부하고, 비-strict 모드에서는 경고만 내고 조용히 변환한다 —
어느 쪽이든 명시적으로 처리하는 편이 안전하다.
"""
table = "ssul_content"
if not await _table_exists(conn, table):
return False # create_all 이 올바른 정의로 만든다
current = await _column_def(conn, table, "store_name")
if current is None:
return False
if current == ("varchar(255)", "NO"):
return False # 이미 목표 정의
null_count = (
await conn.execute(
text(f"SELECT COUNT(*) FROM `{table}` WHERE `store_name` IS NULL")
)
).scalar() or 0
if null_count:
await conn.execute(
text(f"UPDATE `{table}` SET `store_name` = '' WHERE `store_name` IS NULL")
)
logger.info(
f"{LOG_PREFIX} {table}.store_name NULL {null_count}건 → 빈 문자열"
)
await conn.execute(
text(
f"ALTER TABLE `{table}` "
"MODIFY COLUMN `store_name` VARCHAR(255) NOT NULL DEFAULT '' "
"COMMENT '대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)'"
)
)
logger.info(
f"{LOG_PREFIX} {table}.store_name {current} → ('varchar(255)', 'NO')"
)
return True
async def _ensure_reaction_tables_merged(conn) -> bool:
"""`comment` / `video_reaction` 이 썰박스 콘텐츠도 담도록 확장한다.
원래는 `ssul_comment` / `ssul_like` 를 따로 뒀으나, `social_upload` 병합 결정과
맞춰 하나로 합쳤다(2026-07-30). 네 테이블이 모두 0행이던 시점에 수행했다.
각 테이블에 대해:
- `video_id` NOT NULL → NULL (썰박스 행은 비운다)
- `content_id BIGINT NULL` + FK ssul_content 추가
- "정확히 하나만 채워짐" CHECK 추가
- content_id 조회용 인덱스/유니크 추가
**CHECK 가 핵심이다.** 이게 없으면 둘 다 NULL 이거나 둘 다 채워진 행이 조용히
생긴다. MySQL 8.0.16+ 에서 실제로 강제된다(현재 8.4).
"""
changed = False
specs = (
# (테이블, CHECK 이름, 추가 인덱스 SQL 목록)
(
"comment",
"ck_comment_one_target",
["ADD INDEX `idx_comment_content_id` (`content_id`)"],
),
(
"video_reaction",
"ck_video_reaction_one_target",
[
"ADD INDEX `idx_video_reaction_content_id` (`content_id`)",
"ADD UNIQUE KEY `uq_video_reaction_user_content` "
"(`user_uuid`, `content_id`)",
],
),
)
for table, check_name, extra in specs:
if not await _table_exists(conn, table):
continue
if not await _column_exists(conn, table, "content_id"):
await conn.execute(
text(
f"ALTER TABLE `{table}` "
"ADD COLUMN `content_id` BIGINT NULL "
"COMMENT '썰박스 콘텐츠 id (ADO2 대상이면 NULL)', "
f"ADD CONSTRAINT `fk_{table}_content` "
"FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) "
"ON DELETE CASCADE"
)
)
for sql in extra:
await conn.execute(text(f"ALTER TABLE `{table}` {sql}"))
logger.info(f"{LOG_PREFIX} {table}.content_id 추가 (+FK/인덱스)")
changed = True
# video_id 를 nullable 로. 기존 행은 전부 ADO2 라 값이 있어 영향이 없다.
col = await _column_def(conn, table, "video_id")
if col and col[1] == "NO":
await conn.execute(
text(
f"ALTER TABLE `{table}` "
"MODIFY COLUMN `video_id` INT NULL "
"COMMENT 'ADO2 영상 id (썰박스 대상이면 NULL)'"
)
)
logger.info(f"{LOG_PREFIX} {table}.video_id → NULL 허용")
changed = True
if not await _check_exists(conn, table, check_name):
await conn.execute(
text(
f"ALTER TABLE `{table}` ADD CONSTRAINT `{check_name}` "
"CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL))"
)
)
logger.info(f"{LOG_PREFIX} {table}.{check_name} CHECK 추가")
changed = True
return changed
async def _drop_legacy_ssul_reaction_tables(conn) -> bool:
"""병합으로 쓰임이 없어진 `ssul_like` / `ssul_comment` 제거.
**비어 있을 때만 지운다.** 행이 남아 있으면 이관이 끝나지 않은 것이므로
조용히 데이터를 버리지 않고 경고만 남긴다.
"""
changed = False
for table in ("ssul_like", "ssul_comment"):
if not await _table_exists(conn, table):
continue
n = (await conn.execute(text(f"SELECT COUNT(*) FROM `{table}`"))).scalar() or 0
if n:
logger.warning(
f"{LOG_PREFIX} {table} 에 {n}행이 남아 있어 삭제하지 않는다 "
"(comment/video_reaction 으로 이관 후 수동 삭제할 것)"
)
continue
await conn.execute(text(f"DROP TABLE `{table}`"))
logger.info(f"{LOG_PREFIX} {table} 삭제 (병합 완료, 0행)")
changed = True
return changed
async def ensure_ssulbox_schema() -> None:
"""썰박스 스키마 보장. lifespan startup 에서 호출한다.
멱등이므로 매 기동마다 호출해도 안전하다. 실패는 호출부에서 삼켜야 한다 —
썰박스 스키마 문제로 castad 전체가 기동하지 못하면 안 된다.
"""
logger.info(f"{LOG_PREFIX} 스키마 확인 시작")
# 기존 테이블 ALTER (DDL 은 MySQL 에서 암묵적 커밋이라 개별 실행)
async with engine.begin() as conn:
altered_bio = await _ensure_user_bio(conn)
altered_credit = await _ensure_credit_job_keys(conn)
# 테이블이 아직 없으면 아래 create_all 이 올바른 정의로 만든다.
altered_region = await _ensure_ssul_detail_region(conn)
altered_store = await _ensure_ssul_store_name(conn)
# 신규 테이블 생성 — DEBUG 여부와 무관하게 항상 보장한다.
# 신규 테이블이므로 create_all 이 기존 데이터를 위협하지 않는다.
tables = _ssul_tables()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn, tables=tables, checkfirst=True
)
)
# 반응 테이블 병합은 **ssul_content 가 존재한 뒤**에 해야 한다 —
# comment/video_reaction 이 그쪽으로 FK 를 건다.
async with engine.begin() as conn:
altered_merge = await _ensure_reaction_tables_merged(conn)
dropped_legacy = await _drop_legacy_ssul_reaction_tables(conn)
if (
altered_bio
or altered_credit
or altered_region
or altered_store
or altered_merge
or dropped_legacy
):
logger.info(f"{LOG_PREFIX} 스키마 변경 적용 완료")
else:
logger.info(f"{LOG_PREFIX} 변경 없음 (이미 최신)")

445
app/ssulbox/models.py Normal file
View File

@ -0,0 +1,445 @@
"""썰박스 SQLAlchemy 모델.
castad 와 겹치는 테이블은 **신설하지 않고** castad 것을 그대로 쓴다
(user / credit_transaction / social_account, 그리고 2026-07-30 부터 comment /
video_reaction). 그 테이블들은 `video_id` 와 `content_id` 를 모두 nullable 로 두고
**정확히 하나만** 채우도록 CHECK 로 강제한다.
썰박스 고유 도메인(`ssul_content`)만 `ssul_` 접두로 남는다.
컨벤션은 castad 를 따른다: BigInteger PK, user_uuid(String36) 기준 FK,
mysql_engine/charset/collate 명시, 컬럼마다 comment.
`updated_at` 은 대응 castad 테이블이 가진 경우에만 둔다 — `video`/`comment`/`project`/
`lyric`/`song` 은 상태 전이를 겪으면서도 `created_at` 만 갖고, `social_upload` 만 예외다.
주의: Alembic 이 없다. 이 파일을 고친 뒤에는 app/ssulbox/migration.py 의
ensure_ssulbox_schema() 에 대응 DDL 을 함께 추가해야 운영 DB 에 반영된다.
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.user.models import SocialAccount
# MySQL 전용 테이블 옵션 (castad 공통)
_MYSQL_OPTS = {
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
class SsulContent(Base):
"""썰박스 1편 — 생성 요청부터 완성까지 한 행으로 관리한다.
**castad `Video` 와 같은 구조다.** castad 도 "영상 생성 잡"과 "완성된 영상"을
나누지 않고 `video` 한 테이블에 status / result_movie_url 을 함께 둔다.
원본 썰박스는 Task 와 Content 를 나눴지만, 1:1 이면서 목록의 필터(user_uuid,
scenario)와 정렬(created_at)이 서로 다른 테이블에 흩어져 조인 비용이 컸다
(측정: 소유 비율에 따라 18~22ms, 병합 시 1ms 수준).
라이프사이클:
1. 요청 → INSERT (status=queued, video_url=NULL) + 크레딧 선차감
2. 엔진 실행 → UPDATE status/step
3. 완료 → UPDATE video_url/store_name/region, status=done
4. 실패 → UPDATE status=error, error, 크레딧 환불
3번이 INSERT 가 아니라 UPDATE 라 finalize 가 자연히 멱등이다.
목록 조회는 castad `/video/all` 과 동일하게 완성분만 거른다:
WHERE is_deleted=0 AND status='done' AND video_url IS NOT NULL
id 가 크레딧 원장 멱등 키(job_type='ssul', job_ref=str(id))의 앵커다.
"""
__tablename__ = "ssul_content"
__table_args__ = (
# 필터와 정렬이 같은 테이블에 있으므로 복합 인덱스 하나로 filesort 없이 처리된다.
# 전체 목록 (완성분만, 최신순)
Index("idx_ssul_content_list", "is_deleted", "status", "created_at"),
# 내 콘텐츠
Index("idx_ssul_content_user_created", "user_uuid", "created_at"),
# 시나리오 필터
Index("idx_ssul_content_scen_created", "scenario", "created_at"),
# 고아 스윕 (기동 시 queued/running 조회)
Index("idx_ssul_content_status", "status"),
_MYSQL_OPTS,
)
id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자 (크레딧 원장 job_ref 앵커)",
)
# castad `project.user_uuid` 와 동일한 정책(SET NULL).
# 탈퇴해도 콘텐츠는 남고 소유자만 비워진다.
user_uuid: Mapped[Optional[str]] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="SET NULL"),
nullable=True,
comment="생성 요청한 사용자 UUID (탈퇴 시 NULL)",
)
# ==========================================================================
# 생성 요청 정보
# ==========================================================================
scenario: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="시나리오 코드 (joseon/samgukji/greek/odyssey)",
)
input: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="입력값 (네이버 지도 URL 또는 업장명)",
)
# scenes / seconds 는 DB 기본값을 두지 않는다. 기본값(9 / 30)과 허용 범위
# (4~20 / 20~90)는 Pydantic 요청 스키마에서 Field(default, ge, le)로 강제한다.
scenes: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="생성할 장면 수 (요청 스키마에서 4~20 제한, 기본 9)",
)
seconds: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="장면당 초 길이 (요청 스키마에서 20~90 제한, 기본 30)",
)
# ==========================================================================
# 생성 잡 상태
# ==========================================================================
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="queued",
server_default="queued",
comment="상태 (queued/running/done/error). 목록에는 done 만 노출",
)
step: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
comment="진행 단계 0~4 (폴링 응답용. 0=준비, 4=영상 합성 완료)",
)
error: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="실패 사유",
)
# ==========================================================================
# 산출물 (완료 시 채워짐)
# ==========================================================================
video_url: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
comment="완성 영상 URL (Azure Blob 공개 URL 또는 로컬 서빙 경로)",
)
thumbnail_url: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
comment="썸네일 URL (없으면 프론트가 시나리오 표지로 대체)",
)
# ==========================================================================
# 목록 표시 (크롤링 후 채워짐) — castad video 는 Project 에서 가져오는 값들
# ==========================================================================
# castad `project.store_name` 과 동일하게 varchar(255) NOT NULL.
# 통합 목록이 이 컬럼을 UNION 하므로 타입·널 허용이 어긋나면 정렬·비교에서
# 미묘한 차이가 생긴다.
#
# 단 하나 다른 점: **server_default 가 빈 문자열**이다. castad `project` 는
# 크롤링이 끝난 뒤 생성되어 업장명을 이미 알지만, 썰박스는 요청 즉시 행을 만들고
# (크레딧 선차감 때문) 업장명은 그 뒤 크롤링으로 채운다. 기본값이 없으면
# 생성 자체가 불가능하다. 빈 문자열은 "아직 모름"을 뜻하며, 채우는 쪽은
# falsy 검사로 판단한다(`set_place_info`).
store_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
default="",
server_default="",
comment="대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)",
)
region: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="지역 (통합 목록의 지역 필터에 사용)",
)
# castad `project.detail_region_info` 와 동일한 역할·타입(TEXT NULL).
# 지역 필터가 `region` 만 보지 않고 **상세 주소의 별칭까지 부분 일치**로 훑기
# 때문에(`/video/all` 의 SIDO_SEARCH_ALIASES), 이 값이 없으면 썰박스는
# `region IN (cities)` 경로로만 걸려 castad 와 필터 결과가 비대칭이 된다.
detail_region_info: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용",
)
# title / caption / views / like_count / comment_count 는 두지 않는다.
# - castad `video` 도 제목을 갖지 않고 목록 표시는 store_name 으로 한다.
# SNS 업로드 제목·설명은 업로드 시점에 작성해 ssul_social_upload 에 담고,
# 다운로드 파일명은 프론트가 정한다.
# - 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
# 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다.
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default="0",
comment="소프트 삭제 여부",
)
# updated_at 은 **보류**다. 일반론으로는 이런 가변 테이블(queued→running→step→done)에
# 두는 것이 맞고, subprocess 가 멈출 수 있어 "오래 안 움직인 잡 찾기"에도 유용하다.
# 다만 castad `video`/`comment` 에 없어 썰박스만 갖는 게 비대칭이라 미뤘다.
# → ADO2 쪽에 추가할 때 여기도 함께 넣는다(nullable DDL 이라 무중단 가능).
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 요청 일시 (목록 정렬 기준)",
)
def __repr__(self) -> str:
return (
f"<SsulContent(id={self.id}, scenario='{self.scenario}', "
f"status='{self.status}', store_name='{self.store_name}')>"
)
# 좋아요·댓글 모델은 여기 없다.
# castad `video_reaction` / `comment` 에 합쳤다(2026-07-30) — 그쪽 행은
# ADO2 면 video_id, 썰박스면 content_id 가 채워지고 CHECK 로 하나만 강제한다.
# 합친 이유: 네 테이블이 모두 0행이라 이관 비용이 없었고, `like_cache` 가 이미
# 종류별 키를 지원해 Redis write-behind 를 그대로 공유할 수 있었다.
class SsulSocialUpload(Base):
"""썰박스 콘텐츠의 SNS 업로드 기록.
castad `social_upload` 를 재사용하지 못하는 이유: 그쪽 `video_id` 가 NOT NULL 이고
`Video` 를 lazy="selectin" 으로 물고 있어, nullable 로 바꾸면
app/social/services/upload_service.py · app/dashboard/migration.py · 백오피스가
모두 영향을 받는다. 대신 구조를 그대로 본떠 신설한다 —
**컬럼 구성은 castad social_upload 와 완전히 동일하다(21개).**
차이는 두 가지뿐이다: content_id 가 bigint(ssul_content.id 를 따름), 그리고
DB 기본값(server_default)을 명시해 ORM 을 우회한 INSERT 도 안전하게 했다.
예약 업로드(`scheduled_at`)는 castad 에서 이미 동작한다
(app/social/services/upload_service.py 가 예약/즉시를 분기하고 충돌 검사도 한다).
이식 시 같은 서비스 로직을 재사용할 수 있다.
알려진 대가: app/dashboard/migration.py 가 SocialUpload 만 읽으므로 썰박스 업로드는
대시보드 통계에 잡히지 않는다. 통합 시점은 별도 결정 사항이다.
"""
__tablename__ = "ssul_social_upload"
__table_args__ = (
# (content_id, social_account_id, upload_seq) 가 앞 2개 컬럼 조회도 커버하므로
# (content_id, social_account_id) 를 따로 두지 않는다.
# 참고: castad social_upload 에는 이 중복이 남아 있다(video_account, video_id).
Index("idx_ssul_upload_seq", "content_id", "social_account_id", "upload_seq"),
Index("idx_ssul_upload_user", "user_uuid"),
Index("idx_ssul_upload_status", "status"),
Index("idx_ssul_upload_platform", "platform"),
Index("idx_ssul_upload_created_at", "created_at"),
_MYSQL_OPTS,
)
id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
user_uuid: Mapped[str] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="CASCADE"),
nullable=False,
comment="업로드한 사용자 UUID",
)
content_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("ssul_content.id", ondelete="CASCADE"),
nullable=False,
comment="업로드 대상 콘텐츠 ID",
)
social_account_id: Mapped[int] = mapped_column(
# social_account.id 는 BIGINT 가 아니라 INT 다(castad social_upload 도 동일).
# BigInteger 로 두면 MySQL 이 FK 타입 불일치(errno 3780)로 생성을 거부한다.
Integer,
ForeignKey("social_account.id", ondelete="CASCADE"),
nullable=False,
comment="연동 SNS 계정 ID (castad social_account 재사용)",
)
upload_seq: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
server_default="1",
comment="(content, account) 조합 내 업로드 순번 — 재업로드 버전 관리",
)
platform: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="플랫폼 (youtube/instagram/facebook/tiktok)",
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
server_default="pending",
comment="상태 (scheduled/pending/uploading/processing/completed/failed)",
)
upload_progress: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
comment="업로드 진행률 (0~100)",
)
platform_video_id: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="플랫폼 측 영상 ID",
)
platform_url: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
comment="플랫폼 측 영상 URL",
)
title: Mapped[str] = mapped_column(
String(200),
nullable=False,
default="",
server_default="",
comment="업로드 제목",
)
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="업로드 설명",
)
tags: Mapped[Optional[list]] = mapped_column(
JSON,
nullable=True,
comment="태그 목록",
)
privacy_status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="public",
server_default="public",
comment="공개 범위 (public/unlisted/private)",
)
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="예약 업로드 시각 (NULL 이면 즉시 업로드)",
)
platform_options: Mapped[Optional[dict]] = mapped_column(
JSON,
nullable=True,
comment="플랫폼별 추가 옵션",
)
error_message: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="실패 사유",
)
retry_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
comment="재시도 횟수",
)
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="업로드 완료 일시",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="수정 일시",
)
content: Mapped["SsulContent"] = relationship(
"SsulContent",
foreign_keys=[content_id],
lazy="noload",
)
social_account: Mapped["SocialAccount"] = relationship(
"SocialAccount",
foreign_keys=[social_account_id],
lazy="noload",
)
def __repr__(self) -> str:
return (
f"<SsulSocialUpload(id={self.id}, content_id={self.content_id}, "
f"platform='{self.platform}', status='{self.status}')>"
)

View File

View File

@ -0,0 +1,117 @@
"""썰박스 API 요청/응답 스키마 (Pydantic v2)."""
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, Field
from app.ssulbox.constants import SCENARIOS
ScenarioLiteral = Literal["joseon", "samgukji", "greek", "odyssey"]
# =============================================================================
# 장소 검색
# =============================================================================
class SsulPlaceItem(BaseModel):
"""네이버 지도 검색 후보.
castad `/search/accommodation` 응답과 달리 **place_url 을 포함**한다.
generator 가 이 URL 로 place 페이지를 크롤링하기 때문이다.
"""
title: str = Field(..., description="업장명")
category: str = Field(default="", description="업종")
address: str = Field(default="", description="표시용 주소 (동명 업장 구분에 사용)")
roadAddress: str = Field(default="", description="도로명 주소")
place_url: str = Field(..., description="네이버 지도 place URL (생성 파이프라인 입력)")
class SsulPlaceSearchResponse(BaseModel):
query: str
count: int
items: list[SsulPlaceItem]
# =============================================================================
# 생성 요청
# =============================================================================
class SsulCreateRequest(BaseModel):
"""생성 요청.
`scenes`/`seconds` 의 기본값과 허용 범위는 **여기서만** 강제한다.
DB 에 기본값을 두면 진실이 두 곳에 생기므로 모델에는 두지 않았다.
"""
scenario: ScenarioLiteral = Field(..., description="시나리오 코드")
input: str = Field(
...,
min_length=2,
max_length=500,
description="네이버 지도 place URL 또는 업장명",
)
scenes: int = Field(default=9, ge=4, le=20, description="장면 수")
seconds: int = Field(default=30, ge=20, le=90, description="장면당 초 길이")
# 검색으로 업장을 고른 경우 프론트가 함께 보낸다(`/ssul/search/place` 결과).
# 통합 목록의 업장명 표시와 store_name/region 필터가 이 값에 의존한다.
# place URL 을 직접 붙여넣은 경우에는 없으며, 그때 store_name 은 생성 로그의
# `■ 가게:` 마커로 뒤늦게 채운다(region 은 주소가 없어 채울 수 없다).
store_name: str | None = Field(
default=None, max_length=200, description="업장명 (검색 선택 시)"
)
# 도로명·지번을 모두 받는다. castad `/home/crawl` 과 같이 도로명에서 시/군 추출이
# 실패하면 지번으로 재시도해야 지역이 비는 경우를 줄인다.
road_address: str | None = Field(
default=None,
max_length=300,
description="도로명 주소 (검색 선택 시). region 추출에만 쓰고 저장하지 않는다",
)
address: str | None = Field(
default=None,
max_length=300,
description="지번 주소 (검색 선택 시). 도로명 추출 실패 시 폴백",
)
class SsulCreateResponse(BaseModel):
id: int = Field(..., description="생성 잡 ID (폴링·크레딧 원장 앵커)")
status: str = Field(..., description="queued")
poll_interval_seconds: int = Field(
..., description="권장 폴링 간격(초). 클라이언트가 참고한다"
)
# =============================================================================
# 진행 상태 (폴링)
# =============================================================================
class SsulTaskStatus(BaseModel):
"""`GET /ssul/tasks/{id}` 응답. 프론트가 3초마다 폴링한다."""
id: int
scenario: str
status: Literal["queued", "running", "done", "error"]
step: int = Field(..., ge=0, le=4, description="완료한 단계 수 (0=준비, 4=합성 완료)")
error: Optional[str] = None
video_url: Optional[str] = Field(None, description="완료 시에만 채워진다")
created_at: datetime
model_config = {"from_attributes": True}
class SsulActiveTasksResponse(BaseModel):
"""진행 중인 내 잡. 새로고침·새 탭 복구에 쓴다"""
items: list[SsulTaskStatus]
__all__ = [
"SCENARIOS",
"ScenarioLiteral",
"SsulActiveTasksResponse",
"SsulCreateRequest",
"SsulCreateResponse",
"SsulPlaceItem",
"SsulPlaceSearchResponse",
"SsulTaskStatus",
]

View File

View File

@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""썰박스 산출물 Blob 업로드.
castad `AzureBlobUploader` 를 그대로 재사용한다 — 원본 썰박스의 `blob_client.py`
는 이식하지 않는다. 같은 Azure 계정을 쓰므로 업로더를 두 벌 둘 이유가 없다.
경로는 `{user_uuid}/{task_id}/video/{file}` 형태가 되는데, ADO2 콘텐츠와 섞이지 않도록
task_id 자리에 `ssulbox-{id}` 접두를 붙인다.
"""
from pathlib import Path
from typing import Optional
from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader
from config import azure_blob_settings, ssulbox_settings
logger = get_logger("ssulbox")
#: 설정되지 않았을 때의 플레이스홀더 (config.py 기본값)
_PLACEHOLDER_SAS = {"", "your-sas-token", "none"}
def blob_enabled() -> bool:
"""Blob 업로드가 가능한 상태인지.
비활성이면 로컬 파일을 그대로 서빙한다(개발 환경).
"""
token = (azure_blob_settings.AZURE_BLOB_SAS_TOKEN or "").strip()
return token.lower() not in _PLACEHOLDER_SAS
async def upload_ssul_video(
mp4_path: Path, user_uuid: Optional[str], content_id: int
) -> Optional[str]:
"""완성 영상을 Blob 에 올리고 공개 URL 을 반환. 실패·비활성이면 None.
Args:
mp4_path: 로컬 mp4 경로
user_uuid: 소유자. 탈퇴로 NULL 이면 업로드하지 않는다
content_id: `ssul_content.id`
Returns:
SAS 토큰이 제외된 공개 URL, 또는 None
"""
if not blob_enabled():
logger.info("[upload_ssul_video] Blob 비활성 — 로컬 서빙")
return None
if not user_uuid:
logger.warning(f"[upload_ssul_video] user_uuid 없음 id={content_id}")
return None
if not mp4_path.exists():
logger.error(f"[upload_ssul_video] 파일 없음 {mp4_path}")
return None
# ADO2 영상과 경로를 분리한다
task_id = f"{ssulbox_settings.SSULBOX_BLOB_PREFIX}-{content_id}"
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
success = await uploader.upload_video(file_path=str(mp4_path))
if not success:
logger.error(f"[upload_ssul_video] 업로드 실패 id={content_id}")
return None
logger.info(f"[upload_ssul_video] OK id={content_id} url={uploader.public_url}")
return uploader.public_url

View File

@ -0,0 +1,327 @@
# -*- 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 `/search/accommodation` 으로 대체할 수 없다.** 그쪽은 네이버 *검색 API* 라
`title`/`address`/`roadAddress` 만 주고 `place_url` 이 없는데, generator 가 place 페이지를
크롤링하므로 URL 이 반드시 필요하다. (castad `NvMapPwScraper` 는 후보=NAVER API,
place_id 해석=Playwright 로 2단계를 밟지만 여기서는 지도만으로 한 번에 얻는다.)
"""
import asyncio
import json
import sys
from urllib.parse import quote
from playwright.async_api import async_playwright
from app.utils.logger import get_logger
# URL 정규화(단축링크 해석·place_id 추출)는 castad 크롤러 것을 그대로 쓴다.
# 무거운 scrap() 은 쓰지 않는다 — fetch_place_detail docstring 참조.
from app.utils.nvMapScraper import NvMapScraper, URLNotFoundException
logger = get_logger("ssulbox")
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개로 처리.
# 이때도 **주소를 반드시 채운다**. 예전에는 빈 문자열을 돌려줬는데,
# 그러면 프론트가 주소를 못 보내고 지역(region)이 NULL 이 되어
# 그 콘텐츠가 통합 목록의 지역 필터에서 영구 제외된다.
if "/place/" in page.url and "/search/" not in page.url:
detail = await _extract_detail_from_page(page)
if detail:
detail["place_url"] = page.url
return [detail]
# 상세 파싱까지 실패하면 최소 정보라도 준다(생성은 가능해야 한다)
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 _detail_from_state(state: dict) -> dict | None:
"""place 상세 페이지 apollo state → {title, category, address, roadAddress}.
실측(2026-07-29, `zzz/_probe_place_detail.py`): 상세는 `pcmap.place.naver.com`
iframe 안에 `PlaceDetailBase:{place_id}` 키로 들어 있고 name/address/roadAddress/
category 를 모두 갖는다. iframe 경로에 업종 세그먼트가 끼므로
(`/restaurant/{id}/home`) 경로를 고정하면 안 된다.
"""
for key, obj in state.items():
if not key.startswith("PlaceDetailBase:") or not isinstance(obj, dict):
continue
name = (obj.get("name") or "").strip()
if not name:
continue
return {
"title": name,
"category": (obj.get("category") or "").strip(),
"address": (obj.get("address") or "").strip(),
"roadAddress": (obj.get("roadAddress") or "").strip(),
}
return None
async def _extract_detail_from_page(page, tries: int = 12) -> dict | None:
"""열려 있는 place 상세 페이지에서 업장 정보를 뽑는다(iframe 탐색 + 재시도)."""
for _ in range(tries):
for frame in [page, *[f for f in page.frames if "pcmap" in f.url]]:
try:
html = await frame.content()
except Exception:
continue
state = _extract_apollo_state(html)
if not state:
continue
detail = _detail_from_state(state)
if detail:
return detail
await page.wait_for_timeout(700)
return None
async def _detail(place_url: str) -> dict | None:
"""place URL 하나를 열어 업장명·주소를 수집."""
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(place_url, wait_until="domcontentloaded", timeout=40000)
detail = await _extract_detail_from_page(page)
if detail:
detail["place_url"] = page.url
return detail
finally:
await browser.close()
def _detail_blocking(place_url: str) -> dict | None:
"""`_search_blocking` 과 같은 이유로 스레드에서 자체 루프를 쓴다."""
loop = (
asyncio.ProactorEventLoop() if sys.platform == "win32"
else asyncio.new_event_loop()
)
try:
return loop.run_until_complete(_detail(place_url))
finally:
loop.close()
async def fetch_place_detail(
place_url: str, timeout: float = 45.0
) -> dict | None:
"""place URL 로 업장명·주소를 수집. 실패하면 None.
**업장명과 주소는 항상 함께 수집한다** — 주소가 없으면 지역(region)을 못 만들고,
그러면 통합 콘텐츠 목록의 지역 필터에서 그 콘텐츠가 영구히 제외된다.
URL 정규화는 castad `NvMapScraper.parse_url()` 을 **재사용**한다.
`naver.me` 단축링크를 브라우저 없이 HTTP 리다이렉트로 풀고
`place.naver.com/{업종}/{id}` 형식도 처리하므로, ADO2 크롤링과 **같은 URL 형식**을
받아들이게 된다. 형식이 아예 아니면 브라우저를 띄우기 전에 즉시 포기한다.
반면 `NvMapScraper.scrap()` 은 쓰지 않는다 — 사진 다중 페이지·리뷰 통계·
편의시설·메뉴까지 전부 긁어오므로 이름·주소만 필요한 여기에는 과하다.
실패해도 예외를 올리지 않는다: 이 정보는 목록 표시·필터용 부가 정보이고,
생성 자체는 place_url 만으로 진행되므로 크롤링 실패가 생성을 막아선 안 된다.
"""
place_url = (place_url or "").strip()
if not place_url:
return None
# 단축링크 해석 + place_id 추출 (브라우저 없이). 실패해도 원본 URL 로 계속 간다.
try:
place_id = await NvMapScraper(place_url).parse_url()
place_url = f"https://map.naver.com/p/entry/place/{place_id}"
except URLNotFoundException:
logger.warning(f"[fetch_place_detail] place URL 아님 - {place_url}")
return None
except Exception as e:
logger.info(
f"[fetch_place_detail] URL 정규화 실패, 원본으로 진행 - "
f"{type(e).__name__}: {e}"
)
try:
detail = await asyncio.wait_for(
asyncio.to_thread(_detail_blocking, place_url), timeout=timeout
)
if detail:
logger.info(
f"[fetch_place_detail] {place_url} → "
f"title={detail['title']!r} road={detail['roadAddress']!r}"
)
else:
logger.warning(f"[fetch_place_detail] 정보 없음 - {place_url}")
return detail
except asyncio.TimeoutError:
logger.warning(f"[fetch_place_detail] TIMEOUT ({timeout}s) - {place_url}")
return None
except Exception as e:
logger.error(
f"[fetch_place_detail] FAILED {place_url} - {type(e).__name__}: {e}",
exc_info=True,
)
return None
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:
results = await asyncio.wait_for(
asyncio.to_thread(_search_blocking, query, limit), timeout=timeout
)
logger.info(f"[search_places] query='{query}' → {len(results)}건")
return results
except asyncio.TimeoutError:
logger.warning(f"[search_places] TIMEOUT ({timeout}s) query='{query}'")
return []
except Exception as e:
logger.error(
f"[search_places] FAILED query='{query}' - {type(e).__name__}: {e}",
exc_info=True,
)
return []

View File

@ -0,0 +1,294 @@
# -*- coding: utf-8 -*-
"""썰박스 생성 잡 라이프사이클 — 생성(선차감) · 진행 · 완료 · 실패(환불) · 고아 스윕.
**모든 함수는 자체 commit 하지 않는다.** caller 가 트랜잭션을 소유하고 마지막에 한 번
커밋한다(둘 다 성공 or 둘 다 롤백). 되돌릴 수 없는 부수효과(로컬 파일 삭제)는
`finalize_task` 가 반환한 폴더를 caller 가 **커밋 성공 후에** 정리한다.
원본과 달라진 점: Task/Content 를 한 테이블(`ssul_content`)로 합쳤으므로
`finalize` 가 INSERT 가 아니라 **UPDATE** 다 — 멱등성이 자연히 확보된다.
"""
import shutil
from pathlib import Path
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.credit.services.credit_service import (
deduct_credit_for_job,
refund_credit_for_job,
)
from app.ssulbox.constants import JOB_TYPE_SSUL, ORPHAN_STATUSES, SsulTaskStatus
from app.ssulbox.models import SsulContent
# castad 와 **같은 규칙으로** 지역을 뽑는다. 통합 목록에서 한 필터가 양쪽을
# 걸러야 하므로 region 값의 형식이 일치해야 한다.
from app.utils.address_parser import extract_region_from_address
from app.utils.logger import get_logger
from config import ssulbox_settings
logger = get_logger("ssulbox")
def _job_ref(content_id: int) -> str:
"""크레딧 원장 멱등 키. (job_type, job_ref, type) 유니크의 일부"""
return str(content_id)
async def create_task(
session: AsyncSession,
*,
user_uuid: str,
scenario: str,
input_text: str,
scenes: int,
seconds: int,
store_name: Optional[str] = None,
road_address: Optional[str] = None,
address: Optional[str] = None,
) -> SsulContent:
"""행 삽입 + 크레딧 선차감을 **한 트랜잭션**으로 묶는다.
사후차감이면 차감 전에 동시 요청이 들어와 크레딧 1개로 여러 개를 만들 수 있다.
잔액 부족 시 `InsufficientCreditError` 가 전파되므로 caller 가 402 로 변환한다.
`store_name`/`address` 는 검색으로 업장을 고른 경우에만 들어온다.
**생성 시점에 넣는 이유**: 통합 목록의 store_name/region 필터가 이 값에 걸리고,
생성 중인 항목도 `내 콘텐츠`에서 업장명으로 보여야 한다. 완료를 기다리면
그 사이 목록에 이름 없는 카드가 뜬다.
주소는 region 추출에만 쓰고 저장하지 않는다(castad `project` 와 달리 상세 주소를
보관할 화면이 없다). castad `/home/crawl` 과 같이 **도로명·지번을 모두** 넘겨
도로명에서 시/군 추출이 실패하면 지번으로 재시도하게 한다.
"""
row = SsulContent(
user_uuid=user_uuid,
scenario=scenario,
input=input_text,
scenes=scenes,
seconds=seconds,
status=SsulTaskStatus.QUEUED.value,
step=0,
# store_name 은 NOT NULL 이다. 아직 모르면 빈 문자열 —
# 채우는 쪽(`set_place_info`)이 falsy 검사로 "미확정"을 판단한다.
store_name=store_name or "",
region=extract_region_from_address(road_address or None, address or None)
or None,
# castad `/home/crawl` 과 동일: 도로명 우선, 없으면 지번
detail_region_info=(road_address or address or None),
)
session.add(row)
await session.flush() # autoincrement id 확보 — 크레딧 멱등 키로 쓴다
await deduct_credit_for_job(
session=session,
user_uuid=user_uuid,
amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO,
job_type=JOB_TYPE_SSUL,
job_ref=_job_ref(row.id),
reason="썰박스 생성",
)
logger.info(
f"[create_task] id={row.id} user={user_uuid} scenario={scenario}"
)
return row
async def mark_running(session: AsyncSession, content_id: int) -> None:
"""잡이 실제로 시작됐을 때"""
row = await session.get(SsulContent, content_id)
if row is None or row.status != SsulTaskStatus.QUEUED.value:
return
row.status = SsulTaskStatus.RUNNING.value
async def update_step(session: AsyncSession, content_id: int, step: int) -> None:
"""진행 단계 갱신.
원본은 step 을 인메모리에만 뒀지만 castad 는 SSE 대신 폴링을 쓰므로
**DB 에 영속**해야 `GET /ssul/tasks/{id}` 가 진행률을 돌려줄 수 있다.
"""
row = await session.get(SsulContent, content_id)
if row is None or row.status in (
SsulTaskStatus.DONE.value,
SsulTaskStatus.ERROR.value,
):
return
row.status = SsulTaskStatus.RUNNING.value
row.step = max(row.step, step) # 뒤로 가지 않는다
async def finalize_task(
session: AsyncSession,
content_id: int,
mp4_path: Path,
*,
store_name: Optional[str] = None,
region: Optional[str] = None,
) -> tuple[Optional[SsulContent], Optional[Path]]:
"""완료 처리. 같은 행을 UPDATE 하므로 **멱등**이다.
Returns:
(행, 커밋 성공 후 정리할 로컬 job 폴더 또는 None)
폴더 삭제는 되돌릴 수 없으므로 반드시 커밋이 성공한 뒤에 한다.
"""
row = await session.get(SsulContent, content_id)
if row is None:
logger.warning(f"[finalize_task] 행 없음 id={content_id}")
return None, None
if row.status == SsulTaskStatus.DONE.value:
return row, None # 이미 처리됨
job_dir = mp4_path.parent
cleanup: Optional[Path] = None
video_url: Optional[str] = None
# Blob 이 설정돼 있으면 업로드하고 로컬은 커밋 후 정리한다.
# 아니면 로컬 경로를 그대로 서빙한다.
try:
from app.ssulbox.services.blob_service import upload_ssul_video
video_url = await upload_ssul_video(mp4_path, row.user_uuid, content_id)
if video_url:
cleanup = job_dir
except Exception as e:
logger.error(
f"[finalize_task] Blob 업로드 실패 id={content_id} - {type(e).__name__}: {e}",
exc_info=True,
)
if not video_url:
# 로컬 서빙 경로 (StaticFiles 마운트 기준 상대 경로)
try:
rel = mp4_path.resolve().relative_to(
ssulbox_settings.output_path.resolve()
)
video_url = f"/ssul-videos/{rel.as_posix()}"
except ValueError:
logger.error(f"[finalize_task] output 밖 경로 id={content_id} path={mp4_path}")
row.video_url = video_url
# 생성 시점에 이미 채워진 값(검색으로 사용자가 직접 고른 업장)이 우선이다.
# 여기 들어오는 값은 생성 로그에서 뒤늦게 주워온 것이므로 덮어쓰지 않는다.
if store_name and not row.store_name:
row.store_name = store_name
if region and not row.region:
row.region = region
row.status = SsulTaskStatus.DONE.value
row.step = 4
logger.info(f"[finalize_task] DONE id={content_id} url={video_url}")
return row, cleanup
async def fail_task(
session: AsyncSession, content_id: int, error: str
) -> Optional[SsulContent]:
"""실패 처리: 환불(멱등) + status=error. 이미 터미널이면 건너뛴다."""
row = await session.get(SsulContent, content_id)
if row is None:
return None
if row.status in (SsulTaskStatus.DONE.value, SsulTaskStatus.ERROR.value):
return row
if row.user_uuid: # 탈퇴로 NULL 이 된 경우 환불 대상이 없다
await refund_credit_for_job(
session=session,
user_uuid=row.user_uuid,
amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO,
job_type=JOB_TYPE_SSUL,
job_ref=_job_ref(content_id),
reason="썰박스 생성 실패 환불",
)
row.status = SsulTaskStatus.ERROR.value
row.error = (error or "생성 실패")[:2000]
logger.info(f"[fail_task] id={content_id} error={row.error[:80]}")
return row
async def get_place_info(
session: AsyncSession, content_id: int
) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""(store_name, region, detail_region_info).
크롤링으로 채울 값이 남았는지 판단하는 데 쓴다.
"""
row = await session.get(SsulContent, content_id)
if row is None:
return None, None, None
return row.store_name, row.region, row.detail_region_info
async def set_place_info(
session: AsyncSession,
content_id: int,
*,
store_name: Optional[str] = None,
region: Optional[str] = None,
detail_region_info: Optional[str] = None,
) -> Optional[SsulContent]:
"""크롤링으로 얻은 업장 정보를 채운다. **이미 있는 값은 덮지 않는다.**
사용자가 검색으로 직접 고른 값이 크롤링 추정치보다 정확하므로 우선한다.
"""
row = await session.get(SsulContent, content_id)
if row is None:
return None
if store_name and not row.store_name:
row.store_name = store_name
if region and not row.region:
row.region = region
if detail_region_info and not row.detail_region_info:
row.detail_region_info = detail_region_info
logger.info(
f"[set_place_info] id={content_id} store={row.store_name!r} "
f"region={row.region!r} detail={(row.detail_region_info or '')[:30]!r}"
)
return row
async def sweep_orphans(session: AsyncSession) -> int:
"""기동 시 고아 잡 정리 — 환불 + error.
불변식: 프로세스 기동 직후 인메모리 잡은 0개이므로 DB 의 queued/running 은
**전부 이전 프로세스의 고아**다. finalize 가 단일 트랜잭션이라
"영상은 만들어졌는데 running" 같은 중간 상태는 존재하지 않는다.
⚠️ 이 불변식은 **단일 워커 전제**다. `--workers` 를 늘리면 워커 B 가 기동하며
워커 A 가 지금 돌리는 잡을 고아로 오판해 환불·error 처리한다.
"""
rows = (
(
await session.execute(
select(SsulContent).where(SsulContent.status.in_(ORPHAN_STATUSES))
)
)
.scalars()
.all()
)
for row in rows:
if row.user_uuid:
await refund_credit_for_job(
session=session,
user_uuid=row.user_uuid,
amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO,
job_type=JOB_TYPE_SSUL,
job_ref=_job_ref(row.id),
reason="서버 재시작 환불",
)
row.status = SsulTaskStatus.ERROR.value
row.error = "서버 재시작으로 중단되었습니다. 크레딧은 환불되었습니다."
if rows:
logger.info(f"[sweep_orphans] 고아 {len(rows)}건 환불·정리")
return len(rows)
def cleanup_job_dir(job_dir: Optional[Path]) -> None:
"""생성 산출물 폴더 삭제. **커밋이 성공한 뒤에만** 호출할 것."""
if not job_dir or not job_dir.exists():
return
try:
shutil.rmtree(job_dir)
logger.info(f"[cleanup_job_dir] 삭제 {job_dir}")
except Exception as e:
logger.warning(f"[cleanup_job_dir] 삭제 실패 {job_dir} - {e}")

View File

View File

@ -0,0 +1,461 @@
# -*- coding: utf-8 -*-
"""썰박스 생성 잡 매니저 — subprocess 감독 + 진행 단계 DB 영속화.
원본(o2o-ssulbox/app/jobs.py)에서 이식하되 4가지를 바꿨다:
1. **SSE 제거** — castad 는 폴링을 쓴다. `_subscribers`/`_emit` 을 통째로 걷어냈다.
2. **step 을 DB 에 영속화** — 원본은 인메모리 `_jobs` 에만 뒀지만, 폴링이 진행률을
돌려주려면 DB 에 있어야 한다. 나중에 워커를 늘려도 폴링은 그대로 동작한다.
3. **세션 팩토리를 BackgroundSessionLocal 로** — 요청용 풀(20+20)을 장시간 잡이
잠식하지 않게 한다. castad `video_task.py` 와 같은 관행.
4. **좀비 방지** — 원본은 `p.wait()` 에 타임아웃이 없어 엔진이 걸리면 스레드가
영구 블록되고 `_running` 이 안 줄어 큐가 멎었다. **감시견 타이머**로 데드라인에
프로세스를 죽인다. `wait(timeout=)` 만 걸면 안 된다 — stdout 읽기 루프가
EOF 까지 블록하므로 엔진이 조용히 매달리면 wait() 에 도달조차 못 한다
(2026-07-29 `zzz/_ssul_timeout_verify.py` 로 확인한 실제 결함).
⚠️ **단일 워커 전제** — `_jobs`/`_running` 이 프로세스 로컬이고, `sweep_orphans` 가
"기동 시 비터미널 잡은 전부 고아"라는 불변식에 의존한다. `--workers` 를 늘리면
워커 B 가 기동하며 워커 A 의 정상 잡을 환불·error 처리한다.
"""
import asyncio
import os
import subprocess
import sys
import threading
import time
from collections import deque
from pathlib import Path
from typing import Any, Optional
from app.database.session import BackgroundSessionLocal
from app.ssulbox.constants import (
DONE_RE,
JOB_DIR_RE,
SCENARIO_ENGINE,
STEP_NAMES,
STEP_RE,
STORE_RE,
gemini_key,
)
from app.ssulbox.services import place_service, task_service
from app.utils.address_parser import extract_region_from_address
from app.utils.logger import get_logger
from config import ssulbox_settings
logger = get_logger("ssulbox")
# ── 프로세스 로컬 상태 ────────────────────────────────────────
#: 진행 중인 잡의 로그·타이밍 (권위 아님 — 권위는 DB status/step)
_jobs: dict[int, dict[str, Any]] = {}
_queue: deque[int] = deque()
_lock = threading.Lock()
_running = 0
_shutting_down = False
#: 앱 이벤트 루프. 워커 스레드가 DB 작업을 위임할 대상
_loop: Optional[asyncio.AbstractEventLoop] = None
def create_job(
content_id: int, scenario: str, input_text: str, scenes: int, seconds: int
) -> None:
"""잡을 큐에 넣는다.
**반드시 앱 이벤트 루프(요청 핸들러)에서 호출해야 한다** — 여기서 루프를 캡처해
워커 스레드가 DB 작업을 위임할 때 쓴다.
"""
global _loop
_loop = asyncio.get_running_loop()
_jobs[content_id] = {
"id": content_id,
"scenario": scenario,
"input": input_text,
"scenes": scenes,
"seconds": seconds,
"status": "queued",
"step": 0,
"log": [],
"output": None,
"job_dir": None, # generator 가 stdout 으로 알려준다. 실패 정리 대상
"store_name": None, # 검색을 안 거친 경우 생성 로그에서 주워온다
"error": None,
"timings": {},
}
with _lock:
_queue.append(content_id)
_pump()
def get_job(content_id: int) -> Optional[dict]:
"""인메모리 진행 정보. 로그 확인용이며 권위는 DB 다."""
return _jobs.get(content_id)
def shutdown() -> None:
"""신규 큐잉을 막는다. lifespan shutdown 에서 dispose_engine 전에 호출."""
global _shutting_down
_shutting_down = True
with _lock:
dropped = len(_queue)
_queue.clear()
if dropped:
logger.info(f"[job_manager] 종료 — 대기 중이던 {dropped}건은 다음 기동 스윕이 처리")
def _run_db(coro, timeout: Optional[int] = None):
"""워커 스레드에서 앱 루프에 DB 코루틴을 위임하고 완료까지 대기한다.
asyncmy 커넥션 풀은 생성된 이벤트 루프에 바인딩되므로 스레드에서
`asyncio.run` 을 쓰면 풀이 깨진다. 반드시 앱 루프에 위임해야 한다.
"""
if _loop is None or _loop.is_closed():
# 셧다운 중이면 루프가 코루틴을 실행하지 않아 무한정 매달린다.
coro.close()
raise RuntimeError("event loop unavailable (shutting down)")
fut = asyncio.run_coroutine_threadsafe(coro, _loop)
return fut.result(timeout=timeout or ssulbox_settings.SSULBOX_DB_DELEGATE_TIMEOUT)
async def _mark_running(content_id: int) -> None:
async with BackgroundSessionLocal() as session:
await task_service.mark_running(session, content_id)
await session.commit()
async def _update_step(content_id: int, step: int) -> None:
async with BackgroundSessionLocal() as session:
await task_service.update_step(session, content_id, step)
await session.commit()
async def _get_place_info(
content_id: int,
) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""현재 저장된 (store_name, region, detail_region_info)."""
async with BackgroundSessionLocal() as session:
return await task_service.get_place_info(session, content_id)
async def _save_place(
content_id: int, store_name: str, region: str, detail: str
) -> None:
async with BackgroundSessionLocal() as session:
await task_service.set_place_info(
session,
content_id,
store_name=store_name,
region=region,
detail_region_info=detail,
)
await session.commit()
async def _finalize(
content_id: int, mp4: Path, store_name: Optional[str] = None
) -> None:
cleanup: Optional[Path] = None
async with BackgroundSessionLocal() as session:
try:
# store_name 은 생성 시점에 비어 있을 때만 반영된다(finalize_task 가 판단).
_, cleanup = await task_service.finalize_task(
session, content_id, mp4, store_name=store_name
)
await session.commit()
except Exception:
await session.rollback()
raise
# 되돌릴 수 없는 삭제는 커밋이 성공한 뒤에만
task_service.cleanup_job_dir(cleanup)
async def _fail(content_id: int, error: str) -> None:
async with BackgroundSessionLocal() as session:
try:
await task_service.fail_task(session, content_id, error)
await session.commit()
except Exception:
await session.rollback()
raise
def _pump() -> None:
"""동시 실행 한도 안에서 대기 중인 잡을 시작한다."""
global _running
if _shutting_down:
return
with _lock:
while _queue and _running < ssulbox_settings.SSULBOX_MAX_CONCURRENT_JOBS:
content_id = _queue.popleft()
_running += 1
threading.Thread(target=_run, args=(content_id,), daemon=True).start()
def _build_command(job: dict) -> tuple[list[str], Path]:
"""generator 실행 커맨드와 작업 디렉터리."""
engine = SCENARIO_ENGINE[job["scenario"]]
engine_dir = ssulbox_settings.generator_path / 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"]),
]
return cmd, engine_dir
def _is_place_url(text: str) -> bool:
"""네이버 지도 링크로 보이는가 — 프론트 `isNaverUrl` 과 같은 판정."""
t = (text or "").strip().lower()
return t.startswith("http") and (
"naver.me" in t or "map.naver" in t or "place.naver" in t
)
def _collect_place_info(content_id: int, job: dict) -> None:
"""비어 있는 업장명·지역을 크롤링으로 채운다(있는 값은 유지).
**빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 둘 다
채워져 있으므로 브라우저를 띄우지 않는다. 다만 검색 경로에서도 지역만 비는
경우(주소에서 시/군을 못 뽑는 등)가 있어, 존재 여부를 실제로 확인하고 판단한다.
수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다.
"""
if not _is_place_url(job.get("input", "")):
return
try:
store_name, region, detail = _run_db(_get_place_info(content_id))
if store_name and region and detail:
return # 채울 것이 없다
detail = _run_db(
place_service.fetch_place_detail(job["input"]),
# Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다
timeout=120,
)
if not detail:
return
title = detail.get("title") or ""
road = detail.get("roadAddress") or ""
jibun = detail.get("address") or ""
# castad `/home/crawl` 과 동일하게 **도로명·지번을 모두** 넘긴다.
# 도로명에서 시/군 추출이 실패하면 지번으로 재시도한다(한쪽만 넘기면 놓친다).
new_region = extract_region_from_address(road or None, jibun or None)
new_detail = road or jibun # 도로명 우선, 없으면 지번
if title:
job["store_name"] = title
if title or new_region or new_detail:
_run_db(_save_place(content_id, title, new_region, new_detail))
except Exception as e:
logger.warning(
f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): "
f"{type(e).__name__}: {e}"
)
def _run(content_id: int) -> None:
"""워커 스레드 본체. subprocess 를 감독하며 stdout 마커로 진행을 파싱한다."""
global _running
job = _jobs[content_id]
proc: Optional[subprocess.Popen] = None
try:
cmd, engine_dir = _build_command(job)
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
key = gemini_key()
if key:
env["GEMINI_API_KEY"] = key
_run_db(_mark_running(content_id))
job["status"] = "running"
# 업장명·주소를 **항상** 확보한다. 검색으로 고르지 않고 place URL 을
# 붙여넣은 경우 create 시점에 아무것도 없으므로 여기서 크롤링한다.
# 생성 **전에** 하는 이유: 목록에 이름 없는 카드가 뜨는 구간을 없앤다.
# 실패해도 생성은 계속한다(목록 표시·필터용 부가 정보다).
_collect_place_info(content_id, job)
proc = subprocess.Popen(
cmd,
cwd=str(engine_dir),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
started = step_started = time.monotonic()
timeout_s = ssulbox_settings.SSULBOX_JOB_TIMEOUT_SECONDS
# ⚠️ 벽시계 감시견이 필요한 이유 — `proc.wait(timeout=)` 만으로는 못 막는다.
# 아래 `for raw in proc.stdout` 은 EOF 까지 블록한다. 엔진이 출력 없이
# 매달리면(응답 없는 API 호출 등) EOF 가 오지 않아 워커 스레드가 영구
# 정지하고 `_running` 이 안 줄어 **큐 전체가 멎는다**. wait() 의 타임아웃은
# stdout 이 닫힌 뒤에야 평가되므로 정작 그 상황에는 도달하지 못한다.
# 데드라인에 프로세스를 죽여야 stdout 이 닫히고 루프가 풀린다.
timed_out = threading.Event()
def _on_deadline() -> None:
timed_out.set()
logger.error(f"[ssul {content_id}] TIMEOUT {timeout_s}s — 프로세스 종료")
_kill(proc)
watchdog = threading.Timer(timeout_s, _on_deadline)
watchdog.daemon = True
watchdog.start()
try:
for raw in proc.stdout: # type: ignore[union-attr]
line = raw.rstrip()
if not line:
continue
logger.info(f"[ssul {content_id}] {line}")
job["log"].append(line)
if len(job["log"]) > 300:
del job["log"][:-300]
m = STEP_RE.search(line)
if m:
n = int(m.group(1))
if n != job["step"]:
now = time.monotonic()
prev = job["step"]
label = "준비·크롤링" if prev == 0 else STEP_NAMES[prev - 1]
job["timings"][label] = round(now - step_started, 1)
step_started = now
job["step"] = n
# 폴링이 읽을 수 있도록 DB 에 반영한다
try:
_run_db(_update_step(content_id, n))
except Exception as e:
logger.warning(f"[ssul {content_id}] step 저장 실패: {e}")
d = DONE_RE.search(line)
if d:
job["output"] = d.group(1).strip()
# 실패 시 지울 대상. 완료 마커가 없어도 알 수 있는 유일한 경로다.
jd = JOB_DIR_RE.search(line)
if jd:
job["job_dir"] = jd.group(1).strip()
# place URL 을 붙여넣어 검색을 안 거친 경우의 업장명 확보 경로.
# '?' 는 엔진이 이름을 못 얻었을 때 찍는 placeholder 라 버린다.
sm = STORE_RE.search(line)
if sm:
name = sm.group(1).strip()
if name and name != "?":
job["store_name"] = name
# stdout 이 닫혔으니 종료는 임박했다. 짧은 여유만 준다.
code = proc.wait(timeout=30)
finally:
watchdog.cancel()
if timed_out.is_set():
# 감시견이 죽인 것이지 정상 종료가 아니다. 아래 except 로 넘긴다.
raise subprocess.TimeoutExpired(cmd, timeout_s)
summary = " · ".join(f"{k} {v}s" for k, v in job["timings"].items()) or "(마커 없음)"
logger.info(
f"[ssul {content_id}] 단계별 {summary} · 총 {time.monotonic() - started:.1f}s"
)
if code != 0:
raise RuntimeError(f"생성 프로세스 종료코드 {code}")
if not job["output"]:
raise RuntimeError("완성 마커를 찾지 못했습니다")
mp4 = Path(job["output"])
if not mp4.is_absolute():
mp4 = (engine_dir / mp4).resolve()
# finalize 는 Blob 업로드를 포함해 오래 걸리므로 위임 타임아웃을 넉넉히
_run_db(
_finalize(content_id, mp4, job.get("store_name")), timeout=600
)
job["status"] = "done"
job["step"] = 4
except subprocess.TimeoutExpired as e:
# 원본은 타임아웃이 없어 엔진이 걸리면 큐가 영구 정지했다.
# 두 경로로 들어온다: 감시견 데드라인, 또는 stdout 이 닫혔는데도
# 프로세스가 안 끝나는 경우(위 `wait(timeout=30)`).
job["status"] = "error"
job["error"] = f"생성 시간 초과 ({e.timeout}s)"
logger.error(f"[ssul {content_id}] TIMEOUT — 프로세스 종료")
_kill(proc)
_safe_fail(content_id, job["error"])
except Exception as e:
job["status"] = "error"
job["error"] = f"{type(e).__name__}: {e}"
logger.error(f"[ssul {content_id}] FAILED - {job['error']}", exc_info=True)
_kill(proc)
_safe_fail(content_id, job["error"])
finally:
with _lock:
_running -= 1
_pump()
def _kill(proc: Optional[subprocess.Popen]) -> None:
if proc is None or proc.poll() is not None:
return
try:
proc.kill()
except Exception as e:
logger.warning(f"[job_manager] 프로세스 종료 실패: {e}")
def _safe_fail(content_id: int, error: str) -> None:
"""환불·상태 갱신. 이것마저 실패하면 다음 기동의 고아 스윕이 재처리한다."""
try:
_run_db(_fail(content_id, error))
except Exception as e:
logger.error(
f"[ssul {content_id}] 실패 처리 실패(스윕이 재처리): {type(e).__name__}: {e}"
)
finally:
# DB 처리 성패와 무관하게 디스크는 정리한다. 남겨봐야 쓸 곳이 없다.
_cleanup_failed_dir(content_id)
def _cleanup_failed_dir(content_id: int) -> None:
"""실패한 잡의 중간 산출물 삭제.
castad `video_task.py` 는 `finally` 로 성공·실패 양쪽을 정리한다. 썰박스도
맞춘다. 실패 시엔 generator 자신의 자산 정리(`main.py` 의 `out_mp4.exists()`
조건)마저 건너뛰므로 **오히려 실패가 가장 많이 남긴다** — 이미지·음성 전체.
"""
raw = (_jobs.get(content_id) or {}).get("job_dir")
if not raw:
# 폴더 마커 전에 죽었다면 만들어진 것도 없다
return
try:
job_dir = Path(raw).resolve()
root = ssulbox_settings.output_path.resolve()
# stdout 에서 읽어온 경로다. output 밖은 무슨 일이 있어도 지우지 않는다.
# `job_dir.parent != root` 는 `output/<엔진>` 통째 삭제를 막는다
# (정상 경로는 항상 `output/<엔진>/<작업>` 이라 2단계 아래다).
if root not in job_dir.parents or job_dir.parent == root:
logger.warning(f"[ssul {content_id}] output 밖 경로라 정리 생략: {job_dir}")
return
task_service.cleanup_job_dir(job_dir)
except Exception as e:
logger.warning(f"[ssul {content_id}] 실패 정리 실패: {type(e).__name__}: {e}")

View File

@ -143,6 +143,12 @@ class User(Base):
comment="카카오 썸네일 이미지 URL", comment="카카오 썸네일 이미지 URL",
) )
bio: Mapped[Optional[str]] = mapped_column(
String(200),
nullable=True,
comment="한 줄 소개 (썰박스 프로필). Alembic 부재로 수동 DDL 필요 — app/ssulbox/migration.py 참조",
)
# ========================================================================== # ==========================================================================
# 추가 사용자 정보 # 추가 사용자 정보
# ========================================================================== # ==========================================================================

View File

@ -10,7 +10,15 @@ logger = logging.getLogger(__name__)
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool: async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지.""" """크레딧 1 차감. 기존 호출처와 시그니처 호환 유지.
.. deprecated::
사전차감 정책 전환으로 호출처가 사라졌다(video_task.py 의 사후차감 제거).
**새 코드에서 쓰지 말 것.** 이 함수는 멱등성이 없어 재시도 시 중복 차감된다.
생성 작업에는 ``credit_service.deduct_credit_for_job`` /
``refund_credit_for_job`` 을 쓴다.
다음 릴리스에서 제거 예정.
"""
try: try:
await deduct_credit( await deduct_credit(
session=session, session=session,

View File

@ -12,11 +12,15 @@ from sqlalchemy import delete, insert, tuple_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.like_cache import ( from app.database.like_cache import (
CT_SSUL,
CT_VIDEO,
commit_dirty_processing, commit_dirty_processing,
drain_dirty, drain_dirty,
is_user_liked, is_user_liked,
) )
from app.database.session import get_session from app.database.session import get_session
# 썰박스 좋아요도 같은 Redis write-behind 큐 + **같은 테이블**(video_reaction)을
# 쓰므로 여기서 함께 플러시한다. 큐가 하나라 스케줄러를 늘릴 필요가 없다.
from app.video.models import VideoReaction from app.video.models import VideoReaction
from config import internal_settings from config import internal_settings
@ -48,39 +52,58 @@ async def flush_reactions(
detail="Invalid internal secret", detail="Invalid internal secret",
) )
pairs = await drain_dirty() entries = await drain_dirty()
if not pairs: if not entries:
logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료") logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료")
return {"flushed": 0, "adds": 0, "dels": 0} return {"flushed": 0, "adds": 0, "dels": 0}
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(pairs)}건") logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(entries)}건")
# 두 종류가 **같은 테이블**(video_reaction)에 들어간다. 대상 컬럼만 다르다.
# ADO2 는 video_id, 썰박스는 content_id 를 채운다(나머지는 NULL, CHECK 로 강제).
id_col_of = {CT_VIDEO: "video_id", CT_SSUL: "content_id"}
adds: list[dict] = [] adds: list[dict] = []
dels: list[tuple[int, str]] = [] # (컬럼명, 대상 id, user_uuid) — 삭제는 컬럼이 달라 종류별로 묶어야 한다
dels: dict[str, list[tuple[int, str]]] = {"video_id": [], "content_id": []}
# Redis 현재 상태 기준으로 add / delete 분류 # Redis 현재 상태 기준으로 add / delete 분류
for video_id, user_uuid in pairs: for ctype, content_id, user_uuid in entries:
liked = await is_user_liked(video_id, user_uuid) id_col = id_col_of.get(ctype)
if id_col is None:
# 알 수 없는 종류 — 건너뛴다(파서가 걸러주지만 방어)
logger.warning(f"[REACTION_FLUSH] 알 수 없는 종류 무시 - ctype: {ctype}")
continue
liked = await is_user_liked(content_id, user_uuid, ctype=ctype)
if liked: if liked:
adds.append({"video_id": video_id, "user_uuid": user_uuid}) adds.append({id_col: content_id, "user_uuid": user_uuid})
else: else:
dels.append((video_id, user_uuid)) dels[id_col].append((content_id, user_uuid))
total_adds = len(adds)
total_dels = sum(len(v) for v in dels.values())
try: try:
# Bulk INSERT IGNORE — UniqueConstraint 보장으로 멱등 처리 # 쓰기를 **하나의 트랜잭션**으로 묶는다.
if adds: # 부분 반영이 나면 Redis processing SET 이 남아 다음 회차에 재시도되는데,
# INSERT IGNORE + UNIQUE 로 멱등이라 중복 반영은 안전하다.
#
# ⚠️ adds 는 종류별로 키가 달라(video_id vs content_id) 한 번의
# `values(adds)` 로 묶으면 안 된다 — SQLAlchEmy 가 첫 dict 의 키로
# 컬럼 목록을 정하므로 다른 종류의 값이 누락된다. 종류별로 나눠 실행한다.
for id_col in ("video_id", "content_id"):
rows = [a for a in adds if id_col in a]
if rows:
await session.execute( await session.execute(
insert(VideoReaction).prefix_with("IGNORE").values(adds) insert(VideoReaction).prefix_with("IGNORE").values(rows)
) )
# Bulk DELETE if dels[id_col]:
if dels:
await session.execute( await session.execute(
delete(VideoReaction).where( delete(VideoReaction).where(
tuple_( tuple_(
VideoReaction.video_id, getattr(VideoReaction, id_col),
VideoReaction.user_uuid, VideoReaction.user_uuid,
).in_(dels) ).in_(dels[id_col])
) )
) )
@ -88,9 +111,9 @@ async def flush_reactions(
await commit_dirty_processing() await commit_dirty_processing()
logger.info( logger.info(
f"[REACTION_FLUSH] SUCCESS - adds: {len(adds)}, dels: {len(dels)}" f"[REACTION_FLUSH] SUCCESS - adds: {total_adds}, dels: {total_dels}"
) )
return {"flushed": len(pairs), "adds": len(adds), "dels": len(dels)} return {"flushed": len(entries), "adds": total_adds, "dels": total_dels}
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()

View File

@ -13,12 +13,10 @@ Video API Router
app.include_router(router) app.include_router(router)
""" """
import json
from collections import defaultdict
from typing import Literal from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy import func, or_, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
@ -28,26 +26,26 @@ from app.user.models import User
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.home.models import Image, Project, MarketingIntel from app.home.models import Image, Project, MarketingIntel
from app.home.api.routers.v1.home import _extract_region_from_address from app.home.api.routers.v1.home import _extract_region_from_address
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
from app.comment.models import Comment
from app.database.like_cache import ( from app.database.like_cache import (
backfill_user_set, backfill_user_set,
bulk_is_user_liked,
get_like_count, get_like_count,
get_like_counts,
is_user_liked, is_user_liked,
is_user_set_exists, is_user_set_exists,
mark_dirty, mark_dirty,
mset_like_counts,
set_like_count, set_like_count,
toggle_like_atomic, toggle_like_atomic,
) )
from app.credit.exceptions import InsufficientCreditError
from app.credit.services.credit_service import deduct_credit_for_job
from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.video.models import Video, VideoReaction from app.video.models import Video, VideoReaction
from app.video.services import unified_list
from app.video.schemas.video_schema import ( from app.video.schemas.video_schema import (
DownloadVideoResponse, DownloadVideoResponse,
GenerateVideoResponse, GenerateVideoResponse,
@ -64,6 +62,9 @@ from config import creatomate_settings
logger = get_logger("video") logger = get_logger("video")
#: 영상 1편 생성에 차감할 크레딧 (video_task.py 의 VIDEO_CREDIT_COST 와 동일해야 함)
VIDEO_CREDIT_COST = 1
router = APIRouter(prefix="/video", tags=["Video"]) router = APIRouter(prefix="/video", tags=["Video"])
@ -113,12 +114,19 @@ curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5
- Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다. - Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다.
- creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다. - creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
- Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다. - Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다.
## 크레딧
- **요청 시점에 크레딧 1이 선차감됩니다.** (완료 시점 차감에서 변경)
- 생성이 실패하면 자동으로 환불됩니다.
- 잔액이 부족하면 402를 반환하며, Video 행도 생성되지 않습니다.
- 같은 task_id 로 재요청해도 중복 차감되지 않습니다.
""", """,
response_model=GenerateVideoResponse, response_model=GenerateVideoResponse,
responses={ responses={
200: {"description": "영상 생성 요청 성공"}, 200: {"description": "영상 생성 요청 성공"},
400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"}, 400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"},
401: {"description": "인증 실패 (토큰 없음/만료)"}, 401: {"description": "인증 실패 (토큰 없음/만료)"},
402: {"description": "크레딧 부족 (충전 필요)"},
404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"}, 404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"},
500: {"description": "영상 생성 요청 실패"}, 500: {"description": "영상 생성 요청 실패"},
}, },
@ -313,7 +321,11 @@ async def generate_video(
f"timestamps: {len(song_timestamp_list)}" f"timestamps: {len(song_timestamp_list)}"
) )
# ===== Video 테이블에 초기 데이터 저장 및 커밋 ===== # ===== Video 테이블에 초기 데이터 저장 + 크레딧 선차감 (단일 트랜잭션) =====
# 렌더 완료 후가 아니라 "시작 시점"에 차감한다. 사후차감이던 시절에는
# 차감 전에 동시 요청이 들어오면 크레딧 1개로 영상 여러 개를 만들 수 있었다.
# Video insert 와 차감을 한 트랜잭션으로 묶어 한 번만 커밋해야
# "영상 행은 생겼는데 차감은 실패" 같은 반쪽 상태가 생기지 않는다.
video = Video( video = Video(
project_id=project_id, project_id=project_id,
lyric_id=lyric_id, lyric_id=lyric_id,
@ -323,6 +335,21 @@ async def generate_video(
status="processing", status="processing",
) )
session.add(video) session.add(video)
await session.flush() # video.id 확보 (커밋은 차감 후 한 번만)
# job_ref 로 task_id 를 쓴다 — ADO2 에는 영상 재생성 버튼이 없어
# task_id 1건 = 생성 1건이기 때문이다.
# 재생성 UI 를 추가한다면 이 키를 f"{task_id}:{video.id}" 로 바꿔야
# 두 번째 생성이 공짜가 되지 않는다.
await deduct_credit_for_job(
session=session,
user_uuid=current_user.user_uuid,
amount=VIDEO_CREDIT_COST,
job_type=CREDIT_JOB_TYPE_VIDEO,
job_ref=task_id,
reason="영상 생성",
)
await session.commit() await session.commit()
video_id = video.id video_id = video.id
stage1_time = time.perf_counter() stage1_time = time.perf_counter()
@ -334,6 +361,14 @@ async def generate_video(
except HTTPException: except HTTPException:
raise raise
except InsufficientCreditError:
# 크레딧 부족은 "요청 실패"가 아니라 402 로 올려야 프론트가 충전 화면으로 유도한다.
# 아래 except Exception 이 삼켜 200(success=False)으로 내리면 안 되므로 먼저 잡는다.
logger.info(
f"[generate_video] INSUFFICIENT CREDIT - task_id: {task_id}, "
f"user_uuid: {current_user.user_uuid}"
)
raise
except Exception as e: except Exception as e:
logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}")
return GenerateVideoResponse( return GenerateVideoResponse(
@ -850,7 +885,7 @@ async def get_all_videos(
store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"), store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"),
region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"), region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"),
) -> PaginatedResponse[VideoThumbnailItem]: ) -> PaginatedResponse[VideoThumbnailItem]:
"""전체 사용자의 완료된 영상 갤러리를 반환합니다.""" """전체 사용자의 완료된 콘텐츠(ADO2 영상 + 썰박스)를 반환합니다."""
logger.info( logger.info(
f"[get_all_videos] START - page: {pagination.page}, page_size: {pagination.page_size}, " f"[get_all_videos] START - page: {pagination.page}, page_size: {pagination.page_size}, "
f"sort_by: {sort_by}, order: {order}, store_name: {store_name}, region: {region}" f"sort_by: {sort_by}, order: {order}, store_name: {store_name}, region: {region}"
@ -859,144 +894,31 @@ async def get_all_videos(
try: try:
offset = (pagination.page - 1) * pagination.page_size offset = (pagination.page - 1) * pagination.page_size
where_clauses = [ items, total = await unified_list.fetch_gallery(
Video.status == "completed", session,
Video.is_deleted == False, # noqa: E712 offset=offset,
Project.is_deleted == False, # noqa: E712 limit=pagination.page_size,
Video.result_movie_url.is_not(None), sort_by=sort_by,
] order=order,
if store_name: store_name=store_name,
where_clauses.append(Project.store_name.ilike(f"%{store_name}%")) region=region,
if region: user_uuid=current_user.user_uuid if current_user else None,
cities = SIDO_CITIES.get(region)
if cities:
aliases = SIDO_SEARCH_ALIASES.get(region, [region])
where_clauses.append(
or_(
Project.region.in_(cities),
*[Project.detail_region_info.ilike(f"%{a}%") for a in aliases],
) )
)
else:
where_clauses.append(
or_(
Project.region.ilike(f"%{region}%"),
Project.detail_region_info.ilike(f"%{region}%"),
)
)
count_q = (
select(func.count(Video.id))
.join(Project, Video.project_id == Project.id)
.where(*where_clauses)
)
total = (await session.execute(count_q)).scalar() or 0
comment_count_subq = (
select(func.count(Comment.id))
.where(
Comment.video_id == Video.id,
Comment.is_deleted == False, # noqa: E712
)
.correlate(Video)
.scalar_subquery()
)
# like_count 정렬은 Redis 대신 서브쿼리로 처리 (ORDER BY에만 사용)
like_count_subq_for_sort = (
select(func.count(VideoReaction.id))
.where(VideoReaction.video_id == Video.id)
.correlate(Video)
.scalar_subquery()
)
sort_col_map = {
"like_count": like_count_subq_for_sort,
"comment_count": comment_count_subq,
"created_at": Video.created_at,
}
sort_col = sort_col_map.get(sort_by, Video.created_at)
order_clause = sort_col.asc() if order == "asc" else sort_col.desc()
list_q = (
select(
Video,
Project,
comment_count_subq.label("comment_count"),
)
.join(Project, Video.project_id == Project.id)
.where(*where_clauses)
.order_by(order_clause)
.offset(offset)
.limit(pagination.page_size)
)
rows = (await session.execute(list_q)).all()
video_ids = [v.id for v, p, _ in rows]
# Redis mget으로 like_count 일괄 조회
like_count_map = await get_like_counts(video_ids)
# 카운트 캐시 미스 보정
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
if missing_ids:
db_counts = (await session.execute(
select(VideoReaction.video_id, func.count(VideoReaction.id))
.where(VideoReaction.video_id.in_(missing_ids))
.group_by(VideoReaction.video_id)
)).all()
db_found_ids = set()
batch = {}
for vid, cnt in db_counts:
batch[vid] = cnt
like_count_map[vid] = cnt
db_found_ids.add(vid)
await mset_like_counts(batch)
for vid in missing_ids:
if vid not in db_found_ids:
like_count_map[vid] = 0
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
liked_map: dict[int, bool] = {}
if current_user:
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
# user-set이 없는(None) 영상 중 count > 0인 것만 backfill 필요
needs_backfill = [
vid for vid, liked in raw_liked.items()
if liked is None and like_count_map.get(vid, 0) > 0
]
if needs_backfill:
reaction_rows = (await session.execute(
select(VideoReaction.video_id, VideoReaction.user_uuid)
.where(VideoReaction.video_id.in_(needs_backfill))
)).all()
user_map: dict[int, list[str]] = defaultdict(list)
for vid, uuid in reaction_rows:
user_map[vid].append(uuid)
for vid in needs_backfill:
await backfill_user_set(vid, user_map.get(vid, []))
# backfill 후 재조회
updated = await bulk_is_user_liked(needs_backfill, current_user.user_uuid)
raw_liked.update(updated)
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
items = [
VideoThumbnailItem(
video_id=v.id,
store_name=p.store_name,
result_movie_url=v.result_movie_url,
created_at=v.created_at,
like_count=like_count_map.get(v.id) or 0,
is_liked_by_me=liked_map.get(v.id, False),
comment_count=comment_count or 0,
)
for v, p, comment_count in rows
]
response = PaginatedResponse.create( response = PaginatedResponse.create(
items=items, items=[
VideoThumbnailItem(
type=it.ctype,
video_id=it.id,
store_name=it.store_name,
result_movie_url=it.movie_url,
created_at=it.created_at,
like_count=it.like_count,
is_liked_by_me=it.is_liked_by_me,
comment_count=it.comment_count,
)
for it in items
],
total=total, total=total,
page=pagination.page, page=pagination.page,
page_size=pagination.page_size, page_size=pagination.page_size,
@ -1009,6 +931,7 @@ async def get_all_videos(
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}") raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
@router.post( @router.post(
"/{video_id}/like", "/{video_id}/like",
summary="영상 좋아요 토글", summary="영상 좋아요 토글",

View File

@ -1,7 +1,18 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -136,8 +147,11 @@ class Video(Base):
back_populates="videos", back_populates="videos",
) )
# comment/video_reaction 이 video_id 와 content_id 두 FK 를 갖게 되어
# 어느 쪽으로 조인할지 명시해야 한다(없으면 AmbiguousForeignKeysError).
comments: Mapped[List["Comment"]] = relationship( comments: Mapped[List["Comment"]] = relationship(
"Comment", "Comment",
foreign_keys="Comment.video_id",
back_populates="video", back_populates="video",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="noload", lazy="noload",
@ -145,6 +159,7 @@ class Video(Base):
reactions: Mapped[List["VideoReaction"]] = relationship( reactions: Mapped[List["VideoReaction"]] = relationship(
"VideoReaction", "VideoReaction",
foreign_keys="VideoReaction.video_id",
back_populates="video", back_populates="video",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="noload", lazy="noload",
@ -170,14 +185,30 @@ class VideoReaction(Base):
영상 반응 테이블 영상 반응 테이블
사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글). 사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글).
(user_uuid, video_id) 유니크 제약으로 1인 1회 보장.
향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능. 향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능.
**ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id`
중 **정확히 하나**만 채워지며 DB `CHECK` 로 강제한다.
1인 1회 보장은 유니크 두 개로 나눠서 한다. MySQL 은 NULL 을 서로 다른 값으로
취급하므로, 썰박스 행(video_id IS NULL)이 아무리 많아도
`uq_video_reaction_user_video` 에 걸리지 않는다 — 각자 자기 유니크만 지킨다.
""" """
__tablename__ = "video_reaction" __tablename__ = "video_reaction"
__table_args__ = ( __table_args__ = (
CheckConstraint(
"(video_id IS NULL) <> (content_id IS NULL)",
name="ck_video_reaction_one_target",
),
UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"), UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"),
UniqueConstraint(
"user_uuid", "content_id", name="uq_video_reaction_user_content"
),
Index("idx_video_reaction_video_id", "video_id"), Index("idx_video_reaction_video_id", "video_id"),
# 카운트 집계가 content_id 로 묶으므로 선행 컬럼 인덱스가 필요하다
# (유니크는 user_uuid 가 앞이라 이 용도로 못 쓴다).
Index("idx_video_reaction_content_id", "content_id"),
Index("idx_video_reaction_user_uuid", "user_uuid"), Index("idx_video_reaction_user_uuid", "user_uuid"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
@ -189,11 +220,19 @@ class VideoReaction(Base):
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True, comment="고유 식별자" Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
) )
video_id: Mapped[int] = mapped_column( # 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_video_reaction_one_target).
video_id: Mapped[Optional[int]] = mapped_column(
Integer, Integer,
ForeignKey("video.id", ondelete="CASCADE"), ForeignKey("video.id", ondelete="CASCADE"),
nullable=False, nullable=True,
comment="연결된 Video의 id", comment="ADO2 영상 id (썰박스 반응이면 NULL)",
)
content_id: Mapped[Optional[int]] = mapped_column(
# ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780).
BigInteger,
ForeignKey("ssul_content.id", ondelete="CASCADE"),
nullable=True,
comment="썰박스 콘텐츠 id (ADO2 반응이면 NULL)",
) )
user_uuid: Mapped[str] = mapped_column( user_uuid: Mapped[str] = mapped_column(
String(36), String(36),
@ -208,5 +247,8 @@ class VideoReaction(Base):
comment="반응 일시", comment="반응 일시",
) )
video: Mapped["Video"] = relationship("Video", back_populates="reactions") # 썰박스 반응이면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
video: Mapped[Optional["Video"]] = relationship(
"Video", foreign_keys=[video_id], back_populates="reactions"
)
user: Mapped["User"] = relationship("User", back_populates="video_reactions") user: Mapped["User"] = relationship("User", back_populates="video_reactions")

View File

@ -5,7 +5,7 @@ Video API Schemas
""" """
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Optional from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -152,10 +152,22 @@ class VideoListItem(BaseModel):
} }
""" """
video_id: int = Field(..., description="영상 고유 ID") # ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는
# 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두
# **`(type, video_id)` 쌍**으로 다뤄야 한다.
# 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로,
# 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.**
type: Literal["video", "ssul"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)",
)
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
store_name: Optional[str] = Field(None, description="업체명") store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명") region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자") task_id: str = Field(
default="",
description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)",
)
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시") created_at: Optional[datetime] = Field(None, description="생성 일시")
like_count: int = Field(0, description="좋아요 수") like_count: int = Field(0, description="좋아요 수")
@ -169,7 +181,14 @@ class VideoThumbnailItem(BaseModel):
GET /video/all 응답의 개별 영상 정보 GET /video/all 응답의 개별 영상 정보
""" """
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)") # ⚠️ `video_id` 는 종류 안에서만 유일하다. `video.id` 와 `ssul_content.id` 가
# **둘 다 1부터 시작**하므로 식별자는 반드시 `(type, video_id)` 쌍으로 다뤄야 한다.
# 한 곳이라도 id 만 쓰면 다른 종류의 콘텐츠가 열린다.
type: Literal["video", "ssul"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스). video_id 와 쌍으로 식별한다",
)
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
store_name: str = Field(..., description="업체명") store_name: str = Field(..., description="업체명")
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용") result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")

View File

@ -0,0 +1,468 @@
# -*- coding: utf-8 -*-
"""ADO2 영상 + 썰박스 콘텐츠 통합 목록.
`GET /video/all`(전체 갤러리)과 `GET /archive/videos/`(내 콘텐츠)가 두 종류를
**하나의 정렬·페이징된 목록**으로 내려주기 위한 공용 쿼리다.
**왜 프론트에서 못 합치나**: 정렬·검색·지역 필터·페이지네이션이 전부 서버사이드다.
두 API 를 각각 호출해 이어붙이면 1페이지에 ADO2 12개 + 썰박스 12개가 각자 안에서만
정렬된 채 섞이고 `total`/`has_next` 도 어긋난다.
**왜 테이블을 안 합치나**: `video` 는 `project_id`/`lyric_id`/`song_id` 가 NOT NULL 이고
소유자·업장명이 `project` 에 있다. 썰박스를 넣으려면 그 4개를 nullable 로 열고
`Video` 를 참조하는 코드 72곳을 전부 감사해야 한다(2026-07-30 조사).
여기 사는 이유: 두 엔드포인트(`app/video`, `app/archive`)가 함께 쓰는데 엔드포인트
소유가 video 쪽이라 여기 뒀다. `app/archive` 는 이미 `app.video.models` 를 import 한다.
## 성능 설계 — 카운트를 UNION 안에 넣지 않는다
UNION 은 양쪽 브랜치를 **먼저 구체화**하므로, select 목록에 상관 서브쿼리를 두면
페이지 12건이 아니라 **매칭된 전체 행**에 대해 평가된다. 그래서
`created_at` 정렬(기본)에서는 카운트를 빼고, 페이지가 확정된 뒤 그 12건만 집계한다.
좋아요·댓글 수로 **정렬할 때만** 어쩔 수 없이 브랜치 안에서 계산한다.
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Optional
from sqlalchemy import Select, func, literal, or_, select, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from app.comment.models import Comment
from app.database.like_cache import (
CT_SSUL,
CT_VIDEO,
backfill_user_set,
bulk_is_user_liked,
get_like_counts,
mset_like_counts,
)
from app.home.models import Project
from app.ssulbox.models import SsulContent
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.video.models import Video, VideoReaction
ContentType = Literal["video", "ssul"]
#: 정렬 가능한 키. 그 외 값은 created_at 으로 떨어진다.
SORT_CREATED = "created_at"
SORT_LIKE = "like_count"
SORT_COMMENT = "comment_count"
@dataclass(slots=True)
class UnifiedItem:
"""통합 목록의 한 항목.
`id` 는 종류마다 **독립적인 시퀀스**다(`video.id` 와 `ssul_content.id` 모두 1부터
시작한다). 따라서 식별자는 반드시 `(ctype, id)` 쌍으로 다뤄야 한다 —
한 곳이라도 id 만 쓰면 다른 콘텐츠가 열린다.
"""
ctype: ContentType
id: int
store_name: str
region: Optional[str]
movie_url: str
created_at: datetime
#: ADO2 전용. 썰박스는 task_id 개념이 없어 빈 문자열이다
#: (`ssul_content` 는 task/content 를 한 테이블로 합쳤고 크레딧 앵커는 id 다).
task_id: str = ""
like_count: int = 0
comment_count: int = 0
is_liked_by_me: bool = False
# ──────────────────────────────────────────────
# 상관 서브쿼리 (정렬용)
# ──────────────────────────────────────────────
def _video_like_subq():
return (
select(func.count(VideoReaction.id))
.where(VideoReaction.video_id == Video.id)
.correlate(Video)
.scalar_subquery()
)
def _video_comment_subq():
return (
select(func.count(Comment.id))
.where(Comment.video_id == Video.id, Comment.is_deleted.is_(False))
.correlate(Video)
.scalar_subquery()
)
def _ssul_like_subq():
# 좋아요·댓글은 castad 테이블에 합쳤다(2026-07-30). 썰박스 행은 video_id 대신
# content_id 가 채워져 있다 — 테이블은 같고 컬럼만 다르다.
return (
select(func.count(VideoReaction.id))
.where(VideoReaction.content_id == SsulContent.id)
.correlate(SsulContent)
.scalar_subquery()
)
def _ssul_comment_subq():
return (
select(func.count(Comment.id))
.where(
Comment.content_id == SsulContent.id,
Comment.is_deleted.is_(False),
)
.correlate(SsulContent)
.scalar_subquery()
)
# ──────────────────────────────────────────────
# 필터
# ──────────────────────────────────────────────
def _region_clause(region_col, detail_col, region: str):
"""castad `/video/all` 과 **동일한 규칙**으로 지역을 거른다.
시/도 이름이면 그 안의 시·군 목록으로 매칭하고, 동시에 상세 주소를 별칭으로
부분 일치 검색한다. 이 두 경로를 맞추지 않으면 같은 검색어에 ADO2 는 나오고
썰박스는 안 나오는 비대칭이 생긴다.
"""
cities = SIDO_CITIES.get(region)
if cities:
aliases = SIDO_SEARCH_ALIASES.get(region, [region])
return or_(
region_col.in_(cities),
*[detail_col.ilike(f"%{a}%") for a in aliases],
)
return or_(
region_col.ilike(f"%{region}%"),
detail_col.ilike(f"%{region}%"),
)
def _video_where(store_name: Optional[str], region: Optional[str]) -> list:
clauses = [
Video.status == "completed",
Video.is_deleted.is_(False),
Project.is_deleted.is_(False),
Video.result_movie_url.is_not(None),
]
if store_name:
clauses.append(Project.store_name.ilike(f"%{store_name}%"))
if region:
clauses.append(
_region_clause(Project.region, Project.detail_region_info, region)
)
return clauses
def _ssul_where(store_name: Optional[str], region: Optional[str]) -> list:
clauses = [
SsulContent.status == "done",
SsulContent.is_deleted.is_(False),
SsulContent.video_url.is_not(None),
]
if store_name:
clauses.append(SsulContent.store_name.ilike(f"%{store_name}%"))
if region:
clauses.append(
_region_clause(
SsulContent.region, SsulContent.detail_region_info, region
)
)
return clauses
# ──────────────────────────────────────────────
# 브랜치
# ──────────────────────────────────────────────
def _video_branch(where: list, sort_by: str) -> Select:
cols = [
literal(CT_VIDEO).label("ctype"),
Video.id.label("cid"),
Project.store_name.label("store_name"),
Project.region.label("region"),
Video.result_movie_url.label("movie_url"),
Video.created_at.label("created_at"),
Video.task_id.label("task_id"),
]
if sort_by == SORT_LIKE:
cols.append(_video_like_subq().label("sort_value"))
elif sort_by == SORT_COMMENT:
cols.append(_video_comment_subq().label("sort_value"))
return select(*cols).join(Project, Video.project_id == Project.id).where(*where)
def _ssul_branch(where: list, sort_by: str) -> Select:
cols = [
literal(CT_SSUL).label("ctype"),
SsulContent.id.label("cid"),
SsulContent.store_name.label("store_name"),
SsulContent.region.label("region"),
SsulContent.video_url.label("movie_url"),
SsulContent.created_at.label("created_at"),
# UNION 은 컬럼 수·순서가 양쪽 같아야 한다. 썰박스에는 task_id 가 없다.
literal("").label("task_id"),
]
if sort_by == SORT_LIKE:
cols.append(_ssul_like_subq().label("sort_value"))
elif sort_by == SORT_COMMENT:
cols.append(_ssul_comment_subq().label("sort_value"))
return select(*cols).where(*where)
# ──────────────────────────────────────────────
# 페이지 확정 후 집계
# ──────────────────────────────────────────────
def _reaction_target(ctype: str):
"""반응 테이블에서 이 종류가 쓰는 대상 컬럼.
`comment` / `video_reaction` 은 두 종류를 함께 담고 `video_id` 또는 `content_id`
중 하나만 채운다(CHECK 로 강제). 어느 컬럼을 볼지만 갈아끼우면 된다.
"""
return VideoReaction.video_id if ctype == CT_VIDEO else VideoReaction.content_id
def _comment_target(ctype: str):
return Comment.video_id if ctype == CT_VIDEO else Comment.content_id
async def _like_counts_for(
session: AsyncSession, ctype: str, ids: list[int]
) -> dict[int, int]:
"""Redis 우선, 미스는 DB 로 보정하고 캐시에 채운다.
castad 기존 목록 로직과 동일한 절차다. 종류별 키 접두를 쓰므로
`video.id` 와 `ssul_content.id` 가 겹쳐도 섞이지 않는다.
"""
if not ids:
return {}
counts = await get_like_counts(ids, ctype=ctype)
missing = [cid for cid, cnt in counts.items() if cnt is None]
if missing:
col = _reaction_target(ctype)
stmt = (
select(col, func.count(VideoReaction.id))
.where(col.in_(missing))
.group_by(col)
)
found = {cid: cnt for cid, cnt in (await session.execute(stmt)).all()}
await mset_like_counts(found, ctype=ctype)
for cid in missing:
counts[cid] = found.get(cid, 0)
return {cid: (cnt or 0) for cid, cnt in counts.items()}
async def _comment_counts_for(
session: AsyncSession, ctype: str, ids: list[int]
) -> dict[int, int]:
"""페이지에 포함된 항목만 집계한다(UNION 안에서 계산하지 않는 이유는 모듈 docstring 참조)."""
if not ids:
return {}
col = _comment_target(ctype)
stmt = (
select(col, func.count(Comment.id))
.where(col.in_(ids), Comment.is_deleted.is_(False))
.group_by(col)
)
found = {cid: cnt for cid, cnt in (await session.execute(stmt)).all()}
return {cid: found.get(cid, 0) for cid in ids}
async def _liked_map_for(
session: AsyncSession,
ctype: str,
ids: list[int],
user_uuid: str,
like_counts: dict[int, int],
) -> dict[int, bool]:
"""Redis user-set 기준. cold-start 인 항목만 DB 에서 backfill 한다."""
if not ids:
return {}
raw = await bulk_is_user_liked(ids, user_uuid, ctype=ctype)
# user-set 키가 없고(None) 카운트가 0 보다 큰 것만 채우면 된다 —
# 좋아요가 0 이면 backfill 해도 결과가 같다.
needs = [cid for cid, liked in raw.items() if liked is None and like_counts.get(cid, 0) > 0]
if needs:
col = _reaction_target(ctype)
stmt = select(col, VideoReaction.user_uuid).where(col.in_(needs))
by_content: dict[int, list[str]] = {cid: [] for cid in needs}
for cid, uuid in (await session.execute(stmt)).all():
by_content[cid].append(uuid)
for cid in needs:
await backfill_user_set(cid, by_content[cid], ctype=ctype)
raw.update(await bulk_is_user_liked(needs, user_uuid, ctype=ctype))
return {cid: bool(liked) for cid, liked in raw.items()}
async def enrich(
session: AsyncSession,
items: list[UnifiedItem],
user_uuid: Optional[str],
) -> list[UnifiedItem]:
"""페이지에 실린 항목에만 좋아요·댓글 수와 내 좋아요 여부를 채운다."""
for ctype in (CT_VIDEO, CT_SSUL):
ids = [it.id for it in items if it.ctype == ctype]
if not ids:
continue
likes = await _like_counts_for(session, ctype, ids)
comments = await _comment_counts_for(session, ctype, ids)
liked = (
await _liked_map_for(session, ctype, ids, user_uuid, likes)
if user_uuid
else {}
)
for it in items:
if it.ctype != ctype:
continue
it.like_count = likes.get(it.id, 0)
it.comment_count = comments.get(it.id, 0)
it.is_liked_by_me = liked.get(it.id, False)
return items
# ──────────────────────────────────────────────
# 공개 API
# ──────────────────────────────────────────────
async def fetch_gallery(
session: AsyncSession,
*,
offset: int,
limit: int,
sort_by: str = SORT_CREATED,
order: str = "desc",
store_name: Optional[str] = None,
region: Optional[str] = None,
user_uuid: Optional[str] = None,
include_ssul: bool = True,
) -> tuple[list[UnifiedItem], int]:
"""전체 갤러리(`/video/all`). (항목, 전체 개수) 반환."""
v_where = _video_where(store_name, region)
s_where = _ssul_where(store_name, region)
# 전체 개수는 브랜치별로 센다 — UNION 을 만들어 세는 것보다 싸다.
total = (
await session.execute(
select(func.count(Video.id))
.join(Project, Video.project_id == Project.id)
.where(*v_where)
)
).scalar() or 0
if include_ssul:
total += (
await session.execute(
select(func.count(SsulContent.id)).where(*s_where)
)
).scalar() or 0
branches = [_video_branch(v_where, sort_by)]
if include_ssul:
branches.append(_ssul_branch(s_where, sort_by))
u = union_all(*branches).subquery() if len(branches) > 1 else branches[0].subquery()
sort_col = (
u.c.sort_value if sort_by in (SORT_LIKE, SORT_COMMENT) else u.c.created_at
)
order_by = [sort_col.asc() if order == "asc" else sort_col.desc()]
# 정렬 키가 같을 때 페이지 경계에서 순서가 흔들리면 같은 항목이 두 번 보이거나
# 아예 빠진다. 안정적인 2차 키를 반드시 둔다.
if sort_by in (SORT_LIKE, SORT_COMMENT):
order_by.append(u.c.created_at.desc())
order_by.extend([u.c.ctype.asc(), u.c.cid.desc()])
rows = (
await session.execute(
select(u).order_by(*order_by).offset(offset).limit(limit)
)
).all()
items = _to_items(rows)
await enrich(session, items, user_uuid)
return items, total
def _to_items(rows) -> list[UnifiedItem]:
return [
UnifiedItem(
ctype=r.ctype,
id=r.cid,
store_name=r.store_name or "",
region=r.region,
movie_url=r.movie_url,
created_at=r.created_at,
task_id=r.task_id or "",
)
for r in rows
]
async def fetch_my_contents(
session: AsyncSession,
*,
user_uuid: str,
offset: int,
limit: int,
) -> tuple[list[UnifiedItem], int]:
"""내 콘텐츠(`/archive/videos/`). (항목, 전체 개수) 반환.
갤러리와 다른 점 둘:
- 소유자로 거른다 (ADO2 는 `project.user_uuid`, 썰박스는 `ssul_content.user_uuid`)
- ADO2 는 **task_id 당 최신 1건만** 남긴다(같은 작업으로 여러 영상이 생길 수
있다). 썰박스는 task_id 개념이 없어 중복 제거가 필요 없다.
⚠️ **프론트는 반드시 `ctype` 으로 분기해야 한다.** 이 목록에는 삭제 버튼이 붙는데
`DELETE /archive/videos/{id}` 는 `Video.id` 로 지운다. 썰박스 항목의 id 를 그대로
넘기면 **id 가 겹치는 ADO2 영상이 삭제된다**(둘 다 1부터 시작하는 독립 시퀀스).
"""
# ADO2: task_id 별 최신 영상만 (기존 동작 보존)
latest_ids = (
select(func.max(Video.id).label("latest_id"))
.join(Project, Video.project_id == Project.id)
.where(
Project.user_uuid == user_uuid,
Video.status == "completed",
Video.is_deleted.is_(False),
Project.is_deleted.is_(False),
)
.group_by(Video.task_id)
.subquery()
)
v_where = [Video.id.in_(select(latest_ids.c.latest_id))]
s_where = [
SsulContent.user_uuid == user_uuid,
SsulContent.status == "done",
SsulContent.is_deleted.is_(False),
SsulContent.video_url.is_not(None),
]
total = ((await session.execute(
select(func.count(Video.id)).where(*v_where)
)).scalar() or 0) + ((await session.execute(
select(func.count(SsulContent.id)).where(*s_where)
)).scalar() or 0)
u = union_all(
_video_branch(v_where, SORT_CREATED),
_ssul_branch(s_where, SORT_CREATED),
).subquery()
rows = (
await session.execute(
select(u)
.order_by(u.c.created_at.desc(), u.c.ctype.asc(), u.c.cid.desc())
.offset(offset)
.limit(limit)
)
).all()
items = _to_items(rows)
await enrich(session, items, user_uuid)
return items, total

View File

@ -1,13 +1,7 @@
import random
from typing import List
from fastapi import Request, status
from fastapi.exceptions import HTTPException
from sqlalchemy import Connection, text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select, func from sqlalchemy import select, func
from app.database.session import AsyncSessionLocal from app.database.session import AsyncSessionLocal
from app.home.models import Image, ImageTag, Project from app.home.models import Image, ImageTag
# from app.lyric.schemas.lyrics_schema import ( # from app.lyric.schemas.lyrics_schema import (
# AttributeData, # AttributeData,
@ -837,7 +831,6 @@ logger = get_logger("video")
# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, # status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
# detail="서비스 처리 중 오류가 발생했습니다.", # detail="서비스 처리 중 오류가 발생했습니다.",
# ) # )
from sqlalchemy.dialects import mysql
async def get_image_tags_by_task_id(task_id: str) -> list[dict]: async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
# print("taskid", task_id) # print("taskid", task_id)
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:

View File

@ -4,7 +4,6 @@ Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다. 영상 생성 관련 백그라운드 태스크를 정의합니다.
""" """
import traceback
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
@ -12,8 +11,9 @@ import httpx
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.credit.services.credit_service import refund_credit_for_job
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.user.services.credit import consume_credit from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO
from app.video.models import Video from app.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.logger import get_logger from app.utils.logger import get_logger
@ -24,6 +24,57 @@ logger = get_logger("video")
# HTTP 요청 설정 # HTTP 요청 설정
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분) REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
#: 영상 1편 생성에 차감/환불할 크레딧 (generate_video 의 VIDEO_CREDIT_COST 와 동일해야 함)
VIDEO_CREDIT_COST = 1
async def _fail_and_refund(
task_id: str | None,
creatomate_render_id: str | None = None,
user_uuid: str | None = None,
*,
reason: str,
) -> None:
"""영상 실패 처리 + 선차감한 크레딧 환불.
크레딧은 generate_video 에서 선차감되므로 실패 시 돌려줘야 한다.
refund_credit_for_job 은 (job_type, job_ref) 기준 멱등이라 실패 콜백이
여러 번 와도 환불은 한 번만 일어난다. 차감 기록이 없으면(정책 전환 이전에
시작된 in-flight 영상) 아무것도 하지 않는다.
Args:
task_id: 프로젝트 task_id (크레딧 멱등 키이기도 하다)
creatomate_render_id: Creatomate 렌더 ID
user_uuid: 환불 대상 사용자. None 이면 환불을 건너뛴다
reason: 원장에 남길 사유
"""
if task_id:
await _update_video_status(
task_id, "failed", creatomate_render_id=creatomate_render_id
)
if not task_id or not user_uuid:
return
try:
async with BackgroundSessionLocal() as session:
await refund_credit_for_job(
session=session,
user_uuid=user_uuid,
amount=VIDEO_CREDIT_COST,
job_type=CREDIT_JOB_TYPE_VIDEO,
job_ref=task_id,
reason=reason,
)
await session.commit()
except Exception as e:
# 환불 실패로 실패 처리 자체가 막히면 안 된다. 로그를 남기고 넘어간다.
logger.error(
f"[_fail_and_refund] REFUND FAILED - task_id: {task_id}, "
f"user_uuid: {user_uuid}, error: {e}",
exc_info=True,
)
async def _update_video_status( async def _update_video_status(
task_id: str, task_id: str,
@ -156,24 +207,21 @@ async def download_and_upload_video_to_blob(
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별) # Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id) await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지) # 크레딧은 generate_video 에서 이미 선차감했다. 여기서 차감하지 않는다.
async with BackgroundSessionLocal() as session:
await consume_credit(user_uuid, session)
await session.commit()
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}") logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True) logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패")
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True) logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패")
except Exception as e: except Exception as e:
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True) logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패")
finally: finally:
# 임시 파일 삭제 # 임시 파일 삭제
@ -270,18 +318,15 @@ async def download_and_upload_video_by_creatomate_render_id(
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
if task_id: await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
if task_id: await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except Exception as e: except Exception as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
if task_id: await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
finally: finally:
# 임시 파일 삭제 # 임시 파일 삭제

View File

@ -641,6 +641,90 @@ class SocialUploadSettings(BaseSettings):
model_config = _base_config model_config = _base_config
class SsulboxSettings(BaseSettings):
"""썰박스(병맛 역사 썰툰 쇼츠) 생성 설정.
GEMINI/NAVER/AZURE/JWT/KAKAO 등은 **castad 기존 설정을 재사용**하므로 여기 두지
않는다. 썰박스 고유 동작만 담는다.
⚠️ `GEMINI_API_KEY` 는 `APIKeySettings` 의 것을 쓰되, 기본값이 플레이스홀더
(`"your-gemeni-api-key"`)라 `bool()` 검사가 항상 True 다.
반드시 `app/ssulbox/constants.py` 의 `gemini_key()` 를 통해 읽을 것.
"""
SSULBOX_ENABLED: bool = Field(
default=True,
description="썰박스 기능 활성화. False 면 라우터 등록·스키마 보장·고아 스윕을 모두 건너뛴다",
)
# ============================================================
# 생성 엔진 경로
# ============================================================
SSULBOX_GENERATOR_DIR: str = Field(
default="generator",
description="생성 엔진 디렉토리 (프로젝트 루트 기준 상대 경로). app/ 패키지 밖에 둔다",
)
SSULBOX_OUTPUT_DIR: str = Field(
default="generator/output",
description="생성 산출물 디렉토리 (프로젝트 루트 기준 상대 경로)",
)
# ============================================================
# 잡 실행
# ============================================================
SSULBOX_MAX_CONCURRENT_JOBS: int = Field(
default=1,
description=(
"동시 실행 생성 잡 수. 영상 렌더가 API 프로세스와 CPU를 공유하므로 1을 권장. "
"높이면 요청 응답 지연이 커진다"
),
)
SSULBOX_JOB_TIMEOUT_SECONDS: int = Field(
default=900,
description=(
"생성 subprocess 최대 실행 시간(초). 초과 시 kill 후 환불 처리. "
"실측: scenes=4/seconds=20 이 75초. 기본값 scenes=9 는 씬 수에 "
"선형 비례하므로 3~5분대 예상 → 900초는 약 3배 여유. "
"프론트 폴링 한도(30분)보다 짧아야 사용자가 먼저 포기하는 일이 없다"
),
)
SSULBOX_DB_DELEGATE_TIMEOUT: int = Field(
default=60,
description=(
"워커 스레드가 앱 이벤트 루프에 DB 작업을 위임할 때의 대기 한도(초). "
"asyncmy 풀이 루프에 바인딩되므로 스레드에서 직접 쓸 수 없다"
),
)
# ============================================================
# 크레딧 · 저장소 · 폴링
# ============================================================
SSULBOX_CREDITS_PER_VIDEO: int = Field(
default=1,
description="생성 1건당 차감 크레딧 (요청 시점에 선차감, 실패 시 환불)",
)
SSULBOX_BLOB_PREFIX: str = Field(
default="ssulbox",
description="Azure Blob 경로 접두. castad 영상과 저장 경로를 분리한다",
)
SSULBOX_POLL_HINT_SECONDS: int = Field(
default=3,
description="클라이언트에 알려줄 권장 폴링 간격(초). castad waitForVideoComplete 와 동일",
)
model_config = _base_config
@property
def generator_path(self) -> Path:
"""생성 엔진 절대 경로"""
return PROJECT_DIR / self.SSULBOX_GENERATOR_DIR
@property
def output_path(self) -> Path:
"""생성 산출물 절대 경로"""
return PROJECT_DIR / self.SSULBOX_OUTPUT_DIR
prj_settings = ProjectSettings() prj_settings = ProjectSettings()
cors_settings = CORSSettings() cors_settings = CORSSettings()
apikey_settings = APIKeySettings() apikey_settings = APIKeySettings()
@ -658,3 +742,4 @@ social_oauth_settings = SocialOAuthSettings()
meta_conversion_settings = MetaConversionSettings() meta_conversion_settings = MetaConversionSettings()
internal_settings = InternalSettings() internal_settings = InternalSettings()
social_upload_settings = SocialUploadSettings() social_upload_settings = SocialUploadSettings()
ssulbox_settings = SsulboxSettings()

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

294
generator/animation/gen.py Normal file
View File

@ -0,0 +1,294 @@
# -*- 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가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
[SNS 캡션] (sns_caption 필드 — 유튜브/인스타/틱톡에 그대로 붙여넣을 업로드 설명)
- 아래 '형식'을 지켜라. 이모지를 적극 사용(나레이션과 달리 캡션은 이모지 OK). 줄바꿈으로 구분.
- 형식:
1) 첫 줄: 후킹 카피 한 줄 (예: "세종대왕이 사실 OO 단골이었다는 썰 풉니다.")
2) 본문 1~2줄: 조선왕 썰 ↔ 가게를 잇는 재밌는 소개 (이모지 곁들여)
3) 셀링 한 줄: 왕이 반할 식으로 가게 핵심 (예: "세종도 반할 OO시장 숨은 맛집 'OOO' 🍲")
4) 📍 가게명 (위치) ← 가게명/위치 모르면 이 줄 생략
5) 🔥 대표 메뉴·포인트 나열 ← 모르면 생략
6) 👉 방문 팁 또는 행동유도 한 줄
7) 마지막 줄: 해시태그 나열. #조선왕 #역사썰 을 앞에 두고, 가게·지역·메뉴·병맛 태그 + #shorts #AIO2O 로 끝.
- 가게 정보(이름·위치·메뉴)는 '마케팅 대상' 텍스트에서 뽑아라. 없으면 그 줄은 자연스럽게 빼라(억지로 지어내지 마라).
[출력]
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
"""
NARR_USER = """\
[이번 쇼츠 소재]
- 왕: {king} ({doc})
- 썰 키워드: "{keyword}"
- 참고 출처: {url}
[마케팅 대상 (마지막에 이걸로 갈아타기)]
- 가게명: {store}
- 소개/키워드: {product}
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
{seconds}초 쇼츠 나레이션을 만들어라. 썰 키워드가 빈약하면 그 왕의 유명한 일화로 살을 붙여도 된다.
"""
NARR_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
"hook": {"type": "string"},
"story": {"type": "string"},
"pivot": {"type": "string"},
"cta": {"type": "string"},
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
"estimated_seconds": {"type": "integer"},
"hashtags": {"type": "array", "items": {"type": "string"}},
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
},
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
}
def generate(client, model, seed, product, seconds, store=""):
"""조선왕 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini)."""
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
user = NARR_USER.format(
king=seed["king"], doc=seed["doc"],
keyword=seed["keyword"], url=seed["url"], product=product,
store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
)
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
resp = client.models.generate_content(
model=model,
contents=user,
config=types.GenerateContentConfig(
system_instruction=sys_prompt,
temperature=1.1, # 병맛 = 약간 높은 창의성
response_mime_type="application/json",
response_schema=NARR_SCHEMA,
),
)
data = json.loads(resp.text)
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
data["_product"] = product
return data
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
SB_SYSTEM = """\
너는 숏폼 영상 디렉터다. 주어진 '병맛 한국사 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
[핵심 규칙]
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the 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

275
generator/animation/main.py Normal file
View File

@ -0,0 +1,275 @@
# -*- 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:
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not _key:
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
try:
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
except Exception as e:
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
if rstore and not brief_store:
brief_store = rstore
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
if not bullets:
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
"키워드를 직접 주거나 다른 링크를 쓰세요.")
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
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] 나레이션 생성...")
store_name = brief_store or getattr(args, "store", None) or label
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
# 제목엔 해시태그가 들어갈 이유가 없다 — LLM 이 끼워넣은 '#태그' 토큰 제거(태그는 hashtags 필드에만).
narr["title"] = re.sub(r"\s*#\S+", "", narr.get("title", "")).strip()
(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")
# SNS 업로드용 캡션(이모지·해시태그 포함) — content_sync/finalize 가 읽어 Content.caption 에 저장.
caption = (narr.get("sns_caption") or "").strip()
if caption:
(job_dir / "caption.txt").write_text(caption, 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] 이미지·음성 생성...")
store = args.store or brief_store or product.split(",")[0].strip() or None
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
real_images=real_images, real_scenes=real_scenes,
max_real=args.max_real, match_content=not args.no_match,
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
img_model=args.img_model, allow_text=args.allow_text,
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
shutil.rmtree(job_dir / "assets", ignore_errors=True)
for junk in ("narration.json", "storyboard.json"):
(job_dir / junk).unlink(missing_ok=True)
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
print(f"\n✅ 완성: {out_mp4}")
print(f" {seed['king']} 썰 · {product}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,970 @@
# -*- 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
OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/
W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
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급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
def _find_font(name):
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
if c.exists():
return str(c)
return str(HERE / "fonts" / name)
FONT = _find_font("Jua-Regular.ttf")
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
TITLE_FONT = _find_font("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 (superfast)")
return ["-c:v", "libx264", "-preset", "superfast", "-crf", "26"]
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)) # 꽃심
_LOGO_RGBA = None
def _brand_logo():
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
global _LOGO_RGBA
if _LOGO_RGBA is not None:
return _LOGO_RGBA
if not Path(BRAND_LOGO).exists():
return None
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
g = Image.open(BRAND_LOGO).convert("L")
alpha = g.point(lambda p: 255 - p)
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
logo.putalpha(alpha)
_LOGO_RGBA = logo
return logo
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 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) # 옅은 구분선
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
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)
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
ink = (30, 30, 30, 255)
sep = "×" # 곱셈기호(×) — 콜라보 표기
gap = 26
w_title = d.textlength(title, font=tf)
w_sep = d.textlength(sep, font=tf)
logo = _brand_logo()
logo_h = 0
if logo is not None:
logo_h = int(TITLE_SIZE * 1.05)
logo_w = max(1, int(logo.width * logo_h / logo.height))
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
else:
logo_w = 0
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
group_left = (W - total) / 2
x = group_left
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
x += w_title + gap
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
x += w_sep + gap
if logo is not None:
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
rose = (228, 150, 168, 255)
bgap = 46
_blossom(d, group_left - bgap, cy, 17, rose)
_blossom(d, group_left + total + bgap, cy, 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
size = NARR_SIZE
while size > NARR_MIN_SIZE:
f = ImageFont.truetype(font_path, size)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(size * 1.4)
if len(lines) * lh <= avail:
break
size -= 2
else:
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(NARR_MIN_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/<값> 순으로 파일/폴더 탐색
"""
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)])
# 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 _scene_motion(idx: int, n: int, frames: int) -> str:
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
"""
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f = max(frames, 1)
if idx == 1: # 훅: 리빌(살짝 당김→전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
return ("zoompan=z='1.06':"
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
kind = idx % 4
if kind == 0: # 줌아웃(끝에 전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if kind == 1: # 줌인(시작에 전체)
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
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%.
"""
print("[4/4] 영상 합성...", flush=True)
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 * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
motion = _scene_motion(idx, len(scenes), frames)
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.
fades = "" if idx == 1 else f"fade=t=in:st=0:d={fd:.2f}:color=white,"
fades += f"fade=t=out:st={max(dur - fd, 0):.2f}:d={fd:.2f}:color=white"
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
f"crop={bw}:{bh},"
f"{motion}:d={frames}:s={W}x{img_h}:fps={FPS}[img];"
f"[bg][img]overlay=0:{band_h},{fades}[base];"
f"[base][1:v]overlay=0:0[v]")
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
_run([exe, "-y", "-loop", "1", "-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",
# AAC priming(1024샘플) 탓에 concat 이 비디오 시작 pts 를 +0.023s 밀어버리면
# 페북/인스타(Buffer) 썸네일 추출(t=0)이 빈 구간=검은 화면을 잡는다 → pts 0 으로 재작성.
"-bsf:v", "setts=ts=TS-STARTDTS:pts=PTS-STARTPTS",
"-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)
import time as _tm; _t_img = _tm.monotonic()
# --- 이미지: 캐릭터 앵커(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)
print(f"[⏱] 이미지 {_tm.monotonic() - _t_img:.1f}초", flush=True); _t_tts = _tm.monotonic()
# --- 음성: 전부 독립 → 병렬 ---
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)
print(f"[⏱] 음성 {_tm.monotonic() - _t_tts:.1f}초", flush=True)
out = workdir / f"{stem}.mp4"
print(f"\n영상 합성 → {out.name}")
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
bgm=bgm, bgm_volume=bgm_volume)
print(f"완성: {out}")
return out

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,308 @@
# -*- 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가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
[SNS 캡션] (sns_caption 필드 — 유튜브/인스타/틱톡에 그대로 붙여넣을 업로드 설명)
- 아래 '형식'을 지켜라. 이모지를 적극 사용(나레이션과 달리 캡션은 이모지 OK). 줄바꿈으로 구분.
- 형식:
1) 첫 줄: 후킹 카피 한 줄 (예: "제우스가 사실 OO 단골이었다는 썰 풉니다.")
2) 본문 1~2줄: 신화 썰 ↔ 가게를 잇는 재밌는 소개 (이모지 곁들여)
3) 셀링 한 줄: 신이 반할 식으로 가게 핵심 (예: "제우스도 반할 OO시장 숨은 맛집 'OOO' 🍲")
4) 📍 가게명 (위치) ← 가게명/위치 모르면 이 줄 생략
5) 🔥 대표 메뉴·포인트 나열 ← 모르면 생략
6) 👉 방문 팁 또는 행동유도 한 줄
7) 마지막 줄: 해시태그 나열. #그리스로마 #신화썰 을 앞에 두고, 가게·지역·메뉴·병맛 태그 + #shorts #AIO2O 로 끝.
- 가게 정보(이름·위치·메뉴)는 '마케팅 대상' 텍스트에서 뽑아라. 없으면 그 줄은 자연스럽게 빼라(억지로 지어내지 마라).
[출력]
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
"""
NARR_USER = """\
[이번 쇼츠 소재]
- 인물: {king} ({doc})
- 썰 키워드: "{keyword}"
- 참고 출처: {url}
[마케팅 대상 (마지막에 이걸로 갈아타기)]
- 가게명: {store}
- 소개/키워드: {product}
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
{seconds}초 쇼츠 나레이션을 만들어라. 썰 키워드가 빈약하면 그 신/영웅의 유명한 신화 일화로 살을 붙여도 된다.
"""
NARR_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
"hook": {"type": "string"},
"story": {"type": "string"},
"pivot": {"type": "string"},
"cta": {"type": "string"},
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
"estimated_seconds": {"type": "integer"},
"hashtags": {"type": "array", "items": {"type": "string"}},
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
},
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
}
def generate(client, model, seed, product, seconds, store=""):
"""신화 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini)."""
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
user = NARR_USER.format(
king=seed["king"], doc=seed["doc"],
keyword=seed["keyword"], url=seed["url"], product=product,
store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
)
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
resp = client.models.generate_content(
model=model,
contents=user,
config=types.GenerateContentConfig(
system_instruction=sys_prompt,
temperature=1.1, # 병맛 = 약간 높은 창의성
response_mime_type="application/json",
response_schema=NARR_SCHEMA,
),
)
data = json.loads(resp.text)
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
data["_product"] = product
return data
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
SB_SYSTEM = """\
너는 숏폼 영상 디렉터다. 주어진 '병맛 그리스 로마 신화 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
[핵심 규칙]
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the muscular god dramatically throwing a lightning bolt from a cloud, sweating)
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
→ 그래야 컷마다 눈 표정이 다양해진다.
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
★ 한국의 '그리스 로마 신화 학습만화'처럼, 신을 '진짜 신의 모습'으로 그린다 — 의인화 동물 금지.
· 남신/남자 영웅: 조각상 같은 근육질의 멋진 체격(broad shoulders, heroic muscular greek-statue
physique), 잘생긴 얼굴. · 여신: 아름답고 늘씬한 몸매(beautiful, slender, graceful), 긴 머리.
· 반드시 '성인 8등신 비율'로: tall adult heroic proportions, 8-heads-tall elegant body,
NOT chibi, NOT super-deformed, NOT cute mascot — 치비/SD/애기얼굴 금지.
→ 단 과한 노출·선정적 묘사는 금지. 토가/키톤/갑옷 등 신화 복식은 제대로 입힌다.
복식/색/머리/상징물(번개·투구·부엉이·삼지창·월계관 등 그 신의 아이콘)을 못박아 컷마다 동일하게.
(예: Zeus as a tall handsome god with a heroic muscular greek-statue physique, long white hair and
majestic white beard, wearing a white toga with gold trim and a laurel wreath, holding a glowing
golden lightning bolt / Athena as a beautiful slender goddess with long dark hair, wearing a bronze
helmet and flowing white chiton with a golden owl emblem, holding a spear and round shield)
★ 눈 그림체: 순정만화/한국 신화만화풍의 크고 또렷한 눈(large expressive sparkling manhwa eyes,
detailed irises with light reflections)으로 통일. 이 '그림체'만 유지하고, 장면 감정에 따라
눈을 감거나(eyes closed) 실눈(squint)·윙크·부릅뜸(wide dramatic glare) 등 표정은 바꿔도 된다.
- style_guide: 그림 스타일을 영어로. 한국 그리스로마신화 학습만화풍(classic Korean mythology manhwa
style) — 깔끔하고 또렷한 외곽선, 화사하고 선명한 색, 반짝이는 하이라이트, 극적인 연출,
'성인 비율의 캐릭터(tall adult proportions, never chibi or super-deformed)',
단순 배경(고대 그리스 신전·올림포스·구름·대리석 기둥 분위기), 세로 9:16.
[출력] 지정된 JSON 스키마로만.
"""
SB_USER = """\
[마케팅 대상] {product}
[소재 인물] {king}
[목표 장면 수] 약 {scenes}개
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
{narration}
"""
SB_SCHEMA = {
"type": "object",
"properties": {
"character_sheet": {"type": "string"},
"style_guide": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"narration": {"type": "string"},
"caption": {"type": "string"},
"image_prompt": {"type": "string"},
},
"required": ["narration", "caption", "image_prompt"],
},
},
},
"required": ["character_sheet", "style_guide", "scenes"],
}
def clamp_scenes(scene_list, target):
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
def clen(s):
return len(s.get("narration", "").strip())
while len(scene_list) > target:
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
i = min(range(len(scene_list) - 1),
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
base["narration"] = (a + " " + b).strip()
scene_list[i:i + 2] = [base]
return scene_list
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
"""나레이션 → 스토리보드 dict (Gemini)."""
resp = client.models.generate_content(
model=model,
contents=SB_USER.format(product=product, king=king,
scenes=scenes, narration=narration),
config=types.GenerateContentConfig(
system_instruction=SB_SYSTEM,
temperature=0.8,
response_mime_type="application/json",
response_schema=SB_SCHEMA,
),
)
sb = json.loads(resp.text)
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
raw = sb.get("scenes", [])
kept = [s for s in raw if s.get("narration", "").strip()]
if len(kept) < len(raw):
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
sc["narration"] = _no_emoji(sc.get("narration", ""))
sc["caption"] = _no_emoji(sc.get("caption", ""))
sb["_product"] = product
sb["_king"] = king
return sb

View File

@ -0,0 +1,275 @@
# -*- 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:
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not _key:
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
try:
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
except Exception as e:
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
if rstore and not brief_store:
brief_store = rstore
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
if not bullets:
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
"키워드를 직접 주거나 다른 링크를 쓰세요.")
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
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] 나레이션 생성...")
store_name = brief_store or getattr(args, "store", None) or label
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
# 제목엔 해시태그가 들어갈 이유가 없다 — LLM 이 끼워넣은 '#태그' 토큰 제거(태그는 hashtags 필드에만).
narr["title"] = re.sub(r"\s*#\S+", "", narr.get("title", "")).strip()
(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")
# SNS 업로드용 캡션(이모지·해시태그 포함) — content_sync/finalize 가 읽어 Content.caption 에 저장.
caption = (narr.get("sns_caption") or "").strip()
if caption:
(job_dir / "caption.txt").write_text(caption, 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] 이미지·음성 생성...")
store = args.store or brief_store or product.split(",")[0].strip() or None
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
real_images=real_images, real_scenes=real_scenes,
max_real=args.max_real, match_content=not args.no_match,
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
img_model=args.img_model, allow_text=args.allow_text,
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
shutil.rmtree(job_dir / "assets", ignore_errors=True)
for junk in ("narration.json", "storyboard.json"):
(job_dir / junk).unlink(missing_ok=True)
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
print(f"\n✅ 완성: {out_mp4}")
print(f" {seed['king']} 썰 · {product}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,980 @@
# -*- 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
OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/
W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
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급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
def _find_font(name):
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
if c.exists():
return str(c)
return str(HERE / "fonts" / name)
FONT = _find_font("Jua-Regular.ttf")
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
TITLE_FONT = _find_font("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 (superfast)")
return ["-c:v", "libx264", "-preset", "superfast", "-crf", "26"]
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)) # 꽃심
_LOGO_RGBA = None
def _brand_logo():
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
global _LOGO_RGBA
if _LOGO_RGBA is not None:
return _LOGO_RGBA
if not Path(BRAND_LOGO).exists():
return None
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
g = Image.open(BRAND_LOGO).convert("L")
alpha = g.point(lambda p: 255 - p)
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
logo.putalpha(alpha)
_LOGO_RGBA = logo
return logo
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 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) # 옅은 구분선
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
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)
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
ink = (30, 30, 30, 255)
sep = "×" # 곱셈기호(×) — 콜라보 표기
gap = 26
w_title = d.textlength(title, font=tf)
w_sep = d.textlength(sep, font=tf)
logo = _brand_logo()
logo_h = 0
if logo is not None:
logo_h = int(TITLE_SIZE * 1.05)
logo_w = max(1, int(logo.width * logo_h / logo.height))
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
else:
logo_w = 0
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
group_left = (W - total) / 2
x = group_left
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
x += w_title + gap
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
x += w_sep + gap
if logo is not None:
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
rose = (228, 150, 168, 255)
bgap = 46
_blossom(d, group_left - bgap, cy, 17, rose)
_blossom(d, group_left + total + bgap, cy, 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
size = NARR_SIZE
while size > NARR_MIN_SIZE:
f = ImageFont.truetype(font_path, size)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(size * 1.4)
if len(lines) * lh <= avail:
break
size -= 2
else:
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(NARR_MIN_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/<값> 순으로 파일/폴더 탐색
"""
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)])
# 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 _scene_motion(idx: int, n: int, frames: int) -> str:
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
"""
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f = max(frames, 1)
if idx == 1: # 훅: 리빌(살짝 당김→전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
return ("zoompan=z='1.06':"
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
kind = idx % 4
if kind == 0: # 줌아웃(끝에 전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if kind == 1: # 줌인(시작에 전체)
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
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%.
"""
print("[4/4] 영상 합성...", flush=True)
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 * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
motion = _scene_motion(idx, len(scenes), frames)
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.
fades = "" if idx == 1 else f"fade=t=in:st=0:d={fd:.2f}:color=white,"
fades += f"fade=t=out:st={max(dur - fd, 0):.2f}:d={fd:.2f}:color=white"
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
f"crop={bw}:{bh},"
f"{motion}:d={frames}:s={W}x{img_h}:fps={FPS}[img];"
f"[bg][img]overlay=0:{band_h},{fades}[base];"
f"[base][1:v]overlay=0:0[v]")
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
_run([exe, "-y", "-loop", "1", "-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",
# AAC priming(1024샘플) 탓에 concat 이 비디오 시작 pts 를 +0.023s 밀어버리면
# 페북/인스타(Buffer) 썸네일 추출(t=0)이 빈 구간=검은 화면을 잡는다 → pts 0 으로 재작성.
"-bsf:v", "setts=ts=TS-STARTDTS:pts=PTS-STARTPTS",
"-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)
import time as _tm; _t_img = _tm.monotonic()
# --- 이미지: 캐릭터 앵커(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)
print(f"[⏱] 이미지 {_tm.monotonic() - _t_img:.1f}초", flush=True); _t_tts = _tm.monotonic()
# --- 음성: 전부 독립 → 병렬 ---
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)
print(f"[⏱] 음성 {_tm.monotonic() - _t_tts:.1f}초", flush=True)
out = workdir / f"{stem}.mp4"
print(f"\n영상 합성 → {out.name}")
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
bgm=bgm, bgm_volume=bgm_volume)
print(f"완성: {out}")
return out

View File

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

View File

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

View File

@ -0,0 +1,91 @@
번호,인물,문서제목,썰후보여부,키워드,URL
1,오디세우스,오디세우스,Y,트로이 목마 계략,https://namu.wiki/w/오디세우스
2,오디세우스,오디세우스,Y,10년 귀향길 표류,https://namu.wiki/w/오디세우스
3,오디세우스,오디세우스,Y,아무도 아니다 라는 이름 사기,https://namu.wiki/w/폴리페모스
4,폴리페모스,폴리페모스,Y,외눈박이 거인의 동굴 감금,https://namu.wiki/w/폴리페모스
5,폴리페모스,폴리페모스,Y,포도주 먹여 눈 찌르기,https://namu.wiki/w/폴리페모스
6,폴리페모스,폴리페모스,Y,양 배에 매달려 탈출,https://namu.wiki/w/폴리페모스
7,아이올로스,아이올로스,Y,모든 역풍을 가둔 바람주머니 선물,https://namu.wiki/w/아이올로스
8,아이올로스,아이올로스,Y,선원들이 몰래 열어본 바람주머니,https://namu.wiki/w/아이올로스
9,로토파고이,로토파고이,Y,망각의 열매를 먹고 귀향을 잊은 선원들,https://namu.wiki/w/로토파고이
10,키르케,키르케,Y,선원들을 돼지로 만든 마법,https://namu.wiki/w/키르케
11,키르케,키르케,Y,헤르메스가 준 마법 약초로 저항,https://namu.wiki/w/키르케
12,키르케,키르케,Y,1년간 섬에 눌러앉은 오디세우스,https://namu.wiki/w/키르케
13,오디세우스,오디세우스,Y,저승에서 들은 테이레시아스의 귀향 예언,https://namu.wiki/w/오디세우스
14,세이렌,세이렌,Y,아름다운 노래로 선원을 유혹해 죽이는 괴물,https://namu.wiki/w/세이렌
15,세이렌,세이렌,Y,선원 귀 밀랍 막고 자신만 돛대에 결박,https://namu.wiki/w/세이렌
16,스킬라,스킬라,Y,여섯 개 머리로 선원을 잡아먹는 괴물,https://namu.wiki/w/스킬라
17,카립디스,카립디스,Y,배를 통째로 삼키는 거대한 소용돌이,https://namu.wiki/w/카립디스
18,오디세우스,오디세우스,Y,헬리오스의 신성한 소를 잡아먹은 선원들,https://namu.wiki/w/오디세우스
19,오디세우스,오디세우스,Y,제우스의 벼락으로 배 전체가 난파,https://namu.wiki/w/오디세우스
20,칼립소,칼립소,Y,7년간 섬에 억류하며 청혼한 님프,https://namu.wiki/w/칼립소
21,칼립소,칼립소,Y,불멸을 미끼로 붙잡았지만 결국 놓아준 사연,https://namu.wiki/w/칼립소
22,오디세우스,오디세우스,Y,아들 폴리페모스 실명에 분노한 포세이돈의 저주,https://namu.wiki/w/오디세우스
23,오디세우스,오디세우스,Y,오디세우스를 몰래 도운 수호신 아테나,https://namu.wiki/w/오디세우스
24,나우시카,나우시카(그리스 로마 신화),Y,해변에 표류한 오디세우스를 구해준 공주,https://namu.wiki/w/나우시카(그리스 로마 신화)
25,페넬로페,페넬로페,Y,낮에 짜고 밤에 풀며 구혼자를 버틴 수의,https://namu.wiki/w/페넬로페
26,페넬로페,페넬로페,Y,20년을 기다린 정절의 상징,https://namu.wiki/w/페넬로페
27,텔레마코스,텔레마코스,Y,아버지를 찾아 떠난 성장 여정,https://namu.wiki/w/텔레마코스
28,안티노오스,안티노오스,Y,페넬로페 재산을 축내며 눌러앉은 구혼자들,https://namu.wiki/w/안티노오스
29,오디세우스,오디세우스,Y,거지로 변장해 몰래 귀향,https://namu.wiki/w/오디세우스
30,오디세우스,오디세우스,Y,오디세우스만 당길 수 있는 활 시합,https://namu.wiki/w/오디세우스
31,오디세우스,오디세우스,Y,구혼자 전원 응징하고 왕좌 탈환,https://namu.wiki/w/오디세우스
32,오디세우스,오디세우스,Y,참전 피하려고 미친 척 밭 갈다 아들 앞에서 들통,https://namu.wiki/w/오디세우스
33,오디세우스,오디세우스,Y,여장한 아킬레우스를 무기 장사꾼 변장으로 색출,https://namu.wiki/w/오디세우스
34,오디세우스,오디세우스,Y,아킬레우스의 무구를 놓고 아이아스와 말싸움 승리,https://namu.wiki/w/오디세우스
35,오디세우스,오디세우스,Y,거지 변장으로 트로이 성내 잠입 정찰,https://namu.wiki/w/오디세우스
36,오디세우스,오디세우스,Y,트로이 수호 신상 팔라디온 훔치기 작전,https://namu.wiki/w/오디세우스
37,오디세우스,오디세이아,Y,귀향길 첫 정거장 키코네스족 약탈하다 역관광,https://namu.wiki/w/오디세이아
38,폴리페모스,폴리페모스,Y,다 도망쳐놓고 굳이 본명 외쳐서 저주 확정,https://namu.wiki/w/폴리페모스
39,폴리페모스,폴리페모스,Y,사람을 통째로 잡아먹는 식사 매너,https://namu.wiki/w/폴리페모스
40,아이올로스,아이올로스,Y,두 번째 찾아간 오디세우스를 문전박대,https://namu.wiki/w/아이올로스
41,라이스트리고네스,오디세이아,Y,귀향 함대 12척 중 11척을 전멸시킨 식인 거인족,https://namu.wiki/w/오디세이아
42,로토파고이,로토파고이,Y,울며 버티는 선원들을 배에 묶어 끌고 온 오디세우스,https://namu.wiki/w/로토파고이
43,키르케,키르케,Y,돼지가 된 부하들을 되돌리는 조건 협상,https://namu.wiki/w/키르케
44,키르케,키르케,Y,귀향하려면 저승부터 다녀오라는 미션 브리핑,https://namu.wiki/w/키르케
45,엘페노르,오디세이아,Y,지붕에서 자다 떨어져 죽고 저승에서 재회한 막내 선원,https://namu.wiki/w/오디세이아
46,오디세우스,오디세우스,Y,저승에서 어머니의 망령과 마주친 오디세우스,https://namu.wiki/w/오디세우스
47,오디세우스,오디세우스,Y,저승의 아가멤논이 해준 아내 조심하라는 조언,https://namu.wiki/w/오디세우스
48,오디세우스,오디세우스,Y,죽은 영웅보다 산 머슴이 낫다는 아킬레우스의 저승 명언,https://namu.wiki/w/오디세우스
49,오디세우스,오디세우스,Y,저승에서 목격한 시시포스와 탄탈로스의 형벌,https://namu.wiki/w/오디세우스
50,세이렌,세이렌,Y,노래 듣고 풀어달라 몸부림친 오디세우스,https://namu.wiki/w/세이렌
51,스킬라,스킬라,Y,카립디스 피하려다 부하 여섯 명을 상납한 선택,https://namu.wiki/w/스킬라
52,카립디스,카립디스,Y,난파 후 무화과나무에 매달려 소용돌이를 버틴 생존기,https://namu.wiki/w/카립디스
53,오디세우스,오디세우스,Y,굶주린 선원들이 금기를 깨고 소를 구운 항해 최악의 바비큐,https://namu.wiki/w/오디세우스
54,칼립소,칼립소,Y,뗏목 만들어 탈출했지만 포세이돈 폭풍으로 또 난파,https://namu.wiki/w/칼립소
55,오디세우스,오디세우스,Y,여신 이노의 마법 스카프 하나로 이틀 밤낮 헤엄친 표류,https://namu.wiki/w/오디세우스
56,나우시카,나우시카(그리스 로마 신화),Y,빨래하러 나왔다가 알몸 표류자를 주운 공주,https://namu.wiki/w/나우시카(그리스 로마 신화)
57,알키노오스,오디세이아,Y,모험담 들려준 값으로 보물 실어 귀향시켜준 왕,https://namu.wiki/w/오디세이아
58,데모도코스,오디세이아,Y,트로이 목마 노래에 몰래 울다 들킨 오디세우스,https://namu.wiki/w/오디세이아
59,오디세우스,오디세우스,Y,파이아케스 청년들 도발에 원반던지기 한 방으로 제압,https://namu.wiki/w/오디세우스
60,오디세우스,오디세이아,Y,오디세우스를 데려다준 배를 돌로 만든 포세이돈,https://namu.wiki/w/오디세이아
61,오디세우스,오디세우스,Y,잠든 채 이타카 해변에 짐짝처럼 내려진 귀향,https://namu.wiki/w/오디세우스
62,오디세우스,오디세우스,Y,안개 때문에 20년 만의 고향을 못 알아본 오디세우스,https://namu.wiki/w/오디세우스
63,오디세우스,오디세우스,Y,아테나의 분장술로 폭삭 늙은 거지로 변신,https://namu.wiki/w/오디세우스
64,에우마이오스,오디세이아,Y,거지 변장한 주인을 극진히 대접한 충직한 돼지치기,https://namu.wiki/w/오디세이아
65,아르고스,오디세이아,Y,20년을 기다려 변장한 오디세우스를 알아보고 숨을 거둔 개,https://namu.wiki/w/오디세이아
66,에우리클레이아,오디세이아,Y,발 씻기다 흉터를 만지고 오디세우스의 정체를 눈치챈 유모,https://namu.wiki/w/오디세이아
67,이로스,오디세이아,Y,거지 변장한 오디세우스에게 권투 한 방에 뻗은 진짜 거지,https://namu.wiki/w/오디세이아
68,텔레마코스,텔레마코스,Y,아버지 소식 찾아 스파르타의 메넬라오스와 헬레네 방문,https://namu.wiki/w/텔레마코스
69,텔레마코스,텔레마코스,Y,구혼자들이 짠 매복 암살을 피해 돌아온 귀환길,https://namu.wiki/w/텔레마코스
70,텔레마코스,텔레마코스,Y,거지가 아버지라는 사실에 오열한 부자 상봉,https://namu.wiki/w/텔레마코스
71,페넬로페,페넬로페,Y,시녀의 밀고로 들통난 수의 되풀기 3년 사기,https://namu.wiki/w/페넬로페
72,페넬로페,페넬로페,Y,남편을 앞에 두고도 활 시합을 제안한 촉,https://namu.wiki/w/페넬로페
73,페넬로페,페넬로페,Y,침대를 옮겨보라는 함정 문제로 남편 최종 검증,https://namu.wiki/w/페넬로페
74,오디세우스,오디세우스,Y,올리브나무를 통째로 깎아 만든 부부만 아는 침대의 비밀,https://namu.wiki/w/오디세우스
75,안티노오스,안티노오스,Y,술잔 들다 첫 화살에 목이 꿰뚫린 구혼자 대장,https://namu.wiki/w/안티노오스
76,안티노오스,안티노오스,Y,거지에게 의자를 집어던진 구혼자의 최후 복선,https://namu.wiki/w/안티노오스
77,안티노오스,안티노오스,Y,금품으로 목숨을 흥정하다 실패한 구혼자들,https://namu.wiki/w/안티노오스
78,오디세우스,오디세우스,Y,도끼 열두 자루 구멍을 화살 하나로 관통한 활 솜씨,https://namu.wiki/w/오디세우스
79,오디세우스,오디세우스,Y,활 시합 우승 직후 시작된 연회장 대숙청,https://namu.wiki/w/오디세우스
80,라에르테스,라에르테스,Y,과수원 나무 목록을 읊어 아버지에게 정체 증명한 오디세우스,https://namu.wiki/w/라에르테스
81,라에르테스,라에르테스,Y,아들이 트로이에서 죽은 줄 알고 농사만 짓던 전직 왕,https://namu.wiki/w/라에르테스
82,오디세우스,오디세우스,Y,구혼자 유족들과의 내전 직전 아테나의 강제 중재,https://namu.wiki/w/오디세우스
83,키르케,키르케,Y,세이렌과 스킬라 공략법을 알려준 항해 스포일러,https://namu.wiki/w/키르케
84,폴리페모스,폴리페모스,Y,눈 찔리고 아무도가 그랬다며 동료 도움을 못 받은 거인,https://namu.wiki/w/폴리페모스
85,오디세우스,오디세우스,Y,부하들은 다 죽고 혼자만 살아 돌아온 함대의 최후,https://namu.wiki/w/오디세우스
86,페넬로페,페넬로페,Y,재회하고도 밤새 모험담을 들은 부부의 회포,https://namu.wiki/w/페넬로페
87,오디세우스,오디세우스,Y,트로이 전쟁 10년에 귀향 10년 도합 20년 출장,https://namu.wiki/w/오디세우스
88,세이렌,세이렌,Y,오디세우스를 놓치고 체면을 구긴 세이렌 자매,https://namu.wiki/w/세이렌
89,칼립소,칼립소,Y,신들은 되면서 님프 연애는 안 되냐고 항의한 칼립소,https://namu.wiki/w/칼립소
90,오디세우스,오디세우스,Y,노를 어깨에 메고 바다 모르는 내륙까지 걸으라는 마지막 예언,https://namu.wiki/w/오디세우스
1 번호 인물 문서제목 썰후보여부 키워드 URL
2 1 오디세우스 오디세우스 Y 트로이 목마 계략 https://namu.wiki/w/오디세우스
3 2 오디세우스 오디세우스 Y 10년 귀향길 표류 https://namu.wiki/w/오디세우스
4 3 오디세우스 오디세우스 Y 아무도 아니다 라는 이름 사기 https://namu.wiki/w/폴리페모스
5 4 폴리페모스 폴리페모스 Y 외눈박이 거인의 동굴 감금 https://namu.wiki/w/폴리페모스
6 5 폴리페모스 폴리페모스 Y 포도주 먹여 눈 찌르기 https://namu.wiki/w/폴리페모스
7 6 폴리페모스 폴리페모스 Y 양 배에 매달려 탈출 https://namu.wiki/w/폴리페모스
8 7 아이올로스 아이올로스 Y 모든 역풍을 가둔 바람주머니 선물 https://namu.wiki/w/아이올로스
9 8 아이올로스 아이올로스 Y 선원들이 몰래 열어본 바람주머니 https://namu.wiki/w/아이올로스
10 9 로토파고이 로토파고이 Y 망각의 열매를 먹고 귀향을 잊은 선원들 https://namu.wiki/w/로토파고이
11 10 키르케 키르케 Y 선원들을 돼지로 만든 마법 https://namu.wiki/w/키르케
12 11 키르케 키르케 Y 헤르메스가 준 마법 약초로 저항 https://namu.wiki/w/키르케
13 12 키르케 키르케 Y 1년간 섬에 눌러앉은 오디세우스 https://namu.wiki/w/키르케
14 13 오디세우스 오디세우스 Y 저승에서 들은 테이레시아스의 귀향 예언 https://namu.wiki/w/오디세우스
15 14 세이렌 세이렌 Y 아름다운 노래로 선원을 유혹해 죽이는 괴물 https://namu.wiki/w/세이렌
16 15 세이렌 세이렌 Y 선원 귀 밀랍 막고 자신만 돛대에 결박 https://namu.wiki/w/세이렌
17 16 스킬라 스킬라 Y 여섯 개 머리로 선원을 잡아먹는 괴물 https://namu.wiki/w/스킬라
18 17 카립디스 카립디스 Y 배를 통째로 삼키는 거대한 소용돌이 https://namu.wiki/w/카립디스
19 18 오디세우스 오디세우스 Y 헬리오스의 신성한 소를 잡아먹은 선원들 https://namu.wiki/w/오디세우스
20 19 오디세우스 오디세우스 Y 제우스의 벼락으로 배 전체가 난파 https://namu.wiki/w/오디세우스
21 20 칼립소 칼립소 Y 7년간 섬에 억류하며 청혼한 님프 https://namu.wiki/w/칼립소
22 21 칼립소 칼립소 Y 불멸을 미끼로 붙잡았지만 결국 놓아준 사연 https://namu.wiki/w/칼립소
23 22 오디세우스 오디세우스 Y 아들 폴리페모스 실명에 분노한 포세이돈의 저주 https://namu.wiki/w/오디세우스
24 23 오디세우스 오디세우스 Y 오디세우스를 몰래 도운 수호신 아테나 https://namu.wiki/w/오디세우스
25 24 나우시카 나우시카(그리스 로마 신화) Y 해변에 표류한 오디세우스를 구해준 공주 https://namu.wiki/w/나우시카(그리스 로마 신화)
26 25 페넬로페 페넬로페 Y 낮에 짜고 밤에 풀며 구혼자를 버틴 수의 https://namu.wiki/w/페넬로페
27 26 페넬로페 페넬로페 Y 20년을 기다린 정절의 상징 https://namu.wiki/w/페넬로페
28 27 텔레마코스 텔레마코스 Y 아버지를 찾아 떠난 성장 여정 https://namu.wiki/w/텔레마코스
29 28 안티노오스 안티노오스 Y 페넬로페 재산을 축내며 눌러앉은 구혼자들 https://namu.wiki/w/안티노오스
30 29 오디세우스 오디세우스 Y 거지로 변장해 몰래 귀향 https://namu.wiki/w/오디세우스
31 30 오디세우스 오디세우스 Y 오디세우스만 당길 수 있는 활 시합 https://namu.wiki/w/오디세우스
32 31 오디세우스 오디세우스 Y 구혼자 전원 응징하고 왕좌 탈환 https://namu.wiki/w/오디세우스
33 32 오디세우스 오디세우스 Y 참전 피하려고 미친 척 밭 갈다 아들 앞에서 들통 https://namu.wiki/w/오디세우스
34 33 오디세우스 오디세우스 Y 여장한 아킬레우스를 무기 장사꾼 변장으로 색출 https://namu.wiki/w/오디세우스
35 34 오디세우스 오디세우스 Y 아킬레우스의 무구를 놓고 아이아스와 말싸움 승리 https://namu.wiki/w/오디세우스
36 35 오디세우스 오디세우스 Y 거지 변장으로 트로이 성내 잠입 정찰 https://namu.wiki/w/오디세우스
37 36 오디세우스 오디세우스 Y 트로이 수호 신상 팔라디온 훔치기 작전 https://namu.wiki/w/오디세우스
38 37 오디세우스 오디세이아 Y 귀향길 첫 정거장 키코네스족 약탈하다 역관광 https://namu.wiki/w/오디세이아
39 38 폴리페모스 폴리페모스 Y 다 도망쳐놓고 굳이 본명 외쳐서 저주 확정 https://namu.wiki/w/폴리페모스
40 39 폴리페모스 폴리페모스 Y 사람을 통째로 잡아먹는 식사 매너 https://namu.wiki/w/폴리페모스
41 40 아이올로스 아이올로스 Y 두 번째 찾아간 오디세우스를 문전박대 https://namu.wiki/w/아이올로스
42 41 라이스트리고네스 오디세이아 Y 귀향 함대 12척 중 11척을 전멸시킨 식인 거인족 https://namu.wiki/w/오디세이아
43 42 로토파고이 로토파고이 Y 울며 버티는 선원들을 배에 묶어 끌고 온 오디세우스 https://namu.wiki/w/로토파고이
44 43 키르케 키르케 Y 돼지가 된 부하들을 되돌리는 조건 협상 https://namu.wiki/w/키르케
45 44 키르케 키르케 Y 귀향하려면 저승부터 다녀오라는 미션 브리핑 https://namu.wiki/w/키르케
46 45 엘페노르 오디세이아 Y 지붕에서 자다 떨어져 죽고 저승에서 재회한 막내 선원 https://namu.wiki/w/오디세이아
47 46 오디세우스 오디세우스 Y 저승에서 어머니의 망령과 마주친 오디세우스 https://namu.wiki/w/오디세우스
48 47 오디세우스 오디세우스 Y 저승의 아가멤논이 해준 아내 조심하라는 조언 https://namu.wiki/w/오디세우스
49 48 오디세우스 오디세우스 Y 죽은 영웅보다 산 머슴이 낫다는 아킬레우스의 저승 명언 https://namu.wiki/w/오디세우스
50 49 오디세우스 오디세우스 Y 저승에서 목격한 시시포스와 탄탈로스의 형벌 https://namu.wiki/w/오디세우스
51 50 세이렌 세이렌 Y 노래 듣고 풀어달라 몸부림친 오디세우스 https://namu.wiki/w/세이렌
52 51 스킬라 스킬라 Y 카립디스 피하려다 부하 여섯 명을 상납한 선택 https://namu.wiki/w/스킬라
53 52 카립디스 카립디스 Y 난파 후 무화과나무에 매달려 소용돌이를 버틴 생존기 https://namu.wiki/w/카립디스
54 53 오디세우스 오디세우스 Y 굶주린 선원들이 금기를 깨고 소를 구운 항해 최악의 바비큐 https://namu.wiki/w/오디세우스
55 54 칼립소 칼립소 Y 뗏목 만들어 탈출했지만 포세이돈 폭풍으로 또 난파 https://namu.wiki/w/칼립소
56 55 오디세우스 오디세우스 Y 여신 이노의 마법 스카프 하나로 이틀 밤낮 헤엄친 표류 https://namu.wiki/w/오디세우스
57 56 나우시카 나우시카(그리스 로마 신화) Y 빨래하러 나왔다가 알몸 표류자를 주운 공주 https://namu.wiki/w/나우시카(그리스 로마 신화)
58 57 알키노오스 오디세이아 Y 모험담 들려준 값으로 보물 실어 귀향시켜준 왕 https://namu.wiki/w/오디세이아
59 58 데모도코스 오디세이아 Y 트로이 목마 노래에 몰래 울다 들킨 오디세우스 https://namu.wiki/w/오디세이아
60 59 오디세우스 오디세우스 Y 파이아케스 청년들 도발에 원반던지기 한 방으로 제압 https://namu.wiki/w/오디세우스
61 60 오디세우스 오디세이아 Y 오디세우스를 데려다준 배를 돌로 만든 포세이돈 https://namu.wiki/w/오디세이아
62 61 오디세우스 오디세우스 Y 잠든 채 이타카 해변에 짐짝처럼 내려진 귀향 https://namu.wiki/w/오디세우스
63 62 오디세우스 오디세우스 Y 안개 때문에 20년 만의 고향을 못 알아본 오디세우스 https://namu.wiki/w/오디세우스
64 63 오디세우스 오디세우스 Y 아테나의 분장술로 폭삭 늙은 거지로 변신 https://namu.wiki/w/오디세우스
65 64 에우마이오스 오디세이아 Y 거지 변장한 주인을 극진히 대접한 충직한 돼지치기 https://namu.wiki/w/오디세이아
66 65 아르고스 오디세이아 Y 20년을 기다려 변장한 오디세우스를 알아보고 숨을 거둔 개 https://namu.wiki/w/오디세이아
67 66 에우리클레이아 오디세이아 Y 발 씻기다 흉터를 만지고 오디세우스의 정체를 눈치챈 유모 https://namu.wiki/w/오디세이아
68 67 이로스 오디세이아 Y 거지 변장한 오디세우스에게 권투 한 방에 뻗은 진짜 거지 https://namu.wiki/w/오디세이아
69 68 텔레마코스 텔레마코스 Y 아버지 소식 찾아 스파르타의 메넬라오스와 헬레네 방문 https://namu.wiki/w/텔레마코스
70 69 텔레마코스 텔레마코스 Y 구혼자들이 짠 매복 암살을 피해 돌아온 귀환길 https://namu.wiki/w/텔레마코스
71 70 텔레마코스 텔레마코스 Y 거지가 아버지라는 사실에 오열한 부자 상봉 https://namu.wiki/w/텔레마코스
72 71 페넬로페 페넬로페 Y 시녀의 밀고로 들통난 수의 되풀기 3년 사기 https://namu.wiki/w/페넬로페
73 72 페넬로페 페넬로페 Y 남편을 앞에 두고도 활 시합을 제안한 촉 https://namu.wiki/w/페넬로페
74 73 페넬로페 페넬로페 Y 침대를 옮겨보라는 함정 문제로 남편 최종 검증 https://namu.wiki/w/페넬로페
75 74 오디세우스 오디세우스 Y 올리브나무를 통째로 깎아 만든 부부만 아는 침대의 비밀 https://namu.wiki/w/오디세우스
76 75 안티노오스 안티노오스 Y 술잔 들다 첫 화살에 목이 꿰뚫린 구혼자 대장 https://namu.wiki/w/안티노오스
77 76 안티노오스 안티노오스 Y 거지에게 의자를 집어던진 구혼자의 최후 복선 https://namu.wiki/w/안티노오스
78 77 안티노오스 안티노오스 Y 금품으로 목숨을 흥정하다 실패한 구혼자들 https://namu.wiki/w/안티노오스
79 78 오디세우스 오디세우스 Y 도끼 열두 자루 구멍을 화살 하나로 관통한 활 솜씨 https://namu.wiki/w/오디세우스
80 79 오디세우스 오디세우스 Y 활 시합 우승 직후 시작된 연회장 대숙청 https://namu.wiki/w/오디세우스
81 80 라에르테스 라에르테스 Y 과수원 나무 목록을 읊어 아버지에게 정체 증명한 오디세우스 https://namu.wiki/w/라에르테스
82 81 라에르테스 라에르테스 Y 아들이 트로이에서 죽은 줄 알고 농사만 짓던 전직 왕 https://namu.wiki/w/라에르테스
83 82 오디세우스 오디세우스 Y 구혼자 유족들과의 내전 직전 아테나의 강제 중재 https://namu.wiki/w/오디세우스
84 83 키르케 키르케 Y 세이렌과 스킬라 공략법을 알려준 항해 스포일러 https://namu.wiki/w/키르케
85 84 폴리페모스 폴리페모스 Y 눈 찔리고 아무도가 그랬다며 동료 도움을 못 받은 거인 https://namu.wiki/w/폴리페모스
86 85 오디세우스 오디세우스 Y 부하들은 다 죽고 혼자만 살아 돌아온 함대의 최후 https://namu.wiki/w/오디세우스
87 86 페넬로페 페넬로페 Y 재회하고도 밤새 모험담을 들은 부부의 회포 https://namu.wiki/w/페넬로페
88 87 오디세우스 오디세우스 Y 트로이 전쟁 10년에 귀향 10년 도합 20년 출장 https://namu.wiki/w/오디세우스
89 88 세이렌 세이렌 Y 오디세우스를 놓치고 체면을 구긴 세이렌 자매 https://namu.wiki/w/세이렌
90 89 칼립소 칼립소 Y 신들은 되면서 님프 연애는 안 되냐고 항의한 칼립소 https://namu.wiki/w/칼립소
91 90 오디세우스 오디세우스 Y 노를 어깨에 메고 바다 모르는 내륙까지 걸으라는 마지막 예언 https://namu.wiki/w/오디세우스

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,358 @@
# -*- coding: utf-8 -*-
"""
animation_odyssey 생성 단계 (나레이션 + 스토리보드) — 병합본
==================================================================
오디세이아 '썰' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음.
(animation_greekroman 폴더와 동일 구조 — 소재만 '그리스 로마 신화 신·영웅' → '오디세우스의 귀향 대모험'.)
scraper.py 가 만든 odyssey_keywords_*.csv 를 입력으로 한다.
· 나레이션: latest_csv / load_rows / pick_seed / generate
· 스토리보드: clamp_scenes / make_storyboard
구조: 훅 → 썰 전개(역사 + 병맛 과장) → 어거지 전환 → 마케팅 + CTA → 장면별 분할(자막+그림 프롬프트).
"""
import csv
import json
import random
import re
import sys
from pathlib import Path
from google.genai import types
HERE = Path(__file__).parent
DATA_DIR = HERE / "data" # 오디세이아 일화 CSV(선행 데이터). ★ output/ 이 아님 — 거긴 출력물(mp4)만.
DEFAULT_MODEL = "gemini-2.5-flash"
# 자막 폰트(주아/송명/맑은고딕)에 없는 이모지·기호(😊▼★♥ 등)는 화면에서 □ 로 깨진다 → 제거.
_TYPO = {"…": "...", "‘": "'", "’": "'", "“": '"',
"”": '"', "·": " ", "~": "~", "〜": "~"}
# 폰트(주아/송명/맑은고딕)에 확실히 있는 것 = 한글 음절·자모(ㅋㅋ ㅠㅠ) + ASCII 인쇄문자. 그 외(이모지·…·특수기호)는 제거.
_KEEP_RE = re.compile(r"[^가-힣ㄱ-ㅣ\x20-\x7E]")
def _no_emoji(s):
s = s or ""
for _k, _v in _TYPO.items():
s = s.replace(_k, _v)
return re.sub(r" +", " ", _KEEP_RE.sub("", s)).strip()
# ============================== ① 나레이션 (신화 썰 → 병맛 대본) ==============================
def latest_csv() -> Path:
files = sorted(DATA_DIR.glob("odyssey_keywords_*.csv"))
if not files:
sys.exit("[!] animation_odyssey/data/ 에 odyssey_keywords_*.csv 가 없음. 먼저 'python scraper.py' 실행.")
return files[-1]
def load_rows(csv_path: Path):
"""CSV → [{order, king, doc, is_anecdote, keyword, url}, ...] (king = 신화 인물 이름)"""
rows = []
with csv_path.open(encoding="utf-8-sig", newline="") as f:
r = csv.reader(f)
next(r, None) # 헤더 스킵
for line in r:
if len(line) < 6:
continue
order, king, doc, anec, kw, url = line[:6]
rows.append({
"order": int(order) if order.isdigit() else 0,
"king": king, "doc": doc,
"is_anecdote": anec.strip().upper() == "Y",
"keyword": kw, "url": url,
})
return rows
# 오디세이아(귀향 대모험) 핵심 사건·인물 신호 — 이런 키워드를 '썰 씨앗'으로 최우선.
ODYSSEY_HINTS = [
"오디세이아", "오디세우스", "트로이", "목마", "섬", "동굴", "키클롭스", "폴리페모스",
"키르케", "칼립소", "세이렌", "스킬라", "카립디스", "아이올로스", "헬리오스",
"구혼자", "귀향", "파이에케스", "로토파고이", "수의", "표류", "항해", "저승",
"페넬로페", "텔레마코스", "안티노오스", "나우시카", "이타카",
"아무도", "난파", "변장", "활 시합",
]
# 오디세이아와 무관하거나 내용이 빈약한 위키 섹션 — 씨앗에서 제외(뽑히면 '그리스로마신화'로 샌다).
SEED_DROP = [
"개요", "어원", "특징", "총평", "평가", "긍정적", "부정적", "기타", "그 외",
"가계도", "가족", "대중매체", "창작물", "미디어", "만화", "숭배", "해석",
"신화 속 행적", "신화에서", "전승", "이름", "별명", "어록", "상징",
"비블리오테케", "텔레고네이아", # 오디세이아 외 전거(다른 신화 출처)
]
def _seed_pool(rows):
"""범용·무관 섹션을 걸러 '오디세이아에 실질적으로 쓸 만한' 씨앗만 남긴다."""
pool = []
for r in rows:
kw = r["keyword"].strip()
if not kw or kw == r["king"]: # 최상단 표제(= 인물명) 제외
continue
if any(bad in kw for bad in SEED_DROP): # 범용/무관 섹션 제외
continue
pool.append(r)
return pool
def pick_seed(rows, king=None, keyword=None):
"""대본의 씨앗이 될 (인물, 썰키워드) 한 건 선택. ★원 소스는 항상 오디세이아.
※ CSV 의 is_anecdote(Y) 는 '평가/기타/대중매체' 같은 범용 섹션에 붙어 오히려 오디세이아를 벗어나므로 쓰지 않는다."""
cand = rows
if king:
cand = [r for r in cand if r["king"] == king]
if not cand:
kings = sorted({r["king"] for r in rows})
sys.exit(f"[!] '{king}' 없음. 가능: {', '.join(kings)}")
if keyword:
kw_match = [r for r in cand if keyword in r["keyword"]]
if not kw_match:
sys.exit(f"[!] '{keyword}' 키워드가 {king or '전체'}에 없음.")
return random.choice(kw_match)
# ① 오디세이아 핵심 사건 키워드를 최우선(항해·괴물·귀향 등). 인물명도 함께 검사 —
# 키워드 문구에 힌트가 없어도 인물 자체가 오디세이아 전용(세이렌·키르케 등)이면 씨앗으로 유효.
odyssey = [r for r in cand
if any(h in r["keyword"] or h in r["king"] for h in ODYSSEY_HINTS)]
if odyssey:
return random.choice(odyssey)
# ② 없으면 범용/무관 섹션을 걸러낸 실질 씨앗 풀에서
pool = _seed_pool(cand)
if pool:
return random.choice(pool)
# ③ 최후 폴백: 빈 풀 방지
return random.choice(cand)
NARR_SYSTEM = """\
너는 한국 숏폼(쇼츠/릴스) 전문 카피라이터다. '병맛(어이없고 황당하지만 묘하게 중독성 있는)' 유머 톤으로,
오디세이아 속 오디세우스와 그 일행·신들의 '썰'을 풀다가 마지막에 자연스러운 척 억지로 광고로 갈아타는 {seconds}초 나레이션 대본을 쓴다.
[소재 원칙] (가장 중요)
- 원 소스는 반드시 서사시 '오디세이아'(오디세우스의 10년 귀향 대모험)다. 모든 썰은 이 항해 이야기 안에서 풀어라.
- 인물이 신(포세이돈·아테나·제우스 등)이라도 '오디세이아 속 그 신의 역할'로만 다뤄라
(포세이돈=귀향을 방해한 바다신, 아테나=오디세우스를 편애해 도운 여신 식). 오디세이아 밖 일반 그리스·로마 신화
일화(제우스의 바람기, 신들의 탄생·계보, 헤라클레스 과업 등)로 새지 마라 — 그건 다른 장르다.
[톤 규칙]
- 구어체 반말 나레이션. TTS(AI 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게.
- 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환.
- 오디세이아 내용(인물 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다.
유명 장면(트로이 목마, 외눈박이 폴리페모스 "아무도 아니다" 사기, 세이렌의 노래, 키르케의 돼지 마법,
칼립소의 섬, 스킬라와 카립디스, 페넬로페의 수의 짜기, 활쏘기 시합과 구혼자 응징 등)을 드립 소재로 적극 활용해도 된다.
포세이돈의 분노, 아테나의 편애 같은 유명 밈은 써도 되지만 선정적으로 묘사하진 마라.
단, 시청자가 '진짜 이야기'와 '드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
[구조] (반드시 이 4단)
1) hook : 3초 안에 스크롤 멈추게 하는 한 방. 질문/충격/궁금증.
2) story : 썰 본문. 역사로 시작해 점점 병맛으로 과열. 광고로 넘어갈 '연결고리'를 은근히 심어둔다.
3) pivot : "근데 알고 보니/그래서/요즘 같으면…" 식으로 광고로 억지 전환. 이 억지스러움 자체가 개그.
4) cta : 광고 대상의 핵심 셀링포인트 + 행동유도 한 줄.
[분량]
- 한국어 나레이션 기준 약 {seconds}초 분량 = {lo}~{hi}자. full_narration 은 반드시 이 글자수 범위로.
- 이 범위를 넘기지 마라. 너무 길면 TTS가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
[SNS 캡션] (sns_caption 필드 — 유튜브/인스타/틱톡에 그대로 붙여넣을 업로드 설명)
- 아래 '형식'을 지켜라. 이모지를 적극 사용(나레이션과 달리 캡션은 이모지 OK). 줄바꿈으로 구분.
- 형식:
1) 첫 줄: 후킹 카피 한 줄 (예: "7년 스토킹 당한 오디세우스 썰 풉니다.")
2) 본문 1~2줄: 오디세이아 썰 ↔ 가게를 잇는 재밌는 소개 (이모지 곁들여)
3) 셀링 한 줄: 인물이 반할 식으로 가게 핵심 (예: "오디세우스도 반할 OO시장 숨은 맛집 'OOO' 🐟")
4) 📍 가게명 (위치) ← 가게명/위치 모르면 이 줄 생략
5) 🔥 대표 메뉴·포인트 나열 ← 모르면 생략
6) 👉 방문 팁 또는 행동유도 한 줄
7) 마지막 줄: 해시태그 나열. #오디세이 #오디세우스 를 앞에 두고, 가게·지역·메뉴·병맛 태그 + #shorts #AIO2O 로 끝.
- 가게 정보(이름·위치·메뉴)는 '마케팅 대상' 텍스트에서 뽑아라. 없으면 그 줄은 자연스럽게 빼라(억지로 지어내지 마라).
[출력]
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
"""
NARR_USER = """\
[이번 쇼츠 소재]
- 인물: {king} ({doc})
- 썰 키워드: "{keyword}"
- 참고 출처: {url}
[마케팅 대상 (마지막에 이걸로 갈아타기)]
- 가게명: {store}
- 소개/키워드: {product}
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
{seconds}초 쇼츠 나레이션을 만들어라.
썰 키워드가 빈약하면, 그 인물이 '오디세이아(오디세우스의 귀향 대모험)'에서 맡은 역할·사건
(항해·괴물·표류·귀향·구혼자 응징 등)으로 살을 붙여라. 오디세이아와 무관한 다른 신화 일화로 새지 마라.
"""
NARR_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
"hook": {"type": "string"},
"story": {"type": "string"},
"pivot": {"type": "string"},
"cta": {"type": "string"},
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
"estimated_seconds": {"type": "integer"},
"hashtags": {"type": "array", "items": {"type": "string"}},
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
},
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
}
def generate(client, model, seed, product, seconds, store=""):
"""신화 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini). store = 가게명(SNS 캡션용)."""
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
user = NARR_USER.format(
king=seed["king"], doc=seed["doc"], keyword=seed["keyword"], url=seed["url"],
product=product, store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
)
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
resp = client.models.generate_content(
model=model,
contents=user,
config=types.GenerateContentConfig(
system_instruction=sys_prompt,
temperature=1.1, # 병맛 = 약간 높은 창의성
response_mime_type="application/json",
response_schema=NARR_SCHEMA,
),
)
data = json.loads(resp.text)
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
data["_product"] = product
return data
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
SB_SYSTEM = """\
너는 숏폼 영상 디렉터다. 주어진 '병맛 오디세이아 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
[핵심 규칙]
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the muscular god dramatically throwing a lightning bolt from a cloud, sweating)
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
→ 그래야 컷마다 눈 표정이 다양해진다.
★ 잔혹 장면(눈 찌르기, 학살, 잡아먹힘 등)은 이미지 안전필터에 걸려 생성이 거부된다.
폭력을 직접 묘사하지 말고 만화적으로 순화해 적어라 — 유혈/찌르는 순간/시체 금지.
(예: 눈 찌르기 → the cyclops comically covering his eye with cartoon stars around his head,
학살 → suitors comically fleeing in panic with exaggerated cartoon expressions)
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
★ 한국의 '그리스 로마 신화 학습만화'처럼, 신을 '진짜 신의 모습'으로 그린다 — 의인화 동물 금지.
· 남신/남자 영웅: 조각상 같은 근육질의 멋진 체격(broad shoulders, heroic muscular greek-statue
physique), 잘생긴 얼굴. · 여신: 아름답고 늘씬한 몸매(beautiful, slender, graceful), 긴 머리.
· 반드시 '성인 8등신 비율'로: tall adult heroic proportions, 8-heads-tall elegant body,
NOT chibi, NOT super-deformed, NOT cute mascot — 치비/SD/애기얼굴 금지.
→ 단 과한 노출·선정적 묘사는 금지. 토가/키톤/갑옷 등 신화 복식은 제대로 입힌다.
복식/색/머리/상징물(투구·활·삼지창·베일 등 그 인물의 아이콘)을 못박아 컷마다 동일하게.
(예: Odysseus as a tall rugged hero with a heroic muscular greek-statue physique, short dark curly
hair and a trimmed beard, wearing a worn bronze breastplate and a red cloak, holding a bow and a
spear / Athena as a beautiful slender goddess with long dark hair, wearing a bronze helmet and
flowing white chiton with a golden owl emblem, holding a spear and round shield)
★ 눈 그림체: 순정만화/한국 신화만화풍의 크고 또렷한 눈(large expressive sparkling manhwa eyes,
detailed irises with light reflections)으로 통일. 이 '그림체'만 유지하고, 장면 감정에 따라
눈을 감거나(eyes closed) 실눈(squint)·윙크·부릅뜸(wide dramatic glare) 등 표정은 바꿔도 된다.
- style_guide: 그림 스타일을 영어로. 한국 그리스로마신화 학습만화풍(classic Korean mythology manhwa
style) — 깔끔하고 또렷한 외곽선, 화사하고 선명한 색, 반짝이는 하이라이트, 극적인 연출,
'성인 비율의 캐릭터(tall adult proportions, never chibi or super-deformed)',
단순 배경(에게해·범선·동굴·고대 그리스 신전 등 오디세이아 항해 분위기), 세로 9:16.
[출력] 지정된 JSON 스키마로만.
"""
SB_USER = """\
[마케팅 대상] {product}
[소재 인물] {king}
[목표 장면 수] 약 {scenes}개
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
{narration}
"""
SB_SCHEMA = {
"type": "object",
"properties": {
"character_sheet": {"type": "string"},
"style_guide": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"narration": {"type": "string"},
"caption": {"type": "string"},
"image_prompt": {"type": "string"},
},
"required": ["narration", "caption", "image_prompt"],
},
},
},
"required": ["character_sheet", "style_guide", "scenes"],
}
def clamp_scenes(scene_list, target):
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
def clen(s):
return len(s.get("narration", "").strip())
while len(scene_list) > target:
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
i = min(range(len(scene_list) - 1),
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
base["narration"] = (a + " " + b).strip()
scene_list[i:i + 2] = [base]
return scene_list
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
"""나레이션 → 스토리보드 dict (Gemini)."""
resp = client.models.generate_content(
model=model,
contents=SB_USER.format(product=product, king=king,
scenes=scenes, narration=narration),
config=types.GenerateContentConfig(
system_instruction=SB_SYSTEM,
temperature=0.8,
response_mime_type="application/json",
response_schema=SB_SCHEMA,
),
)
sb = json.loads(resp.text)
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
raw = sb.get("scenes", [])
kept = [s for s in raw if s.get("narration", "").strip()]
if len(kept) < len(raw):
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
sc["narration"] = _no_emoji(sc.get("narration", ""))
sc["caption"] = _no_emoji(sc.get("caption", ""))
sb["_product"] = product
sb["_king"] = king
return sb

View File

@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
"""
키워드 → 병맛 오디세이 마케팅 쇼츠 (완전 자동, 원커맨드)
==========================================================
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
1) 랜덤으로 오디세우스 귀향 모험 '썰' 매칭
2) Gemini 로 병맛 마케팅 나레이션 생성
3) 장면 분할(스토리보드)
4) 장면별 이미지 + 음성 생성
5) Ken Burns + 자막(맨 위) + 음성 합성 → 세로 mp4
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
선행: scraper.py 로 만든 data/odyssey_keywords_*.csv 가 있어야 함.
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
★ 실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
python animation_odyssey\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
python animation_odyssey\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
python animation_odyssey\\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_odyssey" # 통합 출력: shorts_all/output/animation_odyssey/
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:
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not _key:
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
try:
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
except Exception as e:
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
if rstore and not brief_store:
brief_store = rstore
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
if not bullets:
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
"키워드를 직접 주거나 다른 링크를 쓰세요.")
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
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) 병맛 나레이션 (+ SNS 업로드용 캡션)
print("[1/4] 나레이션 생성...")
store_name = brief_store or getattr(args, "store", None) or label
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
# 제목엔 해시태그가 들어갈 이유가 없다 — LLM 이 끼워넣은 '#태그' 토큰 제거(태그는 hashtags 필드에만).
narr["title"] = re.sub(r"\s*#\S+", "", narr.get("title", "")).strip()
(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")
# SNS 업로드용 캡션(이모지·해시태그 포함) — content_sync/finalize 가 읽어 Content.caption 에 저장.
caption = (narr.get("sns_caption") or "").strip()
if caption:
(job_dir / "caption.txt").write_text(caption, 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] 이미지·음성 생성...")
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()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
"""
나무위키 오디세이아 등장인물 '썰 키워드' 스크래퍼
====================================================
오디세이아(오디세우스의 귀향 대모험)에 등장하는 주요 인물·신 20여 명의 나무위키 문서에서
목차(섹션 제목)를 긁어와 숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 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만.
# 오디세이아 서사에 '반드시 출현'하는 인물·괴물만: (번호, 이름, 나무위키 문서 제목)
# ★ 제우스/포세이돈/아테나/헤르메스 같은 범용 신 문서는 넣지 않는다 —
# 문서 전체 목차가 크롤되므로 오디세이아와 무관한 일반 신화 키워드(티타노마키아 등)가
# 섞여 들어와 엉뚱한 인물이 주인공인 영상이 만들어진다(2026-07 제우스 썰 사고).
# 신들의 개입 에피소드는 나레이션 프롬프트가 알아서 살을 붙인다.
# 동음이의어 문서는 worker 가 "이름" ↔ "이름(그리스 로마 신화)" ↔ "이름(그리스 신화)" 를 자동 재시도한다.
ODYSSEY_FIGURES = [
# 주인공·가족
(1, "오디세우스", "오디세우스"),
(2, "페넬로페", "페넬로페"),
(3, "텔레마코스", "텔레마코스"),
(4, "라에르테스", "라에르테스"),
# 귀향길 사건의 등장인물·괴물 (오디세이아 서사 전용)
(5, "폴리페모스", "폴리페모스"),
(6, "키르케", "키르케"),
(7, "칼립소", "칼립소"),
(8, "세이렌", "세이렌"),
(9, "스킬라", "스킬라"),
(10, "카립디스", "카립디스"),
(11, "아이올로스", "아이올로스"),
(12, "나우시카", "나우시카(그리스 로마 신화)"),
(13, "로토파고이", "로토파고이"),
# 이타카의 구혼자
(14, "안티노오스", "안티노오스"),
]
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
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 = ODYSSEY_FIGURES
if args.only:
wanted = set(args.only)
kings = [k for k in ODYSSEY_FIGURES if k[1] in wanted]
if not kings:
print(f"[!] 매칭되는 인물 없음: {args.only}")
print(" 가능한 값:", ", ".join(k[1] for k in ODYSSEY_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"odyssey_keywords_{ts}.json"
json_path.write_text(
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
)
csv_path = OUT_DIR / f"odyssey_keywords_{ts}.csv"
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["번호", "인물", "문서제목", "썰후보여부", "키워드", "URL"])
for r in all_results:
anec = set(r["anecdote_keywords"])
for kw in r["keywords"]:
w.writerow([r["order"], r["name"], r["title"],
"Y" if kw in anec else "", kw, r["url"]])
total_kw = sum(len(r["keywords"]) for r in all_results)
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
fails = [r["name"] for r in all_results if r["error"]]
print("\n" + "=" * 50)
print(f"완료: 인물 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}개")
if fails:
print(f"수집 실패: {', '.join(fails)}")
print(f"JSON -> {json_path}")
print(f"CSV -> {csv_path}")
if __name__ == "__main__":
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
asyncio.run(main())

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,298 @@
# -*- 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가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
[SNS 캡션] (sns_caption 필드 — 유튜브/인스타/틱톡에 그대로 붙여넣을 업로드 설명)
- 아래 '형식'을 지켜라. 이모지를 적극 사용(나레이션과 달리 캡션은 이모지 OK). 줄바꿈으로 구분.
- 형식:
1) 첫 줄: 후킹 카피 한 줄 (예: "제갈량이 사실 OO 단골이었다는 썰 풉니다.")
2) 본문 1~2줄: 삼국지 썰 ↔ 가게를 잇는 재밌는 소개 (이모지 곁들여)
3) 셀링 한 줄: 명장이 탐낼 식으로 가게 핵심 (예: "조조도 탐낼 OO시장 숨은 맛집 'OOO' 🍲")
4) 📍 가게명 (위치) ← 가게명/위치 모르면 이 줄 생략
5) 🔥 대표 메뉴·포인트 나열 ← 모르면 생략
6) 👉 방문 팁 또는 행동유도 한 줄
7) 마지막 줄: 해시태그 나열. #삼국지 #역사썰 을 앞에 두고, 가게·지역·메뉴·병맛 태그 + #shorts #AIO2O 로 끝.
- 가게 정보(이름·위치·메뉴)는 '마케팅 대상' 텍스트에서 뽑아라. 없으면 그 줄은 자연스럽게 빼라(억지로 지어내지 마라).
[출력]
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
"""
NARR_USER = """\
[이번 쇼츠 소재]
- 인물: {king} ({doc})
- 썰 키워드: "{keyword}"
- 참고 출처: {url}
[마케팅 대상 (마지막에 이걸로 갈아타기)]
- 가게명: {store}
- 소개/키워드: {product}
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
{seconds}초 쇼츠 나레이션을 만들어라. 썰 키워드가 빈약하면 그 인물의 유명한 일화(연의 속 명장면 포함)로 살을 붙여도 된다.
"""
NARR_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
"hook": {"type": "string"},
"story": {"type": "string"},
"pivot": {"type": "string"},
"cta": {"type": "string"},
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
"estimated_seconds": {"type": "integer"},
"hashtags": {"type": "array", "items": {"type": "string"}},
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
},
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
}
def generate(client, model, seed, product, seconds, store=""):
"""삼국지 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini)."""
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
user = NARR_USER.format(
king=seed["king"], doc=seed["doc"],
keyword=seed["keyword"], url=seed["url"], product=product,
store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
)
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
resp = client.models.generate_content(
model=model,
contents=user,
config=types.GenerateContentConfig(
system_instruction=sys_prompt,
temperature=1.1, # 병맛 = 약간 높은 창의성
response_mime_type="application/json",
response_schema=NARR_SCHEMA,
),
)
data = json.loads(resp.text)
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
data["_product"] = product
return data
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
SB_SYSTEM = """\
너는 숏폼 영상 디렉터다. 주어진 '병맛 삼국지 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
[핵심 규칙]
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the cat general sweating while defending a castle gate alone)
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
→ 그래야 컷마다 눈 표정이 다양해진다.
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의. 삼국지 인물을 '귀여운 통통한 고양이'로 의인화.
복식/색/특징(갑옷·한푸·무기·수염 등 그 인물의 상징)을 못박아 컷마다 동일하게.
(예: a chubby cute cat as Guan Yu from Three Kingdoms, wearing green hanfu robe and golden armor,
long flowing black beard, holding a tiny green dragon crescent blade, round face /
a chubby cute cat as Zhuge Liang, wearing white-and-teal hanfu scholar robe, holding a white feather fan)
★ 눈 그림체: 눈을 떴을 때는 large round eyes with big round solid black pupils and a small
soft white highlight, gentle innocent look (귀엽고 둥근 눈)으로 통일. 단 이 '그림체'만 유지하고,
장면 감정에 따라 눈을 감거나(eyes closed) 실눈(squint)·윙크·졸린 눈 등 표정은 바꿔도 된다.
절대 날카롭거나 찢어진/화난/매서운/사실적인 맹수 눈은 금지.
- style_guide: 그림 스타일을 영어로. 한국 웹툰풍 플랫 일러스트, 굵은 외곽선, 따뜻한 색, 단순 배경(고대 중국 삼국시대 분위기), 세로 9:16.
[출력] 지정된 JSON 스키마로만.
"""
SB_USER = """\
[마케팅 대상] {product}
[소재 인물] {king}
[목표 장면 수] 약 {scenes}개
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
{narration}
"""
SB_SCHEMA = {
"type": "object",
"properties": {
"character_sheet": {"type": "string"},
"style_guide": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"narration": {"type": "string"},
"caption": {"type": "string"},
"image_prompt": {"type": "string"},
},
"required": ["narration", "caption", "image_prompt"],
},
},
},
"required": ["character_sheet", "style_guide", "scenes"],
}
def clamp_scenes(scene_list, target):
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
def clen(s):
return len(s.get("narration", "").strip())
while len(scene_list) > target:
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
i = min(range(len(scene_list) - 1),
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
base["narration"] = (a + " " + b).strip()
scene_list[i:i + 2] = [base]
return scene_list
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
"""나레이션 → 스토리보드 dict (Gemini)."""
resp = client.models.generate_content(
model=model,
contents=SB_USER.format(product=product, king=king,
scenes=scenes, narration=narration),
config=types.GenerateContentConfig(
system_instruction=SB_SYSTEM,
temperature=0.8,
response_mime_type="application/json",
response_schema=SB_SCHEMA,
),
)
sb = json.loads(resp.text)
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
raw = sb.get("scenes", [])
kept = [s for s in raw if s.get("narration", "").strip()]
if len(kept) < len(raw):
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
sc["narration"] = _no_emoji(sc.get("narration", ""))
sc["caption"] = _no_emoji(sc.get("caption", ""))
sb["_product"] = product
sb["_king"] = king
return sb

View File

@ -0,0 +1,275 @@
# -*- 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:
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
if not _key:
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
try:
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
except Exception as e:
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
if rstore and not brief_store:
brief_store = rstore
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
if not bullets:
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
"키워드를 직접 주거나 다른 링크를 쓰세요.")
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
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] 나레이션 생성...")
store_name = brief_store or getattr(args, "store", None) or label
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
# 제목엔 해시태그가 들어갈 이유가 없다 — LLM 이 끼워넣은 '#태그' 토큰 제거(태그는 hashtags 필드에만).
narr["title"] = re.sub(r"\s*#\S+", "", narr.get("title", "")).strip()
(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")
# SNS 업로드용 캡션(이모지·해시태그 포함) — content_sync/finalize 가 읽어 Content.caption 에 저장.
caption = (narr.get("sns_caption") or "").strip()
if caption:
(job_dir / "caption.txt").write_text(caption, 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] 이미지·음성 생성...")
store = args.store or brief_store or product.split(",")[0].strip() or None
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
real_images=real_images, real_scenes=real_scenes,
max_real=args.max_real, match_content=not args.no_match,
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
img_model=args.img_model, allow_text=args.allow_text,
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
shutil.rmtree(job_dir / "assets", ignore_errors=True)
for junk in ("narration.json", "storyboard.json"):
(job_dir / junk).unlink(missing_ok=True)
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
print(f"\n✅ 완성: {out_mp4}")
print(f" {seed['king']} 썰 · {product}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,970 @@
# -*- 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
OUT_DIR = HERE.parent / "output" / "animation_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/
W, H = 1080, 1920 # 세로 쇼츠
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
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급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
def _find_font(name):
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
if c.exists():
return str(c)
return str(HERE / "fonts" / name)
FONT = _find_font("Jua-Regular.ttf")
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
TITLE_FONT = _find_font("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 (superfast)")
return ["-c:v", "libx264", "-preset", "superfast", "-crf", "26"]
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)) # 꽃심
_LOGO_RGBA = None
def _brand_logo():
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
global _LOGO_RGBA
if _LOGO_RGBA is not None:
return _LOGO_RGBA
if not Path(BRAND_LOGO).exists():
return None
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
g = Image.open(BRAND_LOGO).convert("L")
alpha = g.point(lambda p: 255 - p)
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
logo.putalpha(alpha)
_LOGO_RGBA = logo
return logo
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 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) # 옅은 구분선
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
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)
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
ink = (30, 30, 30, 255)
sep = "×" # 곱셈기호(×) — 콜라보 표기
gap = 26
w_title = d.textlength(title, font=tf)
w_sep = d.textlength(sep, font=tf)
logo = _brand_logo()
logo_h = 0
if logo is not None:
logo_h = int(TITLE_SIZE * 1.05)
logo_w = max(1, int(logo.width * logo_h / logo.height))
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
else:
logo_w = 0
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
group_left = (W - total) / 2
x = group_left
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
x += w_title + gap
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
x += w_sep + gap
if logo is not None:
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
rose = (228, 150, 168, 255)
bgap = 46
_blossom(d, group_left - bgap, cy, 17, rose)
_blossom(d, group_left + total + bgap, cy, 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
size = NARR_SIZE
while size > NARR_MIN_SIZE:
f = ImageFont.truetype(font_path, size)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(size * 1.4)
if len(lines) * lh <= avail:
break
size -= 2
else:
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
lines = _wrap_lines(d, text, f, W - 150)
lh = int(NARR_MIN_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/<값> 순으로 파일/폴더 탐색
"""
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)])
# 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 _scene_motion(idx: int, n: int, frames: int) -> str:
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
"""
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f = max(frames, 1)
if idx == 1: # 훅: 리빌(살짝 당김→전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
return ("zoompan=z='1.06':"
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
kind = idx % 4
if kind == 0: # 줌아웃(끝에 전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if kind == 1: # 줌인(시작에 전체)
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
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%.
"""
print("[4/4] 영상 합성...", flush=True)
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 * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
motion = _scene_motion(idx, len(scenes), frames)
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.
fades = "" if idx == 1 else f"fade=t=in:st=0:d={fd:.2f}:color=white,"
fades += f"fade=t=out:st={max(dur - fd, 0):.2f}:d={fd:.2f}:color=white"
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
f"crop={bw}:{bh},"
f"{motion}:d={frames}:s={W}x{img_h}:fps={FPS}[img];"
f"[bg][img]overlay=0:{band_h},{fades}[base];"
f"[base][1:v]overlay=0:0[v]")
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
_run([exe, "-y", "-loop", "1", "-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",
# AAC priming(1024샘플) 탓에 concat 이 비디오 시작 pts 를 +0.023s 밀어버리면
# 페북/인스타(Buffer) 썸네일 추출(t=0)이 빈 구간=검은 화면을 잡는다 → pts 0 으로 재작성.
"-bsf:v", "setts=ts=TS-STARTDTS:pts=PTS-STARTPTS",
"-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)
import time as _tm; _t_img = _tm.monotonic()
# --- 이미지: 캐릭터 앵커(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)
print(f"[⏱] 이미지 {_tm.monotonic() - _t_img:.1f}초", flush=True); _t_tts = _tm.monotonic()
# --- 음성: 전부 독립 → 병렬 ---
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)
print(f"[⏱] 음성 {_tm.monotonic() - _t_tts:.1f}초", flush=True)
out = workdir / f"{stem}.mp4"
print(f"\n영상 합성 → {out.name}")
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
bgm=bgm, bgm_volume=bgm_volume)
print(f"완성: {out}")
return out

View File

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

Binary file not shown.

Binary file not shown.

285
generator/media.py Normal file
View File

@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
"""
Best3 저수준 도구함 (TTS · 비전 · ffmpeg · BGM · 폰트)
======================================================
one_short / render 가 공통으로 쓰는 유틸 모음. 단독 실행용 아님.
· 비전: analyze_photos(사진/클립프레임 라벨·점수), extract_frame
· 음성: gen_tts(Gemini TTS), pcm_to_wav, wav_seconds, process_audio(무음제거·속도)
· ffmpeg: _ffmpeg, _run, pick_encoder(QSV/libx264), _AUDIO_ARGS
· 자막: _wrap_lines(픽셀폭 줄바꿈)
· BGM: resolve_bgm(폴더 랜덤 1곡), mix_bgm
설치: pip install google-genai pillow imageio-ffmpeg
"""
import json
import re
import shutil
import subprocess
import time
import wave
from pathlib import Path
from google.genai import types
HERE = Path(__file__).parent
W, H = 1080, 1920 # 세로 쇼츠
FPS = 30
VISION_MODEL = "gemini-2.5-flash" # 사진/클립 비전 분석용
TTS_MODEL = "gemini-2.5-flash-preview-tts"
TTS_VOICE = "Aoede" # 산뜻하고 가벼운 보이스. 대안: Charon(차분) / Sulafat(따뜻) / Laomedeia(경쾌)
TTS_STYLE = ("다음 문장을 밝고 경쾌하게, 적당히 빠른 속도로 활기차고 신나는 톤으로 읽어줘. "
"지루하지 않게 텐션을 올리되, 과한 병맛·오버는 아니고 세련되게:")
LINK_CTA = "소개한 곳 링크는 고정댓글에서 확인하세요" # info 카드 링크 안내
# 한글 자막용 폰트 — 주아체(fonts/ 동봉), 없으면 맑은고딕 볼드. 제목/정보용은 송명체.
FONT = str(HERE / "fonts" / "Jua-Regular.ttf")
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
TITLE_FONT = str(HERE / "fonts" / "SongMyung-Regular.ttf")
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
# 수동 입력(링크 없이 직접 업로드)용 — 로컬 폴더의 이미지/영상 파일 목록
_LOCAL_IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".bmp")
_LOCAL_VID_EXTS = (".mp4", ".mov", ".webm", ".m4v")
def list_media(folder, kind="image"):
"""로컬 폴더의 이미지(kind='image') 또는 영상(kind='video') 파일을 정렬해 [Path...] 반환.
링크 없이 직접 사진/클립을 올릴 때 사용. 폴더가 없으면 빈 리스트."""
exts = _LOCAL_VID_EXTS if kind == "video" else _LOCAL_IMG_EXTS
d = Path(folder) if folder else None
if not d or not d.is_dir():
return []
return sorted(p for p in d.iterdir() if p.suffix.lower() in exts)
# ---------------------------------------------------------------- ffmpeg
def _ffmpeg() -> str:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def _run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode != 0:
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
return r
def pick_encoder(exe) -> list:
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
test = subprocess.run(
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
"-c:v", "h264_qsv", "-f", "null", "-"],
capture_output=True, text=True, errors="replace")
if test.returncode == 0:
print(" 인코더: h264_qsv (Intel 하드웨어)")
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
print(" 인코더: libx264 (superfast)")
return ["-c:v", "libx264", "-preset", "superfast", "-crf", "26"]
# ---------------------------------------------------------------- 비전
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)])

962
generator/naver.py Normal file
View File

@ -0,0 +1,962 @@
# -*- 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 random
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) ==============================
# ── 안티봇 핑거프린트 회피용 브라우저 프로필 후보군 ────────────────────────────────
# 네이버가 '같은 지문의 자동화 접근'을 반복 감지하면 캡차 벽을 내린다(_check_blocked 참고).
# 요청마다 프로필을 섞어 지문을 분산시킨다. ★ 핵심: 한 프로필 안의 UA·client-hint(sec-ch-ua)·
# platform·viewport 는 서로 '모순 없이' 맞물려야 한다 — 실제 브라우저면 절대 어긋나지 않는 값들이라
# 하나라도 어긋나면 그 자체가 봇 신호가 된다.
# · iOS Safari 는 sec-ch-ua 계열을 아예 보내지 않음 → 힌트 비움(client_hints=None).
# · Android Chrome 은 sec-ch-ua / -mobile / -platform 3종을 UA 버전과 일치시켜 보냄.
BROWSER_PROFILES = [
{
"name": "ios-safari-16",
"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"),
"platform": "iPhone",
"viewport": {"width": 414, "height": 896},
"dsf": 3,
"client_hints": None, # Safari 는 client-hint 미전송
},
{
"name": "ios-safari-17",
"ua": ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"),
"platform": "iPhone",
"viewport": {"width": 390, "height": 844},
"dsf": 3,
"client_hints": None,
},
{
"name": "android-chrome-126",
"ua": ("Mozilla/5.0 (Linux; Android 14; SM-S918N) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Mobile Safari/537.36"),
"platform": "Linux armv81",
"viewport": {"width": 412, "height": 915},
"dsf": 2.625,
"client_hints": {
"sec-ch-ua": '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
},
},
{
"name": "android-chrome-125-pixel",
"ua": ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0.0.0 Mobile Safari/537.36"),
"platform": "Linux armv81",
"viewport": {"width": 412, "height": 883},
"dsf": 2.625,
"client_hints": {
"sec-ch-ua": '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
},
},
]
def pick_profile(profile=None):
"""브라우저 프로필 선택. 문자열이면 name 매칭, dict 면 그대로, None 이면 랜덤."""
if isinstance(profile, dict):
return profile
if isinstance(profile, str):
for pr in BROWSER_PROFILES:
if pr["name"] == profile:
return pr
return random.choice(BROWSER_PROFILES)
def _stealth_script(platform: str) -> str:
"""프로필 platform 과 일관된 자동화 탐지 우회 init 스크립트.
Safari(iPhone) 프로필에선 Chromium 이 노출하는 navigator.userAgentData(실제 Safari 엔 없음)를
제거해, UA 는 Safari 인데 JS 지문은 Chromium 인 모순을 없앤다."""
script = (
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined});"
"Object.defineProperty(navigator,'languages',{get:()=>['ko-KR','ko']});"
f"Object.defineProperty(navigator,'platform',{{get:()=>{json.dumps(platform)}}});"
"window.chrome={runtime:{}};"
)
if platform == "iPhone":
script += "try{Object.defineProperty(navigator,'userAgentData',{get:()=>undefined});}catch(e){}"
return script
# 하위호환: 기존에 MOBILE_UA/STEALTH 를 직접 참조하던 코드용(기본 프로필 값).
MOBILE_UA = BROWSER_PROFILES[0]["ua"]
STEALTH = _stealth_script(BROWSER_PROFILES[0]["platform"])
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 _goto_tolerant(page, url, timeout=30000):
"""naver.me 단축링크는 즉시 리다이렉트하며 최초 내비게이션을 Chromium 이 ERR_ABORTED 로 끊을
때가 있다(원문서가 커밋되기 전에 리다이렉트됨). goto 예외가 ABORTED/net::ERR 계열이면 리다이렉트는
실제로 착지하므로 예외를 삼키고 잠깐 기다렸다 진행한다(그 외 예외는 그대로 전파)."""
try:
await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
except Exception as e:
if "ERR_ABORTED" not in str(e) and "net::ERR" not in str(e):
raise
await page.wait_for_timeout(1500)
async def _resolve_place_home(page, url):
"""링크 → 실제 업종 홈으로 진입하고 (cat, pid) 반환.
naver.me 는 UA(프로필)에 따라 앱브릿지(m.map.naver.com/appLink)로 새어 업종을 못 읽을 때가 있다.
→ place id 만 먼저 뽑은 뒤, 업종 비의존 제네릭 진입(m.place.naver.com/place/{id}/home)으로
재진입해 네이버가 실제 업종(accommodation/restaurant/…)으로 리다이렉트하게 한다. pid 없으면 예외.
※ naver.me goto 는 ERR_ABORTED 로 끊기거나 리다이렉트 실패로 about:blank 에 머물 수 있어 최대 3회 재시도."""
pid = None
for _ in range(3):
await _goto_tolerant(page, url)
await page.wait_for_timeout(2500)
_, pid = extract_place(page.url)
if pid:
break
await page.wait_for_timeout(1200) # 미착지(about:blank) → 잠깐 쉬고 goto 재시도
if not pid:
raise RuntimeError("place id 를 못 찾음. URL 확인 또는 headful=True 로 화면 확인.")
await _goto_tolerant(page, f"https://m.place.naver.com/place/{pid}/home")
# 제네릭 '/place/' → 실제 업종('/accommodation/' 등)으로 리다이렉트될 때까지 폴링(최대 ~6s)
cat = "place"
for _ in range(12):
await page.wait_for_timeout(500)
c, _pid = extract_place(page.url)
if c and c != "place":
cat = c
break
return cat, pid
class NaverBlockedError(RuntimeError):
"""네이버 봇 탐지(캡차/보안 확인)로 접근이 막힌 상태. 프로필 전환 재시도의 트리거."""
# 봇벽 차단 시 재시도 한도 = 최대 3회(최초 1회 + 재시도 3회 = 총 4회). 프로필 개수로도 상한.
MAX_BLOCK_RETRIES = 3
# 프로필 전환 재시도 사이 점진적 대기(초). 같은 IP 를 연달아 두드려 차단이 굳는 걸 늦춘다.
BLOCK_RETRY_DELAYS = [3, 6, 12]
def _shuffled_profiles():
"""중복 없이 섞은 프로필 순서 — 재시도마다 서로 다른 지문을 쓰도록 보장."""
order = BROWSER_PROFILES[:]
random.shuffle(order)
return order
async def _check_blocked(page):
"""네이버 봇 탐지(보안 확인/캡차) 페이지인지 확인. 걸리면 뒤 폴링/파싱은 항상 빈 결과만
내놓아 'AI 브리핑/리뷰가 없다'로 오인되므로, 원인이 드러나도록 즉시 예외로 구분한다."""
try:
text = await page.evaluate("document.body.innerText")
except Exception:
return
if "보안 확인을 완료해" in text:
raise NaverBlockedError(
"네이버가 자동화 접근으로 판단해 보안 인증(캡차)을 요구했어요. "
"브리핑/리뷰가 없는 게 아니라 접근이 막힌 상태 — 잠시 후 다시 시도하세요."
)
async def _with_profile_retry(attempt, label=""):
"""attempt(profile) 코루틴을 프로필을 바꿔가며 시도한다. NaverBlockedError(봇벽) 에만
다른 프로필로 재시도하고, 그 외 예외/정상 반환은 즉시 전파한다(잘못된 URL·브리핑 부재 등은
프로필을 바꿔도 결과가 같으므로 재시도하지 않는다). 마지막 시도 실패 시 마지막 예외를 던진다.
시도 횟수 = min(프로필 수, MAX_BLOCK_RETRIES + 1)."""
profiles = _shuffled_profiles()[: MAX_BLOCK_RETRIES + 1]
last_exc = None
for i, pr in enumerate(profiles):
try:
return await attempt(pr)
except NaverBlockedError as e:
last_exc = e
if i < len(profiles) - 1:
delay = BLOCK_RETRY_DELAYS[min(i, len(BLOCK_RETRY_DELAYS) - 1)]
print(f"■ {label}프로필 '{pr['name']}' 차단 → {delay}s 후 다른 프로필로 재시도...")
await asyncio.sleep(delay)
raise last_exc
def _clean_store(raw: str) -> str:
"""og:title/title 값에서 실제 가게명만 정리. 예: '골목냉면 : 네이버\\x1c' → '골목냉면'."""
if not raw:
return ""
s = re.sub(r"[\x00-\x1f]", "", raw) # 제어문자 제거
s = re.split(r"\s*[:\-|]\s*네이버", s)[0] # ' : 네이버', ' - 네이버 지도' 접미사 제거
s = re.sub(r"\s+", " ", s).strip()
return s.splitlines()[0].strip() if s else ""
async def _store_name(page) -> str:
"""가게명 추출. og:title 메타(가장 안정적) 우선 → title → 화면 요소.
섹션 헤딩('AI 브리핑' 등)이나 '네이버' 포함 값은 배제한다.
(네이버가 span.GHAhO 같은 난독 클래스를 자주 바꿔 화면 셀렉터만으론 불안정하므로 메타 우선.)"""
async def _meta():
return await page.locator("meta[property='og:title']").first.get_attribute("content", timeout=1500)
for getter in (_meta, page.title):
try:
name = _clean_store(await getter())
if name and name != "AI 브리핑" and "네이버" not in name:
return name
except Exception:
pass
for sel in ("span.GHAhO", "#_title span", "h2"): # 최후 폴백(섹션명 배제)
try:
t = (await page.locator(sel).first.inner_text(timeout=1200)).strip().splitlines()[0]
if t and t != "AI 브리핑" and not t.startswith("AI "):
return t
except Exception:
pass
return ""
async def _new_page(p, headful=False, viewport=None, device_scale_factor=None, profile=None):
"""공통 브라우저 컨텍스트(스텔스 + 프로필 지문) → (browser, ctx, page).
profile: name 문자열 / dict / None(랜덤). UA·client-hint·platform·viewport 를 한 세트로 적용.
viewport·device_scale_factor 인자를 명시하면 프로필 값보다 우선(사진 크롤 등 해상도 고정용)."""
pr = pick_profile(profile)
browser = await p.chromium.launch(
headless=not headful,
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"])
headers = {"Accept-Language": "ko-KR,ko;q=0.9"}
if pr.get("client_hints"):
headers.update(pr["client_hints"])
else:
# Safari 프로필: Chromium 이 기본으로 흘리는 sec-ch-ua(…HeadlessChrome…) 를 빈 값으로 덮어
# 'Safari UA + Chromium 힌트' 모순 지문을 차단(실제 Safari 는 이 헤더들을 안 보냄).
headers.update({"sec-ch-ua": "", "sec-ch-ua-mobile": "", "sec-ch-ua-platform": ""})
kwargs = dict(user_agent=pr["ua"], locale="ko-KR",
viewport=viewport or pr.get("viewport") or {"width": 414, "height": 896},
extra_http_headers=headers)
dsf = device_scale_factor or pr.get("dsf")
if dsf:
kwargs["device_scale_factor"] = dsf
ctx = await browser.new_context(**kwargs)
await ctx.add_init_script(_stealth_script(pr.get("platform", "iPhone")))
page = await ctx.new_page()
return browser, ctx, page
# ============================== 브리핑 (이름 · 업종타입 · AI요약) ==============================
# AI 브리핑은 DOM 스크래핑이 아니라 네이버 플레이스가 진입 직후 쏘는 GraphQL(getAiBriefing) 응답에서
# 구조화된 형태로 온다: data.aiBriefing.textSummaries[].sentence. DOM 렌더/스크롤 타이밍에 의존하는
# 기존 방식(_EXTRACT_JS)은 (a) '상단 1줄 요약'과 '하단 상세 브리핑'을 혼동하고, (b) 브리핑이 있어도
# 상단 '요약 티저'가 없는 업체를 놓치는 취약점이 있어, 응답 자체를 가로채는 방식으로 대체했다.
# getAiBriefing 배치 GraphQL 응답의 인트로(preamble) 문장 — 마케팅 소재론 불필요하므로 버린다.
_BRIEFING_INTRO_RE = re.compile(
r"(리뷰[를에]?\s*(바탕|기반|활용))|요약(했|해|합|하면)|정리한\s*(정보|내용)|다음과\s*같습니다")
def _is_ai_briefing_response(resp):
"""이 응답이 getAiBriefing 오퍼레이션을 포함한 GraphQL 배치 응답인가."""
if "graphql" not in resp.url:
return False
req = resp.request
if req.method != "POST":
return False
return "getAiBriefing" in (req.post_data or "")
def _parse_ai_briefing(txt):
"""getAiBriefing 배치 응답(JSON 문자열) 파싱.
반환:
[불릿...] — aiBriefing.textSummaries 에 내용 있음(브리핑 존재). 인트로 문장·'객실 정보:' 류
라벨 제거 + <b> 태그 제거 + 중복 제거까지 마친 마케팅용 불릿.
[] — aiBriefing 필드는 있으나 null/빈 내용(브리핑 없는 업체) → 확정적 '없음'.
None — 배치에 aiBriefing 필드 자체가 없음(무관한 배치/파싱 실패) → 판정 불가(대기 계속)."""
try:
batch = json.loads(txt)
except Exception:
return None
if not isinstance(batch, list):
batch = [batch]
for item in batch:
data = (item or {}).get("data") or {}
if "aiBriefing" not in data:
continue
ai = data.get("aiBriefing") or {}
summaries = ai.get("textSummaries") or []
out, seen = [], set()
for ts in summaries:
s = re.sub(r"</?b>", "", (ts.get("sentence") or "")).strip()
if not s or _BRIEFING_INTRO_RE.search(s):
continue # 빈 문장·인트로(preamble) 제외
s = re.sub(r"^[가-힣A-Za-z ]{1,12}:\s*", "", s) # '객실 정보: ' 류 라벨 제거
s = re.sub(r"\s+", " ", s).strip()
if s and s not in seen:
seen.add(s); out.append(s)
return out # 내용 있으면 [불릿...], 없으면 []
return None
AI_BRIEFING_TIMEOUT = 18 # getAiBriefing 응답 대기(초). 정상은 ~6s, 이 안에 안 오면 스로틀/지연으로 본다.
AI_BRIEFING_RETRIES = 2 # 응답이 끝내 안 올 때(kind='timeout') 새 세션으로 재시도할 횟수.
AI_BRIEFING_RETRY_DELAY = 4 # 재시도 전 대기(초) — 연속 타격으로 스로틀이 굳는 걸 늦춘다.
async def _fetch(url, headful=False):
"""봇벽 차단 시 프로필을 바꿔가며 재시도(_with_profile_retry). 추가로, getAiBriefing 응답이
끝내 안 오면(kind='timeout', 스로틀/지연 추정) 새 세션으로 최대 AI_BRIEFING_RETRIES 회 재시도한다.
반환은 공개 API 형식인 3-튜플 (store, bullets, cat) — 내부 kind 는 여기서 소비.
kind='briefing' → 브리핑 확보(bullets 채움)
kind='none' → 브리핑 없는 업체 확정(bullets=None → 호출부가 리뷰 폴백)
kind='timeout' → 응답 보류(재시도 가치 있음); 소진 시 bullets=None(→ 리뷰 폴백)."""
last_store, last_cat = None, None
for i in range(AI_BRIEFING_RETRIES + 1):
store, bullets, cat, kind = await _with_profile_retry(
lambda pr: _fetch_once(url, pr, headful=headful), label="브리핑 ")
last_store, last_cat = store, cat
if kind in ("briefing", "none"):
return store, bullets, cat # 있음/없음 확정 → 재시도 무의미
# kind == 'timeout': getAiBriefing 응답이 안 옴 → 새 세션 재시도
if i < AI_BRIEFING_RETRIES:
print(f"■ '{store or '이 가게'}' AI 브리핑 응답 지연(스로틀 추정) → "
f"{AI_BRIEFING_RETRY_DELAY}s 후 새 세션 재시도 ({i + 1}/{AI_BRIEFING_RETRIES})...")
await asyncio.sleep(AI_BRIEFING_RETRY_DELAY)
return last_store, None, last_cat # 재시도 소진 → 리뷰 폴백
async def _fetch_once(url, profile, headful=False):
"""네이버 플레이스 진입 → getAiBriefing GraphQL 응답을 가로채 브리핑을 파싱.
반환 4-튜플 (store, bullets, cat, kind):
kind='briefing' → bullets=[불릿...] (브리핑 존재)
kind='none' → bullets=None (aiBriefing null/빈 = 브리핑 없는 업체)
kind='timeout' → bullets=None (응답이 AI_BRIEFING_TIMEOUT 내 안 옴 = 스로틀/지연)."""
async with async_playwright() as p:
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
# getAiBriefing 응답을 진입 전에 미리 리스닝(진입 직후 발사되므로 놓치지 않게).
# parsed: [불릿...] 또는 [](없음 확정) 이면 done, None(무관/파싱실패)이면 계속 대기.
captured = {"bullets": None}
done = asyncio.Event()
async def on_response(resp):
if done.is_set() or not _is_ai_briefing_response(resp):
return
try:
txt = await resp.text()
except Exception:
return # 응답 바디를 못 읽음(경합 등) → 무시
parsed = _parse_ai_briefing(txt)
if parsed is None:
return # aiBriefing 필드 없는 배치 → 계속 대기
captured["bullets"] = parsed # [불릿...] 또는 [](없음) → 확정
done.set()
page.on("response", lambda r: asyncio.ensure_future(on_response(r)))
try:
cat, _pid = await _resolve_place_home(page, url)
except NaverBlockedError:
await browser.close()
raise # 봇벽 → 프로필 전환 재시도(_with_profile_retry)
except Exception:
# 단축링크가 끝내 안 풀림(about:blank 등, 대개 일시적 스로틀) → 예외로 죽이지 말고
# timeout 으로 돌려 새 세션 재시도 대상이 되게 한다(소진 시 None → 호출부 리뷰 폴백).
await browser.close()
return None, None, None, "timeout"
store = await _store_name(page)
try:
await _check_blocked(page)
except Exception:
await browser.close()
raise
# getAiBriefing 응답을 기다린다(스크롤 불필요 — 진입 직후 XHR 로 발사됨).
got = True
try:
await asyncio.wait_for(done.wait(), timeout=AI_BRIEFING_TIMEOUT)
except asyncio.TimeoutError:
got = False
await browser.close()
if not got:
return store, None, cat, "timeout" # 응답 보류 → 재시도 대상
bullets = captured["bullets"]
if not bullets: # [] = aiBriefing null/빈 → 브리핑 없음
return store, None, cat, "none"
return store, bullets, cat, "briefing"
def fetch_briefing(url, headful=False):
"""동기 진입점. (가게이름, [불릿...], 업종타입) 반환.
업종타입 = 링크의 플레이스 타입(restaurant/accommodation/hairshop…). 브리핑 없으면 불릿이 None.
※ animation 처럼 업종이 필요 없는 형식은 3번째 값을 그냥 무시하면 된다."""
return asyncio.run(_fetch(url, headful=headful))
# ============================== 리뷰 크롤링 (브리핑 없을 때 폴백) ==============================
# 방문자 리뷰 '본문' 셀렉터. 네이버가 난독 클래스(pui__…)를 자주 바꾸므로 우선순위대로 시도해
# '충분히(3개+) 잡히는 첫 셀렉터'를 쓴다. ★ 최다매칭이 아님 — 작성자/메타 링크(a.pui__GStJHb 등)가
# 더 많이 잡혀서 그걸 고르면 후기가 아니라 '작성자 이름·리뷰수'가 수집되는 문제가 있었다.
# 리뷰 본문 전용 클래스(.pui__vn15t2)를 최우선으로 둔다.
_REVIEW_JS = r"""() => {
const SELS = ['.pui__vn15t2', 'a.pui__xtsQN-', 'div.pui__vn15t2', 'span.pui__gfuUIT'];
const clean = s => (s || '').replace(/\s*더보기\s*$/,'').replace(/\s+/g,' ').trim();
for (const sel of SELS) {
let els;
try { els = [...document.querySelectorAll(sel)]; } catch (e) { continue; }
const texts = els.map(e => clean(e.innerText))
.filter(t => t.length >= 8 && /[가-힣]/.test(t));
if (texts.length >= 3) return texts;
}
return [];
}"""
async def _fetch_reviews(url, max_reviews=30, headful=False):
"""봇벽 차단 시 프로필을 바꿔가며 재시도(최대 MAX_BLOCK_RETRIES 회)."""
return await _with_profile_retry(
lambda pr: _fetch_reviews_once(url, pr, max_reviews=max_reviews, headful=headful),
label="리뷰 ")
async def _fetch_reviews_once(url, profile, max_reviews=30, headful=False):
"""방문자 리뷰 탭에서 '더보기'를 모두 펼쳐 리뷰 본문을 수집. (가게이름, [리뷰문장…]) 반환."""
async with async_playwright() as p:
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
try:
cat, pid = await _resolve_place_home(page, url)
except Exception:
await browser.close()
raise
store = await _store_name(page)
try:
await _check_blocked(page)
except Exception:
await browser.close()
raise
# 방문자 리뷰 탭으로 이동(구조에 따라 /review/visitor 또는 /review)
reviews, seen = [], set()
for tab in ("review/visitor", "review"):
try:
resp = await page.goto(f"https://m.place.naver.com/{cat}/{pid}/{tab}",
wait_until="domcontentloaded", timeout=25000)
except Exception:
continue
if resp and resp.status >= 400:
continue
await page.wait_for_timeout(2200)
# 스크롤로 리뷰를 더 로드하면서, 매 라운드 '더보기'를 눌러 본문을 전부 펼친 뒤 수집.
stagnant = 0
for _ in range(16):
# ★ 리뷰 '본문(.pui__vn15t2)' 안의 더보기만 클릭한다.
# 페이지 전체의 '더보기'를 무차별 클릭하면 사진/방문정보 더보기가 눌려
# 리뷰 목록이 통째로 사라지는(0개 수집) 문제가 있었다 → 본문 내부로 한정.
try:
bodies = page.locator(".pui__vn15t2")
bn = await bodies.count()
for i in range(bn):
mb = bodies.nth(i).locator(
"xpath=.//*[normalize-space(text())='더보기']")
if await mb.count():
try:
await mb.first.click(timeout=400)
except Exception:
pass
except Exception:
pass
await page.wait_for_timeout(400)
for t in await page.evaluate(_REVIEW_JS):
if t not in seen:
seen.add(t); reviews.append(t)
if len(reviews) >= max_reviews:
break
before = len(reviews)
# 무한스크롤 트리거: 문서 맨 아래로 점프 + 휠 + 대기(리뷰 추가 로드 유발)
try:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
except Exception:
pass
await page.mouse.wheel(0, 3200)
await page.wait_for_timeout(1100)
stagnant = stagnant + 1 if len(reviews) == before else 0
if stagnant >= 4: # 4라운드 연속 새 리뷰 없음 → 끝까지 봤다고 판단
break
if reviews: # 이 탭에서 뭔가 건졌으면 다른 탭은 시도 안 함
break
await browser.close()
return store, reviews[:max_reviews]
def fetch_reviews(url, max_reviews=30, headful=False):
"""동기 진입점. 방문자 리뷰 본문을 '더보기'까지 펼쳐 최대 max_reviews 개 수집. (가게이름, [리뷰…])."""
return asyncio.run(_fetch_reviews(url, max_reviews=max_reviews, headful=headful))
_REVIEW_SUMMARY_SCHEMA = {
"type": "object",
"properties": {
"bullets": {"type": "array", "items": {"type": "string"},
"description": "가게 홍보 소재로 쓸 핵심 특징 불릿(각 한 문장)"},
},
"required": ["bullets"],
}
def summarize_reviews(client, store, reviews, model="gemini-2.5-flash", n=4):
"""방문자 리뷰들을 Gemini 로 요약해 '브리핑 대체' 불릿 리스트로 반환(실패 시 빈 리스트).
AI 브리핑이 없는 가게에서, 브리핑과 같은 형식(짧은 특징 문장들)을 만들어 나레이션 근거로 쓴다."""
reviews = [r for r in reviews if r][:40]
if not reviews:
return []
joined = "\n".join(f"- {r}" for r in reviews)
sname = store or "이 가게"
prompt = (
f"다음은 '{sname}'를 방문한 손님들이 남긴 실제 리뷰 모음이다.\n"
f"이 리뷰들을 종합해서, 가게를 홍보 영상 소재로 쓸 수 있도록 핵심 특징을 "
f"{n}개의 짧은 불릿으로 요약하라.\n"
f"- ★ 첫 번째 불릿은 반드시 가게 이름 '{sname}'(으)로 시작해, 이 가게가 어떤 곳/무슨 메뉴인지 "
f"한 문장으로 소개하라. (예: '{sname}은(는) …가 유명한 …맛집입니다.')\n"
f"- 나머지 불릿에도 맥락이 필요하면 '{sname}'을(를) 자연스럽게 써도 된다. 절대 'OO식당' 같은 "
"익명 표현으로 바꾸지 마라 — 반드시 실제 상호를 쓴다.\n"
"- 각 불릿은 한 문장. 메뉴/맛/분위기/서비스/특징 위주로.\n"
"- 여러 리뷰에서 반복되는 공통점을 우선. 한두 명만 말한 지엽적 내용은 제외.\n"
"- 리뷰에 없는 사실을 지어내지 마라(과장·거짓 금지).\n\n"
f"[리뷰]\n{joined}"
)
try:
resp = client.models.generate_content(
model=model, contents=prompt,
config=types.GenerateContentConfig(
temperature=0.4, response_mime_type="application/json",
response_schema=_REVIEW_SUMMARY_SCHEMA),
)
out, seen = [], set()
for b in json.loads(resp.text).get("bullets", []):
b = re.sub(r"\s+", " ", str(b)).strip()
if b and b not in seen:
seen.add(b); out.append(b)
return out
except Exception:
return []
# ============================== 사진 (크롤 + 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 를 못 찾음.")
store = await _store_name(page)
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

32
main.py
View File

@ -28,8 +28,9 @@ from app.social.api.routers.v1.internal import router as social_internal_router
from app.comment.api.routers.v1.comment import router as comment_router from app.comment.api.routers.v1.comment import router as comment_router
from app.video.api.routers.internal.reactions import router as video_internal_router from app.video.api.routers.internal.reactions import router as video_internal_router
from app.credit.api.routers.v1.credit import router as credit_router from app.credit.api.routers.v1.credit import router as credit_router
from app.ssulbox.api.routers.v1.content import router as ssulbox_router
from app.utils.cors import CustomCORSMiddleware from app.utils.cors import CustomCORSMiddleware
from config import prj_settings from config import prj_settings, ssulbox_settings
tags_metadata = [ tags_metadata = [
{ {
@ -290,6 +291,31 @@ tags_metadata = [
1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조) 1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조)
2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다 2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다
3. 업로드 성공 시 Instagram media_id와 permalink 반환 3. 업로드 성공 시 Instagram media_id와 permalink 반환
""",
},
{
"name": "Ssulbox",
"description": """
**썰박스** - 병맛 역사 썰툰 마케팅 쇼츠 생성
네이버 지도 링크나 업장명만 넣으면 4개 시나리오(조선왕·삼국지·그리스로마신화·오디세이)
중 하나로 대본 → 그림 → 목소리 → 영상을 자동 생성합니다.
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 생성 흐름
1. `GET /ssul/search/place` - 업장 검색 (place_url 획득)
2. `POST /ssul/create` - 생성 요청. **이 시점에 크레딧 1이 선차감됩니다**
3. `GET /ssul/tasks/{id}` - 3초 간격 폴링 (step 0~4)
4. 완료 시 `video_url` 반환. 실패 시 크레딧 자동 환불
## ADO2 영상과의 차이
- ADO2: 크롤링 → 가사 → Suno 음악 → Creatomate 영상
- 썰박스: 크롤링 → 대본 → 그림·목소리(Gemini) → 로컬 렌더
크레딧 지갑과 계정은 **동일**합니다.
""", """,
}, },
] ]
@ -423,6 +449,10 @@ app.include_router(sns_router) # SNS API 라우터 추가
app.include_router(dashboard_router) # Dashboard API 라우터 추가 app.include_router(dashboard_router) # Dashboard API 라우터 추가
app.include_router(credit_router, prefix="/user") # Credit API 라우터 추가 app.include_router(credit_router, prefix="/user") # Credit API 라우터 추가
# 썰박스 — 설정으로 끌 수 있게 한다(생성 엔진 없이 배포하는 환경 대비)
if ssulbox_settings.SSULBOX_ENABLED:
app.include_router(ssulbox_router) # /ssul/* 라우터
# DEBUG 모드에서만 테스트 라우터 등록 # DEBUG 모드에서만 테스트 라우터 등록
if prj_settings.DEBUG: if prj_settings.DEBUG:
app.include_router(auth_test_router, prefix="/user") # Test Auth API 라우터 app.include_router(auth_test_router, prefix="/user") # Test Auth API 라우터

View File

@ -13,7 +13,11 @@ dependencies = [
"fastapi-cli>=0.0.16", "fastapi-cli>=0.0.16",
"fastapi[standard]>=0.125.0", "fastapi[standard]>=0.125.0",
"gspread>=6.2.1", "gspread>=6.2.1",
# 썰박스 생성 엔진(generator/) 전용 — subprocess 로 실행되지만 같은 venv 를 쓴다
"google-genai>=1.0", # 대본·이미지·음성 생성
"imageio-ffmpeg>=0.5", # ffmpeg 바이너리 (영상 렌더)
"openai>=2.13.0", "openai>=2.13.0",
"pillow>=10.0", # 자막·이미지 합성
"playwright>=1.57.0", "playwright>=1.57.0",
"pydantic-settings>=2.12.0", "pydantic-settings>=2.12.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
@ -31,6 +35,11 @@ dependencies = [
dev = [ dev = [
"pytest>=9.0.2", "pytest>=9.0.2",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
# Windows 로컬 개발 전용. 리눅스 배포 컨테이너는 /usr/share/zoneinfo 를 쓰므로
# 불필요하다(compose 에서 TZ=Asia/Seoul 설정). Windows 에는 시스템 tz DB 가 없어
# config.py 의 ZoneInfo("Asia/Seoul") 이 실패한다.
# 배포는 `uv pip install .` 라 dev 그룹을 설치하지 않는다.
"tzdata>=2025.1; sys_platform == 'win32'",
] ]
[build-system] [build-system]

92
uv.lock generated
View File

@ -440,15 +440,20 @@ wheels = [
[[package]] [[package]]
name = "google-auth" name = "google-auth"
version = "2.49.1" version = "2.56.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "pyasn1-modules" }, { name = "pyasn1-modules" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" },
]
[package.optional-dependencies]
requests = [
{ name = "requests" },
] ]
[[package]] [[package]]
@ -464,6 +469,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" },
] ]
[[package]]
name = "google-genai"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "google-auth", extra = ["requests"] },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "sniffio" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/df/4f820054c99f29f2fe3de4a8a7c9534dd795302e4a07483a0cb07c3a29b6/google_genai-2.14.0.tar.gz", hash = "sha256:a9d1f4f362d76280f1be1340fcb3c86e63dbca128f6a4ae09d86ab47ff7148e8", size = 641055, upload-time = "2026-07-22T21:35:44.717Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/86/5ac5fb53e44cca4a6607fb917eb331fa237c65a103b9ec2e8e8acc8a42db/google_genai-2.14.0-py3-none-any.whl", hash = "sha256:ae7172cdd35695189b516b33a878e4132e5daa2dbc03a5b44cddfa8a82fad664", size = 1030738, upload-time = "2026-07-22T21:35:42.785Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.3.0" version = "3.3.0"
@ -554,6 +580,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "imageio-ffmpeg"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" },
{ url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" },
{ url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" },
{ url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" },
{ url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@ -718,8 +758,11 @@ dependencies = [
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "fastapi-cli" }, { name = "fastapi-cli" },
{ name = "google-genai" },
{ name = "gspread" }, { name = "gspread" },
{ name = "imageio-ffmpeg" },
{ name = "openai" }, { name = "openai" },
{ name = "pillow" },
{ name = "playwright" }, { name = "playwright" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@ -737,6 +780,7 @@ dependencies = [
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
[package.metadata] [package.metadata]
@ -748,8 +792,11 @@ requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
{ name = "fastapi-cli", specifier = ">=0.0.16" }, { name = "fastapi-cli", specifier = ">=0.0.16" },
{ name = "google-genai", specifier = ">=1.0" },
{ name = "gspread", specifier = ">=6.2.1" }, { name = "gspread", specifier = ">=6.2.1" },
{ name = "imageio-ffmpeg", specifier = ">=0.5" },
{ name = "openai", specifier = ">=2.13.0" }, { name = "openai", specifier = ">=2.13.0" },
{ name = "pillow", specifier = ">=10.0" },
{ name = "playwright", specifier = ">=1.57.0" }, { name = "playwright", specifier = ">=1.57.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" },
@ -767,6 +814,7 @@ requires-dist = [
dev = [ dev = [
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2025.1" },
] ]
[[package]] [[package]]
@ -806,6 +854,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
] ]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
]
[[package]] [[package]]
name = "playwright" name = "playwright"
version = "1.57.0" version = "1.57.0"
@ -1329,6 +1397,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
] ]
[[package]]
name = "tenacity"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.1" version = "4.67.1"
@ -1377,6 +1454,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
] ]
[[package]]
name = "tzdata"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.6.3"