From 5fcb964d7f1f01ee1667306a04961bbda8d762d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Thu, 20 Aug 2026 10:34:12 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B3=B5=EC=9C=A0=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ssulbox/api/routers/v1/content.py | 50 +- app/video/api/routers/v1/video.py | 2427 +++++++++++++------------ app/video/schemas/video_schema.py | 480 ++--- app/video/services/share_page.py | 155 +- 4 files changed, 1648 insertions(+), 1464 deletions(-) diff --git a/app/ssulbox/api/routers/v1/content.py b/app/ssulbox/api/routers/v1/content.py index b4ca710..1d8b92f 100644 --- a/app/ssulbox/api/routers/v1/content.py +++ b/app/ssulbox/api/routers/v1/content.py @@ -5,7 +5,8 @@ castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 ` 인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기). """ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse from sqlalchemy import func, select from app.credit.exceptions import InsufficientCreditError @@ -28,8 +29,13 @@ from app.user.dependencies.auth import get_current_user, get_current_user_option from app.user.models import User # 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐) from app.video.models import VideoReaction +from app.video.services.share_page import ( + build_ssul_share_html, + get_ssul_share_data, + resolve_frontend_base_url, +) from app.utils.logger import get_logger -from config import ssulbox_settings +from config import prj_settings, ssulbox_settings from sqlalchemy.ext.asyncio import AsyncSession logger = get_logger("ssulbox") @@ -179,6 +185,46 @@ async def get_task( return SsulTaskStatus.model_validate(row) +@router.get( + "/share/{content_id}", + response_class=HTMLResponse, + summary="썰박스 공유용 Open Graph 페이지", + description="콘텐츠별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.", + responses={ + 200: {"description": "공유 메타데이터 HTML 반환"}, + 404: {"description": "공유 가능한 완료 콘텐츠를 찾을 수 없음"}, + }, +) +async def get_ssul_share_page( + content_id: int, + request: Request, + session: AsyncSession = Depends(get_session), +) -> HTMLResponse: + """공개 공유 페이지를 반환하고 일반 브라우저는 썰 상세로 이동시킵니다.""" + share_data = await get_ssul_share_data(session, content_id) + if share_data is None: + raise HTTPException(status_code=404, detail="공유 가능한 콘텐츠를 찾을 수 없습니다.") + + share_url = str(request.url).split("?", maxsplit=1)[0] + html = build_ssul_share_html( + share_data, + share_url=share_url, + frontend_base_url=resolve_frontend_base_url( + request.headers, + prj_settings.SHARE_FRONTEND_URL, + ), + configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL, + ) + return HTMLResponse( + content=html, + headers={ + "Cache-Control": "public, max-age=300", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }, + ) + + @router.get( "/{content_id}", response_model=SsulDetailResponse, diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index cee90af..64e463b 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -1,1208 +1,1219 @@ -""" -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)}") +""" +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, + resolve_frontend_base_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"]) + + + +@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, + 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 = str(request.url).split("?", maxsplit=1)[0] + 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 + + 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), + title=video.title, + description=video.description, + 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)}") diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index d6114e1..f248e38 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -1,238 +1,242 @@ -""" -Video API Schemas - -영상 생성 관련 Pydantic 스키마를 정의합니다. -""" - -from datetime import datetime -from typing import Any, Dict, List, Literal, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -# ============================================================================= -# Response Schemas -# ============================================================================= - - -class GenerateVideoResponse(BaseModel): - """영상 생성 응답 스키마 - - Usage: - GET /video/generate/{task_id} - Returns the task IDs for tracking video generation. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "success": True, - "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", - "creatomate_render_id": "render-id-123456", - "message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.", - "error_message": None, - } - } - ) - - success: bool = Field(..., description="요청 성공 여부") - status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)") - task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)") - creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID") - message: str = Field(..., description="응답 메시지") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -class VideoRenderData(BaseModel): - """Creatomate 렌더링 결과 데이터""" - - id: Optional[str] = Field(None, description="렌더 ID") - status: Optional[str] = Field(None, description="렌더 상태") - url: Optional[str] = Field(None, description="영상 URL") - snapshot_url: Optional[str] = Field(None, description="스냅샷 URL") - video_id: Optional[int] = Field(None, description="Video id(DB)") - - -class PollingVideoResponse(BaseModel): - """영상 생성 상태 조회 응답 스키마 - - Usage: - GET /video/status/{creatomate_render_id} - Creatomate API 작업 상태를 조회합니다. - - Note: - 상태 값: - - planned: 예약됨 - - waiting: 대기 중 - - transcribing: 트랜스크립션 중 - - rendering: 렌더링 중 - - succeeded: 성공 - - failed: 실패 - - Example Response (Success): - { - "success": true, - "status": "succeeded", - "message": "영상 생성이 완료되었습니다.", - "render_data": { - "id": "render-id", - "status": "succeeded", - "url": "https://...", - "snapshot_url": "https://..." - }, - "raw_response": {...}, - "error_message": null - } - """ - - success: bool = Field(..., description="조회 성공 여부") - status: Optional[str] = Field( - None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)" - ) - message: str = Field(..., description="상태 메시지") - render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터") - raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -class DownloadVideoResponse(BaseModel): - """영상 다운로드 응답 스키마 - - Usage: - GET /video/download/{task_id} - Polls for video completion and returns project info with video URL. - - Note: - 상태 값: - - processing: 영상 생성 진행 중 (result_movie_url은 null) - - completed: 영상 생성 완료 (result_movie_url 포함) - - failed: 영상 생성 실패 - - not_found: task_id에 해당하는 Video 없음 - - error: 조회 중 오류 발생 - - Example Response (Completed): - { - "success": true, - "status": "completed", - "message": "영상 다운로드가 완료되었습니다.", - "store_name": "스테이 머뭄", - "region": "군산", - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", - "created_at": "2025-01-15T12:00:00", - "error_message": null - } - """ - - success: bool = Field(..., description="다운로드 성공 여부") - status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)") - message: str = Field(..., description="응답 메시지") - store_name: Optional[str] = Field(None, description="업체명") - region: Optional[str] = Field(None, description="지역명") - task_id: Optional[str] = Field(None, description="작업 고유 식별자") - result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") - created_at: Optional[datetime] = Field(None, description="생성 일시") - error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") - - -class VideoListItem(BaseModel): - """영상 목록 아이템 스키마 - - Usage: - GET /videos 응답의 개별 영상 정보 - - Example: - { - "video_id": 1, - "store_name": "스테이 머뭄", - "region": "군산", - "task_id": "019123ab-cdef-7890-abcd-ef1234567890", - "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" - } - """ - - # ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는 - # 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두 - # **`(type, video_id)` 쌍**으로 다뤄야 한다. - # 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로, - # 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.** - type: Literal["video", "ssul"] = Field( - default="video", - description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)", - ) - video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)") - store_name: Optional[str] = Field(None, description="업체명") - region: Optional[str] = Field(None, description="지역명") - task_id: str = Field( - default="", - description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)", - ) - result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") - poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL") - title: Optional[str] = Field(None, description="SNS 업로드 제목") - description: Optional[str] = Field(None, description="SNS 업로드 설명") - hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록") - created_at: Optional[datetime] = Field(None, description="생성 일시") - like_count: int = Field(0, description="좋아요 수") - comment_count: int = Field(0, description="댓글 수 (대댓글 포함)") - is_liked_by_me: bool = Field( - False, - description="현재 로그인 사용자가 좋아요를 눌렀는지", - ) - - -class VideoThumbnailItem(BaseModel): - """ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용) - - Usage: - GET /video/all 응답의 개별 영상 정보 - """ - - # ⚠️ `video_id` 는 종류 안에서만 유일하다. `video.id` 와 `ssul_content.id` 가 - # **둘 다 1부터 시작**하므로 식별자는 반드시 `(type, video_id)` 쌍으로 다뤄야 한다. - # 한 곳이라도 id 만 쓰면 다른 종류의 콘텐츠가 열린다. - type: Literal["video", "ssul"] = Field( - default="video", - description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스). video_id 와 쌍으로 식별한다", - ) - video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)") - store_name: str = Field(..., description="업체명") - result_movie_url: str = Field(..., description="영상 URL") - poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)") - created_at: datetime = Field(..., description="생성 일시") - like_count: int = Field(..., description="좋아요 수") - is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") - comment_count: int = Field(..., description="댓글 수 (대댓글 포함)") - - -class VideoDetailResponse(BaseModel): - """단일 영상 상세 응답 - - Usage: - GET /video/{video_id} - """ - - video_id: int = Field(..., description="영상 고유 ID") - result_movie_url: str = Field(..., description="영상 URL") - poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL") - store_name: Optional[str] = Field(None, description="업체명") - region: Optional[str] = Field(None, description="지역명") - created_at: datetime = Field(..., description="생성 일시") - like_count: int = Field(..., description="좋아요 수") - is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") - - -class LikeToggleResponse(BaseModel): - """좋아요 토글 응답 - - Usage: - POST /video/{video_id}/like - """ - - video_id: int = Field(..., description="영상 고유 ID") - is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)") - like_count: int = Field(..., description="토글 후 전체 좋아요 수") - - +""" +Video API Schemas + +영상 생성 관련 Pydantic 스키마를 정의합니다. +""" + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================= +# Response Schemas +# ============================================================================= + + +class GenerateVideoResponse(BaseModel): + """영상 생성 응답 스키마 + + Usage: + GET /video/generate/{task_id} + Returns the task IDs for tracking video generation. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "success": True, + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "creatomate_render_id": "render-id-123456", + "message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.", + "error_message": None, + } + } + ) + + success: bool = Field(..., description="요청 성공 여부") + status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)") + task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)") + creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID") + message: str = Field(..., description="응답 메시지") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class VideoRenderData(BaseModel): + """Creatomate 렌더링 결과 데이터""" + + id: Optional[str] = Field(None, description="렌더 ID") + status: Optional[str] = Field(None, description="렌더 상태") + url: Optional[str] = Field(None, description="영상 URL") + snapshot_url: Optional[str] = Field(None, description="스냅샷 URL") + video_id: Optional[int] = Field(None, description="Video id(DB)") + + +class PollingVideoResponse(BaseModel): + """영상 생성 상태 조회 응답 스키마 + + Usage: + GET /video/status/{creatomate_render_id} + Creatomate API 작업 상태를 조회합니다. + + Note: + 상태 값: + - planned: 예약됨 + - waiting: 대기 중 + - transcribing: 트랜스크립션 중 + - rendering: 렌더링 중 + - succeeded: 성공 + - failed: 실패 + + Example Response (Success): + { + "success": true, + "status": "succeeded", + "message": "영상 생성이 완료되었습니다.", + "render_data": { + "id": "render-id", + "status": "succeeded", + "url": "https://...", + "snapshot_url": "https://..." + }, + "raw_response": {...}, + "error_message": null + } + """ + + success: bool = Field(..., description="조회 성공 여부") + status: Optional[str] = Field( + None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)" + ) + message: str = Field(..., description="상태 메시지") + render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터") + raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class DownloadVideoResponse(BaseModel): + """영상 다운로드 응답 스키마 + + Usage: + GET /video/download/{task_id} + Polls for video completion and returns project info with video URL. + + Note: + 상태 값: + - processing: 영상 생성 진행 중 (result_movie_url은 null) + - completed: 영상 생성 완료 (result_movie_url 포함) + - failed: 영상 생성 실패 + - not_found: task_id에 해당하는 Video 없음 + - error: 조회 중 오류 발생 + + Example Response (Completed): + { + "success": true, + "status": "completed", + "message": "영상 다운로드가 완료되었습니다.", + "store_name": "스테이 머뭄", + "region": "군산", + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", + "created_at": "2025-01-15T12:00:00", + "error_message": null + } + """ + + success: bool = Field(..., description="다운로드 성공 여부") + status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)") + message: str = Field(..., description="응답 메시지") + store_name: Optional[str] = Field(None, description="업체명") + region: Optional[str] = Field(None, description="지역명") + task_id: Optional[str] = Field(None, description="작업 고유 식별자") + result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") + created_at: Optional[datetime] = Field(None, description="생성 일시") + error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") + + +class VideoListItem(BaseModel): + """영상 목록 아이템 스키마 + + Usage: + GET /videos 응답의 개별 영상 정보 + + Example: + { + "video_id": 1, + "store_name": "스테이 머뭄", + "region": "군산", + "task_id": "019123ab-cdef-7890-abcd-ef1234567890", + "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" + } + """ + + # ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는 + # 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두 + # **`(type, video_id)` 쌍**으로 다뤄야 한다. + # 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로, + # 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.** + type: Literal["video", "ssul"] = Field( + default="video", + description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)", + ) + video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)") + store_name: Optional[str] = Field(None, description="업체명") + region: Optional[str] = Field(None, description="지역명") + task_id: str = Field( + default="", + description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)", + ) + result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") + poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL") + title: Optional[str] = Field(None, description="SNS 업로드 제목") + description: Optional[str] = Field(None, description="SNS 업로드 설명") + hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록") + created_at: Optional[datetime] = Field(None, description="생성 일시") + like_count: int = Field(0, description="좋아요 수") + comment_count: int = Field(0, description="댓글 수 (대댓글 포함)") + is_liked_by_me: bool = Field( + False, + description="현재 로그인 사용자가 좋아요를 눌렀는지", + ) + + +class VideoThumbnailItem(BaseModel): + """ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용) + + Usage: + GET /video/all 응답의 개별 영상 정보 + """ + + # ⚠️ `video_id` 는 종류 안에서만 유일하다. `video.id` 와 `ssul_content.id` 가 + # **둘 다 1부터 시작**하므로 식별자는 반드시 `(type, video_id)` 쌍으로 다뤄야 한다. + # 한 곳이라도 id 만 쓰면 다른 종류의 콘텐츠가 열린다. + type: Literal["video", "ssul"] = Field( + default="video", + description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스). video_id 와 쌍으로 식별한다", + ) + video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)") + store_name: str = Field(..., description="업체명") + result_movie_url: str = Field(..., description="영상 URL") + poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)") + title: Optional[str] = Field(None, description="SNS 업로드 제목") + description: Optional[str] = Field(None, description="SNS 업로드 설명") + created_at: datetime = Field(..., description="생성 일시") + like_count: int = Field(..., description="좋아요 수") + is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") + comment_count: int = Field(..., description="댓글 수 (대댓글 포함)") + + +class VideoDetailResponse(BaseModel): + """단일 영상 상세 응답 + + Usage: + GET /video/{video_id} + """ + + video_id: int = Field(..., description="영상 고유 ID") + result_movie_url: str = Field(..., description="영상 URL") + poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL") + store_name: Optional[str] = Field(None, description="업체명") + region: Optional[str] = Field(None, description="지역명") + title: Optional[str] = Field(None, description="SNS 업로드 제목") + description: Optional[str] = Field(None, description="SNS 업로드 설명") + created_at: datetime = Field(..., description="생성 일시") + like_count: int = Field(..., description="좋아요 수") + is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") + + +class LikeToggleResponse(BaseModel): + """좋아요 토글 응답 + + Usage: + POST /video/{video_id}/like + """ + + video_id: int = Field(..., description="영상 고유 ID") + is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)") + like_count: int = Field(..., description="토글 후 전체 좋아요 수") + + diff --git a/app/video/services/share_page.py b/app/video/services/share_page.py index 2459b19..fc2ba4c 100644 --- a/app/video/services/share_page.py +++ b/app/video/services/share_page.py @@ -1,5 +1,6 @@ -"""영상 공유 링크용 Open Graph HTML 생성 서비스.""" +"""콘텐츠 공유 링크용 Open Graph HTML 생성 서비스.""" +from collections.abc import Mapping from dataclasses import dataclass from html import escape from urllib.parse import urlsplit, urlunsplit @@ -8,12 +9,14 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.home.models import Project +from app.ssulbox.models import SsulContent from app.video.models import Video FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai" DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png" DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png" + @dataclass(frozen=True, slots=True) class VideoShareData: """공유 페이지에 필요한 영상 및 프로젝트 정보.""" @@ -22,6 +25,20 @@ class VideoShareData: poster_url: str | None store_name: str region: str + title: str | None = None + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class SsulShareData: + """공유 페이지에 필요한 썰박스 콘텐츠 정보.""" + + content_id: int + poster_url: str | None + store_name: str + region: str + title: str | None = None + description: str | None = None async def get_video_share_data( @@ -33,6 +50,8 @@ async def get_video_share_data( select( Video.id, Video.poster_url, + Video.title, + Video.description, Project.store_name, Project.region, ) @@ -53,6 +72,43 @@ async def get_video_share_data( poster_url=row.poster_url, store_name=row.store_name, region=row.region, + title=row.title, + description=row.description, + ) + + +async def get_ssul_share_data( + session: AsyncSession, + content_id: int, +) -> SsulShareData | None: + """공유 가능한 완료 썰박스 콘텐츠를 조회합니다.""" + result = await session.execute( + select( + SsulContent.id, + SsulContent.poster_url, + SsulContent.title, + SsulContent.description, + SsulContent.store_name, + SsulContent.region, + ).where( + SsulContent.id == content_id, + SsulContent.status == "done", + SsulContent.is_deleted.is_(False), + SsulContent.video_url.is_not(None), + SsulContent.video_url != "", + ) + ) + row = result.one_or_none() + if row is None: + return None + + return SsulShareData( + content_id=row.id, + poster_url=row.poster_url, + store_name=row.store_name, + region=row.region or "", + title=row.title, + description=row.description, ) @@ -64,28 +120,61 @@ def build_video_share_html( configured_default_image_url: str = "", ) -> str: """영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다.""" + return _build_share_html( + detail_path=f"/video/{data.video_id}", + poster_url=data.poster_url, + title=_share_title(data.title, data.store_name, fallback_store="ADO2 영상"), + description=_share_description(data.description), + share_url=share_url, + frontend_base_url=frontend_base_url, + configured_default_image_url=configured_default_image_url, + ) + + +def build_ssul_share_html( + data: SsulShareData, + *, + share_url: str, + frontend_base_url: str, + configured_default_image_url: str = "", +) -> str: + """썰박스 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다.""" + return _build_share_html( + detail_path=f"/ssul/{data.content_id}", + poster_url=data.poster_url, + title=_share_title(data.title, data.store_name, fallback_store="ADO2 썰"), + description=_share_description(data.description), + share_url=share_url, + frontend_base_url=frontend_base_url, + configured_default_image_url=configured_default_image_url, + ) + + +def _build_share_html( + *, + detail_path: str, + poster_url: str | None, + title: str, + description: str, + share_url: str, + frontend_base_url: str, + configured_default_image_url: str, +) -> str: + """크롤러용 OG 메타와, 사람용 프론트 상세 이동 링크를 포함한 HTML을 만듭니다.""" frontend_base = _normalise_frontend_base_url(frontend_base_url) - detail_url = f"{frontend_base}/video/{data.video_id}" + detail_url = f"{frontend_base}{detail_path}" fallback_image_url = _resolve_default_image_url( configured_default_image_url, frontend_base, share_url=share_url, ) - image_url = _absolute_http_url(data.poster_url) or fallback_image_url - - store_name = _normalise_text(data.store_name, "ADO2 영상") - region = _normalise_text(data.region, "") - title = f"{store_name} | ADO2" - description = ( - f"{region} · {store_name} 어떤 내용이 들어가야할지 정해야 합니다. 기존 영상 업로드시에 생성되는 내용을 사용하려면 db에 저장하고 업로드 할때마다 변경되는 부분을 전부 수정해야 합니다." - if region - else "ADO2 AI 마케팅 영상" - ) + image_url = _absolute_http_url(poster_url) or fallback_image_url + canonical_url = _absolute_http_url(share_url) or detail_url escaped_title = escape(title, quote=True) escaped_description = escape(description, quote=True) escaped_image_url = escape(image_url, quote=True) - escaped_share_url = escape(_absolute_http_url(share_url) or detail_url, quote=True) + escaped_canonical_url = escape(canonical_url, quote=True) escaped_detail_url = escape(detail_url, quote=True) return f""" @@ -95,13 +184,13 @@ def build_video_share_html( {escaped_title} - + - + @@ -114,7 +203,7 @@ def build_video_share_html(

{escaped_title}

{escaped_description}

- 영상 보기 + 콘텐츠 보기