Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dbe6e4283 |
@@ -0,0 +1,29 @@
|
||||
name: "Verify staging post-deploy"
|
||||
on:
|
||||
push:
|
||||
branches: [debug/verify-staging]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
verify:
|
||||
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
|
||||
H=47.98.113.167; P=22222
|
||||
ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null
|
||||
- name: Run verify
|
||||
shell: bash
|
||||
run: |
|
||||
set -x
|
||||
echo 'IyEvYmluL2Jhc2gKc2V0IC14CmVjaG8gIj09PSAxLiBDdXJyZW50IHJ1bm5pbmcgY29udGFpbmVycyA9PT0iCmRvY2tlciBwcyAtLWZpbHRlciBuYW1lPXhpYW94aWEtKi1zdGFnaW5nIC0tZm9ybWF0ICJ0YWJsZSB7ey5OYW1lc319XHR7ey5TdGF0dXN9fVx0e3suSW1hZ2V9fSIKZWNobyAiIgplY2hvICI9PT0gMi4gV2ViIGxvZ3MgKGxhc3QgMTUgbGluZXMpID09PSIKZG9ja2VyIGxvZ3MgLS10YWlsIDE1IHhpYW94aWEtd2ViLXN0YWdpbmcgMj4mMQplY2hvICIiCmVjaG8gIj09PSAzLiBXZWIgbG9jYWxob3N0IGhlYWx0aCBjaGVjayA9PT0iCmN1cmwgLXNJIC0tbWF4LXRpbWUgNSBodHRwOi8vMTI3LjAuMC4xOjMwMDEvIHwgaGVhZCAtNQplY2hvICIiCmVjaG8gIj09PSA0LiBBUEkgbG9jYWxob3N0IGhlYWx0aCBjaGVjayA9PT0iCmN1cmwgLXNmIC0tbWF4LXRpbWUgNSBodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoCmVjaG8gIiIKZWNobyAiPT09IDUuIEFQSSB2ZXJzaW9uID09PSIKY3VybCAtcyAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aCB8IHB5dGhvbjMgLWMgImltcG9ydCBzeXMsanNvbjsgZD1qc29uLmxvYWQoc3lzLnN0ZGluKTsgcHJpbnQoJ3ZlcnNpb246JywgZC5nZXQoJ3ZlcnNpb24nLCc/JykpIiAyPi9kZXYvbnVsbAplY2hvICIiCmVjaG8gIj09PSA2LiBXb3JrZXIgY29udGFpbmVyIHZlcnNpb24gPT09Igpkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdvcmtlci1zdGFnaW5nIC0tZm9ybWF0ICd7e3JhbmdlIC5Db25maWcuRW52fX17ey59fXt7IlxuIn19e3tlbmR9fScgfCBncmVwIEFQUF9WRVJTSU9OCmVjaG8gIiIKZWNobyAiPT09IDcuIFdvcmtlciBsb2dzIChzdGFydHVwICsgdGFzayByZWdpc3RyYXRpb24pID09PSIKZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtd29ya2VyLXN0YWdpbmcgMj4mMSB8IGdyZXAgLWlFICJyZWdpc3RlcmVkfGNlbGVyeUB8bGlwc3luY3xyZWFkeXxNb2R1bGVOb3RGb3VuZHxFcnJvcnx0YXNrIiB8IGhlYWQgLTMwCmVjaG8gIiIKZWNobyAiPT09IDguIExpc3QgYWxsIHJlZ2lzdGVyZWQgY2VsZXJ5IHRhc2tzIChXb3JrZXLlhoVpbnNwZWN0KSA9PT0iCmRvY2tlciBleGVjIHhpYW94aWEtd29ya2VyLXN0YWdpbmcgc2ggLWMgImNlbGVyeSAtQSB3b3JrZXJfYXBwLmNlbGVyeV9hcHAgaW5zcGVjdCByZWdpc3RlcmVkIDI+JjEgfCBoZWFkIC00MCIgfHwgZWNobyAiY2VsZXJ5IGluc3BlY3QgZmFpbGVkIgplY2hvICIiCmVjaG8gIj09PSA5LiBDaGVjayBpbXBvcnRzIGluc2lkZSBXb3JrZXIgKGxpcHN5bmNfdHRzIG1vZHVsZSkgPT09Igpkb2NrZXIgZXhlYyB4aWFveGlhLXdvcmtlci1zdGFnaW5nIHB5dGhvbiAtYyAiCmltcG9ydCBpbXBvcnRsaWIKbSA9IGltcG9ydGxpYi5pbXBvcnRfbW9kdWxlKCdhcHAudGFza3MubGlwc3luY190dHMnKQpwcmludCgnbGlwc3luY190dHMgbW9kdWxlIGltcG9ydGVkIE9LJykKcHJpbnQoJ3N5bnRoZXNpemVfYW5kX3N1Ym1pdDonLCBoYXNhdHRyKG0sICdzeW50aGVzaXplX2FuZF9zdWJtaXQnKSkKcHJpbnQoJ1Nlc3Npb25Mb2NhbDonLCBoYXNhdHRyKG0sICdTZXNzaW9uTG9jYWwnKSkKIiAyPiYxIHwgaGVhZCAtMTAKZWNobyAiIgplY2hvICI9PT0gMTAuIFB1YmxpYyBBUEkgaGVhbHRoIGNoZWNrIChzdGFnaW5nLWFwaSkgPT09IgpjdXJsIC1zSSAtLW1heC10aW1lIDEwIGh0dHBzOi8vc3RhZ2luZy1hcGkueGlhb3hpYWppYW5qaS5jb20vaGVhbHRoIHwgaGVhZCAtMwplY2hvICIiCmVjaG8gIj09PSAxMS4gUHVibGljIHdlYiBoZWFsdGggY2hlY2sgPT09IgpjdXJsIC1zSSAtLW1heC10aW1lIDEwIGh0dHBzOi8vc3RhZ2luZy54aWFveGlhamlhbmppLmNvbS8gfCBoZWFkIC0zCg==' | base64 -d > /tmp/verify.sh
|
||||
chmod +x /tmp/verify.sh
|
||||
ssh -p 22222 -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@47.98.113.167 'bash -s' < /tmp/verify.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")
|
||||
@@ -11,7 +11,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
@@ -78,16 +77,10 @@ def create_render_job(
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception as exc:
|
||||
logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
|
||||
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
@@ -179,16 +172,10 @@ def retry_render_job(
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception as exc:
|
||||
logger.exception("Celery 任务投递失败(重试): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
|
||||
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
return job
|
||||
|
||||
|
||||
|
||||
@@ -212,11 +199,7 @@ def generate_avatar_smart_cover(
|
||||
raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL")
|
||||
|
||||
try:
|
||||
cover_url = generate_smart_cover(
|
||||
video_url,
|
||||
max_frames=body.max_frames,
|
||||
title_config=getattr(body, "title_config", None),
|
||||
)
|
||||
cover_url = generate_smart_cover(video_url, max_frames=body.max_frames)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"智能封面生成异常: user=%s video_url=%s err=%s",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -229,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()
|
||||
@@ -280,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,
|
||||
@@ -288,25 +265,9 @@ class AiAvatarRenderService:
|
||||
output_path=output_video_path,
|
||||
)
|
||||
|
||||
try:
|
||||
render_result = subprocess.run(
|
||||
cmd_list,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AiAvatarRenderError(
|
||||
"FFmpeg 渲染超时(600s)",
|
||||
code="FFmpegTimeout",
|
||||
) from exc
|
||||
|
||||
if render_result.returncode != 0:
|
||||
stderr_tail = (render_result.stderr or "").strip()[-800:]
|
||||
raise AiAvatarRenderError(
|
||||
f"FFmpeg 渲染失败,退出码: {render_result.returncode}, stderr: {stderr_tail}",
|
||||
code="FFmpegFailed",
|
||||
)
|
||||
exit_code = os.system(cmd)
|
||||
if exit_code != 0:
|
||||
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
|
||||
|
||||
job.progress = 80
|
||||
self.db.commit()
|
||||
@@ -315,27 +276,11 @@ class AiAvatarRenderService:
|
||||
cover_path = ""
|
||||
if job.cover_config:
|
||||
cover_path = os.path.join(tmpdir, "cover.jpg")
|
||||
cover_cmd = self._build_cover_extract_cmd(
|
||||
cover_config=job.cover_config,
|
||||
input_video=output_video_path,
|
||||
output_path=cover_path,
|
||||
)
|
||||
try:
|
||||
cover_result = subprocess.run(
|
||||
cover_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if cover_result.returncode != 0:
|
||||
logger.warning(
|
||||
"封面提取失败(非致命),跳过: exit=%s stderr=%s",
|
||||
cover_result.returncode,
|
||||
(cover_result.stderr or "")[-300:],
|
||||
)
|
||||
cover_path = ""
|
||||
except Exception as cover_err:
|
||||
logger.warning("封面提取异常(非致命),跳过: %s", cover_err)
|
||||
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
|
||||
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
|
||||
cover_exit = os.system(cover_cmd)
|
||||
if cover_exit != 0:
|
||||
logger.warning("封面提取失败,跳过: %s", cover_cmd)
|
||||
cover_path = ""
|
||||
|
||||
job.progress = 90
|
||||
@@ -345,7 +290,7 @@ class AiAvatarRenderService:
|
||||
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
|
||||
job.output_video_url = output_video_url
|
||||
|
||||
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加);
|
||||
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧;
|
||||
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
|
||||
smart_cover_url = ""
|
||||
if output_video_url:
|
||||
@@ -354,13 +299,7 @@ class AiAvatarRenderService:
|
||||
generate_smart_cover,
|
||||
)
|
||||
|
||||
smart_cover_url = generate_smart_cover(
|
||||
output_video_url,
|
||||
job_id=job_id,
|
||||
max_frames=5,
|
||||
# 注意:不传 title_config —— 最终输出视频已经通过 drawtext 叠加了标题,
|
||||
# 再传会导致封面标题双重叠加
|
||||
)
|
||||
smart_cover_url = generate_smart_cover(output_video_url, job_id=job_id, max_frames=5)
|
||||
except Exception:
|
||||
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
|
||||
|
||||
@@ -392,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,
|
||||
@@ -412,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:
|
||||
@@ -425,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:
|
||||
"""下载视频到临时文件."""
|
||||
@@ -450,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,
|
||||
*,
|
||||
@@ -489,86 +391,24 @@ class AiAvatarRenderService:
|
||||
filter_complex: str,
|
||||
final_label: Optional[str],
|
||||
output_path: str,
|
||||
) -> list[str]:
|
||||
"""构建 FFmpeg 命令(list 形式,shell=False).
|
||||
|
||||
根因修复 #1798 P0:OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符,
|
||||
os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被
|
||||
当成独立命令报 sh: -filter_complex: not found(exit 127 → Python 32512)。
|
||||
list + shell=False 彻底规避 shell 转义问题。
|
||||
"""
|
||||
cmd: list[str] = ["ffmpeg", "-i", input_video]
|
||||
) -> 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])
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-y",
|
||||
output_path,
|
||||
]
|
||||
)
|
||||
return cmd
|
||||
|
||||
def _build_cover_extract_cmd(
|
||||
self,
|
||||
*,
|
||||
cover_config: dict[str, Any],
|
||||
input_video: str,
|
||||
output_path: str,
|
||||
) -> list[str]:
|
||||
"""构建封面截帧 FFmpeg 命令(list 形式,shell=False)."""
|
||||
if not cover_config or not isinstance(cover_config, dict):
|
||||
timestamp = 0.0
|
||||
width = 0
|
||||
height = 0
|
||||
filter_arg = f'-filter_complex "{filter_complex}"'
|
||||
else:
|
||||
timestamp = cover_config.get("timestamp", 0.0)
|
||||
width = cover_config.get("width", 0)
|
||||
height = cover_config.get("height", 0)
|
||||
filter_arg = ""
|
||||
|
||||
cmd: list[str] = [
|
||||
"ffmpeg",
|
||||
"-ss",
|
||||
str(timestamp),
|
||||
"-i",
|
||||
input_video,
|
||||
"-frames:v",
|
||||
"1",
|
||||
]
|
||||
if width > 0 and height > 0:
|
||||
vf = (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
||||
)
|
||||
cmd.extend(["-vf", vf])
|
||||
cmd.extend(["-y", output_path])
|
||||
return cmd
|
||||
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset veryfast -crf 23 -y {output_path}"
|
||||
|
||||
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
|
||||
"""上传文件到 OSS,返回 URL.
|
||||
|
||||
@@ -54,160 +54,6 @@ 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",
|
||||
@@ -306,14 +152,13 @@ def tts_synthesize_and_submit(
|
||||
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,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
@@ -333,64 +178,6 @@ def tts_synthesize_and_submit(
|
||||
|
||||
db.commit()
|
||||
|
||||
# 2.5 计算精确句子时间戳(基于 TTS 音频静音检测)
|
||||
import os as _os
|
||||
|
||||
_st_tmp_path = None
|
||||
try:
|
||||
import subprocess as _sp
|
||||
import tempfile as _tmpf
|
||||
|
||||
# 下载音频用于探测时长和静音检测
|
||||
if isinstance(job.audio_url, str) and job.audio_url:
|
||||
from packages.shared.url_security import safe_download_bytes as _sdl
|
||||
|
||||
_audio_bytes = _sdl(job.audio_url, purpose="sentence_timings", timeout=30.0)
|
||||
else:
|
||||
_audio_bytes = audio_data
|
||||
|
||||
# ffprobe 获取音频时长
|
||||
with _tmpf.NamedTemporaryFile(suffix=".mp3", delete=False) as _atmp:
|
||||
_atmp.write(_audio_bytes)
|
||||
_st_tmp_path = _atmp.name
|
||||
|
||||
_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
|
||||
|
||||
if _audio_duration > 0:
|
||||
_timings = _compute_sentence_timings(_audio_bytes, 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,
|
||||
)
|
||||
db.commit()
|
||||
except Exception as _st_err:
|
||||
logger.warning("[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err)
|
||||
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)
|
||||
|
||||
@@ -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 默认 1,page_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)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
getRenderJob,
|
||||
generateSmartCover,
|
||||
} from "./api/aiAvatar"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import {
|
||||
normalizeEmotion,
|
||||
buildTitleConfigPayload,
|
||||
@@ -207,12 +206,9 @@ const AiAvatarPage: React.FC = () => {
|
||||
}
|
||||
state.setIsGenerating(true)
|
||||
try {
|
||||
// 确保有 project_id(AI数字人入口独立,不在项目内,自动取默认项目;#1860 P0 bugfix)
|
||||
const defaultProject = await getOrCreateDefaultProject()
|
||||
const job = await submitRender({
|
||||
lipsync_job_id: state.lipsyncJob.id,
|
||||
script_id: state.script?.id,
|
||||
project_id: defaultProject.id,
|
||||
b_roll_segments: state.bRollSegments.map((seg) => ({
|
||||
script_segment_index: seg.script_segment_index,
|
||||
asset_url: seg.asset.file_url || "",
|
||||
@@ -241,15 +237,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
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)
|
||||
@@ -292,7 +279,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
}
|
||||
setSmartCoverLoading(true)
|
||||
try {
|
||||
const res = await generateSmartCover(videoUrl, buildTitleConfigPayload(state.titleConfig), 5)
|
||||
const res = await generateSmartCover(videoUrl, 5)
|
||||
if (res.cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
@@ -355,6 +342,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
selectedVideo={state.selectedVideo}
|
||||
onSelectVideo={() => state.setShowAssetPicker(true)}
|
||||
onRemoveVideo={state.removeVideo}
|
||||
titleConfig={state.titleConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,7 +444,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
titleConfig={state.titleConfig}
|
||||
onSmartCover={handleSmartCover}
|
||||
smartCoverLoading={smartCoverLoading}
|
||||
canSmartCover={state.lipsyncJob?.status === "completed"}
|
||||
@@ -497,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}
|
||||
/>
|
||||
|
||||
@@ -58,17 +58,15 @@ export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧 + 可选 drawtext 标题叠加) ── */
|
||||
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧,独立于渲染任务) ── */
|
||||
export const generateSmartCover = async (
|
||||
video_url: string,
|
||||
title_config?: Record<string, unknown> | null,
|
||||
max_frames = 5,
|
||||
): Promise<{ cover_url: string; status: string; message: string }> => {
|
||||
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
|
||||
"/ai-avatar/render/smart-cover",
|
||||
{ video_url, max_frames, title_config: title_config ?? null },
|
||||
// smart-cover 链路:下载视频+抽帧+drawtext 加标题+上传 OSS,需要较长时间,120s 超时
|
||||
{ timeout: 120000 },
|
||||
{ video_url, max_frames },
|
||||
{ timeout: 60000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -87,7 +85,7 @@ export const submitRender = async (data: {
|
||||
}
|
||||
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
[scriptText, sentenceTimings],
|
||||
() => splitScriptIntoSentences(scriptText, outputDuration),
|
||||
[scriptText, outputDuration],
|
||||
)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
@@ -267,7 +264,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
>
|
||||
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
|
||||
<span className="aa-sentence-item__text">{sent.text}</span>
|
||||
{sent.endTime > 0 && (
|
||||
{outputDuration > 0 && (
|
||||
<span className="aa-sentence-item__time">
|
||||
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
|
||||
</span>
|
||||
@@ -352,7 +349,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
selectedSentence.endTime,
|
||||
selectedSentence.startTime + 0.5,
|
||||
).toFixed(1)}
|
||||
s
|
||||
s (按字数自动估算)
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* AI数字人 — 面板5:封面 & 生成
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)+ 标题文字实时叠加预览
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
|
||||
* - 分辨率选择(720p / 1080p / 4K)
|
||||
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
|
||||
* - 渐变紫色生成按钮
|
||||
*
|
||||
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
|
||||
*/
|
||||
import React, { useMemo, useRef } from "react"
|
||||
import type { AiAvatarCoverConfig, AiAvatarTitleConfig } from "../types"
|
||||
import React, { useRef } from "react"
|
||||
import type { AiAvatarCoverConfig } from "../types"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution: string
|
||||
onResolutionChange: (r: string) => void
|
||||
@@ -48,19 +47,8 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
failed: { text: "失败", cls: "aa-status-badge--failed" },
|
||||
}
|
||||
|
||||
/** 字体名 → CSS font-family 映射(与后端 drawtext 对齐) */
|
||||
const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
思源黑体: "'Noto Sans SC', 'Source Han Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||
思源宋体: "'Noto Serif SC', 'Source Han Serif SC', 'SimSun', serif",
|
||||
楷体: "KaiTi, 'STKaiti', serif",
|
||||
黑体: "'Heiti SC', 'SimHei', 'Microsoft YaHei', sans-serif",
|
||||
}
|
||||
|
||||
const getFontFamily = (font: string): string => FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
coverConfig,
|
||||
titleConfig,
|
||||
onCoverConfigChange,
|
||||
resolution,
|
||||
onResolutionChange,
|
||||
@@ -98,85 +86,15 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
|
||||
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
|
||||
|
||||
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
|
||||
const coverUrl =
|
||||
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
|
||||
const hasCoverImage = Boolean(coverUrl)
|
||||
|
||||
/** 是否显示标题叠加层:有图、有文字、非加载中 */
|
||||
const showTitleOverlay =
|
||||
hasCoverImage && !smartCoverLoading && titleConfig.title.trim().length > 0
|
||||
|
||||
/** 计算标题叠加层的 inline 样式 */
|
||||
const titleOverlayStyle = useMemo<React.CSSProperties>(() => {
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
width: "90%",
|
||||
transform: "translateX(-50%)",
|
||||
textAlign: "center",
|
||||
boxSizing: "border-box",
|
||||
padding: "0 4px",
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
color: titleConfig.color || "#ffffff",
|
||||
fontSize: `${(titleConfig.size || 28) * 0.55}px`, // 预览容器缩放,与 PanelLipsyncPreview 对齐
|
||||
fontFamily: getFontFamily(titleConfig.font),
|
||||
fontWeight: titleConfig.bold ? "bold" : "normal",
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
lineHeight: 1.3,
|
||||
pointerEvents: "none",
|
||||
}
|
||||
|
||||
// 位置
|
||||
const pos = titleConfig.position || "bottom"
|
||||
if (pos === "top") {
|
||||
style.top = "40px"
|
||||
} else if (pos === "center") {
|
||||
style.top = "50%"
|
||||
style.transform = "translate(-50%, -50%)"
|
||||
} else if (pos === "custom" && titleConfig.pos_x != null && titleConfig.pos_y != null) {
|
||||
// pos_x/pos_y 是相对预览容器的百分比坐标
|
||||
style.left = `${titleConfig.pos_x}%`
|
||||
style.top = `${titleConfig.pos_y}%`
|
||||
style.transform = "translate(-50%, -50%)"
|
||||
} else {
|
||||
style.bottom = "40px"
|
||||
}
|
||||
|
||||
// 描边优先于阴影(二者互斥,与 drawtext 对齐)
|
||||
if (titleConfig.stroke) {
|
||||
// 描边宽度按字号估算,保证视觉一致
|
||||
const strokeWidth = Math.max(1, Math.round(titleConfig.size / 18))
|
||||
;(style as React.CSSProperties)["WebkitTextStroke"] = `${strokeWidth}px rgba(0,0,0,0.75)`
|
||||
style.textShadow = "none"
|
||||
} else if (titleConfig.shadow) {
|
||||
style.textShadow = "0 2px 8px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.5)"
|
||||
} else {
|
||||
// 无描边无阴影时,不加额外效果(与后端 drawtext 对齐:无 stroke/shadow 则不加)
|
||||
style.textShadow = "none"
|
||||
}
|
||||
|
||||
return style
|
||||
}, [titleConfig])
|
||||
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 封面预览(竖屏 9:16) */}
|
||||
<div className="aa-cover-preview">
|
||||
{hasCoverImage ? (
|
||||
<img src={coverUrl!} alt="封面预览" draggable={false} />
|
||||
{coverConfig.thumbnail_url ? (
|
||||
<img src={coverConfig.thumbnail_url} alt="封面预览" />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">暂无封面</span>
|
||||
)}
|
||||
{/* 智能封面加载遮罩 */}
|
||||
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
|
||||
{/* 标题文字叠加层(实时预览,仅前端视觉参考,最终由后端 ffmpeg drawtext 叠加) */}
|
||||
{showTitleOverlay && (
|
||||
<div style={titleOverlayStyle} aria-hidden="true">
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
|
||||
@@ -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 || 28) * 0.55}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>
|
||||
|
||||
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string
|
||||
text,
|
||||
enabled: true,
|
||||
font: cfg.font || "思源黑体",
|
||||
font_size: Math.round(cfg.size) || 28,
|
||||
font_size: Math.round(cfg.size) || 36,
|
||||
font_color: cfg.color || "#ffffff",
|
||||
position,
|
||||
bold: !!cfg.bold,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* AI数字人 — 文案分句工具
|
||||
*
|
||||
* 分句规则与后端 _split_script_into_sentences 保持一致。
|
||||
* 时间戳由后端基于 TTS 音频静音检测精确计算,前端不再做字数比例估算。
|
||||
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
|
||||
*/
|
||||
import type { SentenceTiming } from "../types"
|
||||
|
||||
export interface ScriptSentence {
|
||||
/** 句子序号(从 0 开始,对应提交给后端的 script_segment_index) */
|
||||
@@ -13,21 +9,21 @@ export interface ScriptSentence {
|
||||
text: string
|
||||
/** 句子字数(按中文/字符计,去除空白) */
|
||||
charCount: number
|
||||
/** 累计起始字数 */
|
||||
/** 累计起始字数(用于时间估算) */
|
||||
startChar: number
|
||||
/** 精确起始时间(秒),来自后端 sentence_timings;无数据时为 0 */
|
||||
/** 估算的对口型视频内起始时间(秒) */
|
||||
startTime: number
|
||||
/** 精确结束时间(秒),来自后端 sentence_timings;无数据时为 0 */
|
||||
/** 估算的对口型视频内结束时间(秒) */
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
|
||||
* 时间戳从后端 sentence_timings 获取(精确);若无则返回 0(由调用方降级处理)。
|
||||
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
|
||||
*/
|
||||
export function splitScriptIntoSentences(
|
||||
scriptText: string,
|
||||
sentenceTimings?: SentenceTiming[] | null,
|
||||
outputDuration: number,
|
||||
): ScriptSentence[] {
|
||||
const text = (scriptText || "").trim()
|
||||
if (!text) return []
|
||||
@@ -37,25 +33,30 @@ export function splitScriptIntoSentences(
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
|
||||
const totalChars = rawParts.reduce((sum, part) => sum + part.replace(/\s/g, "").length, 0)
|
||||
const duration = outputDuration > 0 ? outputDuration : 0
|
||||
|
||||
const sentences: ScriptSentence[] = []
|
||||
let accChar = 0
|
||||
rawParts.forEach((part, i) => {
|
||||
const charCount = part.replace(/\s/g, "").length
|
||||
// 从后端精确时间戳获取;无数据时返回 0
|
||||
const timing = sentenceTimings?.[i]
|
||||
const startTime = timing?.start_time ?? 0
|
||||
const endTime = timing?.end_time ?? 0
|
||||
|
||||
const startTime = duration > 0 && totalChars > 0 ? (accChar / totalChars) * duration : 0
|
||||
const endTime =
|
||||
duration > 0 && totalChars > 0 ? ((accChar + charCount) / totalChars) * duration : 0
|
||||
sentences.push({
|
||||
index: i,
|
||||
text: part,
|
||||
charCount,
|
||||
startChar: accChar,
|
||||
startTime,
|
||||
endTime,
|
||||
startTime: round1(startTime),
|
||||
endTime: round1(endTime),
|
||||
})
|
||||
accChar += charCount
|
||||
})
|
||||
|
||||
return sentences
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
/**
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选、无限滚动分页
|
||||
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 列表查询 → hooks/useProductList(useInfiniteQuery 分页)
|
||||
* 列表查询 → 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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -481,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 28)
|
||||
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")
|
||||
@@ -508,30 +504,26 @@ def build_title_drawtext_filter(
|
||||
params.append(f"fontsize={font_size}")
|
||||
params.append(f"fontcolor={font_color}")
|
||||
|
||||
# 粗体:drawtext 没有独立的 bold 参数,通过加大 borderw 模拟视觉粗体效果。
|
||||
# 注意:不能使用 `font=bold`——FFmpeg drawtext 的 font 参数需要 fontconfig 能解析的
|
||||
# 字体族名,而 "bold" 不是合法族名,会导致整个 filter_complex 解析失败(exit code 234)。
|
||||
# 当用户未显式配置描边宽度时,bold 模式自动将 borderw 提升到 3 以模拟粗体。
|
||||
# 粗体:bold 在 drawtext 中通过 font 的 Bold 变体实现
|
||||
# 若字体有 Bold 变体可用 fontfont=bold;否则通过 borderw 模拟
|
||||
if bold:
|
||||
# 使用 font 参数尝试加载 Bold 变体(Noto Sans SC 有 Bold 变体文件)
|
||||
params.append("font=bold")
|
||||
|
||||
# 描边(borderw 需要 libfreetype 支持)
|
||||
# 粗体无显式描边时,自动用 borderw=3 + 近色描边模拟粗体;显式 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 = 3
|
||||
border_color = font_color # 用字体同色描边,视觉上加粗字形而非黑边
|
||||
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:
|
||||
@@ -556,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
|
||||
@@ -586,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 叠加滤镜链。
|
||||
|
||||
支持两种模式:
|
||||
@@ -594,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""验证函数签名和默认参数."""
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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抛异常."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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()
|
||||
@@ -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,14 +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)
|
||||
|
||||
|
||||
class TestDrawtextPositionBranches(unittest.TestCase):
|
||||
"""位置相关分支覆盖。"""
|
||||
@@ -1065,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):
|
||||
|
||||
Reference in New Issue
Block a user