Files
xiaoxia-saas/apps/api/app/services/ai_avatar_render_service.py
T
CI Bot 1b76821307
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m6s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m52s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m29s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 7m39s
CI/CD Pipeline / CI Gate (pull_request) Failing after 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m58s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 120h48m47s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 120h56m27s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 120h56m32s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 120h56m20s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 120h56m11s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 120h56m13s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 120h56m17s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 120h56m17s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 120h56m18s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 120h48m31s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 120h48m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 120h56m3s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 120h48m28s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 120h48m28s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 120h56m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 120h56m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 120h56m11s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 120h56m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 121h31m19s
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-09-11 06:27:13 +00:00

515 lines
19 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.
"""AI数字人渲染合成 Service — #1798.
职责:
- 创建/查询/取消渲染任务
- 调用 Celery 异步任务执行渲染
- B-roll 合成 + 标题叠加 + 封面提取
- 用户隔离
"""
from __future__ import annotations
import logging
import os
import subprocess
import tempfile
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import (
AiAvatarRenderJob,
LipsyncJobModel,
ScriptModel,
)
from packages.domain.video_filter_builder import (
build_title_drawtext_filter,
)
from packages.shared.storage import get_shared_storage_service
logger = logging.getLogger(__name__)
class AiAvatarRenderError(Exception):
"""渲染服务异常."""
def __init__(self, message: str, code: str = "RenderError"):
self.code = code
super().__init__(message)
class AiAvatarRenderService:
"""AI数字人渲染合成 Service."""
def __init__(self, db: Session):
self.db = db
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_render_job(
self,
*,
user_id: str,
lipsync_job_id: str,
script_id: str = "",
b_roll_segments: list[dict[str, Any]] | None = None,
title_config: dict[str, Any],
cover_config: dict[str, Any],
project_id: str = "",
) -> AiAvatarRenderJob:
"""创建渲染任务.
Raises:
AiAvatarRenderError: 校验失败
"""
# 1. 验证对口型任务
lipsync_job = (
self.db.query(LipsyncJobModel)
.filter(
LipsyncJobModel.id == lipsync_job_id,
LipsyncJobModel.user_id == user_id,
)
.first()
)
if lipsync_job is None:
raise AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
if lipsync_job.status != "completed":
raise AiAvatarRenderError(
f"对口型任务状态为 {lipsync_job.status},仅 completed 状态可渲染",
code="LipsyncJobNotCompleted",
)
if not lipsync_job.output_video_url:
raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput")
# 2. 验证文案归属(仅当选了文案库条目时;手动输入文案直生场景 script_id 可空)
script_id = (script_id or "").strip()
if script_id:
script = (
self.db.query(ScriptModel)
.filter(
ScriptModel.id == script_id,
ScriptModel.user_id == user_id,
)
.first()
)
if script is None:
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
# 3. 创建渲染任务
job_id = str(uuid.uuid4())
job = AiAvatarRenderJob(
id=job_id,
user_id=user_id,
project_id=project_id,
lipsync_job_id=lipsync_job_id,
script_id=script_id,
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])],
title_config=title_config,
cover_config=cover_config,
status="pending",
)
self.db.add(job)
self.db.flush()
job.submitted_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
# ── 查询任务 ──────────────────────────────────────────────────────────
def get_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
"""获取渲染任务详情(用户隔离)."""
return (
self.db.query(AiAvatarRenderJob)
.filter(
AiAvatarRenderJob.id == job_id,
AiAvatarRenderJob.user_id == user_id,
)
.first()
)
def list_render_jobs(
self,
*,
user_id: str,
project_id: str = "",
status: str = "",
offset: int = 0,
limit: int = 20,
) -> tuple[list[AiAvatarRenderJob], int]:
"""获取渲染任务列表(分页 + 用户隔离)."""
query = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.user_id == user_id)
if project_id:
query = query.filter(AiAvatarRenderJob.project_id == project_id)
if status:
query = query.filter(AiAvatarRenderJob.status == status)
total = query.count()
items = query.order_by(AiAvatarRenderJob.created_at.desc()).offset(offset).limit(limit).all()
return items, total
# ── 取消任务 ──────────────────────────────────────────────────────────
def cancel_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
"""取消渲染任务(仅 pending 状态可取消)."""
job = self.get_render_job(job_id, user_id)
if job is None:
return None
if job.status in ("pending", "submitted"):
job.status = "cancelled"
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
# ── 重试任务 ──────────────────────────────────────────────────────────
def retry_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
"""重试失败的渲染任务."""
job = self.get_render_job(job_id, user_id)
if job is None:
return None
if job.status != "failed":
return None
job.status = "pending"
job.progress = 0
job.error_message = ""
job.output_video_url = ""
job.output_cover_url = ""
job.output_duration = 0.0
job.started_at = None
job.completed_at = None
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(job)
return job
# ── 执行渲染(Celery 异步调用) ──────────────────────────────────────
def execute_render(self, job_id: str) -> None:
"""执行渲染管线.
由 Celery 异步任务调用,流程:
1. 下载对口型输出视频 (20%)
2. 构建 FFmpeg 滤镜链 (40%)
3. 执行 FFmpeg 渲染 (80%)
4. 提取封面 (90%)
5. 上传到 OSS (95%)
6. 更新任务状态 (100%)
"""
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
if job is None:
logger.error("渲染任务不存在: %s", job_id)
return
if job.status == "cancelled":
logger.info("渲染任务已取消: %s", job_id)
return
try:
# 更新状态为 processing
job.status = "processing"
job.started_at = datetime.now(timezone.utc)
job.progress = 5
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
# 获取对口型任务信息
lipsync_job = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job.lipsync_job_id).first()
if lipsync_job is None:
raise AiAvatarRenderError("关联的对口型任务不存在", code="LipsyncJobNotFound")
# 1. 下载对口型输出视频 (20%)
input_video_path = self._download_video(lipsync_job.output_video_url)
job.progress = 20
self.db.commit()
# 2. 构建 FFmpeg 滤镜链 (40%)
from packages.domain.video_filter_builder import build_broll_overlay_filter
filter_complex = build_broll_overlay_filter(
b_roll_segments=job.b_roll_segments,
video_duration=lipsync_job.output_duration,
)
# 标题叠加
title_filter = build_title_drawtext_filter(job.title_config)
if title_filter:
if filter_complex:
filter_complex += f"[vout]{title_filter}[vout_titled];"
else:
filter_complex = f"[0:v]{title_filter}[vout_titled];"
# 清理末尾分号
if filter_complex.endswith(";"):
filter_complex = filter_complex[:-1]
# 最终输出标签
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
job.progress = 40
self.db.commit()
# 3. 执行 FFmpeg 渲染 (80%)
with tempfile.TemporaryDirectory() as tmpdir:
output_video_path = os.path.join(tmpdir, "output.mp4")
cmd_list = self._build_ffmpeg_command(
input_video=input_video_path,
b_roll_segments=job.b_roll_segments,
filter_complex=filter_complex,
final_label=final_label,
output_path=output_video_path,
)
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()
# 4. 提取封面 (90%)
cover_path = ""
if job.cover_config:
cover_path = os.path.join(tmpdir, "cover.jpg")
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
self.db.commit()
# 5. 上传到 OSS (95%)
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 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加);
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
smart_cover_url = ""
if output_video_url:
try:
from app.services.ai_avatar_cover_service import (
generate_smart_cover,
)
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)
if smart_cover_url:
job.output_cover_url = smart_cover_url
elif cover_path:
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
job.output_cover_url = output_cover_url
# 获取输出视频时长
job.output_duration = lipsync_job.output_duration
job.progress = 95
self.db.commit()
# 6. 完成
job.status = "completed"
job.progress = 100
job.completed_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
logger.info("渲染任务完成: %s", job_id)
# 7. 自动保存成片记录到成片库
if job.output_video_url:
try:
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain.generated_video import GeneratedVideo
clip_name = f"AI数字人_{job_id[:8]}"
clip = GeneratedVideo.create(
project_id=job.project_id,
generation_task_id=job.lipsync_job_id,
name=clip_name,
file_url=job.output_video_url,
user_id=job.user_id,
duration=job.output_duration or 0.0,
thumbnail_url=job.output_cover_url or None,
generation_params={
"source": "ai_avatar_render",
"render_job_id": job.id,
},
)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
video_repo.create(clip)
logger.info("成片记录已保存到成片库: clip_id=%s, render_job=%s", clip.id, job_id)
except Exception as clip_err:
logger.warning(
"自动保存成片记录失败(不影响渲染任务状态): render_job=%s, error=%s",
job_id,
clip_err,
)
except AiAvatarRenderError as exc:
job.status = "failed"
job.error_message = str(exc)
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:
"""下载视频到临时文件."""
import httpx
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
try:
with httpx.Client(timeout=120) as client:
resp = client.get(url)
resp.raise_for_status()
tmp.write(resp.content)
return tmp.name
except Exception:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
raise
def _build_ffmpeg_command(
self,
*,
input_video: str,
b_roll_segments: list[dict[str, Any]],
filter_complex: str,
final_label: Optional[str],
output_path: str,
) -> 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:
cmd.extend(["-i", asset_url])
if filter_complex and final_label:
cmd.extend(["-filter_complex", filter_complex, "-map", f"[{final_label}]"])
elif filter_complex:
cmd.extend(["-filter_complex", filter_complex])
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.
使用 SharedStorageService 统一存储服务。
"""
storage = get_shared_storage_service()
url = storage.upload_file_smart(local_path, oss_key)
if url is None:
raise AiAvatarRenderError(
f"上传文件到 OSS 失败: {oss_key}",
code="OSSUploadFailed",
)
logger.info("上传文件到 OSS 成功: %s -> %s", local_path, url)
return url