diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index b1b8d1b9e..ce19b9701 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +from datetime import datetime, timezone from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session @@ -77,10 +78,16 @@ def create_render_job( from app.tasks.ai_avatar_render import execute_ai_avatar_render execute_ai_avatar_render.delay(job.id) - except Exception: - logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id) + except Exception as exc: + logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc) + job.status = "failed" + job.error_message = f"任务提交失败:{exc}" + job.updated_at = datetime.now(timezone.utc) + svc.db.commit() + svc.db.refresh(job) + return AiAvatarRenderJobResponse.model_validate(job) - return job + return AiAvatarRenderJobResponse.model_validate(job) # ── GET /jobs — 任务列表 ───────────────────────────────────────────────── @@ -172,10 +179,16 @@ def retry_render_job( from app.tasks.ai_avatar_render import execute_ai_avatar_render execute_ai_avatar_render.delay(job.id) - except Exception: - logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id) + except Exception as exc: + logger.exception("Celery 任务投递失败(重试): job_id=%s err=%s", job.id, exc) + job.status = "failed" + job.error_message = f"任务提交失败:{exc}" + job.updated_at = datetime.now(timezone.utc) + svc.db.commit() + svc.db.refresh(job) + return AiAvatarRenderJobResponse.model_validate(job) - return job + return AiAvatarRenderJobResponse.model_validate(job) @@ -199,7 +212,11 @@ def generate_avatar_smart_cover( raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL") try: - cover_url = generate_smart_cover(video_url, max_frames=body.max_frames) + cover_url = generate_smart_cover( + video_url, + max_frames=body.max_frames, + title_config=getattr(body, "title_config", None), + ) except Exception as exc: logger.error( "智能封面生成异常: user=%s video_url=%s err=%s", diff --git a/apps/api/app/schemas/ai_avatar_render.py b/apps/api/app/schemas/ai_avatar_render.py index 88832017d..84761c2f4 100644 --- a/apps/api/app/schemas/ai_avatar_render.py +++ b/apps/api/app/schemas/ai_avatar_render.py @@ -110,10 +110,13 @@ class AiAvatarRenderProgressResponse(BaseModel): class SmartCoverRequest(BaseModel): - """智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧.""" + """智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧 + 可选标题 drawtext 叠加.""" video_url: str = Field(..., description="数字人视频 URL(对口型/渲染成片)") max_frames: int = Field(5, ge=1, le=10, description="抽帧数量(默认 5)") + title_config: Optional[dict[str, Any]] = Field( + None, description="标题配置;传入时在封面上用 drawtext 叠加标题(竖屏 720x1280)" + ) class SmartCoverResponse(BaseModel): diff --git a/apps/api/app/services/ai_avatar_cover_service.py b/apps/api/app/services/ai_avatar_cover_service.py index c1b312432..b109cd079 100644 --- a/apps/api/app/services/ai_avatar_cover_service.py +++ b/apps/api/app/services/ai_avatar_cover_service.py @@ -11,6 +11,8 @@ from __future__ import annotations import logging +import os +import subprocess import tempfile import uuid from pathlib import Path @@ -156,13 +158,79 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str: return "" -def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-avatar/covers") -> str: +def apply_title_to_cover(local_frame: str, *, title_config: dict | None) -> str: + """用 ffmpeg drawtext 在封面图上叠加标题,返回叠加后图片的本地路径. + + ffmpeg 失败时回退返回原始 local_frame。竖屏封面按 720x1280 计算位置。 + """ + if not title_config or not isinstance(title_config, dict): + return local_frame + text = (title_config.get("text") or title_config.get("content") or "").strip() + if not text: + return local_frame + enabled = title_config.get("enabled", True) + if not enabled: + return local_frame + + try: + from packages.domain.video_filter_builder import build_title_drawtext_filter + + drawtext_filter = build_title_drawtext_filter( + title_config, + output_width=720, + output_height=1280, + ) + if not drawtext_filter: + return local_frame + + base, ext = os.path.splitext(local_frame) + titled_path = f"{base}_titled{ext or '.jpg'}" + cmd = [ + "ffmpeg", + "-i", + local_frame, + "-vf", + drawtext_filter, + "-y", + titled_path, + ] + logger.info("[数字人封面] 叠加标题: text=%s", text[:30]) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + logger.warning( + "[数字人封面] drawtext 失败,回退无标题: exit=%s stderr=%s", + result.returncode, + (result.stderr or "")[-300:], + ) + return local_frame + if not os.path.exists(titled_path) or os.path.getsize(titled_path) == 0: + logger.warning("[数字人封面] drawtext 输出为空,回退无标题") + return local_frame + return titled_path + except Exception as exc: + logger.warning("[数字人封面] 标题叠加异常,回退无标题: %s", exc, exc_info=True) + return local_frame + + +def persist_cover_to_oss( + frame_url: str, + *, + job_id: str = "", + prefix: str = "ai-avatar/covers", + title_config: dict | None = None, +) -> str: """下载帧图并转存到 OSS,返回公网封面 URL. Args: frame_url: MediaKit 返回的临时帧图 URL job_id: 关联任务 ID(用于 OSS key 命名) prefix: OSS key 前缀 + title_config: 可选标题配置;传入时用 drawtext 叠加标题(竖屏 720x1280) Returns: OSS 公网 URL;失败回退原始 frame_url @@ -170,6 +238,7 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai- if not frame_url: return "" tmp_path: Optional[str] = None + titled_path: Optional[str] = None try: import httpx @@ -189,12 +258,21 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai- storage = get_shared_storage_service() token = job_id or uuid.uuid4().hex[:12] cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg" + + upload_path = apply_title_to_cover(tmp_path, title_config=title_config) + if upload_path != tmp_path: + titled_path = upload_path + public_url = storage.upload_file( - file_or_path=tmp_path, + file_or_path=upload_path, storage_key=cover_key, content_type="image/jpeg", ) - logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key) + logger.info( + "[数字人封面] 封面已转存 OSS: key=%s titled=%s", + cover_key, + bool(titled_path), + ) # 私有桶:返回预签名 URL(前端才能加载) if public_url: signed = storage.get_download_url(cover_key, expires_seconds=86400) @@ -204,19 +282,32 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai- logger.warning("[数字人封面] 封面转存 OSS 失败,返回原始 URL", exc_info=True) return frame_url finally: - if tmp_path: - try: - Path(tmp_path).unlink(missing_ok=True) - except Exception: - pass + for p in (tmp_path, titled_path): + if p: + try: + Path(p).unlink(missing_ok=True) + except Exception: + pass -def generate_smart_cover(video_url: str, *, job_id: str = "", max_frames: int = 5) -> str: - """一站式:MediaKit 智能抽帧选最佳 → 转存 OSS,返回封面公网 URL. +def generate_smart_cover( + video_url: str, + *, + job_id: str = "", + max_frames: int = 5, + title_config: dict | None = None, +) -> str: + """一站式:MediaKit 智能抽帧选最佳 → (可选)drawtext 叠加标题 → 转存 OSS. 供独立封面接口与渲染管线复用。失败返回空字符串。 + + Args: + video_url: 可公网访问的视频 URL + job_id: 关联任务 ID + max_frames: 抽帧数量 + title_config: 可选标题配置;传入时在封面上叠加 drawtext 标题(竖屏 720x1280) """ best_frame = select_best_cover_frame(video_url, max_frames=max_frames) if not best_frame: return "" - return persist_cover_to_oss(best_frame, job_id=job_id) + return persist_cover_to_oss(best_frame, job_id=job_id, title_config=title_config) diff --git a/apps/api/app/services/ai_avatar_render_service.py b/apps/api/app/services/ai_avatar_render_service.py index 905afbddc..92952663c 100644 --- a/apps/api/app/services/ai_avatar_render_service.py +++ b/apps/api/app/services/ai_avatar_render_service.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging import os +import subprocess import tempfile import uuid from datetime import datetime, timezone @@ -24,7 +25,6 @@ from packages.adapters.sqlalchemy_impl.models import ( ScriptModel, ) from packages.domain.video_filter_builder import ( - build_cover_extract_command, build_title_drawtext_filter, ) from packages.shared.storage import get_shared_storage_service @@ -257,7 +257,7 @@ class AiAvatarRenderService: with tempfile.TemporaryDirectory() as tmpdir: output_video_path = os.path.join(tmpdir, "output.mp4") - cmd = self._build_ffmpeg_command( + cmd_list = self._build_ffmpeg_command( input_video=input_video_path, b_roll_segments=job.b_roll_segments, filter_complex=filter_complex, @@ -265,9 +265,25 @@ class AiAvatarRenderService: output_path=output_video_path, ) - exit_code = os.system(cmd) - if exit_code != 0: - raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed") + try: + render_result = subprocess.run( + cmd_list, + capture_output=True, + text=True, + timeout=600, + ) + except subprocess.TimeoutExpired as exc: + raise AiAvatarRenderError( + "FFmpeg 渲染超时(600s)", + code="FFmpegTimeout", + ) from exc + + if render_result.returncode != 0: + stderr_tail = (render_result.stderr or "").strip()[-800:] + raise AiAvatarRenderError( + f"FFmpeg 渲染失败,退出码: {render_result.returncode}, stderr: {stderr_tail}", + code="FFmpegFailed", + ) job.progress = 80 self.db.commit() @@ -276,11 +292,27 @@ class AiAvatarRenderService: cover_path = "" if job.cover_config: cover_path = os.path.join(tmpdir, "cover.jpg") - cover_cmd = build_cover_extract_command(job.cover_config, cover_path) - cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path) - cover_exit = os.system(cover_cmd) - if cover_exit != 0: - logger.warning("封面提取失败,跳过: %s", cover_cmd) + cover_cmd = self._build_cover_extract_cmd( + cover_config=job.cover_config, + input_video=output_video_path, + output_path=cover_path, + ) + try: + cover_result = subprocess.run( + cover_cmd, + capture_output=True, + text=True, + timeout=60, + ) + if cover_result.returncode != 0: + logger.warning( + "封面提取失败(非致命),跳过: exit=%s stderr=%s", + cover_result.returncode, + (cover_result.stderr or "")[-300:], + ) + cover_path = "" + except Exception as cover_err: + logger.warning("封面提取异常(非致命),跳过: %s", cover_err) cover_path = "" job.progress = 90 @@ -290,7 +322,7 @@ class AiAvatarRenderService: output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4") job.output_video_url = output_video_url - # 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧; + # 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加); # MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path smart_cover_url = "" if output_video_url: @@ -299,7 +331,12 @@ class AiAvatarRenderService: generate_smart_cover, ) - smart_cover_url = generate_smart_cover(output_video_url, job_id=job_id, max_frames=5) + smart_cover_url = generate_smart_cover( + output_video_url, + job_id=job_id, + max_frames=5, + title_config=job.title_config, + ) except Exception: logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True) @@ -360,12 +397,14 @@ class AiAvatarRenderService: job.updated_at = datetime.now(timezone.utc) self.db.commit() logger.error("渲染任务失败 [%s]: %s", job_id, exc) + raise except Exception as exc: job.status = "failed" job.error_message = f"渲染异常: {str(exc)}" job.updated_at = datetime.now(timezone.utc) self.db.commit() logger.exception("渲染任务异常 [%s]", job_id) + raise def _download_video(self, url: str) -> str: """下载视频到临时文件.""" @@ -391,24 +430,73 @@ class AiAvatarRenderService: filter_complex: str, final_label: Optional[str], output_path: str, - ) -> str: - """构建 FFmpeg 命令.""" - # 输入文件 - inputs = f"-i {input_video}" + ) -> list[str]: + """构建 FFmpeg 命令(list 形式,shell=False). + + 根因修复 #1798 P0:OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符, + os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被 + 当成独立命令报 sh: -filter_complex: not found(exit 127 → Python 32512)。 + list + shell=False 彻底规避 shell 转义问题。 + """ + cmd: list[str] = ["ffmpeg", "-i", input_video] for seg in b_roll_segments: asset_url = seg.get("asset_url", "") if asset_url: - inputs += f" -i {asset_url}" + cmd.extend(["-i", asset_url]) - # 滤镜 if filter_complex and final_label: - filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"' + cmd.extend(["-filter_complex", filter_complex, "-map", f"[{final_label}]"]) elif filter_complex: - filter_arg = f'-filter_complex "{filter_complex}"' - else: - filter_arg = "" + cmd.extend(["-filter_complex", filter_complex]) - return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset veryfast -crf 23 -y {output_path}" + cmd.extend( + [ + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-y", + output_path, + ] + ) + return cmd + + def _build_cover_extract_cmd( + self, + *, + cover_config: dict[str, Any], + input_video: str, + output_path: str, + ) -> list[str]: + """构建封面截帧 FFmpeg 命令(list 形式,shell=False).""" + if not cover_config or not isinstance(cover_config, dict): + timestamp = 0.0 + width = 0 + height = 0 + else: + timestamp = cover_config.get("timestamp", 0.0) + width = cover_config.get("width", 0) + height = cover_config.get("height", 0) + + cmd: list[str] = [ + "ffmpeg", + "-ss", + str(timestamp), + "-i", + input_video, + "-frames:v", + "1", + ] + if width > 0 and height > 0: + vf = ( + f"scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2" + ) + cmd.extend(["-vf", vf]) + cmd.extend(["-y", output_path]) + return cmd def _upload_to_oss(self, local_path: str, oss_key: str) -> str: """上传文件到 OSS,返回 URL. diff --git a/apps/api/app/tasks/lipsync_tts.py b/apps/api/app/tasks/lipsync_tts.py index 434d325ea..d241c0b89 100644 --- a/apps/api/app/tasks/lipsync_tts.py +++ b/apps/api/app/tasks/lipsync_tts.py @@ -156,6 +156,7 @@ def tts_synthesize_and_submit( "audio/mpeg", "audio/mp3", "audio/wav", + "audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav,与 audio/wav 等价(RIFF/WAVE) "audio/mp4", "audio/x-m4a", ), diff --git a/tests/unit/test_ai_avatar_render_service.py b/tests/unit/test_ai_avatar_render_service.py index 62cf82691..99e28928b 100644 --- a/tests/unit/test_ai_avatar_render_service.py +++ b/tests/unit/test_ai_avatar_render_service.py @@ -547,7 +547,7 @@ class TestAiAvatarRenderService: with ( patch.object(svc, "_download_video", return_value="/tmp/video.mp4"), patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"), - patch("os.system", return_value=0), + patch("subprocess.run") as mock_run, patch("tempfile.TemporaryDirectory") as tmpdir_mock, patch( "app.services.ai_avatar_cover_service.generate_smart_cover", return_value="https://oss/smart_cover.jpg" @@ -557,6 +557,9 @@ class TestAiAvatarRenderService: "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository" ) as repo_cls, ): + import subprocess as _sp + + mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="") import tempfile as _tf tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir") @@ -605,10 +608,13 @@ class TestAiAvatarRenderService: with ( patch.object(svc, "_download_video", return_value="/tmp/video.mp4"), patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"), - patch("os.system", return_value=0), + patch("subprocess.run") as mock_run, patch("tempfile.TemporaryDirectory") as tmpdir_mock, patch("app.services.ai_avatar_cover_service.generate_smart_cover", side_effect=RuntimeError("DB error")), ): + import subprocess as _sp + + mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="") tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir") tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False) svc.execute_render("render-clip-fail") diff --git a/tests/unit/test_lipsync_speed_optimization.py b/tests/unit/test_lipsync_speed_optimization.py index 7c4a39eb3..5963b8a51 100644 --- a/tests/unit/test_lipsync_speed_optimization.py +++ b/tests/unit/test_lipsync_speed_optimization.py @@ -35,7 +35,10 @@ class TestFFmpegPresetOptimization: final_label=None, output_path="/tmp/output.mp4", ) - assert "-preset veryfast" in cmd, f"期望 -preset veryfast,实际命令: {cmd}" + # cmd 现在是 list[str];preset 与值是相邻两个元素 + assert "-preset" in cmd, f"期望包含 -preset,实际命令: {cmd}" + preset_idx = cmd.index("-preset") + assert cmd[preset_idx + 1] == "veryfast", f"期望 veryfast,实际: {cmd}" def test_preset_veryfast_with_filter(self): """带滤镜场景下也必须使用 veryfast.""" @@ -49,7 +52,8 @@ class TestFFmpegPresetOptimization: final_label="[v]", output_path="/tmp/output.mp4", ) - assert "-preset veryfast" in cmd + assert "-preset" in cmd + assert cmd[cmd.index("-preset") + 1] == "veryfast" assert "-filter_complex" in cmd def test_preset_not_fast(self): @@ -65,11 +69,11 @@ class TestFFmpegPresetOptimization: output_path="/tmp/output.mp4", ) # 确保是 veryfast 而不是 fast - assert "-preset veryfast" in cmd - # 排除 "fast" 单独出现(veryfast 包含 fast 子串,需精确判断) - parts = cmd.split() - preset_idx = parts.index("-preset") - assert parts[preset_idx + 1] == "veryfast" + assert "-preset" in cmd + preset_idx = cmd.index("-preset") + assert cmd[preset_idx + 1] == "veryfast" + # 禁止 fast 单独作为 preset 值(veryfast 包含 "fast" 子串,不影响) + assert cmd[preset_idx + 1] != "fast" # ═══════════════════════════════════════════════════════════════════════════════