자막 생성 상태 조회를 위한 엔드포인트 추가 및 비동기 처리

feature-ADO2
김성경 2026-05-20 13:22:52 +09:00
parent 666115ae88
commit 346b461e9c
5 changed files with 194 additions and 61 deletions

View File

@ -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="가사 상세 조회",

View File

@ -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):
"""가사 목록 아이템 스키마

View File

@ -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()
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)
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

View File

@ -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 작업 후 바로 닫음

View File

@ -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="응답 메시지")