feat(music-lab): Phase 3 백엔드 — 업로드커버, 업로드확장, 보컬추가, 인스트추가, 뮤직비디오
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -757,3 +757,280 @@ def generate_style_boost(content: str) -> Optional[dict]:
|
||||
except Exception as e:
|
||||
logger.warning("Style boost error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ── 오디오 업로드 + 커버 ─────────────────────────────────────────────────────
|
||||
|
||||
def run_upload_cover(task_id: str, params: dict) -> None:
|
||||
"""외부 오디오를 Suno 스타일로 리메이크."""
|
||||
try:
|
||||
if not SUNO_API_KEY:
|
||||
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 5, "AI Cover 요청 중...")
|
||||
|
||||
payload = {
|
||||
"uploadUrl": params["upload_url"],
|
||||
"customMode": params.get("custom_mode", True),
|
||||
"instrumental": params.get("instrumental", False),
|
||||
"model": params.get("model", "V4"),
|
||||
"callBackUrl": "https://example.com/noop",
|
||||
}
|
||||
for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
|
||||
("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags"),
|
||||
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
|
||||
if params.get(key):
|
||||
payload[api_key] = params[key]
|
||||
|
||||
resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-cover", headers=_headers(), json=payload, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Upload Cover API 오류: {resp.text[:300]}")
|
||||
return
|
||||
body = resp.json()
|
||||
if body.get("code") != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Upload Cover 거부: {body.get('msg', 'unknown')}")
|
||||
return
|
||||
|
||||
suno_task_id = body.get("data", {}).get("taskId", "")
|
||||
if not suno_task_id:
|
||||
update_task(task_id, "failed", 0, "", error="Upload Cover 응답에 taskId 없음")
|
||||
return
|
||||
update_task(task_id, "processing", 15, "AI Cover 생성 중...")
|
||||
|
||||
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
||||
if not response:
|
||||
return
|
||||
completed_tracks = response.get("sunoData") or []
|
||||
if not completed_tracks:
|
||||
update_task(task_id, "failed", 0, "", error="AI Cover 생성 완료했으나 트랙 없음")
|
||||
return
|
||||
|
||||
track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
|
||||
if track:
|
||||
update_task(task_id, "succeeded", 100, "AI Cover 완료", audio_url=track["audio_url"])
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Upload cover error for task %s", task_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
# ── 오디오 업로드 + 확장 ─────────────────────────────────────────────────────
|
||||
|
||||
def run_upload_extend(task_id: str, params: dict) -> None:
|
||||
"""외부 오디오를 이어서 확장."""
|
||||
try:
|
||||
if not SUNO_API_KEY:
|
||||
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 5, "Upload Extend 요청 중...")
|
||||
|
||||
payload = {
|
||||
"uploadUrl": params["upload_url"],
|
||||
"defaultParamFlag": params.get("default_param_flag", True),
|
||||
"model": params.get("model", "V4"),
|
||||
"callBackUrl": "https://example.com/noop",
|
||||
}
|
||||
for key, api_key in [("prompt", "prompt"), ("style", "style"), ("title", "title"),
|
||||
("continue_at", "continueAt"), ("instrumental", "instrumental"),
|
||||
("vocal_gender", "vocalGender"), ("negative_tags", "negativeTags")]:
|
||||
if params.get(key) is not None:
|
||||
payload[api_key] = params[key]
|
||||
|
||||
resp = requests.post(f"{SUNO_BASE_URL}/generate/upload-extend", headers=_headers(), json=payload, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Upload Extend API 오류: {resp.text[:300]}")
|
||||
return
|
||||
body = resp.json()
|
||||
if body.get("code") != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Upload Extend 거부: {body.get('msg', 'unknown')}")
|
||||
return
|
||||
|
||||
suno_task_id = body.get("data", {}).get("taskId", "")
|
||||
if not suno_task_id:
|
||||
update_task(task_id, "failed", 0, "", error="Upload Extend 응답에 taskId 없음")
|
||||
return
|
||||
update_task(task_id, "processing", 15, "Upload Extend 생성 중...")
|
||||
|
||||
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
||||
if not response:
|
||||
return
|
||||
completed_tracks = response.get("sunoData") or []
|
||||
if not completed_tracks:
|
||||
update_task(task_id, "failed", 0, "", error="Upload Extend 완료했으나 트랙 없음")
|
||||
return
|
||||
|
||||
track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
|
||||
if track:
|
||||
update_task(task_id, "succeeded", 100, "Upload Extend 완료", audio_url=track["audio_url"])
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Upload extend error for task %s", task_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
# ── 보컬 추가 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_add_vocals(task_id: str, params: dict) -> None:
|
||||
"""인스트루멘탈에 AI 보컬을 추가."""
|
||||
try:
|
||||
if not SUNO_API_KEY:
|
||||
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 5, "보컬 추가 요청 중...")
|
||||
|
||||
payload = {
|
||||
"uploadUrl": params["upload_url"],
|
||||
"prompt": params.get("prompt", ""),
|
||||
"title": params.get("title", ""),
|
||||
"style": params.get("style", ""),
|
||||
"negativeTags": params.get("negative_tags", ""),
|
||||
"callBackUrl": "https://example.com/noop",
|
||||
}
|
||||
for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
|
||||
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
|
||||
if params.get(key) is not None:
|
||||
payload[api_key] = params[key]
|
||||
|
||||
resp = requests.post(f"{SUNO_BASE_URL}/generate/add-vocals", headers=_headers(), json=payload, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Add Vocals API 오류: {resp.text[:300]}")
|
||||
return
|
||||
body = resp.json()
|
||||
if body.get("code") != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Add Vocals 거부: {body.get('msg', 'unknown')}")
|
||||
return
|
||||
|
||||
suno_task_id = body.get("data", {}).get("taskId", "")
|
||||
if not suno_task_id:
|
||||
update_task(task_id, "failed", 0, "", error="Add Vocals 응답에 taskId 없음")
|
||||
return
|
||||
update_task(task_id, "processing", 15, "AI 보컬 생성 중...")
|
||||
|
||||
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
||||
if not response:
|
||||
return
|
||||
completed_tracks = response.get("sunoData") or []
|
||||
if not completed_tracks:
|
||||
update_task(task_id, "failed", 0, "", error="보컬 추가 완료했으나 트랙 없음")
|
||||
return
|
||||
|
||||
track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
|
||||
if track:
|
||||
update_task(task_id, "succeeded", 100, "보컬 추가 완료", audio_url=track["audio_url"])
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Add vocals error for task %s", task_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
# ── 인스트루멘탈 추가 ────────────────────────────────────────────────────────
|
||||
|
||||
def run_add_instrumental(task_id: str, params: dict) -> None:
|
||||
"""보컬에 AI 반주를 추가."""
|
||||
try:
|
||||
if not SUNO_API_KEY:
|
||||
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 5, "인스트루멘탈 추가 요청 중...")
|
||||
|
||||
payload = {
|
||||
"uploadUrl": params["upload_url"],
|
||||
"title": params.get("title", ""),
|
||||
"tags": params.get("tags", ""),
|
||||
"negativeTags": params.get("negative_tags", ""),
|
||||
"callBackUrl": "https://example.com/noop",
|
||||
}
|
||||
for key, api_key in [("vocal_gender", "vocalGender"), ("model", "model"),
|
||||
("style_weight", "styleWeight"), ("audio_weight", "audioWeight")]:
|
||||
if params.get(key) is not None:
|
||||
payload[api_key] = params[key]
|
||||
|
||||
resp = requests.post(f"{SUNO_BASE_URL}/generate/add-instrumental", headers=_headers(), json=payload, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Add Instrumental API 오류: {resp.text[:300]}")
|
||||
return
|
||||
body = resp.json()
|
||||
if body.get("code") != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Add Instrumental 거부: {body.get('msg', 'unknown')}")
|
||||
return
|
||||
|
||||
suno_task_id = body.get("data", {}).get("taskId", "")
|
||||
if not suno_task_id:
|
||||
update_task(task_id, "failed", 0, "", error="Add Instrumental 응답에 taskId 없음")
|
||||
return
|
||||
update_task(task_id, "processing", 15, "AI 반주 생성 중...")
|
||||
|
||||
response = _poll_suno_record("/generate/record-info", suno_task_id, task_id)
|
||||
if not response:
|
||||
return
|
||||
completed_tracks = response.get("sunoData") or []
|
||||
if not completed_tracks:
|
||||
update_task(task_id, "failed", 0, "", error="인스트루멘탈 추가 완료했으나 트랙 없음")
|
||||
return
|
||||
|
||||
track = _download_and_register(task_id=task_id, song=completed_tracks[0], params=params, filename_suffix="")
|
||||
if track:
|
||||
update_task(task_id, "succeeded", 100, "인스트루멘탈 추가 완료", audio_url=track["audio_url"])
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Add instrumental error for task %s", task_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
|
||||
# ── 뮤직비디오 생성 ──────────────────────────────────────────────────────────
|
||||
|
||||
def run_video_generate(task_id: str, params: dict) -> None:
|
||||
"""곡의 뮤직비디오(MP4) 생성."""
|
||||
try:
|
||||
if not SUNO_API_KEY:
|
||||
update_task(task_id, "failed", 0, "", error="SUNO_API_KEY가 설정되지 않았습니다.")
|
||||
return
|
||||
|
||||
update_task(task_id, "processing", 5, "뮤직비디오 생성 요청 중...")
|
||||
|
||||
payload = {
|
||||
"taskId": params["suno_task_id"],
|
||||
"audioId": params["suno_id"],
|
||||
"callBackUrl": "https://example.com/noop",
|
||||
}
|
||||
if params.get("author"):
|
||||
payload["author"] = params["author"][:50]
|
||||
if params.get("domain_name"):
|
||||
payload["domainName"] = params["domain_name"][:50]
|
||||
|
||||
resp = requests.post(f"{SUNO_BASE_URL}/mp4/generate", headers=_headers(), json=payload, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Video API 오류: {resp.text[:300]}")
|
||||
return
|
||||
body = resp.json()
|
||||
if body.get("code") != 200:
|
||||
update_task(task_id, "failed", 0, "", error=f"Video 생성 거부: {body.get('msg', 'unknown')}")
|
||||
return
|
||||
|
||||
video_task_id = body.get("data", {}).get("taskId", params.get("suno_task_id", ""))
|
||||
update_task(task_id, "processing", 15, "뮤직비디오 렌더링 중...")
|
||||
|
||||
response = _poll_suno_record(
|
||||
"/mp4/record-info", video_task_id, task_id,
|
||||
max_attempts=60, interval=10,
|
||||
progress_msg_map={"PENDING": "비디오 렌더링 대기 중...", "GENERATING": "비디오 렌더링 중..."},
|
||||
)
|
||||
if not response:
|
||||
return
|
||||
|
||||
video_url = ""
|
||||
suno_data = response.get("sunoData") or []
|
||||
if suno_data and isinstance(suno_data, list) and isinstance(suno_data[0], dict):
|
||||
video_url = suno_data[0].get("videoUrl") or suno_data[0].get("video_url", "")
|
||||
if not video_url:
|
||||
video_url = response.get("video_url") or response.get("videoUrl", "")
|
||||
|
||||
update_task(task_id, "succeeded", 100, "뮤직비디오 생성 완료", audio_url=video_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Video generate error for task %s", task_id)
|
||||
update_task(task_id, "failed", 0, "", error=str(e))
|
||||
|
||||
Reference in New Issue
Block a user