Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fcedfd5a9 | |||
| cc335673a9 | |||
| 029267cb46 |
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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.config:generation_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 已在选片时分配,
|
|
||||||
# #1855:apply_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(
|
||||||
|
|||||||
@@ -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;参数无效 → 400;MediaKit 提交失败 → 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="任务不存在")
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|||||||
@@ -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/emotion);audio_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="驱动音频 URL(mp3/aac/wav/m4a/flac);直生模式留空")
|
audio_url: str = Field("", description="驱动音频 URL(mp3/aac/wav/m4a/flac);直生模式留空")
|
||||||
audio_duration: Optional[float] = Field(None, ge=0, description="预合成音频时长(秒),可选;后端会 ffprobe 校验")
|
|
||||||
sentence_timings: Optional[list] = Field(None, description="预合成接口返回的句子时间戳,可选;若传入则直接写入 job")
|
|
||||||
|
|
||||||
# 模式 1:TTS 直生
|
# 模式 1:TTS 直生
|
||||||
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_text(TTS 直生模式)"
|
"要么同时传 voice_id + script_text(TTS 直生模式)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 中选定封面 URL(mode=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_url(smart-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 P0:OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符,
|
inputs = f"-i {input_video}"
|
||||||
os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被
|
|
||||||
当成独立命令报 sh: -filter_complex: not found(exit 127 → Python 32512)。
|
|
||||||
list + shell=False 彻底规避 shell 转义问题。
|
|
||||||
"""
|
|
||||||
cmd: list[str] = ["ffmpeg", "-i", input_video]
|
|
||||||
for seg in b_roll_segments:
|
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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.config:generation_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
|
|
||||||
@@ -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 的 LipsyncJobModel(audio_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_text(audio_url 留空)
|
- TTS 直生:voice_id + script_text(audio_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_url(upload_file 返回,不带签名)→ 私有桶匿名访问 403,重签。
|
||||||
|
- 已带签名但即将过期的 URL(如前端 1h 预签名)→ 抽 storage_key 后重签。
|
||||||
|
- 外部 URL(CosyVoice/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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量变体计划响应 */
|
/** 批量变体计划响应 */
|
||||||
|
|||||||
@@ -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 默认 1,page_size 默认 20
|
|
||||||
*/
|
|
||||||
export const getProducts = async (params?: ProductListParams): Promise<ProductListResult> => {
|
|
||||||
const response = await apiClient.get("/videos", {
|
|
||||||
params: {
|
|
||||||
page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
...params,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const data = response.data as {
|
|
||||||
items?: VideoItem[]
|
|
||||||
total?: number
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}
|
|
||||||
const items: VideoItem[] = Array.isArray(data?.items) ? data.items : []
|
|
||||||
return {
|
|
||||||
items: items.map(mapVideoToProductItem),
|
|
||||||
total: data.total ?? items.length,
|
|
||||||
page: data.page ?? params?.page ?? 1,
|
|
||||||
page_size: data.page_size ?? params?.page_size ?? 20,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取单个成品详情 */
|
/** 获取单个成品详情 */
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
/** 人物视频 URL(MP4);由素材 id 经 getAssetById 拿 file_url */
|
/** 人物视频 URL(MP4);由素材 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[]
|
|
||||||
/** 音色 ID(TTS 直生模式用) */
|
|
||||||
voice_id?: string
|
|
||||||
/** 要合成的文案(TTS 直生模式用) */
|
|
||||||
script_text?: string
|
|
||||||
/** 语速 0.5~2.0,默认 1.0(TTS 直生模式用) */
|
|
||||||
speed?: number
|
speed?: number
|
||||||
/** 情绪英文枚举:natural/excited/calm/friendly(TTS 直生模式用) */
|
/** 情绪英文枚举: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数字人 — 封面选择弹窗
|
|
||||||
* 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-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.5:B-roll ── */
|
/* ── 面板3.5:B-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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 dataURL,WYSIWYG 路径优先)。
|
* 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 的 dataURL(data: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) // 所有行的总高度
|
|
||||||
// 文本块顶部 y(textBaseline=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 时自测兜底。
|
|
||||||
* 长度=count,undefined 项表示该变体未提供后端时长。
|
|
||||||
*/
|
|
||||||
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])
|
||||||
|
|
||||||
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
|
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
|
||||||
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 || ""
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* 成片库页面 — V21 设计系统
|
* 成片库页面 — V21 设计系统
|
||||||
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选、无限滚动分页
|
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
|
||||||
*
|
*
|
||||||
* 主组件仅保留 Hook 组装与整体布局
|
* 主组件仅保留 Hook 组装与整体布局
|
||||||
* 列表查询 → hooks/useProductList(useInfiniteQuery 分页)
|
* 列表查询 → 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:
|
||||||
|
|||||||
@@ -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 直接写入 DB(AiAvatarRenderJob.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()
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 -s;rm 失败说明是外部 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 readonly,EBUSY/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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"):
|
|
||||||
"""构造 AuthenticatedUser:current_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()
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
"""验证函数签名和默认参数."""
|
"""验证函数签名和默认参数."""
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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抛异常."""
|
||||||
|
|||||||
@@ -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 db,query().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)
|
|
||||||
@@ -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 含查询参数时,扩展名检查应忽略 ? 后面的部分."""
|
||||||
|
|||||||
@@ -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 时必须重试,防止静默失败"
|
|
||||||
|
|||||||
@@ -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):
|
|
||||||
"""统一 patch:SessionLocal、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()
|
|
||||||
|
|||||||
@@ -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_duration(patch 到 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 返回 0:timings 为空,不抛错。"""
|
|
||||||
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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user