1209 lines
52 KiB
Python
1209 lines
52 KiB
Python
"""
|
|
Video API Router
|
|
|
|
이 모듈은 Creatomate API를 통한 영상 생성 관련 API 엔드포인트를 정의합니다.
|
|
|
|
엔드포인트 목록:
|
|
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
|
|
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
|
|
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
|
|
|
|
사용 예시:
|
|
from app.video.api.routers.v1.video import router
|
|
app.include_router(router)
|
|
"""
|
|
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.session import get_session
|
|
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
|
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
|
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.lyric.models import Lyric
|
|
from app.song.models import Song, SongTimestamp
|
|
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
|
|
|
|
from app.database.like_cache import (
|
|
backfill_user_set,
|
|
get_like_count,
|
|
is_user_liked,
|
|
is_user_set_exists,
|
|
mark_dirty,
|
|
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,
|
|
LikeToggleResponse,
|
|
PollingVideoResponse,
|
|
VideoDetailResponse,
|
|
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
|
|
|
|
|
|
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"])
|
|
|
|
|
|
|
|
@router.get(
|
|
"/generate/{task_id}",
|
|
summary="영상 생성 요청",
|
|
description="""
|
|
Creatomate API를 통해 영상 생성을 요청합니다.
|
|
|
|
## 인증
|
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
|
|
## 경로 파라미터
|
|
- **task_id**: Project/Lyric/Song/Image의 task_id (필수) - 연관된 프로젝트, 가사, 노래, 이미지를 조회하는 데 사용
|
|
|
|
## 쿼리 파라미터
|
|
- **orientation**: 영상 방향 (horizontal: 가로형, vertical: 세로형, 기본값: vertical) - 선택
|
|
|
|
## 자동 조회 정보
|
|
- **image_urls**: Image 테이블에서 task_id로 조회 (img_order 순서로 정렬)
|
|
- **music_url**: Song 테이블의 song_result_url 사용
|
|
- **duration**: Song 테이블의 duration 사용
|
|
- **lyrics**: Song 테이블의 song_prompt (가사) 사용
|
|
|
|
## 반환 정보
|
|
- **success**: 요청 성공 여부
|
|
- **task_id**: 내부 작업 ID (Project task_id)
|
|
- **creatomate_render_id**: Creatomate 렌더 ID (상태 조회에 사용)
|
|
- **message**: 응답 메시지
|
|
|
|
## 사용 예시 (cURL)
|
|
```bash
|
|
# 세로형 영상 생성 (기본값)
|
|
curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5fce431" \\
|
|
-H "Authorization: Bearer {access_token}"
|
|
|
|
# 가로형 영상 생성
|
|
curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal" \\
|
|
-H "Authorization: Bearer {access_token}"
|
|
```
|
|
|
|
## 참고
|
|
- 이미지는 task_id로 Image 테이블에서 자동 조회됩니다 (img_order 순서).
|
|
- 배경 음악(music_url), 영상 길이(duration), 가사(lyrics)는 task_id로 Song 테이블을 조회하여 자동으로 가져옵니다.
|
|
- 같은 task_id로 여러 Song이 있을 경우 **가장 최근 생성된 노래**를 사용합니다.
|
|
- 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": "영상 생성 요청 실패"},
|
|
},
|
|
)
|
|
async def generate_video(
|
|
task_id: str,
|
|
orientation: Literal["horizontal", "vertical"] = Query(
|
|
default="vertical",
|
|
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
|
),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> GenerateVideoResponse:
|
|
"""Creatomate API를 통해 영상을 생성합니다.
|
|
|
|
1. task_id로 Project, Lyric, Song, Image 순차 조회
|
|
2. Video 테이블에 초기 데이터 저장 (status: processing)
|
|
3. Creatomate API 호출 (orientation에 따른 템플릿 자동 선택)
|
|
4. creatomate_render_id 업데이트 후 응답 반환
|
|
|
|
Note: 이 함수는 Depends(get_session)을 사용하지 않고 명시적으로 세션을 관리합니다.
|
|
외부 API 호출 중 DB 커넥션이 유지되지 않도록 하여 커넥션 타임아웃 문제를 방지합니다.
|
|
|
|
중요: SQLAlchemy AsyncSession은 단일 세션에서 동시에 여러 쿼리를 실행하는 것을
|
|
지원하지 않습니다. asyncio.gather()로 병렬 쿼리를 실행하면 세션 상태 충돌이 발생합니다.
|
|
따라서 쿼리는 순차적으로 실행합니다.
|
|
"""
|
|
import time
|
|
|
|
from app.database.session import AsyncSessionLocal
|
|
|
|
request_start = time.perf_counter()
|
|
logger.info(
|
|
f"[generate_video] START - task_id: {task_id}, orientation: {orientation}"
|
|
)
|
|
|
|
# ==========================================================================
|
|
# 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음)
|
|
# ==========================================================================
|
|
# 외부 API 호출 전에 필요한 데이터를 저장할 변수들
|
|
project_id: int | None = None
|
|
lyric_id: int | None = None
|
|
song_id: int | None = None
|
|
video_id: int | None = None
|
|
music_url: str | None = None
|
|
song_duration: float | None = None
|
|
lyrics: str | None = None
|
|
image_urls: list[str] = []
|
|
|
|
try:
|
|
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
|
async with AsyncSessionLocal() as session:
|
|
# ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image =====
|
|
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
|
|
|
# Project 조회 (본인 소유만).
|
|
# ⚠️ task_id 는 경로 파라미터라 남의 값을 넣을 수 있다. 소유자를 안 거르면
|
|
# 남의 프로젝트로 영상을 만들 수 있고, 더 나쁘게는 크레딧 원장에
|
|
# job_ref=피해자 task_id 로 차감이 기록돼 **피해자의 정상 생성이
|
|
# "이미 차감됨"으로 처리**된다(멱등 키 오염).
|
|
project_result = await session.execute(
|
|
select(Project)
|
|
.where(
|
|
Project.task_id == task_id,
|
|
Project.user_uuid == current_user.user_uuid,
|
|
)
|
|
.order_by(Project.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
project = project_result.scalar_one_or_none()
|
|
if not project:
|
|
logger.warning(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
|
)
|
|
project_id = project.id
|
|
store_address = project.detail_region_info
|
|
brand_name = project.store_name
|
|
region = project.region
|
|
industry = project.industry
|
|
output_language = project.language or "Korean"
|
|
|
|
# MarketingIntel 조회
|
|
marketing_result = await session.execute(
|
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
)
|
|
marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none()
|
|
|
|
# 자막 + 이미지 배정 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지
|
|
# 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
|
|
if not marketing_intelligence.subtitle or not marketing_intelligence.image_match:
|
|
pending_what = []
|
|
if not marketing_intelligence.subtitle:
|
|
pending_what.append("자막")
|
|
if not marketing_intelligence.image_match:
|
|
pending_what.append("이미지 배정")
|
|
pending_msg = ", ".join(pending_what)
|
|
logger.info(f"[generate_video] 사전 준비 미완료 ({pending_msg}) - task_id: {task_id}")
|
|
return GenerateVideoResponse(
|
|
success=False,
|
|
status="subtitle_pending",
|
|
task_id=task_id,
|
|
creatomate_render_id=None,
|
|
message=f"{pending_msg} 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{{task_id}}로 완료 확인 후 재요청하세요.",
|
|
error_message=None,
|
|
)
|
|
|
|
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
|
|
target_keywords = marketing_intelligence.intel_result["target_keywords"]
|
|
|
|
# Lyric 조회
|
|
lyric_result = await session.execute(
|
|
select(Lyric)
|
|
.where(Lyric.task_id == task_id)
|
|
.order_by(Lyric.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
|
|
# Song 조회
|
|
song_result = await session.execute(
|
|
select(Song)
|
|
.where(Song.task_id == task_id)
|
|
.order_by(Song.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
|
|
# Image 조회
|
|
image_result = await session.execute(
|
|
select(Image)
|
|
.where(Image.task_id == task_id)
|
|
.order_by(Image.img_order.asc())
|
|
)
|
|
|
|
query_time = time.perf_counter()
|
|
logger.debug(
|
|
f"[generate_video] Queries completed - task_id: {task_id}, "
|
|
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
|
)
|
|
|
|
# ===== 결과 처리: Lyric =====
|
|
lyric = lyric_result.scalar_one_or_none()
|
|
if not lyric:
|
|
logger.warning(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}")
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
|
)
|
|
lyric_id = lyric.id
|
|
lyric_language = lyric.language
|
|
|
|
# ===== 결과 처리: Song =====
|
|
song = song_result.scalar_one_or_none()
|
|
if not song:
|
|
logger.warning(f"[generate_video] Song NOT FOUND - task_id: {task_id}")
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.",
|
|
)
|
|
|
|
song_id = song.id
|
|
music_url = song.song_result_url
|
|
song_duration = song.duration
|
|
lyrics = song.song_prompt
|
|
|
|
if not music_url:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Song(id={song_id})의 음악 URL이 없습니다.",
|
|
)
|
|
|
|
if not lyrics:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Song(id={song_id})의 가사(song_prompt)가 없습니다.",
|
|
)
|
|
|
|
# ===== 결과 처리: Image =====
|
|
images = image_result.scalars().all()
|
|
if not images:
|
|
logger.warning(f"[generate_video] Image NOT FOUND - task_id: {task_id}")
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.",
|
|
)
|
|
image_urls = [img.img_url for img in images]
|
|
|
|
# SongTimestamp 조회 (외부 API 호출 전 필요한 데이터이므로 1단계에서 수집)
|
|
song_timestamp_result = await session.execute(
|
|
select(SongTimestamp).where(
|
|
SongTimestamp.suno_audio_id == song.suno_audio_id
|
|
)
|
|
)
|
|
song_timestamp_list = song_timestamp_result.scalars().all()
|
|
|
|
logger.info(
|
|
f"[generate_video] Data loaded - task_id: {task_id}, "
|
|
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
|
f"song_id: {song_id}, images: {len(image_urls)}, "
|
|
f"timestamps: {len(song_timestamp_list)}"
|
|
)
|
|
|
|
# ===== Video 테이블에 초기 데이터 저장 + 크레딧 선차감 (단일 트랜잭션) =====
|
|
# 렌더 완료 후가 아니라 "시작 시점"에 차감한다. 사후차감이던 시절에는
|
|
# 차감 전에 동시 요청이 들어오면 크레딧 1개로 영상 여러 개를 만들 수 있었다.
|
|
# Video insert 와 차감을 한 트랜잭션으로 묶어 한 번만 커밋해야
|
|
# "영상 행은 생겼는데 차감은 실패" 같은 반쪽 상태가 생기지 않는다.
|
|
video = Video(
|
|
project_id=project_id,
|
|
lyric_id=lyric_id,
|
|
song_id=song_id,
|
|
task_id=task_id,
|
|
creatomate_render_id=None,
|
|
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()
|
|
logger.info(
|
|
f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, "
|
|
f"stage1_elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
|
)
|
|
# 세션이 여기서 자동으로 닫힘 (async with 블록 종료)
|
|
|
|
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(
|
|
success=False,
|
|
task_id=task_id,
|
|
creatomate_render_id=None,
|
|
message="영상 생성 요청에 실패했습니다.",
|
|
error_message=str(e),
|
|
)
|
|
|
|
# ==========================================================================
|
|
# 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음)
|
|
# ==========================================================================
|
|
stage2_start = time.perf_counter()
|
|
|
|
try:
|
|
logger.info(
|
|
f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}"
|
|
)
|
|
creatomate_service = CreatomateService(
|
|
orientation=orientation,
|
|
industry=industry,
|
|
# 미매핑 업종(general 등)은 project_id % len(VST_LIST)로 템플릿을 분배하므로,
|
|
# 사전 이미지 배정 단계(creative_assets_task)와 동일 템플릿을 받으려면 필수
|
|
project_id=project_id,
|
|
)
|
|
logger.debug(
|
|
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
|
|
)
|
|
|
|
# 6-1. 템플릿 조회 (비동기)
|
|
template = await creatomate_service.get_one_template_data(
|
|
creatomate_service.template_id
|
|
)
|
|
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
|
|
|
|
# 6-2. elements에서 리소스 매핑 생성
|
|
# 이미지 배정은 /lyric/generate 사전 단계에서 이미 수행되어 marketing_intelligence.image_match에 저장됨.
|
|
# 여기서는 사전 산출물을 읽어 음악 URL과 주소만 보완한다.
|
|
modifications: dict = dict(marketing_intelligence.image_match) # 슬롯→image_url 사전 배정 결과
|
|
modifications["audio-music"] = music_url
|
|
# address_input 슬롯은 사전 단계에서도 채워지지만 영상 시점에 재확인
|
|
for key in list(modifications.keys()):
|
|
if "address_input" in key:
|
|
modifications[key] = store_address
|
|
logger.info(f"[generate_video] image_match loaded from DB (slots: {len(modifications)}) - task_id: {task_id}")
|
|
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
|
|
|
|
subtitle_modifications = marketing_intelligence.subtitle
|
|
|
|
modifications.update(subtitle_modifications)
|
|
|
|
# 썸네일 텍스트: 사전 자막 생성(LLM, 다국어) 결과에 thumb-* 슬롯이 포함되면
|
|
# 그대로 사용한다. 과거 파이프라인 산출물(subtitle에 thumb-* 없음)은
|
|
# 기존 팩트값 조립(한국어)으로 폴백.
|
|
thumbnail_fallback = creatomate_service.make_thumbnail_modification(
|
|
brand_name =brand_name,
|
|
region = region,
|
|
category_definition= category_definition,
|
|
target_keywords=target_keywords,
|
|
detail_region_info=store_address)
|
|
|
|
for slot_name, fallback_value in thumbnail_fallback.items():
|
|
if not modifications.get(slot_name):
|
|
modifications[slot_name] = fallback_value
|
|
logger.info(
|
|
f"[generate_video] thumbnail slot fallback(factual) 적용: {slot_name} - task_id: {task_id}"
|
|
)
|
|
|
|
# 6-3. elements 수정
|
|
new_elements = creatomate_service.modify_element(
|
|
template["source"]["elements"],
|
|
modifications,
|
|
)
|
|
template["source"]["elements"] = new_elements
|
|
|
|
logger.debug(f"[generate_video] Elements modified - task_id: {task_id}")
|
|
|
|
|
|
# 6-4. duration 확장
|
|
final_template = creatomate_service.extend_template_duration(
|
|
template,
|
|
song_duration,
|
|
)
|
|
|
|
logger.debug(f"[generate_video] Duration extended - task_id: {task_id}")
|
|
|
|
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}")
|
|
|
|
for i, ts in enumerate(song_timestamp_list):
|
|
logger.debug(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}")
|
|
|
|
# 가사 자막 폰트: CJK/태국어는 글리프 지원 폰트로, 그 외는 Noto Sans
|
|
lyric_font = LANGUAGE_FONT_MAP.get(lyric_language, "Noto Sans")
|
|
|
|
# LYRIC AUTO 결정부
|
|
if (creatomate_settings.LYRIC_SUBTITLE):
|
|
if (creatomate_settings.DEBUG_AUTO_LYRIC):
|
|
auto_text_template = creatomate_service.get_auto_text_template()
|
|
final_template["source"]["elements"].append(creatomate_service.auto_lyric(auto_text_template))
|
|
else :
|
|
text_template = creatomate_service.get_text_template()
|
|
for idx, aligned in enumerate(song_timestamp_list):
|
|
caption = creatomate_service.lining_lyric(
|
|
text_template,
|
|
idx,
|
|
aligned.lyric_line,
|
|
aligned.start_time,
|
|
aligned.end_time,
|
|
lyric_font
|
|
)
|
|
final_template["source"]["elements"].append(caption)
|
|
# END - LYRIC AUTO 결정부
|
|
|
|
# 언어별 폰트 교체: 템플릿 기본 폰트는 한글·라틴 전용이라 CJK/태국어는
|
|
# 지원 폰트로 전체 텍스트(자막·키워드·썸네일·가사 캡션 포함)를 교체한다.
|
|
# 가사 캡션 append 이후에 실행해야 캡션까지 커버된다.
|
|
final_template = creatomate_service.apply_language_font(
|
|
final_template, output_language
|
|
)
|
|
|
|
# logger.debug(
|
|
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
|
# )
|
|
# 6-5. 커스텀 렌더링 요청 (비동기)
|
|
render_response = await creatomate_service.make_creatomate_custom_call(
|
|
final_template["source"],
|
|
)
|
|
|
|
logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}")
|
|
|
|
# 렌더 ID 추출
|
|
if isinstance(render_response, list) and len(render_response) > 0:
|
|
creatomate_render_id = render_response[0].get("id")
|
|
elif isinstance(render_response, dict):
|
|
creatomate_render_id = render_response.get("id")
|
|
else:
|
|
creatomate_render_id = None
|
|
|
|
stage2_time = time.perf_counter()
|
|
logger.info(
|
|
f"[generate_video] Stage 2 DONE - task_id: {task_id}, "
|
|
f"render_id: {creatomate_render_id}, "
|
|
f"stage2_elapsed: {(stage2_time - stage2_start) * 1000:.1f}ms"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}"
|
|
)
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
# 외부 API 실패 시 Video 상태를 failed로 갱신하고, 선차감한 크레딧을 환불한다.
|
|
# 크레딧은 1단계에서 이미 차감됐으므로 여기서 돌려주지 않으면 그대로 소멸된다.
|
|
await _fail_and_refund(
|
|
task_id,
|
|
user_uuid=current_user.user_uuid,
|
|
reason="영상 생성 실패 환불 (Creatomate 요청 오류)",
|
|
)
|
|
return GenerateVideoResponse(
|
|
success=False,
|
|
task_id=task_id,
|
|
creatomate_render_id=None,
|
|
message="영상 생성 요청에 실패했습니다.",
|
|
error_message=str(e),
|
|
)
|
|
|
|
# ==========================================================================
|
|
# 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리)
|
|
# ==========================================================================
|
|
stage3_start = time.perf_counter()
|
|
logger.info(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}")
|
|
try:
|
|
from app.database.session import AsyncSessionLocal
|
|
|
|
async with AsyncSessionLocal() as update_session:
|
|
video_result = await update_session.execute(
|
|
select(Video).where(Video.id == video_id)
|
|
)
|
|
video_to_update = video_result.scalar_one_or_none()
|
|
if video_to_update:
|
|
video_to_update.creatomate_render_id = creatomate_render_id
|
|
await update_session.commit()
|
|
|
|
stage3_time = time.perf_counter()
|
|
total_time = stage3_time - request_start
|
|
logger.debug(
|
|
f"[generate_video] Stage 3 DONE - task_id: {task_id}, "
|
|
f"stage3_elapsed: {(stage3_time - stage3_start) * 1000:.1f}ms"
|
|
)
|
|
logger.info(
|
|
f"[generate_video] SUCCESS - task_id: {task_id}, "
|
|
f"render_id: {creatomate_render_id}, "
|
|
f"total_time: {total_time * 1000:.1f}ms"
|
|
)
|
|
|
|
return GenerateVideoResponse(
|
|
success=True,
|
|
task_id=task_id,
|
|
creatomate_render_id=creatomate_render_id,
|
|
message="영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
|
|
error_message=None,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}"
|
|
)
|
|
return GenerateVideoResponse(
|
|
success=False,
|
|
task_id=task_id,
|
|
creatomate_render_id=creatomate_render_id,
|
|
message="영상 생성은 요청되었으나 DB 업데이트에 실패했습니다.",
|
|
error_message=str(e),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/status/{creatomate_render_id}",
|
|
summary="영상 생성 상태 조회",
|
|
description="""
|
|
Creatomate API를 통해 영상 생성 작업의 상태를 조회합니다.
|
|
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블을 업데이트합니다.
|
|
|
|
## 인증
|
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
|
|
## 경로 파라미터
|
|
- **creatomate_render_id**: 영상 생성 시 반환된 Creatomate 렌더 ID (필수)
|
|
|
|
## 반환 정보
|
|
- **success**: 조회 성공 여부
|
|
- **status**: 작업 상태 (planned, waiting, rendering, succeeded, failed)
|
|
- **message**: 상태 메시지
|
|
- **render_data**: 렌더링 결과 데이터 (완료 시)
|
|
- **raw_response**: Creatomate API 원본 응답
|
|
|
|
## 사용 예시 (cURL)
|
|
```bash
|
|
curl -X GET "http://localhost:8000/video/status/{creatomate_render_id}" \\
|
|
-H "Authorization: Bearer {access_token}"
|
|
```
|
|
|
|
## 상태 값
|
|
- **planned**: 예약됨
|
|
- **waiting**: 대기 중
|
|
- **transcribing**: 트랜스크립션 중
|
|
- **rendering**: 렌더링 중
|
|
- **succeeded**: 성공
|
|
- **failed**: 실패
|
|
|
|
## 참고
|
|
- succeeded 시 백그라운드에서 MP4 다운로드 및 DB 업데이트 진행
|
|
""",
|
|
response_model=PollingVideoResponse,
|
|
responses={
|
|
200: {"description": "상태 조회 성공"},
|
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
500: {"description": "상태 조회 실패"},
|
|
},
|
|
)
|
|
async def get_video_status(
|
|
creatomate_render_id: str,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> PollingVideoResponse:
|
|
|
|
logger.info(
|
|
f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}"
|
|
)
|
|
try:
|
|
creatomate_service = CreatomateService()
|
|
result = await creatomate_service.get_render_status(creatomate_render_id)
|
|
logger.debug(
|
|
f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}"
|
|
)
|
|
|
|
status = result.get("status", "unknown")
|
|
video_url = result.get("url")
|
|
|
|
# 상태별 메시지 설정
|
|
status_messages = {
|
|
"planned": "영상 생성이 예약되었습니다.",
|
|
"waiting": "영상 생성 대기 중입니다.",
|
|
"transcribing": "트랜스크립션 진행 중입니다.",
|
|
"rendering": "영상을 렌더링하고 있습니다.",
|
|
"succeeded": "영상 생성이 완료되었습니다.",
|
|
"failed": "영상 생성에 실패했습니다.",
|
|
}
|
|
message = status_messages.get(status, f"상태: {status}")
|
|
|
|
video_id = None
|
|
|
|
# ⚠️ 소유자 검증이 필수다. creatomate_render_id 는 클라이언트가 보내는 값이라
|
|
# 남의 렌더 ID 로 이 엔드포인트를 부를 수 있다. 소유자를 안 거르면
|
|
# - 실패 분기: 남의 실패 건으로 **호출자에게 환불**이 나가고(크레딧 탈취),
|
|
# 원장 멱등 키(job_ref=피해자 task_id)가 소진돼 **피해자의 정당한 환불이 봉쇄**된다.
|
|
# - 성공 분기: 남의 영상이 **호출자 UUID 경로의 Blob** 으로 업로드된다.
|
|
# video 에는 user_uuid 가 없으므로(소유권은 project 에 있다) Project 를 조인한다.
|
|
async def _load_owned_video() -> Video | None:
|
|
row = (
|
|
await session.execute(
|
|
select(Video)
|
|
.join(Project, Video.project_id == Project.id)
|
|
.where(
|
|
Video.creatomate_render_id == creatomate_render_id,
|
|
Project.user_uuid == current_user.user_uuid,
|
|
)
|
|
.order_by(Video.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
logger.warning(
|
|
"[get_video_status] 소유자 아님 또는 영상 없음 — 후속 처리 생략, "
|
|
f"creatomate_render_id: {creatomate_render_id}, "
|
|
f"user: {current_user.user_uuid}"
|
|
)
|
|
return row
|
|
|
|
# succeeded 상태인 경우 백그라운드 태스크 실행
|
|
if status == "succeeded" and video_url:
|
|
# creatomate_render_id로 Video 조회하여 task_id 가져오기 (본인 것만)
|
|
video = await _load_owned_video()
|
|
|
|
if video and video.status != "completed":
|
|
video_id = video.id
|
|
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
|
|
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
|
logger.info(
|
|
f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, creatomate_render_id: {creatomate_render_id}"
|
|
)
|
|
background_tasks.add_task(
|
|
download_and_upload_video_to_blob,
|
|
task_id=video.task_id,
|
|
video_url=video_url,
|
|
creatomate_render_id=creatomate_render_id,
|
|
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"),
|
|
status=status,
|
|
url=video_url,
|
|
snapshot_url=result.get("snapshot_url"),
|
|
video_id = video_id if video_id else None
|
|
)
|
|
|
|
logger.info(
|
|
f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}"
|
|
)
|
|
return PollingVideoResponse(
|
|
success=True,
|
|
status=status,
|
|
message=message,
|
|
render_data=render_data,
|
|
raw_response=result,
|
|
error_message=None,
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
logger.error(
|
|
f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}\n{traceback.format_exc()}"
|
|
)
|
|
return PollingVideoResponse(
|
|
success=False,
|
|
status="error",
|
|
message="상태 조회에 실패했습니다.",
|
|
render_data=None,
|
|
raw_response=None,
|
|
error_message=f"{type(e).__name__}: {e}",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/download/{task_id}",
|
|
summary="영상 생성 URL 조회",
|
|
description="""
|
|
task_id를 기반으로 Video 테이블의 상태를 polling하고,
|
|
completed인 경우 Project 정보와 영상 URL을 반환합니다.
|
|
|
|
## 인증
|
|
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
|
|
## 경로 파라미터
|
|
- **task_id**: 프로젝트 task_id (필수)
|
|
|
|
## 반환 정보
|
|
- **success**: 조회 성공 여부
|
|
- **status**: 처리 상태 (processing, completed, failed)
|
|
- **message**: 응답 메시지
|
|
- **store_name**: 업체명
|
|
- **region**: 지역명
|
|
- **task_id**: 작업 고유 식별자
|
|
- **result_movie_url**: 영상 결과 URL (completed 시)
|
|
- **created_at**: 생성 일시
|
|
|
|
## 사용 예시 (cURL)
|
|
```bash
|
|
curl -X GET "http://localhost:8000/video/download/019123ab-cdef-7890-abcd-ef1234567890" \\
|
|
-H "Authorization: Bearer {access_token}"
|
|
```
|
|
|
|
## 참고
|
|
- processing 상태인 경우 result_movie_url은 null입니다.
|
|
- completed 상태인 경우 Project 정보와 함께 result_movie_url을 반환합니다.
|
|
""",
|
|
response_model=DownloadVideoResponse,
|
|
responses={
|
|
200: {"description": "조회 성공"},
|
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
404: {"description": "Video를 찾을 수 없음"},
|
|
500: {"description": "조회 실패"},
|
|
},
|
|
)
|
|
async def download_video(
|
|
task_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> DownloadVideoResponse:
|
|
"""task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다."""
|
|
logger.info(f"[download_video] START - task_id: {task_id}")
|
|
try:
|
|
# task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택)
|
|
video_result = await session.execute(
|
|
select(Video)
|
|
.where(Video.task_id == task_id)
|
|
.order_by(Video.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
video = video_result.scalar_one_or_none()
|
|
|
|
if not video:
|
|
logger.warning(f"[download_video] Video NOT FOUND - task_id: {task_id}")
|
|
return DownloadVideoResponse(
|
|
success=False,
|
|
status="not_found",
|
|
message=f"task_id '{task_id}'에 해당하는 Video를 찾을 수 없습니다.",
|
|
error_message="Video not found",
|
|
)
|
|
|
|
logger.debug(
|
|
f"[download_video] Video found - task_id: {task_id}, status: {video.status}"
|
|
)
|
|
|
|
# processing 상태인 경우
|
|
if video.status == "processing":
|
|
logger.debug(f"[download_video] PROCESSING - task_id: {task_id}")
|
|
return DownloadVideoResponse(
|
|
success=True,
|
|
status="processing",
|
|
message="영상 생성이 진행 중입니다.",
|
|
task_id=task_id,
|
|
)
|
|
|
|
# failed 상태인 경우
|
|
if video.status == "failed":
|
|
logger.error(f"[download_video] FAILED - task_id: {task_id}")
|
|
return DownloadVideoResponse(
|
|
success=False,
|
|
status="failed",
|
|
message="영상 생성에 실패했습니다.",
|
|
task_id=task_id,
|
|
error_message="Video generation failed",
|
|
)
|
|
|
|
# completed 상태인 경우 - Project 정보 조회
|
|
project_result = await session.execute(
|
|
select(Project).where(Project.id == video.project_id)
|
|
)
|
|
project = project_result.scalar_one_or_none()
|
|
|
|
logger.info(
|
|
f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}"
|
|
)
|
|
return DownloadVideoResponse(
|
|
success=True,
|
|
status="completed",
|
|
message="영상 다운로드가 완료되었습니다.",
|
|
store_name=project.store_name if project else None,
|
|
region=project.region or _extract_region_from_address(project.detail_region_info) if project else None,
|
|
task_id=task_id,
|
|
result_movie_url=video.result_movie_url,
|
|
created_at=video.created_at,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}")
|
|
return DownloadVideoResponse(
|
|
success=False,
|
|
status="error",
|
|
message="영상 다운로드 조회에 실패했습니다.",
|
|
error_message=str(e),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/all",
|
|
summary="ADO2 콘텐츠 - 전체 사용자 영상 갤러리",
|
|
description="""
|
|
## 개요
|
|
모든 사용자가 생성 완료한 영상을 페이지네이션하여 반환합니다.
|
|
|
|
## 쿼리 파라미터
|
|
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
|
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
|
- **sort_by**: 정렬 기준 (created_at: 최신순, like_count: 좋아요순, comment_count: 댓글순, 기본값: created_at)
|
|
- **order**: 정렬 방향 (desc: 내림차순, asc: 오름차순, 기본값: desc)
|
|
- **store_name**: 업체명 검색 (부분 일치, 값이 있을 때만 전송)
|
|
- **region**: 지역명 검색 (부분 일치, 값이 있을 때만 전송)
|
|
""",
|
|
response_model=PaginatedResponse[VideoThumbnailItem],
|
|
responses={
|
|
200: {"description": "갤러리 조회 성공"},
|
|
500: {"description": "조회 실패"},
|
|
},
|
|
)
|
|
async def get_all_videos(
|
|
current_user: User | None = Depends(get_current_user_optional),
|
|
session: AsyncSession = Depends(get_session),
|
|
pagination: PaginationParams = Depends(get_pagination_params),
|
|
sort_by: str = Query(default="created_at", description="정렬 기준 (created_at, like_count, comment_count)"),
|
|
order: str = Query(default="desc", description="정렬 방향 (desc, asc)"),
|
|
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}"
|
|
)
|
|
|
|
try:
|
|
offset = (pagination.page - 1) * pagination.page_size
|
|
|
|
items, total = await unified_list.fetch_gallery(
|
|
session,
|
|
offset=offset,
|
|
limit=pagination.page_size,
|
|
sort_by=sort_by,
|
|
order=order,
|
|
store_name=store_name,
|
|
region=region,
|
|
user_uuid=current_user.user_uuid if current_user else None,
|
|
)
|
|
|
|
response = PaginatedResponse.create(
|
|
items=[
|
|
VideoThumbnailItem(
|
|
type=it.ctype,
|
|
video_id=it.id,
|
|
store_name=it.store_name,
|
|
result_movie_url=it.movie_url,
|
|
poster_url=it.poster_url,
|
|
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,
|
|
)
|
|
logger.info(f"[get_all_videos] SUCCESS - total: {total}, items: {len(items)}")
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error(f"[get_all_videos] EXCEPTION - error: {e}")
|
|
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
|
|
|
|
|
|
|
|
@router.post(
|
|
"/{video_id}/like",
|
|
summary="영상 좋아요 토글",
|
|
description="""
|
|
## 개요
|
|
영상에 좋아요를 토글합니다. 로그인 필수.
|
|
|
|
- 처음 호출: 좋아요 추가 (is_liked=true)
|
|
- 다시 호출: 좋아요 취소 (is_liked=false)
|
|
""",
|
|
response_model=LikeToggleResponse,
|
|
responses={
|
|
200: {"description": "토글 성공"},
|
|
401: {"description": "인증 실패"},
|
|
404: {"description": "영상을 찾을 수 없음"},
|
|
},
|
|
)
|
|
async def toggle_like(
|
|
video_id: int,
|
|
type: Literal["video", "ssul"] = Query(
|
|
default="video",
|
|
description="콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 반드시 함께 보낼 것",
|
|
),
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> LikeToggleResponse:
|
|
"""영상/썰박스 좋아요를 토글합니다.
|
|
|
|
Write-Behind 패턴:
|
|
1. Redis user-set / count를 즉시 원자적으로 업데이트 (Lua script)
|
|
2. dirty SET에 표시 → 스케줄러가 1분마다 MySQL에 반영
|
|
DB write가 없으므로 고트래픽에서도 응답 지연 없음.
|
|
|
|
두 종류가 같은 테이블(video_reaction)·같은 Redis 로직을 쓰므로 엔드포인트도
|
|
하나다. type 은 ① 존재 확인 대상 ② Redis 키 접두 ③ backfill 컬럼만 가른다.
|
|
기본값이 "video" 라 기존 프론트 호출은 수정 없이 동작한다.
|
|
"""
|
|
logger.info(
|
|
f"[toggle_like] START - type: {type}, id: {video_id}, user: {current_user.user_uuid}"
|
|
)
|
|
|
|
try:
|
|
# 대상 존재 확인 (DB read는 유지 — 404 처리 필수).
|
|
# id 가 종류별 독립 시퀀스라 반대쪽 테이블에 같은 id 가 있어도 잡으면 안 된다.
|
|
if type == "ssul":
|
|
exists_q = select(SsulContent.id).where(
|
|
SsulContent.id == video_id,
|
|
SsulContent.status == "done",
|
|
SsulContent.is_deleted.is_(False),
|
|
)
|
|
# DB backfill 시 반응 행을 찾는 컬럼 — 썰박스 행은 content_id 가 채워져 있다
|
|
target_col = VideoReaction.content_id
|
|
else:
|
|
exists_q = select(Video.id).where(
|
|
Video.id == video_id,
|
|
Video.status == "completed",
|
|
Video.is_deleted.is_(False),
|
|
)
|
|
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, ctype=type)
|
|
if count is None:
|
|
# 카운트와 user-set 모두 없음 → DB에서 전체 복구
|
|
user_uuids = (await session.execute(
|
|
select(VideoReaction.user_uuid).where(target_col == video_id)
|
|
)).scalars().all()
|
|
await backfill_user_set(video_id, list(user_uuids), ctype=type)
|
|
await set_like_count(video_id, len(user_uuids), ctype=type)
|
|
elif count > 0:
|
|
if not await is_user_set_exists(video_id, ctype=type):
|
|
# 카운트는 있지만 user-set이 증발한 경우 (부분 캐시 미스)
|
|
user_uuids = (await session.execute(
|
|
select(VideoReaction.user_uuid).where(target_col == video_id)
|
|
)).scalars().all()
|
|
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, ctype=type
|
|
)
|
|
|
|
# dirty SET에 표시 → 스케줄러가 DB에 반영
|
|
await mark_dirty(video_id, current_user.user_uuid, ctype=type)
|
|
|
|
logger.info(
|
|
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)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[toggle_like] EXCEPTION - video_id: {video_id}, error: {e}")
|
|
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
|
|
|
|
|
|
@router.get(
|
|
"/share/{video_id}",
|
|
response_class=HTMLResponse,
|
|
summary="영상 공유용 Open Graph 페이지",
|
|
description="영상별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.",
|
|
responses={
|
|
200: {"description": "공유 메타데이터 HTML 반환"},
|
|
404: {"description": "공유 가능한 완료 영상을 찾을 수 없음"},
|
|
},
|
|
)
|
|
async def get_video_share_page(
|
|
video_id: int,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> HTMLResponse:
|
|
"""공개 공유 페이지를 반환하고 일반 브라우저는 영상 상세로 이동시킵니다."""
|
|
share_data = await get_video_share_data(session, video_id)
|
|
if share_data is None:
|
|
raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.")
|
|
|
|
share_url = str(request.url).split("?", maxsplit=1)[0]
|
|
html = build_video_share_html(
|
|
share_data,
|
|
share_url=share_url,
|
|
frontend_base_url=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(
|
|
"/{video_id}",
|
|
summary="단일 영상 상세 조회",
|
|
description="""
|
|
## 개요
|
|
video_id에 해당하는 완료된 영상의 상세 정보를 반환합니다.
|
|
|
|
## 경로 파라미터
|
|
- **video_id**: 조회할 영상의 ID (Video.id)
|
|
""",
|
|
response_model=VideoDetailResponse,
|
|
responses={
|
|
200: {"description": "상세 조회 성공"},
|
|
404: {"description": "영상을 찾을 수 없음"},
|
|
500: {"description": "조회 실패"},
|
|
},
|
|
)
|
|
async def get_video_detail(
|
|
video_id: int,
|
|
current_user: User | None = Depends(get_current_user_optional),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> VideoDetailResponse:
|
|
"""video_id에 해당하는 완료된 영상 상세 정보를 반환합니다."""
|
|
logger.info(f"[get_video_detail] START - video_id: {video_id}")
|
|
|
|
try:
|
|
result = await session.execute(
|
|
select(Video, Project)
|
|
.join(Project, Video.project_id == Project.id)
|
|
.where(
|
|
Video.id == video_id,
|
|
Video.status == "completed",
|
|
Video.is_deleted == False, # noqa: E712
|
|
Project.is_deleted == False, # noqa: E712
|
|
)
|
|
)
|
|
row = result.one_or_none()
|
|
|
|
if row is None:
|
|
logger.warning(f"[get_video_detail] NOT FOUND - video_id: {video_id}")
|
|
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
|
|
|
video, project = row
|
|
|
|
# like_count: Redis 조회, 캐시 미스 시 DB backfill
|
|
like_count = await get_like_count(video_id)
|
|
if like_count is None:
|
|
user_uuids = (await session.execute(
|
|
select(VideoReaction.user_uuid)
|
|
.where(VideoReaction.video_id == video_id)
|
|
)).scalars().all()
|
|
like_count = len(user_uuids)
|
|
await backfill_user_set(video_id, list(user_uuids))
|
|
await set_like_count(video_id, like_count)
|
|
|
|
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
|
|
is_liked_by_me = False
|
|
if current_user:
|
|
liked = await is_user_liked(video_id, current_user.user_uuid)
|
|
if liked is None:
|
|
# user-set 없음 → count key로 cold-start 여부 판별
|
|
if like_count > 0:
|
|
user_uuids = (await session.execute(
|
|
select(VideoReaction.user_uuid)
|
|
.where(VideoReaction.video_id == video_id)
|
|
)).scalars().all()
|
|
await backfill_user_set(video_id, list(user_uuids))
|
|
liked = current_user.user_uuid in set(user_uuids)
|
|
else:
|
|
liked = False
|
|
is_liked_by_me = liked
|
|
|
|
logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
|
|
return VideoDetailResponse(
|
|
video_id=video.id,
|
|
result_movie_url=video.result_movie_url,
|
|
poster_url=video.poster_url,
|
|
store_name=project.store_name,
|
|
region=project.region or _extract_region_from_address(project.detail_region_info),
|
|
created_at=video.created_at,
|
|
like_count=like_count,
|
|
is_liked_by_me=is_liked_by_me,
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[get_video_detail] EXCEPTION - video_id: {video_id}, error: {e}")
|
|
raise HTTPException(status_code=500, detail=f"영상 조회에 실패했습니다: {str(e)}")
|