pipeline-app/app/ktv_adapter.py

817 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""KTV pipeline step handlers — 通过 llmage API 调用 GPU 服务。
配置方式(环境变量):
KTV_API_BASE: llmage API 地址,默认 https://token.opencomputing.cn/llmage/v1
KTV_API_KEY: Bearer token
KTV_FILE_BASE: 文件服务地址,默认同 KTV_API_BASE 所在域
步骤→模型映射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 time
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,
)
from app.model_selector import handle_model_selecting
logger = logging.getLogger("pipeline.handlers.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_DIR = "/data/pipeline/ktv"
GPU_HOST = None # 不再直连 GPU保留变量兼容旧引用新代码不应使用
def _task_dir(task_id: str) -> str:
d = os.path.join(LOCAL_WORK_DIR, task_id)
os.makedirs(d, exist_ok=True)
return d
# ── 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")
# 全局单例,惰性初始化
_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)
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
except asyncio.TimeoutError:
proc.kill()
return "", "timeout", -1
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)
# ── Media Preparation ──────────────────────────────────────────────
async def handle_audio_preparing(tenant_id, task_id, step_name, input_data, config):
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 参数")
audio_path = os.path.join(work_dir, "original_audio.mp3")
if audio_url.startswith("http"):
await _download(audio_url, audio_path)
else:
await _run_local(f"cp '{audio_url}' '{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(".")}
async def handle_video_preparing(tenant_id, task_id, step_name, input_data, config):
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 _download(video_url, video_path)
else:
await _run_local(f"cp '{video_url}' '{video_path}'")
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 "
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}
# ── Demucs ─────────────────────────────────────────────────────────
async def handle_demucs_separating(tenant_id, task_id, step_name, input_data, config):
"""通过 API 调 ky-demucs-separate。固定使用 separate_full 模式。"""
work_dir = _task_dir(task_id)
audio_path = None
for dep_output in input_data.values():
if isinstance(dep_output, dict):
audio_path = dep_output.get("audio_path")
if audio_path: break
if not audio_path:
raise ValueError("上游步骤未提供 audio_path")
client = get_ktv_client()
result = await client.call(STEP_MODELS["demucs_separating"],
{"audio_file": audio_path,
"task_type": "separate_full"})
if result.get("error"):
raise ValueError(f"Demucs 分离失败: {result['error']}")
# 下载结果文件
vocals_local = os.path.join(work_dir, "vocals.wav")
accompaniment_local = os.path.join(work_dir, "accompaniment.wav")
if result.get("vocals_url"):
await _download(result["vocals_url"], vocals_local)
if result.get("accompaniment_url"):
await _download(result["accompaniment_url"], accompaniment_local)
return {"vocals_path": vocals_local,
"no_vocals_path": accompaniment_local,
"accompaniment_path": accompaniment_local,
"mode": "separate_full",
"usage": result.get("usage", {})}
# ── Lyric Calibration ──────────────────────────────────────────────
async def handle_lyric_calibrating(tenant_id, task_id, step_name, input_data, config):
"""ASR 通过 API + LLM 校准。"""
work_dir = _task_dir(task_id)
params = input_data.get("task_params", {})
lyrics_text = params.get("lyrics", params.get("lyrics_text", ""))
# 找人声文件
vocals_path = None
for dep_output in input_data.values():
if isinstance(dep_output, dict):
vocals_path = dep_output.get("vocals_path")
if vocals_path: break
if not vocals_path:
raise ValueError("上游步骤未提供 vocals_path")
if not lyrics_text:
raise ValueError("缺少 lyrics 参数")
# 通过 API 做 ASR
client = get_ktv_client()
asr_result = await client.call(STEP_MODELS["asr_transcribing"],
{"audio_file": vocals_path})
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','')}")
# LLM 校准
calibrated = await _llm_calibrate(lyrics_text, asr_timings)
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)}
async def _llm_calibrate(lyrics_text: str, asr_timings: list) -> list:
prompt = f"""你是一个歌词时间轴校准专家。
原始歌词文本:
{lyrics_text}
ASR识别的时间戳:
{json.dumps(asr_timings, ensure_ascii=False)}
请将原始歌词的每一句与ASR时间戳对齐输出JSON数组每个元素包含:
- text: 歌词文本
- start: 开始时间(秒,浮点数)
- end: 结束时间(秒,浮点数)
要求:
1. 保持原始歌词的文本和顺序
2. 时间戳以ASR结果为基础进行微调
3. 确保时间不重叠,每句之间留适当间隔
4. 只输出JSON不要其他内容"""
try:
from pipeline_service.llm_bridge import llm_call
result = await llm_call(prompt)
result = result.strip()
if result.startswith("```"):
result = result.split("\n", 1)[1].rsplit("```", 1)[0]
return json.loads(result)
except Exception as e:
logger.warning(f"LLM calibration failed, using ASR timings directly: {e}")
return asr_timings
# ── Subtitle ───────────────────────────────────────────────────────
async def handle_subtitle_rendering(tenant_id, task_id, step_name, input_data, config):
"""本地生成 ASS 字幕(轻量操作,不走 API"""
work_dir = _task_dir(task_id)
calibrated = None
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 not calibrated:
raise ValueError("上游步骤未提供 calibrated_lyrics")
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):
header = """[Script Info]
Title: KTV Karaoke Subtitles
ScriptType: v4.00+
PlayResX: 1920
PlayResY: 1080
WrapStyle: 0
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: KTV,Source Han Sans SC,72,&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,1,0,1,3,1,2,40,40,60,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
with open(path, "w", encoding="utf-8") as f:
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")
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:
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}"
async def handle_subtitle_exporting(tenant_id, task_id, step_name, input_data, config):
ass_path = None
for dep_output in input_data.values():
if isinstance(dep_output, dict):
ass_path = dep_output.get("ass_path")
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 Gen / Eval ───────────────────────────────────────────────
async def handle_lyric_generating(tenant_id, task_id, step_name, input_data, config):
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参数")
prompt = f"""请创作一首{style}风格的{language}歌词。
主题/大纲: {topic}
要求:
1. 包含完整结构: 主歌(Verse)、副歌(Chorus)、桥段(Bridge)
2. 每句歌词节奏感强,适合演唱
3. 押韵自然,情感真实
4. 总长度适合3-5分钟歌曲
直接输出歌词文本,标注段落结构。"""
try:
from pipeline_service.llm_bridge import llm_call
lyrics = await llm_call(prompt)
return {"lyrics": lyrics.strip(), "topic": topic, "style": style}
except Exception as e:
raise ValueError(f"歌词生成失败: {e}")
async def handle_lyric_evaluating(tenant_id, task_id, step_name, input_data, config):
threshold = config.get("threshold", 8.5)
lyrics = None
for dep_output in input_data.values():
if isinstance(dep_output, dict):
lyrics = dep_output.get("lyrics")
if lyrics: break
if not lyrics:
raise ValueError("上游步骤未提供歌词")
prompt = f"""请从以下维度评估这首歌词的质量(1-10分):
1. 韵律节奏: 押韵、节奏感、可唱性
2. 情感表达: 情感真实度、共鸣力
3. 文学性: 用词、意象、修辞
4. 结构完整: 段落编排、层次感
5. 商业潜力: 流行度、记忆点
歌词:
{lyrics}
输出JSON: {{"score": 8.5, "dimensions": {{...}}, "suggestions": "..."}}
只输出JSON。"""
try:
from pipeline_service.llm_bridge import llm_call
result = await llm_call(prompt)
result = result.strip()
if result.startswith("```"):
result = result.split("\n", 1)[1].rsplit("```", 1)[0]
evaluation = json.loads(result)
score = evaluation.get("score", 0)
except Exception:
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}
# ── Music Generation ───────────────────────────────────────────────
async def handle_music_generating(tenant_id, task_id, step_name, input_data, config):
"""提交音乐生成到外部 APISuno/MiniMax"""
lyrics = None
for dep_output in input_data.values():
if isinstance(dep_output, dict):
lyrics = dep_output.get("lyrics")
if lyrics: break
if not lyrics:
raise ValueError("上游步骤未提供歌词")
params = input_data.get("task_params", {})
music_service = params.get("music_service", "suno")
style = params.get("music_style", "pop")
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"}
async def handle_music_polling(tenant_id, task_id, step_name, input_data, config):
job_info = None
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 not job_info:
raise ValueError("上游步骤未提供 music_job_id")
work_dir = _task_dir(task_id)
music_path = os.path.join(work_dir, "generated_music.mp3")
return {"music_path": music_path, "music_job_id": job_info.get("music_job_id"),
"status": "completed"}
# ── Character & Video ──────────────────────────────────────────────
async def handle_character_designing(tenant_id, task_id, step_name, input_data, config):
lyrics = None
params = input_data.get("task_params", {})
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
style = params.get("visual_style", "anime")
prompt = f"""根据以下歌词设计MV角色方案。
歌词:
{lyrics}
视觉风格: {style}
请设计1-3个角色每个角色包含:
1. 角色名称
2. 外貌描述用于AI图像生成的详细prompt
3. 性格特征
4. 在MV中的角色定位
输出JSON数组。"""
try:
from pipeline_service.llm_bridge import llm_call
result = await llm_call(prompt)
result = result.strip()
if result.startswith("```"):
result = result.split("\n", 1)[1].rsplit("```", 1)[0]
characters = json.loads(result)
except Exception as e:
raise ValueError(f"角色设计失败: {e}")
return {"characters": characters, "visual_style": style}
async def handle_character_image_generating(tenant_id, task_id, step_name, input_data, config):
"""通过 /v1/image/generations 生成角色图。"""
characters = None
for dep_output in input_data.values():
if isinstance(dep_output, dict):
characters = dep_output.get("characters")
if characters: break
if not characters:
raise ValueError("上游步骤未提供角色设计")
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
# 调 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")
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})
return {"character_images": char_images}
async def handle_storyboard_generating(tenant_id, task_id, step_name, input_data, config):
lyrics = None
char_images = None
params = input_data.get("task_params", {})
for dep_output in input_data.values():
if isinstance(dep_output, dict):
if dep_output.get("calibrated_lyrics"):
lyrics = dep_output["calibrated_lyrics"]
elif dep_output.get("lyrics"):
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)
prompt = f"""根据歌词和角色生成MV分镜脚本。
歌词:
{json.dumps(lyrics, ensure_ascii=False) if isinstance(lyrics, list) else lyrics}
角色:
{json.dumps(char_images, ensure_ascii=False) if char_images else "无特定角色"}
视频总时长: {duration}
请输出JSON数组每个分镜包含:
- scene_id: 分镜编号
- start_time: 开始秒数
- end_time: 结束秒数
- description: 场景描述英文用于视频生成prompt
- characters: 出现的角色
- camera: 镜头运动描述
- mood: 情绪/色调
确保分镜覆盖整首歌每个分镜5-15秒。"""
try:
from pipeline_service.llm_bridge import llm_call
result = await llm_call(prompt)
result = result.strip()
if result.startswith("```"):
result = result.split("\n", 1)[1].rsplit("```", 1)[0]
storyboard = json.loads(result)
except Exception as e:
raise ValueError(f"分镜生成失败: {e}")
work_dir = _task_dir(task_id)
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):
"""通过 /v1/video/generations 生成场景视频,模型由客户选择。"""
work_dir = _task_dir(task_id)
storyboard = None
selected_model = "wan2.7-t2v" # 默认
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("selected_model"): selected_model = dep_output["selected_model"]
if not storyboard:
raise ValueError("上游步骤未提供分镜脚本")
logger.info(f"Using model: {selected_model}")
client = get_ktv_client()
await client._ensure_session()
scene_videos = []
for i, scene in enumerate(storyboard):
desc = scene.get("description", "")
duration = int(scene.get("end_time", 10) - scene.get("start_time", 5))
body = {"model": selected_model, "catelogid": "t2v",
"prompt": desc, "duration": str(duration)}
url = f"{client.base}/video/generations"
async with client._session.post(url, json=body) as resp:
data = await resp.json()
taskid = data.get("taskid", "")
if taskid:
data = await client._poll(taskid)
local_scene = os.path.join(work_dir, f"scene_{i:03d}.mp4")
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": i, "video_path": local_scene,
"description": desc, "duration": duration})
return {"scene_videos": scene_videos, "scene_count": len(scene_videos),
"selected_model": selected_model}
async def handle_scene_video_evaluating(tenant_id, task_id, step_name, input_data, config):
scene_videos = None
for dep_output in input_data.values():
if isinstance(dep_output, dict):
scene_videos = dep_output.get("scene_videos")
if scene_videos: break
if not scene_videos:
raise ValueError("上游步骤未提供场景视频")
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
valid_scenes.append(sv)
else:
sv["quality_score"] = 0
logger.warning(f"Scene {sv.get('scene_id')} missing or too small: {path}")
if not valid_scenes:
raise ValueError("所有场景视频质量不合格")
avg_score = sum(s.get("quality_score", 0) for s in valid_scenes) / len(valid_scenes)
return {"scene_videos": valid_scenes, "avg_quality": avg_score}
async def handle_scene_video_concatenating(tenant_id, task_id, step_name, input_data, config):
work_dir = _task_dir(task_id)
scene_videos = None
audio_duration = None
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 not scene_videos:
raise ValueError("上游步骤未提供场景视频")
concat_list = os.path.join(work_dir, "concat_list.txt")
with open(concat_list, "w") as f:
for sv in scene_videos:
path = sv.get("video_path", "")
if os.path.exists(path):
f.write(f"file '{path}'\n")
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}'")
final_path = os.path.join(work_dir, "final_video.mp4")
if audio_duration and audio_duration > 0:
stdout, _, _ = await _run_local(
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 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}'")
else:
await _run_local(f"cp '{concat_path}' '{final_path}'")
else:
await _run_local(f"cp '{concat_path}' '{final_path}'")
return {"final_video_path": final_path}
# ── Final Synthesis ────────────────────────────────────────────────
async def handle_ktv_synthesizing(tenant_id, task_id, step_name, input_data, config):
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"]
if not ass_path: raise ValueError("缺少字幕文件")
if not video_path: raise ValueError("缺少视频源")
ktv_path = os.path.join(work_dir, "ktv_final.mp4")
mtv_path = os.path.join(work_dir, "mtv_final.mp4")
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}'")
else:
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}")
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
return result
# ── Quality Gate ───────────────────────────────────────────────────
def _make_quality_handler(original_handler, eval_func):
async def wrapper(tenant_id, task_id, step_name, input_data, config):
from app.quality_gate import with_quality_gate
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)
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
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 ───────────────────────────────────────────────────
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,
}
def load_ktv_adapter():
from pipeline_service.handler import register_handler
for step_type, fn in KTV_HANDLERS.items():
register_handler(step_type, fn)
logger.info(f"Registered {len(KTV_HANDLERS)} KTV handlers (via llmage API, "
f"base={API_BASE})")