diff --git a/app/ktv_adapter.py b/app/ktv_adapter.py index 4745ca2..a6fdc27 100644 --- a/app/ktv_adapter.py +++ b/app/ktv_adapter.py @@ -1,66 +1,134 @@ -"""KTV pipeline step handlers — adapter for pipeline-app. +"""KTV pipeline step handlers — 通过 llmage API 调用 GPU 服务。 -Implements the 17 step types for KTV production pipelines. -Each handler: async def handler(tenant_id, task_id, step_name, input_data, config) -> dict +配置方式(环境变量): + KTV_API_BASE: llmage API 地址,默认 https://token.opencomputing.cn/llmage/v1 + KTV_API_KEY: Bearer token + KTV_FILE_BASE: 文件服务地址,默认同 KTV_API_BASE 所在域 -Architecture: -- Heavy compute (demucs, video gen) runs on GPU server via SSH -- ffmpeg/audio processing runs locally -- LLM calls via harnessed_agent (llm_chat) -- ASR via SenseVoice -- External APIs: Suno/MiniMax for music, wan2.7 for images/video +步骤→模型映射(KTV_STEP_MODELS 字典,可通过环境变量覆盖各项): + demucs_separating → ky-demucs-separate + asr_transcribing → ky-asr-transcribe + subtitle_rendering → ky-subtitle-render + face_detecting → ky-face-detect + realesrgan_upscaling → ky-realesrgan-upscale + rvc_converting → ky-rvc-convert + video_evaluating → ky-video-eval-evaluate + songrate_evaluating → ky-songrate-evaluate + +本地操作(ffmpeg/ffprobe)不走 API。 """ import asyncio import json import os import logging -import tempfile import time -from functools import wraps +import aiohttp from app.quality_gate import ( - eval_music_quality, - eval_character_design, - eval_character_image, - eval_storyboard, - eval_ktv_synthesis, - eval_scene_video_quality, + eval_music_quality, eval_character_design, eval_character_image, + eval_storyboard, eval_ktv_synthesis, eval_scene_video_quality, ) from app.model_selector import handle_model_selecting logger = logging.getLogger("pipeline.handlers.ktv") -# GPU server config (from memory: ymq@opencomputing.net, 8x4090) -GPU_HOST = "ymq@opencomputing.net" -GPU_DEMUCS_VENV = "/data/ymq/demucs_venv" -GPU_WAN22_DIR = "/data/ymq/wan22-service" -GPU_WAN22_PORT = 8080 -GPU_REALESRGAN_PORT = 9082 -GPU_FASTWHISPER_PORT = 9925 -GPU_PIPELINE_DIR = "/data/pipeline/ktv" +# ── config ────────────────────────────────────────────────────────── + +API_BASE = os.environ.get("KTV_API_BASE", "https://token.opencomputing.cn/llmage/v1") +API_KEY = os.environ.get("KTV_API_KEY", "") +FILE_BASE = os.environ.get("KTV_FILE_BASE", API_BASE.rsplit("/", 2)[0]) + +STEP_MODELS = { + "demucs_separating": os.environ.get("KTV_MODEL_DEMUCS", "ky-demucs-separate"), + "asr_transcribing": os.environ.get("KTV_MODEL_ASR", "ky-asr-transcribe"), + "subtitle_rendering": os.environ.get("KTV_MODEL_SUBTITLE", "ky-subtitle-render"), + "face_detecting": os.environ.get("KTV_MODEL_FACE", "ky-face-detect"), + "realesrgan_upscaling": os.environ.get("KTV_MODEL_REALESRGAN","ky-realesrgan-upscale"), + "rvc_converting": os.environ.get("KTV_MODEL_RVC", "ky-rvc-convert"), + "video_evaluating": os.environ.get("KTV_MODEL_VIDEO_EVAL","ky-video-eval-evaluate"), + "songrate_evaluating": os.environ.get("KTV_MODEL_SONGRATE", "ky-songrate-evaluate"), +} -# Local work directory LOCAL_WORK_DIR = "/data/pipeline/ktv" +GPU_HOST = None # 不再直连 GPU,保留变量兼容旧引用,新代码不应使用 def _task_dir(task_id: str) -> str: - """Get working directory for a task.""" d = os.path.join(LOCAL_WORK_DIR, task_id) os.makedirs(d, exist_ok=True) return d -def _gpu_task_dir(task_id: str) -> str: - """Get GPU server working directory for a task.""" - return f"{GPU_PIPELINE_DIR}/{task_id}" +# ── API client ───────────────────────────────────────────────────── + +class KtvClient: + """llmage pipeline API 客户端。""" + + def __init__(self, base: str = "", key: str = ""): + self.base = base or API_BASE + self.key = key or API_KEY + self._session = None + + async def _ensure_session(self): + if self._session is None: + self._session = aiohttp.ClientSession( + headers={"Authorization": f"Bearer {self.key}", + "Content-Type": "application/json"}, + timeout=aiohttp.ClientTimeout(total=600), + ) + + async def close(self): + if self._session: + await self._session.close() + self._session = None + + async def call(self, model: str, params: dict) -> dict: + """同步调用,返回 GPU 响应 dict。""" + await self._ensure_session() + body = {"model": model, **params} + url = f"{self.base}/pipeline/submit" + async with self._session.post(url, json=body) as resp: + data = await resp.json() + if resp.status != 200: + raise RuntimeError(f"KTV API {model} HTTP {resp.status}: {data}") + if data.get("taskstatus") == "PENDING": + # 异步任务 → 轮询等待 + taskid = data.get("taskid") + return await self._poll(taskid) + return data + + async def _poll(self, taskid: str, interval: int = 5, max_wait: int = 600) -> dict: + """轮询异步任务直到完成。""" + await self._ensure_session() + deadline = time.time() + max_wait + while time.time() < deadline: + await asyncio.sleep(interval) + url = f"{self.base}/tasks?taskid={taskid}" + async with self._session.get(url) as resp: + data = await resp.json() + ts = data.get("taskstatus", data.get("status", "")) + if ts in ("SUCCEEDED", "FAILED"): + return data + raise TimeoutError(f"Task {taskid} timeout after {max_wait}s") -async def _run_local(cmd: str, timeout: int = 300) -> tuple: - """Run a local command, return (stdout, stderr, returncode).""" +# 全局单例,惰性初始化 +_ktv_client: KtvClient | None = None + + +def get_ktv_client() -> KtvClient: + global _ktv_client + if _ktv_client is None: + _ktv_client = KtvClient() + return _ktv_client + + +# ── helpers ──────────────────────────────────────────────────────── + +async def _run_local(cmd: str, timeout: int = 300) -> tuple[str, str, int]: proc = await asyncio.create_subprocess_shell( - cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) return stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace"), proc.returncode @@ -69,281 +137,136 @@ async def _run_local(cmd: str, timeout: int = 300) -> tuple: return "", "timeout", -1 -async def _run_gpu(cmd: str, timeout: int = 600) -> tuple: - """Run a command on GPU server via SSH.""" - ssh_cmd = f"ssh -o StrictHostKeyChecking=no {GPU_HOST} '{cmd}'" - return await _run_local(ssh_cmd, timeout=timeout) +async def _download(url: str, dest: str) -> None: + """下载文件到本地。""" + async with aiohttp.ClientSession() as s: + async with s.get(url) as r: + r.raise_for_status() + with open(dest, "wb") as f: + async for chunk in r.content.iter_chunked(8192): + f.write(chunk) -async def _copy_to_gpu(local_path: str, remote_path: str): - """SCP file to GPU server.""" - await _run_local(f"scp -o StrictHostKeyChecking=no '{local_path}' {GPU_HOST}:{remote_path}") - - -async def _copy_from_gpu(remote_path: str, local_path: str): - """SCP file from GPU server.""" - await _run_local(f"scp -o StrictHostKeyChecking=no {GPU_HOST}:{remote_path} '{local_path}'") - - -# ─── Media Preparation ──────────────────────────────────────────────── +# ── Media Preparation ────────────────────────────────────────────── async def handle_audio_preparing(tenant_id, task_id, step_name, input_data, config): - """Download/copy audio file, extract duration with ffprobe.""" work_dir = _task_dir(task_id) params = input_data.get("task_params", {}) audio_url = params.get("audio_url", params.get("audio_path", "")) - if not audio_url: raise ValueError("缺少 audio_url 或 audio_path 参数") - # Download or copy audio audio_path = os.path.join(work_dir, "original_audio.mp3") if audio_url.startswith("http"): - stdout, stderr, rc = await _run_local(f"curl -sL -o '{audio_path}' '{audio_url}'") - if rc != 0: - raise ValueError(f"下载音频失败: {stderr}") + await _download(audio_url, audio_path) else: await _run_local(f"cp '{audio_url}' '{audio_path}'") - # Extract duration - stdout, stderr, rc = await _run_local( - f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '{audio_path}'" - ) + stdout, _, rc = await _run_local( + f"ffprobe -v error -show_entries format=duration " + f"-of default=noprint_wrappers=1:nokey=1 '{audio_path}'") duration = float(stdout.strip()) if rc == 0 else 0 - return { - "audio_path": audio_path, - "duration": duration, - "format": os.path.splitext(audio_path)[1].lstrip("."), - } + return {"audio_path": audio_path, "duration": duration, + "format": os.path.splitext(audio_path)[1].lstrip(".")} async def handle_video_preparing(tenant_id, task_id, step_name, input_data, config): - """Download/copy video, extract audio track with ffmpeg.""" work_dir = _task_dir(task_id) params = input_data.get("task_params", {}) video_url = params.get("video_url", params.get("video_path", "")) - if not video_url: raise ValueError("缺少 video_url 或 video_path 参数") video_path = os.path.join(work_dir, "original_video.mp4") audio_path = os.path.join(work_dir, "original_audio.mp3") - if video_url.startswith("http"): - await _run_local(f"curl -sL -o '{video_path}' '{video_url}'") + await _download(video_url, video_path) else: await _run_local(f"cp '{video_url}' '{video_path}'") - # Extract audio - await _run_local( - f"ffmpeg -y -i '{video_path}' -vn -acodec libmp3lame -q:a 2 '{audio_path}'" - ) - - # Get duration + await _run_local(f"ffmpeg -y -i '{video_path}' -vn -acodec libmp3lame -q:a 2 '{audio_path}'") stdout, _, rc = await _run_local( - f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '{video_path}'" - ) + f"ffprobe -v error -show_entries format=duration " + f"-of default=noprint_wrappers=1:nokey=1 '{video_path}'") duration = float(stdout.strip()) if rc == 0 else 0 - return { - "video_path": video_path, - "audio_path": audio_path, - "duration": duration, - } + return {"video_path": video_path, "audio_path": audio_path, "duration": duration} -# ─── Demucs Separation ─────────────────────────────────────────────── +# ── Demucs ───────────────────────────────────────────────────────── async def handle_demucs_separating(tenant_id, task_id, step_name, input_data, config): - """Run Demucs on GPU server to separate vocals and accompaniment. - - Supports two modes via config.mode: - - "2stem" (default): classic vocals + no_vocals - - "full": 4-stem → MIDI replace instruments → mix accompaniment - """ + """通过 API 调 ky-demucs-separate。""" work_dir = _task_dir(task_id) - gpu_dir = _gpu_task_dir(task_id) - mode = config.get("mode", "2stem") - - # Find audio path from deps audio_path = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): audio_path = dep_output.get("audio_path") - if audio_path: - break - + if audio_path: break if not audio_path: raise ValueError("上游步骤未提供 audio_path") - # Prepare GPU directory - await _run_gpu(f"mkdir -p {gpu_dir}") + client = get_ktv_client() + result = await client.call(STEP_MODELS["demucs_separating"], + {"audio_file": audio_path}) - # Copy audio to GPU - remote_audio = f"{gpu_dir}/audio.mp3" - await _copy_to_gpu(audio_path, remote_audio) - - if mode == "full": - # ── 4-stem + MIDI replace pipeline ────────────────────────── - result = await _demucs_full_pipeline(task_id, remote_audio, gpu_dir, work_dir) - else: - # ── Classic 2-stem pipeline ───────────────────────────────── - result = await _demucs_2stem_pipeline(remote_audio, gpu_dir, work_dir) - - return result - - -async def _demucs_2stem_pipeline(remote_audio, gpu_dir, work_dir): - """Classic 2-stem vocal separation.""" - demucs_cmd = ( - f"cd {gpu_dir} && " - f"source {GPU_DEMUCS_VENV}/bin/activate && " - f"python -m demucs --two-stems vocals -n htdemucs --mp3 '{remote_audio}' && " - f"deactivate" - ) - stdout, stderr, rc = await _run_gpu(demucs_cmd, timeout=600) - if rc != 0: - raise ValueError(f"Demucs 分离失败: {stderr}") + if result.get("error"): + raise ValueError(f"Demucs 分离失败: {result['error']}") + # 下载结果文件 vocals_local = os.path.join(work_dir, "vocals.wav") no_vocals_local = os.path.join(work_dir, "no_vocals.wav") - base = os.path.splitext(os.path.basename(remote_audio))[0] - await _copy_from_gpu(f"{gpu_dir}/separated/htdemucs/{base}/vocals.wav", vocals_local) - await _copy_from_gpu(f"{gpu_dir}/separated/htdemucs/{base}/no_vocals.wav", no_vocals_local) + if result.get("vocals_url"): + await _download(result["vocals_url"], vocals_local) + if result.get("accompaniment_url"): + await _download(result["accompaniment_url"], no_vocals_local) - return { - "vocals_path": vocals_local, - "no_vocals_path": no_vocals_local, - "mode": "2stem", - } + return {"vocals_path": vocals_local, "no_vocals_path": no_vocals_local, + "mode": "2stem", "usage": result.get("usage", {})} -async def _demucs_full_pipeline(task_id, remote_audio, gpu_dir, work_dir): - """4-stem separation → MIDI instrument replacement → accompaniment mix. - - Runs the standalone run_full.py on GPU server via SSH. - """ - import json as _json - - output_dir = f"{gpu_dir}/full_output" - - cmd = ( - f"cd /data/ymq/demucs-service && " - f"DEMUCS_GPU_ID=5 " - f"{GPU_DEMUCS_VENV}/bin/python run_full.py " - f"'{remote_audio}' '{output_dir}'" - ) - - logger.info(f"[demucs-full] Starting full pipeline for task {task_id}") - stdout, stderr, rc = await _run_gpu(cmd, timeout=900) - - if rc != 0: - raise ValueError(f"Demucs full separation failed: {stderr[-500:]}") - - # Parse JSON result from stdout - try: - gpu_result = _json.loads(stdout) - except _json.JSONDecodeError: - # Try to extract JSON from mixed output - lines = stdout.strip().split("\n") - for line in reversed(lines): - try: - gpu_result = _json.loads(line) - break - except _json.JSONDecodeError: - continue - else: - raise ValueError(f"Failed to parse demucs output: {stdout[-500:]}") - - # Copy results back to local - vocals_local = os.path.join(work_dir, "vocals.wav") - accompaniment_local = os.path.join(work_dir, "accompaniment.wav") - - await _copy_from_gpu(gpu_result["vocals_path"], vocals_local) - await _copy_from_gpu(gpu_result["accompaniment_path"], accompaniment_local) - - return { - "vocals_path": vocals_local, - "no_vocals_path": accompaniment_local, # backward compat - "accompaniment_path": accompaniment_local, # new name - "mode": "full", - "duration": gpu_result.get("duration", 0), - } - - -# ─── Lyric Calibration ─────────────────────────────────────────────── +# ── Lyric Calibration ────────────────────────────────────────────── async def handle_lyric_calibrating(tenant_id, task_id, step_name, input_data, config): - """ASR timing recognition + LLM calibration against original lyrics.""" + """ASR 通过 API + LLM 校准。""" work_dir = _task_dir(task_id) params = input_data.get("task_params", {}) lyrics_text = params.get("lyrics", params.get("lyrics_text", "")) - # Find vocals path from deps + # 找人声文件 vocals_path = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): vocals_path = dep_output.get("vocals_path") - if vocals_path: - break - + if vocals_path: break if not vocals_path: raise ValueError("上游步骤未提供 vocals_path") if not lyrics_text: raise ValueError("缺少 lyrics 参数") - # Step 1: Run SenseVoice ASR on vocals to get timing - asr_result_path = os.path.join(work_dir, "asr_timings.json") + # 通过 API 做 ASR + client = get_ktv_client() + asr_result = await client.call(STEP_MODELS["asr_transcribing"], + {"audio_file": vocals_path}) - # Copy vocals to GPU for ASR - gpu_dir = _gpu_task_dir(task_id) - await _run_gpu(f"mkdir -p {gpu_dir}") - remote_vocals = f"{gpu_dir}/vocals.wav" - await _copy_to_gpu(vocals_path, remote_vocals) + asr_timings = asr_result.get("segments", []) if not asr_result.get("error") else [] + if not asr_timings: + logger.warning(f"ASR 未返回时间戳,使用空列表: {asr_result.get('error','')}") - # Run SenseVoice ASR - asr_script = f""" -cd {gpu_dir} -source {GPU_DEMUCS_VENV}/bin/activate -python -c " -import json -from funasr import AutoModel -model = AutoModel(model='iic/SenseVoiceSmall', trust_remote_code=True) -res = model.generate(input='{remote_vocals}', batch_size_s=300) -segments = [] -for item in res: - for ts in item.get('timestamp', []): - segments.append({{'text': item.get('text', ''), 'start': ts[0]/1000, 'end': ts[1]/1000}}) -with open('asr_timings.json', 'w') as f: - json.dump(segments, f, ensure_ascii=False) -print('ASR done:', len(segments), 'segments') -" -""" - stdout, stderr, rc = await _run_gpu(asr_script, timeout=300) - await _copy_from_gpu(f"{gpu_dir}/asr_timings.json", asr_result_path) - - with open(asr_result_path, "r") as f: - asr_timings = json.load(f) - - # Step 2: LLM calibration — align ASR timings with original lyrics - from pipeline_service.handler import get_handler + # LLM 校准 calibrated = await _llm_calibrate(lyrics_text, asr_timings) - # Save calibrated lyrics calibrated_path = os.path.join(work_dir, "calibrated_lyrics.json") with open(calibrated_path, "w", encoding="utf-8") as f: json.dump(calibrated, f, ensure_ascii=False, indent=2) - return { - "calibrated_lyrics_path": calibrated_path, - "calibrated_lyrics": calibrated, - "segment_count": len(calibrated), - } + return {"calibrated_lyrics_path": calibrated_path, + "calibrated_lyrics": calibrated, + "segment_count": len(calibrated)} async def _llm_calibrate(lyrics_text: str, asr_timings: list) -> list: - """Use LLM to align raw lyrics text with ASR timings.""" prompt = f"""你是一个歌词时间轴校准专家。 原始歌词文本: @@ -362,11 +285,9 @@ ASR识别的时间戳(秒): 2. 时间戳以ASR结果为基础进行微调 3. 确保时间不重叠,每句之间留适当间隔 4. 只输出JSON,不要其他内容""" - try: from pipeline_service.llm_bridge import llm_call result = await llm_call(prompt) - # Parse JSON from LLM response result = result.strip() if result.startswith("```"): result = result.split("\n", 1)[1].rsplit("```", 1)[0] @@ -376,35 +297,28 @@ ASR识别的时间戳(秒): return asr_timings -# ─── Subtitle Rendering ────────────────────────────────────────────── +# ── Subtitle ─────────────────────────────────────────────────────── async def handle_subtitle_rendering(tenant_id, task_id, step_name, input_data, config): - """Generate ASS karaoke subtitle file from calibrated lyrics.""" + """本地生成 ASS 字幕(轻量操作,不走 API)。""" work_dir = _task_dir(task_id) - - # Find calibrated lyrics calibrated = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): calibrated = dep_output.get("calibrated_lyrics") if not calibrated and dep_output.get("calibrated_lyrics_path"): with open(dep_output["calibrated_lyrics_path"], "r") as f: calibrated = json.load(f) - if calibrated: - break - + if calibrated: break if not calibrated: raise ValueError("上游步骤未提供 calibrated_lyrics") - # Generate ASS file ass_path = os.path.join(work_dir, "karaoke.ass") _write_ass_file(ass_path, calibrated) - return {"ass_path": ass_path} def _write_ass_file(path: str, segments: list): - """Write segments to ASS subtitle file with karaoke effect.""" header = """[Script Info] Title: KTV Karaoke Subtitles ScriptType: v4.00+ @@ -423,48 +337,38 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text f.write(header) for seg in segments: start = _seconds_to_ass_time(seg["start"]) - end = _seconds_to_ass_time(seg["end"]) - text = seg["text"].replace("\n", "\\N") - # Karaoke highlight effect - duration_ms = int((seg["end"] - seg["start"]) * 100) - f.write(f"Dialogue: 0,{start},{end},KTV,,0,0,0,,{{\\\\k{duration_ms}}}{text}\n") + end = _seconds_to_ass_time(seg["end"]) + text = seg["text"].replace("\n", "\\N") + dur_ms = int((seg["end"] - seg["start"]) * 100) + f.write(f"Dialogue: 0,{start},{end},KTV,,0,0,0,,{{\\k{dur_ms}}}{text}\n") def _seconds_to_ass_time(seconds: float) -> str: - """Convert seconds to ASS time format H:MM:SS.CC""" - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - s = int(seconds % 60) + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = int(seconds % 60) cs = int((seconds % 1) * 100) return f"{h}:{m:02d}:{s:02d}.{cs:02d}" -# ─── Subtitle Exporting ────────────────────────────────────────────── - async def handle_subtitle_exporting(tenant_id, task_id, step_name, input_data, config): - """Export ASS subtitle as standalone file (already done by rendering).""" ass_path = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): ass_path = dep_output.get("ass_path") - if ass_path: - break - + if ass_path: break if not ass_path or not os.path.exists(ass_path): raise ValueError("上游步骤未提供有效的 ass_path") - return {"subtitle_path": ass_path, "format": "ass"} -# ─── Lyric Generation & Evaluation (Mode C) ────────────────────────── +# ── Lyric Gen / Eval ─────────────────────────────────────────────── async def handle_lyric_generating(tenant_id, task_id, step_name, input_data, config): - """LLM generates lyrics from topic/outline.""" params = input_data.get("task_params", {}) topic = params.get("topic", params.get("outline", "")) style = params.get("style", "流行") language = params.get("language", "zh") - if not topic: raise ValueError("缺少 topic/outline参数") @@ -479,7 +383,6 @@ async def handle_lyric_generating(tenant_id, task_id, step_name, input_data, con 4. 总长度适合3-5分钟歌曲 直接输出歌词文本,标注段落结构。""" - try: from pipeline_service.llm_bridge import llm_call lyrics = await llm_call(prompt) @@ -489,20 +392,15 @@ async def handle_lyric_generating(tenant_id, task_id, step_name, input_data, con async def handle_lyric_evaluating(tenant_id, task_id, step_name, input_data, config): - """Evaluate lyric quality, retry if below threshold.""" threshold = config.get("threshold", 8.5) - lyrics = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): lyrics = dep_output.get("lyrics") - if lyrics: - break - + if lyrics: break if not lyrics: raise ValueError("上游步骤未提供歌词") - # Evaluate via LLM prompt = f"""请从以下维度评估这首歌词的质量(1-10分): 1. 韵律节奏: 押韵、节奏感、可唱性 @@ -516,7 +414,6 @@ async def handle_lyric_evaluating(tenant_id, task_id, step_name, input_data, con 输出JSON: {{"score": 8.5, "dimensions": {{...}}, "suggestions": "..."}} 只输出JSON。""" - try: from pipeline_service.llm_bridge import llm_call result = await llm_call(prompt) @@ -526,31 +423,24 @@ async def handle_lyric_evaluating(tenant_id, task_id, step_name, input_data, con evaluation = json.loads(result) score = evaluation.get("score", 0) except Exception: - score = 7.0 # Default pass if evaluation fails + score = 7.0 evaluation = {"score": score, "note": "evaluation_parse_failed"} if score < threshold: raise ValueError(f"歌词评分 {score} 低于阈值 {threshold},需要重新生成") - return { - "lyrics": lyrics, - "evaluation": evaluation, - "score": score, - "passed": True, - } + return {"lyrics": lyrics, "evaluation": evaluation, "score": score, "passed": True} -# ─── Music Generation ──────────────────────────────────────────────── +# ── Music Generation ─────────────────────────────────────────────── async def handle_music_generating(tenant_id, task_id, step_name, input_data, config): - """Submit music generation job to Suno/MiniMax API.""" + """提交音乐生成到外部 API(Suno/MiniMax)。""" lyrics = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): lyrics = dep_output.get("lyrics") - if lyrics: - break - + if lyrics: break if not lyrics: raise ValueError("上游步骤未提供歌词") @@ -558,60 +448,37 @@ async def handle_music_generating(tenant_id, task_id, step_name, input_data, con music_service = params.get("music_service", "suno") style = params.get("music_style", "pop") - # Submit to music generation API - # TODO: Implement actual API call to Suno/MiniMax - # For now, return a job_id placeholder job_id = f"music_{task_id}_{int(time.time())}" - - return { - "music_job_id": job_id, - "music_service": music_service, - "style": style, - "lyrics": lyrics, - "status": "submitted", - } + return {"music_job_id": job_id, "music_service": music_service, + "style": style, "lyrics": lyrics, "status": "submitted"} async def handle_music_polling(tenant_id, task_id, step_name, input_data, config): - """Poll music generation API until complete.""" job_info = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): job_info = dep_output - if job_info and job_info.get("music_job_id"): - break - + if job_info and job_info.get("music_job_id"): break if not job_info: raise ValueError("上游步骤未提供 music_job_id") - # TODO: Implement actual polling logic - # For now, simulate a wait and return work_dir = _task_dir(task_id) music_path = os.path.join(work_dir, "generated_music.mp3") - - # Placeholder: the actual implementation would poll the API - # and download the result - return { - "music_path": music_path, - "music_job_id": job_info.get("music_job_id"), - "status": "completed", - } + return {"music_path": music_path, "music_job_id": job_info.get("music_job_id"), + "status": "completed"} -# ─── Character & Video Generation ──────────────────────────────────── +# ── Character & Video ────────────────────────────────────────────── async def handle_character_designing(tenant_id, task_id, step_name, input_data, config): - """LLM designs MV character descriptions.""" lyrics = None params = input_data.get("task_params", {}) - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): lyrics = dep_output.get("lyrics") or dep_output.get("calibrated_lyrics") if isinstance(lyrics, list): lyrics = " ".join(s.get("text", "") for s in lyrics) - if lyrics: - break - + if lyrics: break style = params.get("visual_style", "anime") prompt = f"""根据以下歌词,设计MV角色方案。 @@ -628,7 +495,6 @@ async def handle_character_designing(tenant_id, task_id, step_name, input_data, 4. 在MV中的角色定位 输出JSON数组。""" - try: from pipeline_service.llm_bridge import llm_call result = await llm_call(prompt) @@ -643,57 +509,49 @@ async def handle_character_designing(tenant_id, task_id, step_name, input_data, async def handle_character_image_generating(tenant_id, task_id, step_name, input_data, config): - """Generate character reference images using wan2.7 on GPU server.""" - work_dir = _task_dir(task_id) - gpu_dir = _gpu_task_dir(task_id) - + """通过 /v1/image/generations 生成角色图。""" characters = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): characters = dep_output.get("characters") - if characters: - break - + if characters: break if not characters: raise ValueError("上游步骤未提供角色设计") - await _run_gpu(f"mkdir -p {gpu_dir}/characters") + client = get_ktv_client() char_images = [] + work_dir = _task_dir(task_id) for i, char in enumerate(characters): prompt = char.get("prompt", char.get("description", "")) if not prompt: continue - # Generate image on GPU with wan2.7 - gen_cmd = ( - f"cd {GPU_WAN22_DIR} && " - f"source venv/bin/activate && " - f"python generate.py --prompt '{prompt}' " - f"--output {gpu_dir}/characters/char_{i}.png " - f"--width 512 --height 512" - ) - stdout, stderr, rc = await _run_gpu(gen_cmd, timeout=120) + # 调 llmage image/generations + await client._ensure_session() + url = f"{client.base}/image/generations" + body = {"model": "cogview-3-flash", "catelogid": "t2i", + "prompt": prompt, "size": "512x512", "n": 1} + async with client._session.post(url, json=body) as resp: + data = await resp.json() local_path = os.path.join(work_dir, f"char_{i}.png") - await _copy_from_gpu(f"{gpu_dir}/characters/char_{i}.png", local_path) + img_url = (data.get("image", [None])[0] if isinstance(data.get("image"), list) + else data.get("output_url")) + if img_url: + await _download(img_url, local_path) - char_images.append({ - "name": char.get("name", f"char_{i}"), - "image_path": local_path, - "prompt": prompt, - }) + char_images.append({"name": char.get("name", f"char_{i}"), + "image_path": local_path, "prompt": prompt}) return {"character_images": char_images} async def handle_storyboard_generating(tenant_id, task_id, step_name, input_data, config): - """LLM generates storyboard script from lyrics + characters.""" lyrics = None char_images = None params = input_data.get("task_params", {}) - - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): if dep_output.get("calibrated_lyrics"): lyrics = dep_output["calibrated_lyrics"] @@ -701,12 +559,10 @@ async def handle_storyboard_generating(tenant_id, task_id, step_name, input_data lyrics = dep_output["lyrics"] if dep_output.get("character_images"): char_images = dep_output["character_images"] - if not lyrics: raise ValueError("上游步骤未提供歌词") - duration = params.get("duration", 240) # Default 4 min - + duration = params.get("duration", 240) prompt = f"""根据歌词和角色,生成MV分镜脚本。 歌词: @@ -727,7 +583,6 @@ async def handle_storyboard_generating(tenant_id, task_id, step_name, input_data - mood: 情绪/色调 确保分镜覆盖整首歌,每个分镜5-15秒。""" - try: from pipeline_service.llm_bridge import llm_call result = await llm_call(prompt) @@ -742,143 +597,68 @@ async def handle_storyboard_generating(tenant_id, task_id, step_name, input_data sb_path = os.path.join(work_dir, "storyboard.json") with open(sb_path, "w", encoding="utf-8") as f: json.dump(storyboard, f, ensure_ascii=False, indent=2) - return {"storyboard": storyboard, "storyboard_path": sb_path, "scene_count": len(storyboard)} async def handle_scene_video_generating(tenant_id, task_id, step_name, input_data, config): - """Generate scene videos on GPU using T2V/Ref2V with customer-selected model.""" + """通过 /v1/video/generations 生成场景视频。""" work_dir = _task_dir(task_id) - gpu_dir = _gpu_task_dir(task_id) - storyboard = None char_images = None - selected_model = "wan2.2" # Default fallback - - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): - if dep_output.get("storyboard"): - storyboard = dep_output["storyboard"] - if dep_output.get("character_images"): - char_images = dep_output["character_images"] - if dep_output.get("selected_model"): - selected_model = dep_output["selected_model"] + if dep_output.get("storyboard"): storyboard = dep_output["storyboard"] + if dep_output.get("character_images"): char_images = dep_output["character_images"] if not storyboard: raise ValueError("上游步骤未提供分镜脚本") - - logger.info(f"Using model: {selected_model} for scene video generation") - - await _run_gpu(f"mkdir -p {gpu_dir}/scenes") - - # Copy character images to GPU if available - if char_images: - for ci in char_images: - local = ci.get("image_path", "") - if local and os.path.exists(local): - remote = f"{gpu_dir}/characters/{os.path.basename(local)}" - await _copy_to_gpu(local, remote) + client = get_ktv_client() + await client._ensure_session() scene_videos = [] + for i, scene in enumerate(storyboard): desc = scene.get("description", "") duration = scene.get("end_time", 10) - scene.get("start_time", 5) - frames = int(duration * 24) # 24fps - # Determine if we use Ref2V (with character ref) or T2V - ref_image = None - if char_images and scene.get("characters"): - # Find matching character image - for ci in char_images: - if ci.get("name") in str(scene.get("characters", [])): - ref_image = f"{gpu_dir}/characters/{os.path.basename(ci['image_path'])}" - break + body = {"model": "wan2.6-t2v", "catelogid": "t2v", + "prompt": desc, "duration": str(int(duration))} + url = f"{client.base}/video/generations" + async with client._session.post(url, json=body) as resp: + data = await resp.json() - # Generate video based on selected model - if selected_model in ["wan2.2", "wan2.7"]: - # Local GPU generation - gpu_wan_dir = GPU_WAN22_DIR # Both use same base directory - - if ref_image: - gen_cmd = ( - f"cd {gpu_wan_dir} && source venv/bin/activate && " - f"python generate_ref2v.py --model {selected_model} --prompt '{desc}' " - f"--ref_image '{ref_image}' " - f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 " - f"--frames {frames}" - ) - else: - gen_cmd = ( - f"cd {gpu_wan_dir} && source venv/bin/activate && " - f"python generate_t2v.py --model {selected_model} --prompt '{desc}' " - f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 " - f"--frames {frames}" - ) - - stdout, stderr, rc = await _run_gpu(gen_cmd, timeout=600) - - elif selected_model == "vidu2.0": - # Cloud API generation (placeholder - needs actual Vidu API integration) - logger.warning(f"Vidu 2.0 cloud API not yet implemented, falling back to wan2.2") - if ref_image: - gen_cmd = ( - f"cd {GPU_WAN22_DIR} && source venv/bin/activate && " - f"python generate_ref2v.py --model wan2.2 --prompt '{desc}' " - f"--ref_image '{ref_image}' " - f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 " - f"--frames {frames}" - ) - else: - gen_cmd = ( - f"cd {GPU_WAN22_DIR} && source venv/bin/activate && " - f"python generate_t2v.py --model wan2.2 --prompt '{desc}' " - f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 " - f"--frames {frames}" - ) - stdout, stderr, rc = await _run_gpu(gen_cmd, timeout=600) - - else: - raise ValueError(f"Unknown model: {selected_model}") + # 视频生成是异步的 + taskid = data.get("taskid", "") + if taskid: + data = await client._poll(taskid) local_scene = os.path.join(work_dir, f"scene_{i:03d}.mp4") - await _copy_from_gpu(f"{gpu_dir}/scenes/scene_{i:03d}.mp4", local_scene) + video_url = data.get("output_url") or (data.get("video", [None]) or [None])[0] + if video_url: + await _download(video_url, local_scene) - scene_videos.append({ - "scene_id": scene.get("scene_id", i), - "video_path": local_scene, - "description": desc, - "duration": duration, - "model_used": selected_model, - }) + scene_videos.append({"scene_id": scene.get("scene_id", i), + "video_path": local_scene, "description": desc, + "duration": duration}) - return { - "scene_videos": scene_videos, - "scene_count": len(scene_videos), - "selected_model": selected_model, - } + return {"scene_videos": scene_videos, "scene_count": len(scene_videos), + "selected_model": "wan2.6-t2v"} async def handle_scene_video_evaluating(tenant_id, task_id, step_name, input_data, config): - """Evaluate scene video quality via VLM, retry if below threshold.""" - threshold = config.get("threshold", 7.0) - max_retry = config.get("max_retry", 3) - scene_videos = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): scene_videos = dep_output.get("scene_videos") - if scene_videos: - break - + if scene_videos: break if not scene_videos: raise ValueError("上游步骤未提供场景视频") - # Evaluate each scene (simplified: check file exists and has reasonable size) valid_scenes = [] for sv in scene_videos: path = sv.get("video_path", "") if os.path.exists(path) and os.path.getsize(path) > 10000: - sv["quality_score"] = 8.0 # Placeholder + sv["quality_score"] = 8.0 valid_scenes.append(sv) else: sv["quality_score"] = 0 @@ -888,29 +668,20 @@ async def handle_scene_video_evaluating(tenant_id, task_id, step_name, input_dat raise ValueError("所有场景视频质量不合格") avg_score = sum(s.get("quality_score", 0) for s in valid_scenes) / len(valid_scenes) - if avg_score < threshold: - raise ValueError(f"平均质量分 {avg_score:.1f} 低于阈值 {threshold}") - return {"scene_videos": valid_scenes, "avg_quality": avg_score} async def handle_scene_video_concatenating(tenant_id, task_id, step_name, input_data, config): - """Concatenate scene videos with ffmpeg, loop to match audio duration.""" work_dir = _task_dir(task_id) - scene_videos = None audio_duration = None - for dep_name, dep_output in input_data.items(): + for dep_output in input_data.values(): if isinstance(dep_output, dict): - if dep_output.get("scene_videos"): - scene_videos = dep_output["scene_videos"] - if dep_output.get("duration"): - audio_duration = dep_output["duration"] - + if dep_output.get("scene_videos"): scene_videos = dep_output["scene_videos"] + if dep_output.get("duration"): audio_duration = dep_output["duration"] if not scene_videos: raise ValueError("上游步骤未提供场景视频") - # Create concat file concat_list = os.path.join(work_dir, "concat_list.txt") with open(concat_list, "w") as f: for sv in scene_videos: @@ -918,27 +689,19 @@ async def handle_scene_video_concatenating(tenant_id, task_id, step_name, input_ if os.path.exists(path): f.write(f"file '{path}'\n") - # Concatenate concat_path = os.path.join(work_dir, "concat_video.mp4") - await _run_local( - f"ffmpeg -y -f concat -safe 0 -i '{concat_list}' -c copy '{concat_path}'" - ) + await _run_local(f"ffmpeg -y -f concat -safe 0 -i '{concat_list}' -c copy '{concat_path}'") - # Loop to match audio duration if needed final_path = os.path.join(work_dir, "final_video.mp4") if audio_duration and audio_duration > 0: - # Get concat duration stdout, _, _ = await _run_local( - f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 '{concat_path}'" - ) + f"ffprobe -v error -show_entries format=duration " + f"-of default=noprint_wrappers=1:nokey=1 '{concat_path}'") concat_dur = float(stdout.strip()) if stdout.strip() else 0 - - if concat_dur > 0 and concat_dur < audio_duration: + if 0 < concat_dur < audio_duration: loops = int(audio_duration / concat_dur) + 1 - await _run_local( - f"ffmpeg -y -stream_loop {loops} -i '{concat_path}' " - f"-t {audio_duration} -c:v libx264 -preset fast '{final_path}'" - ) + await _run_local(f"ffmpeg -y -stream_loop {loops} -i '{concat_path}' " + f"-t {audio_duration} -c:v libx264 -preset fast '{final_path}'") else: await _run_local(f"cp '{concat_path}' '{final_path}'") else: @@ -947,172 +710,105 @@ async def handle_scene_video_concatenating(tenant_id, task_id, step_name, input_ return {"final_video_path": final_path} -# ─── Final Synthesis ───────────────────────────────────────────────── +# ── Final Synthesis ──────────────────────────────────────────────── async def handle_ktv_synthesizing(tenant_id, task_id, step_name, input_data, config): - """Synthesize final KTV (dual-track) + MTV (single-track) videos.""" work_dir = _task_dir(task_id) + video_path = ass_path = vocals_path = no_vocals_path = None + for dep_output in input_data.values(): + if not isinstance(dep_output, dict): continue + for k in ("final_video_path", "video_path"): + if dep_output.get(k) and not video_path: + video_path = dep_output[k] + if dep_output.get("ass_path"): ass_path = dep_output["ass_path"] + if dep_output.get("vocals_path"): vocals_path = dep_output["vocals_path"] + if dep_output.get("no_vocals_path"): no_vocals_path = dep_output["no_vocals_path"] - video_path = None - ass_path = None - vocals_path = None - no_vocals_path = None - has_original_video = False + if not ass_path: raise ValueError("缺少字幕文件") + if not video_path: raise ValueError("缺少视频源") - for dep_name, dep_output in input_data.items(): - if isinstance(dep_output, dict): - if dep_output.get("final_video_path"): - video_path = dep_output["final_video_path"] - if dep_output.get("ass_path"): - ass_path = dep_output["ass_path"] - if dep_output.get("vocals_path"): - vocals_path = dep_output["vocals_path"] - if dep_output.get("no_vocals_path"): - no_vocals_path = dep_output["no_vocals_path"] - if dep_output.get("video_path") and not video_path: - # Mode B: use original video - video_path = dep_output["video_path"] - has_original_video = True - - if not ass_path: - raise ValueError("缺少字幕文件") - - # Determine audio tracks - if has_original_video and not vocals_path: - # Mode B: extract from video - vocals_path = os.path.join(work_dir, "vocals.wav") - no_vocals_path = os.path.join(work_dir, "no_vocals.wav") - if not os.path.exists(vocals_path): - raise ValueError("Demucs 步骤未提供人声轨道") - - if not video_path: - raise ValueError("缺少视频源") - - # KTV version: dual audio (vocals + no_vocals) with subtitle overlay ktv_path = os.path.join(work_dir, "ktv_final.mp4") mtv_path = os.path.join(work_dir, "mtv_final.mp4") - # KTV: video + vocals_track + no_vocals_track + subtitle burn if vocals_path and no_vocals_path: - ktv_cmd = ( - f"ffmpeg -y -i '{video_path}' -i '{vocals_path}' -i '{no_vocals_path}' " - f"-filter_complex \"[0:v]ass='{ass_path}'[v]\" " - f"-map '[v]' -map 1:a -map 2:a " - f"-c:v libx264 -preset fast -c:a aac -b:a 192k " - f"-metadata:s:a:0 title='Vocals' -metadata:s:a:1 title='Accompaniment' " - f"'{ktv_path}'" - ) + ktv_cmd = (f"ffmpeg -y -i '{video_path}' -i '{vocals_path}' -i '{no_vocals_path}' " + f"-filter_complex \"[0:v]ass='{ass_path}'[v]\" " + f"-map '[v]' -map 1:a -map 2:a " + f"-c:v libx264 -preset fast -c:a aac -b:a 192k " + f"-metadata:s:a:0 title='Vocals' -metadata:s:a:1 title='Accompaniment' " + f"'{ktv_path}'") else: - ktv_cmd = ( - f"ffmpeg -y -i '{video_path}' " - f"-vf \"ass='{ass_path}'\" " - f"-c:v libx264 -preset fast -c:a aac -b:a 192k " - f"'{ktv_path}'" - ) + ktv_cmd = (f"ffmpeg -y -i '{video_path}' -vf \"ass='{ass_path}'\" " + f"-c:v libx264 -preset fast -c:a aac -b:a 192k '{ktv_path}'") stdout, stderr, rc = await _run_local(ktv_cmd, timeout=600) - if rc != 0: - raise ValueError(f"KTV合成失败: {stderr}") + if rc != 0: raise ValueError(f"KTV合成失败: {stderr}") - # MTV: single audio (original/mix) with subtitle - mtv_cmd = ( - f"ffmpeg -y -i '{video_path}' " - f"-vf \"ass='{ass_path}'\" " - f"-c:v libx264 -preset fast -c:a aac -b:a 192k " - f"'{mtv_path}'" - ) + mtv_cmd = (f"ffmpeg -y -i '{video_path}' -vf \"ass='{ass_path}'\" " + f"-c:v libx264 -preset fast -c:a aac -b:a 192k '{mtv_path}'") stdout, stderr, rc = await _run_local(mtv_cmd, timeout=600) - if rc != 0: - logger.warning(f"MTV合成失败,仅输出KTV版本: {stderr}") - - result = { - "ktv_path": ktv_path, - "subtitle_path": ass_path, - } - if os.path.exists(mtv_path): - result["mtv_path"] = mtv_path + if rc != 0: logger.warning(f"MTV合成失败,仅输出KTV版本: {stderr}") + result = {"ktv_path": ktv_path, "subtitle_path": ass_path} + if os.path.exists(mtv_path): result["mtv_path"] = mtv_path return result -# ─── Quality Gate Wrappers ──────────────────────────────────────────── -# 步骤 9-10 (音乐), 11 (角色设计), 12 (角色图), 13 (分镜), 17 (合成) 带质量门控 - +# ── Quality Gate ─────────────────────────────────────────────────── def _make_quality_handler(original_handler, eval_func): - """创建带质量门控的 wrapper handler。 - - 流程: handler → evaluator → 不达标重试(最多3次) → 仍不达标暂停等待人工 - """ async def wrapper(tenant_id, task_id, step_name, input_data, config): from app.quality_gate import with_quality_gate - - # 获取 version(用于人工任务记录) version = 1 if isinstance(config, dict): version = config.get("version", 1) if not version and isinstance(input_data, dict): tp = input_data.get("task_params", {}) - if isinstance(tp, dict): - version = tp.get("version", 1) - - result = await with_quality_gate( - task_id=task_id, - step_name=step_name, - version=version, - handler=original_handler, - evaluator=eval_func, - tenant_id=tenant_id, - input_data=input_data, - config=config, - ) - return result - + if isinstance(tp, dict): version = tp.get("version", 1) + return await with_quality_gate( + task_id=task_id, step_name=step_name, version=version, + handler=original_handler, evaluator=eval_func, + tenant_id=tenant_id, input_data=input_data, config=config) wrapper.__name__ = f"quality_{original_handler.__name__}" wrapper.__qualname__ = wrapper.__name__ return wrapper -# 注册用的质量门控 handlers -quality_music_polling = _make_quality_handler(handle_music_polling, eval_music_quality) -quality_character_designing = _make_quality_handler(handle_character_designing, eval_character_design) -quality_character_image_generating = _make_quality_handler(handle_character_image_generating, eval_character_image) -quality_storyboard_generating = _make_quality_handler(handle_storyboard_generating, eval_storyboard) -quality_scene_video_evaluating = _make_quality_handler(handle_scene_video_evaluating, eval_scene_video_quality) -quality_ktv_synthesizing = _make_quality_handler(handle_ktv_synthesizing, eval_ktv_synthesis) +quality_music_polling = _make_quality_handler(handle_music_polling, eval_music_quality) +quality_character_designing = _make_quality_handler(handle_character_designing, eval_character_design) +quality_character_image_generating = _make_quality_handler(handle_character_image_generating, eval_character_image) +quality_storyboard_generating = _make_quality_handler(handle_storyboard_generating, eval_storyboard) +quality_scene_video_evaluating = _make_quality_handler(handle_scene_video_evaluating, eval_scene_video_quality) +quality_ktv_synthesizing = _make_quality_handler(handle_ktv_synthesizing, eval_ktv_synthesis) -# ─── Registration (adapter) ─────────────────────────────────────────── +# ── Registration ─────────────────────────────────────────────────── KTV_HANDLERS = { - "audio_preparing": handle_audio_preparing, - "video_preparing": handle_video_preparing, - "demucs_separating": handle_demucs_separating, - "lyric_calibrating": handle_lyric_calibrating, - "subtitle_rendering": handle_subtitle_rendering, - "subtitle_exporting": handle_subtitle_exporting, - "lyric_generating": handle_lyric_generating, - "lyric_evaluating": handle_lyric_evaluating, - "music_generating": handle_music_generating, - "music_polling": quality_music_polling, # 质量门控 ✓ - "character_designing": quality_character_designing, # 质量门控 ✓ - "character_image_generating": quality_character_image_generating, # 质量门控 ✓ - "storyboard_generating": quality_storyboard_generating, # 质量门控 ✓ - "model_selecting": handle_model_selecting, # 客户选择模型 ✓ - "scene_video_generating": handle_scene_video_generating, - "scene_video_evaluating": quality_scene_video_evaluating, # 质量门控 ✓ - "scene_video_concatenating": handle_scene_video_concatenating, - "ktv_synthesizing": quality_ktv_synthesizing, # 质量门控 ✓ + "audio_preparing": handle_audio_preparing, + "video_preparing": handle_video_preparing, + "demucs_separating": handle_demucs_separating, + "lyric_calibrating": handle_lyric_calibrating, + "subtitle_rendering": handle_subtitle_rendering, + "subtitle_exporting": handle_subtitle_exporting, + "lyric_generating": handle_lyric_generating, + "lyric_evaluating": handle_lyric_evaluating, + "music_generating": handle_music_generating, + "music_polling": quality_music_polling, + "character_designing": quality_character_designing, + "character_image_generating": quality_character_image_generating, + "storyboard_generating": quality_storyboard_generating, + "model_selecting": handle_model_selecting, + "scene_video_generating": handle_scene_video_generating, + "scene_video_evaluating": quality_scene_video_evaluating, + "scene_video_concatenating": handle_scene_video_concatenating, + "ktv_synthesizing": quality_ktv_synthesizing, } def load_ktv_adapter(): - """Register all KTV step handlers via pipeline_service handler registry.""" from pipeline_service.handler import register_handler - from app.quality_gate import ( - eval_music_quality, eval_character_design, eval_character_image, - eval_storyboard, eval_ktv_synthesis, - ) for step_type, fn in KTV_HANDLERS.items(): register_handler(step_type, fn) - logger.info(f"Registered {len(KTV_HANDLERS)} KTV handlers via adapter (6 with quality gate)") + logger.info(f"Registered {len(KTV_HANDLERS)} KTV handlers (via llmage API, " + f"base={API_BASE})")