creatomate 가사 정합

insta
jaehwang 2026-01-21 04:18:14 +00:00
parent 94be8a0746
commit 198513f237
4 changed files with 141 additions and 49 deletions

View File

@ -24,8 +24,7 @@ from app.dependencies.pagination import (
) )
from app.home.models import Project from app.home.models import Project
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song from app.song.models import Song, SongTimestamp
from app.song.models import SongTimestamp
from app.song.schemas.song_schema import ( from app.song.schemas.song_schema import (
DownloadSongResponse, DownloadSongResponse,
@ -335,7 +334,7 @@ GET /song/status/abc123...
}, },
) )
async def get_song_status( async def get_song_status(
suno_task_id: str, song_id: str,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> PollingSongResponse: ) -> PollingSongResponse:
@ -345,6 +344,7 @@ async def get_song_status(
Azure Blob Storage에 업로드한 Song 테이블의 status를 completed로, Azure Blob Storage에 업로드한 Song 테이블의 status를 completed로,
song_result_url을 Blob URL로 업데이트합니다. song_result_url을 Blob URL로 업데이트합니다.
""" """
suno_task_id = song_id # 임시방편 / 외부 suno 노출 방지
logger.info(f"[get_song_status] START - song_id: {suno_task_id}") logger.info(f"[get_song_status] START - song_id: {suno_task_id}")
try: try:
suno_service = SunoService() suno_service = SunoService()
@ -377,44 +377,6 @@ async def get_song_status(
) )
song = song_result.scalar_one_or_none() 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 상태인 경우에만 백그라운드 태스크 실행 (중복 방지) # processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
if song and song.status == "processing": if song and song.status == "processing":
# store_name 조회 # 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}") 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": elif song and song.status == "uploading":
logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}") logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}")
elif song and song.status == "completed": elif song and song.status == "completed":

View File

@ -58,6 +58,77 @@ CACHE_TTL_SECONDS = 300
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) # 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
_shared_client: httpx.AsyncClient | None = None _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: async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
@ -470,3 +541,22 @@ class CreatomateService:
) )
return new_template 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

View File

@ -197,19 +197,19 @@ class SunoService:
"audio_id" : audio_id "audio_id" : audio_id
} }
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get( response = await client.post(
f"{self.BASE_URL}/generate/get-timestamped-lyrics", f"{self.BASE_URL}/generate/get-timestamped-lyrics",
headers=self.headers, headers=self.headers,
json = payload, json=payload,
timeout=30.0, timeout=30.0,
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if data is None: if not data or not data['data']:
raise ValueError("Suno API returned empty response for task status") 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: def parse_status_response(self, result: dict | None) -> PollingSongResponse:
"""Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다. """Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다.
@ -355,12 +355,12 @@ class SunoService:
results.append({ results.append({
'text': sentence, 'text': sentence,
'startS': word_start['startS'], 'start_sec': word_start['startS'],
'endS': word_end['endS'], 'end_sec': word_end['endS'],
}) })
search_pos = found_pos + len(norm_sentence) search_pos = found_pos + len(norm_sentence)
else: else:
results.append({'text': sentence, 'startS': None, 'endS': None}) results.append({'text': sentence, 'start_sec': None, 'end_sec': None})
return results return results

View File

@ -27,7 +27,7 @@ from app.dependencies.pagination import (
) )
from app.home.models import Image, Project from app.home.models import Image, Project
from app.lyric.models import Lyric 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.models import Video
from app.video.schemas.video_schema import ( from app.video.schemas.video_schema import (
DownloadVideoResponse, 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}") 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. 커스텀 렌더링 요청 (비동기) # 6-5. 커스텀 렌더링 요청 (비동기)
render_response = await creatomate_service.make_creatomate_custom_call_async( render_response = await creatomate_service.make_creatomate_custom_call_async(
final_template["source"], final_template["source"],