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
39 changed files with 587 additions and 2306 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
@@ -1,27 +0,0 @@
"""add sentence_timings to lipsync_jobs
Revision ID: 075_add_sentence_timings
Revises: 074_ai_avatar_render_script_id_optional
Create Date: 2026-09-12
"""
import sqlalchemy as sa
from alembic import op
revision = "075_add_sentence_timings"
down_revision = "074_render_script_id_optional"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("lipsync_jobs") as batch:
batch.add_column(
sa.Column("sentence_timings", sa.JSON(), nullable=True),
)
def downgrade() -> None:
with op.batch_alter_table("lipsync_jobs") as batch:
batch.drop_column("sentence_timings")
+9 -96
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,11 @@ 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 job
return AiAvatarRenderJobResponse.model_validate(job)
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
@@ -211,17 +199,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,
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",
current_user.user.id,
video_url[:80],
exc,
current_user.user.id, video_url[:80], exc,
exc_info=True,
)
cover_url = ""
@@ -234,72 +216,3 @@ def generate_avatar_smart_cover(
)
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
return SmartCoverResponse(cover_url=cover_url, status="completed")
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse)
def generate_render_smart_cover(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
- 必须等渲染任务 completed 后才可调用(否则返回 400)
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url
"""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService(db)
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
video_url = (job.output_video_url or "").strip()
if not video_url:
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空")
try:
# 成片已叠加标题,不传 title_config 避免双重叠加
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
except Exception as exc:
logger.error(
"渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s",
current_user.user.id,
job_id,
video_url[:80],
exc,
exc_info=True,
)
cover_url = ""
if not cover_url:
return SmartCoverResponse(
cover_url="",
status="fallback_failed",
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
)
# 更新 render_job 的封面字段(异步写入 DB;失败不影响返回)
try:
job.cover_config = {
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
"mode": "auto_frame",
"url": cover_url,
}
job.output_cover_url = cover_url
job.updated_at = datetime.now(timezone.utc)
db.commit()
except Exception as exc:
logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc)
logger.info(
"渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s",
current_user.user.id,
job_id,
cover_url[:120],
)
return SmartCoverResponse(cover_url=cover_url, status="completed")
+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):
-1
View File
@@ -33,7 +33,6 @@ class LipsyncJobResponse(BaseModel):
output_duration: float
error_message: str
error_code: str
sentence_timings: Optional[list] = None
submitted_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
+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)
+73 -156
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,7 +24,7 @@ from packages.adapters.sqlalchemy_impl.models import (
ScriptModel,
)
from packages.domain.video_filter_builder import (
build_broll_overlay_filter,
build_cover_extract_command,
build_title_drawtext_filter,
)
from packages.shared.storage import get_shared_storage_service
@@ -197,8 +196,9 @@ class AiAvatarRenderService:
1. 下载对口型输出视频 (20%)
2. 构建 FFmpeg 滤镜链 (40%)
3. 执行 FFmpeg 渲染 (80%)
4. 上传到 OSS (95%) — 封面不再自动生成,改由前端主动抽帧
5. 更新任务状态 (100%)
4. 提取封面 (90%)
5. 上传到 OSS (95%)
6. 更新任务状态 (100%)
"""
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
if job is None:
@@ -228,49 +228,27 @@ class AiAvatarRenderService:
self.db.commit()
# 2. 构建 FFmpeg 滤镜链 (40%)
# 用 ffprobe 探测输入视频分辨率,确保 B-roll 缩放与标题位置与实际输出一致。
# AI 数字人对口型输出为 9:16 竖屏,默认兜底 720x1280;探测失败时使用默认值不阻断渲染。
output_width, output_height = self._probe_video_resolution(input_video_path)
if output_width <= 0 or output_height <= 0:
output_width, output_height = 720, 1280
logger.info(
"[数字人渲染] ffprobe 探测分辨率失败或无效,使用默认竖屏尺寸 %sx%s",
output_width,
output_height,
)
else:
logger.info("[数字人渲染] 探测输入视频分辨率: %sx%s", output_width, output_height)
from packages.domain.video_filter_builder import build_broll_overlay_filter
broll_filter, broll_label = build_broll_overlay_filter(
filter_complex = build_broll_overlay_filter(
b_roll_segments=job.b_roll_segments,
video_duration=lipsync_job.output_duration,
output_width=output_width,
output_height=output_height,
)
# 标题叠加(传入实际输出尺寸,保证位置计算正确)
title_filter = build_title_drawtext_filter(
job.title_config,
output_width=output_width,
output_height=output_height,
)
# 标题叠加
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];"
filter_complex = ""
final_label = None
if broll_filter and title_filter:
# B-roll → 标题叠在 B-roll 输出上
filter_complex = broll_filter + f";[{broll_label}]{title_filter}[vout_titled]"
final_label = "vout_titled"
elif broll_filter:
filter_complex = broll_filter
final_label = broll_label
elif title_filter:
filter_complex = f"[0:v]{title_filter}[vout_titled]"
final_label = "vout_titled"
else:
# 无滤镜:直接拷贝视频流
filter_complex = ""
final_label = None
# 清理末尾分号
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()
@@ -279,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,
@@ -287,46 +265,49 @@ 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()
# 4/5. 上传成片到 OSS (95%) —— 已砍掉自动抽封面逻辑(步骤⑤);
# 封面由前端在渲染完成后通过 /smart-cover 接口主动从成片抽帧,不阻塞渲染链路。
# 4. 提取封面 (90%)
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_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
# 封面透传:如果用户已在 cover_config 中选定封面 URLmode=upload 的自定义上传 或
# mode=auto_frame 已有的智能封面结果),直接透传到 output_cover_url,不再重新截帧。
if isinstance(job.cover_config, dict):
_pre_cover_url = (
job.cover_config.get("url")
or job.cover_config.get("imageUrl")
or job.cover_config.get("cover_url")
or ""
)
if _pre_cover_url:
job.output_cover_url = _pre_cover_url
logger.info("[数字人渲染] 使用用户已选定封面 URL: job_id=%s", job_id)
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧;
# 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)
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
@@ -350,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,
@@ -370,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:
@@ -383,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:
"""下载视频到临时文件."""
@@ -408,37 +383,6 @@ class AiAvatarRenderService:
os.unlink(tmp.name)
raise
@staticmethod
def _probe_video_resolution(video_path: str) -> tuple[int, int]:
"""用 ffprobe 探测视频分辨率,返回 (width, height);失败返回 (0, 0)。"""
try:
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
video_path,
],
capture_output=True,
text=True,
timeout=15,
)
if result.returncode == 0 and result.stdout.strip():
parts = result.stdout.strip().split("x")
if len(parts) == 2:
w, h = int(parts[0]), int(parts[1])
if w > 0 and h > 0:
return w, h
except Exception as exc:
logger.warning("[数字人渲染] ffprobe 探测分辨率失败: %s", exc)
return 0, 0
def _build_ffmpeg_command(
self,
*,
@@ -447,51 +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}]",
"-map",
"0:a?",
]
)
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
elif filter_complex:
cmd.extend(["-filter_complex", filter_complex])
filter_arg = f'-filter_complex "{filter_complex}"'
else:
filter_arg = ""
cmd.extend(
[
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-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.
+5 -27
View File
@@ -198,12 +198,6 @@ class LipsyncService:
self.db.add(job)
self.db.flush()
# ⚠️ 必须先 commit 再发 Celery 任务,避免事务竞态:
# worker 是独立进程+独立DB连接,任务被消费(<4ms)时若本事务还未提交,
# worker 查询 job 会返回 None → 静默 return 不重试,job 永远卡在 tts_processing。
self.db.commit()
self.db.refresh(job)
if is_tts_mode:
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交
try:
@@ -229,7 +223,6 @@ class LipsyncService:
job.error_message = f"Celery 任务投递失败: {exc}"
job.error_code = "AsyncDispatchFailed"
job.updated_at = datetime.now(timezone.utc)
self.db.commit() # 投递失败也要落库失败状态
else:
# 2b. 直接音频模式:同步签名并提交 MediaKit
video_url = self._sign_media_url(video_url)
@@ -247,15 +240,15 @@ class LipsyncService:
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
self.db.commit() # submitted 状态落库
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("提交对口型任务失败: %s", exc)
self.db.commit()
raise
self.db.commit()
self.db.refresh(job)
return job
# ── 查询任务 ──────────────────────────────────────────────────────────
@@ -320,26 +313,11 @@ class LipsyncService:
if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {})
job.status = STATUS_COMPLETED
temp_url = result.get("video_url", "")
# 先以临时 URL 立即返回前端(前端可立即播放),再异步 Celery 任务转存自家 OSS(步骤⑦
job.output_video_url = temp_url
output_url = result.get("video_url", "")
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
# 异步转存到自家 OSS(注意:必须在 commit 之后 dispatch,避免 commit 失败任务已发出)
try:
from app.tasks.lipsync_tts import persist_output_video_task
persist_output_video_task.apply_async(args=(job_id, user_id, temp_url))
except Exception as exc:
logger.warning(
"提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s",
job_id,
exc,
)
self.db.refresh(job)
return job
elif mk_status == STATUS_FAILED:
error = status_data.get("error", {})
job.status = "failed"
+14 -338
View File
@@ -54,170 +54,11 @@ def _sign_media_url(url: str) -> str:
return url
def _split_script_into_sentences(script_text: str) -> list[str]:
"""按句号/问号/感叹号/分号/换行分句(与前端 splitScriptIntoSentences 一致)."""
import re
text = (script_text or "").strip()
if not text:
return []
parts = re.split(r"[。!?!?;\n\r]+", text)
return [p.strip() for p in parts if p.strip()]
def _compute_sentence_timings(audio_data: bytes, script_text: str, total_duration: float) -> list[dict]:
"""基于 TTS 音频的静音检测,精确计算每句文案的起止时间.
使用 ffmpeg silencedetect 检测静音段,将静音点与句子边界对齐。
比字数比例估算准确得多。
Args:
audio_data: TTS 音频二进制数据(MP3
script_text: 文案全文
total_duration: 音频总时长(秒)
Returns:
list[{"index": int, "text": str, "start_time": float, "end_time": float}]
"""
import re
import subprocess
import tempfile
sentences = _split_script_into_sentences(script_text)
if not sentences:
return []
# 写入临时音频文件
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
tmp.write(audio_data)
tmp_path = tmp.name
try:
# 用 ffmpeg silencedetect 检测静音段
result = subprocess.run(
[
"ffmpeg",
"-i",
tmp_path,
"-af",
"silencedetect=noise=-25dB:d=0.3",
"-f",
"null",
"-",
],
capture_output=True,
text=True,
timeout=30,
)
stderr = result.stderr or ""
# 解析静音结束时间点(silence_end: X.XXX
silence_ends = []
for match in re.finditer(r"silence_end:\s*([\d.]+)", stderr):
t = float(match.group(1))
if 0 < t < total_duration:
silence_ends.append(t)
# 如果没有检测到足够的静音点,降级为字数比例估算
if len(silence_ends) < len(sentences) - 1:
logger.warning(
"[sentence_timings] 静音点不足(%d < %d),降级为字数比例估算",
len(silence_ends),
len(sentences) - 1,
)
return _estimate_sentence_timings_by_chars(sentences, total_duration)
# 贪心匹配:N-1 个句子边界对应 N-1 个静音点
# 按时间均匀分布期望值,选择最近的静音点
n_boundaries = len(sentences) - 1
boundaries = []
used_indices = set()
for i in range(n_boundaries):
# 期望的边界位置(按句子数量均匀分布)
expected_pos = (i + 1) / len(sentences) * total_duration
# 找最近的未使用静音点
best_idx = None
best_dist = float("inf")
for j, t in enumerate(silence_ends):
if j in used_indices:
continue
dist = abs(t - expected_pos)
if dist < best_dist:
best_dist = dist
best_idx = j
if best_idx is not None:
used_indices.add(best_idx)
boundaries.append(silence_ends[best_idx])
boundaries.sort()
# 构建 sentence_timings
timings = []
prev_end = 0.0
for i, sent in enumerate(sentences):
start = prev_end
end = boundaries[i] if i < len(boundaries) else total_duration
timings.append(
{
"index": i,
"text": sent,
"start_time": round(start, 2),
"end_time": round(end, 2),
}
)
prev_end = end
return timings
except Exception as exc:
logger.warning("[sentence_timings] 静音检测异常,降级为字数比例估算: %s", exc)
return _estimate_sentence_timings_by_chars(sentences, total_duration)
finally:
import os
try:
os.unlink(tmp_path)
except Exception:
pass
def _estimate_sentence_timings_by_chars(sentences: list[str], total_duration: float) -> list[dict]:
"""降级方案:按字数比例估算句子时间(与原前端逻辑一致)."""
if not sentences or total_duration <= 0:
return []
total_chars = sum(len(s.replace(r"\s", "")) for s in sentences)
if total_chars == 0:
return []
timings = []
acc = 0
for i, sent in enumerate(sentences):
chars = len(sent.replace(r"\s", ""))
start = (acc / total_chars) * total_duration
end = ((acc + chars) / total_chars) * total_duration
timings.append(
{
"index": i,
"text": sent,
"start_time": round(start, 2),
"end_time": round(end, 2),
}
)
acc += chars
return timings
@shared_task(
bind=True,
name="lipsync_tts.synthesize_and_submit",
max_retries=5, # 事务竞态重试3次(job not found+ TTS偶发错误2次
max_retries=2,
default_retry_delay=30,
autoretry_for=(OSError, ConnectionError), # 网络/连接错误自动重试
retry_backoff=True,
retry_backoff_max=30,
soft_time_limit=180,
time_limit=200,
)
def tts_synthesize_and_submit(
self,
@@ -262,28 +103,7 @@ def tts_synthesize_and_submit(
)
if job is None:
# 事务竞态防御:API 在 commit 前投递了任务,worker 消费时事务尚未提交。
# Celery 内置 autoretry_for 不支持"业务条件重试",这里手动 retry 3 次,
# 间隔递增(1s/3s/7s),让 API 事务有时间提交。
# max_retries 由 self.request(retries) 维护;默认 self.max_retries=3 由装饰器 soft_time_limit 下方指定。
retries = getattr(self.request, "retries", 0)
max_retries = 3
if retries < max_retries:
backoff = (2**retries) + (retries * 1) # 1s, 3s, 7s
logger.warning(
"[lipsync_tts] Job not found yet (retry %d/%d, backoff %ds): job_id=%s",
retries + 1,
max_retries,
backoff,
job_id,
)
self.db.close()
raise self.retry(countdown=backoff, max_retries=max_retries)
logger.error(
"[lipsync_tts] Job not found after %d retries, giving up: job_id=%s",
max_retries,
job_id,
)
logger.error("[lipsync_tts] Job not found: job_id=%s", job_id)
return
# 已取消的任务不再处理
@@ -292,13 +112,6 @@ def tts_synthesize_and_submit(
return
# 1. TTS 合成
logger.info(
"[lipsync_tts] 开始 TTS 合成: job_id=%s voice_id=%s text_len=%d speed=%.2f",
job_id,
voice_id,
len(script_text),
speed,
)
try:
cosyvoice = CosyVoiceService()
result = cosyvoice.submit_synthesize_task(
@@ -334,113 +147,37 @@ def tts_synthesize_and_submit(
db.commit()
return
# 2. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL
audio_data: bytes | None = None
_st_tmp_path: str | None = None
# 2. 下载转存自家 OSS
try:
audio_data = safe_download_bytes(
temp_url,
purpose="lipsync_tts_audio",
allowed_mime_types={
allowed_mime_types=(
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav,与 audio/wav 等价(RIFF/WAVE
"audio/mp4",
"audio/x-m4a",
},
),
timeout=60.0,
)
logger.info(
"[lipsync_tts] TTS 音频已下载到内存: job_id=%s size=%d",
job_id,
len(audio_data) if audio_data else 0,
)
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
job.audio_url = permanent_url
except Exception as exc:
# 下载失败:audio_data 保持 None2.5 静音检测会跳过;后续仍用 temp_url 提交 MediaKit
logger.warning(
"[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 URL 提交: job_id=%s err=%s",
"[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s",
job_id,
exc,
)
# TTS 音频使用 CosyVoice 临时 URL,跳过自家 OSS 转存(加速,步骤⑥)
job.audio_url = temp_url
logger.info("[lipsync_tts] TTS 音频使用 CosyVoice 临时 URL(跳过 OSS 转存): job_id=%s", job_id)
job.audio_url = temp_url
db.commit()
# 2.5 计算精确句子时间戳(基于 TTS 音频静音检测)
# 直接复用步骤 2 已下载到内存的 audio_data,避免重新下载
import os as _os
try:
import subprocess as _sp
import tempfile as _tmpf
if not audio_data:
logger.warning("[lipsync_tts] 无音频数据,跳过句子时间戳计算: job_id=%s", job_id)
else:
# 写入临时文件供 ffprobe/ffmpeg 使用
with _tmpf.NamedTemporaryFile(suffix=".mp3", delete=False) as _atmp:
_atmp.write(audio_data)
_st_tmp_path = _atmp.name
# ffprobe 获取音频时长
_probe_result = _sp.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
_st_tmp_path,
],
capture_output=True,
text=True,
timeout=10,
)
_audio_duration = float(_probe_result.stdout.strip()) if _probe_result.stdout.strip() else 0.0
logger.info(
"[lipsync_tts] 音频时长探测: job_id=%s duration=%.2f probe_stdout=%s probe_stderr=%s",
job_id,
_audio_duration,
_probe_result.stdout.strip()[:50],
_probe_result.stderr.strip()[:100] if _probe_result.stderr else "",
)
if _audio_duration > 0:
_timings = _compute_sentence_timings(audio_data, script_text, _audio_duration)
if _timings:
job.sentence_timings = _timings
logger.info(
"[lipsync_tts] 句子时间戳已计算: job_id=%s sentences=%d duration=%.1f",
job_id,
len(_timings),
_audio_duration,
)
else:
logger.warning("[lipsync_tts] 句子时间戳计算返回空结果: job_id=%s", job_id)
else:
logger.warning(
"[lipsync_tts] ffprobe 未获取到有效时长,跳过句子时间戳: job_id=%s stdout=%s stderr=%s",
job_id,
_probe_result.stdout.strip()[:100],
_probe_result.stderr.strip()[:200] if _probe_result.stderr else "",
)
db.commit()
except Exception as _st_err:
logger.warning(
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
)
finally:
if _st_tmp_path:
try:
_os.unlink(_st_tmp_path)
except Exception:
pass
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
audio_url = _sign_media_url(job.audio_url)
video_url = _sign_media_url(job.video_url)
@@ -483,64 +220,3 @@ def tts_synthesize_and_submit(
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
finally:
db.close()
@shared_task(
name="lipsync_tts.persist_output_video",
max_retries=2,
default_retry_delay=30,
)
def persist_output_video_task(job_id: str, user_id: str, temp_url: str):
"""异步转存对口型输出视频到自家 OSS(步骤⑦ — 将同步阻塞挪到后台,加速前端响应).
- MediaKit 返回 completed 后先以 temp_url 回前端(前端可立即播放临时 URL)
- Celery 后台下载 temp_url 并转存 OSS,成功后更新 job.output_video_url 为永久 URL
- 失败则保留 temp_url,不阻断主流程
"""
try:
from worker_app.db import SessionLocal # type: ignore
except Exception: # noqa: BLE001
from app.db import SessionLocal # type: ignore
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.shared.storage import get_shared_storage_service
db = SessionLocal()
try:
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
if job is None:
logger.error("[lipsync_tts.persist] Job not found: job_id=%s", job_id)
return
if not temp_url:
logger.warning("[lipsync_tts.persist] temp_url 为空,跳过转存: job_id=%s", job_id)
return
try:
import httpx
with httpx.Client(timeout=180.0, follow_redirects=True) as client:
resp = client.get(temp_url)
resp.raise_for_status()
data = resp.content
storage = get_shared_storage_service()
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
# 对自家 OSS URL 重签 7 天有效期预签名,供前端播放
final_url = _sign_media_url(permanent_url) if permanent_url else temp_url
job.output_video_url = final_url
job.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
except Exception as exc:
logger.warning(
"[lipsync_tts.persist] 输出视频转存失败,保留临时 URL: job_id=%s err=%s",
job_id,
exc,
)
except Exception:
logger.exception("[lipsync_tts.persist] 未预期异常: job_id=%s", job_id)
finally:
db.close()
+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;
+38 -70
View File
@@ -23,10 +23,8 @@ import {
getLipsyncJob,
submitRender,
getRenderJob,
generateRenderSmartCover,
generateSmartCover,
} from "./api/aiAvatar"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { RenderJob } from "./types"
import {
normalizeEmotion,
buildTitleConfigPayload,
@@ -55,6 +53,8 @@ const AiAvatarPage: React.FC = () => {
"generating",
)
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ── 智能封面加载态 ── */
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -62,8 +62,6 @@ const AiAvatarPage: React.FC = () => {
)
const [renderProgress, setRenderProgress] = useState(0)
const [renderErrorMessage, setRenderErrorMessage] = useState("")
/* ── 当前渲染任务对象(轮询更新;用于封面区判断渲染是否完成) ── */
const [currentRenderJob, setCurrentRenderJob] = useState<RenderJob | null>(null)
/* ── 对口型轮询 ── */
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -208,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 || "",
@@ -224,12 +219,7 @@ const AiAvatarPage: React.FC = () => {
pip_scale: seg.pip_scale,
})) as never,
title_config: buildTitleConfigPayload(state.titleConfig),
// 封面不阻塞渲染:用户未选定封面时传空 dict,后端不生成封面;渲染完成后再单独抽帧
cover_config:
state.coverConfig.smart_cover_url ||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
? buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url)
: {},
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
})
// 打开渲染进度弹窗,启动轮询
@@ -237,28 +227,16 @@ const AiAvatarPage: React.FC = () => {
setRenderStatus("generating")
setRenderProgress(job.progress ?? 0)
setRenderErrorMessage("")
setCurrentRenderJob(job as RenderJob)
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = setInterval(async () => {
try {
const updated = await getRenderJob(job.id)
setRenderProgress(updated.progress ?? 0)
setCurrentRenderJob(updated)
if (updated.status === "completed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null
setRenderStatus("completed")
// 渲染完成后:如果后端已返回封面(用户预上传/预设)则同步到前端;
// 否则不自动设置封面,由用户在封面区点击"智能获取封面"主动抽帧(步骤③④)
if (updated.output_cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: updated.output_cover_url,
thumbnail_url: updated.output_cover_url,
}))
}
message.success("视频已生成并保存到成片库")
} else if (updated.status === "failed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
@@ -291,48 +269,38 @@ const AiAvatarPage: React.FC = () => {
setRenderErrorMessage("")
}, [])
/* ── 智能封面:从最终渲染成片抽帧(POST /renders/{id}/smart-cover,步骤③④ ── */
const handleGenerateRenderSmartCover = useCallback(
async (renderId: string): Promise<{ cover_url: string; message?: string }> => {
try {
const res = await generateRenderSmartCover(renderId)
if (res.cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
return { cover_url: res.cover_url }
}
const errMsg = res.message || "智能封面生成失败,请稍后重试"
message.error(errMsg)
return { cover_url: "", message: errMsg }
} catch (err) {
console.error("智能封面生成失败:", err)
const errMsg = err instanceof Error ? err.message : "智能封面生成失败,请重试"
message.error(errMsg)
return { cover_url: "", message: errMsg }
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822 ── */
const handleSmartCover = useCallback(async () => {
// 基于对口型成片抽帧,必须先完成对口型
const videoUrl = state.lipsyncJob?.output_video_url
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
message.warning("请先生成对口型视频,完成后再智能获取封面")
return
}
setSmartCoverLoading(true)
try {
const res = await generateSmartCover(videoUrl, 5)
if (res.cover_url) {
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
} else {
message.error(res.message || "智能封面生成失败,请稍后重试")
}
},
// state.setCoverConfig 是 zustand action 引用稳定,eslint 不需要检查
} catch (err) {
console.error("智能封面生成失败:", err)
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
} finally {
setSmartCoverLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
}, [state.lipsyncJob])
/* ── 配置汇总 ── */
const coverStatus: "not_ready" | "pending" | "selected" = (() => {
if (
state.coverConfig.smart_cover_url ||
state.coverConfig.thumbnail_url ||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
) {
return "selected"
}
if (currentRenderJob?.status === "completed") return "pending"
return "not_ready"
})()
const summary = {
videoName: state.selectedVideo?.name || null,
voiceName: state.selectedVoice?.name || null,
@@ -340,7 +308,7 @@ const AiAvatarPage: React.FC = () => {
lipsyncStatus: state.lipsyncJob?.status || null,
brollCount: state.bRollSegments.length,
hasTitle: state.titleConfig.title.length > 0,
coverStatus,
hasCover: state.coverConfig.enabled,
}
return (
@@ -374,6 +342,7 @@ const AiAvatarPage: React.FC = () => {
selectedVideo={state.selectedVideo}
onSelectVideo={() => state.setShowAssetPicker(true)}
onRemoveVideo={state.removeVideo}
titleConfig={state.titleConfig}
/>
</div>
</div>
@@ -475,9 +444,9 @@ const AiAvatarPage: React.FC = () => {
onCoverConfigChange={(partial) =>
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
}
titleConfig={state.titleConfig}
renderJob={currentRenderJob}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
onSmartCover={handleSmartCover}
smartCoverLoading={smartCoverLoading}
canSmartCover={state.lipsyncJob?.status === "completed"}
resolution={state.resolution}
onResolutionChange={state.setResolution}
isGenerating={state.isGenerating}
@@ -515,9 +484,8 @@ const AiAvatarPage: React.FC = () => {
open={state.showBRollModal}
onClose={() => state.setShowBRollModal(false)}
existingSegments={state.bRollSegments}
scriptText={state.lipsyncJob?.script_text || state.scriptText}
scriptText={state.scriptText}
outputDuration={state.lipsyncJob?.output_duration ?? 0}
sentenceTimings={state.lipsyncJob?.sentence_timings}
onConfirm={state.addBRollSegment}
onRemove={state.removeBRollSegment}
/>
+4 -19
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,23 +85,10 @@ 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
}
export const cancelRenderJob = async (jobId: string): Promise<void> => {
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
}
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover ── */
export const generateRenderSmartCover = async (
jobId: string,
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
`/ai-avatar/render/${jobId}/smart-cover`,
{},
// 抽帧+评分+转存 OSS 链路较长,120s 超时
{ timeout: 120000 },
)
return response.data
}
@@ -5,12 +5,12 @@
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
* (开始/结束时间来自后端精确句子时间戳,基于 TTS 音频静音检测
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算
* - 底部:已配置的画面插入列表(可删除)
*/
import React, { useEffect, useMemo, useState } from "react"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
import type { BRollSegment, BRollInsertMode, PipPosition, SentenceTiming } from "../types"
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
interface ModalBRollEditorProps {
@@ -18,12 +18,10 @@ interface ModalBRollEditorProps {
onClose: () => void
/** 当前已有的 B-roll segments(用于标灰已选素材) */
existingSegments: BRollSegment[]
/** 文案全文(优先使用对口型时锁定的 scriptText */
/** 当前文案全文(用于分句 */
scriptText: string
/** 对口型成片总时长(秒) */
/** 对口型成片总时长(秒),用于时间自动估算 */
outputDuration: number
/** 后端精确句子时间戳(来自 lipsyncJob.sentence_timings */
sentenceTimings?: SentenceTiming[] | null
onConfirm: (segment: BRollSegment) => void
onRemove: (id: string) => void
}
@@ -45,8 +43,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
onClose,
existingSegments,
scriptText,
outputDuration: _outputDuration,
sentenceTimings,
outputDuration,
onConfirm,
onRemove,
}) => {
@@ -65,10 +62,10 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
const [pipScale, setPipScale] = useState(0.3)
/** 文案分句(优先使用后端精确时间戳,降级为字数比例估算 */
/** 文案分句( */
const sentences = useMemo(
() => splitScriptIntoSentences(scriptText, sentenceTimings, _outputDuration),
[scriptText, sentenceTimings, _outputDuration],
() => splitScriptIntoSentences(scriptText, outputDuration),
[scriptText, outputDuration],
)
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
@@ -145,7 +142,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
setSelectedAsset(asset)
}
/** 确认添加一段 B-roll(⑥ 时间取所选句子的精确起止,后端静音检测 / 前端字数比例降级 */
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止 */
const handleConfirm = () => {
if (!selectedAsset || !selectedSentence) return
const startTime = selectedSentence.startTime
@@ -267,9 +264,11 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
>
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
<span className="aa-sentence-item__text">{sent.text}</span>
<span className="aa-sentence-item__time">
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
</span>
{outputDuration > 0 && (
<span className="aa-sentence-item__time">
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
</span>
)}
</button>
)
})}
@@ -350,7 +349,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
selectedSentence.endTime,
selectedSentence.startTime + 0.5,
).toFixed(1)}
s
s
</div>
</>
) : (
@@ -1,30 +1,26 @@
/**
* AI数字人 — 面板5分辨率/配置摘要/生成按钮/封面
* v3 调整(步骤③④):
* - 布局顺序:分辨率 → 配置摘要卡片 → 🔘「开始生成视频」按钮 → (渲染完成后)封面区域
* - 渲染未完成时封面区域显示占位态,按钮 disabled
* - 「智能获取封面」从最终成片抽帧(调用 POST /renders/{id}/smart-cover),不再依赖 lipsync 状态
* - 修复点 2 次 bug:内部维护 smartCoverLoading,不依赖外层异步 state 更新
* AI数字人 — 面板5:封面 & 生成
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
* - 分辨率选择(720p / 1080p / 4K
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
* - 渐变紫色生成按钮
*
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
*/
import React, { useMemo, useRef, useState } from "react"
import type { AiAvatarCoverConfig, AiAvatarTitleConfig, RenderJob } 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
isGenerating: boolean
onGenerate: () => void
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面 */
renderJob: RenderJob | null
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
onUploadCover?: (file: File) => void
/** 智能获取封面(MediaKit 选帧 */
onSmartCover: () => void
smartCoverLoading: boolean
canSmartCover: boolean
/** 配置汇总信息 */
summary: {
videoName: string | null
@@ -33,8 +29,7 @@ interface PanelCoverAndGenerateProps {
lipsyncStatus: string | null
brollCount: number
hasTitle: boolean
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
coverStatus: "not_ready" | "pending" | "selected"
hasCover: boolean
}
}
@@ -52,32 +47,19 @@ 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,
isGenerating,
onGenerate,
renderJob,
onGenerateRenderSmartCover,
onUploadCover,
onSmartCover,
smartCoverLoading,
canSmartCover,
summary,
}) => {
const uploadInputRef = useRef<HTMLInputElement>(null)
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/** 自定义上传封面 */
const handleUploadClick = () => {
@@ -87,118 +69,60 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (onUploadCover) {
onUploadCover(file)
} else {
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
const url = URL.createObjectURL(file)
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
}
// 本地预览:生成 object URL(实际上传由父级/后端链路处理)
const url = URL.createObjectURL(file)
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
// 允许重复选择同一文件
e.target.value = ""
}
/** 智能获取封面(从最终成片抽帧;必须等 render 完成 */
const handleSmartCover = async () => {
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
setSmartCoverLoading(true)
try {
const res = await onGenerateRenderSmartCover(renderJob.id)
if (res.cover_url) {
onCoverConfigChange({
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
})
} else {
// 失败由父组件 message 提示,这里不重复弹窗
console.warn("[智能封面] 返回空 cover_url:", res.message)
}
} catch (err) {
console.error("[智能封面] 调用失败:", err)
} finally {
setSmartCoverLoading(false)
}
/** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822 */
const handleSmartCover = () => {
onCoverConfigChange({ mode: "auto_frame" })
onSmartCover()
}
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
// 渲染已完成 → 封面区可用
const isRenderCompleted = renderJob?.status === "completed"
const canSmartCover = isRenderCompleted && !smartCoverLoading
/** 封面图实际展示的 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 || 48) * 0.35}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) {
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translate(-50%, -50%)"
} else {
style.bottom = "40px"
}
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 = "none"
}
return style
}, [titleConfig])
/** 封面区占位文字 */
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
/** 封面摘要状态文本 */
const coverSummaryNode = (() => {
if (summary.coverStatus === "selected") {
return <span className="aa-config-summary__value"></span>
}
if (summary.coverStatus === "pending") {
return <span className="aa-config-summary__value"></span>
}
return <span className="aa-config-summary__empty"></span>
})()
return (
<div className="aa-cover-generate">
{/* 封面预览(竖屏 9:16 */}
<div className="aa-cover-preview">
{coverConfig.thumbnail_url ? (
<img src={coverConfig.thumbnail_url} alt="封面预览" />
) : (
<span className="aa-cover-preview__placeholder"></span>
)}
</div>
<div className="aa-cover-actions">
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={smartCoverLoading || !canSmartCover}
title={canSmartCover ? "基于对口型成片智能选帧" : "请先完成对口型生成"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
>
📷
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
{/* 分辨率选择 */}
<div className="aa-form-field">
<label className="aa-label"></label>
@@ -206,7 +130,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
className="aa-select"
value={resolution}
onChange={(e) => onResolutionChange(e.target.value)}
disabled={isGenerating}
>
{RESOLUTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -267,7 +190,11 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div>
<div className="aa-config-summary__row">
<span></span>
{coverSummaryNode}
{summary.hasCover ? (
<span className="aa-config-summary__value"></span>
) : (
<span className="aa-config-summary__empty"></span>
)}
</div>
</div>
@@ -285,60 +212,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div>
)}
{isGenerating && (
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
</div>
)}
</div>
{/* 封面区域(视频生成后才激活;步骤③④要求:按钮在封面上方,完成后再显示封面区) */}
<div className="aa-cover-section" style={{ marginTop: 16 }}>
<div className="aa-label" style={{ marginBottom: 8 }}>
</div>
{/* 封面预览(竖屏 9:16 */}
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : (
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
)}
{smartCoverLoading && <div className="aa-cover-preview__loading"> </div>}
{showTitleOverlay && (
<div style={titleOverlayStyle} aria-hidden="true">
{titleConfig.title}
</div>
)}
</div>
<div className="aa-cover-actions">
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover}
disabled={!canSmartCover}
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
>
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button>
<button
type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick}
disabled={!isRenderCompleted || smartCoverLoading}
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
>
📷
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
</div>
)
@@ -14,8 +14,8 @@ interface PanelLipsyncPreviewProps {
onRemoveBRoll: (id: string) => void
/** 标题配置(实时叠加预览用) */
titleConfig?: AiAvatarTitleConfig
/** 标题位置变更回调(拖拽结束时调用,发送百分比坐标 + position:"custom" */
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number; position: string }) => void
/** 标题位置变更回调(拖拽结束时调用) */
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number }) => void
}
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
@@ -56,9 +56,11 @@ export function PanelLipsyncPreview({
const titleOverlayStyle: React.CSSProperties | null = titleConfig?.title
? {
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
color: titleConfig.color || "#ffffff",
fontFamily: titleConfig.font || "思源黑体",
fontSize: `${(titleConfig.size || 48) * 0.35}px`,
fontSize: `${(titleConfig.size || 36) * 0.55}px`, // 预览等比缩
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
@@ -66,19 +68,11 @@ export function PanelLipsyncPreview({
padding: "4px 8px",
textShadow: titleConfig.shadow ? "0 2px 4px rgba(0,0,0,0.8)" : undefined,
WebkitTextStroke: titleConfig.stroke ? "1.5px #000" : undefined,
...(titleConfig.position === "custom" &&
titleConfig.pos_x != null &&
titleConfig.pos_y != null
? {
left: `${titleConfig.pos_x}%`,
top: `${titleConfig.pos_y}%`,
transform: "translateX(-50%) translateY(-50%)",
}
: titleConfig.position === "top"
? { left: "50%", top: 8, transform: "translateX(-50%)" }
: titleConfig.position === "bottom"
? { left: "50%", bottom: 8, transform: "translateX(-50%)" }
: { left: "50%", top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
...(titleConfig.position === "top"
? { top: 8 }
: titleConfig.position === "bottom"
? { bottom: 8 }
: { top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
}
: null
@@ -111,10 +105,7 @@ export function PanelLipsyncPreview({
const rect = previewContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
// 发送百分比坐标(0-100),与后端 drawtext 百分比表达式对齐
const xpct = Math.round((relX / rect.width) * 1000) / 10
const ypct = Math.round((relY / rect.height) * 1000) / 10
onTitlePositionChange({ pos_x: xpct, pos_y: ypct, position: "custom" })
onTitlePositionChange({ pos_x: relX, pos_y: relY })
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
}
@@ -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>
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
+3 -15
View File
@@ -44,23 +44,12 @@ export interface LipsyncJob {
status: LipsyncStatus
progress: number
output_video_url: string | null
/** 对口型成片总时长(秒),后端返回 */
script_text: string
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
output_duration?: number
/** 精确句子时间戳(后端基于 TTS 音频静音检测计算) */
sentence_timings?: SentenceTiming[] | null
error_message: string | null
created_at: string
}
/* ── 句子时间戳(后端精确计算) ── */
export interface SentenceTiming {
index: number
text: string
start_time: number
end_time: number
}
/* ── B-roll 画面插入 ── */
export type BRollInsertMode = "fullscreen" | "pip"
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
@@ -88,7 +77,7 @@ export interface AiAvatarTitleConfig {
shadow: boolean
color: string
auto_subtitle: boolean
/** 自定义位置坐标(position=custom 时生效,百分比 0-100 */
/** 自定义位置坐标(position=custom 时生效,像素 */
pos_x?: number
pos_y?: number
}
@@ -112,7 +101,6 @@ export interface RenderJob {
status: RenderStatus
progress: number
output_video_url: string | null
output_cover_url: string | null
error_message: string | null
created_at: string
}
@@ -122,7 +110,7 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
title: "",
position: "bottom",
font: "思源黑体",
size: 48,
size: 28,
bold: true,
italic: false,
stroke: false,
@@ -39,7 +39,7 @@ export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string
text,
enabled: true,
font: cfg.font || "思源黑体",
font_size: Math.round(cfg.size) || 48,
font_size: Math.round(cfg.size) || 36,
font_color: cfg.color || "#ffffff",
position,
bold: !!cfg.bold,
@@ -67,14 +67,9 @@ export function buildCoverConfigPayload(
// build_cover_extract_command 读取 timestamp(截帧秒数)
timestamp: cfg.frame_time || 0,
}
// 智能封面 URL(后端字段名为 url/imageUrl/cover_url 都兼容,优先 url
if (smartCoverUrl) {
payload.url = smartCoverUrl
payload.cover_url = smartCoverUrl
}
if (smartCoverUrl) payload.cover_url = smartCoverUrl
// 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用
if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) {
payload.url = cfg.upload_url
payload.upload_url = cfg.upload_url
}
return payload
@@ -1,8 +1,5 @@
/**
* AI数字人 — 文案分句 & B-roll 时间计算
*
* 优先使用后端基于 TTS 音频静音检测计算的精确 sentence_timings
* 后端未返回(如对口型还在生成中)时,降级为前端按字数比例估算。
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
*/
export interface ScriptSentence {
@@ -14,63 +11,28 @@ export interface ScriptSentence {
charCount: number
/** 累计起始字数(用于时间估算) */
startChar: number
/** 对口型视频内起始时间(秒)——后端精确值或前端估算 */
/** 估算的对口型视频内起始时间(秒) */
startTime: number
/** 对口型视频内结束时间(秒)——后端精确值或前端估算 */
/** 估算的对口型视频内结束时间(秒) */
endTime: number
}
/**
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
* 空文案返回空数组。时间优先使用后端 sentence_timings;否则按字数线性估算。
*
* @param sentenceTimings 后端返回的精确句子时间戳(来自 lipsync_job.sentence_timings)。
* 非空且有效时优先采用,跳过前端估算。
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
*/
export function splitScriptIntoSentences(
scriptText: string,
sentenceTimings?: { index: number; text: string; start_time: number; end_time: number }[] | null,
outputDuration: number = 0,
outputDuration: number,
): ScriptSentence[] {
const text = (scriptText || "").trim()
if (!text) return []
// 1. 先做基础分句(仅用于降级估算 / 没有 sentenceTimings 时)
const rawParts = text
.split(/[。!?!?;\n\r]+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
// 2. 优先使用后端精确时间戳
// 校验:必须是数组、条数一致、每条都有 start_time/end_time,否则降级估算
if (Array.isArray(sentenceTimings) && sentenceTimings.length === rawParts.length) {
const valid = sentenceTimings.every(
(t) =>
t &&
typeof t.start_time === "number" &&
typeof t.end_time === "number" &&
t.end_time >= t.start_time,
)
if (valid) {
let accChar = 0
return sentenceTimings.map((t, i) => {
const part = rawParts[i] ?? t.text ?? ""
const charCount = part.replace(/\s/g, "").length
const sentence: ScriptSentence = {
index: t.index ?? i,
text: part,
charCount,
startChar: accChar,
startTime: round1(t.start_time),
endTime: round1(t.end_time),
}
accChar += charCount
return sentence
})
}
}
// 3. 降级:按字数比例线性估算
const totalChars = rawParts.reduce((sum, part) => sum + part.replace(/\s/g, "").length, 0)
const duration = outputDuration > 0 ? outputDuration : 0
+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,
@@ -703,9 +703,6 @@ class LipsyncJobModel(Base):
error_message = Column(Text, nullable=False, default="")
error_code = Column(String(100), nullable=False, default="")
# 精确句子时间戳(TTS 合成后由 silencedetect 计算,用于 B-roll 精确定位)
sentence_timings = Column(JSON, nullable=True) # list[{index,text,start_time,end_time}]
# 时间戳
submitted_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
+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,
+93 -188
View File
@@ -377,30 +377,26 @@ def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) -
# ── 标题 drawtext 滤镜构建(#1789)─────────────────────────────────────────────
# drawtext 字体搜索路径:按优先级从高到低排
# 服务器使用 Noto Sans SC(思源黑体)作为默认字体
# - NotoSansSC-VF.ttf 是 worker-base.Dockerfile 中 COPY 的 VF 字体(含所有字重,无 Mono 变体),优先级最高
# - .ttc 系列为 fonts-noto-cjk 包预装字体(Dockerfile 已删除含 Mono 变体的旧 .ttc,存在时作为 fallback
# - DejaVuSans 仅含拉丁字符不支持中文,已移除
# drawtext 字体搜索路径:按优先级列出常见安装位置
# 服务器使用 Noto Sans SC(思源黑体)作为默认字体
DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
"/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
# 前端字体名 → drawtext 字体搜索关键字(匹配 DRAWTEXT_FONT_SEARCH_PATHS 中的文件名关键字)
# 前端字体名 → drawtext 字体搜索关键字
DRAWTEXT_FONT_MAP: dict[str, str] = {
"思源黑体": "NotoSansSC",
"思源黑体": "NotoSansCJK",
"思源宋体": "NotoSerifCJK",
"苹方": "NotoSansSC",
"PingFang": "NotoSansSC",
"微软雅黑": "NotoSansSC",
"苹方": "NotoSansCJK",
"PingFang": "NotoSansCJK",
"微软雅黑": "NotoSansCJK",
"楷体": "NotoSerifCJK",
"华康俪金黑": "NotoSansSC",
"华康俪金黑": "NotoSansCJK",
}
@@ -420,44 +416,21 @@ def _escape_drawtext_text(text: str) -> str:
return result
# 粗体字体文件映射:服务器镜像只保留了 NotoSansSC-VF.ttf(可变字体,已删除
# NotoSansCJK-Bold.ttc 以避免 Mono 变体问题,见 worker-base.Dockerfile),
# 因此无法通过 fontfile 切换到 Bold 字重。这里保留路径列表作为未来扩展,
# 实际加粗通过 borderw 黑色描边实现(见下)。
DRAWTEXT_BOLD_FONT_SEARCH_PATHS: list[str] = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/truetype/noto/NotoSansSC-Bold.ttf",
"/usr/share/fonts/noto/NotoSansSC-Bold.ttf",
]
def _resolve_font_path(font_name: str, bold: bool = False) -> str:
def _resolve_font_path(font_name: str) -> str:
"""解析字体名到服务器实际字体文件路径。
查找策略:
1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字
2. bold=True 时优先查找粗体变体;找不到回退常规字重
3. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径
4. 未找到则返回空字符串(drawtext 使用内置默认字体)
2. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径
3. 未找到则返回空字符串(drawtext 使用内置默认字体)
"""
keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name)
import os
if bold:
for path in DRAWTEXT_BOLD_FONT_SEARCH_PATHS:
if keyword.lower() in path.lower() and os.path.isfile(path):
return path
# 粗体文件找不到时,再查常规字重(后面会用描边兜底加粗)
for path in DRAWTEXT_FONT_SEARCH_PATHS:
if keyword.lower() in path.lower() and os.path.isfile(path):
return path
# fallback:遍历搜索任意可用字体
if bold:
for path in DRAWTEXT_BOLD_FONT_SEARCH_PATHS:
if os.path.isfile(path):
return path
for path in DRAWTEXT_FONT_SEARCH_PATHS:
if os.path.isfile(path):
return path
@@ -504,13 +477,13 @@ def build_title_drawtext_filter(
# ── 样式参数 ──
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
font_size = int(title_config.get("font_size") or title_config.get("size") or 48)
font_size = int(title_config.get("font_size") or title_config.get("size") or 36)
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
if font_color.startswith("#"):
font_color = font_color[1:]
position = title_config.get("position") or "bottom"
position = title_config.get("position", "top")
bold = bool(title_config.get("bold", True))
stroke = title_config.get("stroke")
shadow = title_config.get("shadow")
@@ -518,8 +491,8 @@ def build_title_drawtext_filter(
# ── 构建 drawtext 参数 ──
params: list[str] = []
# 字体文件:粗体优先使用 Bold 字体文件,避免同色描边造成字形偏移/重影
font_path = _resolve_font_path(font_name, bold=bold)
# 字体文件
font_path = _resolve_font_path(font_name)
if font_path:
escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'")
params.append(f"fontfile='{escaped_path}'")
@@ -531,28 +504,26 @@ def build_title_drawtext_filter(
params.append(f"fontsize={font_size}")
params.append(f"fontcolor={font_color}")
# 粗体:bold 在 drawtext 中通过 font 的 Bold 变体实现
# 若字体有 Bold 变体可用 fontfont=bold;否则通过 borderw 模拟
if bold:
# 使用 font 参数尝试加载 Bold 变体(Noto Sans SC 有 Bold 变体文件)
params.append("font=bold")
# 描边(borderw 需要 libfreetype 支持)
# 之前用 borderw=3 + font_color 同色描边模拟粗体,会在小字号/竖屏视频上造成
# 字形偏移、边缘重影,看起来像文字被打印了两次(用户截图中的标题"曝光曝光…")。
# 修复:粗体改用黑色细描边(borderw=2, 黑色),视觉上清晰加粗且不产生偏移。
# 用户显式开启 stroke 时按用户配置走;粗体+无stroke 默认黑色细描边。
border_width = 0
border_color = "000000"
if stroke:
if isinstance(stroke, bool):
border_width = 2
border_color = "000000"
border_color = "black"
elif isinstance(stroke, dict):
if stroke.get("enabled", True):
border_width = int(stroke.get("width", 2))
border_color = (stroke.get("color") or "#000000").lstrip("#")
elif bold:
# 粗体模式且未配描边:黑色细描边,模拟粗体同时保证不重影
border_width = 2
border_color = "000000"
if border_width > 0:
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
border_width = int(stroke.get("width", 2)) if stroke.get("enabled", True) else 0
border_color = (stroke.get("color") or "#000000").lstrip("#")
else:
border_width = 0
border_color = "black"
if border_width > 0:
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
# 阴影(shadowcolor + shadowx/y
if shadow:
@@ -577,13 +548,8 @@ def build_title_drawtext_filter(
and not isinstance(pos_x, bool)
and not isinstance(pos_y, bool)
):
# pos_x/pos_y 为百分比坐标(0-100),转换为 drawtext 表达式
# 例如 pos_x=50 → x=(w-text_w)*0.50(水平居中偏50%
# pos_y=30 → y=(h-text_h)*0.30
pct_x = max(0.0, min(100.0, float(pos_x))) / 100.0
pct_y = max(0.0, min(100.0, float(pos_y))) / 100.0
params.append(f"x=(w-text_w)*{pct_x:.4f}")
params.append(f"y=(h-text_h)*{pct_y:.4f}")
params.append(f"x={int(pos_x)}")
params.append(f"y={int(pos_y)}")
else:
# 三档预设位置:top / center / bottom
# x 始终水平居中:(w-text_w)/2
@@ -607,7 +573,7 @@ def build_broll_overlay_filter(
video_duration: float,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
) -> tuple[str, str | None]:
) -> str:
"""构建 B-roll 叠加滤镜链。
支持两种模式:
@@ -615,182 +581,121 @@ def build_broll_overlay_filter(
- pip: 在对口型视频上叠加画中画 B-roll
Args:
b_roll_segments: B-roll 片段配置列表(原始顺序,决定 FFmpeg -i 输入顺序)
b_roll_segments: B-roll 片段配置列表
video_duration: 对口型视频总时长(秒)
output_width: 输出宽度(默认 1280;AI 数字人竖屏传 720)
output_height: 输出高度(默认 720;AI 数字人竖屏传 1280)
output_width: 输出宽度
output_height: 输出高度
Returns:
(filter_complex_str, final_label)
- filter_complex_str: filter_complex 片段字符串(末尾无分号)
- final_label: 最终输出 pad 标签名,如 "vout";无 B-roll 时返回 None
FFmpeg filter_complex 滤镜字符串片段
"""
if not b_roll_segments:
return "", None
# 建立原始列表下标 → FFmpeg 输入下标的映射:
# cmd 中 [0:v] 是主视频,随后按 b_roll_segments 原始顺序追加 -i
# 因此第 i 个 segment 的输入是 [{i+1}:v]
def _input_label(seg: dict[str, Any]) -> str:
# seg 必须来自 b_roll_segments;通过 id() 在原列表中查找
for i, s in enumerate(b_roll_segments):
if s is seg:
return f"[{i + 1}:v]"
# fallback: 找不到时不应发生,保守返回
return "[1:v]"
return ""
parts: list[str] = []
sorted_segments = sorted(b_roll_segments, key=lambda s: s.get("start_time", 0))
# 按模式分组
# 按模式分组处理
fullscreen_segments = [s for s in sorted_segments if s.get("mode") == "fullscreen"]
pip_segments = [s for s in sorted_segments if s.get("mode") == "pip"]
final_label = None
# ── fullscreen 模式: 切分 + concat ──
if fullscreen_segments:
fs_filter, fs_label = _build_fullscreen_filters(
fullscreen_segments, b_roll_segments, video_duration, output_width, output_height, _input_label
)
parts.append(fs_filter)
final_label = fs_label
else:
fs_label = None
parts.append(_build_fullscreen_filters(fullscreen_segments, video_duration, output_width, output_height))
# ── pip 模式: overlay 滤镜 ──
if pip_segments:
pip_filter, pip_label = _build_pip_filters(
pip_segments, output_width, output_height, _input_label, base_label=fs_label
)
parts.append(pip_filter)
final_label = pip_label
for idx, seg in enumerate(pip_segments):
start = seg.get("start_time", 0)
end = seg.get("end_time", video_duration)
scale = seg.get("pip_scale", 0.3)
position = seg.get("pip_position", "bottom_right")
pip_w = int(output_width * scale)
pip_h = int(output_height * scale)
# 位置映射
pos_map = {
"top_left": "10:10",
"top_right": "W-w-10:10",
"bottom_left": "10:H-h-10",
"bottom_right": "W-w-10:H-h-10",
"center": "(W-w)/2:(H-h)/2",
}
pos_expr = pos_map.get(position, pos_map["bottom_right"])
broll_input_idx = len(sorted_segments) # placeholder for input index
parts.append(
f"[{broll_input_idx + idx}:v]scale={pip_w}:{pip_h}," f"enable='between(t,{start},{end})'[pip{idx}];"
)
# overlay onto main stream
if idx == 0:
base_label = "[vout]" if fullscreen_segments else "[0:v]"
else:
base_label = f"[pip{idx - 1}]"
parts.append(f"{base_label}[pip{idx}]overlay={pos_expr}:enable='between(t,{start},{end})'[vout{idx}];")
result = "".join(parts)
# 清理末尾多余分号
if result.endswith(";"):
result = result[:-1]
return result, final_label
return result
def _build_fullscreen_filters(
sorted_fs_segments: list[dict[str, Any]],
all_segments: list[dict[str, Any]],
segments: list[dict[str, Any]],
video_duration: float,
output_width: int,
output_height: int,
input_label_fn,
) -> tuple[str, str]:
"""构建 fullscreen 模式的切分 + concat 滤镜。
) -> str:
"""构建 fullscreen 模式的切分 + concat 滤镜.
视频按 B-roll 时间段切分,然后用 concat 拼接主视频片段和 B-roll 片段。
Returns:
(filter_str, final_label) 其中 final_label 是 concat 输出的 pad 标签
对口型视频按 B-roll 时间段切分,然后用 concat 拼接 B-roll 片段。
"""
parts: list[str] = []
prev_end = 0.0
# 注意:这里的 idx 是 sorted_fs_segments 中的下标;
# 实际 FFmpeg 输入下标必须通过 input_label_fn 查询
for idx, seg in enumerate(sorted_fs_segments):
for idx, seg in enumerate(segments):
start = seg.get("start_time", 0)
end = seg.get("end_time", video_duration)
# 视频片段(B-roll 之前)
# 保持原视频片段(B-roll 之前的部分
if prev_end < start:
parts.append(f"[0:v]trim=start={prev_end}:end={start},setpts=PTS-STARTPTS[main{idx}];")
# B-roll 片段:缩放到输出分辨率并裁到对应时长
in_lbl = input_label_fn(seg)
# B-roll 片段:缩放至目标分辨率
parts.append(
f"{in_lbl}scale={output_width}:{output_height}"
f"[{idx + 1}:v]scale={output_width}:{output_height}"
f":force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,"
f"trim=start=0:end={end - start},setpts=PTS-STARTPTS[br{idx}];"
)
prev_end = end
# 尾部主视频片段
# 尾部片段
if prev_end < video_duration:
last_idx = len(sorted_fs_segments)
last_idx = len(segments)
parts.append(f"[0:v]trim=start={prev_end}:end={video_duration},setpts=PTS-STARTPTS[main{last_idx}];")
# concat 所有片段
segment_labels: list[str] = []
for idx, seg in enumerate(sorted_fs_segments):
start = seg.get("start_time", 0)
# 每段 B-roll 之前是否有主视频片段?
has_main_before = (idx == 0 and start > 0) or (
idx > 0 and sorted_fs_segments[idx - 1].get("end_time", 0) < start
)
if has_main_before:
segment_labels.append(f"[main{idx}]")
segment_labels = []
for idx in range(len(segments)):
start = segments[idx].get("start_time", 0)
if (idx == 0 and segments[0].get("start_time", 0) > 0) or idx > 0:
prev_end_prev = segments[idx - 1].get("end_time", 0) if idx > 0 else 0
if prev_end_prev < start:
segment_labels.append(f"[main{idx}]")
segment_labels.append(f"[br{idx}]")
if prev_end < video_duration:
segment_labels.append(f"[main{len(sorted_fs_segments)}]")
final_lbl = "vout_fs"
if prev_end < video_duration:
segment_labels.append(f"[main{len(segments)}]")
n = len(segment_labels)
if n > 0:
concat_inputs = "".join(segment_labels)
parts.append(f"{concat_inputs}concat=n={n}:v=1:a=0[{final_lbl}];")
parts.append(f"{concat_inputs}concat=n={n}:v=1:a=0[vout];")
return "".join(parts), final_lbl
def _build_pip_filters(
pip_segments: list[dict[str, Any]],
output_width: int,
output_height: int,
input_label_fn,
base_label: str | None,
) -> tuple[str, str]:
"""构建 PIP(画中画)overlay 滤镜链。
Args:
pip_segments: 按时间排序的 pip 片段
output_width: 输出宽度
output_height: 输出高度
input_label_fn: 片段 → 输入标签的映射函数
base_label: 前序滤镜链输出的标签(如 fullscreen 的 vout_fs),为 None 则基于 [0:v]
Returns:
(filter_str, final_label)
"""
parts: list[str] = []
cur_label = base_label # 当前叠加到的标签
pos_map = {
"top_left": "10:10",
"top_right": "W-w-10:10",
"bottom_left": "10:H-h-10",
"bottom_right": "W-w-10:H-h-10",
"center": "(W-w)/2:(H-h)/2",
}
for idx, seg in enumerate(pip_segments):
start = seg.get("start_time", 0)
end = seg.get("end_time", 0)
scale = seg.get("pip_scale", 0.3)
position = seg.get("pip_position", "bottom_right")
pos_expr = pos_map.get(position, pos_map["bottom_right"])
pip_w = max(1, int(output_width * scale))
pip_h = max(1, int(output_height * scale))
enable_expr = f"enable='between(t,{start},{end})'"
in_lbl = input_label_fn(seg)
pip_scaled = f"pip{idx}"
parts.append(f"{in_lbl}scale={pip_w}:{pip_h},{enable_expr}[{pip_scaled}];")
# overlay onto the current base
base = f"[{cur_label}]" if cur_label else "[0:v]"
out_lbl = f"vout_pip{idx}" if idx < len(pip_segments) - 1 else "vout"
parts.append(f"{base}[{pip_scaled}]overlay={pos_expr}:{enable_expr}[{out_lbl}];")
cur_label = out_lbl
return "".join(parts), cur_label or "vout"
return "".join(parts)
def build_cover_extract_command(
-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
+3 -123
View File
@@ -258,9 +258,8 @@ class TestBrollOverlayFilter:
def test_empty_segments_returns_empty(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
result, label = build_broll_overlay_filter([], 30.0)
result = build_broll_overlay_filter([], 30.0)
assert result == ""
assert label is None
def test_pip_mode_generates_overlay(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
@@ -276,9 +275,8 @@ class TestBrollOverlayFilter:
"pip_scale": 0.3,
}
]
result, label = build_broll_overlay_filter(segments, 30.0)
result = build_broll_overlay_filter(segments, 30.0)
assert "overlay" in result or "scale=" in result
assert label == "vout"
def test_fullscreen_mode_generates_concat(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter
@@ -292,9 +290,8 @@ class TestBrollOverlayFilter:
"end_time": 10.0,
}
]
result, label = build_broll_overlay_filter(segments, 30.0)
result = build_broll_overlay_filter(segments, 30.0)
assert "trim" in result or "concat" in result
assert label == "vout_fs"
def test_cover_extract_command(self):
from packages.domain.video_filter_builder import build_cover_extract_command
@@ -318,120 +315,3 @@ class TestBrollOverlayFilter:
"/tmp/cover.jpg",
)
assert "scale=" in cmd
def _make_mock_auth_user(user_id="user-1"):
"""构造 AuthenticatedUsercurrent_user.user.id."""
auth = MagicMock()
auth.user.id = user_id
return auth
class TestRenderSmartCoverRoute:
"""POST /renders/{job_id}/smart-cover — 从成片智能抽封面(步骤②)."""
def test_smart_cover_job_not_found_returns_404(self):
"""渲染任务不存在 → 404."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_service.get_render_job.return_value = None
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
# 函数内部 `from app.services.ai_avatar_render_service import AiAvatarRenderService`
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-missing", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 404
assert "不存在" in exc_info.value.detail
mock_service.get_render_job.assert_called_once_with("render-missing", "user-1")
def test_smart_cover_job_not_completed_returns_400(self):
"""任务未 completed(如 processing)→ 400."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="processing", output_video_url="https://oss/video.mp4")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
assert "先完成视频生成" in exc_info.value.detail
def test_smart_cover_empty_video_url_returns_400(self):
"""已 completed 但 output_video_url 为空/空白 → 400."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="completed", output_video_url=" ")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
assert "URL 为空" in exc_info.value.detail
def test_smart_cover_success_updates_db_and_returns_url(self):
"""抽帧成功 → 更新 job.cover_config / output_cover_url 并 commit,返回 completed."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/final.mp4",
)
mock_job.cover_config = {"mode": "manual"}
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch(
"app.api.routes.ai_avatar_render.generate_smart_cover", return_value="https://oss/cover.jpg"
) as mock_gen,
):
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
mock_gen.assert_called_once_with("https://oss/final.mp4", job_id="render-1", max_frames=5)
assert result.status == "completed"
assert result.cover_url == "https://oss/cover.jpg"
assert mock_job.output_cover_url == "https://oss/cover.jpg"
assert mock_job.cover_config["mode"] == "auto_frame"
assert mock_job.cover_config["url"] == "https://oss/cover.jpg"
mock_db.commit.assert_called_once()
def test_smart_cover_extract_failure_returns_fallback_failed(self):
"""generate_smart_cover 抛异常 → fallback_failed,不抛错不写 DB."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="completed", output_video_url="https://oss/final.mp4")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch("app.api.routes.ai_avatar_render.generate_smart_cover", side_effect=RuntimeError("mediakit down")),
):
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert result.status == "fallback_failed"
assert result.cover_url == ""
# 失败时不写 cover_config / 不 commit
mock_db.commit.assert_not_called()
+2 -63
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")
@@ -628,58 +622,3 @@ class TestAiAvatarRenderService:
err = AiAvatarRenderError("测试错误", code="TestCode")
assert err.code == "TestCode"
assert str(err) == "测试错误"
class TestAiAvatarRenderCoverPassthrough:
"""execute_render 中封面透传逻辑(320~329 行):cover_config 含 url/imageUrl/cover_url 时直接透传到 output_cover_url."""
def _run_execute(self, mock_job, mock_lipsync_job):
"""驱动 execute_render 跑到完成阶段的通用脚手架(mock IO 部分)."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
# query.filter 返回同一个 filter 两次(render_job 查询、lipsync 查询)
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
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("tempfile.TemporaryDirectory") as tmpdir_mock,
patch("app.services.ai_avatar_cover_service.generate_smart_cover", return_value=""),
patch("packages.domain.generated_video.GeneratedVideo.create", return_value=MagicMock()),
patch(
"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")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
repo_cls.return_value = MagicMock()
svc.execute_render(mock_job.id)
return mock_db, mock_job
def test_cover_url_in_cover_config_passthrough_to_output_cover(self):
"""cover_config.url 存在 → 透传到 output_cover_url."""
mock_job = _make_mock_render_job(job_id="render-cov-1", status="pending")
mock_job.cover_config = {"mode": "upload", "url": "https://oss/user-cover.jpg"}
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
_, job = self._run_execute(mock_job, mock_lipsync_job)
assert job.output_cover_url == "https://oss/user-cover.jpg"
def test_cover_imageurl_fallback_also_passthrough(self):
"""cover_config.imageUrl(老字段)存在 → 也透传到 output_cover_url."""
mock_job = _make_mock_render_job(job_id="render-cov-2", status="pending")
mock_job.cover_config = {"mode": "upload", "imageUrl": "https://oss/user-cover2.jpg"}
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
_, job = self._run_execute(mock_job, mock_lipsync_job)
assert job.output_cover_url == "https://oss/user-cover2.jpg"
+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 -55
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"
# ═══════════════════════════════════════════════════════════════════════════════
@@ -288,47 +284,3 @@ class TestCancelJobTtsProcessing:
result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled"
class TestCreateJobCommitOrder:
"""验证事务顺序修复:create_job 必须先 commit 再发 Celery 任务,避免 worker 消费时 job 不可见。"""
def test_commit_called_before_apply_async_in_tts_mode(self):
"""TTS 模式:db.commit() 必须在 apply_async() 之前调用,防止 worker 查不到 job 永远卡在 tts_processing。"""
svc, client, cosy = _make_service_with_mocks()
call_order: list[str] = []
def track_commit():
call_order.append("commit")
def track_apply_async(*args, **kwargs):
call_order.append("apply_async")
svc.db.commit.side_effect = track_commit
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock(side_effect=track_apply_async)
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="v-1",
script_text="测试",
)
# 至少有一次 commit 在 apply_async 之前
assert "commit" in call_order, "db.commit 必须被调用"
assert "apply_async" in call_order, "apply_async 必须被调用"
assert call_order.index("commit") < call_order.index(
"apply_async"
), f"事务顺序错误:commit 必须在 apply_async 之前,实际顺序 {call_order}"
def test_job_not_found_retry_mechanism_exists(self):
"""worker 侧 job not found 必须有重试机制(self.retry),而不是静默 return。"""
import inspect
from app.tasks.lipsync_tts import tts_synthesize_and_submit
source = inspect.getsource(tts_synthesize_and_submit.run)
assert (
"self.retry" in source or "retry" in source
), "tts_synthesize_and_submit 在 job not found 时必须重试,防止静默失败"
+1 -243
View File
@@ -185,10 +185,7 @@ class TestTtsSynthesizeAndSubmit:
mk_client.submit_lipsync.assert_called_once()
call_kwargs = mk_client.submit_lipsync.call_args.kwargs
assert call_kwargs["client_token"] == "job-1"
# CosyVoice 临时 URL 经 _sign_media_url 透传(mock 统一追加 ?signed),
# 自家 OSS 才会被重签,外部 URL 原样透传;job.audio_url 存原始临时 URL
assert call_kwargs["audio_url"] == "https://tts/raw.mp3?signed"
assert job.audio_url == "https://tts/raw.mp3"
assert call_kwargs["audio_url"].endswith("?signed")
session.commit.assert_called()
session.close.assert_called_once()
@@ -390,242 +387,3 @@ class TestSignMediaUrl:
assert result == "https://anything.example.com/a.mp3"
fake_storage.get_download_url.assert_not_called()
class TestPersistOutputVideoTask:
"""persist_output_video_task:下载 MediaKit 临时视频 → 上传自有 OSS → 更新 DB."""
def _make_persist_job(self, **kwargs):
job = MagicMock()
job.id = kwargs.get("job_id", "job-1")
job.user_id = kwargs.get("user_id", "user-1")
job.output_video_url = kwargs.get("output_video_url", "https://temp.mk/output.mp4")
job.updated_at = None
return job
def _persist_patches(self, *, job, video_bytes=b"FAKEMP4", download_side_effect=None, upload_url=None):
"""统一 patchSessionLocal、httpx.Client、storage、_sign_media_url."""
fake_app_db = ModuleType("app.db")
fake_worker_db = ModuleType("worker_app.db")
session, factory = _build_session(job)
fake_app_db.SessionLocal = factory
fake_worker_db.SessionLocal = factory
# httpx.Client 上下文管理器
fake_response = MagicMock()
fake_response.content = video_bytes
fake_response.raise_for_status = MagicMock()
fake_client = MagicMock()
fake_client.get.return_value = fake_response
fake_client_cm = MagicMock()
fake_client_cm.__enter__ = MagicMock(return_value=fake_client)
fake_client_cm.__exit__ = MagicMock(return_value=False)
FakeHttpxClient = MagicMock(return_value=fake_client_cm)
if download_side_effect is not None:
fake_client.get.side_effect = download_side_effect
# storage
storage = MagicMock()
storage.public_url = "https://oss.example.com/"
storage.upload_file.return_value = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
fake_httpx = ModuleType("httpx")
fake_httpx.Client = FakeHttpxClient
patches = [
patch.dict(
sys.modules,
{"app.db": fake_app_db, "worker_app.db": fake_worker_db, "httpx": fake_httpx},
),
patch("packages.shared.storage.get_shared_storage_service", return_value=storage),
patch("app.tasks.lipsync_tts._sign_media_url", side_effect=lambda url: url + "?signed" if url else url),
]
return session, fake_client, storage, patches
def test_success_download_upload_updates_db(self):
"""正常路径:下载 temp_url → 上传 OSS → 签名 → 写回 DB commit."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
session, fake_client, storage, patches = self._persist_patches(
job=job, video_bytes=b"VIDEODATA", upload_url="https://oss.example.com/lipsync-outputs/u1/j1.mp4"
)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_called_once_with("https://temp.mk/x.mp4")
storage.upload_file.assert_called_once()
# 上传的 key 必须是 lipsync-outputs/{user_id}/{job_id}.mp4
key_arg = (
storage.upload_file.call_args.args[1]
if storage.upload_file.call_args.args
else storage.upload_file.call_args.kwargs.get("key")
)
# upload_file(data, key, content_type=...)
call_args = storage.upload_file.call_args.args
assert call_args[1] == "lipsync-outputs/user-1/job-1.mp4"
# output_video_url 被替换为签名后的永久 URL
assert job.output_video_url == "https://oss.example.com/lipsync-outputs/u1/j1.mp4?signed"
assert job.updated_at is not None
session.commit.assert_called_once()
session.close.assert_called_once()
def test_download_failure_keeps_temp_url_no_commit(self):
"""下载失败(raise)→ 记录 warning、保留 temp_url、不抛异常."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
session, fake_client, storage, patches = self._persist_patches(
job=job, download_side_effect=RuntimeError("network down")
)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
storage.upload_file.assert_not_called()
# output_video_url 保持原值(temp_url
assert job.output_video_url == "https://temp.mk/x.mp4"
# 内层 except 不会 commit
# 注:若内部发生 commit 说明测试失败
session.close.assert_called_once()
def test_empty_temp_url_skips_persist(self):
"""temp_url 为空 → 直接返回,不下载不上传."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="")
session, fake_client, storage, patches = self._persist_patches(job=job)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_not_called()
storage.upload_file.assert_not_called()
session.commit.assert_not_called()
session.close.assert_called_once()
def test_job_not_found_returns_early(self):
"""DB 中找不到 job → 直接返回,不抛错."""
from app.tasks.lipsync_tts import persist_output_video_task
session, fake_client, storage, patches = self._persist_patches(job=None)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("missing", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_not_called()
storage.upload_file.assert_not_called()
session.commit.assert_not_called()
session.close.assert_called_once()
class TestLipsyncServiceRefreshCompletedAsyncPersist:
"""refresh_job_status 在 completed 分支异步转存的单元测试(补 0% 覆盖的 316~335 行)."""
def test_refresh_completed_dispatches_persist_task(self):
"""completed 分支:设置 temp_url → commit → dispatch persist_output_video_task.apply_async."""
from app.services.lipsync_service import LipsyncService
mock_job = MagicMock()
mock_job.id = "job-1"
mock_job.user_id = "user-1"
mock_job.mediakit_task_id = "mk-1"
mock_job.status = "submitted"
mock_job.output_video_url = ""
mock_job.output_duration = 0.0
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
mock_client = MagicMock()
mock_client.get_task_status.return_value = {
"status": "completed",
"result": {"video_url": "https://temp.mk/out.mp4", "duration": 25.5},
}
fake_persist_task = MagicMock()
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
with patch.dict("sys.modules", {}):
# 直接 patch 懒 import 路径
with patch("app.tasks.lipsync_tts.persist_output_video_task", fake_persist_task, create=False):
# 但懒 import 发生在函数内部 from app.tasks.lipsync_tts import persist_output_video_task
# 通过 patch sys.modules 的方式提供
import sys as _sys
fake_mod = MagicMock()
fake_mod.persist_output_video_task = fake_persist_task
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
try:
result = svc.refresh_job_status("job-1", "user-1")
finally:
_sys.modules.pop("app.tasks.lipsync_tts", None)
assert result.status == "completed"
assert result.output_video_url == "https://temp.mk/out.mp4"
assert result.output_duration == 25.5
mock_db.commit.assert_called()
# 必须在 commit 之后 dispatch
fake_persist_task.apply_async.assert_called_once()
kwargs = fake_persist_task.apply_async.call_args.kwargs
assert kwargs["args"] == ("job-1", "user-1", "https://temp.mk/out.mp4")
def test_refresh_completed_dispatch_exception_does_not_break_return(self):
"""apply_async 抛异常(如 Celery 不可用)→ 捕获 warning,仍返回 completed job."""
from app.services.lipsync_service import LipsyncService
mock_job = MagicMock()
mock_job.id = "job-2"
mock_job.user_id = "user-1"
mock_job.mediakit_task_id = "mk-2"
mock_job.status = "submitted"
mock_job.output_video_url = ""
mock_job.output_duration = 0.0
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
mock_client = MagicMock()
mock_client.get_task_status.return_value = {
"status": "completed",
"result": {"video_url": "https://temp.mk/out2.mp4", "duration": 10.0},
}
fake_persist_task = MagicMock()
fake_persist_task.apply_async.side_effect = ConnectionError("celery down")
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
import sys as _sys
fake_mod = MagicMock()
fake_mod.persist_output_video_task = fake_persist_task
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
try:
result = svc.refresh_job_status("job-2", "user-1")
finally:
_sys.modules.pop("app.tasks.lipsync_tts", None)
# 即便 dispatch 失败,主流程不受影响:仍然返回 completed + temp_url
assert result.status == "completed"
assert result.output_video_url == "https://temp.mk/out2.mp4"
fake_persist_task.apply_async.assert_called_once()
-171
View File
@@ -1,171 +0,0 @@
"""Tests for sentence timing functions in lipsync_tts."""
import os
import subprocess
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from apps.api.app.tasks.lipsync_tts import (
_compute_sentence_timings,
_estimate_sentence_timings_by_chars,
_split_script_into_sentences,
)
class TestSplitScriptIntoSentences(unittest.TestCase):
"""Tests for _split_script_into_sentences."""
def test_empty_string(self):
self.assertEqual(_split_script_into_sentences(""), [])
def test_none(self):
self.assertEqual(_split_script_into_sentences(None), [])
def test_whitespace_only(self):
self.assertEqual(_split_script_into_sentences(" \n "), [])
def test_single_sentence(self):
self.assertEqual(_split_script_into_sentences("你好世界。"), ["你好世界"])
def test_multiple_sentences_chinese(self):
result = _split_script_into_sentences("第一句。第二句!第三句?")
self.assertEqual(result, ["第一句", "第二句", "第三句"])
def test_english_punctuation(self):
result = _split_script_into_sentences("Hello World! How are you?")
self.assertEqual(result, ["Hello World", "How are you"])
def test_semicolons(self):
result = _split_script_into_sentences("第一部分;第二部分;第三部分")
self.assertEqual(result, ["第一部分", "第二部分", "第三部分"])
def test_newlines(self):
result = _split_script_into_sentences("第一行\n第二行\n第三行")
self.assertEqual(result, ["第一行", "第二行", "第三行"])
def test_no_trailing_punctuation(self):
result = _split_script_into_sentences("没有标点的句子")
self.assertEqual(result, ["没有标点的句子"])
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
"""Tests for _estimate_sentence_timings_by_chars."""
def test_empty_sentences(self):
self.assertEqual(_estimate_sentence_timings_by_chars([], 10.0), [])
def test_zero_duration(self):
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], 0), [])
def test_negative_duration(self):
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], -5.0), [])
def test_single_sentence(self):
result = _estimate_sentence_timings_by_chars(["hello"], 10.0)
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 10.0)
def test_two_equal_sentences(self):
result = _estimate_sentence_timings_by_chars(["你好", "世界"], 10.0)
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 5.0)
self.assertAlmostEqual(result[1]["start_time"], 5.0)
self.assertAlmostEqual(result[1]["end_time"], 10.0)
def test_unequal_char_distribution(self):
result = _estimate_sentence_timings_by_chars(["ABCD", "EF"], 9.0)
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 6.0) # 4/6 * 9 = 6
self.assertAlmostEqual(result[1]["start_time"], 6.0)
self.assertAlmostEqual(result[1]["end_time"], 9.0)
def test_timing_structure(self):
result = _estimate_sentence_timings_by_chars(["句子一", "句子二"], 6.0)
for item in result:
self.assertIn("index", item)
self.assertIn("text", item)
self.assertIn("start_time", item)
self.assertIn("end_time", item)
class TestComputeSentenceTimings(unittest.TestCase):
"""Tests for _compute_sentence_timings."""
def test_empty_script_returns_empty(self):
self.assertEqual(_compute_sentence_timings(b"fake_audio", "", 10.0), [])
def test_none_script_returns_empty(self):
self.assertEqual(_compute_sentence_timings(b"fake_audio", None, 10.0), [])
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_silence_detection_insufficient_fallback(self, mock_run, mock_tmpfile, mock_unlink):
"""When silence detection finds too few points, fallback to char estimation."""
mock_run.return_value = MagicMock(stderr="", returncode=0)
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
# Should fallback to char estimation with 3 sentences
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_silence_detection_with_enough_points(self, mock_run, mock_tmpfile, mock_unlink):
"""When silence detection finds enough points, use them for boundaries."""
mock_run.return_value = MagicMock(
stderr="[silencedetect] silence_end: 3.5 | silence_duration: 0.4\n"
"[silencedetect] silence_end: 7.0 | silence_duration: 0.3\n",
returncode=0,
)
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 3.5)
self.assertAlmostEqual(result[1]["start_time"], 3.5)
self.assertAlmostEqual(result[1]["end_time"], 7.0)
self.assertAlmostEqual(result[2]["start_time"], 7.0)
self.assertAlmostEqual(result[2]["end_time"], 10.0)
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_ffmpeg_exception_fallback(self, mock_run, mock_tmpfile, mock_unlink):
"""When ffmpeg raises an exception, fallback to char estimation."""
mock_run.side_effect = Exception("ffmpeg not found")
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "句子一。句子二。", 6.0)
# Should fallback to char estimation
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 3.0)
self.assertAlmostEqual(result[1]["start_time"], 3.0)
self.assertAlmostEqual(result[1]["end_time"], 6.0)
if __name__ == "__main__":
unittest.main()
+7 -52
View File
@@ -902,10 +902,9 @@ class TestResolveFontPath(unittest.TestCase):
@patch("os.path.isfile")
def test_unknown_font_fallback(self, mock_isfile):
# DejaVuSans 已从 fallback 列表移除(不支持 CJK),用 VF 路径模拟
mock_isfile.side_effect = lambda p: "NotoSansSC-VF" in p
mock_isfile.side_effect = lambda p: "DejaVu" in p
result = _resolve_font_path("UnknownFont")
self.assertIn("NotoSansSC-VF", result)
self.assertIn("DejaVu", result)
@patch("os.path.isfile")
def test_no_fonts_available(self, mock_isfile):
@@ -928,11 +927,9 @@ class TestResolveFontPath(unittest.TestCase):
@patch("os.path.isfile")
def test_font_fallback_skips_nonexistent(self, mock_isfile):
# 所有中文字体路径都不存在时,fallback 返回第一个存在的文件;
# DejaVuSans 已从列表移除(不支持 CJK),使用 VF 字体路径模拟存在文件
mock_isfile.side_effect = lambda p: "NotoSansSC-VF" in p
mock_isfile.side_effect = lambda p: "DejaVu" in p
result = _resolve_font_path("不存在字体")
self.assertIn("NotoSansSC-VF", result)
self.assertIn("DejaVu", result)
class TestDrawtextFontFileIncluded(unittest.TestCase):
@@ -1031,37 +1028,6 @@ class TestDrawtextBoldFalse(unittest.TestCase):
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
def test_bold_true_does_not_use_font_bold_param(self):
"""粗体模式不得使用 `font=bold`——该参数无效,会导致 filter_complex 解析失败(exit 234)。"""
result = build_title_drawtext_filter({"text": "标题", "bold": True})
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
# 粗体应通过 borderw 实现
self.assertIn("borderw=", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_bold_default_uses_black_stroke_when_no_bold_font(self, mock_font):
"""默认 bold=true 且无 Bold 字体文件时,使用黑色细描边(borderw=2 + 黑),
不得使用与文字同色的 borderw>=3(否则会造成竖屏小字号重影)。"""
mock_font.return_value = "" # 无粗体字体
result = build_title_drawtext_filter({"text": "标题"})
self.assertIsNotNone(result)
self.assertIn("borderw=2", result)
# 黑描边:要么是 black 关键字,要么是 000000
self.assertTrue("bordercolor=black" in result or "bordercolor=000000" in result)
self.assertNotIn("borderw=3", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_bold_with_user_stroke_preserves_user_color(self, mock_font):
"""用户显式开启 stroke 时,stroke 颜色/宽度优先于默认粗体黑边。"""
mock_font.return_value = ""
result = build_title_drawtext_filter(
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}}
)
self.assertIsNotNone(result)
self.assertIn("borderw=4", result)
self.assertIn("bordercolor=ffffff", result) # 去掉 # 前缀
class TestDrawtextPositionBranches(unittest.TestCase):
"""位置相关分支覆盖。"""
@@ -1088,23 +1054,12 @@ class TestDrawtextPositionBranches(unittest.TestCase):
self.assertIn("y=h-text_h-50", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_with_percentage_coords(self, mock_font):
"""自定义位置:百分比坐标转换为 drawtext 表达式."""
mock_font.return_value = ""
# pos_x=50, pos_y=30 → x=(w-text_w)*0.5000, y=(h-text_h)*0.3000
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 50, "pos_y": 30})
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)*0.5000", result)
self.assertIn("y=(h-text_h)*0.3000", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_clamped_to_100(self, mock_font):
"""自定义位置:超过100的坐标被截断到100%."""
def test_position_custom_with_float_coords(self, mock_font):
mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3})
self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)*1.0000", result)
self.assertIn("y=(h-text_h)*1.0000", result)
self.assertIn("x=100", result)
self.assertIn("y=200", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_bool_coords_fallback(self, mock_font):