Compare commits

..

1 Commits

Author SHA1 Message Date
9aa4e0a343 fix: 이미지 태그 EASONING_EFFORT = "low" 지정 2026-09-07 11:31:32 +09:00
132 changed files with 1934 additions and 21230 deletions

3
.gitignore vendored
View File

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

View File

@ -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(

View File

@ -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,

View File

@ -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

View File

@ -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"

View File

@ -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
]

View File

@ -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,51 +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}"
)
# P2V 고아 잡 스윕 — 검수 방치·크래시로 남은 선차감분을 환불하고,
# archiving 에 갇힌 잡을 재시도 가능 상태로 되돌린다 (설계 R-1·R-3).
# 스키마는 앱이 만들지 않는다 — migration_2026-08-25_p2v.sql 수동 실행.
from config import p2v_settings
if p2v_settings.P2V_ENABLED:
try:
from app.database.session import BackgroundSessionLocal
from app.p2v.services import f1_service, f2_service
async with BackgroundSessionLocal() as session:
swept = await f1_service.sweep_orphans(session)
swept += await f2_service.sweep_orphans(session)
await session.commit()
if swept:
logger.info(f"[p2v] 고아 잡 {swept}건 환불·정리")
except Exception as e:
logger.error(
f"[p2v] 고아 스윕 실패 (다음 기동 시 재시도): "
f"{type(e).__name__}: {e}"
)
await NvMapPwScraper.initiate_scraper()
except asyncio.TimeoutError:
logger.error("Database initialization timed out")
@ -91,24 +44,12 @@ 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
from app.p2v.services.client import close_client as close_p2v_client
await close_shared_client()
await close_shared_blob_client()
await close_p2v_client()
from app.database.like_cache import close_like_cache
await close_like_cache()

View File

@ -326,43 +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,
},
)
# P2vException 핸들러 추가
# (SsulboxException 과 같은 (message, status_code, code) 형태 — 수동 등록)
from app.p2v.exceptions import P2vException
@app.exception_handler(P2vException)
def p2v_exception_handler(request: Request, exc: P2vException) -> Response:
if exc.status_code < 500:
logger.warning(f"Handled P2vException: {exc.__class__.__name__} - {exc.message}")
else:
logger.error(f"Handled P2vException: {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):
# 에러 메시지 로깅 (한글 포함 가능)

View File

@ -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):

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -87,10 +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,
)
from app.p2v.models import P2vF1Job, P2vF2Job # noqa: F401
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
tables_to_create = [
@ -113,11 +109,6 @@ async def create_db_tables():
Admin.__table__,
CreditChargeRequest.__table__,
CreditTransaction.__table__,
# 썰박스 (FK 순서: ssul_content 를 나머지가 참조)
SsulContent.__table__,
# P2V (FK 순서: p2v_video 를 p2v_poster 가 참조)
P2vF1Job.__table__,
P2vF2Job.__table__,
]
logger.info("Creating database tables...")
@ -164,11 +155,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
except Exception as e:
await session.rollback()
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
# FastShipError 계열(InsufficientCreditError 등)은 status_code 가 아니라 status 속성을 쓴다
status_code = getattr(e, "status_code", None)
if status_code is None:
status_code = getattr(e, "status", 500)
if status_code < 500:
if getattr(e, "status_code", 500) < 500:
logger.warning(
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"

View File

@ -1,12 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V — 무빙 포스터(F1) / 포스터 스타일링(F2) 프록시 모듈.
P2V 서버(:8010, o2o-ado2-poster-to-video) 사용자 개념이 없는 별도 프로세스다.
모듈은 앞에서 castad 책임지는 것들을 얹는다:
- 인증(JWT) 소유권 (p2v_video / p2v_poster 테이블)
- 크레딧 선차감·환불 (credit_transaction 원장 재사용, job_type='p2v_f1'|'p2v_f2')
- 완성 결과물의 Azure Blob 아카이빙 (P2V 컨테이너가 재생성돼도 결과물이 살아남게)
설계 문서: docs/design/p2v-credit-integration.md
"""

View File

@ -1,429 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V F1(무빙 포스터) 프록시 라우터.
생성 폴링 (검수: 나레이션/메타 수정 승인) 폴링 done 2 게이트.
크레딧은 생성 시점 선차감. 실패 즉시 환불하지 않는다 재시도 버튼이 있어
"환불 + 공짜 재시도" 되기 때문이다. 환불은 삭제·고아 스윕에서 일어난다
(f1_service 모듈 docstring 참조).
"""
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.credit.exceptions import InsufficientCreditError
from app.database.session import AsyncSessionLocal, get_session
from app.p2v.exceptions import (
P2vDisabledError,
P2vException,
P2vInvalidStateError,
P2vUploadTooLargeError,
P2vUpstreamError,
)
from app.p2v.models import P2vF1Job
from app.p2v.schemas.p2v_schema import (
ApproveRequest,
MetadataUpdateRequest,
NarrationUpdateRequest,
P2vDeleteResponse,
P2vF1StatusResponse,
P2vJobCreateResponse,
P2vJobError,
P2vMetadata,
)
from app.p2v.services import archive_service, client, f1_service
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from config import p2v_settings
logger = get_logger("p2v")
router = APIRouter(prefix="/p2v/f1", tags=["P2V"])
def _guard_enabled() -> None:
if not p2v_settings.P2V_ENABLED:
raise P2vDisabledError()
@router.post(
"/jobs",
response_model=P2vJobCreateResponse,
summary="무빙 포스터 생성 요청",
description="**이 시점에 크레딧이 선차감됩니다.** 검수 승인 없이 방치하면 "
"일정 시간 후 자동 환불·실패 처리됩니다.",
responses={
401: {"description": "인증 실패"},
402: {"description": "크레딧 부족"},
413: {"description": "용량 초과"},
503: {"description": "P2V 서버 연결 실패"},
},
)
async def create_job(
background_tasks: BackgroundTasks,
poster: UploadFile = File(...),
name: str = Form(""),
current_user: User = Depends(get_current_user),
) -> P2vJobCreateResponse:
_guard_enabled()
raw = await poster.read()
if len(raw) > p2v_settings.P2V_MAX_UPLOAD_BYTES:
raise P2vUploadTooLargeError(p2v_settings.P2V_MAX_UPLOAD_BYTES)
# 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다 (ssulbox create_ssul 패턴)
async with AsyncSessionLocal() as session:
try:
row = await f1_service.create_job(
session, user_uuid=current_user.user_uuid, name=name.strip()
)
await session.commit()
job_id = row.id
except InsufficientCreditError:
await session.rollback()
logger.info(f"[f1] INSUFFICIENT CREDIT user={current_user.user_uuid}")
raise
except Exception:
await session.rollback()
raise
# 차감이 확정된 뒤에야 P2V 를 부른다
try:
created = await client.request_json(
"POST",
"/api/f1/jobs",
data={"name": name},
files={
"poster": (
poster.filename or "poster.png",
raw,
poster.content_type or "image/png",
)
},
)
except P2vException as e:
# P2V 를 태우지 못했다(실비용 없음) — 환불하고 실패로 남긴다
async with AsyncSessionLocal() as session:
failed_row = await session.get(P2vF1Job, job_id)
if failed_row is not None:
await f1_service.fail_job(
session, failed_row, f"P2V 요청 실패: {e.message}", refund=True
)
await session.commit()
raise
async with AsyncSessionLocal() as session:
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
row.p2v_job_id = str(created["id"])
await session.commit()
background_tasks.add_task(
archive_service.upload_source_image,
"f1",
job_id,
current_user.user_uuid,
raw,
poster.filename or "poster.png",
)
return P2vJobCreateResponse(
id=job_id,
status="queued",
poll_interval_seconds=p2v_settings.P2V_POLL_HINT_SECONDS,
)
def _error_of(row: P2vF1Job) -> Optional[P2vJobError]:
if row.status != "failed" or not row.error:
return None
stage, _, detail = row.error.partition(": ")
return P2vJobError(stage=stage or "?", detail=detail or row.error)
def _metadata_of(row: P2vF1Job) -> Optional[P2vMetadata]:
if not (row.event_name or row.place or row.date_text):
return None
return P2vMetadata(
event_name=row.event_name or "",
date_text=row.date_text or "",
place=row.place or "",
keywords=row.keywords,
)
def _db_response(row: P2vF1Job) -> P2vF1StatusResponse:
"""P2V 를 부르지 않고 castad DB 미러만으로 응답 (archived/failed 종결 상태)."""
artifacts: dict[str, str] = {}
if row.p2v_video_url:
artifacts["video"] = row.p2v_video_url
if row.poster_url:
artifacts["thumbnail"] = row.poster_url
return P2vF1StatusResponse(
id=row.id,
status=row.status, # type: ignore[arg-type]
name=row.name,
narration=row.narration,
metadata=_metadata_of(row),
duration=float(row.duration) if row.duration is not None else None,
error=_error_of(row),
artifacts=artifacts,
)
@router.get(
"/jobs/{job_id}",
response_model=P2vF1StatusResponse,
summary="생성 진행 상태 (폴링)",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def get_job(
job_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> P2vF1StatusResponse:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
# 종결 상태 — P2V 를 부르지 않는다 (컨테이너 재기동으로 잡이 지워졌을 수 있다)
if row.archived_at is not None or row.status == "failed" or not row.p2v_job_id:
return _db_response(row)
try:
p2v_job = await client.request_json("GET", f"/api/f1/jobs/{row.p2v_job_id}")
except P2vUpstreamError as e:
if e.status_code == 404:
# P2V 재기동으로 잡 소실 — 재시도가 불가능하므로 환불하고 실패 처리
await f1_service.fail_job(
session,
row,
"서버 재시작으로 작업이 유실되었습니다. 크레딧을 환불했습니다",
refund=True,
)
await session.commit()
return _db_response(row)
raise
f1_service.sync_from_p2v(row, p2v_job)
status = row.status
if p2v_job.get("status") == "done" and row.archived_at is None:
# Blob 업로드 전까지는 진행 중으로 보인다 — URL 없는 done 창을 만들지 않는다
status = "archiving"
background_tasks.add_task(archive_service.archive_f1, row.id)
await session.commit()
# 검수용 영역분석 이미지는 프록시 경로로 재작성한다 (regions/ 는 인증 프록시)
artifacts: dict[str, str] = {}
check = (p2v_job.get("artifacts") or {}).get("check_jpg")
if check and check.startswith("/files/"):
artifacts["check_jpg"] = "/p2v" + check
return P2vF1StatusResponse(
id=row.id,
status=status, # type: ignore[arg-type]
stage=p2v_job.get("stage"),
stages=p2v_job.get("stages"),
name=row.name,
narration=row.narration,
metadata=_metadata_of(row),
motion_elements=p2v_job.get("motion_elements"),
duration=float(row.duration) if row.duration is not None else None,
queue_size=p2v_job.get("queue_size"),
error=_error_of(row),
artifacts=artifacts,
)
# =============================================================================
# 검수 게이트
# =============================================================================
@router.put(
"/jobs/{job_id}/narration",
summary="검수 중 나레이션 수정",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def update_narration(
job_id: int,
body: NarrationUpdateRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if not row.p2v_job_id:
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
await client.request_json(
"PUT",
f"/api/f1/jobs/{row.p2v_job_id}/narration",
json_body={"narration": body.narration},
)
row.narration = body.narration
await session.commit()
return {"ok": True}
@router.put(
"/jobs/{job_id}/metadata",
summary="검수 중 행사 메타데이터 수정",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def update_metadata(
job_id: int,
body: MetadataUpdateRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if not row.p2v_job_id:
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
await client.request_json(
"PUT",
f"/api/f1/jobs/{row.p2v_job_id}/metadata",
json_body=body.model_dump(),
)
row.event_name = body.event_name[:200]
row.date_text = body.date_text[:100] or None
row.place = body.place[:200]
await session.commit()
return {"ok": True}
@router.post(
"/jobs/{job_id}/approve",
summary="검수 승인 — TTS·BGM·애니메이션·렌더 시작",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def approve_job(
job_id: int,
body: Optional[ApproveRequest] = None,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if not row.p2v_job_id:
raise P2vInvalidStateError("아직 서버에 접수되지 않은 작업입니다")
json_body = (
{"motions": body.motions} if body is not None and body.motions is not None else None
)
await client.request_json(
"POST", f"/api/f1/jobs/{row.p2v_job_id}/approve", json_body=json_body
)
row.status = "running"
await session.commit()
return {"ok": True}
@router.post(
"/jobs/{job_id}/retry",
summary="실패한 스테이지부터 재시도",
description="선차감분이 그대로 유효하므로 재시도에 크레딧이 추가 차감되지 않는다. "
"(재과금 정책은 D-2 로 보류 — 도입 시 attempt 컬럼 추가)",
responses={
401: {"description": "인증 실패"},
404: {"description": "잡 없음"},
409: {"description": "실패 상태가 아님"},
},
)
async def retry_job(
job_id: int,
body: Optional[ApproveRequest] = None,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if row.status != "failed":
raise P2vInvalidStateError(f"실패 상태가 아닙니다: {row.status}")
if not row.p2v_job_id:
raise P2vInvalidStateError("서버에 접수되지 못한 작업은 재시도할 수 없습니다")
json_body = (
{"motions": body.motions} if body is not None and body.motions is not None else None
)
await client.request_json(
"POST", f"/api/f1/jobs/{row.p2v_job_id}/retry", json_body=json_body
)
row.status = "queued"
row.error = None
await session.commit()
return {"ok": True}
@router.post(
"/jobs/{job_id}/force",
summary="실패를 무시하고 진행 (i2v 게이트 실패 전용)",
description="제목 훼손 게이트 등 클립 생성 **이후의** 실패를 사람이 확인하고 "
"이미 만들어진 클립으로 렌더를 잇는다. 재시도와 달리 Higgsfield 재생성이 없다. "
"클립이 없는 실패는 P2V 가 409 로 거절한다.",
responses={
401: {"description": "인증 실패"},
404: {"description": "잡 없음"},
409: {"description": "실패 상태가 아니거나 무시할 수 없는 실패"},
},
)
async def force_job(
job_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
if row.status != "failed":
raise P2vInvalidStateError(f"실패 상태가 아닙니다: {row.status}")
if not row.p2v_job_id:
raise P2vInvalidStateError("서버에 접수되지 못한 작업은 진행할 수 없습니다")
await client.request_json("POST", f"/api/f1/jobs/{row.p2v_job_id}/force")
row.status = "queued"
row.error = None
await session.commit()
return {"ok": True}
@router.delete(
"/jobs/{job_id}",
response_model=P2vDeleteResponse,
summary="잡과 산출물 삭제 — 되돌릴 수 없다",
description="완성(done) 전에 버리는 잡은 이 시점에 크레딧이 환불된다.",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def delete_job(
job_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> P2vDeleteResponse:
_guard_enabled()
row = await f1_service.get_owned(session, job_id, current_user.user_uuid)
# P2V 쪽 산출물 먼저 정리. 이미 사라진 잡(404)은 정상 — 지울 게 없다.
if row.p2v_job_id:
try:
await client.request_json("DELETE", f"/api/f1/jobs/{row.p2v_job_id}")
except P2vUpstreamError as e:
if e.status_code != 404:
raise # 진행 중(409) 등은 그대로 중계 — 반쪽 삭제를 만들지 않는다
# 환불 판정은 잠금 재조회로 한다 (2026-08-26 리뷰 TOCTOU 반영) —
# 처음 읽은 스냅샷과 달리, 그 사이 백그라운드 아카이빙이 done 으로 전이를
# 커밋했을 수 있다. FOR UPDATE 로 최신 상태를 고정한 뒤 판정해야
# "완성본도 받고 환불도 받는" 창이 닫힌다.
result = await session.execute(
select(P2vF1Job)
.where(P2vF1Job.id == job_id)
.with_for_update()
)
row = result.scalar_one_or_none()
if row is None:
return P2vDeleteResponse(id=job_id, removed=True)
# done(완성)·archiving(렌더 완료, 업로드 중)은 결과물이 이미 만들어졌다 — 환불 없음
if row.archived_at is None and row.status not in ("done", "archiving"):
await f1_service.refund_job(session, row, reason="무빙 포스터 삭제(미완성)")
await session.delete(row)
await session.commit()
return P2vDeleteResponse(id=job_id, removed=True)

View File

@ -1,335 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V F2(포스터 스타일링) 프록시 라우터.
브라우저 castad(인증·크레딧) P2V 서버(:8010) 중계.
템플릿 목록 조회는 순수 중계, 생성만 크레딧 선차감이 붙는다.
"""
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.credit.exceptions import InsufficientCreditError
from app.database.session import AsyncSessionLocal, get_session
from app.p2v.exceptions import (
P2vDisabledError,
P2vException,
P2vUploadTooLargeError,
P2vUpstreamError,
)
from app.p2v.models import P2vF2Job
from app.p2v.schemas.p2v_schema import (
F2CategoryResponse,
F2FormatResponse,
F2TemplateResponse,
F2UploadHintResponse,
P2vF2StatusResponse,
P2vJobCreateResponse,
P2vJobError,
)
from app.p2v.services import archive_service, client, f2_service
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from config import p2v_settings
logger = get_logger("p2v")
router = APIRouter(prefix="/p2v/f2", tags=["P2V"])
def _guard_enabled() -> None:
if not p2v_settings.P2V_ENABLED:
raise P2vDisabledError()
def _proxy_thumb(url: str) -> str:
"""P2V 의 /files/... 경로를 castad 프록시 경로로 재작성한다."""
if url.startswith("/files/"):
return "/p2v" + url
return url
def _rewrite_template(t: dict) -> dict:
return {**t, "thumb_url": _proxy_thumb(t.get("thumb_url", ""))}
# =============================================================================
# 템플릿 · 메타 (순수 중계)
# =============================================================================
@router.get(
"/templates",
response_model=list[F2TemplateResponse],
summary="스타일 템플릿 목록",
responses={401: {"description": "인증 실패"}, 503: {"description": "P2V 서버 연결 실패"}},
)
async def list_templates(
current_user: User = Depends(get_current_user),
) -> list[dict]:
_guard_enabled()
items = await client.request_json("GET", "/api/f2/templates")
return [_rewrite_template(t) for t in items]
@router.post(
"/templates",
response_model=F2TemplateResponse,
summary="사용자 레퍼런스 업로드",
description="저장 + 화풍 분석까지 P2V 가 동기로 끝낸다(10초 안팎).",
responses={401: {"description": "인증 실패"}, 413: {"description": "용량 초과"}},
)
async def create_template(
reference: UploadFile = File(...),
name: str = Form(""),
current_user: User = Depends(get_current_user),
) -> dict:
_guard_enabled()
raw = await reference.read()
if len(raw) > p2v_settings.P2V_MAX_UPLOAD_BYTES:
raise P2vUploadTooLargeError(p2v_settings.P2V_MAX_UPLOAD_BYTES)
created = await client.request_json(
"POST",
"/api/f2/templates",
data={"name": name},
files={
"reference": (
reference.filename or "reference.png",
raw,
reference.content_type or "image/png",
)
},
)
return _rewrite_template(created)
@router.delete(
"/templates/{template_id}",
summary="사용자 레퍼런스 삭제",
responses={401: {"description": "인증 실패"}, 404: {"description": "삭제 불가 템플릿"}},
)
async def delete_template(
template_id: str,
current_user: User = Depends(get_current_user),
) -> dict:
_guard_enabled()
return await client.request_json("DELETE", f"/api/f2/templates/{template_id}")
@router.get("/categories", response_model=list[F2CategoryResponse], summary="템플릿 카테고리")
async def list_categories(current_user: User = Depends(get_current_user)) -> list[dict]:
_guard_enabled()
return await client.request_json("GET", "/api/f2/categories")
@router.get("/formats", response_model=list[F2FormatResponse], summary="출력 포맷 목록")
async def list_formats(current_user: User = Depends(get_current_user)) -> list[dict]:
_guard_enabled()
return await client.request_json("GET", "/api/f2/formats")
@router.get("/upload-hint", response_model=F2UploadHintResponse, summary="레퍼런스 업로드 안내")
async def upload_hint(current_user: User = Depends(get_current_user)) -> dict:
_guard_enabled()
return await client.request_json("GET", "/api/f2/upload-hint")
# =============================================================================
# 잡
# =============================================================================
@router.post(
"/jobs",
response_model=P2vJobCreateResponse,
summary="스타일 변환 요청",
description="**이 시점에 크레딧이 선차감됩니다.** 실패 시 자동 환불됩니다.",
responses={
401: {"description": "인증 실패"},
402: {"description": "크레딧 부족"},
413: {"description": "용량 초과"},
503: {"description": "P2V 서버 연결 실패"},
},
)
async def create_job(
background_tasks: BackgroundTasks,
poster: UploadFile = File(...),
template_id: str = Form(...),
format: str = Form("poster"),
current_user: User = Depends(get_current_user),
) -> P2vJobCreateResponse:
_guard_enabled()
raw = await poster.read()
if len(raw) > p2v_settings.P2V_MAX_UPLOAD_BYTES:
raise P2vUploadTooLargeError(p2v_settings.P2V_MAX_UPLOAD_BYTES)
# 템플릿 스냅샷은 차감 **전에** 확보한다 — 없는 템플릿이면 크레딧을 건드리지 않는다
templates = await client.request_json("GET", "/api/f2/templates")
tpl = next((t for t in templates if t.get("id") == template_id), None)
if tpl is None:
raise P2vUpstreamError("존재하지 않는 템플릿입니다", 404)
# 포스터 이름은 업로드 파일명에서 딴다 (F2 에는 F1 의 행사명 같은 입력이 없다).
# 템플릿 이름을 이름 자리에 쓰면 "별이 빛나는 밤"이 포스터 이름처럼 보인다.
poster_name = (poster.filename or "").rsplit(".", 1)[0].strip()
# 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다 (ssulbox create_ssul 패턴)
async with AsyncSessionLocal() as session:
try:
row = await f2_service.create_job(
session,
user_uuid=current_user.user_uuid,
name=poster_name,
template_id=template_id,
template_name=tpl.get("name_ko"),
license=tpl.get("license"),
format=format,
)
await session.commit()
job_id = row.id
except InsufficientCreditError:
await session.rollback()
logger.info(f"[f2] INSUFFICIENT CREDIT user={current_user.user_uuid}")
raise
except Exception:
await session.rollback()
raise
# 차감이 확정된 뒤에야 P2V 를 부른다 — 잔액 없는 사용자가 실비용을 태우지 않게
try:
created = await client.request_json(
"POST",
"/api/f2/jobs",
data={"template_id": template_id, "format": format},
files={
"poster": (
poster.filename or "poster.png",
raw,
poster.content_type or "image/png",
)
},
)
except P2vException as e:
# 차감은 이미 확정됐다 — 새 트랜잭션에서 실패 처리(환불)한다
async with AsyncSessionLocal() as session:
failed_row = await session.get(P2vF2Job, job_id)
if failed_row is not None:
await f2_service.fail_job(
session, failed_row, f"P2V 요청 실패: {e.message}"
)
await session.commit()
raise
async with AsyncSessionLocal() as session:
row = await f2_service.get_owned(session, job_id, current_user.user_uuid)
row.p2v_job_id = str(created["id"])
await session.commit()
background_tasks.add_task(
archive_service.upload_source_image,
"f2",
job_id,
current_user.user_uuid,
raw,
poster.filename or "poster.png",
)
return P2vJobCreateResponse(
id=job_id,
status="queued",
poll_interval_seconds=p2v_settings.P2V_POLL_HINT_SECONDS,
)
@router.delete(
"/jobs/{job_id}",
summary="스타일링 잡 삭제 (내 콘텐츠 목록에서 제거)",
description="완성(done) 전에 버리는 잡은 이 시점에 크레딧이 환불된다. "
"P2V 서버에는 F2 잡 삭제 API 가 없어 castad 기록만 지운다 "
"(P2V 쪽 산출물은 서버 자체 보존 주기를 따른다).",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def delete_job(
job_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
_guard_enabled()
row = await f2_service.get_owned(session, job_id, current_user.user_uuid)
# 완성본을 이미 받았다면 환불 대상이 아니다 (실패분은 fail_job 이 이미 환불했고,
# refund 는 멱등이라 중복 호출돼도 안전하다)
if row.archived_at is None and row.status not in ("done", "archiving"):
await f2_service.refund_job(session, row, reason="포스터 스타일링 삭제(미완성)")
await session.delete(row)
await session.commit()
return {"id": job_id, "removed": True}
def _error_of(row) -> P2vJobError | None:
if row.status != "failed" or not row.error:
return None
stage, _, detail = row.error.partition(": ")
return P2vJobError(stage=stage or "?", detail=detail or row.error)
@router.get(
"/jobs/{job_id}",
response_model=P2vF2StatusResponse,
summary="스타일 변환 상태 (폴링)",
responses={401: {"description": "인증 실패"}, 404: {"description": "잡 없음"}},
)
async def get_job(
job_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> P2vF2StatusResponse:
_guard_enabled()
row = await f2_service.get_owned(session, job_id, current_user.user_uuid)
# 아카이브 완료 또는 종결 상태 — P2V 를 부를 필요가 없다 (잡이 지워졌을 수도 있다)
if row.archived_at is not None or (row.status == "failed") or not row.p2v_job_id:
artifacts = {}
if row.p2v_poster_url:
artifacts["image"] = row.p2v_poster_url
return P2vF2StatusResponse(
id=row.id,
status=row.status, # type: ignore[arg-type]
template_id=row.template_id,
error=_error_of(row),
artifacts=artifacts,
)
try:
p2v_job = await client.request_json("GET", f"/api/f2/jobs/{row.p2v_job_id}")
except P2vUpstreamError as e:
if e.status_code == 404:
# P2V 재기동 등으로 잡 소실 — 결과를 받을 길이 없으니 실패·환불
await f2_service.fail_job(
session, row, "서버 재시작으로 작업이 유실되었습니다. 크레딧을 환불했습니다"
)
await session.commit()
return P2vF2StatusResponse(
id=row.id, status="failed", template_id=row.template_id,
error=_error_of(row), artifacts={},
)
raise
await f2_service.sync_from_p2v(session, row, p2v_job)
status = row.status
if p2v_job.get("status") == "done" and row.archived_at is None:
# Blob 업로드 전까지는 진행 중으로 보인다 — URL 없는 done 창을 만들지 않는다
status = "archiving"
background_tasks.add_task(archive_service.archive_f2, row.id)
await session.commit()
return P2vF2StatusResponse(
id=row.id,
status=status, # type: ignore[arg-type]
stage=p2v_job.get("stage"),
stages=p2v_job.get("stages"),
template_id=row.template_id,
queue_size=p2v_job.get("queue_size"),
error=_error_of(row),
artifacts={}, # 결과 이미지는 아카이브 완료 후에만 내려간다
)

View File

@ -1,81 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V 정적 파일 경량 프록시.
결과물(영상·결과 이미지) Azure Blob 공개 URL 나가므로 여기를 지나지 않는다.
프록시는 **작은 이미지 종류** 중계한다:
- regions/ 검수용 영역분석 이미지. 사용자 포스터가 담기므로 로그인 필수.
(프론트는 authenticatedFetch blob URL <img> 넣는다)
- templates/, user_templates/ 템플릿 썸네일. `<img src>` 인증 헤더를
붙이고 내용도 공개 명화(퍼블릭 도메인) 인증 없이 중계한다.
인증 라우트를 분리한 이유(2026-08-26 리뷰 Critical 반영): 401 응답을 직접 만들면
프로젝트 표준 포맷(detail={"code", "message"}) 어긋나 authenticatedFetch
토큰 자동 갱신이 무력화되고 강제 로그아웃이 난다. get_current_user 의존성에
맡기면 만료/누락 케이스 모두 표준 예외 포맷으로 나간다.
render/·uploads/·f2/ 어느 라우트에도 없다 원본·결과물은 Blob 으로만 나간다.
Range 미지원(대용량 스트리밍 용도가 아니다).
"""
from fastapi import APIRouter, Depends, Response
from app.p2v.constants import PUBLIC_FILE_PREFIXES
from app.p2v.exceptions import P2vDisabledError, P2vFileNotFoundError
from app.p2v.services import client
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from config import p2v_settings
logger = get_logger("p2v")
router = APIRouter(prefix="/p2v", tags=["P2V"])
def _validate_path(path: str) -> None:
"""경로 탈출 방어. uvicorn 이 라우팅 전에 percent-decoding 하므로
%2e%2e 우회도 여기서 걸린다 (2026-08-26 리뷰에서 검증됨)."""
if not p2v_settings.P2V_ENABLED:
raise P2vDisabledError()
if ".." in path or path.startswith("/") or "\\" in path:
raise P2vFileNotFoundError()
async def _relay(path: str, cache: str) -> Response:
content, content_type = await client.download(f"/files/{path}")
return Response(
content=content,
media_type=content_type,
headers={"Cache-Control": cache},
)
# 정적 경로가 더 구체적이므로 catch-all 보다 먼저 선언한다 (ssulbox 라우트 순서 관례)
@router.get(
"/files/regions/{path:path}",
summary="검수용 영역분석 이미지 중계 (로그인 필수)",
responses={
401: {"description": "인증 실패 (표준 인증 오류 포맷 — 토큰 갱신 대상)"},
404: {"description": "없는 파일"},
},
)
async def proxy_region_file(
path: str,
current_user: User = Depends(get_current_user),
) -> Response:
_validate_path(path)
return await _relay(f"regions/{path}", cache="private, max-age=300")
@router.get(
"/files/{path:path}",
summary="P2V 공개 파일 중계 (템플릿 썸네일 전용)",
responses={404: {"description": "화이트리스트 밖이거나 없는 파일"}},
)
async def proxy_public_file(path: str) -> Response:
_validate_path(path)
if not path.startswith(PUBLIC_FILE_PREFIXES):
# render/·uploads/ 등 — 존재 여부를 노출하지 않는다
raise P2vFileNotFoundError()
return await _relay(path, cache="public, max-age=3600")

View File

@ -1,39 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V 모듈 상수."""
from typing import Final
# =============================================================================
# 크레딧 원장 멱등 키
# =============================================================================
#: credit_transaction.job_type 값. (job_type, job_ref, type) 유니크로 중복 차감을 막는다.
#: job_ref 는 p2v_video.id / p2v_poster.id 의 문자열이다 (P2V 서버 잡 id 가 아니다).
JOB_TYPE_P2V_F1: Final[str] = "p2v_f1"
JOB_TYPE_P2V_F2: Final[str] = "p2v_f2"
# =============================================================================
# 잡 상태
# =============================================================================
# P2V 서버가 보고하는 상태에 castad 전용 'archiving'(Blob 업로드 중)을 더한 축.
STATUS_QUEUED: Final[str] = "queued"
STATUS_RUNNING: Final[str] = "running"
STATUS_AWAITING_REVIEW: Final[str] = "awaiting_review"
STATUS_ARCHIVING: Final[str] = "archiving"
STATUS_DONE: Final[str] = "done"
STATUS_FAILED: Final[str] = "failed"
#: 고아 스윕 대상 — 이 상태로 P2V_ORPHAN_TIMEOUT_HOURS 를 넘기면 환불·실패 처리한다.
ORPHAN_STATUSES: Final[frozenset[str]] = frozenset(
{STATUS_QUEUED, STATUS_RUNNING, STATUS_AWAITING_REVIEW}
)
# =============================================================================
# 정적 파일 프록시 화이트리스트
# =============================================================================
# P2V 의 /files/* 마운트 중 castad 프록시가 인증 없이 중계해도 되는 것만 나열한다.
# render/·uploads/·f2/ 는 결과물·원본이라 Blob 으로만 나간다 — 프록시 금지.
# 검수용 regions/ 는 별도 라우트(/p2v/files/regions/*)가 로그인 필수로 중계한다.
#: 인증 없이 중계 (템플릿 썸네일 — <img src> 는 헤더를 못 붙이고, 내용도 공개 명화다)
PUBLIC_FILE_PREFIXES: Final[tuple[str, ...]] = ("templates/", "user_templates/")

View File

@ -1,114 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V 모듈 도메인 예외.
SsulboxException 같은 (message, status_code, code) 형태다.
`code` 프론트엔드가 분기에 쓰므로 값을 바꾸면 된다.
전역 핸들러 등록: app/core/exceptions.py add_exception_handlers().
"""
from fastapi import status
class P2vException(Exception):
"""P2V 기본 예외"""
def __init__(
self,
message: str,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
code: str = "P2V_ERROR",
):
self.message = message
self.status_code = status_code
self.code = code
super().__init__(self.message)
# =============================================================================
# 기능/연결 상태
# =============================================================================
class P2vDisabledError(P2vException):
"""P2V 기능이 설정으로 꺼져 있음"""
def __init__(self):
super().__init__(
message="포스터 생성 기능이 현재 비활성화되어 있습니다.",
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
code="P2V_DISABLED",
)
class P2vUnavailableError(P2vException):
"""P2V 서버 연결 실패 (다운, 타임아웃, 접근 키 불일치)"""
def __init__(self, detail: str = ""):
message = "포스터 생성 서버에 연결할 수 없습니다."
if detail:
message += f" ({detail})"
super().__init__(
message=message,
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
code="P2V_UNAVAILABLE",
)
class P2vUpstreamError(P2vException):
"""P2V 서버가 4xx/5xx 를 돌려줌 — detail 과 상태 코드를 그대로 중계한다"""
def __init__(self, message: str, status_code: int):
super().__init__(
message=message,
status_code=status_code,
code="P2V_UPSTREAM_ERROR",
)
# =============================================================================
# 잡 관련
# =============================================================================
class P2vJobNotFoundError(P2vException):
"""잡을 찾을 수 없음 (없거나 남의 것 — 존재 여부를 노출하지 않는다)"""
def __init__(self):
super().__init__(
message="생성 요청을 찾을 수 없습니다.",
status_code=status.HTTP_404_NOT_FOUND,
code="P2V_JOB_NOT_FOUND",
)
class P2vInvalidStateError(P2vException):
"""현재 상태에서 허용되지 않는 조작 (예: 실패하지 않은 잡의 재시도)"""
def __init__(self, detail: str):
super().__init__(
message=detail,
status_code=status.HTTP_409_CONFLICT,
code="P2V_INVALID_STATE",
)
class P2vFileNotFoundError(P2vException):
"""프록시 화이트리스트 밖이거나 존재하지 않는 파일"""
def __init__(self):
super().__init__(
message="파일을 찾을 수 없습니다.",
status_code=status.HTTP_404_NOT_FOUND,
code="P2V_FILE_NOT_FOUND",
)
class P2vUploadTooLargeError(P2vException):
"""업로드 파일이 상한을 초과"""
def __init__(self, limit_bytes: int):
super().__init__(
message=f"{limit_bytes // (1024 * 1024)}MB 이하만 업로드할 수 있습니다.",
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
code="P2V_UPLOAD_TOO_LARGE",
)

View File

@ -1,265 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V 잡 모델.
프로젝트는 Alembic 마이그레이션을 쓰지 않는다. 스키마 변경은
docs/database-schema/migration_2026-08-25_p2v.sql 수동 실행한다.
테이블명은 산출물 종류를 따른다(p2v_video / p2v_poster). 클래스명의 F1/F2
P2V 서버 API 경로(/api/f1/*, /api/f2/*)와의 대응 관계를 가리킨다.
여기에는 P2V 서버가 가진 것만 둔다 소유권, 크레딧 앵커, Blob URL,
그리고 "P2V 없이도 이력이 완결"되기 위한 확정 메타데이터 스냅샷.
진행 잡의 상세(stages, 검수 중간값) P2V 진실 공급원이다.
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from sqlalchemy import (
JSON,
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.database.session import Base
class P2vF1Job(Base):
"""무빙 포스터(포스터→영상) 잡 — 테이블 p2v_video"""
__tablename__ = "p2v_video"
__table_args__ = (
UniqueConstraint("p2v_job_id", name="uq_p2v_video_ref"),
Index("idx_p2v_video_user_created", "user_uuid", "created_at"),
Index("idx_p2v_video_status", "status"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
autoincrement=True,
comment="고유 식별자 (크레딧 원장 job_ref 앵커)",
)
user_uuid: Mapped[str] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="CASCADE"),
nullable=False,
comment="생성 요청한 사용자 UUID",
)
p2v_job_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="P2V 서버가 발급한 잡 id(F1). 서버 호출 성공 후 채워진다",
)
name: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="사용자가 입력한 행사명 (비우면 서버가 포스터에서 추출)",
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="queued",
server_default="queued",
comment="상태 (queued/running/awaiting_review/archiving/done/failed)",
)
credit_amount: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
comment="차감한 크레딧 수량 (환불 금액 결정)",
)
# ==========================================================================
# 산출물 (Blob 업로드 후 채워짐). 명명은 ssul_content 관례:
# poster_url = 커버/썸네일(og:image 역할), 원본은 source_*, 결과물은 p2v_ 접두.
# ==========================================================================
p2v_video_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="완성 영상 Blob URL"
)
poster_url: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
comment="썸네일 Blob URL (SNS 공유 og:image 역할, ssul_content.poster_url 관례)",
)
source_image_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="원본 업로드 포스터 Blob URL"
)
# ==========================================================================
# 확정 메타데이터 (검수에서 사용자가 수정한 최종본만 보관)
# ==========================================================================
event_name: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, comment="행사명"
)
date_text: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True, comment="일시 표기"
)
place: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, comment="장소"
)
keywords: Mapped[Optional[list]] = mapped_column(
JSON, nullable=True, comment="키워드 목록"
)
narration: Mapped[Optional[list]] = mapped_column(
JSON, nullable=True, comment="나레이션 3문장"
)
duration: Mapped[Optional[Decimal]] = mapped_column(
Numeric(6, 2), nullable=True, comment="완성 영상 길이(초)"
)
error: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True, comment="실패 사유 (스테이지 + detail)"
)
archived_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="Blob 업로드 완료 일시. NULL 이면 아직 P2V 에만 있음",
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), comment="생성 일시"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="수정 일시",
)
def __repr__(self) -> str:
return (
f"<P2vF1Job(id={self.id}, user_uuid='{self.user_uuid}', "
f"p2v_job_id='{self.p2v_job_id}', status='{self.status}')>"
)
class P2vF2Job(Base):
"""포스터 스타일링 잡 — 테이블 p2v_poster"""
__tablename__ = "p2v_poster"
__table_args__ = (
UniqueConstraint("p2v_job_id", name="uq_p2v_poster_ref"),
Index("idx_p2v_poster_user_created", "user_uuid", "created_at"),
Index("idx_p2v_poster_status", "status"),
Index("idx_p2v_poster_source", "source_video_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
autoincrement=True,
comment="고유 식별자 (크레딧 원장 job_ref 앵커)",
)
user_uuid: Mapped[str] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="CASCADE"),
nullable=False,
comment="생성 요청한 사용자 UUID",
)
p2v_job_id: Mapped[Optional[str]] = mapped_column(
String(64), nullable=True, comment="P2V 서버가 발급한 잡 id(F2)"
)
name: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="포스터 이름 (업로드 파일명에서 추출). 내 콘텐츠 카드 제목에 쓴다",
)
source_video_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("p2v_video.id", ondelete="SET NULL"),
nullable=True,
comment="원본 p2v_video 잡 (P2V source_slug 대응). 독립 잡이면 NULL",
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="queued",
server_default="queued",
comment="상태 (queued/running/archiving/done/failed)",
)
credit_amount: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
comment="차감한 크레딧 수량",
)
# ==========================================================================
# 입력 스냅샷 (템플릿이 삭제돼도 이력이 남아야 한다)
# ==========================================================================
template_id: Mapped[str] = mapped_column(
String(64), nullable=False, comment="사용한 스타일 템플릿 id"
)
template_name: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True, comment="템플릿 이름 스냅샷"
)
license: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
comment=(
"템플릿 배포 등급 (public-domain/internal-only/user-uploaded). "
"외부 공개 가부 판단"
),
)
format: Mapped[str] = mapped_column(
String(16),
nullable=False,
default="poster",
server_default="poster",
comment="출력 포맷 (poster/story/feed/square)",
)
# ==========================================================================
# 산출물 (명명은 p2v_video 와 동일 관례)
# ==========================================================================
p2v_poster_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="스타일 변환 결과 Blob URL"
)
source_image_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="원본 업로드 포스터 Blob URL"
)
error: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True, comment="실패 사유"
)
archived_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True, comment="Blob 업로드 완료 일시"
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), comment="생성 일시"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="수정 일시",
)
def __repr__(self) -> str:
return (
f"<P2vF2Job(id={self.id}, user_uuid='{self.user_uuid}', "
f"template_id='{self.template_id}', status='{self.status}')>"
)

View File

@ -1,190 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V 요청/응답 스키마.
응답 형태는 P2V 서버의 JSON 최대한 호환되게 유지한다 프론트엔드
(o2o-castad-frontend p2vApi.ts) 이미 형태(artifacts 딕셔너리, stages )
맞춰 작성돼 있어, 필드명을 바꾸면 P2V 직접 호출 castad 프록시 전환
갈아엎어야 코드가 배로 늘기 때문이다. `id` castad id(정수)
P2V id 절대 밖으로 내보내지 않는다.
검증 범위·기본값은 여기서만 강제한다. DB 모델에 두면 진실이 곳에 생긴다.
"""
from typing import Literal, Optional
from pydantic import BaseModel, Field
F1StatusLiteral = Literal[
"queued", "running", "awaiting_review", "archiving", "done", "failed"
]
F2StatusLiteral = Literal["queued", "running", "archiving", "done", "failed"]
FormatLiteral = Literal["poster", "story", "feed", "square"]
# =============================================================================
# 공통
# =============================================================================
class P2vJobCreateResponse(BaseModel):
"""생성 요청 접수. `id` 로 폴링한다."""
id: int = Field(..., description="castad 잡 ID (P2V 서버 식별자가 아니다)")
status: F1StatusLiteral = Field(..., description="접수 직후 상태")
poll_interval_seconds: int = Field(..., description="권장 폴링 간격(초)")
class P2vJobError(BaseModel):
"""실패 정보 — P2V 의 {stage, detail} 형태를 그대로 중계한다"""
stage: str = Field(..., description="실패한 스테이지")
detail: str = Field(..., description="실패 사유")
class P2vMetadata(BaseModel):
"""행사 메타데이터 (검수 대상)"""
event_name: str = Field(default="", description="행사명")
date_text: str = Field(default="", description="일시 표기")
place: str = Field(default="", description="장소")
category: Optional[str] = Field(None, description="분류")
keywords: Optional[list[str]] = Field(None, description="키워드 목록")
region_guess: Optional[str] = Field(None, description="추정 지역")
class P2vDeleteResponse(BaseModel):
"""삭제 결과"""
id: int = Field(..., description="삭제한 castad 잡 ID")
removed: bool = Field(..., description="삭제 여부")
# =============================================================================
# F1 — 무빙 포스터
# =============================================================================
class P2vF1StatusResponse(BaseModel):
"""F1 잡 상태 (폴링 응답).
진행 중에는 P2V 실시간 값을, archived 이후에는 castad DB 미러를 내려준다.
artifacts 값은 전부 Azure Blob 공개 URL 또는 castad 프록시 경로(/p2v/files/...).
"""
id: int = Field(..., description="castad 잡 ID")
status: F1StatusLiteral = Field(..., description="진행 상태")
stage: Optional[str] = Field(None, description="현재 스테이지 (진행 중에만)")
stages: Optional[dict] = Field(
None, description="스테이지별 상태 (P2V 원형 그대로 — 진행 중에만)"
)
name: Optional[str] = Field(None, description="행사명")
narration: Optional[list[str]] = Field(None, description="나레이션 3문장 (검수 대상)")
metadata: Optional[P2vMetadata] = Field(None, description="행사 메타데이터 (검수 대상)")
motion_elements: Optional[list[str]] = Field(None, description="채택된 모션 요소")
duration: Optional[float] = Field(None, description="완성 영상 길이(초)")
queue_size: Optional[int] = Field(None, description="P2V 대기열 크기 (진행 중에만)")
error: Optional[P2vJobError] = Field(None, description="실패 정보")
artifacts: dict[str, str] = Field(
default_factory=dict,
description="산출물 URL — video/thumbnail 은 Blob, check_jpg 는 프록시 경로",
)
class NarrationUpdateRequest(BaseModel):
"""검수 중 나레이션 수정"""
narration: list[str] = Field(
..., min_length=1, max_length=5, description="수정한 나레이션 문장 목록"
)
class MetadataUpdateRequest(BaseModel):
"""검수 중 행사 메타데이터 수정"""
event_name: str = Field(..., max_length=200, description="행사명")
date_text: str = Field(default="", max_length=100, description="일시 표기")
place: str = Field(..., max_length=200, description="장소")
class ApproveRequest(BaseModel):
"""검수 승인/재시도. motions 를 주면 모션 선정을 덮어쓴다"""
motions: Optional[list[str]] = Field(
None, description="모션 키 목록 (None 이면 서버 선정 유지)"
)
# =============================================================================
# F2 — 포스터 스타일링
# =============================================================================
class P2vF2StatusResponse(BaseModel):
"""F2 잡 상태 (폴링 응답)"""
id: int = Field(..., description="castad 잡 ID")
status: F2StatusLiteral = Field(..., description="진행 상태")
stage: Optional[str] = Field(None, description="현재 스테이지 (진행 중에만)")
stages: Optional[dict] = Field(None, description="스테이지별 상태 (진행 중에만)")
template_id: Optional[str] = Field(None, description="사용한 템플릿 id")
queue_size: Optional[int] = Field(None, description="P2V 대기열 크기 (진행 중에만)")
error: Optional[P2vJobError] = Field(None, description="실패 정보")
artifacts: dict[str, str] = Field(
default_factory=dict, description="산출물 URL — image 는 Blob 공개 URL"
)
class F2TemplateResponse(BaseModel):
"""스타일 템플릿 (P2V 목록 중계 — thumb_url 은 castad 프록시 경로로 재작성됨)"""
id: str = Field(..., description="템플릿 id")
name_ko: str = Field(..., description="한국어 이름")
thumb_url: str = Field(..., description="썸네일 (castad 프록시 경로 /p2v/files/...)")
category: str = Field(..., description="카테고리 id")
license: str = Field(..., description="배포 등급 (public-domain/internal-only/user-uploaded)")
attribution: str = Field(default="", description="출처 표기")
license_note: str = Field(default="", description="등급 설명")
removable: bool = Field(default=False, description="사용자 레퍼런스 여부 (삭제 가능)")
small_ref: bool = Field(default=False, description="저해상 레퍼런스 경고 여부")
class F2CategoryResponse(BaseModel):
"""템플릿 카테고리"""
id: str = Field(..., description="카테고리 id")
label: str = Field(..., description="표시 이름")
class F2FormatResponse(BaseModel):
"""출력 포맷"""
id: str = Field(..., description="포맷 id")
label: str = Field(..., description="표시 이름")
class F2UploadHintResponse(BaseModel):
"""사용자 레퍼런스 업로드 안내"""
enabled: bool = Field(..., description="업로드 허용 여부")
min_long_edge: int = Field(..., description="권장 최소 긴 변(px)")
ref_long_edge: Optional[int] = Field(None, description="분석 시 리사이즈 기준(px)")
__all__ = [
"F1StatusLiteral",
"F2StatusLiteral",
"FormatLiteral",
"P2vJobCreateResponse",
"P2vJobError",
"P2vMetadata",
"P2vDeleteResponse",
"P2vF1StatusResponse",
"NarrationUpdateRequest",
"MetadataUpdateRequest",
"ApproveRequest",
"P2vF2StatusResponse",
"F2TemplateResponse",
"F2CategoryResponse",
"F2FormatResponse",
"F2UploadHintResponse",
]

View File

@ -1,171 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V 산출물 Azure Blob 아카이빙.
P2V 서버는 콜백이 없으므로, 폴링 핸들러가 'done' 처음 관측했을
FastAPI BackgroundTasks 모듈을 태운다 (짧은 async I/O 스레드 워커는 과함).
업로더는 castad `AzureBlobUploader` 그대로 재사용한다 (ssulbox blob_service
같은 이유 같은 Azure 계정에 업로더를 이유가 없다).
경로: {user_uuid}/{P2V_BLOB_PREFIX}-f1-{id}/video/... castad·썰박스와 분리된다.
중복 실행 방지: try_claim_archiving 낙관적 잠금이 태스크에게만 착수권을 준다.
실패 복구: 예외 status 'done'(archived_at NULL)으로 되돌린다 다음 폴링이
다시 선점해 재시도한다. 크래시로 'archiving' 갇힌 행은 기동 스윕이 되돌린다.
"""
from app.database.session import BackgroundSessionLocal
from app.p2v.constants import STATUS_DONE
from app.p2v.models import P2vF1Job, P2vF2Job
from app.p2v.services import client, f1_service, f2_service
from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader
from config import azure_blob_settings, p2v_settings
logger = get_logger("p2v")
#: 설정되지 않았을 때의 플레이스홀더 (ssulbox blob_service 와 동일 판정)
_PLACEHOLDER_SAS = {"", "your-sas-token", "none"}
def blob_enabled() -> bool:
"""Blob 업로드가 가능한 상태인지. 비활성이면 URL 없이 done 처리된다(개발 환경)."""
token = (azure_blob_settings.AZURE_BLOB_SAS_TOKEN or "").strip()
return token.lower() not in _PLACEHOLDER_SAS
def _uploader(user_uuid: str, kind: str, row_id: int) -> AzureBlobUploader:
return AzureBlobUploader(
user_uuid=user_uuid,
task_id=f"{p2v_settings.P2V_BLOB_PREFIX}-{kind}-{row_id}",
)
async def upload_source_image(
kind: str, row_id: int, user_uuid: str, content: bytes, filename: str
) -> None:
"""원본 업로드 포스터를 Blob 에 보관한다 (잡 생성 직후 백그라운드).
생성 요청이 이미 파일 바이트를 들고 있으므로, 나중에 P2V 에서 되받아오는
대신 시점에 바로 올린다. 실패해도 진행에는 영향을 주지 않는다(로그만).
"""
if not blob_enabled():
return
try:
uploader = _uploader(user_uuid, kind, row_id)
ok = await uploader.upload_image_bytes(content, filename or "source.png")
if not ok:
logger.warning(f"[archive] 원본 업로드 실패 {kind} id={row_id}")
return
url = uploader.public_url
model = P2vF1Job if kind == "f1" else P2vF2Job
async with BackgroundSessionLocal() as session:
row = await session.get(model, row_id)
if row is not None:
row.source_image_url = url
await session.commit()
except Exception as e:
# 원본 보관은 부수 기능 — 실패가 잡을 죽이면 안 된다
logger.warning(f"[archive] 원본 업로드 예외 {kind} id={row_id}: {e}")
async def _revert_claim(kind: str, row_id: int) -> None:
"""아카이빙 실패 → 재시도 가능 상태('done', archived_at NULL)로 복구."""
model = P2vF1Job if kind == "f1" else P2vF2Job
try:
async with BackgroundSessionLocal() as session:
row = await session.get(model, row_id)
if row is not None and row.archived_at is None:
row.status = STATUS_DONE
await session.commit()
except Exception as e:
logger.error(f"[archive] 복구 실패 {kind} id={row_id}: {e} — 기동 스윕이 처리한다")
async def archive_f1(row_id: int) -> None:
"""F1 완성 영상·썸네일을 Blob 으로 옮기고 done 전이한다."""
async with BackgroundSessionLocal() as session:
if not await f1_service.try_claim_archiving(session, row_id):
return # 다른 폴링이 이미 집어갔다
await session.commit()
try:
async with BackgroundSessionLocal() as session:
row = await session.get(P2vF1Job, row_id)
if row is None or not row.p2v_job_id:
return
video_url = None
thumb_url = None
if blob_enabled():
p2v_job = await client.request_json(
"GET", f"/api/f1/jobs/{row.p2v_job_id}"
)
artifacts = p2v_job.get("artifacts") or {}
uploader = _uploader(row.user_uuid, "f1", row_id)
if artifacts.get("video"):
content, _ = await client.download(artifacts["video"])
if await uploader.upload_video_bytes(content, "video.mp4"):
video_url = uploader.public_url
else:
raise RuntimeError("영상 Blob 업로드 실패")
if artifacts.get("thumbnail"):
content, _ = await client.download(artifacts["thumbnail"])
if await uploader.upload_image_bytes(content, "thumbnail.jpg"):
thumb_url = uploader.public_url
else:
# 조용히 넘기면 poster_url(og:image)이 영구 결손된다 —
# 영상과 같이 실패시켜 다음 폴링이 아카이빙을 재시도하게 한다
raise RuntimeError("썸네일 Blob 업로드 실패")
# 확정 메타데이터도 이 시점에 마지막으로 미러링해 둔다
f1_service.sync_from_p2v(row, p2v_job)
else:
logger.warning(
f"[archive] Blob 비활성 — f1 id={row_id} URL 없이 done 처리 (개발 환경)"
)
await f1_service.finish_archive(
session, row, video_url=video_url, thumbnail_url=thumb_url
)
await session.commit()
except Exception as e:
logger.error(f"[archive] f1 id={row_id} 아카이빙 실패: {type(e).__name__}: {e}")
await _revert_claim("f1", row_id)
async def archive_f2(row_id: int) -> None:
"""F2 결과 이미지를 Blob 으로 옮기고 done 전이한다."""
async with BackgroundSessionLocal() as session:
if not await f2_service.try_claim_archiving(session, row_id):
return
await session.commit()
try:
async with BackgroundSessionLocal() as session:
row = await session.get(P2vF2Job, row_id)
if row is None or not row.p2v_job_id:
return
image_url = None
if blob_enabled():
p2v_job = await client.request_json(
"GET", f"/api/f2/jobs/{row.p2v_job_id}"
)
artifacts = p2v_job.get("artifacts") or {}
if artifacts.get("image"):
content, _ = await client.download(artifacts["image"])
uploader = _uploader(row.user_uuid, "f2", row_id)
if await uploader.upload_image_bytes(content, "result.png"):
image_url = uploader.public_url
else:
raise RuntimeError("결과 이미지 Blob 업로드 실패")
else:
logger.warning(
f"[archive] Blob 비활성 — f2 id={row_id} URL 없이 done 처리 (개발 환경)"
)
await f2_service.finish_archive(session, row, image_url=image_url)
await session.commit()
except Exception as e:
logger.error(f"[archive] f2 id={row_id} 아카이빙 실패: {type(e).__name__}: {e}")
await _revert_claim("f2", row_id)

View File

@ -1,118 +0,0 @@
# -*- coding: utf-8 -*-
"""P2V 서버 HTTP 클라이언트.
httpx.AsyncClient 모듈 싱글턴으로 유지한다 (creatomate.py _shared_client 패턴).
모든 요청에 X-P2V-Key 실어 보내고, 연결 실패·업스트림 오류를 도메인 예외로 바꾼다.
"""
from typing import Any, Optional
import httpx
from app.p2v.exceptions import P2vUnavailableError, P2vUpstreamError
from app.utils.logger import get_logger
from config import p2v_settings
logger = get_logger("p2v")
#: 기본 타임아웃. 잡 생성(파일 업로드)·레퍼런스 분석(동기 10초 안팎)을 견디도록
#: read 를 넉넉히 잡는다. 폴링 GET 은 서버가 즉시 답하므로 문제되지 않는다.
_DEFAULT_TIMEOUT = httpx.Timeout(10.0, read=120.0)
#: 결과물 다운로드용 (영상 수십 MB)
_DOWNLOAD_TIMEOUT = httpx.Timeout(10.0, read=300.0)
_client: Optional[httpx.AsyncClient] = None
def _headers() -> dict[str, str]:
key = (p2v_settings.P2V_ACCESS_KEY or "").strip()
return {"X-P2V-Key": key} if key else {}
def get_client() -> httpx.AsyncClient:
"""싱글턴 AsyncClient. 커넥션 풀을 프로세스에서 공유한다."""
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
base_url=p2v_settings.P2V_BASE_URL.rstrip("/"),
headers=_headers(),
timeout=_DEFAULT_TIMEOUT,
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
)
return _client
async def close_client() -> None:
"""앱 종료 시 커넥션 풀 정리 (lifespan shutdown 에서 호출)"""
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
def _raise_for_status(resp: httpx.Response) -> None:
"""업스트림 4xx/5xx 를 도메인 예외로 변환한다.
- 401: castad 접근 문제다. 프론트에 401 흘리면 "castad 로그인 만료"
오독되므로 503 으로 바꾼다.
- : P2V detail 상태 코드를 그대로 중계한다 (조용한 fallback 금지).
"""
if resp.status_code < 400:
return
if resp.status_code == 401:
logger.error("[p2v] 접근 키 거부 — P2V_ACCESS_KEY 설정을 확인할 것")
raise P2vUnavailableError("접근 키가 유효하지 않습니다")
try:
detail = resp.json().get("detail", "")
except Exception:
detail = resp.text[:300]
raise P2vUpstreamError(str(detail) or f"P2V 오류 {resp.status_code}", resp.status_code)
async def request_json(
method: str,
path: str,
*,
data: Optional[dict] = None,
files: Optional[dict] = None,
json_body: Optional[dict] = None,
) -> Any:
"""P2V API 호출 → JSON 응답.
Args:
method: HTTP 메서드
path: /api/... 경로
data: form 필드 (multipart files 함께)
files: httpx files 딕셔너리 {"poster": (name, bytes, content_type)}
json_body: JSON 바디
Raises:
P2vUnavailableError: 연결 실패·타임아웃·접근 거부
P2vUpstreamError: P2V 4xx/5xx 반환
"""
try:
resp = await get_client().request(
method, path, data=data, files=files, json=json_body
)
except httpx.HTTPError as e:
logger.warning(f"[p2v] {method} {path} 연결 실패: {type(e).__name__}: {e}")
raise P2vUnavailableError(type(e).__name__)
_raise_for_status(resp)
if not resp.content:
return None
return resp.json()
async def download(path: str) -> tuple[bytes, str]:
"""P2V 정적 파일(/files/...)을 받아온다.
Returns:
(바이트, Content-Type)
"""
try:
resp = await get_client().get(path, timeout=_DOWNLOAD_TIMEOUT)
except httpx.HTTPError as e:
logger.warning(f"[p2v] GET {path} 다운로드 실패: {type(e).__name__}: {e}")
raise P2vUnavailableError(type(e).__name__)
_raise_for_status(resp)
return resp.content, resp.headers.get("content-type", "application/octet-stream")

View File

@ -1,218 +0,0 @@
# -*- coding: utf-8 -*-
"""F1(무빙 포스터) 잡 라이프사이클 + 크레딧.
모든 함수는 **자체 commit 하지 않는다.** 트랜잭션 소유는 라우터/백그라운드 태스크다.
환불 정책 (F2 다르다):
F1 실패는 대부분 재시도 가능하다(제목 게이트, 모델 프론트에 재시도
버튼이 있다). 실패 관측 즉시 환불하면 크레딧 원장의 (job_type, job_ref, type)
유니크 제약 때문에 재시도 재차감이 불가능해져 "환불받고 공짜 재시도" 된다.
그래서 실패 시에는 환불하지 않고, **잡을 버릴 (삭제) 고아 스윕에서만** 환불한다.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.credit.services.credit_service import (
deduct_credit_for_job,
refund_credit_for_job,
)
from app.p2v.constants import (
JOB_TYPE_P2V_F1,
ORPHAN_STATUSES,
STATUS_ARCHIVING,
STATUS_DONE,
STATUS_FAILED,
)
from app.p2v.exceptions import P2vJobNotFoundError
from app.p2v.models import P2vF1Job
from app.utils.logger import get_logger
from config import p2v_settings
logger = get_logger("p2v")
async def create_job(
session: AsyncSession, *, user_uuid: str, name: str
) -> P2vF1Job:
"""잡 행 생성 + 크레딧 선차감 (한 트랜잭션 — 호출부가 commit).
행을 먼저 flush id 확보한 id 멱등 (job_ref) 차감한다.
크레딧이 부족하면 InsufficientCreditError 올라가고 행도 롤백된다
P2V 아직 호출되지 않았으므로 실비용(OpenAI ) 나가지 않는다.
Raises:
InsufficientCreditError: 잔액 부족 (전역 핸들러가 402 변환)
"""
row = P2vF1Job(
user_uuid=user_uuid,
name=name or None,
status="queued",
credit_amount=p2v_settings.P2V_CREDITS_PER_F1,
)
session.add(row)
await session.flush() # id 확보
await deduct_credit_for_job(
session=session,
user_uuid=user_uuid,
amount=p2v_settings.P2V_CREDITS_PER_F1,
job_type=JOB_TYPE_P2V_F1,
job_ref=str(row.id),
reason="무빙 포스터 생성",
)
logger.info(f"[f1] 잡 생성 id={row.id} user={user_uuid}")
return row
async def get_owned(
session: AsyncSession, job_id: int, user_uuid: str
) -> P2vF1Job:
"""소유권 검사 포함 조회. 남의 잡이면 존재를 알리지 않고 동일하게 404."""
row = await session.get(P2vF1Job, job_id)
if row is None or row.user_uuid != user_uuid:
raise P2vJobNotFoundError()
return row
async def refund_job(
session: AsyncSession, row: P2vF1Job, reason: str
) -> None:
"""선차감분 환불 (멱등 — 이미 환불했거나 차감 기록이 없으면 조용히 넘어간다)."""
await refund_credit_for_job(
session=session,
user_uuid=row.user_uuid,
amount=row.credit_amount,
job_type=JOB_TYPE_P2V_F1,
job_ref=str(row.id),
reason=reason,
)
async def fail_job(
session: AsyncSession, row: P2vF1Job, detail: str, *, refund: bool
) -> None:
"""실패 처리.
Args:
refund: True 환불까지. P2V 호출 자체가 실패했거나(실비용 없음)
잡이 P2V 에서 사라져 재시도가 불가능할 때만 True 준다.
일반 스테이지 실패는 재시도 가능하므로 False (모듈 docstring 참조).
"""
row.status = STATUS_FAILED
row.error = detail[:1000]
if refund:
await refund_job(session, row, reason=f"무빙 포스터 실패: {detail[:100]}")
logger.info(f"[f1] 실패 처리 id={row.id} refund={refund} detail={detail[:200]}")
def sync_from_p2v(row: P2vF1Job, p2v_job: dict) -> None:
"""P2V 잡 JSON → castad 행 미러링 (순수 상태 복사 — DB 호출 없음).
'done' 여기서 반영하지 않는다 Blob 아카이빙이 끝나야 done 이다
(archive_service archiving done 전이를 소유한다).
실패도 상태만 미러하고 환불하지 않는다 (재시도 가능 모듈 docstring 참조).
"""
p2v_status = p2v_job.get("status", "")
if p2v_status in ("queued", "running", "awaiting_review"):
row.status = p2v_status
elif p2v_status == "failed":
row.status = STATUS_FAILED
err = p2v_job.get("error") or {}
row.error = f"{err.get('stage', '?')}: {err.get('detail', '')}"[:1000]
if p2v_job.get("name"):
row.name = str(p2v_job["name"])[:100]
if p2v_job.get("narration") is not None:
row.narration = p2v_job["narration"]
md = p2v_job.get("metadata")
if md:
row.event_name = (md.get("event_name") or "")[:200] or None
row.date_text = (md.get("date_text") or "")[:100] or None
row.place = (md.get("place") or "")[:200] or None
row.keywords = md.get("keywords")
if p2v_job.get("duration") is not None:
row.duration = p2v_job["duration"]
async def try_claim_archiving(session: AsyncSession, job_id: int) -> bool:
"""Blob 아카이빙 착수권 선점 (낙관적 잠금).
폴링이 겹쳐도 UPDATE 요청만 성공한다. rowcount 0 이면 다른 폴링이
이미 집어갔거나 이미 아카이브된 것이므로 조용히 물러난다.
"""
result = await session.execute(
update(P2vF1Job)
.where(
P2vF1Job.id == job_id,
P2vF1Job.status != STATUS_ARCHIVING,
P2vF1Job.archived_at.is_(None),
)
.values(status=STATUS_ARCHIVING)
.execution_options(synchronize_session=False)
)
return result.rowcount > 0
async def finish_archive(
session: AsyncSession,
row: P2vF1Job,
*,
video_url: Optional[str],
thumbnail_url: Optional[str],
) -> None:
"""아카이빙 완료 — Blob URL 기록 + done 전이."""
row.p2v_video_url = video_url
row.poster_url = thumbnail_url
row.archived_at = datetime.now()
row.status = STATUS_DONE
logger.info(f"[f1] 아카이브 완료 id={row.id} video={bool(video_url)}")
async def sweep_orphans(session: AsyncSession) -> int:
"""방치된 잡 정리 (앱 기동 시 lifespan 훅에서 호출).
1) 진행/검수 상태로 P2V_ORPHAN_TIMEOUT_HOURS 넘긴 환불 + 실패
(검수 화면에서 이탈한 사용자의 선차감분이 증발하는 것을 막는다 설계 R-1)
2) archiving 으로 P2V_ARCHIVE_STALE_MINUTES 넘게 방치된 'done'(archived_at
NULL) 으로 되돌린다. 다음 폴링이 아카이빙을 다시 선점한다 (설계 R-3 크래시 복구)
시각 비교는 DB 시계(func.now()) 한다 앱과 DB 타임존이 다를 있다.
"""
swept = 0
orphan_cutoff = func.date_sub(
func.now(), text(f"INTERVAL {int(p2v_settings.P2V_ORPHAN_TIMEOUT_HOURS)} HOUR")
)
result = await session.execute(
select(P2vF1Job).where(
P2vF1Job.status.in_(ORPHAN_STATUSES),
P2vF1Job.created_at < orphan_cutoff,
)
)
for row in result.scalars().all():
await fail_job(
session, row, "시간 초과 — 크레딧을 자동 환불했습니다", refund=True
)
swept += 1
stale_cutoff = func.date_sub(
func.now(),
text(f"INTERVAL {int(p2v_settings.P2V_ARCHIVE_STALE_MINUTES)} MINUTE"),
)
result = await session.execute(
update(P2vF1Job)
.where(
P2vF1Job.status == STATUS_ARCHIVING,
P2vF1Job.updated_at < stale_cutoff,
)
.values(status=STATUS_DONE)
.execution_options(synchronize_session=False)
)
if result.rowcount:
logger.info(f"[f1] 정체된 archiving {result.rowcount}건 재시도 가능으로 복구")
return swept

View File

@ -1,190 +0,0 @@
# -*- coding: utf-8 -*-
"""F2(포스터 스타일링) 잡 라이프사이클 + 크레딧.
모든 함수는 **자체 commit 하지 않는다.** 트랜잭션 소유는 라우터/백그라운드 태스크다.
F1 달리 F2 에는 재시도 경로가 없다(P2V API retry 엔드포인트 자체가 없음).
따라서 실패를 관측하는 즉시 환불한다.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.credit.services.credit_service import (
deduct_credit_for_job,
refund_credit_for_job,
)
from app.p2v.constants import (
JOB_TYPE_P2V_F2,
STATUS_ARCHIVING,
STATUS_DONE,
STATUS_FAILED,
)
from app.p2v.exceptions import P2vJobNotFoundError
from app.p2v.models import P2vF2Job
from app.utils.logger import get_logger
from config import p2v_settings
logger = get_logger("p2v")
async def create_job(
session: AsyncSession,
*,
user_uuid: str,
name: Optional[str],
template_id: str,
template_name: Optional[str],
license: Optional[str],
format: str,
) -> P2vF2Job:
"""잡 행 생성 + 크레딧 선차감 (한 트랜잭션 — 호출부가 commit).
템플릿 이름·라이선스는 시점의 스냅샷이다 템플릿이 나중에 삭제되거나
등급이 바뀌어도 "이 결과물이 어떤 등급 레퍼런스로 만들어졌는지" 남는다.
Raises:
InsufficientCreditError: 잔액 부족 (전역 핸들러가 402 변환)
"""
row = P2vF2Job(
user_uuid=user_uuid,
name=(name or "")[:100] or None,
template_id=template_id,
template_name=(template_name or "")[:100] or None,
license=(license or "")[:20] or None,
format=format,
status="queued",
credit_amount=p2v_settings.P2V_CREDITS_PER_F2,
)
session.add(row)
await session.flush() # id 확보
await deduct_credit_for_job(
session=session,
user_uuid=user_uuid,
amount=p2v_settings.P2V_CREDITS_PER_F2,
job_type=JOB_TYPE_P2V_F2,
job_ref=str(row.id),
reason="포스터 스타일링",
)
logger.info(f"[f2] 잡 생성 id={row.id} user={user_uuid} template={template_id}")
return row
async def get_owned(
session: AsyncSession, job_id: int, user_uuid: str
) -> P2vF2Job:
"""소유권 검사 포함 조회. 남의 잡이면 존재를 알리지 않고 동일하게 404."""
row = await session.get(P2vF2Job, job_id)
if row is None or row.user_uuid != user_uuid:
raise P2vJobNotFoundError()
return row
async def refund_job(session: AsyncSession, row: P2vF2Job, reason: str) -> None:
"""선차감분 환불 (멱등 — 이미 환불했거나 차감 기록이 없으면 조용히 넘어간다)."""
await refund_credit_for_job(
session=session,
user_uuid=row.user_uuid,
amount=row.credit_amount,
job_type=JOB_TYPE_P2V_F2,
job_ref=str(row.id),
reason=reason,
)
async def fail_job(session: AsyncSession, row: P2vF2Job, detail: str) -> None:
"""실패 처리 + 즉시 환불 (F2 는 재시도 경로가 없다)."""
row.status = STATUS_FAILED
row.error = detail[:1000]
await refund_credit_for_job(
session=session,
user_uuid=row.user_uuid,
amount=row.credit_amount,
job_type=JOB_TYPE_P2V_F2,
job_ref=str(row.id),
reason=f"포스터 스타일링 실패: {detail[:100]}",
)
logger.info(f"[f2] 실패 처리(환불) id={row.id} detail={detail[:200]}")
async def sync_from_p2v(
session: AsyncSession, row: P2vF2Job, p2v_job: dict
) -> None:
"""P2V 잡 JSON → castad 행 미러링.
'done' 반영하지 않는다 Blob 아카이빙 완료가 done 이다.
'failed' 즉시 환불한다 (F1 다른 지점).
"""
p2v_status = p2v_job.get("status", "")
if p2v_status in ("queued", "running"):
row.status = p2v_status
elif p2v_status == "failed" and row.status != STATUS_FAILED:
err = p2v_job.get("error") or {}
await fail_job(
session, row, f"{err.get('stage', '?')}: {err.get('detail', '')}"
)
async def try_claim_archiving(session: AsyncSession, job_id: int) -> bool:
"""Blob 아카이빙 착수권 선점 (낙관적 잠금 — f1_service 와 동일 방식)."""
result = await session.execute(
update(P2vF2Job)
.where(
P2vF2Job.id == job_id,
P2vF2Job.status != STATUS_ARCHIVING,
P2vF2Job.archived_at.is_(None),
)
.values(status=STATUS_ARCHIVING)
.execution_options(synchronize_session=False)
)
return result.rowcount > 0
async def finish_archive(
session: AsyncSession, row: P2vF2Job, *, image_url: Optional[str]
) -> None:
"""아카이빙 완료 — Blob URL 기록 + done 전이."""
row.p2v_poster_url = image_url
row.archived_at = datetime.now()
row.status = STATUS_DONE
logger.info(f"[f2] 아카이브 완료 id={row.id} image={bool(image_url)}")
async def sweep_orphans(session: AsyncSession) -> int:
"""방치된 잡 정리 — f1_service.sweep_orphans 와 같은 규칙 (검수 단계만 없다)."""
swept = 0
orphan_cutoff = func.date_sub(
func.now(), text(f"INTERVAL {int(p2v_settings.P2V_ORPHAN_TIMEOUT_HOURS)} HOUR")
)
result = await session.execute(
select(P2vF2Job).where(
P2vF2Job.status.in_(("queued", "running")),
P2vF2Job.created_at < orphan_cutoff,
)
)
for row in result.scalars().all():
await fail_job(session, row, "시간 초과 — 크레딧을 자동 환불했습니다")
swept += 1
stale_cutoff = func.date_sub(
func.now(),
text(f"INTERVAL {int(p2v_settings.P2V_ARCHIVE_STALE_MINUTES)} MINUTE"),
)
result = await session.execute(
update(P2vF2Job)
.where(
P2vF2Job.status == STATUS_ARCHIVING,
P2vF2Job.updated_at < stale_cutoff,
)
.values(status=STATUS_DONE)
.execution_options(synchronize_session=False)
)
if result.rowcount:
logger.info(f"[f2] 정체된 archiving {result.rowcount}건 재시도 가능으로 복구")
return swept

View File

@ -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
)

View File

@ -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",
)

View File

@ -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
}
}
)

View File

@ -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="플랫폼명")

View File

@ -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 []),
)

View File

@ -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 [])

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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="콘텐츠가 삭제되었습니다.",
)

View File

@ -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

View File

@ -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",
)

View File

@ -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 는 안 본다).

View File

@ -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",
]

View File

@ -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

View File

@ -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

View File

@ -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}")

View File

@ -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}")

View File

@ -143,10 +143,6 @@ class User(Base):
comment="카카오 썸네일 이미지 URL",
)
# `bio`(썰박스 프로필 한 줄 소개)는 두지 않는다 — 원본 썰박스에는 있었지만
# 프로필 편집 화면을 castad `내 정보`로 대체하면서 쓰는 곳이 사라졌다
# (2026-07-30 제거). 프로필 소개 기능을 만들게 되면 그때 추가한다.
# ==========================================================================
# 추가 사용자 정보
# ==========================================================================

View File

@ -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,

View File

@ -6,6 +6,10 @@ from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommen
import asyncio
# medium 추론은 출력 비용의 80%를 차지하고, minimal은 A/B 비교(4회)에서 narrative 점수가
# welcome 단계로 편향되고 태그를 과다 선택하는 패턴이 반복돼 low로 고정한다.
IMAGE_TAG_REASONING_EFFORT = "low"
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list
chatgpt = ChatgptService(model_type="gpt")
image_input_data = {
@ -17,7 +21,7 @@ async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_
"motion_recommended" : list(MotionRecommended)
}
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT)
return image_result
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
@ -31,7 +35,7 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
"motion_recommended" : list(MotionRecommended)
}for image_url in image_url_list]
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], True, silent = True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for image_input_data in image_input_data_list]
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
MAX_RETRY = 2
for _ in range(MAX_RETRY):
@ -40,7 +44,7 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
if not failed_idx:
break
retried = await asyncio.gather(
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], True, silent=True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for i in failed_idx],
return_exceptions=True
)
for i, result in zip(failed_idx, retried):

View File

@ -47,6 +47,19 @@ class ChatgptService:
case _:
raise NotImplementedError(f"Unknown Provider : {model_type}")
def _log_usage(self, response, model: str, output_format: type[BaseModel]) -> None:
usage = getattr(response, "usage", None)
if usage is None:
return
# 토큰 소모량 로깅 (필요 시 주석 해제)
# cached = getattr(getattr(usage, "prompt_tokens_details", None), "cached_tokens", None) or 0
# reasoning = getattr(getattr(usage, "completion_tokens_details", None), "reasoning_tokens", None) or 0
# logger.info(
# f"[ChatgptService({self.model_type})] usage model={model} output={output_format.__name__} "
# f"prompt={usage.prompt_tokens} cached={cached} "
# f"completion={usage.completion_tokens} reasoning={reasoning} total={usage.total_tokens}"
# )
async def _call_pydantic_output(
self,
prompt : str,
@ -115,7 +128,8 @@ class ChatgptService:
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str,
img_url : str,
image_detail_high : bool) -> BaseModel:
image_detail_high : bool,
reasoning_effort : Optional[str] = None) -> BaseModel:
content = []
if img_url:
content.append({
@ -129,13 +143,16 @@ class ChatgptService:
"type": "text",
"text": prompt
})
# gpt-5.4 계열/Gemini 호환 엔드포인트는 허용 값이 다르거나 파라미터를 거부하므로 지정된 경우에만 전달
extra_kwargs = {"reasoning_effort": reasoning_effort} if reasoning_effort else {}
last_error = None
for attempt in range(self.max_retries + 1):
try:
response = await self.client.beta.chat.completions.parse(
model=model,
messages=[{"role": "user", "content": content}],
response_format=output_format
response_format=output_format,
**extra_kwargs,
)
except (ValidationError, json.JSONDecodeError) as e:
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
@ -148,6 +165,7 @@ class ChatgptService:
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
continue
self._log_usage(response, model, output_format)
# Response 디버그 로깅
# logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
# logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
@ -225,6 +243,7 @@ class ChatgptService:
continue
raise last_error
self._log_usage(response, model, output_format)
choice = response.choices[0]
if choice.finish_reason == "stop":
return choice.message.parsed
@ -242,7 +261,8 @@ class ChatgptService:
input_data : dict,
img_url : Optional[str] = None,
img_detail_high : bool = False,
silent : bool = True
silent : bool = True,
reasoning_effort : Optional[str] = None,
) -> BaseModel:
prompt_text = prompt.build_prompt(input_data, silent)
@ -253,5 +273,5 @@ class ChatgptService:
# GPT API 호출
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high, reasoning_effort)
return parsed

View File

@ -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'로 통합됨.

View File

@ -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

View File

@ -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="태그 키워드 리스트")

View File

@ -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()

View File

@ -13,11 +13,13 @@ Video API Router
app.include_router(router)
"""
import json
from collections import defaultdict
from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
@ -27,28 +29,27 @@ from app.user.models import User
from app.utils.pagination import PaginatedResponse
from app.home.models import Image, Project, MarketingIntel
from app.home.api.routers.v1.home import _extract_region_from_address
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.utils.upload_blob_as_request import to_playback_url
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
from app.utils.upload_blob_as_request import to_playback_url
from app.comment.models import Comment
from app.database.like_cache import (
backfill_user_set,
bulk_is_user_liked,
get_like_count,
get_like_counts,
is_user_liked,
is_user_set_exists,
mark_dirty,
mset_like_counts,
set_like_count,
toggle_like_atomic,
)
from app.credit.exceptions import InsufficientCreditError
from app.credit.services.credit_service import deduct_credit_for_job
from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO
from app.ssulbox.models import SsulContent
from app.utils.logger import get_logger
from app.video.models import Video, VideoReaction
from app.video.services import unified_list
from app.video.schemas.video_schema import (
DownloadVideoResponse,
GenerateVideoResponse,
@ -58,25 +59,19 @@ from app.video.schemas.video_schema import (
VideoRenderData,
VideoThumbnailItem,
)
from app.video.worker.video_task import (
_fail_and_refund,
download_and_upload_video_to_blob,
)
from app.video.services.share_page import (
build_video_share_html,
get_video_share_data,
resolve_frontend_base_url,
resolve_share_url,
)
from app.video.worker.video_task import download_and_upload_video_to_blob
from config import creatomate_settings, prj_settings
logger = get_logger("video")
#: 영상 1편 생성에 차감할 크레딧 (video_task.py 의 VIDEO_CREDIT_COST 와 동일해야 함)
VIDEO_CREDIT_COST = 1
router = APIRouter(prefix="/video", tags=["Video"])
@ -128,8 +123,6 @@ async def _get_official_site_urls(
return url_by_project
@router.get(
"/generate/{task_id}",
summary="영상 생성 요청",
@ -175,19 +168,12 @@ curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5
- Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다.
- creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 있습니다.
- Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다.
## 크레딧
- **요청 시점에 크레딧 1 선차감됩니다.** (완료 시점 차감에서 변경)
- 생성이 실패하면 자동으로 환불됩니다.
- 잔액이 부족하면 402 반환하며, Video 행도 생성되지 않습니다.
- 같은 task_id 재요청해도 중복 차감되지 않습니다.
""",
response_model=GenerateVideoResponse,
responses={
200: {"description": "영상 생성 요청 성공"},
400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
402: {"description": "크레딧 부족 (충전 필요)"},
404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"},
500: {"description": "영상 생성 요청 실패"},
},
@ -242,17 +228,10 @@ async def generate_video(
# ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image =====
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
# Project 조회 (본인 소유만).
# ⚠️ task_id 는 경로 파라미터라 남의 값을 넣을 수 있다. 소유자를 안 거르면
# 남의 프로젝트로 영상을 만들 수 있고, 더 나쁘게는 크레딧 원장에
# job_ref=피해자 task_id 로 차감이 기록돼 **피해자의 정상 생성이
# "이미 차감됨"으로 처리**된다(멱등 키 오염).
# Project 조회
project_result = await session.execute(
select(Project)
.where(
Project.task_id == task_id,
Project.user_uuid == current_user.user_uuid,
)
.where(Project.task_id == task_id)
.order_by(Project.created_at.desc())
.limit(1)
)
@ -389,11 +368,7 @@ async def generate_video(
f"timestamps: {len(song_timestamp_list)}"
)
# ===== Video 테이블에 초기 데이터 저장 + 크레딧 선차감 (단일 트랜잭션) =====
# 렌더 완료 후가 아니라 "시작 시점"에 차감한다. 사후차감이던 시절에는
# 차감 전에 동시 요청이 들어오면 크레딧 1개로 영상 여러 개를 만들 수 있었다.
# Video insert 와 차감을 한 트랜잭션으로 묶어 한 번만 커밋해야
# "영상 행은 생겼는데 차감은 실패" 같은 반쪽 상태가 생기지 않는다.
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
video = Video(
project_id=project_id,
lyric_id=lyric_id,
@ -403,21 +378,6 @@ async def generate_video(
status="processing",
)
session.add(video)
await session.flush() # video.id 확보 (커밋은 차감 후 한 번만)
# job_ref 로 task_id 를 쓴다 — ADO2 에는 영상 재생성 버튼이 없어
# task_id 1건 = 생성 1건이기 때문이다.
# 재생성 UI 를 추가한다면 이 키를 f"{task_id}:{video.id}" 로 바꿔야
# 두 번째 생성이 공짜가 되지 않는다.
await deduct_credit_for_job(
session=session,
user_uuid=current_user.user_uuid,
amount=VIDEO_CREDIT_COST,
job_type=CREDIT_JOB_TYPE_VIDEO,
job_ref=task_id,
reason="영상 생성",
)
await session.commit()
video_id = video.id
stage1_time = time.perf_counter()
@ -429,14 +389,6 @@ async def generate_video(
except HTTPException:
raise
except InsufficientCreditError:
# 크레딧 부족은 "요청 실패"가 아니라 402 로 올려야 프론트가 충전 화면으로 유도한다.
# 아래 except Exception 이 삼켜 200(success=False)으로 내리면 안 되므로 먼저 잡는다.
logger.info(
f"[generate_video] INSUFFICIENT CREDIT - task_id: {task_id}, "
f"user_uuid: {current_user.user_uuid}"
)
raise
except Exception as e:
logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}")
return GenerateVideoResponse(
@ -589,13 +541,17 @@ async def generate_video(
)
import traceback
logger.error(traceback.format_exc())
# 외부 API 실패 시 Video 상태를 failed로 갱신하고, 선차감한 크레딧을 환불한다.
# 크레딧은 1단계에서 이미 차감됐으므로 여기서 돌려주지 않으면 그대로 소멸된다.
await _fail_and_refund(
task_id,
user_uuid=current_user.user_uuid,
reason="영상 생성 실패 환불 (Creatomate 요청 오류)",
)
# 외부 API 실패 시 Video 상태를 failed로 업데이트
from app.database.session import AsyncSessionLocal
async with AsyncSessionLocal() as update_session:
video_result = await update_session.execute(
select(Video).where(Video.id == video_id)
)
video_to_update = video_result.scalar_one_or_none()
if video_to_update:
video_to_update.status = "failed"
await update_session.commit()
return GenerateVideoResponse(
success=False,
task_id=task_id,
@ -730,41 +686,20 @@ async def get_video_status(
message = status_messages.get(status, f"상태: {status}")
video_id = None
# ⚠️ 소유자 검증이 필수다. creatomate_render_id 는 클라이언트가 보내는 값이라
# 남의 렌더 ID 로 이 엔드포인트를 부를 수 있다. 소유자를 안 거르면
# - 실패 분기: 남의 실패 건으로 **호출자에게 환불**이 나가고(크레딧 탈취),
# 원장 멱등 키(job_ref=피해자 task_id)가 소진돼 **피해자의 정당한 환불이 봉쇄**된다.
# - 성공 분기: 남의 영상이 **호출자 UUID 경로의 Blob** 으로 업로드된다.
# video 에는 user_uuid 가 없으므로(소유권은 project 에 있다) Project 를 조인한다.
async def _load_owned_video() -> Video | None:
row = (
await session.execute(
select(Video)
.join(Project, Video.project_id == Project.id)
.where(
Video.creatomate_render_id == creatomate_render_id,
Project.user_uuid == current_user.user_uuid,
)
.order_by(Video.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
if row is None:
logger.warning(
"[get_video_status] 소유자 아님 또는 영상 없음 — 후속 처리 생략, "
f"creatomate_render_id: {creatomate_render_id}, "
f"user: {current_user.user_uuid}"
)
return row
# succeeded 상태인 경우 백그라운드 태스크 실행
if status == "succeeded" and video_url:
# creatomate_render_id로 Video 조회하여 task_id 가져오기 (본인 것만)
video = await _load_owned_video()
# creatomate_render_id로 Video 조회하여 task_id 가져오기
video_result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = video_result.scalar_one_or_none()
video_id = video.id
if video and video.status != "completed":
video_id = video.id
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
logger.info(
@ -778,25 +713,9 @@ async def get_video_status(
user_uuid=current_user.user_uuid,
)
elif video and video.status == "completed":
video_id = video.id
logger.debug(
f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}"
)
elif status == "failed":
# 렌더 실패는 Creatomate 가 명시적으로 알려준 시점에만 알 수 있다.
# 크레딧은 generate_video 에서 선차감됐으므로 여기서 돌려줘야 한다.
# 조회를 본인 소유로 제한했으므로 환불 대상이 곧 소유자다.
video = await _load_owned_video()
if video:
video_id = video.id
if video.status != "failed":
await _fail_and_refund(
video.task_id,
creatomate_render_id=creatomate_render_id,
user_uuid=current_user.user_uuid,
reason="영상 생성 실패 환불 (Creatomate 렌더 실패)",
)
render_data = VideoRenderData(
id=result.get("id"),
@ -986,7 +905,7 @@ async def get_all_videos(
store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"),
region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"),
) -> PaginatedResponse[VideoThumbnailItem]:
"""전체 사용자의 완료된 콘텐츠(ADO2 영상 + 썰박스)를 반환합니다."""
"""전체 사용자의 완료된 영상 갤러리를 반환합니다."""
logger.info(
f"[get_all_videos] START - page: {pagination.page}, page_size: {pagination.page_size}, "
f"sort_by: {sort_by}, order: {order}, store_name: {store_name}, region: {region}"
@ -995,34 +914,150 @@ async def get_all_videos(
try:
offset = (pagination.page - 1) * pagination.page_size
items, total = await unified_list.fetch_gallery(
session,
offset=offset,
limit=pagination.page_size,
sort_by=sort_by,
order=order,
store_name=store_name,
region=region,
user_uuid=current_user.user_uuid if current_user else None,
where_clauses = [
Video.status == "completed",
Video.is_deleted == False, # noqa: E712
Project.is_deleted == False, # noqa: E712
Video.result_movie_url.is_not(None),
]
if store_name:
where_clauses.append(Project.store_name.ilike(f"%{store_name}%"))
if region:
cities = SIDO_CITIES.get(region)
if cities:
aliases = SIDO_SEARCH_ALIASES.get(region, [region])
where_clauses.append(
or_(
Project.region.in_(cities),
*[Project.detail_region_info.ilike(f"%{a}%") for a in aliases],
)
)
else:
where_clauses.append(
or_(
Project.region.ilike(f"%{region}%"),
Project.detail_region_info.ilike(f"%{region}%"),
)
)
count_q = (
select(func.count(Video.id))
.join(Project, Video.project_id == Project.id)
.where(*where_clauses)
)
total = (await session.execute(count_q)).scalar() or 0
comment_count_subq = (
select(func.count(Comment.id))
.where(
Comment.video_id == Video.id,
Comment.is_deleted == False, # noqa: E712
)
.correlate(Video)
.scalar_subquery()
)
# like_count 정렬은 Redis 대신 서브쿼리로 처리 (ORDER BY에만 사용)
like_count_subq_for_sort = (
select(func.count(VideoReaction.id))
.where(VideoReaction.video_id == Video.id)
.correlate(Video)
.scalar_subquery()
)
sort_col_map = {
"like_count": like_count_subq_for_sort,
"comment_count": comment_count_subq,
"created_at": Video.created_at,
}
sort_col = sort_col_map.get(sort_by, Video.created_at)
order_clause = sort_col.asc() if order == "asc" else sort_col.desc()
list_q = (
select(
Video,
Project,
comment_count_subq.label("comment_count"),
)
.join(Project, Video.project_id == Project.id)
.where(*where_clauses)
.order_by(order_clause)
.offset(offset)
.limit(pagination.page_size)
)
rows = (await session.execute(list_q)).all()
video_ids = [v.id for v, p, _ in rows]
# Redis mget으로 like_count 일괄 조회
like_count_map = await get_like_counts(video_ids)
# 카운트 캐시 미스 보정
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
if missing_ids:
db_counts = (await session.execute(
select(VideoReaction.video_id, func.count(VideoReaction.id))
.where(VideoReaction.video_id.in_(missing_ids))
.group_by(VideoReaction.video_id)
)).all()
db_found_ids = set()
batch = {}
for vid, cnt in db_counts:
batch[vid] = cnt
like_count_map[vid] = cnt
db_found_ids.add(vid)
await mset_like_counts(batch)
for vid in missing_ids:
if vid not in db_found_ids:
like_count_map[vid] = 0
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
liked_map: dict[int, bool] = {}
if current_user:
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
# user-set이 없는(None) 영상 중 count > 0인 것만 backfill 필요
needs_backfill = [
vid for vid, liked in raw_liked.items()
if liked is None and like_count_map.get(vid, 0) > 0
]
if needs_backfill:
reaction_rows = (await session.execute(
select(VideoReaction.video_id, VideoReaction.user_uuid)
.where(VideoReaction.video_id.in_(needs_backfill))
)).all()
user_map: dict[int, list[str]] = defaultdict(list)
for vid, uuid in reaction_rows:
user_map[vid].append(uuid)
for vid in needs_backfill:
await backfill_user_set(vid, user_map.get(vid, []))
# backfill 후 재조회
updated = await bulk_is_user_liked(needs_backfill, current_user.user_uuid)
raw_liked.update(updated)
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
official_site_url_map = await _get_official_site_urls(
session, [p for _, p, _ in rows]
)
items = [
VideoThumbnailItem(
video_id=v.id,
store_name=p.store_name,
result_movie_url=to_playback_url(v.result_movie_url),
poster_url=v.poster_url,
created_at=v.created_at,
like_count=like_count_map.get(v.id) or 0,
is_liked_by_me=liked_map.get(v.id, False),
comment_count=comment_count or 0,
official_site_url=official_site_url_map.get(p.id),
)
for v, p, comment_count in rows
]
response = PaginatedResponse.create(
items=[
VideoThumbnailItem(
type=it.ctype,
video_id=it.id,
store_name=it.store_name,
result_movie_url=to_playback_url(it.movie_url),
poster_url=it.poster_url,
title=it.title,
description=it.description,
created_at=it.created_at,
like_count=it.like_count,
is_liked_by_me=it.is_liked_by_me,
comment_count=it.comment_count,
)
for it in items
],
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
@ -1035,7 +1070,6 @@ async def get_all_videos(
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
@router.post(
"/{video_id}/like",
summary="영상 좋아요 토글",
@ -1055,77 +1089,57 @@ async def get_all_videos(
)
async def toggle_like(
video_id: int,
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),
) -> LikeToggleResponse:
"""영상/썰박스 좋아요를 토글합니다.
"""영상 좋아요를 토글합니다.
Write-Behind 패턴:
1. Redis user-set / count를 즉시 원자적으로 업데이트 (Lua script)
2. dirty SET에 표시 스케줄러가 1분마다 MySQL에 반영
DB write가 없으므로 고트래픽에서도 응답 지연 없음.
종류가 같은 테이블(video_reaction)·같은 Redis 로직을 쓰므로 엔드포인트도
하나다. type 존재 확인 대상 Redis 접두 backfill 컬럼만 가른다.
기본값이 "video" 기존 프론트 호출은 수정 없이 동작한다.
"""
logger.info(
f"[toggle_like] START - type: {type}, id: {video_id}, user: {current_user.user_uuid}"
)
logger.info(f"[toggle_like] START - video_id: {video_id}, user: {current_user.user_uuid}")
try:
# 대상 존재 확인 (DB read는 유지 — 404 처리 필수).
# id 가 종류별 독립 시퀀스라 반대쪽 테이블에 같은 id 가 있어도 잡으면 안 된다.
if type == "ssul":
exists_q = select(SsulContent.id).where(
SsulContent.id == video_id,
SsulContent.status == "done",
SsulContent.is_deleted.is_(False),
)
# DB backfill 시 반응 행을 찾는 컬럼 — 썰박스 행은 content_id 가 채워져 있다
target_col = VideoReaction.content_id
else:
exists_q = select(Video.id).where(
# 영상 존재 확인 (DB read는 유지 — 404 처리 필수)
video_result = await session.execute(
select(Video).where(
Video.id == video_id,
Video.status == "completed",
Video.is_deleted.is_(False),
Video.is_deleted == False, # noqa: E712
)
target_col = VideoReaction.video_id
if (await session.execute(exists_q)).scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="콘텐츠를 찾을 수 없습니다.")
)
if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
# Cold-start 보정: Redis에 데이터가 없으면 DB에서 backfill
count = await get_like_count(video_id, ctype=type)
count = await get_like_count(video_id)
if count is None:
# 카운트와 user-set 모두 없음 → DB에서 전체 복구
user_uuids = (await session.execute(
select(VideoReaction.user_uuid).where(target_col == video_id)
select(VideoReaction.user_uuid)
.where(VideoReaction.video_id == video_id)
)).scalars().all()
await backfill_user_set(video_id, list(user_uuids), ctype=type)
await set_like_count(video_id, len(user_uuids), ctype=type)
await backfill_user_set(video_id, list(user_uuids))
await set_like_count(video_id, len(user_uuids))
elif count > 0:
if not await is_user_set_exists(video_id, ctype=type):
if not await is_user_set_exists(video_id):
# 카운트는 있지만 user-set이 증발한 경우 (부분 캐시 미스)
user_uuids = (await session.execute(
select(VideoReaction.user_uuid).where(target_col == video_id)
select(VideoReaction.user_uuid)
.where(VideoReaction.video_id == video_id)
)).scalars().all()
await backfill_user_set(video_id, list(user_uuids), ctype=type)
await backfill_user_set(video_id, list(user_uuids))
# Lua 스크립트로 원자적 토글 (race condition 방지)
is_liked, like_count = await toggle_like_atomic(
video_id, current_user.user_uuid, ctype=type
)
is_liked, like_count = await toggle_like_atomic(video_id, current_user.user_uuid)
# dirty SET에 표시 → 스케줄러가 DB에 반영
await mark_dirty(video_id, current_user.user_uuid, ctype=type)
await mark_dirty(video_id, current_user.user_uuid)
logger.info(
f"[toggle_like] SUCCESS - type: {type}, id: {video_id}, "
f"[toggle_like] SUCCESS - video_id: {video_id}, "
f"is_liked: {is_liked}, count: {like_count}"
)
return LikeToggleResponse(video_id=video_id, is_liked=is_liked, like_count=like_count)

View File

@ -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")

View File

@ -5,7 +5,7 @@ Video API Schemas
"""
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
@ -153,23 +153,10 @@ class VideoListItem(BaseModel):
}
"""
# ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는
# 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두
# **`(type, video_id)` 쌍**으로 다뤄야 한다.
# 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로,
# 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.**
type: Literal["video", "ssul", "p2v_video", "p2v_poster"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스, "
"p2v_video: 무빙 포스터, p2v_poster: 포스터 스타일링 — 이미지라 <img> 로 그릴 것)",
)
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
video_id: int = Field(..., description="영상 고유 ID")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(
default="",
description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)",
)
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 업로드 제목")
@ -191,20 +178,10 @@ class VideoThumbnailItem(BaseModel):
GET /video/all 응답의 개별 영상 정보
"""
# ⚠️ `video_id` 는 종류 안에서만 유일하다. `video.id` 와 `ssul_content.id` 가
# **둘 다 1부터 시작**하므로 식별자는 반드시 `(type, video_id)` 쌍으로 다뤄야 한다.
# 한 곳이라도 id 만 쓰면 다른 종류의 콘텐츠가 열린다.
type: Literal["video", "ssul", "p2v_video", "p2v_poster"] = Field(
default="video",
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스, p2v_video: 무빙 포스터, "
"p2v_poster: 포스터 스타일링 — 이미지라 <img> 로 그릴 것). video_id 와 쌍으로 식별한다",
)
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
store_name: str = Field(..., description="업체명 (P2V 는 행사명/포스터명)")
result_movie_url: str = Field(..., description="영상 URL (p2v_poster 는 이미지 URL)")
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 (썸네일 표시용)")
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)")
@ -227,7 +204,7 @@ class VideoDetailResponse(BaseModel):
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 업로드 제목")
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="좋아요 수")

View File

@ -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()

View File

@ -1,606 +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, null, 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.p2v.models import P2vF1Job, P2vF2Job
from app.ssulbox.models import SsulContent
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.video.models import Video, VideoReaction
#: p2v_video(무빙 포스터 영상)·p2v_poster(스타일링 이미지). 내 콘텐츠와 갤러리
#: 양쪽에 나오지만(2026-08-26 갤러리 합류) **좋아요·댓글 축은 없다** — enrich 는
#: CT_VIDEO/CT_SSUL 만 돌아 0 으로 남고, 프론트도 P2V 카드에는 소셜 액션을 숨긴다.
#: 갤러리의 F2 는 퍼블릭 도메인 템플릿 결과물만 나간다(fetch_gallery 참조).
ContentType = Literal["video", "ssul", "p2v_video", "p2v_poster"]
CT_P2V_VIDEO = "p2v_video"
CT_P2V_POSTER = "p2v_poster"
#: 정렬 가능한 키. 그 외 값은 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 _p2v_f1_branch(where: list, sort_by: str = SORT_CREATED) -> Select:
"""무빙 포스터(F1) 브랜치.
통합 목록의 축과 P2V 필드 대응: store_name행사명, region장소,
hashtags키워드. P2V 에는 좋아요·댓글 축이 없으므로 해당 정렬에서는
sort_value 0 으로 뒤에 깔린다 (UNION 컬럼 맞춤용).
"""
cols = [
literal(CT_P2V_VIDEO).label("ctype"),
P2vF1Job.id.label("cid"),
func.coalesce(P2vF1Job.event_name, P2vF1Job.name).label("store_name"),
P2vF1Job.place.label("region"),
P2vF1Job.p2v_video_url.label("movie_url"),
P2vF1Job.created_at.label("created_at"),
literal("").label("task_id"),
P2vF1Job.poster_url.label("poster_url"),
P2vF1Job.name.label("title"),
null().label("description"),
P2vF1Job.keywords.label("hashtags"),
]
if sort_by in (SORT_LIKE, SORT_COMMENT):
cols.append(literal(0).label("sort_value"))
return select(*cols).where(*where)
def _p2v_f2_branch(where: list, sort_by: str = SORT_CREATED) -> Select:
"""포스터 스타일링(F2) 브랜치 — 결과물이 영상이 아니라 이미지다.
movie_url 자리에 결과 이미지 URL 싣는다. **프론트는 ctype 으로 분기해
<img> 그려야 한다** (ssul/video 처럼 <video> 넣으면 나온다).
"""
cols = [
literal(CT_P2V_POSTER).label("ctype"),
P2vF2Job.id.label("cid"),
# 카드 제목 = 포스터 이름(업로드 파일명). 이름이 없던 구버전 행만 템플릿명으로.
func.coalesce(P2vF2Job.name, P2vF2Job.template_name, literal("포스터 스타일링")).label("store_name"),
null().label("region"),
P2vF2Job.p2v_poster_url.label("movie_url"),
P2vF2Job.created_at.label("created_at"),
literal("").label("task_id"),
# 썸네일 = 결과물 자신 (별도 썸네일이 없다)
P2vF2Job.p2v_poster_url.label("poster_url"),
# 스타일 정보는 제목이 아니라 부가 정보 자리(title)에 남긴다
P2vF2Job.template_name.label("title"),
null().label("description"),
null().label("hashtags"),
]
if sort_by in (SORT_LIKE, SORT_COMMENT):
cols.append(literal(0).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)
# ── P2V (2026-08-26 갤러리 합류) ──────────────────────────────
# Blob 아카이브까지 끝난 완성본만. 검색어는 행사명/포스터명에 대응시킨다.
f1_where = [
P2vF1Job.status == "done",
P2vF1Job.archived_at.is_not(None),
P2vF1Job.p2v_video_url.is_not(None),
]
if store_name:
f1_where.append(
func.coalesce(P2vF1Job.event_name, P2vF1Job.name).ilike(f"%{store_name}%")
)
if region:
# place 는 자유 텍스트 장소라 시/도 별칭 매칭(_region_clause)이 안 맞는다 —
# 부분 일치로만 거른다.
f1_where.append(P2vF1Job.place.ilike(f"%{region}%"))
# F2 공개 조건 셋: ① 설정 플래그(P2V_POSTER_IN_GALLERY — 임시 비노출 중),
# ② 퍼블릭 도메인 템플릿으로 만든 것만(사용자 업로드 레퍼런스는 권리 미확인이라
# 공개 금지 — 내 콘텐츠에는 나온다), ③ 지역 개념이 없어 지역 필터가 걸리면 빠진다.
from config import p2v_settings
include_f2 = p2v_settings.P2V_POSTER_IN_GALLERY and region is None
f2_where = [
P2vF2Job.status == "done",
P2vF2Job.archived_at.is_not(None),
P2vF2Job.p2v_poster_url.is_not(None),
P2vF2Job.license == "public-domain",
]
if store_name:
f2_where.append(
func.coalesce(P2vF2Job.name, P2vF2Job.template_name).ilike(f"%{store_name}%")
)
# 전체 개수는 브랜치별로 센다 — 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
total += (
await session.execute(select(func.count(P2vF1Job.id)).where(*f1_where))
).scalar() or 0
if include_f2:
total += (
await session.execute(select(func.count(P2vF2Job.id)).where(*f2_where))
).scalar() or 0
branches = [_video_branch(v_where, sort_by)]
if include_ssul:
branches.append(_ssul_branch(s_where, sort_by))
branches.append(_p2v_f1_branch(f1_where, sort_by))
if include_f2:
branches.append(_p2v_f2_branch(f2_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),
]
# P2V — Blob 아카이브까지 끝난(URL 이 실재하는) 완성본만.
# archiving 중이거나 실패한 잡은 P2V 진행 화면이 담당하므로 여기 안 나온다.
f1_where = [
P2vF1Job.user_uuid == user_uuid,
P2vF1Job.status == "done",
P2vF1Job.archived_at.is_not(None),
P2vF1Job.p2v_video_url.is_not(None),
]
f2_where = [
P2vF2Job.user_uuid == user_uuid,
P2vF2Job.status == "done",
P2vF2Job.archived_at.is_not(None),
P2vF2Job.p2v_poster_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) + ((await session.execute(
select(func.count(P2vF1Job.id)).where(*f1_where)
)).scalar() or 0) + ((await session.execute(
select(func.count(P2vF2Job.id)).where(*f2_where)
)).scalar() or 0)
u = union_all(
_video_branch(v_where, SORT_CREATED),
_ssul_branch(s_where, SORT_CREATED),
_p2v_f1_branch(f1_where),
_p2v_f2_branch(f2_where),
).subquery()
rows = (
await session.execute(
select(u)
.order_by(u.c.created_at.desc(), u.c.ctype.asc(), u.c.cid.desc())
.offset(offset)
.limit(limit)
)
).all()
items = _to_items(rows)
await enrich(session, items, user_uuid)
return items, total

View File

@ -1,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:

View File

@ -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:
# 임시 파일 삭제

151
config.py
View File

@ -691,155 +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
class P2vSettings(BaseSettings):
"""P2V(무빙 포스터 F1 / 포스터 스타일링 F2) 프록시·크레딧 설정.
P2V 서버는 별도 프로세스(:8010, o2o-ado2-poster-to-video) 뜨고 사용자 개념이
없다. castad 인증·소유권·크레딧·Blob 아카이브를 얹는다. AZURE/JWT 등은
castad 기존 설정을 재사용하므로 여기 두지 않는다.
"""
P2V_ENABLED: bool = Field(
default=True,
description="P2V 기능 활성화. False 면 라우터 등록·고아 스윕을 모두 건너뛴다",
)
P2V_BASE_URL: str = Field(
default="http://localhost:8010",
description="P2V 서버 주소. 도커 네트워크에서는 http://product-api-1:8010 형태",
)
P2V_ACCESS_KEY: str = Field(
default="",
description="P2V 서버 X-P2V-Key 접근 키. P2V 쪽이 무인증이면 비워둔다",
)
# ============================================================
# 크레딧 (D-3: 단가 확정 전까지 env 로 조정한다)
# ============================================================
P2V_CREDITS_PER_F1: int = Field(
default=1,
description="무빙 포스터(F1) 1건당 차감 크레딧 (요청 시점 선차감)",
)
P2V_CREDITS_PER_F2: int = Field(
default=1,
description="포스터 스타일링(F2) 1건당 차감 크레딧 (요청 시점 선차감)",
)
# ============================================================
# 아카이브 · 폴링 · 스윕
# ============================================================
P2V_BLOB_PREFIX: str = Field(
default="p2v",
description="Azure Blob 경로 접두. castad·썰박스 콘텐츠와 저장 경로를 분리한다",
)
P2V_POLL_HINT_SECONDS: int = Field(
default=3,
description="클라이언트에 알려줄 권장 폴링 간격(초)",
)
P2V_ORPHAN_TIMEOUT_HOURS: int = Field(
default=24,
description="이 시간 넘게 진행 중(검수 대기 포함)인 잡을 환불·실패 처리하는 기준",
)
P2V_ARCHIVE_STALE_MINUTES: int = Field(
default=30,
description="archiving 상태로 이만큼 방치된 잡을 재시도 가능 상태로 되돌리는 기준",
)
P2V_MAX_UPLOAD_BYTES: int = Field(
default=30 * 1024 * 1024,
description="포스터/레퍼런스 업로드 상한 (P2V 서버의 30MB 제한과 일치)",
)
P2V_POSTER_IN_GALLERY: bool = Field(
default=False,
description="스타일링 결과(p2v_poster)를 공개 갤러리에 노출할지. "
"임시 비노출(2026-08-26 결정) — 내 콘텐츠에는 항상 나온다",
)
model_config = _base_config
prj_settings = ProjectSettings()
cors_settings = CORSSettings()
apikey_settings = APIKeySettings()
@ -857,5 +708,3 @@ social_oauth_settings = SocialOAuthSettings()
meta_conversion_settings = MetaConversionSettings()
internal_settings = InternalSettings()
social_upload_settings = SocialUploadSettings()
ssulbox_settings = SsulboxSettings()
p2v_settings = P2vSettings()

View File

@ -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`;

View File

@ -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));

View File

@ -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`;

View File

@ -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`;

View File

@ -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;

View File

@ -1,88 +0,0 @@
-- ============================================================
-- Migration: P2V(무빙 포스터 F1 / 포스터 스타일링 F2) 잡 테이블 추가
-- Date: 2026-08-25
-- Description: P2V 서버에는 사용자 개념이 없어 castad 가 소유권을 들고,
-- 크레딧 선차감·환불과 Azure Blob 아카이브 URL 을 함께 보관한다.
-- 파이프라인마다 과정·산출물이 달라 테이블을 나눴다.
-- 테이블명은 산출물 종류를 따른다 — F1(포스터→영상)은 p2v_video,
-- F2(포스터 스타일링)는 p2v_poster. "F1/F2"는 P2V 서버 자체 API
-- 경로(/api/f1/*, /api/f2/*)를 가리킬 때만 코드·주석에 남는다.
-- 선행 조건: migration_2026-04-29_add_credit_tables.sql 먼저 실행
-- 비고: credit_transaction 은 스키마 변경 없이 재사용한다
-- (job_type='p2v_f1'|'p2v_f2', job_ref=이 테이블의 id).
-- ============================================================
CREATE TABLE IF NOT EXISTS p2v_video (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자 (크레딧 원장 job_ref 앵커)',
user_uuid VARCHAR(36) NOT NULL COMMENT '생성 요청한 사용자 UUID',
p2v_job_id VARCHAR(64) NULL COMMENT 'P2V 서버가 발급한 잡 id(F1). 서버 호출 성공 후 채워진다',
name VARCHAR(100) NULL COMMENT '사용자가 입력한 행사명 (비우면 서버가 포스터에서 추출)',
status VARCHAR(20) NOT NULL DEFAULT 'queued' COMMENT '상태 (queued/running/awaiting_review/archiving/done/failed)',
credit_amount INT NOT NULL DEFAULT 0 COMMENT '차감한 크레딧 수량 (환불 금액 결정)',
-- 산출물 (Blob 업로드 후 채워짐). 명명은 ssul_content 관례를 따른다:
-- poster_url = 커버/썸네일(SNS 공유 og:image 역할), 원본은 source_*, 결과물은 파이프라인 접두.
p2v_video_url VARCHAR(500) NULL COMMENT '완성 영상 Blob URL',
poster_url VARCHAR(500) NULL COMMENT '썸네일 Blob URL (SNS 공유 og:image 역할, ssul_content.poster_url 관례)',
source_image_url VARCHAR(500) NULL COMMENT '원본 업로드 포스터 Blob URL',
-- 확정 메타데이터 (검수에서 사용자가 수정한 최종본만 보관)
event_name VARCHAR(200) NULL COMMENT '행사명',
date_text VARCHAR(100) NULL COMMENT '일시 표기',
place VARCHAR(200) NULL COMMENT '장소',
keywords JSON NULL COMMENT '키워드 목록',
narration JSON NULL COMMENT '나레이션 3문장',
duration DECIMAL(6,2) NULL COMMENT '완성 영상 길이(초)',
error VARCHAR(1000) NULL COMMENT '실패 사유 (스테이지 + detail)',
archived_at DATETIME NULL COMMENT 'Blob 업로드 완료 일시. NULL 이면 아직 P2V 에만 있음',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
PRIMARY KEY (id),
CONSTRAINT fk_p2v_video_user FOREIGN KEY (user_uuid) REFERENCES `user`(user_uuid) ON DELETE CASCADE,
UNIQUE KEY uq_p2v_video_ref (p2v_job_id),
INDEX idx_p2v_video_user_created (user_uuid, created_at),
INDEX idx_p2v_video_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='P2V F1 — 무빙 포스터(포스터→영상) 잡';
CREATE TABLE IF NOT EXISTS p2v_poster (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자 (크레딧 원장 job_ref 앵커)',
user_uuid VARCHAR(36) NOT NULL COMMENT '생성 요청한 사용자 UUID',
p2v_job_id VARCHAR(64) NULL COMMENT 'P2V 서버가 발급한 잡 id(F2)',
name VARCHAR(100) NULL COMMENT '포스터 이름 (업로드 파일명에서 추출). 내 콘텐츠 카드 제목',
source_video_id BIGINT NULL COMMENT '원본 p2v_video 잡 (P2V source_slug 대응). 독립 잡이면 NULL',
status VARCHAR(20) NOT NULL DEFAULT 'queued' COMMENT '상태 (queued/running/archiving/done/failed)',
credit_amount INT NOT NULL DEFAULT 0 COMMENT '차감한 크레딧 수량',
-- 입력 스냅샷 (템플릿이 삭제돼도 이력이 남아야 한다)
template_id VARCHAR(64) NOT NULL COMMENT '사용한 스타일 템플릿 id',
template_name VARCHAR(100) NULL COMMENT '템플릿 이름 스냅샷',
license VARCHAR(20) NULL COMMENT '템플릿 배포 등급 (public-domain/internal-only/user-uploaded). 외부 공개 가부 판단',
format VARCHAR(16) NOT NULL DEFAULT 'poster' COMMENT '출력 포맷 (poster/story/feed/square)',
-- 산출물 (명명은 p2v_video와 동일 관례)
p2v_poster_url VARCHAR(500) NULL COMMENT '스타일 변환 결과 Blob URL',
source_image_url VARCHAR(500) NULL COMMENT '원본 업로드 포스터 Blob URL',
error VARCHAR(1000) NULL COMMENT '실패 사유',
archived_at DATETIME NULL COMMENT 'Blob 업로드 완료 일시',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
PRIMARY KEY (id),
CONSTRAINT fk_p2v_poster_user FOREIGN KEY (user_uuid) REFERENCES `user`(user_uuid) ON DELETE CASCADE,
CONSTRAINT fk_p2v_poster_source FOREIGN KEY (source_video_id) REFERENCES p2v_video(id) ON DELETE SET NULL,
UNIQUE KEY uq_p2v_poster_ref (p2v_job_id),
INDEX idx_p2v_poster_user_created (user_uuid, created_at),
INDEX idx_p2v_poster_status (status),
INDEX idx_p2v_poster_source (source_video_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='P2V F2 — 포스터 스타일링 잡';
-- ============================================================
-- 롤백
-- ============================================================
-- DROP TABLE IF EXISTS p2v_poster; -- FK 때문에 poster 를 먼저 지운다
-- DROP TABLE IF EXISTS p2v_video;

View File

@ -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 (

View File

@ -1,414 +0,0 @@
# 📋 설계 문서 — P2V(무빙 포스터·포스터 스타일링) 크레딧 연동
작성일: 2026-08-25
대상 모듈: `app/p2v/` (신규)
---
## 1. 요구사항 요약
### 배경
프론트의 **무빙 포스터(F1)****포스터 스타일링(F2)** 은 castad 백엔드를 거치지 않고
별도 P2V 서버(`:8010`, `o2o-ado2-poster-to-video`)를 브라우저에서 **직접 호출**한다.
그 결과 castad의 크레딧 원장이 이 요청을 볼 수 없어 **과금이 전혀 되지 않는다.**
### 기능적 요구사항
| # | 내용 |
|---|---|
| FR-1 | F1·F2 생성 요청 시 크레딧을 **선차감**하고, 실패 시 **자동 환불**한다 |
| FR-2 | 잡의 **소유자**를 castad가 관리한다 (P2V 서버에는 사용자 개념이 없음) |
| FR-3 | 완성 **결과물을 Azure Blob에 업로드**하고 URL을 DB에 보관한다 |
| FR-4 | 프론트의 P2V 직접 호출을 castad 프록시 경유로 전환한다 |
| FR-5 | 파이프라인별로 결과 메타데이터를 보관해 P2V 없이도 이력 조회가 가능해야 한다 |
### 비기능적 요구사항
- P2V 서버는 **단일 워커** 전제 — castad가 동시 요청을 늘려도 P2V 처리량은 그대로다
- 크레딧 차감/환불은 **멱등**이어야 한다 (폴링 중복·재시도·크래시 복구)
- 저작권: 결과물이 공개 Blob URL에 올라가므로 **템플릿 라이선스 등급을 기록**한다
### 범위 밖 (명시적 제외)
- P2V 서버 자체 코드 수정 (콜백/웹훅 추가 등) — castad가 폴링으로 해결한다
- retry 재과금 정책 — **보류**. §9 참조
---
## 2. 설계 개요
```
브라우저
│ Authorization: Bearer <castad JWT>
castad :8000 /p2v/*
├─ 인증(get_current_user) · 소유권 검사
├─ 크레딧 선차감/환불 (credit_transaction 원장 재사용)
├─ P2V API 중계 (httpx)
└─ 완료 감지 시 결과물 Blob 아카이빙 (BackgroundTasks)
│ X-P2V-Key
P2V :8010 (Docker, 외부 비공개)
```
### 핵심 결정
1. **id 체계**: 프론트에는 castad 자체 id(BIGINT)만 노출한다. P2V 잡 id는 내부 컬럼에만 둔다.
소유권 검사가 castad id 기준으로 단순해지고, P2V 식별자가 외부로 새지 않는다.
2. **결과물은 Blob, 나머지는 경량 프록시**: mp4·결과 이미지는 Blob 공개 URL로 서빙하므로
Range 요청 스트리밍 프록시가 **불필요**하다. 검수용 `check_jpg`와 템플릿 썸네일만
작은 이미지 프록시로 처리한다.
3. **`archiving` 상태 도입**: P2V가 `done`을 보고해도 Blob 업로드 전까지는 `archiving`으로
응답한다. 프론트는 이를 진행 중으로 취급하므로 "URL이 잠깐 비어 있는" 창이 생기지 않는다.
4. **서비스는 commit 하지 않는다** — 트랜잭션 소유는 라우터/워커 (ssulbox 규칙 준수).
---
## 3. API 설계
`APIRouter(prefix="/p2v", tags=["P2V"])` — 전 엔드포인트 `Depends(get_current_user)`.
### F1 — 무빙 포스터
| 메서드 | 경로 | 요청 | 응답 | 크레딧 |
|---|---|---|---|---|
| POST | `/p2v/f1/jobs` | multipart(`poster`, `name`) | `P2vJobCreateResponse` | **선차감** |
| GET | `/p2v/f1/jobs/{job_id}` | — | `P2vF1StatusResponse` | — |
| PUT | `/p2v/f1/jobs/{job_id}/narration` | `NarrationUpdateRequest` | `204` | — |
| PUT | `/p2v/f1/jobs/{job_id}/metadata` | `MetadataUpdateRequest` | `204` | — |
| POST | `/p2v/f1/jobs/{job_id}/approve` | `ApproveRequest?` | `202` | — |
| POST | `/p2v/f1/jobs/{job_id}/retry` | `ApproveRequest?` | `202` | 보류(§9) |
| DELETE | `/p2v/f1/jobs/{job_id}` | — | `P2vDeleteResponse` | — |
### F2 — 포스터 스타일링
| 메서드 | 경로 | 요청 | 응답 | 크레딧 |
|---|---|---|---|---|
| GET | `/p2v/f2/templates` | — | `list[F2TemplateResponse]` | — |
| POST | `/p2v/f2/templates` | multipart(`reference`, `name`) | `F2TemplateResponse` | — |
| DELETE | `/p2v/f2/templates/{template_id}` | — | `P2vDeleteResponse` | — |
| GET | `/p2v/f2/categories` | — | `list[F2CategoryResponse]` | — |
| GET | `/p2v/f2/formats` | — | `list[F2FormatResponse]` | — |
| GET | `/p2v/f2/upload-hint` | — | `F2UploadHintResponse` | — |
| POST | `/p2v/f2/jobs` | multipart(`poster`, `template_id`, `format`) | `P2vJobCreateResponse` | **선차감** |
| GET | `/p2v/f2/jobs/{job_id}` | — | `P2vF2StatusResponse` | — |
### 정적 파일 경량 프록시
| 메서드 | 경로 | 용도 |
|---|---|---|
| GET | `/p2v/files/{path:path}` | 검수용 `check_jpg`, 템플릿 썸네일 |
- 허용 prefix **화이트리스트**: `files/templates/`, `files/user_templates/`, `files/regions/`
`files/render/`·`files/uploads/`는 **차단**(결과물은 Blob으로만 나간다)
- 경로 traversal 방어(`..` 거부), `Content-Type` 중계, Range 미지원(작은 이미지 전용)
### 공통 응답 코드
`401` 인증 실패 · `402` 크레딧 부족 · `404` 잡 없음/**남의 잡** · `503` P2V 비활성·연결 실패
> 소유권 위반은 403이 아니라 **404** — 존재 여부를 노출하지 않는 ssulbox 정책을 따른다.
---
## 4. 데이터 모델
### `app/p2v/models.py`
파이프라인별로 **2개 테이블**. 과정과 산출물이 다르고, P2V 없이도 이력이 완결되어야 한다.
#### `P2vF1Job``p2v_video` (클래스명은 파이프라인 코드명 F1을 유지, 테이블명은 산출물 종류를 따른다)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| `id` | BigInteger PK | 크레딧 원장 `job_ref` 앵커 |
| `user_uuid` | String(36) FK→user | 소유자 |
| `p2v_job_id` | String(64) UNIQUE NULL | P2V 잡 id. 서버 호출 성공 후 채움 |
| `name` | String(100) NULL | 사용자 입력 행사명 |
| `status` | String(20) | `queued/running/awaiting_review/archiving/done/failed` |
| `credit_amount` | Integer | 차감 크레딧 (환불 금액 결정) |
| `p2v_video_url` | String(500) NULL | 완성 영상 **Blob URL** (결과물) |
| `poster_url` | String(500) NULL | 썸네일 Blob URL (SNS 공유 og:image 역할 — `ssul_content.poster_url` 관례) |
| `source_image_url` | String(500) NULL | 원본 업로드 포스터 Blob URL |
| `event_name` / `date_text` / `place` | String | 검수 확정 메타데이터 |
| `keywords` / `narration` | JSON NULL | 키워드·나레이션 3문장 |
| `duration` | Decimal(6,2) NULL | 영상 길이(초) |
| `error` | String(1000) NULL | 실패 사유 |
| `archived_at` | DateTime NULL | Blob 업로드 완료 시각 |
| `created_at` / `updated_at` | DateTime | |
#### `P2vF2Job``p2v_poster`
| 컬럼 | 타입 | 설명 |
|---|---|---|
| `id` | BigInteger PK | 크레딧 원장 앵커 |
| `user_uuid` | String(36) FK→user | 소유자 |
| `p2v_job_id` | String(64) UNIQUE NULL | P2V 잡 id |
| `source_video_id` | BigInteger FK→p2v_video NULL | P2V `source_slug` 대응. 독립 잡이면 NULL |
| `status` | String(20) | `queued/running/archiving/done/failed` |
| `credit_amount` | Integer | 차감 크레딧 |
| `template_id` | String(64) | 사용 템플릿 |
| `template_name` | String(100) NULL | 이름 스냅샷 (템플릿 삭제 대비) |
| `license` | String(20) NULL | 배포 등급 — **외부 공개 가부 판단용** |
| `format` | String(16) | `poster/story/feed/square` |
| `p2v_poster_url` | String(500) NULL | 스타일 변환 결과 이미지 **Blob URL** (결과물) |
| `source_image_url` | String(500) NULL | 원본 업로드 포스터 Blob URL |
| `error` / `archived_at` / `created_at` / `updated_at` | | F1과 동일 |
### 크레딧 원장 — **스키마 변경 없음**
`credit_transaction`이 이미 `(job_type, job_ref, type)` 멱등 키로 이질적 작업을 수용한다.
`constants.py`에 상수만 추가:
```python
JOB_TYPE_P2V_F1: Final[str] = "p2v_f1"
JOB_TYPE_P2V_F2: Final[str] = "p2v_f2"
```
파이프라인별 가격 차등이 자연스럽게 가능하다
(F1은 Higgsfield 22크레딧+OpenAI 다수, F2는 gpt-image 1회로 실비용 차이가 크다).
### 마이그레이션
`docs/database-schema/migration_2026-08-25_p2v.sql`**수동 실행**. 앱은 DDL을 실행하지 않는다.
`app/database/session.py``create_db_tables()`에 모델 import + `__table__` 등록 필요.
---
## 5. 서비스 레이어
### `app/p2v/services/client.py` — P2V HTTP 클라이언트
- `httpx.AsyncClient` 모듈 싱글턴 (`app/utils/creatomate.py`의 `_shared_client` 패턴)
- 모든 요청에 `X-P2V-Key` 주입, timeout/limits 튜닝
- `createF2Template`은 서버가 **동기로 10초 안팎 화풍 분석**을 하므로 timeout ≥ 60s
- 연결 실패 → `P2vUnavailableError`(503)
### `app/p2v/services/f1_service.py` / `f2_service.py` — 잡 라이프사이클
**모듈 레벨 함수, commit 하지 않음** (ssulbox `task_service` 스타일)
```
create_job(session, *, user_uuid, ...) -> P2vF1Job
session.add(row)
await session.flush() # id 확보
await deduct_credit_for_job( # ← 같은 트랜잭션
session, user_uuid=..., amount=...,
job_type=JOB_TYPE_P2V_F1, job_ref=str(row.id), reason=...)
return row # commit 은 라우터가
sync_from_p2v(session, row, p2v_job) -> P2vF1Job
상태·메타데이터 미러링. failed 로 전이하면 refund_credit_for_job 호출(멱등)
fail_job(session, row, detail) # 환불 + status='failed'
sweep_orphans(session) # 오래된 awaiting_review/queued 환불 (lifespan 훅)
```
### `app/p2v/services/archive_service.py` — Blob 아카이빙
- `blob_enabled()` 가드 (ssulbox `blob_service` 복제)
- `AzureBlobUploader(user_uuid=..., task_id=f"p2v-f1-{id}")` → 경로가 castad 콘텐츠와 분리됨
- P2V에서 바이트로 받아 **로컬 파일 없이** `upload_video_bytes` / `upload_image_bytes`로 중계
- 성공 시 F1은 `p2v_video_url`/`poster_url`, F2는 `p2v_poster_url` + `archived_at` 기록, `status='done'`
### 트랜잭션 경계 (ssulbox 규칙 준수)
| 시점 | 세션 | 이유 |
|---|---|---|
| 생성(POST) | `AsyncSessionLocal()` **직접** | INSERT + 선차감을 한 트랜잭션으로 묶는다 |
| 그 외 라우터 | `Depends(get_session)` | 일반 규칙 |
| 아카이빙(BackgroundTasks) | `BackgroundSessionLocal()` | 요청 풀과 분리 |
**생성 순서 — 이 순서가 핵심이다:**
```
1. INSERT + flush + 크레딧 차감 ← 부족하면 402, P2V 미호출
2. commit ← 차감 확정
3. P2V API 호출 (파일 업로드)
4. p2v_job_id UPDATE + commit
```
크레딧 검사를 P2V 호출보다 **먼저** 하므로, 잔액이 없는 사용자가 OpenAI 실비용을
태우는 일이 없다. 3에서 실패하면 `fail_job`으로 즉시 환불한다.
### Blob 아카이빙 트리거
폴링 엔드포인트가 P2V `done`을 **처음 관측**했을 때 `BackgroundTasks`로 위임한다.
(짧은 async I/O이므로 job_manager 스레드 방식은 과하다 — 선택 기준 준수)
중복 실행 방지는 낙관적 잠금:
```sql
UPDATE p2v_video SET status='archiving'
WHERE id=:id AND status<>'archiving' AND archived_at IS NULL
```
`rowcount == 0`이면 다른 폴링이 이미 집어간 것이므로 조용히 넘어간다.
---
## 6. 스키마 (`app/p2v/schemas/p2v_schema.py`)
Pydantic v2. 요청은 `str | None`, 응답은 `Optional[...]`, 상태는 `Literal`로 좁힌다.
검증 범위·기본값은 **스키마에만** 두고 DB 모델에 중복하지 않는다.
```python
F1StatusLiteral = Literal["queued", "running", "awaiting_review", "archiving", "done", "failed"]
F2StatusLiteral = Literal["queued", "running", "archiving", "done", "failed"]
FormatLiteral = Literal["poster", "story", "feed", "square"]
class P2vJobCreateResponse(BaseModel):
"""생성 요청 접수. id 로 폴링한다."""
id: int = Field(..., description="castad 잡 ID (P2V 식별자가 아니다)")
status: F1StatusLiteral = Field(..., description="접수 직후 상태")
poll_interval_seconds: int = Field(..., description="권장 폴링 간격(초)")
class P2vF1StatusResponse(BaseModel):
id: int = Field(..., description="잡 ID")
status: F1StatusLiteral = Field(..., description="진행 상태")
stage: Optional[str] = Field(None, description="현재 스테이지")
progress: int = Field(0, ge=0, le=100, description="진행률")
narration: Optional[list[str]] = Field(None, description="나레이션 3문장 (검수 대상)")
metadata: Optional[P2vMetadata] = Field(None, description="행사 메타데이터 (검수 대상)")
motion_elements: Optional[list[str]] = Field(None, description="채택된 모션 요소")
p2v_video_url: Optional[str] = Field(None, description="완성 영상 Blob URL (done 에서만)")
poster_url: Optional[str] = Field(None, description="썸네일 Blob URL (SNS 공유 og:image 역할)")
check_image_url: Optional[str] = Field(None, description="검수용 영역분석 이미지 (프록시 경로)")
error: Optional[str] = Field(None, description="실패 사유")
# 필드명을 DB 컬럼명과 맞춰 from_attributes 자동 매핑이 깨지지 않게 한다.
model_config = {"from_attributes": True}
class NarrationUpdateRequest(BaseModel):
narration: list[str] = Field(..., min_length=1, max_length=5,
description="수정한 나레이션 문장 목록")
class MetadataUpdateRequest(BaseModel):
event_name: str = Field(..., max_length=200, description="행사명")
date_text: str = Field(default="", max_length=100, description="일시 표기")
place: str = Field(..., max_length=200, description="장소")
class F2JobCreateRequest(BaseModel):
"""multipart 이므로 실제로는 Form 파라미터. 검증 범위 문서화용."""
template_id: str = Field(..., max_length=64, description="스타일 템플릿 id")
format: FormatLiteral = Field(default="poster", description="출력 포맷")
```
---
## 7. 파일 구조
### 신규 — `app/p2v/`
```
app/p2v/
├── __init__.py 모듈 docstring
├── constants.py JOB_TYPE_P2V_F1/F2, 상태 Enum, 프록시 화이트리스트
├── exceptions.py P2vException + 하위 예외
├── models.py P2vF1Job, P2vF2Job
├── api/routers/v1/
│ ├── f1.py 무빙 포스터 라우터
│ ├── f2.py 스타일링 라우터
│ └── files.py 경량 정적 프록시
├── schemas/p2v_schema.py 요청/응답 DTO
└── services/
├── client.py P2V httpx 싱글턴
├── f1_service.py F1 잡 라이프사이클 + 크레딧
├── f2_service.py F2 잡 라이프사이클 + 크레딧
└── archive_service.py Blob 업로드
```
(각 `__init__.py`는 빈 파일 — 재수출하지 않는다)
### 수정
| 파일 | 작업 |
|---|---|
| `config.py` | `P2vSettings` 추가 + 파일 끝 `p2v_settings = P2vSettings()` |
| `app/core/exceptions.py` | `add_exception_handlers()``@app.exception_handler(P2vException)` 블록 추가 |
| `app/database/session.py` | `create_db_tables()`에 모델 import + `__table__` 등록 |
| `app/core/common.py` | lifespan에 `sweep_orphans` 훅 (ssulbox 패턴) |
| `main.py` | 라우터 import + `tags_metadata` 엔트리 + `if p2v_settings.P2V_ENABLED:` 조건부 등록 |
| `docs/database-schema/migration_2026-08-25_p2v.sql` | DDL (수동 실행) |
### `P2vSettings` 필드 (env_prefix 대신 필드명에 접두)
```
P2V_ENABLED bool 기본 True
P2V_BASE_URL str 기본 http://localhost:8010
P2V_ACCESS_KEY str 기본 "" (P2V 서버 X-P2V-Key)
P2V_CREDITS_PER_F1 int 기본 ? ← 가격 미정
P2V_CREDITS_PER_F2 int 기본 ? ← 가격 미정
P2V_BLOB_PREFIX str 기본 "p2v"
P2V_POLL_HINT_SECONDS int 기본 3
P2V_ORPHAN_TIMEOUT_HOURS int 기본 24 (검수 방치 잡 환불 기준)
```
### 프론트엔드 (별도 저장소)
| 파일 | 작업 |
|---|---|
| `src/utils/p2vApi.ts` | **핵심 교체 지점**`P2V_URL`→castad `/p2v`, `p2vFetch`→`authenticatedFetch`, `p2vFile()` 재작성, 402 처리 추가, 키 저장 함수 제거 |
| `src/components/P2vKeyGate.tsx` | **삭제** (castad JWT가 대체) |
| `PosterCreateForm.tsx` / `StylingContent.tsx` | `P2vAuthError` 분기 → 402 크레딧 부족 UI |
| `PosterResultContent.tsx` / `PosterReviewContent.tsx` | `p2vFile` 사용처가 Blob URL·프록시 경로로 바뀜 |
| `useP2vJob.ts` | `authNeeded` 의미 변경 |
---
## 8. 구현 순서
1. **마이그레이션 SQL 작성** → 사용자가 수동 실행 (앱은 DDL 미실행)
2. `config.py``P2vSettings` + 인스턴스
3. `app/p2v/` 뼈대: `__init__/constants/exceptions/models`
4. `app/core/exceptions.py``P2vException` 핸들러 등록
5. `app/database/session.py`에 모델 등록
6. `services/client.py` — P2V 중계 클라이언트 (가장 먼저 단독 검증 가능)
7. `schemas/p2v_schema.py`
8. `services/f1_service.py` / `f2_service.py` — 크레딧 차감·환불·상태 미러
9. `services/archive_service.py` — Blob 업로드
10. `api/routers/v1/f2.py`**F2를 먼저** (단일 단계라 흐름이 단순, 검증이 빠름)
11. `api/routers/v1/f1.py` — 검수 게이트 포함
12. `api/routers/v1/files.py` — 경량 프록시
13. `main.py` 등록 + `tags_metadata`
14. `app/core/common.py` lifespan 고아 스윕
15. 프론트 `p2vApi.ts` 교체 → 나머지 컴포넌트
> **10번을 F2부터** 하는 이유: F1은 7단계 파이프라인 + 검수 게이트라 왕복이 길다.
> F2로 "인증→차감→프록시→Blob→환불" 전 경로를 먼저 관통시켜 검증한 뒤 F1에 적용한다.
---
## 9. 설계 검수 결과
### 체크리스트
| 항목 | 결과 |
|---|---|
| 기존 프로젝트 패턴과 일관성 | ✅ ssulbox 구조·예외(B계열)·트랜잭션 경계·Blob 재사용 |
| 비동기 처리 적절성 | ✅ httpx AsyncClient, BackgroundTasks, BackgroundSessionLocal |
| N+1 쿼리 | ✅ 단건 조회 위주. 목록은 단일 `select` + `scalars().all()` |
| 트랜잭션 경계 명확성 | ✅ 서비스는 commit 안 함, 생성만 `AsyncSessionLocal()` 직접 |
| 예외 처리 전략 | ✅ `P2vException(message, status_code, code)` + 전역 핸들러 |
| 확장성 | ✅ 파이프라인 추가 시 테이블·서비스만 추가. 원장은 무변경 |
| 직관적 구조 | ✅ F1/F2 라우터 분리로 파이프라인 차이가 코드에 드러남 |
| SOLID | ✅ client/service/archive 책임 분리 |
### 발견된 위험과 대응
| # | 위험 | 대응 |
|---|---|---|
| R-1 | **검수 방치 시 크레딧 증발** — 업로드 후 approve 안 하면 선차감분이 안 돌아옴 | lifespan `sweep_orphans``P2V_ORPHAN_TIMEOUT_HOURS` 초과 `awaiting_review` 잡 환불 |
| R-2 | **게이트 실패 시 실비용 손실** — 제목 훼손 게이트는 Higgsfield 크레딧이 나간 **뒤** 실패한다. 유저 환불 시 실비용은 회사 부담 | 정책상 수용. 손실 추적을 위해 `error`에 사유 보존 |
| R-3 | 폴링 중복으로 Blob 이중 업로드 | `status='archiving'` 낙관적 잠금 (rowcount 검사) |
| R-4 | P2V 단일 워커 — castad 동시성이 늘어도 처리량 불변 | `queue_size` 를 응답에 노출해 대기열 안내 |
| R-5 | 사용자 업로드 레퍼런스는 권리 미확인 | `license` 컬럼에 등급 스냅샷 보존 |
| R-6 | P2V 컨테이너 재생성 시 `/app/prompts` 소실 | P2V `docker-compose.yml``p2v-prompts` 볼륨 추가 권장 (범위 밖, 별도 조치) |
### 미결 사항 — **구현 착수 전 확정 필요**
| # | 항목 | 영향 |
|---|---|---|
| D-1 | **F1 차감 시점** — 잡 생성 vs approve(검수 승인) | 본 설계는 ssulbox 선례를 따라 **생성 시점**으로 잡고 R-1 스윕으로 보완했다. approve 시점으로 바꾸면 스윕이 불필요해지는 대신 검수 전 무료 구간이 생긴다 |
| D-2 | **retry 재과금** — 보류 중 | 재과금 시 `attempt INT NOT NULL DEFAULT 1` 컬럼 추가 + `job_ref = "{id}:{attempt}"`. **순수 추가형 ALTER**라 나중에 결정해도 비파괴적. 미결 동안은 재시도 무료가 기본 동작 |
| D-3 | **크레딧 단가**`P2V_CREDITS_PER_F1/F2` | 실비용 차이가 크다(F1: Higgsfield 22크레딧+OpenAI 다수 / F2: gpt-image 1회) |
---
## 10. 구현 노트 (2026-08-25 /develop 반영)
설계와 달라진 두 가지 — 구현 중 발견한 근거로 조정했다:
1. **응답 스키마는 P2V 호환 형태(artifacts 딕셔너리)를 유지했다.** §6 초안의
`p2v_video_url` 평면 필드 대신, 프론트가 이미 맞춰져 있는 P2V 잡 JSON 형태
(`artifacts.video`, `stages`, `queue_size`)를 유지해 프론트 수정을 3개 파일로
줄였다. DB 컬럼명(§4)은 초안대로다 — API 표현과 저장 표현을 분리한 것.
2. **F1 은 실패 관측 시 환불하지 않는다.** 크레딧 원장의 (job_type, job_ref, type)
유니크 제약상 환불 후 재차감이 불가능해, "실패 → 자동 환불 → 무료 재시도"가
되는 구멍이 있었다. F1 환불 시점은 **삭제·고아 스윕·P2V 잡 소실** 세 경우로
한정한다. F2 는 재시도 경로가 없으므로 초안대로 실패 즉시 환불한다.
## 다음 단계
`/review` 로 코드 리뷰를 진행한다. 배포 전 수동 작업:
- `migration_2026-08-25_p2v.sql` 수동 실행 (DDL 은 앱이 실행하지 않는다)
- castad 백엔드가 컨테이너로 뜨는 환경에서는 `.env`
`P2V_BASE_URL=http://host.docker.internal:8010` 지정 (기본값 localhost 는
컨테이너 자신을 가리킨다). P2V 포트는 호스트 127.0.0.1 에만 바인딩돼 있다.
- 크레딧 단가(D-3) 확정 시 `P2V_CREDITS_PER_F1/F2` 설정

View File

@ -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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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())

View File

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

Some files were not shown because too many files have changed in this diff Show More