feat(video): 영상 첫 프레임 썸네일 생성 및 저장
This commit is contained in:
parent
4c4297c37a
commit
8e9e12fe0b
@ -157,6 +157,7 @@ async def get_videos(
|
|||||||
region=project.region,
|
region=project.region,
|
||||||
task_id=video.task_id,
|
task_id=video.task_id,
|
||||||
result_movie_url=video.result_movie_url,
|
result_movie_url=video.result_movie_url,
|
||||||
|
poster_url=video.poster_url,
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
like_count=like_count_map.get(video.id) or 0,
|
like_count=like_count_map.get(video.id) or 0,
|
||||||
comment_count=comment_count or 0,
|
comment_count=comment_count or 0,
|
||||||
|
|||||||
@ -46,7 +46,7 @@ router = APIRouter(prefix="/comment", tags=["Comment"])
|
|||||||
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
|
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책).
|
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보를 그대로 사용합니다 (클라이언트에서 지정 불가).
|
||||||
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
|
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
|
||||||
""",
|
""",
|
||||||
response_model=CommentCreateResponse,
|
response_model=CommentCreateResponse,
|
||||||
@ -71,7 +71,7 @@ async def post_comment(
|
|||||||
session=session,
|
session=session,
|
||||||
video_id=video_id,
|
video_id=video_id,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
nickname=body.nickname,
|
nickname=current_user.nickname,
|
||||||
content=body.content,
|
content=body.content,
|
||||||
parent_id=body.parent_id,
|
parent_id=body.parent_id,
|
||||||
)
|
)
|
||||||
@ -79,6 +79,7 @@ async def post_comment(
|
|||||||
return CommentCreateResponse(
|
return CommentCreateResponse(
|
||||||
id=comment.id,
|
id=comment.id,
|
||||||
nickname=comment.nickname or "익명",
|
nickname=comment.nickname or "익명",
|
||||||
|
profile_image_url=current_user.profile_image_url,
|
||||||
parent_id=comment.parent_id,
|
parent_id=comment.parent_id,
|
||||||
content=comment.content,
|
content=comment.content,
|
||||||
created_at=comment.created_at,
|
created_at=comment.created_at,
|
||||||
@ -101,7 +102,7 @@ async def post_comment(
|
|||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
|
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
|
||||||
- 작성자 정보는 노출되지 않으며, is_mine으로 본인 댓글 여부만 확인 가능합니다.
|
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보 기준이며, is_mine으로 본인 댓글 여부도 확인 가능합니다.
|
||||||
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
|
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
|
||||||
""",
|
""",
|
||||||
response_model=PaginatedResponse[CommentItem],
|
response_model=PaginatedResponse[CommentItem],
|
||||||
|
|||||||
@ -17,7 +17,8 @@ class Comment(Base):
|
|||||||
|
|
||||||
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
||||||
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
||||||
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책).
|
작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며,
|
||||||
|
프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "comment"
|
__tablename__ = "comment"
|
||||||
@ -54,7 +55,7 @@ class Comment(Base):
|
|||||||
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
|
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
|
||||||
)
|
)
|
||||||
nickname: Mapped[Optional[str]] = mapped_column(
|
nickname: Mapped[Optional[str]] = mapped_column(
|
||||||
String(50), nullable=True, comment="댓글 작성자 닉네임 (null이면 익명)"
|
String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)"
|
||||||
)
|
)
|
||||||
content: Mapped[str] = mapped_column(
|
content: Mapped[str] = mapped_column(
|
||||||
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"
|
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"
|
||||||
|
|||||||
@ -5,7 +5,6 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
class CommentCreateRequest(BaseModel):
|
class CommentCreateRequest(BaseModel):
|
||||||
nickname: Optional[str] = Field(None, min_length=1, max_length=50, description="작성자 닉네임 (미입력 시 익명)")
|
|
||||||
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
|
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
|
||||||
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
|
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
|
||||||
|
|
||||||
@ -14,7 +13,8 @@ class ReplyItem(BaseModel):
|
|||||||
"""대댓글 응답"""
|
"""대댓글 응답"""
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
id: int = Field(..., description="댓글 고유 ID")
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
||||||
|
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
is_deleted: bool = Field(..., description="삭제 여부")
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
||||||
@ -25,7 +25,8 @@ class CommentItem(BaseModel):
|
|||||||
"""최상위 댓글 응답 — replies 포함"""
|
"""최상위 댓글 응답 — replies 포함"""
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
id: int = Field(..., description="댓글 고유 ID")
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
||||||
|
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
is_deleted: bool = Field(..., description="삭제 여부")
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
||||||
@ -35,7 +36,8 @@ class CommentItem(BaseModel):
|
|||||||
|
|
||||||
class CommentCreateResponse(BaseModel):
|
class CommentCreateResponse(BaseModel):
|
||||||
id: int = Field(..., description="생성된 댓글 고유 ID")
|
id: int = Field(..., description="생성된 댓글 고유 ID")
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
|
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
||||||
|
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필)")
|
||||||
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
|
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
|
||||||
content: str = Field(..., description="댓글 본문")
|
content: str = Field(..., description="댓글 본문")
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
created_at: datetime = Field(..., description="작성 일시")
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.comment.models import Comment
|
from app.comment.models import Comment
|
||||||
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
|
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
|
||||||
|
from app.user.models import User
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ def _build_comment_items(
|
|||||||
parents: list,
|
parents: list,
|
||||||
replies_map: dict,
|
replies_map: dict,
|
||||||
current_user_uuid: Optional[str],
|
current_user_uuid: Optional[str],
|
||||||
|
profile_image_map: dict,
|
||||||
) -> List[CommentItem]:
|
) -> List[CommentItem]:
|
||||||
items = []
|
items = []
|
||||||
for c in parents:
|
for c in parents:
|
||||||
@ -45,6 +47,7 @@ def _build_comment_items(
|
|||||||
ReplyItem(
|
ReplyItem(
|
||||||
id=r.id,
|
id=r.id,
|
||||||
nickname=r.nickname or "익명",
|
nickname=r.nickname or "익명",
|
||||||
|
profile_image_url=profile_image_map.get(r.user_uuid),
|
||||||
content=None if r.is_deleted else r.content,
|
content=None if r.is_deleted else r.content,
|
||||||
is_deleted=r.is_deleted,
|
is_deleted=r.is_deleted,
|
||||||
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
|
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
|
||||||
@ -56,6 +59,7 @@ def _build_comment_items(
|
|||||||
CommentItem(
|
CommentItem(
|
||||||
id=c.id,
|
id=c.id,
|
||||||
nickname=c.nickname or "익명",
|
nickname=c.nickname or "익명",
|
||||||
|
profile_image_url=profile_image_map.get(c.user_uuid),
|
||||||
content=None if c.is_deleted else c.content,
|
content=None if c.is_deleted else c.content,
|
||||||
is_deleted=c.is_deleted,
|
is_deleted=c.is_deleted,
|
||||||
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
|
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
|
||||||
@ -70,7 +74,7 @@ async def create_comment(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
video_id: int,
|
video_id: int,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
nickname: str,
|
nickname: Optional[str],
|
||||||
content: str,
|
content: str,
|
||||||
parent_id: Optional[int],
|
parent_id: Optional[int],
|
||||||
) -> Comment:
|
) -> Comment:
|
||||||
@ -143,6 +147,7 @@ async def list_comments(
|
|||||||
parents = (await session.execute(parents_q)).scalars().all()
|
parents = (await session.execute(parents_q)).scalars().all()
|
||||||
|
|
||||||
replies_map: dict = defaultdict(list)
|
replies_map: dict = defaultdict(list)
|
||||||
|
replies: list = []
|
||||||
if parents:
|
if parents:
|
||||||
parent_ids = [c.id for c in parents]
|
parent_ids = [c.id for c in parents]
|
||||||
replies_q = (
|
replies_q = (
|
||||||
@ -157,7 +162,16 @@ async def list_comments(
|
|||||||
for r in replies:
|
for r in replies:
|
||||||
replies_map[r.parent_id].append(r)
|
replies_map[r.parent_id].append(r)
|
||||||
|
|
||||||
items = _build_comment_items(list(parents), replies_map, current_user_uuid)
|
# 작성자 프로필 이미지는 스냅샷을 저장하지 않고, 응답 시 User 테이블을 조인해 최신값을 조회한다.
|
||||||
|
user_uuids = {c.user_uuid for c in parents} | {r.user_uuid for r in replies}
|
||||||
|
profile_image_map: dict = {}
|
||||||
|
if user_uuids:
|
||||||
|
profile_q = select(User.user_uuid, User.profile_image_url).where(
|
||||||
|
User.user_uuid.in_(user_uuids)
|
||||||
|
)
|
||||||
|
profile_image_map = {uuid: url for uuid, url in (await session.execute(profile_q)).all()}
|
||||||
|
|
||||||
|
items = _build_comment_items(list(parents), replies_map, current_user_uuid, profile_image_map)
|
||||||
|
|
||||||
return PaginatedResponse.create(
|
return PaginatedResponse.create(
|
||||||
items=items,
|
items=items,
|
||||||
|
|||||||
173
app/utils/video_poster.py
Normal file
173
app/utils/video_poster.py
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"""영상 파일에서 SNS 공유용 포스터 이미지를 생성하고 저장합니다."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
|
|
||||||
|
logger = get_logger("video_poster")
|
||||||
|
|
||||||
|
FFMPEG_TIMEOUT_SECONDS = 30.0
|
||||||
|
FFMPEG_CLEANUP_TIMEOUT_SECONDS = 5.0
|
||||||
|
_STDERR_LOG_LIMIT = 500
|
||||||
|
|
||||||
|
|
||||||
|
async def _kill_and_wait(process: asyncio.subprocess.Process) -> None:
|
||||||
|
"""실행 중인 ffmpeg 프로세스를 종료하고 자원을 회수합니다."""
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] ffmpeg 프로세스 종료에 실패했습니다: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
process.wait(),
|
||||||
|
timeout=FFMPEG_CLEANUP_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] 종료한 ffmpeg 프로세스 회수 시간이 초과되었습니다 "
|
||||||
|
"(timeout=%ss)",
|
||||||
|
FFMPEG_CLEANUP_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] ffmpeg 프로세스 회수에 실패했습니다: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_stderr(stderr: bytes) -> str:
|
||||||
|
"""ffmpeg 표준 오류를 로그에 안전한 길이의 문자열로 변환합니다."""
|
||||||
|
return stderr.decode("utf-8", errors="replace").strip()[-_STDERR_LOG_LIMIT:]
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_first_frame(video_path: str | Path) -> bytes | None:
|
||||||
|
"""로컬 영상의 첫 프레임을 JPEG 바이트로 추출하고 실패 시 ``None``을 반환합니다."""
|
||||||
|
process: asyncio.subprocess.Process | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"ffmpeg",
|
||||||
|
"-nostdin",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
str(video_path),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-f",
|
||||||
|
"image2pipe",
|
||||||
|
"-c:v",
|
||||||
|
"mjpeg",
|
||||||
|
"pipe:1",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
process.communicate(),
|
||||||
|
timeout=FFMPEG_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
await _kill_and_wait(process)
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] ffmpeg 첫 프레임 추출 시간이 초과되었습니다 "
|
||||||
|
"(path=%s, timeout=%ss)",
|
||||||
|
video_path,
|
||||||
|
FFMPEG_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] ffmpeg 첫 프레임 추출에 실패했습니다 "
|
||||||
|
"(path=%s, returncode=%s, stderr=%s)",
|
||||||
|
video_path,
|
||||||
|
process.returncode,
|
||||||
|
_format_stderr(stderr),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not stdout:
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] ffmpeg가 빈 이미지를 반환했습니다 (path=%s)",
|
||||||
|
video_path,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if process is not None:
|
||||||
|
await _kill_and_wait(process)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
if process is not None:
|
||||||
|
await _kill_and_wait(process)
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] 첫 프레임 추출 중 오류가 발생했습니다 "
|
||||||
|
"(path=%s, error=%s: %s)",
|
||||||
|
video_path,
|
||||||
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_and_store_poster(
|
||||||
|
*,
|
||||||
|
video_path: str | Path,
|
||||||
|
user_uuid: str,
|
||||||
|
task_id: str,
|
||||||
|
file_stem: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""첫 프레임을 Blob에 저장하고 공개 URL을 반환하며, 실패 시 ``None``을 반환합니다."""
|
||||||
|
try:
|
||||||
|
image_bytes = await extract_first_frame(video_path)
|
||||||
|
if image_bytes is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
|
||||||
|
uploaded = await uploader.upload_image_bytes(
|
||||||
|
image_bytes,
|
||||||
|
f"{file_stem}.jpg",
|
||||||
|
)
|
||||||
|
if not uploaded:
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] 포스터 Blob 업로드에 실패했습니다 "
|
||||||
|
"(path=%s, task_id=%s)",
|
||||||
|
video_path,
|
||||||
|
task_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not uploader.public_url:
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] 포스터 업로드 후 공개 URL이 비어 있습니다 "
|
||||||
|
"(path=%s, task_id=%s)",
|
||||||
|
video_path,
|
||||||
|
task_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return uploader.public_url
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[video_poster] 포스터 생성 또는 저장 중 오류가 발생했습니다 "
|
||||||
|
"(path=%s, task_id=%s, error=%s: %s)",
|
||||||
|
video_path,
|
||||||
|
task_id,
|
||||||
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
@ -987,6 +987,7 @@ async def get_all_videos(
|
|||||||
video_id=v.id,
|
video_id=v.id,
|
||||||
store_name=p.store_name,
|
store_name=p.store_name,
|
||||||
result_movie_url=v.result_movie_url,
|
result_movie_url=v.result_movie_url,
|
||||||
|
poster_url=v.poster_url,
|
||||||
created_at=v.created_at,
|
created_at=v.created_at,
|
||||||
like_count=like_count_map.get(v.id) or 0,
|
like_count=like_count_map.get(v.id) or 0,
|
||||||
is_liked_by_me=liked_map.get(v.id, False),
|
is_liked_by_me=liked_map.get(v.id, False),
|
||||||
@ -1166,6 +1167,7 @@ async def get_video_detail(
|
|||||||
return VideoDetailResponse(
|
return VideoDetailResponse(
|
||||||
video_id=video.id,
|
video_id=video.id,
|
||||||
result_movie_url=video.result_movie_url,
|
result_movie_url=video.result_movie_url,
|
||||||
|
poster_url=video.poster_url,
|
||||||
store_name=project.store_name,
|
store_name=project.store_name,
|
||||||
region=project.region or _extract_region_from_address(project.detail_region_info),
|
region=project.region or _extract_region_from_address(project.detail_region_info),
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
|
|||||||
@ -29,6 +29,7 @@ class Video(Base):
|
|||||||
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
|
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||||
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
||||||
|
poster_url: 영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
@ -106,6 +107,12 @@ class Video(Base):
|
|||||||
comment="생성된 영상 URL",
|
comment="생성된 영상 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
poster_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(2048),
|
||||||
|
nullable=True,
|
||||||
|
comment="영상 첫 프레임 포스터 이미지 URL (SNS 공유용)",
|
||||||
|
)
|
||||||
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
Boolean,
|
Boolean,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@ -148,6 +148,7 @@ class VideoListItem(BaseModel):
|
|||||||
"region": "군산",
|
"region": "군산",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
||||||
|
"poster_url": "http://localhost:8000/media/2025-01-15/video.jpg",
|
||||||
"created_at": "2025-01-15T12:00:00"
|
"created_at": "2025-01-15T12:00:00"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@ -157,6 +158,7 @@ class VideoListItem(BaseModel):
|
|||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||||
|
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
like_count: int = Field(0, description="좋아요 수")
|
like_count: int = Field(0, description="좋아요 수")
|
||||||
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
|
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
|
||||||
@ -171,7 +173,8 @@ class VideoThumbnailItem(BaseModel):
|
|||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
|
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
|
||||||
store_name: str = Field(..., description="업체명")
|
store_name: str = Field(..., description="업체명")
|
||||||
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
|
result_movie_url: str = Field(..., description="영상 URL")
|
||||||
|
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
like_count: int = Field(..., description="좋아요 수")
|
like_count: int = Field(..., description="좋아요 수")
|
||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||||
@ -187,6 +190,7 @@ class VideoDetailResponse(BaseModel):
|
|||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
video_id: int = Field(..., description="영상 고유 ID")
|
||||||
result_movie_url: str = Field(..., description="영상 URL")
|
result_movie_url: str = Field(..., description="영상 URL")
|
||||||
|
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
|
|||||||
@ -4,7 +4,6 @@ Video Background Tasks
|
|||||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import traceback
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@ -17,6 +16,7 @@ from app.user.services.credit import consume_credit
|
|||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.video_poster import generate_and_store_poster
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("video")
|
logger = get_logger("video")
|
||||||
@ -30,6 +30,7 @@ async def _update_video_status(
|
|||||||
status: str,
|
status: str,
|
||||||
video_url: str | None = None,
|
video_url: str | None = None,
|
||||||
creatomate_render_id: str | None = None,
|
creatomate_render_id: str | None = None,
|
||||||
|
poster_url: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Video 테이블의 상태를 업데이트합니다.
|
"""Video 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ async def _update_video_status(
|
|||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
status: 변경할 상태 ("processing", "completed", "failed")
|
||||||
video_url: 영상 URL
|
video_url: 영상 URL
|
||||||
creatomate_render_id: Creatomate render ID (선택)
|
creatomate_render_id: Creatomate render ID (선택)
|
||||||
|
poster_url: 영상 첫 프레임 포스터 URL (선택)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: 업데이트 성공 여부
|
bool: 업데이트 성공 여부
|
||||||
@ -65,6 +67,8 @@ async def _update_video_status(
|
|||||||
video.status = status
|
video.status = status
|
||||||
if video_url is not None:
|
if video_url is not None:
|
||||||
video.result_movie_url = video_url
|
video.result_movie_url = video_url
|
||||||
|
if poster_url is not None:
|
||||||
|
video.poster_url = poster_url
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
||||||
return True
|
return True
|
||||||
@ -80,6 +84,28 @@ async def _update_video_status(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _try_generate_poster(
|
||||||
|
temp_file_path: Path,
|
||||||
|
user_uuid: str,
|
||||||
|
task_id: str,
|
||||||
|
render_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""포스터 생성 실패가 영상 생성 완료 처리에 영향을 주지 않도록 격리합니다."""
|
||||||
|
try:
|
||||||
|
return await generate_and_store_poster(
|
||||||
|
video_path=temp_file_path,
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
task_id=task_id,
|
||||||
|
file_stem=render_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[VideoPoster] Failed to generate poster - task_id: {task_id}, render_id: {render_id}, error: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _download_video(url: str, task_id: str) -> bytes:
|
async def _download_video(url: str, task_id: str) -> bytes:
|
||||||
"""URL에서 영상을 다운로드합니다.
|
"""URL에서 영상을 다운로드합니다.
|
||||||
|
|
||||||
@ -153,8 +179,18 @@ async def download_and_upload_video_to_blob(
|
|||||||
blob_url = uploader.public_url
|
blob_url = uploader.public_url
|
||||||
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||||
|
|
||||||
|
poster_url = await _try_generate_poster(
|
||||||
|
temp_file_path, user_uuid, task_id, creatomate_render_id
|
||||||
|
)
|
||||||
|
|
||||||
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
||||||
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
|
await _update_video_status(
|
||||||
|
task_id,
|
||||||
|
"completed",
|
||||||
|
blob_url,
|
||||||
|
creatomate_render_id,
|
||||||
|
poster_url=poster_url,
|
||||||
|
)
|
||||||
|
|
||||||
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
@ -259,12 +295,17 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||||||
blob_url = uploader.public_url
|
blob_url = uploader.public_url
|
||||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||||
|
|
||||||
|
poster_url = await _try_generate_poster(
|
||||||
|
temp_file_path, user_uuid, task_id, creatomate_render_id
|
||||||
|
)
|
||||||
|
|
||||||
# Video 테이블 업데이트
|
# Video 테이블 업데이트
|
||||||
await _update_video_status(
|
await _update_video_status(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
status="completed",
|
status="completed",
|
||||||
video_url=blob_url,
|
video_url=blob_url,
|
||||||
creatomate_render_id=creatomate_render_id,
|
creatomate_render_id=creatomate_render_id,
|
||||||
|
poster_url=poster_url,
|
||||||
)
|
)
|
||||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration: video 테이블에 poster_url 컬럼 추가
|
||||||
|
-- Date: 2026-08-13
|
||||||
|
-- Description: 영상 첫 프레임 포스터 이미지 URL. SNS 공유 og:image 용도.
|
||||||
|
-- 관련 코드: app/utils/video_poster.py, app/video/worker/video_task.py
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE `video`
|
||||||
|
ADD COLUMN `poster_url` VARCHAR(2048) NULL
|
||||||
|
COMMENT '영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)' AFTER `result_movie_url`;
|
||||||
Loading…
Reference in New Issue
Block a user