diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 25c9ffe..9bfd0ff 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -24,8 +24,7 @@ from app.dependencies.pagination import ( ) from app.home.models import Project from app.lyric.models import Lyric -from app.song.models import Song -from app.song.models import SongTimestamp +from app.song.models import Song, SongTimestamp from app.song.schemas.song_schema import ( DownloadSongResponse, @@ -335,7 +334,7 @@ GET /song/status/abc123... }, ) async def get_song_status( - suno_task_id: str, + song_id: str, background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), ) -> PollingSongResponse: @@ -345,6 +344,7 @@ async def get_song_status( Azure Blob Storage에 업로드한 뒤 Song 테이블의 status를 completed로, song_result_url을 Blob URL로 업데이트합니다. """ + suno_task_id = song_id # 임시방편 / 외부 suno 노출 방지 logger.info(f"[get_song_status] START - song_id: {suno_task_id}") try: suno_service = SunoService() @@ -377,44 +377,6 @@ async def get_song_status( ) song = song_result.scalar_one_or_none() - suno_audio_id = first_clip.id - word_data = await suno_service.get_lyric_timestamp(suno_task_id, suno_audio_id) - lyric_result = await session.execute( - select(Lyric) - .where(Lyric.task_id == song.task_id) - .order_by(Lyric.created_at.desc()) - .limit(1) - ) - lyric = lyric_result.scalar_one_or_none() - gt_lyric = lyric.lyric_result - lyric_line_list = gt_lyric.split("\n") - sentences = [lyric_line.strip(',. ') for lyric_line in lyric_line_list if lyric_line and lyric_line != "---"] - - timestamped_lyrics = await suno_service.align_lyrics(word_data, sentences) - # TODO : DB upload timestamped_lyrics - for order_idx, timestamped_lyric in enumerate(timestamped_lyrics): - song_timestamp = SongTimestamp( - suno_audio_id = suno_audio_id, - order_idx = order_idx, - lyric_line = timestamped_lyric["text"], - start_time = timestamped_lyric["start_sec"], - end_time = timestamped_lyric["end_sec"] - ) - session.add(song_timestamp) - - await session.commit() - - if song and song.status != "completed": - # 첫 번째 클립의 audio_url과 duration을 직접 DB에 저장 - song.status = "completed" - song.song_result_url = audio_url - if clip_duration is not None: - song.duration = clip_duration - await session.commit() - logger.info(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}") - elif song and song.status == "completed": - logger.info(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}") - # processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지) if song and song.status == "processing": # store_name 조회 @@ -440,6 +402,33 @@ async def get_song_status( ) logger.info(f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}") + suno_audio_id = first_clip.get('id') + word_data = await suno_service.get_lyric_timestamp(suno_task_id, suno_audio_id) + lyric_result = await session.execute( + select(Lyric) + .where(Lyric.task_id == song.task_id) + .order_by(Lyric.created_at.desc()) + .limit(1) + ) + lyric = lyric_result.scalar_one_or_none() + gt_lyric = lyric.lyric_result + lyric_line_list = gt_lyric.split("\n") + sentences = [lyric_line.strip(',. ') for lyric_line in lyric_line_list if lyric_line and lyric_line != "---"] + + timestamped_lyrics = suno_service.align_lyrics(word_data, sentences) + # TODO : DB upload timestamped_lyrics + for order_idx, timestamped_lyric in enumerate(timestamped_lyrics): + song_timestamp = SongTimestamp( + suno_audio_id = suno_audio_id, + order_idx = order_idx, + lyric_line = timestamped_lyric["text"], + start_time = timestamped_lyric["start_sec"], + end_time = timestamped_lyric["end_sec"] + ) + session.add(song_timestamp) + + await session.commit() + elif song and song.status == "uploading": logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}") elif song and song.status == "completed": diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 35d65fd..4024892 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -58,6 +58,77 @@ CACHE_TTL_SECONDS = 300 # 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) _shared_client: httpx.AsyncClient | None = None +text_template_v_1 = { + "type": "composition", + "track": 3, + "elements": [ + { + "type": "text", + "time": 0, + "y": "80%", + "width": "100%", + "height": "40%", + "x_alignment": "50%", + "y_alignment": "50%", + "text": "Lorem ipsum dolor sit amet, consectetur ad", + "font_family": "Noto Sans", + "font_weight": "700", + "font_size": "8 vmin", + "background_color": "rgba(216,216,216,0)", + "background_x_padding": "33%", + "background_y_padding": "7%", + "background_border_radius": "28%", + "fill_color": "#ffffff", + "stroke_color": "#333333", + "stroke_width": "1.25 vmin", + } + ] +} +text_template_v_2 = { + "type": "composition", + "track": 3, + "elements": [ + { + "type": "text", + "time": 0, + "x": "7.7233%", + "y": "89.5758%", + "width": "84.5534%", + "height": "5.7015%", + "x_anchor": "0%", + "y_anchor": "0%", + "y_alignment": "100%", + "font_family": "Noto Sans", + "font_weight": "700", + "font_size": "6.9999 vmin", + "fill_color": "#ffffff" + } + ] +} + +text_template_h_1 = { + "type": "composition", + "track": 3, + "elements": [ + { + "type": "text", + "time": 0, + "x": "10%", + "y": "80%", + "width": "80%", + "height": "15%", + "x_anchor": "0%", + "y_anchor": "0%", + "x_alignment": "50%", + "font_family": "Noto Sans", + "font_weight": "700", + "font_size": "5.9998 vmin", + "fill_color": "#ffffff", + "stroke_color": "#333333", + "stroke_width": "0.2 vmin" + } + ] +} async def get_shared_client() -> httpx.AsyncClient: """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" @@ -470,3 +541,22 @@ class CreatomateService: ) return new_template + + def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float) -> dict: + duration = end_sec - start_sec + text_scene = copy.deepcopy(text_template) + text_scene["name"] = f"Caption-{lyric_index}" + text_scene["duration"] = duration + text_scene["time"] = start_sec + text_scene["elements"][0]["name"] = f"lyric-{lyric_index}" + text_scene["elements"][0]["text"] = lyric_text + return text_scene + + + def get_text_template(self): + match self.orientation: + case "vertical": + return text_template_v_1 + case "horizontal": + return text_template_h_1 + diff --git a/app/utils/suno.py b/app/utils/suno.py index 9fc2051..88d2744 100644 --- a/app/utils/suno.py +++ b/app/utils/suno.py @@ -197,19 +197,19 @@ class SunoService: "audio_id" : audio_id } async with httpx.AsyncClient() as client: - response = await client.get( + response = await client.post( f"{self.BASE_URL}/generate/get-timestamped-lyrics", headers=self.headers, - json = payload, + json=payload, timeout=30.0, ) response.raise_for_status() data = response.json() - if data is None: + if not data or not data['data']: raise ValueError("Suno API returned empty response for task status") - return data['alignedWords'] + return data['data']['alignedWords'] def parse_status_response(self, result: dict | None) -> PollingSongResponse: """Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다. @@ -355,12 +355,12 @@ class SunoService: results.append({ 'text': sentence, - 'startS': word_start['startS'], - 'endS': word_end['endS'], + 'start_sec': word_start['startS'], + 'end_sec': word_end['endS'], }) search_pos = found_pos + len(norm_sentence) else: - results.append({'text': sentence, 'startS': None, 'endS': None}) + results.append({'text': sentence, 'start_sec': None, 'end_sec': None}) return results \ No newline at end of file diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 58214d1..f443628 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -27,7 +27,7 @@ from app.dependencies.pagination import ( ) from app.home.models import Image, Project from app.lyric.models import Lyric -from app.song.models import Song +from app.song.models import Song, SongTimestamp from app.video.models import Video from app.video.schemas.video_schema import ( DownloadVideoResponse, @@ -304,6 +304,19 @@ async def generate_video( ) logger.debug(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") + # 이런거 추가해야하는데 AI가 자꾸 번호 달면 제가 번호를 다 밀어야 하나요? + + 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() + + 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 ) + final_template['source']['elements'].append(caption) + # 6-5. 커스텀 렌더링 요청 (비동기) render_response = await creatomate_service.make_creatomate_custom_call_async( final_template["source"],