자막 생성 상태 조회를 위한 엔드포인트 추가 및 비동기 처리
parent
666115ae88
commit
346b461e9c
|
|
@ -40,6 +40,7 @@ from app.lyric.schemas.lyric import (
|
||||||
LyricDetailResponse,
|
LyricDetailResponse,
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
|
SubtitleStatusResponse,
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
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(
|
@router.get(
|
||||||
"/{task_id}",
|
"/{task_id}",
|
||||||
summary="가사 상세 조회",
|
summary="가사 상세 조회",
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,46 @@ class LyricDetailResponse(BaseModel):
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
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):
|
class LyricListItem(BaseModel):
|
||||||
"""가사 목록 아이템 스키마
|
"""가사 목록 아이템 스키마
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,8 @@ async def generate_subtitle_background(
|
||||||
orientation: str,
|
orientation: str,
|
||||||
task_id: str
|
task_id: str
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}")
|
logger.info(f"[generate_subtitle_background] START - task_id: {task_id}, orientation: {orientation}")
|
||||||
|
try:
|
||||||
creatomate_service = CreatomateService(orientation=orientation)
|
creatomate_service = CreatomateService(orientation=orientation)
|
||||||
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
||||||
pitchings = creatomate_service.extract_text_format_from_template(template)
|
pitchings = creatomate_service.extract_text_format_from_template(template)
|
||||||
|
|
@ -182,17 +183,20 @@ async def generate_subtitle_background(
|
||||||
|
|
||||||
store_address = project.detail_region_info
|
store_address = project.detail_region_info
|
||||||
customer_name = project.store_name
|
customer_name = project.store_name
|
||||||
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
|
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, store_address: {store_address}")
|
||||||
|
|
||||||
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
||||||
marketing_intelligence = marketing_intelligence.intel_result,
|
marketing_intelligence=marketing_intelligence.intel_result,
|
||||||
pitching_label_list = pitchings,
|
pitching_label_list=pitchings,
|
||||||
customer_name = customer_name,
|
customer_name=customer_name,
|
||||||
detail_region_info = store_address,
|
detail_region_info=store_address,
|
||||||
)
|
)
|
||||||
pitching_output_list = generated_subtitles.pitching_results
|
pitching_output_list = generated_subtitles.pitching_results
|
||||||
|
|
||||||
subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list}
|
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}")
|
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
|
||||||
|
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
|
|
@ -202,8 +206,11 @@ async def generate_subtitle_background(
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
marketing_intelligence.subtitle = subtitle_modifications
|
marketing_intelligence.subtitle = subtitle_modifications
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
|
|
||||||
|
|
||||||
|
logger.info(f"[generate_subtitle_background] DONE - task_id: {task_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
return
|
logger.error(
|
||||||
|
f"[generate_subtitle_background] FAILED - task_id: {task_id}, error: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ Video API Router
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
|
@ -148,8 +147,6 @@ async def generate_video(
|
||||||
image_urls: list[str] = []
|
image_urls: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subtitle_done = False
|
|
||||||
count = 0
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
project_result = await session.execute(
|
project_result = await session.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
|
|
@ -159,21 +156,28 @@ async def generate_video(
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
while not subtitle_done:
|
if not project:
|
||||||
async with AsyncSessionLocal() as session:
|
raise HTTPException(
|
||||||
logger.info(f"[generate_video] Checking subtitle- task_id: {task_id}, count : {count}")
|
status_code=404,
|
||||||
|
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
marketing_result = await session.execute(
|
marketing_result = await session.execute(
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
)
|
)
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
subtitle_done = bool(marketing_intelligence.subtitle)
|
|
||||||
if subtitle_done:
|
# subtitle 미완료 시 즉시 202 반환 — 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
|
||||||
logger.info(f"[generate_video] Check subtitle done task_id: {task_id}")
|
if not marketing_intelligence.subtitle:
|
||||||
break
|
logger.info(f"[generate_video] subtitle pending - task_id: {task_id}")
|
||||||
await asyncio.sleep(5)
|
return GenerateVideoResponse(
|
||||||
if count > 60 :
|
success=False,
|
||||||
raise Exception("subtitle 결과 생성 실패")
|
status="subtitle_pending",
|
||||||
count += 1
|
task_id=task_id,
|
||||||
|
creatomate_render_id=None,
|
||||||
|
message="자막 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{task_id}로 완료 확인 후 재요청하세요.",
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class GenerateVideoResponse(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
success: bool = Field(..., description="요청 성공 여부")
|
||||||
|
status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)")
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
||||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue