Compare commits
No commits in common. "feature-ssulbox" and "main" have entirely different histories.
feature-ss
...
main
5
.gitignore
vendored
5
.gitignore
vendored
@ -56,7 +56,4 @@ Dockerfile
|
||||
|
||||
zzz/
|
||||
credentials/service_account.json
|
||||
o2o-castad-scheduler/
|
||||
|
||||
generator/output/
|
||||
generator/*/load_image/
|
||||
o2o-castad-scheduler/
|
||||
@ -17,9 +17,14 @@ from app.user.models import User
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
from app.utils.upload_blob_as_request import to_playback_url
|
||||
from app.video.models import Video
|
||||
from app.comment.models import Comment
|
||||
from app.database.like_cache import (
|
||||
bulk_is_user_liked,
|
||||
get_like_counts,
|
||||
mset_like_counts,
|
||||
)
|
||||
from app.video.models import Video, VideoReaction
|
||||
from app.video.schemas.video_schema import VideoListItem
|
||||
from app.video.services import unified_list
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@ -79,32 +84,112 @@ async def get_videos(
|
||||
try:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
items, total = await unified_list.fetch_my_contents(
|
||||
session,
|
||||
user_uuid=current_user.user_uuid,
|
||||
offset=offset,
|
||||
limit=pagination.page_size,
|
||||
# 서브쿼리: task_id별 최신 Video ID 추출
|
||||
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치
|
||||
latest_video_ids = (
|
||||
select(func.max(Video.id).label("latest_id"))
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.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
|
||||
|
||||
# is_liked_by_me: Redis user-set 기준, 캐시 미스 시 현재 사용자 상태만 DB 조회
|
||||
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
|
||||
needs_db_lookup = [
|
||||
vid for vid, liked in raw_liked.items()
|
||||
if liked is None and like_count_map.get(vid, 0) > 0
|
||||
]
|
||||
if needs_db_lookup:
|
||||
liked_video_ids = set((await session.execute(
|
||||
select(VideoReaction.video_id).where(
|
||||
VideoReaction.video_id.in_(needs_db_lookup),
|
||||
VideoReaction.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)).scalars().all())
|
||||
for vid in needs_db_lookup:
|
||||
raw_liked[vid] = vid in liked_video_ids
|
||||
|
||||
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
|
||||
|
||||
# VideoListItem으로 변환
|
||||
items = [
|
||||
VideoListItem(
|
||||
type=it.ctype,
|
||||
video_id=it.id,
|
||||
store_name=it.store_name,
|
||||
region=it.region,
|
||||
# 썰박스는 task_id 개념이 없어 빈 문자열이다.
|
||||
# 프론트는 반드시 (type, video_id) 쌍으로 식별할 것.
|
||||
task_id=it.task_id,
|
||||
result_movie_url=to_playback_url(it.movie_url),
|
||||
poster_url=it.poster_url,
|
||||
title=it.title,
|
||||
description=it.description,
|
||||
hashtags=it.hashtags,
|
||||
created_at=it.created_at,
|
||||
like_count=it.like_count,
|
||||
comment_count=it.comment_count,
|
||||
is_liked_by_me=it.is_liked_by_me,
|
||||
video_id=video.id,
|
||||
store_name=project.store_name,
|
||||
region=project.region,
|
||||
task_id=video.task_id,
|
||||
result_movie_url=to_playback_url(video.result_movie_url),
|
||||
poster_url=video.poster_url,
|
||||
title=video.title,
|
||||
description=video.description,
|
||||
hashtags=video.hashtags,
|
||||
created_at=video.created_at,
|
||||
like_count=like_count_map.get(video.id) or 0,
|
||||
comment_count=comment_count or 0,
|
||||
is_liked_by_me=liked_map.get(video.id, False),
|
||||
)
|
||||
for it in items
|
||||
for video, project, comment_count in rows
|
||||
]
|
||||
|
||||
response = PaginatedResponse.create(
|
||||
|
||||
@ -36,7 +36,7 @@ async def soft_delete_by_task_id(task_id: str) -> dict:
|
||||
dict: 각 테이블별 업데이트된 레코드 수
|
||||
"""
|
||||
logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}")
|
||||
logger.debug("[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
|
||||
logger.debug(f"[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
|
||||
|
||||
result = {
|
||||
"task_id": task_id,
|
||||
|
||||
@ -9,9 +9,7 @@ Comment API Router
|
||||
- DELETE /comment/{comment_id}: 본인 댓글 소프트 삭제 (로그인 필수)
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.comment.schemas.comment_schema import (
|
||||
@ -62,16 +60,12 @@ router = APIRouter(prefix="/comment", tags=["Comment"])
|
||||
async def post_comment(
|
||||
video_id: int,
|
||||
body: CommentCreateRequest,
|
||||
type: Literal["video", "ssul"] = Query(
|
||||
default="video",
|
||||
description="콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 반드시 함께 보낼 것",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CommentCreateResponse:
|
||||
logger.info(
|
||||
f"[post_comment] START - type: {type}, id: {video_id}, "
|
||||
f"user: {current_user.user_uuid}, parent_id: {body.parent_id}"
|
||||
f"[post_comment] START - video_id: {video_id}, user: {current_user.user_uuid}, "
|
||||
f"parent_id: {body.parent_id}"
|
||||
)
|
||||
comment = await create_comment(
|
||||
session=session,
|
||||
@ -80,7 +74,6 @@ async def post_comment(
|
||||
nickname=current_user.nickname,
|
||||
content=body.content,
|
||||
parent_id=body.parent_id,
|
||||
content_type=type,
|
||||
)
|
||||
logger.info(f"[post_comment] SUCCESS - comment_id: {comment.id}")
|
||||
return CommentCreateResponse(
|
||||
@ -120,16 +113,12 @@ async def post_comment(
|
||||
)
|
||||
async def get_comments(
|
||||
video_id: int,
|
||||
type: Literal["video", "ssul"] = Query(
|
||||
default="video",
|
||||
description="콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 반드시 함께 보낼 것",
|
||||
),
|
||||
current_user: User | None = Depends(get_current_user_optional),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
) -> PaginatedResponse[CommentItem]:
|
||||
logger.info(
|
||||
f"[get_comments] START - type: {type}, id: {video_id}, "
|
||||
f"[get_comments] START - video_id: {video_id}, "
|
||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
)
|
||||
current_user_uuid = current_user.user_uuid if current_user else None
|
||||
@ -139,7 +128,6 @@ async def get_comments(
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
current_user_uuid=current_user_uuid,
|
||||
content_type=type,
|
||||
)
|
||||
logger.info(f"[get_comments] SUCCESS - total: {result.total}, items: {len(result.items)}")
|
||||
return result
|
||||
|
||||
@ -1,23 +1,12 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.ssulbox.models import SsulContent
|
||||
from app.user.models import User
|
||||
from app.video.models import Video
|
||||
|
||||
@ -30,22 +19,11 @@ class Comment(Base):
|
||||
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
||||
작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며,
|
||||
프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다.
|
||||
|
||||
**ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id`
|
||||
중 **정확히 하나**만 채워지며, 이를 DB `CHECK` 로 강제한다. MySQL 은 하나의 FK 가
|
||||
두 테이블을 조건부로 가리키게 할 수 없어 컬럼을 나눠 둔 것이다.
|
||||
별도의 종류 컬럼은 두지 않는다 — 어느 FK 가 채워졌는지가 곧 종류이고,
|
||||
컬럼을 하나 더 두면 둘이 어긋날 수 있다.
|
||||
"""
|
||||
|
||||
__tablename__ = "comment"
|
||||
__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_content_id", "content_id"),
|
||||
Index("idx_comment_user_uuid", "user_uuid"),
|
||||
Index("idx_comment_parent_id", "parent_id"),
|
||||
Index("idx_comment_is_deleted", "is_deleted"),
|
||||
@ -59,19 +37,11 @@ class Comment(Base):
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
||||
)
|
||||
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_comment_one_target).
|
||||
video_id: Mapped[Optional[int]] = mapped_column(
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
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)",
|
||||
nullable=False,
|
||||
comment="연결된 Video의 id",
|
||||
)
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
@ -100,13 +70,7 @@ class Comment(Base):
|
||||
comment="작성 일시",
|
||||
)
|
||||
|
||||
# 썰박스 댓글이면 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"
|
||||
)
|
||||
video: Mapped["Video"] = relationship("Video", back_populates="comments")
|
||||
user: Mapped["User"] = relationship("User", back_populates="comments")
|
||||
parent: Mapped[Optional["Comment"]] = relationship(
|
||||
"Comment", remote_side=[id], back_populates="replies"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections import defaultdict
|
||||
from typing import List, Literal, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import exists, select
|
||||
@ -7,55 +7,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.comment.models import Comment
|
||||
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
|
||||
from app.ssulbox.models import SsulContent
|
||||
from app.user.models import User
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
from app.video.models import Video
|
||||
|
||||
ContentType = Literal["video", "ssul"]
|
||||
|
||||
|
||||
def _target_col(content_type: ContentType):
|
||||
"""댓글이 달린 대상을 가리키는 컬럼.
|
||||
|
||||
`comment` 는 ADO2 영상과 썰박스를 함께 담고 `video_id` / `content_id` 중
|
||||
정확히 하나만 채운다(CHECK 로 강제). 종류는 어느 컬럼이 채워졌는지가 결정한다.
|
||||
"""
|
||||
return Comment.video_id if content_type == "video" else Comment.content_id
|
||||
|
||||
|
||||
async def _ensure_target_exists(
|
||||
session: AsyncSession,
|
||||
content_type: ContentType,
|
||||
target_id: int,
|
||||
) -> None:
|
||||
"""댓글 대상(영상 또는 썰박스 콘텐츠)이 존재·공개 상태인지 확인.
|
||||
|
||||
id 가 종류별 독립 시퀀스라, 반대쪽 테이블에 같은 id 가 있어도 잡으면 안 된다.
|
||||
"""
|
||||
if content_type == "ssul":
|
||||
q = select(SsulContent.id).where(
|
||||
SsulContent.id == target_id,
|
||||
SsulContent.status == "done",
|
||||
SsulContent.is_deleted.is_(False),
|
||||
)
|
||||
else:
|
||||
q = select(Video.id).where(
|
||||
Video.id == target_id,
|
||||
Video.status == "completed",
|
||||
Video.is_deleted.is_(False),
|
||||
)
|
||||
if (await session.execute(q)).scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="콘텐츠를 찾을 수 없습니다.")
|
||||
|
||||
|
||||
async def _validate_parent(
|
||||
session: AsyncSession,
|
||||
parent_id: int,
|
||||
content_type: ContentType,
|
||||
target_id: int,
|
||||
video_id: int,
|
||||
) -> None:
|
||||
"""2-depth 제한 + 동일 대상 검증."""
|
||||
"""2-depth 제한 + 동일 video 검증."""
|
||||
result = await session.execute(
|
||||
select(Comment).where(
|
||||
Comment.id == parent_id,
|
||||
@ -66,11 +28,8 @@ async def _validate_parent(
|
||||
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.")
|
||||
# 부모가 같은 종류의 같은 대상에 달렸는지 본다. video_id 만 비교하면
|
||||
# 썰박스 댓글(video_id=NULL)에 ADO2 대댓글이 달리는 교차를 못 막는다.
|
||||
parent_target = parent.video_id if content_type == "video" else parent.content_id
|
||||
if parent_target != target_id:
|
||||
raise HTTPException(status_code=400, detail="다른 콘텐츠의 댓글에는 대댓글을 달 수 없습니다.")
|
||||
if parent.video_id != video_id:
|
||||
raise HTTPException(status_code=400, detail="다른 영상의 댓글에는 대댓글을 달 수 없습니다.")
|
||||
if parent.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)")
|
||||
|
||||
@ -118,19 +77,24 @@ async def create_comment(
|
||||
nickname: Optional[str],
|
||||
content: str,
|
||||
parent_id: Optional[int],
|
||||
content_type: ContentType = "video",
|
||||
) -> Comment:
|
||||
# 대상 존재 확인
|
||||
await _ensure_target_exists(session, content_type, video_id)
|
||||
# Video 존재 확인
|
||||
video_result = await session.execute(
|
||||
select(Video).where(
|
||||
Video.id == video_id,
|
||||
Video.status == "completed",
|
||||
Video.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
if video_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
||||
|
||||
# parent_id 검증
|
||||
if parent_id is not None:
|
||||
await _validate_parent(session, parent_id, content_type, video_id)
|
||||
await _validate_parent(session, parent_id, video_id)
|
||||
|
||||
comment = Comment(
|
||||
# 종류에 따라 둘 중 하나만 채운다 (CHECK ck_comment_one_target 이 강제)
|
||||
video_id=video_id if content_type == "video" else None,
|
||||
content_id=video_id if content_type == "ssul" else None,
|
||||
video_id=video_id,
|
||||
user_uuid=user_uuid,
|
||||
nickname=nickname,
|
||||
parent_id=parent_id,
|
||||
@ -148,7 +112,6 @@ async def list_comments(
|
||||
page: int,
|
||||
page_size: int,
|
||||
current_user_uuid: Optional[str],
|
||||
content_type: ContentType = "video",
|
||||
) -> PaginatedResponse[CommentItem]:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
@ -162,10 +125,9 @@ async def list_comments(
|
||||
.correlate(Comment)
|
||||
)
|
||||
|
||||
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것.
|
||||
# 종류에 맞는 대상 컬럼으로 걸러야 같은 id 의 반대 종류 댓글이 섞이지 않는다.
|
||||
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것
|
||||
parent_where = [
|
||||
_target_col(content_type) == video_id,
|
||||
Comment.video_id == video_id,
|
||||
Comment.parent_id.is_(None),
|
||||
(Comment.is_deleted == False) | has_live_reply, # noqa: E712
|
||||
]
|
||||
|
||||
@ -6,8 +6,6 @@ from fastapi import FastAPI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||
from config import ssulbox_settings
|
||||
|
||||
logger = get_logger("core")
|
||||
|
||||
|
||||
@ -31,29 +29,6 @@ async def lifespan(app: FastAPI):
|
||||
from app.dashboard.migration import init_dashboard_table
|
||||
await init_dashboard_table()
|
||||
|
||||
# 썰박스 스키마는 앱이 만들지 않는다 — **DB 변경은 전부 수동**이 방침이다.
|
||||
# 배포 전에 docs/database-schema/migration_2026-07-30_ssulbox.sql 을 직접 실행할 것.
|
||||
# (자동 마이그레이션 ensure_ssulbox_schema() 는 2026-07-30 제거)
|
||||
if ssulbox_settings.SSULBOX_ENABLED:
|
||||
# 고아 잡 스윕 — 이전 프로세스가 죽으며 남긴 queued/running 을 환불·정리.
|
||||
# 이건 DDL 이 아니라 데이터 정리(환불 UPDATE)라 앱 책임으로 남긴다.
|
||||
# 기동 직후 인메모리 잡은 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()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Database initialization timed out")
|
||||
@ -69,16 +44,6 @@ async def lifespan(app: FastAPI):
|
||||
# Shutdown - 애플리케이션 종료 시
|
||||
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 클라이언트 종료
|
||||
from app.utils.creatomate import close_shared_client
|
||||
from app.utils.upload_blob_as_request import close_shared_blob_client
|
||||
|
||||
@ -326,25 +326,6 @@ 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)
|
||||
def internal_server_error_handler(request, exception):
|
||||
# 에러 메시지 로깅 (한글 포함 가능)
|
||||
|
||||
@ -6,9 +6,7 @@ from app.core.exceptions import FastShipError
|
||||
class InsufficientCreditError(FastShipError):
|
||||
"""크레딧이 부족합니다."""
|
||||
|
||||
# 402 Payment Required. 사전차감 전환 전에는 라우터로 노출된 적이 없어(내부에서만
|
||||
# raise/catch) 400 이었다. 프론트가 "충전 화면으로 유도"를 402 로 분기한다.
|
||||
status = status.HTTP_402_PAYMENT_REQUIRED
|
||||
status = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class InvalidRequestStateError(FastShipError):
|
||||
|
||||
@ -2,16 +2,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
@ -155,10 +146,6 @@ class CreditTransaction(Base):
|
||||
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
|
||||
Index("idx_credit_tx_type", "type"),
|
||||
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_charset": "utf8mb4",
|
||||
@ -219,25 +206,6 @@ class CreditTransaction(Base):
|
||||
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(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
|
||||
@ -32,8 +32,6 @@ async def record_transaction(
|
||||
reason: Optional[str] = None,
|
||||
admin_id: Optional[int] = None,
|
||||
related_request_id: Optional[int] = None,
|
||||
job_type: Optional[str] = None,
|
||||
job_ref: Optional[str] = None,
|
||||
) -> CreditTransaction:
|
||||
tx = CreditTransaction(
|
||||
user_uuid=user_uuid,
|
||||
@ -43,8 +41,6 @@ async def record_transaction(
|
||||
reason=reason,
|
||||
admin_id=admin_id,
|
||||
related_request_id=related_request_id,
|
||||
job_type=job_type,
|
||||
job_ref=job_ref,
|
||||
)
|
||||
session.add(tx)
|
||||
await session.flush()
|
||||
@ -127,189 +123,6 @@ async def deduct_credit(
|
||||
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(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
|
||||
@ -5,19 +5,11 @@ Write-Behind 패턴 적용:
|
||||
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
|
||||
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
|
||||
|
||||
**콘텐츠 종류(ctype)** 를 받아 ADO2 영상과 썰박스 콘텐츠 양쪽에 같은 로직을 쓴다.
|
||||
`ctype` 기본값이 "video" 라서 기존 castad 호출부는 수정 없이 그대로 동작한다.
|
||||
검증된 Lua 원자 토글은 키를 인자로 받으므로 변경하지 않았다.
|
||||
|
||||
Key 패턴:
|
||||
- {ctype}:like:count:{content_id} INT — 좋아요 카운트
|
||||
- {ctype}:like:users:{content_id} SET — 좋아요 누른 user_uuid 목록
|
||||
- video:reaction:dirty SET — DB 동기화 대기 "{ctype}:{content_id}:{user_uuid}"
|
||||
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
|
||||
|
||||
ctype="video" 일 때 카운트/유저 키가 기존과 **완전히 동일**하므로 캐시 이관이 필요 없다.
|
||||
dirty SET 키 이름도 "video:" 접두를 유지한다 — 배포 순간 큐에 남아 있는 항목을
|
||||
잃지 않기 위해서다(이름을 바꾸면 그 항목들이 영구 미반영된다).
|
||||
- video:like:count:{video_id} INT — 좋아요 카운트
|
||||
- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록
|
||||
- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}"
|
||||
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
|
||||
|
||||
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
|
||||
"""
|
||||
@ -52,17 +44,6 @@ end
|
||||
_DIRTY_KEY = "video:reaction:dirty"
|
||||
_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:
|
||||
global _client
|
||||
@ -87,71 +68,61 @@ async def close_like_cache() -> None:
|
||||
# Key 헬퍼
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _key(content_id: int, ctype: str = CT_VIDEO) -> str:
|
||||
return f"{ctype}:like:count:{content_id}"
|
||||
def _key(video_id: int) -> str:
|
||||
return f"video:like:count:{video_id}"
|
||||
|
||||
|
||||
def _user_key(content_id: int, ctype: str = CT_VIDEO) -> str:
|
||||
return f"{ctype}:like:users:{content_id}"
|
||||
def _user_key(video_id: int) -> str:
|
||||
return f"video:like:users:{video_id}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 카운트 (기존 API 유지)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def get_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int | None:
|
||||
async def get_like_count(video_id: int) -> int | None:
|
||||
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
|
||||
val = await get_like_cache().get(_key(content_id, ctype))
|
||||
val = await get_like_cache().get(_key(video_id))
|
||||
if val is None:
|
||||
return None
|
||||
return max(int(val), 0)
|
||||
|
||||
|
||||
async def get_like_counts(
|
||||
content_ids: list[int], *, ctype: str = CT_VIDEO
|
||||
) -> dict[int, int | None]:
|
||||
"""여러 콘텐츠의 like_count를 한 번에 조회 (mget).
|
||||
캐시 미스인 content_id는 None으로 반환."""
|
||||
if not content_ids:
|
||||
async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]:
|
||||
"""여러 영상의 like_count를 한 번에 조회 (mget).
|
||||
캐시 미스인 video_id는 None으로 반환."""
|
||||
if not video_ids:
|
||||
return {}
|
||||
keys = [_key(cid, ctype) for cid in content_ids]
|
||||
keys = [_key(vid) for vid in video_ids]
|
||||
values = await get_like_cache().mget(*keys)
|
||||
return {
|
||||
cid: max(int(v), 0) if v is not None else None
|
||||
for cid, v in zip(content_ids, values)
|
||||
vid: max(int(v), 0) if v is not None else None
|
||||
for vid, v in zip(video_ids, values)
|
||||
}
|
||||
|
||||
|
||||
async def set_like_count(
|
||||
content_id: int, count: int, *, ctype: str = CT_VIDEO
|
||||
) -> None:
|
||||
async def set_like_count(video_id: int, count: int) -> None:
|
||||
"""like_count를 Redis에 저장 (음수 방지)."""
|
||||
await get_like_cache().set(_key(content_id, ctype), max(count, 0))
|
||||
await get_like_cache().set(_key(video_id), max(count, 0))
|
||||
|
||||
|
||||
async def mset_like_counts(
|
||||
counts: dict[int, int], *, ctype: str = CT_VIDEO
|
||||
) -> None:
|
||||
"""여러 콘텐츠의 like_count를 한 번에 저장 (mset)."""
|
||||
async def mset_like_counts(counts: dict[int, int]) -> None:
|
||||
"""여러 영상의 like_count를 한 번에 저장 (mset)."""
|
||||
if not counts:
|
||||
return
|
||||
await get_like_cache().mset(
|
||||
{_key(cid, ctype): max(cnt, 0) for cid, cnt in counts.items()}
|
||||
)
|
||||
await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()})
|
||||
|
||||
|
||||
async def incr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
|
||||
async def incr_like_count(video_id: int) -> int:
|
||||
"""like_count를 1 증가 후 반환."""
|
||||
return max(int(await get_like_cache().incr(_key(content_id, ctype))), 0)
|
||||
return max(int(await get_like_cache().incr(_key(video_id))), 0)
|
||||
|
||||
|
||||
async def decr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
|
||||
async def decr_like_count(video_id: int) -> int:
|
||||
"""like_count를 1 감소 후 반환 (음수 방지)."""
|
||||
client = get_like_cache()
|
||||
key = _key(content_id, ctype)
|
||||
count = int(await client.decr(key))
|
||||
count = int(await get_like_cache().decr(_key(video_id)))
|
||||
if count < 0:
|
||||
await client.set(key, 0)
|
||||
await get_like_cache().set(_key(video_id), 0)
|
||||
return 0
|
||||
return count
|
||||
|
||||
@ -160,9 +131,7 @@ async def decr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
|
||||
# 유저 SET (is_liked_by_me source of truth)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def toggle_like_atomic(
|
||||
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||
) -> tuple[bool, int]:
|
||||
async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
||||
"""Lua 스크립트로 원자적 좋아요 토글.
|
||||
|
||||
Returns:
|
||||
@ -171,16 +140,14 @@ async def toggle_like_atomic(
|
||||
result = await get_like_cache().eval(
|
||||
_TOGGLE_LIKE_SCRIPT,
|
||||
2,
|
||||
_user_key(content_id, ctype),
|
||||
_key(content_id, ctype),
|
||||
_user_key(video_id),
|
||||
_key(video_id),
|
||||
user_uuid,
|
||||
)
|
||||
return bool(result[0]), int(result[1])
|
||||
|
||||
|
||||
async def is_user_liked(
|
||||
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||
) -> bool | None:
|
||||
async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
||||
"""Redis user-set에서 좋아요 여부 조회.
|
||||
|
||||
Returns:
|
||||
@ -188,101 +155,59 @@ async def is_user_liked(
|
||||
None: user-set 키가 없음 (cold-start backfill 필요 신호)
|
||||
"""
|
||||
client = get_like_cache()
|
||||
key = _user_key(content_id, ctype)
|
||||
key = _user_key(video_id)
|
||||
if not await client.exists(key):
|
||||
return None
|
||||
return bool(await client.sismember(key, user_uuid))
|
||||
|
||||
|
||||
async def is_user_set_exists(content_id: int, *, ctype: str = CT_VIDEO) -> bool:
|
||||
async def is_user_set_exists(video_id: int) -> bool:
|
||||
"""Redis user-set 키 존재 여부 확인."""
|
||||
return bool(await get_like_cache().exists(_user_key(content_id, ctype)))
|
||||
return bool(await get_like_cache().exists(_user_key(video_id)))
|
||||
|
||||
|
||||
async def bulk_is_user_liked(
|
||||
content_ids: list[int], user_uuid: str, *, ctype: str = CT_VIDEO
|
||||
video_ids: list[int], user_uuid: str
|
||||
) -> dict[int, bool | None]:
|
||||
"""여러 콘텐츠의 is_liked 여부를 한 번에 조회 (pipeline).
|
||||
|
||||
통합 목록처럼 두 종류가 섞인 경우에는 **종류별로 나눠 각각 호출한다** —
|
||||
반환 키가 content_id 하나여서 종류가 다른 같은 id 를 구분할 수 없다.
|
||||
"""여러 영상의 is_liked 여부를 한 번에 조회 (pipeline).
|
||||
|
||||
Returns:
|
||||
{content_id: True/False} — user-set 키가 없는 항목은 None
|
||||
{video_id: True/False} — user-set 키가 없는 영상은 None
|
||||
"""
|
||||
if not content_ids:
|
||||
if not video_ids:
|
||||
return {}
|
||||
client = get_like_cache()
|
||||
async with client.pipeline(transaction=False) as pipe:
|
||||
for cid in content_ids:
|
||||
pipe.exists(_user_key(cid, ctype))
|
||||
pipe.sismember(_user_key(cid, ctype), user_uuid)
|
||||
for vid in video_ids:
|
||||
pipe.exists(_user_key(vid))
|
||||
pipe.sismember(_user_key(vid), user_uuid)
|
||||
responses = await pipe.execute()
|
||||
|
||||
return {
|
||||
cid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
||||
for i, cid in enumerate(content_ids)
|
||||
vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
||||
for i, vid in enumerate(video_ids)
|
||||
}
|
||||
|
||||
|
||||
async def backfill_user_set(
|
||||
content_id: int, user_uuids: list[str], *, ctype: str = CT_VIDEO
|
||||
) -> None:
|
||||
async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None:
|
||||
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
|
||||
if user_uuids:
|
||||
await get_like_cache().sadd(_user_key(content_id, ctype), *user_uuids)
|
||||
await get_like_cache().sadd(_user_key(video_id), *user_uuids)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Dirty SET (Write-Behind 큐)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def mark_dirty(
|
||||
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||
) -> None:
|
||||
async def mark_dirty(video_id: int, user_uuid: str) -> None:
|
||||
"""DB 동기화 대기 목록에 추가."""
|
||||
await get_like_cache().sadd(_DIRTY_KEY, f"{ctype}:{content_id}:{user_uuid}")
|
||||
await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}")
|
||||
|
||||
|
||||
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]]:
|
||||
async def drain_dirty() -> list[tuple[int, str]]:
|
||||
"""dirty SET을 processing으로 RENAME 후 전체 반환.
|
||||
|
||||
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
|
||||
|
||||
Returns:
|
||||
[(ctype, content_id, user_uuid), ...]
|
||||
"""
|
||||
client = get_like_cache()
|
||||
|
||||
@ -298,12 +223,10 @@ async def drain_dirty() -> list[tuple[str, int, str]]:
|
||||
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
||||
members = await client.smembers(_DIRTY_PROCESSING_KEY)
|
||||
|
||||
result: list[tuple[str, int, str]] = []
|
||||
result = []
|
||||
for member in members:
|
||||
parsed = _parse_dirty(member)
|
||||
if parsed is None:
|
||||
continue
|
||||
result.append(parsed)
|
||||
vid_str, user_uuid = member.split(":", 1)
|
||||
result.append((int(vid_str), user_uuid))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@ -87,9 +87,6 @@ async def create_db_tables():
|
||||
from app.dashboard.models import Dashboard # noqa: F401
|
||||
from app.backoffice.admin.models import Admin # noqa: F401
|
||||
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
|
||||
from app.ssulbox.models import ( # noqa: F401
|
||||
SsulContent,
|
||||
)
|
||||
|
||||
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
|
||||
tables_to_create = [
|
||||
@ -112,8 +109,6 @@ async def create_db_tables():
|
||||
Admin.__table__,
|
||||
CreditChargeRequest.__table__,
|
||||
CreditTransaction.__table__,
|
||||
# 썰박스 (FK 순서: ssul_content 를 나머지가 참조)
|
||||
SsulContent.__table__,
|
||||
]
|
||||
|
||||
logger.info("Creating database tables...")
|
||||
|
||||
@ -7,7 +7,7 @@ SEO 관련 엔드포인트를 제공합니다.
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
@ -32,21 +32,6 @@ async def youtube_seo_description(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> YoutubeDescriptionResponse:
|
||||
if request_body.content_type == "ssul":
|
||||
content_id = request_body.video_id
|
||||
if content_id is None:
|
||||
try:
|
||||
content_id = int(request_body.task_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="썰박스 SEO 에는 video_id 또는 숫자 task_id 가 필요합니다.",
|
||||
)
|
||||
return await seo_service.get_ssul_seo(content_id, current_user, session)
|
||||
|
||||
return await seo_service.get_youtube_seo_description(
|
||||
current_user,
|
||||
session,
|
||||
video_id=request_body.video_id,
|
||||
task_id=request_body.task_id,
|
||||
request_body.video_id, current_user, session
|
||||
)
|
||||
|
||||
@ -7,17 +7,7 @@ Social Media Models
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.mysql import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@ -63,13 +53,6 @@ class SocialUpload(Base):
|
||||
|
||||
__tablename__ = "social_upload"
|
||||
__table_args__ = (
|
||||
# ADO2 영상과 썰박스 콘텐츠를 함께 담는다(2026-07-30 병합,
|
||||
# docs/database-schema/migration_2026-07-30_social_upload_merge.sql).
|
||||
# 대상은 video_id / content_id 중 **정확히 하나**만 채워진다.
|
||||
CheckConstraint(
|
||||
"(video_id IS NULL) <> (content_id IS NULL)",
|
||||
name="ck_social_upload_one_target",
|
||||
),
|
||||
Index("idx_social_upload_user_uuid", "user_uuid"),
|
||||
Index("idx_social_upload_video_id", "video_id"),
|
||||
Index("idx_social_upload_social_account_id", "social_account_id"),
|
||||
@ -78,14 +61,8 @@ class SocialUpload(Base):
|
||||
Index("idx_social_upload_created_at", "created_at"),
|
||||
# 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능)
|
||||
Index("idx_social_upload_video_account", "video_id", "social_account_id"),
|
||||
# 순번 조회용 인덱스 (종류별. content 쪽은 선행 컬럼이라 FK 인덱스도 겸한다)
|
||||
# 순번 조회용 인덱스
|
||||
Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"),
|
||||
Index(
|
||||
"idx_social_upload_content_seq",
|
||||
"content_id",
|
||||
"social_account_id",
|
||||
"upload_seq",
|
||||
),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
@ -114,19 +91,11 @@ class SocialUpload(Base):
|
||||
comment="사용자 UUID (User.user_uuid 참조)",
|
||||
)
|
||||
|
||||
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_social_upload_one_target).
|
||||
video_id: Mapped[Optional[int]] = mapped_column(
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
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)",
|
||||
nullable=False,
|
||||
comment="Video 외래키",
|
||||
)
|
||||
|
||||
social_account_id: Mapped[int] = mapped_column(
|
||||
@ -273,10 +242,8 @@ class SocialUpload(Base):
|
||||
# ==========================================================================
|
||||
# Relationships
|
||||
# ==========================================================================
|
||||
# 썰박스 업로드면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
|
||||
video: Mapped[Optional["Video"]] = relationship(
|
||||
video: Mapped["Video"] = relationship(
|
||||
"Video",
|
||||
foreign_keys=[video_id],
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
@ -2,36 +2,18 @@
|
||||
소셜 SEO 관련 Pydantic 스키마
|
||||
"""
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class YoutubeDescriptionRequest(BaseModel):
|
||||
"""유튜브 SEO Description 제안 요청"""
|
||||
|
||||
content_type: Literal["video", "ssul"] = Field(
|
||||
default="video", description="콘텐츠 종류"
|
||||
)
|
||||
video_id: Optional[int] = Field(
|
||||
None, description="ADO2 video.id 또는 썰박스 ssul_content.id"
|
||||
)
|
||||
task_id: Optional[str] = Field(
|
||||
None,
|
||||
description="ADO2 작업 UUID. 썰박스는 ssul_content.id 문자열도 허용",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_identifier(self) -> "YoutubeDescriptionRequest":
|
||||
if self.video_id is None and not self.task_id:
|
||||
raise ValueError("video_id 또는 task_id 가 필요합니다.")
|
||||
return self
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"content_type": "video",
|
||||
"video_id": 123,
|
||||
"video_id": 123
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@ -13,14 +13,7 @@ from app.social.constants import PrivacyStatus, UploadStatus
|
||||
class SocialUploadRequest(BaseModel):
|
||||
"""소셜 업로드 요청"""
|
||||
|
||||
# ⚠️ video_id 는 content_type 안에서만 유일하다 — video.id 와 ssul_content.id 는
|
||||
# 각각 1부터 시작하는 독립 시퀀스라 값이 겹친다. content_type 없이 썰박스 id 를
|
||||
# 보내면 **id 가 겹치는 남의 ADO2 영상이 업로드된다.**
|
||||
content_type: Literal["video", "ssul"] = Field(
|
||||
default="video",
|
||||
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)",
|
||||
)
|
||||
video_id: int = Field(..., description="업로드할 콘텐츠 ID (content_type 안에서만 유일)")
|
||||
video_id: int = Field(..., description="업로드할 영상 ID")
|
||||
social_account_id: int = Field(..., description="업로드할 소셜 계정 ID (연동 계정 목록의 id)")
|
||||
title: str = Field(..., min_length=1, max_length=100, description="영상 제목")
|
||||
description: Optional[str] = Field(
|
||||
@ -84,9 +77,7 @@ class SocialUploadStatusResponse(BaseModel):
|
||||
"""업로드 상태 조회 응답"""
|
||||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
# 썰박스 업로드면 video_id 가 None 이고 content_id 가 채워진다 (정확히 하나만)
|
||||
video_id: Optional[int] = Field(None, description="ADO2 영상 ID (썰박스면 None)")
|
||||
content_id: Optional[int] = Field(None, description="썰박스 콘텐츠 ID (ADO2 면 None)")
|
||||
video_id: int = Field(..., description="영상 ID")
|
||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
@ -128,9 +119,7 @@ class SocialUploadHistoryItem(BaseModel):
|
||||
"""업로드 이력 아이템"""
|
||||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
# 썰박스 업로드면 video_id 가 None 이고 content_id 가 채워진다 (정확히 하나만)
|
||||
video_id: Optional[int] = Field(None, description="ADO2 영상 ID (썰박스면 None)")
|
||||
content_id: Optional[int] = Field(None, description="썰박스 콘텐츠 ID (ADO2 면 None)")
|
||||
video_id: int = Field(..., description="영상 ID")
|
||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
"""
|
||||
유튜브 SEO 서비스
|
||||
|
||||
ADO2 영상은 제목/설명/해시태그를 video 테이블에 저장합니다.
|
||||
썰박스는 별도 프롬프트로 생성합니다.
|
||||
영상 제목/설명/해시태그를 생성하고 video 테이블에 저장합니다.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -15,7 +14,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.home.models import MarketingIntel, Project
|
||||
from app.social.schemas import YoutubeDescriptionResponse
|
||||
from app.social.services.sns_metadata import apply_sns_metadata, has_stored_sns_metadata
|
||||
from app.ssulbox.models import SsulContent
|
||||
from app.user.models import User
|
||||
from app.video.models import Video
|
||||
|
||||
@ -27,129 +25,36 @@ class SeoService:
|
||||
|
||||
async def get_youtube_seo_description(
|
||||
self,
|
||||
video_id: int,
|
||||
current_user: User,
|
||||
session: AsyncSession,
|
||||
video_id: int | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> YoutubeDescriptionResponse:
|
||||
"""저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다."""
|
||||
video = None
|
||||
if video_id is not None:
|
||||
video = await self._get_owned_video(video_id, current_user.user_uuid, session)
|
||||
elif task_id:
|
||||
video = await self._get_owned_video_by_task(
|
||||
task_id, current_user.user_uuid, session
|
||||
)
|
||||
"""
|
||||
저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[SEO_SERVICE] Load metadata - user: {current_user.user_uuid} / video_id: {video_id}"
|
||||
)
|
||||
|
||||
video = await self._get_owned_video(video_id, current_user.user_uuid, session)
|
||||
if video is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="해당하는 영상을 찾을 수 없습니다.",
|
||||
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[SEO_SERVICE] Load metadata - user: {current_user.user_uuid} / video_id: {video.id}"
|
||||
)
|
||||
|
||||
if has_stored_sns_metadata(video):
|
||||
return self._response_from_row(video)
|
||||
return self._response_from_video(video)
|
||||
|
||||
result = await self.generate_and_save_for_video(video.id, session)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"video_id '{video.id}'에 해당하는 영상을 찾을 수 없습니다.",
|
||||
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
|
||||
)
|
||||
await session.commit()
|
||||
return result
|
||||
|
||||
async def get_ssul_seo(
|
||||
self,
|
||||
content_id: int,
|
||||
current_user: User,
|
||||
session: AsyncSession,
|
||||
) -> YoutubeDescriptionResponse:
|
||||
"""썰박스 콘텐츠용 SEO 생성 — ADO2 와 다른 프롬프트(시트 ssul_upload)를 쓴다."""
|
||||
try:
|
||||
content = (
|
||||
await session.execute(
|
||||
select(SsulContent).where(
|
||||
SsulContent.id == content_id,
|
||||
SsulContent.user_uuid == current_user.user_uuid,
|
||||
SsulContent.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if content is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="콘텐츠를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
if has_stored_sns_metadata(content):
|
||||
return self._response_from_row(content)
|
||||
|
||||
result = await self.generate_and_save_for_ssul(content_id, session)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="콘텐츠를 찾을 수 없습니다."
|
||||
)
|
||||
await session.commit()
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[SEO_SERVICE] SSUL EXCEPTION - error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"썰박스 SEO 생성에 실패했습니다. : {str(e)}",
|
||||
)
|
||||
|
||||
async def generate_and_save_for_ssul(
|
||||
self,
|
||||
content_id: int,
|
||||
session: AsyncSession,
|
||||
) -> YoutubeDescriptionResponse | None:
|
||||
"""GPT로 썰박스 SNS 메타데이터를 생성해 저장합니다. 워커/온디맨드 공용."""
|
||||
content = await session.get(SsulContent, content_id)
|
||||
if content is None:
|
||||
logger.warning(f"[SEO_SERVICE] SsulContent NOT FOUND - content_id: {content_id}")
|
||||
return None
|
||||
|
||||
if has_stored_sns_metadata(content):
|
||||
return self._response_from_row(content)
|
||||
|
||||
result = await self._generate_ssul_seo_description(content)
|
||||
apply_sns_metadata(content, result.title, result.description, result.keywords)
|
||||
await session.flush()
|
||||
logger.info(f"[SEO_SERVICE] Saved ssul metadata - content_id: {content_id}")
|
||||
return result
|
||||
|
||||
async def _generate_ssul_seo_description(
|
||||
self,
|
||||
content: SsulContent,
|
||||
) -> YoutubeDescriptionResponse:
|
||||
"""썰박스 전용 프롬프트로 제목/설명/해시태그를 생성합니다."""
|
||||
from app.ssulbox.constants import SCENARIO_NAMES
|
||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||
from app.utils.prompts.prompts import get_ssul_upload_prompt
|
||||
|
||||
input_data = {
|
||||
"store_name": content.store_name or "",
|
||||
"region": content.region or "",
|
||||
"scenario_name": SCENARIO_NAMES.get(content.scenario, content.scenario),
|
||||
}
|
||||
chatgpt = ChatgptService(timeout=180)
|
||||
out = await chatgpt.generate_structured_output(
|
||||
get_ssul_upload_prompt(), input_data
|
||||
)
|
||||
return YoutubeDescriptionResponse(
|
||||
title=out.title,
|
||||
description=out.description,
|
||||
keywords=out.keywords,
|
||||
)
|
||||
|
||||
async def generate_and_save_for_video(
|
||||
self,
|
||||
video_id: int,
|
||||
@ -163,7 +68,7 @@ class SeoService:
|
||||
return None
|
||||
|
||||
if has_stored_sns_metadata(video):
|
||||
return self._response_from_row(video)
|
||||
return self._response_from_video(video)
|
||||
|
||||
result = await self._generate_seo_description(video.task_id, session)
|
||||
apply_sns_metadata(video, result.title, result.description, result.keywords)
|
||||
@ -188,25 +93,6 @@ class SeoService:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_owned_video_by_task(
|
||||
self,
|
||||
task_id: str,
|
||||
user_uuid: str,
|
||||
session: AsyncSession,
|
||||
) -> Video | None:
|
||||
result = await session.execute(
|
||||
select(Video)
|
||||
.join(Project, Project.id == Video.project_id)
|
||||
.where(
|
||||
Video.task_id == task_id,
|
||||
Project.user_uuid == user_uuid,
|
||||
Video.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Video.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _generate_seo_description(
|
||||
self,
|
||||
task_id: str,
|
||||
@ -275,11 +161,11 @@ class SeoService:
|
||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
||||
)
|
||||
|
||||
def _response_from_row(self, row) -> YoutubeDescriptionResponse:
|
||||
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
|
||||
return YoutubeDescriptionResponse(
|
||||
title=row.title or "",
|
||||
description=row.description or "",
|
||||
keywords=list(row.hashtags or []),
|
||||
title=video.title or "",
|
||||
description=video.description or "",
|
||||
keywords=list(video.hashtags or []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,45 +1,36 @@
|
||||
"""SNS 업로드용 메타데이터 비교/반영 헬퍼.
|
||||
"""SNS 업로드용 영상 메타데이터 비교/반영 헬퍼."""
|
||||
|
||||
`video` 와 `ssul_content` 모두 `title` / `description` / `hashtags` 를 갖는다.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
from app.video.models import Video
|
||||
|
||||
|
||||
class SnsMetadataTarget(Protocol):
|
||||
title: str | None
|
||||
description: str | None
|
||||
hashtags: list | None
|
||||
|
||||
|
||||
def has_stored_sns_metadata(row: SnsMetadataTarget) -> bool:
|
||||
"""행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
|
||||
return bool(row.title)
|
||||
def has_stored_sns_metadata(video: Video) -> bool:
|
||||
"""video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
|
||||
return bool(video.title)
|
||||
|
||||
|
||||
def sns_metadata_changed(
|
||||
row: SnsMetadataTarget,
|
||||
video: Video,
|
||||
title: str,
|
||||
description: str | None,
|
||||
tags: list[str] | None,
|
||||
) -> bool:
|
||||
"""게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다."""
|
||||
stored_tags = list(row.hashtags or [])
|
||||
stored_tags = list(video.hashtags or [])
|
||||
incoming_tags = list(tags or [])
|
||||
return (
|
||||
(row.title or "") != title
|
||||
or (row.description or "") != (description or "")
|
||||
(video.title or "") != title
|
||||
or (video.description or "") != (description or "")
|
||||
or stored_tags != incoming_tags
|
||||
)
|
||||
|
||||
|
||||
def apply_sns_metadata(
|
||||
row: SnsMetadataTarget,
|
||||
video: Video,
|
||||
title: str,
|
||||
description: str | None,
|
||||
hashtags: list[str] | None,
|
||||
) -> None:
|
||||
"""행에 SNS 메타데이터를 반영합니다."""
|
||||
row.title = title
|
||||
row.description = description
|
||||
row.hashtags = list(hashtags or [])
|
||||
"""video 행에 SNS 메타데이터를 반영합니다."""
|
||||
video.title = title
|
||||
video.description = description
|
||||
video.hashtags = list(hashtags or [])
|
||||
|
||||
@ -29,8 +29,6 @@ from app.social.services.account_service import SocialAccountService
|
||||
from app.social.services.sns_metadata import apply_sns_metadata, sns_metadata_changed
|
||||
from app.social.worker.upload_task import process_social_upload
|
||||
from app.user.models import User
|
||||
from app.home.models import Project
|
||||
from app.ssulbox.models import SsulContent
|
||||
from app.video.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -58,68 +56,31 @@ class SocialUploadService:
|
||||
logger.info(
|
||||
f"[UPLOAD_SERVICE] 업로드 요청 - "
|
||||
f"user_uuid: {current_user.user_uuid}, "
|
||||
f"type: {body.content_type}, id: {body.video_id}, "
|
||||
f"video_id: {body.video_id}, "
|
||||
f"social_account_id: {body.social_account_id}"
|
||||
)
|
||||
|
||||
# 1. 대상 조회 및 검증.
|
||||
# video.id 와 ssul_content.id 는 값이 겹치는 독립 시퀀스라 content_type 으로
|
||||
# 정확히 한 테이블만 봐야 한다 — 아니면 남의 다른 콘텐츠가 업로드된다.
|
||||
# `target_col` 은 이후 중복 확인·채번에서도 같은 컬럼을 쓰기 위한 것이다.
|
||||
# ⚠️ 소유자 필터가 필수다. 대상 id 는 클라이언트가 보내는 값이라, 안 거르면
|
||||
# **남의 콘텐츠를 자기 SNS 채널에 업로드**할 수 있다(계정 소유권만 검증하고
|
||||
# 콘텐츠 소유권을 안 보면 IDOR 이 된다).
|
||||
if body.content_type == "ssul":
|
||||
target_col = SocialUpload.content_id
|
||||
content = (
|
||||
await session.execute(
|
||||
select(SsulContent).where(
|
||||
SsulContent.id == body.video_id,
|
||||
SsulContent.user_uuid == current_user.user_uuid,
|
||||
SsulContent.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not content:
|
||||
logger.warning(f"[UPLOAD_SERVICE] 썰박스 콘텐츠 없음 - id: {body.video_id}")
|
||||
raise VideoNotFoundError(video_id=body.video_id)
|
||||
if content.status != "done" or not content.video_url:
|
||||
logger.warning(f"[UPLOAD_SERVICE] 썰박스 영상 미완성 - id: {body.video_id}")
|
||||
raise VideoNotFoundError(
|
||||
video_id=body.video_id,
|
||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||
)
|
||||
target = content
|
||||
else:
|
||||
target_col = SocialUpload.video_id
|
||||
# video 에는 user_uuid 가 없다 — 소유권은 project 에 있으므로 조인한다.
|
||||
video = (
|
||||
await session.execute(
|
||||
select(Video)
|
||||
.join(Project, Video.project_id == Project.id)
|
||||
.where(
|
||||
Video.id == body.video_id,
|
||||
Project.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# 1. 영상 조회 및 검증
|
||||
video_result = await session.execute(
|
||||
select(Video).where(Video.id == body.video_id)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
|
||||
if not video:
|
||||
logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}")
|
||||
raise VideoNotFoundError(video_id=body.video_id)
|
||||
if not video:
|
||||
logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}")
|
||||
raise VideoNotFoundError(video_id=body.video_id)
|
||||
|
||||
if not video.result_movie_url:
|
||||
logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}")
|
||||
raise VideoNotFoundError(
|
||||
video_id=body.video_id,
|
||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||
)
|
||||
target = video
|
||||
if not video.result_movie_url:
|
||||
logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}")
|
||||
raise VideoNotFoundError(
|
||||
video_id=body.video_id,
|
||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||
)
|
||||
|
||||
if sns_metadata_changed(target, body.title, body.description, body.tags):
|
||||
apply_sns_metadata(target, body.title, body.description, body.tags)
|
||||
if sns_metadata_changed(video, body.title, body.description, body.tags):
|
||||
apply_sns_metadata(video, body.title, body.description, body.tags)
|
||||
logger.info(
|
||||
f"[UPLOAD_SERVICE] SNS 메타데이터 갱신 - type: {body.content_type}, id: {body.video_id}"
|
||||
f"[UPLOAD_SERVICE] video SNS 메타데이터 갱신 - video_id: {body.video_id}"
|
||||
)
|
||||
|
||||
# 2. 소셜 계정 조회 및 소유권 검증
|
||||
@ -142,7 +103,7 @@ class SocialUploadService:
|
||||
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
|
||||
in_progress_result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
target_col == body.video_id,
|
||||
SocialUpload.video_id == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||
or_(
|
||||
@ -168,7 +129,7 @@ class SocialUploadService:
|
||||
# 3-2. 미래 예약 업로드 확인
|
||||
scheduled_result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
target_col == body.video_id,
|
||||
SocialUpload.video_id == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
SocialUpload.status == UploadStatus.PENDING.value,
|
||||
SocialUpload.scheduled_at.isnot(None),
|
||||
@ -194,7 +155,7 @@ class SocialUploadService:
|
||||
# 4. 업로드 순번 계산
|
||||
max_seq_result = await session.execute(
|
||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
||||
target_col == body.video_id,
|
||||
SocialUpload.video_id == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
)
|
||||
)
|
||||
@ -203,9 +164,7 @@ class SocialUploadService:
|
||||
# 5. 새 업로드 레코드 생성
|
||||
social_upload = SocialUpload(
|
||||
user_uuid=current_user.user_uuid,
|
||||
# 종류에 따라 둘 중 하나만 채운다 (CHECK ck_social_upload_one_target 이 강제)
|
||||
video_id=body.video_id if body.content_type == "video" else None,
|
||||
content_id=body.video_id if body.content_type == "ssul" else None,
|
||||
video_id=body.video_id,
|
||||
social_account_id=account.id,
|
||||
upload_seq=next_seq,
|
||||
platform=account.platform,
|
||||
@ -273,7 +232,6 @@ class SocialUploadService:
|
||||
return SocialUploadStatusResponse(
|
||||
upload_id=upload.id,
|
||||
video_id=upload.video_id,
|
||||
content_id=upload.content_id,
|
||||
social_account_id=upload.social_account_id,
|
||||
upload_seq=upload.upload_seq,
|
||||
platform=upload.platform,
|
||||
@ -367,7 +325,6 @@ class SocialUploadService:
|
||||
SocialUploadHistoryItem(
|
||||
upload_id=upload.id,
|
||||
video_id=upload.video_id,
|
||||
content_id=upload.content_id,
|
||||
social_account_id=upload.social_account_id,
|
||||
upload_seq=upload.upload_seq,
|
||||
platform=upload.platform,
|
||||
|
||||
@ -5,6 +5,8 @@ Social Upload Background Task
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@ -19,12 +21,11 @@ from config import social_upload_settings
|
||||
from app.dashboard.tasks import insert_dashboard
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.social.constants import SocialPlatform, UploadStatus
|
||||
from app.social.exceptions import TokenExpiredError, UploadQuotaExceededError
|
||||
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
||||
from app.social.models import SocialUpload
|
||||
from app.social.services import social_account_service
|
||||
from app.social.uploader import get_uploader
|
||||
from app.social.uploader.base import UploadMetadata
|
||||
from app.ssulbox.models import SsulContent
|
||||
from app.user.models import SocialAccount
|
||||
from app.video.models import Video
|
||||
|
||||
@ -168,27 +169,16 @@ async def process_social_upload(upload_id: int) -> None:
|
||||
logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
|
||||
return
|
||||
|
||||
# 2. 대상 영상 조회.
|
||||
# 병합 스키마: video_id / content_id 중 정확히 하나만 채워져 있다(CHECK).
|
||||
# 채워진 쪽이 곧 종류다 — 썰박스면 ssul_content.video_url 을 쓴다.
|
||||
if upload.content_id is not None:
|
||||
content_result = await session.execute(
|
||||
select(SsulContent).where(SsulContent.id == upload.content_id)
|
||||
)
|
||||
content = content_result.scalar_one_or_none()
|
||||
source_url = content.video_url if content else None
|
||||
else:
|
||||
video_result = await session.execute(
|
||||
select(Video).where(Video.id == upload.video_id)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
source_url = video.result_movie_url if video else None
|
||||
# 2. Video 정보 조회
|
||||
video_result = await session.execute(
|
||||
select(Video).where(Video.id == upload.video_id)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
|
||||
if not source_url:
|
||||
if not video or not video.result_movie_url:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - "
|
||||
f"upload_id: {upload_id}, video_id: {upload.video_id}, "
|
||||
f"content_id: {upload.content_id}"
|
||||
f"upload_id: {upload_id}, video_id: {upload.video_id}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
@ -216,7 +206,7 @@ async def process_social_upload(upload_id: int) -> None:
|
||||
return
|
||||
|
||||
# 필요한 정보 저장
|
||||
video_url = source_url
|
||||
video_url = video.result_movie_url
|
||||
platform = SocialPlatform(upload.platform)
|
||||
upload_title = upload.title
|
||||
upload_description = upload.description
|
||||
@ -357,7 +347,7 @@ async def process_social_upload(upload_id: int) -> None:
|
||||
f"upload_id: {upload_id}, error: {result.error_message}"
|
||||
)
|
||||
|
||||
except UploadQuotaExceededError:
|
||||
except UploadQuotaExceededError as e:
|
||||
logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}")
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
"""썰박스(ssulbox) 모듈.
|
||||
|
||||
네이버 지도 링크나 업장명을 입력하면 4개 시나리오(조선왕·삼국지·그리스로마신화·오디세이)
|
||||
중 하나로 대본 → 그림 → 목소리 → 영상을 자동 생성하는 병맛 역사 썰툰 쇼츠 파이프라인.
|
||||
|
||||
별도 저장소(o2o-ssulbox)로 개발되던 것을 castad 백엔드로 이식했다.
|
||||
계정·크레딧·소셜 계정은 castad 것을 그대로 쓰고(User / CreditTransaction / SocialAccount),
|
||||
썰박스 고유 도메인만 `ssul_` 접두 테이블로 신설한다.
|
||||
"""
|
||||
@ -1,354 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""썰박스 API — 장소 검색 · 생성 요청 · 진행 폴링.
|
||||
|
||||
castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `/ssul` 로 노출한다.
|
||||
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.credit.exceptions import InsufficientCreditError
|
||||
from app.database.like_cache import get_like_count, is_user_liked, set_like_count
|
||||
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,
|
||||
SsulDeleteResponse,
|
||||
SsulDetailResponse,
|
||||
SsulTaskStatus,
|
||||
)
|
||||
from app.ssulbox.services import task_service
|
||||
from app.ssulbox.worker import job_manager
|
||||
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
||||
from app.user.models import User
|
||||
# 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐)
|
||||
from app.video.models import VideoReaction
|
||||
from app.video.services.share_page import (
|
||||
build_ssul_share_html,
|
||||
get_ssul_share_data,
|
||||
resolve_frontend_base_url,
|
||||
resolve_share_url,
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
from config import prj_settings, ssulbox_settings
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger("ssulbox")
|
||||
|
||||
router = APIRouter(prefix="/ssul", tags=["Ssulbox"])
|
||||
|
||||
|
||||
|
||||
@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,
|
||||
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 은 앱 이벤트 루프를 캡처하므로 반드시 요청 핸들러에서 호출한다.
|
||||
# place URL 해석은 워커가 한다 — 10초 이상 걸려 요청을 막으면 안 된다.
|
||||
job_manager.create_job(
|
||||
content_id,
|
||||
body.scenario,
|
||||
body.input,
|
||||
body.scenes,
|
||||
body.seconds,
|
||||
store_name=body.store_name,
|
||||
road_address=body.road_address,
|
||||
address=body.address,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/share/{content_id}",
|
||||
response_class=HTMLResponse,
|
||||
summary="썰박스 공유용 Open Graph 페이지",
|
||||
description="콘텐츠별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.",
|
||||
responses={
|
||||
200: {"description": "공유 메타데이터 HTML 반환"},
|
||||
404: {"description": "공유 가능한 완료 콘텐츠를 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_ssul_share_page(
|
||||
content_id: int,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""공개 공유 페이지를 반환하고 일반 브라우저는 썰 상세로 이동시킵니다."""
|
||||
share_data = await get_ssul_share_data(session, content_id)
|
||||
if share_data is None:
|
||||
raise HTTPException(status_code=404, detail="공유 가능한 콘텐츠를 찾을 수 없습니다.")
|
||||
|
||||
share_url = resolve_share_url(
|
||||
request.headers,
|
||||
str(request.url).split("?", maxsplit=1)[0],
|
||||
prj_settings.SHARE_API_BASE_URL,
|
||||
)
|
||||
html = build_ssul_share_html(
|
||||
share_data,
|
||||
share_url=share_url,
|
||||
frontend_base_url=resolve_frontend_base_url(
|
||||
request.headers,
|
||||
prj_settings.SHARE_FRONTEND_URL,
|
||||
),
|
||||
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
|
||||
)
|
||||
return HTMLResponse(
|
||||
content=html,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=300",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{content_id}",
|
||||
response_model=SsulDetailResponse,
|
||||
summary="썰박스 콘텐츠 공개 상세",
|
||||
description="""
|
||||
완성된 썰박스 콘텐츠의 상세 정보를 반환합니다. **비로그인도 접근 가능**합니다
|
||||
(공유 링크 `/ssul/{content_id}` 가 이 API 를 씁니다 — castad `/video/{video_id}` 와 동일한 패턴).
|
||||
|
||||
- `status=done` 이고 삭제되지 않은 콘텐츠만 조회됩니다.
|
||||
- `is_liked_by_me` 는 비로그인이면 항상 false 입니다.
|
||||
- 댓글은 `GET /comment/video/{content_id}?type=ssul` 로 따로 조회합니다.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "조회 성공"},
|
||||
404: {"description": "콘텐츠를 찾을 수 없음"},
|
||||
},
|
||||
)
|
||||
async def get_content_detail(
|
||||
content_id: int,
|
||||
current_user: User | None = Depends(get_current_user_optional),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SsulDetailResponse:
|
||||
row = await session.get(SsulContent, content_id)
|
||||
if (
|
||||
row is None
|
||||
or row.is_deleted
|
||||
or row.status != "done"
|
||||
or not row.video_url
|
||||
):
|
||||
raise TaskNotFoundError()
|
||||
|
||||
# 좋아요 수: Redis 우선, 미스면 DB 집계 후 캐시에 채운다 (castad 상세와 동일)
|
||||
like_count = await get_like_count(content_id, ctype="ssul")
|
||||
if like_count is None:
|
||||
like_count = (
|
||||
await session.execute(
|
||||
select(func.count(VideoReaction.id)).where(
|
||||
VideoReaction.content_id == content_id
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
await set_like_count(content_id, like_count, ctype="ssul")
|
||||
|
||||
is_liked = False
|
||||
if current_user:
|
||||
cached = await is_user_liked(
|
||||
content_id, current_user.user_uuid, ctype="ssul"
|
||||
)
|
||||
if cached is None:
|
||||
# user-set 콜드 미스 — 응답 정확성만 필요하므로 DB 존재 확인으로 대체
|
||||
is_liked = (
|
||||
await session.execute(
|
||||
select(VideoReaction.id).where(
|
||||
VideoReaction.content_id == content_id,
|
||||
VideoReaction.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none() is not None
|
||||
else:
|
||||
is_liked = cached
|
||||
|
||||
return SsulDetailResponse(
|
||||
content_id=row.id,
|
||||
scenario=row.scenario,
|
||||
video_url=row.video_url,
|
||||
poster_url=row.poster_url,
|
||||
title=row.title,
|
||||
description=row.description,
|
||||
store_name=row.store_name or None,
|
||||
region=row.region,
|
||||
official_site_url=row.official_site_url,
|
||||
created_at=row.created_at,
|
||||
like_count=like_count,
|
||||
is_liked_by_me=is_liked,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{content_id}",
|
||||
response_model=SsulDeleteResponse,
|
||||
summary="썰박스 콘텐츠 소프트 삭제",
|
||||
description="""
|
||||
본인 소유의 썰박스 콘텐츠를 소프트 삭제합니다 (`is_deleted=True`, 데이터는 유지).
|
||||
castad `DELETE /archive/videos/{video_id}` 와 동일한 정책입니다.
|
||||
|
||||
- 진행 중(queued/running)인 잡은 삭제할 수 없습니다 — 완료·실패 후에 지워야
|
||||
워커·크레딧 환불 경로와 충돌하지 않습니다.
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "삭제 성공"},
|
||||
401: {"description": "인증 실패"},
|
||||
404: {"description": "콘텐츠를 찾을 수 없음 (남의 것 포함)"},
|
||||
409: {"description": "진행 중인 잡은 삭제 불가"},
|
||||
},
|
||||
)
|
||||
async def delete_content(
|
||||
content_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SsulDeleteResponse:
|
||||
row = await session.get(SsulContent, content_id)
|
||||
# 남의 것이면 존재 여부를 노출하지 않고 동일하게 404
|
||||
if row is None or row.is_deleted or row.user_uuid != current_user.user_uuid:
|
||||
raise TaskNotFoundError()
|
||||
|
||||
# 진행 중인 잡을 지우면 워커가 완료 시점에 삭제된 행을 done 으로 되살리거나,
|
||||
# 실패 환불 경로와 꼬인다. 터미널 상태에서만 허용한다.
|
||||
if row.status in ("queued", "running"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="생성이 진행 중입니다. 완료된 뒤에 삭제해주세요.",
|
||||
)
|
||||
|
||||
row.is_deleted = True
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[delete_content] id={content_id} user={current_user.user_uuid}"
|
||||
)
|
||||
return SsulDeleteResponse(
|
||||
success=True,
|
||||
content_id=content_id,
|
||||
message="콘텐츠가 삭제되었습니다.",
|
||||
)
|
||||
@ -1,119 +0,0 @@
|
||||
"""썰박스 상수.
|
||||
|
||||
설정(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()}
|
||||
|
||||
#: 시나리오 한글 표시명. SNS 업로드 SEO 프롬프트 입력({scenario_name})에 쓴다.
|
||||
#: 프론트 i18n(ssulbox.scenario.*.name)과 값을 맞춘다.
|
||||
SCENARIO_NAMES: Final[dict[str, str]] = {
|
||||
"joseon": "조선왕",
|
||||
"samgukji": "삼국지",
|
||||
"greek": "그리스·로마 신화",
|
||||
"odyssey": "오디세이",
|
||||
}
|
||||
|
||||
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
|
||||
@ -1,146 +0,0 @@
|
||||
"""썰박스 예외.
|
||||
|
||||
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",
|
||||
)
|
||||
@ -1,274 +0,0 @@
|
||||
"""썰박스 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 이 없고 **DB 변경은 전부 수동**이 방침이다. 이 파일을 고쳐도 앱은
|
||||
어떤 DDL 도 실행하지 않는다 — 대응 SQL 을 docs/database-schema/ 에 추가하고 배포 전에
|
||||
직접 실행해야 운영 DB 에 반영된다. (자동 마이그레이션은 2026-07-30 제거)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.mysql import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database.session import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
# 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)",
|
||||
)
|
||||
|
||||
# castad `marketing.official_site_url` 과 같은 의미다 — 영상 종료 직전 오버레이의 링크.
|
||||
# 생성 요청 시점에는 알 수 없어 NULL 로 시작하고, 워커가 place 페이지를 크롤링할 때
|
||||
# 채운다(홈페이지 항목 우선, 없으면 네이버 플레이스 URL). 업장명만 입력해 place URL
|
||||
# 해석까지 실패하면 끝까지 NULL 이고, 그때는 프론트가 오버레이를 그리지 않는다.
|
||||
official_site_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(2048),
|
||||
nullable=True,
|
||||
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL; 미확보 시 NULL)",
|
||||
)
|
||||
|
||||
# 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 또는 로컬 서빙 경로)",
|
||||
)
|
||||
|
||||
poster_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(2048),
|
||||
nullable=True,
|
||||
comment="포스터 URL (SNS 공유 og:image. 없으면 프론트가 시나리오 표지로 대체)",
|
||||
)
|
||||
|
||||
title: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="SNS 업로드 제목",
|
||||
)
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="SNS 업로드 설명",
|
||||
)
|
||||
|
||||
hashtags: Mapped[Optional[list]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="SNS 해시태그 목록",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 목록 표시 (크롤링 후 채워짐) — 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="상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용",
|
||||
)
|
||||
|
||||
# views / like_count / comment_count 는 두지 않는다.
|
||||
# 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
|
||||
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
|
||||
# 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다.
|
||||
# SNS 제목·설명·태그는 video 와 같이 이 테이블에 저장한다.
|
||||
|
||||
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 를 그대로 공유할 수 있었다.
|
||||
|
||||
|
||||
# SNS 업로드 모델도 여기 없다.
|
||||
# castad `social_upload` 에 병합됐다(2026-07-30, docs/database-schema/
|
||||
# migration_2026-07-30_social_upload_merge.sql) — 그쪽 행은 ADO2 면 video_id, 썰박스면
|
||||
# content_id 가 채워지고 CHECK 로 하나만 강제한다. 덕분에 dashboard 통계가
|
||||
# 썰박스 업로드를 무수정으로 집계한다(SocialUpload 만 읽고 video_id 는 안 본다).
|
||||
@ -1,151 +0,0 @@
|
||||
"""썰박스 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"]
|
||||
|
||||
|
||||
# 장소 검색 스키마는 두지 않는다 — 업장 검색은 ADO2 와 동일하게
|
||||
# `/search/accommodation`(네이버 검색 API)을 쓰고, place URL 은 생성 요청 시
|
||||
# 서버가 해석한다(2026-07-31 전환). 썰박스 전용 검색 엔드포인트는 제거됐다.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 생성 요청
|
||||
# =============================================================================
|
||||
class SsulCreateRequest(BaseModel):
|
||||
"""생성 요청.
|
||||
|
||||
`scenes`/`seconds` 의 기본값과 허용 범위는 **여기서만** 강제한다.
|
||||
DB 에 기본값을 두면 진실이 두 곳에 생기므로 모델에는 두지 않았다.
|
||||
"""
|
||||
|
||||
scenario: ScenarioLiteral = Field(..., description="시나리오 코드")
|
||||
input: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=500,
|
||||
description=(
|
||||
"네이버 지도 place URL 또는 업장명. "
|
||||
"자동완성으로 고른 경우 store_name/address 가 함께 오며, "
|
||||
"그때는 서버가 ADO2 와 동일한 방식으로 place URL 을 해석한다."
|
||||
),
|
||||
)
|
||||
scenes: int = Field(default=9, ge=4, le=20, description="장면 수")
|
||||
seconds: int = Field(default=30, ge=20, le=90, description="장면당 초 길이")
|
||||
|
||||
# 자동완성(`/search/accommodation`)으로 업장을 고른 경우 프론트가 함께 보낸다.
|
||||
# 세 가지에 쓰인다: ① place URL 해석 ② 통합 목록의 업장명 표시
|
||||
# ③ 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="완료 시에만 채워진다")
|
||||
# 완료 화면의 파일명·표시에 쓴다(ADO2 가 업장명으로 파일명을 만드는 것과 동일).
|
||||
#
|
||||
# **`status='done'` 이면 채워져 있다.** 확보 경로가 3중이라서다:
|
||||
# ① 자동완성으로 고른 경우 생성 요청에 실려 온다
|
||||
# ② 아니면 워커가 place 상세를 크롤링해 채운다(`set_place_info`)
|
||||
# ③ 그래도 비어 있으면 엔진 로그의 `■ 가게:` 마커로 finalize 시 채운다
|
||||
# 다만 생성 **중**(queued/running)에는 아직 비어 있을 수 있다 — 행은 요청 즉시
|
||||
# 만들어지기 때문이다(크레딧 선차감). 진행 화면에서 쓰려면 그 점을 감안할 것.
|
||||
store_name: str = Field(
|
||||
"", description="대상 업장명. 완료(done) 시점에는 항상 채워져 있다"
|
||||
)
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SsulActiveTasksResponse(BaseModel):
|
||||
"""진행 중인 내 잡. 새로고침·새 탭 복구에 쓴다"""
|
||||
|
||||
items: list[SsulTaskStatus]
|
||||
|
||||
|
||||
class SsulDetailResponse(BaseModel):
|
||||
"""`GET /ssul/{content_id}` 공개 상세 응답.
|
||||
|
||||
castad `VideoDetailResponse` 와 같은 필드 구성에 `scenario` 만 더했다
|
||||
(프론트가 시나리오 표지·라벨을 그리는 데 쓴다). 공유 링크로 들어온
|
||||
**비로그인 사용자도 볼 수 있다** — `is_liked_by_me` 는 비로그인이면 항상 False.
|
||||
"""
|
||||
|
||||
content_id: int = Field(..., description="콘텐츠 고유 ID")
|
||||
scenario: str = Field(..., description="시나리오 코드")
|
||||
video_url: str = Field(..., description="완성 영상 URL")
|
||||
poster_url: Optional[str] = Field(None, description="포스터 이미지 URL")
|
||||
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||
store_name: Optional[str] = Field(None, description="업장명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
official_site_url: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL). "
|
||||
"영상 종료 직전 오버레이에 쓰며, 미확보 시 null 이라 오버레이를 그리지 않는다"
|
||||
),
|
||||
)
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
like_count: int = Field(..., description="좋아요 수")
|
||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지")
|
||||
|
||||
|
||||
class SsulDeleteResponse(BaseModel):
|
||||
"""`DELETE /ssul/{content_id}` 응답. castad 삭제 응답과 같은 형태."""
|
||||
|
||||
success: bool
|
||||
content_id: int
|
||||
message: str
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SCENARIOS",
|
||||
"ScenarioLiteral",
|
||||
"SsulActiveTasksResponse",
|
||||
"SsulCreateRequest",
|
||||
"SsulCreateResponse",
|
||||
"SsulDeleteResponse",
|
||||
"SsulDetailResponse",
|
||||
"SsulTaskStatus",
|
||||
]
|
||||
@ -1,117 +0,0 @@
|
||||
# -*- 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 app.utils.video_poster import extract_first_frame, generate_and_store_poster
|
||||
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
|
||||
|
||||
|
||||
def _ssul_task_id(content_id: int) -> str:
|
||||
return f"{ssulbox_settings.SSULBOX_BLOB_PREFIX}-{content_id}"
|
||||
|
||||
|
||||
async def generate_ssul_poster(
|
||||
mp4_path: Path, user_uuid: Optional[str], content_id: int
|
||||
) -> Optional[str]:
|
||||
"""영상 첫 프레임을 포스터로 만들고 URL을 반환합니다. 실패 시 None.
|
||||
|
||||
Blob이 켜져 있으면 ADO2와 같은 업로더를 쓰고, 꺼져 있으면 로컬 mp4 옆에
|
||||
jpg를 두어 `/ssul-videos/` 로 서빙한다.
|
||||
"""
|
||||
try:
|
||||
if blob_enabled() and user_uuid:
|
||||
url = await generate_and_store_poster(
|
||||
video_path=mp4_path,
|
||||
user_uuid=user_uuid,
|
||||
task_id=_ssul_task_id(content_id),
|
||||
file_stem=_ssul_task_id(content_id),
|
||||
)
|
||||
if url:
|
||||
logger.info(f"[generate_ssul_poster] OK id={content_id} url={url}")
|
||||
return url
|
||||
logger.warning(f"[generate_ssul_poster] Blob 포스터 없음 id={content_id}")
|
||||
return None
|
||||
|
||||
image_bytes = await extract_first_frame(mp4_path)
|
||||
if not image_bytes:
|
||||
return None
|
||||
poster_path = mp4_path.with_suffix(".jpg")
|
||||
poster_path.write_bytes(image_bytes)
|
||||
try:
|
||||
rel = poster_path.resolve().relative_to(
|
||||
ssulbox_settings.output_path.resolve()
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"[generate_ssul_poster] output 밖 경로 id={content_id} path={poster_path}"
|
||||
)
|
||||
return None
|
||||
url = f"/ssul-videos/{rel.as_posix()}"
|
||||
logger.info(f"[generate_ssul_poster] 로컬 서빙 id={content_id} url={url}")
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[generate_ssul_poster] 실패 id={content_id} - {type(e).__name__}: {e}"
|
||||
)
|
||||
return None
|
||||
@ -1,246 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""네이버 지도 업장 정보 조회 (Playwright).
|
||||
|
||||
두 가지를 한다:
|
||||
1. `resolve_place_url` — 업장명(+주소) → place URL.
|
||||
**castad `NvMapPwScraper.get_place_id_url` 을 재사용**한다(ADO2 자동완성과 동일 경로).
|
||||
2. `fetch_place_detail` — place URL → 업장명·주소.
|
||||
생성 시 store_name/region 을 채우는 데 쓴다. place 상세의 `__APOLLO_STATE__`
|
||||
안 `PlaceDetailBase:{id}` 를 파싱한다.
|
||||
|
||||
**후보 목록 검색은 여기 없다.** 예전에는 지도 검색 iframe 을 크롤링해 후보를
|
||||
받았지만 **17초**가 걸려, ADO2 와 동일하게 네이버 검색 API(`/search/accommodation`,
|
||||
~0.2초)로 자동완성하고 place URL 은 제출 시 1건만 해석하도록 바꿨다(2026-07-31).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
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.nvMapPwScraper import NvMapPwScraper
|
||||
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
|
||||
|
||||
|
||||
async def resolve_place_url(
|
||||
title: str, address: str = "", road_address: str = ""
|
||||
) -> str | None:
|
||||
"""업장명(+주소)으로 네이버 지도 place URL 을 해석한다.
|
||||
|
||||
**castad `NvMapPwScraper.get_place_id_url` 을 그대로 재사용한다** — ADO2 자동완성이
|
||||
쓰는 바로 그 경로다(`app/home` 의 `_autocomplete_logic`). allSearch API 응답을
|
||||
캡처해 이름·주소 유사도로 후보를 고르고, 실패하면 정제주소 → 업체명 단독 →
|
||||
컨텍스트 재생성 순으로 3단 재시도한다.
|
||||
|
||||
썰박스 자체 지도 크롤링(`search_places`)을 쓰지 않는 이유: 그쪽은 후보 목록을
|
||||
받으려고 iframe 렌더를 기다려 **17초**가 걸린다. ADO2 는 후보 탐색을 네이버
|
||||
검색 API(0.2초)로 하고 여기서는 확정된 1건만 해석하므로 훨씬 빠르고,
|
||||
안티봇 차단 대응까지 이미 들어 있다.
|
||||
|
||||
실패하면 None. 호출부는 사용자가 링크를 직접 붙여넣는 우회 경로를 남겨둔다.
|
||||
"""
|
||||
title = (title or "").strip()
|
||||
if not title:
|
||||
return None
|
||||
try:
|
||||
selected = {
|
||||
"title": title,
|
||||
"address": address or "",
|
||||
"roadAddress": road_address or address or "",
|
||||
}
|
||||
async with NvMapPwScraper() as scraper:
|
||||
url = await scraper.get_place_id_url(selected)
|
||||
logger.info(f"[resolve_place_url] {title!r} → {url}")
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[resolve_place_url] FAILED {title!r} - {type(e).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
# 공식 링크는 apollo state 의 파싱된 dict 가 아니라 원문 HTML 에서 뽑는다
|
||||
# (`homepages` 는 GraphQL placeDetail 이 노출하지 않아 castad 도 같은 방식).
|
||||
# 이미 받아 둔 html 을 재사용하므로 추가 요청·왕복이 없다.
|
||||
detail["homepage"] = NvMapScraper._extract_homepage_from_html(html)
|
||||
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
|
||||
|
||||
|
||||
@ -1,308 +0,0 @@
|
||||
# -*- 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
|
||||
from app.ssulbox.services.blob_service import generate_ssul_poster, upload_ssul_video
|
||||
# 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,
|
||||
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,
|
||||
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:
|
||||
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
|
||||
try:
|
||||
poster_url = await generate_ssul_poster(mp4_path, row.user_uuid, content_id)
|
||||
if poster_url:
|
||||
row.poster_url = poster_url
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[finalize_task] 포스터 생성 실패 id={content_id} - "
|
||||
f"{type(e).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
# 생성 시점에 이미 채워진 값(검색으로 사용자가 직접 고른 업장)이 우선이다.
|
||||
# 여기 들어오는 값은 생성 로그에서 뒤늦게 주워온 것이므로 덮어쓰지 않는다.
|
||||
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], Optional[str]]:
|
||||
"""(store_name, region, detail_region_info, official_site_url).
|
||||
|
||||
크롤링으로 채울 값이 남았는지 판단하는 데 쓴다.
|
||||
"""
|
||||
row = await session.get(SsulContent, content_id)
|
||||
if row is None:
|
||||
return None, None, None, None
|
||||
return row.store_name, row.region, row.detail_region_info, row.official_site_url
|
||||
|
||||
|
||||
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,
|
||||
official_site_url: Optional[str] = None,
|
||||
) -> Optional[SsulContent]:
|
||||
"""크롤링으로 얻은 업장 정보를 채운다. **이미 있는 값은 덮지 않는다.**
|
||||
|
||||
사용자가 검색으로 직접 고른 값이 크롤링 추정치보다 정확하므로 우선한다.
|
||||
|
||||
`official_site_url` 만 예외로 덮어쓴다 — 호출부가 플레이스 URL 을 먼저 폴백으로
|
||||
넣어 두고 크롤링에 성공하면 진짜 홈페이지로 승급시키기 때문이다.
|
||||
"""
|
||||
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
|
||||
if official_site_url:
|
||||
row.official_site_url = official_site_url[:2048]
|
||||
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} "
|
||||
f"site={(row.official_site_url or '')[:60]!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}")
|
||||
@ -1,551 +0,0 @@
|
||||
# -*- 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,
|
||||
*,
|
||||
store_name: str | None = None,
|
||||
road_address: str | None = None,
|
||||
address: str | None = None,
|
||||
) -> 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, # 검색을 안 거친 경우 생성 로그에서 주워온다
|
||||
# 자동완성으로 고른 값. place URL 해석(_resolve_input_url)에만 쓴다 —
|
||||
# DB 저장은 create_task 가 이미 했으므로 여기서는 힌트일 뿐이다.
|
||||
"store_name_hint": store_name,
|
||||
"road_address_hint": road_address,
|
||||
"address_hint": address,
|
||||
"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], Optional[str]]:
|
||||
"""현재 저장된 (store_name, region, detail_region_info, official_site_url)."""
|
||||
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,
|
||||
official_site_url: Optional[str] = None,
|
||||
) -> 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,
|
||||
official_site_url=official_site_url,
|
||||
)
|
||||
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 _try_generate_sns_metadata(content_id: int) -> None:
|
||||
"""제목/설명/해시태그 생성 실패가 완료 처리에 영향을 주지 않도록 격리합니다."""
|
||||
from app.social.services.seo_service import seo_service
|
||||
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
await seo_service.generate_and_save_for_ssul(content_id, session)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[ssul {content_id}] SNS 메타데이터 생성 실패: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
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 시점에 업장명·지역이
|
||||
채워져 있으나 공식 링크는 늘 비어 있으므로, 그 경로에서도 이 함수가 크롤링한다
|
||||
(Playwright 1회, 최대 120초). 링크를 못 얻어도 place URL 폴백은 남는다.
|
||||
|
||||
수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다.
|
||||
"""
|
||||
place_url = job.get("input", "")
|
||||
if not _is_place_url(place_url):
|
||||
return # 업장명 해석 실패 — 링크로 쓸 값이 없다
|
||||
try:
|
||||
store_name, region, detail, site_url = _run_db(_get_place_info(content_id))
|
||||
if store_name and region and detail and site_url:
|
||||
return # 채울 것이 없다
|
||||
|
||||
# 크롤링이 실패해도 오버레이가 뜨도록 place URL 을 먼저 폴백으로 저장한다.
|
||||
# castad 가 `official_site_url or 크롤링 소스 URL` 로 폴백하는 것과 같은 규칙.
|
||||
if not site_url:
|
||||
_run_db(_save_place(content_id, "", "", "", place_url))
|
||||
|
||||
detail = _run_db(
|
||||
place_service.fetch_place_detail(place_url),
|
||||
# Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다
|
||||
timeout=120,
|
||||
)
|
||||
if not detail:
|
||||
return
|
||||
|
||||
title = detail.get("title") or ""
|
||||
road = detail.get("roadAddress") or ""
|
||||
jibun = detail.get("address") or ""
|
||||
homepage = detail.get("homepage") 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 or homepage:
|
||||
# homepage 가 있으면 위에서 넣어 둔 place URL 폴백을 진짜 홈페이지로 승급시킨다.
|
||||
_run_db(_save_place(content_id, title, new_region, new_detail, homepage))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): "
|
||||
f"{type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_input_url(job: dict) -> None:
|
||||
"""업장명 입력을 네이버 지도 place URL 로 바꾼다(자동완성 선택 경로).
|
||||
|
||||
generator 는 place 페이지를 크롤링하므로 URL 이 있어야 정확한 가게를 잡는다.
|
||||
해석은 castad `NvMapPwScraper` 경로를 재사용한다(ADO2 자동완성과 동일).
|
||||
|
||||
실패하면 원래 입력(업장명)을 그대로 둔다 — generator 가 업장명만으로도
|
||||
네이버 브리핑을 시도한다. 정확도는 떨어지지만 생성을 막지는 않는다.
|
||||
"""
|
||||
raw = (job.get("input") or "").strip()
|
||||
store = (job.get("store_name_hint") or "").strip()
|
||||
if not store or _is_place_url(raw):
|
||||
return
|
||||
try:
|
||||
url = _run_db(
|
||||
place_service.resolve_place_url(
|
||||
store,
|
||||
address=job.get("address_hint") or "",
|
||||
road_address=job.get("road_address_hint") or "",
|
||||
),
|
||||
# Playwright 기동 + 최대 3단 재시도라 DB 위임 기본 타임아웃보다 길다
|
||||
timeout=120,
|
||||
)
|
||||
if url:
|
||||
job["input"] = url
|
||||
logger.info(f"[_resolve_input_url] {store!r} → {url}")
|
||||
else:
|
||||
logger.warning(f"[_resolve_input_url] 해석 실패, 업장명으로 진행 - {store!r}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[_resolve_input_url] 실패(업장명으로 진행): {type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _run(content_id: int) -> None:
|
||||
"""워커 스레드 본체. subprocess 를 감독하며 stdout 마커로 진행을 파싱한다."""
|
||||
global _running
|
||||
job = _jobs[content_id]
|
||||
proc: Optional[subprocess.Popen] = None
|
||||
|
||||
try:
|
||||
_run_db(_mark_running(content_id))
|
||||
job["status"] = "running"
|
||||
|
||||
# 업장명만 들어온 경우(자동완성 선택) place URL 로 바꾼다.
|
||||
# **요청 핸들러가 아니라 여기서 하는 이유**: 해석에 10초 이상 걸려
|
||||
# `/ssul/create` 응답이 그만큼 막히고 사용자가 폼에 묶인다.
|
||||
# 워커로 옮기면 요청은 즉시 끝나고 진행 화면이 바로 뜬다.
|
||||
_resolve_input_url(job)
|
||||
|
||||
# 업장명·주소를 **항상** 확보한다. 검색으로 고르지 않고 place URL 을
|
||||
# 붙여넣은 경우 create 시점에 아무것도 없으므로 여기서 크롤링한다.
|
||||
# 생성 **전에** 하는 이유: 목록에 이름 없는 카드가 뜨는 구간을 없앤다.
|
||||
# 실패해도 생성은 계속한다(목록 표시·필터용 부가 정보다).
|
||||
_collect_place_info(content_id, job)
|
||||
|
||||
# ⚠️ 커맨드는 위 두 단계가 끝난 **뒤에** 만든다 — job["input"] 이 갱신되므로
|
||||
# 먼저 만들면 해석 전 값(업장명)이 그대로 엔진에 넘어간다.
|
||||
cmd, engine_dir = _build_command(job)
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
key = gemini_key()
|
||||
if key:
|
||||
env["GEMINI_API_KEY"] = key
|
||||
|
||||
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
|
||||
)
|
||||
# ADO2 video_task 와 같이 완료 커밋 뒤에 SEO를 돌린다. 실패해도 영상은 유지.
|
||||
try:
|
||||
_run_db(_try_generate_sns_metadata(content_id), timeout=240)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[ssul {content_id}] SNS 메타데이터 위임 실패: {type(e).__name__}: {e}"
|
||||
)
|
||||
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}")
|
||||
@ -143,10 +143,6 @@ class User(Base):
|
||||
comment="카카오 썸네일 이미지 URL",
|
||||
)
|
||||
|
||||
# `bio`(썰박스 프로필 한 줄 소개)는 두지 않는다 — 원본 썰박스에는 있었지만
|
||||
# 프로필 편집 화면을 castad `내 정보`로 대체하면서 쓰는 곳이 사라졌다
|
||||
# (2026-07-30 제거). 프로필 소개 기능을 만들게 되면 그때 추가한다.
|
||||
|
||||
# ==========================================================================
|
||||
# 추가 사용자 정보
|
||||
# ==========================================================================
|
||||
|
||||
@ -10,15 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
|
||||
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지.
|
||||
|
||||
.. deprecated::
|
||||
사전차감 정책 전환으로 호출처가 사라졌다(video_task.py 의 사후차감 제거).
|
||||
**새 코드에서 쓰지 말 것.** 이 함수는 멱등성이 없어 재시도 시 중복 차감된다.
|
||||
생성 작업에는 ``credit_service.deduct_credit_for_job`` /
|
||||
``refund_credit_for_job`` 을 쓴다.
|
||||
다음 릴리스에서 제거 예정.
|
||||
"""
|
||||
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지."""
|
||||
try:
|
||||
await deduct_credit(
|
||||
session=session,
|
||||
|
||||
@ -113,32 +113,6 @@ image_autotag_prompt = Prompt(
|
||||
prompt_output_class=ImageTagPromptOutput,
|
||||
)
|
||||
|
||||
@lru_cache()
|
||||
def get_ssul_upload_prompt() -> Prompt:
|
||||
"""썰박스 SNS 업로드 SEO 프롬프트 (시트: ssul_upload).
|
||||
|
||||
**lazy 로 만드는 이유**: 모듈 레벨 Prompt() 는 import 시점에 시트를 읽는다 —
|
||||
스프레드시트에 `ssul_upload` 시트가 아직 없으면 **앱 기동 자체가 죽는다.**
|
||||
lazy 면 시트가 없어도 앱은 살고, 썰박스 SEO 요청만 500 이 난다.
|
||||
(그래서 _preload_all_sheets 목록에도 넣지 않았다 — preload 는 실패를
|
||||
경고로 삼키지만, 이후 Prompt() 생성 시 worksheet() 폴백에서 죽는 건 같다)
|
||||
|
||||
ADO2(yt_upload)와 다른 프롬프트를 쓴다: 광고 영상 SEO 가 아니라
|
||||
병맛 역사 썰툰 톤의 제목·설명·태그를 만든다.
|
||||
템플릿 변수: {store_name} {region} {scenario_name}
|
||||
"""
|
||||
from app.utils.prompts.schemas import (
|
||||
SsulUploadPromptInput,
|
||||
SsulUploadPromptOutput,
|
||||
)
|
||||
|
||||
return Prompt(
|
||||
sheet_name="ssul_upload",
|
||||
prompt_input_class=SsulUploadPromptInput,
|
||||
prompt_output_class=SsulUploadPromptOutput,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt:
|
||||
# industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨.
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
from .lyric import LyricPromptInput, LyricPromptOutput
|
||||
from .marketing import MarketingPromptInput, MarketingPromptOutput
|
||||
from .ssulbox import SsulUploadPromptInput, SsulUploadPromptOutput
|
||||
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
|
||||
from .image import *
|
||||
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
|
||||
# Input 정의
|
||||
class SsulUploadPromptInput(BaseModel):
|
||||
"""썰박스 SNS 업로드 SEO 프롬프트 입력.
|
||||
|
||||
ADO2(yt_upload)와 별도 프롬프트를 쓴다 — ADO2 는 업장 마케팅 분석 보고서 기반의
|
||||
광고 영상이지만, 썰박스는 "병맛 역사 썰툰"이라 제목·설명의 톤이 완전히 다르다.
|
||||
스프레드시트 시트명: `ssul_upload` (B2=모델, B3=템플릿).
|
||||
템플릿에서 쓸 수 있는 변수: {store_name} {region} {scenario_name}
|
||||
"""
|
||||
|
||||
store_name: str = Field(..., description="마케팅 대상 업장명 (모르면 빈 문자열)")
|
||||
region: str = Field(default="", description="업장 지역 (모르면 빈 문자열)")
|
||||
scenario_name: str = Field(
|
||||
..., description="시나리오 한글명 (조선왕/삼국지/그리스·로마 신화/오디세이)"
|
||||
)
|
||||
|
||||
|
||||
# Output 정의
|
||||
class SsulUploadPromptOutput(BaseModel):
|
||||
title: str = Field(..., description="쇼츠 제목 - 병맛 톤 + SEO")
|
||||
description: str = Field(..., description="업로드 설명 - 병맛 톤 + SEO/해시태그 포함")
|
||||
# ADO2 는 마케팅 분석의 target_keywords 를 태그로 재사용하지만 썰박스에는
|
||||
# 그 분석이 없다 — GPT 가 태그까지 함께 만든다.
|
||||
keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
||||
@ -12,15 +12,11 @@ from sqlalchemy import delete, insert, tuple_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.like_cache import (
|
||||
CT_SSUL,
|
||||
CT_VIDEO,
|
||||
commit_dirty_processing,
|
||||
drain_dirty,
|
||||
is_user_liked,
|
||||
)
|
||||
from app.database.session import get_session
|
||||
# 썰박스 좋아요도 같은 Redis write-behind 큐 + **같은 테이블**(video_reaction)을
|
||||
# 쓰므로 여기서 함께 플러시한다. 큐가 하나라 스케줄러를 늘릴 필요가 없다.
|
||||
from app.video.models import VideoReaction
|
||||
from config import internal_settings
|
||||
|
||||
@ -52,68 +48,49 @@ async def flush_reactions(
|
||||
detail="Invalid internal secret",
|
||||
)
|
||||
|
||||
entries = await drain_dirty()
|
||||
if not entries:
|
||||
pairs = await drain_dirty()
|
||||
if not pairs:
|
||||
logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료")
|
||||
return {"flushed": 0, "adds": 0, "dels": 0}
|
||||
|
||||
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(entries)}건")
|
||||
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(pairs)}건")
|
||||
|
||||
# 두 종류가 **같은 테이블**(video_reaction)에 들어간다. 대상 컬럼만 다르다.
|
||||
# ADO2 는 video_id, 썰박스는 content_id 를 채운다(나머지는 NULL, CHECK 로 강제).
|
||||
id_col_of = {CT_VIDEO: "video_id", CT_SSUL: "content_id"}
|
||||
adds: list[dict] = []
|
||||
# (컬럼명, 대상 id, user_uuid) — 삭제는 컬럼이 달라 종류별로 묶어야 한다
|
||||
dels: dict[str, list[tuple[int, str]]] = {"video_id": [], "content_id": []}
|
||||
dels: list[tuple[int, str]] = []
|
||||
|
||||
# Redis 현재 상태 기준으로 add / delete 분류
|
||||
for ctype, content_id, user_uuid in entries:
|
||||
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)
|
||||
for video_id, user_uuid in pairs:
|
||||
liked = await is_user_liked(video_id, user_uuid)
|
||||
if liked:
|
||||
adds.append({id_col: content_id, "user_uuid": user_uuid})
|
||||
adds.append({"video_id": video_id, "user_uuid": user_uuid})
|
||||
else:
|
||||
dels[id_col].append((content_id, user_uuid))
|
||||
|
||||
total_adds = len(adds)
|
||||
total_dels = sum(len(v) for v in dels.values())
|
||||
dels.append((video_id, user_uuid))
|
||||
|
||||
try:
|
||||
# 쓰기를 **하나의 트랜잭션**으로 묶는다.
|
||||
# 부분 반영이 나면 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(
|
||||
insert(VideoReaction).prefix_with("IGNORE").values(rows)
|
||||
)
|
||||
# Bulk INSERT IGNORE — UniqueConstraint 보장으로 멱등 처리
|
||||
if adds:
|
||||
await session.execute(
|
||||
insert(VideoReaction).prefix_with("IGNORE").values(adds)
|
||||
)
|
||||
|
||||
if dels[id_col]:
|
||||
await session.execute(
|
||||
delete(VideoReaction).where(
|
||||
tuple_(
|
||||
getattr(VideoReaction, id_col),
|
||||
VideoReaction.user_uuid,
|
||||
).in_(dels[id_col])
|
||||
)
|
||||
# Bulk DELETE
|
||||
if dels:
|
||||
await session.execute(
|
||||
delete(VideoReaction).where(
|
||||
tuple_(
|
||||
VideoReaction.video_id,
|
||||
VideoReaction.user_uuid,
|
||||
).in_(dels)
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await commit_dirty_processing()
|
||||
|
||||
logger.info(
|
||||
f"[REACTION_FLUSH] SUCCESS - adds: {total_adds}, dels: {total_dels}"
|
||||
f"[REACTION_FLUSH] SUCCESS - adds: {len(adds)}, dels: {len(dels)}"
|
||||
)
|
||||
return {"flushed": len(entries), "adds": total_adds, "dels": total_dels}
|
||||
return {"flushed": len(pairs), "adds": len(adds), "dels": len(dels)}
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,19 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.mysql import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@ -177,11 +165,8 @@ class Video(Base):
|
||||
back_populates="videos",
|
||||
)
|
||||
|
||||
# comment/video_reaction 이 video_id 와 content_id 두 FK 를 갖게 되어
|
||||
# 어느 쪽으로 조인할지 명시해야 한다(없으면 AmbiguousForeignKeysError).
|
||||
comments: Mapped[List["Comment"]] = relationship(
|
||||
"Comment",
|
||||
foreign_keys="Comment.video_id",
|
||||
back_populates="video",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="noload",
|
||||
@ -189,7 +174,6 @@ class Video(Base):
|
||||
|
||||
reactions: Mapped[List["VideoReaction"]] = relationship(
|
||||
"VideoReaction",
|
||||
foreign_keys="VideoReaction.video_id",
|
||||
back_populates="video",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="noload",
|
||||
@ -215,30 +199,14 @@ class VideoReaction(Base):
|
||||
영상 반응 테이블
|
||||
|
||||
사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글).
|
||||
(user_uuid, video_id) 유니크 제약으로 1인 1회 보장.
|
||||
향후 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"
|
||||
__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", "content_id", name="uq_video_reaction_user_content"
|
||||
),
|
||||
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"),
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
@ -250,19 +218,11 @@ class VideoReaction(Base):
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
||||
)
|
||||
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_video_reaction_one_target).
|
||||
video_id: Mapped[Optional[int]] = mapped_column(
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
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)",
|
||||
nullable=False,
|
||||
comment="연결된 Video의 id",
|
||||
)
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
@ -277,8 +237,5 @@ class VideoReaction(Base):
|
||||
comment="반응 일시",
|
||||
)
|
||||
|
||||
# 썰박스 반응이면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
|
||||
video: Mapped[Optional["Video"]] = relationship(
|
||||
"Video", foreign_keys=[video_id], back_populates="reactions"
|
||||
)
|
||||
video: Mapped["Video"] = relationship("Video", back_populates="reactions")
|
||||
user: Mapped["User"] = relationship("User", back_populates="video_reactions")
|
||||
|
||||
@ -1,250 +1,229 @@
|
||||
"""
|
||||
Video API Schemas
|
||||
|
||||
영상 생성 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GenerateVideoResponse(BaseModel):
|
||||
"""영상 생성 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/generate/{task_id}
|
||||
Returns the task IDs for tracking video generation.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"creatomate_render_id": "render-id-123456",
|
||||
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
||||
"error_message": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)")
|
||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class VideoRenderData(BaseModel):
|
||||
"""Creatomate 렌더링 결과 데이터"""
|
||||
|
||||
id: Optional[str] = Field(None, description="렌더 ID")
|
||||
status: Optional[str] = Field(None, description="렌더 상태")
|
||||
url: Optional[str] = Field(None, description="영상 URL")
|
||||
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
|
||||
video_id: Optional[int] = Field(None, description="Video id(DB)")
|
||||
|
||||
|
||||
class PollingVideoResponse(BaseModel):
|
||||
"""영상 생성 상태 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/status/{creatomate_render_id}
|
||||
Creatomate API 작업 상태를 조회합니다.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- planned: 예약됨
|
||||
- waiting: 대기 중
|
||||
- transcribing: 트랜스크립션 중
|
||||
- rendering: 렌더링 중
|
||||
- succeeded: 성공
|
||||
- failed: 실패
|
||||
|
||||
Example Response (Success):
|
||||
{
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"message": "영상 생성이 완료되었습니다.",
|
||||
"render_data": {
|
||||
"id": "render-id",
|
||||
"status": "succeeded",
|
||||
"url": "https://...",
|
||||
"snapshot_url": "https://..."
|
||||
},
|
||||
"raw_response": {...},
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="조회 성공 여부")
|
||||
status: Optional[str] = Field(
|
||||
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
|
||||
)
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
|
||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class DownloadVideoResponse(BaseModel):
|
||||
"""영상 다운로드 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/download/{task_id}
|
||||
Polls for video completion and returns project info with video URL.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- processing: 영상 생성 진행 중 (result_movie_url은 null)
|
||||
- completed: 영상 생성 완료 (result_movie_url 포함)
|
||||
- failed: 영상 생성 실패
|
||||
- not_found: task_id에 해당하는 Video 없음
|
||||
- error: 조회 중 오류 발생
|
||||
|
||||
Example Response (Completed):
|
||||
{
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"message": "영상 다운로드가 완료되었습니다.",
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||
"created_at": "2025-01-15T12:00:00",
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="다운로드 성공 여부")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class VideoListItem(BaseModel):
|
||||
"""영상 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
GET /videos 응답의 개별 영상 정보
|
||||
|
||||
Example:
|
||||
{
|
||||
"video_id": 1,
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||
"poster_url": "http://localhost:8000/media/2025-01-15/video.jpg",
|
||||
"created_at": "2025-01-15T12:00:00"
|
||||
}
|
||||
"""
|
||||
|
||||
# ⚠️ `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="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(
|
||||
default="",
|
||||
description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)",
|
||||
)
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
||||
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||
hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
like_count: int = Field(0, description="좋아요 수")
|
||||
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
|
||||
is_liked_by_me: bool = Field(
|
||||
False,
|
||||
description="현재 로그인 사용자가 좋아요를 눌렀는지",
|
||||
)
|
||||
|
||||
|
||||
class VideoThumbnailItem(BaseModel):
|
||||
"""ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용)
|
||||
|
||||
Usage:
|
||||
GET /video/all 응답의 개별 영상 정보
|
||||
"""
|
||||
|
||||
# ⚠️ `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="업체명")
|
||||
result_movie_url: str = Field(..., description="영상 URL")
|
||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
|
||||
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
like_count: int = Field(..., description="좋아요 수")
|
||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
|
||||
official_site_url: Optional[str] = Field(
|
||||
None,
|
||||
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
|
||||
)
|
||||
|
||||
|
||||
class VideoDetailResponse(BaseModel):
|
||||
"""단일 영상 상세 응답
|
||||
|
||||
Usage:
|
||||
GET /video/{video_id}
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
result_movie_url: str = Field(..., description="영상 URL")
|
||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
like_count: int = Field(..., description="좋아요 수")
|
||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||
official_site_url: Optional[str] = Field(
|
||||
None,
|
||||
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
|
||||
)
|
||||
|
||||
|
||||
class LikeToggleResponse(BaseModel):
|
||||
"""좋아요 토글 응답
|
||||
|
||||
Usage:
|
||||
POST /video/{video_id}/like
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)")
|
||||
like_count: int = Field(..., description="토글 후 전체 좋아요 수")
|
||||
|
||||
|
||||
"""
|
||||
Video API Schemas
|
||||
|
||||
영상 생성 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GenerateVideoResponse(BaseModel):
|
||||
"""영상 생성 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/generate/{task_id}
|
||||
Returns the task IDs for tracking video generation.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"creatomate_render_id": "render-id-123456",
|
||||
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
||||
"error_message": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)")
|
||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class VideoRenderData(BaseModel):
|
||||
"""Creatomate 렌더링 결과 데이터"""
|
||||
|
||||
id: Optional[str] = Field(None, description="렌더 ID")
|
||||
status: Optional[str] = Field(None, description="렌더 상태")
|
||||
url: Optional[str] = Field(None, description="영상 URL")
|
||||
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
|
||||
video_id: Optional[int] = Field(None, description="Video id(DB)")
|
||||
|
||||
|
||||
class PollingVideoResponse(BaseModel):
|
||||
"""영상 생성 상태 조회 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/status/{creatomate_render_id}
|
||||
Creatomate API 작업 상태를 조회합니다.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- planned: 예약됨
|
||||
- waiting: 대기 중
|
||||
- transcribing: 트랜스크립션 중
|
||||
- rendering: 렌더링 중
|
||||
- succeeded: 성공
|
||||
- failed: 실패
|
||||
|
||||
Example Response (Success):
|
||||
{
|
||||
"success": true,
|
||||
"status": "succeeded",
|
||||
"message": "영상 생성이 완료되었습니다.",
|
||||
"render_data": {
|
||||
"id": "render-id",
|
||||
"status": "succeeded",
|
||||
"url": "https://...",
|
||||
"snapshot_url": "https://..."
|
||||
},
|
||||
"raw_response": {...},
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="조회 성공 여부")
|
||||
status: Optional[str] = Field(
|
||||
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
|
||||
)
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
|
||||
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class DownloadVideoResponse(BaseModel):
|
||||
"""영상 다운로드 응답 스키마
|
||||
|
||||
Usage:
|
||||
GET /video/download/{task_id}
|
||||
Polls for video completion and returns project info with video URL.
|
||||
|
||||
Note:
|
||||
상태 값:
|
||||
- processing: 영상 생성 진행 중 (result_movie_url은 null)
|
||||
- completed: 영상 생성 완료 (result_movie_url 포함)
|
||||
- failed: 영상 생성 실패
|
||||
- not_found: task_id에 해당하는 Video 없음
|
||||
- error: 조회 중 오류 발생
|
||||
|
||||
Example Response (Completed):
|
||||
{
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"message": "영상 다운로드가 완료되었습니다.",
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||
"created_at": "2025-01-15T12:00:00",
|
||||
"error_message": null
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="다운로드 성공 여부")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class VideoListItem(BaseModel):
|
||||
"""영상 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
GET /videos 응답의 개별 영상 정보
|
||||
|
||||
Example:
|
||||
{
|
||||
"video_id": 1,
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||
"poster_url": "http://localhost:8000/media/2025-01-15/video.jpg",
|
||||
"created_at": "2025-01-15T12:00:00"
|
||||
}
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
||||
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||
hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
like_count: int = Field(0, description="좋아요 수")
|
||||
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
|
||||
is_liked_by_me: bool = Field(
|
||||
False,
|
||||
description="현재 로그인 사용자가 좋아요를 눌렀는지",
|
||||
)
|
||||
|
||||
|
||||
class VideoThumbnailItem(BaseModel):
|
||||
"""ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용)
|
||||
|
||||
Usage:
|
||||
GET /video/all 응답의 개별 영상 정보
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
|
||||
store_name: str = Field(..., description="업체명")
|
||||
result_movie_url: str = Field(..., description="영상 URL")
|
||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
like_count: int = Field(..., description="좋아요 수")
|
||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
|
||||
official_site_url: Optional[str] = Field(
|
||||
None,
|
||||
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
|
||||
)
|
||||
|
||||
|
||||
class VideoDetailResponse(BaseModel):
|
||||
"""단일 영상 상세 응답
|
||||
|
||||
Usage:
|
||||
GET /video/{video_id}
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
result_movie_url: str = Field(..., description="영상 URL")
|
||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
title: Optional[str] = Field(None, description="SNS 업로드 제목 (공유 시 og:title 및 공유 제목으로 사용)")
|
||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
like_count: int = Field(..., description="좋아요 수")
|
||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||
official_site_url: Optional[str] = Field(
|
||||
None,
|
||||
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
|
||||
)
|
||||
|
||||
|
||||
class LikeToggleResponse(BaseModel):
|
||||
"""좋아요 토글 응답
|
||||
|
||||
Usage:
|
||||
POST /video/{video_id}/like
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)")
|
||||
like_count: int = Field(..., description="토글 후 전체 좋아요 수")
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""콘텐츠 공유 링크용 Open Graph HTML 생성 서비스."""
|
||||
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
@ -9,14 +9,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.home.models import Project
|
||||
from app.ssulbox.models import SsulContent
|
||||
from app.video.models import Video
|
||||
|
||||
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
|
||||
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
|
||||
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VideoShareData:
|
||||
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
|
||||
@ -29,18 +27,6 @@ class VideoShareData:
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SsulShareData:
|
||||
"""공유 페이지에 필요한 썰박스 콘텐츠 정보."""
|
||||
|
||||
content_id: int
|
||||
poster_url: str | None
|
||||
store_name: str
|
||||
region: str
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
async def get_video_share_data(
|
||||
session: AsyncSession,
|
||||
video_id: int,
|
||||
@ -77,41 +63,6 @@ async def get_video_share_data(
|
||||
)
|
||||
|
||||
|
||||
async def get_ssul_share_data(
|
||||
session: AsyncSession,
|
||||
content_id: int,
|
||||
) -> SsulShareData | None:
|
||||
"""공유 가능한 완료 썰박스 콘텐츠를 조회합니다."""
|
||||
result = await session.execute(
|
||||
select(
|
||||
SsulContent.id,
|
||||
SsulContent.poster_url,
|
||||
SsulContent.title,
|
||||
SsulContent.description,
|
||||
SsulContent.store_name,
|
||||
SsulContent.region,
|
||||
).where(
|
||||
SsulContent.id == content_id,
|
||||
SsulContent.status == "done",
|
||||
SsulContent.is_deleted.is_(False),
|
||||
SsulContent.video_url.is_not(None),
|
||||
SsulContent.video_url != "",
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return SsulShareData(
|
||||
content_id=row.id,
|
||||
poster_url=row.poster_url,
|
||||
store_name=row.store_name,
|
||||
region=row.region or "",
|
||||
title=row.title,
|
||||
description=row.description,
|
||||
)
|
||||
|
||||
|
||||
def build_video_share_html(
|
||||
data: VideoShareData,
|
||||
*,
|
||||
@ -120,58 +71,19 @@ def build_video_share_html(
|
||||
configured_default_image_url: str = "",
|
||||
) -> str:
|
||||
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
|
||||
return _build_share_html(
|
||||
detail_path=f"/video/{data.video_id}",
|
||||
poster_url=data.poster_url,
|
||||
title=_share_title(data.title, data.store_name, fallback_store="ADO2 영상"),
|
||||
description=_share_description(data.description),
|
||||
share_url=share_url,
|
||||
frontend_base_url=frontend_base_url,
|
||||
configured_default_image_url=configured_default_image_url,
|
||||
)
|
||||
|
||||
|
||||
def build_ssul_share_html(
|
||||
data: SsulShareData,
|
||||
*,
|
||||
share_url: str,
|
||||
frontend_base_url: str,
|
||||
configured_default_image_url: str = "",
|
||||
) -> str:
|
||||
"""썰박스 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
|
||||
return _build_share_html(
|
||||
detail_path=f"/ssul/{data.content_id}",
|
||||
poster_url=data.poster_url,
|
||||
title=_share_title(data.title, data.store_name, fallback_store="ADO2 썰"),
|
||||
description=_share_description(data.description),
|
||||
share_url=share_url,
|
||||
frontend_base_url=frontend_base_url,
|
||||
configured_default_image_url=configured_default_image_url,
|
||||
)
|
||||
|
||||
|
||||
def _build_share_html(
|
||||
*,
|
||||
detail_path: str,
|
||||
poster_url: str | None,
|
||||
title: str,
|
||||
description: str,
|
||||
share_url: str,
|
||||
frontend_base_url: str,
|
||||
configured_default_image_url: str,
|
||||
) -> str:
|
||||
"""크롤러용 OG 메타와, 사람용 프론트 상세 이동 링크를 포함한 HTML을 만듭니다."""
|
||||
frontend_base = _normalise_frontend_base_url(frontend_base_url)
|
||||
detail_url = f"{frontend_base}{detail_path}"
|
||||
detail_url = f"{frontend_base}/video/{data.video_id}"
|
||||
fallback_image_url = _resolve_default_image_url(
|
||||
configured_default_image_url,
|
||||
frontend_base,
|
||||
share_url=share_url,
|
||||
)
|
||||
image_url = _absolute_http_url(poster_url) or fallback_image_url
|
||||
canonical_url = _absolute_http_url(share_url)
|
||||
image_url = _absolute_http_url(data.poster_url) or fallback_image_url
|
||||
|
||||
title = _share_title(data.title, data.store_name)
|
||||
description = _share_description(data.description)
|
||||
canonical_tags = _canonical_tags(_absolute_http_url(share_url))
|
||||
image_size_tags = _og_image_size_tags(image_url, fallback_image_url)
|
||||
canonical_tags = _canonical_tags(canonical_url)
|
||||
|
||||
escaped_title = escape(title, quote=True)
|
||||
escaped_description = escape(description, quote=True)
|
||||
@ -202,7 +114,7 @@ def _build_share_html(
|
||||
<main>
|
||||
<h1>{escaped_title}</h1>
|
||||
<p>{escaped_description}</p>
|
||||
<a id="continue-link" href="{escaped_detail_url}">콘텐츠 보기</a>
|
||||
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a>
|
||||
</main>
|
||||
<script>
|
||||
(function () {{
|
||||
@ -229,12 +141,12 @@ def _normalise_text(value: str | None, fallback: str) -> str:
|
||||
_OG_DESCRIPTION_MAX_LEN = 300
|
||||
|
||||
|
||||
def _share_title(title: str | None, store_name: str, *, fallback_store: str) -> str:
|
||||
def _share_title(title: str | None, store_name: str) -> str:
|
||||
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
|
||||
stored = _normalise_text(title, "")
|
||||
if stored:
|
||||
return stored
|
||||
name = _normalise_text(store_name, fallback_store)
|
||||
name = _normalise_text(store_name, "ADO2 영상")
|
||||
return f"{name} | ADO2"
|
||||
|
||||
|
||||
@ -304,14 +216,34 @@ def _forwarded_origin(headers: Mapping[str, str]) -> str | None:
|
||||
return f"{forwarded_proto}://{forwarded_host}"
|
||||
|
||||
|
||||
def _origin_from_url(value: str) -> str | None:
|
||||
"""URL에서 scheme + host(+port) origin만 추출합니다."""
|
||||
absolute_url = _absolute_http_url(value)
|
||||
if not absolute_url:
|
||||
return None
|
||||
def _canonical_tags(canonical_url: str | None) -> str:
|
||||
"""공유 URL을 확신할 때만 canonical/og:url을 붙입니다.
|
||||
|
||||
parts = urlsplit(absolute_url)
|
||||
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
잘못된 og:url을 내보내면 크롤러가 그 주소를 다시 읽어, OG 메타가 없는
|
||||
프론트 SPA 문서를 미리보기로 쓴다. 확신이 없으면 크롤러가 실제로 받은
|
||||
URL을 쓰도록 태그 자체를 생략한다.
|
||||
"""
|
||||
if not canonical_url:
|
||||
return ""
|
||||
escaped = escape(canonical_url, quote=True)
|
||||
return (
|
||||
f' <link rel="canonical" href="{escaped}">\n'
|
||||
f' <meta property="og:url" content="{escaped}">\n'
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_OG_IMAGE_SIZE = (385, 385)
|
||||
|
||||
|
||||
def _og_image_size_tags(image_url: str, fallback_image_url: str) -> str:
|
||||
"""폴백 로고처럼 크기를 아는 이미지에만 width/height 메타를 붙입니다."""
|
||||
if image_url != fallback_image_url:
|
||||
return ""
|
||||
width, height = _DEFAULT_OG_IMAGE_SIZE
|
||||
return (
|
||||
f' <meta property="og:image:width" content="{width}">\n'
|
||||
f' <meta property="og:image:height" content="{height}">\n'
|
||||
)
|
||||
|
||||
|
||||
def _normalise_frontend_base_url(value: str) -> str:
|
||||
@ -347,36 +279,6 @@ def _resolve_default_image_url(
|
||||
return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}"
|
||||
|
||||
|
||||
def _canonical_tags(canonical_url: str | None) -> str:
|
||||
"""공유 URL을 확신할 때만 canonical/og:url을 붙입니다.
|
||||
|
||||
잘못된 og:url을 내보내면 크롤러가 그 주소를 다시 읽어, OG 메타가 없는
|
||||
프론트 SPA 문서를 미리보기로 쓴다. 확신이 없으면 크롤러가 실제로 받은
|
||||
URL을 쓰도록 태그 자체를 생략한다.
|
||||
"""
|
||||
if not canonical_url:
|
||||
return ""
|
||||
escaped = escape(canonical_url, quote=True)
|
||||
return (
|
||||
f' <link rel="canonical" href="{escaped}">\n'
|
||||
f' <meta property="og:url" content="{escaped}">\n'
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_OG_IMAGE_SIZE = (385, 385)
|
||||
|
||||
|
||||
def _og_image_size_tags(image_url: str, fallback_image_url: str) -> str:
|
||||
"""폴백 로고처럼 크기를 아는 이미지에만 width/height 메타를 붙입니다."""
|
||||
if image_url != fallback_image_url:
|
||||
return ""
|
||||
width, height = _DEFAULT_OG_IMAGE_SIZE
|
||||
return (
|
||||
f' <meta property="og:image:width" content="{width}">\n'
|
||||
f' <meta property="og:image:height" content="{height}">\n'
|
||||
)
|
||||
|
||||
|
||||
def _api_base_from_share_url(share_url: str) -> str | None:
|
||||
"""공유 URL에서 API 베이스를 만듭니다. ``/api/video/share/1`` → ``https://host/api``."""
|
||||
absolute_url = _absolute_http_url(share_url)
|
||||
@ -385,19 +287,24 @@ def _api_base_from_share_url(share_url: str) -> str | None:
|
||||
|
||||
parts = urlsplit(absolute_url)
|
||||
origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
path = parts.path or ""
|
||||
prefix = ""
|
||||
for marker in ("/video/share/", "/ssul/share/"):
|
||||
idx = path.find(marker)
|
||||
if idx >= 0:
|
||||
prefix = path[:idx].rstrip("/")
|
||||
break
|
||||
else:
|
||||
idx = (parts.path or "").find("/video/share/")
|
||||
if idx < 0:
|
||||
return origin
|
||||
|
||||
prefix = parts.path[:idx].rstrip("/")
|
||||
return f"{origin}{prefix}" if prefix else origin
|
||||
|
||||
|
||||
def _origin_from_url(value: str) -> str | None:
|
||||
"""URL에서 scheme + host(+port) origin만 추출합니다."""
|
||||
absolute_url = _absolute_http_url(value)
|
||||
if not absolute_url:
|
||||
return None
|
||||
|
||||
parts = urlsplit(absolute_url)
|
||||
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
|
||||
|
||||
def _absolute_http_url(value: str | None) -> str | None:
|
||||
"""값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다."""
|
||||
candidate = (value or "").strip()
|
||||
|
||||
@ -1,484 +0,0 @@
|
||||
# -*- 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
|
||||
poster_url: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
hashtags: Optional[list] = None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 상관 서브쿼리 (정렬용)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
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"),
|
||||
Video.poster_url.label("poster_url"),
|
||||
Video.title.label("title"),
|
||||
Video.description.label("description"),
|
||||
Video.hashtags.label("hashtags"),
|
||||
]
|
||||
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"),
|
||||
SsulContent.poster_url.label("poster_url"),
|
||||
SsulContent.title.label("title"),
|
||||
SsulContent.description.label("description"),
|
||||
SsulContent.hashtags.label("hashtags"),
|
||||
]
|
||||
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 "",
|
||||
poster_url=r.poster_url,
|
||||
title=r.title,
|
||||
description=r.description,
|
||||
hashtags=list(r.hashtags) if r.hashtags else None,
|
||||
)
|
||||
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
|
||||
@ -1,7 +1,13 @@
|
||||
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 app.database.session import AsyncSessionLocal
|
||||
from app.home.models import Image, ImageTag
|
||||
from app.home.models import Image, ImageTag, Project
|
||||
|
||||
# from app.lyric.schemas.lyrics_schema import (
|
||||
# AttributeData,
|
||||
@ -831,6 +837,7 @@ logger = get_logger("video")
|
||||
# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
# detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
# )
|
||||
from sqlalchemy.dialects import mysql
|
||||
async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
|
||||
# print("taskid", task_id)
|
||||
async with AsyncSessionLocal() as session:
|
||||
|
||||
@ -11,9 +11,8 @@ import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.credit.services.credit_service import refund_credit_for_job
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO
|
||||
from app.user.services.credit import consume_credit
|
||||
from app.video.models import Video
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from app.utils.logger import get_logger
|
||||
@ -25,57 +24,6 @@ logger = get_logger("video")
|
||||
# HTTP 요청 설정
|
||||
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(
|
||||
task_id: str,
|
||||
@ -261,21 +209,24 @@ async def download_and_upload_video_to_blob(
|
||||
if video_id is not None:
|
||||
await _try_generate_sns_metadata(video_id)
|
||||
|
||||
# 크레딧은 generate_video 에서 이미 선차감했다. 여기서 차감하지 않는다.
|
||||
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
||||
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}")
|
||||
|
||||
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)
|
||||
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:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
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:
|
||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
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:
|
||||
# 임시 파일 삭제
|
||||
@ -379,15 +330,18 @@ async def download_and_upload_video_by_creatomate_render_id(
|
||||
|
||||
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)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패")
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
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)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패")
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
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)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패")
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
|
||||
85
config.py
85
config.py
@ -691,90 +691,6 @@ class SocialUploadSettings(BaseSettings):
|
||||
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()
|
||||
cors_settings = CORSSettings()
|
||||
apikey_settings = APIKeySettings()
|
||||
@ -792,4 +708,3 @@ social_oauth_settings = SocialOAuthSettings()
|
||||
meta_conversion_settings = MetaConversionSettings()
|
||||
internal_settings = InternalSettings()
|
||||
social_upload_settings = SocialUploadSettings()
|
||||
ssulbox_settings = SsulboxSettings()
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
|
||||
-- 실행 순서: migration_2026-07-30_ssulbox.sql 의 §1(ssul_content 생성) 이후.
|
||||
-- 대상: MySQL 8.0.16+ (CHECK 가 실제로 강제되는 버전. 운영은 8.4)
|
||||
|
||||
-- 1. content_id 추가 + FK.
|
||||
-- upload_seq 채번 인덱스 (content_id, social_account_id, upload_seq) 는
|
||||
-- 선행 컬럼이 content_id 라 FK 인덱스 요건도 겸한다(단독 인덱스 불필요 —
|
||||
-- video_reaction 때는 유니크가 user_uuid 선행이라 단독이 필요했던 것과 다르다).
|
||||
ALTER TABLE `social_upload`
|
||||
ADD COLUMN `content_id` BIGINT NULL
|
||||
COMMENT '썰박스 콘텐츠 id (ADO2 업로드면 NULL)' AFTER `video_id`,
|
||||
ADD CONSTRAINT `fk_social_upload_content`
|
||||
FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) ON DELETE CASCADE,
|
||||
ADD INDEX `idx_social_upload_content_seq`
|
||||
(`content_id`, `social_account_id`, `upload_seq`);
|
||||
|
||||
-- 2. video_id 를 nullable 로. 기존 행은 전부 ADO2 업로드라 값이 있어 영향 없다.
|
||||
ALTER TABLE `social_upload`
|
||||
MODIFY COLUMN `video_id` INT NULL COMMENT 'ADO2 영상 id (썰박스 업로드면 NULL)';
|
||||
|
||||
-- 3. "정확히 하나만 채워짐" 강제. 이게 없으면 둘 다 NULL 이거나 둘 다 채워진
|
||||
-- 행이 조용히 생긴다.
|
||||
ALTER TABLE `social_upload`
|
||||
ADD CONSTRAINT `ck_social_upload_one_target`
|
||||
CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL));
|
||||
|
||||
-- 4. 과도기 테이블 제거 (로컬 등 이미 만든 환경만 해당. 운영은 애초에 안 만든다).
|
||||
-- ⚠️ 행이 남아 있으면 social_upload 로 이관한 뒤에 지울 것.
|
||||
DROP TABLE IF EXISTS `ssul_social_upload`;
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
|
||||
-- 실행 순서: 이 파일 먼저 → migration_2026-07-30_social_upload_merge.sql
|
||||
-- (§1 의 ssul_content 를 FK 로 참조하므로 순서를 바꾸면 실패한다)
|
||||
-- 대상: MySQL 8.0.16+ (CHECK 가 실제로 강제되는 버전. 운영은 8.4)
|
||||
-- 참고: MySQL 은 ADD COLUMN IF NOT EXISTS 를 지원하지 않는다. 일부만 적용된 DB 라면
|
||||
-- 이미 적용된 문은 개별적으로 건너뛸 것(각 문이 독립적으로 실행 가능하다).
|
||||
|
||||
-- =============================================================================
|
||||
-- §1. 신규 테이블
|
||||
-- =============================================================================
|
||||
|
||||
-- 썰박스 1편 — 생성 잡과 산출물을 한 행으로 관리 (castad video 와 같은 구조).
|
||||
-- id 가 크레딧 원장 멱등 키(job_type='ssul', job_ref=str(id))의 앵커다.
|
||||
CREATE TABLE IF NOT EXISTS `ssul_content` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '고유 식별자 (크레딧 원장 job_ref 앵커)',
|
||||
`user_uuid` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '생성 요청한 사용자 UUID (탈퇴 시 NULL)',
|
||||
`scenario` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '시나리오 코드 (joseon/samgukji/greek/odyssey)',
|
||||
`input` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '입력값 (네이버 지도 URL 또는 업장명)',
|
||||
`scenes` int NOT NULL COMMENT '생성할 장면 수 (요청 스키마에서 4~20 제한, 기본 9)',
|
||||
`seconds` int NOT NULL COMMENT '장면당 초 길이 (요청 스키마에서 20~90 제한, 기본 30)',
|
||||
`status` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'queued' COMMENT '상태 (queued/running/done/error). 목록에는 done 만 노출',
|
||||
`step` int NOT NULL DEFAULT '0' COMMENT '진행 단계 0~4 (폴링 응답용. 0=준비, 4=영상 합성 완료)',
|
||||
`error` text COLLATE utf8mb4_unicode_ci COMMENT '실패 사유',
|
||||
`video_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '완성 영상 URL (Azure Blob 공개 URL 또는 로컬 서빙 경로)',
|
||||
`poster_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '포스터 URL (없으면 프론트가 시나리오 표지로 대체)',
|
||||
`store_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)',
|
||||
`region` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '지역 (통합 목록의 지역 필터에 사용)',
|
||||
`detail_region_info` text COLLATE utf8mb4_unicode_ci COMMENT '상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용',
|
||||
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '소프트 삭제 여부',
|
||||
`created_at` datetime NOT NULL DEFAULT (now()) COMMENT '생성 요청 일시 (목록 정렬 기준)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ssul_content_status` (`status`),
|
||||
KEY `idx_ssul_content_list` (`is_deleted`,`status`,`created_at`),
|
||||
KEY `idx_ssul_content_user_created` (`user_uuid`,`created_at`),
|
||||
KEY `idx_ssul_content_scen_created` (`scenario`,`created_at`),
|
||||
CONSTRAINT `ssul_content_ibfk_1` FOREIGN KEY (`user_uuid`) REFERENCES `user` (`user_uuid`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- §2. 기존 테이블 확장
|
||||
-- =============================================================================
|
||||
|
||||
-- 2-1. credit_transaction — 크레딧 멱등 키.
|
||||
-- (job_type, job_ref, type) 유니크가 "작업 1건당 consume 1행 / refund 1행"을
|
||||
-- DB 레벨에서 보장한다. 기존 행은 job_type 이 NULL 이라 제약에 걸리지 않는다
|
||||
-- (MySQL 은 유니크에서 NULL 을 서로 다른 값으로 취급).
|
||||
ALTER TABLE `credit_transaction`
|
||||
ADD COLUMN `job_type` VARCHAR(20) NULL COMMENT '차감 유발 작업 종류 (ssul/video)',
|
||||
ADD COLUMN `job_ref` VARCHAR(64) NULL COMMENT '작업 식별자 (ssul_content.id 문자열 또는 video.task_id)',
|
||||
ADD UNIQUE KEY `uq_credit_job` (`job_type`, `job_ref`, `type`);
|
||||
|
||||
-- 2-2. comment — ADO2 영상과 썰박스 댓글을 한 테이블로.
|
||||
-- 대상은 video_id / content_id 중 **정확히 하나**만 채워지며 CHECK 로 강제한다.
|
||||
-- 기존 행은 전부 video_id 가 채워져 있어 CHECK 를 이미 만족한다.
|
||||
ALTER TABLE `comment`
|
||||
ADD COLUMN `content_id` BIGINT NULL COMMENT '썰박스 콘텐츠 id (ADO2 댓글이면 NULL)',
|
||||
ADD CONSTRAINT `fk_comment_content`
|
||||
FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE `comment`
|
||||
ADD INDEX `idx_comment_content_id` (`content_id`);
|
||||
|
||||
ALTER TABLE `comment`
|
||||
MODIFY COLUMN `video_id` INT NULL COMMENT 'ADO2 영상 id (썰박스 댓글이면 NULL)';
|
||||
|
||||
ALTER TABLE `comment`
|
||||
ADD CONSTRAINT `ck_comment_one_target`
|
||||
CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL));
|
||||
|
||||
-- 2-3. video_reaction — 좋아요도 동일하게 병합.
|
||||
-- 1인 1회 보장은 유니크 두 개로 나눈다. 썰박스 행(video_id IS NULL)은
|
||||
-- uq_video_reaction_user_video 에 걸리지 않는다(NULL 은 서로 다른 값 취급).
|
||||
-- content_id 단독 인덱스는 카운트 집계(GROUP BY content_id)용이다 —
|
||||
-- 유니크는 user_uuid 가 선행이라 이 용도로 못 쓴다.
|
||||
ALTER TABLE `video_reaction`
|
||||
ADD COLUMN `content_id` BIGINT NULL COMMENT '썰박스 콘텐츠 id (ADO2 반응이면 NULL)',
|
||||
ADD CONSTRAINT `fk_video_reaction_content`
|
||||
FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE `video_reaction`
|
||||
ADD INDEX `idx_video_reaction_content_id` (`content_id`),
|
||||
ADD UNIQUE KEY `uq_video_reaction_user_content` (`user_uuid`, `content_id`);
|
||||
|
||||
ALTER TABLE `video_reaction`
|
||||
MODIFY COLUMN `video_id` INT NULL COMMENT 'ADO2 영상 id (썰박스 반응이면 NULL)';
|
||||
|
||||
ALTER TABLE `video_reaction`
|
||||
ADD CONSTRAINT `ck_video_reaction_one_target`
|
||||
CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL));
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Migration: ssul_content 에 SNS 메타데이터 컬럼 추가
|
||||
-- Date: 2026-08-19
|
||||
-- Description: video 와 동일하게 제목/설명/해시태그를 저장한다.
|
||||
-- poster_url 은 이미 있으므로 길이만 video 와 맞춘다.
|
||||
-- 관련 코드: app/ssulbox/models.py, app/social/services/seo_service.py
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `ssul_content`
|
||||
MODIFY COLUMN `poster_url` VARCHAR(2048) NULL
|
||||
COMMENT '포스터 URL (SNS 공유 og:image. 없으면 프론트가 시나리오 표지로 대체)';
|
||||
|
||||
ALTER TABLE `ssul_content`
|
||||
ADD COLUMN `title` VARCHAR(100) NULL
|
||||
COMMENT 'SNS 업로드 제목' AFTER `poster_url`,
|
||||
ADD COLUMN `description` TEXT NULL
|
||||
COMMENT 'SNS 업로드 설명' AFTER `title`,
|
||||
ADD COLUMN `hashtags` JSON NULL
|
||||
COMMENT 'SNS 해시태그 목록' AFTER `description`;
|
||||
@ -1,11 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Migration: marketing 테이블에 official_site_url 컬럼 추가
|
||||
-- Date: 2026-08-24
|
||||
-- Description: 업체 공식 링크. 영상 종료 직전 오버레이·응답의 official_site_url 용도.
|
||||
-- 관련 코드: app/home/models.py(MarketingIntel), app/home/api/routers/v1/home.py,
|
||||
-- app/video/api/routers/v1/video.py(_get_official_site_urls)
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `marketing`
|
||||
ADD COLUMN `official_site_url` VARCHAR(2048) NULL
|
||||
COMMENT '업체 공식 링크' AFTER `place_id`;
|
||||
@ -1,21 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Migration: ssul_content.input → official_site_url 로 전환
|
||||
-- Date: 2026-08-24
|
||||
-- Description: 썰박스 상세에도 ADO2 영상과 동일한 '공식 링크 오버레이'를 적용한다.
|
||||
-- `input`(요청 원본: place URL 또는 업장명)은 INSERT 후 어디서도
|
||||
-- 읽지 않는 쓰기 전용 컬럼이었다 — 워커는 인메모리 job dict 를 쓴다.
|
||||
-- 그 자리를 공식 링크(플레이스 홈페이지 우선, 없으면 플레이스 URL)로 돌린다.
|
||||
-- 관련 코드: app/ssulbox/worker/job_manager.py(_collect_place_info),
|
||||
-- app/ssulbox/services/place_service.py(_extract_detail_from_page)
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `ssul_content`
|
||||
CHANGE COLUMN `input` `official_site_url` VARCHAR(2048) NULL
|
||||
COMMENT '업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL; 미확보 시 NULL)';
|
||||
|
||||
-- ⚠️ 반드시 함께 실행할 것.
|
||||
-- CHANGE 는 기존 `input` 값을 새 컬럼으로 그대로 옮긴다. 업장명만 입력해 만든 행은
|
||||
-- URL 이 아닌 문자열("스테이 머뭄" 등)을 갖게 되고, 그대로 두면 상세 화면의 링크
|
||||
-- href 에 그 값이 들어간다. 기존 콘텐츠는 오버레이 대상이 아니므로(신규 생성분부터
|
||||
-- 적용) 전부 비운다.
|
||||
UPDATE `ssul_content` SET `official_site_url` = NULL;
|
||||
@ -2,7 +2,7 @@
|
||||
-- Migration: 크레딧 충전 요청 / 거래 이력 테이블 추가
|
||||
-- Date: 2026-04-29
|
||||
-- Description: 백오피스 크레딧 워크플로우 도입
|
||||
-- 선행 조건: migration_2026-04-29_add_admin_table.sql 먼저 실행
|
||||
-- 선행 조건: migration_2026_04_29_add_admin_table.sql 먼저 실행
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credit_charge_request (
|
||||
@ -1,15 +0,0 @@
|
||||
[캐릭터 공식 레퍼런스 폴더 — '눈만 고정']
|
||||
|
||||
이 폴더에 고양이 왕 기준 이미지를 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.
|
Before Width: | Height: | Size: 164 KiB |
@ -1,394 +0,0 @@
|
||||
대수,왕,문서제목,썰후보여부,키워드,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/순종(대한제국)
|
||||
|
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@ -1,315 +0,0 @@
|
||||
# -*- 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). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
|
||||
→ 그래야 컷마다 눈 표정이 다양해진다.
|
||||
- camera: 그 장면에 어울리는 카메라 움직임을 아래 중 하나로 고른다. 장면의 '감정'에 맞춰라.
|
||||
reveal : 상황 전체가 드러남 (첫 컷 기본)
|
||||
punch_in : 반전·폭로·핵심 한 방 (강하다. 영상당 1~2번만)
|
||||
slow_in : 몰입이 쌓이는 서술, 마지막 CTA
|
||||
slow_out : 상황이 점점 보이는 서술
|
||||
pan_left / pan_right : 이동·전개·시간 흐름
|
||||
tilt_up : 올려다봄 — 권위·거대함·감탄
|
||||
tilt_down : 내려다봄 — 추락·좌절·한심함
|
||||
shake : 충격·당황·사고 (강하다. 영상당 1번이면 충분)
|
||||
hold : 정적인 대사·여운. 강한 컷 앞뒤에 넣으면 대비가 산다
|
||||
★ 같은 값을 3연속으로 쓰지 마라. 강한 효과(punch_in/shake)를 연달아 쓰면 산만해진다.
|
||||
|
||||
[전역 설정]
|
||||
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의. 한국사 인물을 '귀여운 통통한 고양이'로 의인화.
|
||||
복식/색/특징을 못박아 컷마다 동일하게. (예: 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"},
|
||||
# 값은 media.CAMERA_KINDS 와 반드시 일치해야 한다.
|
||||
# 모르는 값이 오면 렌더가 조용히 기본 순환으로 떨어진다.
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reveal", "punch_in", "slow_in", "slow_out",
|
||||
"pan_left", "pan_right", "tilt_up", "tilt_down",
|
||||
"shake", "hold",
|
||||
],
|
||||
},
|
||||
},
|
||||
"required": ["narration", "caption", "image_prompt", "camera"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["character_sheet", "style_guide", "scenes"],
|
||||
}
|
||||
|
||||
|
||||
def clamp_scenes(scene_list, target):
|
||||
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
|
||||
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
|
||||
def clen(s):
|
||||
return len(s.get("narration", "").strip())
|
||||
|
||||
while len(scene_list) > target:
|
||||
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
|
||||
i = min(range(len(scene_list) - 1),
|
||||
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
|
||||
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
|
||||
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
|
||||
base["narration"] = (a + " " + b).strip()
|
||||
scene_list[i:i + 2] = [base]
|
||||
return scene_list
|
||||
|
||||
|
||||
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
|
||||
"""나레이션 → 스토리보드 dict (Gemini)."""
|
||||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=SB_USER.format(product=product, king=king,
|
||||
scenes=scenes, narration=narration),
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=SB_SYSTEM,
|
||||
temperature=0.8,
|
||||
response_mime_type="application/json",
|
||||
response_schema=SB_SCHEMA,
|
||||
),
|
||||
)
|
||||
sb = json.loads(resp.text)
|
||||
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
|
||||
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
|
||||
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
|
||||
raw = sb.get("scenes", [])
|
||||
kept = [s for s in raw if s.get("narration", "").strip()]
|
||||
if len(kept) < len(raw):
|
||||
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
|
||||
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
|
||||
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
|
||||
sc["narration"] = _no_emoji(sc.get("narration", ""))
|
||||
sc["caption"] = _no_emoji(sc.get("caption", ""))
|
||||
sb["_product"] = product
|
||||
sb["_king"] = king
|
||||
return sb
|
||||
@ -1,282 +0,0 @@
|
||||
# -*- 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:
|
||||
# 리뷰 수집 실패로 생성 전체를 죽이지 않는다 — 가게명만으로라도 계속 간다.
|
||||
print(f"[!] 리뷰 수집 실패(수집된 정보만으로 계속): {type(e).__name__}: {e}")
|
||||
rstore, reviews = None, []
|
||||
if rstore and not brief_store:
|
||||
brief_store = rstore
|
||||
bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||
if reviews else [])
|
||||
if bullets:
|
||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||
elif brief_store:
|
||||
# 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고
|
||||
# 그때까지 수집된 정보(가게명)만으로 생성을 계속한다.
|
||||
print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행")
|
||||
bullets = [brief_store]
|
||||
# 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다.
|
||||
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()
|
||||
@ -1,949 +0,0 @@
|
||||
# -*- 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 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
|
||||
# 카메라 순환 시작점 — 콘텐츠마다 다르되 같은 콘텐츠면 항상 같다.
|
||||
# (media.motion_seed 주석 참조)
|
||||
cam_seed = MU.motion_seed("".join(str(s.get("narration", "")) for s in scenes))
|
||||
|
||||
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 = MU.scene_motion(sc, idx, len(scenes), frames, cam_seed)
|
||||
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
|
||||
@ -1,270 +0,0 @@
|
||||
# -*- 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())
|
||||
@ -1,13 +0,0 @@
|
||||
[캐릭터 공식 레퍼런스 폴더 — greekroman 은 기본 '비움']
|
||||
|
||||
greekroman(그리스 로마 신화) 버전은 고양이 의인화가 아니라 '실제 신의 모습'
|
||||
(근육질 남신 / 아름답고 늘씬한 여신, 한국 신화만화풍)으로 그리므로,
|
||||
조선/삼국지에서 쓰던 고양이 눈 레퍼런스 이미지를 넣지 않습니다.
|
||||
|
||||
- 폴더가 비어 있으면: 각 영상의 '첫 컷'이 자동 앵커가 되어
|
||||
나머지 컷들이 그 캐릭터 디자인/화풍을 그대로 따라갑니다(영상 내 일관성 유지).
|
||||
- 특정 그림체를 전 영상에 걸쳐 고정하고 싶으면 기준 이미지를 1장 넣으세요
|
||||
(폴더 안 '첫 번째' 이미지 파일 사용. png/jpg/jpeg/webp).
|
||||
단 이 경우 '눈만 고정' 모드라 눈/표정만 통일되고 복장은 신별로 다르게 나옵니다.
|
||||
|
||||
연결 코드: render.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||
@ -1,466 +0,0 @@
|
||||
번호,인물,문서제목,썰후보여부,키워드,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,913 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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.
|
Before Width: | Height: | Size: 15 KiB |
@ -1,329 +0,0 @@
|
||||
# -*- 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). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
|
||||
→ 그래야 컷마다 눈 표정이 다양해진다.
|
||||
- camera: 그 장면에 어울리는 카메라 움직임을 아래 중 하나로 고른다. 장면의 '감정'에 맞춰라.
|
||||
reveal : 상황 전체가 드러남 (첫 컷 기본)
|
||||
punch_in : 반전·폭로·핵심 한 방 (강하다. 영상당 1~2번만)
|
||||
slow_in : 몰입이 쌓이는 서술, 마지막 CTA
|
||||
slow_out : 상황이 점점 보이는 서술
|
||||
pan_left / pan_right : 이동·전개·시간 흐름
|
||||
tilt_up : 올려다봄 — 권위·거대함·감탄
|
||||
tilt_down : 내려다봄 — 추락·좌절·한심함
|
||||
shake : 충격·당황·사고 (강하다. 영상당 1번이면 충분)
|
||||
hold : 정적인 대사·여운. 강한 컷 앞뒤에 넣으면 대비가 산다
|
||||
★ 같은 값을 3연속으로 쓰지 마라. 강한 효과(punch_in/shake)를 연달아 쓰면 산만해진다.
|
||||
|
||||
[전역 설정]
|
||||
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
|
||||
★ 한국의 '그리스 로마 신화 학습만화'처럼, 신을 '진짜 신의 모습'으로 그린다 — 의인화 동물 금지.
|
||||
· 남신/남자 영웅: 조각상 같은 근육질의 멋진 체격(broad shoulders, heroic muscular greek-statue
|
||||
physique), 잘생긴 얼굴. · 여신: 아름답고 늘씬한 몸매(beautiful, slender, graceful), 긴 머리.
|
||||
· 반드시 '성인 8등신 비율'로: tall adult heroic proportions, 8-heads-tall elegant body,
|
||||
NOT chibi, NOT super-deformed, NOT cute mascot — 치비/SD/애기얼굴 금지.
|
||||
→ 단 과한 노출·선정적 묘사는 금지. 토가/키톤/갑옷 등 신화 복식은 제대로 입힌다.
|
||||
복식/색/머리/상징물(번개·투구·부엉이·삼지창·월계관 등 그 신의 아이콘)을 못박아 컷마다 동일하게.
|
||||
(예: 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"},
|
||||
# 값은 media.CAMERA_KINDS 와 반드시 일치해야 한다.
|
||||
# 모르는 값이 오면 렌더가 조용히 기본 순환으로 떨어진다.
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reveal", "punch_in", "slow_in", "slow_out",
|
||||
"pan_left", "pan_right", "tilt_up", "tilt_down",
|
||||
"shake", "hold",
|
||||
],
|
||||
},
|
||||
},
|
||||
"required": ["narration", "caption", "image_prompt", "camera"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["character_sheet", "style_guide", "scenes"],
|
||||
}
|
||||
|
||||
|
||||
def clamp_scenes(scene_list, target):
|
||||
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
|
||||
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
|
||||
def clen(s):
|
||||
return len(s.get("narration", "").strip())
|
||||
|
||||
while len(scene_list) > target:
|
||||
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
|
||||
i = min(range(len(scene_list) - 1),
|
||||
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
|
||||
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
|
||||
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
|
||||
base["narration"] = (a + " " + b).strip()
|
||||
scene_list[i:i + 2] = [base]
|
||||
return scene_list
|
||||
|
||||
|
||||
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
|
||||
"""나레이션 → 스토리보드 dict (Gemini)."""
|
||||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=SB_USER.format(product=product, king=king,
|
||||
scenes=scenes, narration=narration),
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=SB_SYSTEM,
|
||||
temperature=0.8,
|
||||
response_mime_type="application/json",
|
||||
response_schema=SB_SCHEMA,
|
||||
),
|
||||
)
|
||||
sb = json.loads(resp.text)
|
||||
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
|
||||
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
|
||||
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
|
||||
raw = sb.get("scenes", [])
|
||||
kept = [s for s in raw if s.get("narration", "").strip()]
|
||||
if len(kept) < len(raw):
|
||||
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
|
||||
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
|
||||
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
|
||||
sc["narration"] = _no_emoji(sc.get("narration", ""))
|
||||
sc["caption"] = _no_emoji(sc.get("caption", ""))
|
||||
sb["_product"] = product
|
||||
sb["_king"] = king
|
||||
return sb
|
||||
@ -1,282 +0,0 @@
|
||||
# -*- 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:
|
||||
# 리뷰 수집 실패로 생성 전체를 죽이지 않는다 — 가게명만으로라도 계속 간다.
|
||||
print(f"[!] 리뷰 수집 실패(수집된 정보만으로 계속): {type(e).__name__}: {e}")
|
||||
rstore, reviews = None, []
|
||||
if rstore and not brief_store:
|
||||
brief_store = rstore
|
||||
bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||
if reviews else [])
|
||||
if bullets:
|
||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||
elif brief_store:
|
||||
# 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고
|
||||
# 그때까지 수집된 정보(가게명)만으로 생성을 계속한다.
|
||||
print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행")
|
||||
bullets = [brief_store]
|
||||
# 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다.
|
||||
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()
|
||||
@ -1,959 +0,0 @@
|
||||
# -*- 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.1-flash-image" # 인체·화풍 품질과 비용 절충. 최상위: gemini-3-pro-image, 더 저렴하게: 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 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 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
|
||||
# 카메라 순환 시작점 — 콘텐츠마다 다르되 같은 콘텐츠면 항상 같다.
|
||||
# (media.motion_seed 주석 참조)
|
||||
cam_seed = MU.motion_seed("".join(str(s.get("narration", "")) for s in scenes))
|
||||
|
||||
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 = MU.scene_motion(sc, idx, len(scenes), frames, cam_seed)
|
||||
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
|
||||
@ -1,287 +0,0 @@
|
||||
# -*- 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())
|
||||
@ -1,13 +0,0 @@
|
||||
[캐릭터 공식 레퍼런스 폴더 — odyssey 는 기본 '비움']
|
||||
|
||||
odyssey(오디세이) 버전은 고양이 의인화가 아니라 '실제 사람'
|
||||
(근육질 영웅·신 / 아름답고 늘씬한 여신, 한국 신화만화풍)으로 그리므로,
|
||||
조선/삼국지에서 쓰던 고양이 눈 레퍼런스 이미지를 넣지 않습니다.
|
||||
|
||||
- 폴더가 비어 있으면: 각 영상의 '첫 컷'이 자동 앵커가 되어
|
||||
나머지 컷들이 그 캐릭터 디자인/화풍을 그대로 따라갑니다(영상 내 일관성 유지).
|
||||
- 특정 그림체를 전 영상에 걸쳐 고정하고 싶으면 기준 이미지를 1장 넣으세요
|
||||
(폴더 안 '첫 번째' 이미지 파일 사용. png/jpg/jpeg/webp).
|
||||
단 이 경우 '눈만 고정' 모드라 눈/표정만 통일되고 복장은 신별로 다르게 나옵니다.
|
||||
|
||||
연결 코드: render.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||
@ -1,91 +0,0 @@
|
||||
번호,인물,문서제목,썰후보여부,키워드,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,19 +0,0 @@
|
||||
번호,인물,문서제목,썰후보여부,키워드,URL
|
||||
1,오디세우스,오디세우스,Y,아가멤논이 아들을 데려갈 거라는 협박 때문에 참전하게 된 영화판 명분,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
|
||||
2,폴리페모스,폴리페모스,Y,말도 걸지 않고 잠든 사이에 눈을 찔러버리는 영화판 각색,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
|
||||
3,라이스트리고네스,라이스트리고네스,Y,갑옷을 두른 성인 거인족 병사들로 다시 그려진 영화판 설정,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
|
||||
4,오디세우스,오디세우스,Y,출발할 땐 12척이었던 함대가 3척으로 줄어든 영화판 설정,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,트로이 문명을 무너뜨리고 무고한 시민들을 죽게 만든 죄책감에 시달리는 영화판 오디세우스,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/오디세이(영화)/줄거리 및 탐구
|
||||
|
@ -1,125 +0,0 @@
|
||||
1. 발단
|
||||
영화는 In a time of Apparent Magic이라는 문구와 함께 시작된다. 자막에서는 '마법이 존재하던 시대'라고 간단하게 번역되었지만, Apparent가 '분명한'이라는 뜻과 '~처럼 보이는'이라는 뜻을 둘 다 가지고 있다는 것을 생각하면 "마법이 분명하게 존재하던 시대에" 나 "마법이 존재하던 것처럼 보이던 시대에" 등 여러 방향으로 해석이 가능한 부분이다.
|
||||
|
||||
이후 이타카 궁의 연회장에서 음유시인(트래비스 스캇)이 지팡이로 바닥을 치며 시를 읊고 흥을 돋구는 장면으로 첫 시퀀스가 진행된다. 화면이 전환되고 나무로 된 거대한 말 앞에서 이타카 병사 시논(엘리엇 페이지)이 트로이 병사가 던진 창에 맞으며 제압된다. 그리고는 흰 갑옷을 입은[1] 트로이 병사가 시논의 목에 칼을 겨누고 이 목마가 무엇이냐 물어보고, 이것은 아테나에게 바치는 제물이라 답하자 한 병사가 무시하며 그냥 죽이려고 했지만 다른 트로이 병사가 제지하며 아테나는 양국 모두 섬기는 신이니 가져가 제물로 삼겠다며 목마를 자신들의 성으로 가져가게 한다.
|
||||
|
||||
음유시인은 이타카의 왕 오디세우스의 트로이 전쟁 영웅담에 대해 노래하나 중문 너머에서 이를 듣던 페넬로페는 그 노래는 듣기 싫다며 중단시킨다. 한편 안티노오스(로버트 패틴슨)을 비롯한 구혼자들은 트로이 전쟁이 끝났다는 소식 후 8년이 지났음에도 아직 어디에 있는지 알려지지 않은 오디세우스 왕의 왕비 페넬로페(앤 해서웨이)와 결혼하여 권력과 부를 얻을 생각을 하고 있었다.
|
||||
|
||||
현 시점에서 조금 전, 오디세우스의 아들 텔레마코스(톰 홀랜드)는 오디세우스의 심복이었으나 눈이 멀어 현재는 돼지를 치고 있는 에우마이오스, 그리고 부관 멘토르에게 검술을 지도받던 도중 오디세우스의 사냥개 '아르고스' [2]에 대한 이야기를 듣는다. 아르고스는 오디세우스가 이타카를 떠나기 전 사냥개로 키우던 개[3]였고 무려 20년 동안[4] 오디세우스를 기다리던 충직한 사냥개였다.[5]
|
||||
|
||||
오디세우스는 사냥을 나가면 활을 쏘기 전 반드시 활 시위를 한번 튕겨 소리를 내 사냥감에게 알리는 버릇이 있었는데, 왜 굳이 사냥감에게 그걸 알려주냐는 부하들의 물음에 공정한 싸움이어야 한다는 생각 때문에 경고를 주는 것이라고 이야기한다. 당시 사냥에 함께 나가던 에우마이오스는 공정한 것이 좋지만 그런 정직함이 언젠간 발목을 잡을 것이라고 경고하고 그 말처럼 사냥감이던 멧돼지가 화살 한 발로 죽지 않고 오디세우스에게 달려드는 바람에 오른발목 피부가 찢어지는 상처를 입는다.[6]
|
||||
|
||||
그 후 텔레마코스는 어머니 페넬로페와도 대화를 나눈다. 후계가 있어야 한다는 원로들의 압박에 대한 얘기에 텔레마코스는 자신이 왕위를 이으면 된다고 하지만 페넬로페는 넌 아직 어리다며, 오디세우스가 남기고 간 활을 주고 시위를 걸어보라 한다. 텔레마코스는 시도해보지만 실패하고 활의 시위를 거는 것은 오직 오디세우스만 가능하기 때문에 불공평하지 않냐고 묻는다. 페넬로페는 계속 왕위를 텔레마코스에게 물려주는 걸 거절하자 텔레마코스는 왕좌가 탐나는거냐며 비난한다. 물론 페넬로페는 왕좌를 탐낸 게 아니고 오직 오디세우스만 기다린 것이었지만, 출정 이후 20년이란 세월이 흐르는 동안 오디세우스는 감감무소식인데다 원로에 구혼자들까지 압박이 들어오자 하나뿐인 아들과도 점차 갈등이 잦아지기 시작한 것.
|
||||
|
||||
한편 오디세우스가 이타카를 떠나기 전의 회상이 나온다. 오디세우스는 연회에서 활에 시위를 걸고 특유의 사냥 전 사냥감에게 미리 경고를 하는 활 시위를 당겨 소리를 내는 퍼포먼스를 보여준 후 12개의 교차로 세워진 도끼 사이로[7] 화살을 날려 보내는 묘기를 보여주는 걸 즐겼다.[8] 밤이 되고 잠자리에 들기 전 페넬로페는 왜 전쟁을 하는거냐고 묻는데 오디세우스는 트로이 전쟁에서 헬레네의 납치는 명목상의 이유일뿐, 사실은 아가멤논이 노리는 것은 트로이의 무역항로이며, 또한 자신이 참전하지 않는다면 아들을 데려갈 것이고 아가멤논은 심지어 전쟁에서 승리하기 위해 자신의 딸[9]조차 제물[10]을 삼을 정도로 피도 눈물도 없이 승리만 생각하는 인물이라고 말한다. 이에 페넬로페가 전쟁에 참여하지 않고 서쪽 멀리 도망치면 안 되냐고 하자 그러면 아가멤논의 성격상 이타카를 상대로 군사를 일으킬 것이므로 그럴 수는 없다고 얘기하며 만약 자신이 돌아오지 않는다면 이타카를 위해 다른 사람과 결혼해 달라는 부탁을 한다. 페넬로페는 이를 눈물 흘리며 거절하고 반드시 돌아와 달라는 부탁과 함께 아테나 여신을 본뜬 덩굴 장식의 금 브로치를 건네주며 누구나 알아볼 수 있게 항상 차고 다녀 달라고 한다. 또한 오디세우스는 함께 트로이 전쟁에 출전할 군대를 추첨으로 뽑게 했는데, 작중 초반 죽은 시논은 사실 군대 추첨에서 면제를 뽑아 군 면제 대상이었으나 징집 표를 뽑은 안티노오스가 시논의 면제 표를 뺏고 자신의 징집 표를 바꿔치기 했고 풍족하지 못한 시논의 가족을 지원해 주는 대신 군대에 가달라는 부탁을 하며 추첨 표적을 바꿔치려는 것을 오디세우스의 묵인 아래[11][12] 시논은 트로이의 전장으로 향하게 된다.
|
||||
|
||||
그날 밤 여느 때처럼 구혼자들 때문에 궁정은 연회를 열고 있었다. 나이가 든 사냥개 아르고스가 구혼자들이 모여 있는 식탁 옆에 누워있는 것을 구혼자들은 발로 툭툭 건드리기 시작했고 이를 본 에우마이오스는 제발 개를 그냥 두라며 간청한다, 그러자 안하무인이던 구혼자들과 안티노오스는 에우마이오스를 괴롭히기 시작한다.[13] 이를 두고 볼 수 없던 텔레마코스가 개입하나 안티노오스의 친우인 폴리부스에게 제압당하고 역으로 그를 정당하게 공격할 구실을 제공하고 만다. 이때 페넬로페가 나서 자신의 아들을 그냥 두라며 호소하고 그녀의 눈치를 살핀 안티노오스의 중재로 폴리부스와 구혼자들은 물러선다. 이후 자존심이 훼손당하고 인내심이 한계에 달한 텔레마코스는 일전에 한 걸인으로부터 스파르타에 가면 오디세우스에 대한 정보를 얻을 수 있을 거라는 조언을 듣고 메넬라오스(존 번탈)가 있는 스파르타로 향할 계획을 세운다. 다만 이타카를 떠나버리면 가는 길에 수많은 구혼자들이 아무 명분 없이 텔레마코스를 제거하기 좋기에 비밀리에 준비를 치른 후 몰래 출발한다.
|
||||
|
||||
그 즈음 어떤 섬에서 깨어난 오디세우스(맷 데이먼)는 자신에게 돌아가야 할 집이 있음을 기억해내고 같이 생활하던 칼립소와 이야기하며 과거를 회상한다.
|
||||
|
||||
2.2 트로이 전쟁에서 (텔레마코스 시점)
|
||||
텔레마코스는 자신의 아버지 오디세우스의 행방을 찾아 트로이에서 돌아온 스파르타의 왕 메넬라오스를 찾아간다. 사전에 아무 연락 없이 찾아간 것이기에 어떻게 만날까 막막했으나 의외로 마치 올 것을 알고 있었다는 듯 손님을 직접 보고 싶다며 불려가게 된다. 마침 메넬라오스의 딸의 결혼 축하 연회라[14] 잘 왔다고 반갑게 맞이해준 메넬라오스는 텔레마코스를 자신의 옆자리에 앉히고 이야기를 들려주기 시작한다.
|
||||
|
||||
메넬라오스는 트로이 전쟁은 트로이가 자신의 부인 헬레네를 납치해서 일어난 전쟁이라고 얘기하나 헬레네는 이에 당신의 형의 탐욕 때문이라고 꼬집으며 반박한다. 헬레네의 얼굴 반쪽은 흉터로 가득하고, [15][16] 트로이 전쟁에 참여했던 대다수의 인물이 귀향하는데 어려움을 겪었고 자신도 겨우 스파르타로 돌아올 수 있었다고 한다.
|
||||
|
||||
메넬라오스는 아이아스, 파트로클로스, 아킬레우스 같은 영웅도 있었지만[17] 자신이 가장 다시 보고 싶은 인물은 지략이 가장 뛰어났던 오디세우스라며 텔레마코스에게 트로이 목마에 대해 아주 자세히 알려준다. 오디세우스의 아이디어로 고안한 트로이 목마는 아테나에게 바치는 공물로 속여 트로이 목마 안에 정예 부대가 숨어있다가 상황이 정리 된 후 정문을 열어 아카이아 연합군을 돌격시키는 작전이었다. 메넬라오스도 트로이 목마 안에 있었으며 목마가 바다에 반쯤 잠겨 있어서 밑 부분에 있던 일부 병사는 익사하고[18] 그리스 놈들의 공물은 불태우라며 분노하는 트로이 시민들 때문에 겁을 먹은 일부 병사가 탈출을 하려고 하는 등 여러 소란이 있었으나[19] 작전은 성공해 성문을 열고 트로이의 성 안에 연합군을 돌격시키는데 성공한다. 이 이야기를 들은 텔레마코스는 시인들이 말하는 영웅담이 아닌 참전 장수에게 처음으로 직접 아버지의 활약상을 듣고는 눈물을 흘린다.
|
||||
|
||||
한편 옆자리에 앉은 헬레네는 메넬라오스에게 당신의 형이 귀향한 후 자신의 쌍둥이 자매인 클리타임네스트라에게 어떤 꼴을 당했는지도 말하라고 재촉하지만 메넬라오스는 지금 텔레마코스가 알고 싶은 것은 이게 아닐 거라며 대답을 회피한다.
|
||||
|
||||
이후 함께 사냥을 하던 중 아가멤논에 대해 이야기를 해준다. 아가멤논은 친딸 이피게네이아를 전쟁을 위한 제물로 바쳐 부인인 클리타임네스트라의 원한을 샀고, 이후 클리타임네스트라는 아가멤논이 자리를 비운 사이 불륜 상대인 아이기스토스와 공모해 아가멤논을 살해했다고 한다. 이후 아가멤논과 클리타임네스트라의 아들인 오레스테스에게 클리타임네스트라와 아이기스토스는 살해당했고 이를 들은 텔레마코스는 자신의 어머니를 죽인 거냐며 놀란다.
|
||||
|
||||
메넬라오스는 오레스테스는 그저 아버지의 복수를 한 거라며 이야기하곤 이타카의 왕좌가 너무 오래 비어 있었고 제우스의 율법을 모르는 바다 민족의 위험이 다가오고 있으니 그리스 전역의 군대를 이끌 왕이 필요할 거라 조언한다.
|
||||
|
||||
한편 이타카. 베틀에 앉아 있는 페넬로페에게 안티노오스는 당신이 시아버지의 수의 완성을 핑계 삼아 재혼을 미루고 있지만, 사실은 매일 밤 다시 풀고 있지 않냐고 추궁한다. 안티노오스는 오디세우스가 자신에게 본인이 돌아오지 않는다면 이타카를 보살펴 달라 부탁했다며 페넬로페를 유혹하나 텔레마코스에 대해 "그가 다음 왕이다. 만약 우리가 아이를 갖지 않는 한..."이란 실언을 하는 바람에 차가운 냉대를 받고 만다.[20][21] 또한 안티노오스는 시녀 중 한 명인 멜란토(미아 고스)와 내통 관계였는데 수의를 풀고 있던 것 역시 멜란토로부터 들은 것이었다. 하지만 성 안에 몰래 숨어있었던 에우마이오스가 이 둘의 내통 관계를 눈치채게 된다. 멜란토에게 텔레마코스가 사실은 스파르타로 떠난 것을 듣게 되며 친우 폴리부스와 그를 죽일 계획을 세우게 된다.[22]
|
||||
|
||||
이후 메넬라오스는 텔레마코스에게 방문 기념 선물을 주고 인사를 끝으로 퇴장한다. 헬레네는 텔레마코스가 떠나기 전 자신의 이름으로 일어난 전쟁에 대해 사과하고 어머니에게도 사과를 전해달라고 한다.[23]
|
||||
|
||||
2.2. 트로이 전쟁 이후 (오디세우스와 칼립소 시점)
|
||||
기억을 잃은 오디세우스는 여신 칼립소의 도움으로 기억을 되짚으며 자신에게 무슨 일이 있었는지 기억을 돌아보기 시작한다.
|
||||
|
||||
트로이 전쟁에서 목마 안에 타있던 그리스군은 시논의 희생을 통해 무사히 궁 안에 진입하여 대승을 거둔다. 전쟁에서 승리한 그리스군은 각각 고향으로 돌아가기 시작했다. 오디세우스는 잘 알려진 항로로 가는 다른 그리스군을 보며 앞서 가고 있는 아가멤논의 배와 달리 우리는 다른 항로로 가자고 제안한다. 이 항로가 오히려 빠를 것이며 가는 길에 다른 섬들도 둘러 보고 가자고 덧붙였다. 이는 아마도 아가멤논에게 완전히 질려버려 더는 그의 뒤를 따르고 싶지 않았기 때문으로 보인다. 오디세우스는 섬 마을에 들러 구걸하기도 하고, 부탁하기도 하고, 약탈을 하기도 하며[24][25][26] 고향으로 가는 여정을 시작한다.
|
||||
|
||||
전쟁 이후 배를 타고 식량을 얻기 위해 한 섬에 도착한 오디세우스의 부관 한 명이 양 한 마리가 지나가는 것을 발견하고 바로 죽여서 식량으로 쓰려고 하지만 양은 무리지어 다닌다는 오디세우스의 만류로 양을 바로 죽이지 않고 양을 쫒아가기로 하고 양을 쫓아 이동하다가 한 동굴 안으로 들어가게 된다. 동굴 안에는 상태가 괜찮은 식량[27]이 많이 있어서 허락을 구하고[28] 먹으려던 찰나 양들이 우르르 동굴 안으로 몰려들어오는데, 수상함을 느낄 새도 없이 거대한 외눈박이 거인이 마지막으로 들어와 돌문으로 동굴 입구를 닫아 암흑 천지를 만들더니, 이내 어둠 속에서 튀어나오며 병사들을 잡아먹는다.[29] 병사를 잡아먹자마자 아무렇지도 않게 태연히 바로 누우며 눈을 감고 잠을 자는데 기회라 생각하고, 폴리페무스를 죽이기 위해 병사들이 칼을 뽑고 다가가지만 생각해보니 여기서 거인을 죽이면 동굴을 막고 있는 커다란 돌을 치우지 못해 영원히 갇힌다는 것을 깨닫게 되어 포기한다. 결국 며칠 기회를 보다가[30] 어느날 밤 이들이 폴리페무스 몰래 돌문에 끼워서 고정해 둘까 의논했던 거대 나무창을 모닥불에 달군 뒤, 잠든 폴리페무스를 공격해[31] 눈을 뜨게 한 후 곧장 찔러넣어서 실명시킨다.[32] 이 때 처음으로 말을 하는데, 눈이 찔린 고통으로 비명을 지르며[33] 자신의 아버지인 포세이돈에게 저주를 내려달라고 한다.[34] 앞이 보이지 않게 된 폴리페무스가 다음날 아침 동굴 밖으로 양들만 내보내려고 동굴의 입구에 앉아 손으로 양의 등을 만져 오디세우스 일행이 빠져나가는 것을 막으려 하자 등에 지푸라기를 얹어 양털과 비슷한 촉감을 내어 거인을 속이고 빠져나간다.
|
||||
|
||||
부하들이 완전히 빠져나가고 마지막으로 오디세우스도 얌전히 탈출하였는데 그 순간 무언가 왠지 석연찮음을 느끼고 동굴 밖에 얼굴을 내민 폴리페무스가 잡아먹혔던 부하의 투구 잔해를 퉤 하고 뱉어낸다.[35] 그것을 보고 희생당한 부하들이 생각난 오디세우스는 순간 분노하여 활을 겨눈다. 하나밖에 없는 눈을 공격당해 시력을 잃은 거인으로부터 조용히 탈출하기 위한 직전이었기에 에우릴로코스(히마시 파텔)는 쏘지 말라고 만류하지만 오디세우스는 기어코 활 시위를 놓으며 거인의 먼 눈을 재차 맞추어 응징하고, 이 공격으로 인해 자신들이 동굴 밖으로 빠져나왔다는 확신을 줘버린 데다가 도리어 폴리페무스를 크게 자극해버린 탓에 졸지에 고통에 광분하여 언덕을 뛰어내려오는 폴리페무스에게서 빠르게 도망쳐야 하는 상황이 되어버린다. 결국 이 과정에서 늦게 도착해 미처 배를 타지 못한 병사 둘이 잡아먹혀버리고, 도망치는 데 성공한 오디세우스와 병사들은 배에 타서 다른 섬으로 이동한다.[36]
|
||||
|
||||
다른 섬에 도착하자마자 쪼그려 앉은 한 사람이 보여 병사들이 음식을 받을 수 있냐고 물어보는데, 그 아이는 거인족 어린아이였다. 거인족은 처음에는 오디세우스 일행을 보며 그냥 웃기만 하지만[37] 에우릴로코스가 뭐가 웃기냐며 위협을 하자 거인족 아이는 갑자기 정색을 하고는 큰 소리를 지르고 도망가며 갑옷을 두른 성인 거인족 병사들을 불러 모은다.[38] 이들은 오디세우스 일행의 2-3 배쯤 되는 거인족인데 일반 병사들은 그냥 툭 쳐도 저 멀리 날려 보낼 정도의 무시무시한 괴력을 가졌다. 그냥 거인도 싸우기 버거운데 청동 갑주를 능가하는 풀 플레이트 아머를 두른 거인족을 당연히 이길 수는 없었고 심지어 어떤 마법인지 함정인지 오디세우스의 병사들이 무아지경으로 도망치자 움직이는 나무로 퇴로를 차단하는 덫에 갇혀 거인들의 손에 죽게 되는 모습도 나온다. 물론 오디세우스 및 일부 군인들은 항전해보려고 했으나 결과적으로 체급 차이는 중과부적이었다.[39] 결국 남은 병사들은 나무가 없는 해안가까지 간신히 도착해 배가 있는 바다로 도망치나 속도가 만만치 않았던 거인들은 3대의 배 중 2대를 양쪽에서 괴롭히듯 좌우로 흔들어대거나 무기로 작살내가며 기어이 침몰시키고 생존자들을 철저하게 학살한다. 이때 오디세우스는 병사들을 지키고자 활을 쏘아 일부 라이스트뤼고네스들을 죽이는 데 성공하기는 하지만 결국엔 겨우 후퇴한다. 결국 오디세우스가 탔던 배 1대분의 병사들만 남은 채 간신히 섬을 탈출한다.
|
||||
|
||||
겨우 목숨을 부지한 병사들은 또 다른 섬으로 이동하는 것으로 예상했지만, 오디세우스는 이제 식량이 다 떨어졌기 때문에 더 이상 멀리 갈 수 없으니 해안선을 따라 돌아가야 한다고 명령한다. 결국 명령대로 해안선을 따라가다 보니 해안선 위에 연기가 나는 곳을 발견하고, 그곳에 식량이 있을 곳이라는 생각에 이 섬에 정박하기로 한다. 오디세우스는 병사들에게 한 명은 남아서 배를 지키고 나머지는 자신을 따라 집으로 가자고 제안하지만, 에우릴로코스는 한 명만 남는 것은 동의하나 그것은 오디세우스여야 하며 이것은 본인 혼자만의 생각이 아니라 병사들 모두 만장일치로 동의한 의견이라 말한다.[40] 이는 폴리페무스의 저주를 들은 병사들이 두 번에 걸쳐 괴물을 만난 상태였기에 오디세우스가 포세이돈의 저주에 걸렸다고 생각했기 때문이었다. 오디세우스는 반란이라도 일으키는 거냐며 황당해하고, 부하들은 '그것은 당신에게 달렸다'고 답하며 긴장감을 형성하지만, 이내 오디세우스는 활을 달라고 하며, 능청스럽게 그렇다면 내가 책임지고 먹을 식량이라도 마련해 놓겠다며 다녀오라고 이야기하여 험악한 분위기가 그나마 약간 누그러진다.[41] 집으로 올라가는 길에 사자, 표범, 호랑이 등 맹수들이 한 마리씩 있었는데[42] 사람을 잡아먹어도 이상하지 않을 맹수들은 그저 멀뚱히 애처로운 눈빛을 던지며 쳐다보기만 한다. 한편 혼자 떨어져 있다가 중도 합류할 생각이던 오디세우스는 노루 1마리를 활로 쏴 사냥하는데[43], 사냥을 무사히 마치고 노루 사체가 떨어진 구덩이에 가보니 노루는 사라지고 화살에 맞아 죽은 사람이 있었다. 사실 그 섬은 사람을 동물로 바꾸는 주술을 쓰는 마녀 키르케가 사는 섬이였던 것이다. 그렇게 집을 찾아간 병사들에게 키르케는 식사를 차려주는데, 아니나 다를까 밥을 맛있게 먹던 병사들은 그녀의 주술로 인해 돼지로 변해 버렸다.[44] 이윽고 부하들을 찾아 나선 오디세우스도 키르케의 집에 들어와 주술에 걸리는 음식을 먹을 뻔하지만,[45] 부하가 남긴 반지가 음식 그릇에 있는 것을 발견하며 그녀의 술수를 눈치챈다. 오디세우스는 새로 변한 키르케의 누이를 인질 삼아 그녀와 대치하고, 강간하고 약탈하러 온 탐욕스러운 놈들의 본모습을 드러냈을 뿐이라고 비난하는 키르케에게[46] 자신의 병사들은 그저 집에 가고 싶은 것뿐이라며 주술을 해제해 달라 한다. 오디세우스의 말처럼 병사들은 키르케에게 손 하나 대지 않았고, 무기는 집 밖에 내려놓아 달라는 요구도 전부 군말 없이 수용한 것은 사실이기에 결국 키르케가 주술을 해제하여 병사들은 다시 인간으로 돌아오고[47][48] 키르케는 고향에 돌아갈 길을 찾고자 조언을 구하는 오디세우스에게 고향에 돌아갈 길을 찾으려면 저승의 길목에서 예언자 테이레시아스에게 조언을 구해야 한다고 말해준다. 또한 이때 키르케는 오디세우스만이 살아서 고향으로 돌아갈 운명이라 알려주며, 부하들에게는 이를 말하지 말라 당부하며 조언한다.
|
||||
|
||||
키르케의 섬을 탈출한 오디세우스의 병사들은 멀리 북쪽에 있는 망자들의 해변에 가서[49] 돌아갈 해답을 얻기 위해 양 두 마리를 제물로 바쳐 저승의 망자들을 이 세상에 소환한다.[50] 그곳에서 처음 트로이 목마를 바치기 위해 희생해야 했던 시논을 다시 보게 된다. 오디세우스는 시논이 임무에 자원하며 자신의 아버지에게 자신이 용감했다고 전해달라며, 더 많은 활약을 했다고 부풀려도 좋다며 너스레를 떨던 모습을 회상한다. 그는 오디세우스에게 자신을 묻어준 것은 고맙지만 왜 자신을 믿지 못하고 목마 안에 군사를 숨긴 걸 자신에게도 숨겼냐고 원망한다. 오디세우스는 그렇게 하지 않았다면 적들은 속지 않았을 거라며 미안해한다. 오디세우스에게 죽음 앞에서 비밀은 없다는 대답을 들은 그는 자신이 저승(하데스)에 온 이후에 자신의 아버지를 만났지만, 아버지는 고향인 이타카에서 굶어 죽은 지 오래였고 그 이유는 안티노오스가 자신이 대신 징병당하면 가족을 돌봐준다는 약속을 깼기 때문임을 말해준다. 시논은 안티노오스에 대한 복수와 서쪽의 미지의 땅으로 가서 미처 묻히지 못한 병사들의 명복을 빌어줄 것을 부탁한다. 그 다음으로 오디세우스에게 말을 건 사람은 전혀 예상하지 못했던 먼저 귀향했던 아가멤논. 아가멤논은 자신은 성대히 귀향했다가 전쟁 출발 전 딸을 신에게 바친 것을 원망한 아내인 클리타임네스트라에게 죽었다며[51] 고향에 돌아간다면 환대 받지 못할 수도 있으니 변장해서 절대로 정문이 아니라 신분을 숨기고 천천히 상황을 살피라고 조언한다. 이야기를 마친 후 예언자는 오디세우스에게 "첫 번째로 세이렌이 있는 곳을 지날 때 그들의 노래를 들어선 안 된다. 이후 카리브디스와 스킬라 중 하나를 선택해야 하는데, 카리브디스는 모든 병사들을 죽일 것이고, 스킬라는 6명만 죽일 것이다. 마지막으로, 헬리오스의 섬에서 태양신의 소를 잡아먹으면 죽을 것이다."라는 예언을 하고, 병사들에게는 말하지 말라고 전한다. 끝에는 오딧세우스만 살아 귀향하는 것이 신들이 정한 운명이라는 테이레시아스의 예언에, 오디세우스는 그렇다면 자신은 신들과 맞서겠다면서 운명을 바꾸고 어떻게든 부하들을 살리려는 의지를 내보인다. 하지만 테이레시아스는 부하들 스스로가 자기들의 죽음을 자초하게 될거라는 말로 답할 뿐.
|
||||
|
||||
이후 묻히지 못한 수많은 망자들이 오디세우스와 산 병사들을 원망하며 '자신들의 불명예를 나눠가져라'라며 앞에 모여 있던 오디세우스 일행과 그나마 대화가 가능했던 묻힌 망자들을 밀쳐내고 오디세우스와 병사들을 뒤쫓아오다가, 이내 해변에서 물로 더는 쫓아오지 않고 멈춰서서 떠나는 그들을 원망스럽게 바라본다.[52] 묻히지 못한 모든 망령들을 뒤로 하고, 다시 바다로 나온 오디세우스와 병사들은 세이렌을 마주친다.[53] 오디세우스는 다른 병사들은 밀랍으로 귀를 막아 그 노래를 못 듣게 하지만, 자신은 노래를 듣고 최초로 살아남은 사람이 될 거라며 기둥에 자신을 묶어달라고 한다. 병사들 중 안키알루스(윌 윤 리)[54]가 호기심을 이기지 못하고 밀랍을 떼고 노래에 홀려 병사들의 만류를 뿌리치고 바다에 뛰어들어 세이렌들 쪽으로 헤엄쳐 가며 사라지고, 오디세우스가 굉장히 괴로워 했다는 것 이외에는 모두 무사히 지나간다.[55][56]
|
||||
|
||||
이후 오디세우스의 배는 거대한 소용돌이 카리브디스와 바다 괴물 스킬라가 있는 길목에 들어선다. 오디세우스는 일부러 카리브디스가 있는 소용돌이 쪽으로 가 급류를 역으로 추진력으로 삼아 순식간에 섬 뒤로 가자고 제안했고[57] 예상대로 반발한 병사들이 스스로 방향을 꺾어 스킬라가 있는 협곡으로 진입한다. 스킬라의 협곡은 생각보다 안전한 듯 보였으나, 갑자기 협곡 틈 사이에서 스킬라의 기괴한 여섯 아가리가 튀어 나오더니 병사 6명을 낚아채고는 까마득히 높이 있는 바위굴 속으로 순식간에 사라져 버린다.
|
||||
|
||||
스킬라에게서 벗어난 후 에우릴로코스가 너무나 담담한 오디세우스의 태도와 표정을 발견하고는 "당신은 이 모든 일들이 일어날 것을 이미 알고 있지 않았냐"며 거세게 항의하지만, 오디세우스는 6명 아니면 모두가 죽는 것이었다고 말한다. 이후 섬이 보이자 지친 병사들은 상륙하려 한다. 오디세우스는 저 섬은 태양신의 섬이라며 이들을 말렸지만, 다들 지쳐 있고 배도 수리할 겸 정박을 요청하자 섬의 소를 절대로 먹으면 안된다는 오디세우스의 말에 물하고 열매만 먹으면 되지 않는거냐며 반드시 그렇게 하겠다고 약속한다. 결국 오디세우스는 그 말은 무조건 지키라고 말하며 섬에 정박을 지시한다.
|
||||
|
||||
처음에는 병사들도 소를 먹어서는 안 된다는 것을 지키며[58] 지냈지만, 이윽고 식량이 떨어지고 바람이 역풍으로 불어 오랜 시간 동안 배를 띄울 수 없게 되자[59], 병사들은 참지 못하고 오디세우스 몰래 소를 잡아먹고야 만다.[60] 밤중 몰래 소를 잡아 고기를 게걸스레 뜯고 있는 병사들을 발견한 오디세우스는 자신이 보지 않는다고 신도 모르는 줄 아냐며 어이없어하고, 병사들은 굶어죽을 바엔 저주받아 죽겠다며 오히려 화를 낸다.[61] 그 직후 바람이 멎게 되었고, 이에 병사들은 서둘러 배를 띄워 섬을 빠져나간다. 그러나 이내 거친 폭풍을 만나 배가 반파되며[62] 병사들은 모조리 죽고 오디세우스는 표류하게 된다.[63] 오디세우스는 의식을 잃은 채 표류하다가 칼립소가 있는 섬에 도착하고, 칼립소에게 구출된다.
|
||||
|
||||
하지만 오디세우스에게 반한 칼립소는 그가 떠나지 않도록 오디세우스에게 로터스 나무의 꽃을 계속 먹였다. 이는 치료의 효과도 있지만 기억을 잃게 만들기에, 오디세우스는 기억을 잃은 채 칼립소와 7년 가까이 시간을 보내게 된다. 그러나 기억을 잃었음에도 오디세우스의 마음 한 켠에는 부인 페넬로페에 대한 생각이 계속 있었고, 아무것도 기억하지 못함에도 해변에 떠밀려 온 나무를 모아가며 뗏목을 만들려고 했다. 이런 모습을 본 칼립소는 오디세우스를 묶어둘 수 없다는 것을 깨닫고 오디세우스에게 모든 진실을 알려주며, 오디세우스가 기억을 잃고 몸도 성치 않을 때도 절대 놓지 않은 것이 있었다고 이야기한다. 그것은 바로 페넬로페가 건네준 금 브로치. 칼립소는 모든 기억이 돌아온 오디세우스에게 그는 평생 운명을 통제하려 들며 분투했지만, 이 일은 그럴 수 있는 것이 아니라며 포세이돈이 내리는 벌에 몸을 맡기라고 조언한다. 바다에 몸을 던져도 제우스가 지켜줘 죽음은 피할 수 있을 것이라고 칼립소가 알려준 후, 마침내 완전히 기억을 되찾은 오디세우스는 뗏목을 만들어 바다로 떠난 후 폭풍 속에서 표류한다.
|
||||
|
||||
3. 절정
|
||||
뗏목에서 표류해 해안가로 떠내려온 오디세우스가 정신을 차리자 옆에는 아테나가 앉아있었다. 여기가 어디냐고 묻는 오디세우스에게 아테나는 자신의 고향도 알아보지 못하는 거냐며 이곳은 오디세우스의 고향 이타카임을 알린다.
|
||||
|
||||
이후 아가멤논의 조언을 기억해 스스로 걸인으로 위장하고 에우마이오스를 만나러 간다. 직후 자신의 충직한 부하였던 에우마이오스의 뒷모습을 보고 반갑게 말을 걸지만 에우마이오스는 피투성이가 된 상태였다. 스파르타에서 돌아오는 텔레마코스를 죽이기 위해 안티노오스의 친우 폴리부스의 부관이 통솔하는 도적들이 안티노오스의 사주를 받고 그의 집 개들과 두 아들을 죽인 뒤였던 것이다. 에우마이오스는 오디세우스도 돌아오지 않고 텔레마코스도 죽을 위기라며 오열한다. 오디세우스가 그를 진정시키며 오디세우스를 정말 사랑한 거 같다고 묻자 에우마이오스는 오디세우스를 자신의 친아들처럼 사랑했다고 말한다. 이를 들은 오디세우스는 여전히 정체를 숨긴 채 자신은 왕의 사자이자 함께 트로이 전쟁에 참여한 병사 "시논"이며, 오디세우스가 오는 중이라고 말한다. 에우마이오스는 텔레마코스가 습격당할 것이며 이미 도적들이 텔레마코스에게 갔다고 알려준다.
|
||||
|
||||
오디세우스는 그 길로 신전으로 향한다. 폴리부스의 부관을 필두로 한 도적들은 텔레마코스의 경호원을 모두 암살한 다음 신전의 사제들로 위장해 독을 먹여 텔레마코스와 그의 검술 스승 멘토르를 죽이려고 한다. 그러나 텔레마코스는 부관의 사제복에 핏자국이 있는 걸 보고 뭔가 의심이 들어 건네준 포도주를 마시지 않고 들고만 있었다. 텔레마코스를 따라온 오디세우스가[64] 텔레마코스에게 위험을 경고하자, 멘토르는 여긴 신전인데 어떻게 그런 일이 있을 수 있겠냐며 의문을 표한다. 그러나 직후 이미 독약을 탄 포도주를 마셔버린 멘토르는 허망히 사망한다. 오디세우스와 텔레마코스는 폴리부스의 부관과 도적들을 죽이고[65] 신전에 불을 질러 빠져나오는데 성공한다.[66][67] 그리고 텔레마코스에게 여전히 신분을 숨기며 자신은 시논이고 오디세우스는 살아있으며 그가 곧 돌아올 것이라 말한다. 이후 행패를 부리는 구혼자들을 응징해야 하니 페넬로페에게 재혼하라고 말할 것을 설득한다.
|
||||
|
||||
이후 오디세우스의 조언대로 메넬라오스가 선물로 준 화려한 예복을 차려입은 채 당당히 성에 귀환한 텔레마코스는 페넬로페에게 재혼할 남자를 고르라고 설득한다. 재혼할 남자가 정해진다면 그 외에 다른 구혼자들이 흩어질 것이고, 메넬라오스가 돕기로 약속했으니 그 자만 없애면 자신이 왕으로 등극할 수 있다고 했으나 페넬로페는 그런 말에 자신이 왕이 없는 20년간 이 자리를 지켜왔는데 다른 이들보다 자신이 더 정치를 못하리란 법이 있냐, 겨우 수염난 애송이인 네가 뭘 아냐고 분노한다. 또한 자신은 밖에 있는 구혼자 따위와 결혼하고 싶은 생각은 없으며 오히려 모두 불타 죽여버렸으면 한다고 답한다. 이를 들은 텔레마코스가 평화를 원하는지 복수를 원하는지 묻자 페넬로페는 격앙된 목소리로 ‘난 오디세우스를 원한다‘며 일갈하고 자리를 박차고 나가버린다.[68]
|
||||
|
||||
거지 차림의 오디세우스는 구혼자들이 있는 연회장에 도착한다. 입구에서 그가 과거에 직접 골라 강아지 때부터 기른 사냥개인 아르고스를 마주하고, 주인을 알아본 아르고스는 최후의 힘을 다해 꼬리를 조금 흔든 뒤 [69]그 자리에서 숨을 거둔다. 오디세우스는 조용히, 아르고스를 언덕 위에 묻어달라 부탁한다. 옆에서 이를 목격한 텔레마코스는 오디세우스와 아르고스가 처음 만났던 장소가 언덕 위라는 걸 떠올리고는, 시논의 정체를 알아채고 감격하지만 이내 함구한다.[70]
|
||||
|
||||
연회장에서 여전히 걸인으로 돌아다니다 안티노오스에게 시논의 징병표를 줘 당황하게 하고 이내 구혼자들에게 몰매를 맞지만[71] 텔레마코스가 격분하여 연회 종료와 축객령을 선언하며 상황이 강제로 마무리된다. 그리고 결국 오디세우스는 연회가 끝난 이후까지 남아서 결국 페넬로페와 대화를 한다. 페넬로페는 늙은 시녀인 에우리클레이아에게 오디세우스의 발을 씻겨주라고 한다. 발을 씻겨주기 위해 오디세우스의 신발을 벗기는데 에우리클레이아는 오디세우스의 오른발목에서 피부가 찢어진 흉터를 보고는 걸인이 오디세우스임을 알아차린다. 그 흉터는 오디세우스가 출정하기 전 멧돼지를 사냥하다가 생긴 흉터이기에 알아본 것이다. 다만 오디세우스는 고개를 저으며 정체를 알리지 말라 부탁했고, 에우리클레이아는 고개를 끄덕이며 그 정체를 함구한다.
|
||||
|
||||
페넬로페는 걸인이 정말 오디세우스와 함께 트로이 전쟁에서 싸운 사람인지 확인하기 위해 전쟁 출발 전 건네준 장식품에 대해 물어보았고, 당연히 오디세우스는 정확히 묘사하며 의심을 풀게 된다. 이후 오디세우스는 끝까지 정체를 숨기고 페넬로페에게 자신이 왜 이렇게 귀향에 오래 걸리는지 그 이유를 전한다.
|
||||
|
||||
오디세우스가 전한 진실은 바로 그가 만든 트로이 목마에 의해 온 도시가 불타는 모습을 보며 하나의 문명을 없앴다는 큰 죄책감을 느꼈고, 그 폐허를 마주한 이후로는 집으로 돌아갈 용기가 없어졌다는 말이었다. 특히 전쟁을 끝내기 위해 본인이 직접 고안한 트로이 목마 작전은 제우스의 규율을 정면으로 깨버리는, 즉 같은 신을 믿는 두 문명 사이를 잇는 최소한의 이성과 규율을 배반한 행위였다는 것을 깨달은 것이다.[72][73] 결과적으로 이 전쟁 때문에 모든 나라가 제우스의 율법을 지키기 어렵게 되었고, 결국 오디세우스는 자신이 문명 그 자체를 무너뜨렸다는 죄책감에 빠져 스스로 그 업보를 치르려 한 것이다.
|
||||
|
||||
거기에 자그마치 10년이라는 기간동안 집과 가족을 떠나 전쟁을 치르며 쌓인 분노를 풀기 위해, 병사들은 무기를 든 군인 뿐만 아니라 민가를 습격해 시민들을 학살하고 도시에 불을 지르며 파괴 행각을 일삼았고, 심지어는 자신들과 트로이인이 같이 숭배하는 아테나 여신상의 목까지 쳐버리는 막장 행위가 벌어지게 된 것이다. 이때 계속해서 환영으로 나타나 오디세우스에게 말을 걸던 아테나(젠데이아)의 정체도 밝혀지는데, 오디세우스가 봤던 아테나는 사실 트로이 함락 당시 무고하게 참수당한 아테나의 여사제였다.[74][75] 당시의 시대상을 생각하면 전투 중의 방화와 민가 습격까지는 흔히 일어나던 일이었으나, 종교적 신념을 적극적으로 이용한 기만 작전 뿐만 아니라 신전까지 공격한 것이 문제였다. 당시에 신전은 신들의 보호를 받는 장소라는 걸 모두가 알고 있었고, 전쟁 중이라도 신전으로 대피한 민간인들은 신들의 보호를 받는다 여겨 공격하지 않는 것이 불문율이었다. 하지만 10년 동안의 원정으로 피폐해진 병사들은 그 불문율조차 어겨가며 신전까지 쳐들어갔다.[76]
|
||||
|
||||
결국 도시를 정복한 이후에도 이러한 광기는 끝나지 않아 다음날까지 잔혹 행위가 이어졌고,[77] 오디세우스는 침공 중에 도시의 건물이 무너질 때마다 자신의 집이 무너져내리는 것을 보는 듯 전쟁의 참상과 파괴, 폐허를 온몸으로 겪으며 정신적으로 크게 무너져 내려버린 것이다. 그래서 성벽 안의 트로이군이 덤벼들어도 몇 명 쓰러트리지 못하고 트로이 병사가 눈앞에서 칼을 휘둘러도 제대로 피하지 못해 부관이 오디세우스를 구해주고 영문을 모르고 오디세우스를 쳐다보기도 한다.[78]
|
||||
|
||||
오디세우스가 말해준 트로이의 참상을 들은 페넬로페는 그에게 “당신이 바다에서 온 사람들(The People from the Sea)인가요?”라고 묻고, 오디세우스는 이를 긍정한다.[79] 하지만 페넬로페는 자기 남편을 눈앞에 둔 상황에서 '자신은 그 폐허 속에서 너무 오랫동안 살아왔으니 이제는 오디세우스가 돌아와야 할 때'라고 말한다.
|
||||
|
||||
긴 대화 직후 페넬로페는 남편을 새로 찾기로 결정했다며 다음날 그것을 위해 시험을 할 것이라 선언한다. 그리고 한 명을 정했을 때 연회장에 무기가 있다면 곧장 전투가 일어날 거라며 무기를 모두 2층에 숨긴다.[80] 그리고 새벽에 구혼자들을 불러모아 새 남편이 될 이를 찾기 위한 시험이 열린다. 그 시험은 전쟁 전 오디세우스가 자주 하던 그의 활 시위를 장궁에 걸고, 화살을 발사해 도끼 12개를 한 번에 통과하는 시험이었다. 대부분의 구혼자들은 활 시위를 거는 것에도 낑낑거리며 실패한다. 안티노오스는 어렸을 때 그 활에 시위를 걸 수 있는 것은 이타카에 오디세우스밖에 없다는 것을 이미 봤기 때문에 다른 구혼자들이 이 활에 시위를 거는 것이 가능하냐고 짜증을 낼 때마다 가능한 사람이 있다며 핀잔만 주다, 결국 본인 차례가 되자 본인도 할 수 없다는 것을 알고 있으니 우리 모두가 실패했고 오디세우스의 자리를 완벽하게 대체할 수 없는건 알지만 그렇다 해도 우리 중 한명을 선택해야 한다고 말하며 임기응변으로 곤란한 상황을 능구렁이처럼 넘어가려 든다. 그때 걸인으로 위장하고 있던 오디세우스는 자신의 차례가 남았다고 말한다. 안티노오스는 이를 비웃으며 누가 이 걸인 좀 쫒아내라고 하지만 텔레마코스가 당신이 하지 않는다면 그 기회를 저 걸인에게 주겠다고 말하며 오디세우스에게도 시험의 기회가 주어진다. 오디세우스는 활을 조정하는가 싶더니 거뜬히 활에 시위를 거는데 성공하고,[81] 늘 하던 대로 시위를 튕겨 경고음을 낸 뒤[82] 도끼 12개 사이로 화살을 발사해 과녁에 명중시킨다.
|
||||
|
||||
활을 쏘기 전 내는 경고음, 그리고 특징적인 활 소리와 함께 걸인의 정체가 오디세우스라는 사실을 그 자리에 있던 모두가 알게 된다.[83]
|
||||
|
||||
직후 제일 먼저 오디세우스를 알아본 안티노오스의 친우 폴리부스가 목에 그의 화살을 맞아 절명하는 것을 시작으로, 무기도 없는 구혼자들을 향한 오디세우스의 일방적인 학살이 시작된다. [84] 그동안 권위를 내세워 구혼자들의 대장질을 했던 안티노오스는 정작 사태가 벌어지니 혼비백산이 되어 가장 먼저 기둥 뒤에 숨으면서 멜란티오스에게 빨리 윗층에 있는 무기들을 가지고 오라고 닦달하고, 멜란티오스는 서둘러 2층의 무기를 보관한 창고로 달려가지만 미리 간파한 에우마이오스에 의해 역으로 창고에 갇히게 된다. [85] 역시 그간 스파이 노릇을 하며 왕실의 정보를 안티노오스에 팔아넘긴 멜란토 역시 자신이 무사하지 못할 것을 깨닫고 중문으로 도망갔으나, 이미 멜란토의 배신을 알고 있던 페넬로페는 "네 자리를 지켜야지"라며 싸늘한 표정과 함께 문을 닫아버린다.[86]
|
||||
|
||||
오디세우스가 꽂혀있던 도끼들까지 집어들고 구혼자들을 도륙하던 그때, 멜란티오스는 나무로 된 바닥을 무기로 내리쳐 부숴 구멍을 내고 그 틈새로 무기를 떨어뜨리기 시작한다. 이를 발견한 텔레마코스가 서둘러 멜란티오스를 막으러 뛰어올라간다. 겨우 무기를 든 구혼자들은 오디세우스의 상대가 되지 못하고 화살을 꽂는 등의 부상 정도밖에 입히지 못하지만, 멜란티오스가 방패와 창까지 떨어뜨리기 시작하자 오디세우스도 위기에 봉착한다. 그 와중에 안티노오스는 오디세우스가 위기에 빠져 있을 때도 겁을 먹어 나서지 못하고 다른 구혼자들에게 검과 창을 들고 싸우라며 닦달한다. 한편 텔레마코스는 건장한 멜란티오스에게 체급에서 밀려 고전을 면치 못하지만, 배를 일부러 노출해 공격을 유도해 내고 빈틈을 노려[87] 멜란티오스의 목을 베어내며 승리한다.
|
||||
|
||||
등에 창까지 꽂힌 오디세우스는 중과부적으로 화살을 하나 들고 휘두르며 구혼자들의 창에 포위당하는 위기까지 맞게 되지만, 텔레마코스가 내려준 검을 받더니 그 검으로 덤벼오는 구혼자들을 처치한다. 게다가 텔레마코스가 오디세우스에게 검을 내려주며 "아버지!"라고 외쳤기 때문에 오디세우스의 신분이 확실히 드러나자[88] 여러 구혼자들이 전의를 상실하고 복종의 의미로 무릎을 꿇기까지 한다. 오디세우스는 이미 대세가 기운 와중에 마지막으로 발악하던 안티노오스에게 덤벼들어 제압한 후, 저승에 있는 시논에게 자신이 서쪽으로 가서 죽은 전우들을 기릴 것이란 말을 전하라고 하고, 또한 시논에게도 사과하라면서 입에 시논의 징병표를 쑤셔넣은 뒤 배에 칼을 찔러 넣어 죽이며 그의 장대한 귀향을 끝낸다.
|
||||
|
||||
4. 결말
|
||||
마침내 복수를 마친 오디세우스에게 무릎을 꿇은 텔레마코스와 구혼자들, 에우마이오스 등의 하인들은 검을 바닥에 연신 부딪히며 경의를 표한다. 그러나 오디세우스 역시 전투 중 부상을 입은 터였기에 이내 쓰러져 페넬로페에게 안긴 다음, 아테나 형상의 머리핀을 보여준다. 페넬로페는 오디세우스를 알아보고 죽으면 안 된다며 오열하지만, 오디세우스는 "죽는 게 아니라 떠나는 거요(Not dying, exile)"[90]라며 안심시킨 뒤 그녀의 품속에서 미소를 짓는다.[91][92]
|
||||
|
||||
세월이 흘러 텔레마코스는 이타카의 새로운 왕이 되었고 오디세우스는 아내 페넬로페와 함께 이타카를 떠나 행복한 여생을 보내게 된다. 오디세우스는 그동안 묻히지 못하고 죽은 부하들의 넋과 혼을 기리기 위해 아내와 서쪽으로 떠난다.[93] 오디세우스는 이것을 스스로 제우스의 규칙을 어긴 자신에게 내리는 유배형이라고 하는데, 그 많은 고생 속에서도 신에 대한 존중을 버리지 않았던 원작의 오디세우스다운 선택이다.[94] 오디세우스는 페넬로페에게 "우리 문명에 대한 이야기는 훗날엔 그저 노래로만 남게 될 것이오. 그럼 우리의 죄도 다시 한 번 잊혀지겠지."라 고백한다.[95] 그러다 이후 회상 장면으로, 영화가 트로이 목마를 비추며 막을 올린 것처럼 트로이 전쟁 때 오디세우스가 불타는 목마를 바라보던 모습으로 영화는 막을 내린다.
|
||||
|
||||
- 원작과 차이점
|
||||
오디세우스는 처음에는 전쟁에 끌려가지 않기 위해서 미친 척 행세하였으나, 영화에서는 그런 모습은 보여지지 않는다.[98] 다만 오디세우스는 페넬로페에게 참전하지 않으면 아가멤논이 자신의 아들을 데려갈 것이라고 말하고, 페넬로페는 함께 서쪽 멀리 도망치면 안되냐고 묻자 자신들이 도망가면 아가멤논의 성격 상 이타카를 불바다로 만들 것이라며 어쩔 수 없이 참전한다. 병역에 끌려가기 싫어 쟁기를 끌고 소금을 뿌리는 장면이 나오지 않으면서 팔라메데스의 원한관계도 사라져 트로이 전쟁때 팔라메데스를 죽일 필요도 없었고 팔라메데스 부친 나우폴로리스 2세가 오디세우스가 죽었다고 거짓 소문을 퍼트리지 않은걸로 보인다.[99]
|
||||
원전에서는 폴리페무스의 섬을 탈출한 이후 바람의 신 아이올로스의 섬에 도착하여 아이올로스로부터 오디세우스와 그의 부하들이 고향 이타카로 돌아가는데 방해가 되는 바람을 전부 자루에 넣어서 이타카로 순항하게끔 도와주나, 이타카에 거의 다 도착했을 무렵 부하들이 바람이 들은 자루를 오디세우스가 독차지하려는 보물로 착각하여 열어버리는 바람에 순식간에 모든 바람들이 자루에서 빠져나오면서 생긴 돌풍으로 이타카에서 다시 멀어지는 내용이 있으나, 영화상에서는 아이올로스부터 고향 이타카에 거의 다 도착했으나 돌풍에 휩쓸려 이타카로부터 다시 멀어지는 내용 전부 스킵되었다.
|
||||
트로이 전쟁의 다른 영웅들은 아가멤논과 메넬라오스 이외에는 말로만 등장한다. 특히 트로이 전쟁에서 오디세우스와 쌍벽을 이루는 주연급이라고 할 수 있는 아킬레우스는 말로 딱 한번 언급되는 것이 다이며,[100] 신화에서는 보통 디오메데스와 가장 가까운 사이었던 오디세우스가 메넬라오스의 존경을 받는 것으로 약간 각색되었다. 마찬가지로 영화상에서 저승의 길목에 방문하여 죽은 아킬레우스, 파트로클로스, 대 아이아스, 심지어는 자신의 돌아가신 어머니 안티클레이아의 망령까지 만나지만 전부 다 생략되었으며[101], 대신 앞서 말한 장군들은 텔레마코스가 메넬라오스를 찾아갔을 때 메넬라오스의 대사를 통해 이름으로만 언급된다.
|
||||
원작에서는 외눈 거인인 폴리페무스가 처음부터 말을 할 수 있으며 이들과 대화를 하던 것으로 나온다. 이에 오디세우스가 자신을 "우티스(아무도 아니)"라고 소개하고 포도로 만든 포도주를 잔뜩 먹여 과음으로 잠들게 한뒤 불에 달군 통나무로 외눈을 찔러 장님으로 만드는 장면이 나오지만, 영화에서는 거인이 이들에게 말을 걸지 않고, 오디세우스가 거인의 눈을 잠든 사이 그냥 찌른다. 또한 원작과 달리 폴리페무스가 유일한 키클롭스로 나오기에 친구인 다른 동족들을 불러 "내 눈을 찌른건 '아무도 아니'다"라고 도와달라는 장면도 삭제되었다.[102] 이 때문에 오디세우스가 도망치는 과정에서 자신의 진짜 이름을 밝혀 도발했다가 되려 포세이돈에게 노여움을 산 이야기를 도망치는 과정에서 폴리페무스의 눈에 화살을 쐈다가[103] 폴리페무스를 자극해버린 탓에 애먼 부하들이 죽어나간 것으로 각색하였다.[104]
|
||||
원작에서 폴리페무스는 갈라테아[105]라는 바다의 여신[106]에게 사랑에 빠져 구애를 수차례 시도하지만 갈라테아는 눈길 하나 주지 않고, 아키스라는 이름의 청년과 사랑에 빠진다. 단순히 여기서 그치지 않고 두 커플이 자신을 조롱하기까지 하자, 이에 질투심과 분노에 사로잡힌 폴리페무스는 아키스를 돌팔매질로 죽이고 그 일 이후로 사람을 잡아먹기 시작했다. 이 사건이 얼마 지나지 않아 오디세우스 일행이 타이밍 나쁘게 찾아오지만, 이 내용은 영화에서 생략되었다.
|
||||
또한 아래의 헤르메스의 도움이 삭제된 부분도 그렇지만 전반적으로 그리스의 신들이 직접적으로 개입하는 장면이 삭제되었다. 비교적 격이 낮으며 오디세우스와 직접적으로 교류하는 칼립소와 키르케는 사람의 모습을 한 채로 나오지만, 이들이 다른 신들과 교류하는 장면은 없으며, 소박한 차림과 생활을 하며 여신들이라기보단 신묘한 능력과 지식을 가진 마녀에 가깝게 묘사된다. 본작에서는 천둥과 폭풍 같은 식으로 간접적으로 묘사되는데 신화속 괴물과 마녀, 여신이 직접 나오는것도 그렇고 신들이 존재하지 않는 건 아니라는 해석이 주류다.[107] 다만 직접적 묘사 대신 간접적인 표현을 사용한 것으로 보인다. 심지어 직접적으로 행동한다고 할 수 있는 아테나도 본인의 본모습이라기 보다는 오디세우스 눈 앞에서 죽은 어린 여사제의 모습을 빌려 나타났다는 점에서 주신들이 직접 모습을 드러낸 것은 없다고 봐야할 것.
|
||||
원작에서는 오디세우스와 부하들이 소 떼가 있는 섬에 상륙하고 그 섬에 사는 소 떼의 주인은 헬리오스이지만, 영화에서는 헬리오스의 인지도 때문인지 소들의 주인이 '태양신(Sun God)'이라고만 나온다. 또한 원전에서 오디세우스의 부하들이 소를 잡아먹고 헬리오스가 제우스에게 벌을 달라고 요청하자, 죽은 소의 가죽이 살아 있는 것처럼 기어다니며 울음소리를 내고 꼬챙이에 꿰어져 있는 쇠고기가 꿈틀거리는 에피소드는 생략되었다.
|
||||
원작에서는 라이스트뤼고네스들이 바르바로이를 연상시키는, 문명과 거리가 먼 식인 생활을 하는 거인으로 나오지만 영화에서는 그리스군보다 진보한 철갑옷과 망토를 갖춰 입은 선진 문명의 모습으로 등장한다. 거인들은 압도적인 피지컬로 등장하는데 대략 2m 중반에서 후반 정도의 크기로 보이지만 신화속 괴물의 느낌보단 조금 더 인간 종족 느낌이 나기도 한다.[108]
|
||||
원작에선 트로이에서 이타카로 출항하는 배가 12척이지만 여기선 3척으로 묘사된다. 따라서 라이스트뤼고네스들에게 배 12척 중 11척이 전멸당하는 에피소드도 영화에서는 3척 중 2척이 당한다.
|
||||
원작에서는 오디세우스가 헤르메스의 도움으로 마녀 키르케의 지팡이에 닿아도 동물이 되지 않는 약초를 먹고 찾아가 그녀에게 칼을 들고 목에 겨눠서 제압한다. 하지만 영화에서는 헤르메스의 도움도 존재하지 않으며, 오디세우스는 키르케의 자매인 까마귀의 목을 잡고 키르케를 잡고 협박해서 위험을 벗어난다. 까마귀의 존재 또한 영화 한정 등장이다. 키르케는 님프들을 거느리고 있었지만 가족과 같이 있지 않았고, 자매라고 해봤자 원전에서 등장하는 인물은 파시파에 정도가 다인데 둘은 신화에서도 자매라는 점 외에는 접점이 없다. 또한 원전에서 키르케는 특출난 마법 실력과 아름다운 외모를 지닌 여신으로 묘사되며 해당 사건 이후 오디세우스와 정분이 나 오디세우스는 1년 정도 키르케와 같이 산다. 하지만 영화에서는 미녀라기보다는 차라리 중세 민담의 늙은 마녀처럼 묘사되며, 서로 눈이 맞는 일도 없다. 사건이 마무리되자 오디세우스는 식량만 보충하고[109] 키르케의 조언을 받은 뒤, 즉시 섬을 떠난다.
|
||||
키르케와 정분이 나는 장면이 삭제됨에 따라 오디세이아 이후 이어지는 텔레고네이아에 대한 이야기도 없는게 되었다. 이에 결말에는 아들인 텔레마코스가 왕위를 이어받아 이타카의 새로운 왕이 되고 오디세우스와 페넬로페는 바다 너머 미지의 땅으로 떠나는 결말로 끝난다.[110]
|
||||
세이렌들이 오디세우스 일행을 노래로 유혹하지 못하자 자살하는 장면이 영화에서는 나오지 않는다. 다만 오디세이아에도 나오지 않으므로 원작과의 차이점이라고 할 수는 없다. 세이렌의 자살은 오디세이아가 아니라 다른 작품에 나온다.
|
||||
신화에서 스킬라는 본래 님프이며 그녀를 사랑하는 글라우코스에 대한 키르케의 질투로 인해 괴물이 되어버린 여인이다. 특히 뱀이나 개의 머리가 여럿 달린 괴수 혹은 님프 시절의 원형이 남아있는 반인반수 형상의 요괴인데, 본작에서는 보다 현실적인 생물의 외형으로 재해석되었다. 거대한 따개비와 유사한 형태로 바위에 붙어살며 꽈배기처럼 꼬인 여러 개의 입들을 길게 늘려 선원들을 포식하며, 외피도 자연적 위장처럼 주변의 바위들과 유사한 질감과 색상이다.
|
||||
신화에서 아가멤논은 목욕하다가 아이기스토스의 도끼에 맞아죽지만 영화에서는 침대에 누웠다가 아내인 클뤼타임네스트라의 단검에 목을 찔려 죽는다. 원전에서는 이때 카산드라와 자식들도 죽임을 당하지만, 영화에는 카산드라와 자식들의 존재 자체가 등장하지도 않고 언급되지도 않는다. 아이기스토스도 클뤼타임네스트라의 불륜 상대 정도라는 대사 정도로만 언급되며 이후에 복수하는 아들인 오레스테스 역시 언급만 된다.
|
||||
오디세우스가 칼립소와 헤어진 후 이타카 북쪽의 섬나라인 파이아키아에서 나우시카 공주를 만나는 부분과 이노[111], 메넬라오스가 에이도테아와 만나는 등 부분이 영화에서는 생략되었다.
|
||||
이타카로 돌아온 오디세우스가 원작에서는 구혼자들을 죽일 때 거의 압도적으로 학살하지만 현실적인 연출을 좋아하는 놀란 감독의 성향답게, 영화에서는 활도 맞고 다굴도 당하는 등 조금 어렵게 싸우는 식으로 긴장감 있게 각색되었다. 아무리 무력이 좋은 사람이라 하더라도 다굴 앞에는 장사 없는 것이 맞으니 이것이 보다 현실적이다. 또한 원작에서는 구혼자들을 다 죽여버리지만 영화에서는 많은 구혼자들이 불리해지자 전투를 포기하고 항복해 복종을 맹세하며, 원작에서 가장 먼저 화살에 맞아 죽었던 안티노오스는 영화에선 가장 마지막에 죽는다. 배신한 하인과 하녀들을 전부 처형하는 장면[112]이나 이후에 구혼자들의 유가족이 일으킨 반란을 진압하는 장면[113]도 영화에서는 하객들을 죽이면 그 가족들의 복수가 이어질거란 언급만 나오고 생략되었다.[114]
|
||||
원작에선 전혀 없지만 영화에서는 바다 민족과 미케네 문명의 붕괴에 대한 경고가 여러 차례 나온다. 오디세우스는 인간들의 배신과 문명국 트로이의 멸망을 시작으로 암흑시대가 올 것을 예견한다. 실제로 일리아드와 오디세이아는 그리스 청동기 문명이 멸망한 수백년의 암흑기 후에 철기 도시국가 폴리스 시대에 호메로스가 구전되던 설화를 정리하여서 오늘날까지 전해지고 있다. 영화에서의 오디세우스도 자신들의 문명이 노래로만 전해지고 문명이 언젠가 다시 세워지더라도 자신들의 어리석음은 잊힐 것이라 언급한다. 미묘해진 것은 그리스 문명이 망할 것을 예견하고도 텔레마코스를 이타카에 두고 떠났단 점인데 영화가 텔레마코스에 대해 긍정적이고 결말 역시 희망적인 분위기로 끝난걸 보면 이후 텔레마코스는 이타카를 잘 이끄는 왕이 될거란 해석이 가능하다.[115]
|
||||
원작과 달리 오디세우스는 자신의 속임수로 위대한 문명국가인 트로이를 멸망시키고 무고한 시민들이 처참히 살해당하게 만든 것에 죄책감을 느끼고 있다. 어찌 보면 원전과의 가장 큰 차이점이라 볼 수 있으며, 스포일러로 볼 수도 있다. 가끔씩 나타나는 아테나 여신이 트로이 함락 당시 살해당했던 여사제의 모습으로 나타나는 것도 신전의 여사제를 죽게 내버려 둔 것에서 죄의식을 받았기 때문이다.[116]
|
||||
사실 원전에 따르면 본작의 해석과는 달리 트로이 목마 역시 제우스의 법도 위반이라고 보기 어렵다. 애당초 크세니아는 손님에 대한 환대를 목적으로 한 법이지 적에 대한 것이 아니며 무사도나 기사도 정신마냥 전쟁은 정정당당하게 해야 한다는 식의 도덕이 아니기 때문이다. 오히려 크세니아를 먼저 어긴 것은 스파르타에서 환대를 받아놓고도 헬레네와 간통하여 그녀를 트로이아로 데려간 파리스이며[117], 트로이아인들 중 그 누구도 아카이아 연합군을 자신들의 손님이라고 생각하지 않았기에 트로이 전쟁은 크세니아를 적용할 만한 일이 아니고 그 과정에서 오디세우스가 어떤 전략을 쓰든 이는 정당한[118] 전쟁 수행 과정의 일부라고 보는 편이 타당하다. 원전에서도 트로이아의 함락은 엄연히 오디세우스의 전공이자 영예로운 위업으로 인식된다. 만약 고대 그리스의 관점에서도 오디세우스의 트로이 목마가 크세니아 위반이었다면, 원전에서 아테나를 위시한 신들이 오디세우스를 그렇게까지 비호해줄 수도 없었을 것이고 그가 이타카로 귀환해 복수를 완수한 이후에도 제우스가 크세니아 위반을 명목으로 구혼자들의 유가족들에게 벼락을 떨어뜨리며 엄포를 놓는 일 역시 없었을 것이다. 그리고 상술하였지만 크세니아를 먼저 어긴 것은 아프로디테의 유혹에 넘어가 메넬라오스의 환대를 제대로 배신하고 10년 전쟁의 도화선을 당긴 파리스고, 따라서 제우스의 법도를 위반한 탓에 세상에 악이 풀려나고 도덕이 무너지며 나아가 그리스 세계 전체가 멸망의 길로 들어선다는 본작의 설정에 따르면 그 원흉 역시 오디세우스가 아닌 파리스여야 한다.[119] 그러나 본작의 전개와 PTSD에 시달리는 군인이라는 본작 오디세우스의 캐릭터성을 생각해보았을 때, 놀란 감독이 이러한 점을 몰랐을 리는 없고 일부러 독자적인 해석을 가미해 의도적으로 원전을 비틀었다고 보는 것이 타당하다. 특히 본작에서는 아테나를 제외한[120] 올림포스의 신들이 직접 화신체를 갖추어 등장하는 경우가 거의 없으며, 트로이 전쟁의 개전 자체도 원전처럼 명분론적 정의구현이 아닌 아가멤논의 정치적 목적 달성이라는 것으로 각색된 데다가 구혼자 유가족들의 반란도 묘사되지 않는다는 점에서 더욱 그렇다. 또한 오디세우스와 그의 계략을 비열하다고 보는 것 역시 단테의 신곡 등 고전 작품들에서도 흔히 드러나는 또 하나의 관점이다. 결론적으로 오디세우스는 당시의 시대상과 창작자들의 관점에 따라 뛰어난 지략을 갖춘 영웅이 되기도, 비열한 술책으로 도시를 무너뜨린 인간이 되기도 했던 것이며 놀란 감독은 자신이 세운 주제를 관객들에게 효과적으로 피력하기 위한 목적으로 후자를 선택한 것으로 볼 수 있다. 그리고 그리스 로마 신화와 오디세이아를 접한 대부분의 관객들은 오디세우스를 군신 아테나조차 인정한 뛰어난 지략가로 인식하고 있으므로, 오디세우스의 캐릭터성에 변주를 준 놀란 감독의 이러한 선택은 성공한 클리셰 비틀기라고도 할 수 있을 것이다.
|
||||
트로이아 성채로 목마를 들이도록 앞잡이 역할을 한 시논은 <오디세이아>에선 등장하지 않고 트로이아 전쟁 이후 아이네아스의 시선에서 벌어지는 이야기를 다룬, 베르길리우스가 집필한 <아이네이스>에 등장하는 인물이다.
|
||||
원작에서는 트로이인들이 세워져 있는 목마를 끌고 성 안에 데려오지만 영화에서는 목마의 형태가 앞다리를 들고 뒷다리로 선 말의 형태라서 눕혀진 목마를 끌고 온 뒤 지렛대로 세운다. 또한, 좁은 목마 안에 무기도 없이 밀물과 씨름하면서 다닥다닥 붙어서 작전의 성공만을 기다리는 그리스 별동대를 현실감 있게 묘사했다. 목마 내부에서 배설물을 처리할 방법이 없어 더러운 상태로 있는가 하면, 밀물을 고려하지 못해 안에서 손도 못 쓰고 익사를 당한다는 충격적이면서도 신선한 발상이 나타난 부분도 원작에선 묘사된 바가 없었다.
|
||||
바다를 건너온 자의 이야기인 텔레고네이아가 사실은 바다 민족으로 각색되는가 했지만 사실은 얼마든지 도리를 어기면서 문명 하나를 통째로 멸망시킬 수 있다는 확신을 퍼뜨린 오디세우스 본인으로 각색되었다.[121] 바다 민족도 머나먼 세상에서 찾아오는 침략자들이 아니라 트로이 목마로 퍼져나가는 도덕의 해이가 불러올 인류의 윤리적 쇠퇴가 문명의 붕괴로 이어질 것임을 암시한다. 역사적으로도 나름 최신 학설을 반영한 것으로 볼 수 있는데, 해양 민족의 정체와 암흑 시대의 도래에 대해서는 여러 가설이 있지만 현대 들어서는 사실 바다 민족에 의해 암흑시대가 도래한 것이 아니라 폭력성이 극에 달한 미케네 문명 자신들이 멸망을 불러온 것이며 멸망한 미케네인들이 지중해를 건너 이집트, 레반트, 히타이트를 쳐들어간 것이 바로 바다 민족의 정체라고 결론이 나고 있기 때문이다. 중간에 마을 노인이 해적이 찾아온 줄 알았다며 이타카군을 오해한 것 또한 이러한 맥락으로, 바다 민족은 늘 있는 해적일 뿐, 멸망의 주체는 문명인들 자신이라는 뜻이 된다. 극중 대사로 보면 초반에는 다른 지역 사람들이 여러 차례 오디세우스 일행을 보고 바다 민족이냐고 묻고 오디세우스 일행은 이를 부정하지만, 결국 오디세우스는 후반에는 이를 인정한다. 실제 역사상으로도 미케네 멸망 후 암흑시대 중 레반트 지역에 자리잡은 바다 민족에는 아나톨리아나 이탈리아 근방에서 온 집단 외에 그리스계도 많았다.
|
||||
신화를 다루고 있는 만큼, 여러 신화적 존재들과 괴물들이 등장하기는 하나 정작 올림포스 12신들이 명확하게 묘사되는 원작과 달리 본 작품에서는 등장인물들 사이에서 언급만 될 뿐 존재 자체가 일절 등장하지 않는다.[122] 물론 하위 신격인 존재들, 칼립소나 키르케같은 인물들이 등장하긴 하나 원작에 비하면 각색된 면이 강하고 주신들이 서로 회의를 하거나 인간들에게 도움을 주는 등 직접적인 교류가 묘사된걸 비교하면 그 차이가 명확하게 드러난다.
|
||||
오디세이아의 이야기의 주제를 놓고 따진다면 현대적인 각색이 들어갔음에도 의외로 원작의 주제를 잘 따르고 있는 편이다. 오디세이아가 모험담을 그리고 있기는 하지만 동시에 긴 전쟁으로 고향과 단절되었던 남자가 얼마나 스스로를 쉽게 포기할 수 있는지[123], 그리고 그렇게 자신을 포기한 남자가 여정의 끝에서도 자신이 누군지를 기억할 수 있는지를 보여주었다. 물론 당시에는 없던 PTSD라는 개념이 잘 정립된 21세기에 만들어진 만큼 오디세우스의 혼란의 중심이 신비와 미지보다는 전쟁에 대한 트라우마가 더 강조되는 차이점은 있다. 원작에서도 오디세우스가 트로이 전쟁 당시의 참상에 고통스러워하는 장면이 있지만, 그것이 본 영화만큼 오디세우스의 심리의 중심이 되지는 않는다. 이렇듯 오디세우스 개인의 심리가 집중적으로 묘사되는 한편 오디세우스가 일으킨 행보가 세상에 퍼지면서 윤리가 붕괴되고 문명이 멸망하는 폭력성으로 번지게 될 미래를 직시한다는 점에서 놀란 감독의 전작 오펜하이머의 그리스 로마 신화 판이라는 분석과 비평도 제시되고 있다.[124]
|
||||
마지막에 오디세우스가 일행을 이끌고 서쪽으로 떠나는 것은 로마 제국의 공식 신화인 아이네이스에서 아이네이아스가 트로이 전쟁의 생존자들과 함께 서쪽의 이탈리아 반도로 이주하는 것과 유사하다. 바다 민족 셰르덴의 사례를 보면 그리스 지역에서 서쪽 이탈리아 근방으로 이주한 사람들도 존재한 것으로 여겨진다.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@ -1,371 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
animation_odyssey 생성 단계 (나레이션 + 스토리보드) — 병합본
|
||||
==================================================================
|
||||
오디세이아 '썰' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음.
|
||||
(animation_greekroman 폴더와 동일 구조 — 소재만 '그리스 로마 신화 신·영웅' → '오디세우스의 귀향 대모험'.)
|
||||
scraper.py 가 만든 odyssey_keywords_*.csv 를 입력으로 한다.
|
||||
|
||||
· 나레이션: latest_csv / load_rows / pick_seed / generate
|
||||
· 스토리보드: clamp_scenes / make_storyboard
|
||||
|
||||
구조: 훅 → 썰 전개(역사 + 병맛 과장) → 어거지 전환 → 마케팅 + CTA → 장면별 분할(자막+그림 프롬프트).
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from google.genai import types
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
DATA_DIR = HERE / "data" # 오디세이아 일화 CSV(선행 데이터). ★ output/ 이 아님 — 거긴 출력물(mp4)만.
|
||||
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||
|
||||
# 자막 폰트(주아/송명/맑은고딕)에 없는 이모지·기호(😊▼★♥ 등)는 화면에서 □ 로 깨진다 → 제거.
|
||||
_TYPO = {"…": "...", "‘": "'", "’": "'", "“": '"',
|
||||
"”": '"', "·": " ", "~": "~", "〜": "~"}
|
||||
# 폰트(주아/송명/맑은고딕)에 확실히 있는 것 = 한글 음절·자모(ㅋㅋ ㅠㅠ) + ASCII 인쇄문자. 그 외(이모지·…·특수기호)는 제거.
|
||||
_KEEP_RE = re.compile(r"[^가-힣ㄱ-ㅣ\x20-\x7E]")
|
||||
|
||||
|
||||
def _no_emoji(s):
|
||||
s = s or ""
|
||||
for _k, _v in _TYPO.items():
|
||||
s = s.replace(_k, _v)
|
||||
return re.sub(r" +", " ", _KEEP_RE.sub("", s)).strip()
|
||||
|
||||
|
||||
# ============================== ① 나레이션 (신화 썰 → 병맛 대본) ==============================
|
||||
|
||||
def latest_csv() -> Path:
|
||||
files = sorted(DATA_DIR.glob("odyssey_keywords_*.csv"))
|
||||
if not files:
|
||||
sys.exit("[!] animation_odyssey/data/ 에 odyssey_keywords_*.csv 가 없음. 먼저 'python scraper.py' 실행.")
|
||||
return files[-1]
|
||||
|
||||
|
||||
def load_rows(csv_path: Path):
|
||||
"""CSV → [{order, king, doc, is_anecdote, keyword, url}, ...] (king = 신화 인물 이름)"""
|
||||
rows = []
|
||||
with csv_path.open(encoding="utf-8-sig", newline="") as f:
|
||||
r = csv.reader(f)
|
||||
next(r, None) # 헤더 스킵
|
||||
for line in r:
|
||||
if len(line) < 6:
|
||||
continue
|
||||
order, king, doc, anec, kw, url = line[:6]
|
||||
rows.append({
|
||||
"order": int(order) if order.isdigit() else 0,
|
||||
"king": king, "doc": doc,
|
||||
"is_anecdote": anec.strip().upper() == "Y",
|
||||
"keyword": kw, "url": url,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# 오디세이아(귀향 대모험) 핵심 사건·인물 신호 — 이런 키워드를 '썰 씨앗'으로 최우선.
|
||||
ODYSSEY_HINTS = [
|
||||
"오디세이아", "오디세우스", "트로이", "목마", "섬", "동굴", "키클롭스", "폴리페모스",
|
||||
"키르케", "칼립소", "세이렌", "스킬라", "카립디스", "아이올로스", "헬리오스",
|
||||
"구혼자", "귀향", "파이에케스", "로토파고이", "수의", "표류", "항해", "저승",
|
||||
"페넬로페", "텔레마코스", "안티노오스", "나우시카", "이타카",
|
||||
"아무도", "난파", "변장", "활 시합",
|
||||
]
|
||||
# 오디세이아와 무관하거나 내용이 빈약한 위키 섹션 — 씨앗에서 제외(뽑히면 '그리스로마신화'로 샌다).
|
||||
SEED_DROP = [
|
||||
"개요", "어원", "특징", "총평", "평가", "긍정적", "부정적", "기타", "그 외",
|
||||
"가계도", "가족", "대중매체", "창작물", "미디어", "만화", "숭배", "해석",
|
||||
"신화 속 행적", "신화에서", "전승", "이름", "별명", "어록", "상징",
|
||||
"비블리오테케", "텔레고네이아", # 오디세이아 외 전거(다른 신화 출처)
|
||||
]
|
||||
|
||||
|
||||
def _seed_pool(rows):
|
||||
"""범용·무관 섹션을 걸러 '오디세이아에 실질적으로 쓸 만한' 씨앗만 남긴다."""
|
||||
pool = []
|
||||
for r in rows:
|
||||
kw = r["keyword"].strip()
|
||||
if not kw or kw == r["king"]: # 최상단 표제(= 인물명) 제외
|
||||
continue
|
||||
if any(bad in kw for bad in SEED_DROP): # 범용/무관 섹션 제외
|
||||
continue
|
||||
pool.append(r)
|
||||
return pool
|
||||
|
||||
|
||||
def pick_seed(rows, king=None, keyword=None):
|
||||
"""대본의 씨앗이 될 (인물, 썰키워드) 한 건 선택. ★원 소스는 항상 오디세이아.
|
||||
※ CSV 의 is_anecdote(Y) 는 '평가/기타/대중매체' 같은 범용 섹션에 붙어 오히려 오디세이아를 벗어나므로 쓰지 않는다."""
|
||||
cand = rows
|
||||
if king:
|
||||
cand = [r for r in cand if r["king"] == king]
|
||||
if not cand:
|
||||
kings = sorted({r["king"] for r in rows})
|
||||
sys.exit(f"[!] '{king}' 없음. 가능: {', '.join(kings)}")
|
||||
if keyword:
|
||||
kw_match = [r for r in cand if keyword in r["keyword"]]
|
||||
if not kw_match:
|
||||
sys.exit(f"[!] '{keyword}' 키워드가 {king or '전체'}에 없음.")
|
||||
return random.choice(kw_match)
|
||||
# ① 오디세이아 핵심 사건 키워드를 최우선(항해·괴물·귀향 등). 인물명도 함께 검사 —
|
||||
# 키워드 문구에 힌트가 없어도 인물 자체가 오디세이아 전용(세이렌·키르케 등)이면 씨앗으로 유효.
|
||||
odyssey = [r for r in cand
|
||||
if any(h in r["keyword"] or h in r["king"] for h in ODYSSEY_HINTS)]
|
||||
if odyssey:
|
||||
return random.choice(odyssey)
|
||||
# ② 없으면 범용/무관 섹션을 걸러낸 실질 씨앗 풀에서
|
||||
pool = _seed_pool(cand)
|
||||
if pool:
|
||||
return random.choice(pool)
|
||||
# ③ 최후 폴백: 빈 풀 방지
|
||||
return random.choice(cand)
|
||||
|
||||
|
||||
NARR_SYSTEM = """\
|
||||
너는 한국 숏폼(쇼츠/릴스) 전문 카피라이터다. '병맛(어이없고 황당하지만 묘하게 중독성 있는)' 유머 톤으로,
|
||||
오디세이아 속 오디세우스와 그 일행·신들의 '썰'을 풀다가 마지막에 자연스러운 척 억지로 광고로 갈아타는 {seconds}초 나레이션 대본을 쓴다.
|
||||
|
||||
[소재 원칙] (가장 중요)
|
||||
- 원 소스는 반드시 서사시 '오디세이아'(오디세우스의 10년 귀향 대모험)다. 모든 썰은 이 항해 이야기 안에서 풀어라.
|
||||
- 인물이 신(포세이돈·아테나·제우스 등)이라도 '오디세이아 속 그 신의 역할'로만 다뤄라
|
||||
(포세이돈=귀향을 방해한 바다신, 아테나=오디세우스를 편애해 도운 여신 식). 오디세이아 밖 일반 그리스·로마 신화
|
||||
일화(제우스의 바람기, 신들의 탄생·계보, 헤라클레스 과업 등)로 새지 마라 — 그건 다른 장르다.
|
||||
|
||||
[톤 규칙]
|
||||
- 구어체 반말 나레이션. TTS(AI 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게.
|
||||
- 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환.
|
||||
- 오디세이아 내용(인물 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다.
|
||||
유명 장면(트로이 목마, 외눈박이 폴리페모스 "아무도 아니다" 사기, 세이렌의 노래, 키르케의 돼지 마법,
|
||||
칼립소의 섬, 스킬라와 카립디스, 페넬로페의 수의 짜기, 활쏘기 시합과 구혼자 응징 등)을 드립 소재로 적극 활용해도 된다.
|
||||
포세이돈의 분노, 아테나의 편애 같은 유명 밈은 써도 되지만 선정적으로 묘사하진 마라.
|
||||
단, 시청자가 '진짜 이야기'와 '드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
|
||||
- 2026년 영화화(각색판)에서 원작과 달라진 지점(예: 트로이 목마를 신의 법도를 어긴 죄로 재해석, 칼립소와의
|
||||
동거 삭제, 왕위를 아들에게 넘기고 다시 떠나는 결말 등)도 "요즘은 영화로도 나왔는데 각색이 다르다더라" 식
|
||||
드립으로 언급해도 된다. 단, 실제 배우·감독 등 실존 인물 이름은 절대 언급하지 마라 — 오직 각색 내용(줄거리
|
||||
차이)만 다뤄라.
|
||||
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
|
||||
|
||||
[구조] (반드시 이 4단)
|
||||
1) hook : 3초 안에 스크롤 멈추게 하는 한 방. 질문/충격/궁금증.
|
||||
2) story : 썰 본문. 역사로 시작해 점점 병맛으로 과열. 광고로 넘어갈 '연결고리'를 은근히 심어둔다.
|
||||
3) pivot : "근데 알고 보니/그래서/요즘 같으면…" 식으로 광고로 억지 전환. 이 억지스러움 자체가 개그.
|
||||
4) cta : 광고 대상의 핵심 셀링포인트 + 행동유도 한 줄.
|
||||
|
||||
[분량]
|
||||
- 한국어 나레이션 기준 약 {seconds}초 분량 = {lo}~{hi}자. full_narration 은 반드시 이 글자수 범위로.
|
||||
- 이 범위를 넘기지 마라. 너무 길면 TTS가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
|
||||
|
||||
[출력]
|
||||
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
|
||||
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
|
||||
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
|
||||
"""
|
||||
|
||||
NARR_USER = """\
|
||||
[이번 쇼츠 소재]
|
||||
- 인물: {king} ({doc})
|
||||
- 썰 키워드: "{keyword}"
|
||||
- 참고 출처: {url}
|
||||
|
||||
[마케팅 대상 (마지막에 이걸로 갈아타기)]
|
||||
- 가게명: {store}
|
||||
- 소개/키워드: {product}
|
||||
|
||||
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
|
||||
{seconds}초 쇼츠 나레이션을 만들어라.
|
||||
썰 키워드가 빈약하면, 그 인물이 '오디세이아(오디세우스의 귀향 대모험)'에서 맡은 역할·사건
|
||||
(항해·괴물·표류·귀향·구혼자 응징 등)으로 살을 붙여라. 오디세이아와 무관한 다른 신화 일화로 새지 마라.
|
||||
"""
|
||||
|
||||
NARR_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
|
||||
"hook": {"type": "string"},
|
||||
"story": {"type": "string"},
|
||||
"pivot": {"type": "string"},
|
||||
"cta": {"type": "string"},
|
||||
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
|
||||
"estimated_seconds": {"type": "integer"},
|
||||
"hashtags": {"type": "array", "items": {"type": "string"}},
|
||||
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
|
||||
},
|
||||
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
|
||||
}
|
||||
|
||||
|
||||
def generate(client, model, seed, product, seconds, store=""):
|
||||
"""신화 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini). store = 가게명(SNS 캡션용)."""
|
||||
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
|
||||
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
|
||||
user = NARR_USER.format(
|
||||
king=seed["king"], doc=seed["doc"], keyword=seed["keyword"], url=seed["url"],
|
||||
product=product, store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
|
||||
)
|
||||
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
|
||||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=user,
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=sys_prompt,
|
||||
temperature=1.1, # 병맛 = 약간 높은 창의성
|
||||
response_mime_type="application/json",
|
||||
response_schema=NARR_SCHEMA,
|
||||
),
|
||||
)
|
||||
data = json.loads(resp.text)
|
||||
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
|
||||
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
|
||||
data["_product"] = product
|
||||
return data
|
||||
|
||||
|
||||
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
|
||||
|
||||
SB_SYSTEM = """\
|
||||
너는 숏폼 영상 디렉터다. 주어진 '병맛 오디세이아 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
|
||||
|
||||
[핵심 규칙]
|
||||
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
|
||||
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
|
||||
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
|
||||
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
|
||||
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
|
||||
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the muscular god dramatically throwing a lightning bolt from a cloud, sweating)
|
||||
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
|
||||
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
|
||||
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
|
||||
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
|
||||
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
|
||||
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
|
||||
→ 그래야 컷마다 눈 표정이 다양해진다.
|
||||
★ 잔혹 장면(눈 찌르기, 학살, 잡아먹힘 등)은 이미지 안전필터에 걸려 생성이 거부된다.
|
||||
폭력을 직접 묘사하지 말고 만화적으로 순화해 적어라 — 유혈/찌르는 순간/시체 금지.
|
||||
(예: 눈 찌르기 → the cyclops comically covering his eye with cartoon stars around his head,
|
||||
학살 → suitors comically fleeing in panic with exaggerated cartoon expressions)
|
||||
- camera: 그 장면에 어울리는 카메라 움직임을 아래 중 하나로 고른다. 장면의 '감정'에 맞춰라.
|
||||
reveal : 상황 전체가 드러남 (첫 컷 기본)
|
||||
punch_in : 반전·폭로·핵심 한 방 (강하다. 영상당 1~2번만)
|
||||
slow_in : 몰입이 쌓이는 서술, 마지막 CTA
|
||||
slow_out : 상황이 점점 보이는 서술
|
||||
pan_left / pan_right : 이동·전개·시간 흐름
|
||||
tilt_up : 올려다봄 — 권위·거대함·감탄
|
||||
tilt_down : 내려다봄 — 추락·좌절·한심함
|
||||
shake : 충격·당황·사고 (강하다. 영상당 1번이면 충분)
|
||||
hold : 정적인 대사·여운. 강한 컷 앞뒤에 넣으면 대비가 산다
|
||||
★ 같은 값을 3연속으로 쓰지 마라. 강한 효과(punch_in/shake)를 연달아 쓰면 산만해진다.
|
||||
|
||||
[전역 설정]
|
||||
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
|
||||
★ 한국의 '그리스 로마 신화 학습만화'처럼, 신을 '진짜 신의 모습'으로 그린다 — 의인화 동물 금지.
|
||||
· 남신/남자 영웅: 조각상 같은 근육질의 멋진 체격(broad shoulders, heroic muscular greek-statue
|
||||
physique), 잘생긴 얼굴. · 여신: 아름답고 늘씬한 몸매(beautiful, slender, graceful), 긴 머리.
|
||||
· 반드시 '성인 8등신 비율'로: tall adult heroic proportions, 8-heads-tall elegant body,
|
||||
NOT chibi, NOT super-deformed, NOT cute mascot — 치비/SD/애기얼굴 금지.
|
||||
→ 단 과한 노출·선정적 묘사는 금지. 토가/키톤/갑옷 등 신화 복식은 제대로 입힌다.
|
||||
복식/색/머리/상징물(투구·활·삼지창·베일 등 그 인물의 아이콘)을 못박아 컷마다 동일하게.
|
||||
(예: Odysseus as a tall rugged hero with a heroic muscular greek-statue physique, short dark curly
|
||||
hair and a trimmed beard, wearing a worn bronze breastplate and a red cloak, holding a bow and a
|
||||
spear / Athena as a beautiful slender goddess with long dark hair, wearing a bronze helmet and
|
||||
flowing white chiton with a golden owl emblem, holding a spear and round shield)
|
||||
★ 눈 그림체: 순정만화/한국 신화만화풍의 크고 또렷한 눈(large expressive sparkling manhwa eyes,
|
||||
detailed irises with light reflections)으로 통일. 이 '그림체'만 유지하고, 장면 감정에 따라
|
||||
눈을 감거나(eyes closed) 실눈(squint)·윙크·부릅뜸(wide dramatic glare) 등 표정은 바꿔도 된다.
|
||||
- style_guide: 그림 스타일을 영어로. 한국 그리스로마신화 학습만화풍(classic Korean mythology manhwa
|
||||
style) — 깔끔하고 또렷한 외곽선, 화사하고 선명한 색, 반짝이는 하이라이트, 극적인 연출,
|
||||
'성인 비율의 캐릭터(tall adult proportions, never chibi or super-deformed)',
|
||||
단순 배경(에게해·범선·동굴·고대 그리스 신전 등 오디세이아 항해 분위기), 세로 9:16.
|
||||
|
||||
[출력] 지정된 JSON 스키마로만.
|
||||
"""
|
||||
|
||||
SB_USER = """\
|
||||
[마케팅 대상] {product}
|
||||
[소재 인물] {king}
|
||||
[목표 장면 수] 약 {scenes}개
|
||||
|
||||
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
|
||||
{narration}
|
||||
"""
|
||||
|
||||
SB_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"character_sheet": {"type": "string"},
|
||||
"style_guide": {"type": "string"},
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"narration": {"type": "string"},
|
||||
"caption": {"type": "string"},
|
||||
"image_prompt": {"type": "string"},
|
||||
# 값은 media.CAMERA_KINDS 와 반드시 일치해야 한다.
|
||||
# 모르는 값이 오면 렌더가 조용히 기본 순환으로 떨어진다.
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reveal", "punch_in", "slow_in", "slow_out",
|
||||
"pan_left", "pan_right", "tilt_up", "tilt_down",
|
||||
"shake", "hold",
|
||||
],
|
||||
},
|
||||
},
|
||||
"required": ["narration", "caption", "image_prompt", "camera"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["character_sheet", "style_guide", "scenes"],
|
||||
}
|
||||
|
||||
|
||||
def clamp_scenes(scene_list, target):
|
||||
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
|
||||
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
|
||||
def clen(s):
|
||||
return len(s.get("narration", "").strip())
|
||||
|
||||
while len(scene_list) > target:
|
||||
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
|
||||
i = min(range(len(scene_list) - 1),
|
||||
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
|
||||
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
|
||||
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
|
||||
base["narration"] = (a + " " + b).strip()
|
||||
scene_list[i:i + 2] = [base]
|
||||
return scene_list
|
||||
|
||||
|
||||
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
|
||||
"""나레이션 → 스토리보드 dict (Gemini)."""
|
||||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=SB_USER.format(product=product, king=king,
|
||||
scenes=scenes, narration=narration),
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=SB_SYSTEM,
|
||||
temperature=0.8,
|
||||
response_mime_type="application/json",
|
||||
response_schema=SB_SCHEMA,
|
||||
),
|
||||
)
|
||||
sb = json.loads(resp.text)
|
||||
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
|
||||
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
|
||||
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
|
||||
raw = sb.get("scenes", [])
|
||||
kept = [s for s in raw if s.get("narration", "").strip()]
|
||||
if len(kept) < len(raw):
|
||||
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
|
||||
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
|
||||
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
|
||||
sc["narration"] = _no_emoji(sc.get("narration", ""))
|
||||
sc["caption"] = _no_emoji(sc.get("caption", ""))
|
||||
sb["_product"] = product
|
||||
sb["_king"] = king
|
||||
return sb
|
||||
@ -1,282 +0,0 @@
|
||||
# -*- 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:
|
||||
# 리뷰 수집 실패로 생성 전체를 죽이지 않는다 — 가게명만으로라도 계속 간다.
|
||||
print(f"[!] 리뷰 수집 실패(수집된 정보만으로 계속): {type(e).__name__}: {e}")
|
||||
rstore, reviews = None, []
|
||||
if rstore and not brief_store:
|
||||
brief_store = rstore
|
||||
bullets = (NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||
if reviews else [])
|
||||
if bullets:
|
||||
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||
elif brief_store:
|
||||
# 브리핑·방문자 리뷰·블로그 리뷰 모두 부실 — 에러로 끝내지 않고
|
||||
# 그때까지 수집된 정보(가게명)만으로 생성을 계속한다.
|
||||
print(f"■ 소재 부족 → 수집된 정보(가게명 '{brief_store}')만으로 생성 진행")
|
||||
bullets = [brief_store]
|
||||
# 가게명조차 없으면 bullets 가 비어 아래 product 가드에서 종료된다.
|
||||
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()
|
||||
@ -1,980 +0,0 @@
|
||||
# -*- 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_odyssey" # 통합 출력: shorts_all/output/animation_odyssey/
|
||||
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.1-flash-image" # 인체·화풍 품질과 비용 절충. 최상위: gemini-3-pro-image, 더 저렴하게: 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 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),
|
||||
# 모든 영상의 모든 컷이 그 캐릭터의 얼굴/눈/화풍을 그대로 따라간다.
|
||||
# ★ odyssey 는 '실제 사람/신 모습'이라 조선/삼국지의 고양이 레퍼런스를 쓰지 않는다(폴더 비움).
|
||||
# 비어 있으면 각 영상의 첫 컷이 자동 앵커가 되어 컷 간 일관성을 유지한다.
|
||||
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 "")))
|
||||
# 이미지 모델도 TTS처럼 가끔 빈 응답(content=None)을 준다(일시 오류·안전필터 차단 등).
|
||||
# → gen_tts 와 동일하게: 안전 추출 + finish_reason 기록 + 점증 백오프 재시도.
|
||||
retries, last = 4, ""
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
resp = client.models.generate_content(
|
||||
model=model or IMG_MODEL,
|
||||
contents=parts,
|
||||
config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
|
||||
)
|
||||
for c in (getattr(resp, "candidates", None) or []):
|
||||
content = getattr(c, "content", None)
|
||||
for p in (getattr(content, "parts", None) or []) if content else []:
|
||||
inline = getattr(p, "inline_data", None)
|
||||
if inline and inline.data:
|
||||
return inline.data
|
||||
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
|
||||
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 ↻ 이미지 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
|
||||
end="", flush=True)
|
||||
time.sleep(wait)
|
||||
raise RuntimeError(f"이미지 생성 실패(재시도 {retries}회): {last}\n 프롬프트: {prompt[:80]}…")
|
||||
|
||||
|
||||
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 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
|
||||
# 카메라 순환 시작점 — 콘텐츠마다 다르되 같은 콘텐츠면 항상 같다.
|
||||
# (media.motion_seed 주석 참조)
|
||||
cam_seed = MU.motion_seed("".join(str(s.get("narration", "")) for s in scenes))
|
||||
|
||||
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 = MU.scene_motion(sc, idx, len(scenes), frames, cam_seed)
|
||||
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
|
||||
@ -1,276 +0,0 @@
|
||||
# -*- 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())
|
||||
@ -1,15 +0,0 @@
|
||||
[캐릭터 공식 레퍼런스 폴더 — '눈만 고정']
|
||||
|
||||
이 폴더에 고양이 왕 기준 이미지를 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.
|
Before Width: | Height: | Size: 164 KiB |
@ -1,408 +0,0 @@
|
||||
번호,인물,문서제목,썰후보여부,키워드,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,920 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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.
|
Before Width: | Height: | Size: 15 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user