diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index 71b689f..dedbcd2 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -40,6 +40,7 @@ from app.lyric.schemas.lyric import ( LyricDetailResponse, LyricListItem, LyricStatusResponse, + SubtitleStatusResponse, ) from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background from app.utils.prompts.chatgpt_prompt import ChatgptService @@ -515,6 +516,86 @@ async def list_lyrics( ) +@router.get( + "/subtitle/status/{task_id}", + summary="자막 생성 상태 조회", + description=""" +자막(subtitle) 생성 완료 여부를 조회합니다. + +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + +## 경로 파라미터 +- **task_id**: 프로젝트 task_id (필수) + +## 상태 값 +- **pending**: 자막 생성 진행 중 — 잠시 후 재요청 +- **completed**: 자막 생성 완료 — `/video/generate/{task_id}` 호출 가능 + +## 사용 예시 (cURL) +```bash +curl -X GET "http://localhost:8000/lyric/subtitle/status/019123ab-cdef-7890-abcd-ef1234567890" \\ + -H "Authorization: Bearer {access_token}" +``` + +## 참고 +- 자막은 `/lyric/generate` 호출 시 백그라운드에서 자동 생성됩니다. +- 클라이언트는 `completed` 상태 확인 후 `/video/generate`를 호출해야 합니다. + """, + response_model=SubtitleStatusResponse, + responses={ + 200: {"description": "상태 조회 성공"}, + 401: {"description": "인증 실패 (토큰 없음/만료)"}, + 404: {"description": "해당 task_id에 해당하는 프로젝트를 찾을 수 없음"}, + }, +) +async def get_subtitle_status( + task_id: str, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> SubtitleStatusResponse: + """task_id로 자막 생성 상태를 조회합니다.""" + logger.info(f"[get_subtitle_status] START - task_id: {task_id}") + + project_result = await session.execute( + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) + ) + project = project_result.scalar_one_or_none() + if not project: + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 프로젝트를 찾을 수 없습니다.", + ) + + marketing_result = await session.execute( + select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) + ) + intel = marketing_result.scalar_one_or_none() + if not intel: + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.", + ) + + if intel.subtitle: + logger.info(f"[get_subtitle_status] completed - task_id: {task_id}") + return SubtitleStatusResponse( + task_id=task_id, + status="completed", + message="자막 생성이 완료되었습니다.", + ) + + logger.info(f"[get_subtitle_status] pending - task_id: {task_id}") + return SubtitleStatusResponse( + task_id=task_id, + status="pending", + message="자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.", + ) + + @router.get( "/{task_id}", summary="가사 상세 조회", diff --git a/app/lyric/schemas/lyric.py b/app/lyric/schemas/lyric.py index ce28214..4201dff 100644 --- a/app/lyric/schemas/lyric.py +++ b/app/lyric/schemas/lyric.py @@ -202,6 +202,46 @@ class LyricDetailResponse(BaseModel): created_at: Optional[datetime] = Field(None, description="생성 일시") +class SubtitleStatusResponse(BaseModel): + """자막 생성 상태 조회 응답 스키마 + + Usage: + GET /subtitle/status/{task_id} + 클라이언트가 subtitle 완료 여부를 polling할 때 사용합니다. + + Status Values: + - pending: 자막 생성 진행 중 (재시도 필요) + - completed: 자막 생성 완료 (/video/generate 호출 가능) + """ + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "summary": "생성 중", + "value": { + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "status": "pending", + "message": "자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.", + }, + }, + { + "summary": "완료", + "value": { + "task_id": "0694b716-dbff-7219-8000-d08cb5fce431", + "status": "completed", + "message": "자막 생성이 완료되었습니다.", + }, + }, + ] + } + ) + + task_id: str = Field(..., description="작업 고유 식별자") + status: Literal["pending", "completed"] = Field(..., description="자막 생성 상태") + message: str = Field(..., description="상태 메시지") + + class LyricListItem(BaseModel): """가사 목록 아이템 스키마 diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index 543f2dc..aa1f015 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -160,50 +160,57 @@ async def generate_subtitle_background( orientation: str, task_id: str ) -> None: - logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}") - creatomate_service = CreatomateService(orientation=orientation) - template = await creatomate_service.get_one_template_data(creatomate_service.template_id) - pitchings = creatomate_service.extract_text_format_from_template(template) + logger.info(f"[generate_subtitle_background] START - task_id: {task_id}, orientation: {orientation}") + try: + creatomate_service = CreatomateService(orientation=orientation) + template = await creatomate_service.get_one_template_data(creatomate_service.template_id) + pitchings = creatomate_service.extract_text_format_from_template(template) - subtitle_generator = SubtitleContentsGenerator() - - async with BackgroundSessionLocal() as session: - project_result = await session.execute( - select(Project) - .where(Project.task_id == task_id) - .order_by(Project.created_at.desc()) - .limit(1) + subtitle_generator = SubtitleContentsGenerator() + + async with BackgroundSessionLocal() as session: + project_result = await session.execute( + select(Project) + .where(Project.task_id == task_id) + .order_by(Project.created_at.desc()) + .limit(1) + ) + project = project_result.scalar_one_or_none() + marketing_result = await session.execute( + select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) + ) + marketing_intelligence = marketing_result.scalar_one_or_none() + + store_address = project.detail_region_info + customer_name = project.store_name + logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}") + + generated_subtitles = await subtitle_generator.generate_subtitle_contents( + marketing_intelligence=marketing_intelligence.intel_result, + pitching_label_list=pitchings, + customer_name=customer_name, + detail_region_info=store_address, ) - project = project_result.scalar_one_or_none() - marketing_result = await session.execute( - select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) + pitching_output_list = generated_subtitles.pitching_results + + subtitle_modifications = { + pitching_output.pitching_tag: pitching_output.pitching_data + for pitching_output in pitching_output_list + } + logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}") + + async with BackgroundSessionLocal() as session: + marketing_result = await session.execute( + select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) + ) + marketing_intelligence = marketing_result.scalar_one_or_none() + marketing_intelligence.subtitle = subtitle_modifications + await session.commit() + + logger.info(f"[generate_subtitle_background] DONE - task_id: {task_id}") + + except Exception as e: + logger.error( + f"[generate_subtitle_background] FAILED - task_id: {task_id}, error: {e}", + exc_info=True, ) - marketing_intelligence = marketing_result.scalar_one_or_none() - - store_address = project.detail_region_info - customer_name = project.store_name - logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}") - - generated_subtitles = await subtitle_generator.generate_subtitle_contents( - marketing_intelligence = marketing_intelligence.intel_result, - pitching_label_list = pitchings, - customer_name = customer_name, - detail_region_info = store_address, - ) - pitching_output_list = generated_subtitles.pitching_results - - subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list} - logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}") - - async with BackgroundSessionLocal() as session: - marketing_result = await session.execute( - select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) - ) - marketing_intelligence = marketing_result.scalar_one_or_none() - marketing_intelligence.subtitle = subtitle_modifications - await session.commit() - logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE") - - - - return diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index d94d814..b75b2ed 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -14,7 +14,6 @@ Video API Router """ import json -import asyncio from typing import Literal @@ -148,8 +147,6 @@ async def generate_video( image_urls: list[str] = [] try: - subtitle_done = False - count = 0 async with AsyncSessionLocal() as session: project_result = await session.execute( select(Project) @@ -159,21 +156,28 @@ async def generate_video( ) project = project_result.scalar_one_or_none() - while not subtitle_done: - async with AsyncSessionLocal() as session: - logger.info(f"[generate_video] Checking subtitle- task_id: {task_id}, count : {count}") - marketing_result = await session.execute( - select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) + if not project: + raise HTTPException( + status_code=404, + detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", ) - marketing_intelligence = marketing_result.scalar_one_or_none() - subtitle_done = bool(marketing_intelligence.subtitle) - if subtitle_done: - logger.info(f"[generate_video] Check subtitle done task_id: {task_id}") - break - await asyncio.sleep(5) - if count > 60 : - raise Exception("subtitle 결과 생성 실패") - count += 1 + + marketing_result = await session.execute( + select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) + ) + marketing_intelligence = marketing_result.scalar_one_or_none() + + # subtitle 미완료 시 즉시 202 반환 — 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도 + if not marketing_intelligence.subtitle: + logger.info(f"[generate_video] subtitle pending - task_id: {task_id}") + return GenerateVideoResponse( + success=False, + status="subtitle_pending", + task_id=task_id, + creatomate_render_id=None, + message="자막 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{task_id}로 완료 확인 후 재요청하세요.", + error_message=None, + ) # 세션을 명시적으로 열고 DB 작업 후 바로 닫음 diff --git a/app/video/schemas/video_schema.py b/app/video/schemas/video_schema.py index 27ecdd8..d63d903 100644 --- a/app/video/schemas/video_schema.py +++ b/app/video/schemas/video_schema.py @@ -36,6 +36,7 @@ class GenerateVideoResponse(BaseModel): ) 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="응답 메시지")