o2o-castad-backend/app/video/api/routers/v1/video.py

1278 lines
53 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.utils.upload_blob_as_request import to_playback_url
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,
resolve_frontend_base_url,
resolve_share_url,
)
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"])
def _place_id_to_site_url(place_id: str | None) -> str | None:
"""MarketingIntel.place_id("nv{네이버 place ID}")를 네이버 플레이스 URL로 변환한다.
크롤링 없이 직접 입력된 업체는 place_id가 없으므로 None을 반환한다.
"""
if place_id and place_id.startswith("nv") and place_id[2:].isdigit():
return f"https://map.naver.com/p/entry/place/{place_id[2:]}"
return None
async def _get_official_site_urls(
session: AsyncSession, projects: list[Project]
) -> dict[int, str | None]:
"""프로젝트 목록에 대해 {project_id: 공식 페이지 URL(or None)}을 일괄 조회한다.
Project.marketing_intelligence(문자열로 저장된 MarketingIntel.id)를 경유해
저장된 official_site_url을 우선 사용하고, 컬럼 도입 전 기존 행은
place_id 기반 네이버 플레이스 URL로 폴백한다.
"""
m_id_by_project: dict[int, int] = {}
for p in projects:
try:
if p.marketing_intelligence is not None:
m_id_by_project[p.id] = int(p.marketing_intelligence)
except (TypeError, ValueError):
continue
url_by_project: dict[int, str | None] = {p.id: None for p in projects}
if not m_id_by_project:
return url_by_project
rows = (
await session.execute(
select(
MarketingIntel.id,
MarketingIntel.place_id,
MarketingIntel.official_site_url,
).where(MarketingIntel.id.in_(set(m_id_by_project.values())))
)
).all()
intel_by_m_id = {m_id: (place_id, site_url) for m_id, place_id, site_url in rows}
for project_id, m_id in m_id_by_project.items():
place_id, site_url = intel_by_m_id.get(m_id, (None, None))
url_by_project[project_id] = site_url or _place_id_to_site_url(place_id)
return url_by_project
@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=to_playback_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=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,
)
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 = resolve_share_url(
request.headers,
str(request.url).split("?", maxsplit=1)[0],
prj_settings.SHARE_API_BASE_URL,
)
html = build_video_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(
"/{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
official_site_url_map = await _get_official_site_urls(session, [project])
logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
return VideoDetailResponse(
video_id=video.id,
result_movie_url=to_playback_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),
title=video.title,
description=video.description,
created_at=video.created_at,
like_count=like_count,
is_liked_by_me=is_liked_by_me,
official_site_url=official_site_url_map.get(project.id),
)
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)}")