feat(video): 영상 첫 프레임 썸네일 생성 및 저장

This commit is contained in:
김성경 2026-08-13 17:23:06 +09:00
parent 4c4297c37a
commit 8e9e12fe0b
11 changed files with 270 additions and 14 deletions

View File

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

View File

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

View File

@ -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자 이내)"

View File

@ -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="작성 일시")

View File

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

View File

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

View File

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

View File

@ -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="생성 일시")

View File

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

View File

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