Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoxia 4fcedfd5a9 debug: fix YAML, use base64 script transfer
Debug: Web container v2 (mount conflict) / web-diag (push) Successful in 22s
2026-09-11 11:09:13 +08:00
xiaoxia cc335673a9 debug: add ssh diag on debug branch 2026-09-11 11:06:16 +08:00
xiaoxia 029267cb46 debug: SSH diagnose web container v2 2026-09-11 10:55:04 +08:00
72 changed files with 2620 additions and 5521 deletions
+9 -39
View File
@@ -8,53 +8,23 @@ jobs:
runs-on: runtime-builder runs-on: runtime-builder
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Setup SSH and diagnose - name: Setup SSH
shell: bash shell: bash
env: env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }} STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: | run: |
set -x set -eux
which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client) which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
mkdir -p ~/.ssh && chmod 700 ~/.ssh mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
H=47.98.113.167; P=22222 H=47.98.113.167; P=22222
ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null
ssh -p $P -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@$H 'bash -s' <<'REMOTE' - name: Run diag via base64
shell: bash
run: |
set -x set -x
echo "=== Current staging containers ===" echo 'IyEvYmluL2Jhc2gKc2V0IC14CmVjaG8gIj09PSBDdXJyZW50IHN0YWdpbmcgY29udGFpbmVycyA9PT0iCmRvY2tlciBwcyAtYSAtLWZpbHRlciBuYW1lPXhpYW94aWEtKi1zdGFnaW5nIC0tZm9ybWF0ICJ0YWJsZSB7ey5OYW1lc319XHR7ey5TdGF0dXN9fVx0e3suSW1hZ2V9fSIKZWNobyAiIgplY2hvICI9PT0gV2ViIGNvbnRhaW5lciBsb2dzIChjdXJyZW50L3JvbGxlZC1iYWNrKSA9PT0iCmRvY2tlciBsb2dzIHhpYW94aWEtd2ViLXN0YWdpbmcgMj4mMSB8IHRhaWwgLTQwCmVjaG8gIiIKZWNobyAiPT09IFdlYiBpbnNwZWN0OiBlbnYgJiBtb3VudHMgPT09Igpkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdlYi1zdGFnaW5nIC0tZm9ybWF0ICdFbnRyeXBvaW50OiB7ey5Db25maWcuRW50cnlwb2ludH19IENtZDoge3suQ29uZmlnLkNtZH19Jwpkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdlYi1zdGFnaW5nIC0tZm9ybWF0ICd7e3JhbmdlIC5Db25maWcuRW52fX17ey59fXt7IlxuIn19e3tlbmR9fScgfCBncmVwIC1FICJBUFBfRU5WfFZFUlNJT04iCmVjaG8gIk1vdW50czoiCmRvY2tlciBpbnNwZWN0IHhpYW94aWEtd2ViLXN0YWdpbmcgLS1mb3JtYXQgJ3t7cmFuZ2UgLk1vdW50c319e3suVHlwZX19IHt7LlNvdXJjZX19IC0+IHt7LkRlc3RpbmF0aW9ufX0gKHJ3PXt7LlJXfX0pe3siXG4ifX17e2VuZH19JwplY2hvICIiCmVjaG8gIj09PSBSZXByb2R1Y2U6IHJtIG9uIHJlYWQtb25seSBiaW5kIG1vdW50IChpbWFnZSAzODc1MTRjKSA9PT0iCmRvY2tlciBydW4gLS1ybSAtLW5hbWUgbmdpbngtcm8tdGVzdCBcCiAgLXYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvbmdpbngtc3RhZ2luZy5jb25mOi9ldGMvbmdpbngvY29uZi5kL2RlZmF1bHQuY29uZjpybyBcCiAgZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzL3hpYW94aWEtc2Fhcy13ZWI6Mzg3NTE0YyBcCiAgL2Jpbi9zaCAtYyAnc2V0IC14OyBlY2hvIEJFRk9SRTsgbHMgLWxhIC9ldGMvbmdpbngvY29uZi5kLzsgcm0gLWYgL2V0Yy9uZ2lueC9jb25mLmQvZGVmYXVsdC5jb25mOyBlY2hvICJybSBleGl0PSQ/IjsgZWNobyBBRlRFUjsgbHMgLWxhIC9ldGMvbmdpbngvY29uZi5kLycgMj4mMQplY2hvICIiCmVjaG8gIj09PSBMb2NhbCB3ZWIgaW1hZ2VzIGF2YWlsYWJsZSA9PT0iCmRvY2tlciBpbWFnZXMgfCBncmVwIHhpYW94aWEtc2Fhcy13ZWIgfCBoZWFkIC01Cg==' | base64 -d > /tmp/diag.sh
docker ps -a --filter name=xiaoxia-*-staging --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" chmod +x /tmp/diag.sh
echo "" cat /tmp/diag.sh
echo "=== Web container logs (current/current-rolledback) ===" ssh -p 22222 -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@47.98.113.167 'bash -s' < /tmp/diag.sh
docker logs xiaoxia-web-staging 2>&1 | tail -40
echo ""
echo "=== Web inspect: env & mounts ==="
docker inspect xiaoxia-web-staging --format 'Entrypoint: {{.Config.Entrypoint}} Cmd: {{.Config.Cmd}}'
docker inspect xiaoxia-web-staging --format '{{range .Config.Env}}{{.}}{{"\n"}}{{end}}' | grep -E "APP_ENV|VERSION"
echo "Mounts:"
docker inspect xiaoxia-web-staging --format '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} (rw={{.RW}}){{"\n"}}{{end}}'
echo ""
echo "=== Reproduce: rm on read-only bind mount ==="
docker run --rm --name nginx-ro-test \
-v /var/lib/xiaoxia-saas-staging/nginx-staging.conf:/etc/nginx/conf.d/default.conf:ro \
git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/xiaoxia-saas-web:387514c \
sh -c '
set -x
echo "Before:"
ls -la /etc/nginx/conf.d/
echo "Try rm (as entrypoint does):"
rm -f /etc/nginx/conf.d/default.conf
echo "rm exitcode=$?"
echo "After rm:"
ls -la /etc/nginx/conf.d/
echo "Test ln:"
ln -s /etc/nginx/nginx-staging.conf /etc/nginx/conf.d/default.conf
echo "ln exitcode=$?"
ls -la /etc/nginx/conf.d/
echo "nginx -t:"
nginx -t 2>&1
' 2>&1
echo ""
echo "=== Also test with NEW fixed image (9c0d4b1 if present) ==="
docker images | grep xiaoxia-saas-web | head -5
REMOTE
@@ -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")
+25 -131
View File
@@ -11,14 +11,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime, timezone
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session from app.dependencies import get_db_session
from app.schemas.ai_avatar_render import ( from app.schemas.ai_avatar_render import (
AiAvatarRenderJobResponse, AiAvatarRenderJobResponse,
CreateAiAvatarRenderRequest, CreateAiAvatarRenderRequest,
FinalizeRenderResponse, SmartCoverRequest,
SmartCoverResponse, SmartCoverResponse,
) )
from app.services.ai_avatar_cover_service import generate_smart_cover from app.services.ai_avatar_cover_service import generate_smart_cover
@@ -78,16 +77,10 @@ def create_render_job(
from app.tasks.ai_avatar_render import execute_ai_avatar_render from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(job.id) execute_ai_avatar_render.delay(job.id)
except Exception as exc: except Exception:
logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc) logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
job.status = "failed"
job.error_message = f"任务提交失败:{exc}"
job.updated_at = datetime.now(timezone.utc)
svc.db.commit()
svc.db.refresh(job)
return AiAvatarRenderJobResponse.model_validate(job)
return AiAvatarRenderJobResponse.model_validate(job) return job
# ── GET /jobs — 任务列表 ───────────────────────────────────────────────── # ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@@ -179,54 +172,38 @@ def retry_render_job(
from app.tasks.ai_avatar_render import execute_ai_avatar_render from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(job.id) execute_ai_avatar_render.delay(job.id)
except Exception as exc: except Exception:
logger.exception("Celery 任务投递失败重试: job_id=%s err=%s", job.id, exc) logger.warning("Celery 任务提交失败重试任务已重置但未触发执行: %s", job.id)
job.status = "failed"
job.error_message = f"任务提交失败:{exc}"
job.updated_at = datetime.now(timezone.utc)
svc.db.commit()
svc.db.refresh(job)
return AiAvatarRenderJobResponse.model_validate(job)
return AiAvatarRenderJobResponse.model_validate(job) return job
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse) @router.post("/smart-cover", response_model=SmartCoverResponse)
def generate_render_smart_cover( def generate_avatar_smart_cover(
job_id: str, body: SmartCoverRequest,
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session), ) -> SmartCoverResponse:
): """智能获取数字人视频封面.
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
- 必须等渲染任务 completed 后才可调用(否则返回 400) 复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧逻辑(非 FFmpeg 简单截帧),
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url 并将选中帧转存到自家 OSS,返回非临时的封面公网 URL。
前端「智能获取封面」按钮可直接调用本接口;不依赖渲染任务完成。
""" """
from app.services.ai_avatar_render_service import AiAvatarRenderService video_url = (body.video_url or "").strip()
if not video_url.startswith(("http://", "https://")):
svc = AiAvatarRenderService(db) raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL")
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
video_url = (job.output_video_url or "").strip()
if not video_url:
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空")
try: try:
# 从最终成片抽帧,帧本身已含标题/B-roll,直接转存 OSS cover_url = generate_smart_cover(video_url, max_frames=body.max_frames)
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s", "智能封面生成异常: user=%s video_url=%s err=%s",
current_user.user.id, current_user.user.id, video_url[:80], exc,
job_id,
video_url[:80],
exc,
exc_info=True, exc_info=True,
) )
cover_url = "" cover_url = ""
@@ -237,88 +214,5 @@ def generate_render_smart_cover(
status="fallback_failed", status="fallback_failed",
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试", message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
) )
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
# 更新 render_job 的封面字段(异步写入 DB;失败不影响返回)
try:
job.cover_config = {
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
"mode": "auto_frame",
"url": cover_url,
}
job.output_cover_url = cover_url
job.updated_at = datetime.now(timezone.utc)
db.commit()
except Exception as exc:
logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc)
logger.info(
"渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s",
current_user.user.id,
job_id,
cover_url[:120],
)
return SmartCoverResponse(cover_url=cover_url, status="completed") return SmartCoverResponse(cover_url=cover_url, status="completed")
# ── POST /{job_id}/finalize — 封面选定后正式入库成片库 ────────────────────
@router.post("/{job_id}/finalize", response_model=FinalizeRenderResponse)
def finalize_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""用户完成封面选择后,将视频正式保存到成片库.
- 必须等渲染任务 completed 后才可调用
- 如果已通过 smart-cover/custom-cover 设置了封面,会自动带上
- 返回成片库视频ID
- 幂等:已 finalize 的任务重复调用会返回 existing 记录
"""
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
svc = AiAvatarRenderService(db)
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
# 幂等检查(通过 generation_task_id=job_id 识别,finalize_job 内部也做了一次,这里提前返回简化)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
existing = (
db.query(GeneratedVideoModel)
.filter(
GeneratedVideoModel.user_id == current_user.user.id,
GeneratedVideoModel.generation_task_id == job_id,
)
.first()
)
if existing is not None:
return FinalizeRenderResponse(
video_id=existing.id,
cover_url=existing.thumbnail_url or "",
status="already_finalized",
)
try:
video = svc.finalize_job(job_id, current_user.user.id)
return FinalizeRenderResponse(
video_id=video.id,
cover_url=video.thumbnail_url or job.output_cover_url or "",
status="success",
)
except AiAvatarRenderError as exc:
status_map = {
"RenderJobNotFound": 404,
"RenderNotCompleted": 400,
"OutputVideoMissing": 400,
}
raise HTTPException(
status_code=status_map.get(exc.code, 400),
detail=str(exc),
) from exc
except Exception as exc:
logger.error("渲染任务finalize失败: job_id=%s err=%s", job_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail=f"保存到成片库失败: {str(exc)}") from exc
+111 -36
View File
@@ -58,10 +58,27 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]: def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
"""[已下沉] 路由层兼容别名 → app.services.generation_common.query_voice_durations。""" """批量查询配音素材时长(秒),#1749 配音时长分配用。
from app.services.generation_common import query_voice_durations
return query_voice_durations(db, voice_ids) 逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
"""
ids = [v for v in dict.fromkeys(voice_ids or []) if v]
if not ids:
return []
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(ids)).all()
dur_map: dict[str, float] = {}
for row in rows:
try:
dur_map[row[0]] = float(row[1] or 0.0)
except (TypeError, ValueError):
dur_map[row[0]] = 0.0
return [dur_map.get(v, 0.0) for v in ids]
except Exception:
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
return [0.0 for _ in ids]
def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generation_task_response(task) -> GenerationTaskResponse:
@@ -167,10 +184,61 @@ def _writeback_edit_plan_config(
title_config: dict | None, title_config: dict | None,
db: Session, db: Session,
) -> None: ) -> None:
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。""" """任务入队成功后,回写 EditPlan.configgeneration_task_id + title_config。
from app.services.generation_common import writeback_edit_plan_config
return writeback_edit_plan_config(plan_id, task_id, title_config, db) 用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
# 标题变化,清除旧封面
if "cover" in merged:
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id,
old_title_text,
new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def _resolve_project_and_library( def _resolve_project_and_library(
@@ -421,14 +489,25 @@ def create_generation_task(
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断) # 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
voice_durations = _query_voice_durations(db, variant_voices) voice_durations = _query_voice_durations(db, variant_voices)
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(公共函数 # 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源
from app.services.generation_common import resolve_latest_plan_by_template batch_source_plan_id = request.source_edit_plan_id
if not batch_source_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
batch_source_plan_id = ( _latest = (
request.source_edit_plan_id db.query(EditPlanModel)
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) .filter(
or "" EditPlanModel.template_id == request.template_id,
) EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
batch_source_plan_id = _latest.id
except Exception:
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
if not batch_source_plan_id and not request.variant_plan_ids: if not batch_source_plan_id and not request.variant_plan_ids:
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源 # 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
@@ -473,12 +552,7 @@ def create_generation_task(
) from clone_err ) from clone_err
variant_plan_ids.append(_plan0.id) variant_plan_ids.append(_plan0.id)
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数) # 变体 1..N-1 独立选片
from app.services.generation_common import collect_plan_segments as _collect_segments
_batch_segments = _collect_segments(_plan0.id, _plan_svc._clip_repo)
# 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让)
for task_index in range(1, count): for task_index in range(1, count):
variant = None variant = None
last_err: Exception | None = None last_err: Exception | None = None
@@ -490,7 +564,6 @@ def create_generation_task(
created_by_user_id=user_id, created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}", name_suffix=f"批量{task_index + 1}",
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0, voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
batch_segments=_batch_segments,
) )
break break
except ValueError as ve: except ValueError as ve:
@@ -522,16 +595,7 @@ def create_generation_task(
) from last_err ) from last_err
variant_plan_ids.append(variant.id) variant_plan_ids.append(variant.id)
# #1855 P0:把新变体的clips区间追加到batch_segments,供下一变体避让 # ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配)
try:
_new_segs = _collect_segments(variant.id, _plan_svc._clip_repo)
for _aid, _ivs in _new_segs.items():
_batch_segments.setdefault(_aid, []).extend(_ivs)
except Exception:
logger.exception("[生成任务] 变体%d 区间收集失败(不阻断)", task_index)
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配,
# #1855apply_voice_duration_to_plan 已内置幂等判断,重复调用安全)
for _vi, _pid in enumerate(variant_plan_ids): for _vi, _pid in enumerate(variant_plan_ids):
_vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0 _vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0
if _vd > 0: if _vd > 0:
@@ -552,13 +616,24 @@ def create_generation_task(
) )
_single_vd: list[float] = _query_voice_durations(db, _voices) _single_vd: list[float] = _query_voice_durations(db, _voices)
_single_dur = _single_vd[0] if _single_vd else 0.0 _single_dur = _single_vd[0] if _single_vd else 0.0
from app.services.generation_common import resolve_latest_plan_by_template _single_plan = request.source_edit_plan_id
if not _single_plan and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_single_plan = ( _latest = (
request.source_edit_plan_id db.query(EditPlanModel)
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) .filter(
or "" EditPlanModel.template_id == request.template_id,
) EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
_single_plan = _latest.id
except Exception:
logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True)
if _single_dur > 0 and _single_plan: if _single_dur > 0 and _single_plan:
from app.services.edit_plan_service import EditPlanService from app.services.edit_plan_service import EditPlanService
@@ -90,12 +90,25 @@ def create_variant_plans(
except VariantVoiceError as exc: except VariantVoiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新(公共函数) # 解析源 plan:显式传入优先;否则按 template_id + user 查最新
from app.services.generation_common import resolve_latest_plan_by_template
source_plan_id = request.source_edit_plan_id.strip() source_plan_id = request.source_edit_plan_id.strip()
if not source_plan_id and request.template_id.strip(): if not source_plan_id and request.template_id.strip():
source_plan_id = resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) or "" try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id.strip(),
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _latest:
source_plan_id = _latest.id
except Exception:
logger.exception("[variant-plans] 源 plan 解析失败")
if not source_plan_id: if not source_plan_id:
raise HTTPException( raise HTTPException(
+15 -64
View File
@@ -1,12 +1,11 @@
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整, #1845 配音前置. """对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整.
接口: 接口:
POST /api/v1/lipsync/jobs 提交对口型任务(支持 TTS/直传/预合成 三种模式) POST /api/v1/lipsync/jobs 提交对口型任务
GET /api/v1/lipsync/jobs 任务列表 GET /api/v1/lipsync/jobs 任务列表
GET /api/v1/lipsync/jobs/{id} 任务详情 GET /api/v1/lipsync/jobs/{id} 任务详情
POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态 POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态
POST /api/v1/lipsync/jobs/{id}/cancel 取消任务 POST /api/v1/lipsync/jobs/{id}/cancel 取消任务
POST /api/v1/lipsync/tts-preview #1845 步骤1 TTS 预合成(同步 HTTP~2-3s
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,12 +17,7 @@ from app.dependencies import (
get_db_session, get_db_session,
get_voice_clone_profile_repository, get_voice_clone_profile_repository,
) )
from app.schemas.lipsync import ( from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
AiAvatarTtsPreviewRequest,
AiAvatarTtsPreviewResponse,
CreateLipsyncJobRequest,
LipsyncJobResponse,
)
from app.services.lipsync_service import LipsyncService from app.services.lipsync_service import LipsyncService
from app.services.mediakit_client import MediaKitError from app.services.mediakit_client import MediaKitError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
@@ -39,6 +33,7 @@ def _get_service(
voice_clone_repo=Depends(get_voice_clone_profile_repository), voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> LipsyncService: ) -> LipsyncService:
# voice_clone_repo 用于克隆音色 profile 解析 # voice_clone_repo 用于克隆音色 profile 解析
# TTS 合成已移至 Celery 异步任务,无需同步注入 cosyvoice_service
return LipsyncService( return LipsyncService(
db, db,
voice_clone_repo=voice_clone_repo, voice_clone_repo=voice_clone_repo,
@@ -56,20 +51,15 @@ def create_lipsync_job(
): ):
"""提交对口型任务. """提交对口型任务.
三种模式: #1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?}
- TTS 直生(旧版/降级):传 {video_url, voice_id, script_text, speed?, emotion?} 后端创建任务记录(状态 tts_processing),dispatch Celery 异步任务执行 TTS 合成 + MediaKit 提交;
后端 dispatch Celery 异步任务 也支持直接传 {video_url, audio_url}(同步提交 MediaKit
- 直接音频:传 {video_url, audio_url},后端同步下载+算timings+提交MediaKit。
- 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings}
后端同步ffprobe+写入timings+直接提交MediaKit~2-3s)。
""" """
try: try:
job = svc.create_job( job = svc.create_job(
user_id=current_user.user.id, user_id=current_user.user.id,
video_url=body.video_url, video_url=body.video_url,
audio_url=body.audio_url, audio_url=body.audio_url,
audio_duration=body.audio_duration,
sentence_timings=body.sentence_timings,
voice_id=body.voice_id, voice_id=body.voice_id,
script_text=body.script_text, script_text=body.script_text,
speed=body.speed, speed=body.speed,
@@ -78,8 +68,10 @@ def create_lipsync_job(
project_id=body.project_id, project_id=body.project_id,
) )
except ValueError as exc: except ValueError as exc:
# 参数无效(如 voice_id 格式不对、文本过长等)
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except MediaKitError as exc: except MediaKitError as exc:
# 音色无权访问 → 403;参数无效 → 400MediaKit 提交失败 → 502
status_code = 502 status_code = 502
if exc.code in ("VoiceForbidden",): if exc.code in ("VoiceForbidden",):
status_code = 403 status_code = 403
@@ -94,6 +86,7 @@ def create_lipsync_job(
}, },
) from exc ) from exc
except Exception as exc: except Exception as exc:
# 兜底:任何未预期的错误返回 400 而非 500
logger.error("创建对口型任务异常: %s", exc, exc_info=True) logger.error("创建对口型任务异常: %s", exc, exc_info=True)
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@@ -103,52 +96,6 @@ def create_lipsync_job(
return job return job
# ── POST /tts-preview — #1845 步骤1 TTS 预合成 ──────────────────────────
@router.post("/tts-preview", response_model=AiAvatarTtsPreviewResponse)
def preview_tts(
body: AiAvatarTtsPreviewRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""步骤1「生成配音」同步 TTS 预合成.
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL~24h 有效)。
耗时约 2-3 秒。
"""
try:
result = svc.preview_tts(
user_id=current_user.user.id,
voice_id=body.voice_id,
script_text=body.script_text,
speed=body.speed,
emotion=body.emotion,
)
except MediaKitError as exc:
status_code = 400
if exc.code in ("VoiceForbidden",):
status_code = 403
elif exc.code in ("TTSNoAudio",):
status_code = 502
raise HTTPException(
status_code=status_code,
detail={
"code": exc.code,
"message": str(exc),
},
) from exc
except Exception as exc:
logger.error("TTS 预合成异常: %s", exc, exc_info=True)
raise HTTPException(
status_code=400,
detail=f"TTS 合成失败: {exc}",
) from exc
return result
# ── GET /jobs — 任务列表 ───────────────────────────────────────────────── # ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@@ -187,7 +134,11 @@ def get_lipsync_job(
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service), svc: LipsyncService = Depends(_get_service),
): ):
"""获取对口型任务详情.""" """获取对口型任务详情.
非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态),
避免 MediaKit 慢响应阻塞前端轮询。
"""
job = svc.get_job(job_id, current_user.user.id) job = svc.get_job(job_id, current_user.user.id)
if job is None: if job is None:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
+10 -12
View File
@@ -52,9 +52,7 @@ class CreateAiAvatarRenderRequest(BaseModel):
lipsync_job_id: str = Field(..., description="对口型任务 ID") lipsync_job_id: str = Field(..., description="对口型任务 ID")
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)") script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表") b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
title_config: dict[str, Any] = Field( title_config: dict[str, Any] = Field(default_factory=dict, description="标题配置")
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL"
)
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置") cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
project_id: str = Field("", description="项目 ID") project_id: str = Field("", description="项目 ID")
@@ -69,6 +67,7 @@ class CreateAiAvatarRenderRequest(BaseModel):
@field_validator("script_id") @field_validator("script_id")
@classmethod @classmethod
def validate_script_id(cls, v: str) -> str: def validate_script_id(cls, v: str) -> str:
# script_id 可选:手动输入文案(TTS 直生)场景不关联文案库条目
return (v or "").strip() return (v or "").strip()
@@ -110,17 +109,16 @@ class AiAvatarRenderProgressResponse(BaseModel):
error_message: str error_message: str
class SmartCoverRequest(BaseModel):
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧."""
video_url: str = Field(..., description="数字人视频 URL(对口型/渲染成片)")
max_frames: int = Field(5, ge=1, le=10, description="抽帧数量(默认 5")
class SmartCoverResponse(BaseModel): class SmartCoverResponse(BaseModel):
"""智能封面响应(封面从最终成片抽帧,不再叠加标题).""" """智能封面响应."""
cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空") cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空")
status: str = Field("completed", description="completed / fallback_failed") status: str = Field("completed", description="completed / fallback_failed")
message: str = Field("", description="失败原因(如有)") message: str = Field("", description="失败原因(如有)")
class FinalizeRenderResponse(BaseModel):
"""封面选好后点「完成」,正式入库成片库的响应."""
video_id: str = Field(..., description="成片库视频ID")
cover_url: str = Field("", description="封面URL")
status: str = Field("success", description="success/already_finalized")
+11 -42
View File
@@ -1,12 +1,9 @@
"""对口型 API Schema 定义 — #1796 / #1809 / #1822 / #1845(配音前置). """对口型 API Schema 定义 — #1796 / #1809 / #1822.
支持种输入模式: 支持种输入模式(二选一)
1. TTS 直生模式(兼容旧版前端):传 voice_id + script_text+ speed/emotion), 1. TTS 直生模式(推荐):传 voice_id + script_text+ speed/emotion),
后端 Celery 异步做 TTS 合成 + MediaKit 提交 后端内部先调 CosyVoice 合成音频,再提交 MediaKit 对口型
2. 直接音频模式:传 video_url + audio_url(音频已由调用方准备好)。 2. 直接音频模式:传 video_url + audio_url(音频已由调用方准备好)。
3. 预合成音频模式(#1845 配音前置新主路径):前端先调 POST /lipsync/tts-preview
拿到 audio_url + sentence_timings,再在 create_job 时传 audio_url + audio_duration
+ sentence_timings,后端跳过 TTS 和时间戳计算,直接 ffprobe 校验后提交 MediaKit。
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,7 +33,6 @@ class LipsyncJobResponse(BaseModel):
output_duration: float output_duration: float
error_message: str error_message: str
error_code: str error_code: str
sentence_timings: Optional[list] = None
submitted_at: Optional[datetime] = None submitted_at: Optional[datetime] = None
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
created_at: datetime created_at: datetime
@@ -49,19 +45,15 @@ class LipsyncJobResponse(BaseModel):
class CreateLipsyncJobRequest(BaseModel): class CreateLipsyncJobRequest(BaseModel):
"""创建对口型任务请求. """创建对口型任务请求.
种模式(选一): 种模式(选一):
- TTS 直生(旧版/降级)voice_id + script_text 必填;audio_url 留空。 - TTS 直生:voice_id + script_text 必填+ 可选 speed/emotionaudio_url 留空。
- 直接音频:video_url + audio_url 必填。 - 直接音频:video_url + audio_url 必填。
- 预合成音频(#1845 新主路径):audio_url 必填 + 可选 audio_duration/sentence_timings
后端同步 ffprobe 校验时长、写入 timings,直接提交 MediaKit。
""" """
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)") video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
# 模式 2/3:直接/预合成音频 # 模式 2:直接音频
audio_url: str = Field("", description="驱动音频 URLmp3/aac/wav/m4a/flac);直生模式留空") audio_url: str = Field("", description="驱动音频 URLmp3/aac/wav/m4a/flac);直生模式留空")
audio_duration: Optional[float] = Field(None, ge=0, description="预合成音频时长(秒),可选;后端会 ffprobe 校验")
sentence_timings: Optional[list] = Field(None, description="预合成接口返回的句子时间戳,可选;若传入则直接写入 job")
# 模式 1TTS 直生 # 模式 1TTS 直生
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID") voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID")
@@ -69,9 +61,7 @@ class CreateLipsyncJobRequest(BaseModel):
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)") emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
enable_video_loop: bool = Field( enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)"
)
project_id: str = Field("", description="项目 ID(可选)") project_id: str = Field("", description="项目 ID(可选)")
@model_validator(mode="after") @model_validator(mode="after")
@@ -82,16 +72,15 @@ class CreateLipsyncJobRequest(BaseModel):
if not video.startswith(("http://", "https://")): if not video.startswith(("http://", "https://")):
raise ValueError("video_url 必须是 HTTP/HTTPS URL") raise ValueError("video_url 必须是 HTTP/HTTPS URL")
lower = video.lower().split("?")[0] lower = video.lower().split("?")[0]
allowed_video_exts = (".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv", ".3gp") if not lower.endswith(".mp4"):
if not any(lower.endswith(ext) for ext in allowed_video_exts): raise ValueError("video_url 仅支持 MP4 格式")
raise ValueError("video_url 格式不支持,仅支持: " + ", ".join(allowed_video_exts))
has_audio = bool((self.audio_url or "").strip()) has_audio = bool((self.audio_url or "").strip())
has_tts = bool((self.voice_id or "").strip()) and bool((self.script_text or "").strip()) has_tts = bool((self.voice_id or "").strip()) and bool((self.script_text or "").strip())
if not has_audio and not has_tts: if not has_audio and not has_tts:
raise ValueError( raise ValueError(
"必须提供驱动音频:要么传 audio_url(直接/预合成音频模式)," "必须提供驱动音频:要么传 audio_url(直接音频模式),"
"要么同时传 voice_id + script_textTTS 直生模式)" "要么同时传 voice_id + script_textTTS 直生模式)"
) )
@@ -109,23 +98,3 @@ class CreateLipsyncJobRequest(BaseModel):
self.audio_url = au self.audio_url = au
return self return self
# ── #1845 TTS 预合成接口 ────────────────────────────────────────────────
class AiAvatarTtsPreviewRequest(BaseModel):
"""步骤1「生成配音」预合成请求(同步 HTTP,~2-3s)."""
voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID")
script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
emotion: str = Field("natural", max_length=32, description="情绪")
class AiAvatarTtsPreviewResponse(BaseModel):
"""TTS 预合成响应(临时 URL,24h 内有效,足够当前会话使用)."""
audio_url: str = Field(..., description="CosyVoice 临时音频 URL")
duration: float = Field(..., ge=0, description="音频总时长(秒),ffprobe 测得")
sentence_timings: list[dict] = Field(..., description="句子级精确时间戳")
@@ -1,13 +1,10 @@
"""AI 数字人封面服务 — MediaKit 抽帧 + 质量评分选最佳帧 + 转存 OSS. """AI 数字人封面服务 — 复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧.
与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧): 与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧):
1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略) 1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略)
2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳 2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳
3. 下载最佳帧并转存 OSS,返回公网封面 URL 3. 下载最佳帧并转存 OSS,返回公网封面 URL
设计原则:封面一律从最终成片(已叠加标题/B-roll)抽帧,帧本身已含标题,
本服务**不再叠加标题**。对口型阶段的裸视频封面入口已删除(废弃)。
降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。 降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。
""" """
@@ -22,9 +19,9 @@ from urllib.parse import urlparse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# MediaKit 抽帧轮询参数poll_interval=2s × max_poll=30 → 最长 60s(与 mediakit_client 默认值/lipsync 轮询保持一致,防止合成视频下载+抽帧超时 # MediaKit 抽帧轮询参数(与 MediaKit API timeout=60s 对齐
COVER_POLL_INTERVAL = 2.0 COVER_POLL_INTERVAL = 3.0
COVER_MAX_POLL_ATTEMPTS = 30 COVER_MAX_POLL_ATTEMPTS = 20 # 最多等 60 秒
# 帧图片下载超时(秒) # 帧图片下载超时(秒)
FRAME_DOWNLOAD_TIMEOUT = 20 FRAME_DOWNLOAD_TIMEOUT = 20
@@ -52,6 +49,7 @@ def _sign_video_url_for_mediakit(video_url: str) -> str:
own_host = urlparse(public_base).netloc.lower() own_host = urlparse(public_base).netloc.lower()
url_host = urlparse(video_url).netloc.lower() url_host = urlparse(video_url).netloc.lower()
if own_host and url_host == own_host: if own_host and url_host == own_host:
# 是自家 OSS URL,重签 7 天有效期供 MediaKit 拉取
signed = storage.get_download_url(video_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS) signed = storage.get_download_url(video_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
if signed: if signed:
logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)") logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)")
@@ -62,10 +60,19 @@ def _sign_video_url_for_mediakit(video_url: str) -> str:
def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str: def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL.""" """从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL.
Args:
video_url: 可公网访问的视频 URL
max_frames: 抽帧数量
Returns:
最佳帧图片 URL;失败返回空字符串
"""
if not video_url: if not video_url:
return "" return ""
# 确保 MediaKit 能访问 video_url(自家 OSS 私有桶需重签)
video_url = _sign_video_url_for_mediakit(video_url) video_url = _sign_video_url_for_mediakit(video_url)
try: try:
@@ -78,9 +85,11 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
return "" return ""
logger.info( logger.info(
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d", "[数字人封面] 开始抽帧: video_url=%s max_frames=%d poll_interval=%.1f max_poll=%d",
video_url[:80], video_url[:80],
max_frames, max_frames,
COVER_POLL_INTERVAL,
COVER_MAX_POLL_ATTEMPTS,
) )
snapshots = mk.extract_frames( snapshots = mk.extract_frames(
@@ -98,6 +107,7 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
if len(snapshots) == 1: if len(snapshots) == 1:
return snapshots[0].get("image_url") or snapshots[0].get("url") or "" return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
# 使用连接池下载各帧(复用 TCP 连接,减少延迟)
import httpx import httpx
candidates = [] candidates = []
@@ -125,6 +135,7 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
best = scored[0] if scored else None best = scored[0] if scored else None
best_url = best.get("url", "") if best else "" best_url = best.get("url", "") if best else ""
# 清理临时文件
for c in candidates: for c in candidates:
p = c.get("image_path") p = c.get("image_path")
if p: if p:
@@ -145,15 +156,16 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
return "" return ""
def persist_cover_to_oss( def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-avatar/covers") -> str:
frame_url: str, """下载帧图并转存到 OSS,返回公网封面 URL.
*,
job_id: str = "",
prefix: str = "ai-avatar/covers",
) -> str:
"""下载最佳帧图并转存到 OSS,返回公网封面 URL(预签名).
封面来自最终成片抽帧,帧本身已含标题,本函数不再做任何文字/图片叠加。 Args:
frame_url: MediaKit 返回的临时帧图 URL
job_id: 关联任务 ID(用于 OSS key 命名)
prefix: OSS key 前缀
Returns:
OSS 公网 URL;失败回退原始 frame_url
""" """
if not frame_url: if not frame_url:
return "" return ""
@@ -177,13 +189,13 @@ def persist_cover_to_oss(
storage = get_shared_storage_service() storage = get_shared_storage_service()
token = job_id or uuid.uuid4().hex[:12] token = job_id or uuid.uuid4().hex[:12]
cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg" cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg"
public_url = storage.upload_file( public_url = storage.upload_file(
file_or_path=tmp_path, file_or_path=tmp_path,
storage_key=cover_key, storage_key=cover_key,
content_type="image/jpeg", content_type="image/jpeg",
) )
logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key) logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key)
# 私有桶:返回预签名 URL(前端才能加载)
if public_url: if public_url:
signed = storage.get_download_url(cover_key, expires_seconds=86400) signed = storage.get_download_url(cover_key, expires_seconds=86400)
return signed return signed
@@ -199,15 +211,10 @@ def persist_cover_to_oss(
pass pass
def generate_smart_cover( def generate_smart_cover(video_url: str, *, job_id: str = "", max_frames: int = 5) -> str:
video_url: str, """一站式:MediaKit 智能抽帧选最佳 → 转存 OSS,返回封面公网 URL.
*,
job_id: str = "",
max_frames: int = 5,
) -> str:
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS。失败返回空字符串。
封面从最终成片抽帧,不再叠加任何标题(帧本身已含) 供独立封面接口与渲染管线复用。失败返回空字符串
""" """
best_frame = select_best_cover_frame(video_url, max_frames=max_frames) best_frame = select_best_cover_frame(video_url, max_frames=max_frames)
if not best_frame: if not best_frame:
+99 -331
View File
@@ -9,11 +9,8 @@
from __future__ import annotations from __future__ import annotations
import base64
import binascii
import logging import logging
import os import os
import subprocess
import tempfile import tempfile
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -27,9 +24,8 @@ from packages.adapters.sqlalchemy_impl.models import (
ScriptModel, ScriptModel,
) )
from packages.domain.video_filter_builder import ( from packages.domain.video_filter_builder import (
build_broll_overlay_filter, build_cover_extract_command,
build_title_drawtext_filter, build_title_drawtext_filter,
build_title_overlay_filter,
) )
from packages.shared.storage import get_shared_storage_service from packages.shared.storage import get_shared_storage_service
@@ -200,8 +196,9 @@ class AiAvatarRenderService:
1. 下载对口型输出视频 (20%) 1. 下载对口型输出视频 (20%)
2. 构建 FFmpeg 滤镜链 (40%) 2. 构建 FFmpeg 滤镜链 (40%)
3. 执行 FFmpeg 渲染 (80%) 3. 执行 FFmpeg 渲染 (80%)
4. 上传到 OSS (95%) — 封面不再自动生成,改由前端主动抽帧 4. 提取封面 (90%)
5. 更新任务状态 (100%) 5. 上传到 OSS (95%)
6. 更新任务状态 (100%)
""" """
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first() job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
if job is None: if job is None:
@@ -231,32 +228,27 @@ class AiAvatarRenderService:
self.db.commit() self.db.commit()
# 2. 构建 FFmpeg 滤镜链 (40%) # 2. 构建 FFmpeg 滤镜链 (40%)
# 用 ffprobe 探测输入视频分辨率,确保 B-roll 缩放与标题位置与实际输出一致。 from packages.domain.video_filter_builder import build_broll_overlay_filter
# 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)
broll_filter, broll_label = build_broll_overlay_filter( filter_complex = build_broll_overlay_filter(
b_roll_segments=job.b_roll_segments, b_roll_segments=job.b_roll_segments,
video_duration=lipsync_job.output_duration, video_duration=lipsync_job.output_duration,
output_width=output_width,
output_height=output_height,
) )
# 标题叠加路径:优先前端 Canvas 渲染的 PNG 图层(所见即所得), # 标题叠加
# 无 title_image_dataurl 时降级到 drawtext 重画文字。 title_filter = build_title_drawtext_filter(job.title_config)
title_cfg = job.title_config if isinstance(job.title_config, dict) else {} if title_filter:
title_dataurl = (title_cfg or {}).get("title_image_dataurl") if title_cfg else None if filter_complex:
use_title_png = isinstance(title_dataurl, str) and title_dataurl.startswith("data:image/") filter_complex += f"[vout]{title_filter}[vout_titled];"
title_input_index = 1 + len(job.b_roll_segments or []) if use_title_png else None else:
filter_complex = f"[0:v]{title_filter}[vout_titled];"
# 清理末尾分号
if filter_complex.endswith(";"):
filter_complex = filter_complex[:-1]
# 最终输出标签
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
job.progress = 40 job.progress = 40
self.db.commit() self.db.commit()
@@ -265,126 +257,57 @@ class AiAvatarRenderService:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
output_video_path = os.path.join(tmpdir, "output.mp4") output_video_path = os.path.join(tmpdir, "output.mp4")
# 在临时目录里解码保存标题 PNG(with 退出自动清理) cmd = self._build_ffmpeg_command(
title_png_path: Optional[str] = None
extra_inputs: list[str] = []
title_filter = None
if use_title_png:
try:
title_png_path = os.path.join(tmpdir, f"title_{job.id}.png")
self._save_title_dataurl_to_file(title_dataurl, dst_path=title_png_path)
extra_inputs.append(title_png_path)
logger.info(
"[数字人渲染] 标题 PNG 已保存: %s (input index %d)", title_png_path, title_input_index
)
except Exception as exc:
logger.warning("[数字人渲染] 标题 PNG 解码/保存失败,降级 drawtext: %s", exc)
title_png_path = None
extra_inputs = []
# 构建标题滤镜
final_label = None
if title_png_path and title_input_index is not None:
title_input_label = f"[{title_input_index}:v]"
base_label = f"[{broll_label}]" if broll_label else "[0:v]"
title_filter = build_title_overlay_filter(
title_cfg,
output_width=output_width,
output_height=output_height,
title_png_path=title_png_path,
title_input_label=title_input_label,
base_label=base_label,
output_label="vout_titled",
)
if not title_filter:
# build 返回 None → 文件不存在(极端并发情况),降级 drawtext
title_png_path = None
extra_inputs = []
if title_png_path:
# overlay 路径
if broll_filter and title_filter:
filter_complex = broll_filter + f";{title_filter}"
elif broll_filter:
filter_complex = broll_filter
final_label = broll_label
elif title_filter:
filter_complex = title_filter
else:
filter_complex = ""
if title_filter:
final_label = "vout_titled"
elif not final_label:
final_label = None
else:
# 降级:drawtext 重画文字
title_filter = build_title_drawtext_filter(
title_cfg,
output_width=output_width,
output_height=output_height,
)
if broll_filter and title_filter:
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
cmd_list = self._build_ffmpeg_command(
input_video=input_video_path, input_video=input_video_path,
b_roll_segments=job.b_roll_segments, b_roll_segments=job.b_roll_segments,
extra_inputs=extra_inputs,
filter_complex=filter_complex, filter_complex=filter_complex,
final_label=final_label, final_label=final_label,
output_path=output_video_path, output_path=output_video_path,
) )
try: exit_code = os.system(cmd)
render_result = subprocess.run( if exit_code != 0:
cmd_list, raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
capture_output=True,
text=True,
timeout=600,
)
except subprocess.TimeoutExpired as exc:
raise AiAvatarRenderError(
"FFmpeg 渲染超时(600s",
code="FFmpegTimeout",
) from exc
if render_result.returncode != 0:
stderr_tail = (render_result.stderr or "").strip()[-800:]
raise AiAvatarRenderError(
f"FFmpeg 渲染失败,退出码: {render_result.returncode}, stderr: {stderr_tail}",
code="FFmpegFailed",
)
job.progress = 80 job.progress = 80
self.db.commit() self.db.commit()
# 4/5. 上传成片到 OSS (95%) —— 已砍掉自动抽封面逻辑(步骤⑤); # 4. 提取封面 (90%)
# 封面由前端在渲染完成后通过 /smart-cover 接口主动从成片抽帧,不阻塞渲染链路。 cover_path = ""
if job.cover_config:
cover_path = os.path.join(tmpdir, "cover.jpg")
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
cover_exit = os.system(cover_cmd)
if cover_exit != 0:
logger.warning("封面提取失败,跳过: %s", cover_cmd)
cover_path = ""
job.progress = 90
self.db.commit()
# 5. 上传到 OSS (95%)
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4") output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
job.output_video_url = output_video_url job.output_video_url = output_video_url
# 封面透传:如果用户已在 cover_config 中选定封面 URLmode=upload 的自定义上传 或 # 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧;
# mode=auto_frame 已有的智能封面结果),直接透传到 output_cover_url,不再重新截帧。 # MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
if isinstance(job.cover_config, dict): smart_cover_url = ""
_pre_cover_url = ( if output_video_url:
job.cover_config.get("url") try:
or job.cover_config.get("imageUrl") from app.services.ai_avatar_cover_service import (
or job.cover_config.get("cover_url") generate_smart_cover,
or "" )
)
if _pre_cover_url: smart_cover_url = generate_smart_cover(output_video_url, job_id=job_id, max_frames=5)
job.output_cover_url = _pre_cover_url except Exception:
logger.info("[数字人渲染] 使用用户已选定封面 URL: job_id=%s", job_id) logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
if smart_cover_url:
job.output_cover_url = smart_cover_url
elif cover_path:
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
job.output_cover_url = output_cover_url
# 获取输出视频时长 # 获取输出视频时长
job.output_duration = lipsync_job.output_duration job.output_duration = lipsync_job.output_duration
@@ -399,9 +322,37 @@ class AiAvatarRenderService:
self.db.commit() self.db.commit()
logger.info("渲染任务完成: %s", job_id) logger.info("渲染任务完成: %s", job_id)
# 7. 渲染完成,停留在「待选封面」状态:不自动入库。 # 7. 自动保存成片记录到成片库
# 用户在前端选好封面、点「完成」后,由 /{job_id}/finalize 接口显式入库。 if job.output_video_url:
logger.info("渲染任务完成,等待用户选择封面后入库: job_id=%s", job_id) try:
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain.generated_video import GeneratedVideo
clip_name = f"AI数字人_{job_id[:8]}"
clip = GeneratedVideo.create(
project_id=job.project_id,
generation_task_id=job.lipsync_job_id,
name=clip_name,
file_url=job.output_video_url,
user_id=job.user_id,
duration=job.output_duration or 0.0,
thumbnail_url=job.output_cover_url or None,
generation_params={
"source": "ai_avatar_render",
"render_job_id": job.id,
},
)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
video_repo.create(clip)
logger.info("成片记录已保存到成片库: clip_id=%s, render_job=%s", clip.id, job_id)
except Exception as clip_err:
logger.warning(
"自动保存成片记录失败(不影响渲染任务状态): render_job=%s, error=%s",
job_id,
clip_err,
)
except AiAvatarRenderError as exc: except AiAvatarRenderError as exc:
job.status = "failed" job.status = "failed"
@@ -409,95 +360,12 @@ class AiAvatarRenderService:
job.updated_at = datetime.now(timezone.utc) job.updated_at = datetime.now(timezone.utc)
self.db.commit() self.db.commit()
logger.error("渲染任务失败 [%s]: %s", job_id, exc) logger.error("渲染任务失败 [%s]: %s", job_id, exc)
raise
except Exception as exc: except Exception as exc:
job.status = "failed" job.status = "failed"
job.error_message = f"渲染异常: {str(exc)}" job.error_message = f"渲染异常: {str(exc)}"
job.updated_at = datetime.now(timezone.utc) job.updated_at = datetime.now(timezone.utc)
self.db.commit() self.db.commit()
logger.exception("渲染任务异常 [%s]", job_id) logger.exception("渲染任务异常 [%s]", job_id)
raise
def _persist_to_library(self, job: AiAvatarRenderJob, cover_url: Optional[str] = None):
"""将渲染结果写入成片库,返回 GeneratedVideo 领域对象.
Args:
job: 渲染任务(必须 status=completed 且 output_video_url 非空)
cover_url: 可选的封面 URL 覆盖(finalize 时传入即优先使用,否则取 job.output_cover_url
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
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.id
effective_cover = (cover_url or "").strip() if cover_url else (job.output_cover_url or "").strip()
clip = GeneratedVideo.create(
project_id=clip_project_id,
generation_task_id=clip_generation_task_id,
name=clip_name,
file_url=job.output_video_url,
user_id=job.user_id,
duration=job.output_duration or 0.0,
thumbnail_url=effective_cover or None,
generation_params={
"source": "ai_avatar_render",
"render_job_id": job.id,
},
)
video_repo = SQLAlchemyGeneratedVideoRepository(self.db)
video_repo.create(clip)
logger.info("[数字人渲染] 成片已入库: clip_id=%s render_job=%s", clip.id, job.id)
return clip
def finalize_job(self, job_id: str, user_id: str, cover_url: Optional[str] = None):
"""用户在前端点「完成」后调用:将已 completed 的渲染任务正式入库到成片库.
- 必须 status=completed 才可调用
- cover_url 若传入则优先使用并回写 job.output_cover_url;否则使用 job.output_cover_urlsmart-cover/custom-cover 已写入)
- 幂等:已入库则返回已存在的 GeneratedVideo
"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
job = self.get_render_job(job_id, user_id)
if job is None:
raise AiAvatarRenderError("渲染任务不存在", code="RenderJobNotFound")
if job.status != "completed":
raise AiAvatarRenderError(f"渲染任务未完成(当前状态: {job.status}),无法入库", code="RenderNotCompleted")
if not (job.output_video_url or "").strip():
raise AiAvatarRenderError("渲染成片视频 URL 为空,无法入库", code="OutputVideoMissing")
# 幂等检查:已入库直接返回现有记录(通过 generation_task_id=job_id 识别,
# 因为入库时 generation_task_id 被设置为 render_job_id 自身)
existing = (
self.db.query(GeneratedVideoModel)
.filter(
GeneratedVideoModel.user_id == user_id,
GeneratedVideoModel.generation_task_id == job_id,
)
.first()
)
if existing is not None:
logger.info("[数字人渲染] finalize 幂等命中,返回已存在记录: clip_id=%s job_id=%s", existing.id, job_id)
return SQLAlchemyGeneratedVideoRepository(self.db).get(existing.id)
# 传入 cover_url 时回写到 job
if cover_url and cover_url.strip():
job.output_cover_url = cover_url.strip()
# 同步更新 cover_config,保持 smart-cover 路径一致
if isinstance(job.cover_config, dict):
job.cover_config = {**job.cover_config, "mode": "auto_frame", "url": cover_url.strip()}
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
return self._persist_to_library(job, cover_url=cover_url)
def _download_video(self, url: str) -> str: def _download_video(self, url: str) -> str:
"""下载视频到临时文件.""" """下载视频到临时文件."""
@@ -515,132 +383,32 @@ class AiAvatarRenderService:
os.unlink(tmp.name) os.unlink(tmp.name)
raise raise
@staticmethod
def _save_title_dataurl_to_file(dataurl: str, *, dst_path: str | None = None, job_id: str = "") -> str:
"""解码前端传来的 data:image/png;base64,... 并保存为本地 PNG 文件。
Args:
dataurl: 完整 dataURL 字符串
dst_path: 指定输出路径;为 None 时创建临时文件并返回路径
job_id: 仅在 dst_path 为空时用于临时文件命名
Returns:
保存后的本地文件路径
"""
if not isinstance(dataurl, str) or not dataurl.startswith("data:image/"):
raise ValueError("title_image_dataurl 不是合法的 data:image URL")
# 拆分 data:image/png;base64,<payload>
try:
header, b64 = dataurl.split(",", 1)
except ValueError as exc:
raise ValueError("title_image_dataurl 缺少 base64 payload") from exc
if "base64" not in header:
raise ValueError("title_image_dataurl 不是 base64 编码")
try:
png_bytes = base64.b64decode(b64, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(f"title_image_dataurl base64 解码失败: {exc}") from exc
if not png_bytes:
raise ValueError("title_image_dataurl 解码后为空")
if dst_path:
out_path = dst_path
with open(out_path, "wb") as f:
f.write(png_bytes)
return out_path
suffix = f"_title_{job_id}.png" if job_id else "_title.png"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(png_bytes)
return tmp.name
@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( def _build_ffmpeg_command(
self, self,
*, *,
input_video: str, input_video: str,
b_roll_segments: list[dict[str, Any]], b_roll_segments: list[dict[str, Any]],
extra_inputs: list[str] | None = None,
filter_complex: str, filter_complex: str,
final_label: Optional[str], final_label: Optional[str],
output_path: str, output_path: str,
) -> list[str]: ) -> str:
"""构建 FFmpeg 命令list 形式,shell=False. """构建 FFmpeg 命令."""
# 输入文件
根因修复 #1798 P0OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符, inputs = f"-i {input_video}"
os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被
当成独立命令报 sh: -filter_complex: not foundexit 127 → Python 32512)。
list + shell=False 彻底规避 shell 转义问题。
"""
cmd: list[str] = ["ffmpeg", "-i", input_video]
for seg in b_roll_segments: for seg in b_roll_segments:
asset_url = seg.get("asset_url", "") asset_url = seg.get("asset_url", "")
if asset_url: if asset_url:
cmd.extend(["-i", asset_url]) inputs += f" -i {asset_url}"
# 额外输入(例如前端 Canvas 渲染的标题 PNG)
for extra in extra_inputs or []:
cmd.extend(["-i", extra])
# 滤镜
if filter_complex and final_label: if filter_complex and final_label:
cmd.extend( filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
[
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-map",
"0:a?",
]
)
elif filter_complex: elif filter_complex:
cmd.extend(["-filter_complex", filter_complex]) filter_arg = f'-filter_complex "{filter_complex}"'
else:
filter_arg = ""
cmd.extend( return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset veryfast -crf 23 -y {output_path}"
[
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-y",
output_path,
]
)
return cmd
def _upload_to_oss(self, local_path: str, oss_key: str) -> str: def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
"""上传文件到 OSS,返回 URL. """上传文件到 OSS,返回 URL.
+70 -142
View File
@@ -473,7 +473,6 @@ class EditPlanService:
name_suffix: str = "变体", name_suffix: str = "变体",
voice_duration: float = 0.0, voice_duration: float = 0.0,
rng=None, rng=None,
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
) -> EditPlan: ) -> EditPlan:
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。 """为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
@@ -490,8 +489,6 @@ class EditPlanService:
created_by_user_id: 新 plan 归属用户。 created_by_user_id: 新 plan 归属用户。
name_suffix: plan 名后缀。 name_suffix: plan 名后缀。
rng: 可选随机数(测试注入种子)。 rng: 可选随机数(测试注入种子)。
batch_segments: 可选,外部传入的批次内已使用素材区间(前序变体避让用)。
传入时作为初始避让对象;未传则保持原逻辑从源 plan clips 自建(向后兼容)。
Raises: Raises:
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。 ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
@@ -540,26 +537,16 @@ class EditPlanService:
voice = float(voice_duration or 0.0) voice = float(voice_duration or 0.0)
except (TypeError, ValueError): except (TypeError, ValueError):
voice = 0.0 voice = 0.0
rhythm_template_for_reselect = None
if source.config:
rhythm_template_for_reselect = source.config.get("rhythm_template")
if voice > 0 and source_clips_data: if voice > 0 and source_clips_data:
from packages.domain.voice_duration_planner import plan_clip_durations from packages.domain.voice_duration_planner import plan_clip_durations
_effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data] _effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data]
_tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data] _tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data]
# #1855 P0:先占位durations为空dict,真正查durations在后面pool_ids确定后执行;
# plan_clip_durations 的 asset_durations 参数在该函数中仅作最大段长钳制,
# 这里先不依赖它(durations 还没查),传 None 让planner用默认策略;
# 真正的asset_durations会在后面 clips_data 生成时传入 reselect_clips_for_variant
target_durations = plan_clip_durations( target_durations = plan_clip_durations(
len(source_clips_data), len(source_clips_data),
voice, voice,
transition_effects=_effects, transition_effects=_effects,
transition_durations=_tdurs, transition_durations=_tdurs,
rhythm_template=rhythm_template_for_reselect,
asset_durations=None,
) )
if target_durations: if target_durations:
for _c, _d in zip(source_clips_data, target_durations, strict=False): for _c, _d in zip(source_clips_data, target_durations, strict=False):
@@ -595,18 +582,12 @@ class EditPlanService:
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""), created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
) )
# 批次内区间:外部传入时使用外部传入(含前序变体已用区间); # 批次内区间:以源 plan(变体 0)片段为初始避让对象
# 否则保持原逻辑从源 plan clips 自建(向后兼容) batch_segments: dict[str, list[tuple[float, float]]] = {}
if batch_segments is not None: for c in clips:
batch_segments_resolved: dict[str, list[tuple[float, float]]] = { if c.asset_id and float(c.duration or 0) > 0:
k: list(v) for k, v in batch_segments.items() st = float(c.start_time or 0.0)
} batch_segments.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
else:
batch_segments_resolved = {}
for c in clips:
if c.asset_id and float(c.duration or 0) > 0:
st = float(c.start_time or 0.0)
batch_segments_resolved.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
clips_data = reselect_clips_for_variant( clips_data = reselect_clips_for_variant(
source_clips_data, source_clips_data,
@@ -614,7 +595,7 @@ class EditPlanService:
asset_durations=durations, asset_durations=durations,
asset_scene_points=scene_points, asset_scene_points=scene_points,
historical_used_segments=historical, historical_used_segments=historical,
batch_segments=batch_segments_resolved, batch_segments=batch_segments,
target_durations=target_durations, target_durations=target_durations,
rng=rng, rng=rng,
) )
@@ -786,17 +767,6 @@ class EditPlanService:
if plan is None: if plan is None:
return None return None
# #1855 P0:幂等判断——如果已成功分配过且当前 total_duration 已接近 voice_duration,直接返回
try:
existing_mark = None
if plan.config:
existing_mark = plan.config.get("voice_duration_applied")
cur_total = float(plan.total_duration or 0.0)
if existing_mark is not None and abs(existing_mark - voice) < 1e-6 and abs(cur_total - voice) < 0.5:
return plan
except Exception:
pass
clips: List[EditPlanClip] = [] clips: List[EditPlanClip] = []
skip, page = 0, 500 skip, page = 0, 500
while True: while True:
@@ -868,10 +838,6 @@ class EditPlanService:
) )
try: try:
plan.total_duration = net plan.total_duration = net
# #1855 P0:写入幂等标记,避免二次调用时只重分配 duration 不重算 start_time
new_cfg = dict(plan.config or {})
new_cfg["voice_duration_applied"] = voice
plan.config = new_cfg
db = self._clip_repo.session db = self._clip_repo.session
db.commit() db.commit()
except Exception: except Exception:
@@ -912,69 +878,6 @@ class EditPlanService:
rng = rng or _random.Random() rng = rng or _random.Random()
plan_ids: list[str] = [] plan_ids: list[str] = []
# #1855 P0:先确定片段数 clip_count(用于节奏模板生成长度匹配)
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
from packages.domain.variant_plan_selector import (
generate_pixel_perturbation,
generate_visual_perturbation,
)
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
clip_count = 0
# 从源 plan 获取片段数(分页读,避免关系加载问题)
_sclips: list = []
_sk, _pg = 0, 500
while True:
_b = self._clip_repo.list_by_plan(source_plan_id, skip=_sk, limit=_pg)
if not _b:
break
_sclips.extend(_b)
if len(_b) < _pg:
break
_sk += _pg
clip_count = len(_sclips)
# 预先生成所有 N 个变体的节奏模板/BGM/扰动参数(时机提前到选片前写入config)
rhythm_templates_for_variants: list = []
for _idx in range(count):
if clip_count > 0:
variant_seed = rng.randint(0, 999999)
_tpl = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
rhythm_templates_for_variants.append(_tpl)
else:
rhythm_templates_for_variants.append(None)
source_bgm_config: dict = {}
source_plan = self.get_plan(source_plan_id)
if source_plan and source_plan.config:
source_bgm_config = source_plan.config.get("bgm", {}) or {}
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in range(count)]
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
def _build_variant_config_update(idx: int) -> dict:
"""构建单个变体的 config 更新(节奏模板/BGM/视觉/像素扰动)。"""
upd: dict = {}
try:
perturbation = generate_visual_perturbation(rng)
if idx == 0:
perturbation["hflip"] = False
upd["visual_perturbation"] = perturbation
except Exception:
logger.exception("变体 %d 视觉扰动生成失败(不阻断)", idx)
try:
pixel_pert = generate_pixel_perturbation(rng)
upd["pixel_perturbation"] = pixel_pert
except Exception:
logger.exception("变体 %d 像素扰动生成失败(不阻断)", idx)
rt = rhythm_templates_for_variants[idx] if idx < len(rhythm_templates_for_variants) else None
if rt is not None:
upd["rhythm_template"] = rt
if idx < len(bgm_pool_assignments):
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
existing_bgm.update(bgm_pool_assignments[idx])
upd["bgm"] = existing_bgm
return upd
# 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan # 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan
plan0 = self.clone_plan_for_variant( plan0 = self.clone_plan_for_variant(
source_plan_id, source_plan_id,
@@ -987,15 +890,6 @@ class EditPlanService:
v0_voice = float(voice_durations[0] or 0.0) v0_voice = float(voice_durations[0] or 0.0)
except (TypeError, ValueError): except (TypeError, ValueError):
v0_voice = 0.0 v0_voice = 0.0
# #1855 P0:在配音分配前先写入变体0的节奏模板/扰动/BGM,确保 apply_voice_duration_to_plan 能读到 rhythm_template
try:
_cfg0 = _build_variant_config_update(0)
if _cfg0:
self.update_plan_config(plan0.id, _cfg0)
except Exception:
logger.exception("变体0 配置写入失败(不阻断): plan=%s", plan0.id)
if v0_voice > 0: if v0_voice > 0:
try: try:
self.apply_voice_duration_to_plan(plan0.id, v0_voice) self.apply_voice_duration_to_plan(plan0.id, v0_voice)
@@ -1003,12 +897,7 @@ class EditPlanService:
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id) logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
plan_ids.append(plan0.id) plan_ids.append(plan0.id)
# #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值(公共函数) # 变体 1..N-1:独立选片
from app.services.generation_common import collect_plan_segments as _collect_plan_segments
batch_segments_acc: dict[str, list[tuple[float, float]]] = _collect_plan_segments(plan0.id, self._clip_repo)
# 变体 1..N-1:独立选片(传入累积的 batch_segments 做区间避让)
for i in range(1, count): for i in range(1, count):
voice = 0.0 voice = 0.0
if voice_durations and i < len(voice_durations): if voice_durations and i < len(voice_durations):
@@ -1016,14 +905,6 @@ class EditPlanService:
voice = float(voice_durations[i] or 0.0) voice = float(voice_durations[i] or 0.0)
except (TypeError, ValueError): except (TypeError, ValueError):
voice = 0.0 voice = 0.0
# #1855 P0:在reselect前先为"变体i"准备配置更新——但reselect内部复制的是source.config
# 所以每个变体独立的节奏模板需要在reselect后单独写入config
# 但 plan_clip_durations 用的是 source.config.rhythm_template(即源plan的节奏模板),
# 为了让每个变体在选片阶段就使用自己的节奏模板分配段长,这里采用:
# - reselect 仍使用源 plan 的 rhythm_template(保持片段骨架一致)
# - 选片完成后立即写入该变体自己的 rhythm_template/扰动/BGM 到config
# 后续不再二次 apply_voice_duration_to_plan(由幂等标记跳过)
variant = self.reselect_plan_for_variant( variant = self.reselect_plan_for_variant(
source_plan_id, source_plan_id,
candidate_asset_ids, candidate_asset_ids,
@@ -1031,26 +912,73 @@ class EditPlanService:
name_suffix=f"变体{i + 1}", name_suffix=f"变体{i + 1}",
voice_duration=voice, voice_duration=voice,
rng=rng, rng=rng,
batch_segments=batch_segments_acc,
) )
# 选片完成后写入该变体的独立配置(节奏模板/扰动/BGM)
try:
_cfgi = _build_variant_config_update(i)
if _cfgi:
self.update_plan_config(variant.id, _cfgi)
except Exception:
logger.exception("变体 %d 配置写入失败(不阻断): plan=%s", i, variant.id)
plan_ids.append(variant.id) plan_ids.append(variant.id)
# #1855 P0:把当前新变体的 clips 区间追加到 batch_segments,供下一变体避让 # #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
clip_count = 0
if voice_durations and len(voice_durations) > 0:
# 从源 plan 获取片段数
source_plan = self.get_plan(source_plan_id)
if source_plan and hasattr(source_plan, "clips"):
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
rhythm_templates_for_variants = []
if clip_count > 0:
for idx in range(len(plan_ids)):
# 每个变体用不同的 seed 选择节奏模板
variant_seed = rng.randint(0, 999999)
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
rhythm_templates_for_variants.append(template)
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
# #1767:BGM 池差异化分配(让批量变体使用不同 BGM / 段落 / 音量)
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
source_bgm_config = {}
source_plan = self.get_plan(source_plan_id)
if source_plan and source_plan.config:
source_bgm_config = source_plan.config.get("bgm", {}) or {}
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in plan_ids]
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
from packages.domain.variant_plan_selector import generate_visual_perturbation
for idx, pid in enumerate(plan_ids):
try: try:
_new_segs = _collect_plan_segments(variant.id, self._clip_repo) perturbation = generate_visual_perturbation(rng)
for _aid, _ivs in _new_segs.items(): # 变体 0 不做 hflip(保持预览 plan 原始画面方向)
batch_segments_acc.setdefault(_aid, []).extend(_ivs) if idx == 0:
perturbation["hflip"] = False
config_update = {"visual_perturbation": perturbation}
# #1764:写入节奏模板
if idx < len(rhythm_templates_for_variants):
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
# #1765:写入像素级扰动滤镜
from packages.domain.variant_plan_selector import generate_pixel_perturbation
pixel_pert = generate_pixel_perturbation(rng)
config_update["pixel_perturbation"] = pixel_pert
# #1767:写入 BGM 池分配(覆盖 bgm 配置中的 preset_id / audio_offset / volume_adjust_db
if idx < len(bgm_pool_assignments):
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
existing_bgm.update(bgm_pool_assignments[idx])
config_update["bgm"] = existing_bgm
self.update_plan_config(pid, config_update)
logger.info(
"变体 %d 视觉扰动+像素扰动+BGM池: plan=%s vis=%s pix=%s bgm=%s",
idx,
pid,
perturbation,
pixel_pert,
bgm_pool_assignments[idx] if idx < len(bgm_pool_assignments) else None,
)
except Exception: except Exception:
logger.exception("变体 %d 区间收集失败(不阻断): plan=%s", i, variant.id) logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
# 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready) # 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready)
for pid in plan_ids: for pid in plan_ids:
-175
View File
@@ -1,175 +0,0 @@
"""智能剪辑公共服务辅助函数(从 route 层下沉)。
集中管理:
- query_voice_durations:批量查询配音素材时长
- writeback_edit_plan_config:任务入队后回写 EditPlan.config
- collect_plan_segments:分页读取 plan clips 构建素材区间表(变体避让用)
- resolve_latest_plan_by_template:按 template_id + user_id 查最新 EditPlan
设计原则:
- 无副作用的纯查询 / 幂等写回;失败一律不阻断主流程(记日志 + 返回安全默认值)
- 不依赖 FastAPI / HTTPException,便于 service 层和 worker 复用
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
#1855 P0修复:不再对 voice_ids 去重,保持与调用方传入顺序/长度一致,
允许同配音id多次出现时返回相同时长(支持"同配音N变体"的时长对齐)。
"""
raw_ids = list(voice_ids or [])
if not raw_ids:
return []
unique_ids: list[str] = []
_seen: set[str] = set()
for v in raw_ids:
if v and v not in _seen:
_seen.add(v)
unique_ids.append(v)
if not unique_ids:
return [0.0 for _ in raw_ids]
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(unique_ids)).all()
dur_map: dict[str, float] = {}
for row in rows:
try:
dur_map[row[0]] = float(row[1] or 0.0)
except (TypeError, ValueError):
dur_map[row[0]] = 0.0
return [dur_map.get(v, 0.0) if v else 0.0 for v in raw_ids]
except Exception:
logger.warning("[generation_common] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
return [0.0 for _ in raw_ids]
def writeback_edit_plan_config(
plan_id: str,
task_id: str,
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.configgeneration_task_id + title_config。
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[generation_common] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
if "cover" in merged:
del merged["cover"]
logger.info(
"[generation_common] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id,
old_title_text,
new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
"[generation_common] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[generation_common] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def collect_plan_segments(
plan_id: str,
clip_repo: Any,
*,
page_size: int = 500,
) -> dict[str, list[tuple[float, float]]]:
"""分页读取 plan 所有 clips,构建 {asset_id: [(start, end), ...]} 素材区间表。
用于 #1855 P0 批次内素材区间避让(变体间素材片段重叠控制)。
"""
segs: dict[str, list[tuple[float, float]]] = {}
sk, pg = 0, page_size
while True:
batch = clip_repo.list_by_plan(plan_id, skip=sk, limit=pg)
if not batch:
break
for c in batch:
if c.asset_id and float(c.duration or 0) > 0:
st = float(c.start_time or 0.0)
segs.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
if len(batch) < pg:
break
sk += pg
return segs
def resolve_latest_plan_by_template(
db: Session,
*,
template_id: str,
user_id: str,
) -> Optional[str]:
"""按 template_id + user_id 查找最新的 EditPlan.id(模板兜底用)。找不到返回 None。"""
if not (template_id or "").strip():
return None
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
latest = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == template_id.strip(),
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
return latest.id if latest else None
except Exception:
logger.warning(
"[generation_common] 按template查找最新plan失败: template=%s user=%s",
template_id,
user_id,
exc_info=True,
)
return None
+64 -247
View File
@@ -1,12 +1,8 @@
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整, #1845 配音前置. """对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
职责: 职责:
- 创建/查询对口型任务 - 创建/查询对口型任务
- 输入模式: - 输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url
1. TTS 直生(voice_id + script_text)→ 走 Celery 异步(降级路径)
2. 直接音频(audio_url,前端未传 timings)→ 同步下载 + 算 timings + 提交 MediaKit
3. 预合成音频(audio_url + sentence_timings#1845 新主路径)→ 同步 ffprobe 校验时长 +
写入前端传来的 timings → 直接提交 MediaKit~2-3s
- 调用 MediaKit 客户端提交异步任务 - 调用 MediaKit 客户端提交异步任务
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS) - 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
- 用户隔离(每个用户只能操作自己的任务) - 用户隔离(每个用户只能操作自己的任务)
@@ -30,22 +26,19 @@ from app.services.mediakit_client import (
get_mediakit_client, get_mediakit_client,
) )
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径 # Celery 异步任务:TTS 合成 + MediaKit 提交(#lipsync-speed-optimization
from app.tasks.lipsync_tts import tts_synthesize_and_submit from app.tasks.lipsync_tts import tts_synthesize_and_submit
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError, normalize_emotion from packages.application.cosyvoice_service import CosyVoiceError, normalize_emotion
from packages.domain.sentence_timings import (
compute_sentence_timings,
probe_audio_duration,
)
from packages.shared.storage import get_shared_storage_service from packages.shared.storage import get_shared_storage_service
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 传给 MediaKit GPU worker / 回给前端播放的 OSS 预签名有效期:7 天。 # 传给 MediaKit GPU worker / 回给前端播放的 OSS 预签名有效期:7 天。
# MediaKit 排队 + 拉取可能延迟,私有桶裸 URL 或 1 小时短预签名都会 403,故统一重签长有效期。
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600 MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
@@ -149,100 +142,6 @@ class LipsyncService:
logger.warning("TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc) logger.warning("TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
return temp_url return temp_url
def _submit_audio_direct(
self,
*,
job: LipsyncJobModel,
supplied_timings: Optional[list] = None,
supplied_duration: Optional[float] = None,
) -> None:
"""音频直传模式(包含 #1845 预合成路径):同步下载 → ffprobe → timings → 提交 MediaKit.
直接在 HTTP 请求内完成,不走 Celery。job.status 成功后置为 submitted。
失败时把 job 标成 failed 并 commit,然后抛 MediaKitError。
Args:
job: 已 commit 的 LipsyncJobModelaudio_url / video_url 已写入)
supplied_timings: 前端传来的预合成 timings(可选,可信时直接用)
supplied_duration: 前端传来的预合成时长(可选,用于优先避免重复探测)
"""
# 1. 下载音频
audio_data: bytes | None = None
try:
audio_data = safe_download_bytes(
job.audio_url,
purpose="lipsync_direct_audio",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
logger.info(
"[lipsync] 直传音频下载完成: job_id=%s size=%d",
job.id,
len(audio_data) if audio_data else 0,
)
except Exception as exc:
logger.warning("[lipsync] 直传音频下载失败,跳过 timings 计算: job_id=%s err=%s", job.id, exc)
# 2. ffprobe 探测时长(优先用前端传入的预合成时长,但以 ffprobe 为准做兜底校验)
audio_duration = 0.0
if audio_data:
audio_duration = probe_audio_duration(audio_data)
if audio_duration <= 0 and supplied_duration and supplied_duration > 0:
audio_duration = supplied_duration
logger.info(
"[lipsync] ffprobe 失败,使用前端传入的预合成时长: job_id=%s duration=%.2f", job.id, audio_duration
)
# 3. 句子时间戳:优先用前端预合成传入的 timings(后端预合成接口已经算过,可信);
# 否则若音频下载成功则重算;否则不设置(不阻塞主流程)
timings: Optional[list] = None
if supplied_timings:
timings = supplied_timings
logger.info("[lipsync] 使用前端预合成句子时间戳: job_id=%s sentences=%d", job.id, len(timings))
elif audio_data and audio_duration > 0 and job.script_text:
try:
timings = compute_sentence_timings(audio_data, job.script_text, audio_duration)
logger.info(
"[lipsync] 后端重算句子时间戳: job_id=%s sentences=%d duration=%.2f",
job.id,
len(timings) if timings else 0,
audio_duration,
)
except Exception as exc:
logger.warning("[lipsync] 句子时间戳计算失败(不阻塞): job_id=%s err=%s", job.id, exc)
if timings:
job.sentence_timings = timings
# 4. 签名 URL 并提交 MediaKit
video_url = self._sign_media_url(job.video_url)
signed_audio_url = self._sign_media_url(job.audio_url)
job.audio_url = signed_audio_url
try:
result = self.client.submit_lipsync(
video_url=video_url,
audio_url=signed_audio_url,
enable_video_loop=job.enable_video_loop,
client_token=job.id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
self.db.commit()
logger.info(
"[lipsync] 直传音频已提交 MediaKit: job_id=%s task_id=%s",
job.id,
result["task_id"],
)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("[lipsync] 直传音频提交 MediaKit 失败: job_id=%s err=%s", job.id, exc)
self.db.commit()
raise
# ── 创建任务 ────────────────────────────────────────────────────────── # ── 创建任务 ──────────────────────────────────────────────────────────
def create_job( def create_job(
@@ -251,35 +150,27 @@ class LipsyncService:
user_id: str, user_id: str,
video_url: str, video_url: str,
audio_url: str = "", audio_url: str = "",
audio_duration: Optional[float] = None,
sentence_timings: Optional[list] = None,
voice_id: str = "", voice_id: str = "",
script_text: str = "", script_text: str = "",
speed: float = 1.0, speed: float = 1.0,
emotion: str = "", emotion: str = "",
enable_video_loop: bool = True, enable_video_loop: bool = False,
project_id: str = "", project_id: str = "",
) -> LipsyncJobModel: ) -> LipsyncJobModel:
"""创建对口型任务. """创建对口型任务.
种输入模式: 种输入模式:
- TTS 直生:voice_id + script_textaudio_url 留空) - TTS 直生:voice_id + script_textaudio_url 留空)
→ 创建 DB 记录(状态 tts_processing),dispatch Celery 异步任务(降级路径)。 创建 DB 记录(状态 tts_processing),dispatch Celery 异步任务
API 响应 <1s。 执行 TTS 合成 + MediaKit 提交。API 响应 <1s。
- 直接音频:audio_url 非空 + 无 sentence_timings - 直接音频:提供 audio_url
→ 同步下载音频 + 重算 timings + 提交 MediaKit(几秒完成) → 同步提交 MediaKit,状态直接设为 submitted
- 预合成音频(#1845 新主路径):audio_url 非空 + 传 sentence_timings
→ 同步 ffprobe 校验时长 + 写入 timings + 提交 MediaKit~2-3s)。
Raises: Raises:
MediaKitError: 参数校验失败或 MediaKit 提交失败 MediaKitError: 参数校验失败或 MediaKit 提交失败(仅直接音频模式)
""" """
# 0. 输入校验 # 0. 输入校验
is_pre_synth = bool(audio_url) and bool(sentence_timings) if not audio_url:
bool(audio_url) and not is_pre_synth
is_tts_mode = not bool(audio_url)
if is_tts_mode:
if not (voice_id and script_text): if not (voice_id and script_text):
raise MediaKitError( raise MediaKitError(
"必须提供 audio_url 或 voice_id+script_text", "必须提供 audio_url 或 voice_id+script_text",
@@ -287,13 +178,10 @@ class LipsyncService:
) )
# TTS 模式:在 HTTP 请求中同步校验音色归属,快速失败 # TTS 模式:在 HTTP 请求中同步校验音色归属,快速失败
self._resolve_voice_id(voice_id, user_id) self._resolve_voice_id(voice_id, user_id)
elif is_pre_synth:
# 预合成模式:script_text 可空(因为 timings 已自带句子文本),但仍建议传
if not isinstance(sentence_timings, list) or len(sentence_timings) == 0:
raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput")
# 1. 创建数据库记录 # 1. 创建数据库记录
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
is_tts_mode = not bool(audio_url)
job = LipsyncJobModel( job = LipsyncJobModel(
id=job_id, id=job_id,
user_id=user_id, user_id=user_id,
@@ -304,19 +192,14 @@ class LipsyncService:
voice_id=voice_id or "", voice_id=voice_id or "",
script_text=script_text or "", script_text=script_text or "",
speed=speed, speed=speed,
emotion=normalize_emotion(emotion) if is_tts_mode else (emotion or ""), emotion=normalize_emotion(emotion),
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
status="tts_processing" if is_tts_mode else "pending", status="tts_processing" if is_tts_mode else "pending",
) )
self.db.add(job) self.db.add(job)
self.db.flush() self.db.flush()
# ⚠️ 必须先 commit 再发 Celery 任务 / 后续同步操作,避免事务竞态
self.db.commit()
self.db.refresh(job)
if is_tts_mode: if is_tts_mode:
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交(降级路径) # 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交
try: try:
tts_synthesize_and_submit.apply_async( tts_synthesize_and_submit.apply_async(
args=( args=(
@@ -329,6 +212,8 @@ class LipsyncService:
) )
) )
except Exception as exc: except Exception as exc:
# 投递失败时立即把 job 标成 failed 并写入 error_message
# 前端轮询时能直接看到失败原因,不会无限卡在 tts_processing。
logger.exception( logger.exception(
"Celery 任务提交失败,TTS 任务已创建但未触发执行: job_id=%s err=%s", "Celery 任务提交失败,TTS 任务已创建但未触发执行: job_id=%s err=%s",
job_id, job_id,
@@ -338,102 +223,34 @@ class LipsyncService:
job.error_message = f"Celery 任务投递失败: {exc}" job.error_message = f"Celery 任务投递失败: {exc}"
job.error_code = "AsyncDispatchFailed" job.error_code = "AsyncDispatchFailed"
job.updated_at = datetime.now(timezone.utc) job.updated_at = datetime.now(timezone.utc)
self.db.commit()
else: else:
# 2b/2c. 直接音频 / 预合成音频:同步路径 # 2b. 直接音频模式:同步签名并提交 MediaKit
self._submit_audio_direct( video_url = self._sign_media_url(video_url)
job=job, if audio_url:
supplied_timings=sentence_timings, audio_url = self._sign_media_url(audio_url)
supplied_duration=audio_duration, job.audio_url = audio_url
)
self.db.refresh(job)
try:
result = self.client.submit_lipsync(
video_url=video_url,
audio_url=audio_url,
enable_video_loop=enable_video_loop,
client_token=job_id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(timezone.utc)
except MediaKitError as exc:
job.status = "failed"
job.error_message = str(exc)
job.error_code = exc.code
logger.error("提交对口型任务失败: %s", exc)
raise
self.db.commit()
self.db.refresh(job)
return job return job
# ── TTS 预合成(#1845 步骤1「生成配音」同步接口使用) ──────────────────
def preview_tts(
self,
*,
user_id: str,
voice_id: str,
script_text: str,
speed: float = 1.0,
emotion: str = "natural",
) -> dict:
"""同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算.
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL~24h 有效期)。
耗时约 2-3 秒,由前端在步骤1点「生成配音」时同步等待。
Returns:
{"audio_url": str, "duration": float, "sentence_timings": list[dict]}
Raises:
MediaKitError: TTS 合成失败 / 下载失败 / ffprobe 失败
"""
# 1. 音色解析(校验克隆音色归属)
actual_voice_id = self._resolve_voice_id(voice_id, user_id)
cosyvoice = self._get_cosyvoice()
# 2. TTS 合成(同步,~2-3s
try:
result = cosyvoice.submit_synthesize_task(
text=script_text,
voice_id=actual_voice_id,
speed=speed,
emotion=normalize_emotion(emotion),
)
except CosyVoiceError as exc:
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
except ValueError as exc:
raise MediaKitError(f"TTS 参数错误: {exc}", code="TTSInvalidParam") from exc
temp_url = result.get("audio_url", "")
if not temp_url:
raise MediaKitError("TTS 未返回音频 URL", code="TTSNoAudio")
# 3. 下载音频到内存(用于 ffprobe + 静音检测)
try:
audio_data = safe_download_bytes(
temp_url,
purpose="tts_preview_audio",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
except Exception as exc:
logger.warning("[tts-preview] TTS 音频下载失败,仍返回 audio_url: user_id=%s err=%s", user_id, exc)
return {
"audio_url": temp_url,
"duration": 0.0,
"sentence_timings": [],
}
# 4. ffprobe 时长
duration = probe_audio_duration(audio_data)
if duration <= 0:
logger.warning("[tts-preview] ffprobe 未返回有效时长,timings 留空: user_id=%s", user_id)
return {
"audio_url": temp_url,
"duration": 0.0,
"sentence_timings": [],
}
# 5. 句子时间戳
timings = compute_sentence_timings(audio_data, script_text, duration)
logger.info(
"[tts-preview] TTS 预合成完成: user_id=%s duration=%.2f sentences=%d",
user_id,
duration,
len(timings),
)
return {
"audio_url": temp_url,
"duration": round(duration, 2),
"sentence_timings": timings,
}
# ── 查询任务 ────────────────────────────────────────────────────────── # ── 查询任务 ──────────────────────────────────────────────────────────
def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]: def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
@@ -467,7 +284,11 @@ class LipsyncService:
# ── 更新任务状态(轮询) ────────────────────────────────────────────── # ── 更新任务状态(轮询) ──────────────────────────────────────────────
def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]: def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
"""从 MediaKit 拉取最新状态并更新本地记录.""" """从 MediaKit 拉取最新状态并更新本地记录.
Returns:
更新后的 Job,或 None(任务不存在/不属于该用户)
"""
job = self.get_job(job_id, user_id) job = self.get_job(job_id, user_id)
if job is None: if job is None:
return None return None
@@ -492,25 +313,11 @@ class LipsyncService:
if mk_status == STATUS_COMPLETED: if mk_status == STATUS_COMPLETED:
result = status_data.get("result", {}) result = status_data.get("result", {})
job.status = STATUS_COMPLETED job.status = STATUS_COMPLETED
temp_url = result.get("video_url", "") output_url = result.get("video_url", "")
job.output_video_url = temp_url # MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL)
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
job.output_duration = result.get("duration", 0.0) job.output_duration = result.get("duration", 0.0)
job.completed_at = datetime.now(timezone.utc) job.completed_at = datetime.now(timezone.utc)
job.updated_at = datetime.now(timezone.utc)
self.db.commit()
# 异步转存自家 OSS
try:
from app.tasks.lipsync_tts import persist_output_video_task
persist_output_video_task.apply_async(args=(job_id, user_id, temp_url))
except Exception as exc:
logger.warning(
"提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s",
job_id,
exc,
)
self.db.refresh(job)
return job
elif mk_status == STATUS_FAILED: elif mk_status == STATUS_FAILED:
error = status_data.get("error", {}) error = status_data.get("error", {})
job.status = "failed" job.status = "failed"
@@ -518,6 +325,7 @@ class LipsyncService:
job.error_code = error.get("code", "TaskFailed") job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(timezone.utc) job.completed_at = datetime.now(timezone.utc)
else: else:
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
if isinstance(mk_status, str) and mk_status: if isinstance(mk_status, str) and mk_status:
job.status = mk_status job.status = mk_status
job.updated_at = datetime.now(timezone.utc) job.updated_at = datetime.now(timezone.utc)
@@ -526,7 +334,10 @@ class LipsyncService:
return job return job
def _persist_output_video(self, temp_url: str, job_id: str, user_id: str) -> str: def _persist_output_video(self, temp_url: str, job_id: str, user_id: str) -> str:
"""将 MediaKit 输出的临时视频 URL 转存到自家 OSS. 失败时回退返回原始临时 URL.""" """将 MediaKit 输出的临时视频 URL 转存到自家 OSS.
失败时回退返回原始临时 URL,不影响任务完成。
"""
if not temp_url: if not temp_url:
return "" return ""
try: try:
@@ -546,21 +357,27 @@ class LipsyncService:
return temp_url return temp_url
def _sign_media_url(self, url: str) -> str: def _sign_media_url(self, url: str) -> str:
"""对自家 OSS 私有桶 URL 重签长有效期预签名.""" """对自家 OSS 私有桶 URL 重签长有效期预签名,供 MediaKit 拉取 / 前端播放。
- 裸 public_urlupload_file 返回,不带签名)→ 私有桶匿名访问 403,重签。
- 已带签名但即将过期的 URL(如前端 1h 预签名)→ 抽 storage_key 后重签。
- 外部 URLCosyVoice/MediaKit 临时链接,非本桶 host)→ 原样透传。
- 任何异常都降级原样返回,不阻断主流程。
"""
if not url: if not url:
return url return url
try: try:
storage = get_shared_storage_service() storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "") public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base: if not isinstance(public_base, str) or not public_base:
return url return url # 无法判定归属,保守透传
own_host = urlparse(public_base).netloc.lower() own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower() host = urlparse(url).netloc.lower()
if not own_host or host != own_host: if not own_host or host != own_host:
return url # 外部临时链接原样透传 return url # 非自家 OSS外部临时链接),不处理
signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS) signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
return signed or url return signed or url
except Exception as exc: except Exception as exc: # noqa: BLE001 - 签名失败不阻断,降级原 URL
logger.warning("对口型 URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc) logger.warning("对口型 URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
return url return url
+3 -2
View File
@@ -75,7 +75,7 @@ class MediaKitClient:
*, *,
video_url: str, video_url: str,
audio_url: str, audio_url: str,
enable_video_loop: bool = True, enable_video_loop: bool = False,
callback_url: Optional[str] = None, callback_url: Optional[str] = None,
callback_args: Optional[str] = None, callback_args: Optional[str] = None,
client_token: Optional[str] = None, client_token: Optional[str] = None,
@@ -103,7 +103,8 @@ class MediaKitClient:
"video_url": video_url, "video_url": video_url,
"audio_url": audio_url, "audio_url": audio_url,
} }
payload["enable_video_loop"] = bool(enable_video_loop) if enable_video_loop:
payload["enable_video_loop"] = True
if callback_url: if callback_url:
payload["callback_url"] = callback_url payload["callback_url"] = callback_url
if callback_args: if callback_args:
+15 -148
View File
@@ -12,10 +12,6 @@
注意:使用 @shared_task 而非绑定到某个 celery_app 实例, 注意:使用 @shared_task 而非绑定到某个 celery_app 实例,
确保任务能被 Worker 侧 celery_app 正确注册,同时 API 侧 send_task/apply_async 仍可正常调用。 确保任务能被 Worker 侧 celery_app 正确注册,同时 API 侧 send_task/apply_async 仍可正常调用。
#1845:句子时间戳计算已提取至 packages/domain/sentence_timings.py,本模块保留
_ 开头别名兼容历史导入,但 _compute_sentence_timings/_split_script_into_sentences/
_estimate_sentence_timings_by_chars 等内部函数已复用共享实现,避免重复代码。
""" """
import io import io
@@ -25,12 +21,6 @@ from urllib.parse import urlparse
from celery import shared_task from celery import shared_task
# 复用共享的句子时间戳工具(#1845 配音前置)
from packages.domain.sentence_timings import compute_sentence_timings as _compute_sentence_timings
from packages.domain.sentence_timings import (
probe_audio_duration,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# MediaKit 预签名 URL 有效期(7天,秒),与 LipsyncService._sign_media_url 保持一致 # MediaKit 预签名 URL 有效期(7天,秒),与 LipsyncService._sign_media_url 保持一致
@@ -67,13 +57,8 @@ def _sign_media_url(url: str) -> str:
@shared_task( @shared_task(
bind=True, bind=True,
name="lipsync_tts.synthesize_and_submit", name="lipsync_tts.synthesize_and_submit",
max_retries=5, # 事务竞态重试3次(job not found+ TTS偶发错误2次 max_retries=2,
default_retry_delay=30, default_retry_delay=30,
autoretry_for=(OSError, ConnectionError), # 网络/连接错误自动重试
retry_backoff=True,
retry_backoff_max=30,
soft_time_limit=180,
time_limit=200,
) )
def tts_synthesize_and_submit( def tts_synthesize_and_submit(
self, self,
@@ -86,8 +71,7 @@ def tts_synthesize_and_submit(
): ):
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交. """异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
在 Celery worker 中运行,不阻塞 HTTP 请求。保留作为降级路径 在 Celery worker 中运行,不阻塞 HTTP 请求。
(预合成失败 / 旧版前端未传 audio_url 时走此路径)。
""" """
from app.services.mediakit_client import MediaKitError, get_mediakit_client from app.services.mediakit_client import MediaKitError, get_mediakit_client
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
@@ -119,25 +103,7 @@ def tts_synthesize_and_submit(
) )
if job is None: if job is None:
# 事务竞态防御:API 在 commit 前投递了任务,worker 消费时事务尚未提交。 logger.error("[lipsync_tts] Job not found: job_id=%s", job_id)
retries = getattr(self.request, "retries", 0)
max_retries = 3
if retries < max_retries:
backoff = (2**retries) + (retries * 1) # 1s, 3s, 7s
logger.warning(
"[lipsync_tts] Job not found yet (retry %d/%d, backoff %ds): job_id=%s",
retries + 1,
max_retries,
backoff,
job_id,
)
self.db.close()
raise self.retry(countdown=backoff, max_retries=max_retries)
logger.error(
"[lipsync_tts] Job not found after %d retries, giving up: job_id=%s",
max_retries,
job_id,
)
return return
# 已取消的任务不再处理 # 已取消的任务不再处理
@@ -146,13 +112,6 @@ def tts_synthesize_and_submit(
return return
# 1. TTS 合成 # 1. TTS 合成
logger.info(
"[lipsync_tts] 开始 TTS 合成: job_id=%s voice_id=%s text_len=%d speed=%.2f",
job_id,
voice_id,
len(script_text),
speed,
)
try: try:
cosyvoice = CosyVoiceService() cosyvoice = CosyVoiceService()
result = cosyvoice.submit_synthesize_task( result = cosyvoice.submit_synthesize_task(
@@ -188,74 +147,37 @@ def tts_synthesize_and_submit(
db.commit() db.commit()
return return
# 2. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL # 2. 下载转存自家 OSS
audio_data: bytes | None = None
try: try:
audio_data = safe_download_bytes( audio_data = safe_download_bytes(
temp_url, temp_url,
purpose="lipsync_tts_audio", purpose="lipsync_tts_audio",
allowed_mime_types={ allowed_mime_types=(
"audio/mpeg", "audio/mpeg",
"audio/mp3", "audio/mp3",
"audio/wav", "audio/wav",
"audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav
"audio/mp4", "audio/mp4",
"audio/x-m4a", "audio/x-m4a",
}, ),
timeout=60.0, timeout=60.0,
) )
logger.info( from packages.shared.storage import get_shared_storage_service
"[lipsync_tts] TTS 音频已下载到内存: job_id=%s size=%d",
job_id, storage = get_shared_storage_service()
len(audio_data) if audio_data else 0, storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
) permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
logger.info("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
job.audio_url = permanent_url
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 URL 提交: job_id=%s err=%s", "[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s",
job_id, job_id,
exc, exc,
) )
# TTS 音频使用 CosyVoice 临时 URL,跳过自家 OSS 转存(加速,步骤⑥) job.audio_url = temp_url
job.audio_url = temp_url
logger.info("[lipsync_tts] TTS 音频使用 CosyVoice 临时 URL(跳过 OSS 转存): job_id=%s", job_id)
db.commit() db.commit()
# 2.5 计算精确句子时间戳(基于 TTS 音频静音检测)—— 复用共享工具
try:
if not audio_data:
logger.warning("[lipsync_tts] 无音频数据,跳过句子时间戳计算: job_id=%s", job_id)
else:
_audio_duration = probe_audio_duration(audio_data)
logger.info(
"[lipsync_tts] 音频时长探测: job_id=%s duration=%.2f",
job_id,
_audio_duration,
)
if _audio_duration > 0:
_timings = _compute_sentence_timings(audio_data, script_text, _audio_duration)
if _timings:
job.sentence_timings = _timings
logger.info(
"[lipsync_tts] 句子时间戳已计算: job_id=%s sentences=%d duration=%.1f",
job_id,
len(_timings),
_audio_duration,
)
else:
logger.warning("[lipsync_tts] 句子时间戳计算返回空结果: job_id=%s", job_id)
else:
logger.warning(
"[lipsync_tts] ffprobe 未获取到有效时长,跳过句子时间戳: job_id=%s",
job_id,
)
db.commit()
except Exception as _st_err:
logger.warning(
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
)
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合) # 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
audio_url = _sign_media_url(job.audio_url) audio_url = _sign_media_url(job.audio_url)
video_url = _sign_media_url(job.video_url) video_url = _sign_media_url(job.video_url)
@@ -298,58 +220,3 @@ def tts_synthesize_and_submit(
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id) logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
finally: finally:
db.close() db.close()
@shared_task(
name="lipsync_tts.persist_output_video",
max_retries=2,
default_retry_delay=30,
)
def persist_output_video_task(job_id: str, user_id: str, temp_url: str):
"""异步转存对口型输出视频到自家 OSS(步骤⑦ — 将同步阻塞挪到后台,加速前端响应)."""
try:
from worker_app.db import SessionLocal # type: ignore
except Exception: # noqa: BLE001
from app.db import SessionLocal # type: ignore
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.shared.storage import get_shared_storage_service
db = SessionLocal()
try:
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
if job is None:
logger.error("[lipsync_tts.persist] Job not found: job_id=%s", job_id)
return
if not temp_url:
logger.warning("[lipsync_tts.persist] temp_url 为空,跳过转存: job_id=%s", job_id)
return
try:
import httpx
with httpx.Client(timeout=180.0, follow_redirects=True) as client:
resp = client.get(temp_url)
resp.raise_for_status()
data = resp.content
storage = get_shared_storage_service()
storage_key = f"lipsync-outputs/{user_id}/{job_id}.mp4"
permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4")
final_url = _sign_media_url(permanent_url) if permanent_url else temp_url
job.output_video_url = final_url
job.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
except Exception as exc:
logger.warning(
"[lipsync_tts.persist] 输出视频转存失败,保留临时 URL: job_id=%s err=%s",
job_id,
exc,
)
except Exception:
logger.exception("[lipsync_tts.persist] 未预期异常: job_id=%s", job_id)
finally:
db.close()
@@ -26,10 +26,6 @@ export interface BatchVariantPlansRequest {
count: number count: number
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */ /** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
source_edit_plan_id?: string source_edit_plan_id?: string
/** 统一配音 ID(共用配音模式);独立配音模式不传,改传 voice_library_ids */
voice_library_id?: string
/** 独立配音 ID 列表(长度=count,按变体序号一一对应);共用配音模式不传 */
voice_library_ids?: string[]
} }
/** 单个变体的计划片段 */ /** 单个变体的计划片段 */
@@ -40,8 +36,6 @@ export interface VariantPlan {
plan_id: string plan_id: string
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */ /** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
clips: EditPlanClip[] clips: EditPlanClip[]
/** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */
voice_duration?: number
} }
/** 批量变体计划响应 */ /** 批量变体计划响应 */
+11 -34
View File
@@ -1,6 +1,6 @@
/** /**
* 成品 / 视频相关 API 函数 * 成品 / 视频相关 API 函数
* 后端实际接口:/videos(分页:page/page_size,返回 {items, total, page, page_size} * 后端实际接口:/videos
*/ */
import apiClient from "../client" import apiClient from "../client"
import type { import type {
@@ -12,39 +12,16 @@ import type {
} from "./types" } from "./types"
import { mapVideoToProductItem } from "./utils" import { mapVideoToProductItem } from "./utils"
/** 分页列表响应(前端消费用 */ /** 获取成品列表(支持分页和筛选 */
export interface ProductListResult { export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
items: ProductItem[] const response = await apiClient.get("/videos", { params })
total: number const data = response.data
page: number const videos: VideoItem[] = Array.isArray(data?.items)
page_size: number ? data.items
} : Array.isArray(data)
? data
/** : []
* 获取成品列表(分页) return videos.map(mapVideoToProductItem)
* @param params 分页与筛选参数:page 默认 1page_size 默认 20
*/
export const getProducts = async (params?: ProductListParams): Promise<ProductListResult> => {
const response = await apiClient.get("/videos", {
params: {
page: 1,
page_size: 20,
...params,
},
})
const data = response.data as {
items?: VideoItem[]
total?: number
page?: number
page_size?: number
}
const items: VideoItem[] = Array.isArray(data?.items) ? data.items : []
return {
items: items.map(mapVideoToProductItem),
total: data.total ?? items.length,
page: data.page ?? params?.page ?? 1,
page_size: data.page_size ?? params?.page_size ?? 20,
}
} }
/** 获取单个成品详情 */ /** 获取单个成品详情 */
+1 -33
View File
@@ -552,7 +552,7 @@
max-width: 240px; max-width: 240px;
aspect-ratio: 9/16; aspect-ratio: 9/16;
background: #f0f0f5; background: #f0f0f5;
border-radius: 12px; border-radius: 8px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -564,10 +564,8 @@
.aa-cover-preview img { .aa-cover-preview img {
width: 100%; width: 100%;
height: 100%; height: 100%;
aspect-ratio: 9/16;
object-fit: cover; object-fit: cover;
display: block; display: block;
border-radius: 12px;
} }
.aa-cover-preview__placeholder { .aa-cover-preview__placeholder {
@@ -575,19 +573,6 @@
color: #8c8ca1; 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 { .aa-cover-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -1265,20 +1250,3 @@
width: auto; width: auto;
min-width: 300px; min-width: 300px;
} }
/* 渲染完成后的封面确认区 */
.aa-finalize-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0 8px;
}
.aa-finalize-cover {
width: 100%;
max-width: 240px;
aspect-ratio: 9/16;
border-radius: 12px;
overflow: hidden;
position: relative;
}
+96 -561
View File
@@ -1,7 +1,7 @@
/** /**
* AI数字人 — 主页面(v3 两步骤版 + #1845 配音前置 * AI数字人 — 主页面(v3 两步骤版)
* 步骤1:出镜视频 / 配音库 / 文案 → 点击「🎵 生成配音」做 TTS 预合成(同步,~2-3s) * 步骤1:出镜视频 / 配音库 / 文案
* 步骤2:对口型预览(音频已就绪、B-roll 句子时间戳立即可用)/ 标题配置 / 封面&生成 * 步骤2:对口型预览(含插入画面)/ 标题配置 / 封面&生成
*/ */
import React, { useState, useCallback, useEffect, useRef } from "react" import React, { useState, useCallback, useEffect, useRef } from "react"
import { message } from "antd" import { message } from "antd"
@@ -16,26 +16,20 @@ import PanelTitleConfig from "./components/PanelTitleConfig"
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate" import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
import { ModalAssetPicker } from "./components/ModalAssetPicker" import { ModalAssetPicker } from "./components/ModalAssetPicker"
import ModalBRollEditor from "./components/ModalBRollEditor" import ModalBRollEditor from "./components/ModalBRollEditor"
import ModalCoverSelect from "./components/ModalCoverSelect"
import { import {
getScripts, getScripts,
getAssetById, getAssetById,
createLipsyncJob, createLipsyncJob,
getLipsyncJob, getLipsyncJob,
previewTts,
submitRender, submitRender,
getRenderJob, getRenderJob,
generateRenderSmartCover, generateSmartCover,
finalizeRenderJob,
} from "./api/aiAvatar" } from "./api/aiAvatar"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { RenderJob, SentenceTiming } from "./types"
import { import {
normalizeEmotion, normalizeEmotion,
buildTitleConfigPayload, buildTitleConfigPayload,
buildCoverConfigPayload, buildCoverConfigPayload,
} from "./utils/contract" } from "./utils/contract"
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
/** 面板折叠状态 */ /** 面板折叠状态 */
type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover" type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover"
@@ -53,18 +47,14 @@ const AiAvatarPage: React.FC = () => {
cover: false, cover: false,
}) })
/* ── #1845 TTS 预合成弹窗 ── */
const [showTtsModal, setShowTtsModal] = useState(false)
const [ttsProgress, setTtsProgress] = useState(0)
const [ttsErrorMessage, setTtsErrorMessage] = useState("")
const ttsProgressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* ── 对口型生成弹窗 ── */ /* ── 对口型生成弹窗 ── */
const [showLipsyncModal, setShowLipsyncModal] = useState(false) const [showLipsyncModal, setShowLipsyncModal] = useState(false)
const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">( const [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">(
"generating", "generating",
) )
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("") const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
/* ── 智能封面加载态 ── */
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/* ── 渲染进度弹窗 ── */ /* ── 渲染进度弹窗 ── */
const [showRenderModal, setShowRenderModal] = useState(false) const [showRenderModal, setShowRenderModal] = useState(false)
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">( const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
@@ -72,12 +62,6 @@ const AiAvatarPage: React.FC = () => {
) )
const [renderProgress, setRenderProgress] = useState(0) const [renderProgress, setRenderProgress] = useState(0)
const [renderErrorMessage, setRenderErrorMessage] = useState("") const [renderErrorMessage, setRenderErrorMessage] = useState("")
/* ── 当前渲染任务对象 ── */
const [currentRenderJob, setCurrentRenderJob] = useState<RenderJob | null>(null)
/* ── 封面选择弹窗 ── */
const [showCoverModal, setShowCoverModal] = useState(false)
const [selectedCoverUrl, setSelectedCoverUrl] = useState("")
const [finalizeLoading, setFinalizeLoading] = useState(false)
/* ── 对口型轮询 ── */ /* ── 对口型轮询 ── */
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null) const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -88,29 +72,8 @@ const AiAvatarPage: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })) setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
}, []) }, [])
/* ── #1845 文案/音色/语速变更时重置 TTS 预合成状态,避免音频与文案不一致 ── */ /* ── 步骤切换 ── */
useEffect(() => { const handleNextStep = useCallback(() => {
if (state.ttsPreview.status !== "idle") {
state.resetTtsPreview()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.scriptText, state.selectedVoice?.voice_id, state.speed, state.emotion])
const _clearTtsProgressTimer = useCallback(() => {
if (ttsProgressTimerRef.current) {
clearInterval(ttsProgressTimerRef.current)
ttsProgressTimerRef.current = null
}
}, [])
useEffect(() => {
return () => {
_clearTtsProgressTimer()
}
}, [_clearTtsProgressTimer])
/* ── #1845 步骤1:点击「🎵 生成配音」→ 同步 TTS 预合成 ── */
const handleGenerateTts = useCallback(async () => {
const missing: string[] = [] const missing: string[] = []
if (!state.selectedVideo) missing.push("出镜视频") if (!state.selectedVideo) missing.push("出镜视频")
if (!state.selectedVoice) missing.push("配音") if (!state.selectedVoice) missing.push("配音")
@@ -119,115 +82,45 @@ const AiAvatarPage: React.FC = () => {
message.warning(`请先完成${missing.join("、")}`) message.warning(`请先完成${missing.join("、")}`)
return return
} }
// 打开弹窗 & 启动模拟进度条
setShowTtsModal(true)
setTtsProgress(0)
setTtsErrorMessage("")
state.setTtsPreview({
audioUrl: null,
duration: 0,
sentenceTimings: [],
status: "generating",
error: null,
})
// 模拟进度:每 300ms +10%,到 90% 停住,真完成后瞬间到 100%
_clearTtsProgressTimer()
let fake = 0
ttsProgressTimerRef.current = setInterval(() => {
fake = Math.min(fake + 10, 90)
setTtsProgress(fake)
if (fake >= 90) {
_clearTtsProgressTimer()
}
}, 300)
try {
const res = await previewTts({
voice_id: state.selectedVoice!.voice_id,
script_text: state.scriptText,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
})
_clearTtsProgressTimer()
setTtsProgress(100)
state.setTtsPreview({
audioUrl: res.audio_url,
duration: res.duration,
sentenceTimings: res.sentence_timings as SentenceTiming[],
status: "done",
error: null,
})
message.success("配音合成完成")
} catch (err) {
_clearTtsProgressTimer()
const errMsg =
(err as { response?: { data?: { message?: string; detail?: unknown } } })?.response?.data
?.message || (err instanceof Error ? err.message : "配音合成失败,请重试")
setTtsErrorMessage(typeof errMsg === "string" ? errMsg : "配音合成失败,请重试")
state.setTtsPreview({
audioUrl: null,
duration: 0,
sentenceTimings: [],
status: "failed",
error: typeof errMsg === "string" ? errMsg : "配音合成失败",
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
const handleRetryTts = useCallback(() => {
handleGenerateTts()
}, [handleGenerateTts])
const handleTtsNext = useCallback(() => {
setShowTtsModal(false)
setTtsProgress(0)
setCurrentStep(2) setCurrentStep(2)
}, []) }, [state.selectedVideo, state.selectedVoice, state.scriptText])
const handleCancelTts = useCallback(() => {
_clearTtsProgressTimer()
setShowTtsModal(false)
setTtsProgress(0)
setTtsErrorMessage("")
// 若用户在生成中途关闭,把状态重置回 idle,允许重新点击
if (state.ttsPreview.status === "generating") {
state.resetTtsPreview()
}
}, [_clearTtsProgressTimer, state])
/* ── 上一步(返回步骤1,不会丢失 TTS 预合成结果) ── */
const handlePrevStep = useCallback(() => { const handlePrevStep = useCallback(() => {
setCurrentStep(1) setCurrentStep(1)
}, []) }, [])
/* ── 对口型 ── */ /* ── 对口型 ── */
const handleGenerateLipsync = useCallback(async () => { const handleGenerateLipsync = useCallback(async () => {
// ② 缺项明确提示(#1809):不再静默 return
const video = state.selectedVideo const video = state.selectedVideo
const voice = state.selectedVoice
const text = state.scriptText.trim() const text = state.scriptText.trim()
const missing: string[] = [] const missing: string[] = []
if (!video) missing.push("出镜视频") if (!video) missing.push("出镜视频")
if (!voice) missing.push("音色")
if (!text) missing.push("文案") if (!text) missing.push("文案")
if (missing.length > 0 || !video) { if (missing.length > 0 || !video || !voice) {
message.warning(`请先选择${missing.join("、")}`) message.warning(`请先选择${missing.join("、")}`)
return return
} }
// #1845:预合成模式下必须要有 audioUrl(理论上到了步骤2肯定有,兜底防御)
const isPreSynth = state.ttsPreview.status === "done" && !!state.ttsPreview.audioUrl
if (!isPreSynth && !state.selectedVoice) {
message.warning("请先选择音色或完成配音合成")
return
}
try { try {
// 显示生成弹窗
setShowLipsyncModal(true) setShowLipsyncModal(true)
setLipsyncStatus("generating") setLipsyncStatus("generating")
setLipsyncErrorMessage("") setLipsyncErrorMessage("")
// ① 先按素材 id 拿 file_url(#1809 补充:对齐后端新参数 video_url)
console.log("[对口型] 开始生成:", {
videoId: video.id,
voiceId: voice.voice_id,
voiceType: voice.type,
textLen: state.scriptText.length,
})
const asset = await getAssetById(video.id) const asset = await getAssetById(video.id)
console.log("[对口型] getAssetById 响应:", {
id: asset?.id,
file_url: asset?.file_url?.substring(0, 100),
})
const videoUrl = asset?.file_url const videoUrl = asset?.file_url
if (!videoUrl) { if (!videoUrl) {
console.error("[对口型] file_url 为空,asset:", asset) console.error("[对口型] file_url 为空,asset:", asset)
@@ -235,38 +128,29 @@ const AiAvatarPage: React.FC = () => {
message.error("获取出镜视频播放地址失败,请重新选择素材") message.error("获取出镜视频播放地址失败,请重新选择素材")
return return
} }
// ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822)
type LipsyncPayload = Parameters<typeof createLipsyncJob>[0] const payload = {
let payload: LipsyncPayload voice_id: voice.voice_id,
if (isPreSynth) { script_text: state.scriptText,
// 预合成模式:传 audio_url + audio_duration + sentence_timings(后端直接提交 MediaKit~2-3s video_url: videoUrl,
payload = { speed: state.speed, // 语速 0.5~2.0
video_url: videoUrl, emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly
audio_url: state.ttsPreview.audioUrl!,
audio_duration: state.ttsPreview.duration,
sentence_timings: state.ttsPreview.sentenceTimings,
enable_video_loop: true,
}
} else {
// 降级:TTS 直生(旧路径,前端未预合成时)
payload = {
voice_id: state.selectedVoice!.voice_id,
script_text: state.scriptText,
video_url: videoUrl,
speed: state.speed,
emotion: normalizeEmotion(state.emotion),
}
} }
console.log("[对口型] createLipsyncJob 请求:", payload)
const job = await createLipsyncJob(payload) const job = await createLipsyncJob(payload)
console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status })
state.setLipsyncJob(job) state.setLipsyncJob(job)
// 开始轮询
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
// 但仍需轮询等 completed
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
lipsyncTimerRef.current = setInterval(async () => { lipsyncTimerRef.current = setInterval(async () => {
try { try {
const updated = await getLipsyncJob(job.id) const updated = await getLipsyncJob(job.id)
state.setLipsyncJob(updated) state.setLipsyncJob(updated)
console.log("[对口型] 轮询状态:", {
id: updated.id,
status: updated.status,
error: updated.error_message,
})
if (updated.status === "completed") { if (updated.status === "completed") {
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current) if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
setLipsyncStatus("completed") setLipsyncStatus("completed")
@@ -293,14 +177,7 @@ const AiAvatarPage: React.FC = () => {
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试") message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
state.selectedVideo,
state.selectedVoice,
state.scriptText,
state.speed,
state.emotion,
state.ttsPreview,
])
// 取消对口型生成 // 取消对口型生成
const handleCancelLipsync = useCallback(() => { const handleCancelLipsync = useCallback(() => {
@@ -321,14 +198,6 @@ const AiAvatarPage: React.FC = () => {
} }
}, []) }, [])
/* ── B-roll 弹窗可用的句子时间戳:优先 lipsyncJob.sentence_timings,否则用 ttsPreview.sentenceTimings ── */
const bRollSentenceTimings: SentenceTiming[] | undefined =
(state.lipsyncJob?.sentence_timings as SentenceTiming[] | undefined) ??
(state.ttsPreview.status === "done" ? state.ttsPreview.sentenceTimings : undefined)
/* ── B-roll 可用的总时长:优先 lipsyncJob.output_duration,否则用 ttsPreview.duration ── */
const bRollDuration = state.lipsyncJob?.output_duration || state.ttsPreview.duration || 0
/* ── 生成视频(含实时进度轮询) ── */ /* ── 生成视频(含实时进度轮询) ── */
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") { if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
@@ -337,28 +206,9 @@ const AiAvatarPage: React.FC = () => {
} }
state.setIsGenerating(true) state.setIsGenerating(true)
try { try {
const defaultProject = await getOrCreateDefaultProject()
// 用 Canvas 预渲染标题为 PNG dataURL
let titleImageDataUrl: string | null = null
if (state.titleConfig.title?.trim()) {
try {
const res = await getVideoResolution(state.lipsyncJob.output_video_url || "")
titleImageDataUrl = renderTitleToPngDataUrl({
titleConfig: state.titleConfig,
videoWidth: res.width,
videoHeight: res.height,
})
} catch (canvasErr) {
console.warn("[渲染] 标题 Canvas 渲染失败,降级 drawtext:", canvasErr)
titleImageDataUrl = null
}
}
const job = await submitRender({ const job = await submitRender({
lipsync_job_id: state.lipsyncJob.id, lipsync_job_id: state.lipsyncJob.id,
script_id: state.script?.id, script_id: state.script?.id,
project_id: defaultProject.id,
b_roll_segments: state.bRollSegments.map((seg) => ({ b_roll_segments: state.bRollSegments.map((seg) => ({
script_segment_index: seg.script_segment_index, script_segment_index: seg.script_segment_index,
asset_url: seg.asset.file_url || "", asset_url: seg.asset.file_url || "",
@@ -368,45 +218,26 @@ const AiAvatarPage: React.FC = () => {
pip_position: seg.pip_position, pip_position: seg.pip_position,
pip_scale: seg.pip_scale, pip_scale: seg.pip_scale,
})) as never, })) as never,
title_config: buildTitleConfigPayload(state.titleConfig, titleImageDataUrl), title_config: buildTitleConfigPayload(state.titleConfig),
cover_config: cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
state.coverConfig.smart_cover_url ||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
? buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url)
: {},
}) })
// 打开渲染进度弹窗,启动轮询
setShowRenderModal(true) setShowRenderModal(true)
setRenderStatus("generating") setRenderStatus("generating")
setRenderProgress(job.progress ?? 0) setRenderProgress(job.progress ?? 0)
setRenderErrorMessage("") setRenderErrorMessage("")
setCurrentRenderJob(job as RenderJob)
// 每次新渲染重置封面状态
setSelectedCoverUrl("")
setShowCoverModal(false)
if (renderTimerRef.current) clearInterval(renderTimerRef.current) if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = setInterval(async () => { renderTimerRef.current = setInterval(async () => {
try { try {
const updated = await getRenderJob(job.id) const updated = await getRenderJob(job.id)
setRenderProgress(updated.progress ?? 0) setRenderProgress(updated.progress ?? 0)
setCurrentRenderJob(updated)
if (updated.status === "completed") { if (updated.status === "completed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current) if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null renderTimerRef.current = null
setRenderStatus("completed") setRenderStatus("completed")
// 不再自动入库/自动跳转:渲染完成后停留在主页面,等用户选封面、点「完成」才入库 message.success("视频已生成并保存到成片库")
// 如果后端在透传时已经带了封面(旧逻辑兜底),同步本地状态
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,
}))
setSelectedCoverUrl(updated.output_cover_url)
}
message.success("视频生成完成,请选择封面")
} else if (updated.status === "failed") { } else if (updated.status === "failed") {
if (renderTimerRef.current) clearInterval(renderTimerRef.current) if (renderTimerRef.current) clearInterval(renderTimerRef.current)
renderTimerRef.current = null renderTimerRef.current = null
@@ -438,109 +269,38 @@ const AiAvatarPage: React.FC = () => {
setRenderErrorMessage("") setRenderErrorMessage("")
}, []) }, [])
/* ── 智能封面 ── */ /* ── 智能封面:调后端 MediaKit 选帧接口(#1822 ── */
const handleGenerateRenderSmartCover = useCallback( const handleSmartCover = useCallback(async () => {
async (renderId: string): Promise<{ cover_url: string; message?: string }> => { // 基于对口型成片抽帧,必须先完成对口型
try { const videoUrl = state.lipsyncJob?.output_video_url
const res = await generateRenderSmartCover(renderId) if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
if (res.cover_url) { message.warning("请先生成对口型视频,完成后再智能获取封面")
state.setCoverConfig((prev) => ({
...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
return { cover_url: res.cover_url }
}
const errMsg = res.message || "智能封面生成失败,请稍后重试"
message.error(errMsg)
return { cover_url: "", message: errMsg }
} catch (err) {
console.error("智能封面生成失败:", err)
const errMsg = err instanceof Error ? err.message : "智能封面生成失败,请重试"
message.error(errMsg)
return { cover_url: "", message: errMsg }
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
/* ── 封面弹窗回调 ── */
const handleCoverSelected = useCallback((coverUrl: string) => {
setSelectedCoverUrl(coverUrl || "")
}, [])
const handleOpenCoverModal = useCallback(() => {
if (currentRenderJob?.status !== "completed") {
message.warning("请先完成视频生成")
return return
} }
setShowCoverModal(true) setSmartCoverLoading(true)
}, [currentRenderJob])
const handleCloseCoverModal = useCallback(() => {
setShowCoverModal(false)
}, [])
/* ── 自定义上传封面(本地预览,不单独上传;点完成时一起入库) ── */
const handleUploadCover = useCallback(
(file: File) => {
const url = URL.createObjectURL(file)
state.setCoverConfig((prev) => ({
...prev,
mode: "upload",
upload_url: url,
thumbnail_url: url,
}))
setSelectedCoverUrl(url)
},
[state],
)
/* ── 点「完成」:调用 finalize 入库成片库,成功后跳转到成片库 ── */
const handleFinalize = useCallback(async () => {
if (!currentRenderJob?.id) {
message.error("渲染任务不存在")
return
}
if (currentRenderJob.status !== "completed") {
message.warning("请先完成视频生成")
return
}
setFinalizeLoading(true)
try { try {
const res = await finalizeRenderJob(currentRenderJob.id) const res = await generateSmartCover(videoUrl, 5)
if (res.data?.status === "success" || res.data?.status === "already_finalized") { if (res.cover_url) {
message.success("已保存到成片库") state.setCoverConfig((prev) => ({
navigate("/app/products") ...prev,
mode: "auto_frame",
smart_cover_url: res.cover_url,
thumbnail_url: res.cover_url,
}))
message.success("智能封面已生成")
} else { } else {
message.error("保存失败,请重试") message.error(res.message || "智能封面生成失败,请稍后重试")
} }
} catch (err) { } catch (err) {
console.error("finalize 失败:", err) console.error("智能封面生成失败:", err)
const errMsg = message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
(err as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail ||
(err instanceof Error ? err.message : "保存到成片库失败")
message.error(typeof errMsg === "string" ? errMsg : "保存到成片库失败")
} finally { } finally {
setFinalizeLoading(false) setSmartCoverLoading(false)
} }
}, [currentRenderJob, navigate]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.lipsyncJob])
/* ── 配置汇总 ── */ /* ── 配置汇总 ── */
const coverStatus: "not_ready" | "pending" | "selected" = (() => {
if (
state.coverConfig.smart_cover_url ||
state.coverConfig.thumbnail_url ||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
) {
return "selected"
}
if (currentRenderJob?.status === "completed") return "pending"
return "not_ready"
})()
const summary = { const summary = {
videoName: state.selectedVideo?.name || null, videoName: state.selectedVideo?.name || null,
voiceName: state.selectedVoice?.name || null, voiceName: state.selectedVoice?.name || null,
@@ -548,7 +308,7 @@ const AiAvatarPage: React.FC = () => {
lipsyncStatus: state.lipsyncJob?.status || null, lipsyncStatus: state.lipsyncJob?.status || null,
brollCount: state.bRollSegments.length, brollCount: state.bRollSegments.length,
hasTitle: state.titleConfig.title.length > 0, hasTitle: state.titleConfig.title.length > 0,
coverStatus, hasCover: state.coverConfig.enabled,
} }
return ( return (
@@ -582,6 +342,7 @@ const AiAvatarPage: React.FC = () => {
selectedVideo={state.selectedVideo} selectedVideo={state.selectedVideo}
onSelectVideo={() => state.setShowAssetPicker(true)} onSelectVideo={() => state.setShowAssetPicker(true)}
onRemoveVideo={state.removeVideo} onRemoveVideo={state.removeVideo}
titleConfig={state.titleConfig}
/> />
</div> </div>
</div> </div>
@@ -621,33 +382,19 @@ const AiAvatarPage: React.FC = () => {
onOpenScriptModal={() => state.setShowScriptModal(true)} onOpenScriptModal={() => state.setShowScriptModal(true)}
/> />
<div className="aa-step-btn-row"> <div className="aa-step-btn-row">
<button <button type="button" className="aa-btn aa-btn--primary" onClick={handleNextStep}>
type="button"
className="aa-btn aa-btn--primary"
onClick={handleGenerateTts}
disabled={state.ttsPreview.status === "generating"}
>
{state.ttsPreview.status === "done" ? "🎵 重新生成配音" : "🎵 生成配音"}
</button> </button>
{state.ttsPreview.status === "done" && (
<button
type="button"
className="aa-btn aa-btn--primary"
onClick={() => setCurrentStep(2)}
style={{ marginLeft: 12 }}
>
</button>
)}
</div> </div>
</div> </div>
</div> </div>
</> </>
)} )}
{/* ════ 步骤 2:对口型预览 / 标题配置 / 封面&生成 ════ */} {/* ════ 步骤 2:对口型预览(含插入画面)/ 标题配置 / 封面&生成 ════ */}
{currentStep === 2 && ( {currentStep === 2 && (
<> <>
{/* 面板:对口型预览 + 插入画面 */}
<div className={`aa-panel aa-panel--s2-wide${collapsed.lipsync ? " collapsed" : ""}`}> <div className={`aa-panel aa-panel--s2-wide${collapsed.lipsync ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("lipsync")}> <div className="aa-panel__header" onClick={() => togglePanel("lipsync")}>
<span className="aa-panel__title"></span> <span className="aa-panel__title"></span>
@@ -685,98 +432,27 @@ const AiAvatarPage: React.FC = () => {
</div> </div>
</div> </div>
{/* 面板5:封面 & 生成 {/* 面板5:封面 & 生成 */}
- 渲染未完成:显示分辨率/配置摘要/「开始生成」按钮(PanelCoverAndGenerate setup 变体,无封面区)
- 渲染完成:显示封面预览 + 「🎬 选择封面」/「✅ 完成」按钮,封面选择在弹窗中完成 */}
<div className={`aa-panel aa-panel--s2${collapsed.cover ? " collapsed" : ""}`}> <div className={`aa-panel aa-panel--s2${collapsed.cover ? " collapsed" : ""}`}>
<div className="aa-panel__header" onClick={() => togglePanel("cover")}> <div className="aa-panel__header" onClick={() => togglePanel("cover")}>
<span className="aa-panel__title"> <span className="aa-panel__title"> & </span>
{currentRenderJob?.status === "completed" ? "视频已生成" : "封面 & 生成"}
</span>
<span className="aa-panel__toggle"></span> <span className="aa-panel__toggle"></span>
</div> </div>
<div className="aa-panel__body"> <div className="aa-panel__body">
{currentRenderJob?.status !== "completed" ? ( <PanelCoverAndGenerate
<PanelCoverAndGenerate coverConfig={state.coverConfig}
variant="setup" onCoverConfigChange={(partial) =>
coverConfig={state.coverConfig} state.setCoverConfig((prev) => ({ ...prev, ...partial }))
onCoverConfigChange={(partial) => }
state.setCoverConfig((prev) => ({ ...prev, ...partial })) onSmartCover={handleSmartCover}
} smartCoverLoading={smartCoverLoading}
renderJob={currentRenderJob} canSmartCover={state.lipsyncJob?.status === "completed"}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover} resolution={state.resolution}
resolution={state.resolution} onResolutionChange={state.setResolution}
onResolutionChange={state.setResolution} isGenerating={state.isGenerating}
isGenerating={state.isGenerating} onGenerate={handleGenerate}
onGenerate={handleGenerate} summary={summary}
summary={summary} />
/>
) : (
<div className="aa-finalize-section">
<div
style={{ marginBottom: 8, fontSize: 13, color: "#1a1a2e", fontWeight: 500 }}
>
</div>
<div className="aa-finalize-cover">
{selectedCoverUrl ? (
<img
src={selectedCoverUrl}
alt="封面预览"
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: 8,
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
border: "2px dashed #d9d9d9",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#8c8ca1",
fontSize: 12,
flexDirection: "column",
gap: 4,
}}
>
<span style={{ fontSize: 24 }}>🎬</span>
<span></span>
</div>
)}
</div>
<div
style={{
display: "flex",
gap: 10,
marginTop: 12,
}}
>
<button
type="button"
className="aa-btn aa-btn--ghost"
onClick={handleOpenCoverModal}
>
🎬
</button>
<button
type="button"
className="aa-btn aa-btn--primary"
onClick={handleFinalize}
disabled={finalizeLoading}
>
{finalizeLoading ? "⏳ 保存中..." : "✅ 完成"}
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
</> </>
@@ -802,144 +478,19 @@ const AiAvatarPage: React.FC = () => {
/> />
)} )}
{/* B-roll 编辑器弹窗 — #1845:timings 在对口型完成前就可用(来自 TTS 预合成) */} {/* B-roll 编辑器弹窗 */}
{state.showBRollModal && ( {state.showBRollModal && (
<ModalBRollEditor <ModalBRollEditor
open={state.showBRollModal} open={state.showBRollModal}
onClose={() => state.setShowBRollModal(false)} onClose={() => state.setShowBRollModal(false)}
existingSegments={state.bRollSegments} existingSegments={state.bRollSegments}
scriptText={state.lipsyncJob?.script_text || state.scriptText} scriptText={state.scriptText}
outputDuration={bRollDuration} outputDuration={state.lipsyncJob?.output_duration ?? 0}
sentenceTimings={bRollSentenceTimings}
onConfirm={state.addBRollSegment} onConfirm={state.addBRollSegment}
onRemove={state.removeBRollSegment} onRemove={state.removeBRollSegment}
/> />
)} )}
{/* #1845 TTS 预合成弹窗 */}
{showTtsModal && (
<div className="aa-modal-overlay">
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
{state.ttsPreview.status !== "generating" && (
<button className="aa-modal__close" onClick={handleCancelTts}>
</button>
)}
</div>
<div
className="aa-modal__body"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "40px 20px",
}}
>
{state.ttsPreview.status === "generating" && (
<>
<div className="aa-lipsync-spinner" />
<div style={{ marginTop: 20, fontSize: 15, color: "#1a1a2e" }}>
</div>
<div
style={{
marginTop: 20,
fontSize: 32,
fontWeight: 700,
color: "#1890ff",
}}
>
{ttsProgress}%
</div>
<div
style={{
marginTop: 12,
width: "80%",
height: 8,
backgroundColor: "#f0f0f0",
borderRadius: 4,
overflow: "hidden",
}}
>
<div
style={{
width: `${ttsProgress}%`,
height: "100%",
backgroundColor: "#1890ff",
borderRadius: 4,
transition: "width 0.3s ease",
}}
/>
</div>
<div style={{ marginTop: 12, fontSize: 13, color: "#8c8ca1" }}>
</div>
</>
)}
{state.ttsPreview.status === "done" && (
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
</div>
<div style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1" }}>
{state.ttsPreview.duration.toFixed(1)}s{" "}
{state.ttsPreview.sentenceTimings.length}
</div>
</>
)}
{state.ttsPreview.status === "failed" && (
<>
<div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}></div>
{ttsErrorMessage && (
<div
style={{
marginTop: 8,
fontSize: 13,
color: "#ff4d4f",
textAlign: "center",
padding: "0 20px",
}}
>
{ttsErrorMessage}
</div>
)}
</>
)}
</div>
<div className="aa-modal__footer">
{state.ttsPreview.status === "generating" && (
<button className="aa-btn aa-btn--danger" onClick={handleCancelTts}>
</button>
)}
{state.ttsPreview.status === "done" && (
<button className="aa-btn aa-btn--primary" onClick={handleTtsNext}>
</button>
)}
{state.ttsPreview.status === "failed" && (
<>
<button className="aa-btn" onClick={handleCancelTts}>
</button>
<button
className="aa-btn aa-btn--primary"
onClick={handleRetryTts}
style={{ marginLeft: 12 }}
>
</button>
</>
)}
</div>
</div>
</div>
)}
{/* 对口型生成弹窗 */} {/* 对口型生成弹窗 */}
{showLipsyncModal && ( {showLipsyncModal && (
<div className="aa-modal-overlay"> <div className="aa-modal-overlay">
@@ -1072,21 +623,17 @@ const AiAvatarPage: React.FC = () => {
<> <>
<div style={{ fontSize: 48 }}></div> <div style={{ fontSize: 48 }}></div>
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}> <div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
</div>
<div
style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1", textAlign: "center" }}
>
</div> </div>
<button <button
className="aa-btn aa-btn--primary" className="aa-btn"
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
onClick={() => { onClick={() => {
setShowRenderModal(false) setShowRenderModal(false)
navigate("/app/products")
}} }}
> >
🎬 📁
</button> </button>
</> </>
)} )}
@@ -1117,18 +664,6 @@ const AiAvatarPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* 封面选择弹窗 */}
<ModalCoverSelect
open={showCoverModal}
onClose={handleCloseCoverModal}
renderJob={currentRenderJob}
coverConfig={state.coverConfig}
onCoverConfigChange={(partial) => state.setCoverConfig((prev) => ({ ...prev, ...partial }))}
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
onUploadCover={handleUploadCover}
onCoverSelected={handleCoverSelected}
/>
</div> </div>
) )
} }
+22 -59
View File
@@ -2,7 +2,7 @@
* AI数字人 — API 封装(#1822 契约对齐) * AI数字人 — API 封装(#1822 契约对齐)
*/ */
import apiClient from "@/api/client" import apiClient from "@/api/client"
import type { Script, LipsyncJob, RenderJob, BRollSegment, SentenceTiming } from "../types" import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types"
/* ── 文案库 ── */ /* ── 文案库 ── */
export const getScripts = async (): Promise<Script[]> => { export const getScripts = async (): Promise<Script[]> => {
@@ -34,28 +34,17 @@ export const getAssetById = async (id: string): Promise<{ file_url?: string; id:
return response.data return response.data
} }
/* ── 对口型(支持三种模式) ── /* ── 对口型(模式A:TTS 直生,后端内部合成音频;不要先调 TTS 拿 audio_url ── */
* 1. TTS 直生(降级/旧版):传 voice_id + script_text+speed/emotion),后端 Celery 异步合成
* 2. 直接音频:传 video_url + audio_url,后端同步下载+算timings+提交MediaKit
* 3. 预合成音频(#1845 新主路径):先调 previewTts 拿 audio_url+sentence_timings
* 再把 audio_url + audio_duration + sentence_timings 一起传过来,后端直接提交 MediaKit
*/
export const createLipsyncJob = async (data: { export const createLipsyncJob = async (data: {
/** 人物视频 URLMP4);由素材 id 经 getAssetById 拿 file_url */ /** 人物视频 URLMP4);由素材 id 经 getAssetById 拿 file_url,禁止传 video_asset_id */
video_url: string video_url: string
/** 预合成/直接音频模式:音频 URL(#1845 步骤1 预合成的 CosyVoice 临时 URL,或外部音频 URL */ /** 音色 ID(预置音色 或 克隆音色 profile UUID,后端会解析 */
audio_url?: string voice_id: string
/** 合成音频时长(秒),由 previewTts 返回 */ /** 合成的文案(手动输入或文案库内容) */
audio_duration?: number script_text: string
/** 预合成接口返回的句子时间戳(精确),后端直接写入 job */ /** 语速 0.5~2.0,默认 1.0 */
sentence_timings?: SentenceTiming[]
/** 音色 IDTTS 直生模式用) */
voice_id?: string
/** 要合成的文案(TTS 直生模式用) */
script_text?: string
/** 语速 0.5~2.0,默认 1.0TTS 直生模式用) */
speed?: number speed?: number
/** 情绪英文枚举:natural/excited/calm/friendlyTTS 直生模式用) */ /** 情绪英文枚举:natural/excited/calm/friendly */
emotion?: string emotion?: string
enable_video_loop?: boolean enable_video_loop?: boolean
project_id?: string project_id?: string
@@ -64,27 +53,21 @@ export const createLipsyncJob = async (data: {
return response.data return response.data
} }
/* ── #1845 TTS 预合成(步骤1「生成配音」同步接口,~2-3s) ── */ export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
export const previewTts = async (data: { const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
voice_id: string
script_text: string
speed?: number
emotion?: string
}): Promise<{
audio_url: string
duration: number
sentence_timings: SentenceTiming[]
}> => {
const response = await apiClient.post<{
audio_url: string
duration: number
sentence_timings: SentenceTiming[]
}>("/lipsync/tts-preview", data, { timeout: 30000 })
return response.data return response.data
} }
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => { /* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧,独立于渲染任务) ── */
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 }) export const generateSmartCover = async (
video_url: string,
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 },
{ timeout: 60000 },
)
return response.data return response.data
} }
@@ -97,35 +80,15 @@ export const submitRender = async (data: {
cover_config?: Record<string, unknown> cover_config?: Record<string, unknown>
project_id?: string project_id?: string
}): Promise<RenderJob> => { }): Promise<RenderJob> => {
// title_config 内可含 title_image_dataurl(前端 Canvas 渲染的 PNG dataURL
const response = await apiClient.post<RenderJob>("/ai-avatar/render", data) const response = await apiClient.post<RenderJob>("/ai-avatar/render", data)
return response.data return response.data
} }
export const getRenderJob = async (jobId: string): Promise<RenderJob> => { 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 return response.data
} }
export const cancelRenderJob = async (jobId: string): Promise<void> => { export const cancelRenderJob = async (jobId: string): Promise<void> => {
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`) await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
} }
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover ── */
export const generateRenderSmartCover = async (
jobId: string,
): Promise<{ cover_url: string; status: string; message: string }> => {
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
`/ai-avatar/render/${jobId}/smart-cover`,
{},
// 抽帧+评分+转存 OSS 链路较长,120s 超时
{ timeout: 120000 },
)
return response.data
}
/* ── 封面选定后点「完成」正式入库(POST /ai-avatar/render/{job_id}/finalize ── */
export const finalizeRenderJob = (renderId: string) =>
apiClient.post<{ video_id: string; cover_url: string; status: string }>(
`/ai-avatar/render/${renderId}/finalize`,
)
@@ -5,12 +5,12 @@
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材 * - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择) * 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小 * - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
* (开始/结束时间来自后端精确句子时间戳,基于 TTS 音频静音检测 * (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算
* - 底部:已配置的画面插入列表(可删除) * - 底部:已配置的画面插入列表(可删除)
*/ */
import React, { useEffect, useMemo, useState } from "react" import React, { useEffect, useMemo, useState } from "react"
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets" 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" import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
interface ModalBRollEditorProps { interface ModalBRollEditorProps {
@@ -18,12 +18,10 @@ interface ModalBRollEditorProps {
onClose: () => void onClose: () => void
/** 当前已有的 B-roll segments(用于标灰已选素材) */ /** 当前已有的 B-roll segments(用于标灰已选素材) */
existingSegments: BRollSegment[] existingSegments: BRollSegment[]
/** 文案全文(优先使用对口型时锁定的 scriptText */ /** 当前文案全文(用于分句 */
scriptText: string scriptText: string
/** 对口型成片总时长(秒) */ /** 对口型成片总时长(秒),用于时间自动估算 */
outputDuration: number outputDuration: number
/** 后端精确句子时间戳(来自 lipsyncJob.sentence_timings */
sentenceTimings?: SentenceTiming[] | null
onConfirm: (segment: BRollSegment) => void onConfirm: (segment: BRollSegment) => void
onRemove: (id: string) => void onRemove: (id: string) => void
} }
@@ -45,8 +43,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
onClose, onClose,
existingSegments, existingSegments,
scriptText, scriptText,
outputDuration: _outputDuration, outputDuration,
sentenceTimings,
onConfirm, onConfirm,
onRemove, onRemove,
}) => { }) => {
@@ -65,10 +62,10 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right") const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
const [pipScale, setPipScale] = useState(0.3) const [pipScale, setPipScale] = useState(0.3)
/** 文案分句(优先使用后端精确时间戳,降级为字数比例估算 */ /** 文案分句( */
const sentences = useMemo( const sentences = useMemo(
() => splitScriptIntoSentences(scriptText, sentenceTimings, _outputDuration), () => splitScriptIntoSentences(scriptText, outputDuration),
[scriptText, sentenceTimings, _outputDuration], [scriptText, outputDuration],
) )
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */ /** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
@@ -145,7 +142,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
setSelectedAsset(asset) setSelectedAsset(asset)
} }
/** 确认添加一段 B-roll(⑥ 时间取所选句子的精确起止,后端静音检测 / 前端字数比例降级 */ /** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止 */
const handleConfirm = () => { const handleConfirm = () => {
if (!selectedAsset || !selectedSentence) return if (!selectedAsset || !selectedSentence) return
const startTime = selectedSentence.startTime const startTime = selectedSentence.startTime
@@ -267,9 +264,11 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
> >
<span className="aa-sentence-item__idx">{sent.index + 1}</span> <span className="aa-sentence-item__idx">{sent.index + 1}</span>
<span className="aa-sentence-item__text">{sent.text}</span> <span className="aa-sentence-item__text">{sent.text}</span>
<span className="aa-sentence-item__time"> {outputDuration > 0 && (
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s <span className="aa-sentence-item__time">
</span> {sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
</span>
)}
</button> </button>
) )
})} })}
@@ -350,7 +349,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
selectedSentence.endTime, selectedSentence.endTime,
selectedSentence.startTime + 0.5, selectedSentence.startTime + 0.5,
).toFixed(1)} ).toFixed(1)}
s s
</div> </div>
</> </>
) : ( ) : (
@@ -1,59 +0,0 @@
/**
* AI数字人 — 封面选择弹窗
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerateselect-cover 变体)提供
* 智能抽帧 + 自定义上传 + 预览 + 确定按钮。
*/
import React from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types"
import PanelCoverAndGenerate from "./PanelCoverAndGenerate"
interface ModalCoverSelectProps {
open: boolean
onClose: () => void
renderJob: RenderJob | null
coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
onUploadCover?: (file: File) => void
onCoverSelected: (coverUrl: string) => void
}
const ModalCoverSelect: React.FC<ModalCoverSelectProps> = ({
open,
onClose,
renderJob,
coverConfig,
onCoverConfigChange,
onGenerateRenderSmartCover,
onUploadCover,
onCoverSelected,
}) => {
if (!open) return null
return (
<div className="aa-modal-overlay" onClick={onClose}>
<div className="aa-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 480 }}>
<div className="aa-modal__header">
<span className="aa-modal__title"></span>
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
<div className="aa-modal__body" style={{ padding: 20 }}>
<PanelCoverAndGenerate
variant="select-cover"
coverConfig={coverConfig}
onCoverConfigChange={onCoverConfigChange}
renderJob={renderJob}
onGenerateRenderSmartCover={onGenerateRenderSmartCover}
onUploadCover={onUploadCover}
onClose={onClose}
onCoverSelected={onCoverSelected}
/>
</div>
</div>
</div>
)
}
export default ModalCoverSelect
@@ -1,45 +1,35 @@
/** /**
* AI数字人 — 面板5 / 封面选择弹窗内容: * AI数字人 — 面板5:封面 & 生成
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段; * - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。 * - 分辨率选择(720p / 1080p / 4K
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览), * - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮 * - 渐变紫色生成按钮
* *
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题 * 注意:v3 已删除"画面插入模式",本面板不包含该选项
*/ */
import React, { useRef, useState } from "react" import React, { useRef } from "react"
import type { AiAvatarCoverConfig, RenderJob } from "../types" import type { AiAvatarCoverConfig } from "../types"
type PanelVariant = "setup" | "select-cover"
interface PanelCoverAndGenerateProps { interface PanelCoverAndGenerateProps {
variant?: PanelVariant
coverConfig: AiAvatarCoverConfig coverConfig: AiAvatarCoverConfig
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
resolution?: string resolution: string
onResolutionChange?: (r: string) => void onResolutionChange: (r: string) => void
isGenerating?: boolean isGenerating: boolean
onGenerate?: () => void onGenerate: () => void
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面 */ /** 智能获取封面(MediaKit 选帧 */
renderJob: RenderJob | null onSmartCover: () => void
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */ smartCoverLoading: boolean
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }> canSmartCover: boolean
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */ /** 配置汇总信息 */
onUploadCover?: (file: File) => void summary: {
/** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */
onClose?: () => void
/** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */
onCoverSelected?: (coverUrl: string) => void
/** 配置汇总信息(仅 variant="setup" 使用) */
summary?: {
videoName: string | null videoName: string | null
voiceName: string | null voiceName: string | null
scriptLength: number scriptLength: number
lipsyncStatus: string | null lipsyncStatus: string | null
brollCount: number brollCount: number
hasTitle: boolean hasTitle: boolean
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */ hasCover: boolean
coverStatus: "not_ready" | "pending" | "selected"
} }
} }
@@ -58,116 +48,53 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
} }
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
variant = "setup",
coverConfig, coverConfig,
onCoverConfigChange, onCoverConfigChange,
resolution = "720p", resolution,
onResolutionChange, onResolutionChange,
isGenerating = false, isGenerating,
onGenerate, onGenerate,
renderJob, onSmartCover,
onGenerateRenderSmartCover, smartCoverLoading,
onUploadCover, canSmartCover,
onClose,
onCoverSelected,
summary, summary,
}) => { }) => {
const uploadInputRef = useRef<HTMLInputElement>(null) const uploadInputRef = useRef<HTMLInputElement>(null)
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
/** 自定义上传封面 */ /** 自定义上传封面 */
const handleUploadClick = () => { const handleUploadClick = () => {
uploadInputRef.current?.click() uploadInputRef.current?.click()
} }
const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => {
const partial: Partial<AiAvatarCoverConfig> = {
mode,
thumbnail_url: url,
}
if (mode === "auto_frame") {
partial.smart_cover_url = url
} else {
partial.upload_url = url
}
onCoverConfigChange(partial)
onCoverSelected?.(url)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
if (onUploadCover) { // 本地预览:生成 object URL(实际上传由父级/后端链路处理)
onUploadCover(file)
e.target.value = ""
return
}
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file)
_applyCoverUrl(url, "upload") onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
// 允许重复选择同一文件
e.target.value = "" e.target.value = ""
} }
/** 智能获取封面(从最终成片抽帧;必须等 render 完成 */ /** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822 */
const handleSmartCover = async () => { const handleSmartCover = () => {
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return onCoverConfigChange({ mode: "auto_frame" })
setSmartCoverLoading(true) onSmartCover()
try {
const res = await onGenerateRenderSmartCover(renderJob.id)
if (res.cover_url) {
_applyCoverUrl(res.cover_url, "auto_frame")
} else {
// 失败由父组件 message 提示,这里不重复弹窗
console.warn("[智能封面] 返回空 cover_url:", res.message)
}
} catch (err) {
console.error("[智能封面] 调用失败:", err)
} finally {
setSmartCoverLoading(false)
}
} }
const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
// 渲染已完成 → 封面区可用
const isRenderCompleted = renderJob?.status === "completed"
const canSmartCover = isRenderCompleted && !smartCoverLoading
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */ const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
const coverUrl =
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
const hasCoverImage = Boolean(coverUrl)
/** 封面区占位文字 */ return (
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面" <div className="aa-cover-generate">
{/* 封面预览(竖屏 9:16 */}
/** 配置摘要中的封面状态标签 */ <div className="aa-cover-preview">
const coverSummaryNode = (() => { {coverConfig.thumbnail_url ? (
if (!summary) return null <img src={coverConfig.thumbnail_url} alt="封面预览" />
if (summary.coverStatus === "selected") {
return <span className="aa-config-summary__value"></span>
}
if (summary.coverStatus === "pending") {
return <span className="aa-config-summary__value"></span>
}
return <span className="aa-config-summary__empty"></span>
})()
// ── 封面选择区(两种 variant 共用) ─────────────────────────────────
const coverSection = (
<div className="aa-cover-section" style={{ marginTop: variant === "select-cover" ? 0 : 16 }}>
<div className="aa-label" style={{ marginBottom: 8 }}>
{variant === "select-cover" ? "选择封面" : "封面"}
</div>
{/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */}
<div className="aa-cover-preview" style={{ opacity: isRenderCompleted ? 1 : 0.5 }}>
{hasCoverImage ? (
<img src={coverUrl!} alt="封面预览" draggable={false} />
) : ( ) : (
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span> <span className="aa-cover-preview__placeholder"></span>
)} )}
{smartCoverLoading && <div className="aa-cover-preview__loading"> </div>}
</div> </div>
<div className="aa-cover-actions"> <div className="aa-cover-actions">
@@ -175,8 +102,8 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
type="button" type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`} className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
onClick={handleSmartCover} onClick={handleSmartCover}
disabled={!canSmartCover} disabled={smartCoverLoading || !canSmartCover}
title={isRenderCompleted ? "成片智能选帧" : "请先生成视频"} title={canSmartCover ? "基于对口型成片智能选帧" : "请先完成对口型生成"}
> >
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"} {smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
</button> </button>
@@ -184,8 +111,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
type="button" type="button"
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`} className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
onClick={handleUploadClick} onClick={handleUploadClick}
disabled={!isRenderCompleted || smartCoverLoading}
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
> >
📷 📷
</button> </button>
@@ -197,36 +122,14 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
onChange={handleFileChange} onChange={handleFileChange}
/> />
</div> </div>
</div>
)
// ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ──
if (variant === "select-cover") {
return (
<div className="aa-cover-generate">
{coverSection}
{onClose && (
<div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end" }}>
<button type="button" className="aa-btn aa-btn--primary" onClick={onClose}>
</button>
</div>
)}
</div>
)
}
// ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ──
return (
<div className="aa-cover-generate">
{/* 分辨率选择 */} {/* 分辨率选择 */}
<div className="aa-form-field"> <div className="aa-form-field">
<label className="aa-label"></label> <label className="aa-label"></label>
<select <select
className="aa-select" className="aa-select"
value={resolution} value={resolution}
onChange={(e) => onResolutionChange?.(e.target.value)} onChange={(e) => onResolutionChange(e.target.value)}
disabled={isGenerating}
> >
{RESOLUTION_OPTIONS.map((opt) => ( {RESOLUTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
@@ -241,7 +144,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
<div className="aa-config-summary"> <div className="aa-config-summary">
<div className="aa-config-summary__row"> <div className="aa-config-summary__row">
<span></span> <span></span>
{summary?.videoName ? ( {summary.videoName ? (
<span className="aa-config-summary__value">{summary.videoName}</span> <span className="aa-config-summary__value">{summary.videoName}</span>
) : ( ) : (
<span className="aa-config-summary__empty"></span> <span className="aa-config-summary__empty"></span>
@@ -249,7 +152,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div> </div>
<div className="aa-config-summary__row"> <div className="aa-config-summary__row">
<span></span> <span></span>
{summary?.voiceName ? ( {summary.voiceName ? (
<span className="aa-config-summary__value">{summary.voiceName}</span> <span className="aa-config-summary__value">{summary.voiceName}</span>
) : ( ) : (
<span className="aa-config-summary__empty"></span> <span className="aa-config-summary__empty"></span>
@@ -257,7 +160,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div> </div>
<div className="aa-config-summary__row"> <div className="aa-config-summary__row">
<span></span> <span></span>
{summary && summary.scriptLength > 0 ? ( {summary.scriptLength > 0 ? (
<span className="aa-config-summary__value">{summary.scriptLength} </span> <span className="aa-config-summary__value">{summary.scriptLength} </span>
) : ( ) : (
<span className="aa-config-summary__empty"></span> <span className="aa-config-summary__empty"></span>
@@ -274,12 +177,12 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
<div className="aa-config-summary__row"> <div className="aa-config-summary__row">
<span>B-roll </span> <span>B-roll </span>
<span className="aa-config-summary__value"> <span className="aa-config-summary__value">
{summary && summary.brollCount > 0 ? `${summary.brollCount}` : "无"} {summary.brollCount > 0 ? `${summary.brollCount}` : "无"}
</span> </span>
</div> </div>
<div className="aa-config-summary__row"> <div className="aa-config-summary__row">
<span></span> <span></span>
{summary?.hasTitle ? ( {summary.hasTitle ? (
<span className="aa-config-summary__value"></span> <span className="aa-config-summary__value"></span>
) : ( ) : (
<span className="aa-config-summary__empty"></span> <span className="aa-config-summary__empty"></span>
@@ -287,7 +190,11 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
</div> </div>
<div className="aa-config-summary__row"> <div className="aa-config-summary__row">
<span></span> <span></span>
{coverSummaryNode} {summary.hasCover ? (
<span className="aa-config-summary__value"></span>
) : (
<span className="aa-config-summary__empty"></span>
)}
</div> </div>
</div> </div>
@@ -300,16 +207,11 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
> >
{isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"} {isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"}
</button> </button>
{summary?.lipsyncStatus !== "completed" && !isGenerating && ( {summary.lipsyncStatus !== "completed" && !isGenerating && (
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}> <div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
</div> </div>
)} )}
{isGenerating && (
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
</div>
)}
</div> </div>
</div> </div>
) )
@@ -1,9 +1,9 @@
/** /**
* AI数字人 — 对口型预览面板(步骤2用) * AI数字人 — 对口型预览面板(步骤2用)
* B-roll 画面插入 + 对口型视频预览 + 生成/重新生成按钮 * B-roll 画面插入 + 对口型视频预览 + 生成/重新生成按钮
* v3.1: 标题字号按预览容器实际宽度动态计算 previewScale(基准 720p),与成片一致 * v3.1: 预览容器按 1/2 缩放、标题实时叠加预览
*/ */
import React, { useCallback, useEffect, useRef, useState } from "react" import React, { useRef } from "react"
import type { LipsyncJob, BRollSegment, AiAvatarTitleConfig } from "../types" import type { LipsyncJob, BRollSegment, AiAvatarTitleConfig } from "../types"
interface PanelLipsyncPreviewProps { interface PanelLipsyncPreviewProps {
@@ -14,8 +14,8 @@ interface PanelLipsyncPreviewProps {
onRemoveBRoll: (id: string) => void onRemoveBRoll: (id: string) => void
/** 标题配置(实时叠加预览用) */ /** 标题配置(实时叠加预览用) */
titleConfig?: AiAvatarTitleConfig 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> = { const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
@@ -29,16 +29,6 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}` return `${m}:${s.toString().padStart(2, "0")}`
} }
/** 字体名 → CSS font-family 映射(与 titleCanvas 字体链对齐) */
const FONT_FAMILY_MAP: Record<string, string> = {
:
"'Noto Sans CJK SC', 'Source Han Sans CN', '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["思源黑体"]
export function PanelLipsyncPreview({ export function PanelLipsyncPreview({
lipsyncJob, lipsyncJob,
onGenerateLipsync, onGenerateLipsync,
@@ -51,8 +41,6 @@ export function PanelLipsyncPreview({
const titleDragRef = useRef<HTMLDivElement>(null) const titleDragRef = useRef<HTMLDivElement>(null)
const draggingTitleRef = useRef(false) const draggingTitleRef = useRef(false)
const previewContainerRef = useRef<HTMLDivElement>(null) const previewContainerRef = useRef<HTMLDivElement>(null)
// 预览容器实际宽度(通过 ResizeObserver 监听),用于动态计算 previewScale
const [containerWidth, setContainerWidth] = useState(0)
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing" const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
const isDone = lipsyncJob?.status === "completed" const isDone = lipsyncJob?.status === "completed"
const isFailed = lipsyncJob?.status === "failed" const isFailed = lipsyncJob?.status === "failed"
@@ -64,84 +52,29 @@ export function PanelLipsyncPreview({
? "排队中…" ? "排队中…"
: "对口型生成中…" : "对口型生成中…"
// 监听预览容器尺寸变化,动态测量宽度以计算 previewScale(基准 720p /** 标题叠加样式 */
useEffect(() => { const titleOverlayStyle: React.CSSProperties | null = titleConfig?.title
const el = previewContainerRef.current ? {
if (!el) return position: "absolute",
const update = () => setContainerWidth(el.clientWidth || 0) left: "50%",
update() transform: "translateX(-50%)",
if (typeof ResizeObserver !== "undefined") { color: titleConfig.color || "#ffffff",
const ro = new ResizeObserver(update) fontFamily: titleConfig.font || "思源黑体",
ro.observe(el) fontSize: `${(titleConfig.size || 36) * 0.55}px`, // 预览等比缩
return () => ro.disconnect() fontWeight: titleConfig.bold ? 700 : 400,
} fontStyle: titleConfig.italic ? "italic" : "normal",
window.addEventListener("resize", update) textAlign: "center",
return () => window.removeEventListener("resize", update) width: "90%",
}, []) padding: "4px 8px",
textShadow: titleConfig.shadow ? "0 2px 4px rgba(0,0,0,0.8)" : undefined,
// 预览缩放比:预览宽度 / 720(基准宽度) WebkitTextStroke: titleConfig.stroke ? "1.5px #000" : undefined,
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35 ...(titleConfig.position === "top"
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale]) ? { top: 8 }
: titleConfig.position === "bottom"
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */ ? { bottom: 8 }
const titleOverlayStyle: React.CSSProperties | null = : { top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
titleConfig?.title && containerWidth > 0 }
? (() => { : null
const baseSize = titleConfig.size || 48
const fontSize = ps(baseSize)
// 描边宽度基准 ≈ size * 0.06,最小 1.5px @720p
const strokeW = Math.max(ps(1.5), +(baseSize * 0.06 * previewScale).toFixed(2))
// 阴影按比例缩放
const shadowBlur = ps(4)
const shadowOffsetY = ps(2)
// padding / top 边距按比例(基准 8px 对应预览小窗,成片基准 16px,这里 8px 对应约 0.33 缩放)
const padV = ps(16) * 0.5 // ≈ 8px in ~240px container
const padH = ps(24) * 0.5
const style: React.CSSProperties = {
position: "absolute",
color: titleConfig.color || "#ffffff",
fontFamily: getFontFamily(titleConfig.font || "思源黑体"),
fontSize: `${fontSize}px`,
fontWeight: titleConfig.bold ? 700 : 400,
fontStyle: titleConfig.italic ? "italic" : "normal",
textAlign: "center",
width: "90%",
lineHeight: 1.2,
padding: `${ps(4)}px ${padH}px`,
textShadow: titleConfig.shadow
? `0 ${shadowOffsetY}px ${shadowBlur}px rgba(0,0,0,0.8), 0 0 ${ps(2)}px rgba(0,0,0,0.5)`
: undefined,
WebkitTextStroke: titleConfig.stroke ? `${strokeW}px #000` : undefined,
boxSizing: "border-box",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}
if (
titleConfig.position === "custom" &&
titleConfig.pos_x != null &&
titleConfig.pos_y != null
) {
style.left = `${titleConfig.pos_x}%`
style.top = `${titleConfig.pos_y}%`
style.transform = "translateX(-50%) translateY(-50%)"
} else if (titleConfig.position === "top") {
style.left = "50%"
style.top = padV
style.transform = "translateX(-50%)"
} else if (titleConfig.position === "bottom") {
style.left = "50%"
style.bottom = padV
style.transform = "translateX(-50%)"
} else {
style.left = "50%"
style.top = "50%"
style.transform = "translateX(-50%) translateY(-50%)"
}
return style
})()
: null
const handleTitlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => { const handleTitlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !previewContainerRef.current) return if (!onTitlePositionChange || !previewContainerRef.current) return
@@ -172,10 +105,7 @@ export function PanelLipsyncPreview({
const rect = previewContainerRef.current.getBoundingClientRect() const rect = previewContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) 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)) const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
// 发送百分比坐标(0-100),与后端 drawtext 百分比表达式对齐 onTitlePositionChange({ pos_x: relX, pos_y: relY })
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" })
} }
;(e.currentTarget as HTMLDivElement).style.cursor = "grab" ;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
} }
@@ -244,7 +174,7 @@ export function PanelLipsyncPreview({
)} )}
</div> </div>
{/* ── 对口型预览(标题字号按 previewScale 动态缩放 ─ */} {/* ── 对口型预览(v3.1: 缩放1/2 + 标题叠加 ─ */}
<div className="aa-lipsync-section"> <div className="aa-lipsync-section">
<div className="aa-lipsync-section__title"></div> <div className="aa-lipsync-section__title"></div>
@@ -2,16 +2,17 @@
* AI数字人 — 出镜视频选择面板 * AI数字人 — 出镜视频选择面板
* - 未选视频:虚线上传区,点击打开素材库弹窗 * - 未选视频:虚线上传区,点击打开素材库弹窗
* - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮 * - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮
*
* 注意:本面板只展示原始素材视频,不叠加标题(标题在对口型预览和最终成片上展示)
*/ */
import type { AssetItem } from "@/api/assets" import type { AssetItem } from "@/api/assets"
import type { AiAvatarTitleConfig } from "../types"
import { getFontFamily } from "@/pages/generate/constants"
export interface PanelVideoSelectorProps { export interface PanelVideoSelectorProps {
selectedVideo: AssetItem | null selectedVideo: AssetItem | null
/** 触发打开素材库弹窗 */ /** 触发打开素材库弹窗 */
onSelectVideo: () => void onSelectVideo: () => void
onRemoveVideo: () => void onRemoveVideo: () => void
titleConfig?: AiAvatarTitleConfig
} }
/** 格式化时长(秒 → mm:ss */ /** 格式化时长(秒 → mm:ss */
@@ -26,6 +27,7 @@ export function PanelVideoSelector({
selectedVideo, selectedVideo,
onSelectVideo, onSelectVideo,
onRemoveVideo, onRemoveVideo,
titleConfig,
}: PanelVideoSelectorProps) { }: PanelVideoSelectorProps) {
/* 未选视频:虚线上传区,点击打开素材库弹窗 */ /* 未选视频:虚线上传区,点击打开素材库弹窗 */
if (!selectedVideo) { if (!selectedVideo) {
@@ -55,13 +57,42 @@ export function PanelVideoSelector({
return ( return (
<div> <div>
{/* 竖屏 9:16 视频预览播放器(纯素材预览,不叠加标题) */} {/* 竖屏 9:16 视频预览播放器 + 标题实时预览 */}
<div className="aa-video-preview"> <div className="aa-video-preview" style={{ position: "relative" }}>
{fileUrl ? ( {fileUrl ? (
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline /> <video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
) : ( ) : (
<div className="aa-video-preview__placeholder"></div> <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> </div>
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */} {/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
@@ -139,6 +139,13 @@ export function PanelVoiceSelector({
} }
const targetId = voice.voice_clone_profile_id || voice.id const targetId = voice.voice_clone_profile_id || voice.id
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因 // DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
console.log("[AI数字人-克隆试听] previewTts 请求:", {
voice_id: targetId,
voice_name: voice.name,
voice_type: voice.type,
voice_clone_profile_id: voice.voice_clone_profile_id,
voice_id_field: voice.voice_id,
})
setPreviewingId(voice.id) setPreviewingId(voice.id)
try { try {
const res = await previewTts({ const res = await previewTts({
@@ -147,6 +154,10 @@ export function PanelVoiceSelector({
speed: speed, // 透传用户选择的语速(#1822) speed: speed, // 透传用户选择的语速(#1822)
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举 emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
}) })
console.log("[AI数字人-克隆试听] previewTts 响应:", {
audio_url: res.audio_url?.substring(0, 80),
duration: res.duration,
})
if (!res.audio_url) { if (!res.audio_url) {
setPreviewingId(null) setPreviewingId(null)
message.error("合成试听失败:未返回音频") message.error("合成试听失败:未返回音频")
@@ -1,5 +1,5 @@
/** /**
* AI数字人 — 页面全局状态管理 hook(v3 + #1845 配音前置 * AI数字人 — 页面全局状态管理 hook(v3)
*/ */
import { useState, useCallback } from "react" import { useState, useCallback } from "react"
import type { AssetItem } from "@/api/assets" import type { AssetItem } from "@/api/assets"
@@ -13,19 +13,10 @@ import {
type BRollSegment, type BRollSegment,
type AiAvatarTitleConfig, type AiAvatarTitleConfig,
type AiAvatarCoverConfig, type AiAvatarCoverConfig,
type TtsPreviewResult,
DEFAULT_TITLE_CONFIG, DEFAULT_TITLE_CONFIG,
DEFAULT_COVER_CONFIG, DEFAULT_COVER_CONFIG,
} from "../types" } from "../types"
const DEFAULT_TTS_PREVIEW: TtsPreviewResult = {
audioUrl: null,
duration: 0,
sentenceTimings: [],
status: "idle",
error: null,
}
export function useAiAvatar() { export function useAiAvatar() {
/* ── 面板1:出镜视频 ── */ /* ── 面板1:出镜视频 ── */
const [selectedVideo, setSelectedVideo] = useState<AssetItem | null>(null) const [selectedVideo, setSelectedVideo] = useState<AssetItem | null>(null)
@@ -45,9 +36,6 @@ export function useAiAvatar() {
const [showScriptModal, setShowScriptModal] = useState(false) const [showScriptModal, setShowScriptModal] = useState(false)
const [showBRollModal, setShowBRollModal] = useState(false) const [showBRollModal, setShowBRollModal] = useState(false)
/* ── #1845 TTS 预合成(步骤1「生成配音」) ── */
const [ttsPreview, setTtsPreview] = useState<TtsPreviewResult>(DEFAULT_TTS_PREVIEW)
/* ── 面板3.5B-roll ── */ /* ── 面板3.5B-roll ── */
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([]) const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
@@ -93,7 +81,6 @@ export function useAiAvatar() {
setScript(null) setScript(null)
setScriptText("") setScriptText("")
setLipsyncJob(null) setLipsyncJob(null)
setTtsPreview(DEFAULT_TTS_PREVIEW)
setBRollSegments([]) setBRollSegments([])
setTitleConfig(DEFAULT_TITLE_CONFIG) setTitleConfig(DEFAULT_TITLE_CONFIG)
setCoverConfig(DEFAULT_COVER_CONFIG) setCoverConfig(DEFAULT_COVER_CONFIG)
@@ -131,10 +118,6 @@ export function useAiAvatar() {
showBRollModal, showBRollModal,
setShowBRollModal, setShowBRollModal,
selectScript, selectScript,
// #1845 TTS 预合成
ttsPreview,
setTtsPreview,
resetTtsPreview: useCallback(() => setTtsPreview(DEFAULT_TTS_PREVIEW), []),
// B-roll // B-roll
bRollSegments, bRollSegments,
addBRollSegment, addBRollSegment,
+3 -26
View File
@@ -28,17 +28,6 @@ export const VOICE_LANGUAGE_OPTIONS: { value: VoiceLanguage; label: string }[] =
/* ── 对口型任务状态 ── */ /* ── 对口型任务状态 ── */
export type LipsyncStatus = "idle" | "pending" | "processing" | "completed" | "failed" export type LipsyncStatus = "idle" | "pending" | "processing" | "completed" | "failed"
/* ── TTS 预合成(#1845 配音前置:步骤1「生成配音」状态) ── */
export type TtsPreviewStatus = "idle" | "generating" | "done" | "failed"
export interface TtsPreviewResult {
audioUrl: string | null
duration: number
sentenceTimings: SentenceTiming[]
status: TtsPreviewStatus
error: string | null
}
/* ── 文案 ── */ /* ── 文案 ── */
export interface Script { export interface Script {
id: string id: string
@@ -55,23 +44,12 @@ export interface LipsyncJob {
status: LipsyncStatus status: LipsyncStatus
progress: number progress: number
output_video_url: string | null output_video_url: string | null
/** 对口型成片总时长(秒),后端返回 */ /** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
script_text: string
output_duration?: number output_duration?: number
/** 精确句子时间戳(后端基于 TTS 音频静音检测计算) */
sentence_timings?: SentenceTiming[] | null
error_message: string | null error_message: string | null
created_at: string created_at: string
} }
/* ── 句子时间戳(后端精确计算) ── */
export interface SentenceTiming {
index: number
text: string
start_time: number
end_time: number
}
/* ── B-roll 画面插入 ── */ /* ── B-roll 画面插入 ── */
export type BRollInsertMode = "fullscreen" | "pip" export type BRollInsertMode = "fullscreen" | "pip"
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right" export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
@@ -99,7 +77,7 @@ export interface AiAvatarTitleConfig {
shadow: boolean shadow: boolean
color: string color: string
auto_subtitle: boolean auto_subtitle: boolean
/** 自定义位置坐标(position=custom 时生效,百分比 0-100 */ /** 自定义位置坐标(position=custom 时生效,像素 */
pos_x?: number pos_x?: number
pos_y?: number pos_y?: number
} }
@@ -123,7 +101,6 @@ export interface RenderJob {
status: RenderStatus status: RenderStatus
progress: number progress: number
output_video_url: string | null output_video_url: string | null
output_cover_url: string | null
error_message: string | null error_message: string | null
created_at: string created_at: string
} }
@@ -133,7 +110,7 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
title: "", title: "",
position: "bottom", position: "bottom",
font: "思源黑体", font: "思源黑体",
size: 48, size: 28,
bold: true, bold: true,
italic: false, italic: false,
stroke: false, stroke: false,
+4 -16
View File
@@ -28,13 +28,10 @@ export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
* 后端真实字段:text(或content)、font(或font_preset)、font_size(或size)、 * 后端真实字段:text(或content)、font(或font_preset)、font_size(或size)、
* font_color(或color,可传 #RRGGBB)、position(top/center/bottom/custom)、 * font_color(或color,可传 #RRGGBB)、position(top/center/bottom/custom)、
* enabled、bold、stroke{enabled,width,color}、shadow{enabled,color,offset_x,offset_y}、 * enabled、bold、stroke{enabled,width,color}、shadow{enabled,color,offset_x,offset_y}、
* pos_x/pos_y(custom 时)、title_image_dataurl(前端 Canvas 渲染的 PNG dataURLWYSIWYG 路径优先) * pos_x/pos_y(custom 时)。
* 口播标题默认 position=bottom(不传后端会默认 top 跑到画面顶部)。 * 口播标题默认 position=bottom(不传后端会默认 top 跑到画面顶部)。
*/ */
export function buildTitleConfigPayload( export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string, unknown> {
cfg: AiAvatarTitleConfig,
titleImageDataUrl?: string | null,
): Record<string, unknown> {
const text = (cfg.title || "").trim() const text = (cfg.title || "").trim()
if (!text) return {} if (!text) return {}
const position = cfg.position || "bottom" const position = cfg.position || "bottom"
@@ -42,7 +39,7 @@ export function buildTitleConfigPayload(
text, text,
enabled: true, enabled: true,
font: cfg.font || "思源黑体", font: cfg.font || "思源黑体",
font_size: Math.round(cfg.size) || 48, font_size: Math.round(cfg.size) || 36,
font_color: cfg.color || "#ffffff", font_color: cfg.color || "#ffffff",
position, position,
bold: !!cfg.bold, bold: !!cfg.bold,
@@ -56,10 +53,6 @@ export function buildTitleConfigPayload(
payload.pos_x = cfg.pos_x payload.pos_x = cfg.pos_x
payload.pos_y = cfg.pos_y payload.pos_y = cfg.pos_y
} }
// 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层)
if (titleImageDataUrl) {
payload.title_image_dataurl = titleImageDataUrl
}
return payload return payload
} }
@@ -74,14 +67,9 @@ export function buildCoverConfigPayload(
// build_cover_extract_command 读取 timestamp(截帧秒数) // build_cover_extract_command 读取 timestamp(截帧秒数)
timestamp: cfg.frame_time || 0, timestamp: cfg.frame_time || 0,
} }
// 智能封面 URL(后端字段名为 url/imageUrl/cover_url 都兼容,优先 url if (smartCoverUrl) payload.cover_url = smartCoverUrl
if (smartCoverUrl) {
payload.url = smartCoverUrl
payload.cover_url = smartCoverUrl
}
// 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用 // 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用
if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) { if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) {
payload.url = cfg.upload_url
payload.upload_url = cfg.upload_url payload.upload_url = cfg.upload_url
} }
return payload return payload
@@ -1,10 +1,5 @@
/** /**
* AI数字人 — 文案分句 & B-roll 时间计算 * AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
*
* 数据来源优先级:
* 1. 后端 sentence_timings(基于 TTS 音频静音检测,精确到句子边界)—— 直接使用,不重新分句
* 2. 后端 output_duration(最终渲染视频时长) + 本地分句 —— 按字数比例估算
* 3. 两者都没有(对口型还在生成中)—— 返回分句文本但 startTime/endTime 全部 0,等数据到位重算
*/ */
export interface ScriptSentence { export interface ScriptSentence {
@@ -16,67 +11,25 @@ export interface ScriptSentence {
charCount: number charCount: number
/** 累计起始字数(用于时间估算) */ /** 累计起始字数(用于时间估算) */
startChar: number startChar: number
/** 对口型视频内起始时间(秒)——后端精确值或前端估算 */ /** 估算的对口型视频内起始时间(秒) */
startTime: number startTime: number
/** 对口型视频内结束时间(秒)——后端精确值或前端估算 */ /** 估算的对口型视频内结束时间(秒) */
endTime: number endTime: number
} }
/** 句子分隔符:中英文句号/问号/感叹号/分号/逗号/换行(覆盖中文短视频常用断句) */
const SENTENCE_SPLIT_RE = /[。!?!??!;,\n\r]+/
/** /**
* 分句并计算每句的起止时间 * 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)
* * 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
* @param sentenceTimings 后端返回的精确句子时间戳(来自 lipsync_job.sentence_timings)。
* 非空时直接按后端返回的句子列表渲染,不再本地分句(避免前后端分句不一致导致时间错位)。
* @param outputDuration 最终视频时长(秒)。对口型预览阶段可能为 0,此时降级估算只能给 0。
*/ */
export function splitScriptIntoSentences( export function splitScriptIntoSentences(
scriptText: string, scriptText: string,
sentenceTimings?: outputDuration: number,
{ index?: number; text?: string; start_time: number; end_time: number }[] | null,
outputDuration: number = 0,
): ScriptSentence[] { ): ScriptSentence[] {
const text = (scriptText || "").trim() const text = (scriptText || "").trim()
if (!text) return [] if (!text) return []
// 1. 后端返回了 sentence_timings:校验通过就直接用,跳过本地分句
// 校验条件放宽:只要是数组、至少1条、每条 start_time/end_time 是数字即可
// (不再强制要求条数相等——后端静音检测可能按停顿切出更多/更少边界,
// 比如文案用逗号连写时本地只分1句、后端按停顿切4句,后端的切法才是对的)
if (Array.isArray(sentenceTimings) && sentenceTimings.length > 0) {
const valid = sentenceTimings.every(
(t) =>
t &&
typeof t.start_time === "number" &&
typeof t.end_time === "number" &&
isFinite(t.start_time) &&
isFinite(t.end_time) &&
t.end_time >= t.start_time,
)
if (valid) {
let accChar = 0
return sentenceTimings.map((t, i) => {
const sentenceText = (t.text || "").trim() || `句子${i + 1}`
const charCount = sentenceText.replace(/\s/g, "").length
const sentence: ScriptSentence = {
index: typeof t.index === "number" ? t.index : i,
text: sentenceText,
charCount,
startChar: accChar,
startTime: round1(t.start_time),
endTime: round1(t.end_time),
}
accChar += charCount
return sentence
})
}
}
// 2. 本地分句 + 按字数比例估算(降级路径)
const rawParts = text const rawParts = text
.split(SENTENCE_SPLIT_RE) .split(/[。!?!?;\n\r]+/)
.map((part) => part.trim()) .map((part) => part.trim())
.filter((part) => part.length > 0) .filter((part) => part.length > 0)
@@ -1,179 +0,0 @@
/**
* AI数字人 — 标题 Canvas 渲染工具
*
* 把标题按前端预览的 HTML/CSS 效果画到透明背景 PNG 上(与视频同分辨率),
* 以 dataURL 形式传给后端,后端用 FFmpeg overlay 直接叠加图层,
* 彻底解决前端 HTML/CSS 预览 ≠ FFmpeg drawtext 成片的 WYSIWYG 问题。
*
* 约定:titleConfig.size 的语义是"720p 基准宽度下的字号(px",
* 按 videoWidth / 720 得到 scale,所有长度类参数乘以 scale,
* 保证 1080p / 4K 成片里标题视觉大小与预览一致。
*/
import type { AiAvatarTitleConfig } from "../types"
export interface RenderTitlePngOptions {
/** 标题配置 */
titleConfig: AiAvatarTitleConfig
/** 视频宽度(像素),默认 720 */
videoWidth?: number
/** 视频高度(像素),默认 1280 */
videoHeight?: number
}
/**
* 将标题渲染为透明背景 PNG 的 dataURLdata:image/png;base64,...
* Canvas 尺寸与视频一致,保证叠加时 1:1 像素对齐。
*
* 标题为空时返回 null。
*/
export function renderTitleToPngDataUrl(opts: RenderTitlePngOptions): string | null {
const { titleConfig, videoWidth = 720, videoHeight = 1280 } = opts
if (!titleConfig) return null
const rawTitle = (titleConfig.title || "").trim()
if (!rawTitle) return null
// 按 / 或 分割为多行
const lines = rawTitle
.split(/[/]/)
.map((l) => l.trim())
.filter((l) => l.length > 0)
if (lines.length === 0) return null
// 分辨率缩放系数:基准 720p,所有长度类参数乘以 scale
const scale = videoWidth / 720
const r = (v: number) => Math.round(v * scale)
const canvas = document.createElement("canvas")
canvas.width = videoWidth
canvas.height = videoHeight
const ctx = canvas.getContext("2d")
if (!ctx) return null
const baseSize = Math.max(12, Math.round(titleConfig.size || 48))
const size = r(baseSize)
const bold = !!titleConfig.bold
const italic = !!titleConfig.italic
const color = titleConfig.color || "#ffffff"
const stroke = !!titleConfig.stroke
const shadow = !!titleConfig.shadow
// 字体族 fallback 链:优先中文字体
const fontFamily =
'"Noto Sans CJK SC","Source Han Sans CN","PingFang SC","Microsoft YaHei",sans-serif'
const fontParts: string[] = []
if (italic) fontParts.push("italic")
if (bold) fontParts.push("bold")
fontParts.push(`${size}px`, fontFamily)
ctx.font = fontParts.join(" ")
ctx.fillStyle = color
ctx.textAlign = "center"
ctx.textBaseline = "middle"
// 阴影(shadow=true 时开启)——按 scale 缩放
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(4)
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = r(2)
}
// 位置计算:与 PanelLipsyncPreview 的 CSS 对齐(按 scale 缩放 PAD
const PAD = r(16)
let centerX = videoWidth / 2
const position = titleConfig.position || "bottom"
const lineGap = size * 1.2
const totalTextH = lines.length * lineGap - (lineGap - size) // 所有行的总高度
// 文本块顶部 ytextBaseline=middle 时首行基线)
let firstLineY: number
if (
position === "custom" &&
typeof titleConfig.pos_x === "number" &&
typeof titleConfig.pos_y === "number"
) {
centerX = (Math.max(0, Math.min(100, titleConfig.pos_x)) / 100) * videoWidth
const centerY = (Math.max(0, Math.min(100, titleConfig.pos_y)) / 100) * videoHeight
firstLineY = centerY - totalTextH / 2 + size / 2
} else if (position === "top") {
// 顶部:y = size/2 + PAD
firstLineY = size / 2 + PAD
} else if (position === "center") {
firstLineY = videoHeight / 2 - totalTextH / 2 + size / 2
} else {
// bottom(默认)
firstLineY = videoHeight - totalTextH - PAD + size / 2
}
// 描边参数:描边 lineWidth 按 scale 缩放(基准 size * 0.06,最小 2px @720p
const doStroke = stroke
const strokeWidth = Math.max(r(2), Math.round(size * 0.06))
// 逐行绘制
lines.forEach((line, idx) => {
const y = firstLineY + idx * lineGap
if (doStroke) {
const prevShadowColor = ctx.shadowColor
const prevShadowBlur = ctx.shadowBlur
// 描边不要带阴影(避免黑色描边发虚)
ctx.shadowColor = "rgba(0,0,0,0)"
ctx.shadowBlur = 0
ctx.lineWidth = strokeWidth
ctx.strokeStyle = "#000000"
ctx.lineJoin = "round"
ctx.strokeText(line, centerX, y)
// 恢复阴影
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = r(4)
} else {
ctx.shadowColor = prevShadowColor
ctx.shadowBlur = prevShadowBlur
}
}
ctx.fillText(line, centerX, y)
})
try {
return canvas.toDataURL("image/png")
} catch {
return null
}
}
/**
* 获取视频真实分辨率(HTMLVideoElement + loadedmetadata,超时 3 秒兜底 720×1280)。
*/
export function getVideoResolution(
videoUrl: string,
timeoutMs = 3000,
): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
if (!videoUrl) {
resolve({ width: 720, height: 1280 })
return
}
const video = document.createElement("video")
video.preload = "metadata"
video.muted = true
video.playsInline = true
video.crossOrigin = "anonymous"
let settled = false
const done = (w: number, h: number) => {
if (settled) return
settled = true
video.removeAttribute("src")
video.load()
resolve({ width: w, height: h })
}
const timer = window.setTimeout(() => done(720, 1280), timeoutMs)
video.onloadedmetadata = () => {
window.clearTimeout(timer)
const w = video.videoWidth || 720
const h = video.videoHeight || 1280
done(w, h)
}
video.onerror = () => {
window.clearTimeout(timer)
done(720, 1280)
}
video.src = videoUrl
})
}
@@ -231,13 +231,9 @@ const GeneratePage: React.FC = () => {
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ── /* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求; 仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */ 变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
// P0 fix:批量变体计划请求需携带配音参数,避免后端按"无配音"选片导致 clips 时长与配音错位
const batchVoiceLibraryId =
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
const { const {
clipsByVariant: variantClips, clipsByVariant: variantClips,
planIdsByVariant: variantPlanIds, planIdsByVariant: variantPlanIds,
voiceDurationsByVariant: variantVoiceDurations,
loading: variantClipsLoading, loading: variantClipsLoading,
error: variantClipsError, error: variantClipsError,
retry: retryVariantClips, retry: retryVariantClips,
@@ -247,9 +243,6 @@ const GeneratePage: React.FC = () => {
templateId: selectedTemplate || "", templateId: selectedTemplate || "",
assetIds: previewAssetIds, assetIds: previewAssetIds,
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "", sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
voiceLibraryId: batchVoiceLibraryId,
voiceLibraryIds: voiceLibraryIds || [],
voiceModePerVideo,
}) })
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条; /* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
@@ -483,7 +476,6 @@ const GeneratePage: React.FC = () => {
titles={previewTitles} titles={previewTitles}
titleSettings={titleSettings} titleSettings={titleSettings}
voiceAudioUrls={variantVoiceAudioUrls} voiceAudioUrls={variantVoiceAudioUrls}
voiceDurations={variantVoiceDurations}
variantClips={variantClips} variantClips={variantClips}
clipsLoading={variantClipsLoading} clipsLoading={variantClipsLoading}
clipsError={variantClipsError} clipsError={variantClipsError}
@@ -31,12 +31,6 @@ interface CanvasPreviewGridProps {
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中)) * 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
*/ */
voiceAudioUrls?: (string | null)[] voiceAudioUrls?: (string | null)[]
/**
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
* 长度=countundefined 项表示该变体未提供后端时长。
*/
voiceDurations?: (number | undefined)[]
/** /**
* 各变体的后端真实片段(#1744/#1750):长度=count。 * 各变体的后端真实片段(#1744/#1750):长度=count。
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。 * 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
@@ -62,7 +56,6 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
titles, titles,
titleSettings, titleSettings,
voiceAudioUrls, voiceAudioUrls,
voiceDurations,
variantClips, variantClips,
clipsLoading = false, clipsLoading = false,
clipsError = false, clipsError = false,
@@ -127,7 +120,6 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
serverClips={variantClips[i]} serverClips={variantClips[i]}
variantTitle={titles[i] || ""} variantTitle={titles[i] || ""}
voiceAudioUrl={voiceAudioUrls?.[i] || undefined} voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
voiceDurationHint={voiceDurations?.[i]}
activePlayToken={activePlayToken} activePlayToken={activePlayToken}
onPlayTokenChange={setActivePlayToken} onPlayTokenChange={setActivePlayToken}
compact compact
@@ -1,22 +1,26 @@
/** /**
* 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI) * 前端预览播放器 — Canvas + WebCodecs 方案
* *
* 架构: * 架构:
* - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度), * - 浏览器支持 WebCodecs → Canvas 渲染(帧级精确控制 + 标题合成)
* 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。 * - 浏览器不支持 → fallback 到多 video 元素方案
* UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。
* - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除),
* 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。
* *
* 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。 * 对外 API 不变:assets, template, videoRatio, ready, voiceAudioUrl
*/ */
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react" import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons" import {
PlayCircleOutlined,
PauseCircleOutlined,
SoundOutlined,
LoadingOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets" import type { AssetItem } from "@/api/assets"
import type { EditPlanClip } from "@/api/template-editor" import type { EditPlanClip } from "@/api/template-editor"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler" import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { usePreviewAudio } from "../hooks/usePreviewAudio" import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
import { PreviewControls } from "./PreviewControls"
interface FrontendPreviewPlayerProps { interface FrontendPreviewPlayerProps {
assets: AssetItem[] assets: AssetItem[]
videoRatio: string videoRatio: string
@@ -54,11 +58,12 @@ interface FrontendPreviewPlayerProps {
activePlayToken?: number | null activePlayToken?: number | null
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */ /** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
onPlayTokenChange?: (token: number | null) => void onPlayTokenChange?: (token: number | null) => void
/** }
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
* 未提供则在 audio loadedmetadata 后自测兜底。 function formatTime(seconds: number): string {
*/ const m = Math.floor(seconds / 60)
voiceDurationHint?: number const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, "0")}`
} }
/** /**
@@ -111,7 +116,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
ready, ready,
serverClips, serverClips,
voiceAudioUrl, voiceAudioUrl,
voiceDurationHint,
titleSettings, titleSettings,
onTitlePositionChange, onTitlePositionChange,
playToken, playToken,
@@ -120,11 +124,26 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
activePlayToken = null, activePlayToken = null,
onPlayTokenChange, onPlayTokenChange,
}) => { }) => {
// #1754→P0:配音时长作为音画时长锚点。 // #1754:测量配音时长,计算缩放因子
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。 const [voiceDuration, setVoiceDuration] = useState(0)
const [voiceDuration, setVoiceDuration] = useState<number>(() => useEffect(() => {
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0, if (!voiceAudioUrl) {
) setVoiceDuration(0)
return
}
const audio = new Audio()
audio.preload = "metadata"
const onLoaded = () => {
if (audio.duration && isFinite(audio.duration)) {
setVoiceDuration(audio.duration)
}
}
audio.addEventListener("loadedmetadata", onLoaded)
audio.src = voiceAudioUrl
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
}
}, [voiceAudioUrl])
// #1756clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位) // #1756clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
const rawClipsDuration = useMemo(() => { const rawClipsDuration = useMemo(() => {
@@ -170,10 +189,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
? (titleSettings.posY / playRes.height) * 100 ? (titleSettings.posY / playRes.height) * 100
: null : null
// ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)── // ── 拖拽状态(用 ref 避免每帧渲染中触发重渲染)──
const draggingTitleRef = useRef(false) const draggingTitleRef = useRef(false)
const titleDragRef = useRef<HTMLDivElement>(null) const titleDragRef = useRef<HTMLDivElement>(null)
const playerContainerRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback( const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return if (!onTitlePositionChange || !playerContainerRef.current) return
@@ -189,6 +207,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
if (!draggingTitleRef.current || !playerContainerRef.current) return if (!draggingTitleRef.current || !playerContainerRef.current) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
if (titleDragRef.current) { if (titleDragRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect() const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
@@ -203,6 +222,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return if (!draggingTitleRef.current) return
draggingTitleRef.current = false draggingTitleRef.current = false
// 拖拽结束时才调用 onTitlePositionChange 保存最终位置
if (onTitlePositionChange && playerContainerRef.current) { if (onTitlePositionChange && playerContainerRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect() const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
@@ -223,6 +243,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
[onTitlePositionChange, playRes.width, playRes.height], [onTitlePositionChange, playRes.width, playRes.height],
) )
const playerContainerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(0) const [containerHeight, setContainerHeight] = useState(0)
useEffect(() => { useEffect(() => {
const el = playerContainerRef.current const el = playerContainerRef.current
@@ -247,157 +268,234 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100 const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100 const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100 const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
// 描边/阴影也要按缩放比例放大
const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1 const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1
const titleStrokeWidth = Math.max(1, 2 * titleScale) const titleStrokeWidth = Math.max(1, 2 * titleScale)
const titleShadowBlur = 4 * titleScale const titleShadowBlur = 4 * titleScale
const titleShadowOffset = 2 * titleScale const titleShadowOffset = 2 * titleScale
// ── Video 播放器(默认路径,浏览器原生硬件解码) ── // 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
const useWebCodecs = false
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
const canvasRef = useRef<HTMLCanvasElement>(null)
// ── Canvas 播放器(WebCodecs 路径) ──
const canvasTitle = titleSettings
? {
text: effectiveTitle || "标题预览",
fontSize: titleSettings.size,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
position: titleSettings.position || "top",
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
}
: undefined
const canvasSegments = useMemo(
() =>
segments.map((s) => ({
assetId: s.assetId,
videoUrl: s.videoUrl,
startTime: s.startTime,
endTime: s.endTime,
})),
[segments],
)
// WebCodecs 解码失败后强制走 video fallback
const [forceVideoFallback, setForceVideoFallback] = useState(false)
const handleCanvasError = useCallback((err: Error) => {
console.error("[FrontendPreviewPlayer] Canvas decode Error, switching to video fallback:", err)
setForceVideoFallback(true)
}, [])
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
canvasRef,
useWebCodecs && !forceVideoFallback ? canvasSegments : [],
useWebCodecs && !forceVideoFallback ? canvasTitle : undefined,
handleCanvasError,
useWebCodecs && !forceVideoFallback,
)
// WebCodecs 报告解码失败时自动切换到 video fallback
useEffect(() => {
if (canvasState.hasDecodeError && !forceVideoFallback) {
console.warn("[FrontendPreviewPlayer] hasDecodeError detected, forcing video fallback")
setForceVideoFallback(true)
}
}, [canvasState.hasDecodeError, forceVideoFallback])
// ── Video 播放器(fallback 路径) ──
const { const {
isPlaying, isPlaying: videoIsPlaying,
currentTime, currentTime: videoCurrentTime,
totalDuration, totalDuration: videoTotalDuration,
currentSegmentIndex, currentSegmentIndex: videoCurrentSegIdx,
canPlay, canPlay: videoCanPlay,
togglePlayPause, togglePlayPause: videoTogglePlayPause,
seekTo, seekTo: videoSeekTo,
pause, pause: videoPause,
videoRefs, videoRefs,
} = useSegmentScheduler(segments) } = useSegmentScheduler(segments)
// P0 fix:以配音时长为音画同步锚点。 // 选择哪条路径的状态(WebCodecs 解码失败时强制走 video fallback
// 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。 const effectiveUseWebCodecs = useWebCodecs && !forceVideoFallback
const effectiveTotalDuration = const isPlaying = effectiveUseWebCodecs ? canvasState.isPlaying : videoIsPlaying
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration const currentTime = effectiveUseWebCodecs ? canvasState.currentTime : videoCurrentTime
const totalDuration = effectiveUseWebCodecs ? canvasState.duration : videoTotalDuration
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片 // 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
const [muted, setMuted] = useState(false) const [muted, setMuted] = useState(false)
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底 // 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
const hasVoice = !!voiceAudioUrl const hasVoice = !!voiceAudioUrl
// 音频 ended:兜底触发暂停与释放播放权
const handleAudioEnded = useCallback(() => {
if (!isPlaying) return
pause()
if (playToken != null) onPlayTokenChange?.(null)
}, [isPlaying, pause, playToken, onPlayTokenChange])
const {
seekTo: audioSeekTo,
ensurePlayingAt: audioEnsurePlayingAt,
pause: audioPause,
} = usePreviewAudio({
voiceAudioUrl,
voiceDurationHint,
muted,
isPlaying,
currentTime,
onVoiceDurationChange: setVoiceDuration,
onEnded: handleAudioEnded,
})
// 片段切换时同步音频时间(video fallback
useEffect(() => { useEffect(() => {
if (!isPlaying) return if (!voiceAudioUrl) {
audioSeekTo(currentTime) if (audioRef.current) {
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿 audioRef.current.pause()
// eslint-disable-next-line react-hooks/exhaustive-deps audioRef.current.src = ""
}, [currentSegmentIndex, isPlaying]) audioRef.current = null
}
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
// 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
const tailStartRef = useRef<number>(0)
const tailBaseRef = useRef<number>(0)
const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause })
tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }
useEffect(() => {
const needTail =
!!voiceAudioUrl &&
voiceDuration > 0 &&
!isPlaying &&
typeof currentTime === "number" &&
currentTime >= totalDuration - 0.1 &&
currentTime < voiceDuration - 0.1
if (needTail && tailCurrentTime === null) {
tailBaseRef.current = currentTime
tailStartRef.current = performance.now()
setTailCurrentTime(currentTime)
tailAudioRef.current.ensurePlayingAt(currentTime)
return return
} }
if (!needTail && tailCurrentTime !== null) { if (!audioRef.current) {
setTailCurrentTime(null) audioRef.current = new Audio()
audioRef.current.preload = "auto"
} }
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime]) if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
audioRef.current.muted = muted
}, [voiceAudioUrl, muted])
useEffect(() => { useEffect(() => {
if (tailCurrentTime === null) return const audio = audioRef.current
let raf = 0 if (!audio || !audio.src) return
const tick = () => { if (isPlaying && !prevIsPlayingRef.current) {
const elapsed = (performance.now() - tailStartRef.current) / 1000 audio.currentTime = currentTime
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current) audio.play().catch(() => {})
setTailCurrentTime(t) } else if (!isPlaying && prevIsPlayingRef.current) {
tailAudioRef.current.ensurePlayingAt(t) audio.pause()
if (t >= (voiceDuration || 0) - 0.05) {
tailAudioRef.current.pause()
if (playToken != null) onPlayTokenChange?.(null)
setTailCurrentTime(null)
return
}
raf = requestAnimationFrame(tick)
} }
raf = requestAnimationFrame(tick) prevIsPlayingRef.current = isPlaying
return () => cancelAnimationFrame(raf) }, [isPlaying, currentTime])
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间 // 片段切换时同步音频(仅 fallback 路径需要)
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src || !isPlaying) return
audio.currentTime = currentTime
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segmentSyncKey, isPlaying])
const handleSeekTo = useCallback( const handleSeekTo = useCallback(
(time: number) => { (time: number) => {
setTailCurrentTime(null) if (effectiveUseWebCodecs) {
seekTo(time) canvasControls.seek(time)
audioSeekTo(time) } else {
videoSeekTo(time)
}
const audio = audioRef.current
if (audio && audio.src) {
audio.currentTime = time
}
}, },
[seekTo, audioSeekTo], [effectiveUseWebCodecs, canvasControls, videoSeekTo],
) )
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ── // ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
useEffect(() => { useEffect(() => {
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
if (isPlaying) { if (effectiveUseWebCodecs) {
pause() if (canvasState.isPlaying) canvasControls.pause()
} else if (isPlaying) {
videoPause()
} }
// isPlaying 不放依赖:只在 token 变化时执行一次暂停 // isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePlayToken, playToken]) }, [activePlayToken, playToken, effectiveUseWebCodecs])
const handleTogglePlay = useCallback(() => { const handleTogglePlay = useCallback(() => {
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken) if (effectiveUseWebCodecs) {
togglePlayPause() if (canvasState.isPlaying) {
}, [togglePlayPause, isPlaying, playToken, onPlayTokenChange]) canvasControls.pause()
onPlayTokenChange?.(null)
// P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结) } else {
useEffect(() => { if (playToken != null) onPlayTokenChange?.(playToken)
if (!isPlaying) return canvasControls.play()
if (!voiceAudioUrl || voiceDuration <= 0) return }
if (displayCurrentTime < voiceDuration - 0.08) return } else {
pause() // video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
audioPause() if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
if (playToken != null) onPlayTokenChange?.(null) videoTogglePlayPause()
}
}, [ }, [
effectiveUseWebCodecs,
canvasState.isPlaying,
canvasControls,
videoTogglePlayPause,
isPlaying, isPlaying,
displayCurrentTime,
voiceAudioUrl,
voiceDuration,
pause,
audioPause,
playToken, playToken,
onPlayTokenChange, onPlayTokenChange,
]) ])
// ── 进度条拖拽 ──
const [isDragging, setIsDragging] = useState(false)
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
handleSeekTo(ratio * totalDuration)
},
[totalDuration, handleSeekTo],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
handleProgressClick(e)
},
[handleProgressClick],
)
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
handleSeekTo(ratio * totalDuration)
}
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, totalDuration, handleSeekTo])
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ──
const canvasContainerRef = useRef<HTMLDivElement>(null)
// ── 未就绪 ── // ── 未就绪 ──
if (!ready || !assets.length) { if (!ready || !assets.length) {
return ( return (
@@ -431,6 +529,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── 无播放片段 ── // ── 无播放片段 ──
if (!canPlay) { if (!canPlay) {
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
return ( return (
<div <div
style={{ style={{
@@ -450,15 +549,48 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
padding: 24, padding: 24,
}} }}
> >
<PlayCircleOutlined {isBuffering ? (
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }} <>
/> <LoadingOutlined style={{ fontSize: 40, color: "#fff", marginBottom: 12 }} spin />
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}> <p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14, margin: 0 }}>...</p>
</>
</p> ) : showDecodeError ? (
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}> <>
<PlayCircleOutlined style={{ fontSize: 40, color: "#ef4444", marginBottom: 12 }} />
</p> <p
style={{
color: "rgba(255,255,255,0.9)",
fontSize: 14,
margin: "0 0 4px",
fontWeight: 500,
}}
>
</p>
<p
style={{
color: "rgba(255,255,255,0.5)",
fontSize: 12,
margin: 0,
textAlign: "center",
}}
>
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
</p>
</>
) : (
<>
<PlayCircleOutlined
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
/>
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
</p>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
</p>
</>
)}
</div> </div>
) )
} }
@@ -480,30 +612,53 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
: "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)", : "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
}} }}
> >
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码 ── */} {/* ── Canvas 渲染层(WebCodecs 路径 ── */}
{segments.map((seg, i) => ( {effectiveUseWebCodecs && (
<video <div
key={seg.assetId} ref={canvasContainerRef}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1, zIndex: 1,
opacity: i === currentSegmentIndex ? 1 : 0, background: "#000",
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
}} }}
muted={hasVoice || muted} >
playsInline <canvas
/> ref={canvasRef}
))} style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</div>
)}
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
{!effectiveUseWebCodecs &&
segments.map((seg, i) => (
<video
key={seg.assetId}
muted={hasVoice || muted}
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
background: "#000",
zIndex: 1,
opacity: i === videoCurrentSegIdx ? 1 : 0,
pointerEvents: i === videoCurrentSegIdx ? "auto" : "none",
}}
playsInline
/>
))}
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */} {/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
{titleSettings?.title && ( {titleSettings?.title && (
@@ -580,19 +735,200 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
</div> </div>
)} )}
<PreviewControls {/* 中央播放按钮 */}
isPlaying={isPlaying} {!isPlaying && (
onTogglePlay={handleTogglePlay} <button
muted={muted} onClick={handleTogglePlay}
onToggleMute={() => setMuted((m) => !m)} style={{
hasSegments={segments.length > 0} position: "absolute",
segmentIndex={currentSegmentIndex} top: "50%",
segmentCount={segments.length} left: "50%",
currentTime={displayCurrentTime} transform: "translate(-50%, -50%)",
totalDuration={effectiveTotalDuration} background: "rgba(0,0,0,0.45)",
onSeek={handleSeekTo} backdropFilter: "blur(12px)",
compact={compact} WebkitBackdropFilter: "blur(12px)",
/> border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
{segments.length > 0 && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={() => setMuted((m) => !m)}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${videoCurrentSegIdx + 1} / ${segments.length}`}
</div>
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={handleTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatTime(currentTime)} / {formatTime(totalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</div>
</div> </div>
) )
} }
@@ -1,195 +0,0 @@
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
AudioOutlined,
AudioMutedOutlined,
} from "@ant-design/icons"
import { PreviewProgressBar } from "./PreviewProgressBar"
interface PreviewControlsProps {
isPlaying: boolean
onTogglePlay: () => void
muted: boolean
onToggleMute: () => void
hasSegments: boolean
segmentIndex: number
segmentCount: number
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条)
*/
export const PreviewControls: React.FC<PreviewControlsProps> = ({
isPlaying,
onTogglePlay,
muted,
onToggleMute,
hasSegments,
segmentIndex,
segmentCount,
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
return (
<>
{/* 静音/有声切换(#1741):左上角 */}
{hasSegments && (
<button
type="button"
aria-label={muted ? "取消静音" : "静音"}
title={muted ? "取消静音" : "静音"}
onClick={onToggleMute}
style={{
position: "absolute",
top: 8,
left: 8,
width: compact ? 26 : 30,
height: compact ? 26 : 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
fontSize: compact ? 13 : 15,
cursor: "pointer",
zIndex: 10,
padding: 0,
transition: "background 0.15s, color 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
</button>
)}
{/* 片段指示器 — 右上角胶囊 */}
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.9)",
fontSize: compact ? 9 : 10,
fontWeight: 500,
padding: compact ? "1px 6px" : "2px 8px",
borderRadius: 999,
zIndex: 10,
border: "1px solid rgba(255,255,255,0.1)",
letterSpacing: 0.3,
}}
>
{`${segmentIndex + 1} / ${segmentCount}`}
</div>
{/* 中央播放按钮 */}
{!isPlaying && (
<button
onClick={onTogglePlay}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0,0,0,0.45)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.15)",
borderRadius: "50%",
width: 52,
height: 52,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 26,
zIndex: 10,
transition: "transform 0.2s ease, background 0.2s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
}}
>
<PlayCircleOutlined />
</button>
)}
{/* 控制条 — 手机风格毛玻璃 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
gap: compact ? 6 : 10,
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<button
onClick={onTogglePlay}
style={{
background: "rgba(255,255,255,0.15)",
border: "none",
color: "#fff",
fontSize: compact ? 14 : 16,
cursor: "pointer",
width: compact ? 26 : 32,
height: compact ? 26 : 32,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
}}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<PreviewProgressBar
currentTime={currentTime}
totalDuration={totalDuration}
onSeek={onSeek}
compact={compact}
/>
</div>
</>
)
}
@@ -1,108 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react"
import { formatDuration } from "../utils/formatDuration"
interface PreviewProgressBarProps {
currentTime: number
totalDuration: number
onSeek: (time: number) => void
compact?: boolean
}
/**
* 进度条组件:点击/拖拽 seek
*/
export const PreviewProgressBar: React.FC<PreviewProgressBarProps> = ({
currentTime,
totalDuration,
onSeek,
compact = false,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)
const seekByClientX = useCallback(
(clientX: number) => {
if (!progressRef.current || totalDuration <= 0) return
const rect = progressRef.current.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
onSeek(ratio * totalDuration)
},
[totalDuration, onSeek],
)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
seekByClientX(e.clientX)
},
[seekByClientX],
)
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX)
const handleMouseUp = () => setIsDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, seekByClientX])
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
return (
<>
<span
style={{
fontSize: compact ? 10 : 11,
color: "rgba(255,255,255,0.85)",
minWidth: compact ? 58 : 72,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.2,
}}
>
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
</span>
<div
ref={progressRef}
onMouseDown={handleMouseDown}
style={{
flex: 1,
height: 3,
background: "rgba(255,255,255,0.2)",
borderRadius: 2,
cursor: "pointer",
position: "relative",
}}
>
<div
style={{
height: "100%",
width: `${progressPercent}%`,
background: "#fff",
borderRadius: 2,
transition: isDragging ? "none" : "width 0.1s linear",
}}
/>
<div
style={{
position: "absolute",
top: "50%",
left: `${progressPercent}%`,
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
opacity: isDragging ? 1 : 0,
transition: "opacity 0.15s",
}}
/>
</div>
</>
)
}
@@ -171,6 +171,7 @@ export function useBatchCovers({
let okCount = 0 let okCount = 0
let failCount = 0 let failCount = 0
for (const i of pending) { for (const i of pending) {
// eslint-disable-next-line no-await-in-loop
const ok = await generateOne(i) const ok = await generateOne(i)
if (ok) okCount += 1 if (ok) okCount += 1
else failCount += 1 else failCount += 1
@@ -26,8 +26,6 @@ export interface BatchVariantClipsState {
clipsByVariant: EditPlanClip[][] clipsByVariant: EditPlanClip[][]
/** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */ /** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */
planIdsByVariant: string[] planIdsByVariant: string[]
/** 各变体的后端返回配音时长(秒);未就绪/未返回为 undefined */
voiceDurationsByVariant: (number | undefined)[]
/** 是否正在向后端申请变体计划 */ /** 是否正在向后端申请变体计划 */
loading: boolean loading: boolean
/** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */ /** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */
@@ -46,12 +44,6 @@ interface UseBatchVariantPlansOptions {
assetIds: string[] assetIds: string[]
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */ /** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
sourcePlanId?: string sourcePlanId?: string
/** 统一配音 ID(共用配音模式),参考 useGenerateVideo voiceLibraryId 计算 */
voiceLibraryId?: string
/** 独立配音 ID 列表(每变体一条),voiceModePerVideo=true 时使用 */
voiceLibraryIds?: string[]
/** 是否启用独立配音模式(每变体各自一条配音) */
voiceModePerVideo?: boolean
} }
export function useBatchVariantPlans({ export function useBatchVariantPlans({
@@ -60,13 +52,9 @@ export function useBatchVariantPlans({
templateId, templateId,
assetIds, assetIds,
sourcePlanId = "", sourcePlanId = "",
voiceLibraryId = "",
voiceLibraryIds = [],
voiceModePerVideo = false,
}: UseBatchVariantPlansOptions): BatchVariantClipsState { }: UseBatchVariantPlansOptions): BatchVariantClipsState {
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([]) const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([]) const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
const [voiceDurationsByVariant, setVoiceDurationsByVariant] = useState<(number | undefined)[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState(false) const [error, setError] = useState(false)
@@ -82,30 +70,17 @@ export function useBatchVariantPlans({
setLoading(true) setLoading(true)
setError(false) setError(false)
try { try {
// 配音参数:与 useGenerateVideo 保持一致的传参逻辑
// - 独立配音模式 + voiceLibraryIds 非空:传 voice_library_ids
// - 统一配音:传 voice_library_id
// - 都没选:不传
const voiceParam: { voice_library_id?: string; voice_library_ids?: string[] } = {}
if (voiceModePerVideo && voiceLibraryIds.length > 0) {
voiceParam.voice_library_ids = voiceLibraryIds
} else if (voiceLibraryId) {
voiceParam.voice_library_id = voiceLibraryId
}
const resp = await createBatchVariantPlans({ const resp = await createBatchVariantPlans({
template_id: templateId, template_id: templateId,
asset_ids: assetIds, asset_ids: assetIds,
count, count,
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}), ...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
...voiceParam,
}) })
if (seq !== requestSeqRef.current) return if (seq !== requestSeqRef.current) return
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : [] const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
const clips: EditPlanClip[][] = Array.from({ length: count }, () => []) const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
const planIds: string[] = Array.from({ length: count }, () => "") const planIds: string[] = Array.from({ length: count }, () => "")
const voiceDurs: (number | undefined)[] = Array.from({ length: count }, () => undefined)
for (const item of items) { for (const item of items) {
const idx = item.variant_index const idx = item.variant_index
if (idx < 0 || idx >= count) continue if (idx < 0 || idx >= count) continue
@@ -113,9 +88,6 @@ export function useBatchVariantPlans({
clips[idx] = (item.clips || []) clips[idx] = (item.clips || [])
.filter((c) => c && c.asset_id && c.status === "ready") .filter((c) => c && c.asset_id && c.status === "ready")
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
if (typeof item.voice_duration === "number" && item.voice_duration > 0) {
voiceDurs[idx] = item.voice_duration
}
} }
// 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充) // 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充)
const incomplete = clips.some((list) => list.length === 0) const incomplete = clips.some((list) => list.length === 0)
@@ -123,12 +95,10 @@ export function useBatchVariantPlans({
console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败") console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败")
setClipsByVariant([]) setClipsByVariant([])
setPlanIdsByVariant([]) setPlanIdsByVariant([])
setVoiceDurationsByVariant([])
setError(true) setError(true)
} else { } else {
setClipsByVariant(clips) setClipsByVariant(clips)
setPlanIdsByVariant(planIds) setPlanIdsByVariant(planIds)
setVoiceDurationsByVariant(voiceDurs)
setError(false) setError(false)
} }
} catch (err) { } catch (err) {
@@ -137,20 +107,11 @@ export function useBatchVariantPlans({
console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err) console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err)
setClipsByVariant([]) setClipsByVariant([])
setPlanIdsByVariant([]) setPlanIdsByVariant([])
setVoiceDurationsByVariant([])
setError(true) setError(true)
} finally { } finally {
if (seq === requestSeqRef.current) setLoading(false) if (seq === requestSeqRef.current) setLoading(false)
} }
}, [ }, [templateId, count, sourcePlanId, assetIds])
templateId,
count,
sourcePlanId,
assetIds,
voiceLibraryId,
voiceLibraryIds,
voiceModePerVideo,
])
/** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */ /** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */
const retry = useCallback(() => { const retry = useCallback(() => {
@@ -164,40 +125,24 @@ export function useBatchVariantPlans({
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环 // 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
setClipsByVariant((prev) => (prev.length === 0 ? prev : [])) setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : [])) setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
setVoiceDurationsByVariant((prev) => (prev.length === 0 ? prev : []))
setLoading((prev) => (prev === false ? prev : false)) setLoading((prev) => (prev === false ? prev : false))
setError((prev) => (prev === false ? prev : false)) setError((prev) => (prev === false ? prev : false))
lastKeyRef.current = "" lastKeyRef.current = ""
return return
} }
const voiceKey = voiceModePerVideo
? `per:${[...voiceLibraryIds].sort().join(",")}`
: `one:${voiceLibraryId}`
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds] const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
.sort() .sort()
.join(",")}|${voiceKey}` .join(",")}`
if (key === lastKeyRef.current) return if (key === lastKeyRef.current) return
lastKeyRef.current = key lastKeyRef.current = key
load() load()
}, [ }, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce])
enabled,
templateId,
count,
sourcePlanId,
assetIds,
load,
retryNonce,
voiceLibraryId,
voiceLibraryIds,
voiceModePerVideo,
])
const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0) const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0)
return { return {
clipsByVariant, clipsByVariant,
planIdsByVariant, planIdsByVariant,
voiceDurationsByVariant,
loading, loading,
ready, ready,
error, error,
File diff suppressed because it is too large Load Diff
@@ -51,6 +51,7 @@ export function useTitleCoverSync({
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url, thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
})) }))
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTemplate, setTitleSettings, setCoverSettings]) }, [selectedTemplate, setTitleSettings, setCoverSettings])
// ↑ 移除 userTemplates,只在 selectedTemplate 真正变化时触发 // ↑ 移除 userTemplates,只在 selectedTemplate 真正变化时触发
} }
@@ -1,131 +0,0 @@
import { useCallback, useEffect, useRef } from "react"
interface UsePreviewAudioOptions {
voiceAudioUrl: string | undefined
voiceDurationHint: number | undefined
muted: boolean
isPlaying: boolean
currentTime: number
onVoiceDurationChange: (d: number) => void
onEnded: () => void
}
interface UsePreviewAudioReturn {
seekTo: (time: number) => void
ensurePlayingAt: (time: number) => void
pause: () => void
}
/**
* 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、
* ended 事件回调、seek 同步、末帧冻结期间续播。
*/
export function usePreviewAudio({
voiceAudioUrl,
voiceDurationHint,
muted,
isPlaying,
currentTime,
onVoiceDurationChange,
onEnded,
}: UsePreviewAudioOptions): UsePreviewAudioReturn {
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 外部 hint 初始化(自测值前的兜底)
useEffect(() => {
if (voiceDurationHint && voiceDurationHint > 0) {
onVoiceDurationChange(voiceDurationHint)
}
}, [voiceDurationHint, onVoiceDurationChange])
// 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended
useEffect(() => {
if (!voiceAudioUrl) {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
audioRef.current = null
}
return
}
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.preload = "auto"
}
if (audioRef.current.src !== voiceAudioUrl) {
audioRef.current.src = voiceAudioUrl
}
audioRef.current.muted = muted
const audio = audioRef.current
const onLoaded = () => {
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
onVoiceDurationChange(audio.duration)
}
}
const onEndedHandler = () => onEnded()
audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("ended", onEndedHandler)
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("ended", onEndedHandler)
}
}, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded])
// mute 变化即时同步
useEffect(() => {
if (audioRef.current) audioRef.current.muted = muted
}, [muted])
// 播放/暂停同步(跟随视频 isPlaying
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying && !prevIsPlayingRef.current) {
if (Math.abs(audio.currentTime - currentTime) > 0.3) {
try {
audio.currentTime = currentTime
} catch {
/* ignore */
}
}
audio.play().catch(() => {})
} else if (!isPlaying && prevIsPlayingRef.current) {
audio.pause()
}
prevIsPlayingRef.current = isPlaying
}, [isPlaying, currentTime])
const seekTo = useCallback((time: number) => {
const audio = audioRef.current
if (audio && audio.src) {
try {
audio.currentTime = time
} catch {
/* ignore */
}
}
}, [])
const ensurePlayingAt = useCallback((time: number) => {
const audio = audioRef.current
if (!audio || !audio.src) return
try {
if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time
if (audio.paused) audio.play().catch(() => {})
} catch {
/* ignore */
}
}, [])
const pause = useCallback(() => {
try {
audioRef.current?.pause()
} catch {
/* ignore */
}
}, [])
return { seekTo, ensurePlayingAt, pause }
}
@@ -125,6 +125,7 @@ export function useVariantVoicePreview({
continue continue
} }
try { try {
// eslint-disable-next-line no-await-in-loop
const res = await previewTts({ text: job.title, voice_id: job.voiceId }) const res = await previewTts({ text: job.title, voice_id: job.voiceId })
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
const audioUrl = res.audio_url || "" const audioUrl = res.audio_url || ""
+25 -77
View File
@@ -1,22 +1,17 @@
/** /**
* 成片库页面 — V21 设计系统 * 成片库页面 — V21 设计系统
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选、无限滚动分页 * 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
* *
* 主组件仅保留 Hook 组装与整体布局 * 主组件仅保留 Hook 组装与整体布局
* 列表查询 → hooks/useProductListuseInfiniteQuery 分页) * 列表查询 → hooks/useProductList
* 操作逻辑 → hooks/useProductActions * 操作逻辑 → hooks/useProductActions
* 筛选栏 → components/ProductFilterBar * 筛选栏 → components/ProductFilterBar
* 批量操作栏 → components/ProductBatchBar * 批量操作栏 → components/ProductBatchBar
* 空状态 → components/ProductEmptyState * 空状态 → components/ProductEmptyState
* 产品卡片 → components/ProductCard(内联视频播放) * 产品卡片 → components/ProductCard(内联视频播放)
*/ */
import React, { useEffect, useRef } from "react" import React from "react"
import { import { VideoCameraOutlined, DownloadOutlined, ReloadOutlined } from "@ant-design/icons"
VideoCameraOutlined,
DownloadOutlined,
ReloadOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui" import { Button } from "@/components/ui"
import { ProductCard } from "./components/ProductCard" import { ProductCard } from "./components/ProductCard"
import { ProductFilterBar } from "./components/ProductFilterBar" import { ProductFilterBar } from "./components/ProductFilterBar"
@@ -29,13 +24,11 @@ import "./products.css"
const ProductLibrary: React.FC = () => { const ProductLibrary: React.FC = () => {
const { const {
products,
filteredProducts, filteredProducts,
isLoading, isLoading,
isFetchingNextPage,
isError, isError,
error, error,
hasNextPage,
fetchNextPage,
refetch, refetch,
searchText, searchText,
setSearchText, setSearchText,
@@ -71,40 +64,19 @@ const ProductLibrary: React.FC = () => {
} = useProductActions({ } = useProductActions({
selectedIds, selectedIds,
clearSelection, clearSelection,
products: filteredProducts, products,
setPlayingProduct: () => {}, // 不再使用弹窗播放 setPlayingProduct: () => {}, // 不再使用弹窗播放
}) })
const { recomputeDedup, isRecomputing } = useRecomputeDedup() const { recomputeDedup, isRecomputing } = useRecomputeDedup()
/* ── 无限滚动:IntersectionObserver 监听底部哨兵元素 ── */ // ── Loading 状态 ──
const sentinelRef = useRef<HTMLDivElement>(null) if (isLoading) {
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) {
return <ProductEmptyState type="loading" /> return <ProductEmptyState type="loading" />
} }
// ── Error 状态 ── // ── Error 状态 ──
if (isError && filteredProducts.length === 0) { if (isError) {
console.error("[ProductLibrary] 加载失败:", error) console.error("[ProductLibrary] 加载失败:", error)
const errorMsg = error?.message || "加载失败" const errorMsg = error?.message || "加载失败"
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found") const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found")
@@ -171,46 +143,22 @@ const ProductLibrary: React.FC = () => {
{/* 卡片网格 */} {/* 卡片网格 */}
{filteredProducts.length > 0 ? ( {filteredProducts.length > 0 ? (
<> <div className="xx-products-grid">
<div className="xx-products-grid"> {filteredProducts.map((product) => (
{filteredProducts.map((product) => ( <ProductCard
<ProductCard key={product.id}
key={product.id} product={product}
product={product} isSelected={selectedIds.has(product.id)}
isSelected={selectedIds.has(product.id)} batchMode={batchMode}
batchMode={batchMode} onToggleSelect={handleToggleSelect}
onToggleSelect={handleToggleSelect} onDownload={handleDownload}
onDownload={handleDownload} onShare={handleShare}
onShare={handleShare} onDelete={handleDelete}
onDelete={handleDelete} onPublish={handlePublish}
onPublish={handlePublish} onReviewStatusChange={handleReviewStatusChange}
onReviewStatusChange={handleReviewStatusChange} />
/> ))}
))} </div>
</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>
</>
) : ( ) : (
<ProductEmptyState type="empty" /> <ProductEmptyState type="empty" />
)} )}
@@ -1,53 +1,28 @@
import { useMemo } from "react" 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 { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
import { mapApiProduct } from "../../utils" import { mapApiProduct } from "../../utils"
import type { ProductItem } from "../../types"
import { useProductFiltering } from "./useProductFiltering" import { useProductFiltering } from "./useProductFiltering"
import { useBatchSelection } from "./useBatchSelection" import { useBatchSelection } from "./useBatchSelection"
export type { Filters } from "./useProductFiltering" export type { Filters } from "./useProductFiltering"
const PAGE_SIZE = 20
export const useProductList = () => { export const useProductList = () => {
/* ── 无限滚动获取成品列表(每页 20 条) ── */ /* ── 获取成品列表 ── */
const { const {
data, data: apiProducts = [],
isLoading, isLoading,
isFetchingNextPage,
isError, isError,
error, error,
hasNextPage,
fetchNextPage,
refetch, refetch,
} = useInfiniteQuery< } = useQuery<ApiProductItem[], Error>({
{
items: ApiProductItem[]
total: number
page: number
page_size: number
},
Error
>({
queryKey: ["products"], queryKey: ["products"],
queryFn: async ({ pageParam = 1 }) => queryFn: () => getProducts(),
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
},
staleTime: 30_000, staleTime: 30_000,
}) })
// 将所有页拼接为一维数组,再做前端映射+排序 // 映射为前端类型,按创建时间倒序排列,防御非数组返回
const apiProducts = useMemo<ApiProductItem[]>(() => { const products = useMemo(
if (!data?.pages) return []
return data.pages.flatMap((p) => p.items)
}, [data])
const products = useMemo<ProductItem[]>(
() => () =>
(Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => { (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => {
if (!a.date || a.date === "—") return 1 if (!a.date || a.date === "—") return 1
@@ -90,11 +65,8 @@ export const useProductList = () => {
products, products,
filteredProducts, filteredProducts,
isLoading, isLoading,
isFetchingNextPage,
isError, isError,
error, error,
hasNextPage,
fetchNextPage,
refetch, refetch,
// 筛选 // 筛选
searchText, searchText,
@@ -1,7 +1,7 @@
/** /**
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750 * FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750
* *
* useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑: * useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
* - 有配音时 video 保持 muted(素材原声不与配音混音) * - 有配音时 video 保持 muted(素材原声不与配音混音)
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声) * - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换 * - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
@@ -221,18 +221,17 @@ class RenderAdapter:
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
stderr_text = (exc.stderr or "").strip() stderr_text = (exc.stderr or "").strip()
stderr_tail = stderr_text[-5000:] if len(stderr_text) > 5000 else stderr_text
logger.error( logger.error(
"[render-adapter] ffmpeg渲染失败: plan_id=%s job_id=%s exit_code=%d\nstderr:\n%s", "[render-adapter] ffmpeg渲染失败: plan_id=%s job_id=%s exit_code=%d\nstderr:\n%s",
plan_id, plan_id,
job_id, job_id,
exc.returncode, exc.returncode,
stderr_tail, stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
) )
return RenderAdapterResult( return RenderAdapterResult(
success=False, success=False,
error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[-500:]}", error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[:200]}",
error_detail=stderr_tail, error_detail=stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
) )
except Exception as exc: except Exception as exc:
logger.exception( logger.exception(
@@ -722,17 +721,16 @@ class RenderAdapter:
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
stderr_text = (exc.stderr or "").strip() stderr_text = (exc.stderr or "").strip()
stderr_tail = stderr_text[-5000:] if len(stderr_text) > 5000 else stderr_text
logger.error( logger.error(
"[render-adapter] 内存模式渲染失败: plan_id=%s exit_code=%d\nstderr:\n%s", "[render-adapter] 内存模式渲染失败: plan_id=%s exit_code=%d\nstderr:\n%s",
actual_plan_id, actual_plan_id,
exc.returncode, exc.returncode,
stderr_tail, stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
) )
return RenderAdapterResult( return RenderAdapterResult(
success=False, success=False,
error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[-500:]}", error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[:200]}",
error_detail=stderr_tail, error_detail=stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
) )
except Exception as exc: except Exception as exc:
logger.exception( logger.exception(
@@ -2301,13 +2301,13 @@ class UnifiedRenderService:
filters.append(f"eq=contrast={contrast:.3f}") filters.append(f"eq=contrast={contrast:.3f}")
elif filt == "color_balance": elif filt == "color_balance":
# RGB 通道偏移:colorbalance=rs=...:gs=...:bs=... # RGB 通道偏移:color_balance=rs=...:gs=...:bs=...
r = pixel_pert.get("color_r", 0) r = pixel_pert.get("color_r", 0)
g = pixel_pert.get("color_g", 0) g = pixel_pert.get("color_g", 0)
b = pixel_pert.get("color_b", 0) b = pixel_pert.get("color_b", 0)
if r != 0 or g != 0 or b != 0: if r != 0 or g != 0 or b != 0:
# color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换 # color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换
filters.append(f"colorbalance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}") filters.append(f"color_balance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}")
@staticmethod @staticmethod
def _clip_volume(clip: ResolvedClip) -> float: def _clip_volume(clip: ResolvedClip) -> float:
-2
View File
@@ -34,8 +34,6 @@ celery_app.conf.imports = (
"worker_app.tasks.tts_synthesis", "worker_app.tasks.tts_synthesis",
"worker_app.tasks.batch_download", "worker_app.tasks.batch_download",
"worker_app.tasks.duplication_check", "worker_app.tasks.duplication_check",
# #1798 AI 数字人渲染:必须在 Worker 实例上注册同名任务,否则消息无人消费(渲染卡 0%)
"worker_app.tasks.ai_avatar_render",
"worker_app.tasks._startup", "worker_app.tasks._startup",
"apps.worker.video_processing.dedup", "apps.worker.video_processing.dedup",
"worker_app.tasks.cleanup", "worker_app.tasks.cleanup",
@@ -1,48 +0,0 @@
"""AI 数字人渲染任务 — Worker 侧 Celery 任务注册.
#1798 渲染进度卡在 0% 的根因:渲染任务定义在 API 侧(`app.tasks.ai_avatar_render`),
装饰在 API 自己的 Celery 实例(`xiaoxia-saas-api`)上;而 Worker 用的是
`worker_app.celery_app` 实例,`conf.imports` 从未导入该任务,Worker 的任务
注册表里没有 `ai_avatar_render.execute`,消息被路由到默认 `celery` 队列后
无人消费,任务永远停在 0%
修复:在 Worker 侧用 `worker_app.celery_app` 注册同名任务,直接调用与 API
服务一致的 `AiAvatarRenderService.execute_render` 核心管线(业务逻辑在
`apps.api.app.services`worker 镜像已复制 `apps/api/app`)。任务名保持
`ai_avatar_render.execute`,与 API 生产端 `.delay()` 的消息路由一致;未在
task_routes 显式配置,走默认 `celery` 队列,由 transcode worker 消费。
"""
from __future__ import annotations
import logging
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="ai_avatar_render.execute", max_retries=2)
def execute_ai_avatar_render(self, job_id: str) -> dict:
"""执行 AI 数字人渲染管线(Worker 侧入口).
进度由 service 直接写入 DBAiAvatarRenderJob.progress:
0→5→20→40→80→90→95→100),API 通过轮询 progress 字段展示。
"""
logger.info("开始执行渲染任务: %s", job_id)
self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id})
session = SessionLocal()
try:
from app.services.ai_avatar_render_service import AiAvatarRenderService
service = AiAvatarRenderService(session)
service.execute_render(job_id)
return {"status": "completed", "job_id": job_id}
except Exception as exc:
logger.exception("渲染任务执行异常 [%s]: %s", job_id, exc)
self.update_state(state="FAILED", meta={"progress": 0, "error": str(exc)})
raise
finally:
session.close()
+1 -2
View File
@@ -705,8 +705,7 @@ def _render_from_edit_plan(
) )
if not result.success: if not result.success:
detail_suffix = f"\n[detail] {result.error_detail}" if result.error_detail else "" raise RuntimeError(f"渲染失败: {result.error_message}")
raise RuntimeError(f"渲染失败: {result.error_message}{detail_suffix}")
render_elapsed = time.monotonic() - render_start render_elapsed = time.monotonic() - render_start
logger.info( logger.info(
+9 -35
View File
@@ -1,49 +1,23 @@
#!/bin/sh #!/bin/sh
# Select nginx config based on APP_ENV (staging/production). # Select nginx config based on APP_ENV (staging/production).
# # Both configs are baked into the image at well-known paths.
# 两种运行模式: # nginx reads config only at startup, so symlink before exec.
# 1. CI/CD 部署(staging/production):部署脚本通过 `-v 宿主机文件:/etc/nginx/conf.d/default.conf:ro` # 注意:基础镜像 /etc/nginx/conf.d/default.conf 是普通文件(非 symlink/目录),
# 把宿主机生成的带 resolver/docker upstream 的配置 bind mount 进来,entrypoint 不应改动。 # alpine busybox ln -sf 在 target 已存在且为普通文件时行为不稳定(会尝试在
# bind mount 的文件是 readonly 的,rm 会报 EBUSY ("Resource busy"),直接 exec nginx 即可 # target 目录下建子链接),必须先 rm 再 ln 才能正确替换
# 2. 本地 docker-compose / 直接 `docker run`(无外部挂载):镜像烤入了 nginx-staging.conf 与
# nginx-production.conf 到 /etc/nginx/entrypoint 根据 APP_ENV 把 default.conf 换成正确的 symlink。
#
# 策略:
# - 如果 /etc/nginx/conf.d/default.conf 已经是指向目标 conf 的 symlink,什么都不做;
# - 否则尝试 rm -f 再 ln -srm 失败说明是外部 bind mount(已有正确配置),不阻塞启动;
# - 兜底:只要 conf.d 目录里有 .conf 文件(含 bind mount 来的),就直接启动 nginx。
set -e set -e
NGINX_CONF_DIR="/etc/nginx/conf.d" NGINX_CONF_DIR="/etc/nginx/conf.d"
TARGET_CONF=""
case "${APP_ENV:-production}" in case "${APP_ENV:-production}" in
staging) staging)
TARGET_CONF="/etc/nginx/nginx-staging.conf" rm -f "$NGINX_CONF_DIR/default.conf"
ln -s /etc/nginx/nginx-staging.conf "$NGINX_CONF_DIR/default.conf"
;; ;;
*) *)
TARGET_CONF="/etc/nginx/nginx-production.conf" rm -f "$NGINX_CONF_DIR/default.conf"
ln -s /etc/nginx/nginx-production.conf "$NGINX_CONF_DIR/default.conf"
;; ;;
esac esac
DEFAULT_CONF="$NGINX_CONF_DIR/default.conf"
# 1. 已经是正确的 symlink:直接启动
if [ -L "$DEFAULT_CONF" ] && [ "$(readlink "$DEFAULT_CONF" 2>/dev/null)" = "$TARGET_CONF" ]; then
exec nginx -g "daemon off;"
fi
# 2. 尝试替换为目标 symlink(无 bind mount 的场景)
# 若 rm 失败(bind mount readonlyEBUSY/EPERM),则认为外部已注入配置,不阻塞。
rm -f "$DEFAULT_CONF" 2>/dev/null || true
if [ -f "$TARGET_CONF" ] && [ ! -e "$DEFAULT_CONF" ]; then
ln -s "$TARGET_CONF" "$DEFAULT_CONF" 2>/dev/null || true
fi
# 3. 兜底:至少要有一个 .conf 文件,否则 nginx 起不来
if ! ls "$NGINX_CONF_DIR"/*.conf >/dev/null 2>&1; then
echo "ERROR: no nginx config found in $NGINX_CONF_DIR (tried $TARGET_CONF and external bind mount)" >&2
exit 1
fi
exec nginx -g "daemon off;" exec nginx -g "daemon off;"
@@ -703,9 +703,6 @@ class LipsyncJobModel(Base):
error_message = Column(Text, nullable=False, default="") error_message = Column(Text, nullable=False, default="")
error_code = Column(String(100), 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) submitted_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True)
+9 -7
View File
@@ -49,17 +49,19 @@ class GeneratedVideo:
thumbnail_url: str | None = None, thumbnail_url: str | None = None,
generation_params: dict[str, Any] | None = None, generation_params: dict[str, Any] | None = None,
) -> "GeneratedVideo": ) -> "GeneratedVideo":
# project_id / generation_task_id 允许为空:AI数字人等无项目场景下,前端可能不传 project_id; if not project_id.strip():
# lipsync 路径下 generation_task_id 也可能暂时为空。空串会被下面统一兜底为 "" 入库。 raise ValueError("project_id cannot be empty")
if not name or not name.strip(): if not generation_task_id.strip():
raise ValueError("generation_task_id cannot be empty")
if not name.strip():
raise ValueError("name cannot be empty") 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") raise ValueError("file_url cannot be empty")
return cls( return cls(
id=uuid4().hex, id=uuid4().hex,
project_id=(project_id or "").strip(), project_id=project_id.strip(),
user_id=(user_id or "").strip(), user_id=user_id.strip(),
generation_task_id=(generation_task_id or "").strip(), generation_task_id=generation_task_id.strip(),
name=name.strip(), name=name.strip(),
file_url=file_url.strip(), file_url=file_url.strip(),
file_size=file_size, file_size=file_size,
-205
View File
@@ -1,205 +0,0 @@
"""共享的句子时间戳计算工具 — 供 Celery TTS 任务和 /lipsync/tts-preview 同步接口复用.
- `_split_script_into_sentences`: 按标点分句(中英文逗号/句号/问号/感叹号/分号/换行)
- `_estimate_sentence_timings_by_chars`: 按字数比例估算(静音检测失败时降级)
- `_probe_audio_duration`: ffprobe 读取音频时长
- `compute_sentence_timings`: 基于 ffmpeg silencedetect 精确计算每句起止时间
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
import tempfile
from typing import Optional
logger = logging.getLogger(__name__)
def split_script_into_sentences(script_text: str) -> list[str]:
"""按句号/问号/感叹号/分号/逗号/换行分句(与前端 SENTENCE_SPLIT_RE 一致).
中文短视频文案习惯用「,」断小句(如"卖花的叫花无缺,卖姜的叫姜子牙"),
必须把逗号也纳入分隔符,否则多句文案会被识别成一整句,导致 B-roll 时间戳错位。
"""
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 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
def probe_audio_duration(audio_data: bytes, timeout: int = 10) -> float:
"""用 ffprobe 读取音频字节流的时长(秒).
Returns:
时长(秒),失败返回 0.0
"""
if not audio_data:
return 0.0
tmp_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
tmp.write(audio_data)
tmp_path = tmp.name
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
tmp_path,
],
capture_output=True,
text=True,
timeout=timeout,
)
stdout = (result.stdout or "").strip()
if not stdout:
logger.warning("[sentence_timings] ffprobe 无输出: stderr=%s", (result.stderr or "")[:200])
return 0.0
return float(stdout)
except Exception as exc:
logger.warning("[sentence_timings] ffprobe 时长探测失败: %s", exc)
return 0.0
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except Exception:
pass
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}]
"""
sentences = split_script_into_sentences(script_text)
if not sentences:
return []
tmp_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
tmp.write(audio_data)
tmp_path = tmp.name
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_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_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()
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:
if tmp_path:
try:
os.unlink(tmp_path)
except Exception:
pass
+91 -210
View File
@@ -377,30 +377,26 @@ def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) -
# ── 标题 drawtext 滤镜构建(#1789)───────────────────────────────────────────── # ── 标题 drawtext 滤镜构建(#1789)─────────────────────────────────────────────
# drawtext 字体搜索路径:按优先级从高到低排 # drawtext 字体搜索路径:按优先级列出常见安装位置
# 服务器使用 Noto Sans SC(思源黑体)作为默认字体 # 服务器使用 Noto Sans SC(思源黑体)作为默认字体
# - NotoSansSC-VF.ttf 是 worker-base.Dockerfile 中 COPY 的 VF 字体(含所有字重,无 Mono 变体),优先级最高
# - .ttc 系列为 fonts-noto-cjk 包预装字体(Dockerfile 已删除含 Mono 变体的旧 .ttc,存在时作为 fallback
# - DejaVuSans 仅含拉丁字符不支持中文,已移除
DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [ 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-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-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/truetype/noto/NotoSansSC-Regular.ttf",
"/usr/share/fonts/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] = { DRAWTEXT_FONT_MAP: dict[str, str] = {
"思源黑体": "NotoSansSC", "思源黑体": "NotoSansCJK",
"思源宋体": "NotoSerifCJK", "思源宋体": "NotoSerifCJK",
"苹方": "NotoSansSC", "苹方": "NotoSansCJK",
"PingFang": "NotoSansSC", "PingFang": "NotoSansCJK",
"微软雅黑": "NotoSansSC", "微软雅黑": "NotoSansCJK",
"楷体": "NotoSerifCJK", "楷体": "NotoSerifCJK",
"华康俪金黑": "NotoSansSC", "华康俪金黑": "NotoSansCJK",
} }
@@ -420,11 +416,6 @@ def _escape_drawtext_text(text: str) -> str:
return result return result
# 粗体字体已由前端 Canvas 直接渲染(Canvas 使用浏览器原生粗体 glyph),
# FFmpeg 侧不再需要查找 Bold 字体文件;drawtext 仅作为旧版前端的降级路径,
# 通过 borderw 黑色细描边模拟粗体(见 build_title_drawtext_filter)。
def _resolve_font_path(font_name: str) -> str: def _resolve_font_path(font_name: str) -> str:
"""解析字体名到服务器实际字体文件路径。 """解析字体名到服务器实际字体文件路径。
@@ -432,9 +423,6 @@ def _resolve_font_path(font_name: str) -> str:
1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字 1. 通过 DRAWTEXT_FONT_MAP 映射前端字体名到服务器关键字
2. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径 2. 在 DRAWTEXT_FONT_SEARCH_PATHS 中查找匹配路径
3. 未找到则返回空字符串(drawtext 使用内置默认字体) 3. 未找到则返回空字符串(drawtext 使用内置默认字体)
注:粗体已由前端 Canvas 渲染时直接用浏览器 bold glyph 绘制,
此处仅作为旧版前端降级路径,无需切换 Bold 字体文件。
""" """
keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name) keyword = DRAWTEXT_FONT_MAP.get(font_name, font_name)
import os import os
@@ -478,10 +466,8 @@ def build_title_drawtext_filter(
if not title_config or not isinstance(title_config, dict): if not title_config or not isinstance(title_config, dict):
return None return None
# 字段名归一化:兼容 content/text/title 三套命名 # 字段名归一化:兼容 content/text、font_preset/font 两套命名
text = ( text = (title_config.get("text") or title_config.get("content") or "").strip()
title_config.get("text") or title_config.get("content") or title_config.get("title") or ""
).strip()
if not text: if not text:
return None return None
@@ -491,13 +477,13 @@ def build_title_drawtext_filter(
# ── 样式参数 ── # ── 样式参数 ──
font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体" font_name = title_config.get("font") or title_config.get("font_preset") or "思源黑体"
font_size = int(title_config.get("font_size") or title_config.get("size") or 48) font_size = int(title_config.get("font_size") or title_config.get("size") or 36)
font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff" font_color = title_config.get("font_color") or title_config.get("color") or "#ffffff"
# 去掉 # 前缀(drawtext 用纯 hex 或颜色名) # 去掉 # 前缀(drawtext 用纯 hex 或颜色名)
if font_color.startswith("#"): if font_color.startswith("#"):
font_color = font_color[1:] 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)) bold = bool(title_config.get("bold", True))
stroke = title_config.get("stroke") stroke = title_config.get("stroke")
shadow = title_config.get("shadow") shadow = title_config.get("shadow")
@@ -505,7 +491,7 @@ def build_title_drawtext_filter(
# ── 构建 drawtext 参数 ── # ── 构建 drawtext 参数 ──
params: list[str] = [] params: list[str] = []
# 字体文件(drawtext 降级路径:粗体通过 borderw 黑色描边模拟) # 字体文件
font_path = _resolve_font_path(font_name) font_path = _resolve_font_path(font_name)
if font_path: if font_path:
escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'") escaped_path = font_path.replace("\\", "\\\\").replace(":", "\\\\:").replace("'", "\\\\'")
@@ -518,28 +504,26 @@ def build_title_drawtext_filter(
params.append(f"fontsize={font_size}") params.append(f"fontsize={font_size}")
params.append(f"fontcolor={font_color}") params.append(f"fontcolor={font_color}")
# 粗体:bold 在 drawtext 中通过 font 的 Bold 变体实现
# 若字体有 Bold 变体可用 fontfont=bold;否则通过 borderw 模拟
if bold:
# 使用 font 参数尝试加载 Bold 变体(Noto Sans SC 有 Bold 变体文件)
params.append("font=bold")
# 描边(borderw 需要 libfreetype 支持) # 描边(borderw 需要 libfreetype 支持)
# 之前用 borderw=3 + font_color 同色描边模拟粗体,会在小字号/竖屏视频上造成
# 字形偏移、边缘重影,看起来像文字被打印了两次(用户截图中的标题"曝光曝光…")。
# 修复:粗体改用黑色细描边(borderw=2, 黑色),视觉上清晰加粗且不产生偏移。
# 用户显式开启 stroke 时按用户配置走;粗体+无stroke 默认黑色细描边。
border_width = 0
border_color = "000000"
if stroke: if stroke:
if isinstance(stroke, bool): if isinstance(stroke, bool):
border_width = 2 border_width = 2
border_color = "000000" border_color = "black"
elif isinstance(stroke, dict): elif isinstance(stroke, dict):
if stroke.get("enabled", True): border_width = int(stroke.get("width", 2)) if stroke.get("enabled", True) else 0
border_width = int(stroke.get("width", 2)) border_color = (stroke.get("color") or "#000000").lstrip("#")
border_color = (stroke.get("color") or "#000000").lstrip("#") else:
elif bold: border_width = 0
# 粗体模式且未配描边:黑色细描边,模拟粗体同时保证不重影 border_color = "black"
border_width = 2 if border_width > 0:
border_color = "000000" params.append(f"borderw={border_width}")
if border_width > 0: params.append(f"bordercolor={border_color}")
params.append(f"borderw={border_width}")
params.append(f"bordercolor={border_color}")
# 阴影(shadowcolor + shadowx/y # 阴影(shadowcolor + shadowx/y
if shadow: if shadow:
@@ -564,13 +548,8 @@ def build_title_drawtext_filter(
and not isinstance(pos_x, bool) and not isinstance(pos_x, bool)
and not isinstance(pos_y, bool) and not isinstance(pos_y, bool)
): ):
# pos_x/pos_y 为百分比坐标(0-100),转换为 drawtext 表达式 params.append(f"x={int(pos_x)}")
# 例如 pos_x=50 → x=(w-text_w)*0.50(水平居中偏50% params.append(f"y={int(pos_y)}")
# 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}")
else: else:
# 三档预设位置:top / center / bottom # 三档预设位置:top / center / bottom
# x 始终水平居中:(w-text_w)/2 # x 始终水平居中:(w-text_w)/2
@@ -586,43 +565,6 @@ def build_title_drawtext_filter(
return "drawtext=" + ":".join(params) return "drawtext=" + ":".join(params)
def build_title_overlay_filter(
title_config: dict[str, Any],
output_width: int, # noqa: ARG001 - 保留参数签名,PNG 已按视频分辨率绘制
output_height: int, # noqa: ARG001
title_png_path: str,
*,
title_input_label: str = "[1:v]",
base_label: str = "[0:v]",
output_label: str = "vout_titled",
) -> str | None:
"""构建标题 PNG 图层 overlay 滤镜(WYSIWYG 路径)。
前端用 Canvas 把标题画成与视频同分辨率的透明 PNG(所见即所得),
后端直接 overlay=0:0 叠加即可,PNG 透明区域不遮挡视频。
Args:
title_config: 标题配置 dict(仅用来判断降级)
output_width: 输出宽度(未使用,PNG 已按该分辨率绘制)
output_height: 输出高度(未使用)
title_png_path: 已保存到本地的标题 PNG 文件路径
title_input_label: 标题 PNG 在 filter_complex 中的输入标签(默认 "[1:v]"
base_label: 前序滤镜输出标签(如 B-roll 输出 "[vout]"
output_label: overlay 输出标签名
Returns:
overlay 滤镜字符串;title_png_path 为空/文件不存在时返回 None(降级到 drawtext
"""
import os
if not title_png_path or not os.path.isfile(title_png_path):
return None
if not title_config or not isinstance(title_config, dict):
return None
return f"{base_label}{title_input_label}overlay=0:0[{output_label}]"
# ── B-roll 叠加滤镜 ───────────────────────────────────────────────────────── # ── B-roll 叠加滤镜 ─────────────────────────────────────────────────────────
@@ -631,7 +573,7 @@ def build_broll_overlay_filter(
video_duration: float, video_duration: float,
output_width: int = DEFAULT_OUTPUT_WIDTH, output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT, output_height: int = DEFAULT_OUTPUT_HEIGHT,
) -> tuple[str, str | None]: ) -> str:
"""构建 B-roll 叠加滤镜链。 """构建 B-roll 叠加滤镜链。
支持两种模式: 支持两种模式:
@@ -639,182 +581,121 @@ def build_broll_overlay_filter(
- pip: 在对口型视频上叠加画中画 B-roll - pip: 在对口型视频上叠加画中画 B-roll
Args: Args:
b_roll_segments: B-roll 片段配置列表(原始顺序,决定 FFmpeg -i 输入顺序) b_roll_segments: B-roll 片段配置列表
video_duration: 对口型视频总时长(秒) video_duration: 对口型视频总时长(秒)
output_width: 输出宽度(默认 1280;AI 数字人竖屏传 720) output_width: 输出宽度
output_height: 输出高度(默认 720;AI 数字人竖屏传 1280) output_height: 输出高度
Returns: Returns:
(filter_complex_str, final_label) FFmpeg filter_complex 滤镜字符串片段
- filter_complex_str: filter_complex 片段字符串(末尾无分号)
- final_label: 最终输出 pad 标签名,如 "vout";无 B-roll 时返回 None
""" """
if not b_roll_segments: if not b_roll_segments:
return "", None return ""
# 建立原始列表下标 → 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]"
parts: list[str] = [] parts: list[str] = []
sorted_segments = sorted(b_roll_segments, key=lambda s: s.get("start_time", 0)) 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"] 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"] pip_segments = [s for s in sorted_segments if s.get("mode") == "pip"]
final_label = None
# ── fullscreen 模式: 切分 + concat ── # ── fullscreen 模式: 切分 + concat ──
if fullscreen_segments: if fullscreen_segments:
fs_filter, fs_label = _build_fullscreen_filters( parts.append(_build_fullscreen_filters(fullscreen_segments, video_duration, output_width, output_height))
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
# ── pip 模式: overlay 滤镜 ── # ── pip 模式: overlay 滤镜 ──
if pip_segments: if pip_segments:
pip_filter, pip_label = _build_pip_filters( for idx, seg in enumerate(pip_segments):
pip_segments, output_width, output_height, _input_label, base_label=fs_label start = seg.get("start_time", 0)
) end = seg.get("end_time", video_duration)
parts.append(pip_filter) scale = seg.get("pip_scale", 0.3)
final_label = pip_label 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) result = "".join(parts)
# 清理末尾多余分号 # 清理末尾多余分号
if result.endswith(";"): if result.endswith(";"):
result = result[:-1] result = result[:-1]
return result, final_label return result
def _build_fullscreen_filters( def _build_fullscreen_filters(
sorted_fs_segments: list[dict[str, Any]], segments: list[dict[str, Any]],
all_segments: list[dict[str, Any]],
video_duration: float, video_duration: float,
output_width: int, output_width: int,
output_height: int, output_height: int,
input_label_fn, ) -> str:
) -> tuple[str, str]: """构建 fullscreen 模式的切分 + concat 滤镜.
"""构建 fullscreen 模式的切分 + concat 滤镜。
视频按 B-roll 时间段切分,然后用 concat 拼接主视频片段和 B-roll 片段。 对口型视频按 B-roll 时间段切分,然后用 concat 拼接 B-roll 片段。
Returns:
(filter_str, final_label) 其中 final_label 是 concat 输出的 pad 标签
""" """
parts: list[str] = [] parts: list[str] = []
prev_end = 0.0 prev_end = 0.0
# 注意:这里的 idx 是 sorted_fs_segments 中的下标; for idx, seg in enumerate(segments):
# 实际 FFmpeg 输入下标必须通过 input_label_fn 查询
for idx, seg in enumerate(sorted_fs_segments):
start = seg.get("start_time", 0) start = seg.get("start_time", 0)
end = seg.get("end_time", video_duration) end = seg.get("end_time", video_duration)
# 视频片段(B-roll 之前) # 保持原视频片段(B-roll 之前的部分
if prev_end < start: if prev_end < start:
parts.append(f"[0:v]trim=start={prev_end}:end={start},setpts=PTS-STARTPTS[main{idx}];") parts.append(f"[0:v]trim=start={prev_end}:end={start},setpts=PTS-STARTPTS[main{idx}];")
# B-roll 片段:缩放到输出分辨率并裁到对应时长 # B-roll 片段:缩放至目标分辨率
in_lbl = input_label_fn(seg)
parts.append( 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":force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2," f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,"
f"trim=start=0:end={end - start},setpts=PTS-STARTPTS[br{idx}];" f"trim=start=0:end={end - start},setpts=PTS-STARTPTS[br{idx}];"
) )
prev_end = end prev_end = end
# 尾部主视频片段 # 尾部片段
if prev_end < video_duration: 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}];") parts.append(f"[0:v]trim=start={prev_end}:end={video_duration},setpts=PTS-STARTPTS[main{last_idx}];")
# concat 所有片段 # concat 所有片段
segment_labels: list[str] = [] segment_labels = []
for idx, seg in enumerate(sorted_fs_segments): for idx in range(len(segments)):
start = seg.get("start_time", 0) start = segments[idx].get("start_time", 0)
# 每段 B-roll 之前是否有主视频片段? if (idx == 0 and segments[0].get("start_time", 0) > 0) or idx > 0:
has_main_before = (idx == 0 and start > 0) or ( prev_end_prev = segments[idx - 1].get("end_time", 0) if idx > 0 else 0
idx > 0 and sorted_fs_segments[idx - 1].get("end_time", 0) < start if prev_end_prev < start:
) segment_labels.append(f"[main{idx}]")
if has_main_before:
segment_labels.append(f"[main{idx}]")
segment_labels.append(f"[br{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) n = len(segment_labels)
if n > 0: if n > 0:
concat_inputs = "".join(segment_labels) 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 return "".join(parts)
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"
def build_cover_extract_command( def build_cover_extract_command(
-4
View File
@@ -13,7 +13,3 @@ pytest-cov==6.0.0
# 工具 # 工具
python-dotenv==1.0.1 python-dotenv==1.0.1
# AI 数字人封面智能选帧(cover_frame_scorer 用 cv2/numpy 做清晰度/亮度/色彩评分)
numpy==1.26.4
opencv-python-headless==4.10.0.84
+18 -9
View File
@@ -87,19 +87,28 @@ class TestGeneratedVideoCreate:
assert v.file_url == "http://x/v" assert v.file_url == "http://x/v"
def test_create_empty_project_id(self): def test_create_empty_project_id(self):
"""空 project_id 允许(AI数字人无项目场景).""" """空 project_id 无效."""
v = GeneratedVideo.create("", "t1", "v", "http://x/v") try:
assert v.project_id == "" 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): def test_create_whitespace_project_id(self):
"""纯空白 project_id 归一化为空串.""" """纯空白 project_id 无效."""
v = GeneratedVideo.create(" ", "t1", "v", "http://x/v") try:
assert v.project_id == "" 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): def test_create_empty_task_id(self):
"""空 generation_task_id 允许.""" """空 generation_task_id 无效."""
v = GeneratedVideo.create("p1", "", "v", "http://x/v") try:
assert v.generation_task_id == "" 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): def test_create_empty_name(self):
"""空 name 无效.""" """空 name 无效."""
@@ -262,8 +262,8 @@ def test_smart_cover_selects_best_frame_and_persists():
score_patch.assert_called_once() score_patch.assert_called_once()
# 验证使用了增大的轮询参数 # 验证使用了增大的轮询参数
call_kwargs = mk.extract_frames.call_args call_kwargs = mk.extract_frames.call_args
assert call_kwargs.kwargs.get("poll_interval") == 2.0 or call_kwargs[1].get("poll_interval") == 2.0 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") == 30 or call_kwargs[1].get("max_poll_attempts") == 30 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(): 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) cov.select_best_cover_frame("https://other/avatar.mp4", max_frames=3)
call_kwargs = mk.extract_frames.call_args call_kwargs = mk.extract_frames.call_args
assert call_kwargs.kwargs.get("poll_interval") == 2.0 or call_kwargs[1].get("poll_interval") == 2.0 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") == 30 or call_kwargs[1].get("max_poll_attempts") == 30 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 assert call_kwargs.kwargs.get("max_retries") == 1 or call_kwargs[1].get("max_retries") == 1
+3 -123
View File
@@ -258,9 +258,8 @@ class TestBrollOverlayFilter:
def test_empty_segments_returns_empty(self): def test_empty_segments_returns_empty(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter 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 result == ""
assert label is None
def test_pip_mode_generates_overlay(self): def test_pip_mode_generates_overlay(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter from packages.domain.video_filter_builder import build_broll_overlay_filter
@@ -276,9 +275,8 @@ class TestBrollOverlayFilter:
"pip_scale": 0.3, "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 "overlay" in result or "scale=" in result
assert label == "vout"
def test_fullscreen_mode_generates_concat(self): def test_fullscreen_mode_generates_concat(self):
from packages.domain.video_filter_builder import build_broll_overlay_filter from packages.domain.video_filter_builder import build_broll_overlay_filter
@@ -292,9 +290,8 @@ class TestBrollOverlayFilter:
"end_time": 10.0, "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 "trim" in result or "concat" in result
assert label == "vout_fs"
def test_cover_extract_command(self): def test_cover_extract_command(self):
from packages.domain.video_filter_builder import build_cover_extract_command from packages.domain.video_filter_builder import build_cover_extract_command
@@ -318,120 +315,3 @@ class TestBrollOverlayFilter:
"/tmp/cover.jpg", "/tmp/cover.jpg",
) )
assert "scale=" in cmd assert "scale=" in cmd
def _make_mock_auth_user(user_id="user-1"):
"""构造 AuthenticatedUsercurrent_user.user.id."""
auth = MagicMock()
auth.user.id = user_id
return auth
class TestRenderSmartCoverRoute:
"""POST /renders/{job_id}/smart-cover — 从成片智能抽封面(步骤②)."""
def test_smart_cover_job_not_found_returns_404(self):
"""渲染任务不存在 → 404."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_service.get_render_job.return_value = None
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
# 函数内部 `from app.services.ai_avatar_render_service import AiAvatarRenderService`
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-missing", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 404
assert "不存在" in exc_info.value.detail
mock_service.get_render_job.assert_called_once_with("render-missing", "user-1")
def test_smart_cover_job_not_completed_returns_400(self):
"""任务未 completed(如 processing)→ 400."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="processing", output_video_url="https://oss/video.mp4")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
assert "先完成视频生成" in exc_info.value.detail
def test_smart_cover_empty_video_url_returns_400(self):
"""已 completed 但 output_video_url 为空/空白 → 400."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
from fastapi import HTTPException
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="completed", output_video_url=" ")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service):
with pytest.raises(HTTPException) as exc_info:
generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert exc_info.value.status_code == 400
assert "URL 为空" in exc_info.value.detail
def test_smart_cover_success_updates_db_and_returns_url(self):
"""抽帧成功 → 更新 job.cover_config / output_cover_url 并 commit,返回 completed."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
mock_service = MagicMock()
mock_job = _make_mock_render_job(
status="completed",
output_video_url="https://oss/final.mp4",
)
mock_job.cover_config = {"mode": "manual"}
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch(
"app.api.routes.ai_avatar_render.generate_smart_cover", return_value="https://oss/cover.jpg"
) as mock_gen,
):
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
mock_gen.assert_called_once_with("https://oss/final.mp4", job_id="render-1", max_frames=5)
assert result.status == "completed"
assert result.cover_url == "https://oss/cover.jpg"
assert mock_job.output_cover_url == "https://oss/cover.jpg"
assert mock_job.cover_config["mode"] == "auto_frame"
assert mock_job.cover_config["url"] == "https://oss/cover.jpg"
mock_db.commit.assert_called_once()
def test_smart_cover_extract_failure_returns_fallback_failed(self):
"""generate_smart_cover 抛异常 → fallback_failed,不抛错不写 DB."""
from app.api.routes.ai_avatar_render import generate_render_smart_cover
mock_service = MagicMock()
mock_job = _make_mock_render_job(status="completed", output_video_url="https://oss/final.mp4")
mock_service.get_render_job.return_value = mock_job
mock_db = MagicMock()
mock_user = _make_mock_auth_user()
with (
patch("app.services.ai_avatar_render_service.AiAvatarRenderService", return_value=mock_service),
patch("app.api.routes.ai_avatar_render.generate_smart_cover", side_effect=RuntimeError("mediakit down")),
):
result = generate_render_smart_cover(job_id="render-1", current_user=mock_user, db=mock_db)
assert result.status == "fallback_failed"
assert result.cover_url == ""
# 失败时不写 cover_config / 不 commit
mock_db.commit.assert_not_called()
+21 -184
View File
@@ -519,8 +519,8 @@ class TestAiAvatarRenderService:
# 不应执行渲染逻辑 # 不应执行渲染逻辑
mock_db.commit.assert_not_called() mock_db.commit.assert_not_called()
def test_execute_render_completed_does_not_auto_persist(self): def test_execute_render_success_creates_clip_record(self):
"""execute_render 完成后自动入库成片库(改为用户点「完成」时由 finalize_job 入库).""" """execute_render 完成后自动创建成片记录到成片库."""
from app.services.ai_avatar_render_service import AiAvatarRenderService from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db() mock_db = _make_mock_db()
@@ -547,141 +547,35 @@ class TestAiAvatarRenderService:
with ( with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"), 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.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("tempfile.TemporaryDirectory") as tmpdir_mock,
patch(
"app.services.ai_avatar_cover_service.generate_smart_cover", return_value="https://oss/smart_cover.jpg"
),
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create, patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
): ):
import subprocess as _sp import tempfile as _tf
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.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False) tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
svc.execute_render("render-ok")
# 状态应为 completed,但没有自动入库
assert mock_job.status == "completed"
gv_create.assert_not_called()
assert mock_job.output_video_url.startswith("https://oss/")
def test_finalize_job_persists_to_library(self):
"""finalize_job 在用户点「完成」后写入成片库,thumbnail_url 使用 job.output_cover_url."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = MagicMock()
mock_job = _make_mock_render_job(
job_id="render-finalize",
status="completed",
output_video_url="https://oss/ai-avatar/render-finalize/output.mp4",
output_cover_url="https://oss/cover.jpg",
output_duration=12.0,
)
# get_render_job → db.query(AiAvatarRenderJob).filter().first() 返回 mock_job
# finalize 幂等检查 → db.query(GeneratedVideoModel).filter().first() 返回 None(未入库)
def _query_side_effect(model):
q = MagicMock()
if model.__name__ == "AiAvatarRenderJob":
q.filter.return_value.first.return_value = mock_job
else:
# GeneratedVideoModel
q.filter.return_value.first.return_value = None
return q
mock_db.query.side_effect = _query_side_effect
svc = AiAvatarRenderService(mock_db)
with (
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
):
mock_clip = MagicMock() mock_clip = MagicMock()
mock_clip.id = "clip-new" mock_clip.id = "clip-001"
mock_clip.thumbnail_url = "https://oss/cover.jpg"
gv_create.return_value = mock_clip gv_create.return_value = mock_clip
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.create.return_value = mock_clip
mock_repo.get.return_value = mock_clip
repo_cls.return_value = mock_repo repo_cls.return_value = mock_repo
video = svc.finalize_job("render-finalize", "user-1") svc.execute_render("render-ok")
assert video.id == "clip-new"
gv_create.assert_called_once()
call_kwargs = gv_create.call_args.kwargs
assert call_kwargs["file_url"].endswith("output.mp4")
assert call_kwargs["thumbnail_url"] == "https://oss/cover.jpg"
assert call_kwargs["generation_task_id"] == "render-finalize"
mock_repo.create.assert_called_once()
def test_finalize_job_idempotent_when_already_persisted(self): assert mock_job.status == "completed"
"""finalize_job 重复调用:幂等检查命中后直接返回已有记录,不再 create.""" gv_create.assert_called_once()
from app.services.ai_avatar_render_service import AiAvatarRenderService call_kwargs = gv_create.call_args
assert "https://oss/" in call_kwargs.kwargs["file_url"]
mock_db = MagicMock() assert call_kwargs.kwargs["user_id"] == "user-1"
mock_job = _make_mock_render_job( mock_repo.create.assert_called_once_with(mock_clip)
job_id="render-finalize-2",
status="completed",
output_video_url="https://oss/output.mp4",
output_cover_url="https://oss/cover.jpg",
output_duration=12.0,
)
existing_model = MagicMock()
existing_model.id = "clip-existing"
existing_model.thumbnail_url = "https://oss/cover.jpg"
def _query_side_effect(model):
q = MagicMock()
if model.__name__ == "AiAvatarRenderJob":
q.filter.return_value.first.return_value = mock_job
else:
q.filter.return_value.first.return_value = existing_model
return q
mock_db.query.side_effect = _query_side_effect
svc = AiAvatarRenderService(mock_db)
with (
patch("packages.domain.generated_video.GeneratedVideo.create") as gv_create,
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
):
mock_existing = MagicMock()
mock_existing.id = "clip-existing"
mock_repo = MagicMock()
mock_repo.get.return_value = mock_existing
repo_cls.return_value = mock_repo
video = svc.finalize_job("render-finalize-2", "user-1")
assert video.id == "clip-existing"
gv_create.assert_not_called()
mock_repo.create.assert_not_called()
def test_finalize_job_requires_completed_status(self):
"""finalize_job 在非 completed 状态下抛异常."""
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
mock_db = MagicMock()
mock_job = _make_mock_render_job(
job_id="render-pending",
status="processing",
output_video_url="",
output_cover_url="",
output_duration=0.0,
)
filter_mock = MagicMock()
filter_mock.first.return_value = mock_job
query_mock = MagicMock()
query_mock.filter.return_value = filter_mock
mock_db.query.return_value = query_mock
svc = AiAvatarRenderService(mock_db)
with pytest.raises(AiAvatarRenderError):
svc.finalize_job("render-pending", "user-1")
def test_execute_render_clip_failure_does_not_affect_render(self): def test_execute_render_clip_failure_does_not_affect_render(self):
"""成片创建失败不影响渲染任务标记为成功.""" """成片创建失败不影响渲染任务标记为成功."""
@@ -711,17 +605,15 @@ class TestAiAvatarRenderService:
with ( with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"), 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.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("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.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False) tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
svc.execute_render("render-clip-fail") svc.execute_render("render-clip-fail")
# 渲染任务仍应标记为 completed(不入库不影响渲染成功) # 即使成片创建失败,渲染任务仍应标记为 completed
assert mock_job.status == "completed" assert mock_job.status == "completed"
def test_error_exception_has_code(self): def test_error_exception_has_code(self):
@@ -730,58 +622,3 @@ class TestAiAvatarRenderService:
err = AiAvatarRenderError("测试错误", code="TestCode") err = AiAvatarRenderError("测试错误", code="TestCode")
assert err.code == "TestCode" assert err.code == "TestCode"
assert str(err) == "测试错误" assert str(err) == "测试错误"
class TestAiAvatarRenderCoverPassthrough:
"""execute_render 中封面透传逻辑(320~329 行):cover_config 含 url/imageUrl/cover_url 时直接透传到 output_cover_url."""
def _run_execute(self, mock_job, mock_lipsync_job):
"""驱动 execute_render 跑到完成阶段的通用脚手架(mock IO 部分)."""
from app.services.ai_avatar_render_service import AiAvatarRenderService
mock_db = _make_mock_db()
mock_filter = MagicMock()
# query.filter 返回同一个 filter 两次(render_job 查询、lipsync 查询)
mock_filter.first.side_effect = [mock_job, mock_lipsync_job]
mock_query = MagicMock()
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
svc = AiAvatarRenderService(mock_db)
with (
patch.object(svc, "_download_video", return_value="/tmp/video.mp4"),
patch.object(svc, "_upload_to_oss", side_effect=lambda path, key: f"https://oss/{key}"),
patch("subprocess.run") as mock_run,
patch("tempfile.TemporaryDirectory") as tmpdir_mock,
patch("app.services.ai_avatar_cover_service.generate_smart_cover", return_value=""),
patch("packages.domain.generated_video.GeneratedVideo.create", return_value=MagicMock()),
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository"
) as repo_cls,
):
import subprocess as _sp
mock_run.return_value = _sp.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
import tempfile as _tf
tmpdir_mock.return_value.__enter__ = MagicMock(return_value="/tmp/testdir")
tmpdir_mock.return_value.__exit__ = MagicMock(return_value=False)
repo_cls.return_value = MagicMock()
svc.execute_render(mock_job.id)
return mock_db, mock_job
def test_cover_url_in_cover_config_passthrough_to_output_cover(self):
"""cover_config.url 存在 → 透传到 output_cover_url."""
mock_job = _make_mock_render_job(job_id="render-cov-1", status="pending")
mock_job.cover_config = {"mode": "upload", "url": "https://oss/user-cover.jpg"}
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
_, job = self._run_execute(mock_job, mock_lipsync_job)
assert job.output_cover_url == "https://oss/user-cover.jpg"
def test_cover_imageurl_fallback_also_passthrough(self):
"""cover_config.imageUrl(老字段)存在 → 也透传到 output_cover_url."""
mock_job = _make_mock_render_job(job_id="render-cov-2", status="pending")
mock_job.cover_config = {"mode": "upload", "imageUrl": "https://oss/user-cover2.jpg"}
mock_lipsync_job = _make_mock_lipsync_job(status="completed", output_duration=10.0)
_, job = self._run_execute(mock_job, mock_lipsync_job)
assert job.output_cover_url == "https://oss/user-cover2.jpg"
+4 -5
View File
@@ -43,7 +43,7 @@ class TestScoreFrame:
@requires_cv2 @requires_cv2
def test_clear_image_high_score(self): def test_clear_image_high_score(self):
"""清晰、亮度适中、色彩丰富的图像应得高分.""" """清晰、亮度适中、色彩丰富的图像应得高分."""
# 创建一个清晰的渐变图像(色彩丰富、亮度适中) # 创建一个清晰的渐变图像(色彩丰富、亮度适中)
img = np.zeros((100, 100, 3), dtype=np.uint8) img = np.zeros((100, 100, 3), dtype=np.uint8)
for i in range(100): for i in range(100):
@@ -53,8 +53,7 @@ class TestScoreFrame:
from packages.shared.cover_frame_scorer import score_frame from packages.shared.cover_frame_scorer import score_frame
score = score_frame(img) score = score_frame(img)
# 渐变图清晰度中等+亮度尚可+色彩有变化,分数应明显高于模糊/全黑/全白 assert 50.0 <= score <= 100.0, f"清晰图像应得高分,实际: {score}"
assert 40.0 <= score <= 100.0, f"清晰图像应得较高分,实际: {score}"
@requires_cv2 @requires_cv2
def test_blurry_image_low_clarity(self): def test_blurry_image_low_clarity(self):
@@ -77,8 +76,8 @@ class TestScoreFrame:
from packages.shared.cover_frame_scorer import score_frame from packages.shared.cover_frame_scorer import score_frame
score = score_frame(img) score = score_frame(img)
# 全黑:清晰度 0,亮度偏离130扣约24分,色彩 0 → 得分约0~7,允许cv2内部微小浮点差异 # 全黑:清晰度 0,亮度 0,色彩 0
assert score <= 10.0, f"全黑图像应接近 0 分,实际: {score}" assert score <= 5.0, f"全黑图像应接近 0 分,实际: {score}"
@requires_cv2 @requires_cv2
def test_bright_image_low_brightness(self): def test_bright_image_low_brightness(self):
+8 -6
View File
@@ -180,26 +180,28 @@ class TestDetectKeyframeTimestamps:
def test_cannot_open_video_raises(self): def test_cannot_open_video_raises(self):
"""无法打开视频时抛出 RuntimeError.""" """无法打开视频时抛出 RuntimeError."""
cv2_mock = _dedup_mod.cv2
mock_cap = MagicMock() mock_cap = MagicMock()
mock_cap.isOpened.return_value = False mock_cap.isOpened.return_value = False
cv2_mock.VideoCapture.return_value = mock_cap
import pytest import pytest
with patch.object(_dedup_mod.cv2, "VideoCapture", return_value=mock_cap): with pytest.raises(RuntimeError, match="Cannot open video"):
with pytest.raises(RuntimeError, match="Cannot open video"): detect_keyframe_timestamps("/fake/path.mp4")
detect_keyframe_timestamps("/fake/path.mp4")
def test_zero_duration_returns_empty(self): def test_zero_duration_returns_empty(self):
"""视频时长为 0 时返回空列表.""" """视频时长为 0 时返回空列表."""
cv2_mock = _dedup_mod.cv2
mock_cap = MagicMock() mock_cap = MagicMock()
mock_cap.isOpened.return_value = True mock_cap.isOpened.return_value = True
# cv2.CAP_PROP_FPS etc. are Mock objects; configure get() to return 0 for frame_count # cv2.CAP_PROP_FPS etc. are Mock objects; configure get() to return 0 for frame_count
mock_cap.get.return_value = 0 mock_cap.get.return_value = 0
mock_cap.read.return_value = (False, None) 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")
result = detect_keyframe_timestamps("/fake/zero.mp4") assert result == []
assert result == []
def test_function_signature(self): def test_function_signature(self):
"""验证函数签名和默认参数.""" """验证函数签名和默认参数."""
+24 -27
View File
@@ -47,35 +47,32 @@ class TestGeneratedVideoCreate:
assert video.file_url == "https://example.com/video.mp4" assert video.file_url == "https://example.com/video.mp4"
assert video.user_id == "user1" assert video.user_id == "user1"
def test_create_empty_project_id_allowed(self): def test_create_empty_project_id_raises(self):
"""project_id 允许为空(AI数字人等无项目场景)。""" with pytest.raises(ValueError, match="project_id cannot be empty"):
video = GeneratedVideo.create( GeneratedVideo.create(
project_id="", project_id="",
generation_task_id="task1", generation_task_id="task1",
name="视频", name="视频",
file_url="https://example.com/v.mp4", file_url="https://example.com/v.mp4",
) )
assert video.project_id == ""
def test_create_whitespace_project_id_normalized_to_empty(self): def test_create_whitespace_project_id_raises(self):
"""project_id 纯空白会被 strip 为空串,不抛异常。""" with pytest.raises(ValueError, match="project_id cannot be empty"):
video = GeneratedVideo.create( GeneratedVideo.create(
project_id=" ", project_id=" ",
generation_task_id="task1", generation_task_id="task1",
name="视频", name="视频",
file_url="https://example.com/v.mp4", file_url="https://example.com/v.mp4",
) )
assert video.project_id == ""
def test_create_empty_generation_task_id_allowed(self): def test_create_empty_generation_task_id_raises(self):
"""generation_task_id 允许为空(兼容部分异步链路)。""" with pytest.raises(ValueError, match="generation_task_id cannot be empty"):
video = GeneratedVideo.create( GeneratedVideo.create(
project_id="proj1", project_id="proj1",
generation_task_id="", generation_task_id="",
name="视频", name="视频",
file_url="https://example.com/v.mp4", file_url="https://example.com/v.mp4",
) )
assert video.generation_task_id == ""
def test_create_empty_name_raises(self): def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="name cannot be empty"): with pytest.raises(ValueError, match="name cannot be empty"):
+24 -27
View File
@@ -75,35 +75,32 @@ class TestGeneratedVideoCreate:
assert video.file_url == "https://example.com/out.mp4" assert video.file_url == "https://example.com/out.mp4"
assert video.user_id == "user_003" assert video.user_id == "user_003"
def test_create_empty_project_id_allowed(self): def test_create_empty_project_id_raises(self):
"""project_id 允许为空(AI数字人等无项目场景)。""" with pytest.raises(ValueError, match="project_id"):
video = GeneratedVideo.create( GeneratedVideo.create(
project_id="", project_id="",
generation_task_id="t", generation_task_id="t",
name="n", name="n",
file_url="u", file_url="u",
) )
assert video.project_id == ""
def test_create_whitespace_project_id_normalized(self): def test_create_whitespace_project_id_raises(self):
"""project_id 纯空白归一化为空串。""" with pytest.raises(ValueError, match="project_id"):
video = GeneratedVideo.create( GeneratedVideo.create(
project_id=" ", project_id=" ",
generation_task_id="t", generation_task_id="t",
name="n", name="n",
file_url="u", file_url="u",
) )
assert video.project_id == ""
def test_create_empty_generation_task_id_allowed(self): def test_create_empty_generation_task_id_raises(self):
"""generation_task_id 允许为空。""" with pytest.raises(ValueError, match="generation_task_id"):
video = GeneratedVideo.create( GeneratedVideo.create(
project_id="p", project_id="p",
generation_task_id="", generation_task_id="",
name="n", name="n",
file_url="u", file_url="u",
) )
assert video.generation_task_id == ""
def test_create_empty_name_raises(self): def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="name"): with pytest.raises(ValueError, match="name"):
@@ -45,25 +45,25 @@ class TestGeneratedVideo:
assert video.duplicate_of is None assert video.duplicate_of is None
assert video.generation_params == {} assert video.generation_params == {}
def test_create_empty_project_id_allowed(self): def test_create_empty_project_id_raises(self):
"""project_id 允许为空(AI数字人场景),空白归一化为空串.""" """project_id抛异常."""
video = GeneratedVideo.create( with pytest.raises(ValueError, match="project_id"):
project_id=" ", GeneratedVideo.create(
generation_task_id="t1", project_id=" ",
name="v.mp4", generation_task_id="t1",
file_url="https://x.com/v.mp4", name="v.mp4",
) file_url="https://x.com/v.mp4",
assert video.project_id == "" )
def test_create_empty_task_id_allowed(self): def test_create_empty_task_id_raises(self):
"""generation_task_id 允许为空.""" """generation_task_id抛异常."""
video = GeneratedVideo.create( with pytest.raises(ValueError, match="generation_task_id"):
project_id="p1", GeneratedVideo.create(
generation_task_id="", project_id="p1",
name="v.mp4", generation_task_id="",
file_url="https://x.com/v.mp4", name="v.mp4",
) file_url="https://x.com/v.mp4",
assert video.generation_task_id == "" )
def test_create_empty_name_raises(self): def test_create_empty_name_raises(self):
"""空name抛异常.""" """空name抛异常."""
-259
View File
@@ -1,259 +0,0 @@
"""generation_common 公共服务辅助函数单元测试。
覆盖 query_voice_durations / writeback_edit_plan_config / collect_plan_segments /
resolve_latest_plan_by_template 四个下沉函数的主路径边界与容错路径
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
# ═══════════════════════════════════════════════════════════════════════════════
# query_voice_durations
# ═══════════════════════════════════════════════════════════════════════════════
class TestQueryVoiceDurations:
def _make_db_with_rows(self, rows):
"""构造 MagicMock dbquery().filter().all() 返回 rows。"""
db = MagicMock()
db.query.return_value.filter.return_value.all.return_value = list(rows)
return db
def test_empty_input_returns_empty_list(self):
from app.services.generation_common import query_voice_durations
db = MagicMock()
assert query_voice_durations(db, []) == []
assert query_voice_durations(db, None) == []
db.query.assert_not_called()
def test_all_empty_or_falsy_ids_returns_zero_list(self):
from app.services.generation_common import query_voice_durations
db = MagicMock()
assert query_voice_durations(db, ["", None, ""]) == [0.0, 0.0, 0.0]
def test_normal_lookup_returns_durations_in_input_order(self):
from app.services.generation_common import query_voice_durations
db = self._make_db_with_rows([("v1", 3.5), ("v2", 7.2)])
result = query_voice_durations(db, ["v1", "v2", "v-missing"])
assert result == [3.5, 7.2, 0.0]
def test_duplicate_ids_returns_consistent_durations_preserves_order(self):
"""#1855:同配音 id 多次出现应返回相同时长,保持输入顺序/长度。"""
from app.services.generation_common import query_voice_durations
db = self._make_db_with_rows([("v1", 4.0)])
result = query_voice_durations(db, ["v1", "v1", "v1"])
assert result == [4.0, 4.0, 4.0]
def test_non_numeric_duration_coerced_to_zero(self):
from app.services.generation_common import query_voice_durations
db = self._make_db_with_rows([("v1", None), ("v2", "not-a-number"), ("v3", 2.0)])
result = query_voice_durations(db, ["v1", "v2", "v3"])
assert result == [0.0, 0.0, 2.0]
def test_db_exception_returns_zeros_and_logs(self, caplog):
from app.services.generation_common import query_voice_durations
db = MagicMock()
db.query.side_effect = RuntimeError("DB boom")
with caplog.at_level("WARNING"):
result = query_voice_durations(db, ["v1", "v2"])
assert result == [0.0, 0.0]
assert any("配音时长查询失败" in rec.message for rec in caplog.records)
# ═══════════════════════════════════════════════════════════════════════════════
# writeback_edit_plan_config
# ═══════════════════════════════════════════════════════════════════════════════
def _make_plan_model(config=None):
plan = MagicMock()
plan.config = config if config is not None else {}
return plan
class TestWritebackEditPlanConfig:
def test_empty_plan_id_returns_immediately(self):
from app.services.generation_common import writeback_edit_plan_config
db = MagicMock()
writeback_edit_plan_config("", "task1", None, db)
db.query.assert_not_called()
def test_plan_not_found_logs_and_returns(self, caplog):
from app.services.generation_common import writeback_edit_plan_config
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
with caplog.at_level("WARNING"):
writeback_edit_plan_config("p999", "task1", None, db)
db.commit.assert_not_called()
assert any("plan不存在" in rec.message for rec in caplog.records)
def test_writes_task_id_preserves_existing_config(self):
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"other": "keep-me"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "task-xyz", None, db)
assert plan.config["generation_task_id"] == "task-xyz"
assert plan.config["other"] == "keep-me"
assert "title_config" not in plan.config
db.commit.assert_called_once()
def test_merges_title_config_without_title_change(self):
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
assert plan.config["title_config"] == {"text": "old"}
# 标题未变 → cover 保留
assert plan.config.get("cover") == "x"
def test_title_change_clears_cover(self):
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "new-title"}, db)
assert "cover" not in plan.config
assert plan.config["title_config"] == {"text": "new-title"}
def test_config_not_dict_treated_as_empty(self):
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model(config=None)
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "hi"}, db)
assert plan.config["generation_task_id"] == "t1"
assert plan.config["title_config"] == {"text": "hi"}
def test_exception_triggers_rollback_and_logs(self, caplog):
from app.services.generation_common import writeback_edit_plan_config
db = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = RuntimeError("fail")
with caplog.at_level("WARNING"):
writeback_edit_plan_config("p1", "t1", None, db)
db.rollback.assert_called_once()
assert any("回写plan.config异常" in rec.message for rec in caplog.records)
def test_exception_with_rollback_also_failing_is_safe(self, caplog):
"""外层异常后,db.rollback() 自己也抛异常时也不应中断(pass 兜底)。"""
from app.services.generation_common import writeback_edit_plan_config
db = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = RuntimeError("fail")
db.rollback.side_effect = RuntimeError("rollback boom")
with caplog.at_level("WARNING"):
# 不应抛出异常
writeback_edit_plan_config("p1", "t1", None, db)
assert any("回写plan.config异常" in rec.message for rec in caplog.records)
# ═══════════════════════════════════════════════════════════════════════════════
# collect_plan_segments
# ═══════════════════════════════════════════════════════════════════════════════
def _make_clip(asset_id, start, duration):
c = MagicMock()
c.asset_id = asset_id
c.start_time = start
c.duration = duration
return c
class TestCollectPlanSegments:
def test_empty_plan_returns_empty(self):
from app.services.generation_common import collect_plan_segments
repo = MagicMock()
repo.list_by_plan.return_value = []
assert collect_plan_segments("p1", repo) == {}
def test_single_page_collects_segments(self):
from app.services.generation_common import collect_plan_segments
repo = MagicMock()
repo.list_by_plan.side_effect = [
[_make_clip("a1", 0.0, 5.0), _make_clip("a1", 10.0, 3.0), _make_clip("a2", 2.0, 4.0)],
[],
]
segs = collect_plan_segments("p1", repo, page_size=500)
assert segs["a1"] == [(0.0, 5.0), (10.0, 13.0)]
assert segs["a2"] == [(2.0, 6.0)]
def test_pagination_walks_all_batches(self):
from app.services.generation_common import collect_plan_segments
repo = MagicMock()
page1 = [_make_clip("a1", 0.0, 1.0)] * 2
page2 = [_make_clip("a2", 0.0, 2.0)] * 2
page3 = [_make_clip("a3", 0.0, 1.0)] # short final batch → stop
repo.list_by_plan.side_effect = [page1, page2, page3]
segs = collect_plan_segments("p1", repo, page_size=2)
assert set(segs.keys()) == {"a1", "a2", "a3"}
assert repo.list_by_plan.call_count == 3
def test_skips_zero_or_negative_duration_clips(self):
from app.services.generation_common import collect_plan_segments
repo = MagicMock()
repo.list_by_plan.side_effect = [
[_make_clip(None, 0.0, 5.0), _make_clip("a1", 0.0, 0.0), _make_clip("a1", 1.0, -1.0)],
[],
]
assert collect_plan_segments("p1", repo) == {}
# ═══════════════════════════════════════════════════════════════════════════════
# resolve_latest_plan_by_template
# ═══════════════════════════════════════════════════════════════════════════════
class TestResolveLatestPlanByTemplate:
@pytest.mark.parametrize("tid", ["", None, " "])
def test_empty_template_returns_none(self, tid):
from app.services.generation_common import resolve_latest_plan_by_template
db = MagicMock()
assert resolve_latest_plan_by_template(db, template_id=tid, user_id="u1") is None
db.query.assert_not_called()
def test_returns_latest_plan_id(self):
from app.services.generation_common import resolve_latest_plan_by_template
db = MagicMock()
latest = MagicMock(id="plan-xyz")
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = latest
assert resolve_latest_plan_by_template(db, template_id=" tpl1 ", user_id="u1") == "plan-xyz"
def test_no_plan_returns_none(self):
from app.services.generation_common import resolve_latest_plan_by_template
db = MagicMock()
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
def test_db_exception_returns_none_and_logs(self, caplog):
from app.services.generation_common import resolve_latest_plan_by_template
db = MagicMock()
db.query.side_effect = RuntimeError("boom")
with caplog.at_level("WARNING"):
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
assert any("查找最新plan失败" in rec.message for rec in caplog.records)
+4 -16
View File
@@ -91,27 +91,16 @@ class TestSchemaValidation:
assert req.voice_id == "longxiaochun_v3" assert req.voice_id == "longxiaochun_v3"
assert req.script_text == "大家好,欢迎来到直播间" assert req.script_text == "大家好,欢迎来到直播间"
def test_invalid_video_url_unsupported_ext(self): def test_invalid_video_url_not_mp4(self):
from app.schemas.lipsync import CreateLipsyncJobRequest from app.schemas.lipsync import CreateLipsyncJobRequest
# 不支持的扩展名(.txt)应报错 with pytest.raises(ValueError, match="MP4"):
with pytest.raises(ValueError, match="格式不支持"):
CreateLipsyncJobRequest( CreateLipsyncJobRequest(
video_url="https://example.com/video.txt", video_url="https://example.com/video.mov",
voice_id="longxiaochun_v3", voice_id="longxiaochun_v3",
script_text="测试文本", script_text="测试文本",
) )
def test_mov_video_url_accepted(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
# .MOV 是 iPhone 拍摄的常见容器,h264 编码可直接被 MediaKit 处理
req = CreateLipsyncJobRequest(
video_url="https://example.com/video.mov",
audio_url="https://example.com/audio.mp3",
)
assert req.video_url.endswith(".mov")
def test_invalid_video_url_empty(self): def test_invalid_video_url_empty(self):
from app.schemas.lipsync import CreateLipsyncJobRequest from app.schemas.lipsync import CreateLipsyncJobRequest
@@ -170,8 +159,7 @@ class TestSchemaValidation:
voice_id="longxiaochun_v3", voice_id="longxiaochun_v3",
script_text="测试文本", script_text="测试文本",
) )
# AI数字人场景文案长度不可控,默认开启视频循环,防止音频长于视频时被截断 assert req.enable_video_loop is False
assert req.enable_video_loop is True
def test_video_url_strip_query_params(self): def test_video_url_strip_query_params(self):
"""视频 URL 含查询参数时,扩展名检查应忽略 ? 后面的部分.""" """视频 URL 含查询参数时,扩展名检查应忽略 ? 后面的部分."""
+7 -55
View File
@@ -35,10 +35,7 @@ class TestFFmpegPresetOptimization:
final_label=None, final_label=None,
output_path="/tmp/output.mp4", output_path="/tmp/output.mp4",
) )
# cmd 现在是 list[str]preset 与值是相邻两个元素 assert "-preset veryfast" in cmd, f"期望 -preset veryfast,实际命令: {cmd}"
assert "-preset" in cmd, f"期望包含 -preset,实际命令: {cmd}"
preset_idx = cmd.index("-preset")
assert cmd[preset_idx + 1] == "veryfast", f"期望 veryfast,实际: {cmd}"
def test_preset_veryfast_with_filter(self): def test_preset_veryfast_with_filter(self):
"""带滤镜场景下也必须使用 veryfast.""" """带滤镜场景下也必须使用 veryfast."""
@@ -52,8 +49,7 @@ class TestFFmpegPresetOptimization:
final_label="[v]", final_label="[v]",
output_path="/tmp/output.mp4", output_path="/tmp/output.mp4",
) )
assert "-preset" in cmd assert "-preset veryfast" in cmd
assert cmd[cmd.index("-preset") + 1] == "veryfast"
assert "-filter_complex" in cmd assert "-filter_complex" in cmd
def test_preset_not_fast(self): def test_preset_not_fast(self):
@@ -69,11 +65,11 @@ class TestFFmpegPresetOptimization:
output_path="/tmp/output.mp4", output_path="/tmp/output.mp4",
) )
# 确保是 veryfast 而不是 fast # 确保是 veryfast 而不是 fast
assert "-preset" in cmd assert "-preset veryfast" in cmd
preset_idx = cmd.index("-preset") # 排除 "fast" 单独出现(veryfast 包含 fast 子串,需精确判断)
assert cmd[preset_idx + 1] == "veryfast" parts = cmd.split()
# 禁止 fast 单独作为 preset 值(veryfast 包含 "fast" 子串,不影响) preset_idx = parts.index("-preset")
assert cmd[preset_idx + 1] != "fast" assert parts[preset_idx + 1] == "veryfast"
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@@ -288,47 +284,3 @@ class TestCancelJobTtsProcessing:
result = svc.cancel_job("job-1", "user-1") result = svc.cancel_job("job-1", "user-1")
assert result.status == "cancelled" assert result.status == "cancelled"
class TestCreateJobCommitOrder:
"""验证事务顺序修复:create_job 必须先 commit 再发 Celery 任务,避免 worker 消费时 job 不可见。"""
def test_commit_called_before_apply_async_in_tts_mode(self):
"""TTS 模式:db.commit() 必须在 apply_async() 之前调用,防止 worker 查不到 job 永远卡在 tts_processing。"""
svc, client, cosy = _make_service_with_mocks()
call_order: list[str] = []
def track_commit():
call_order.append("commit")
def track_apply_async(*args, **kwargs):
call_order.append("apply_async")
svc.db.commit.side_effect = track_commit
with patch("app.services.lipsync_service.tts_synthesize_and_submit") as mock_task:
mock_task.apply_async = MagicMock(side_effect=track_apply_async)
svc.create_job(
user_id="user-1",
video_url="https://example.com/video.mp4",
voice_id="v-1",
script_text="测试",
)
# 至少有一次 commit 在 apply_async 之前
assert "commit" in call_order, "db.commit 必须被调用"
assert "apply_async" in call_order, "apply_async 必须被调用"
assert call_order.index("commit") < call_order.index(
"apply_async"
), f"事务顺序错误:commit 必须在 apply_async 之前,实际顺序 {call_order}"
def test_job_not_found_retry_mechanism_exists(self):
"""worker 侧 job not found 必须有重试机制(self.retry),而不是静默 return。"""
import inspect
from app.tasks.lipsync_tts import tts_synthesize_and_submit
source = inspect.getsource(tts_synthesize_and_submit.run)
assert (
"self.retry" in source or "retry" in source
), "tts_synthesize_and_submit 在 job not found 时必须重试,防止静默失败"
+1 -240
View File
@@ -185,10 +185,7 @@ class TestTtsSynthesizeAndSubmit:
mk_client.submit_lipsync.assert_called_once() mk_client.submit_lipsync.assert_called_once()
call_kwargs = mk_client.submit_lipsync.call_args.kwargs call_kwargs = mk_client.submit_lipsync.call_args.kwargs
assert call_kwargs["client_token"] == "job-1" assert call_kwargs["client_token"] == "job-1"
# CosyVoice 临时 URL 经 _sign_media_url 透传(mock 统一追加 ?signed), assert call_kwargs["audio_url"].endswith("?signed")
# 自家 OSS 才会被重签,外部 URL 原样透传;job.audio_url 存原始临时 URL
assert call_kwargs["audio_url"] == "https://tts/raw.mp3?signed"
assert job.audio_url == "https://tts/raw.mp3"
session.commit.assert_called() session.commit.assert_called()
session.close.assert_called_once() session.close.assert_called_once()
@@ -390,239 +387,3 @@ class TestSignMediaUrl:
assert result == "https://anything.example.com/a.mp3" assert result == "https://anything.example.com/a.mp3"
fake_storage.get_download_url.assert_not_called() fake_storage.get_download_url.assert_not_called()
class TestPersistOutputVideoTask:
"""persist_output_video_task:下载 MediaKit 临时视频 → 上传自有 OSS → 更新 DB."""
def _make_persist_job(self, **kwargs):
job = MagicMock()
job.id = kwargs.get("job_id", "job-1")
job.user_id = kwargs.get("user_id", "user-1")
job.output_video_url = kwargs.get("output_video_url", "https://temp.mk/output.mp4")
job.updated_at = None
return job
def _persist_patches(self, *, job, video_bytes=b"FAKEMP4", download_side_effect=None, upload_url=None):
"""统一 patchSessionLocal、httpx.Client、storage、_sign_media_url."""
fake_app_db = ModuleType("app.db")
fake_worker_db = ModuleType("worker_app.db")
session, factory = _build_session(job)
fake_app_db.SessionLocal = factory
fake_worker_db.SessionLocal = factory
# httpx.Client 上下文管理器
fake_response = MagicMock()
fake_response.content = video_bytes
fake_response.raise_for_status = MagicMock()
fake_client = MagicMock()
fake_client.get.return_value = fake_response
fake_client_cm = MagicMock()
fake_client_cm.__enter__ = MagicMock(return_value=fake_client)
fake_client_cm.__exit__ = MagicMock(return_value=False)
FakeHttpxClient = MagicMock(return_value=fake_client_cm)
if download_side_effect is not None:
fake_client.get.side_effect = download_side_effect
# storage
storage = MagicMock()
storage.public_url = "https://oss.example.com/"
storage.upload_file.return_value = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
# _sign_media_url 内部会调 storage.get_download_url,必须mock返回字符串
_upload_url = upload_url or "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
storage.get_download_url.return_value = _upload_url + "?signed"
fake_httpx = ModuleType("httpx")
fake_httpx.Client = FakeHttpxClient
patches = [
patch.dict(
sys.modules,
{"app.db": fake_app_db, "worker_app.db": fake_worker_db, "httpx": fake_httpx},
),
patch("packages.shared.storage.get_shared_storage_service", return_value=storage),
patch("app.tasks.lipsync_tts._sign_media_url", side_effect=lambda url: url + "?signed" if url else url),
]
return session, fake_client, storage, patches
def test_success_download_upload_updates_db(self):
"""正常路径:下载 temp_url → 上传 OSS → 签名 → 写回 DB commit."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
session, fake_client, storage, patches = self._persist_patches(
job=job, video_bytes=b"VIDEODATA", upload_url="https://oss.example.com/lipsync-outputs/user-1/job-1.mp4"
)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_called_once_with("https://temp.mk/x.mp4")
storage.upload_file.assert_called_once()
call_args = storage.upload_file.call_args.args
# 上传的 key 必须是 lipsync-outputs/{user_id}/{job_id}.mp4
assert call_args[1] == "lipsync-outputs/user-1/job-1.mp4"
# upload_file 返回永久 URL,再被 _sign_media_url 追加 ?signed
assert job.output_video_url == "https://oss.example.com/lipsync-outputs/user-1/job-1.mp4?signed"
assert job.updated_at is not None
session.commit.assert_called_once()
session.close.assert_called_once()
def test_download_failure_keeps_temp_url_no_commit(self):
"""下载失败(raise)→ 记录 warning、保留 temp_url、不抛异常."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="https://temp.mk/x.mp4")
session, fake_client, storage, patches = self._persist_patches(
job=job, download_side_effect=RuntimeError("network down")
)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
storage.upload_file.assert_not_called()
# output_video_url 保持原值(temp_url
assert job.output_video_url == "https://temp.mk/x.mp4"
# 内层 except 不会 commit
# 注:若内部发生 commit 说明测试失败
session.close.assert_called_once()
def test_empty_temp_url_skips_persist(self):
"""temp_url 为空 → 直接返回,不下载不上传."""
from app.tasks.lipsync_tts import persist_output_video_task
job = self._make_persist_job(output_video_url="")
session, fake_client, storage, patches = self._persist_patches(job=job)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("job-1", "user-1", "")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_not_called()
storage.upload_file.assert_not_called()
session.commit.assert_not_called()
session.close.assert_called_once()
def test_job_not_found_returns_early(self):
"""DB 中找不到 job → 直接返回,不抛错."""
from app.tasks.lipsync_tts import persist_output_video_task
session, fake_client, storage, patches = self._persist_patches(job=None)
entered = [p.__enter__() for p in patches]
try:
persist_output_video_task("missing", "user-1", "https://temp.mk/x.mp4")
finally:
for p in reversed(patches):
p.__exit__(None, None, None)
fake_client.get.assert_not_called()
storage.upload_file.assert_not_called()
session.commit.assert_not_called()
session.close.assert_called_once()
class TestLipsyncServiceRefreshCompletedAsyncPersist:
"""refresh_job_status 在 completed 分支异步转存的单元测试(补 0% 覆盖的 316~335 行)."""
def test_refresh_completed_dispatches_persist_task(self):
"""completed 分支:设置 temp_url → commit → dispatch persist_output_video_task.apply_async."""
from app.services.lipsync_service import LipsyncService
mock_job = MagicMock()
mock_job.id = "job-1"
mock_job.user_id = "user-1"
mock_job.mediakit_task_id = "mk-1"
mock_job.status = "submitted"
mock_job.output_video_url = ""
mock_job.output_duration = 0.0
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
mock_client = MagicMock()
mock_client.get_task_status.return_value = {
"status": "completed",
"result": {"video_url": "https://temp.mk/out.mp4", "duration": 25.5},
}
fake_persist_task = MagicMock()
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
with patch.dict("sys.modules", {}):
# 直接 patch 懒 import 路径
with patch("app.tasks.lipsync_tts.persist_output_video_task", fake_persist_task, create=False):
# 但懒 import 发生在函数内部 from app.tasks.lipsync_tts import persist_output_video_task
# 通过 patch sys.modules 的方式提供
import sys as _sys
fake_mod = MagicMock()
fake_mod.persist_output_video_task = fake_persist_task
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
try:
result = svc.refresh_job_status("job-1", "user-1")
finally:
_sys.modules.pop("app.tasks.lipsync_tts", None)
assert result.status == "completed"
assert result.output_video_url == "https://temp.mk/out.mp4"
assert result.output_duration == 25.5
mock_db.commit.assert_called()
# 必须在 commit 之后 dispatch
fake_persist_task.apply_async.assert_called_once()
kwargs = fake_persist_task.apply_async.call_args.kwargs
assert kwargs["args"] == ("job-1", "user-1", "https://temp.mk/out.mp4")
def test_refresh_completed_dispatch_exception_does_not_break_return(self):
"""apply_async 抛异常(如 Celery 不可用)→ 捕获 warning,仍返回 completed job."""
from app.services.lipsync_service import LipsyncService
mock_job = MagicMock()
mock_job.id = "job-2"
mock_job.user_id = "user-1"
mock_job.mediakit_task_id = "mk-2"
mock_job.status = "submitted"
mock_job.output_video_url = ""
mock_job.output_duration = 0.0
mock_db = MagicMock()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_job
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
mock_client = MagicMock()
mock_client.get_task_status.return_value = {
"status": "completed",
"result": {"video_url": "https://temp.mk/out2.mp4", "duration": 10.0},
}
fake_persist_task = MagicMock()
fake_persist_task.apply_async.side_effect = ConnectionError("celery down")
svc = LipsyncService(mock_db, client=mock_client, cosyvoice_service=MagicMock())
import sys as _sys
fake_mod = MagicMock()
fake_mod.persist_output_video_task = fake_persist_task
_sys.modules["app.tasks.lipsync_tts"] = fake_mod
try:
result = svc.refresh_job_status("job-2", "user-1")
finally:
_sys.modules.pop("app.tasks.lipsync_tts", None)
# 即便 dispatch 失败,主流程不受影响:仍然返回 completed + temp_url
assert result.status == "completed"
assert result.output_video_url == "https://temp.mk/out2.mp4"
fake_persist_task.apply_async.assert_called_once()
-169
View File
@@ -1,169 +0,0 @@
"""AI 数字人 对口型 TTS 预合成接口(#1845)单元测试 — 覆盖 LipsyncService.preview_tts 成功/失败路径.
直接调用 LipsyncService.preview_tts()mock CosyVoiceService / safe_download_bytes / ffprobe
验证返回结构错误码与共享 sentence_timings 工具的协作
"""
import os
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
def _make_service(
*,
cosyvoice=None,
download_bytes=b"FAKE_MP3_DATA",
download_error=None,
ffprobe_duration=5.0,
timings_result=None,
):
"""构造 LipsyncService 并把 CosyVoiceService/safe_download_bytes/probe/compute 全部 mock 掉。"""
from app.services.lipsync_service import LipsyncService
db = MagicMock()
# 构造唯一的 cosyvoice mock 实例,便于断言
_cosy_inst = MagicMock()
if cosyvoice is None:
_cosy_inst.submit_synthesize_task.return_value = {"audio_url": "https://cosy.example.com/tts.mp3"}
elif isinstance(cosyvoice, Exception):
_cosy_inst.submit_synthesize_task.side_effect = cosyvoice
else:
_cosy_inst.submit_synthesize_task.return_value = cosyvoice
def _fake_get_cosyvoice(self): # noqa: ARG001
return _cosy_inst
def _fake_resolve_voice_id(self, voice_id, user_id): # noqa: ARG001
return voice_id
svc = LipsyncService(db=db, client=MagicMock(), voice_clone_repo=MagicMock())
svc._cosyvoice = _cosy_inst
patch.object(LipsyncService, "_get_cosyvoice", _fake_get_cosyvoice).start()
patch.object(LipsyncService, "_resolve_voice_id", _fake_resolve_voice_id).start()
# mock safe_download_bytes
if download_error is not None:
patch(
"app.services.lipsync_service.safe_download_bytes",
side_effect=download_error,
).start()
else:
patch(
"app.services.lipsync_service.safe_download_bytes",
return_value=download_bytes,
).start()
# mock probe_audio_durationpatch 到 lipsync_service 模块的命名空间)
patch(
"app.services.lipsync_service.probe_audio_duration",
return_value=ffprobe_duration,
).start()
# mock compute_sentence_timings
default_timings = [
{"index": 0, "text": "你好", "start_time": 0.0, "end_time": 1.5},
{"index": 1, "text": "世界", "start_time": 1.5, "end_time": 5.0},
]
patch(
"app.services.lipsync_service.compute_sentence_timings",
return_value=timings_result if timings_result is not None else default_timings,
).start()
svc.__dict__["_test_cosy"] = _cosy_inst
return svc
def test_preview_tts_success():
"""正常路径:TTS 合成成功 → 下载 → ffprobe → 计算 timings,返回完整结构。"""
svc = _make_service(ffprobe_duration=5.0)
try:
result = svc.preview_tts(
user_id="user-1",
voice_id="longxiaochun",
script_text="你好,世界",
speed=1.0,
emotion="natural",
)
assert result["audio_url"] == "https://cosy.example.com/tts.mp3"
assert result["duration"] == 5.0
assert isinstance(result["sentence_timings"], list)
assert len(result["sentence_timings"]) == 2
assert result["sentence_timings"][0]["text"] == "你好"
cosy = svc.__dict__["_test_cosy"]
cosy.submit_synthesize_task.assert_called_once()
kwargs = cosy.submit_synthesize_task.call_args.kwargs
assert kwargs["text"] == "你好,世界"
assert kwargs["voice_id"] == "longxiaochun"
finally:
patch.stopall()
def test_preview_tts_cosyvoice_error():
"""CosyVoice 抛错:应该包装成 MediaKitError 抛出。"""
from app.services.mediakit_client import MediaKitError
from packages.application.cosyvoice_service import CosyVoiceError
svc = _make_service(cosyvoice=CosyVoiceError("cosyvoice down"))
try:
with pytest.raises(MediaKitError):
svc.preview_tts(
user_id="user-1",
voice_id="longxiaochun",
script_text="你好",
)
finally:
patch.stopall()
def test_preview_tts_download_fail_still_returns_url():
"""音频下载失败:不抛错,返回 audio_url + 空 timings,前端仍能继续(降级)。"""
svc = _make_service(download_error=RuntimeError("network down"))
try:
result = svc.preview_tts(
user_id="user-1",
voice_id="longxiaochun",
script_text="你好,世界",
)
assert result["audio_url"] == "https://cosy.example.com/tts.mp3"
assert result["duration"] == 0.0
assert result["sentence_timings"] == []
finally:
patch.stopall()
def test_preview_tts_ffprobe_zero_duration():
"""ffprobe 返回 0timings 为空,不抛错。"""
svc = _make_service(ffprobe_duration=0.0)
try:
result = svc.preview_tts(
user_id="user-1",
voice_id="longxiaochun",
script_text="你好",
)
assert result["audio_url"]
assert result["duration"] == 0.0
assert result["sentence_timings"] == []
finally:
patch.stopall()
def test_preview_tts_no_audio_url_in_response():
"""CosyVoice 返回无 audio_url:抛 MediaKitError TTSNoAudio。"""
from app.services.mediakit_client import MediaKitError
svc = _make_service(cosyvoice={"audio_url": ""})
try:
with pytest.raises(MediaKitError) as exc_info:
svc.preview_tts(
user_id="user-1",
voice_id="longxiaochun",
script_text="你好",
)
assert exc_info.value.code == "TTSNoAudio"
finally:
patch.stopall()
-169
View File
@@ -1,169 +0,0 @@
"""Tests for sentence timing functions (now in packages/domain/sentence_timings.py)."""
import os
import subprocess
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from packages.domain.sentence_timings import compute_sentence_timings as _compute_sentence_timings
from packages.domain.sentence_timings import estimate_sentence_timings_by_chars as _estimate_sentence_timings_by_chars
from packages.domain.sentence_timings import split_script_into_sentences as _split_script_into_sentences
class TestSplitScriptIntoSentences(unittest.TestCase):
"""Tests for _split_script_into_sentences."""
def test_empty_string(self):
self.assertEqual(_split_script_into_sentences(""), [])
def test_none(self):
self.assertEqual(_split_script_into_sentences(None), [])
def test_whitespace_only(self):
self.assertEqual(_split_script_into_sentences(" \n "), [])
def test_single_sentence(self):
self.assertEqual(_split_script_into_sentences("你好世界。"), ["你好世界"])
def test_multiple_sentences_chinese(self):
result = _split_script_into_sentences("第一句。第二句!第三句?")
self.assertEqual(result, ["第一句", "第二句", "第三句"])
def test_english_punctuation(self):
result = _split_script_into_sentences("Hello World! How are you?")
self.assertEqual(result, ["Hello World", "How are you"])
def test_semicolons(self):
result = _split_script_into_sentences("第一部分;第二部分;第三部分")
self.assertEqual(result, ["第一部分", "第二部分", "第三部分"])
def test_newlines(self):
result = _split_script_into_sentences("第一行\n第二行\n第三行")
self.assertEqual(result, ["第一行", "第二行", "第三行"])
def test_no_trailing_punctuation(self):
result = _split_script_into_sentences("没有标点的句子")
self.assertEqual(result, ["没有标点的句子"])
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
"""Tests for _estimate_sentence_timings_by_chars."""
def test_empty_sentences(self):
self.assertEqual(_estimate_sentence_timings_by_chars([], 10.0), [])
def test_zero_duration(self):
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], 0), [])
def test_negative_duration(self):
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], -5.0), [])
def test_single_sentence(self):
result = _estimate_sentence_timings_by_chars(["hello"], 10.0)
self.assertEqual(len(result), 1)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 10.0)
def test_two_equal_sentences(self):
result = _estimate_sentence_timings_by_chars(["你好", "世界"], 10.0)
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 5.0)
self.assertAlmostEqual(result[1]["start_time"], 5.0)
self.assertAlmostEqual(result[1]["end_time"], 10.0)
def test_unequal_char_distribution(self):
result = _estimate_sentence_timings_by_chars(["ABCD", "EF"], 9.0)
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 6.0) # 4/6 * 9 = 6
self.assertAlmostEqual(result[1]["start_time"], 6.0)
self.assertAlmostEqual(result[1]["end_time"], 9.0)
def test_timing_structure(self):
result = _estimate_sentence_timings_by_chars(["句子一", "句子二"], 6.0)
for item in result:
self.assertIn("index", item)
self.assertIn("text", item)
self.assertIn("start_time", item)
self.assertIn("end_time", item)
class TestComputeSentenceTimings(unittest.TestCase):
"""Tests for _compute_sentence_timings."""
def test_empty_script_returns_empty(self):
self.assertEqual(_compute_sentence_timings(b"fake_audio", "", 10.0), [])
def test_none_script_returns_empty(self):
self.assertEqual(_compute_sentence_timings(b"fake_audio", None, 10.0), [])
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_silence_detection_insufficient_fallback(self, mock_run, mock_tmpfile, mock_unlink):
"""When silence detection finds too few points, fallback to char estimation."""
mock_run.return_value = MagicMock(stderr="", returncode=0)
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
# Should fallback to char estimation with 3 sentences
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_silence_detection_with_enough_points(self, mock_run, mock_tmpfile, mock_unlink):
"""When silence detection finds enough points, use them for boundaries."""
mock_run.return_value = MagicMock(
stderr="[silencedetect] silence_end: 3.5 | silence_duration: 0.4\n"
"[silencedetect] silence_end: 7.0 | silence_duration: 0.3\n",
returncode=0,
)
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 3.5)
self.assertAlmostEqual(result[1]["start_time"], 3.5)
self.assertAlmostEqual(result[1]["end_time"], 7.0)
self.assertAlmostEqual(result[2]["start_time"], 7.0)
self.assertAlmostEqual(result[2]["end_time"], 10.0)
@patch("os.unlink")
@patch.object(tempfile, "NamedTemporaryFile")
@patch.object(subprocess, "run")
def test_ffmpeg_exception_fallback(self, mock_run, mock_tmpfile, mock_unlink):
"""When ffmpeg raises an exception, fallback to char estimation."""
mock_run.side_effect = Exception("ffmpeg not found")
mock_tmp = MagicMock()
mock_tmp.name = "/tmp/fake.mp3"
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
mock_tmp.__exit__ = MagicMock(return_value=False)
mock_tmpfile.return_value = mock_tmp
result = _compute_sentence_timings(b"fake_audio", "句子一。句子二。", 6.0)
# Should fallback to char estimation
self.assertEqual(len(result), 2)
self.assertAlmostEqual(result[0]["start_time"], 0.0)
self.assertAlmostEqual(result[0]["end_time"], 3.0)
self.assertAlmostEqual(result[1]["start_time"], 3.0)
self.assertAlmostEqual(result[1]["end_time"], 6.0)
if __name__ == "__main__":
unittest.main()
+7 -147
View File
@@ -13,7 +13,6 @@
from __future__ import annotations from __future__ import annotations
import os
import unittest import unittest
from dataclasses import FrozenInstanceError from dataclasses import FrozenInstanceError
from unittest.mock import patch from unittest.mock import patch
@@ -30,12 +29,10 @@ from packages.domain.video_filter_builder import (
ClipFilterChain, ClipFilterChain,
_escape_drawtext_text, _escape_drawtext_text,
_resolve_font_path, _resolve_font_path,
build_broll_overlay_filter,
build_clip_filter, build_clip_filter,
build_concat_filter, build_concat_filter,
build_filter_complex, build_filter_complex,
build_title_drawtext_filter, build_title_drawtext_filter,
build_title_overlay_filter,
build_xfade_filter, build_xfade_filter,
chain_filters, chain_filters,
has_audio, has_audio,
@@ -905,10 +902,9 @@ class TestResolveFontPath(unittest.TestCase):
@patch("os.path.isfile") @patch("os.path.isfile")
def test_unknown_font_fallback(self, mock_isfile): def test_unknown_font_fallback(self, mock_isfile):
# DejaVuSans 已从 fallback 列表移除(不支持 CJK),用 VF 路径模拟 mock_isfile.side_effect = lambda p: "DejaVu" in p
mock_isfile.side_effect = lambda p: "NotoSansSC-VF" in p
result = _resolve_font_path("UnknownFont") result = _resolve_font_path("UnknownFont")
self.assertIn("NotoSansSC-VF", result) self.assertIn("DejaVu", result)
@patch("os.path.isfile") @patch("os.path.isfile")
def test_no_fonts_available(self, mock_isfile): def test_no_fonts_available(self, mock_isfile):
@@ -931,11 +927,9 @@ class TestResolveFontPath(unittest.TestCase):
@patch("os.path.isfile") @patch("os.path.isfile")
def test_font_fallback_skips_nonexistent(self, mock_isfile): def test_font_fallback_skips_nonexistent(self, mock_isfile):
# 所有中文字体路径都不存在时,fallback 返回第一个存在的文件; mock_isfile.side_effect = lambda p: "DejaVu" in p
# DejaVuSans 已从列表移除(不支持 CJK),使用 VF 字体路径模拟存在文件
mock_isfile.side_effect = lambda p: "NotoSansSC-VF" in p
result = _resolve_font_path("不存在字体") result = _resolve_font_path("不存在字体")
self.assertIn("NotoSansSC-VF", result) self.assertIn("DejaVu", result)
class TestDrawtextFontFileIncluded(unittest.TestCase): class TestDrawtextFontFileIncluded(unittest.TestCase):
@@ -1034,37 +1028,6 @@ class TestDrawtextBoldFalse(unittest.TestCase):
self.assertIsNotNone(result) self.assertIsNotNone(result)
self.assertNotIn("font=bold", result) self.assertNotIn("font=bold", result)
def test_bold_true_does_not_use_font_bold_param(self):
"""粗体模式不得使用 `font=bold`——该参数无效,会导致 filter_complex 解析失败(exit 234)。"""
result = build_title_drawtext_filter({"text": "标题", "bold": True})
self.assertIsNotNone(result)
self.assertNotIn("font=bold", result)
# 粗体应通过 borderw 实现
self.assertIn("borderw=", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_bold_default_uses_black_stroke_when_no_bold_font(self, mock_font):
"""默认 bold=true 且无 Bold 字体文件时,使用黑色细描边(borderw=2 + 黑),
不得使用与文字同色的 borderw>=3否则会造成竖屏小字号重影"""
mock_font.return_value = "" # 无粗体字体
result = build_title_drawtext_filter({"text": "标题"})
self.assertIsNotNone(result)
self.assertIn("borderw=2", result)
# 黑描边:要么是 black 关键字,要么是 000000
self.assertTrue("bordercolor=black" in result or "bordercolor=000000" in result)
self.assertNotIn("borderw=3", result)
@patch("packages.domain.video_filter_builder._resolve_font_path")
def test_bold_with_user_stroke_preserves_user_color(self, mock_font):
"""用户显式开启 stroke 时,stroke 颜色/宽度优先于默认粗体黑边。"""
mock_font.return_value = ""
result = build_title_drawtext_filter(
{"text": "标题", "bold": True, "stroke": {"width": 4, "color": "#ffffff"}}
)
self.assertIsNotNone(result)
self.assertIn("borderw=4", result)
self.assertIn("bordercolor=ffffff", result) # 去掉 # 前缀
class TestDrawtextPositionBranches(unittest.TestCase): class TestDrawtextPositionBranches(unittest.TestCase):
"""位置相关分支覆盖。""" """位置相关分支覆盖。"""
@@ -1091,23 +1054,12 @@ class TestDrawtextPositionBranches(unittest.TestCase):
self.assertIn("y=h-text_h-50", result) self.assertIn("y=h-text_h-50", result)
@patch("packages.domain.video_filter_builder._resolve_font_path") @patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_with_percentage_coords(self, mock_font): def test_position_custom_with_float_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%."""
mock_font.return_value = "" mock_font.return_value = ""
result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3}) result = build_title_drawtext_filter({"text": "标题", "position": "custom", "pos_x": 100.7, "pos_y": 200.3})
self.assertIsNotNone(result) self.assertIsNotNone(result)
self.assertIn("x=(w-text_w)*1.0000", result) self.assertIn("x=100", result)
self.assertIn("y=(h-text_h)*1.0000", result) self.assertIn("y=200", result)
@patch("packages.domain.video_filter_builder._resolve_font_path") @patch("packages.domain.video_filter_builder._resolve_font_path")
def test_position_custom_bool_coords_fallback(self, mock_font): def test_position_custom_bool_coords_fallback(self, mock_font):
@@ -1164,97 +1116,5 @@ class TestDrawtextNotDictConfig(unittest.TestCase):
self.assertIsNone(build_title_drawtext_filter([1, 2, 3])) self.assertIsNone(build_title_drawtext_filter([1, 2, 3]))
class TestTitleOverlay(unittest.TestCase):
"""build_title_overlay_filter 单元测试(WYSIWYG PNG 叠加路径)。"""
def test_overlay_filter_format(self):
"""PNG 文件存在时返回正确的 overlay 滤镜字符串。"""
import tempfile
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp.write(b"\x89PNG\r\n\x1a\n")
tmp_path = tmp.name
try:
result = build_title_overlay_filter(
{"text": "标题"},
output_width=720,
output_height=1280,
title_png_path=tmp_path,
title_input_label="[2:v]",
base_label="[vout]",
output_label="vout_titled",
)
self.assertIsNotNone(result)
self.assertIn("[vout][2:v]overlay=0:0[vout_titled]", result)
finally:
os.unlink(tmp_path)
def test_overlay_default_labels(self):
"""不传 label 参数时使用默认 [0:v] / [1:v] / vout_titled。"""
import tempfile
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp.write(b"\x89PNG\r\n\x1a\n")
tmp_path = tmp.name
try:
result = build_title_overlay_filter(
{"text": "标题"},
output_width=720,
output_height=1280,
title_png_path=tmp_path,
)
self.assertEqual(result, "[0:v][1:v]overlay=0:0[vout_titled]")
finally:
os.unlink(tmp_path)
def test_overlay_returns_none_when_png_missing(self):
"""PNG 文件不存在时返回 None,供调用方降级到 drawtext。"""
result = build_title_overlay_filter(
{"text": "标题"},
output_width=720,
output_height=1280,
title_png_path="/nonexistent/path/title.png",
)
self.assertIsNone(result)
def test_overlay_returns_none_for_empty_config(self):
"""title_config 为空/非 dict 时返回 None。"""
import tempfile
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp.write(b"\x89PNG\r\n\x1a\n")
tmp_path = tmp.name
try:
self.assertIsNone(
build_title_overlay_filter(
None,
output_width=720,
output_height=1280,
title_png_path=tmp_path,
)
)
self.assertIsNone(
build_title_overlay_filter(
"not a dict",
output_width=720,
output_height=1280,
title_png_path=tmp_path,
)
)
finally:
os.unlink(tmp_path)
def test_overlay_returns_none_for_empty_path(self):
"""title_png_path 为空字符串时返回 None。"""
self.assertIsNone(
build_title_overlay_filter(
{"text": "标题"},
output_width=720,
output_height=1280,
title_png_path="",
)
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()