Compare commits
20 Commits
main
...
feature-p2
| Author | SHA1 | Date | |
|---|---|---|---|
| 43709bf7e0 | |||
| 5de5c7507d | |||
| b3385e7df2 | |||
| 6d46614dcd | |||
| 934410a83b | |||
| 009bdbfe73 | |||
| a727d4f1ac | |||
| 048a8f36a8 | |||
| 5fcb964d7f | |||
| e47a97476d | |||
| fde48e8674 | |||
| 701f0542da | |||
| bb8b20a7a9 | |||
| e1dca36d21 | |||
| 434d0ca9c0 | |||
| 459099998e | |||
| 50dc7af5bc | |||
| fb8febe462 | |||
| ed1ba71bfe | |||
| 666c3bf6d1 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -57,3 +57,6 @@ Dockerfile
|
||||
zzz/
|
||||
credentials/service_account.json
|
||||
o2o-castad-scheduler/
|
||||
|
||||
generator/output/
|
||||
generator/*/load_image/
|
||||
@ -17,14 +17,9 @@ 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.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.models import Video
|
||||
from app.video.schemas.video_schema import VideoListItem
|
||||
from app.video.services import unified_list
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@ -84,112 +79,32 @@ async def get_videos(
|
||||
try:
|
||||
offset = (pagination.page - 1) * 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
|
||||
items, total = await unified_list.fetch_my_contents(
|
||||
session,
|
||||
user_uuid=current_user.user_uuid,
|
||||
offset=offset,
|
||||
limit=pagination.page_size,
|
||||
)
|
||||
.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(
|
||||
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),
|
||||
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,
|
||||
)
|
||||
for video, project, comment_count in rows
|
||||
for it in items
|
||||
]
|
||||
|
||||
response = PaginatedResponse.create(
|
||||
|
||||
@ -36,7 +36,7 @@ async def soft_delete_by_task_id(task_id: str) -> dict:
|
||||
dict: 각 테이블별 업데이트된 레코드 수
|
||||
"""
|
||||
logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}")
|
||||
logger.debug(f"[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
|
||||
logger.debug("[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
|
||||
|
||||
result = {
|
||||
"task_id": task_id,
|
||||
|
||||
@ -9,7 +9,9 @@ Comment API Router
|
||||
- DELETE /comment/{comment_id}: 본인 댓글 소프트 삭제 (로그인 필수)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.comment.schemas.comment_schema import (
|
||||
@ -60,12 +62,16 @@ 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 - video_id: {video_id}, user: {current_user.user_uuid}, "
|
||||
f"parent_id: {body.parent_id}"
|
||||
f"[post_comment] START - type: {type}, id: {video_id}, "
|
||||
f"user: {current_user.user_uuid}, parent_id: {body.parent_id}"
|
||||
)
|
||||
comment = await create_comment(
|
||||
session=session,
|
||||
@ -74,6 +80,7 @@ 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(
|
||||
@ -113,12 +120,16 @@ 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 - video_id: {video_id}, "
|
||||
f"[get_comments] START - type: {type}, id: {video_id}, "
|
||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
)
|
||||
current_user_uuid = current_user.user_uuid if current_user else None
|
||||
@ -128,6 +139,7 @@ async def get_comments(
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
current_user_uuid=current_user_uuid,
|
||||
content_type=type,
|
||||
)
|
||||
logger.info(f"[get_comments] SUCCESS - total: {result.total}, items: {len(result.items)}")
|
||||
return result
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from 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
|
||||
|
||||
@ -19,11 +30,22 @@ 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"),
|
||||
@ -37,11 +59,19 @@ class Comment(Base):
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
||||
)
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_comment_one_target).
|
||||
video_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="연결된 Video의 id",
|
||||
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)",
|
||||
)
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||
@ -70,7 +100,13 @@ class Comment(Base):
|
||||
comment="작성 일시",
|
||||
)
|
||||
|
||||
video: Mapped["Video"] = relationship("Video", back_populates="comments")
|
||||
# 썰박스 댓글이면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
|
||||
video: Mapped[Optional["Video"]] = relationship(
|
||||
"Video", foreign_keys=[video_id], back_populates="comments"
|
||||
)
|
||||
content_ref: Mapped[Optional["SsulContent"]] = relationship(
|
||||
"SsulContent", foreign_keys=[content_id], lazy="noload"
|
||||
)
|
||||
user: Mapped["User"] = relationship("User", back_populates="comments")
|
||||
parent: Mapped[Optional["Comment"]] = relationship(
|
||||
"Comment", remote_side=[id], back_populates="replies"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import exists, select
|
||||
@ -7,17 +7,55 @@ 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,
|
||||
video_id: int,
|
||||
content_type: ContentType,
|
||||
target_id: int,
|
||||
) -> None:
|
||||
"""2-depth 제한 + 동일 video 검증."""
|
||||
"""2-depth 제한 + 동일 대상 검증."""
|
||||
result = await session.execute(
|
||||
select(Comment).where(
|
||||
Comment.id == parent_id,
|
||||
@ -28,8 +66,11 @@ async def _validate_parent(
|
||||
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.")
|
||||
if parent.video_id != video_id:
|
||||
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.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)")
|
||||
|
||||
@ -77,24 +118,19 @@ async def create_comment(
|
||||
nickname: Optional[str],
|
||||
content: str,
|
||||
parent_id: Optional[int],
|
||||
content_type: ContentType = "video",
|
||||
) -> Comment:
|
||||
# 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="영상을 찾을 수 없습니다.")
|
||||
# 대상 존재 확인
|
||||
await _ensure_target_exists(session, content_type, video_id)
|
||||
|
||||
# parent_id 검증
|
||||
if parent_id is not None:
|
||||
await _validate_parent(session, parent_id, video_id)
|
||||
await _validate_parent(session, parent_id, content_type, video_id)
|
||||
|
||||
comment = Comment(
|
||||
video_id=video_id,
|
||||
# 종류에 따라 둘 중 하나만 채운다 (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,
|
||||
user_uuid=user_uuid,
|
||||
nickname=nickname,
|
||||
parent_id=parent_id,
|
||||
@ -112,6 +148,7 @@ 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
|
||||
|
||||
@ -125,9 +162,10 @@ async def list_comments(
|
||||
.correlate(Comment)
|
||||
)
|
||||
|
||||
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것
|
||||
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것.
|
||||
# 종류에 맞는 대상 컬럼으로 걸러야 같은 id 의 반대 종류 댓글이 섞이지 않는다.
|
||||
parent_where = [
|
||||
Comment.video_id == video_id,
|
||||
_target_col(content_type) == video_id,
|
||||
Comment.parent_id.is_(None),
|
||||
(Comment.is_deleted == False) | has_live_reply, # noqa: E712
|
||||
]
|
||||
|
||||
@ -6,6 +6,8 @@ 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")
|
||||
|
||||
|
||||
@ -29,6 +31,51 @@ 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")
|
||||
@ -44,12 +91,24 @@ 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()
|
||||
|
||||
@ -326,6 +326,43 @@ 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):
|
||||
# 에러 메시지 로깅 (한글 포함 가능)
|
||||
|
||||
@ -6,7 +6,9 @@ from app.core.exceptions import FastShipError
|
||||
class InsufficientCreditError(FastShipError):
|
||||
"""크레딧이 부족합니다."""
|
||||
|
||||
status = status.HTTP_400_BAD_REQUEST
|
||||
# 402 Payment Required. 사전차감 전환 전에는 라우터로 노출된 적이 없어(내부에서만
|
||||
# raise/catch) 400 이었다. 프론트가 "충전 화면으로 유도"를 402 로 분기한다.
|
||||
status = status.HTTP_402_PAYMENT_REQUIRED
|
||||
|
||||
|
||||
class InvalidRequestStateError(FastShipError):
|
||||
|
||||
@ -2,7 +2,16 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
@ -146,6 +155,10 @@ class CreditTransaction(Base):
|
||||
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
|
||||
Index("idx_credit_tx_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",
|
||||
@ -206,6 +219,25 @@ class CreditTransaction(Base):
|
||||
comment="연관 충전 요청 ID",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 작업 기반 멱등 키 — (job_type, job_ref, type) 유니크
|
||||
# ==========================================================================
|
||||
# 사전차감 정책의 핵심. 생성 작업을 시작할 때 차감하고 실패 시 환불하는데,
|
||||
# 재시도·중복 요청·크래시 후 재처리에도 "작업 1건당 정확히 1번"을 보장해야 한다.
|
||||
# FK 를 걸지 않는 이유: 썰박스(ssul_task.id 숫자)와 영상(video.task_id UUID7 문자열)
|
||||
# 이라는 이질적인 두 작업 테이블을 하나의 컬럼이 가리키기 때문이다.
|
||||
job_type: Mapped[Optional[str]] = mapped_column(
|
||||
String(20),
|
||||
nullable=True,
|
||||
comment="차감 유발 작업 종류 (ssul/video). 충전·관리자 조정은 NULL",
|
||||
)
|
||||
|
||||
job_ref: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
comment="작업 식별자 (ssul_task.id 문자열 또는 video.task_id)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
|
||||
@ -32,6 +32,8 @@ 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,
|
||||
@ -41,6 +43,8 @@ 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()
|
||||
@ -123,6 +127,189 @@ async def deduct_credit(
|
||||
return tx
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 작업 기반 차감/환불 (사전차감 정책)
|
||||
# =============================================================================
|
||||
# 위의 charge_credit / deduct_credit 은 멱등성이 없어 재시도 시 중복 반영된다.
|
||||
# 생성 작업처럼 "시작할 때 차감하고 실패하면 환불"하는 경로에서는 아래 두 함수를 쓴다.
|
||||
#
|
||||
# 멱등 보장 방식은 2중이다:
|
||||
# 1) 먼저 (job_type, job_ref, type) 로 기존 행을 조회해 있으면 그대로 반환
|
||||
# 2) 경합으로 1)을 통과한 두 요청이 동시에 INSERT 하면 DB 유니크 제약이 막는다
|
||||
# 잔액 자체는 User 행을 with_for_update() 로 잠근 뒤 읽기→갱신하므로 경합에 안전하다.
|
||||
#
|
||||
# 두 함수 모두 **자체 commit 하지 않는다.** 호출부가 트랜잭션을 소유하고,
|
||||
# 작업 행 생성과 차감을 한 트랜잭션으로 묶어 마지막에 한 번만 커밋해야 한다.
|
||||
|
||||
|
||||
async def _find_job_transaction(
|
||||
session: AsyncSession,
|
||||
job_type: str,
|
||||
job_ref: str,
|
||||
type: CreditTransactionType,
|
||||
) -> Optional[CreditTransaction]:
|
||||
"""(job_type, job_ref, type) 에 해당하는 기존 원장 행 조회"""
|
||||
result = await session.execute(
|
||||
select(CreditTransaction).where(
|
||||
CreditTransaction.job_type == job_type,
|
||||
CreditTransaction.job_ref == job_ref,
|
||||
CreditTransaction.type == type,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def deduct_credit_for_job(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_uuid: str,
|
||||
amount: int,
|
||||
job_type: str,
|
||||
job_ref: str,
|
||||
reason: Optional[str] = None,
|
||||
) -> CreditTransaction:
|
||||
"""작업 시작 시점에 크레딧을 선차감한다. (job_type, job_ref) 기준 멱등.
|
||||
|
||||
Args:
|
||||
session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다.
|
||||
user_uuid: 사용자 UUID
|
||||
amount: 차감할 크레딧 (양수)
|
||||
job_type: 작업 종류 ("video" | "ssul")
|
||||
job_ref: 작업 식별자 (video.task_id 또는 str(ssul_task.id))
|
||||
reason: 원장에 남길 사유
|
||||
|
||||
Returns:
|
||||
새로 만든 차감 원장 행. 이미 차감된 작업이면 기존 행을 그대로 반환한다.
|
||||
|
||||
Raises:
|
||||
InsufficientCreditError: 잔액 부족 (호출부에서 402 로 변환할 것)
|
||||
UserNotFoundError: 사용자 없음
|
||||
"""
|
||||
from app.user.models import User
|
||||
|
||||
existing = await _find_job_transaction(
|
||||
session, job_type, job_ref, CreditTransactionType.CONSUME
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
f"[CREDIT] deduct skipped (already charged) "
|
||||
f"job={job_type}:{job_ref} tx_id={existing.id}"
|
||||
)
|
||||
return existing
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.user_uuid == user_uuid).with_for_update()
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
from app.user.services.auth import UserNotFoundError
|
||||
|
||||
raise UserNotFoundError()
|
||||
|
||||
if user.credits < amount:
|
||||
logger.warning(
|
||||
f"[CREDIT] insufficient credits user_uuid={user_uuid} "
|
||||
f"credits={user.credits} requested={amount} job={job_type}:{job_ref}"
|
||||
)
|
||||
raise InsufficientCreditError()
|
||||
|
||||
user.credits = user.credits - amount
|
||||
await session.flush()
|
||||
|
||||
tx = await record_transaction(
|
||||
session=session,
|
||||
user_uuid=user_uuid,
|
||||
amount=-amount,
|
||||
balance_after=user.credits,
|
||||
type=CreditTransactionType.CONSUME,
|
||||
reason=reason,
|
||||
job_type=job_type,
|
||||
job_ref=job_ref,
|
||||
)
|
||||
logger.info(
|
||||
f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} "
|
||||
f"balance_after={user.credits} job={job_type}:{job_ref}"
|
||||
)
|
||||
return tx
|
||||
|
||||
|
||||
async def refund_credit_for_job(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_uuid: str,
|
||||
amount: int,
|
||||
job_type: str,
|
||||
job_ref: str,
|
||||
reason: Optional[str] = None,
|
||||
) -> Optional[CreditTransaction]:
|
||||
"""작업 실패 시 선차감한 크레딧을 환불한다. (job_type, job_ref) 기준 멱등.
|
||||
|
||||
차감 기록이 없으면 환불하지 않는다 — 애초에 차감되지 않은 작업(예: 정책 전환
|
||||
이전에 시작된 in-flight 작업)에 환불을 얹으면 크레딧이 늘어나기 때문이다.
|
||||
|
||||
Args:
|
||||
session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다.
|
||||
user_uuid: 사용자 UUID
|
||||
amount: 환불할 크레딧 (양수)
|
||||
job_type: 작업 종류 ("video" | "ssul")
|
||||
job_ref: 작업 식별자
|
||||
reason: 원장에 남길 사유
|
||||
|
||||
Returns:
|
||||
새로 만든 환불 원장 행. 이미 환불했거나 차감 기록이 없으면 None.
|
||||
"""
|
||||
from app.user.models import User
|
||||
|
||||
already = await _find_job_transaction(
|
||||
session, job_type, job_ref, CreditTransactionType.REFUND
|
||||
)
|
||||
if already is not None:
|
||||
logger.info(
|
||||
f"[CREDIT] refund skipped (already refunded) "
|
||||
f"job={job_type}:{job_ref} tx_id={already.id}"
|
||||
)
|
||||
return None
|
||||
|
||||
consumed = await _find_job_transaction(
|
||||
session, job_type, job_ref, CreditTransactionType.CONSUME
|
||||
)
|
||||
if consumed is None:
|
||||
logger.info(
|
||||
f"[CREDIT] refund skipped (never charged) job={job_type}:{job_ref}"
|
||||
)
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.user_uuid == user_uuid).with_for_update()
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
logger.warning(
|
||||
f"[CREDIT] refund skipped (user not found) "
|
||||
f"user_uuid={user_uuid} job={job_type}:{job_ref}"
|
||||
)
|
||||
return None
|
||||
|
||||
user.credits = user.credits + amount
|
||||
await session.flush()
|
||||
|
||||
tx = await record_transaction(
|
||||
session=session,
|
||||
user_uuid=user_uuid,
|
||||
amount=amount,
|
||||
balance_after=user.credits,
|
||||
type=CreditTransactionType.REFUND,
|
||||
reason=reason,
|
||||
job_type=job_type,
|
||||
job_ref=job_ref,
|
||||
)
|
||||
logger.info(
|
||||
f"[CREDIT] refund user_uuid={user_uuid} amount=+{amount} "
|
||||
f"balance_after={user.credits} job={job_type}:{job_ref}"
|
||||
)
|
||||
return tx
|
||||
|
||||
|
||||
async def approve_charge_request(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
|
||||
@ -5,12 +5,20 @@ Write-Behind 패턴 적용:
|
||||
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
|
||||
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
|
||||
|
||||
**콘텐츠 종류(ctype)** 를 받아 ADO2 영상과 썰박스 콘텐츠 양쪽에 같은 로직을 쓴다.
|
||||
`ctype` 기본값이 "video" 라서 기존 castad 호출부는 수정 없이 그대로 동작한다.
|
||||
검증된 Lua 원자 토글은 키를 인자로 받으므로 변경하지 않았다.
|
||||
|
||||
Key 패턴:
|
||||
- video:like:count:{video_id} INT — 좋아요 카운트
|
||||
- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록
|
||||
- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}"
|
||||
- {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:" 접두를 유지한다 — 배포 순간 큐에 남아 있는 항목을
|
||||
잃지 않기 위해서다(이름을 바꾸면 그 항목들이 영구 미반영된다).
|
||||
|
||||
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
|
||||
"""
|
||||
|
||||
@ -44,6 +52,17 @@ 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
|
||||
@ -68,61 +87,71 @@ async def close_like_cache() -> None:
|
||||
# Key 헬퍼
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _key(video_id: int) -> str:
|
||||
return f"video:like:count:{video_id}"
|
||||
def _key(content_id: int, ctype: str = CT_VIDEO) -> str:
|
||||
return f"{ctype}:like:count:{content_id}"
|
||||
|
||||
|
||||
def _user_key(video_id: int) -> str:
|
||||
return f"video:like:users:{video_id}"
|
||||
def _user_key(content_id: int, ctype: str = CT_VIDEO) -> str:
|
||||
return f"{ctype}:like:users:{content_id}"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 카운트 (기존 API 유지)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def get_like_count(video_id: int) -> int | None:
|
||||
async def get_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int | None:
|
||||
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
|
||||
val = await get_like_cache().get(_key(video_id))
|
||||
val = await get_like_cache().get(_key(content_id, ctype))
|
||||
if val is None:
|
||||
return None
|
||||
return max(int(val), 0)
|
||||
|
||||
|
||||
async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]:
|
||||
"""여러 영상의 like_count를 한 번에 조회 (mget).
|
||||
캐시 미스인 video_id는 None으로 반환."""
|
||||
if not video_ids:
|
||||
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:
|
||||
return {}
|
||||
keys = [_key(vid) for vid in video_ids]
|
||||
keys = [_key(cid, ctype) for cid in content_ids]
|
||||
values = await get_like_cache().mget(*keys)
|
||||
return {
|
||||
vid: max(int(v), 0) if v is not None else None
|
||||
for vid, v in zip(video_ids, values)
|
||||
cid: max(int(v), 0) if v is not None else None
|
||||
for cid, v in zip(content_ids, values)
|
||||
}
|
||||
|
||||
|
||||
async def set_like_count(video_id: int, count: int) -> None:
|
||||
async def set_like_count(
|
||||
content_id: int, count: int, *, ctype: str = CT_VIDEO
|
||||
) -> None:
|
||||
"""like_count를 Redis에 저장 (음수 방지)."""
|
||||
await get_like_cache().set(_key(video_id), max(count, 0))
|
||||
await get_like_cache().set(_key(content_id, ctype), max(count, 0))
|
||||
|
||||
|
||||
async def mset_like_counts(counts: dict[int, int]) -> None:
|
||||
"""여러 영상의 like_count를 한 번에 저장 (mset)."""
|
||||
async def mset_like_counts(
|
||||
counts: dict[int, int], *, ctype: str = CT_VIDEO
|
||||
) -> None:
|
||||
"""여러 콘텐츠의 like_count를 한 번에 저장 (mset)."""
|
||||
if not counts:
|
||||
return
|
||||
await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()})
|
||||
await get_like_cache().mset(
|
||||
{_key(cid, ctype): max(cnt, 0) for cid, cnt in counts.items()}
|
||||
)
|
||||
|
||||
|
||||
async def incr_like_count(video_id: int) -> int:
|
||||
async def incr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
|
||||
"""like_count를 1 증가 후 반환."""
|
||||
return max(int(await get_like_cache().incr(_key(video_id))), 0)
|
||||
return max(int(await get_like_cache().incr(_key(content_id, ctype))), 0)
|
||||
|
||||
|
||||
async def decr_like_count(video_id: int) -> int:
|
||||
async def decr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
|
||||
"""like_count를 1 감소 후 반환 (음수 방지)."""
|
||||
count = int(await get_like_cache().decr(_key(video_id)))
|
||||
client = get_like_cache()
|
||||
key = _key(content_id, ctype)
|
||||
count = int(await client.decr(key))
|
||||
if count < 0:
|
||||
await get_like_cache().set(_key(video_id), 0)
|
||||
await client.set(key, 0)
|
||||
return 0
|
||||
return count
|
||||
|
||||
@ -131,7 +160,9 @@ async def decr_like_count(video_id: int) -> int:
|
||||
# 유저 SET (is_liked_by_me source of truth)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
||||
async def toggle_like_atomic(
|
||||
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||
) -> tuple[bool, int]:
|
||||
"""Lua 스크립트로 원자적 좋아요 토글.
|
||||
|
||||
Returns:
|
||||
@ -140,14 +171,16 @@ async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
||||
result = await get_like_cache().eval(
|
||||
_TOGGLE_LIKE_SCRIPT,
|
||||
2,
|
||||
_user_key(video_id),
|
||||
_key(video_id),
|
||||
_user_key(content_id, ctype),
|
||||
_key(content_id, ctype),
|
||||
user_uuid,
|
||||
)
|
||||
return bool(result[0]), int(result[1])
|
||||
|
||||
|
||||
async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
||||
async def is_user_liked(
|
||||
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||
) -> bool | None:
|
||||
"""Redis user-set에서 좋아요 여부 조회.
|
||||
|
||||
Returns:
|
||||
@ -155,59 +188,101 @@ async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
||||
None: user-set 키가 없음 (cold-start backfill 필요 신호)
|
||||
"""
|
||||
client = get_like_cache()
|
||||
key = _user_key(video_id)
|
||||
key = _user_key(content_id, ctype)
|
||||
if not await client.exists(key):
|
||||
return None
|
||||
return bool(await client.sismember(key, user_uuid))
|
||||
|
||||
|
||||
async def is_user_set_exists(video_id: int) -> bool:
|
||||
async def is_user_set_exists(content_id: int, *, ctype: str = CT_VIDEO) -> bool:
|
||||
"""Redis user-set 키 존재 여부 확인."""
|
||||
return bool(await get_like_cache().exists(_user_key(video_id)))
|
||||
return bool(await get_like_cache().exists(_user_key(content_id, ctype)))
|
||||
|
||||
|
||||
async def bulk_is_user_liked(
|
||||
video_ids: list[int], user_uuid: str
|
||||
content_ids: list[int], user_uuid: str, *, ctype: str = CT_VIDEO
|
||||
) -> dict[int, bool | None]:
|
||||
"""여러 영상의 is_liked 여부를 한 번에 조회 (pipeline).
|
||||
"""여러 콘텐츠의 is_liked 여부를 한 번에 조회 (pipeline).
|
||||
|
||||
통합 목록처럼 두 종류가 섞인 경우에는 **종류별로 나눠 각각 호출한다** —
|
||||
반환 키가 content_id 하나여서 종류가 다른 같은 id 를 구분할 수 없다.
|
||||
|
||||
Returns:
|
||||
{video_id: True/False} — user-set 키가 없는 영상은 None
|
||||
{content_id: True/False} — user-set 키가 없는 항목은 None
|
||||
"""
|
||||
if not video_ids:
|
||||
if not content_ids:
|
||||
return {}
|
||||
client = get_like_cache()
|
||||
async with client.pipeline(transaction=False) as pipe:
|
||||
for vid in video_ids:
|
||||
pipe.exists(_user_key(vid))
|
||||
pipe.sismember(_user_key(vid), user_uuid)
|
||||
for cid in content_ids:
|
||||
pipe.exists(_user_key(cid, ctype))
|
||||
pipe.sismember(_user_key(cid, ctype), user_uuid)
|
||||
responses = await pipe.execute()
|
||||
|
||||
return {
|
||||
vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
||||
for i, vid in enumerate(video_ids)
|
||||
cid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
||||
for i, cid in enumerate(content_ids)
|
||||
}
|
||||
|
||||
|
||||
async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None:
|
||||
async def backfill_user_set(
|
||||
content_id: int, user_uuids: list[str], *, ctype: str = CT_VIDEO
|
||||
) -> None:
|
||||
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
|
||||
if user_uuids:
|
||||
await get_like_cache().sadd(_user_key(video_id), *user_uuids)
|
||||
await get_like_cache().sadd(_user_key(content_id, ctype), *user_uuids)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Dirty SET (Write-Behind 큐)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def mark_dirty(video_id: int, user_uuid: str) -> None:
|
||||
async def mark_dirty(
|
||||
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||
) -> None:
|
||||
"""DB 동기화 대기 목록에 추가."""
|
||||
await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}")
|
||||
await get_like_cache().sadd(_DIRTY_KEY, f"{ctype}:{content_id}:{user_uuid}")
|
||||
|
||||
|
||||
async def drain_dirty() -> list[tuple[int, str]]:
|
||||
def _parse_dirty(member: str) -> tuple[str, int, str] | None:
|
||||
"""dirty 항목 문자열 → (ctype, content_id, user_uuid).
|
||||
|
||||
두 형식을 모두 받는다:
|
||||
- 현재: "{ctype}:{content_id}:{user_uuid}"
|
||||
- 구형: "{content_id}:{user_uuid}" ← 종류 도입 전에 큐에 들어간 항목
|
||||
|
||||
**구형 관용이 필요한 이유**: 배포 순간 dirty SET 에 구형 항목이 남아 있다.
|
||||
새 파서가 이를 못 읽으면 그 좋아요는 DB 에 영구 미반영된다.
|
||||
구형은 ADO2 영상뿐이었으므로 CT_VIDEO 로 해석한다.
|
||||
|
||||
형식이 깨진 항목은 None 을 돌려 호출부가 건너뛰게 한다 — 하나 때문에
|
||||
플러시 전체가 죽으면 큐가 무한히 쌓인다.
|
||||
"""
|
||||
parts = member.split(":", 2)
|
||||
|
||||
# 길이가 아니라 **첫 토큰이 알려진 종류인지**로 판정한다.
|
||||
# user_uuid 에 콜론이 있어도 구형이 3조각으로 보일 수 있다.
|
||||
if len(parts) == 3 and parts[0] in CONTENT_TYPES:
|
||||
ctype, id_str, user_uuid = parts
|
||||
else:
|
||||
ctype = CT_VIDEO
|
||||
legacy = member.split(":", 1)
|
||||
if len(legacy) != 2:
|
||||
return None
|
||||
id_str, user_uuid = legacy
|
||||
|
||||
if not id_str.isdigit() or not user_uuid:
|
||||
return None
|
||||
return ctype, int(id_str), user_uuid
|
||||
|
||||
|
||||
async def drain_dirty() -> list[tuple[str, int, str]]:
|
||||
"""dirty SET을 processing으로 RENAME 후 전체 반환.
|
||||
|
||||
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
|
||||
|
||||
Returns:
|
||||
[(ctype, content_id, user_uuid), ...]
|
||||
"""
|
||||
client = get_like_cache()
|
||||
|
||||
@ -223,10 +298,12 @@ async def drain_dirty() -> list[tuple[int, str]]:
|
||||
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
||||
members = await client.smembers(_DIRTY_PROCESSING_KEY)
|
||||
|
||||
result = []
|
||||
result: list[tuple[str, int, str]] = []
|
||||
for member in members:
|
||||
vid_str, user_uuid = member.split(":", 1)
|
||||
result.append((int(vid_str), user_uuid))
|
||||
parsed = _parse_dirty(member)
|
||||
if parsed is None:
|
||||
continue
|
||||
result.append(parsed)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@ -87,6 +87,10 @@ 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 = [
|
||||
@ -109,6 +113,11 @@ 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...")
|
||||
@ -155,7 +164,11 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
|
||||
if getattr(e, "status_code", 500) < 500:
|
||||
# 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:
|
||||
logger.warning(
|
||||
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
|
||||
12
app/p2v/__init__.py
Normal file
12
app/p2v/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
# -*- 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
|
||||
"""
|
||||
0
app/p2v/api/__init__.py
Normal file
0
app/p2v/api/__init__.py
Normal file
0
app/p2v/api/routers/__init__.py
Normal file
0
app/p2v/api/routers/__init__.py
Normal file
0
app/p2v/api/routers/v1/__init__.py
Normal file
0
app/p2v/api/routers/v1/__init__.py
Normal file
429
app/p2v/api/routers/v1/f1.py
Normal file
429
app/p2v/api/routers/v1/f1.py
Normal file
@ -0,0 +1,429 @@
|
||||
# -*- 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)
|
||||
335
app/p2v/api/routers/v1/f2.py
Normal file
335
app/p2v/api/routers/v1/f2.py
Normal file
@ -0,0 +1,335 @@
|
||||
# -*- 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={}, # 결과 이미지는 아카이브 완료 후에만 내려간다
|
||||
)
|
||||
81
app/p2v/api/routers/v1/files.py
Normal file
81
app/p2v/api/routers/v1/files.py
Normal file
@ -0,0 +1,81 @@
|
||||
# -*- 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")
|
||||
39
app/p2v/constants.py
Normal file
39
app/p2v/constants.py
Normal file
@ -0,0 +1,39 @@
|
||||
# -*- 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/")
|
||||
114
app/p2v/exceptions.py
Normal file
114
app/p2v/exceptions.py
Normal file
@ -0,0 +1,114 @@
|
||||
# -*- 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",
|
||||
)
|
||||
265
app/p2v/models.py
Normal file
265
app/p2v/models.py
Normal file
@ -0,0 +1,265 @@
|
||||
# -*- 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}')>"
|
||||
)
|
||||
0
app/p2v/schemas/__init__.py
Normal file
0
app/p2v/schemas/__init__.py
Normal file
190
app/p2v/schemas/p2v_schema.py
Normal file
190
app/p2v/schemas/p2v_schema.py
Normal file
@ -0,0 +1,190 @@
|
||||
# -*- 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",
|
||||
]
|
||||
0
app/p2v/services/__init__.py
Normal file
0
app/p2v/services/__init__.py
Normal file
171
app/p2v/services/archive_service.py
Normal file
171
app/p2v/services/archive_service.py
Normal file
@ -0,0 +1,171 @@
|
||||
# -*- 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)
|
||||
118
app/p2v/services/client.py
Normal file
118
app/p2v/services/client.py
Normal file
@ -0,0 +1,118 @@
|
||||
# -*- 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")
|
||||
218
app/p2v/services/f1_service.py
Normal file
218
app/p2v/services/f1_service.py
Normal file
@ -0,0 +1,218 @@
|
||||
# -*- 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
|
||||
190
app/p2v/services/f2_service.py
Normal file
190
app/p2v/services/f2_service.py
Normal file
@ -0,0 +1,190 @@
|
||||
# -*- 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
|
||||
@ -7,7 +7,7 @@ SEO 관련 엔드포인트를 제공합니다.
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
@ -32,6 +32,21 @@ async def youtube_seo_description(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> YoutubeDescriptionResponse:
|
||||
return await seo_service.get_youtube_seo_description(
|
||||
request_body.video_id, current_user, session
|
||||
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,
|
||||
)
|
||||
|
||||
@ -7,7 +7,17 @@ Social Media Models
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.mysql import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@ -53,6 +63,13 @@ 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"),
|
||||
@ -61,8 +78,14 @@ 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",
|
||||
@ -91,11 +114,19 @@ class SocialUpload(Base):
|
||||
comment="사용자 UUID (User.user_uuid 참조)",
|
||||
)
|
||||
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_social_upload_one_target).
|
||||
video_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Video 외래키",
|
||||
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)",
|
||||
)
|
||||
|
||||
social_account_id: Mapped[int] = mapped_column(
|
||||
@ -242,8 +273,10 @@ class SocialUpload(Base):
|
||||
# ==========================================================================
|
||||
# Relationships
|
||||
# ==========================================================================
|
||||
video: Mapped["Video"] = relationship(
|
||||
# 썰박스 업로드면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
|
||||
video: Mapped[Optional["Video"]] = relationship(
|
||||
"Video",
|
||||
foreign_keys=[video_id],
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
@ -2,18 +2,36 @@
|
||||
소셜 SEO 관련 Pydantic 스키마
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class YoutubeDescriptionRequest(BaseModel):
|
||||
"""유튜브 SEO Description 제안 요청"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
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
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"video_id": 123
|
||||
"content_type": "video",
|
||||
"video_id": 123,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@ -13,7 +13,14 @@ from app.social.constants import PrivacyStatus, UploadStatus
|
||||
class SocialUploadRequest(BaseModel):
|
||||
"""소셜 업로드 요청"""
|
||||
|
||||
video_id: int = Field(..., description="업로드할 영상 ID")
|
||||
# ⚠️ 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 안에서만 유일)")
|
||||
social_account_id: int = Field(..., description="업로드할 소셜 계정 ID (연동 계정 목록의 id)")
|
||||
title: str = Field(..., min_length=1, max_length=100, description="영상 제목")
|
||||
description: Optional[str] = Field(
|
||||
@ -77,7 +84,9 @@ class SocialUploadStatusResponse(BaseModel):
|
||||
"""업로드 상태 조회 응답"""
|
||||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
video_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)")
|
||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
@ -119,7 +128,9 @@ class SocialUploadHistoryItem(BaseModel):
|
||||
"""업로드 이력 아이템"""
|
||||
|
||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||
video_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)")
|
||||
social_account_id: int = Field(..., description="소셜 계정 ID")
|
||||
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
|
||||
platform: str = Field(..., description="플랫폼명")
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"""
|
||||
유튜브 SEO 서비스
|
||||
|
||||
영상 제목/설명/해시태그를 생성하고 video 테이블에 저장합니다.
|
||||
ADO2 영상은 제목/설명/해시태그를 video 테이블에 저장합니다.
|
||||
썰박스는 별도 프롬프트로 생성합니다.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -14,6 +15,7 @@ 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
|
||||
|
||||
@ -25,36 +27,129 @@ 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에 저장합니다.
|
||||
"""
|
||||
logger.info(
|
||||
f"[SEO_SERVICE] Load metadata - user: {current_user.user_uuid} / video_id: {video_id}"
|
||||
"""저장된 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
|
||||
)
|
||||
|
||||
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=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
|
||||
detail="해당하는 영상을 찾을 수 없습니다.",
|
||||
)
|
||||
|
||||
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_video(video)
|
||||
return self._response_from_row(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,
|
||||
@ -68,7 +163,7 @@ class SeoService:
|
||||
return None
|
||||
|
||||
if has_stored_sns_metadata(video):
|
||||
return self._response_from_video(video)
|
||||
return self._response_from_row(video)
|
||||
|
||||
result = await self._generate_seo_description(video.task_id, session)
|
||||
apply_sns_metadata(video, result.title, result.description, result.keywords)
|
||||
@ -93,6 +188,25 @@ 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,
|
||||
@ -161,11 +275,11 @@ class SeoService:
|
||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
||||
)
|
||||
|
||||
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
|
||||
def _response_from_row(self, row) -> YoutubeDescriptionResponse:
|
||||
return YoutubeDescriptionResponse(
|
||||
title=video.title or "",
|
||||
description=video.description or "",
|
||||
keywords=list(video.hashtags or []),
|
||||
title=row.title or "",
|
||||
description=row.description or "",
|
||||
keywords=list(row.hashtags or []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,36 +1,45 @@
|
||||
"""SNS 업로드용 영상 메타데이터 비교/반영 헬퍼."""
|
||||
"""SNS 업로드용 메타데이터 비교/반영 헬퍼.
|
||||
|
||||
from app.video.models import Video
|
||||
`video` 와 `ssul_content` 모두 `title` / `description` / `hashtags` 를 갖는다.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
def has_stored_sns_metadata(video: Video) -> bool:
|
||||
"""video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
|
||||
return bool(video.title)
|
||||
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 sns_metadata_changed(
|
||||
video: Video,
|
||||
row: SnsMetadataTarget,
|
||||
title: str,
|
||||
description: str | None,
|
||||
tags: list[str] | None,
|
||||
) -> bool:
|
||||
"""게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다."""
|
||||
stored_tags = list(video.hashtags or [])
|
||||
stored_tags = list(row.hashtags or [])
|
||||
incoming_tags = list(tags or [])
|
||||
return (
|
||||
(video.title or "") != title
|
||||
or (video.description or "") != (description or "")
|
||||
(row.title or "") != title
|
||||
or (row.description or "") != (description or "")
|
||||
or stored_tags != incoming_tags
|
||||
)
|
||||
|
||||
|
||||
def apply_sns_metadata(
|
||||
video: Video,
|
||||
row: SnsMetadataTarget,
|
||||
title: str,
|
||||
description: str | None,
|
||||
hashtags: list[str] | None,
|
||||
) -> None:
|
||||
"""video 행에 SNS 메타데이터를 반영합니다."""
|
||||
video.title = title
|
||||
video.description = description
|
||||
video.hashtags = list(hashtags or [])
|
||||
"""행에 SNS 메타데이터를 반영합니다."""
|
||||
row.title = title
|
||||
row.description = description
|
||||
row.hashtags = list(hashtags or [])
|
||||
|
||||
@ -29,6 +29,8 @@ 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__)
|
||||
@ -56,15 +58,51 @@ class SocialUploadService:
|
||||
logger.info(
|
||||
f"[UPLOAD_SERVICE] 업로드 요청 - "
|
||||
f"user_uuid: {current_user.user_uuid}, "
|
||||
f"video_id: {body.video_id}, "
|
||||
f"type: {body.content_type}, id: {body.video_id}, "
|
||||
f"social_account_id: {body.social_account_id}"
|
||||
)
|
||||
|
||||
# 1. 영상 조회 및 검증
|
||||
video_result = await session.execute(
|
||||
select(Video).where(Video.id == body.video_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),
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
)
|
||||
).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()
|
||||
|
||||
if not video:
|
||||
logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}")
|
||||
@ -76,11 +114,12 @@ class SocialUploadService:
|
||||
video_id=body.video_id,
|
||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||
)
|
||||
target = video
|
||||
|
||||
if sns_metadata_changed(video, body.title, body.description, body.tags):
|
||||
apply_sns_metadata(video, body.title, body.description, body.tags)
|
||||
if sns_metadata_changed(target, body.title, body.description, body.tags):
|
||||
apply_sns_metadata(target, body.title, body.description, body.tags)
|
||||
logger.info(
|
||||
f"[UPLOAD_SERVICE] video SNS 메타데이터 갱신 - video_id: {body.video_id}"
|
||||
f"[UPLOAD_SERVICE] SNS 메타데이터 갱신 - type: {body.content_type}, id: {body.video_id}"
|
||||
)
|
||||
|
||||
# 2. 소셜 계정 조회 및 소유권 검증
|
||||
@ -103,7 +142,7 @@ class SocialUploadService:
|
||||
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
|
||||
in_progress_result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.video_id == body.video_id,
|
||||
target_col == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||
or_(
|
||||
@ -129,7 +168,7 @@ class SocialUploadService:
|
||||
# 3-2. 미래 예약 업로드 확인
|
||||
scheduled_result = await session.execute(
|
||||
select(SocialUpload).where(
|
||||
SocialUpload.video_id == body.video_id,
|
||||
target_col == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
SocialUpload.status == UploadStatus.PENDING.value,
|
||||
SocialUpload.scheduled_at.isnot(None),
|
||||
@ -155,7 +194,7 @@ class SocialUploadService:
|
||||
# 4. 업로드 순번 계산
|
||||
max_seq_result = await session.execute(
|
||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
||||
SocialUpload.video_id == body.video_id,
|
||||
target_col == body.video_id,
|
||||
SocialUpload.social_account_id == account.id,
|
||||
)
|
||||
)
|
||||
@ -164,7 +203,9 @@ class SocialUploadService:
|
||||
# 5. 새 업로드 레코드 생성
|
||||
social_upload = SocialUpload(
|
||||
user_uuid=current_user.user_uuid,
|
||||
video_id=body.video_id,
|
||||
# 종류에 따라 둘 중 하나만 채운다 (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,
|
||||
social_account_id=account.id,
|
||||
upload_seq=next_seq,
|
||||
platform=account.platform,
|
||||
@ -232,6 +273,7 @@ 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,
|
||||
@ -325,6 +367,7 @@ class SocialUploadService:
|
||||
SocialUploadHistoryItem(
|
||||
upload_id=upload.id,
|
||||
video_id=upload.video_id,
|
||||
content_id=upload.content_id,
|
||||
social_account_id=upload.social_account_id,
|
||||
upload_seq=upload.upload_seq,
|
||||
platform=upload.platform,
|
||||
|
||||
@ -5,8 +5,6 @@ Social Upload Background Task
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@ -21,11 +19,12 @@ 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, UploadError, UploadQuotaExceededError
|
||||
from app.social.exceptions import TokenExpiredError, 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
|
||||
|
||||
@ -169,16 +168,27 @@ async def process_social_upload(upload_id: int) -> None:
|
||||
logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
|
||||
return
|
||||
|
||||
# 2. Video 정보 조회
|
||||
# 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
|
||||
|
||||
if not video or not video.result_movie_url:
|
||||
if not source_url:
|
||||
logger.error(
|
||||
f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - "
|
||||
f"upload_id: {upload_id}, video_id: {upload.video_id}"
|
||||
f"upload_id: {upload_id}, video_id: {upload.video_id}, "
|
||||
f"content_id: {upload.content_id}"
|
||||
)
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
@ -206,7 +216,7 @@ async def process_social_upload(upload_id: int) -> None:
|
||||
return
|
||||
|
||||
# 필요한 정보 저장
|
||||
video_url = video.result_movie_url
|
||||
video_url = source_url
|
||||
platform = SocialPlatform(upload.platform)
|
||||
upload_title = upload.title
|
||||
upload_description = upload.description
|
||||
@ -347,7 +357,7 @@ async def process_social_upload(upload_id: int) -> None:
|
||||
f"upload_id: {upload_id}, error: {result.error_message}"
|
||||
)
|
||||
|
||||
except UploadQuotaExceededError as e:
|
||||
except UploadQuotaExceededError:
|
||||
logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}")
|
||||
await _update_upload_status(
|
||||
upload_id=upload_id,
|
||||
|
||||
9
app/ssulbox/__init__.py
Normal file
9
app/ssulbox/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""썰박스(ssulbox) 모듈.
|
||||
|
||||
네이버 지도 링크나 업장명을 입력하면 4개 시나리오(조선왕·삼국지·그리스로마신화·오디세이)
|
||||
중 하나로 대본 → 그림 → 목소리 → 영상을 자동 생성하는 병맛 역사 썰툰 쇼츠 파이프라인.
|
||||
|
||||
별도 저장소(o2o-ssulbox)로 개발되던 것을 castad 백엔드로 이식했다.
|
||||
계정·크레딧·소셜 계정은 castad 것을 그대로 쓰고(User / CreditTransaction / SocialAccount),
|
||||
썰박스 고유 도메인만 `ssul_` 접두 테이블로 신설한다.
|
||||
"""
|
||||
0
app/ssulbox/api/__init__.py
Normal file
0
app/ssulbox/api/__init__.py
Normal file
0
app/ssulbox/api/routers/__init__.py
Normal file
0
app/ssulbox/api/routers/__init__.py
Normal file
0
app/ssulbox/api/routers/v1/__init__.py
Normal file
0
app/ssulbox/api/routers/v1/__init__.py
Normal file
354
app/ssulbox/api/routers/v1/content.py
Normal file
354
app/ssulbox/api/routers/v1/content.py
Normal file
@ -0,0 +1,354 @@
|
||||
# -*- 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="콘텐츠가 삭제되었습니다.",
|
||||
)
|
||||
119
app/ssulbox/constants.py
Normal file
119
app/ssulbox/constants.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""썰박스 상수.
|
||||
|
||||
설정(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
|
||||
146
app/ssulbox/exceptions.py
Normal file
146
app/ssulbox/exceptions.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""썰박스 예외.
|
||||
|
||||
app/dashboard/exceptions.py 와 동일한 (message, status_code, code) 형태를 따른다.
|
||||
전역 핸들러가 code 를 그대로 응답 본문에 실어 보내므로, 프론트가 분기에 쓰는
|
||||
문자열이다 — 이미 나간 code 값은 함부로 바꾸지 말 것.
|
||||
"""
|
||||
|
||||
from fastapi import status
|
||||
|
||||
|
||||
class SsulboxException(Exception):
|
||||
"""썰박스 기본 예외"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code: str = "SSULBOX_ERROR",
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 생성 요청 관련
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class InvalidScenarioError(SsulboxException):
|
||||
"""지원하지 않는 시나리오 코드"""
|
||||
|
||||
def __init__(self, scenario: str = ""):
|
||||
message = "지원하지 않는 시나리오입니다."
|
||||
if scenario:
|
||||
message += f" ({scenario})"
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="SSUL_INVALID_SCENARIO",
|
||||
)
|
||||
|
||||
|
||||
class GenerationUnavailableError(SsulboxException):
|
||||
"""생성 엔진 사용 불가 (Gemini API 키 미설정 등)"""
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
message = "썰박스 생성 기능이 현재 비활성화되어 있습니다."
|
||||
if detail:
|
||||
message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
code="SSUL_GENERATION_UNAVAILABLE",
|
||||
)
|
||||
|
||||
|
||||
class TaskAlreadyRunningError(SsulboxException):
|
||||
"""이미 진행 중인 생성 잡이 있음"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="이미 생성 중인 썰박스가 있습니다. 완료 후 다시 시도해주세요.",
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
code="SSUL_TASK_ALREADY_RUNNING",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 조회 관련
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TaskNotFoundError(SsulboxException):
|
||||
"""생성 잡을 찾을 수 없음 (없거나 남의 것)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="생성 요청을 찾을 수 없습니다.",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="SSUL_TASK_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class ContentNotFoundError(SsulboxException):
|
||||
"""콘텐츠를 찾을 수 없음"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="썰박스 콘텐츠를 찾을 수 없습니다.",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="SSUL_CONTENT_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class CommentNotFoundError(SsulboxException):
|
||||
"""댓글을 찾을 수 없음"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="댓글을 찾을 수 없습니다.",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
code="SSUL_COMMENT_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class CommentDepthExceededError(SsulboxException):
|
||||
"""댓글은 2-depth(댓글 + 대댓글)까지만 허용"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="대댓글에는 답글을 달 수 없습니다.",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
code="SSUL_COMMENT_DEPTH_EXCEEDED",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 생성 실행 관련
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GenerationFailedError(SsulboxException):
|
||||
"""생성 엔진 실행 실패"""
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
message = "썰박스 생성에 실패했습니다."
|
||||
if detail:
|
||||
message += f" ({detail})"
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
code="SSUL_GENERATION_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class GenerationTimeoutError(SsulboxException):
|
||||
"""생성 엔진 실행 시간 초과"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="썰박스 생성 시간이 초과되었습니다. 크레딧은 환불됩니다.",
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
code="SSUL_GENERATION_TIMEOUT",
|
||||
)
|
||||
274
app/ssulbox/models.py
Normal file
274
app/ssulbox/models.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""썰박스 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 는 안 본다).
|
||||
0
app/ssulbox/schemas/__init__.py
Normal file
0
app/ssulbox/schemas/__init__.py
Normal file
151
app/ssulbox/schemas/ssulbox_schema.py
Normal file
151
app/ssulbox/schemas/ssulbox_schema.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""썰박스 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",
|
||||
]
|
||||
0
app/ssulbox/services/__init__.py
Normal file
0
app/ssulbox/services/__init__.py
Normal file
117
app/ssulbox/services/blob_service.py
Normal file
117
app/ssulbox/services/blob_service.py
Normal file
@ -0,0 +1,117 @@
|
||||
# -*- 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
|
||||
246
app/ssulbox/services/place_service.py
Normal file
246
app/ssulbox/services/place_service.py
Normal file
@ -0,0 +1,246 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
308
app/ssulbox/services/task_service.py
Normal file
308
app/ssulbox/services/task_service.py
Normal file
@ -0,0 +1,308 @@
|
||||
# -*- 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}")
|
||||
0
app/ssulbox/worker/__init__.py
Normal file
0
app/ssulbox/worker/__init__.py
Normal file
551
app/ssulbox/worker/job_manager.py
Normal file
551
app/ssulbox/worker/job_manager.py
Normal file
@ -0,0 +1,551 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""썰박스 생성 잡 매니저 — subprocess 감독 + 진행 단계 DB 영속화.
|
||||
|
||||
원본(o2o-ssulbox/app/jobs.py)에서 이식하되 4가지를 바꿨다:
|
||||
|
||||
1. **SSE 제거** — castad 는 폴링을 쓴다. `_subscribers`/`_emit` 을 통째로 걷어냈다.
|
||||
2. **step 을 DB 에 영속화** — 원본은 인메모리 `_jobs` 에만 뒀지만, 폴링이 진행률을
|
||||
돌려주려면 DB 에 있어야 한다. 나중에 워커를 늘려도 폴링은 그대로 동작한다.
|
||||
3. **세션 팩토리를 BackgroundSessionLocal 로** — 요청용 풀(20+20)을 장시간 잡이
|
||||
잠식하지 않게 한다. castad `video_task.py` 와 같은 관행.
|
||||
4. **좀비 방지** — 원본은 `p.wait()` 에 타임아웃이 없어 엔진이 걸리면 스레드가
|
||||
영구 블록되고 `_running` 이 안 줄어 큐가 멎었다. **감시견 타이머**로 데드라인에
|
||||
프로세스를 죽인다. `wait(timeout=)` 만 걸면 안 된다 — stdout 읽기 루프가
|
||||
EOF 까지 블록하므로 엔진이 조용히 매달리면 wait() 에 도달조차 못 한다
|
||||
(2026-07-29 `zzz/_ssul_timeout_verify.py` 로 확인한 실제 결함).
|
||||
|
||||
⚠️ **단일 워커 전제** — `_jobs`/`_running` 이 프로세스 로컬이고, `sweep_orphans` 가
|
||||
"기동 시 비터미널 잡은 전부 고아"라는 불변식에 의존한다. `--workers` 를 늘리면
|
||||
워커 B 가 기동하며 워커 A 의 정상 잡을 환불·error 처리한다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.ssulbox.constants import (
|
||||
DONE_RE,
|
||||
JOB_DIR_RE,
|
||||
SCENARIO_ENGINE,
|
||||
STEP_NAMES,
|
||||
STEP_RE,
|
||||
STORE_RE,
|
||||
gemini_key,
|
||||
)
|
||||
from app.ssulbox.services import place_service, task_service
|
||||
from app.utils.address_parser import extract_region_from_address
|
||||
from app.utils.logger import get_logger
|
||||
from config import ssulbox_settings
|
||||
|
||||
logger = get_logger("ssulbox")
|
||||
|
||||
# ── 프로세스 로컬 상태 ────────────────────────────────────────
|
||||
#: 진행 중인 잡의 로그·타이밍 (권위 아님 — 권위는 DB status/step)
|
||||
_jobs: dict[int, dict[str, Any]] = {}
|
||||
_queue: deque[int] = deque()
|
||||
_lock = threading.Lock()
|
||||
_running = 0
|
||||
_shutting_down = False
|
||||
#: 앱 이벤트 루프. 워커 스레드가 DB 작업을 위임할 대상
|
||||
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
|
||||
def create_job(
|
||||
content_id: int,
|
||||
scenario: str,
|
||||
input_text: str,
|
||||
scenes: int,
|
||||
seconds: int,
|
||||
*,
|
||||
store_name: str | None = None,
|
||||
road_address: str | None = None,
|
||||
address: str | None = None,
|
||||
) -> None:
|
||||
"""잡을 큐에 넣는다.
|
||||
|
||||
**반드시 앱 이벤트 루프(요청 핸들러)에서 호출해야 한다** — 여기서 루프를 캡처해
|
||||
워커 스레드가 DB 작업을 위임할 때 쓴다.
|
||||
"""
|
||||
global _loop
|
||||
_loop = asyncio.get_running_loop()
|
||||
_jobs[content_id] = {
|
||||
"id": content_id,
|
||||
"scenario": scenario,
|
||||
"input": input_text,
|
||||
"scenes": scenes,
|
||||
"seconds": seconds,
|
||||
"status": "queued",
|
||||
"step": 0,
|
||||
"log": [],
|
||||
"output": None,
|
||||
"job_dir": None, # generator 가 stdout 으로 알려준다. 실패 정리 대상
|
||||
"store_name": None, # 검색을 안 거친 경우 생성 로그에서 주워온다
|
||||
# 자동완성으로 고른 값. place URL 해석(_resolve_input_url)에만 쓴다 —
|
||||
# DB 저장은 create_task 가 이미 했으므로 여기서는 힌트일 뿐이다.
|
||||
"store_name_hint": store_name,
|
||||
"road_address_hint": road_address,
|
||||
"address_hint": address,
|
||||
"error": None,
|
||||
"timings": {},
|
||||
}
|
||||
with _lock:
|
||||
_queue.append(content_id)
|
||||
_pump()
|
||||
|
||||
|
||||
def get_job(content_id: int) -> Optional[dict]:
|
||||
"""인메모리 진행 정보. 로그 확인용이며 권위는 DB 다."""
|
||||
return _jobs.get(content_id)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""신규 큐잉을 막는다. lifespan shutdown 에서 dispose_engine 전에 호출."""
|
||||
global _shutting_down
|
||||
_shutting_down = True
|
||||
with _lock:
|
||||
dropped = len(_queue)
|
||||
_queue.clear()
|
||||
if dropped:
|
||||
logger.info(f"[job_manager] 종료 — 대기 중이던 {dropped}건은 다음 기동 스윕이 처리")
|
||||
|
||||
|
||||
def _run_db(coro, timeout: Optional[int] = None):
|
||||
"""워커 스레드에서 앱 루프에 DB 코루틴을 위임하고 완료까지 대기한다.
|
||||
|
||||
asyncmy 커넥션 풀은 생성된 이벤트 루프에 바인딩되므로 스레드에서
|
||||
`asyncio.run` 을 쓰면 풀이 깨진다. 반드시 앱 루프에 위임해야 한다.
|
||||
"""
|
||||
if _loop is None or _loop.is_closed():
|
||||
# 셧다운 중이면 루프가 코루틴을 실행하지 않아 무한정 매달린다.
|
||||
coro.close()
|
||||
raise RuntimeError("event loop unavailable (shutting down)")
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, _loop)
|
||||
return fut.result(timeout=timeout or ssulbox_settings.SSULBOX_DB_DELEGATE_TIMEOUT)
|
||||
|
||||
|
||||
async def _mark_running(content_id: int) -> None:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
await task_service.mark_running(session, content_id)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _update_step(content_id: int, step: int) -> None:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
await task_service.update_step(session, content_id, step)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _get_place_info(
|
||||
content_id: int,
|
||||
) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
"""현재 저장된 (store_name, region, detail_region_info, official_site_url)."""
|
||||
async with BackgroundSessionLocal() as session:
|
||||
return await task_service.get_place_info(session, content_id)
|
||||
|
||||
|
||||
async def _save_place(
|
||||
content_id: int,
|
||||
store_name: str,
|
||||
region: str,
|
||||
detail: str,
|
||||
official_site_url: Optional[str] = None,
|
||||
) -> None:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
await task_service.set_place_info(
|
||||
session,
|
||||
content_id,
|
||||
store_name=store_name,
|
||||
region=region,
|
||||
detail_region_info=detail,
|
||||
official_site_url=official_site_url,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _finalize(
|
||||
content_id: int, mp4: Path, store_name: Optional[str] = None
|
||||
) -> None:
|
||||
cleanup: Optional[Path] = None
|
||||
async with BackgroundSessionLocal() as session:
|
||||
try:
|
||||
# store_name 은 생성 시점에 비어 있을 때만 반영된다(finalize_task 가 판단).
|
||||
_, cleanup = await task_service.finalize_task(
|
||||
session, content_id, mp4, store_name=store_name
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
# 되돌릴 수 없는 삭제는 커밋이 성공한 뒤에만
|
||||
task_service.cleanup_job_dir(cleanup)
|
||||
|
||||
|
||||
async def _try_generate_sns_metadata(content_id: int) -> None:
|
||||
"""제목/설명/해시태그 생성 실패가 완료 처리에 영향을 주지 않도록 격리합니다."""
|
||||
from app.social.services.seo_service import seo_service
|
||||
|
||||
try:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
await seo_service.generate_and_save_for_ssul(content_id, session)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[ssul {content_id}] SNS 메타데이터 생성 실패: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _fail(content_id: int, error: str) -> None:
|
||||
async with BackgroundSessionLocal() as session:
|
||||
try:
|
||||
await task_service.fail_task(session, content_id, error)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def _pump() -> None:
|
||||
"""동시 실행 한도 안에서 대기 중인 잡을 시작한다."""
|
||||
global _running
|
||||
if _shutting_down:
|
||||
return
|
||||
with _lock:
|
||||
while _queue and _running < ssulbox_settings.SSULBOX_MAX_CONCURRENT_JOBS:
|
||||
content_id = _queue.popleft()
|
||||
_running += 1
|
||||
threading.Thread(target=_run, args=(content_id,), daemon=True).start()
|
||||
|
||||
|
||||
def _build_command(job: dict) -> tuple[list[str], Path]:
|
||||
"""generator 실행 커맨드와 작업 디렉터리."""
|
||||
engine = SCENARIO_ENGINE[job["scenario"]]
|
||||
engine_dir = ssulbox_settings.generator_path / engine
|
||||
main_py = engine_dir / "main.py"
|
||||
if not main_py.exists():
|
||||
raise FileNotFoundError(f"generator not found: {main_py}")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-u",
|
||||
str(main_py),
|
||||
job["input"],
|
||||
"--scenes",
|
||||
str(job["scenes"]),
|
||||
"--seconds",
|
||||
str(job["seconds"]),
|
||||
]
|
||||
return cmd, engine_dir
|
||||
|
||||
|
||||
def _is_place_url(text: str) -> bool:
|
||||
"""네이버 지도 링크로 보이는가 — 프론트 `isNaverUrl` 과 같은 판정."""
|
||||
t = (text or "").strip().lower()
|
||||
return t.startswith("http") and (
|
||||
"naver.me" in t or "map.naver" in t or "place.naver" in t
|
||||
)
|
||||
|
||||
|
||||
def _collect_place_info(content_id: int, job: dict) -> None:
|
||||
"""비어 있는 업장명·지역·공식 링크를 크롤링으로 채운다(있는 값은 유지).
|
||||
|
||||
**빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 업장명·지역이
|
||||
채워져 있으나 공식 링크는 늘 비어 있으므로, 그 경로에서도 이 함수가 크롤링한다
|
||||
(Playwright 1회, 최대 120초). 링크를 못 얻어도 place URL 폴백은 남는다.
|
||||
|
||||
수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다.
|
||||
"""
|
||||
place_url = job.get("input", "")
|
||||
if not _is_place_url(place_url):
|
||||
return # 업장명 해석 실패 — 링크로 쓸 값이 없다
|
||||
try:
|
||||
store_name, region, detail, site_url = _run_db(_get_place_info(content_id))
|
||||
if store_name and region and detail and site_url:
|
||||
return # 채울 것이 없다
|
||||
|
||||
# 크롤링이 실패해도 오버레이가 뜨도록 place URL 을 먼저 폴백으로 저장한다.
|
||||
# castad 가 `official_site_url or 크롤링 소스 URL` 로 폴백하는 것과 같은 규칙.
|
||||
if not site_url:
|
||||
_run_db(_save_place(content_id, "", "", "", place_url))
|
||||
|
||||
detail = _run_db(
|
||||
place_service.fetch_place_detail(place_url),
|
||||
# Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다
|
||||
timeout=120,
|
||||
)
|
||||
if not detail:
|
||||
return
|
||||
|
||||
title = detail.get("title") or ""
|
||||
road = detail.get("roadAddress") or ""
|
||||
jibun = detail.get("address") or ""
|
||||
homepage = detail.get("homepage") or ""
|
||||
# castad `/home/crawl` 과 동일하게 **도로명·지번을 모두** 넘긴다.
|
||||
# 도로명에서 시/군 추출이 실패하면 지번으로 재시도한다(한쪽만 넘기면 놓친다).
|
||||
new_region = extract_region_from_address(road or None, jibun or None)
|
||||
new_detail = road or jibun # 도로명 우선, 없으면 지번
|
||||
if title:
|
||||
job["store_name"] = title
|
||||
if title or new_region or new_detail or homepage:
|
||||
# homepage 가 있으면 위에서 넣어 둔 place URL 폴백을 진짜 홈페이지로 승급시킨다.
|
||||
_run_db(_save_place(content_id, title, new_region, new_detail, homepage))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): "
|
||||
f"{type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_input_url(job: dict) -> None:
|
||||
"""업장명 입력을 네이버 지도 place URL 로 바꾼다(자동완성 선택 경로).
|
||||
|
||||
generator 는 place 페이지를 크롤링하므로 URL 이 있어야 정확한 가게를 잡는다.
|
||||
해석은 castad `NvMapPwScraper` 경로를 재사용한다(ADO2 자동완성과 동일).
|
||||
|
||||
실패하면 원래 입력(업장명)을 그대로 둔다 — generator 가 업장명만으로도
|
||||
네이버 브리핑을 시도한다. 정확도는 떨어지지만 생성을 막지는 않는다.
|
||||
"""
|
||||
raw = (job.get("input") or "").strip()
|
||||
store = (job.get("store_name_hint") or "").strip()
|
||||
if not store or _is_place_url(raw):
|
||||
return
|
||||
try:
|
||||
url = _run_db(
|
||||
place_service.resolve_place_url(
|
||||
store,
|
||||
address=job.get("address_hint") or "",
|
||||
road_address=job.get("road_address_hint") or "",
|
||||
),
|
||||
# Playwright 기동 + 최대 3단 재시도라 DB 위임 기본 타임아웃보다 길다
|
||||
timeout=120,
|
||||
)
|
||||
if url:
|
||||
job["input"] = url
|
||||
logger.info(f"[_resolve_input_url] {store!r} → {url}")
|
||||
else:
|
||||
logger.warning(f"[_resolve_input_url] 해석 실패, 업장명으로 진행 - {store!r}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[_resolve_input_url] 실패(업장명으로 진행): {type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _run(content_id: int) -> None:
|
||||
"""워커 스레드 본체. subprocess 를 감독하며 stdout 마커로 진행을 파싱한다."""
|
||||
global _running
|
||||
job = _jobs[content_id]
|
||||
proc: Optional[subprocess.Popen] = None
|
||||
|
||||
try:
|
||||
_run_db(_mark_running(content_id))
|
||||
job["status"] = "running"
|
||||
|
||||
# 업장명만 들어온 경우(자동완성 선택) place URL 로 바꾼다.
|
||||
# **요청 핸들러가 아니라 여기서 하는 이유**: 해석에 10초 이상 걸려
|
||||
# `/ssul/create` 응답이 그만큼 막히고 사용자가 폼에 묶인다.
|
||||
# 워커로 옮기면 요청은 즉시 끝나고 진행 화면이 바로 뜬다.
|
||||
_resolve_input_url(job)
|
||||
|
||||
# 업장명·주소를 **항상** 확보한다. 검색으로 고르지 않고 place URL 을
|
||||
# 붙여넣은 경우 create 시점에 아무것도 없으므로 여기서 크롤링한다.
|
||||
# 생성 **전에** 하는 이유: 목록에 이름 없는 카드가 뜨는 구간을 없앤다.
|
||||
# 실패해도 생성은 계속한다(목록 표시·필터용 부가 정보다).
|
||||
_collect_place_info(content_id, job)
|
||||
|
||||
# ⚠️ 커맨드는 위 두 단계가 끝난 **뒤에** 만든다 — job["input"] 이 갱신되므로
|
||||
# 먼저 만들면 해석 전 값(업장명)이 그대로 엔진에 넘어간다.
|
||||
cmd, engine_dir = _build_command(job)
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
key = gemini_key()
|
||||
if key:
|
||||
env["GEMINI_API_KEY"] = key
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(engine_dir),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
started = step_started = time.monotonic()
|
||||
timeout_s = ssulbox_settings.SSULBOX_JOB_TIMEOUT_SECONDS
|
||||
|
||||
# ⚠️ 벽시계 감시견이 필요한 이유 — `proc.wait(timeout=)` 만으로는 못 막는다.
|
||||
# 아래 `for raw in proc.stdout` 은 EOF 까지 블록한다. 엔진이 출력 없이
|
||||
# 매달리면(응답 없는 API 호출 등) EOF 가 오지 않아 워커 스레드가 영구
|
||||
# 정지하고 `_running` 이 안 줄어 **큐 전체가 멎는다**. wait() 의 타임아웃은
|
||||
# stdout 이 닫힌 뒤에야 평가되므로 정작 그 상황에는 도달하지 못한다.
|
||||
# 데드라인에 프로세스를 죽여야 stdout 이 닫히고 루프가 풀린다.
|
||||
timed_out = threading.Event()
|
||||
|
||||
def _on_deadline() -> None:
|
||||
timed_out.set()
|
||||
logger.error(f"[ssul {content_id}] TIMEOUT {timeout_s}s — 프로세스 종료")
|
||||
_kill(proc)
|
||||
|
||||
watchdog = threading.Timer(timeout_s, _on_deadline)
|
||||
watchdog.daemon = True
|
||||
watchdog.start()
|
||||
|
||||
try:
|
||||
for raw in proc.stdout: # type: ignore[union-attr]
|
||||
line = raw.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
logger.info(f"[ssul {content_id}] {line}")
|
||||
job["log"].append(line)
|
||||
if len(job["log"]) > 300:
|
||||
del job["log"][:-300]
|
||||
|
||||
m = STEP_RE.search(line)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
if n != job["step"]:
|
||||
now = time.monotonic()
|
||||
prev = job["step"]
|
||||
label = "준비·크롤링" if prev == 0 else STEP_NAMES[prev - 1]
|
||||
job["timings"][label] = round(now - step_started, 1)
|
||||
step_started = now
|
||||
job["step"] = n
|
||||
# 폴링이 읽을 수 있도록 DB 에 반영한다
|
||||
try:
|
||||
_run_db(_update_step(content_id, n))
|
||||
except Exception as e:
|
||||
logger.warning(f"[ssul {content_id}] step 저장 실패: {e}")
|
||||
|
||||
d = DONE_RE.search(line)
|
||||
if d:
|
||||
job["output"] = d.group(1).strip()
|
||||
|
||||
# 실패 시 지울 대상. 완료 마커가 없어도 알 수 있는 유일한 경로다.
|
||||
jd = JOB_DIR_RE.search(line)
|
||||
if jd:
|
||||
job["job_dir"] = jd.group(1).strip()
|
||||
|
||||
# place URL 을 붙여넣어 검색을 안 거친 경우의 업장명 확보 경로.
|
||||
# '?' 는 엔진이 이름을 못 얻었을 때 찍는 placeholder 라 버린다.
|
||||
sm = STORE_RE.search(line)
|
||||
if sm:
|
||||
name = sm.group(1).strip()
|
||||
if name and name != "?":
|
||||
job["store_name"] = name
|
||||
|
||||
# stdout 이 닫혔으니 종료는 임박했다. 짧은 여유만 준다.
|
||||
code = proc.wait(timeout=30)
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
|
||||
if timed_out.is_set():
|
||||
# 감시견이 죽인 것이지 정상 종료가 아니다. 아래 except 로 넘긴다.
|
||||
raise subprocess.TimeoutExpired(cmd, timeout_s)
|
||||
|
||||
summary = " · ".join(f"{k} {v}s" for k, v in job["timings"].items()) or "(마커 없음)"
|
||||
logger.info(
|
||||
f"[ssul {content_id}] 단계별 {summary} · 총 {time.monotonic() - started:.1f}s"
|
||||
)
|
||||
|
||||
if code != 0:
|
||||
raise RuntimeError(f"생성 프로세스 종료코드 {code}")
|
||||
if not job["output"]:
|
||||
raise RuntimeError("완성 마커를 찾지 못했습니다")
|
||||
|
||||
mp4 = Path(job["output"])
|
||||
if not mp4.is_absolute():
|
||||
mp4 = (engine_dir / mp4).resolve()
|
||||
|
||||
# finalize 는 Blob 업로드·포스터를 포함해 오래 걸리므로 위임 타임아웃을 넉넉히
|
||||
_run_db(
|
||||
_finalize(content_id, mp4, job.get("store_name")), timeout=600
|
||||
)
|
||||
# ADO2 video_task 와 같이 완료 커밋 뒤에 SEO를 돌린다. 실패해도 영상은 유지.
|
||||
try:
|
||||
_run_db(_try_generate_sns_metadata(content_id), timeout=240)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[ssul {content_id}] SNS 메타데이터 위임 실패: {type(e).__name__}: {e}"
|
||||
)
|
||||
job["status"] = "done"
|
||||
job["step"] = 4
|
||||
|
||||
except subprocess.TimeoutExpired as e:
|
||||
# 원본은 타임아웃이 없어 엔진이 걸리면 큐가 영구 정지했다.
|
||||
# 두 경로로 들어온다: 감시견 데드라인, 또는 stdout 이 닫혔는데도
|
||||
# 프로세스가 안 끝나는 경우(위 `wait(timeout=30)`).
|
||||
job["status"] = "error"
|
||||
job["error"] = f"생성 시간 초과 ({e.timeout}s)"
|
||||
logger.error(f"[ssul {content_id}] TIMEOUT — 프로세스 종료")
|
||||
_kill(proc)
|
||||
_safe_fail(content_id, job["error"])
|
||||
|
||||
except Exception as e:
|
||||
job["status"] = "error"
|
||||
job["error"] = f"{type(e).__name__}: {e}"
|
||||
logger.error(f"[ssul {content_id}] FAILED - {job['error']}", exc_info=True)
|
||||
_kill(proc)
|
||||
_safe_fail(content_id, job["error"])
|
||||
|
||||
finally:
|
||||
with _lock:
|
||||
_running -= 1
|
||||
_pump()
|
||||
|
||||
|
||||
def _kill(proc: Optional[subprocess.Popen]) -> None:
|
||||
if proc is None or proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception as e:
|
||||
logger.warning(f"[job_manager] 프로세스 종료 실패: {e}")
|
||||
|
||||
|
||||
def _safe_fail(content_id: int, error: str) -> None:
|
||||
"""환불·상태 갱신. 이것마저 실패하면 다음 기동의 고아 스윕이 재처리한다."""
|
||||
try:
|
||||
_run_db(_fail(content_id, error))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ssul {content_id}] 실패 처리 실패(스윕이 재처리): {type(e).__name__}: {e}"
|
||||
)
|
||||
finally:
|
||||
# DB 처리 성패와 무관하게 디스크는 정리한다. 남겨봐야 쓸 곳이 없다.
|
||||
_cleanup_failed_dir(content_id)
|
||||
|
||||
|
||||
def _cleanup_failed_dir(content_id: int) -> None:
|
||||
"""실패한 잡의 중간 산출물 삭제.
|
||||
|
||||
castad `video_task.py` 는 `finally` 로 성공·실패 양쪽을 정리한다. 썰박스도
|
||||
맞춘다. 실패 시엔 generator 자신의 자산 정리(`main.py` 의 `out_mp4.exists()`
|
||||
조건)마저 건너뛰므로 **오히려 실패가 가장 많이 남긴다** — 이미지·음성 전체.
|
||||
"""
|
||||
raw = (_jobs.get(content_id) or {}).get("job_dir")
|
||||
if not raw:
|
||||
# 폴더 마커 전에 죽었다면 만들어진 것도 없다
|
||||
return
|
||||
try:
|
||||
job_dir = Path(raw).resolve()
|
||||
root = ssulbox_settings.output_path.resolve()
|
||||
# stdout 에서 읽어온 경로다. output 밖은 무슨 일이 있어도 지우지 않는다.
|
||||
# `job_dir.parent != root` 는 `output/<엔진>` 통째 삭제를 막는다
|
||||
# (정상 경로는 항상 `output/<엔진>/<작업>` 이라 2단계 아래다).
|
||||
if root not in job_dir.parents or job_dir.parent == root:
|
||||
logger.warning(f"[ssul {content_id}] output 밖 경로라 정리 생략: {job_dir}")
|
||||
return
|
||||
task_service.cleanup_job_dir(job_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"[ssul {content_id}] 실패 정리 실패: {type(e).__name__}: {e}")
|
||||
@ -143,6 +143,10 @@ class User(Base):
|
||||
comment="카카오 썸네일 이미지 URL",
|
||||
)
|
||||
|
||||
# `bio`(썰박스 프로필 한 줄 소개)는 두지 않는다 — 원본 썰박스에는 있었지만
|
||||
# 프로필 편집 화면을 castad `내 정보`로 대체하면서 쓰는 곳이 사라졌다
|
||||
# (2026-07-30 제거). 프로필 소개 기능을 만들게 되면 그때 추가한다.
|
||||
|
||||
# ==========================================================================
|
||||
# 추가 사용자 정보
|
||||
# ==========================================================================
|
||||
|
||||
@ -10,7 +10,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
|
||||
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지."""
|
||||
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지.
|
||||
|
||||
.. deprecated::
|
||||
사전차감 정책 전환으로 호출처가 사라졌다(video_task.py 의 사후차감 제거).
|
||||
**새 코드에서 쓰지 말 것.** 이 함수는 멱등성이 없어 재시도 시 중복 차감된다.
|
||||
생성 작업에는 ``credit_service.deduct_credit_for_job`` /
|
||||
``refund_credit_for_job`` 을 쓴다.
|
||||
다음 릴리스에서 제거 예정.
|
||||
"""
|
||||
try:
|
||||
await deduct_credit(
|
||||
session=session,
|
||||
|
||||
@ -6,10 +6,6 @@ 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 = {
|
||||
@ -21,7 +17,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, True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT)
|
||||
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
|
||||
return image_result
|
||||
|
||||
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
|
||||
@ -35,7 +31,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'], True, silent = True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) 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'], False, silent = True) 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):
|
||||
@ -44,7 +40,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'], True, silent=True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for i in failed_idx],
|
||||
*[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],
|
||||
return_exceptions=True
|
||||
)
|
||||
for i, result in zip(failed_idx, retried):
|
||||
|
||||
@ -47,19 +47,6 @@ 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,
|
||||
@ -128,8 +115,7 @@ class ChatgptService:
|
||||
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||
model : str,
|
||||
img_url : str,
|
||||
image_detail_high : bool,
|
||||
reasoning_effort : Optional[str] = None) -> BaseModel:
|
||||
image_detail_high : bool) -> BaseModel:
|
||||
content = []
|
||||
if img_url:
|
||||
content.append({
|
||||
@ -143,16 +129,13 @@ 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,
|
||||
**extra_kwargs,
|
||||
response_format=output_format
|
||||
)
|
||||
except (ValidationError, json.JSONDecodeError) as e:
|
||||
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
|
||||
@ -165,7 +148,6 @@ 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}")
|
||||
@ -243,7 +225,6 @@ 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
|
||||
@ -261,8 +242,7 @@ class ChatgptService:
|
||||
input_data : dict,
|
||||
img_url : Optional[str] = None,
|
||||
img_detail_high : bool = False,
|
||||
silent : bool = True,
|
||||
reasoning_effort : Optional[str] = None,
|
||||
silent : bool = True
|
||||
) -> BaseModel:
|
||||
prompt_text = prompt.build_prompt(input_data, silent)
|
||||
|
||||
@ -273,5 +253,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, reasoning_effort)
|
||||
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
||||
return parsed
|
||||
@ -113,6 +113,32 @@ image_autotag_prompt = Prompt(
|
||||
prompt_output_class=ImageTagPromptOutput,
|
||||
)
|
||||
|
||||
@lru_cache()
|
||||
def get_ssul_upload_prompt() -> Prompt:
|
||||
"""썰박스 SNS 업로드 SEO 프롬프트 (시트: ssul_upload).
|
||||
|
||||
**lazy 로 만드는 이유**: 모듈 레벨 Prompt() 는 import 시점에 시트를 읽는다 —
|
||||
스프레드시트에 `ssul_upload` 시트가 아직 없으면 **앱 기동 자체가 죽는다.**
|
||||
lazy 면 시트가 없어도 앱은 살고, 썰박스 SEO 요청만 500 이 난다.
|
||||
(그래서 _preload_all_sheets 목록에도 넣지 않았다 — preload 는 실패를
|
||||
경고로 삼키지만, 이후 Prompt() 생성 시 worksheet() 폴백에서 죽는 건 같다)
|
||||
|
||||
ADO2(yt_upload)와 다른 프롬프트를 쓴다: 광고 영상 SEO 가 아니라
|
||||
병맛 역사 썰툰 톤의 제목·설명·태그를 만든다.
|
||||
템플릿 변수: {store_name} {region} {scenario_name}
|
||||
"""
|
||||
from app.utils.prompts.schemas import (
|
||||
SsulUploadPromptInput,
|
||||
SsulUploadPromptOutput,
|
||||
)
|
||||
|
||||
return Prompt(
|
||||
sheet_name="ssul_upload",
|
||||
prompt_input_class=SsulUploadPromptInput,
|
||||
prompt_output_class=SsulUploadPromptOutput,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt:
|
||||
# industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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
|
||||
|
||||
28
app/utils/prompts/schemas/ssulbox.py
Normal file
28
app/utils/prompts/schemas/ssulbox.py
Normal file
@ -0,0 +1,28 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
|
||||
# Input 정의
|
||||
class SsulUploadPromptInput(BaseModel):
|
||||
"""썰박스 SNS 업로드 SEO 프롬프트 입력.
|
||||
|
||||
ADO2(yt_upload)와 별도 프롬프트를 쓴다 — ADO2 는 업장 마케팅 분석 보고서 기반의
|
||||
광고 영상이지만, 썰박스는 "병맛 역사 썰툰"이라 제목·설명의 톤이 완전히 다르다.
|
||||
스프레드시트 시트명: `ssul_upload` (B2=모델, B3=템플릿).
|
||||
템플릿에서 쓸 수 있는 변수: {store_name} {region} {scenario_name}
|
||||
"""
|
||||
|
||||
store_name: str = Field(..., description="마케팅 대상 업장명 (모르면 빈 문자열)")
|
||||
region: str = Field(default="", description="업장 지역 (모르면 빈 문자열)")
|
||||
scenario_name: str = Field(
|
||||
..., description="시나리오 한글명 (조선왕/삼국지/그리스·로마 신화/오디세이)"
|
||||
)
|
||||
|
||||
|
||||
# Output 정의
|
||||
class SsulUploadPromptOutput(BaseModel):
|
||||
title: str = Field(..., description="쇼츠 제목 - 병맛 톤 + SEO")
|
||||
description: str = Field(..., description="업로드 설명 - 병맛 톤 + SEO/해시태그 포함")
|
||||
# ADO2 는 마케팅 분석의 target_keywords 를 태그로 재사용하지만 썰박스에는
|
||||
# 그 분석이 없다 — GPT 가 태그까지 함께 만든다.
|
||||
keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
||||
@ -12,11 +12,15 @@ 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
|
||||
|
||||
@ -48,39 +52,58 @@ async def flush_reactions(
|
||||
detail="Invalid internal secret",
|
||||
)
|
||||
|
||||
pairs = await drain_dirty()
|
||||
if not pairs:
|
||||
entries = await drain_dirty()
|
||||
if not entries:
|
||||
logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료")
|
||||
return {"flushed": 0, "adds": 0, "dels": 0}
|
||||
|
||||
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(pairs)}건")
|
||||
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(entries)}건")
|
||||
|
||||
# 두 종류가 **같은 테이블**(video_reaction)에 들어간다. 대상 컬럼만 다르다.
|
||||
# ADO2 는 video_id, 썰박스는 content_id 를 채운다(나머지는 NULL, CHECK 로 강제).
|
||||
id_col_of = {CT_VIDEO: "video_id", CT_SSUL: "content_id"}
|
||||
adds: list[dict] = []
|
||||
dels: list[tuple[int, str]] = []
|
||||
# (컬럼명, 대상 id, user_uuid) — 삭제는 컬럼이 달라 종류별로 묶어야 한다
|
||||
dels: dict[str, list[tuple[int, str]]] = {"video_id": [], "content_id": []}
|
||||
|
||||
# Redis 현재 상태 기준으로 add / delete 분류
|
||||
for video_id, user_uuid in pairs:
|
||||
liked = await is_user_liked(video_id, user_uuid)
|
||||
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)
|
||||
if liked:
|
||||
adds.append({"video_id": video_id, "user_uuid": user_uuid})
|
||||
adds.append({id_col: content_id, "user_uuid": user_uuid})
|
||||
else:
|
||||
dels.append((video_id, user_uuid))
|
||||
dels[id_col].append((content_id, user_uuid))
|
||||
|
||||
total_adds = len(adds)
|
||||
total_dels = sum(len(v) for v in dels.values())
|
||||
|
||||
try:
|
||||
# Bulk INSERT IGNORE — UniqueConstraint 보장으로 멱등 처리
|
||||
if adds:
|
||||
# 쓰기를 **하나의 트랜잭션**으로 묶는다.
|
||||
# 부분 반영이 나면 Redis processing SET 이 남아 다음 회차에 재시도되는데,
|
||||
# INSERT IGNORE + UNIQUE 로 멱등이라 중복 반영은 안전하다.
|
||||
#
|
||||
# ⚠️ adds 는 종류별로 키가 달라(video_id vs content_id) 한 번의
|
||||
# `values(adds)` 로 묶으면 안 된다 — SQLAlchEmy 가 첫 dict 의 키로
|
||||
# 컬럼 목록을 정하므로 다른 종류의 값이 누락된다. 종류별로 나눠 실행한다.
|
||||
for id_col in ("video_id", "content_id"):
|
||||
rows = [a for a in adds if id_col in a]
|
||||
if rows:
|
||||
await session.execute(
|
||||
insert(VideoReaction).prefix_with("IGNORE").values(adds)
|
||||
insert(VideoReaction).prefix_with("IGNORE").values(rows)
|
||||
)
|
||||
|
||||
# Bulk DELETE
|
||||
if dels:
|
||||
if dels[id_col]:
|
||||
await session.execute(
|
||||
delete(VideoReaction).where(
|
||||
tuple_(
|
||||
VideoReaction.video_id,
|
||||
getattr(VideoReaction, id_col),
|
||||
VideoReaction.user_uuid,
|
||||
).in_(dels)
|
||||
).in_(dels[id_col])
|
||||
)
|
||||
)
|
||||
|
||||
@ -88,9 +111,9 @@ async def flush_reactions(
|
||||
await commit_dirty_processing()
|
||||
|
||||
logger.info(
|
||||
f"[REACTION_FLUSH] SUCCESS - adds: {len(adds)}, dels: {len(dels)}"
|
||||
f"[REACTION_FLUSH] SUCCESS - adds: {total_adds}, dels: {total_dels}"
|
||||
)
|
||||
return {"flushed": len(pairs), "adds": len(adds), "dels": len(dels)}
|
||||
return {"flushed": len(entries), "adds": total_adds, "dels": total_dels}
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
|
||||
@ -13,13 +13,11 @@ 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 func, or_, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
@ -29,27 +27,28 @@ 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,
|
||||
@ -59,19 +58,25 @@ 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"])
|
||||
|
||||
|
||||
@ -123,6 +128,8 @@ async def _get_official_site_urls(
|
||||
return url_by_project
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
"/generate/{task_id}",
|
||||
summary="영상 생성 요청",
|
||||
@ -168,12 +175,19 @@ 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": "영상 생성 요청 실패"},
|
||||
},
|
||||
@ -228,10 +242,17 @@ async def generate_video(
|
||||
# ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image =====
|
||||
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
||||
|
||||
# Project 조회
|
||||
# Project 조회 (본인 소유만).
|
||||
# ⚠️ task_id 는 경로 파라미터라 남의 값을 넣을 수 있다. 소유자를 안 거르면
|
||||
# 남의 프로젝트로 영상을 만들 수 있고, 더 나쁘게는 크레딧 원장에
|
||||
# job_ref=피해자 task_id 로 차감이 기록돼 **피해자의 정상 생성이
|
||||
# "이미 차감됨"으로 처리**된다(멱등 키 오염).
|
||||
project_result = await session.execute(
|
||||
select(Project)
|
||||
.where(Project.task_id == task_id)
|
||||
.where(
|
||||
Project.task_id == task_id,
|
||||
Project.user_uuid == current_user.user_uuid,
|
||||
)
|
||||
.order_by(Project.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@ -368,7 +389,11 @@ async def generate_video(
|
||||
f"timestamps: {len(song_timestamp_list)}"
|
||||
)
|
||||
|
||||
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
|
||||
# ===== Video 테이블에 초기 데이터 저장 + 크레딧 선차감 (단일 트랜잭션) =====
|
||||
# 렌더 완료 후가 아니라 "시작 시점"에 차감한다. 사후차감이던 시절에는
|
||||
# 차감 전에 동시 요청이 들어오면 크레딧 1개로 영상 여러 개를 만들 수 있었다.
|
||||
# Video insert 와 차감을 한 트랜잭션으로 묶어 한 번만 커밋해야
|
||||
# "영상 행은 생겼는데 차감은 실패" 같은 반쪽 상태가 생기지 않는다.
|
||||
video = Video(
|
||||
project_id=project_id,
|
||||
lyric_id=lyric_id,
|
||||
@ -378,6 +403,21 @@ 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()
|
||||
@ -389,6 +429,14 @@ 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(
|
||||
@ -541,17 +589,13 @@ async def generate_video(
|
||||
)
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
# 외부 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)
|
||||
# 외부 API 실패 시 Video 상태를 failed로 갱신하고, 선차감한 크레딧을 환불한다.
|
||||
# 크레딧은 1단계에서 이미 차감됐으므로 여기서 돌려주지 않으면 그대로 소멸된다.
|
||||
await _fail_and_refund(
|
||||
task_id,
|
||||
user_uuid=current_user.user_uuid,
|
||||
reason="영상 생성 실패 환불 (Creatomate 요청 오류)",
|
||||
)
|
||||
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,
|
||||
@ -686,20 +730,41 @@ async def get_video_status(
|
||||
message = status_messages.get(status, f"상태: {status}")
|
||||
|
||||
video_id = None
|
||||
# succeeded 상태인 경우 백그라운드 태스크 실행
|
||||
if status == "succeeded" and video_url:
|
||||
# creatomate_render_id로 Video 조회하여 task_id 가져오기
|
||||
video_result = await session.execute(
|
||||
|
||||
# ⚠️ 소유자 검증이 필수다. 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)
|
||||
.where(Video.creatomate_render_id == creatomate_render_id)
|
||||
.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)
|
||||
)
|
||||
video = video_result.scalar_one_or_none()
|
||||
).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
|
||||
|
||||
video_id = video.id
|
||||
# succeeded 상태인 경우 백그라운드 태스크 실행
|
||||
if status == "succeeded" and video_url:
|
||||
# creatomate_render_id로 Video 조회하여 task_id 가져오기 (본인 것만)
|
||||
video = await _load_owned_video()
|
||||
|
||||
if video and video.status != "completed":
|
||||
video_id = video.id
|
||||
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
|
||||
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
||||
logger.info(
|
||||
@ -713,9 +778,25 @@ 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"),
|
||||
@ -905,7 +986,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}"
|
||||
@ -914,150 +995,34 @@ async def get_all_videos(
|
||||
try:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
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],
|
||||
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,
|
||||
)
|
||||
)
|
||||
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=items,
|
||||
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
|
||||
],
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
@ -1070,6 +1035,7 @@ async def get_all_videos(
|
||||
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
|
||||
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{video_id}/like",
|
||||
summary="영상 좋아요 토글",
|
||||
@ -1089,57 +1055,77 @@ 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 - video_id: {video_id}, user: {current_user.user_uuid}")
|
||||
logger.info(
|
||||
f"[toggle_like] START - type: {type}, id: {video_id}, user: {current_user.user_uuid}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 영상 존재 확인 (DB read는 유지 — 404 처리 필수)
|
||||
video_result = await session.execute(
|
||||
select(Video).where(
|
||||
# 대상 존재 확인 (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(
|
||||
Video.id == video_id,
|
||||
Video.status == "completed",
|
||||
Video.is_deleted == False, # noqa: E712
|
||||
Video.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if video_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
||||
target_col = VideoReaction.video_id
|
||||
|
||||
if (await session.execute(exists_q)).scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="콘텐츠를 찾을 수 없습니다.")
|
||||
|
||||
# Cold-start 보정: Redis에 데이터가 없으면 DB에서 backfill
|
||||
count = await get_like_count(video_id)
|
||||
count = await get_like_count(video_id, ctype=type)
|
||||
if count is None:
|
||||
# 카운트와 user-set 모두 없음 → DB에서 전체 복구
|
||||
user_uuids = (await session.execute(
|
||||
select(VideoReaction.user_uuid)
|
||||
.where(VideoReaction.video_id == video_id)
|
||||
select(VideoReaction.user_uuid).where(target_col == video_id)
|
||||
)).scalars().all()
|
||||
await backfill_user_set(video_id, list(user_uuids))
|
||||
await set_like_count(video_id, len(user_uuids))
|
||||
await backfill_user_set(video_id, list(user_uuids), ctype=type)
|
||||
await set_like_count(video_id, len(user_uuids), ctype=type)
|
||||
elif count > 0:
|
||||
if not await is_user_set_exists(video_id):
|
||||
if not await is_user_set_exists(video_id, ctype=type):
|
||||
# 카운트는 있지만 user-set이 증발한 경우 (부분 캐시 미스)
|
||||
user_uuids = (await session.execute(
|
||||
select(VideoReaction.user_uuid)
|
||||
.where(VideoReaction.video_id == video_id)
|
||||
select(VideoReaction.user_uuid).where(target_col == video_id)
|
||||
)).scalars().all()
|
||||
await backfill_user_set(video_id, list(user_uuids))
|
||||
await backfill_user_set(video_id, list(user_uuids), ctype=type)
|
||||
|
||||
# Lua 스크립트로 원자적 토글 (race condition 방지)
|
||||
is_liked, like_count = await toggle_like_atomic(video_id, current_user.user_uuid)
|
||||
is_liked, like_count = await toggle_like_atomic(
|
||||
video_id, current_user.user_uuid, ctype=type
|
||||
)
|
||||
|
||||
# dirty SET에 표시 → 스케줄러가 DB에 반영
|
||||
await mark_dirty(video_id, current_user.user_uuid)
|
||||
await mark_dirty(video_id, current_user.user_uuid, ctype=type)
|
||||
|
||||
logger.info(
|
||||
f"[toggle_like] SUCCESS - video_id: {video_id}, "
|
||||
f"[toggle_like] SUCCESS - type: {type}, 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)
|
||||
|
||||
@ -1,7 +1,19 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.mysql import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@ -165,8 +177,11 @@ 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",
|
||||
@ -174,6 +189,7 @@ class Video(Base):
|
||||
|
||||
reactions: Mapped[List["VideoReaction"]] = relationship(
|
||||
"VideoReaction",
|
||||
foreign_keys="VideoReaction.video_id",
|
||||
back_populates="video",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="noload",
|
||||
@ -199,14 +215,30 @@ 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",
|
||||
@ -218,11 +250,19 @@ class VideoReaction(Base):
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
||||
)
|
||||
video_id: Mapped[int] = mapped_column(
|
||||
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_video_reaction_one_target).
|
||||
video_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("video.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="연결된 Video의 id",
|
||||
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)",
|
||||
)
|
||||
user_uuid: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
@ -237,5 +277,8 @@ class VideoReaction(Base):
|
||||
comment="반응 일시",
|
||||
)
|
||||
|
||||
video: Mapped["Video"] = relationship("Video", back_populates="reactions")
|
||||
# 썰박스 반응이면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
|
||||
video: Mapped[Optional["Video"]] = relationship(
|
||||
"Video", foreign_keys=[video_id], back_populates="reactions"
|
||||
)
|
||||
user: Mapped["User"] = relationship("User", back_populates="video_reactions")
|
||||
|
||||
@ -5,7 +5,7 @@ Video API Schemas
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@ -153,10 +153,23 @@ class VideoListItem(BaseModel):
|
||||
}
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID")
|
||||
# ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는
|
||||
# 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두
|
||||
# **`(type, video_id)` 쌍**으로 다뤄야 한다.
|
||||
# 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로,
|
||||
# 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.**
|
||||
type: Literal["video", "ssul", "p2v_video", "p2v_poster"] = Field(
|
||||
default="video",
|
||||
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스, "
|
||||
"p2v_video: 무빙 포스터, p2v_poster: 포스터 스타일링 — 이미지라 <img> 로 그릴 것)",
|
||||
)
|
||||
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
task_id: str = Field(
|
||||
default="",
|
||||
description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)",
|
||||
)
|
||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
||||
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||
@ -178,10 +191,20 @@ class VideoThumbnailItem(BaseModel):
|
||||
GET /video/all 응답의 개별 영상 정보
|
||||
"""
|
||||
|
||||
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
|
||||
store_name: str = Field(..., description="업체명")
|
||||
result_movie_url: str = Field(..., description="영상 URL")
|
||||
# ⚠️ `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)")
|
||||
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)")
|
||||
@ -204,7 +227,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 업로드 제목 (공유 시 og:title 및 공유 제목으로 사용)")
|
||||
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||
created_at: datetime = Field(..., description="생성 일시")
|
||||
like_count: int = Field(..., description="좋아요 수")
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
|
||||
"""콘텐츠 공유 링크용 Open Graph HTML 생성 서비스."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
@ -9,12 +9,14 @@ 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:
|
||||
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
|
||||
@ -27,6 +29,18 @@ 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,
|
||||
@ -63,6 +77,41 @@ 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,
|
||||
*,
|
||||
@ -71,19 +120,58 @@ 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}/video/{data.video_id}"
|
||||
detail_url = f"{frontend_base}{detail_path}"
|
||||
fallback_image_url = _resolve_default_image_url(
|
||||
configured_default_image_url,
|
||||
frontend_base,
|
||||
share_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_url = _absolute_http_url(poster_url) or fallback_image_url
|
||||
canonical_url = _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)
|
||||
@ -114,7 +202,7 @@ def build_video_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 () {{
|
||||
@ -141,12 +229,12 @@ def _normalise_text(value: str | None, fallback: str) -> str:
|
||||
_OG_DESCRIPTION_MAX_LEN = 300
|
||||
|
||||
|
||||
def _share_title(title: str | None, store_name: str) -> str:
|
||||
def _share_title(title: str | None, store_name: str, *, fallback_store: str) -> str:
|
||||
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
|
||||
stored = _normalise_text(title, "")
|
||||
if stored:
|
||||
return stored
|
||||
name = _normalise_text(store_name, "ADO2 영상")
|
||||
name = _normalise_text(store_name, fallback_store)
|
||||
return f"{name} | ADO2"
|
||||
|
||||
|
||||
@ -216,34 +304,14 @@ def _forwarded_origin(headers: Mapping[str, str]) -> str | None:
|
||||
return f"{forwarded_proto}://{forwarded_host}"
|
||||
|
||||
|
||||
def _canonical_tags(canonical_url: str | None) -> str:
|
||||
"""공유 URL을 확신할 때만 canonical/og:url을 붙입니다.
|
||||
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
|
||||
|
||||
잘못된 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'
|
||||
)
|
||||
parts = urlsplit(absolute_url)
|
||||
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
|
||||
|
||||
def _normalise_frontend_base_url(value: str) -> str:
|
||||
@ -279,6 +347,36 @@ 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)
|
||||
@ -287,24 +385,19 @@ def _api_base_from_share_url(share_url: str) -> str | None:
|
||||
|
||||
parts = urlsplit(absolute_url)
|
||||
origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
idx = (parts.path or "").find("/video/share/")
|
||||
if idx < 0:
|
||||
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:
|
||||
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()
|
||||
|
||||
606
app/video/services/unified_list.py
Normal file
606
app/video/services/unified_list.py
Normal file
@ -0,0 +1,606 @@
|
||||
# -*- 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
|
||||
@ -1,13 +1,7 @@
|
||||
import random
|
||||
from typing import List
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from sqlalchemy import Connection, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy import select, func
|
||||
from app.database.session import AsyncSessionLocal
|
||||
from app.home.models import Image, ImageTag, Project
|
||||
from app.home.models import Image, ImageTag
|
||||
|
||||
# from app.lyric.schemas.lyrics_schema import (
|
||||
# AttributeData,
|
||||
@ -837,7 +831,6 @@ logger = get_logger("video")
|
||||
# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
# detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
# )
|
||||
from sqlalchemy.dialects import mysql
|
||||
async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
|
||||
# print("taskid", task_id)
|
||||
async with AsyncSessionLocal() as session:
|
||||
|
||||
@ -11,8 +11,9 @@ 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.user.services.credit import consume_credit
|
||||
from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO
|
||||
from app.video.models import Video
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from app.utils.logger import get_logger
|
||||
@ -24,6 +25,57 @@ 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,
|
||||
@ -209,24 +261,21 @@ async def download_and_upload_video_to_blob(
|
||||
if video_id is not None:
|
||||
await _try_generate_sns_metadata(video_id)
|
||||
|
||||
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
||||
async with BackgroundSessionLocal() as session:
|
||||
await consume_credit(user_uuid, session)
|
||||
await session.commit()
|
||||
# 크레딧은 generate_video 에서 이미 선차감했다. 여기서 차감하지 않는다.
|
||||
|
||||
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 _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
@ -330,18 +379,15 @@ 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)
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패")
|
||||
|
||||
finally:
|
||||
# 임시 파일 삭제
|
||||
|
||||
151
config.py
151
config.py
@ -691,6 +691,155 @@ 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()
|
||||
@ -708,3 +857,5 @@ social_oauth_settings = SocialOAuthSettings()
|
||||
meta_conversion_settings = MetaConversionSettings()
|
||||
internal_settings = InternalSettings()
|
||||
social_upload_settings = SocialUploadSettings()
|
||||
ssulbox_settings = SsulboxSettings()
|
||||
p2v_settings = P2vSettings()
|
||||
|
||||
@ -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 (
|
||||
@ -0,0 +1,30 @@
|
||||
|
||||
-- 실행 순서: 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`;
|
||||
|
||||
91
docs/database-schema/migration_2026-07-30_ssulbox.sql
Normal file
91
docs/database-schema/migration_2026-07-30_ssulbox.sql
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
-- 실행 순서: 이 파일 먼저 → 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));
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
-- ============================================================
|
||||
-- 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`;
|
||||
@ -0,0 +1,11 @@
|
||||
-- ============================================================
|
||||
-- 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`;
|
||||
@ -0,0 +1,21 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
88
docs/database-schema/migration_2026-08-25_p2v.sql
Normal file
88
docs/database-schema/migration_2026-08-25_p2v.sql
Normal file
@ -0,0 +1,88 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
414
docs/design/p2v-credit-integration.md
Normal file
414
docs/design/p2v-credit-integration.md
Normal file
@ -0,0 +1,414 @@
|
||||
# 📋 설계 문서 — 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` 설정
|
||||
15
generator/animation/character/README.txt
Normal file
15
generator/animation/character/README.txt
Normal file
@ -0,0 +1,15 @@
|
||||
[캐릭터 공식 레퍼런스 폴더 — '눈만 고정']
|
||||
|
||||
이 폴더에 고양이 왕 기준 이미지를 1장 넣어두면(예: king_ref.png),
|
||||
모든 쇼츠 영상의 고양이 왕이 '그 눈과 표정'을 그대로 따라갑니다.
|
||||
|
||||
- 고정되는 것 : 눈 모양 / 눈동자 / 표정 (귀여운 순한 눈으로 통일)
|
||||
- 고정 안 됨 : 곤룡포 색, 관모, 포즈, 배경 → 왕별 character_sheet / 장면 프롬프트대로
|
||||
(즉 광해군·순종 등 왕마다 복장은 다르게 나옴)
|
||||
|
||||
- 파일명은 자유 (폴더 안 '첫 번째' 이미지 파일을 사용. png/jpg/jpeg/webp)
|
||||
- 한 장만 두는 걸 권장 (여러 장이면 정렬상 첫 파일만 사용)
|
||||
- 빼고 싶으면 이미지를 폴더 밖으로 빼거나 폴더를 비우면 됨
|
||||
→ 그럼 예전처럼 영상마다 캐릭터를 새로 그림(눈은 EYE_STYLE 텍스트 지시로 귀엽게 유지)
|
||||
|
||||
연결 코드: make_short.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||
BIN
generator/animation/character/image.png
Normal file
BIN
generator/animation/character/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
394
generator/animation/data/joseon_keywords_20260601_092737.csv
Normal file
394
generator/animation/data/joseon_keywords_20260601_092737.csv
Normal file
@ -0,0 +1,394 @@
|
||||
대수,왕,문서제목,썰후보여부,키워드,URL
|
||||
1,태조,태조(조선),,태조(조선),https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,개요,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,생애,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),Y,이름,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,묘호(廟號)와 시호(諡號),https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,휘(諱),https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),Y,가족 관계,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,사용한 무구,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,어궁구,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,전어도,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,화살보다 빠른 말,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,팔준마,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,직접 쓴 글과 시,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),Y,평가,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,어진,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),Y,여담,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),Y,대중매체,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,관련 문서,https://namu.wiki/w/태조(조선)
|
||||
1,태조,태조(조선),,둘러보기,https://namu.wiki/w/태조(조선)
|
||||
2,정종,정종(조선),,정종(조선),https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),,개요,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),,생애,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),Y,가족 관계,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),,선대 가계,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),,배우자 / 자녀,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),Y,평가,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),Y,여담,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),,정종 무인정사 배후설?,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),Y,대중매체,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),,관련 문서,https://namu.wiki/w/정종(조선)
|
||||
2,정종,정종(조선),,둘러보기,https://namu.wiki/w/정종(조선)
|
||||
3,태종,태종(조선),,태종(조선),https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),,개요,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),,묘호와 시호,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),Y,이름과 작위,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),,생애,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),Y,평가,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),Y,일화,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),,직접 쓴 글과 시,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),Y,기타,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),Y,가족 관계,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),Y,대중매체,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),,관련 문서,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),,외부 링크,https://namu.wiki/w/태종(조선)
|
||||
3,태종,태종(조선),,둘러보기,https://namu.wiki/w/태종(조선)
|
||||
4,세종,세종(조선),,세종(조선),https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,개요,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,생애,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),Y,업적,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),Y,비판과 반론,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),Y,특이한 기록들,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,가계,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,조상,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,배우자 / 자녀,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,영릉,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,어진,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),Y,대중매체,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,직접 쓴 글과 시,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),Y,여담,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),Y,어록,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,관련 단체,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,관련 문서,https://namu.wiki/w/세종(조선)
|
||||
4,세종,세종(조선),,둘러보기,https://namu.wiki/w/세종(조선)
|
||||
5,문종,문종(조선),,문종(조선),https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,개요,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,생애,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),Y,평가,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,가계,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,조상,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,배우자 / 자녀,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,직접 쓴 글과 시,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),Y,기타,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),Y,대중매체,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,드라마,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,영화,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,소설,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,게임,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,관련 문서,https://namu.wiki/w/문종(조선)
|
||||
5,문종,문종(조선),,둘러보기,https://namu.wiki/w/문종(조선)
|
||||
6,단종,단종(조선),,단종(조선),https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,개요,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,휘,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,생애,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),Y,평가,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),Y,가족관계,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,조상,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,형제자매,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,배우자,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,직접 쓴 글과 시,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,어진,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),Y,대중매체,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,만화,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,연극,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,영화,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,드라마,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,다큐멘터리,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,TV조선왕조실록,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,고운님 여의옵고,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,EBS 특집 다큐멘터리,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,MBC 다큐프라임,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,역사스페셜 시간여행자,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,소설,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),Y,여담,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,관련 문서,https://namu.wiki/w/단종(조선)
|
||||
6,단종,단종(조선),,둘러보기,https://namu.wiki/w/단종(조선)
|
||||
7,세조,세조(조선),,세조(조선),https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,개요,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,생애,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),Y,평가,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,어진,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,어필,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,수결(서명),https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,직접 쓴 글과 시,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,가계,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,조상,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,배우자 / 자녀,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),Y,대중매체,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),Y,여담,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,관련 유물,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,관련 문서,https://namu.wiki/w/세조(조선)
|
||||
7,세조,세조(조선),,둘러보기,https://namu.wiki/w/세조(조선)
|
||||
8,예종,예종(조선),,예종(조선),https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),,개요,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),,생애,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),,직접 쓴 글과 시,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),Y,평가,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),,가계,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),,조상,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),,배우자 / 자녀,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),Y,기타,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),Y,대중매체,https://namu.wiki/w/예종(조선)
|
||||
8,예종,예종(조선),,둘러보기,https://namu.wiki/w/예종(조선)
|
||||
9,성종,성종(조선),,성종(조선),https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,개요,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,생애,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,가계,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,조상,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,배우자 / 자녀,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),Y,평가,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),Y,기타,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),Y,대중매체,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,영화,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,드라마,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,관련 문서,https://namu.wiki/w/성종(조선)
|
||||
9,성종,성종(조선),,둘러보기,https://namu.wiki/w/성종(조선)
|
||||
10,연산군,연산군,,연산군,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,,개요,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,,생애,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,Y,여담,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,,가계,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,,조상,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,,배우자/자녀,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,Y,평가,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,Y,대중매체,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,,관련 문서,https://namu.wiki/w/연산군
|
||||
10,연산군,연산군,,둘러보기,https://namu.wiki/w/연산군
|
||||
11,중종,중종(조선),,중종(조선),https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,개요,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,생애,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),Y,평가,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),Y,기타,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,가계,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,조상,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,배우자 / 자녀,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),Y,대중매체,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,드라마,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,영화,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,관련 문서,https://namu.wiki/w/중종(조선)
|
||||
11,중종,중종(조선),,둘러보기,https://namu.wiki/w/중종(조선)
|
||||
12,인종,인종(조선),,인종(조선),https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,개요,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,생애,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,가계,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,친가(전주 이씨),https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,조상,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,외가(파평 윤씨),https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,처가(반남 박씨),https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,배우자 / 자녀,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),Y,기타,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),Y,대중매체,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,만화,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,영화,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,드라마,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,소설,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,관련 문서,https://namu.wiki/w/인종(조선)
|
||||
12,인종,인종(조선),,둘러보기,https://namu.wiki/w/인종(조선)
|
||||
13,명종,명종(조선),,명종(조선),https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),,개요,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),,생애,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),,가계,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),,조상,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),,배우자 / 자녀,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),Y,평가,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),Y,기타,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),Y,대중매체,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),,관련 문서,https://namu.wiki/w/명종(조선)
|
||||
13,명종,명종(조선),,둘러보기,https://namu.wiki/w/명종(조선)
|
||||
14,선조,선조(조선),,선조(조선),https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,개요,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,생애,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),Y,평가,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),Y,가족 관계,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,조상,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,배우자/자녀,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),Y,기타,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,어진(御眞),https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),Y,대중매체,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,소설,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,만화,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,게임,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,영화,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,드라마,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,교양·다큐멘터리,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,관련 문서,https://namu.wiki/w/선조(조선)
|
||||
14,선조,선조(조선),,둘러보기,https://namu.wiki/w/선조(조선)
|
||||
15,광해군,광해군,,광해군,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,개요,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,생애,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,Y,평가,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,Y,여담,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,Y,가족 관계,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,조상,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,배우자/자녀,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,광해군묘,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,Y,대중매체,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,관련 다큐,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,한국사 傳,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,역사스페셜,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,역사스페셜 시간여행자,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,같이 보기,https://namu.wiki/w/광해군
|
||||
15,광해군,광해군,,둘러보기,https://namu.wiki/w/광해군
|
||||
17,효종,효종(조선),,효종(조선),https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,개요,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,생애,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,즉위 과정,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,북벌 준비와 군비 확장,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),Y,사망 징조와 죽음,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,후일담,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),Y,평가,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,북벌론,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,정통성에 대한 태도,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),Y,기타,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),Y,가족,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,조상,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,배우자/자녀,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),Y,대중매체,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,드라마,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,영화,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,관련 문서,https://namu.wiki/w/효종(조선)
|
||||
17,효종,효종(조선),,둘러보기,https://namu.wiki/w/효종(조선)
|
||||
18,현종,현종(조선),,현종(조선),https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),,개요,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),,생애,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),Y,평가,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),Y,기타,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),,가계,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),,조상,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),,배우자 / 자녀,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),Y,대중매체,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),,관련 문서,https://namu.wiki/w/현종(조선)
|
||||
18,현종,현종(조선),,둘러보기,https://namu.wiki/w/현종(조선)
|
||||
19,숙종,숙종(조선),,숙종(조선),https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,개요,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,생애,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,가계,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,조상,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,배우자 / 자녀,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),Y,여담,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,"금덕, 금손, 애묘인",https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,어진,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,직접 쓴 글과 시,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),Y,평가,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),Y,긍정적 평가,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),Y,부정적 평가,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),Y,대중매체,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,영화,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,드라마,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,소설,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,관련 문서,https://namu.wiki/w/숙종(조선)
|
||||
19,숙종,숙종(조선),,둘러보기,https://namu.wiki/w/숙종(조선)
|
||||
20,경종,경종(조선),,경종(조선),https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,개요,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,생애,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,조상,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),Y,여담,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,불임 의혹,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,이복동생 연잉군과의 관계,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),Y,대중매체,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,영화,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,드라마,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,소설,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,뮤지컬,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),Y,기타,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,관련 문서,https://namu.wiki/w/경종(조선)
|
||||
20,경종,경종(조선),,둘러보기,https://namu.wiki/w/경종(조선)
|
||||
22,정조,정조(조선),,정조(조선),https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,개요,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,생애,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),Y,업적과 정책,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,"묘호, 시호, 휘",https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),Y,평가,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,가계,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,조상,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,어진,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),Y,기타,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,엄친아,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),Y,유성한 사건,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),Y,의빈 성씨와의 일화,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,술과 담배 사랑,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,정조의 비밀 편지들,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,과인은 사도세자의 아들이다?,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),Y,대중매체,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,관련 문서,https://namu.wiki/w/정조(조선)
|
||||
22,정조,정조(조선),,둘러보기,https://namu.wiki/w/정조(조선)
|
||||
23,순조,순조,,순조,https://namu.wiki/w/순조
|
||||
23,순조,순조,,개요,https://namu.wiki/w/순조
|
||||
23,순조,순조,,생애,https://namu.wiki/w/순조
|
||||
23,순조,순조,Y,평가,https://namu.wiki/w/순조
|
||||
23,순조,순조,,묘호와 시호,https://namu.wiki/w/순조
|
||||
23,순조,순조,,어진,https://namu.wiki/w/순조
|
||||
23,순조,순조,Y,기타,https://namu.wiki/w/순조
|
||||
23,순조,순조,,가계,https://namu.wiki/w/순조
|
||||
23,순조,순조,,조상,https://namu.wiki/w/순조
|
||||
23,순조,순조,,형제자매,https://namu.wiki/w/순조
|
||||
23,순조,순조,,배우자/자녀,https://namu.wiki/w/순조
|
||||
23,순조,순조,Y,대중매체,https://namu.wiki/w/순조
|
||||
23,순조,순조,,관련 문서,https://namu.wiki/w/순조
|
||||
23,순조,순조,,둘러보기,https://namu.wiki/w/순조
|
||||
24,헌종,헌종(조선),,헌종(조선),https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,개요,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,생애,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),Y,평가,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,가계,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,조상,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,친가(전주 이씨),https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,외가(풍양 조씨),https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,처가,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,안동 김씨,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,남양 홍씨,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,배우자 / 자녀,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,어진,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),Y,여담,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),Y,대중매체,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,관련 문서,https://namu.wiki/w/헌종(조선)
|
||||
24,헌종,헌종(조선),,둘러보기,https://namu.wiki/w/헌종(조선)
|
||||
25,철종,철종(조선),,철종(조선),https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,개요,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,생애,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),Y,평가,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,어진,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,외부 링크,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,보물 (舊 보물 제1492호),https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,가계,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,조상,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,친가(전주 이씨),https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,외가(용담 염씨),https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,처가(안동 김씨),https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,배우자 / 자녀,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),Y,여담,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),Y,대중매체,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,소설,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,영화,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,드라마,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,만화,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,게임,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,관련 문서,https://namu.wiki/w/철종(조선)
|
||||
25,철종,철종(조선),,둘러보기,https://namu.wiki/w/철종(조선)
|
||||
26,고종,고종(대한제국),,고종(대한제국),https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,개요,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,호칭,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,즉위 배경,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,생애,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),Y,평가,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,청과의 영토 분쟁,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),Y,가족 관계,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,조상,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),Y,"사진과 어진, 기타 그림",https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,사진,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,어진,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),Y,기타 그림,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),Y,여담,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),Y,대중매체,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,관련 문서,https://namu.wiki/w/고종(대한제국)
|
||||
26,고종,고종(대한제국),,둘러보기,https://namu.wiki/w/고종(대한제국)
|
||||
27,순종,순종(대한제국),,순종(대한제국),https://namu.wiki/w/순종(대한제국)
|
||||
27,순종,순종(대한제국),,개요,https://namu.wiki/w/순종(대한제국)
|
||||
27,순종,순종(대한제국),,생애,https://namu.wiki/w/순종(대한제국)
|
||||
27,순종,순종(대한제국),Y,평가,https://namu.wiki/w/순종(대한제국)
|
||||
27,순종,순종(대한제국),,어진,https://namu.wiki/w/순종(대한제국)
|
||||
27,순종,순종(대한제국),Y,기타,https://namu.wiki/w/순종(대한제국)
|
||||
27,순종,순종(대한제국),Y,대중매체,https://namu.wiki/w/순종(대한제국)
|
||||
27,순종,순종(대한제국),,관련 문서,https://namu.wiki/w/순종(대한제국)
|
||||
27,순종,순종(대한제국),,둘러보기,https://namu.wiki/w/순종(대한제국)
|
||||
|
BIN
generator/animation/fonts/brand_logo.png
Normal file
BIN
generator/animation/fonts/brand_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
315
generator/animation/gen.py
Normal file
315
generator/animation/gen.py
Normal file
@ -0,0 +1,315 @@
|
||||
# -*- 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
|
||||
282
generator/animation/main.py
Normal file
282
generator/animation/main.py
Normal file
@ -0,0 +1,282 @@
|
||||
# -*- 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()
|
||||
949
generator/animation/render.py
Normal file
949
generator/animation/render.py
Normal file
@ -0,0 +1,949 @@
|
||||
# -*- 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
|
||||
270
generator/animation/scraper.py
Normal file
270
generator/animation/scraper.py
Normal file
@ -0,0 +1,270 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
나무위키 조선 왕 '썰 키워드' 스크래퍼
|
||||
====================================
|
||||
|
||||
조선 27대 왕의 나무위키 문서에서 목차(섹션 제목)를 긁어와
|
||||
숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 JSON / CSV 로 저장한다.
|
||||
|
||||
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
|
||||
Playwright(Chromium)로 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
|
||||
|
||||
설치:
|
||||
pip install playwright beautifulsoup4
|
||||
playwright install chromium # (이미 설치돼 있으면 생략)
|
||||
|
||||
실행:
|
||||
python namu_joseon_scraper.py
|
||||
|
||||
옵션:
|
||||
python namu_joseon_scraper.py --headful # 브라우저 창 보이게
|
||||
python namu_joseon_scraper.py --only 세종 광해군 # 특정 왕만
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 설정
|
||||
# ----------------------------------------------------------------------------
|
||||
BASE = "https://namu.wiki/w/"
|
||||
OUT_DIR = Path(__file__).parent / "data" # 조선왕 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
|
||||
|
||||
# 조선 27대 왕: (대수, 묘호/군호, 나무위키 문서 제목)
|
||||
# 군주는 보통 "묘호(조선)" 형태, 폐위된 임금(연산군/광해군)은 군호 단독.
|
||||
JOSEON_KINGS = [
|
||||
(1, "태조", "태조(조선)"),
|
||||
(2, "정종", "정종(조선)"),
|
||||
(3, "태종", "태종(조선)"),
|
||||
(4, "세종", "세종(조선)"),
|
||||
(5, "문종", "문종(조선)"),
|
||||
(6, "단종", "단종(조선)"),
|
||||
(7, "세조", "세조(조선)"),
|
||||
(8, "예종", "예종(조선)"),
|
||||
(9, "성종", "성종(조선)"),
|
||||
(10, "연산군", "연산군"),
|
||||
(11, "중종", "중종(조선)"),
|
||||
(12, "인종", "인종(조선)"),
|
||||
(13, "명종", "명종(조선)"),
|
||||
(14, "선조", "선조(조선)"),
|
||||
(15, "광해군", "광해군"),
|
||||
(16, "인조", "인조(조선)"),
|
||||
(17, "효종", "효종(조선)"),
|
||||
(18, "현종", "현종(조선)"),
|
||||
(19, "숙종", "숙종(조선)"),
|
||||
(20, "경종", "경종(조선)"),
|
||||
(21, "영조", "영조(조선)"),
|
||||
(22, "정조", "정조(조선)"),
|
||||
(23, "순조", "순조"),
|
||||
(24, "헌종", "헌종(조선)"),
|
||||
(25, "철종", "철종(조선)"),
|
||||
(26, "고종", "고종(대한제국)"),
|
||||
(27, "순종", "순종(대한제국)"),
|
||||
]
|
||||
|
||||
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
|
||||
ANECDOTE_HINTS = [
|
||||
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
|
||||
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
|
||||
"독살", "암살", "치세", "업적", "가족", "후궁", "이름", "별명",
|
||||
]
|
||||
|
||||
|
||||
def clean_heading(text: str) -> str:
|
||||
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
|
||||
t = text.replace("[편집]", "").strip()
|
||||
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
|
||||
t = re.sub(r"^\s*[\d.]+\s*", "", t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
def is_anecdote(keyword: str) -> bool:
|
||||
return any(h in keyword for h in ANECDOTE_HINTS)
|
||||
|
||||
|
||||
async def scrape_king(page, title: str):
|
||||
"""한 왕 문서를 열고 목차 키워드 목록을 반환."""
|
||||
url = BASE + title
|
||||
result = {"title": title, "url": url, "page_title": None,
|
||||
"keywords": [], "anecdote_keywords": [], "error": None}
|
||||
try:
|
||||
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
|
||||
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
|
||||
try:
|
||||
await page.wait_for_selector(
|
||||
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
|
||||
timeout=8000,
|
||||
)
|
||||
except Exception:
|
||||
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
|
||||
await page.wait_for_timeout(800)
|
||||
|
||||
# 문서 실제 제목
|
||||
try:
|
||||
result["page_title"] = (await page.title()).split(" - ")[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if resp and resp.status >= 400:
|
||||
result["error"] = f"HTTP {resp.status}"
|
||||
return result
|
||||
|
||||
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
|
||||
keywords = await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const clean = s => s.replace('[편집]', '').trim();
|
||||
let items = [];
|
||||
|
||||
// 1순위: 목차(table of contents)
|
||||
const tocLinks = document.querySelectorAll(
|
||||
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
|
||||
);
|
||||
if (tocLinks.length) {
|
||||
items = Array.from(tocLinks).map(a => clean(a.textContent));
|
||||
}
|
||||
|
||||
// 2순위(목차가 없으면): 본문 헤딩 태그
|
||||
if (items.length === 0) {
|
||||
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
||||
items = Array.from(hs).map(h => clean(h.textContent));
|
||||
}
|
||||
return items.filter(Boolean);
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# 중복 제거 + 번호 정리
|
||||
seen, cleaned = set(), []
|
||||
for raw in keywords:
|
||||
kw = clean_heading(raw)
|
||||
if kw and kw not in seen and len(kw) <= 60:
|
||||
seen.add(kw)
|
||||
cleaned.append(kw)
|
||||
|
||||
result["keywords"] = cleaned
|
||||
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
|
||||
except Exception as e:
|
||||
result["error"] = f"{type(e).__name__}: {e}"
|
||||
return result
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
|
||||
parser.add_argument("--only", nargs="*", help="특정 왕만 (묘호/군호로 지정)")
|
||||
parser.add_argument("--concurrency", type=int, default=4,
|
||||
help="동시에 열 페이지 수 (기본 4)")
|
||||
args = parser.parse_args()
|
||||
|
||||
kings = JOSEON_KINGS
|
||||
if args.only:
|
||||
wanted = set(args.only)
|
||||
kings = [k for k in JOSEON_KINGS if k[1] in wanted]
|
||||
if not kings:
|
||||
print(f"[!] 매칭되는 왕 없음: {args.only}")
|
||||
print(" 가능한 값:", ", ".join(k[1] for k in JOSEON_KINGS))
|
||||
return
|
||||
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
all_results = []
|
||||
|
||||
total = len(kings)
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=not args.headful)
|
||||
context = await browser.new_context(
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
locale="ko-KR",
|
||||
)
|
||||
|
||||
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
|
||||
async def _block(route):
|
||||
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
|
||||
await route.abort()
|
||||
else:
|
||||
await route.continue_()
|
||||
await context.route("**/*", _block)
|
||||
|
||||
sem = asyncio.Semaphore(max(1, args.concurrency))
|
||||
done = 0
|
||||
|
||||
async def worker(idx, name, title):
|
||||
nonlocal done
|
||||
async with sem:
|
||||
page = await context.new_page()
|
||||
try:
|
||||
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
|
||||
# 실패하면 백오프하며 최대 3회까지 재시도
|
||||
r = await scrape_king(page, title)
|
||||
for attempt in range(3):
|
||||
if not r["error"]:
|
||||
break
|
||||
await page.wait_for_timeout(1500 * (attempt + 1))
|
||||
r = await scrape_king(page, title)
|
||||
finally:
|
||||
await page.close()
|
||||
r["order"] = idx
|
||||
r["name"] = name
|
||||
done += 1
|
||||
if r["error"]:
|
||||
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
|
||||
else:
|
||||
print(f"[{done:2d}/{total}] {name} ({title}) "
|
||||
f"키워드 {len(r['keywords'])}개 "
|
||||
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
|
||||
return r
|
||||
|
||||
all_results = await asyncio.gather(
|
||||
*(worker(idx, name, title) for idx, name, title in kings)
|
||||
)
|
||||
all_results = sorted(all_results, key=lambda r: r["order"])
|
||||
|
||||
await browser.close()
|
||||
|
||||
# 저장 ---------------------------------------------------------------
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
json_path = OUT_DIR / f"joseon_keywords_{ts}.json"
|
||||
json_path.write_text(
|
||||
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
csv_path = OUT_DIR / f"joseon_keywords_{ts}.csv"
|
||||
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["대수", "왕", "문서제목", "썰후보여부", "키워드", "URL"])
|
||||
for r in all_results:
|
||||
anec = set(r["anecdote_keywords"])
|
||||
for kw in r["keywords"]:
|
||||
w.writerow([r["order"], r["name"], r["title"],
|
||||
"Y" if kw in anec else "", kw, r["url"]])
|
||||
|
||||
total_kw = sum(len(r["keywords"]) for r in all_results)
|
||||
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
|
||||
fails = [r["name"] for r in all_results if r["error"]]
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"완료: 왕 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}개")
|
||||
if fails:
|
||||
print(f"수집 실패: {', '.join(fails)}")
|
||||
print(f"JSON -> {json_path}")
|
||||
print(f"CSV -> {csv_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
asyncio.run(main())
|
||||
13
generator/animation_greekroman/character/README.txt
Normal file
13
generator/animation_greekroman/character/README.txt
Normal file
@ -0,0 +1,13 @@
|
||||
[캐릭터 공식 레퍼런스 폴더 — greekroman 은 기본 '비움']
|
||||
|
||||
greekroman(그리스 로마 신화) 버전은 고양이 의인화가 아니라 '실제 신의 모습'
|
||||
(근육질 남신 / 아름답고 늘씬한 여신, 한국 신화만화풍)으로 그리므로,
|
||||
조선/삼국지에서 쓰던 고양이 눈 레퍼런스 이미지를 넣지 않습니다.
|
||||
|
||||
- 폴더가 비어 있으면: 각 영상의 '첫 컷'이 자동 앵커가 되어
|
||||
나머지 컷들이 그 캐릭터 디자인/화풍을 그대로 따라갑니다(영상 내 일관성 유지).
|
||||
- 특정 그림체를 전 영상에 걸쳐 고정하고 싶으면 기준 이미지를 1장 넣으세요
|
||||
(폴더 안 '첫 번째' 이미지 파일 사용. png/jpg/jpeg/webp).
|
||||
단 이 경우 '눈만 고정' 모드라 눈/표정만 통일되고 복장은 신별로 다르게 나옵니다.
|
||||
|
||||
연결 코드: render.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user