Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoxia 46941b1fac debug: e2e v3 - fix model import path
E2E lipsync verify v3 / e2e (push) Failing after 11m7s
2026-09-11 11:58:19 +08:00
xiaoxia e3a0c0a5ba debug: e2e v2 - fix WORKDIR, inspect jobs/users/queues
E2E lipsync verify v2 / e2e (push) Successful in 19s
2026-09-11 11:53:34 +08:00
xiaoxia fc1776bea1 debug: e2e lipsync verify
E2E lipsync verify / e2e (push) Successful in 21s
2026-09-11 11:49:43 +08:00
25 changed files with 286 additions and 656 deletions
+28
View File
@@ -0,0 +1,28 @@
name: "E2E lipsync verify v3"
on:
push:
branches: [debug/e2e-lipsync]
workflow_dispatch:
jobs:
e2e:
runs-on: runtime-builder
timeout-minutes: 10
steps:
- name: Setup SSH
shell: bash
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -p 22222 -H 47.98.113.167 >> ~/.ssh/known_hosts 2>/dev/null
- name: Run
shell: bash
run: |
set -x
echo 'IyEvYmluL2Jhc2gKc2V0IC14CmVjaG8gIj09PSAxLiBSZWNlbnQgbGlwc3luYyBqb2JzIChjb3JyZWN0IG1vZGVsIHBhdGgpID09PSIKZG9ja2VyIGV4ZWMgeGlhb3hpYS1hcGktc3RhZ2luZyBzaCAtYyAiY2QgL2FwcCAmJiBweXRob24gLWMgXCIKZnJvbSBhcHAuZGIgaW1wb3J0IFNlc3Npb25Mb2NhbApmcm9tIHBhY2thZ2VzLmFkYXB0ZXJzLnNxbGFsY2hlbXlfaW1wbC5tb2RlbHMgaW1wb3J0IExpcHN5bmNKb2JNb2RlbCBhcyBMSgpkYiA9IFNlc3Npb25Mb2NhbCgpCnRvdGFsID0gZGIucXVlcnkoTEopLmNvdW50KCkKcHJpbnQoZidUb3RhbCBsaXBzeW5jIGpvYnM6IHt0b3RhbH0nKQpqb2JzID0gZGIucXVlcnkoTEopLm9yZGVyX2J5KExKLmNyZWF0ZWRfYXQuZGVzYygpKS5saW1pdCgxMCkuYWxsKCkKZm9yIGogaW4gam9iczoKICAgIGVyciA9IChqLmVycm9yX21lc3NhZ2Ugb3IgJycpWzoxMDBdCiAgICBwcmludChmJyAgaWQ9e2ouaWR9IHVzZXI9e2oudXNlcl9pZH0gc3RhdHVzPXtqLnN0YXR1c30gZXJyX2NvZGU9e2ouZXJyb3JfY29kZX0gZXJyPXtlcnJ9JykKZGIuY2xvc2UoKQpcIiIgMj4mMSB8IHRhaWwgLTMwCmVjaG8gIiIKZWNobyAiPT09IDIuIFRlc3QgdXNlcnMgPT09Igpkb2NrZXIgZXhlYyB4aWFveGlhLWFwaS1zdGFnaW5nIHNoIC1jICJjZCAvYXBwICYmIHB5dGhvbiAtYyBcIgpmcm9tIGFwcC5kYiBpbXBvcnQgU2Vzc2lvbkxvY2FsCmZyb20gcGFja2FnZXMuYWRhcHRlcnMuc3FsYWxjaGVteV9pbXBsLm1vZGVscyBpbXBvcnQgVXNlck1vZGVsIGFzIFUKZGIgPSBTZXNzaW9uTG9jYWwoKQp1c2VycyA9IGRiLnF1ZXJ5KFUpLm9yZGVyX2J5KFUuY3JlYXRlZF9hdC5kZXNjKCkpLmxpbWl0KDUpLmFsbCgpCmZvciB1IGluIHVzZXJzOgogICAgcHJpbnQoZicgIGlkPXt1LmlkfSBlbWFpbD17dS5lbWFpbH0gcGhvbmU9e2dldGF0dHIodSxcInBob25lX251bWJlclwiLE5vbmUpfSBhY3RpdmU9e2dldGF0dHIodSxcImlzX2FjdGl2ZVwiLE5vbmUpfScpCmRiLmNsb3NlKCkKXCIiIDI+JjEgfCB0YWlsIC0xNQplY2hvICIiCmVjaG8gIj09PSAzLiBBUEkgd29ya2VyIGxvZ3M6IGFueSBsaXBzeW5jIHRhc2sgZXhlY3V0aW9uIGhpc3Rvcnk/ID09PSIKZG9ja2VyIGxvZ3MgeGlhb3hpYS13b3JrZXItc3RhZ2luZyAyPiYxIHwgZ3JlcCAtaUUgImxpcHN5bmNfdHRzfHN5bnRoZXNpemVfYW5kX3N1Ym1pdHxBc3luY0Rpc3BhdGNoRmFpbGVkfHR0c19wcm9jZXNzaW5nfHR0c19wcm94eSIgfCB0YWlsIC0yMAplY2hvICIiCmVjaG8gIj09PSA0LiBQdWJsaWMgR0VUIC9oZWFsdGggd2l0aCBHRVQgbWV0aG9kID09PSIKY3VybCAtc2YgLS1tYXgtdGltZSAxMCBodHRwczovL3N0YWdpbmctYXBpLnhpYW94aWFqaWFuamkuY29tL2hlYWx0aCAyPiYxIHwgaGVhZCAtNQplY2hvICIiCmVjaG8gIj09PSA1LiBQdWJsaWMgd2ViIHJvb3QgPT09IgpjdXJsIC1zZiAtLW1heC10aW1lIDEwIGh0dHBzOi8vc3RhZ2luZy54aWFveGlhamlhbmppLmNvbS8gMj4mMSB8IGhlYWQgLTUK' | base64 -d > /tmp/e2e.sh
chmod +x /tmp/e2e.sh
ssh -p 22222 -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@47.98.113.167 'bash -s' < /tmp/e2e.sh
+7 -24
View File
@@ -11,7 +11,6 @@
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
@@ -78,16 +77,10 @@ def create_render_job(
from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(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)
except Exception:
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
return AiAvatarRenderJobResponse.model_validate(job)
return job
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@@ -179,16 +172,10 @@ def retry_render_job(
from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(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)
except Exception:
logger.warning("Celery 任务提交失败重试任务已重置但未触发执行: %s", job.id)
return AiAvatarRenderJobResponse.model_validate(job)
return job
@@ -212,11 +199,7 @@ 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,
title_config=getattr(body, "title_config", None),
)
cover_url = generate_smart_cover(video_url, max_frames=body.max_frames)
except Exception as exc:
logger.error(
"智能封面生成异常: user=%s video_url=%s err=%s",
+1 -4
View File
@@ -110,13 +110,10 @@ class AiAvatarRenderProgressResponse(BaseModel):
class SmartCoverRequest(BaseModel):
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧 + 可选标题 drawtext 叠加."""
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧."""
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):
+14 -105
View File
@@ -11,8 +11,6 @@
from __future__ import annotations
import logging
import os
import subprocess
import tempfile
import uuid
from pathlib import Path
@@ -21,9 +19,9 @@ from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# MediaKit 抽帧轮询参数poll_interval=1s × max_poll=15 → 最长 15s,配合前端 120s 超时足够
COVER_POLL_INTERVAL = 1.0
COVER_MAX_POLL_ATTEMPTS = 15
# MediaKit 抽帧轮询参数(与 MediaKit API timeout=60s 对齐)
COVER_POLL_INTERVAL = 3.0
COVER_MAX_POLL_ATTEMPTS = 20 # 最多等 60 秒
# 帧图片下载超时(秒)
FRAME_DOWNLOAD_TIMEOUT = 20
@@ -158,79 +156,13 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
return ""
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:
def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-avatar/covers") -> 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
@@ -238,7 +170,6 @@ def persist_cover_to_oss(
if not frame_url:
return ""
tmp_path: Optional[str] = None
titled_path: Optional[str] = None
try:
import httpx
@@ -258,21 +189,12 @@ def persist_cover_to_oss(
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=upload_path,
file_or_path=tmp_path,
storage_key=cover_key,
content_type="image/jpeg",
)
logger.info(
"[数字人封面] 封面已转存 OSS: key=%s titled=%s",
cover_key,
bool(titled_path),
)
logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key)
# 私有桶:返回预签名 URL(前端才能加载)
if public_url:
signed = storage.get_download_url(cover_key, expires_seconds=86400)
@@ -282,32 +204,19 @@ def persist_cover_to_oss(
logger.warning("[数字人封面] 封面转存 OSS 失败,返回原始 URL", exc_info=True)
return frame_url
finally:
for p in (tmp_path, titled_path):
if p:
try:
Path(p).unlink(missing_ok=True)
except Exception:
pass
if tmp_path:
try:
Path(tmp_path).unlink(missing_ok=True)
except Exception:
pass
def generate_smart_cover(
video_url: str,
*,
job_id: str = "",
max_frames: int = 5,
title_config: dict | None = None,
) -> str:
"""一站式:MediaKit 智能抽帧选最佳 → (可选)drawtext 叠加标题 → 转存 OSS.
def generate_smart_cover(video_url: str, *, job_id: str = "", max_frames: int = 5) -> str:
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS,返回封面公网 URL.
供独立封面接口与渲染管线复用。失败返回空字符串。
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, title_config=title_config)
return persist_cover_to_oss(best_frame, job_id=job_id)
+28 -121
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
import logging
import os
import subprocess
import tempfile
import uuid
from datetime import datetime, timezone
@@ -25,6 +24,7 @@ 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_list = self._build_ffmpeg_command(
cmd = self._build_ffmpeg_command(
input_video=input_video_path,
b_roll_segments=job.b_roll_segments,
filter_complex=filter_complex,
@@ -265,25 +265,9 @@ class AiAvatarRenderService:
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",
)
exit_code = os.system(cmd)
if exit_code != 0:
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
job.progress = 80
self.db.commit()
@@ -292,27 +276,11 @@ class AiAvatarRenderService:
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_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_path = ""
job.progress = 90
@@ -322,7 +290,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 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加)
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧;
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
smart_cover_url = ""
if output_video_url:
@@ -331,13 +299,7 @@ class AiAvatarRenderService:
generate_smart_cover,
)
smart_cover_url = generate_smart_cover(
output_video_url,
job_id=job_id,
max_frames=5,
# 注意:不传 title_config —— 最终输出视频已经通过 drawtext 叠加了标题,
# 再传会导致封面标题双重叠加
)
smart_cover_url = generate_smart_cover(output_video_url, job_id=job_id, max_frames=5)
except Exception:
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
@@ -369,13 +331,9 @@ class AiAvatarRenderService:
from packages.domain.generated_video import GeneratedVideo
clip_name = f"AI数字人_{job_id[:8]}"
# AI数字人入口是独立页面,前端可能不传 project_id(无项目概念),
# 兜底为 "ai_avatar" 避免 DB 非空约束/查询问题;generation_task_id 同样兜底用 render_job_id
clip_project_id = (job.project_id or "").strip() or "ai_avatar"
clip_generation_task_id = (job.lipsync_job_id or "").strip() or job_id
clip = GeneratedVideo.create(
project_id=clip_project_id,
generation_task_id=clip_generation_task_id,
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,
@@ -389,11 +347,11 @@ class AiAvatarRenderService:
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
video_repo.create(clip)
logger.info("成片记录已保存到成片库: clip_id=%s, render_job=%s", clip.id, job_id)
except Exception:
logger.error(
"自动保存成片记录失败(不影响渲染任务状态): render_job=%s",
except Exception as clip_err:
logger.warning(
"自动保存成片记录失败(不影响渲染任务状态): render_job=%s, error=%s",
job_id,
exc_info=True,
clip_err,
)
except AiAvatarRenderError as exc:
@@ -402,14 +360,12 @@ 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:
"""下载视频到临时文件."""
@@ -435,73 +391,24 @@ class AiAvatarRenderService:
filter_complex: str,
final_label: Optional[str],
output_path: str,
) -> list[str]:
"""构建 FFmpeg 命令list 形式,shell=False.
根因修复 #1798 P0OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符,
os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被
当成独立命令报 sh: -filter_complex: not foundexit 127 → Python 32512)。
list + shell=False 彻底规避 shell 转义问题。
"""
cmd: list[str] = ["ffmpeg", "-i", input_video]
) -> str:
"""构建 FFmpeg 命令."""
# 输入文件
inputs = f"-i {input_video}"
for seg in b_roll_segments:
asset_url = seg.get("asset_url", "")
if asset_url:
cmd.extend(["-i", asset_url])
inputs += f" -i {asset_url}"
# 滤镜
if filter_complex and final_label:
cmd.extend(["-filter_complex", filter_complex, "-map", f"[{final_label}]"])
filter_arg = f'-filter_complex "{filter_complex}" -map "[{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
filter_arg = f'-filter_complex "{filter_complex}"'
else:
timestamp = cover_config.get("timestamp", 0.0)
width = cover_config.get("width", 0)
height = cover_config.get("height", 0)
filter_arg = ""
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
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset veryfast -crf 23 -y {output_path}"
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
"""上传文件到 OSS,返回 URL.
-1
View File
@@ -156,7 +156,6 @@ 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",
),
+11 -34
View File
@@ -1,6 +1,6 @@
/**
* 成品 / 视频相关 API 函数
* 后端实际接口:/videos(分页:page/page_size,返回 {items, total, page, page_size}
* 后端实际接口:/videos
*/
import apiClient from "../client"
import type {
@@ -12,39 +12,16 @@ import type {
} from "./types"
import { mapVideoToProductItem } from "./utils"
/** 分页列表响应(前端消费用 */
export interface ProductListResult {
items: ProductItem[]
total: number
page: number
page_size: number
}
/**
* 获取成品列表(分页)
* @param params 分页与筛选参数:page 默认 1page_size 默认 20
*/
export const getProducts = async (params?: ProductListParams): Promise<ProductListResult> => {
const response = await apiClient.get("/videos", {
params: {
page: 1,
page_size: 20,
...params,
},
})
const data = response.data as {
items?: VideoItem[]
total?: number
page?: number
page_size?: number
}
const items: VideoItem[] = Array.isArray(data?.items) ? data.items : []
return {
items: items.map(mapVideoToProductItem),
total: data.total ?? items.length,
page: data.page ?? params?.page ?? 1,
page_size: data.page_size ?? params?.page_size ?? 20,
}
/** 获取成品列表(支持分页和筛选 */
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
const response = await apiClient.get("/videos", { params })
const data = response.data
const videos: VideoItem[] = Array.isArray(data?.items)
? data.items
: Array.isArray(data)
? data
: []
return videos.map(mapVideoToProductItem)
}
/** 获取单个成品详情 */
+1 -16
View File
@@ -552,7 +552,7 @@
max-width: 240px;
aspect-ratio: 9/16;
background: #f0f0f5;
border-radius: 12px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
@@ -564,10 +564,8 @@
.aa-cover-preview img {
width: 100%;
height: 100%;
aspect-ratio: 9/16;
object-fit: cover;
display: block;
border-radius: 12px;
}
.aa-cover-preview__placeholder {
@@ -575,19 +573,6 @@
color: #8c8ca1;
}
.aa-cover-preview__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 13px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.aa-cover-actions {
display: flex;
gap: 8px;
+2 -16
View File
@@ -25,7 +25,6 @@ import {
getRenderJob,
generateSmartCover,
} from "./api/aiAvatar"
import { getOrCreateDefaultProject } from "@/api/projects"
import {
normalizeEmotion,
buildTitleConfigPayload,
@@ -207,12 +206,9 @@ const AiAvatarPage: React.FC = () => {
}
state.setIsGenerating(true)
try {
// 确保有 project_id(AI数字人入口独立,不在项目内,自动取默认项目;#1860 P0 bugfix
const defaultProject = await getOrCreateDefaultProject()
const job = await submitRender({
lipsync_job_id: state.lipsyncJob.id,
script_id: state.script?.id,
project_id: defaultProject.id,
b_roll_segments: state.bRollSegments.map((seg) => ({
script_segment_index: seg.script_segment_index,
asset_url: seg.asset.file_url || "",
@@ -233,17 +229,7 @@ const AiAvatarPage: React.FC = () => {
setRenderErrorMessage("")
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
let renderPollCount = 0
const RENDER_MAX_POLLS = 200 // 最多轮询 10 分钟(200 次 × 3s)
renderTimerRef.current = setInterval(async () => {
renderPollCount++
if (renderPollCount > RENDER_MAX_POLLS) {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null
setRenderStatus("failed")
setRenderErrorMessage("渲染超时(超过10分钟),请稍后在任务历史查看结果")
return
}
try {
const updated = await getRenderJob(job.id)
setRenderProgress(updated.progress ?? 0)
@@ -293,7 +279,7 @@ const AiAvatarPage: React.FC = () => {
}
setSmartCoverLoading(true)
try {
const res = await generateSmartCover(videoUrl, buildTitleConfigPayload(state.titleConfig), 5)
const res = await generateSmartCover(videoUrl, 5)
if (res.cover_url) {
state.setCoverConfig((prev) => ({
...prev,
@@ -356,6 +342,7 @@ const AiAvatarPage: React.FC = () => {
selectedVideo={state.selectedVideo}
onSelectVideo={() => state.setShowAssetPicker(true)}
onRemoveVideo={state.removeVideo}
titleConfig={state.titleConfig}
/>
</div>
</div>
@@ -457,7 +444,6 @@ const AiAvatarPage: React.FC = () => {
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
titleConfig={state.titleConfig}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={state.lipsyncJob?.status === "completed"}
+4 -6
View File
@@ -58,17 +58,15 @@ export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
return response.data
}
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧 + 可选 drawtext 标题叠加 ── */
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧,独立于渲染任务 ── */
export const generateSmartCover = async (
video_url: string,
title_config?: Record<string, unknown> | null,
max_frames = 5,
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
"/ai-avatar/render/smart-cover",
{ video_url, max_frames, title_config: title_config ?? null },
// smart-cover 链路:下载视频+抽帧+drawtext 加标题+上传 OSS,需要较长时间,120s 超时
{ timeout: 120000 },
{ video_url, max_frames },
{ timeout: 60000 },
)
return response.data
}
@@ -87,7 +85,7 @@ export const submitRender = async (data: {
}
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`)
return response.data
}
@@ -1,18 +1,17 @@
/**
* AI数字人 — 面板5:封面 & 生成
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)+ 标题文字实时叠加预览
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
* - 分辨率选择(720p / 1080p / 4K
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
* - 渐变紫色生成按钮
*
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
*/
import React, { useMemo, useRef } from "react"
import type { AiAvatarCoverConfig, AiAvatarTitleConfig } from "../types"
import React, { useRef } from "react"
import type { AiAvatarCoverConfig } from "../types"
interface PanelCoverAndGenerateProps {
coverConfig: AiAvatarCoverConfig
titleConfig: AiAvatarTitleConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
resolution: string
onResolutionChange: (r: string) => void
@@ -48,19 +47,8 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
failed: { text: "失败", cls: "aa-status-badge--failed" },
}
/** 字体名 → CSS font-family 映射(与后端 drawtext 对齐) */
const FONT_FAMILY_MAP: Record<string, string> = {
: "'Noto Sans SC', 'Source Han Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
: "KaiTi, 'STKaiti', serif",
: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
}
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
coverConfig,
titleConfig,
onCoverConfigChange,
resolution,
onResolutionChange,
@@ -98,85 +86,15 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
const coverUrl =
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
const hasCoverImage = Boolean(coverUrl)
/** 是否显示标题叠加层:有图、有文字、非加载中 */
const showTitleOverlay =
hasCoverImage && !smartCoverLoading && titleConfig.title.trim().length > 0
/** 计算标题叠加层的 inline 样式 */
const titleOverlayStyle = useMemo<React.CSSProperties>(() => {
const style: React.CSSProperties = {
position: "absolute",
left: "50%",
width: "90%",
transform: "translateX(-50%)",
textAlign: "center",
boxSizing: "border-box",
padding: "0 4px",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
color: titleConfig.color || "#ffffff",
fontSize: `${titleConfig.size}px`,
fontFamily: getFontFamily(titleConfig.font),
fontWeight: titleConfig.bold ? "bold" : "normal",
fontStyle: titleConfig.italic ? "italic" : "normal",
lineHeight: 1.3,
pointerEvents: "none",
}
// 位置
const pos = titleConfig.position || "bottom"
if (pos === "top") {
style.top = "40px"
} else if (pos === "center") {
style.top = "50%"
style.transform = "translate(-50%, -50%)"
} else if (pos === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null) {
// pos_x/pos_y 是相对预览容器的百分比坐标
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translate(-50%, -50%)"
} else {
style.bottom = "40px"
}
// 描边优先于阴影(二者互斥,与 drawtext 对齐)
if (titleConfig.stroke) {
// 描边宽度按字号估算,保证视觉一致
const strokeWidth = Math.max(1, Math.round(titleConfig.size / 18))
;(style as React.CSSProperties)["WebkitTextStroke"] = `${strokeWidth}px rgba(0,0,0,0.75)`
style.textShadow = "none"
} else if (titleConfig.shadow) {
style.textShadow = "0 2px 8px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.5)"
} else {
// 默认给轻微阴影保证白字在亮背景可读
style.textShadow = "0 2px 6px rgba(0,0,0,0.6)"
}
return style
}, [titleConfig])
return (
<div className="aa-cover-generate">
{/* 封面预览(竖屏 9:16 */}
<div className="aa-cover-preview">
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
{coverConfig.thumbnail_url ? (
<img src={coverConfig.thumbnail_url} alt="封面预览" />
) : (
<span className="aa-cover-preview__placeholder"></span>
)}
{/* 智能封面加载遮罩 */}
{smartCoverLoading && <div className="aa-cover-preview__loading"> </div>}
{/* 标题文字叠加层(实时预览,仅前端视觉参考,最终由后端 ffmpeg drawtext 叠加) */}
{showTitleOverlay && (
<div style={titleOverlayStyle} aria-hidden="true">
{titleConfig.title}
</div>
)}
</div>
<div className="aa-cover-actions">
@@ -2,16 +2,17 @@
* AI数字人 — 出镜视频选择面板
* - 未选视频:虚线上传区,点击打开素材库弹窗
* - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮
*
* 注意:本面板只展示原始素材视频,不叠加标题(标题在对口型预览和最终成片上展示)
*/
import type { AssetItem } from "@/api/assets"
import type { AiAvatarTitleConfig } from "../types"
import { getFontFamily } from "@/pages/generate/constants"
export interface PanelVideoSelectorProps {
selectedVideo: AssetItem | null
/** 触发打开素材库弹窗 */
onSelectVideo: () => void
onRemoveVideo: () => void
titleConfig?: AiAvatarTitleConfig
}
/** 格式化时长(秒 → mm:ss */
@@ -26,6 +27,7 @@ export function PanelVideoSelector({
selectedVideo,
onSelectVideo,
onRemoveVideo,
titleConfig,
}: PanelVideoSelectorProps) {
/* 未选视频:虚线上传区,点击打开素材库弹窗 */
if (!selectedVideo) {
@@ -55,13 +57,42 @@ export function PanelVideoSelector({
return (
<div>
{/* 竖屏 9:16 视频预览播放器(纯素材预览,不叠加标题) */}
<div className="aa-video-preview">
{/* 竖屏 9:16 视频预览播放器 + 标题实时预览 */}
<div className="aa-video-preview" style={{ position: "relative" }}>
{fileUrl ? (
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
) : (
<div className="aa-video-preview__placeholder"></div>
)}
{titleConfig?.title && (
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
...(titleConfig.position === "top"
? { top: "10%" }
: titleConfig.position === "bottom"
? { bottom: "10%" }
: { top: "50%", transform: "translate(-50%, -50%)" }),
fontSize: Math.max(titleConfig.size, 32),
fontFamily: getFontFamily(titleConfig.font),
color: titleConfig.color,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textShadow: "0 2px 4px rgba(0,0,0,0.5)",
WebkitTextStroke: "2px #000",
pointerEvents: "none",
zIndex: 10,
maxWidth: "90%",
textAlign: "center",
whiteSpace: "pre-wrap",
lineHeight: 1.3,
}}
>
{titleConfig.title}
</div>
)}
</div>
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
+25 -77
View File
@@ -1,22 +1,17 @@
/**
* 成片库页面 — V21 设计系统
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选、无限滚动分页
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
*
* 主组件仅保留 Hook 组装与整体布局
* 列表查询 → hooks/useProductListuseInfiniteQuery 分页)
* 列表查询 → hooks/useProductList
* 操作逻辑 → hooks/useProductActions
* 筛选栏 → components/ProductFilterBar
* 批量操作栏 → components/ProductBatchBar
* 空状态 → components/ProductEmptyState
* 产品卡片 → components/ProductCard(内联视频播放)
*/
import React, { useEffect, useRef } from "react"
import {
VideoCameraOutlined,
DownloadOutlined,
ReloadOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import React from "react"
import { VideoCameraOutlined, DownloadOutlined, ReloadOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import { ProductCard } from "./components/ProductCard"
import { ProductFilterBar } from "./components/ProductFilterBar"
@@ -29,13 +24,11 @@ import "./products.css"
const ProductLibrary: React.FC = () => {
const {
products,
filteredProducts,
isLoading,
isFetchingNextPage,
isError,
error,
hasNextPage,
fetchNextPage,
refetch,
searchText,
setSearchText,
@@ -71,40 +64,19 @@ const ProductLibrary: React.FC = () => {
} = useProductActions({
selectedIds,
clearSelection,
products: filteredProducts,
products,
setPlayingProduct: () => {}, // 不再使用弹窗播放
})
const { recomputeDedup, isRecomputing } = useRecomputeDedup()
/* ── 无限滚动:IntersectionObserver 监听底部哨兵元素 ── */
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = sentinelRef.current
if (!el) return
// 已有数据但正在加载中/没有更多页时不触发
if (isFetchingNextPage || !hasNextPage) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
void fetchNextPage()
}
},
{ rootMargin: "200px" },
)
observer.observe(el)
return () => observer.disconnect()
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
// ── Loading 状态(仅首次加载)──
if (isLoading && filteredProducts.length === 0) {
// ── Loading 状态 ──
if (isLoading) {
return <ProductEmptyState type="loading" />
}
// ── Error 状态 ──
if (isError && filteredProducts.length === 0) {
if (isError) {
console.error("[ProductLibrary] 加载失败:", error)
const errorMsg = error?.message || "加载失败"
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found")
@@ -171,46 +143,22 @@ const ProductLibrary: React.FC = () => {
{/* 卡片网格 */}
{filteredProducts.length > 0 ? (
<>
<div className="xx-products-grid">
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
isSelected={selectedIds.has(product.id)}
batchMode={batchMode}
onToggleSelect={handleToggleSelect}
onDownload={handleDownload}
onShare={handleShare}
onDelete={handleDelete}
onPublish={handlePublish}
onReviewStatusChange={handleReviewStatusChange}
/>
))}
</div>
{/* 底部哨兵 + 状态提示 */}
<div
ref={sentinelRef}
style={{
gridColumn: "1 / -1",
textAlign: "center",
padding: "24px 0",
fontSize: 13,
color: "#8c8ca1",
}}
>
{isFetchingNextPage ? (
<>
<LoadingOutlined />
</>
) : hasNextPage ? (
<span style={{ opacity: 0 }}></span>
) : (
<span> </span>
)}
</div>
</>
<div className="xx-products-grid">
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
isSelected={selectedIds.has(product.id)}
batchMode={batchMode}
onToggleSelect={handleToggleSelect}
onDownload={handleDownload}
onShare={handleShare}
onDelete={handleDelete}
onPublish={handlePublish}
onReviewStatusChange={handleReviewStatusChange}
/>
))}
</div>
) : (
<ProductEmptyState type="empty" />
)}
@@ -1,53 +1,28 @@
import { useMemo } from "react"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useQuery } from "@tanstack/react-query"
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
import { mapApiProduct } from "../../utils"
import type { ProductItem } from "../../types"
import { useProductFiltering } from "./useProductFiltering"
import { useBatchSelection } from "./useBatchSelection"
export type { Filters } from "./useProductFiltering"
const PAGE_SIZE = 20
export const useProductList = () => {
/* ── 无限滚动获取成品列表(每页 20 条) ── */
/* ── 获取成品列表 ── */
const {
data,
data: apiProducts = [],
isLoading,
isFetchingNextPage,
isError,
error,
hasNextPage,
fetchNextPage,
refetch,
} = useInfiniteQuery<
{
items: ApiProductItem[]
total: number
page: number
page_size: number
},
Error
>({
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: async ({ pageParam = 1 }) =>
getProducts({ page: pageParam as number, page_size: PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const loadedCount = lastPage.page * lastPage.page_size
return loadedCount < lastPage.total ? lastPage.page + 1 : undefined
},
queryFn: () => getProducts(),
staleTime: 30_000,
})
// 将所有页拼接为一维数组,再做前端映射+排序
const apiProducts = useMemo<ApiProductItem[]>(() => {
if (!data?.pages) return []
return data.pages.flatMap((p) => p.items)
}, [data])
const products = useMemo<ProductItem[]>(
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
const products = useMemo(
() =>
(Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => {
if (!a.date || a.date === "—") return 1
@@ -90,11 +65,8 @@ export const useProductList = () => {
products,
filteredProducts,
isLoading,
isFetchingNextPage,
isError,
error,
hasNextPage,
fetchNextPage,
refetch,
// 筛选
searchText,
+9 -7
View File
@@ -49,17 +49,19 @@ class GeneratedVideo:
thumbnail_url: str | None = None,
generation_params: dict[str, Any] | None = None,
) -> "GeneratedVideo":
# project_id / generation_task_id 允许为空:AI数字人等无项目场景下,前端可能不传 project_id;
# lipsync 路径下 generation_task_id 也可能暂时为空。空串会被下面统一兜底为 "" 入库。
if not name or not name.strip():
if not project_id.strip():
raise ValueError("project_id cannot be empty")
if not generation_task_id.strip():
raise ValueError("generation_task_id cannot be empty")
if not name.strip():
raise ValueError("name cannot be empty")
if not file_url or not file_url.strip():
if not file_url.strip():
raise ValueError("file_url cannot be empty")
return cls(
id=uuid4().hex,
project_id=(project_id or "").strip(),
user_id=(user_id or "").strip(),
generation_task_id=(generation_task_id or "").strip(),
project_id=project_id.strip(),
user_id=user_id.strip(),
generation_task_id=generation_task_id.strip(),
name=name.strip(),
file_url=file_url.strip(),
file_size=file_size,
-4
View File
@@ -13,7 +13,3 @@ pytest-cov==6.0.0
# 工具
python-dotenv==1.0.1
# AI 数字人封面智能选帧(cover_frame_scorer 用 cv2/numpy 做清晰度/亮度/色彩评分)
numpy==1.26.4
opencv-python-headless==4.10.0.84
+18 -9
View File
@@ -87,19 +87,28 @@ class TestGeneratedVideoCreate:
assert v.file_url == "http://x/v"
def test_create_empty_project_id(self):
"""空 project_id 允许(AI数字人无项目场景)."""
v = GeneratedVideo.create("", "t1", "v", "http://x/v")
assert v.project_id == ""
"""空 project_id 无效."""
try:
GeneratedVideo.create("", "t1", "v", "http://x/v")
assert False
except ValueError as e:
assert "project_id" in str(e)
def test_create_whitespace_project_id(self):
"""纯空白 project_id 归一化为空串."""
v = GeneratedVideo.create(" ", "t1", "v", "http://x/v")
assert v.project_id == ""
"""纯空白 project_id 无效."""
try:
GeneratedVideo.create(" ", "t1", "v", "http://x/v")
assert False
except ValueError as e:
assert "project_id" in str(e)
def test_create_empty_task_id(self):
"""空 generation_task_id 允许."""
v = GeneratedVideo.create("p1", "", "v", "http://x/v")
assert v.generation_task_id == ""
"""空 generation_task_id 无效."""
try:
GeneratedVideo.create("p1", "", "v", "http://x/v")
assert False
except ValueError as e:
assert "generation_task_id" in str(e)
def test_create_empty_name(self):
"""空 name 无效."""
@@ -262,8 +262,8 @@ def test_smart_cover_selects_best_frame_and_persists():
score_patch.assert_called_once()
# 验证使用了增大的轮询参数
call_kwargs = mk.extract_frames.call_args
assert call_kwargs.kwargs.get("poll_interval") == 1.0 or call_kwargs[1].get("poll_interval") == 1.0
assert call_kwargs.kwargs.get("max_poll_attempts") == 15 or call_kwargs[1].get("max_poll_attempts") == 15
assert call_kwargs.kwargs.get("poll_interval") == 3.0 or call_kwargs[1].get("poll_interval") == 3.0
assert call_kwargs.kwargs.get("max_poll_attempts") == 20 or call_kwargs[1].get("max_poll_attempts") == 20
def test_smart_cover_returns_empty_when_mediakit_unavailable():
@@ -334,8 +334,8 @@ def test_extract_frames_uses_extended_poll_params():
cov.select_best_cover_frame("https://other/avatar.mp4", max_frames=3)
call_kwargs = mk.extract_frames.call_args
assert call_kwargs.kwargs.get("poll_interval") == 1.0 or call_kwargs[1].get("poll_interval") == 1.0
assert call_kwargs.kwargs.get("max_poll_attempts") == 15 or call_kwargs[1].get("max_poll_attempts") == 15
assert call_kwargs.kwargs.get("poll_interval") == 3.0 or call_kwargs[1].get("poll_interval") == 3.0
assert call_kwargs.kwargs.get("max_poll_attempts") == 20 or call_kwargs[1].get("max_poll_attempts") == 20
assert call_kwargs.kwargs.get("max_retries") == 1 or call_kwargs[1].get("max_retries") == 1
+2 -8
View File
@@ -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("subprocess.run") as mock_run,
patch("os.system", return_value=0),
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
patch(
"app.services.ai_avatar_cover_service.generate_smart_cover", return_value="https://oss/smart_cover.jpg"
@@ -557,9 +557,6 @@ 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")
@@ -608,13 +605,10 @@ 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("subprocess.run") as mock_run,
patch("os.system", return_value=0),
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")
+4 -5
View File
@@ -43,7 +43,7 @@ class TestScoreFrame:
@requires_cv2
def test_clear_image_high_score(self):
"""清晰、亮度适中、色彩丰富的图像应得高分."""
"""清晰、亮度适中、色彩丰富的图像应得高分."""
# 创建一个清晰的渐变图像(色彩丰富、亮度适中)
img = np.zeros((100, 100, 3), dtype=np.uint8)
for i in range(100):
@@ -53,8 +53,7 @@ class TestScoreFrame:
from packages.shared.cover_frame_scorer import score_frame
score = score_frame(img)
# 渐变图清晰度中等+亮度尚可+色彩有变化,分数应明显高于模糊/全黑/全白
assert 40.0 <= score <= 100.0, f"清晰图像应得较高分,实际: {score}"
assert 50.0 <= score <= 100.0, f"清晰图像应得高分,实际: {score}"
@requires_cv2
def test_blurry_image_low_clarity(self):
@@ -77,8 +76,8 @@ class TestScoreFrame:
from packages.shared.cover_frame_scorer import score_frame
score = score_frame(img)
# 全黑:清晰度 0,亮度偏离130扣约24分,色彩 0 → 得分约0~7,允许cv2内部微小浮点差异
assert score <= 10.0, f"全黑图像应接近 0 分,实际: {score}"
# 全黑:清晰度 0,亮度 0,色彩 0
assert score <= 5.0, f"全黑图像应接近 0 分,实际: {score}"
@requires_cv2
def test_bright_image_low_brightness(self):
+8 -6
View File
@@ -180,26 +180,28 @@ class TestDetectKeyframeTimestamps:
def test_cannot_open_video_raises(self):
"""无法打开视频时抛出 RuntimeError."""
cv2_mock = _dedup_mod.cv2
mock_cap = MagicMock()
mock_cap.isOpened.return_value = False
cv2_mock.VideoCapture.return_value = mock_cap
import pytest
with patch.object(_dedup_mod.cv2, "VideoCapture", return_value=mock_cap):
with pytest.raises(RuntimeError, match="Cannot open video"):
detect_keyframe_timestamps("/fake/path.mp4")
with pytest.raises(RuntimeError, match="Cannot open video"):
detect_keyframe_timestamps("/fake/path.mp4")
def test_zero_duration_returns_empty(self):
"""视频时长为 0 时返回空列表."""
cv2_mock = _dedup_mod.cv2
mock_cap = MagicMock()
mock_cap.isOpened.return_value = True
# cv2.CAP_PROP_FPS etc. are Mock objects; configure get() to return 0 for frame_count
mock_cap.get.return_value = 0
mock_cap.read.return_value = (False, None)
cv2_mock.VideoCapture.return_value = mock_cap
with patch.object(_dedup_mod.cv2, "VideoCapture", return_value=mock_cap):
result = detect_keyframe_timestamps("/fake/zero.mp4")
assert result == []
result = detect_keyframe_timestamps("/fake/zero.mp4")
assert result == []
def test_function_signature(self):
"""验证函数签名和默认参数."""
+24 -27
View File
@@ -47,35 +47,32 @@ class TestGeneratedVideoCreate:
assert video.file_url == "https://example.com/video.mp4"
assert video.user_id == "user1"
def test_create_empty_project_id_allowed(self):
"""project_id 允许为空(AI数字人等无项目场景)。"""
video = GeneratedVideo.create(
project_id="",
generation_task_id="task1",
name="视频",
file_url="https://example.com/v.mp4",
)
assert video.project_id == ""
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id cannot be empty"):
GeneratedVideo.create(
project_id="",
generation_task_id="task1",
name="视频",
file_url="https://example.com/v.mp4",
)
def test_create_whitespace_project_id_normalized_to_empty(self):
"""project_id 纯空白会被 strip 为空串,不抛异常。"""
video = GeneratedVideo.create(
project_id=" ",
generation_task_id="task1",
name="视频",
file_url="https://example.com/v.mp4",
)
assert video.project_id == ""
def test_create_whitespace_project_id_raises(self):
with pytest.raises(ValueError, match="project_id cannot be empty"):
GeneratedVideo.create(
project_id=" ",
generation_task_id="task1",
name="视频",
file_url="https://example.com/v.mp4",
)
def test_create_empty_generation_task_id_allowed(self):
"""generation_task_id 允许为空(兼容部分异步链路)。"""
video = GeneratedVideo.create(
project_id="proj1",
generation_task_id="",
name="视频",
file_url="https://example.com/v.mp4",
)
assert video.generation_task_id == ""
def test_create_empty_generation_task_id_raises(self):
with pytest.raises(ValueError, match="generation_task_id cannot be empty"):
GeneratedVideo.create(
project_id="proj1",
generation_task_id="",
name="视频",
file_url="https://example.com/v.mp4",
)
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="name cannot be empty"):
+24 -27
View File
@@ -75,35 +75,32 @@ class TestGeneratedVideoCreate:
assert video.file_url == "https://example.com/out.mp4"
assert video.user_id == "user_003"
def test_create_empty_project_id_allowed(self):
"""project_id 允许为空(AI数字人等无项目场景)。"""
video = GeneratedVideo.create(
project_id="",
generation_task_id="t",
name="n",
file_url="u",
)
assert video.project_id == ""
def test_create_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
GeneratedVideo.create(
project_id="",
generation_task_id="t",
name="n",
file_url="u",
)
def test_create_whitespace_project_id_normalized(self):
"""project_id 纯空白归一化为空串。"""
video = GeneratedVideo.create(
project_id=" ",
generation_task_id="t",
name="n",
file_url="u",
)
assert video.project_id == ""
def test_create_whitespace_project_id_raises(self):
with pytest.raises(ValueError, match="project_id"):
GeneratedVideo.create(
project_id=" ",
generation_task_id="t",
name="n",
file_url="u",
)
def test_create_empty_generation_task_id_allowed(self):
"""generation_task_id 允许为空。"""
video = GeneratedVideo.create(
project_id="p",
generation_task_id="",
name="n",
file_url="u",
)
assert video.generation_task_id == ""
def test_create_empty_generation_task_id_raises(self):
with pytest.raises(ValueError, match="generation_task_id"):
GeneratedVideo.create(
project_id="p",
generation_task_id="",
name="n",
file_url="u",
)
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="name"):
@@ -45,25 +45,25 @@ class TestGeneratedVideo:
assert video.duplicate_of is None
assert video.generation_params == {}
def test_create_empty_project_id_allowed(self):
"""project_id 允许为空(AI数字人场景),空白归一化为空串."""
video = GeneratedVideo.create(
project_id=" ",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
assert video.project_id == ""
def test_create_empty_project_id_raises(self):
"""project_id抛异常."""
with pytest.raises(ValueError, match="project_id"):
GeneratedVideo.create(
project_id=" ",
generation_task_id="t1",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
def test_create_empty_task_id_allowed(self):
"""generation_task_id 允许为空."""
video = GeneratedVideo.create(
project_id="p1",
generation_task_id="",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
assert video.generation_task_id == ""
def test_create_empty_task_id_raises(self):
"""generation_task_id抛异常."""
with pytest.raises(ValueError, match="generation_task_id"):
GeneratedVideo.create(
project_id="p1",
generation_task_id="",
name="v.mp4",
file_url="https://x.com/v.mp4",
)
def test_create_empty_name_raises(self):
"""空name抛异常."""
+7 -11
View File
@@ -35,10 +35,7 @@ class TestFFmpegPresetOptimization:
final_label=None,
output_path="/tmp/output.mp4",
)
# 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}"
assert "-preset veryfast" in cmd, f"期望 -preset veryfast,实际命令: {cmd}"
def test_preset_veryfast_with_filter(self):
"""带滤镜场景下也必须使用 veryfast."""
@@ -52,8 +49,7 @@ class TestFFmpegPresetOptimization:
final_label="[v]",
output_path="/tmp/output.mp4",
)
assert "-preset" in cmd
assert cmd[cmd.index("-preset") + 1] == "veryfast"
assert "-preset veryfast" in cmd
assert "-filter_complex" in cmd
def test_preset_not_fast(self):
@@ -69,11 +65,11 @@ class TestFFmpegPresetOptimization:
output_path="/tmp/output.mp4",
)
# 确保是 veryfast 而不是 fast
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"
assert "-preset veryfast" in cmd
# 排除 "fast" 单独出现(veryfast 包含 fast 子串,需精确判断)
parts = cmd.split()
preset_idx = parts.index("-preset")
assert parts[preset_idx + 1] == "veryfast"
# ═══════════════════════════════════════════════════════════════════════════════