Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fcedfd5a9 | |||
| cc335673a9 | |||
| 029267cb46 |
@@ -196,10 +196,3 @@ DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
# ==================== 积分/会员系统 (#1895) ====================
|
||||
# 积分扣点总开关:默认 false(对现有用户零影响)。
|
||||
# P2 阶段各业务路由逐个接入 @points_gate 时,用
|
||||
# `if settings.points_enabled: ...`
|
||||
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。
|
||||
POINTS_ENABLED=false
|
||||
|
||||
@@ -1022,22 +1022,12 @@ jobs:
|
||||
BRANCH_TAG="${IMAGE_FULL}:${GITHUB_REF_NAME}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
|
||||
# develop 分支额外推送 :dev tag(Watchtower 监听的滚动更新 tag)
|
||||
if [ "${GITHUB_REF_NAME}" = "develop" ]; then
|
||||
DEV_TAG="${IMAGE_FULL}:dev"
|
||||
EXTRA_TAGS="$BRANCH_TAG $DEV_TAG"
|
||||
PUSHED_TAGS_SUMMARY="${BRANCH_TAG} + ${DEV_TAG}"
|
||||
else
|
||||
EXTRA_TAGS="$BRANCH_TAG"
|
||||
PUSHED_TAGS_SUMMARY="${BRANCH_TAG}"
|
||||
fi
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker build 尝试 $i/3 (${{ matrix.service_display }}) ==="
|
||||
if EXTRA_TAGS="$EXTRA_TAGS" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
if EXTRA_TAGS="$BRANCH_TAG" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "✅ Docker build 成功"
|
||||
break
|
||||
fi
|
||||
@@ -1050,7 +1040,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${PUSHED_TAGS_SUMMARY})"
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${BRANCH_TAG})"
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1254,11 +1244,9 @@ jobs:
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
run: |
|
||||
set -eux
|
||||
# CI runner (act_runner) 部署在 116 staging 本机(116.62.226.203 公网 22 未开放),
|
||||
# 默认走 127.0.0.1:22 本机 SSH,避免跨机网络依赖;可通过 secrets 覆盖。
|
||||
staging_host="${STAGING_SSH_HOST:-127.0.0.1}"
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
staging_port="${STAGING_SSH_PORT:-22}"
|
||||
staging_port="${STAGING_SSH_PORT:-22222}"
|
||||
echo "Host: $staging_host"
|
||||
echo "Port: $staging_port"
|
||||
|
||||
@@ -1482,6 +1470,7 @@ jobs:
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
|
||||
strategy:
|
||||
@@ -2135,4 +2124,3 @@ jobs:
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
# CI retry trigger
|
||||
|
||||
@@ -8,53 +8,23 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Setup SSH and diagnose
|
||||
- name: Setup SSH
|
||||
shell: bash
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -x
|
||||
set -eux
|
||||
which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
H=47.98.113.167; P=22222
|
||||
ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null
|
||||
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
|
||||
echo "=== Current staging containers ==="
|
||||
docker ps -a --filter name=xiaoxia-*-staging --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"
|
||||
echo ""
|
||||
echo "=== Web container logs (current/current-rolledback) ==="
|
||||
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
|
||||
echo 'IyEvYmluL2Jhc2gKc2V0IC14CmVjaG8gIj09PSBDdXJyZW50IHN0YWdpbmcgY29udGFpbmVycyA9PT0iCmRvY2tlciBwcyAtYSAtLWZpbHRlciBuYW1lPXhpYW94aWEtKi1zdGFnaW5nIC0tZm9ybWF0ICJ0YWJsZSB7ey5OYW1lc319XHR7ey5TdGF0dXN9fVx0e3suSW1hZ2V9fSIKZWNobyAiIgplY2hvICI9PT0gV2ViIGNvbnRhaW5lciBsb2dzIChjdXJyZW50L3JvbGxlZC1iYWNrKSA9PT0iCmRvY2tlciBsb2dzIHhpYW94aWEtd2ViLXN0YWdpbmcgMj4mMSB8IHRhaWwgLTQwCmVjaG8gIiIKZWNobyAiPT09IFdlYiBpbnNwZWN0OiBlbnYgJiBtb3VudHMgPT09Igpkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdlYi1zdGFnaW5nIC0tZm9ybWF0ICdFbnRyeXBvaW50OiB7ey5Db25maWcuRW50cnlwb2ludH19IENtZDoge3suQ29uZmlnLkNtZH19Jwpkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdlYi1zdGFnaW5nIC0tZm9ybWF0ICd7e3JhbmdlIC5Db25maWcuRW52fX17ey59fXt7IlxuIn19e3tlbmR9fScgfCBncmVwIC1FICJBUFBfRU5WfFZFUlNJT04iCmVjaG8gIk1vdW50czoiCmRvY2tlciBpbnNwZWN0IHhpYW94aWEtd2ViLXN0YWdpbmcgLS1mb3JtYXQgJ3t7cmFuZ2UgLk1vdW50c319e3suVHlwZX19IHt7LlNvdXJjZX19IC0+IHt7LkRlc3RpbmF0aW9ufX0gKHJ3PXt7LlJXfX0pe3siXG4ifX17e2VuZH19JwplY2hvICIiCmVjaG8gIj09PSBSZXByb2R1Y2U6IHJtIG9uIHJlYWQtb25seSBiaW5kIG1vdW50IChpbWFnZSAzODc1MTRjKSA9PT0iCmRvY2tlciBydW4gLS1ybSAtLW5hbWUgbmdpbngtcm8tdGVzdCBcCiAgLXYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXN0YWdpbmcvbmdpbngtc3RhZ2luZy5jb25mOi9ldGMvbmdpbngvY29uZi5kL2RlZmF1bHQuY29uZjpybyBcCiAgZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzL3hpYW94aWEtc2Fhcy13ZWI6Mzg3NTE0YyBcCiAgL2Jpbi9zaCAtYyAnc2V0IC14OyBlY2hvIEJFRk9SRTsgbHMgLWxhIC9ldGMvbmdpbngvY29uZi5kLzsgcm0gLWYgL2V0Yy9uZ2lueC9jb25mLmQvZGVmYXVsdC5jb25mOyBlY2hvICJybSBleGl0PSQ/IjsgZWNobyBBRlRFUjsgbHMgLWxhIC9ldGMvbmdpbngvY29uZi5kLycgMj4mMQplY2hvICIiCmVjaG8gIj09PSBMb2NhbCB3ZWIgaW1hZ2VzIGF2YWlsYWJsZSA9PT0iCmRvY2tlciBpbWFnZXMgfCBncmVwIHhpYW94aWEtc2Fhcy13ZWIgfCBoZWFkIC01Cg==' | base64 -d > /tmp/diag.sh
|
||||
chmod +x /tmp/diag.sh
|
||||
cat /tmp/diag.sh
|
||||
ssh -p 22222 -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@47.98.113.167 'bash -s' < /tmp/diag.sh
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
retrigger3
|
||||
@@ -263,4 +263,3 @@ pytest --cov=packages --cov-report=html
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
<!-- CI trigger: 1788229339 -->
|
||||
@@ -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")
|
||||
@@ -1,133 +0,0 @@
|
||||
"""add membership & points system
|
||||
|
||||
Revision ID: 076_membership_points
|
||||
Revises: 075_add_sentence_timings
|
||||
Create Date: 2026-09-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "076_membership_points"
|
||||
down_revision = "075_add_sentence_timings"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. users 表新增字段
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.add_column(
|
||||
sa.Column("is_member", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("member_type", sa.String(20), nullable=True),
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("member_expires_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("points_balance", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
)
|
||||
|
||||
# 2. points_accounts 积分账户表
|
||||
op.create_table(
|
||||
"points_accounts",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, unique=True, index=True),
|
||||
sa.Column("balance", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_earned", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_spent", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
)
|
||||
|
||||
# 3. points_transactions 积分流水表
|
||||
op.create_table(
|
||||
"points_transactions",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("account_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("type", sa.String(20), nullable=False, index=True),
|
||||
sa.Column("source", sa.String(50), nullable=False, index=True),
|
||||
sa.Column("amount", sa.Integer(), nullable=False),
|
||||
sa.Column("balance_after", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("ref_id", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
)
|
||||
|
||||
# 4. points_orders 积分/会员订单表
|
||||
op.create_table(
|
||||
"points_orders",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("order_type", sa.String(20), nullable=False),
|
||||
sa.Column("product_code", sa.String(50), nullable=False),
|
||||
sa.Column("amount_cents", sa.Integer(), nullable=False),
|
||||
sa.Column("original_amount_cents", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("discount", sa.Float(), nullable=False, server_default=sa.text("1.0")),
|
||||
sa.Column("points_amount", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("payment_method", sa.String(50), nullable=True),
|
||||
sa.Column("payment_id", sa.String(100), nullable=True),
|
||||
sa.Column("paid_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
)
|
||||
|
||||
# 5. daily_usage_records 每日使用记录表
|
||||
op.create_table(
|
||||
"daily_usage_records",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("usage_date", sa.DateTime(), nullable=False),
|
||||
sa.Column("usage_type", sa.String(50), nullable=False, server_default="free_clip"),
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.text("NOW()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"user_id",
|
||||
"usage_date",
|
||||
"usage_type",
|
||||
name="uq_daily_usage_user_date_type",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("daily_usage_records")
|
||||
op.drop_table("points_orders")
|
||||
op.drop_table("points_transactions")
|
||||
op.drop_table("points_accounts")
|
||||
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.drop_column("points_balance")
|
||||
batch.drop_column("member_expires_at")
|
||||
batch.drop_column("member_type")
|
||||
batch.drop_column("is_member")
|
||||
@@ -6,7 +6,6 @@ from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.clips_standalone import router as clips_standalone_router
|
||||
from app.api.routes.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
@@ -18,10 +17,8 @@ from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.lipsync import router as lipsync_router
|
||||
from app.api.routes.points import points_router, usage_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.scripts import router as scripts_router
|
||||
from app.api.routes.scripts_ai import router as scripts_ai_router
|
||||
from app.api.routes.share import router as share_router
|
||||
from app.api.routes.subscription import router as subscription_router
|
||||
from app.api.routes.tags import router as tags_router
|
||||
@@ -159,10 +156,6 @@ api_router.include_router(
|
||||
prefix="/templates",
|
||||
tags=["Template"],
|
||||
)
|
||||
api_router.include_router(
|
||||
clips_standalone_router,
|
||||
tags=["Clips"],
|
||||
)
|
||||
api_router.include_router(
|
||||
templates_editor_router,
|
||||
prefix="/templates/{template_id}/editor",
|
||||
@@ -191,23 +184,8 @@ api_router.include_router(
|
||||
prefix="/scripts",
|
||||
tags=["ScriptLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
scripts_ai_router,
|
||||
prefix="/scripts",
|
||||
tags=["ScriptLibrary AI"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ai_avatar_render_router,
|
||||
prefix="/ai-avatar/render",
|
||||
tags=["AI Avatar Render"],
|
||||
)
|
||||
api_router.include_router(
|
||||
points_router,
|
||||
prefix="/points",
|
||||
tags=["Points"],
|
||||
)
|
||||
api_router.include_router(
|
||||
usage_router,
|
||||
prefix="/usage",
|
||||
tags=["Usage"],
|
||||
)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"""默认模板兜底共享逻辑(P0 #1922).
|
||||
|
||||
提供 get_or_create_default_template_id(db, user_id) 共享函数,
|
||||
供 templates.py 列表查询、clips_standalone.py 独立端点、dependencies.py
|
||||
resolve_draft_plan_id 三处复用,避免三处各写一套兜底逻辑产生分叉。
|
||||
|
||||
根因:PR#1918 清理模板管理 API 时误删了 GET /templates 自动创建默认模板
|
||||
兜底,前端 PR#1913 去掉空 tid 拦截后首次进入生成页拼出
|
||||
/templates//editor/clips/from-assets(双斜杠)→ FastAPI 404,阻断新用户首次
|
||||
生成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_or_create_default_template_id(db: Session, user_id: str) -> Optional[str]:
|
||||
"""获取或自动创建默认配音模板的 id。
|
||||
|
||||
判定逻辑(不做异常降级,只有确实创建失败时才回滚重查):
|
||||
1. 查用户名下 is_active=True 且有 TemplateClipConfig 的模板 → 返回其 id;
|
||||
2. 无则调用 CreateTemplateUseCase 创建一条默认 voice_over 模板;
|
||||
3. 创建异常时 rollback 再重查一次(防并发唯一键冲突),重查仍无返回 None。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.application.template.commands import (
|
||||
CreateTemplateCommand,
|
||||
SegmentCommand,
|
||||
)
|
||||
from packages.application.template.use_cases import CreateTemplateUseCase
|
||||
|
||||
existing = (
|
||||
db.query(TemplateModel)
|
||||
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
|
||||
.order_by(TemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
has_seg = (
|
||||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
|
||||
)
|
||||
if has_seg:
|
||||
return existing.id
|
||||
|
||||
try:
|
||||
repo = SQLAlchemyTemplateRepository(db)
|
||||
cmd = CreateTemplateCommand(
|
||||
user_id=user_id,
|
||||
name="默认配音模板",
|
||||
mode="voice_over",
|
||||
category="default",
|
||||
tags=[],
|
||||
title_config={},
|
||||
subtitle_config={},
|
||||
bgm_config={},
|
||||
estimated_duration=0.0,
|
||||
segments=[SegmentCommand(segment_order=0, duration_min=1.0, duration_max=30.0)],
|
||||
)
|
||||
tpl = CreateTemplateUseCase(repo).execute(cmd)
|
||||
db.commit()
|
||||
logger.info("auto-created default voice_over template: id=%s user=%s", tpl.id, user_id)
|
||||
return tpl.id
|
||||
except Exception:
|
||||
db.rollback()
|
||||
# 重查:可能并发请求已建好
|
||||
existing = (
|
||||
db.query(TemplateModel)
|
||||
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
|
||||
.order_by(TemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
has_seg = (
|
||||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
|
||||
)
|
||||
if has_seg:
|
||||
return existing.id
|
||||
logger.exception("failed to auto-create default template user=%s", user_id)
|
||||
return None
|
||||
@@ -1,6 +1,6 @@
|
||||
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -138,4 +138,4 @@ def format_utc_datetime(dt: datetime | None) -> str:
|
||||
return dt
|
||||
if dt.tzinfo is None:
|
||||
return dt.isoformat() + "Z"
|
||||
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import List, Literal
|
||||
|
||||
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
|
||||
from fastapi import APIRouter
|
||||
@@ -31,7 +31,7 @@ class GenerateTitlesRequest(BaseModel):
|
||||
class GenerateTitlesResponse(BaseModel):
|
||||
"""智能标题生成响应."""
|
||||
|
||||
titles: list[str] = Field(..., description="生成的标题列表")
|
||||
titles: List[str] = Field(..., description="生成的标题列表")
|
||||
style: str = Field(..., description="实际使用的风格")
|
||||
source: str = Field(..., description="来源:doubao 或 fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
@@ -53,7 +53,7 @@ class AssetMatchItem(BaseModel):
|
||||
|
||||
id: str = Field(..., description="素材ID")
|
||||
name: str = Field(default="", description="素材名称")
|
||||
tags: list[str] = Field(default_factory=list, description="标签列表")
|
||||
tags: List[str] = Field(default_factory=list, description="标签列表")
|
||||
description: str = Field(default="", description="素材描述")
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class SemanticMatchRequest(BaseModel):
|
||||
"""语义匹配请求."""
|
||||
|
||||
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
|
||||
assets: list[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
|
||||
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
|
||||
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class SemanticMatchResultItem(AssetMatchItem):
|
||||
class SemanticMatchResponse(BaseModel):
|
||||
"""语义匹配响应."""
|
||||
|
||||
matches: list[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
|
||||
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
|
||||
source: str = Field(..., description="来源:doubao / fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
total: int = Field(..., description="输入素材总数")
|
||||
@@ -99,7 +99,7 @@ def generate_titles(request: GenerateTitlesRequest):
|
||||
return GenerateTitlesResponse(**result)
|
||||
|
||||
|
||||
@router.get("/titles/styles", response_model=list[TitleStyleInfo])
|
||||
@router.get("/titles/styles", response_model=List[TitleStyleInfo])
|
||||
def list_title_styles():
|
||||
"""获取支持的标题风格列表."""
|
||||
return [
|
||||
|
||||
@@ -11,14 +11,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
FinalizeRenderResponse,
|
||||
SmartCoverRequest,
|
||||
SmartCoverResponse,
|
||||
)
|
||||
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
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception as exc:
|
||||
logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
|
||||
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
@@ -179,54 +172,38 @@ def retry_render_job(
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception as exc:
|
||||
logger.exception("Celery 任务投递失败(重试): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
|
||||
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
|
||||
|
||||
# ── POST /smart-cover — 智能获取封面(MediaKit 抽帧 + 评分选帧)────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse)
|
||||
def generate_render_smart_cover(
|
||||
job_id: str,
|
||||
@router.post("/smart-cover", response_model=SmartCoverResponse)
|
||||
def generate_avatar_smart_cover(
|
||||
body: SmartCoverRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
|
||||
) -> SmartCoverResponse:
|
||||
"""智能获取数字人视频封面.
|
||||
|
||||
- 必须等渲染任务 completed 后才可调用(否则返回 400)
|
||||
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url
|
||||
复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧逻辑(非 FFmpeg 简单截帧),
|
||||
并将选中帧转存到自家 OSS,返回非临时的封面公网 URL。
|
||||
|
||||
前端「智能获取封面」按钮可直接调用本接口;不依赖渲染任务完成。
|
||||
"""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
svc = AiAvatarRenderService(db)
|
||||
job = svc.get_render_job(job_id, current_user.user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "completed":
|
||||
raise HTTPException(status_code=400, detail="请先完成视频生成")
|
||||
video_url = (job.output_video_url or "").strip()
|
||||
if not video_url:
|
||||
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空")
|
||||
video_url = (body.video_url or "").strip()
|
||||
if not video_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL")
|
||||
|
||||
try:
|
||||
# 从最终成片抽帧,帧本身已含标题/B-roll,直接转存 OSS
|
||||
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
|
||||
cover_url = generate_smart_cover(video_url, max_frames=body.max_frames)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s",
|
||||
current_user.user.id,
|
||||
job_id,
|
||||
video_url[:80],
|
||||
exc,
|
||||
"智能封面生成异常: user=%s video_url=%s err=%s",
|
||||
current_user.user.id, video_url[:80], exc,
|
||||
exc_info=True,
|
||||
)
|
||||
cover_url = ""
|
||||
@@ -237,88 +214,5 @@ def generate_render_smart_cover(
|
||||
status="fallback_failed",
|
||||
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
|
||||
)
|
||||
|
||||
# 更新 render_job 的封面字段(异步写入 DB;失败不影响返回)
|
||||
try:
|
||||
job.cover_config = {
|
||||
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
|
||||
"mode": "auto_frame",
|
||||
"url": cover_url,
|
||||
}
|
||||
job.output_cover_url = cover_url
|
||||
job.updated_at = datetime.now(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],
|
||||
)
|
||||
logger.info("智能封面生成成功: user=%s cover_url=%s", current_user.user.id, cover_url[:120])
|
||||
return SmartCoverResponse(cover_url=cover_url, status="completed")
|
||||
|
||||
# ── POST /{job_id}/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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.api.routes._helpers import check_project_access, format_utc_datetime
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -390,7 +390,7 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=list[AssetResponse])
|
||||
@router.post("/batch", response_model=List[AssetResponse])
|
||||
def batch_get_assets(
|
||||
request: BatchGetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
@@ -156,7 +156,7 @@ def _cleanup_expired_uploads() -> int:
|
||||
if not CHUNK_STORAGE_ROOT.exists():
|
||||
return 0
|
||||
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
cleaned = 0
|
||||
|
||||
for meta_file in CHUNK_STORAGE_ROOT.glob("*.meta.json"):
|
||||
@@ -166,7 +166,7 @@ def _cleanup_expired_uploads() -> int:
|
||||
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Only cleanup uploads that are not actively being uploaded
|
||||
if expires_at < now and meta.get("status") != "uploading":
|
||||
@@ -226,7 +226,7 @@ async def init_chunked_upload(
|
||||
|
||||
# Generate upload ID
|
||||
upload_id = uuid4().hex
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(hours=CHUNK_EXPIRY_HOURS)
|
||||
|
||||
# Create chunk directory
|
||||
@@ -421,9 +421,9 @@ async def upload_chunk(
|
||||
# Check expiry
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires_at < datetime.now(UTC):
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
|
||||
|
||||
# Validate chunk index
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""独立的从素材创建片段端点(不依赖 template_id 路径参数).
|
||||
|
||||
POST /api/v1/clips/from-assets
|
||||
- 与 /api/v1/templates/{template_id}/editor/clips/from-assets 功能一致
|
||||
- 区别:template_id 从 body 传入(可选),为空时后端自动创建/查找默认模板
|
||||
- 解决前端首次加载时 templateId 为空导致双斜杠 404 的问题(P0 #1922)
|
||||
- 内部复用 resolve_draft_plan_id 和 create_clips_from_assets_editor 的核心逻辑
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_asset_repository, get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
|
||||
from ._default_template import get_or_create_default_template_id
|
||||
from .templates_editor.clips import create_clips_from_assets_editor
|
||||
from .templates_editor.dependencies import resolve_draft_plan_id
|
||||
from .templates_editor.schemas import ClipsFromAssetsRequest, ClipsFromAssetsResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Clips"])
|
||||
|
||||
|
||||
class StandaloneClipsRequest(ClipsFromAssetsRequest):
|
||||
"""扩展请求:template_id 可选(不传则后端自动兜底默认模板)。"""
|
||||
|
||||
template_id: str | None = None
|
||||
|
||||
|
||||
def _get_editor_services_direct(db: Session) -> tuple[EditTemplateService, EditPlanService]:
|
||||
"""直接构造服务实例(非 Depends 版本,供独立端点内部调用)。"""
|
||||
return EditTemplateService(db), EditPlanService(db)
|
||||
|
||||
|
||||
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets(
|
||||
body: StandaloneClipsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
) -> ClipsFromAssetsResponse:
|
||||
"""从素材批量创建片段(template_id 可选,为空自动兜底)。"""
|
||||
user_id = str(current_user.user.id)
|
||||
services = _get_editor_services_direct(db)
|
||||
|
||||
# 1. 解析/兜底 template_id,拿到 plan_id
|
||||
template_id = (body.template_id or "").strip()
|
||||
if not template_id:
|
||||
template_id = get_or_create_default_template_id(db, user_id)
|
||||
if not template_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id=template_id,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False, # 上面已兜底过
|
||||
)
|
||||
|
||||
# 2. 构造标准化请求(去除独立端扩展字段),复用原端点核心逻辑
|
||||
core_body = ClipsFromAssetsRequest(
|
||||
asset_ids=body.asset_ids,
|
||||
clip_type=body.clip_type,
|
||||
clip_count=body.clip_count,
|
||||
required_clips_count=body.required_clips_count,
|
||||
)
|
||||
|
||||
# 3. 直接调用原端点函数(此时所有 Depends 依赖已手动传入)
|
||||
return create_clips_from_assets_editor(
|
||||
template_id=template_id,
|
||||
body=core_body,
|
||||
background_tasks=background_tasks,
|
||||
plan_id=plan_id,
|
||||
services=services,
|
||||
asset_repo=asset_repo,
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
)
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -41,7 +41,7 @@ router = APIRouter(tags=["Generation"])
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: list[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
@@ -766,12 +766,7 @@ def generate_cover(
|
||||
storage_svc = get_shared_storage_service()
|
||||
mk_client = get_mediakit_client()
|
||||
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
|
||||
# #1901 统一读 "title",兼容老数据 "title_config"
|
||||
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
|
||||
if not isinstance(_e2_title_cfg, dict) or not (_e2_title_cfg.get("text") or "").strip():
|
||||
_alt = (plan.config or {}).get("title_config", {}) or {}
|
||||
if isinstance(_alt, dict):
|
||||
_e2_title_cfg = _alt
|
||||
if not isinstance(_e2_title_cfg, dict):
|
||||
_e2_title_cfg = {}
|
||||
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
|
||||
|
||||
@@ -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]:
|
||||
"""[已下沉] 路由层兼容别名 → app.services.generation_common.query_voice_durations。"""
|
||||
from app.services.generation_common import query_voice_durations
|
||||
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
|
||||
|
||||
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:
|
||||
@@ -167,10 +184,61 @@ def _writeback_edit_plan_config(
|
||||
title_config: dict | None,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_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(
|
||||
@@ -421,14 +489,25 @@ def create_generation_task(
|
||||
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
|
||||
voice_durations = _query_voice_durations(db, variant_voices)
|
||||
|
||||
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(公共函数)
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源)
|
||||
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 = (
|
||||
request.source_edit_plan_id
|
||||
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
|
||||
or ""
|
||||
)
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
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:
|
||||
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
|
||||
@@ -473,12 +552,7 @@ def create_generation_task(
|
||||
) from clone_err
|
||||
variant_plan_ids.append(_plan0.id)
|
||||
|
||||
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数)
|
||||
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做素材区间避让)
|
||||
# 变体 1..N-1 独立选片
|
||||
for task_index in range(1, count):
|
||||
variant = None
|
||||
last_err: Exception | None = None
|
||||
@@ -490,7 +564,6 @@ def create_generation_task(
|
||||
created_by_user_id=user_id,
|
||||
name_suffix=f"批量{task_index + 1}",
|
||||
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
|
||||
batch_segments=_batch_segments,
|
||||
)
|
||||
break
|
||||
except ValueError as ve:
|
||||
@@ -522,16 +595,7 @@ def create_generation_task(
|
||||
) from last_err
|
||||
variant_plan_ids.append(variant.id)
|
||||
|
||||
# #1855 P0:把新变体的clips区间追加到batch_segments,供下一变体避让
|
||||
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 已内置幂等判断,重复调用安全)
|
||||
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配)
|
||||
for _vi, _pid in enumerate(variant_plan_ids):
|
||||
_vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0
|
||||
if _vd > 0:
|
||||
@@ -552,13 +616,24 @@ def create_generation_task(
|
||||
)
|
||||
_single_vd: list[float] = _query_voice_durations(db, _voices)
|
||||
_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 = (
|
||||
request.source_edit_plan_id
|
||||
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
|
||||
or ""
|
||||
)
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
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:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
|
||||
@@ -30,74 +30,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_or_create_default_template_id(db: Session, user_id: str) -> str | None:
|
||||
"""为用户查找一个有效模板;若不存在则自动创建默认配音模板。
|
||||
|
||||
前端 #1911 删除了模板选择 UI,当调用方未传 template_id/source_edit_plan_id
|
||||
时(如剪辑页首次进入直接选片),后端兜底查找/创建默认模板,避免 400。
|
||||
|
||||
Returns:
|
||||
template_id(字符串);失败时返回 None。
|
||||
"""
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||||
from packages.application.template.commands import CreateTemplateCommand, SegmentCommand
|
||||
from packages.application.template.use_cases import CreateTemplateUseCase
|
||||
|
||||
# 1. 先查已有有效模板(is_active=True 且存在片段配置)
|
||||
existing = (
|
||||
db.query(TemplateModel)
|
||||
.filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(TemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
# 验证该模板是否有片段配置;若没有继续尝试创建默认
|
||||
has_seg = (
|
||||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
|
||||
)
|
||||
if has_seg:
|
||||
return existing.id
|
||||
|
||||
# 2. 无有效模板 → 自动创建默认配音模板
|
||||
try:
|
||||
repo = SQLAlchemyTemplateRepository(db)
|
||||
cmd = CreateTemplateCommand(
|
||||
user_id=user_id,
|
||||
name="默认配音模板",
|
||||
mode="voice_over",
|
||||
category="default",
|
||||
tags=[],
|
||||
title_config={},
|
||||
subtitle_config={},
|
||||
bgm_config={},
|
||||
estimated_duration=0.0,
|
||||
segments=[
|
||||
SegmentCommand(
|
||||
segment_order=0,
|
||||
duration_min=1.0,
|
||||
duration_max=30.0,
|
||||
material_type=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
use_case = CreateTemplateUseCase(repo)
|
||||
tpl = use_case.execute(cmd)
|
||||
logger.info(
|
||||
"[variant-plans] 自动创建默认模板: user=%s tpl=%s",
|
||||
user_id,
|
||||
tpl.id,
|
||||
)
|
||||
return tpl.id
|
||||
except Exception:
|
||||
logger.exception("[variant-plans] 自动创建默认模板失败: user=%s", user_id)
|
||||
return None
|
||||
|
||||
|
||||
class VariantPlanRequest(BaseModel):
|
||||
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
|
||||
|
||||
@@ -111,8 +43,8 @@ class VariantPlanRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate(self) -> "VariantPlanRequest":
|
||||
# 不再强制要求 template_id / source_edit_plan_id:
|
||||
# 后端在路由内会自动查找/创建默认模板兜底(#1911 后前端不再显式选模板)。
|
||||
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
|
||||
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
|
||||
try:
|
||||
resolve_variant_voice_ids(
|
||||
count=self.count,
|
||||
@@ -158,19 +90,25 @@ def create_variant_plans(
|
||||
except VariantVoiceError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新(公共函数)
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新
|
||||
source_plan_id = request.source_edit_plan_id.strip()
|
||||
template_id = request.template_id.strip()
|
||||
if not source_plan_id and request.template_id.strip():
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
# P0 兜底:前端 #1911 已删除模板选择 UI,调用方可能不传 template_id;
|
||||
# 此时自动为该用户查找/创建默认模板。
|
||||
if not source_plan_id and not template_id:
|
||||
template_id = _get_or_create_default_template_id(db, user_id) or ""
|
||||
|
||||
if not source_plan_id and template_id:
|
||||
source_plan_id = resolve_latest_plan_by_template(db, template_id=template_id, user_id=user_id) or ""
|
||||
_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:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg
|
||||
import redis
|
||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["Health"])
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ async def startup_check():
|
||||
all_ready = all(check["status"] == "healthy" for check in checks.values())
|
||||
response = {
|
||||
"status": "started" if all_ready else "starting",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"checks": checks,
|
||||
}
|
||||
if not all_ready:
|
||||
|
||||
@@ -1,40 +1,28 @@
|
||||
"""对口型 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/{id} 任务详情
|
||||
POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态
|
||||
POST /api/v1/lipsync/jobs/{id}/cancel 取消任务
|
||||
POST /api/v1/lipsync/tts-preview #1845 步骤1 TTS 预合成(同步 HTTP,~2-3s)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import UTC
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import (
|
||||
get_db_session,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
from app.schemas.lipsync import (
|
||||
AiAvatarTtsPreviewRequest,
|
||||
AiAvatarTtsPreviewResponse,
|
||||
CreateLipsyncJobRequest,
|
||||
LipsyncJobResponse,
|
||||
)
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -45,6 +33,7 @@ def _get_service(
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> LipsyncService:
|
||||
# voice_clone_repo 用于克隆音色 profile 解析
|
||||
# TTS 合成已移至 Celery 异步任务,无需同步注入 cosyvoice_service
|
||||
return LipsyncService(
|
||||
db,
|
||||
voice_clone_repo=voice_clone_repo,
|
||||
@@ -58,56 +47,19 @@ def _get_service(
|
||||
def create_lipsync_job(
|
||||
body: CreateLipsyncJobRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
user_id = current_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_digital_human"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
# 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟)
|
||||
if body.audio_url and body.audio_duration and body.audio_duration > 0:
|
||||
est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0))
|
||||
elif body.script_text:
|
||||
est_minutes = max(1.0, math.ceil(len(body.script_text) / 240))
|
||||
else:
|
||||
est_minutes = 1.0
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(current_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(current_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
"""提交对口型任务.
|
||||
|
||||
三种模式:
|
||||
- TTS 直生(旧版/降级):传 {video_url, voice_id, script_text, speed?, emotion?},
|
||||
后端 dispatch Celery 异步任务。
|
||||
- 直接音频:传 {video_url, audio_url},后端同步下载+算timings+提交MediaKit。
|
||||
- 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings},
|
||||
后端同步ffprobe+写入timings+直接提交MediaKit(~2-3s)。
|
||||
#1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?},
|
||||
后端创建任务记录(状态 tts_processing),dispatch Celery 异步任务执行 TTS 合成 + MediaKit 提交;
|
||||
也支持直接传 {video_url, audio_url}(同步提交 MediaKit)。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=user_id,
|
||||
user_id=current_user.user.id,
|
||||
video_url=body.video_url,
|
||||
audio_url=body.audio_url,
|
||||
audio_duration=body.audio_duration,
|
||||
sentence_timings=body.sentence_timings,
|
||||
voice_id=body.voice_id,
|
||||
script_text=body.script_text,
|
||||
speed=body.speed,
|
||||
@@ -116,18 +68,10 @@ def create_lipsync_job(
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}")
|
||||
# 参数无效(如 voice_id 格式不对、文本过长等)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except MediaKitError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}")
|
||||
# 音色无权访问 → 403;参数无效 → 400;MediaKit 提交失败 → 502
|
||||
status_code = 502
|
||||
if exc.code in ("VoiceForbidden",):
|
||||
status_code = 403
|
||||
@@ -142,110 +86,16 @@ def create_lipsync_job(
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
# 兜底:任何未预期的错误返回 400 而非 500
|
||||
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型异常退积分异常: err={refund_err}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"创建对口型任务失败: {exc}",
|
||||
) from exc
|
||||
|
||||
# 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等)
|
||||
# 若任务已创建且状态为 failed,退费
|
||||
if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed":
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
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),
|
||||
db: Session = Depends(get_db_session),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
user_id = current_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_digital_human"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(current_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(current_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
"""步骤1「生成配音」同步 TTS 预合成.
|
||||
|
||||
同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算,
|
||||
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。
|
||||
耗时约 2-3 秒。
|
||||
"""
|
||||
try:
|
||||
result = svc.preview_tts(
|
||||
user_id=user_id,
|
||||
voice_id=body.voice_id,
|
||||
script_text=body.script_text,
|
||||
speed=body.speed,
|
||||
emotion=body.emotion,
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}")
|
||||
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)
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"TTS 合成失败: {exc}",
|
||||
) from exc
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -284,28 +134,17 @@ def get_lipsync_job(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务详情."""
|
||||
"""获取对口型任务详情.
|
||||
|
||||
非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态),
|
||||
避免 MediaKit 慢响应阻塞前端轮询。
|
||||
"""
|
||||
job = svc.get_job(job_id, current_user.user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if job.status not in ("completed", "failed"):
|
||||
# 三层防御 ①:如果距上次更新超过 30 秒,同步刷新一次(避免 background task
|
||||
# 静默失败导致前端永远看到 running);否则挂后台异步刷新(避免阻塞轮询)。
|
||||
from datetime import datetime as _dt
|
||||
|
||||
_now = _dt.now(UTC)
|
||||
_stale = job.updated_at is None or (_now - job.updated_at).total_seconds() > 30
|
||||
if _stale:
|
||||
try:
|
||||
refreshed = svc.refresh_job_status(job_id, current_user.user.id)
|
||||
if refreshed is not None:
|
||||
job = refreshed
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("同步刷新对口型状态失败 job_id=%s err=%s", job_id, exc, exc_info=True)
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
else:
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
"""积分 & 会员 API 路由 (#1895)
|
||||
|
||||
导出两个 router:
|
||||
- points_router: 积分相关路由,前缀 /points
|
||||
- usage_router: 每日额度路由,前缀 /usage
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.points import (
|
||||
DailyUsageResponse,
|
||||
MembershipStatusResponse,
|
||||
PointRuleItem,
|
||||
PointsBalanceResponse,
|
||||
PointsCheckRequest,
|
||||
PointsCheckResponse,
|
||||
PointsDeductRequest,
|
||||
PointsOrderResponse,
|
||||
PointsPackageItem,
|
||||
PointsPackagesResponse,
|
||||
PointsRechargeRequest,
|
||||
PointsRefundRequest,
|
||||
PointsRulesResponse,
|
||||
PointsTransactionsResponse,
|
||||
SimpleMessageResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.points_rules import (
|
||||
FREE_USER_MULTIPLIER,
|
||||
MEMBER_DISCOUNT,
|
||||
POINTS_PACKAGES,
|
||||
POINTS_SCENES,
|
||||
calculate_points_cost,
|
||||
)
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 两个 router ──
|
||||
points_router = APIRouter()
|
||||
usage_router = APIRouter()
|
||||
|
||||
|
||||
def _get_service() -> PointsService:
|
||||
return PointsService()
|
||||
|
||||
|
||||
def _is_member(user: AuthenticatedUser) -> bool:
|
||||
"""判断用户是否为付费会员。"""
|
||||
return getattr(user.user, "is_member", False)
|
||||
|
||||
|
||||
def _member_type(user: AuthenticatedUser) -> str | None:
|
||||
return getattr(user.user, "member_type", None)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# 积分相关路由 (prefix=/points)
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@points_router.get("/balance", response_model=PointsBalanceResponse)
|
||||
def get_balance(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""查询当前用户积分余额 + 会员状态。"""
|
||||
svc = _get_service()
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
return PointsBalanceResponse(
|
||||
balance=account["balance"],
|
||||
total_earned=account["total_earned"],
|
||||
total_spent=account["total_spent"],
|
||||
is_member=_is_member(current_user),
|
||||
member_type=_member_type(current_user),
|
||||
member_expires_at=getattr(current_user.user, "member_expires_at", None),
|
||||
)
|
||||
|
||||
|
||||
@points_router.get("/transactions", response_model=PointsTransactionsResponse)
|
||||
def get_transactions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
type: Optional[str] = Query(None, description="筛选类型: add/deduct"),
|
||||
source: Optional[str] = Query(None, description="筛选来源场景"),
|
||||
start_date: Optional[datetime] = Query(None),
|
||||
end_date: Optional[datetime] = Query(None),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""查询积分流水(分页+筛选)。"""
|
||||
svc = _get_service()
|
||||
result = svc.get_transactions(
|
||||
user_id=current_user.user.id,
|
||||
db=db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
type_filter=type,
|
||||
source_filter=source,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
return PointsTransactionsResponse(**result)
|
||||
|
||||
|
||||
@points_router.get("/rules", response_model=PointsRulesResponse)
|
||||
def get_rules(
|
||||
_current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询所有积分消耗规则。"""
|
||||
rules = []
|
||||
for scene_key, scene_data in POINTS_SCENES.items():
|
||||
rules.append(
|
||||
PointRuleItem(
|
||||
scene_key=scene_key,
|
||||
name=scene_data["name"],
|
||||
base_points=scene_data["base_points"],
|
||||
unit=scene_data["unit"],
|
||||
extra_per_30s=scene_data.get("extra_per_30s"),
|
||||
)
|
||||
)
|
||||
return PointsRulesResponse(
|
||||
rules=rules,
|
||||
free_user_multiplier=FREE_USER_MULTIPLIER,
|
||||
)
|
||||
|
||||
|
||||
@points_router.get("/packages", response_model=PointsPackagesResponse)
|
||||
def get_packages(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询可购买的积分包列表。"""
|
||||
packages = []
|
||||
for code, pkg in POINTS_PACKAGES.items():
|
||||
unit_price = f"¥{pkg['price_cents'] / 100 / pkg['points']:.3f}/积分"
|
||||
packages.append(
|
||||
PointsPackageItem(
|
||||
code=code,
|
||||
name=pkg["name"],
|
||||
points=pkg["points"],
|
||||
price_cents=pkg["price_cents"],
|
||||
unit_price=unit_price,
|
||||
)
|
||||
)
|
||||
mt = _member_type(current_user)
|
||||
discount = MEMBER_DISCOUNT.get(mt) if mt else None
|
||||
return PointsPackagesResponse(packages=packages, user_discount=discount)
|
||||
|
||||
|
||||
@points_router.post("/check", response_model=PointsCheckResponse)
|
||||
def check_points(
|
||||
body: PointsCheckRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""消费前检查余额是否足够。"""
|
||||
is_mem = _is_member(current_user)
|
||||
mt = _member_type(current_user)
|
||||
|
||||
# 混剪场景先检查免费额度
|
||||
is_free_quota = False
|
||||
if body.scene_key == "ai_video" and not is_mem:
|
||||
svc = _get_service()
|
||||
if svc.check_daily_free_clip(current_user.user.id, db):
|
||||
is_free_quota = True
|
||||
|
||||
required = calculate_points_cost(
|
||||
body.scene_key,
|
||||
is_mem,
|
||||
quantity=body.quantity or 1,
|
||||
duration_minutes=body.duration_minutes or 0,
|
||||
member_type=mt,
|
||||
)
|
||||
|
||||
svc = _get_service()
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
balance = account["balance"]
|
||||
|
||||
return PointsCheckResponse(
|
||||
allowed=is_free_quota or balance >= required,
|
||||
required_points=required,
|
||||
current_balance=balance,
|
||||
remaining_after=balance - required,
|
||||
is_free_quota=is_free_quota,
|
||||
)
|
||||
|
||||
|
||||
@points_router.post("/deduct", response_model=SimpleMessageResponse)
|
||||
def deduct_points(
|
||||
body: PointsDeductRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""积分扣减(内部服务调用)。"""
|
||||
svc = _get_service()
|
||||
result = svc.deduct_points(
|
||||
user_id=current_user.user.id,
|
||||
amount=body.amount,
|
||||
source=body.scene_key,
|
||||
db=db,
|
||||
description=body.description or "",
|
||||
ref_id=body.ref_id or "",
|
||||
)
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {body.amount},余额 {result['balance']}",
|
||||
},
|
||||
)
|
||||
return SimpleMessageResponse(
|
||||
success=True,
|
||||
message=f"扣减 {body.amount} 积分成功",
|
||||
data={"transaction_id": result["transaction_id"], "balance": result["balance"]},
|
||||
)
|
||||
|
||||
|
||||
@points_router.post("/refund", response_model=SimpleMessageResponse)
|
||||
def refund_points(
|
||||
body: PointsRefundRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""积分退还(内部服务调用)。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
|
||||
|
||||
txn = (
|
||||
db.query(PointsTransactionModel)
|
||||
.filter(PointsTransactionModel.id == body.transaction_id)
|
||||
.first()
|
||||
)
|
||||
if txn is None:
|
||||
raise HTTPException(status_code=404, detail="交易记录不存在")
|
||||
if txn.user_id != current_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权退还他人积分")
|
||||
|
||||
svc = _get_service()
|
||||
result = svc.refund_points(
|
||||
user_id=current_user.user.id,
|
||||
amount=txn.amount,
|
||||
source=txn.source,
|
||||
db=db,
|
||||
ref_id=body.transaction_id,
|
||||
description=body.reason or f"退还: {txn.description}",
|
||||
)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=500, detail="退还失败")
|
||||
return SimpleMessageResponse(
|
||||
success=True,
|
||||
message=f"退还 {txn.amount} 积分成功",
|
||||
data={"transaction_id": result["transaction_id"], "balance": result["balance"]},
|
||||
)
|
||||
|
||||
|
||||
@points_router.post("/recharge", response_model=PointsOrderResponse)
|
||||
def create_recharge_order(
|
||||
body: PointsRechargeRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""创建积分充值订单。"""
|
||||
svc = _get_service()
|
||||
try:
|
||||
order = svc.create_order(
|
||||
user_id=current_user.user.id,
|
||||
order_type="points",
|
||||
product_code=body.package_id,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
return PointsOrderResponse(**order)
|
||||
|
||||
|
||||
@points_router.get("/subscription/membership", response_model=MembershipStatusResponse)
|
||||
def get_membership_status(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""获取当前用户会员状态(聚合信息)。"""
|
||||
svc = _get_service()
|
||||
account = svc.get_or_create_account(current_user.user.id, db)
|
||||
is_mem = _is_member(current_user)
|
||||
max_resolution = "1080p" if is_mem else "720p"
|
||||
|
||||
return MembershipStatusResponse(
|
||||
is_member=is_mem,
|
||||
member_type=_member_type(current_user),
|
||||
member_expires_at=getattr(current_user.user, "member_expires_at", None),
|
||||
points_balance=account["balance"],
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# 每日额度路由 (prefix=/usage)
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@usage_router.get("/daily", response_model=DailyUsageResponse)
|
||||
def get_daily_usage(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""查询今日免费混剪额度使用情况。"""
|
||||
svc = _get_service()
|
||||
result = svc.get_daily_usage(current_user.user.id, db)
|
||||
return DailyUsageResponse(**result)
|
||||
|
||||
|
||||
# 为了向后兼容,也导出一个不带后缀的 router(方便旧引用)
|
||||
router = points_router
|
||||
@@ -1,243 +0,0 @@
|
||||
"""Scripts AI 能力路由 — Issue #1893.
|
||||
|
||||
三个 AI 工具接口(均挂载在 /api/v1/scripts 前缀下):
|
||||
- POST /extract-from-douyin 从抖音视频提取文案(yt-dlp 下载 + ASR 转写)
|
||||
- POST /ai-rewrite AI 文案改写(复用豆包 LLM)
|
||||
- POST /ai-generate-titles AI 标题生成(复用 generate_smart_titles)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.scripts_ai import (
|
||||
AiGenerateTitlesRequest,
|
||||
AiGenerateTitlesResponse,
|
||||
AiRewriteRequest,
|
||||
AiRewriteResponse,
|
||||
ExtractFromDouyinRequest,
|
||||
ExtractFromDouyinResponse,
|
||||
)
|
||||
from app.services.script_asr_service import (
|
||||
ASRNotConfiguredError,
|
||||
ASRTranscriptionError,
|
||||
transcribe_to_text,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.middleware.points_gate import points_gate
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 抖音 URL 校验:支持短链 v.douyin.com 和长链 www.douyin.com/video/
|
||||
_DOUYIN_URL_RE = re.compile(
|
||||
r"^(https?://)?(v\.douyin\.com/\S+|www\.douyin\.com/video/\S+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _validate_douyin_url(url: str) -> None:
|
||||
"""校验抖音 URL 格式,不合法时抛 HTTPException(400)."""
|
||||
if not url or not url.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="链接不能为空",
|
||||
)
|
||||
if not _DOUYIN_URL_RE.match(url.strip()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的抖音链接,仅支持 v.douyin.com 短链或 www.douyin.com/video/ 长链",
|
||||
)
|
||||
|
||||
|
||||
# ── 1. 从抖音视频提取文案 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/extract-from-douyin",
|
||||
response_model=ExtractFromDouyinResponse,
|
||||
)
|
||||
@points_gate("douyin_extract")
|
||||
def extract_from_douyin(
|
||||
request: ExtractFromDouyinRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> ExtractFromDouyinResponse:
|
||||
"""从抖音视频下载无水印视频并通过 ASR 提取文案."""
|
||||
source_url = request.url.strip()
|
||||
_validate_douyin_url(source_url)
|
||||
|
||||
# 确保 URL 有 scheme(yt-dlp 需要完整 URL)
|
||||
url_for_download = source_url
|
||||
if not re.match(r"^https?://", url_for_download, re.IGNORECASE):
|
||||
url_for_download = "https://" + url_for_download
|
||||
|
||||
# 使用临时目录下载视频,退出时自动清理
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir:
|
||||
import yt_dlp
|
||||
|
||||
ydl_opts = {
|
||||
"format": "best[ext=mp4]/best",
|
||||
"outtmpl": f"{temp_dir}/%(id)s.%(ext)s",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noplaylist": True,
|
||||
}
|
||||
|
||||
try:
|
||||
ydl = yt_dlp.YoutubeDL(ydl_opts)
|
||||
info = ydl.extract_info(url_for_download, download=True)
|
||||
except Exception as exc:
|
||||
logger.error("抖音视频下载失败: url=%s error=%s", source_url, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"视频下载失败: {exc}",
|
||||
) from exc
|
||||
|
||||
if info is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无法解析该抖音链接",
|
||||
)
|
||||
|
||||
video_path = ydl.prepare_filename(info)
|
||||
duration = float(info.get("duration") or 0)
|
||||
|
||||
# ASR 转写
|
||||
try:
|
||||
text = transcribe_to_text(video_path)
|
||||
except ASRNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except ASRTranscriptionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
return ExtractFromDouyinResponse(
|
||||
text=text,
|
||||
duration_seconds=duration,
|
||||
source_url=source_url,
|
||||
)
|
||||
|
||||
|
||||
# ── 2. AI 文案改写 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ai-rewrite",
|
||||
response_model=AiRewriteResponse,
|
||||
)
|
||||
@points_gate("ai_rewrite")
|
||||
def ai_rewrite(
|
||||
request: AiRewriteRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> AiRewriteResponse:
|
||||
"""使用豆包大模型改写文案."""
|
||||
content = (request.content or "").strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文案内容不能为空",
|
||||
)
|
||||
|
||||
style = request.style or "口语化"
|
||||
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI 服务不可用,请联系管理员配置豆包大模型 API Key",
|
||||
)
|
||||
|
||||
system_prompt = (
|
||||
"你是一个专业的短视频文案改写专家。请对以下文案进行改写,"
|
||||
"要求:保留原意、口语化、适合短视频口播、调整语序避免查重。"
|
||||
)
|
||||
if style:
|
||||
system_prompt += f"\n风格要求:{style}"
|
||||
|
||||
user_prompt = f"请改写以下文案:\n\n{content}"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
try:
|
||||
rewritten = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.8,
|
||||
max_tokens=2048,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("AI 改写调用失败: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"AI 改写失败: {exc}",
|
||||
) from exc
|
||||
|
||||
if not rewritten:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI 改写未返回有效结果",
|
||||
)
|
||||
|
||||
return AiRewriteResponse(
|
||||
original=content,
|
||||
rewritten=rewritten.strip(),
|
||||
style=style,
|
||||
)
|
||||
|
||||
|
||||
# ── 3. AI 标题生成 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ai-generate-titles",
|
||||
response_model=AiGenerateTitlesResponse,
|
||||
)
|
||||
@points_gate("ai_title")
|
||||
def ai_generate_titles(
|
||||
request: AiGenerateTitlesRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> AiGenerateTitlesResponse:
|
||||
"""使用现有 generate_smart_titles 生成标题."""
|
||||
content = (request.content or "").strip()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文案内容不能为空",
|
||||
)
|
||||
|
||||
# count 限制在 1-5(Pydantic ge=1 le=5 已校验),但为兼容直接调用场景截断
|
||||
count = max(1, min(5, request.count))
|
||||
|
||||
from app.services.ai_service import generate_smart_titles
|
||||
|
||||
result = generate_smart_titles(
|
||||
description=content,
|
||||
style="viral",
|
||||
count=count,
|
||||
)
|
||||
|
||||
titles = result.get("titles", [])[:count]
|
||||
|
||||
return AiGenerateTitlesResponse(titles=titles)
|
||||
@@ -4,7 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
@@ -66,7 +67,7 @@ def _get_plan_price(plan_id: str, billing_cycle: str) -> float:
|
||||
|
||||
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
|
||||
"""构建订阅信息响应"""
|
||||
now = datetime.now(UTC)
|
||||
now = datetime.now(timezone.utc)
|
||||
if user.user.subscription_expires_at:
|
||||
period_end = user.user.subscription_expires_at.isoformat()
|
||||
period_start = now.isoformat()
|
||||
@@ -99,10 +100,10 @@ async def get_current_subscription(
|
||||
return _build_subscription_info(current_user)
|
||||
|
||||
|
||||
@router.get("/billing-records", response_model=list[BillingRecord])
|
||||
@router.get("/billing-records", response_model=List[BillingRecord])
|
||||
async def get_billing_records(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> list[BillingRecord]:
|
||||
) -> List[BillingRecord]:
|
||||
"""获取账单记录列表"""
|
||||
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
@@ -250,7 +251,7 @@ async def payment_callback(
|
||||
|
||||
# 计算到期时间
|
||||
days = 365 if billing_cycle == "yearly" else 30
|
||||
expires_at = datetime.now(UTC) + timedelta(days=days)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
repo.update_subscription_on_payment(user_id, plan, expires_at)
|
||||
|
||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
"""Template 列表路由(供生成页自动选模板).
|
||||
|
||||
保留:
|
||||
- GET /templates:列表查询(生成页使用)
|
||||
- 默认模板自动创建兜底逻辑(复用 _default_template.get_or_create_default_template_id)
|
||||
|
||||
其他模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等 HTTP 端点
|
||||
已在 PR#1918 中删除(前端 PR#1911 已删除 my-templates / editing-planner /
|
||||
templates 管理页面)。
|
||||
"""
|
||||
"""Template CRUD + generate + category routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,19 +7,53 @@ import logging
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.template import (
|
||||
CategoryResponse,
|
||||
CopyTemplateRequest,
|
||||
CreateCategoryRequest,
|
||||
CreateTemplateRequest,
|
||||
GenerateWarningResponse,
|
||||
ListCategoriesResponse,
|
||||
ListTagsResponse,
|
||||
ListTemplatesResponse,
|
||||
SegmentResponse,
|
||||
TemplateResponse,
|
||||
TemplateUsageResponse,
|
||||
ToggleFavoriteResponse,
|
||||
UpdateTemplateRequest,
|
||||
ValidateTemplateRequest,
|
||||
ValidateTemplateResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||||
from packages.application.template.commands import ListTemplatesFilter
|
||||
from packages.application.template.use_cases import CountTemplatesUseCase, ListTemplatesUseCase
|
||||
|
||||
from ._default_template import get_or_create_default_template_id
|
||||
from packages.application.template.commands import (
|
||||
CopyTemplateCommand,
|
||||
CreateCategoryCommand,
|
||||
CreateTemplateCommand,
|
||||
ListTemplatesFilter,
|
||||
SegmentCommand,
|
||||
UpdateTemplateCommand,
|
||||
ValidateTemplateCommand,
|
||||
)
|
||||
from packages.application.template.use_cases import (
|
||||
CopyTemplateUseCase,
|
||||
CountTemplatesUseCase,
|
||||
CreateCategoryUseCase,
|
||||
CreateTemplateUseCase,
|
||||
DeleteCategoryUseCase,
|
||||
DeleteTemplateUseCase,
|
||||
GetTemplateUseCase,
|
||||
ListCategoriesUseCase,
|
||||
ListTagsUseCase,
|
||||
ListTemplatesUseCase,
|
||||
NotFoundError,
|
||||
UpdateTemplateUseCase,
|
||||
ValidateTemplateUseCase,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -37,32 +62,354 @@ def _get_template_repository(session: Session = Depends(get_db_session)) -> SQLA
|
||||
return SQLAlchemyTemplateRepository(session)
|
||||
|
||||
|
||||
@router.get("", response_model=ListTemplatesResponse, summary="获取模板列表")
|
||||
def _segment_to_response(seg) -> SegmentResponse:
|
||||
return SegmentResponse(
|
||||
id=seg.id,
|
||||
template_id=seg.template_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
created_at=seg.created_at,
|
||||
updated_at=seg.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_response(template, usage_count: int = 0) -> TemplateResponse:
|
||||
return TemplateResponse(
|
||||
id=template.id,
|
||||
user_id=template.user_id,
|
||||
name=template.name,
|
||||
mode=template.mode,
|
||||
category=template.category,
|
||||
tags=template.tags,
|
||||
title_config=template.title_config,
|
||||
subtitle_config=template.subtitle_config,
|
||||
bgm_config=template.bgm_config,
|
||||
estimated_duration=template.estimated_duration,
|
||||
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
|
||||
is_active=template.is_active,
|
||||
usage_count=usage_count,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ── Template CRUD ──
|
||||
|
||||
|
||||
@router.get("", response_model=ListTemplatesResponse)
|
||||
def list_templates(
|
||||
mode: str | None = Query(None, description="编辑模式:generic/vlog/storyboard,不传返回全部"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
category: str | None = Query(None, description="按分类筛选"),
|
||||
tag: str | None = Query(None, description="按标签筛选"),
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页条数,默认 20"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""获取用户可用的模板列表(仅返回 active 状态)。"""
|
||||
user_id = str(current_user.user.id)
|
||||
# P0 兜底:无有效模板时自动创建默认配音模板(解决新用户首次进入生成页 404)
|
||||
get_or_create_default_template_id(db, user_id)
|
||||
keyword: str | None = Query(None, description="按名称关键词搜索"),
|
||||
mode: str | None = Query(None, description="按剪辑模式筛选"),
|
||||
valid_only: bool = Query(
|
||||
False,
|
||||
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTemplatesResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
tpl_filter = ListTemplatesFilter(
|
||||
category=category,
|
||||
tag=tag,
|
||||
keyword=keyword,
|
||||
mode=mode,
|
||||
valid_only=valid_only,
|
||||
)
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
|
||||
count_use_case = CountTemplatesUseCase(template_repository)
|
||||
total = count_use_case.execute(user_id, filter=tpl_filter)
|
||||
|
||||
list_uc = ListTemplatesUseCase(repo)
|
||||
count_uc = CountTemplatesUseCase(repo)
|
||||
filters = ListTemplatesFilter(
|
||||
category=category,
|
||||
tag=tag,
|
||||
mode=mode,
|
||||
valid_only=True, # 仅返回 active + 有片段配置
|
||||
# 批量查询使用次数
|
||||
items = []
|
||||
for t in templates:
|
||||
usage = template_repository.get_usage_count(t.id)
|
||||
items.append(_to_response(t, usage_count=usage))
|
||||
except Exception:
|
||||
logger.exception("list_templates 查询失败: user_id=%s", user_id)
|
||||
return ListTemplatesResponse(items=[], total=0)
|
||||
return ListTemplatesResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
)
|
||||
skip = (page - 1) * page_size
|
||||
templates = list_uc.execute(user_id, skip=skip, limit=page_size, filter=filters)
|
||||
total = count_uc.execute(user_id, filter=filters)
|
||||
items = [TemplateResponse.model_validate(tpl, from_attributes=True) for tpl in templates]
|
||||
return ListTemplatesResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateResponse)
|
||||
def get_template(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> TemplateResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
template = use_case.execute(template_id, user_id)
|
||||
usage = template_repository.get_usage_count(template_id)
|
||||
except Exception as _e:
|
||||
logger.exception("get_template 查询失败: template_id=%s", template_id)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return _to_response(template, usage_count=usage)
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_template(
|
||||
request: CreateTemplateRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> TemplateResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = CreateTemplateCommand(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
mode=request.mode,
|
||||
category=request.category,
|
||||
tags=request.tags,
|
||||
title_config=request.title_config,
|
||||
subtitle_config=request.subtitle_config,
|
||||
bgm_config=request.bgm_config,
|
||||
estimated_duration=request.estimated_duration,
|
||||
segments=[
|
||||
SegmentCommand(
|
||||
segment_order=s.segment_order,
|
||||
duration_min=s.duration_min,
|
||||
duration_max=s.duration_max,
|
||||
material_type=s.material_type,
|
||||
)
|
||||
for s in request.segments
|
||||
],
|
||||
)
|
||||
use_case = CreateTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@router.patch("/{template_id}", response_model=TemplateResponse)
|
||||
def update_template(
|
||||
template_id: str,
|
||||
request: UpdateTemplateRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> TemplateResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = UpdateTemplateCommand(
|
||||
template_id=template_id,
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
mode=request.mode,
|
||||
category=request.category,
|
||||
tags=request.tags,
|
||||
title_config=request.title_config,
|
||||
subtitle_config=request.subtitle_config,
|
||||
bgm_config=request.bgm_config,
|
||||
estimated_duration=request.estimated_duration,
|
||||
segments=(
|
||||
[
|
||||
SegmentCommand(
|
||||
segment_order=s.segment_order,
|
||||
duration_min=s.duration_min,
|
||||
duration_max=s.duration_max,
|
||||
material_type=s.material_type,
|
||||
)
|
||||
for s in request.segments
|
||||
]
|
||||
if request.segments is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
use_case = UpdateTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_template(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> Response:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = DeleteTemplateUseCase(template_repository)
|
||||
deleted = use_case.execute(template_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return
|
||||
|
||||
|
||||
@router.post("/{template_id}/copy", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def copy_template(
|
||||
template_id: str,
|
||||
request: CopyTemplateRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> TemplateResponse:
|
||||
"""复制模板(含所有片段配置)"""
|
||||
user_id = authenticated_user.user.id
|
||||
command = CopyTemplateCommand(
|
||||
template_id=template_id,
|
||||
user_id=user_id,
|
||||
new_name=request.new_name,
|
||||
)
|
||||
use_case = CopyTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@router.get("/{template_id}/usage", response_model=TemplateUsageResponse)
|
||||
def get_template_usage(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> TemplateUsageResponse:
|
||||
"""获取模板使用次数(关联的剪辑计划数量)"""
|
||||
user_id = authenticated_user.user.id
|
||||
# 鉴权:确保模板存在且属于当前用户
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
template = use_case.execute(template_id, user_id)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
usage = template_repository.get_usage_count(template_id)
|
||||
return TemplateUsageResponse(template_id=template_id, usage_count=usage)
|
||||
|
||||
|
||||
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
|
||||
def toggle_favorite(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ToggleFavoriteResponse:
|
||||
"""切换模板收藏状态(当前为兼容端点,始终返回 false)"""
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(template_id, user_id)
|
||||
except Exception as _e:
|
||||
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
|
||||
|
||||
|
||||
# ── Validate template ──
|
||||
|
||||
|
||||
@router.post("/{template_id}/validate", response_model=ValidateTemplateResponse)
|
||||
def validate_template(
|
||||
template_id: str,
|
||||
request: ValidateTemplateRequest = ValidateTemplateRequest(),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ValidateTemplateResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = ValidateTemplateCommand(
|
||||
template_id=template_id,
|
||||
user_id=user_id,
|
||||
voiceover_duration=request.voiceover_duration,
|
||||
)
|
||||
use_case = ValidateTemplateUseCase(template_repository)
|
||||
try:
|
||||
result = use_case.execute(command)
|
||||
except NotFoundError as _e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
return ValidateTemplateResponse(
|
||||
template=_to_response(result.template),
|
||||
warnings=[GenerateWarningResponse(code=w.code, message=w.message, details=w.details) for w in result.warnings],
|
||||
)
|
||||
|
||||
|
||||
# ── Category CRUD ──
|
||||
|
||||
|
||||
@router.get("/categories/list", response_model=ListCategoriesResponse)
|
||||
def list_categories(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListCategoriesResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
use_case = ListCategoriesUseCase(template_repository)
|
||||
categories = use_case.execute(user_id)
|
||||
except Exception:
|
||||
logger.exception("list_categories 查询失败: user_id=%s", user_id)
|
||||
return ListCategoriesResponse(items=[])
|
||||
return ListCategoriesResponse(
|
||||
items=[CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) for c in categories],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(
|
||||
request: CreateCategoryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> CategoryResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = CreateCategoryCommand(user_id=user_id, name=request.name)
|
||||
use_case = CreateCategoryUseCase(template_repository)
|
||||
category = use_case.execute(command)
|
||||
return CategoryResponse(
|
||||
id=category.id,
|
||||
user_id=category.user_id,
|
||||
name=category.name,
|
||||
created_at=category.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
||||
)
|
||||
def delete_category(
|
||||
category_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> Response:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = DeleteCategoryUseCase(template_repository)
|
||||
deleted = use_case.execute(category_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# ── Tags ──
|
||||
|
||||
|
||||
@router.get("/tags/list", response_model=ListTagsResponse)
|
||||
def list_tags(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTagsResponse:
|
||||
"""获取用户所有模板标签(去重排序)"""
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
use_case = ListTagsUseCase(template_repository)
|
||||
tags = use_case.execute(user_id)
|
||||
except Exception:
|
||||
logger.exception("list_tags 查询失败: user_id=%s", user_id)
|
||||
return ListTagsResponse(items=[])
|
||||
return ListTagsResponse(items=tags)
|
||||
|
||||
@@ -28,7 +28,7 @@ from .adjustments import router as adjustments_router
|
||||
from .ai_features import router as ai_features_router
|
||||
from .bgm import router as bgm_router
|
||||
from .clips import router as clips_router
|
||||
from .dependencies import get_draft_plan_id, get_editor_services, resolve_draft_plan_id # noqa: F401
|
||||
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||
from .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
from .export import router as export_router
|
||||
|
||||
@@ -622,10 +622,8 @@ def create_clips_from_assets_editor(
|
||||
"""从素材批量创建片段(按模板segment配置创建,MediaKit异步更新).
|
||||
|
||||
逻辑:
|
||||
1. 从模板读取 segments,片段数量优先级:显式 clip_count(1-10)→ 旧字段
|
||||
required_clips_count(兼容,超10截断)→ 默认 3(产品默认 3 段)。
|
||||
片段数大于模板 segment 数时按顺序循环复用 segment 配置。
|
||||
2. 每个片段时长在对应 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count)
|
||||
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段
|
||||
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的(随机起始时间)
|
||||
5. 立即返回响应(目标 <1秒)
|
||||
@@ -650,22 +648,6 @@ def create_clips_from_assets_editor(
|
||||
detail="模板未配置片段",
|
||||
)
|
||||
|
||||
# 1.5 归一化片段数量:
|
||||
# 优先级:显式 clip_count → 旧字段 required_clips_count(由 schema 归一化到 clip_count)
|
||||
# → 默认 3(产品默认 3 段)。按 N 循环复用 segment 配置;N <= len(segments) 时截取前 N 个
|
||||
# (保持向后兼容:原模板有 N 个 segment、前端不传 clip_count 且 N<=10 时按模板段数创建;
|
||||
# 默认模板仅有 1 个通用 segment 时按 clip_count=3 循环生成 3 段)。
|
||||
requested_clip_count = getattr(body, "clip_count", None)
|
||||
if requested_clip_count is None:
|
||||
# schema 未显式传 clip_count 且无 legacy:使用模板 segments 数量,若超出 10 则截断
|
||||
requested_clip_count = len(segments) if 1 <= len(segments) <= 10 else 3
|
||||
requested_clip_count = max(1, min(int(requested_clip_count), 10))
|
||||
effective_segments: list[tuple[int, float, float]] = []
|
||||
for i in range(requested_clip_count):
|
||||
src = segments[i % len(segments)]
|
||||
effective_segments.append((i, float(src[1]), float(src[2])))
|
||||
segments = effective_segments
|
||||
|
||||
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
|
||||
# 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422
|
||||
asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()]
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
核心依赖:
|
||||
- get_editor_services: 获取模板+计划服务
|
||||
- get_draft_plan_id: Depends 形式的路径依赖(template_id 路径参数必填)
|
||||
- resolve_draft_plan_id: 纯函数版本,供 clips_standalone 等非路径参数场景复用
|
||||
(支持空 tid 时自动兜底创建默认模板)
|
||||
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.api.routes._default_template import get_or_create_default_template_id
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
@@ -33,61 +30,50 @@ def get_editor_services(
|
||||
return EditTemplateService(db), EditPlanService(db)
|
||||
|
||||
|
||||
def resolve_draft_plan_id(
|
||||
def get_draft_plan_id(
|
||||
template_id: str,
|
||||
services: tuple[EditTemplateService, EditPlanService],
|
||||
current_user: AuthenticatedUser,
|
||||
db: Session,
|
||||
auto_create_default: bool = True,
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> str:
|
||||
"""根据 template_id 获取或创建草稿,返回 plan_id(纯函数,不带 Depends)。
|
||||
"""路径依赖:根据 template_id 获取或创建草稿,返回 plan_id.
|
||||
|
||||
当 auto_create_default=True 且 template_id 为空时,自动调用
|
||||
get_or_create_default_template_id 创建默认模板(用于 clips_standalone
|
||||
等非路径参数场景)。
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
模板读取遵循单一数据源、显式判定(不使用异常降级):
|
||||
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
|
||||
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
|
||||
模板不存在、已删除或不归属于当前用户时,一律返回 404。
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 0. 空 tid 兜底
|
||||
if not template_id:
|
||||
if auto_create_default:
|
||||
tid = get_or_create_default_template_id(db, user_id)
|
||||
if not tid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
template_id = tid
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="template_id 不能为空",
|
||||
)
|
||||
|
||||
# 1. 门禁:校验模板存在且可访问
|
||||
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
|
||||
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get_active(template_id, user_id)
|
||||
is_global_template = tpl_svc.get_template(template_id) is not None
|
||||
if old_template is None and not is_global_template:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 2. 草稿已存在 → 直接返回
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 3. 全局模板(新系统)→ 用新服务创建草稿
|
||||
# 2. 全局模板(新系统)→ 用新服务创建草稿
|
||||
if is_global_template:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 4. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段)
|
||||
pseudo_template = EditTemplate(
|
||||
id=old_template.id,
|
||||
name=old_template.name,
|
||||
@@ -95,6 +81,7 @@ def resolve_draft_plan_id(
|
||||
status=EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
|
||||
# 将旧模板 segments 转换为 clip_configs
|
||||
clip_configs: list[TemplateClipConfig] = []
|
||||
for seg in old_template.segments or []:
|
||||
clip_configs.append(
|
||||
@@ -118,6 +105,7 @@ def resolve_draft_plan_id(
|
||||
)
|
||||
plan = result["plan"]
|
||||
|
||||
# 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑)
|
||||
plan_svc.update_plan_config(plan.id, {"is_template_draft": True})
|
||||
|
||||
logger.info(
|
||||
@@ -127,23 +115,3 @@ def resolve_draft_plan_id(
|
||||
user_id,
|
||||
)
|
||||
return plan.id
|
||||
|
||||
|
||||
def get_draft_plan_id(
|
||||
template_id: str,
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> str:
|
||||
"""路径依赖:根据 template_id 获取或创建草稿,返回 plan_id.
|
||||
|
||||
Depends 版本:路径参数 template_id 由 FastAPI 保证非空,不自动兜底。
|
||||
兜底逻辑走 resolve_draft_plan_id(auto_create_default=False)。
|
||||
"""
|
||||
return resolve_draft_plan_id(
|
||||
template_id=template_id,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re as _re
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator, validator
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
|
||||
_EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
|
||||
@@ -21,7 +21,7 @@ _EXPORT_VALID_FORMATS = {"mp4", "mov"}
|
||||
class AIRecommendRequest(BaseModel):
|
||||
"""AI 推荐片段方案请求体"""
|
||||
|
||||
asset_ids: list[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
|
||||
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||
|
||||
@@ -44,7 +44,7 @@ class AIRecommendResponse(BaseModel):
|
||||
"""AI 推荐片段方案响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
clips: list[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
||||
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
||||
config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)")
|
||||
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
@@ -137,7 +137,7 @@ class ClipReorderItem(BaseModel):
|
||||
class ClipReorderRequest(BaseModel):
|
||||
"""片段重排序请求"""
|
||||
|
||||
items: list[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
|
||||
|
||||
class ClipReorderResponse(BaseModel):
|
||||
@@ -151,7 +151,7 @@ class ClipReorderResponse(BaseModel):
|
||||
class ClipBatchDeleteRequest(BaseModel):
|
||||
"""批量删除片段请求"""
|
||||
|
||||
clip_ids: list[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
|
||||
|
||||
class ClipBatchDeleteResponse(BaseModel):
|
||||
@@ -162,26 +162,13 @@ class ClipBatchDeleteResponse(BaseModel):
|
||||
message: str = ""
|
||||
|
||||
|
||||
# sentinel:区分「前端未传 clip_count」和「显式传 0/None」
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
clip_count: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="片段数量(1-10);不传时使用旧字段 required_clips_count;两者都不传时回退为模板 segments 数量(默认 3 段)。",
|
||||
)
|
||||
required_clips_count: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=200,
|
||||
description="[已废弃] 旧字段,请使用 clip_count;仅作向后兼容——clip_count 未显式传入时才回退本字段(超10截断到10)。",
|
||||
default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量"
|
||||
)
|
||||
|
||||
@validator("asset_ids", pre=True)
|
||||
@@ -193,23 +180,6 @@ class ClipsFromAssetsRequest(BaseModel):
|
||||
return v
|
||||
return [x for x in v if isinstance(x, str) and x.strip()]
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _backfill_clip_count(cls, data: Any) -> Any:
|
||||
"""兼容旧字段 required_clips_count:仅当新字段 clip_count 未显式传入时才回退旧字段;
|
||||
两者都没传时保持 clip_count=None,路由层按模板 segments 数量兜底。旧字段超 10 截断到 10。"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
has_new = "clip_count" in data and data["clip_count"] is not None
|
||||
if not has_new:
|
||||
legacy = data.get("required_clips_count")
|
||||
if legacy is not None:
|
||||
try:
|
||||
data["clip_count"] = max(1, min(int(legacy), 10))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
"""从素材批量创建片段响应"""
|
||||
@@ -218,7 +188,7 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
created_count: int
|
||||
plan_id: str = ""
|
||||
message: str = ""
|
||||
clip_ids: list[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
duplicate_warning: Optional[str] = Field(default=None, description="查重率超标警告")
|
||||
exhaustion_warning: Optional[str] = Field(default=None, description="素材耗尽警告")
|
||||
|
||||
@@ -302,7 +272,7 @@ class ExportPresetItem(BaseModel):
|
||||
class ExportPresetListResponse(BaseModel):
|
||||
"""导出预设列表响应"""
|
||||
|
||||
items: list[ExportPresetItem]
|
||||
items: List[ExportPresetItem]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -316,7 +286,7 @@ class FilterPresetResponse(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FilterConfigResponse(BaseModel):
|
||||
@@ -346,7 +316,7 @@ class FilterUpdateRequest(BaseModel):
|
||||
class FilterPresetListResponse(BaseModel):
|
||||
"""滤镜预设列表响应"""
|
||||
|
||||
items: list[FilterPresetResponse]
|
||||
items: List[FilterPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -360,7 +330,7 @@ class TransitionPresetResponse(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
default_duration: float
|
||||
min_duration: float
|
||||
max_duration: float
|
||||
@@ -402,7 +372,7 @@ class BatchTransitionResponse(BaseModel):
|
||||
class TransitionPresetListResponse(BaseModel):
|
||||
"""转场预设列表响应"""
|
||||
|
||||
items: list[TransitionPresetResponse]
|
||||
items: List[TransitionPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -458,7 +428,7 @@ class EditorClipResponse(BaseModel):
|
||||
class EditorClipListResponse(BaseModel):
|
||||
"""片段列表响应"""
|
||||
|
||||
items: list[EditorClipResponse]
|
||||
items: List[EditorClipResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -496,7 +466,7 @@ class EditorClipBatchItem(BaseModel):
|
||||
class EditorClipBatchUpdateRequest(BaseModel):
|
||||
"""批量替换clips请求(全量覆盖)"""
|
||||
|
||||
clips: list[EditorClipBatchItem] = Field(default_factory=list)
|
||||
clips: List[EditorClipBatchItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EditorClipBatchUpdateResponse(BaseModel):
|
||||
@@ -584,4 +554,4 @@ class EditorTimelineResponse(BaseModel):
|
||||
|
||||
plan_id: str
|
||||
total_duration: float
|
||||
scenes: list[EditorTimelineSceneResponse]
|
||||
scenes: List[EditorTimelineSceneResponse]
|
||||
|
||||
Regular → Executable
+13
-93
@@ -4,14 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
@@ -53,8 +51,6 @@ from packages.application.tts_job.use_cases import (
|
||||
)
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
from packages.domain.voice_presets import list_voices
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
@@ -132,7 +128,6 @@ def _to_response(job, sign_url=None) -> TTSJobResponse:
|
||||
def synthesize(
|
||||
request: TTSSynthesizeRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
@@ -144,31 +139,6 @@ def synthesize(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_voice"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
# 中文按 ~240 字/分钟粗估时长,至少按 1 分钟扣 1 分
|
||||
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
|
||||
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
|
||||
actual_voice_id = request.voice_id
|
||||
@@ -207,7 +177,6 @@ def synthesize(
|
||||
synthesis_meta = {
|
||||
"speed": request.speed,
|
||||
"emotion": request.emotion or "",
|
||||
"language": request.language or "zh-CN",
|
||||
}
|
||||
if request.metadata_:
|
||||
synthesis_meta.update(request.metadata_)
|
||||
@@ -228,7 +197,6 @@ def synthesize(
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
)
|
||||
|
||||
synthesis_error: Exception | None = None
|
||||
try:
|
||||
job = workflow.start_synthesis(job.id)
|
||||
except Exception as e:
|
||||
@@ -236,17 +204,10 @@ def synthesize(
|
||||
# 但 DB 异常、网络异常等意外错误可能逃逸。
|
||||
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
|
||||
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
|
||||
synthesis_error = e
|
||||
try:
|
||||
job = workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception as inner_e:
|
||||
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
|
||||
# 合成失败且已扣积分 → 退费
|
||||
if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
|
||||
if job.status.value == "processing":
|
||||
@@ -261,17 +222,10 @@ def synthesize(
|
||||
celery_app.send_task("worker.process_tts_synthesis", args=[job.id])
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
# e used below for refund context
|
||||
try:
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
|
||||
# 调度失败退费
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"Celery 调度失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
return TTSSynthesizeResponse(
|
||||
job_id=job.id,
|
||||
@@ -508,17 +462,10 @@ def save_tts_job_to_library(
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
str(tmp_path),
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", str(tmp_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
fmt = json.loads(proc.stdout).get("format", {})
|
||||
@@ -598,7 +545,6 @@ def save_tts_job_to_library(
|
||||
def preview_tts(
|
||||
request: TTSPreviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> TTSPreviewResponse:
|
||||
@@ -607,31 +553,6 @@ def preview_tts(
|
||||
用于前端预览配音效果,限制文本长度 200 字以内。
|
||||
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_voice"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
|
||||
actual_voice_id = request.voice_id
|
||||
profile = voice_clone_repo.get(request.voice_id)
|
||||
@@ -655,18 +576,17 @@ def preview_tts(
|
||||
voice_id=actual_voice_id,
|
||||
speed=request.speed,
|
||||
emotion=request.emotion,
|
||||
language=getattr(request, "language", "zh-CN"),
|
||||
)
|
||||
except (CosyVoiceError, ValueError) as e:
|
||||
# 合成失败退费
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 预览失败退积分异常: {refund_err}")
|
||||
if isinstance(e, CosyVoiceError):
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS 合成失败: {e}") from e
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"TTS 合成失败: {e}",
|
||||
) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
return TTSPreviewResponse(
|
||||
audio_url=result.audio_url,
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
@@ -25,7 +22,6 @@ from app.schemas.voice_clone import (
|
||||
VoiceCloneStatusResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
||||
SQLAlchemyVoiceCloneProfileRepository,
|
||||
@@ -42,11 +38,6 @@ from packages.application.voice_clone.use_cases import (
|
||||
from packages.application.voice_clone.workflow import (
|
||||
VoiceCloneWorkflowService,
|
||||
)
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
# remove duplicate
|
||||
_DUMMY_DELETED = ()
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.shared.storage import SharedStorageService
|
||||
@@ -296,101 +287,22 @@ def retry_voice_clone(
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
_ALLOWED_PREVIEW_EMOTIONS = {
|
||||
"",
|
||||
# 7 种标准英文枚举(CosyVoice v3 官方值)
|
||||
"neutral",
|
||||
"happy",
|
||||
"sad",
|
||||
"angry",
|
||||
"surprised",
|
||||
"fearful",
|
||||
"disgusted",
|
||||
# 前端中文 7 标签
|
||||
"中立",
|
||||
"开心",
|
||||
"难过",
|
||||
"生气",
|
||||
"惊讶",
|
||||
"恐惧",
|
||||
"厌恶",
|
||||
# 旧英文 4 枚举 + 常见中文别名兼容
|
||||
"natural",
|
||||
"excited",
|
||||
"calm",
|
||||
"friendly",
|
||||
"自然",
|
||||
"愉快",
|
||||
"高兴",
|
||||
"快乐",
|
||||
"兴奋",
|
||||
"悲伤",
|
||||
"愤怒",
|
||||
"惊奇",
|
||||
"吃惊",
|
||||
"害怕",
|
||||
"讨厌",
|
||||
# 灵应 P1 指定别名
|
||||
"中性",
|
||||
"伤心",
|
||||
"沉稳",
|
||||
"亲切",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
|
||||
def get_voice_clone_preview(
|
||||
clone_id: str,
|
||||
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
||||
speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"),
|
||||
emotion: str = Query(
|
||||
"",
|
||||
description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted,兼容旧值 natural/excited/calm/friendly,空为默认自然",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> VoiceClonePreviewResponse:
|
||||
"""获取克隆音色试听音频(实时 TTS 合成)。
|
||||
|
||||
- 克隆音色必须处于 ready 状态
|
||||
- 使用默认试听文本时,结果缓存 7 天(仅默认 text+speed=1.0+emotion=空 组合缓存)
|
||||
- 可传入自定义 text/speed/emotion 试听不同效果
|
||||
- 使用默认试听文本时,结果缓存 7 天
|
||||
- 可传入自定义 text 参数试听不同文本
|
||||
"""
|
||||
import time
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
_points_deducted = 0
|
||||
_points_scene = "voice_clone_synth"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
_preview_text_for_points = text.strip() or CLONE_PREVIEW_TEMPLATE
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(_preview_text_for_points) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
if emotion not in _ALLOWED_PREVIEW_EMOTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或中文 中立/中性/开心/难过/伤心/生气/愤怒/惊讶/吃惊/恐惧/害怕/厌恶/讨厌 或留空",
|
||||
)
|
||||
|
||||
use_case = GetVoiceCloneUseCase(repository)
|
||||
try:
|
||||
profile = use_case.execute(clone_id, authenticated_user.user.id)
|
||||
@@ -403,8 +315,8 @@ def get_voice_clone_preview(
|
||||
detail=f"Voice clone is not ready (current status: {profile.status})",
|
||||
)
|
||||
|
||||
# 仅默认试听文本 + 默认 speed + 默认 emotion 时使用缓存
|
||||
use_cache = (not text.strip()) and abs(speed - 1.0) < 1e-6 and (not emotion)
|
||||
# 有自定义文本时不缓存
|
||||
use_cache = not text.strip()
|
||||
|
||||
if use_cache and clone_id in _clone_preview_cache:
|
||||
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
|
||||
@@ -425,20 +337,12 @@ def get_voice_clone_preview(
|
||||
text=preview_text,
|
||||
voice_id=profile.voice_id,
|
||||
format="mp3",
|
||||
speed=speed,
|
||||
emotion=emotion,
|
||||
speed=1.0,
|
||||
)
|
||||
except (CosyVoiceError, ValueError) as e:
|
||||
if _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"克隆音色试听失败退积分异常: clone_id={clone_id}, err={refund_err}")
|
||||
if isinstance(e, CosyVoiceError):
|
||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
||||
|
||||
# 缓存(仅默认参数组合)
|
||||
# 缓存(仅默认试听文本)
|
||||
if use_cache:
|
||||
_clone_preview_cache[clone_id] = (
|
||||
result.audio_url,
|
||||
|
||||
@@ -6,7 +6,7 @@ ensuring proper lifecycle management and testability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Generator
|
||||
|
||||
import redis
|
||||
from app.config import settings
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
@@ -10,7 +10,7 @@ Exposes:
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from prometheus_client import (
|
||||
|
||||
@@ -52,9 +52,7 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
lipsync_job_id: str = Field(..., description="对口型任务 ID")
|
||||
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
|
||||
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
|
||||
title_config: dict[str, Any] = Field(
|
||||
default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL)"
|
||||
)
|
||||
title_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")
|
||||
|
||||
@@ -69,6 +67,7 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
@field_validator("script_id")
|
||||
@classmethod
|
||||
def validate_script_id(cls, v: str) -> str:
|
||||
# script_id 可选:手动输入文案(TTS 直生)场景不关联文案库条目
|
||||
return (v or "").strip()
|
||||
|
||||
|
||||
@@ -110,17 +109,16 @@ class AiAvatarRenderProgressResponse(BaseModel):
|
||||
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):
|
||||
"""智能封面响应(封面从最终成片抽帧,不再叠加标题)."""
|
||||
"""智能封面响应."""
|
||||
|
||||
cover_url: str = Field("", description="封面图公网 URL(OSS,非临时);失败为空")
|
||||
status: str = Field("completed", description="completed / fallback_failed")
|
||||
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),
|
||||
后端 Celery 异步做 TTS 合成 + MediaKit 提交。
|
||||
支持两种输入模式(二选一):
|
||||
1. TTS 直生模式(推荐):传 voice_id + script_text(+ speed/emotion),
|
||||
后端内部先调 CosyVoice 合成音频,再提交 MediaKit 对口型。
|
||||
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
|
||||
@@ -36,7 +33,6 @@ class LipsyncJobResponse(BaseModel):
|
||||
output_duration: float
|
||||
error_message: str
|
||||
error_code: str
|
||||
sentence_timings: Optional[list] = None
|
||||
submitted_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
@@ -49,32 +45,23 @@ class LipsyncJobResponse(BaseModel):
|
||||
class CreateLipsyncJobRequest(BaseModel):
|
||||
"""创建对口型任务请求.
|
||||
|
||||
三种模式(三选一):
|
||||
- TTS 直生(旧版/降级):voice_id + script_text 必填;audio_url 留空。
|
||||
两种模式(二选一):
|
||||
- TTS 直生:voice_id + script_text 必填(+ 可选 speed/emotion);audio_url 留空。
|
||||
- 直接音频:video_url + audio_url 必填。
|
||||
- 预合成音频(#1845 新主路径):audio_url 必填 + 可选 audio_duration/sentence_timings;
|
||||
后端同步 ffprobe 校验时长、写入 timings,直接提交 MediaKit。
|
||||
"""
|
||||
|
||||
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
|
||||
|
||||
# 模式 2/3:直接/预合成音频
|
||||
# 模式 2:直接音频
|
||||
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 直生
|
||||
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
|
||||
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||
emotion: str = Field(
|
||||
"",
|
||||
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
|
||||
)
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
|
||||
|
||||
enable_video_loop: bool = Field(
|
||||
True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)"
|
||||
)
|
||||
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
|
||||
project_id: str = Field("", description="项目 ID(可选)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -85,16 +72,15 @@ class CreateLipsyncJobRequest(BaseModel):
|
||||
if not video.startswith(("http://", "https://")):
|
||||
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
|
||||
lower = video.lower().split("?")[0]
|
||||
allowed_video_exts = (".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv", ".3gp")
|
||||
if not any(lower.endswith(ext) for ext in allowed_video_exts):
|
||||
raise ValueError("video_url 格式不支持,仅支持: " + ", ".join(allowed_video_exts))
|
||||
if not lower.endswith(".mp4"):
|
||||
raise ValueError("video_url 仅支持 MP4 格式")
|
||||
|
||||
has_audio = bool((self.audio_url 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:
|
||||
raise ValueError(
|
||||
"必须提供驱动音频:要么传 audio_url(直接/预合成音频模式),"
|
||||
"必须提供驱动音频:要么传 audio_url(直接音频模式),"
|
||||
"要么同时传 voice_id + script_text(TTS 直生模式)"
|
||||
)
|
||||
|
||||
@@ -112,27 +98,3 @@ class CreateLipsyncJobRequest(BaseModel):
|
||||
self.audio_url = au
|
||||
|
||||
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(
|
||||
"neutral",
|
||||
max_length=32,
|
||||
description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral)",
|
||||
)
|
||||
|
||||
|
||||
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,182 +0,0 @@
|
||||
"""积分 & 会员相关 Pydantic Schema (#1895)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ============ 余额 & 账户 ============
|
||||
|
||||
|
||||
class PointsBalanceResponse(BaseModel):
|
||||
"""积分余额 + 会员状态"""
|
||||
|
||||
balance: int = Field(..., description="当前积分余额")
|
||||
total_earned: int = Field(..., description="累计获得积分")
|
||||
total_spent: int = Field(..., description="累计消耗积分")
|
||||
is_member: bool = Field(default=False, description="是否付费会员")
|
||||
member_type: Optional[str] = Field(None, description="会员类型: monthly/quarterly/yearly")
|
||||
member_expires_at: Optional[datetime] = Field(None, description="会员到期时间")
|
||||
|
||||
|
||||
# ============ 流水 ============
|
||||
|
||||
|
||||
class PointsTransactionItem(BaseModel):
|
||||
"""单条积分流水"""
|
||||
|
||||
id: str
|
||||
type: str = Field(..., description="类型: add/deduct")
|
||||
source: str = Field(..., description="来源场景")
|
||||
amount: int
|
||||
balance_after: int
|
||||
description: str = ""
|
||||
ref_id: str = ""
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class PointsTransactionsResponse(BaseModel):
|
||||
"""积分流水分页响应"""
|
||||
|
||||
items: list[PointsTransactionItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ============ 规则 & 积分包 ============
|
||||
|
||||
|
||||
class PointRuleItem(BaseModel):
|
||||
"""单条积分规则"""
|
||||
|
||||
scene_key: str
|
||||
name: str
|
||||
base_points: int
|
||||
unit: str
|
||||
extra_per_30s: Optional[int] = None
|
||||
|
||||
|
||||
class PointsRulesResponse(BaseModel):
|
||||
"""所有积分消耗规则"""
|
||||
|
||||
rules: list[PointRuleItem]
|
||||
free_user_multiplier: float = Field(..., description="免费用户积分上浮系数")
|
||||
|
||||
|
||||
class PointsPackageItem(BaseModel):
|
||||
"""积分包信息"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
points: int
|
||||
price_cents: int
|
||||
unit_price: str = Field("", description="单价描述,如 ¥0.099/积分")
|
||||
|
||||
|
||||
class PointsPackagesResponse(BaseModel):
|
||||
"""可购买的积分包列表"""
|
||||
|
||||
packages: list[PointsPackageItem]
|
||||
user_discount: Optional[float] = Field(None, description="当前用户折扣(会员)")
|
||||
|
||||
|
||||
# ============ 消费前检查 ============
|
||||
|
||||
|
||||
class PointsCheckRequest(BaseModel):
|
||||
"""消费前余额检查请求"""
|
||||
|
||||
scene_key: str
|
||||
duration_minutes: Optional[float] = None
|
||||
quantity: Optional[int] = 1
|
||||
|
||||
|
||||
class PointsCheckResponse(BaseModel):
|
||||
"""消费前余额检查响应"""
|
||||
|
||||
allowed: bool
|
||||
required_points: int
|
||||
current_balance: int
|
||||
remaining_after: int
|
||||
is_free_quota: bool = False
|
||||
|
||||
|
||||
# ============ 手动扣减 / 退还(内部接口) ============
|
||||
|
||||
|
||||
class PointsDeductRequest(BaseModel):
|
||||
"""积分扣减请求"""
|
||||
|
||||
scene_key: str
|
||||
amount: int
|
||||
description: Optional[str] = ""
|
||||
ref_id: Optional[str] = ""
|
||||
|
||||
|
||||
class PointsRefundRequest(BaseModel):
|
||||
"""积分退还请求"""
|
||||
|
||||
transaction_id: str
|
||||
reason: Optional[str] = ""
|
||||
|
||||
|
||||
class PointsRechargeRequest(BaseModel):
|
||||
"""积分充值请求"""
|
||||
|
||||
package_id: str = Field(..., description="积分包 code,如 starter_pack")
|
||||
|
||||
|
||||
# ============ 订单 ============
|
||||
|
||||
|
||||
class PointsOrderResponse(BaseModel):
|
||||
"""订单信息"""
|
||||
|
||||
id: str
|
||||
order_type: str
|
||||
product_code: str
|
||||
amount_cents: int
|
||||
status: str
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
# ============ 每日额度 ============
|
||||
|
||||
|
||||
class DailyUsageResponse(BaseModel):
|
||||
"""今日免费额度使用情况"""
|
||||
|
||||
free_clips_used: int
|
||||
free_clips_limit: int
|
||||
free_clips_remaining: int
|
||||
reset_at: str
|
||||
|
||||
|
||||
# ============ 会员状态(聚合) ============
|
||||
|
||||
|
||||
class MembershipStatusResponse(BaseModel):
|
||||
"""当前用户会员状态(聚合信息)"""
|
||||
|
||||
is_member: bool
|
||||
member_type: Optional[str] = None
|
||||
member_expires_at: Optional[datetime] = None
|
||||
points_balance: int
|
||||
max_resolution: str = Field(
|
||||
default="1080p",
|
||||
description="可用最高分辨率: 720p(free) / 1080p(paid)",
|
||||
)
|
||||
|
||||
|
||||
# ============ 通用响应 ============
|
||||
|
||||
|
||||
class SimpleMessageResponse(BaseModel):
|
||||
"""简单消息响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[dict[str, Any]] = None
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -20,8 +20,8 @@ class ScriptResponse(BaseModel):
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
segments: list[ScriptSegment] = Field(default_factory=list)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -34,12 +34,12 @@ class ScriptListResponse(BaseModel):
|
||||
class CreateScriptRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = ""
|
||||
segments: list[ScriptSegment] = Field(default_factory=list)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateScriptRequest(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
content: Optional[str] = None
|
||||
segments: Optional[list[ScriptSegment]] = None
|
||||
tags: Optional[list[str]] = None
|
||||
segments: Optional[List[ScriptSegment]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Scripts AI 能力 Pydantic schemas — Issue #1893.
|
||||
|
||||
抖音文案提取、AI 改写、AI 标题生成的请求/响应模型。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ── 抖音文案提取 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ExtractFromDouyinRequest(BaseModel):
|
||||
"""从抖音视频提取文案请求."""
|
||||
|
||||
url: str = Field(..., description="抖音视频链接(短链或长链)")
|
||||
|
||||
|
||||
class ExtractFromDouyinResponse(BaseModel):
|
||||
"""从抖音视频提取文案响应."""
|
||||
|
||||
text: str = Field(..., description="ASR 识别出的文案文本")
|
||||
duration_seconds: float = Field(..., description="视频时长(秒)")
|
||||
source_url: str = Field(..., description="原始视频链接")
|
||||
|
||||
|
||||
# ── AI 改写 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AiRewriteRequest(BaseModel):
|
||||
"""AI 文案改写请求."""
|
||||
|
||||
content: str = Field(..., description="原文内容")
|
||||
style: Optional[str] = Field("口语化", description="改写风格,如 口语化/正式/活泼")
|
||||
|
||||
|
||||
class AiRewriteResponse(BaseModel):
|
||||
"""AI 文案改写响应."""
|
||||
|
||||
original: str = Field(..., description="原文")
|
||||
rewritten: str = Field(..., description="改写后的文案")
|
||||
style: str = Field(..., description="使用的改写风格")
|
||||
|
||||
|
||||
# ── AI 标题生成 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AiGenerateTitlesRequest(BaseModel):
|
||||
"""AI 标题生成请求."""
|
||||
|
||||
content: str = Field(..., description="文案内容")
|
||||
count: int = Field(3, ge=1, le=5, description="生成标题数量(1-5,默认3)")
|
||||
|
||||
|
||||
class AiGenerateTitlesResponse(BaseModel):
|
||||
"""AI 标题生成响应."""
|
||||
|
||||
titles: List[str] = Field(..., description="生成的标题列表")
|
||||
@@ -1,14 +1,9 @@
|
||||
"""Template API schemas(精简版:仅保留列表接口 + 默认模板自动兜底所需字段).
|
||||
|
||||
前端 PR#1911 删除 my-templates / editing-planner / templates 管理页后,
|
||||
模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等端点全部下线,
|
||||
对应 Request/Response 模型也一并清理。
|
||||
"""
|
||||
"""Template API schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -42,12 +37,12 @@ class TemplateResponse(BaseModel):
|
||||
name: str
|
||||
mode: str
|
||||
category: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
title_config: dict[str, Any] = Field(default_factory=dict)
|
||||
subtitle_config: dict[str, Any] = Field(default_factory=dict)
|
||||
bgm_config: dict[str, Any] = Field(default_factory=dict)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
title_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
bgm_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
estimated_duration: float = 0.0
|
||||
segments: list[SegmentResponse] = Field(default_factory=list)
|
||||
segments: List[SegmentResponse] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
is_favorite: bool = False
|
||||
usage_count: int = 0
|
||||
@@ -55,29 +50,96 @@ class TemplateResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ToggleFavoriteResponse(BaseModel):
|
||||
id: str
|
||||
is_favorite: bool
|
||||
|
||||
|
||||
class ListTemplatesResponse(BaseModel):
|
||||
items: list[TemplateResponse]
|
||||
items: List[TemplateResponse]
|
||||
total: int = 0
|
||||
|
||||
|
||||
# ── Template Request(保留给内部 _get_or_create_default_template_id 兜底创建默认模板使用)──
|
||||
# ── Template Request ──
|
||||
|
||||
|
||||
class CreateTemplateRequest(BaseModel):
|
||||
name: str
|
||||
mode: str
|
||||
category: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
title_config: dict[str, Any] = Field(default_factory=dict)
|
||||
subtitle_config: dict[str, Any] = Field(default_factory=dict)
|
||||
bgm_config: dict[str, Any] = Field(default_factory=dict)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
title_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
bgm_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
estimated_duration: float = 0.0
|
||||
segments: list[SegmentRequest] = Field(default_factory=list)
|
||||
segments: List[SegmentRequest] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateTemplateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
title_config: Optional[Dict[str, Any]] = None
|
||||
subtitle_config: Optional[Dict[str, Any]] = None
|
||||
bgm_config: Optional[Dict[str, Any]] = None
|
||||
estimated_duration: Optional[float] = None
|
||||
segments: Optional[List[SegmentRequest]] = None
|
||||
|
||||
|
||||
# ── Validate ──
|
||||
|
||||
|
||||
class ValidateTemplateRequest(BaseModel):
|
||||
voiceover_duration: Optional[float] = None # 配音实际时长(秒)
|
||||
|
||||
|
||||
class GenerateWarningResponse(BaseModel):
|
||||
"""兼容老 import(如校验逻辑内部复用);模板管理页已下线,可按需进一步清理。"""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ValidateTemplateResponse(BaseModel):
|
||||
template: TemplateResponse
|
||||
warnings: List[GenerateWarningResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Category ──
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateCategoryRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class ListCategoriesResponse(BaseModel):
|
||||
items: List[CategoryResponse]
|
||||
|
||||
|
||||
# ── Copy Template ──
|
||||
|
||||
|
||||
class CopyTemplateRequest(BaseModel):
|
||||
new_name: str
|
||||
|
||||
|
||||
# ── Tags ──
|
||||
|
||||
|
||||
class ListTagsResponse(BaseModel):
|
||||
items: List[str]
|
||||
|
||||
|
||||
# ── Usage Stats ──
|
||||
|
||||
|
||||
class TemplateUsageResponse(BaseModel):
|
||||
template_id: str
|
||||
usage_count: int
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -15,7 +15,7 @@ class TitleLibraryItemResponse(BaseModel):
|
||||
text: str
|
||||
category: str = "default"
|
||||
description: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
usage_count: int = 0
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
@@ -32,7 +32,7 @@ class CreateTitleLibraryRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=500)
|
||||
category: str = "default"
|
||||
description: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateTitleLibraryRequest(BaseModel):
|
||||
@@ -40,4 +40,4 @@ class UpdateTitleLibraryRequest(BaseModel):
|
||||
text: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -16,14 +16,11 @@ class TTSSynthesizeRequest(BaseModel):
|
||||
output_name: str = Field("", description="输出文件名")
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
emotion: str = Field(
|
||||
"",
|
||||
description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)",
|
||||
)
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文 自然/兴奋/沉稳/亲切)")
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID")
|
||||
format: str = Field("mp3", description="输出格式(mp3/wav/pcm)")
|
||||
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
@@ -49,7 +46,7 @@ class TTSJobResponse(BaseModel):
|
||||
error_message: str = ""
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
@@ -83,7 +80,7 @@ class TTSSynthesizeResponse(BaseModel):
|
||||
class ListTTSJobResponse(BaseModel):
|
||||
"""TTS 任务列表响应。"""
|
||||
|
||||
items: list[TTSJobResponse]
|
||||
items: List[TTSJobResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -113,8 +110,7 @@ class TTSPreviewRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
|
||||
voice_id: str = Field(..., min_length=1, description="音色 ID")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)")
|
||||
language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)")
|
||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文)")
|
||||
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -61,7 +61,7 @@ class ShareResponse(BaseModel):
|
||||
class ShareListResponse(BaseModel):
|
||||
"""分享列表响应."""
|
||||
|
||||
items: list[ShareResponse]
|
||||
items: List[ShareResponse]
|
||||
total: int = 0
|
||||
skip: int = 0
|
||||
limit: int = 20
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -56,7 +56,7 @@ class UnifiedVoiceItemResponse(BaseModel):
|
||||
status: str = "completed"
|
||||
"""状态"""
|
||||
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
"""标签列表"""
|
||||
|
||||
# 克隆音色特有字段
|
||||
@@ -113,7 +113,7 @@ class PresetVoiceItemResponse(BaseModel):
|
||||
preview_url: str = ""
|
||||
"""预览音频 URL"""
|
||||
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
"""标签列表"""
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -19,7 +19,7 @@ class CreateVoiceCloneRequest(BaseModel):
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
gender: str = Field("unknown", description="性别")
|
||||
max_retries: int = Field(3, ge=1, le=10, description="最大重试次数")
|
||||
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
@@ -41,7 +41,7 @@ class VoiceCloneProfileResponse(BaseModel):
|
||||
error_message: str = ""
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
metadata_: Optional[dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据")
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -62,7 +62,7 @@ class VoiceCloneStatusResponse(BaseModel):
|
||||
class ListVoiceCloneResponse(BaseModel):
|
||||
"""音色克隆列表响应。"""
|
||||
|
||||
items: list[VoiceCloneProfileResponse]
|
||||
items: List[VoiceCloneProfileResponse]
|
||||
total: int
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -21,7 +21,7 @@ class VoiceLibraryItemResponse(BaseModel):
|
||||
file_size: int = 0
|
||||
status: str = "completed"
|
||||
project_id: Optional[str] = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -42,7 +42,7 @@ class CreateVoiceLibraryRequest(BaseModel):
|
||||
file_size: int = 0
|
||||
status: str = "completed"
|
||||
project_id: Optional[str] = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateVoiceLibraryRequest(BaseModel):
|
||||
@@ -55,4 +55,4 @@ class UpdateVoiceLibraryRequest(BaseModel):
|
||||
duration: Optional[float] = None
|
||||
file_size: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"""AI 数字人封面服务 — MediaKit 抽帧 + 质量评分选最佳帧 + 转存 OSS.
|
||||
"""AI 数字人封面服务 — 复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧.
|
||||
|
||||
与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧):
|
||||
1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略)
|
||||
2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳
|
||||
3. 下载最佳帧并转存 OSS,返回公网封面 URL
|
||||
|
||||
设计原则:封面一律从最终成片(已叠加标题/B-roll)抽帧,帧本身已含标题,
|
||||
本服务**不再叠加标题**。对口型阶段的裸视频封面入口已删除(废弃)。
|
||||
|
||||
降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。
|
||||
"""
|
||||
|
||||
@@ -22,9 +19,9 @@ from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MediaKit 抽帧轮询参数:poll_interval=2s × max_poll=30 → 最长 60s(与 mediakit_client 默认值/lipsync 轮询保持一致,防止合成视频下载+抽帧超时)
|
||||
COVER_POLL_INTERVAL = 2.0
|
||||
COVER_MAX_POLL_ATTEMPTS = 30
|
||||
# MediaKit 抽帧轮询参数(与 MediaKit API timeout=60s 对齐)
|
||||
COVER_POLL_INTERVAL = 3.0
|
||||
COVER_MAX_POLL_ATTEMPTS = 20 # 最多等 60 秒
|
||||
|
||||
# 帧图片下载超时(秒)
|
||||
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()
|
||||
url_host = urlparse(video_url).netloc.lower()
|
||||
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)
|
||||
if signed:
|
||||
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:
|
||||
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL."""
|
||||
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL.
|
||||
|
||||
Args:
|
||||
video_url: 可公网访问的视频 URL
|
||||
max_frames: 抽帧数量
|
||||
|
||||
Returns:
|
||||
最佳帧图片 URL;失败返回空字符串
|
||||
"""
|
||||
if not video_url:
|
||||
return ""
|
||||
|
||||
# 确保 MediaKit 能访问 video_url(自家 OSS 私有桶需重签)
|
||||
video_url = _sign_video_url_for_mediakit(video_url)
|
||||
|
||||
try:
|
||||
@@ -78,9 +85,11 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
return ""
|
||||
|
||||
logger.info(
|
||||
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d",
|
||||
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d poll_interval=%.1f max_poll=%d",
|
||||
video_url[:80],
|
||||
max_frames,
|
||||
COVER_POLL_INTERVAL,
|
||||
COVER_MAX_POLL_ATTEMPTS,
|
||||
)
|
||||
|
||||
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:
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
# 使用连接池下载各帧(复用 TCP 连接,减少延迟)
|
||||
import httpx
|
||||
|
||||
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_url = best.get("url", "") if best else ""
|
||||
|
||||
# 清理临时文件
|
||||
for c in candidates:
|
||||
p = c.get("image_path")
|
||||
if p:
|
||||
@@ -145,15 +156,16 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def persist_cover_to_oss(
|
||||
frame_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
prefix: str = "ai-avatar/covers",
|
||||
) -> str:
|
||||
"""下载最佳帧图并转存到 OSS,返回公网封面 URL(预签名).
|
||||
def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-avatar/covers") -> str:
|
||||
"""下载帧图并转存到 OSS,返回公网封面 URL.
|
||||
|
||||
封面来自最终成片抽帧,帧本身已含标题,本函数不再做任何文字/图片叠加。
|
||||
Args:
|
||||
frame_url: MediaKit 返回的临时帧图 URL
|
||||
job_id: 关联任务 ID(用于 OSS key 命名)
|
||||
prefix: OSS key 前缀
|
||||
|
||||
Returns:
|
||||
OSS 公网 URL;失败回退原始 frame_url
|
||||
"""
|
||||
if not frame_url:
|
||||
return ""
|
||||
@@ -177,13 +189,13 @@ def persist_cover_to_oss(
|
||||
storage = get_shared_storage_service()
|
||||
token = job_id or uuid.uuid4().hex[:12]
|
||||
cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg"
|
||||
|
||||
public_url = storage.upload_file(
|
||||
file_or_path=tmp_path,
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key)
|
||||
# 私有桶:返回预签名 URL(前端才能加载)
|
||||
if public_url:
|
||||
signed = storage.get_download_url(cover_key, expires_seconds=86400)
|
||||
return signed
|
||||
@@ -199,15 +211,10 @@ def persist_cover_to_oss(
|
||||
pass
|
||||
|
||||
|
||||
def generate_smart_cover(
|
||||
video_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
max_frames: int = 5,
|
||||
) -> str:
|
||||
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS。失败返回空字符串。
|
||||
def generate_smart_cover(video_url: str, *, job_id: str = "", max_frames: int = 5) -> str:
|
||||
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS,返回封面公网 URL.
|
||||
|
||||
封面从最终成片抽帧,不再叠加任何标题(帧本身已含)。
|
||||
供独立封面接口与渲染管线复用。失败返回空字符串。
|
||||
"""
|
||||
best_frame = select_best_cover_frame(video_url, max_frames=max_frames)
|
||||
if not best_frame:
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -27,9 +24,8 @@ from packages.adapters.sqlalchemy_impl.models import (
|
||||
ScriptModel,
|
||||
)
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_broll_overlay_filter,
|
||||
build_cover_extract_command,
|
||||
build_title_drawtext_filter,
|
||||
build_title_overlay_filter,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
@@ -117,7 +113,7 @@ class AiAvatarRenderService:
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
@@ -164,7 +160,7 @@ class AiAvatarRenderService:
|
||||
return None
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
@@ -186,7 +182,7 @@ class AiAvatarRenderService:
|
||||
job.output_duration = 0.0
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
@@ -200,8 +196,9 @@ class AiAvatarRenderService:
|
||||
1. 下载对口型输出视频 (20%)
|
||||
2. 构建 FFmpeg 滤镜链 (40%)
|
||||
3. 执行 FFmpeg 渲染 (80%)
|
||||
4. 上传到 OSS (95%) — 封面不再自动生成,改由前端主动抽帧
|
||||
5. 更新任务状态 (100%)
|
||||
4. 提取封面 (90%)
|
||||
5. 上传到 OSS (95%)
|
||||
6. 更新任务状态 (100%)
|
||||
"""
|
||||
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
|
||||
if job is None:
|
||||
@@ -215,9 +212,9 @@ class AiAvatarRenderService:
|
||||
try:
|
||||
# 更新状态为 processing
|
||||
job.status = "processing"
|
||||
job.started_at = datetime.now(UTC)
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.progress = 5
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
# 获取对口型任务信息
|
||||
@@ -231,32 +228,27 @@ class AiAvatarRenderService:
|
||||
self.db.commit()
|
||||
|
||||
# 2. 构建 FFmpeg 滤镜链 (40%)
|
||||
# 用 ffprobe 探测输入视频分辨率,确保 B-roll 缩放与标题位置与实际输出一致。
|
||||
# AI 数字人对口型输出为 9:16 竖屏,默认兜底 720x1280;探测失败时使用默认值不阻断渲染。
|
||||
output_width, output_height = self._probe_video_resolution(input_video_path)
|
||||
if output_width <= 0 or output_height <= 0:
|
||||
output_width, output_height = 720, 1280
|
||||
logger.info(
|
||||
"[数字人渲染] ffprobe 探测分辨率失败或无效,使用默认竖屏尺寸 %sx%s",
|
||||
output_width,
|
||||
output_height,
|
||||
)
|
||||
else:
|
||||
logger.info("[数字人渲染] 探测输入视频分辨率: %sx%s", output_width, output_height)
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
broll_filter, broll_label = build_broll_overlay_filter(
|
||||
filter_complex = build_broll_overlay_filter(
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
video_duration=lipsync_job.output_duration,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
)
|
||||
|
||||
# 标题叠加路径:优先前端 Canvas 渲染的 PNG 图层(所见即所得),
|
||||
# 无 title_image_dataurl 时降级到 drawtext 重画文字。
|
||||
title_cfg = job.title_config if isinstance(job.title_config, dict) else {}
|
||||
title_dataurl = (title_cfg or {}).get("title_image_dataurl") if title_cfg else None
|
||||
use_title_png = isinstance(title_dataurl, str) and title_dataurl.startswith("data:image/")
|
||||
title_input_index = 1 + len(job.b_roll_segments or []) if use_title_png else None
|
||||
# 标题叠加
|
||||
title_filter = build_title_drawtext_filter(job.title_config)
|
||||
if title_filter:
|
||||
if filter_complex:
|
||||
filter_complex += f"[vout]{title_filter}[vout_titled];"
|
||||
else:
|
||||
filter_complex = f"[0:v]{title_filter}[vout_titled];"
|
||||
|
||||
# 清理末尾分号
|
||||
if filter_complex.endswith(";"):
|
||||
filter_complex = filter_complex[:-1]
|
||||
|
||||
# 最终输出标签
|
||||
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
|
||||
|
||||
job.progress = 40
|
||||
self.db.commit()
|
||||
@@ -265,126 +257,57 @@ class AiAvatarRenderService:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_video_path = os.path.join(tmpdir, "output.mp4")
|
||||
|
||||
# 在临时目录里解码保存标题 PNG(with 退出自动清理)
|
||||
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(
|
||||
cmd = self._build_ffmpeg_command(
|
||||
input_video=input_video_path,
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
extra_inputs=extra_inputs,
|
||||
filter_complex=filter_complex,
|
||||
final_label=final_label,
|
||||
output_path=output_video_path,
|
||||
)
|
||||
|
||||
try:
|
||||
render_result = subprocess.run(
|
||||
cmd_list,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AiAvatarRenderError(
|
||||
"FFmpeg 渲染超时(600s)",
|
||||
code="FFmpegTimeout",
|
||||
) from exc
|
||||
|
||||
if render_result.returncode != 0:
|
||||
stderr_tail = (render_result.stderr or "").strip()[-800:]
|
||||
raise AiAvatarRenderError(
|
||||
f"FFmpeg 渲染失败,退出码: {render_result.returncode}, stderr: {stderr_tail}",
|
||||
code="FFmpegFailed",
|
||||
)
|
||||
exit_code = os.system(cmd)
|
||||
if exit_code != 0:
|
||||
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
|
||||
|
||||
job.progress = 80
|
||||
self.db.commit()
|
||||
|
||||
# 4/5. 上传成片到 OSS (95%) —— 已砍掉自动抽封面逻辑(步骤⑤);
|
||||
# 封面由前端在渲染完成后通过 /smart-cover 接口主动从成片抽帧,不阻塞渲染链路。
|
||||
# 4. 提取封面 (90%)
|
||||
cover_path = ""
|
||||
if job.cover_config:
|
||||
cover_path = os.path.join(tmpdir, "cover.jpg")
|
||||
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
|
||||
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
|
||||
cover_exit = os.system(cover_cmd)
|
||||
if cover_exit != 0:
|
||||
logger.warning("封面提取失败,跳过: %s", cover_cmd)
|
||||
cover_path = ""
|
||||
|
||||
job.progress = 90
|
||||
self.db.commit()
|
||||
|
||||
# 5. 上传到 OSS (95%)
|
||||
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
|
||||
job.output_video_url = output_video_url
|
||||
|
||||
# 封面透传:如果用户已在 cover_config 中选定封面 URL(mode=upload 的自定义上传 或
|
||||
# mode=auto_frame 已有的智能封面结果),直接透传到 output_cover_url,不再重新截帧。
|
||||
if isinstance(job.cover_config, dict):
|
||||
_pre_cover_url = (
|
||||
job.cover_config.get("url")
|
||||
or job.cover_config.get("imageUrl")
|
||||
or job.cover_config.get("cover_url")
|
||||
or ""
|
||||
)
|
||||
if _pre_cover_url:
|
||||
job.output_cover_url = _pre_cover_url
|
||||
logger.info("[数字人渲染] 使用用户已选定封面 URL: job_id=%s", job_id)
|
||||
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧;
|
||||
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
|
||||
smart_cover_url = ""
|
||||
if output_video_url:
|
||||
try:
|
||||
from app.services.ai_avatar_cover_service import (
|
||||
generate_smart_cover,
|
||||
)
|
||||
|
||||
smart_cover_url = generate_smart_cover(output_video_url, job_id=job_id, max_frames=5)
|
||||
except Exception:
|
||||
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
|
||||
|
||||
if smart_cover_url:
|
||||
job.output_cover_url = smart_cover_url
|
||||
elif cover_path:
|
||||
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
|
||||
job.output_cover_url = output_cover_url
|
||||
|
||||
# 获取输出视频时长
|
||||
job.output_duration = lipsync_job.output_duration
|
||||
@@ -394,110 +317,55 @@ class AiAvatarRenderService:
|
||||
# 6. 完成
|
||||
job.status = "completed"
|
||||
job.progress = 100
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.info("渲染任务完成: %s", job_id)
|
||||
|
||||
# 7. 渲染完成,停留在「待选封面」状态:不自动入库。
|
||||
# 用户在前端选好封面、点「完成」后,由 /{job_id}/finalize 接口显式入库。
|
||||
logger.info("渲染任务完成,等待用户选择封面后入库: job_id=%s", job_id)
|
||||
# 7. 自动保存成片记录到成片库
|
||||
if job.output_video_url:
|
||||
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:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.error("渲染任务失败 [%s]: %s", job_id, exc)
|
||||
raise
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"渲染异常: {str(exc)}"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
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(UTC)
|
||||
self.db.commit()
|
||||
|
||||
return self._persist_to_library(job, cover_url=cover_url)
|
||||
|
||||
def _download_video(self, url: str) -> str:
|
||||
"""下载视频到临时文件."""
|
||||
@@ -515,132 +383,32 @@ class AiAvatarRenderService:
|
||||
os.unlink(tmp.name)
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
input_video: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
extra_inputs: list[str] | None = None,
|
||||
filter_complex: str,
|
||||
final_label: Optional[str],
|
||||
output_path: str,
|
||||
) -> list[str]:
|
||||
"""构建 FFmpeg 命令(list 形式,shell=False).
|
||||
|
||||
根因修复 #1798 P0:OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符,
|
||||
os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被
|
||||
当成独立命令报 sh: -filter_complex: not found(exit 127 → Python 32512)。
|
||||
list + shell=False 彻底规避 shell 转义问题。
|
||||
"""
|
||||
cmd: list[str] = ["ffmpeg", "-i", input_video]
|
||||
) -> str:
|
||||
"""构建 FFmpeg 命令."""
|
||||
# 输入文件
|
||||
inputs = f"-i {input_video}"
|
||||
for seg in b_roll_segments:
|
||||
asset_url = seg.get("asset_url", "")
|
||||
if asset_url:
|
||||
cmd.extend(["-i", asset_url])
|
||||
# 额外输入(例如前端 Canvas 渲染的标题 PNG)
|
||||
for extra in extra_inputs or []:
|
||||
cmd.extend(["-i", extra])
|
||||
inputs += f" -i {asset_url}"
|
||||
|
||||
# 滤镜
|
||||
if filter_complex and final_label:
|
||||
cmd.extend(
|
||||
[
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
f"[{final_label}]",
|
||||
"-map",
|
||||
"0:a?",
|
||||
]
|
||||
)
|
||||
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
|
||||
elif filter_complex:
|
||||
cmd.extend(["-filter_complex", filter_complex])
|
||||
filter_arg = f'-filter_complex "{filter_complex}"'
|
||||
else:
|
||||
filter_arg = ""
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-y",
|
||||
output_path,
|
||||
]
|
||||
)
|
||||
return cmd
|
||||
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset veryfast -crf 23 -y {output_path}"
|
||||
|
||||
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
|
||||
"""上传文件到 OSS,返回 URL.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
|
||||
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
|
||||
@@ -64,7 +64,7 @@ def _generate_titles_fallback(
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> list[str]:
|
||||
) -> List[str]:
|
||||
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
|
||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||||
return _generate_titles_fallback_base(description, style_info, count)
|
||||
@@ -74,7 +74,7 @@ def generate_smart_titles(
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
"""生成智能标题.
|
||||
|
||||
Args:
|
||||
@@ -164,16 +164,16 @@ def generate_smart_titles(
|
||||
|
||||
def _semantic_match_fallback(
|
||||
description: str,
|
||||
assets: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
assets: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
|
||||
return _semantic_match_fallback_base(description, assets)
|
||||
|
||||
|
||||
def _parse_semantic_match_response(
|
||||
content: str,
|
||||
asset_ids: list[str],
|
||||
) -> Optional[dict[str, float]]:
|
||||
asset_ids: List[str],
|
||||
) -> Optional[Dict[str, float]]:
|
||||
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
|
||||
result = _parse_semantic_match_base(content, asset_ids)
|
||||
if result is None:
|
||||
@@ -183,9 +183,9 @@ def _parse_semantic_match_response(
|
||||
|
||||
def semantic_match_assets(
|
||||
description: str,
|
||||
assets: list[dict[str, Any]],
|
||||
assets: List[Dict[str, Any]],
|
||||
top_k: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
"""智能素材语义匹配.
|
||||
|
||||
根据用户描述,评估每个素材的语义匹配度并排序。
|
||||
@@ -336,13 +336,13 @@ class AIService:
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
return generate_smart_titles(description, style, count)
|
||||
|
||||
def semantic_match(
|
||||
self,
|
||||
description: str,
|
||||
assets: list[dict[str, Any]],
|
||||
assets: List[Dict[str, Any]],
|
||||
top_k: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
return semantic_match_assets(description, assets, top_k)
|
||||
|
||||
@@ -28,8 +28,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -57,7 +57,7 @@ _REUSE_OVERLAP_RATIO = 0.6
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _read_meta(model) -> dict:
|
||||
@@ -156,7 +156,7 @@ def record_used_segments(
|
||||
r["plan_id"] = plan_id
|
||||
meta[USED_RANGES_KEY] = ranges
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
return
|
||||
|
||||
ranges.append(
|
||||
@@ -171,7 +171,7 @@ def record_used_segments(
|
||||
)
|
||||
meta[USED_RANGES_KEY] = ranges
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def remove_used_segment(
|
||||
@@ -215,7 +215,7 @@ def remove_used_segment(
|
||||
if removed:
|
||||
meta[USED_RANGES_KEY] = remaining
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
return removed
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def reset_used_segments(db: Session, asset_id: str) -> None:
|
||||
if meta.get(USED_RANGES_KEY):
|
||||
meta[USED_RANGES_KEY] = []
|
||||
model.classification_result = json.dumps(meta, ensure_ascii=False)
|
||||
model.updated_at = datetime.now(UTC)
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.services.asset_segment_tracker import (
|
||||
REUSE_RATIO_LIMIT,
|
||||
@@ -206,7 +206,7 @@ class EditPlanService:
|
||||
status: Optional[EditPlanClipStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[EditPlanClip]:
|
||||
) -> List[EditPlanClip]:
|
||||
"""列出计划的片段"""
|
||||
# 确保计划存在
|
||||
self.get_plan_or_raise(plan_id)
|
||||
@@ -473,7 +473,6 @@ class EditPlanService:
|
||||
name_suffix: str = "变体",
|
||||
voice_duration: float = 0.0,
|
||||
rng=None,
|
||||
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||||
) -> EditPlan:
|
||||
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
|
||||
|
||||
@@ -490,8 +489,6 @@ class EditPlanService:
|
||||
created_by_user_id: 新 plan 归属用户。
|
||||
name_suffix: plan 名后缀。
|
||||
rng: 可选随机数(测试注入种子)。
|
||||
batch_segments: 可选,外部传入的批次内已使用素材区间(前序变体避让用)。
|
||||
传入时作为初始避让对象;未传则保持原逻辑从源 plan clips 自建(向后兼容)。
|
||||
|
||||
Raises:
|
||||
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
|
||||
@@ -503,7 +500,7 @@ class EditPlanService:
|
||||
source = self.get_plan_or_raise(source_plan_id)
|
||||
|
||||
# 分页读取源 plan 全部片段
|
||||
clips: list[EditPlanClip] = []
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
|
||||
@@ -540,26 +537,16 @@ class EditPlanService:
|
||||
voice = float(voice_duration or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
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:
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations
|
||||
|
||||
_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]
|
||||
# #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(
|
||||
len(source_clips_data),
|
||||
voice,
|
||||
transition_effects=_effects,
|
||||
transition_durations=_tdurs,
|
||||
rhythm_template=rhythm_template_for_reselect,
|
||||
asset_durations=None,
|
||||
)
|
||||
if target_durations:
|
||||
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 ""),
|
||||
)
|
||||
|
||||
# 批次内区间:外部传入时使用外部传入(含前序变体已用区间);
|
||||
# 否则保持原逻辑从源 plan clips 自建(向后兼容)
|
||||
if batch_segments is not None:
|
||||
batch_segments_resolved: dict[str, list[tuple[float, float]]] = {
|
||||
k: list(v) for k, v in batch_segments.items()
|
||||
}
|
||||
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)))
|
||||
# 批次内区间:以源 plan(变体 0)片段为初始避让对象
|
||||
batch_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
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.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
|
||||
|
||||
clips_data = reselect_clips_for_variant(
|
||||
source_clips_data,
|
||||
@@ -614,7 +595,7 @@ class EditPlanService:
|
||||
asset_durations=durations,
|
||||
asset_scene_points=scene_points,
|
||||
historical_used_segments=historical,
|
||||
batch_segments=batch_segments_resolved,
|
||||
batch_segments=batch_segments,
|
||||
target_durations=target_durations,
|
||||
rng=rng,
|
||||
)
|
||||
@@ -665,7 +646,7 @@ class EditPlanService:
|
||||
source = self.get_plan_or_raise(source_plan_id)
|
||||
|
||||
# 分页读取源 plan 全部片段
|
||||
clips: list[EditPlanClip] = []
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
|
||||
@@ -786,18 +767,7 @@ class EditPlanService:
|
||||
if plan is 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
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page)
|
||||
@@ -868,10 +838,6 @@ class EditPlanService:
|
||||
)
|
||||
try:
|
||||
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.commit()
|
||||
except Exception:
|
||||
@@ -912,69 +878,6 @@ class EditPlanService:
|
||||
rng = rng or _random.Random()
|
||||
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
|
||||
plan0 = self.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
@@ -987,15 +890,6 @@ class EditPlanService:
|
||||
v0_voice = float(voice_durations[0] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
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:
|
||||
try:
|
||||
self.apply_voice_duration_to_plan(plan0.id, v0_voice)
|
||||
@@ -1003,12 +897,7 @@ class EditPlanService:
|
||||
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
|
||||
plan_ids.append(plan0.id)
|
||||
|
||||
# #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值(公共函数)
|
||||
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 做区间避让)
|
||||
# 变体 1..N-1:独立选片
|
||||
for i in range(1, count):
|
||||
voice = 0.0
|
||||
if voice_durations and i < len(voice_durations):
|
||||
@@ -1016,14 +905,6 @@ class EditPlanService:
|
||||
voice = float(voice_durations[i] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
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(
|
||||
source_plan_id,
|
||||
candidate_asset_ids,
|
||||
@@ -1031,26 +912,73 @@ class EditPlanService:
|
||||
name_suffix=f"变体{i + 1}",
|
||||
voice_duration=voice,
|
||||
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)
|
||||
|
||||
# #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:
|
||||
_new_segs = _collect_plan_segments(variant.id, self._clip_repo)
|
||||
for _aid, _ivs in _new_segs.items():
|
||||
batch_segments_acc.setdefault(_aid, []).extend(_ivs)
|
||||
perturbation = generate_visual_perturbation(rng)
|
||||
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
|
||||
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:
|
||||
logger.exception("变体 %d 区间收集失败(不阻断): plan=%s", i, variant.id)
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
|
||||
|
||||
# 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready)
|
||||
for pid in plan_ids:
|
||||
@@ -1063,7 +991,7 @@ class EditPlanService:
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> dict[str, Any]:
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
"""将一个片段从指定位置分割为两个片段
|
||||
|
||||
Args:
|
||||
@@ -1151,7 +1079,7 @@ class EditPlanService:
|
||||
"right_clip": created_right,
|
||||
}
|
||||
|
||||
def merge_clips(self, clip_ids: list[str]) -> EditPlanClip:
|
||||
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
|
||||
"""合并多个连续片段为一个片段
|
||||
|
||||
Args:
|
||||
@@ -1217,7 +1145,7 @@ class EditPlanService:
|
||||
|
||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||
|
||||
def get_generation_status(self, plan_id: str) -> dict[str, Any]:
|
||||
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
|
||||
"""获取渲染进度状态
|
||||
|
||||
Returns:
|
||||
@@ -1364,7 +1292,7 @@ class EditPlanService:
|
||||
)
|
||||
return count
|
||||
|
||||
def update_plan_config(self, plan_id: str, config_updates: dict[str, Any]) -> EditPlan:
|
||||
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
||||
"""更新计划配置(合并更新)
|
||||
|
||||
Args:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -76,7 +76,7 @@ class EditTemplateService:
|
||||
active_only: bool = False,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[EditTemplate]:
|
||||
) -> List[EditTemplate]:
|
||||
"""列出模板
|
||||
|
||||
Args:
|
||||
@@ -227,7 +227,7 @@ class EditTemplateService:
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[TemplateClipConfig]:
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""列出模板的片段配置
|
||||
|
||||
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
|
||||
@@ -253,7 +253,7 @@ class EditTemplateService:
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[TemplateClipConfig]:
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""编辑器读取模板片段配置的单一数据源入口.
|
||||
|
||||
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
|
||||
@@ -404,8 +404,8 @@ class EditTemplateService:
|
||||
def reorder_clip_configs(
|
||||
self,
|
||||
template_id: str,
|
||||
config_ids: list[str],
|
||||
) -> list[TemplateClipConfig]:
|
||||
config_ids: List[str],
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""重新排序片段配置
|
||||
|
||||
Args:
|
||||
@@ -560,7 +560,7 @@ class EditTemplateService:
|
||||
)
|
||||
|
||||
# 5. 转换每个片段为模板片段配置
|
||||
created_configs: list[TemplateClipConfig] = []
|
||||
created_configs: List[TemplateClipConfig] = []
|
||||
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
|
||||
created = self._clip_config_repo.create(clip_config_obj)
|
||||
created_configs.append(created)
|
||||
|
||||
@@ -1,189 +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:
|
||||
# #1901 统一字段名为 "title"(worker sync_configs_to_plan 写的是 "title")
|
||||
# 先读取新旧两个 key,判断标题文字是否变化
|
||||
old_title_cfg = merged.get("title", {}) or {}
|
||||
if not isinstance(old_title_cfg, dict) or not (old_title_cfg.get("text") or "").strip():
|
||||
old_title_cfg = merged.get("title_config", {}) or {}
|
||||
old_title_text = (old_title_cfg.get("text") or "").strip() if isinstance(old_title_cfg, dict) else ""
|
||||
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,
|
||||
)
|
||||
# 字段名归一化(font_size→size, font_preset→font, font_color→color),与 worker sync_configs_to_plan 保持一致
|
||||
normalized = dict(title_config)
|
||||
if "font_size" in normalized and "size" not in normalized:
|
||||
normalized["size"] = normalized["font_size"]
|
||||
if "font_preset" in normalized and "font" not in normalized:
|
||||
normalized["font"] = normalized["font_preset"]
|
||||
if "font_color" in normalized and "color" not in normalized:
|
||||
normalized["color"] = normalized["font_color"]
|
||||
merged["title"] = normalized
|
||||
# 清掉旧 key,避免双字段并存
|
||||
merged.pop("title_config", None)
|
||||
|
||||
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 参数调整.
|
||||
|
||||
职责:
|
||||
- 创建/查询对口型任务
|
||||
- 三输入模式:
|
||||
1. TTS 直生(voice_id + script_text)→ 走 Celery 异步(降级路径)
|
||||
2. 直接音频(audio_url,前端未传 timings)→ 同步下载 + 算 timings + 提交 MediaKit
|
||||
3. 预合成音频(audio_url + sentence_timings,#1845 新主路径)→ 同步 ffprobe 校验时长 +
|
||||
写入前端传来的 timings → 直接提交 MediaKit(~2-3s)
|
||||
- 双输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url)
|
||||
- 调用 MediaKit 客户端提交异步任务
|
||||
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
|
||||
- 用户隔离(每个用户只能操作自己的任务)
|
||||
@@ -17,7 +13,7 @@ from __future__ import annotations
|
||||
import io
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -30,22 +26,19 @@ from app.services.mediakit_client import (
|
||||
get_mediakit_client,
|
||||
)
|
||||
|
||||
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径)
|
||||
# Celery 异步任务:TTS 合成 + MediaKit 提交(#lipsync-speed-optimization)
|
||||
from app.tasks.lipsync_tts import tts_synthesize_and_submit
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
from packages.domain.sentence_timings import (
|
||||
compute_sentence_timings,
|
||||
probe_audio_duration,
|
||||
)
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, normalize_emotion
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 传给 MediaKit GPU worker / 回给前端播放的 OSS 预签名有效期:7 天。
|
||||
# MediaKit 排队 + 拉取可能延迟,私有桶裸 URL 或 1 小时短预签名都会 403,故统一重签长有效期。
|
||||
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
@@ -121,8 +114,7 @@ class LipsyncService:
|
||||
text=script_text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=speed,
|
||||
emotion=emotion, # normalize 在 CosyVoiceService 内部完成
|
||||
language="zh",
|
||||
emotion=normalize_emotion(emotion),
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc
|
||||
@@ -150,100 +142,6 @@ class LipsyncService:
|
||||
logger.warning("TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
|
||||
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(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(
|
||||
@@ -252,35 +150,27 @@ class LipsyncService:
|
||||
user_id: str,
|
||||
video_url: str,
|
||||
audio_url: str = "",
|
||||
audio_duration: Optional[float] = None,
|
||||
sentence_timings: Optional[list] = None,
|
||||
voice_id: str = "",
|
||||
script_text: str = "",
|
||||
speed: float = 1.0,
|
||||
emotion: str = "",
|
||||
enable_video_loop: bool = True,
|
||||
enable_video_loop: bool = False,
|
||||
project_id: str = "",
|
||||
) -> LipsyncJobModel:
|
||||
"""创建对口型任务.
|
||||
|
||||
三种输入模式:
|
||||
两种输入模式:
|
||||
- TTS 直生:voice_id + script_text(audio_url 留空)
|
||||
→ 创建 DB 记录(状态 tts_processing),dispatch Celery 异步任务(降级路径)。
|
||||
API 响应 <1s。
|
||||
- 直接音频:audio_url 非空 + 无 sentence_timings
|
||||
→ 同步下载音频 + 重算 timings + 提交 MediaKit(几秒完成)。
|
||||
- 预合成音频(#1845 新主路径):audio_url 非空 + 传 sentence_timings
|
||||
→ 同步 ffprobe 校验时长 + 写入 timings + 提交 MediaKit(~2-3s)。
|
||||
→ 先创建 DB 记录(状态 tts_processing),再 dispatch Celery 异步任务
|
||||
执行 TTS 合成 + MediaKit 提交。API 响应 <1s。
|
||||
- 直接音频:提供 audio_url
|
||||
→ 同步提交 MediaKit,状态直接设为 submitted。
|
||||
|
||||
Raises:
|
||||
MediaKitError: 参数校验失败或 MediaKit 提交失败
|
||||
MediaKitError: 参数校验失败或 MediaKit 提交失败(仅直接音频模式)
|
||||
"""
|
||||
# 0. 输入校验
|
||||
is_pre_synth = bool(audio_url) and bool(sentence_timings)
|
||||
bool(audio_url) and not is_pre_synth
|
||||
is_tts_mode = not bool(audio_url)
|
||||
|
||||
if is_tts_mode:
|
||||
if not audio_url:
|
||||
if not (voice_id and script_text):
|
||||
raise MediaKitError(
|
||||
"必须提供 audio_url 或 voice_id+script_text",
|
||||
@@ -288,13 +178,10 @@ class LipsyncService:
|
||||
)
|
||||
# TTS 模式:在 HTTP 请求中同步校验音色归属,快速失败
|
||||
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. 创建数据库记录
|
||||
job_id = str(uuid.uuid4())
|
||||
is_tts_mode = not bool(audio_url)
|
||||
job = LipsyncJobModel(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
@@ -305,19 +192,14 @@ class LipsyncService:
|
||||
voice_id=voice_id or "",
|
||||
script_text=script_text or "",
|
||||
speed=speed,
|
||||
emotion=emotion or "",
|
||||
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
|
||||
emotion=normalize_emotion(emotion),
|
||||
status="tts_processing" if is_tts_mode else "pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
# ⚠️ 必须先 commit 再发 Celery 任务 / 后续同步操作,避免事务竞态
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
|
||||
if is_tts_mode:
|
||||
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交(降级路径)
|
||||
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交
|
||||
try:
|
||||
tts_synthesize_and_submit.apply_async(
|
||||
args=(
|
||||
@@ -326,10 +208,12 @@ class LipsyncService:
|
||||
voice_id,
|
||||
script_text,
|
||||
speed,
|
||||
emotion or "",
|
||||
normalize_emotion(emotion),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
# 投递失败时立即把 job 标成 failed 并写入 error_message,
|
||||
# 前端轮询时能直接看到失败原因,不会无限卡在 tts_processing。
|
||||
logger.exception(
|
||||
"Celery 任务提交失败,TTS 任务已创建但未触发执行: job_id=%s err=%s",
|
||||
job_id,
|
||||
@@ -338,104 +222,35 @@ class LipsyncService:
|
||||
job.status = "failed"
|
||||
job.error_message = f"Celery 任务投递失败: {exc}"
|
||||
job.error_code = "AsyncDispatchFailed"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# 2b/2c. 直接音频 / 预合成音频:同步路径
|
||||
self._submit_audio_direct(
|
||||
job=job,
|
||||
supplied_timings=sentence_timings,
|
||||
supplied_duration=audio_duration,
|
||||
)
|
||||
self.db.refresh(job)
|
||||
# 2b. 直接音频模式:同步签名并提交 MediaKit
|
||||
video_url = self._sign_media_url(video_url)
|
||||
if audio_url:
|
||||
audio_url = self._sign_media_url(audio_url)
|
||||
job.audio_url = audio_url
|
||||
|
||||
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
|
||||
|
||||
# ── TTS 预合成(#1845 步骤1「生成配音」同步接口使用) ──────────────────
|
||||
|
||||
def preview_tts(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float = 1.0,
|
||||
emotion: str = "neutral",
|
||||
) -> 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=emotion, # normalize 在 CosyVoiceService 内部完成
|
||||
language="zh",
|
||||
)
|
||||
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]:
|
||||
@@ -469,7 +284,11 @@ class LipsyncService:
|
||||
# ── 更新任务状态(轮询) ──────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
if job is None:
|
||||
return None
|
||||
@@ -491,57 +310,34 @@ class LipsyncService:
|
||||
mk_status = status_data.get("status", STATUS_RUNNING)
|
||||
logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status)
|
||||
|
||||
try:
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
temp_url = result.get("video_url", "")
|
||||
job.output_video_url = temp_url
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(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,
|
||||
)
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(UTC)
|
||||
else:
|
||||
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
except Exception as exc: # noqa: BLE001 - DB 提交失败必须记录日志并重试,否则后台任务静默失败
|
||||
logger.error(
|
||||
"refresh_job_status 提交 DB 失败 job_id=%s mk_status=%s err=%s",
|
||||
job_id,
|
||||
mk_status,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
self.db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
# DB commit 失败不 raise,返回当前 job 对象让下次轮询再试
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
output_url = result.get("video_url", "")
|
||||
# MediaKit 输出为临时 URL,转存自家 OSS 防止过期(失败则回退临时 URL)
|
||||
job.output_video_url = self._persist_output_video(output_url, job_id, user_id)
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
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:
|
||||
return ""
|
||||
try:
|
||||
@@ -561,21 +357,27 @@ class LipsyncService:
|
||||
return temp_url
|
||||
|
||||
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:
|
||||
return url
|
||||
try:
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return url
|
||||
return url # 无法判定归属,保守透传
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
host = urlparse(url).netloc.lower()
|
||||
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)
|
||||
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)
|
||||
return url
|
||||
|
||||
@@ -589,7 +391,7 @@ class LipsyncService:
|
||||
|
||||
if job.status in ("pending", "tts_processing", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class MediaKitClient:
|
||||
*,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = True,
|
||||
enable_video_loop: bool = False,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_args: Optional[str] = None,
|
||||
client_token: Optional[str] = None,
|
||||
@@ -103,7 +103,8 @@ class MediaKitClient:
|
||||
"video_url": video_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:
|
||||
payload["callback_url"] = callback_url
|
||||
if callback_args:
|
||||
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
from typing import Any, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -62,8 +62,8 @@ class PlanGeneratorService:
|
||||
def generate_from_template(
|
||||
self,
|
||||
template: EditTemplate,
|
||||
clip_configs: list[TemplateClipConfig],
|
||||
asset_ids: list[str],
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
asset_ids: List[str],
|
||||
*,
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
@@ -135,7 +135,7 @@ class PlanGeneratorService:
|
||||
)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: list[EditPlanClip] = []
|
||||
created_clips: List[EditPlanClip] = []
|
||||
total_duration = 0.0
|
||||
for clip in clips:
|
||||
saved = self._clip_repo.create(clip)
|
||||
@@ -184,15 +184,15 @@ class PlanGeneratorService:
|
||||
def _create_clips_from_configs(
|
||||
self,
|
||||
plan_id: str,
|
||||
clip_configs: list[TemplateClipConfig],
|
||||
) -> list[EditPlanClip]:
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
) -> List[EditPlanClip]:
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
|
||||
|
||||
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
|
||||
"""
|
||||
return create_clips_from_configs(plan_id, clip_configs)
|
||||
|
||||
def _map_clip_types_for_mode(self, clips: list[EditPlanClip], editing_mode: str) -> None:
|
||||
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
|
||||
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
|
||||
|
||||
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
|
||||
@@ -204,7 +204,7 @@ class PlanGeneratorService:
|
||||
plan_id: str,
|
||||
editing_mode: str,
|
||||
asset_count: int,
|
||||
) -> list[EditPlanClip]:
|
||||
) -> List[EditPlanClip]:
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
|
||||
|
||||
委托给 plan_generator_utils.generate_default_clips 纯函数。
|
||||
@@ -213,8 +213,8 @@ class PlanGeneratorService:
|
||||
|
||||
def _distribute_assets(
|
||||
self,
|
||||
clips: list[EditPlanClip],
|
||||
asset_ids: list[str],
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
*,
|
||||
random_selection: bool = False,
|
||||
@@ -259,7 +259,7 @@ class PlanGeneratorService:
|
||||
external_used_segments=external_used_segments,
|
||||
)
|
||||
|
||||
def _fetch_asset_scene_points(self, asset_ids: list[str]) -> dict[str, list[float]]:
|
||||
def _fetch_asset_scene_points(self, asset_ids: List[str]) -> dict[str, list[float]]:
|
||||
"""从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。"""
|
||||
points_map: dict[str, list[float]] = {}
|
||||
if not self._asset_repo:
|
||||
@@ -272,7 +272,7 @@ class PlanGeneratorService:
|
||||
points_map[asset_id] = points
|
||||
return points_map
|
||||
|
||||
def _sort_assets_by_smart_score(self, asset_ids: list[str]) -> list[str]:
|
||||
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
|
||||
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
|
||||
|
||||
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
|
||||
@@ -295,7 +295,7 @@ class PlanGeneratorService:
|
||||
)
|
||||
return [aid for aid, _ in scored]
|
||||
|
||||
def _fetch_asset_durations(self, asset_ids: list[str]) -> dict[str, float]:
|
||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
||||
"""从数据库获取素材时长信息.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""文案提取 ASR 服务封装 — Issue #1893.
|
||||
|
||||
将已有的 ASR 服务工厂封装为面向文案提取场景的简单接口:
|
||||
- transcribe_to_text(video_path) -> str:将视频/音频转写为纯文本
|
||||
- 未配置 ASR 时抛 ASRNotConfiguredError(路由层映射为 503)
|
||||
- ASR 调用失败时抛 ASRTranscriptionError(路由层映射为 502)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from packages.ports.asr_service import ASRServiceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ASRNotConfiguredError(Exception):
|
||||
"""ASR 服务未配置."""
|
||||
|
||||
|
||||
class ASRTranscriptionError(Exception):
|
||||
"""ASR 转写失败."""
|
||||
|
||||
|
||||
def transcribe_to_text(media_path: str | Path) -> str:
|
||||
"""将视频/音频文件转写为纯文本.
|
||||
|
||||
Args:
|
||||
media_path: 媒体文件路径
|
||||
|
||||
Returns:
|
||||
转写出的文本
|
||||
|
||||
Raises:
|
||||
ASRNotConfiguredError: ASR 服务未配置
|
||||
ASRTranscriptionError: ASR 调用失败
|
||||
"""
|
||||
# 延迟导入,避免循环依赖和启动时副作用
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
|
||||
asr = get_asr_service()
|
||||
if asr is None:
|
||||
raise ASRNotConfiguredError("ASR 服务未配置,请联系管理员配置火山 MediaKit 或阿里云 ASR 密钥")
|
||||
|
||||
try:
|
||||
timeline = asr.transcribe(Path(media_path))
|
||||
# 拼接所有分段的文本
|
||||
text = "".join(seg.text for seg in timeline.segments)
|
||||
return text.strip()
|
||||
except ASRNotConfiguredError:
|
||||
raise
|
||||
except ASRServiceError as exc:
|
||||
logger.error("ASR 转写失败: %s", exc)
|
||||
raise ASRTranscriptionError(f"语音识别失败: {exc}") from exc
|
||||
except Exception as exc:
|
||||
logger.error("ASR 转写异常: %s", exc)
|
||||
raise ASRTranscriptionError(f"语音识别失败: {exc}") from exc
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -93,7 +93,7 @@ class ScriptService:
|
||||
script.segments = segments
|
||||
if tags is not None:
|
||||
script.tags = tags
|
||||
script.updated_at = datetime.now(UTC)
|
||||
script.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(script)
|
||||
return script
|
||||
|
||||
@@ -12,25 +12,15 @@
|
||||
|
||||
注意:使用 @shared_task 而非绑定到某个 celery_app 实例,
|
||||
确保任务能被 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 logging
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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__)
|
||||
|
||||
# MediaKit 预签名 URL 有效期(7天,秒),与 LipsyncService._sign_media_url 保持一致
|
||||
@@ -67,13 +57,8 @@ def _sign_media_url(url: str) -> str:
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="lipsync_tts.synthesize_and_submit",
|
||||
max_retries=5, # 事务竞态重试3次(job not found)+ TTS偶发错误2次
|
||||
max_retries=2,
|
||||
default_retry_delay=30,
|
||||
autoretry_for=(OSError, ConnectionError), # 网络/连接错误自动重试
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=30,
|
||||
soft_time_limit=180,
|
||||
time_limit=200,
|
||||
)
|
||||
def tts_synthesize_and_submit(
|
||||
self,
|
||||
@@ -86,8 +71,7 @@ def tts_synthesize_and_submit(
|
||||
):
|
||||
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
|
||||
|
||||
在 Celery worker 中运行,不阻塞 HTTP 请求。保留作为降级路径
|
||||
(预合成失败 / 旧版前端未传 audio_url 时走此路径)。
|
||||
在 Celery worker 中运行,不阻塞 HTTP 请求。
|
||||
"""
|
||||
from app.services.mediakit_client import MediaKitError, get_mediakit_client
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
@@ -119,25 +103,7 @@ def tts_synthesize_and_submit(
|
||||
)
|
||||
|
||||
if job is None:
|
||||
# 事务竞态防御:API 在 commit 前投递了任务,worker 消费时事务尚未提交。
|
||||
retries = getattr(self.request, "retries", 0)
|
||||
max_retries = 3
|
||||
if retries < max_retries:
|
||||
backoff = (2**retries) + (retries * 1) # 1s, 3s, 7s
|
||||
logger.warning(
|
||||
"[lipsync_tts] Job not found yet (retry %d/%d, backoff %ds): job_id=%s",
|
||||
retries + 1,
|
||||
max_retries,
|
||||
backoff,
|
||||
job_id,
|
||||
)
|
||||
self.db.close()
|
||||
raise self.retry(countdown=backoff, max_retries=max_retries)
|
||||
logger.error(
|
||||
"[lipsync_tts] Job not found after %d retries, giving up: job_id=%s",
|
||||
max_retries,
|
||||
job_id,
|
||||
)
|
||||
logger.error("[lipsync_tts] Job not found: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
# 已取消的任务不再处理
|
||||
@@ -146,13 +112,6 @@ def tts_synthesize_and_submit(
|
||||
return
|
||||
|
||||
# 1. TTS 合成
|
||||
logger.info(
|
||||
"[lipsync_tts] 开始 TTS 合成: job_id=%s voice_id=%s text_len=%d speed=%.2f",
|
||||
job_id,
|
||||
voice_id,
|
||||
len(script_text),
|
||||
speed,
|
||||
)
|
||||
try:
|
||||
cosyvoice = CosyVoiceService()
|
||||
result = cosyvoice.submit_synthesize_task(
|
||||
@@ -160,14 +119,13 @@ def tts_synthesize_and_submit(
|
||||
voice_id=voice_id,
|
||||
speed=speed,
|
||||
emotion=emotion,
|
||||
language="zh",
|
||||
)
|
||||
except CosyVoiceError as exc:
|
||||
logger.error("[lipsync_tts] TTS 合成失败: job_id=%s err=%s", job_id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"TTS 合成失败: {exc}"
|
||||
job.error_code = "TTSSynthesisFailed"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
except ValueError as exc:
|
||||
@@ -175,7 +133,7 @@ def tts_synthesize_and_submit(
|
||||
job.status = "failed"
|
||||
job.error_message = f"TTS 参数错误: {exc}"
|
||||
job.error_code = "TTSInvalidParam"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
@@ -185,78 +143,41 @@ def tts_synthesize_and_submit(
|
||||
job.status = "failed"
|
||||
job.error_message = "TTS 未返回音频 URL"
|
||||
job.error_code = "TTSNoAudio"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# 2. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL)
|
||||
audio_data: bytes | None = None
|
||||
# 2. 下载并转存到自家 OSS
|
||||
try:
|
||||
audio_data = safe_download_bytes(
|
||||
temp_url,
|
||||
purpose="lipsync_tts_audio",
|
||||
allowed_mime_types={
|
||||
allowed_mime_types=(
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
},
|
||||
),
|
||||
timeout=60.0,
|
||||
)
|
||||
logger.info(
|
||||
"[lipsync_tts] TTS 音频已下载到内存: job_id=%s size=%d",
|
||||
job_id,
|
||||
len(audio_data) if audio_data else 0,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
storage_key = f"lipsync-tts/{user_id}/{job_id}.mp3"
|
||||
permanent_url = storage.upload_file(io.BytesIO(audio_data), storage_key, content_type="audio/mpeg")
|
||||
logger.info("[lipsync_tts] TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
job.audio_url = permanent_url
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 URL 提交: job_id=%s err=%s",
|
||||
"[lipsync_tts] TTS 音频转存 OSS 失败,回退临时 URL: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
# TTS 音频使用 CosyVoice 临时 URL,跳过自家 OSS 转存(加速,步骤⑥)
|
||||
job.audio_url = temp_url
|
||||
logger.info("[lipsync_tts] TTS 音频使用 CosyVoice 临时 URL(跳过 OSS 转存): job_id=%s", job_id)
|
||||
job.audio_url = temp_url
|
||||
|
||||
db.commit()
|
||||
|
||||
# 2.5 计算精确句子时间戳(基于 TTS 音频静音检测)—— 复用共享工具
|
||||
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 的耦合)
|
||||
audio_url = _sign_media_url(job.audio_url)
|
||||
video_url = _sign_media_url(job.video_url)
|
||||
@@ -271,7 +192,7 @@ def tts_synthesize_and_submit(
|
||||
)
|
||||
job.mediakit_task_id = mk_result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
logger.info(
|
||||
"[lipsync_tts] 已提交 MediaKit: job_id=%s task_id=%s",
|
||||
job_id,
|
||||
@@ -283,18 +204,6 @@ def tts_synthesize_and_submit(
|
||||
job.error_code = exc.code
|
||||
logger.error("[lipsync_tts] 提交 MediaKit 失败: job_id=%s err=%s", job_id, exc)
|
||||
|
||||
# 三层防御 ③:链式触发 Celery 兜底轮询——MediaKit 提交成功后由 worker
|
||||
# 主动拉取状态到终态,不依赖前端轮询触发的 FastAPI background task
|
||||
# (background task 可能静默失败导致永久卡 running)。
|
||||
if job.status == "submitted" and job.mediakit_task_id:
|
||||
try:
|
||||
poll_mediakit_status.apply_async(
|
||||
kwargs={"job_id": job_id, "user_id": user_id},
|
||||
countdown=10, # 10 秒后开始轮询,给 MediaKit 一点处理时间
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("[lipsync_tts] 提交兜底轮询任务失败(不影响主流程): job_id=%s err=%s", job_id, exc)
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception:
|
||||
@@ -305,157 +214,9 @@ def tts_synthesize_and_submit(
|
||||
job.status = "failed"
|
||||
job.error_message = "TTS 异步任务执行异常"
|
||||
job.error_code = "AsyncTaskError"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="lipsync_tts.poll_mediakit_status",
|
||||
max_retries=60, # 最多轮询 60 次
|
||||
default_retry_delay=10, # 每次间隔 10 秒(总兜底时长 10 分钟)
|
||||
)
|
||||
def poll_mediakit_status(self, job_id: str, user_id: str):
|
||||
"""Celery 兜底轮询:TTS 提交 MediaKit 后,由 worker 主动拉取状态直到终态。
|
||||
|
||||
不依赖前端轮询,避免 background task 静默失败导致任务永久卡 running/submitted。
|
||||
"""
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
try:
|
||||
from worker_app.db import SessionLocal # type: ignore
|
||||
except Exception: # noqa: BLE001
|
||||
from app.db import SessionLocal # type: ignore
|
||||
|
||||
db: DBSession = SessionLocal()
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first()
|
||||
if job is None:
|
||||
logger.warning("[lipsync_poll] Job not found: job_id=%s", job_id)
|
||||
return
|
||||
|
||||
# 已终态,不需要再轮询
|
||||
if job.status in ("completed", "failed", "cancelled"):
|
||||
return
|
||||
|
||||
if not job.mediakit_task_id:
|
||||
logger.warning("[lipsync_poll] Job has no mediakit_task_id: job_id=%s status=%s", job_id, job.status)
|
||||
return
|
||||
|
||||
from app.services.lipsync_service import STATUS_COMPLETED as _SC
|
||||
from app.services.lipsync_service import STATUS_FAILED as _SF
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError, get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
try:
|
||||
status_data = client.get_task_status(job.mediakit_task_id)
|
||||
except MediaKitError as exc:
|
||||
logger.warning("[lipsync_poll] 拉取 MediaKit 状态失败,将重试: job_id=%s err=%s", job_id, exc)
|
||||
raise self.retry(exc=exc) from exc
|
||||
|
||||
mk_status = status_data.get("status", "running")
|
||||
|
||||
if mk_status in ("succeeded", _SC):
|
||||
|
||||
svc = LipsyncService(db)
|
||||
result = status_data.get("result", {})
|
||||
job.status = "completed"
|
||||
output_url = result.get("video_url", "")
|
||||
try:
|
||||
job.output_video_url = svc._persist_output_video(output_url, job_id, user_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("[lipsync_poll] 转存 OSS 失败,保留临时 URL: job_id=%s err=%s", job_id, exc)
|
||||
job.output_video_url = output_url
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info("[lipsync_poll] 任务完成: job_id=%s", job_id)
|
||||
elif mk_status in ("failed", "error", _SF):
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(UTC)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info("[lipsync_poll] 任务失败: job_id=%s err=%s", job_id, job.error_message)
|
||||
else:
|
||||
# 中间状态,更新时间戳,继续重试
|
||||
job.updated_at = datetime.now(UTC)
|
||||
if isinstance(mk_status, str) and mk_status:
|
||||
job.status = mk_status
|
||||
db.commit()
|
||||
logger.debug("[lipsync_poll] 任务仍在 %s,继续轮询: job_id=%s", mk_status, job_id)
|
||||
raise self.retry()
|
||||
except Exception as exc:
|
||||
logger.exception("[lipsync_poll] 未预期异常: job_id=%s", job_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise self.retry(exc=exc) from exc
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="lipsync_tts.persist_output_video",
|
||||
max_retries=2,
|
||||
default_retry_delay=30,
|
||||
)
|
||||
def persist_output_video_task(job_id: str, user_id: str, temp_url: str):
|
||||
"""异步转存对口型输出视频到自家 OSS(步骤⑦ — 将同步阻塞挪到后台,加速前端响应)."""
|
||||
|
||||
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(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()
|
||||
|
||||
@@ -40,6 +40,7 @@ async function loginWithRetry(
|
||||
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type TemplateResponse = { id: string }
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string
|
||||
@@ -125,17 +126,28 @@ test.describe("Core generation flow", () => {
|
||||
)
|
||||
.toBe("ready")
|
||||
|
||||
// #1926 P0 fix: POST /templates CRUD endpoint removed; GET /templates
|
||||
// now auto-creates a default template for new users. Use the first one.
|
||||
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
|
||||
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
|
||||
const templatesData = (await templatesResp.json()) as {
|
||||
items: Array<{ id: string }>
|
||||
}
|
||||
expect(Array.isArray(templatesData.items)).toBe(true)
|
||||
expect(templatesData.items.length).toBeGreaterThan(0)
|
||||
const templateId = templatesData.items[0].id
|
||||
expect(templateId).toBeTruthy()
|
||||
// Create an editing template so the generate page has at least one template
|
||||
// (templates are now loaded from API; new users have none by default)
|
||||
const template = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 测试模板 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
})
|
||||
expect(template.status(), await template.text()).toBe(201)
|
||||
const templateData = (await template.json()) as TemplateResponse
|
||||
expect(templateData.id).toBeTruthy()
|
||||
|
||||
// Set auth in localStorage
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -26,10 +26,6 @@ export interface BatchVariantPlansRequest {
|
||||
count: number
|
||||
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
|
||||
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
|
||||
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
|
||||
clips: EditPlanClip[]
|
||||
/** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */
|
||||
voice_duration?: number
|
||||
}
|
||||
|
||||
/** 批量变体计划响应 */
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
/**
|
||||
* 积分 & 会员 API 封装(v2 两档会员制)
|
||||
* 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
PointsBalance,
|
||||
PointsTransaction,
|
||||
PointsTransactionsParams,
|
||||
PointsTransactionsResponse,
|
||||
PointsPackagesResponse,
|
||||
PointsRechargeRequest,
|
||||
PointsOrder,
|
||||
PointsRulesResponse,
|
||||
PointsCheckRequest,
|
||||
PointsCheckResponse,
|
||||
SubscriptionCurrent,
|
||||
SubscribeRequest,
|
||||
SubscriptionPlan,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false)
|
||||
*/
|
||||
export const POINTS_API_MOCK = true
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
const MOCK_BALANCE: PointsBalance = {
|
||||
balance: 580,
|
||||
total_earned: 1200,
|
||||
total_spent: 620,
|
||||
is_member: false,
|
||||
member_type: null,
|
||||
member_expires_at: null,
|
||||
free_clips_used: 1,
|
||||
free_clips_limit: 2,
|
||||
free_clips_remaining: 1,
|
||||
}
|
||||
|
||||
const MOCK_PACKAGES: PointsPackagesResponse = {
|
||||
packages: [
|
||||
{
|
||||
id: "starter_pack",
|
||||
name: "体验包",
|
||||
points: 100,
|
||||
price: 990,
|
||||
discounted_price_for_free: 990,
|
||||
discounted_price_for_monthly: 891,
|
||||
discounted_price_for_quarterly: 861,
|
||||
discounted_price_for_yearly: 792,
|
||||
},
|
||||
{
|
||||
id: "basic_pack",
|
||||
name: "基础包",
|
||||
points: 500,
|
||||
price: 3900,
|
||||
discounted_price_for_free: 3900,
|
||||
discounted_price_for_monthly: 3510,
|
||||
discounted_price_for_quarterly: 3393,
|
||||
discounted_price_for_yearly: 3120,
|
||||
},
|
||||
{
|
||||
id: "pro_pack",
|
||||
name: "专业包",
|
||||
points: 2000,
|
||||
price: 12900,
|
||||
discounted_price_for_free: 12900,
|
||||
discounted_price_for_monthly: 11610,
|
||||
discounted_price_for_quarterly: 11223,
|
||||
discounted_price_for_yearly: 10320,
|
||||
},
|
||||
],
|
||||
user_member_type: "free",
|
||||
unit_price_yuan: 0.1,
|
||||
}
|
||||
|
||||
const MOCK_RULES: PointsRulesResponse = {
|
||||
rules: [
|
||||
{
|
||||
scene_key: "ai_voice",
|
||||
scene_name: "AI 配音",
|
||||
points_per_use: 1,
|
||||
unit: "分钟",
|
||||
description: "每生成 1 分钟配音",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_video",
|
||||
scene_name: "智能混剪",
|
||||
points_per_use: 3,
|
||||
unit: "条",
|
||||
extra_per_30s: 1,
|
||||
description: "每条 ≤30s 3 积分,每加 30s +1",
|
||||
},
|
||||
{
|
||||
scene_key: "ai_digital_human",
|
||||
scene_name: "AI 数字人",
|
||||
points_per_use: 15,
|
||||
unit: "分钟",
|
||||
description: "每生成 1 分钟口播",
|
||||
},
|
||||
{
|
||||
scene_key: "voice_clone_train",
|
||||
scene_name: "声音克隆训练",
|
||||
points_per_use: 0,
|
||||
unit: "次",
|
||||
description: "训练免费",
|
||||
},
|
||||
{ scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" },
|
||||
{ scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" },
|
||||
{ scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" },
|
||||
],
|
||||
free_user_multiplier: 1.15,
|
||||
note: "免费用户消耗 = 会员消耗 × 1.15,向上取整",
|
||||
}
|
||||
|
||||
function genMockTransactions(): PointsTransactionsResponse {
|
||||
const now = new Date()
|
||||
const list = [
|
||||
{
|
||||
src: "ai_voice",
|
||||
name: "AI 配音",
|
||||
type: "spend" as const,
|
||||
amt: 1,
|
||||
desc: "生成配音 1 分钟",
|
||||
days: 0,
|
||||
hours: 0,
|
||||
mins: 30,
|
||||
},
|
||||
{
|
||||
src: "ai_video",
|
||||
name: "智能混剪",
|
||||
type: "spend" as const,
|
||||
amt: 5,
|
||||
desc: "生成 1 分钟视频(基础3+30s*2)",
|
||||
days: 0,
|
||||
hours: 1,
|
||||
mins: 15,
|
||||
},
|
||||
{
|
||||
src: "task_reward",
|
||||
name: "任务奖励",
|
||||
type: "earn" as const,
|
||||
amt: 20,
|
||||
desc: "首次生成视频奖励",
|
||||
days: 1,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "recharge",
|
||||
name: "充值",
|
||||
type: "earn" as const,
|
||||
amt: 500,
|
||||
desc: "基础包充值",
|
||||
days: 15,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "ai_rewrite",
|
||||
name: "AI 改写文案",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "免费用户价(1×1.15 向上取整)",
|
||||
days: 16,
|
||||
hours: 2,
|
||||
mins: 10,
|
||||
},
|
||||
{
|
||||
src: "ai_title",
|
||||
name: "AI 标题生成",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "免费用户价",
|
||||
days: 16,
|
||||
hours: 3,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "douyin_extract",
|
||||
name: "抖音链接提取",
|
||||
type: "spend" as const,
|
||||
amt: 2,
|
||||
desc: "提取 3 分钟文案",
|
||||
days: 18,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "ai_digital_human",
|
||||
name: "AI 数字人",
|
||||
type: "spend" as const,
|
||||
amt: 18,
|
||||
desc: "数字人口播 1 分钟(免费用户价)",
|
||||
days: 20,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
{
|
||||
src: "task_reward",
|
||||
name: "任务奖励",
|
||||
type: "earn" as const,
|
||||
amt: 50,
|
||||
desc: "注册赠送",
|
||||
days: 30,
|
||||
hours: 0,
|
||||
mins: 0,
|
||||
},
|
||||
]
|
||||
let bal = MOCK_BALANCE.balance
|
||||
const items = list
|
||||
.map((t, i) => {
|
||||
const signed = t.type === "earn" ? t.amt : -t.amt
|
||||
const balance_after = bal // 按时间倒序:earliest 先算
|
||||
// adjust running bal
|
||||
bal = t.type === "earn" ? bal - t.amt : bal + t.amt
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - t.days)
|
||||
d.setHours(d.getHours() - t.hours)
|
||||
d.setMinutes(d.getMinutes() - t.mins)
|
||||
return {
|
||||
id: `tx_${i + 1}`,
|
||||
type: t.type,
|
||||
source: t.src as PointsBalance extends never ? never : string,
|
||||
source_name: t.name,
|
||||
amount: t.amt,
|
||||
signed_amount: signed,
|
||||
balance_after,
|
||||
description: t.desc,
|
||||
ref_id: null,
|
||||
created_at: d.toISOString(),
|
||||
}
|
||||
})
|
||||
.reverse()
|
||||
// Rebuild balance_after going forward
|
||||
let running = 50 + 0 // after registration gift
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const it = items[i] as PointsTransaction & { balance_after?: number }
|
||||
if (it.source === "task_reward" && it.description.includes("注册")) running = 50
|
||||
}
|
||||
running = 50
|
||||
const fwd = [...items].reverse() as Array<PointsTransaction & { balance_after?: number }>
|
||||
for (const it of fwd) {
|
||||
running += it.signed_amount
|
||||
it.balance_after = running
|
||||
}
|
||||
return { items: fwd, total: fwd.length, page: 1, page_size: 20 } as PointsTransactionsResponse
|
||||
}
|
||||
|
||||
// ==================== 真实 API ====================
|
||||
|
||||
/** 查询积分余额 + 会员状态 */
|
||||
export async function getPointsBalance(): Promise<PointsBalance> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_BALANCE }), 180))
|
||||
}
|
||||
const res = await apiClient.get("/points/balance")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分流水(分页) */
|
||||
export async function getPointsTransactions(
|
||||
params: PointsTransactionsParams = {},
|
||||
): Promise<PointsTransactionsResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r(genMockTransactions()), 200))
|
||||
}
|
||||
const res = await apiClient.get("/points/transactions", { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分包列表 */
|
||||
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_PACKAGES }), 150))
|
||||
}
|
||||
const res = await apiClient.get("/points/packages")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 创建积分充值订单(mock 阶段返回 "pending" 订单,前端弹"支付开发中") */
|
||||
export async function createPointsOrder(req: PointsRechargeRequest): Promise<PointsOrder> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const pkg = MOCK_PACKAGES.packages.find((p) => p.id === req.package_id)
|
||||
const mt = MOCK_PACKAGES.user_member_type
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const discountKey = `discounted_price_for_${mt}` as DiscountKey
|
||||
const price = pkg?.[discountKey] ?? pkg?.price ?? 0
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
id: `mock_order_${Date.now()}`,
|
||||
package_id: req.package_id,
|
||||
package_name: pkg?.name ?? "",
|
||||
points_amount: pkg?.points ?? 0,
|
||||
price_cents: price,
|
||||
original_price_cents: pkg?.price ?? 0,
|
||||
discount: price / (pkg?.price || 1),
|
||||
currency: "CNY",
|
||||
status: "pending",
|
||||
payment_method: null,
|
||||
payment_id: null,
|
||||
paid_at: null,
|
||||
expire_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
300,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/points/recharge", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 查询积分消耗规则 */
|
||||
export async function getPointsRules(): Promise<PointsRulesResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) => setTimeout(() => r({ ...MOCK_RULES }), 120))
|
||||
}
|
||||
const res = await apiClient.get("/points/rules")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 消费前余额预检查 */
|
||||
export async function checkPoints(req: PointsCheckRequest): Promise<PointsCheckResponse> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const rule = MOCK_RULES.rules.find((r) => r.scene_key === req.scene_key)
|
||||
if (!rule) {
|
||||
return {
|
||||
allowed: false,
|
||||
required_points: 0,
|
||||
current_balance: MOCK_BALANCE.balance,
|
||||
remaining_after: MOCK_BALANCE.balance,
|
||||
is_free_quota: false,
|
||||
code: "SCENE_NOT_FOUND",
|
||||
message: "未知场景",
|
||||
recharge_url: "/app/points",
|
||||
}
|
||||
}
|
||||
const units = req.units ?? 1
|
||||
let base = rule.points_per_use * units
|
||||
if (rule.extra_per_30s && units > 1) {
|
||||
// ai_video extra_per_30s: base already covers first 30s, subtract
|
||||
base = rule.points_per_use + rule.extra_per_30s * (units - 1)
|
||||
}
|
||||
const isFree =
|
||||
MOCK_BALANCE.is_member === false &&
|
||||
req.scene_key === "ai_video" &&
|
||||
(MOCK_BALANCE.free_clips_remaining ?? 0) > 0
|
||||
const needed = isFree
|
||||
? 0
|
||||
: MOCK_BALANCE.is_member
|
||||
? base
|
||||
: Math.ceil(base * MOCK_RULES.free_user_multiplier)
|
||||
const allowed = isFree || MOCK_BALANCE.balance >= needed
|
||||
return {
|
||||
allowed,
|
||||
required_points: needed,
|
||||
current_balance: MOCK_BALANCE.balance,
|
||||
remaining_after: MOCK_BALANCE.balance - needed,
|
||||
is_free_quota: isFree,
|
||||
code: allowed ? undefined : "INSUFFICIENT_POINTS",
|
||||
message: allowed
|
||||
? undefined
|
||||
: `积分不足,需要 ${needed} 积分,当前余额 ${MOCK_BALANCE.balance}`,
|
||||
recharge_url: "/app/points",
|
||||
}
|
||||
}
|
||||
const res = await apiClient.post("/points/check", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ==================== 订阅相关 ====================
|
||||
|
||||
/** 订阅套餐(定价常量,前端硬编码;折扣由后端会员类型决定) */
|
||||
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
|
||||
{
|
||||
id: "monthly",
|
||||
name: "月卡",
|
||||
price_cents: 1990,
|
||||
price_yuan: 19.9,
|
||||
per_month_yuan: 19.9,
|
||||
billing_label: "/月",
|
||||
},
|
||||
{
|
||||
id: "quarterly",
|
||||
name: "季卡",
|
||||
price_cents: 3990,
|
||||
price_yuan: 39.9,
|
||||
per_month_yuan: 13.3,
|
||||
savings_percent: 33,
|
||||
recommended: true,
|
||||
billing_label: "/季",
|
||||
},
|
||||
{
|
||||
id: "yearly",
|
||||
name: "年卡",
|
||||
price_cents: 15900,
|
||||
price_yuan: 159,
|
||||
per_month_yuan: 13.25,
|
||||
savings_percent: 33,
|
||||
billing_label: "/年",
|
||||
},
|
||||
]
|
||||
|
||||
/** 查询当前订阅 */
|
||||
export async function getCurrentSubscription(): Promise<SubscriptionCurrent> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
is_member: MOCK_BALANCE.is_member,
|
||||
member_type: MOCK_BALANCE.member_type,
|
||||
member_type_name: MOCK_BALANCE.is_member ? "付费会员" : "免费会员",
|
||||
status: MOCK_BALANCE.is_member ? "active" : "none",
|
||||
current_period_start: null,
|
||||
current_period_end: MOCK_BALANCE.member_expires_at,
|
||||
auto_renew: false,
|
||||
points_discount: MOCK_BALANCE.is_member ? 0.9 : 1.0,
|
||||
}),
|
||||
150,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.get("/subscription/current")
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 开通/续费会员 */
|
||||
export async function subscribe(req: SubscribeRequest): Promise<PointsOrder> {
|
||||
if (POINTS_API_MOCK) {
|
||||
const plan = SUBSCRIPTION_PLANS.find((p) => p.id === req.member_type)!
|
||||
return new Promise((r) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
r({
|
||||
id: `mock_sub_${Date.now()}`,
|
||||
package_id: plan.id,
|
||||
package_name: plan.name,
|
||||
points_amount: 0,
|
||||
price_cents: plan.price_cents,
|
||||
original_price_cents: plan.price_cents,
|
||||
discount: 1,
|
||||
currency: "CNY",
|
||||
status: "pending",
|
||||
payment_method: null,
|
||||
payment_id: null,
|
||||
paid_at: null,
|
||||
expire_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
300,
|
||||
),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/subscription/subscribe", req)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 取消自动续费 */
|
||||
export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> {
|
||||
if (POINTS_API_MOCK) {
|
||||
return new Promise((r) =>
|
||||
setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.post("/subscription/cancel")
|
||||
return res.data
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* 积分 & 会员系统 API 类型定义(v2 两档会员制)
|
||||
* 接口契约对齐后端设计文档 membership-points-design-v2.md
|
||||
*/
|
||||
|
||||
/** 会员类型 */
|
||||
export type MemberType = "free" | "monthly" | "quarterly" | "yearly"
|
||||
|
||||
/** 积分流水类型 */
|
||||
export type PointsTxType = "earn" | "spend" | "refund"
|
||||
|
||||
/** 积分来源/消耗场景 */
|
||||
export type PointsSource =
|
||||
| "recharge" // 充值
|
||||
| "task_reward" // 任务奖励
|
||||
| "ai_voice" // AI 配音
|
||||
| "ai_digital_human" // AI 数字人
|
||||
| "ai_video" // 智能混剪
|
||||
| "voice_clone_train" // 声音克隆训练
|
||||
| "voice_clone_synth" // 声音克隆合成
|
||||
| "douyin_extract" // 抖音链接提取
|
||||
| "ai_rewrite" // AI 改写文案
|
||||
| "ai_title" // AI 标题生成
|
||||
| "ai_cover" // AI 封面生成
|
||||
| "subscription_bonus" // 会员赠送
|
||||
| "admin_adjust" // 管理员调整
|
||||
| "refund" // 失败退还
|
||||
|
||||
/** 会员 & 积分余额响应 */
|
||||
export interface PointsBalance {
|
||||
/** 当前可用积分 */
|
||||
balance: number
|
||||
/** 累计获得 */
|
||||
total_earned: number
|
||||
/** 累计消耗 */
|
||||
total_spent: number
|
||||
/** 是否付费会员(free 用户为 false) */
|
||||
is_member: boolean
|
||||
/** 会员类型:monthly / quarterly / yearly;free 用户为 null */
|
||||
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
|
||||
/** 会员到期时间 ISO 字符串 */
|
||||
member_expires_at: string | null
|
||||
/** 今日免费混剪已用次数 */
|
||||
free_clips_used?: number
|
||||
/** 今日免费混剪额度上限 */
|
||||
free_clips_limit?: number
|
||||
/** 今日免费混剪剩余 */
|
||||
free_clips_remaining?: number
|
||||
}
|
||||
|
||||
/** 积分流水记录 */
|
||||
export interface PointsTransaction {
|
||||
id: string
|
||||
/** earn / spend / refund */
|
||||
type: PointsTxType
|
||||
/** 来源场景 */
|
||||
source: PointsSource
|
||||
/** 场景中文名称 */
|
||||
source_name: string
|
||||
/** 变动数量(正数) */
|
||||
amount: number
|
||||
/** 带符号的变动数(收入+,支出-) */
|
||||
signed_amount: number
|
||||
/** 变动后余额 */
|
||||
balance_after: number
|
||||
/** 备注描述 */
|
||||
description: string
|
||||
/** 关联业务 ID */
|
||||
ref_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 积分流水分页响应 */
|
||||
export interface PointsTransactionsResponse {
|
||||
items: PointsTransaction[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 积分流水查询参数 */
|
||||
export interface PointsTransactionsParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
type?: PointsTxType
|
||||
source?: PointsSource
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
/** 积分包 */
|
||||
export interface PointsPackage {
|
||||
id: "starter_pack" | "basic_pack" | "pro_pack" | string
|
||||
/** 中文名称 */
|
||||
name: string
|
||||
/** 积分数量 */
|
||||
points: number
|
||||
/** 原价(分) */
|
||||
price: number
|
||||
/** 各会员类型折扣价(分) */
|
||||
discounted_price_for_free: number
|
||||
discounted_price_for_monthly: number
|
||||
discounted_price_for_quarterly: number
|
||||
discounted_price_for_yearly: number
|
||||
}
|
||||
|
||||
/** 积分包列表响应 */
|
||||
export interface PointsPackagesResponse {
|
||||
packages: PointsPackage[]
|
||||
/** 当前用户会员类型,用于前端计算折后价 */
|
||||
user_member_type: MemberType
|
||||
/** 积分单价(元/积分,按会员价计) */
|
||||
unit_price_yuan: number
|
||||
}
|
||||
|
||||
/** 创建充值订单请求 */
|
||||
export interface PointsRechargeRequest {
|
||||
package_id: string
|
||||
payment_method?: "wechat_pay" | "alipay"
|
||||
}
|
||||
|
||||
/** 订单状态 */
|
||||
export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired"
|
||||
|
||||
/** 充值订单响应 */
|
||||
export interface PointsOrder {
|
||||
id: string
|
||||
package_id: string
|
||||
package_name: string
|
||||
points_amount: number
|
||||
price_cents: number
|
||||
original_price_cents: number
|
||||
discount: number
|
||||
currency: "CNY"
|
||||
status: OrderStatus
|
||||
payment_method: string | null
|
||||
payment_id: string | null
|
||||
paid_at: string | null
|
||||
expire_at: string | null
|
||||
created_at: string
|
||||
/** 微信/支付宝支付参数(mock 阶段前端自行处理) */
|
||||
pay_params?: Record<string, string>
|
||||
}
|
||||
|
||||
/** 订阅套餐(月/季/年) */
|
||||
export interface SubscriptionPlan {
|
||||
id: "monthly" | "quarterly" | "yearly"
|
||||
name: string
|
||||
price_cents: number
|
||||
price_yuan: number
|
||||
per_month_yuan: number
|
||||
savings_percent?: number
|
||||
recommended?: boolean
|
||||
billing_label: string
|
||||
}
|
||||
|
||||
/** 当前订阅详情 */
|
||||
export interface SubscriptionCurrent {
|
||||
is_member: boolean
|
||||
member_type: Extract<MemberType, "monthly" | "quarterly" | "yearly"> | null
|
||||
member_type_name: string
|
||||
status: "active" | "expired" | "cancelled" | "none"
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
auto_renew: boolean
|
||||
/** 订阅会员对应的积分折扣 */
|
||||
points_discount: number
|
||||
}
|
||||
|
||||
/** 开通/续费订阅请求 */
|
||||
export interface SubscribeRequest {
|
||||
member_type: "monthly" | "quarterly" | "yearly"
|
||||
payment_method?: "wechat_pay" | "alipay"
|
||||
}
|
||||
|
||||
/** 积分消耗规则 */
|
||||
export interface PointsRule {
|
||||
scene_key: PointsSource
|
||||
scene_name: string
|
||||
/** 每次消耗基础积分(会员价) */
|
||||
points_per_use: number
|
||||
/** 计量单位:条/分钟/次/张 */
|
||||
unit: string
|
||||
/** 额外每 30s 加积分(ai_video 用) */
|
||||
extra_per_30s?: number
|
||||
/** 说明文案 */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface PointsRulesResponse {
|
||||
rules: PointsRule[]
|
||||
/** 免费用户消耗倍率 */
|
||||
free_user_multiplier: number
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 消费前余额检查请求 */
|
||||
export interface PointsCheckRequest {
|
||||
scene_key: PointsSource
|
||||
/** 单位数量(时长/条数),默认 1 */
|
||||
units?: number
|
||||
}
|
||||
|
||||
/** 消费前余额检查响应 */
|
||||
export interface PointsCheckResponse {
|
||||
allowed: boolean
|
||||
required_points: number
|
||||
current_balance: number
|
||||
remaining_after: number
|
||||
/** 是否走免费额度(混剪场景) */
|
||||
is_free_quota: boolean
|
||||
/** 拒绝原因代码 */
|
||||
code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND"
|
||||
message?: string
|
||||
/** 充值页跳转 URL */
|
||||
recharge_url?: string
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API 函数
|
||||
* 后端实际接口:/videos(分页:page/page_size,返回 {items, total, page, page_size})
|
||||
* 后端实际接口:/videos
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
@@ -12,39 +12,16 @@ import type {
|
||||
} from "./types"
|
||||
import { mapVideoToProductItem } from "./utils"
|
||||
|
||||
/** 分页列表响应(前端消费用) */
|
||||
export interface ProductListResult {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成品列表(分页)
|
||||
* @param params 分页与筛选参数:page 默认 1,page_size 默认 20
|
||||
*/
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductListResult> => {
|
||||
const response = await apiClient.get("/videos", {
|
||||
params: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
const data = response.data as {
|
||||
items?: VideoItem[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
const items: VideoItem[] = Array.isArray(data?.items) ? data.items : []
|
||||
return {
|
||||
items: items.map(mapVideoToProductItem),
|
||||
total: data.total ?? items.length,
|
||||
page: data.page ?? params?.page ?? 1,
|
||||
page_size: data.page_size ?? params?.page_size ?? 20,
|
||||
}
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/videos", { params })
|
||||
const data = response.data
|
||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
return videos.map(mapVideoToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./scripts"
|
||||
export * from "./types"
|
||||
export * from "./scripts-ai"
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 文案库 AI 能力 API(#1893)
|
||||
* 三个端点均走真实后端,不参与 SCRIPTS_API_MOCK 开关。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
|
||||
/** ── 1. 从抖音视频提取文案(下载 + ASR) */
|
||||
export interface ExtractFromDouyinRequest {
|
||||
url: string
|
||||
}
|
||||
export interface ExtractFromDouyinResponse {
|
||||
text: string
|
||||
duration_seconds?: number
|
||||
source_url?: string
|
||||
}
|
||||
|
||||
export async function extractScriptFromDouyin(
|
||||
body: ExtractFromDouyinRequest,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<ExtractFromDouyinResponse> {
|
||||
const res = await apiClient.post<ExtractFromDouyinResponse>(
|
||||
"/scripts/extract-from-douyin",
|
||||
body,
|
||||
{
|
||||
// ASR 可能较慢,给足超时
|
||||
timeout: 60_000,
|
||||
signal: opts?.signal,
|
||||
},
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** ── 2. AI 改写文案 */
|
||||
export type RewriteStyle = "口语化" | "正式" | "活泼" | "治愈" | "励志"
|
||||
|
||||
export const REWRITE_STYLE_OPTIONS: { value: RewriteStyle; label: string }[] = [
|
||||
{ value: "口语化", label: "口语化" },
|
||||
{ value: "正式", label: "正式" },
|
||||
{ value: "活泼", label: "活泼" },
|
||||
{ value: "治愈", label: "治愈" },
|
||||
{ value: "励志", label: "励志" },
|
||||
]
|
||||
|
||||
export interface AiRewriteRequest {
|
||||
content: string
|
||||
style?: RewriteStyle
|
||||
}
|
||||
export interface AiRewriteResponse {
|
||||
original: string
|
||||
rewritten: string
|
||||
style: RewriteStyle
|
||||
}
|
||||
|
||||
export async function aiRewriteScript(
|
||||
body: AiRewriteRequest,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<AiRewriteResponse> {
|
||||
const res = await apiClient.post<AiRewriteResponse>("/scripts/ai-rewrite", body, {
|
||||
timeout: 60_000,
|
||||
signal: opts?.signal,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** ── 3. AI 生成标题 */
|
||||
export interface AiGenerateTitlesRequest {
|
||||
content: string
|
||||
count?: number
|
||||
}
|
||||
export interface AiGenerateTitlesResponse {
|
||||
titles: string[]
|
||||
}
|
||||
|
||||
export async function aiGenerateTitles(
|
||||
body: AiGenerateTitlesRequest,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<AiGenerateTitlesResponse> {
|
||||
const res = await apiClient.post<AiGenerateTitlesResponse>(
|
||||
"/scripts/ai-generate-titles",
|
||||
{ content: body.content, count: body.count ?? 3 },
|
||||
{
|
||||
timeout: 30_000,
|
||||
signal: opts?.signal,
|
||||
},
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -1,195 +1,37 @@
|
||||
/**
|
||||
* 文案库 API(#1811 v2)
|
||||
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
|
||||
* 文案库 API
|
||||
* 对接后端 /api/v1/scripts(CRUD + 列表解包)
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
ScriptItem,
|
||||
ScriptListParams,
|
||||
ScriptListResponse,
|
||||
ScriptUpsertRequest,
|
||||
ScriptCategory,
|
||||
CreateScriptRequest,
|
||||
UpdateScriptRequest,
|
||||
} from "./types"
|
||||
|
||||
/** 是否启用 mock(后端合入后改为 false) */
|
||||
export const SCRIPTS_API_MOCK = true
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
|
||||
const MOCK_CATEGORIES: ScriptCategory[] = [
|
||||
"promo",
|
||||
"vlog",
|
||||
"knowledge",
|
||||
"story",
|
||||
"emotion",
|
||||
"other",
|
||||
]
|
||||
|
||||
const SAMPLE_TITLES: Record<ScriptCategory, string[]> = {
|
||||
promo: ["新品上市限时特惠", "618大促开场", "品牌故事宣传片"],
|
||||
vlog: ["周末citywalk记录", "打工人的一天", "探店vlog"],
|
||||
knowledge: ["3分钟学会XX", "冷知识科普", "行业深度解读"],
|
||||
story: ["励志小故事", "情感故事一则", "反转剧情"],
|
||||
emotion: ["深夜emo时刻", "治愈系文案", "朋友圈金句"],
|
||||
other: ["通用开场白", "节日祝福", "万能结尾"],
|
||||
}
|
||||
|
||||
const SAMPLE_TAGS = ["热门", "新品", "节日", "情感", "干货", "搞笑", "治愈", "励志"]
|
||||
|
||||
function genMockScripts(): ScriptItem[] {
|
||||
const items: ScriptItem[] = []
|
||||
const now = Date.now()
|
||||
let idx = 0
|
||||
for (const cat of MOCK_CATEGORIES) {
|
||||
const titles = SAMPLE_TITLES[cat]
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
idx++
|
||||
const title = titles[i]
|
||||
const content = `这是一条【${cat}】分类下的示例文案,标题为「${title}」。\n\n正文可以包含多段落,每段对应一个片段(segments)。\n\n此为 mock 数据,后端接口就绪后会自动切换为真实数据。`
|
||||
const segments = content.split(/\n\n+/).filter(Boolean)
|
||||
const tagCount = 1 + (idx % 3)
|
||||
const tags: string[] = []
|
||||
for (let t = 0; t < tagCount; t++) {
|
||||
tags.push(SAMPLE_TAGS[(idx + t) % SAMPLE_TAGS.length])
|
||||
}
|
||||
items.push({
|
||||
id: `mock_${idx}`,
|
||||
title,
|
||||
content,
|
||||
segments,
|
||||
tags,
|
||||
title_text: title,
|
||||
title_category: cat,
|
||||
title_config: {
|
||||
font: "default",
|
||||
color: "#ffffff",
|
||||
stroke: "#000000",
|
||||
position: (["top", "center", "bottom"] as const)[idx % 3],
|
||||
size: 48,
|
||||
bold: idx % 2 === 0,
|
||||
italic: false,
|
||||
},
|
||||
char_count: content.length,
|
||||
use_count: Math.floor(Math.random() * 50),
|
||||
created_at: new Date(now - idx * 86400_000 * 2).toISOString(),
|
||||
updated_at: new Date(now - idx * 86400_000).toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const MOCK_SCRIPTS = genMockScripts()
|
||||
|
||||
// ==================== 真实 API ====================
|
||||
|
||||
/** 获取文案列表(支持分页/搜索/分类) */
|
||||
export async function getScripts(params: ScriptListParams = {}): Promise<ScriptListResponse> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.page_size ?? 20
|
||||
let items = [...MOCK_SCRIPTS]
|
||||
if (params.keyword) {
|
||||
const kw = params.keyword.toLowerCase()
|
||||
items = items.filter(
|
||||
(s) => s.title.toLowerCase().includes(kw) || s.content.toLowerCase().includes(kw),
|
||||
)
|
||||
}
|
||||
if (params.category && params.category !== "all") {
|
||||
items = items.filter((s) => s.title_category === params.category)
|
||||
}
|
||||
if (params.tag) {
|
||||
items = items.filter((s) => s.tags?.includes(params.tag as string))
|
||||
}
|
||||
const total = items.length
|
||||
const start = (page - 1) * pageSize
|
||||
const pageItems = items.slice(start, start + pageSize)
|
||||
return new Promise((r) =>
|
||||
setTimeout(() => r({ items: pageItems, total, page, page_size: pageSize }), 200),
|
||||
)
|
||||
}
|
||||
const res = await apiClient.get<ScriptListResponse>("/scripts", { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/** 获取单条文案详情 */
|
||||
export async function getScript(id: string): Promise<ScriptItem> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const item = MOCK_SCRIPTS.find((s) => s.id === id)
|
||||
return new Promise((r) => setTimeout(() => r(item ?? MOCK_SCRIPTS[0]), 120))
|
||||
}
|
||||
const res = await apiClient.get<ScriptItem>(`/scripts/${id}`)
|
||||
return res.data
|
||||
/** 获取文案列表 — 必须解包 items(后端返回 {items,total})*/
|
||||
export const getScripts = async (): Promise<ScriptItem[]> => {
|
||||
const response = await apiClient.get<ScriptListResponse | ScriptItem[]>("/scripts")
|
||||
const data = response.data as unknown
|
||||
if (Array.isArray(data)) return data
|
||||
const items = (data as { items?: ScriptItem[] })?.items
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
/** 新建文案 */
|
||||
export async function createScript(data: ScriptUpsertRequest): Promise<ScriptItem> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const segments =
|
||||
data.segments && data.segments.length > 0
|
||||
? data.segments
|
||||
: data.content.split(/\n\n+/).filter(Boolean)
|
||||
const item: ScriptItem = {
|
||||
id: `mock_${Date.now()}`,
|
||||
...data,
|
||||
segments,
|
||||
char_count: data.content.length,
|
||||
use_count: 0,
|
||||
tags: data.tags ?? [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
MOCK_SCRIPTS.unshift(item)
|
||||
return new Promise((r) => setTimeout(() => r(item), 200))
|
||||
}
|
||||
const res = await apiClient.post<ScriptItem>("/scripts", data)
|
||||
return res.data
|
||||
export const createScript = async (data: CreateScriptRequest): Promise<ScriptItem> => {
|
||||
const response = await apiClient.post<ScriptItem>("/scripts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新文案 */
|
||||
export async function updateScript(id: string, data: ScriptUpsertRequest): Promise<ScriptItem> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id)
|
||||
const segments =
|
||||
data.segments && data.segments.length > 0
|
||||
? data.segments
|
||||
: data.content.split(/\n\n+/).filter(Boolean)
|
||||
const updated: ScriptItem = {
|
||||
...MOCK_SCRIPTS[idx],
|
||||
...data,
|
||||
segments,
|
||||
char_count: data.content.length,
|
||||
tags: data.tags ?? MOCK_SCRIPTS[idx]?.tags ?? [],
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
if (idx >= 0) MOCK_SCRIPTS[idx] = updated
|
||||
return new Promise((r) => setTimeout(() => r(updated), 200))
|
||||
}
|
||||
const res = await apiClient.put<ScriptItem>(`/scripts/${id}`, data)
|
||||
return res.data
|
||||
export const updateScript = async (id: string, data: UpdateScriptRequest): Promise<ScriptItem> => {
|
||||
const response = await apiClient.put<ScriptItem>(`/scripts/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除文案 */
|
||||
export async function deleteScript(id: string): Promise<void> {
|
||||
if (SCRIPTS_API_MOCK) {
|
||||
const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id)
|
||||
if (idx >= 0) MOCK_SCRIPTS.splice(idx, 1)
|
||||
return new Promise((r) => setTimeout(r, 150))
|
||||
}
|
||||
export const deleteScript = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
|
||||
/** 复制文案(返回新副本) */
|
||||
export async function duplicateScript(id: string): Promise<ScriptItem> {
|
||||
const orig = await getScript(id)
|
||||
const copy = await createScript({
|
||||
title: `${orig.title}(副本)`,
|
||||
content: orig.content,
|
||||
segments: orig.segments,
|
||||
tags: orig.tags,
|
||||
title_text: orig.title_text,
|
||||
title_category: orig.title_category,
|
||||
title_config: orig.title_config,
|
||||
})
|
||||
return copy
|
||||
}
|
||||
|
||||
@@ -1,97 +1,24 @@
|
||||
/**
|
||||
* 文案库 API — 类型定义(#1811 v2 完整字段版)
|
||||
* 字段对齐后端契约:title / content / segments / tags / title_text / title_category / title_config
|
||||
* 同时保留 char_count / use_count / timestamps 等展示字段
|
||||
* 文案库 API — 类型定义
|
||||
* 对接后端 /api/v1/scripts
|
||||
*/
|
||||
|
||||
/** 标题配置(字体、颜色、位置、字号) */
|
||||
export interface ScriptTitleConfig {
|
||||
/** 字体预设 key,如 "default" / "bold" / "handwritten" */
|
||||
font?: string
|
||||
/** 文字颜色(CSS color) */
|
||||
color?: string
|
||||
/** 描边色 */
|
||||
stroke?: string
|
||||
/** 位置:top / center / bottom */
|
||||
position?: "top" | "center" | "bottom"
|
||||
/** 字号(px) */
|
||||
size?: number
|
||||
/** 是否加粗 */
|
||||
bold?: boolean
|
||||
/** 是否斜体 */
|
||||
italic?: boolean
|
||||
}
|
||||
|
||||
/** 文案分类(可枚举,也支持自定义) */
|
||||
export type ScriptCategory =
|
||||
| "promo" // 营销推广
|
||||
| "vlog" // Vlog/日常
|
||||
| "knowledge" // 知识科普
|
||||
| "story" // 故事剧情
|
||||
| "emotion" // 情感语录
|
||||
| "other" // 其他
|
||||
|
||||
export const SCRIPT_CATEGORY_LABEL: Record<ScriptCategory, string> = {
|
||||
promo: "营销推广",
|
||||
vlog: "Vlog 日常",
|
||||
knowledge: "知识科普",
|
||||
story: "故事剧情",
|
||||
emotion: "情感语录",
|
||||
other: "其他",
|
||||
}
|
||||
|
||||
/** 文案条目 */
|
||||
export interface ScriptItem {
|
||||
id: string
|
||||
/** 名称(标题) */
|
||||
title: string
|
||||
/** 正文 */
|
||||
content: string
|
||||
/** 分段(按段落切分,供后端/生成步骤逐段使用) */
|
||||
segments?: string[]
|
||||
/** 标签(逗号分隔或数组,列表展示用 Tag) */
|
||||
tags?: string[]
|
||||
/** 配套标题文本(选填,"使用"跳创作页时会预填到标题) */
|
||||
title_text?: string
|
||||
/** 分类 */
|
||||
title_category?: ScriptCategory
|
||||
/** 标题样式配置(字体/颜色/位置/字号) */
|
||||
title_config?: ScriptTitleConfig
|
||||
/** 正文字符数(后端返回,前端用于展示) */
|
||||
char_count?: number
|
||||
/** 使用次数(后端返回) */
|
||||
use_count?: number
|
||||
char_count: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数(支持搜索/分类/分页) */
|
||||
export interface ScriptListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
/** 标题/正文模糊搜索 */
|
||||
keyword?: string
|
||||
/** 分类筛选 */
|
||||
category?: ScriptCategory | "all"
|
||||
/** 标签筛选 */
|
||||
tag?: string
|
||||
}
|
||||
|
||||
/** 列表响应 */
|
||||
export interface ScriptListResponse {
|
||||
items: ScriptItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 创建/编辑请求 */
|
||||
export interface ScriptUpsertRequest {
|
||||
export interface CreateScriptRequest {
|
||||
title: string
|
||||
content: string
|
||||
segments?: string[]
|
||||
tags?: string[]
|
||||
title_text?: string
|
||||
title_category?: ScriptCategory
|
||||
title_config?: ScriptTitleConfig
|
||||
}
|
||||
|
||||
export type UpdateScriptRequest = Partial<CreateScriptRequest>
|
||||
|
||||
@@ -85,13 +85,9 @@ export async function batchDeleteEditPlanClips(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 从素材批量创建片段(追加到时间线末尾)。
|
||||
* #1921 修复:templateId 为空时调用新端点 POST /clips/from-assets,避免拼出双斜杠
|
||||
* `/templates//editor/clips/from-assets` 导致 404;有 templateId 时保持原路径向后兼容。
|
||||
*/
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
export async function createClipsFromAssets(
|
||||
templateId: string | undefined | null,
|
||||
templateId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
@@ -104,15 +100,16 @@ export async function createClipsFromAssets(
|
||||
if (requiredClipsCount !== undefined) {
|
||||
body.required_clips_count = requiredClipsCount
|
||||
}
|
||||
// 新端点(#1921):templateId 为空时,body 不传 template_id,由后端兜底创建默认模板
|
||||
const hasTid = !!templateId
|
||||
const url = hasTid ? `/templates/${templateId}/editor/clips/from-assets` : "/clips/from-assets"
|
||||
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(url, body, {
|
||||
timeout: 60000,
|
||||
signal: opts?.signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
|
||||
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
})
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
body,
|
||||
{
|
||||
timeout: 60000,
|
||||
signal: opts?.signal,
|
||||
// _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777)
|
||||
...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface TTSSynthesizeRequest {
|
||||
voice_id?: string
|
||||
output_name?: string
|
||||
language?: string
|
||||
emotion?: string
|
||||
speed?: number
|
||||
voice_model?: string
|
||||
voice_clone_profile_id?: string
|
||||
@@ -104,8 +103,7 @@ export interface TTSPreviewRequest {
|
||||
voice_id: string
|
||||
speed?: number
|
||||
pitch?: number
|
||||
language?: string
|
||||
emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签)
|
||||
emotion?: string // 情绪参数:natural/excited/calm/friendly
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
|
||||
@@ -96,12 +96,9 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
|
||||
export const getVoiceClonePreview = async (
|
||||
cloneId: string,
|
||||
text?: string,
|
||||
options?: { speed?: number; emotion?: string },
|
||||
): Promise<VoiceClonePreviewResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (text) searchParams.set("text", text)
|
||||
if (options?.speed !== undefined) searchParams.set("speed", String(options.speed))
|
||||
if (options?.emotion) searchParams.set("emotion", options.emotion)
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<VoiceClonePreviewResponse>(
|
||||
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/* Header 积分徽章 & Popover 样式 */
|
||||
.xx-points-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-tertiary, #fafafa);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.xx-points-badge:hover {
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
color: var(--primary-color, #6366f1);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-points-badge.zero {
|
||||
color: #faad14;
|
||||
border-color: #ffe58f;
|
||||
background: #fffbe6;
|
||||
}
|
||||
|
||||
.xx-points-badge.low {
|
||||
color: #fa8c16;
|
||||
border-color: #ffd591;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.xx-points-badge-icon {
|
||||
font-size: 14px;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.xx-points-badge.zero .xx-points-badge-icon,
|
||||
.xx-points-badge.low .xx-points-badge-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.xx-points-badge-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Popover */
|
||||
.xx-points-popover {
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-points-popover-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.xx-points-popover-balance {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-points-icon {
|
||||
color: #8b5cf6;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.xx-points-value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #111827);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xx-points-unit {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-points-warn {
|
||||
margin: 0 !important;
|
||||
padding: 8px 10px;
|
||||
background: #fffbe6;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-points-expire {
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-points-popover-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary, #fafafa);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.xx-points-stat-val {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Header 右上角的积分徽章(💎 580)
|
||||
* - 余额 <10 时橙色告警
|
||||
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
|
||||
*/
|
||||
import React, { useEffect } from "react"
|
||||
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
CrownOutlined,
|
||||
RightOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./PointsBadge.css"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
const PointsBadge: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { balance, init, loading } = usePointsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!balance) init()
|
||||
}, [balance, init])
|
||||
|
||||
const bal = balance?.balance ?? 0
|
||||
const lowBalance = bal > 0 && bal < 10
|
||||
const zero = bal === 0
|
||||
const isMember = !!balance?.is_member
|
||||
const memberLabel = isMember
|
||||
? balance?.member_type === "yearly"
|
||||
? "年卡会员"
|
||||
: balance?.member_type === "quarterly"
|
||||
? "季卡会员"
|
||||
: balance?.member_type === "monthly"
|
||||
? "月卡会员"
|
||||
: "付费会员"
|
||||
: "免费会员"
|
||||
|
||||
const popContent = (
|
||||
<div className="xx-points-popover">
|
||||
<div className="xx-points-popover-top">
|
||||
<div className="xx-points-popover-balance">
|
||||
<ThunderboltOutlined className="xx-points-icon" />
|
||||
<span className="xx-points-value">{loading ? "…" : bal}</span>
|
||||
<span className="xx-points-unit">积分</span>
|
||||
</div>
|
||||
<Tag color={isMember ? "gold" : "default"} icon={<CrownOutlined />}>
|
||||
{memberLabel}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{(zero || lowBalance) && (
|
||||
<Paragraph type="warning" className="xx-points-warn">
|
||||
<WarningOutlined /> 积分不足,充值后可继续使用 AI 功能
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{balance?.member_expires_at && (
|
||||
<Text type="secondary" className="xx-points-expire">
|
||||
会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="xx-points-popover-stats">
|
||||
<div>
|
||||
<Text type="secondary">累计获得</Text>
|
||||
<div className="xx-points-stat-val">+{balance?.total_earned ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">累计消耗</Text>
|
||||
<div className="xx-points-stat-val">-{balance?.total_spent ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={8}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
block
|
||||
onClick={() => navigate("/app/points")}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
<Button block onClick={() => navigate("/app/points/transactions")}>
|
||||
积分明细
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
{!isMember && (
|
||||
<Button block type="link" onClick={() => navigate("/app/subscription")}>
|
||||
<CrownOutlined /> 升级会员享折扣
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover content={popContent} placement="bottomRight" trigger="click" arrow={false}>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-points-badge ${zero ? "zero" : lowBalance ? "low" : ""}`}
|
||||
>
|
||||
<Badge dot={zero} color={zero ? "#faad14" : undefined}>
|
||||
<ThunderboltOutlined className="xx-points-badge-icon" />
|
||||
</Badge>
|
||||
<span className="xx-points-badge-val">{loading ? "…" : bal}</span>
|
||||
</button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsBadge
|
||||
@@ -1,67 +0,0 @@
|
||||
/* 功能按钮旁的积分消耗提示 */
|
||||
.xx-points-cost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-points-cost.free,
|
||||
.xx-points-cost.free-quota {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.xx-points-cost.insufficient {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-points-gem {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-points-num {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.xx-points-unit-label {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
|
||||
.xx-points-tag-free {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-points-free-user-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.xx-points-desc {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.xx-points-warn-icon {
|
||||
color: #ef4444;
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* 功能操作按钮旁的"消耗积分"提示
|
||||
* 例:[生成配音] 💎 -1 积分
|
||||
* - 根据 scene_key 自动读取规则
|
||||
* - 免费用户自动计算 ×1.15 向上取整
|
||||
* - 余额不足时显示红色告警 + 充值提示
|
||||
*
|
||||
* 使用:<PointsCost scene="ai_voice" units={1} />
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { WarningOutlined } from "@ant-design/icons"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import type { PointsSource } from "@/api/points/types"
|
||||
import { getPointsRules } from "@/api/points"
|
||||
import "./PointsCost.css"
|
||||
|
||||
interface Props {
|
||||
/** 消耗场景 key */
|
||||
scene: PointsSource
|
||||
/** 单位数(分钟数/条数/张数),默认 1 */
|
||||
units?: number
|
||||
/** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */
|
||||
compact?: boolean
|
||||
/** 余额不足时,是否显示充值提示 */
|
||||
showRechargeHint?: boolean
|
||||
/** 自定义 class */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 单位中文 */
|
||||
const UNIT_LABEL: Record<string, string> = {
|
||||
分钟: "分钟",
|
||||
条: "条",
|
||||
次: "次",
|
||||
张: "张",
|
||||
}
|
||||
|
||||
const PointsCost: React.FC<Props> = ({
|
||||
scene,
|
||||
units = 1,
|
||||
compact = false,
|
||||
showRechargeHint = true,
|
||||
className = "",
|
||||
}) => {
|
||||
const { balance, init } = usePointsStore()
|
||||
const [rules, setRules] = useState<Awaited<ReturnType<typeof getPointsRules>> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!balance) init()
|
||||
if (!rules) {
|
||||
getPointsRules()
|
||||
.then(setRules)
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [balance, init, rules])
|
||||
|
||||
const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => {
|
||||
if (!rules || !balance) {
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule: null,
|
||||
isFreeUser: !balance?.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
}
|
||||
const rule = rules.rules.find((r) => r.scene_key === scene)
|
||||
if (!rule)
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule: null,
|
||||
isFreeUser: !balance.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
// 免费训练不扣费
|
||||
if (rule.points_per_use === 0) {
|
||||
return {
|
||||
cost: 0,
|
||||
isFreeQuota: false,
|
||||
rule,
|
||||
isFreeUser: !balance.is_member,
|
||||
insufficient: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 智能混剪:首条30s=3分,每+30s +1
|
||||
let baseCost: number
|
||||
if (scene === "ai_video" && rule.extra_per_30s) {
|
||||
// units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1
|
||||
const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments
|
||||
baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1)
|
||||
} else {
|
||||
baseCost = rule.points_per_use * Math.max(1, units)
|
||||
}
|
||||
|
||||
// 混剪 + 免费用户 + 今日有免费额度 → 免费
|
||||
const isFree =
|
||||
scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0
|
||||
|
||||
const isFreeUser = !balance.is_member
|
||||
const cost = isFree
|
||||
? 0
|
||||
: isFreeUser
|
||||
? Math.ceil(baseCost * rules.free_user_multiplier)
|
||||
: baseCost
|
||||
const insufficient = !isFree && balance.balance < cost
|
||||
return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient }
|
||||
}, [rules, balance, scene, units])
|
||||
|
||||
if (!rule || !balance) {
|
||||
return <span className={`xx-points-cost ${className}`} />
|
||||
}
|
||||
|
||||
if (rule.points_per_use === 0) {
|
||||
return (
|
||||
<span className={`xx-points-cost free ${className}`}>
|
||||
<span className="xx-points-tag-free">免费</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFreeQuota) {
|
||||
return (
|
||||
<Tooltip title={`今日免费额度剩余 ${balance.free_clips_remaining} 条,不扣积分`}>
|
||||
<span className={`xx-points-cost free-quota ${className}`}>
|
||||
<span className="xx-points-tag-free">免费</span>
|
||||
{!compact && (
|
||||
<span className="xx-points-desc">(今日剩余 {balance.free_clips_remaining} 条)</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const unitLabel = compact
|
||||
? ""
|
||||
: ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}`
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`xx-points-cost ${insufficient ? "insufficient" : ""} ${isFreeUser ? "free-user" : ""} ${className}`}
|
||||
>
|
||||
<span className="xx-points-gem">💎</span>
|
||||
<span className="xx-points-num">-{cost}</span>
|
||||
{unitLabel && <span className="xx-points-unit-label">积分{unitLabel}</span>}
|
||||
{isFreeUser && !compact && (
|
||||
<Tooltip title={`免费用户消耗 ×${rules?.free_user_multiplier ?? 1.15},开通会员享标准价`}>
|
||||
<span className="xx-points-free-user-tag">非会员</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{insufficient && showRechargeHint && (
|
||||
<Tooltip title={`积分不足,需 ${cost} 积分,当前余额 ${balance.balance}`}>
|
||||
<WarningOutlined className="xx-points-warn-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsCost
|
||||
@@ -1,5 +1,4 @@
|
||||
/* Phase 1 Header 样式 + 手机端适配
|
||||
v2: 增加积分徽章、升级按钮、会员金冠 */
|
||||
/* Phase 1 Header 样式 + 手机端适配 */
|
||||
.xx-top-nav {
|
||||
height: 68px;
|
||||
position: sticky;
|
||||
@@ -78,40 +77,10 @@
|
||||
.xx-right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* v2: 顶部积分徽章 */
|
||||
.xx-header-points {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* v2: 升级会员按钮 */
|
||||
.xx-upgrade-btn {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.xx-upgrade-btn:hover {
|
||||
background: linear-gradient(135deg, #d97706, #dc2626) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* v2: 会员金冠按钮 */
|
||||
.xx-crown-btn {
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.xx-crown-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.xx-user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -146,29 +115,6 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* v2: 手机端积分/会员行 */
|
||||
.xx-mobile-points-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 4px 16px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-mobile-member-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
background: #fef3c7;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -211,7 +157,6 @@
|
||||
|
||||
.xx-top-nav-inner {
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-brand-text {
|
||||
@@ -232,19 +177,6 @@
|
||||
.xx-username {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 手机端:缩小升级按钮文字 */
|
||||
.xx-upgrade-btn {
|
||||
padding: 0 8px !important;
|
||||
font-size: 12px !important;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.xx-crown-btn {
|
||||
padding: 0 4px !important;
|
||||
font-size: 12px !important;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕平板:导航文字缩小 */
|
||||
@@ -256,14 +188,4 @@
|
||||
.xx-nav-links button {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-header-points {
|
||||
transform: scale(0.9);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.xx-upgrade-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
/**
|
||||
* Phase 1 Header 重构
|
||||
* 扁平化导航菜单 + 手机端汉堡菜单
|
||||
* v2: 增加积分徽章、升级会员入口、我的积分入口
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Avatar, Badge, Button, Dropdown, Drawer, Space, Tooltip } from "antd"
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
MenuOutlined,
|
||||
CrownFilled,
|
||||
ThunderboltOutlined,
|
||||
HistoryOutlined,
|
||||
WalletOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import React, { useState } from "react"
|
||||
import { Avatar, Dropdown, Drawer, Space } from "antd"
|
||||
import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { useLogout } from "@/hooks/useAuth"
|
||||
import type { MenuProps } from "antd"
|
||||
import { NAV_ITEMS } from "@/config/navigation"
|
||||
import PointsBadge from "@/components/common/PointsBadge"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import "./Header.css"
|
||||
|
||||
const Header: React.FC = () => {
|
||||
@@ -31,18 +19,6 @@ const Header: React.FC = () => {
|
||||
const logoutMutation = useLogout()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
// 积分与会员状态
|
||||
const { balance, init } = usePointsStore()
|
||||
useEffect(() => {
|
||||
init()
|
||||
// 每 60s 刷新一次(余额可能变化)
|
||||
const t = setInterval(() => init(), 60_000)
|
||||
return () => clearInterval(t)
|
||||
}, [init])
|
||||
|
||||
const isMember = !!balance?.is_member
|
||||
const memberType = balance?.member_type ?? null
|
||||
|
||||
/** 用户下拉菜单 */
|
||||
const menuItems: MenuProps["items"] = [
|
||||
{
|
||||
@@ -57,30 +33,6 @@ const Header: React.FC = () => {
|
||||
label: "订阅管理",
|
||||
onClick: () => navigate("/app/subscription"),
|
||||
},
|
||||
// v2: 我的积分入口
|
||||
{
|
||||
key: "points-center",
|
||||
icon: <ThunderboltOutlined />,
|
||||
label: (
|
||||
<Space>
|
||||
我的积分
|
||||
{balance && <span style={{ color: "#8b5cf6", fontWeight: 700 }}>{balance.balance}</span>}
|
||||
</Space>
|
||||
),
|
||||
onClick: () => navigate("/app/points"),
|
||||
},
|
||||
{
|
||||
key: "points-history",
|
||||
icon: <HistoryOutlined />,
|
||||
label: "积分明细",
|
||||
onClick: () => navigate("/app/points/transactions"),
|
||||
},
|
||||
{
|
||||
key: "recharge",
|
||||
icon: <WalletOutlined />,
|
||||
label: "充值积分",
|
||||
onClick: () => navigate("/app/points/recharge"),
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "logout",
|
||||
@@ -92,6 +44,7 @@ const Header: React.FC = () => {
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return (
|
||||
location.pathname === "/" ||
|
||||
@@ -125,47 +78,6 @@ const Header: React.FC = () => {
|
||||
</nav>
|
||||
|
||||
<div className="xx-right-section">
|
||||
{/* v2: 积分徽章(顶部显示) */}
|
||||
<PointsBadge />
|
||||
|
||||
{/* v2: 升级会员入口(仅免费用户显示) */}
|
||||
{!isMember && (
|
||||
<Tooltip title="升级会员解锁无限混剪、批量导出,积分 8 折起">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CrownFilled />}
|
||||
className="xx-upgrade-btn"
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
升级会员
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* v2: 付费会员显示金冠标识 */}
|
||||
{isMember && (
|
||||
<Tooltip
|
||||
title={`${memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}会员${
|
||||
balance?.member_expires_at
|
||||
? ` · 到期 ${new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Badge dot color="gold">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CrownFilled style={{ color: "#f59e0b" }} />}
|
||||
className="xx-crown-btn"
|
||||
onClick={() => navigate("/app/subscription")}
|
||||
>
|
||||
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "会员"}
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 手机端汉堡菜单按钮 */}
|
||||
<button className="xx-hamburger" type="button" onClick={() => setMobileMenuOpen(true)}>
|
||||
<MenuOutlined />
|
||||
@@ -191,31 +103,6 @@ const Header: React.FC = () => {
|
||||
width={260}
|
||||
className="xx-mobile-drawer"
|
||||
>
|
||||
{/* 手机端积分/会员入口 */}
|
||||
{balance && (
|
||||
<div className="xx-mobile-points-row">
|
||||
<PointsBadge />
|
||||
{!isMember ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CrownFilled />}
|
||||
onClick={() => {
|
||||
navigate("/app/subscription")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
) : (
|
||||
<span className="xx-mobile-member-tag">
|
||||
<CrownFilled style={{ color: "#f59e0b" }} />{" "}
|
||||
{memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-mobile-nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
|
||||
@@ -8,14 +8,15 @@ import {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
AudioOutlined,
|
||||
AppstoreOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
VideoCameraOutlined,
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
@@ -69,6 +70,24 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/app/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑模板",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
|
||||
{
|
||||
key: "generate",
|
||||
@@ -106,18 +125,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "会员订阅",
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
]
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -143,6 +150,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/ai-avatar",
|
||||
icon: React.createElement(UserOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑模板",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -178,6 +191,18 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/products",
|
||||
icon: React.createElement(TrophyOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/app/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -209,16 +234,10 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "会员订阅",
|
||||
label: "订阅管理",
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
label: "积分中心",
|
||||
path: "/app/points",
|
||||
icon: React.createElement(ThunderboltOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -25,6 +25,15 @@ body {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── 自定义字体 ── */
|
||||
@font-face {
|
||||
font-family: "华康俪金黑";
|
||||
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 滚动条 - V21 样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -552,7 +552,7 @@
|
||||
max-width: 240px;
|
||||
aspect-ratio: 9/16;
|
||||
background: #f0f0f5;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -564,10 +564,8 @@
|
||||
.aa-cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 9/16;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.aa-cover-preview__placeholder {
|
||||
@@ -575,19 +573,6 @@
|
||||
color: #8c8ca1;
|
||||
}
|
||||
|
||||
.aa-cover-preview__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.aa-cover-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -1265,20 +1250,3 @@
|
||||
width: auto;
|
||||
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 配音前置)
|
||||
* 步骤1:出镜视频 / 配音库 / 文案 → 点击「🎵 生成配音」做 TTS 预合成(同步,~2-3s)
|
||||
* 步骤2:对口型预览(音频已就绪、B-roll 句子时间戳立即可用)/ 标题配置 / 封面&生成
|
||||
* AI数字人 — 主页面(v3 两步骤版)
|
||||
* 步骤1:出镜视频 / 配音库 / 文案
|
||||
* 步骤2:对口型预览(含插入画面)/ 标题配置 / 封面&生成
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
@@ -16,26 +16,20 @@ import PanelTitleConfig from "./components/PanelTitleConfig"
|
||||
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
|
||||
import { ModalAssetPicker } from "./components/ModalAssetPicker"
|
||||
import ModalBRollEditor from "./components/ModalBRollEditor"
|
||||
import ModalCoverSelect from "./components/ModalCoverSelect"
|
||||
import {
|
||||
getScripts,
|
||||
getAssetById,
|
||||
createLipsyncJob,
|
||||
getLipsyncJob,
|
||||
previewTts,
|
||||
submitRender,
|
||||
getRenderJob,
|
||||
generateRenderSmartCover,
|
||||
finalizeRenderJob,
|
||||
generateSmartCover,
|
||||
} from "./api/aiAvatar"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { RenderJob, SentenceTiming } from "./types"
|
||||
import {
|
||||
normalizeEmotion,
|
||||
buildTitleConfigPayload,
|
||||
buildCoverConfigPayload,
|
||||
} from "./utils/contract"
|
||||
import { renderTitleToPngDataUrl, getVideoResolution } from "./utils/titleCanvas"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
type PanelKey = "video" | "voice" | "script" | "lipsync" | "title" | "cover"
|
||||
@@ -53,18 +47,14 @@ const AiAvatarPage: React.FC = () => {
|
||||
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 [lipsyncStatus, setLipsyncStatus] = useState<"generating" | "completed" | "failed">(
|
||||
"generating",
|
||||
)
|
||||
const [lipsyncErrorMessage, setLipsyncErrorMessage] = useState("")
|
||||
/* ── 智能封面加载态 ── */
|
||||
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
|
||||
/* ── 渲染进度弹窗 ── */
|
||||
const [showRenderModal, setShowRenderModal] = useState(false)
|
||||
const [renderStatus, setRenderStatus] = useState<"generating" | "completed" | "failed">(
|
||||
@@ -72,12 +62,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
)
|
||||
const [renderProgress, setRenderProgress] = useState(0)
|
||||
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)
|
||||
@@ -88,29 +72,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
/* ── #1845 文案/音色/语速变更时重置 TTS 预合成状态,避免音频与文案不一致 ── */
|
||||
useEffect(() => {
|
||||
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 handleNextStep = useCallback(() => {
|
||||
const missing: string[] = []
|
||||
if (!state.selectedVideo) missing.push("出镜视频")
|
||||
if (!state.selectedVoice) missing.push("配音")
|
||||
@@ -119,115 +82,45 @@ const AiAvatarPage: React.FC = () => {
|
||||
message.warning(`请先完成${missing.join("、")}`)
|
||||
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)
|
||||
}, [])
|
||||
}, [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(() => {
|
||||
setCurrentStep(1)
|
||||
}, [])
|
||||
|
||||
/* ── 对口型 ── */
|
||||
const handleGenerateLipsync = useCallback(async () => {
|
||||
// ② 缺项明确提示(#1809):不再静默 return
|
||||
const video = state.selectedVideo
|
||||
const voice = state.selectedVoice
|
||||
const text = state.scriptText.trim()
|
||||
const missing: string[] = []
|
||||
if (!video) missing.push("出镜视频")
|
||||
if (!voice) missing.push("音色")
|
||||
if (!text) missing.push("文案")
|
||||
if (missing.length > 0 || !video) {
|
||||
if (missing.length > 0 || !video || !voice) {
|
||||
message.warning(`请先选择${missing.join("、")}`)
|
||||
return
|
||||
}
|
||||
|
||||
// #1845:预合成模式下必须要有 audioUrl(理论上到了步骤2肯定有,兜底防御)
|
||||
const isPreSynth = state.ttsPreview.status === "done" && !!state.ttsPreview.audioUrl
|
||||
if (!isPreSynth && !state.selectedVoice) {
|
||||
message.warning("请先选择音色或完成配音合成")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示生成弹窗
|
||||
setShowLipsyncModal(true)
|
||||
setLipsyncStatus("generating")
|
||||
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)
|
||||
console.log("[对口型] getAssetById 响应:", {
|
||||
id: asset?.id,
|
||||
file_url: asset?.file_url?.substring(0, 100),
|
||||
})
|
||||
const videoUrl = asset?.file_url
|
||||
if (!videoUrl) {
|
||||
console.error("[对口型] file_url 为空,asset:", asset)
|
||||
@@ -235,38 +128,29 @@ const AiAvatarPage: React.FC = () => {
|
||||
message.error("获取出镜视频播放地址失败,请重新选择素材")
|
||||
return
|
||||
}
|
||||
|
||||
type LipsyncPayload = Parameters<typeof createLipsyncJob>[0]
|
||||
let payload: LipsyncPayload
|
||||
if (isPreSynth) {
|
||||
// 预合成模式:传 audio_url + audio_duration + sentence_timings(后端直接提交 MediaKit,~2-3s)
|
||||
payload = {
|
||||
video_url: videoUrl,
|
||||
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),
|
||||
}
|
||||
// ② 模式A TTS直生:video_url + voice_id + script_text,语速/情绪英文枚举透传(#1822)
|
||||
const payload = {
|
||||
voice_id: voice.voice_id,
|
||||
script_text: state.scriptText,
|
||||
video_url: videoUrl,
|
||||
speed: state.speed, // 语速 0.5~2.0
|
||||
emotion: normalizeEmotion(state.emotion), // natural/excited/calm/friendly
|
||||
}
|
||||
console.log("[对口型] createLipsyncJob 请求:", payload)
|
||||
const job = await createLipsyncJob(payload)
|
||||
console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status })
|
||||
state.setLipsyncJob(job)
|
||||
|
||||
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
|
||||
// 但仍需轮询等 completed
|
||||
// 开始轮询
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getLipsyncJob(job.id)
|
||||
state.setLipsyncJob(updated)
|
||||
console.log("[对口型] 轮询状态:", {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
error: updated.error_message,
|
||||
})
|
||||
if (updated.status === "completed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
setLipsyncStatus("completed")
|
||||
@@ -293,14 +177,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
state.selectedVideo,
|
||||
state.selectedVoice,
|
||||
state.scriptText,
|
||||
state.speed,
|
||||
state.emotion,
|
||||
state.ttsPreview,
|
||||
])
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText, state.speed, state.emotion])
|
||||
|
||||
// 取消对口型生成
|
||||
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 () => {
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
|
||||
@@ -337,28 +206,9 @@ const AiAvatarPage: React.FC = () => {
|
||||
}
|
||||
state.setIsGenerating(true)
|
||||
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({
|
||||
lipsync_job_id: state.lipsyncJob.id,
|
||||
script_id: state.script?.id,
|
||||
project_id: defaultProject.id,
|
||||
b_roll_segments: state.bRollSegments.map((seg) => ({
|
||||
script_segment_index: seg.script_segment_index,
|
||||
asset_url: seg.asset.file_url || "",
|
||||
@@ -368,45 +218,26 @@ const AiAvatarPage: React.FC = () => {
|
||||
pip_position: seg.pip_position,
|
||||
pip_scale: seg.pip_scale,
|
||||
})) as never,
|
||||
title_config: buildTitleConfigPayload(state.titleConfig, titleImageDataUrl),
|
||||
cover_config:
|
||||
state.coverConfig.smart_cover_url ||
|
||||
(state.coverConfig.upload_url && !state.coverConfig.upload_url.startsWith("blob:"))
|
||||
? buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url)
|
||||
: {},
|
||||
title_config: buildTitleConfigPayload(state.titleConfig),
|
||||
cover_config: buildCoverConfigPayload(state.coverConfig, state.coverConfig.smart_cover_url),
|
||||
})
|
||||
|
||||
// 打开渲染进度弹窗,启动轮询
|
||||
setShowRenderModal(true)
|
||||
setRenderStatus("generating")
|
||||
setRenderProgress(job.progress ?? 0)
|
||||
setRenderErrorMessage("")
|
||||
setCurrentRenderJob(job as RenderJob)
|
||||
// 每次新渲染重置封面状态
|
||||
setSelectedCoverUrl("")
|
||||
setShowCoverModal(false)
|
||||
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getRenderJob(job.id)
|
||||
setRenderProgress(updated.progress ?? 0)
|
||||
setCurrentRenderJob(updated)
|
||||
if (updated.status === "completed") {
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = null
|
||||
setRenderStatus("completed")
|
||||
// 不再自动入库/自动跳转:渲染完成后停留在主页面,等用户选封面、点「完成」才入库
|
||||
// 如果后端在透传时已经带了封面(旧逻辑兜底),同步本地状态
|
||||
if (updated.output_cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
mode: "auto_frame",
|
||||
smart_cover_url: updated.output_cover_url,
|
||||
thumbnail_url: updated.output_cover_url,
|
||||
}))
|
||||
setSelectedCoverUrl(updated.output_cover_url)
|
||||
}
|
||||
message.success("视频生成完成,请选择封面")
|
||||
message.success("视频已生成并保存到成片库")
|
||||
} else if (updated.status === "failed") {
|
||||
if (renderTimerRef.current) clearInterval(renderTimerRef.current)
|
||||
renderTimerRef.current = null
|
||||
@@ -438,109 +269,38 @@ const AiAvatarPage: React.FC = () => {
|
||||
setRenderErrorMessage("")
|
||||
}, [])
|
||||
|
||||
/* ── 智能封面 ── */
|
||||
const handleGenerateRenderSmartCover = useCallback(
|
||||
async (renderId: string): Promise<{ cover_url: string; message?: string }> => {
|
||||
try {
|
||||
const res = await generateRenderSmartCover(renderId)
|
||||
if (res.cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
mode: "auto_frame",
|
||||
smart_cover_url: res.cover_url,
|
||||
thumbnail_url: res.cover_url,
|
||||
}))
|
||||
message.success("智能封面已生成")
|
||||
return { cover_url: res.cover_url }
|
||||
}
|
||||
const errMsg = res.message || "智能封面生成失败,请稍后重试"
|
||||
message.error(errMsg)
|
||||
return { cover_url: "", message: errMsg }
|
||||
} catch (err) {
|
||||
console.error("智能封面生成失败:", err)
|
||||
const errMsg = err instanceof Error ? err.message : "智能封面生成失败,请重试"
|
||||
message.error(errMsg)
|
||||
return { cover_url: "", message: errMsg }
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
)
|
||||
|
||||
/* ── 封面弹窗回调 ── */
|
||||
const handleCoverSelected = useCallback((coverUrl: string) => {
|
||||
setSelectedCoverUrl(coverUrl || "")
|
||||
}, [])
|
||||
|
||||
const handleOpenCoverModal = useCallback(() => {
|
||||
if (currentRenderJob?.status !== "completed") {
|
||||
message.warning("请先完成视频生成")
|
||||
/* ── 智能封面:调后端 MediaKit 选帧接口(#1822) ── */
|
||||
const handleSmartCover = useCallback(async () => {
|
||||
// 基于对口型成片抽帧,必须先完成对口型
|
||||
const videoUrl = state.lipsyncJob?.output_video_url
|
||||
if (state.lipsyncJob?.status !== "completed" || !videoUrl) {
|
||||
message.warning("请先生成对口型视频,完成后再智能获取封面")
|
||||
return
|
||||
}
|
||||
setShowCoverModal(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)
|
||||
setSmartCoverLoading(true)
|
||||
try {
|
||||
const res = await finalizeRenderJob(currentRenderJob.id)
|
||||
if (res.data?.status === "success" || res.data?.status === "already_finalized") {
|
||||
message.success("已保存到成片库")
|
||||
navigate("/app/products")
|
||||
const res = await generateSmartCover(videoUrl, 5)
|
||||
if (res.cover_url) {
|
||||
state.setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
mode: "auto_frame",
|
||||
smart_cover_url: res.cover_url,
|
||||
thumbnail_url: res.cover_url,
|
||||
}))
|
||||
message.success("智能封面已生成")
|
||||
} else {
|
||||
message.error("保存失败,请重试")
|
||||
message.error(res.message || "智能封面生成失败,请稍后重试")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("finalize 失败:", err)
|
||||
const errMsg =
|
||||
(err as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail ||
|
||||
(err instanceof Error ? err.message : "保存到成片库失败")
|
||||
message.error(typeof errMsg === "string" ? errMsg : "保存到成片库失败")
|
||||
console.error("智能封面生成失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "智能封面生成失败,请重试")
|
||||
} 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 = {
|
||||
videoName: state.selectedVideo?.name || null,
|
||||
voiceName: state.selectedVoice?.name || null,
|
||||
@@ -548,7 +308,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
lipsyncStatus: state.lipsyncJob?.status || null,
|
||||
brollCount: state.bRollSegments.length,
|
||||
hasTitle: state.titleConfig.title.length > 0,
|
||||
coverStatus,
|
||||
hasCover: state.coverConfig.enabled,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -582,6 +342,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
selectedVideo={state.selectedVideo}
|
||||
onSelectVideo={() => state.setShowAssetPicker(true)}
|
||||
onRemoveVideo={state.removeVideo}
|
||||
titleConfig={state.titleConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -621,33 +382,19 @@ const AiAvatarPage: React.FC = () => {
|
||||
onOpenScriptModal={() => state.setShowScriptModal(true)}
|
||||
/>
|
||||
<div className="aa-step-btn-row">
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
onClick={handleGenerateTts}
|
||||
disabled={state.ttsPreview.status === "generating"}
|
||||
>
|
||||
{state.ttsPreview.status === "done" ? "🎵 重新生成配音" : "🎵 生成配音"}
|
||||
<button type="button" className="aa-btn aa-btn--primary" onClick={handleNextStep}>
|
||||
下一步 →
|
||||
</button>
|
||||
{state.ttsPreview.status === "done" && (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
onClick={() => setCurrentStep(2)}
|
||||
style={{ marginLeft: 12 }}
|
||||
>
|
||||
下一步 →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ════ 步骤 2:对口型预览 / 标题配置 / 封面&生成 ════ */}
|
||||
{/* ════ 步骤 2:对口型预览(含插入画面)/ 标题配置 / 封面&生成 ════ */}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
{/* 面板:对口型预览 + 插入画面 */}
|
||||
<div className={`aa-panel aa-panel--s2-wide${collapsed.lipsync ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("lipsync")}>
|
||||
<span className="aa-panel__title">对口型预览</span>
|
||||
@@ -685,98 +432,27 @@ const AiAvatarPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板5:封面 & 生成
|
||||
- 渲染未完成:显示分辨率/配置摘要/「开始生成」按钮(PanelCoverAndGenerate setup 变体,无封面区)
|
||||
- 渲染完成:显示封面预览 + 「🎬 选择封面」/「✅ 完成」按钮,封面选择在弹窗中完成 */}
|
||||
{/* 面板5:封面 & 生成 */}
|
||||
<div className={`aa-panel aa-panel--s2${collapsed.cover ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
|
||||
<span className="aa-panel__title">
|
||||
{currentRenderJob?.status === "completed" ? "视频已生成" : "封面 & 生成"}
|
||||
</span>
|
||||
<span className="aa-panel__title">封面 & 生成</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
{currentRenderJob?.status !== "completed" ? (
|
||||
<PanelCoverAndGenerate
|
||||
variant="setup"
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
renderJob={currentRenderJob}
|
||||
onGenerateRenderSmartCover={handleGenerateRenderSmartCover}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
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>
|
||||
)}
|
||||
<PanelCoverAndGenerate
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSmartCover={handleSmartCover}
|
||||
smartCoverLoading={smartCoverLoading}
|
||||
canSmartCover={state.lipsyncJob?.status === "completed"}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
summary={summary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -802,144 +478,19 @@ const AiAvatarPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* B-roll 编辑器弹窗 — #1845:timings 在对口型完成前就可用(来自 TTS 预合成) */}
|
||||
{/* B-roll 编辑器弹窗 */}
|
||||
{state.showBRollModal && (
|
||||
<ModalBRollEditor
|
||||
open={state.showBRollModal}
|
||||
onClose={() => state.setShowBRollModal(false)}
|
||||
existingSegments={state.bRollSegments}
|
||||
scriptText={state.lipsyncJob?.script_text || state.scriptText}
|
||||
outputDuration={bRollDuration}
|
||||
sentenceTimings={bRollSentenceTimings}
|
||||
scriptText={state.scriptText}
|
||||
outputDuration={state.lipsyncJob?.output_duration ?? 0}
|
||||
onConfirm={state.addBRollSegment}
|
||||
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 && (
|
||||
<div className="aa-modal-overlay">
|
||||
@@ -1072,21 +623,17 @@ const AiAvatarPage: React.FC = () => {
|
||||
<>
|
||||
<div style={{ fontSize: 48 }}>✅</div>
|
||||
<div style={{ marginTop: 16, fontSize: 15, color: "#1a1a2e" }}>
|
||||
视频生成完成,请选择封面
|
||||
</div>
|
||||
<div
|
||||
style={{ marginTop: 8, fontSize: 13, color: "#8c8ca1", textAlign: "center" }}
|
||||
>
|
||||
关闭此窗口后可在下方选择封面并保存到成片库
|
||||
视频已保存到成片库
|
||||
</div>
|
||||
<button
|
||||
className="aa-btn aa-btn--primary"
|
||||
className="aa-btn"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => {
|
||||
setShowRenderModal(false)
|
||||
navigate("/app/products")
|
||||
}}
|
||||
>
|
||||
🎬 选择封面
|
||||
📁 查看成片
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -1117,18 +664,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* AI数字人 — API 封装(#1822 契约对齐)
|
||||
*/
|
||||
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[]> => {
|
||||
@@ -34,28 +34,17 @@ export const getAssetById = async (id: string): Promise<{ file_url?: string; id:
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 对口型(支持三种模式) ──
|
||||
* 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
|
||||
*/
|
||||
/* ── 对口型(模式A:TTS 直生,后端内部合成音频;不要先调 TTS 拿 audio_url) ── */
|
||||
export const createLipsyncJob = async (data: {
|
||||
/** 人物视频 URL(MP4);由素材 id 经 getAssetById 拿 file_url */
|
||||
/** 人物视频 URL(MP4);由素材 id 经 getAssetById 拿 file_url,禁止传 video_asset_id */
|
||||
video_url: string
|
||||
/** 预合成/直接音频模式:音频 URL(#1845 步骤1 预合成的 CosyVoice 临时 URL,或外部音频 URL) */
|
||||
audio_url?: string
|
||||
/** 预合成音频时长(秒),由 previewTts 返回 */
|
||||
audio_duration?: number
|
||||
/** 预合成接口返回的句子时间戳(精确),后端直接写入 job */
|
||||
sentence_timings?: SentenceTiming[]
|
||||
/** 音色 ID(TTS 直生模式用) */
|
||||
voice_id?: string
|
||||
/** 要合成的文案(TTS 直生模式用) */
|
||||
script_text?: string
|
||||
/** 语速 0.5~2.0,默认 1.0(TTS 直生模式用) */
|
||||
/** 音色 ID(预置音色 或 克隆音色 profile UUID,后端会解析) */
|
||||
voice_id: string
|
||||
/** 要合成的文案(手动输入或文案库内容) */
|
||||
script_text: string
|
||||
/** 语速 0.5~2.0,默认 1.0 */
|
||||
speed?: number
|
||||
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgusted(TTS 直生模式用;前端经 normalizeEmotion 归一化) */
|
||||
/** 情绪英文枚举:natural/excited/calm/friendly */
|
||||
emotion?: string
|
||||
enable_video_loop?: boolean
|
||||
project_id?: string
|
||||
@@ -64,27 +53,21 @@ export const createLipsyncJob = async (data: {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── #1845 TTS 预合成(步骤1「生成配音」同步接口,~2-3s) ── */
|
||||
export const previewTts = async (data: {
|
||||
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 })
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
|
||||
/* ── 智能封面(MediaKit 抽帧 + 质量评分选最佳帧,独立于渲染任务) ── */
|
||||
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
|
||||
}
|
||||
|
||||
@@ -97,35 +80,15 @@ export const submitRender = async (data: {
|
||||
cover_config?: Record<string, unknown>
|
||||
project_id?: string
|
||||
}): Promise<RenderJob> => {
|
||||
// title_config 内可含 title_image_dataurl(前端 Canvas 渲染的 PNG dataURL)
|
||||
const response = await apiClient.post<RenderJob>("/ai-avatar/render", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
}
|
||||
|
||||
/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */
|
||||
export const generateRenderSmartCover = async (
|
||||
jobId: string,
|
||||
): Promise<{ cover_url: string; status: string; message: string }> => {
|
||||
const response = await apiClient.post<{ cover_url: string; status: string; message: string }>(
|
||||
`/ai-avatar/render/${jobId}/smart-cover`,
|
||||
{},
|
||||
// 抽帧+评分+转存 OSS 链路较长,120s 超时
|
||||
{ timeout: 120000 },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 封面选定后点「完成」正式入库(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 使用的素材
|
||||
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
|
||||
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
|
||||
* (开始/结束时间来自后端精确句子时间戳,基于 TTS 音频静音检测)
|
||||
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算)
|
||||
* - 底部:已配置的画面插入列表(可删除)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition, SentenceTiming } from "../types"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
|
||||
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
|
||||
|
||||
interface ModalBRollEditorProps {
|
||||
@@ -18,12 +18,10 @@ interface ModalBRollEditorProps {
|
||||
onClose: () => void
|
||||
/** 当前已有的 B-roll segments(用于标灰已选素材) */
|
||||
existingSegments: BRollSegment[]
|
||||
/** 文案全文(优先使用对口型时锁定的 scriptText) */
|
||||
/** 当前文案全文(用于分句) */
|
||||
scriptText: string
|
||||
/** 对口型成片总时长(秒) */
|
||||
/** 对口型成片总时长(秒),用于时间自动估算 */
|
||||
outputDuration: number
|
||||
/** 后端精确句子时间戳(来自 lipsyncJob.sentence_timings) */
|
||||
sentenceTimings?: SentenceTiming[] | null
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
@@ -45,8 +43,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
onClose,
|
||||
existingSegments,
|
||||
scriptText,
|
||||
outputDuration: _outputDuration,
|
||||
sentenceTimings,
|
||||
outputDuration,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}) => {
|
||||
@@ -65,10 +62,10 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
|
||||
const [pipScale, setPipScale] = useState(0.3)
|
||||
|
||||
/** 文案分句(优先使用后端精确时间戳,降级为字数比例估算) */
|
||||
/** 文案分句(⑤) */
|
||||
const sentences = useMemo(
|
||||
() => splitScriptIntoSentences(scriptText, sentenceTimings, _outputDuration),
|
||||
[scriptText, sentenceTimings, _outputDuration],
|
||||
() => splitScriptIntoSentences(scriptText, outputDuration),
|
||||
[scriptText, outputDuration],
|
||||
)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
@@ -145,7 +142,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
setSelectedAsset(asset)
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的精确起止,后端静音检测 / 前端字数比例降级) */
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止) */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset || !selectedSentence) return
|
||||
const startTime = selectedSentence.startTime
|
||||
@@ -267,9 +264,11 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
>
|
||||
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
|
||||
<span className="aa-sentence-item__text">{sent.text}</span>
|
||||
<span className="aa-sentence-item__time">
|
||||
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
|
||||
</span>
|
||||
{outputDuration > 0 && (
|
||||
<span className="aa-sentence-item__time">
|
||||
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -350,7 +349,7 @@ const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
selectedSentence.endTime,
|
||||
selectedSentence.startTime + 0.5,
|
||||
).toFixed(1)}
|
||||
s
|
||||
s (按字数自动估算)
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,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 / 封面选择弹窗内容:
|
||||
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
|
||||
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
|
||||
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
|
||||
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
|
||||
* AI数字人 — 面板5:封面 & 生成
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
|
||||
* - 分辨率选择(720p / 1080p / 4K)
|
||||
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
|
||||
* - 渐变紫色生成按钮
|
||||
*
|
||||
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
|
||||
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
|
||||
*/
|
||||
import React, { useRef, useState } from "react"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
|
||||
type PanelVariant = "setup" | "select-cover"
|
||||
import React, { useRef } from "react"
|
||||
import type { AiAvatarCoverConfig } from "../types"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
variant?: PanelVariant
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution?: string
|
||||
onResolutionChange?: (r: string) => void
|
||||
isGenerating?: boolean
|
||||
onGenerate?: () => void
|
||||
/** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */
|
||||
renderJob: RenderJob | null
|
||||
/** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */
|
||||
onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }>
|
||||
/** 自定义上传封面(选择本地文件后由父组件处理实际上传) */
|
||||
onUploadCover?: (file: File) => void
|
||||
/** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */
|
||||
onClose?: () => void
|
||||
/** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */
|
||||
onCoverSelected?: (coverUrl: string) => void
|
||||
/** 配置汇总信息(仅 variant="setup" 使用) */
|
||||
summary?: {
|
||||
resolution: string
|
||||
onResolutionChange: (r: string) => void
|
||||
isGenerating: boolean
|
||||
onGenerate: () => void
|
||||
/** 智能获取封面(MediaKit 选帧) */
|
||||
onSmartCover: () => void
|
||||
smartCoverLoading: boolean
|
||||
canSmartCover: boolean
|
||||
/** 配置汇总信息 */
|
||||
summary: {
|
||||
videoName: string | null
|
||||
voiceName: string | null
|
||||
scriptLength: number
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
|
||||
coverStatus: "not_ready" | "pending" | "selected"
|
||||
hasCover: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,116 +48,53 @@ const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
}
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
variant = "setup",
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
resolution = "720p",
|
||||
resolution,
|
||||
onResolutionChange,
|
||||
isGenerating = false,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
renderJob,
|
||||
onGenerateRenderSmartCover,
|
||||
onUploadCover,
|
||||
onClose,
|
||||
onCoverSelected,
|
||||
onSmartCover,
|
||||
smartCoverLoading,
|
||||
canSmartCover,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
// 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序)
|
||||
const [smartCoverLoading, setSmartCoverLoading] = useState(false)
|
||||
|
||||
/** 自定义上传封面 */
|
||||
const handleUploadClick = () => {
|
||||
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 file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (onUploadCover) {
|
||||
onUploadCover(file)
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
// 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示)
|
||||
// 本地预览:生成 object URL(实际上传由父级/后端链路处理)
|
||||
const url = URL.createObjectURL(file)
|
||||
_applyCoverUrl(url, "upload")
|
||||
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
|
||||
// 允许重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 智能获取封面(从最终成片抽帧;必须等 render 完成) */
|
||||
const handleSmartCover = async () => {
|
||||
if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return
|
||||
setSmartCoverLoading(true)
|
||||
try {
|
||||
const res = await onGenerateRenderSmartCover(renderJob.id)
|
||||
if (res.cover_url) {
|
||||
_applyCoverUrl(res.cover_url, "auto_frame")
|
||||
} else {
|
||||
// 失败由父组件 message 提示,这里不重复弹窗
|
||||
console.warn("[智能封面] 返回空 cover_url:", res.message)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[智能封面] 调用失败:", err)
|
||||
} finally {
|
||||
setSmartCoverLoading(false)
|
||||
}
|
||||
/** 智能获取封面(调后端 MediaKit 抽帧评分选最佳帧,#1822) */
|
||||
const handleSmartCover = () => {
|
||||
onCoverConfigChange({ mode: "auto_frame" })
|
||||
onSmartCover()
|
||||
}
|
||||
|
||||
const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
|
||||
// 渲染已完成 → 封面区可用
|
||||
const isRenderCompleted = renderJob?.status === "completed"
|
||||
const canSmartCover = isRenderCompleted && !smartCoverLoading
|
||||
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
|
||||
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
|
||||
const coverUrl =
|
||||
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
|
||||
const hasCoverImage = Boolean(coverUrl)
|
||||
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
|
||||
|
||||
/** 封面区占位文字 */
|
||||
const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面"
|
||||
|
||||
/** 配置摘要中的封面状态标签 */
|
||||
const coverSummaryNode = (() => {
|
||||
if (!summary) return null
|
||||
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} />
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 封面预览(竖屏 9:16) */}
|
||||
<div className="aa-cover-preview">
|
||||
{coverConfig.thumbnail_url ? (
|
||||
<img src={coverConfig.thumbnail_url} alt="封面预览" />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
|
||||
<span className="aa-cover-preview__placeholder">暂无封面</span>
|
||||
)}
|
||||
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
@@ -175,8 +102,8 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleSmartCover}
|
||||
disabled={!canSmartCover}
|
||||
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
|
||||
disabled={smartCoverLoading || !canSmartCover}
|
||||
title={canSmartCover ? "基于对口型成片智能选帧" : "请先完成对口型生成"}
|
||||
>
|
||||
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
|
||||
</button>
|
||||
@@ -184,8 +111,6 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
disabled={!isRenderCompleted || smartCoverLoading}
|
||||
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
@@ -197,36 +122,14 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</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">
|
||||
<label className="aa-label">分辨率</label>
|
||||
<select
|
||||
className="aa-select"
|
||||
value={resolution}
|
||||
onChange={(e) => onResolutionChange?.(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
onChange={(e) => onResolutionChange(e.target.value)}
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((opt) => (
|
||||
<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__row">
|
||||
<span>出镜视频</span>
|
||||
{summary?.videoName ? (
|
||||
{summary.videoName ? (
|
||||
<span className="aa-config-summary__value">{summary.videoName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
@@ -249,7 +152,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>音色</span>
|
||||
{summary?.voiceName ? (
|
||||
{summary.voiceName ? (
|
||||
<span className="aa-config-summary__value">{summary.voiceName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
@@ -257,7 +160,7 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>文案</span>
|
||||
{summary && summary.scriptLength > 0 ? (
|
||||
{summary.scriptLength > 0 ? (
|
||||
<span className="aa-config-summary__value">{summary.scriptLength} 字</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
@@ -274,12 +177,12 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
<div className="aa-config-summary__row">
|
||||
<span>B-roll 画面插入</span>
|
||||
<span className="aa-config-summary__value">
|
||||
{summary && summary.brollCount > 0 ? `${summary.brollCount} 段` : "无"}
|
||||
{summary.brollCount > 0 ? `${summary.brollCount} 段` : "无"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>标题</span>
|
||||
{summary?.hasTitle ? (
|
||||
{summary.hasTitle ? (
|
||||
<span className="aa-config-summary__value">已设置</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
@@ -287,7 +190,11 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>封面</span>
|
||||
{coverSummaryNode}
|
||||
{summary.hasCover ? (
|
||||
<span className="aa-config-summary__value">已开启</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -300,16 +207,11 @@ const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
>
|
||||
{isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"}
|
||||
</button>
|
||||
{summary?.lipsyncStatus !== "completed" && !isGenerating && (
|
||||
{summary.lipsyncStatus !== "completed" && !isGenerating && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
|
||||
请先完成对口型生成
|
||||
</div>
|
||||
)}
|
||||
{isGenerating && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
|
||||
视频生成中,请稍候…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* AI数字人 — 对口型预览面板(步骤2用)
|
||||
* 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"
|
||||
|
||||
interface PanelLipsyncPreviewProps {
|
||||
@@ -14,8 +14,8 @@ interface PanelLipsyncPreviewProps {
|
||||
onRemoveBRoll: (id: string) => void
|
||||
/** 标题配置(实时叠加预览用) */
|
||||
titleConfig?: AiAvatarTitleConfig
|
||||
/** 标题位置变更回调(拖拽结束时调用,发送百分比坐标 + position:"custom") */
|
||||
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number; position: string }) => void
|
||||
/** 标题位置变更回调(拖拽结束时调用) */
|
||||
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number }) => void
|
||||
}
|
||||
|
||||
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
|
||||
@@ -29,16 +29,6 @@ function formatTime(seconds: number): string {
|
||||
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({
|
||||
lipsyncJob,
|
||||
onGenerateLipsync,
|
||||
@@ -51,8 +41,6 @@ export function PanelLipsyncPreview({
|
||||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||||
const draggingTitleRef = useRef(false)
|
||||
const previewContainerRef = useRef<HTMLDivElement>(null)
|
||||
// 预览容器实际宽度(通过 ResizeObserver 监听),用于动态计算 previewScale
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
|
||||
const isDone = lipsyncJob?.status === "completed"
|
||||
const isFailed = lipsyncJob?.status === "failed"
|
||||
@@ -64,84 +52,29 @@ export function PanelLipsyncPreview({
|
||||
? "排队中…"
|
||||
: "对口型生成中…"
|
||||
|
||||
// 监听预览容器尺寸变化,动态测量宽度以计算 previewScale(基准 720p)
|
||||
useEffect(() => {
|
||||
const el = previewContainerRef.current
|
||||
if (!el) return
|
||||
const update = () => setContainerWidth(el.clientWidth || 0)
|
||||
update()
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}
|
||||
window.addEventListener("resize", update)
|
||||
return () => window.removeEventListener("resize", update)
|
||||
}, [])
|
||||
|
||||
// 预览缩放比:预览宽度 / 720(基准宽度)
|
||||
const previewScale = containerWidth > 0 ? containerWidth / 720 : 0.35
|
||||
const ps = useCallback((v: number) => Math.round(v * previewScale * 100) / 100, [previewScale])
|
||||
|
||||
/** 标题叠加样式(字号/padding/描边/阴影均按 previewScale 缩放,保持与成片视觉一致) */
|
||||
const titleOverlayStyle: React.CSSProperties | null =
|
||||
titleConfig?.title && containerWidth > 0
|
||||
? (() => {
|
||||
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 titleOverlayStyle: React.CSSProperties | null = titleConfig?.title
|
||||
? {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
color: titleConfig.color || "#ffffff",
|
||||
fontFamily: titleConfig.font || "思源黑体",
|
||||
fontSize: `${(titleConfig.size || 36) * 0.55}px`, // 预览等比缩
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
textAlign: "center",
|
||||
width: "90%",
|
||||
padding: "4px 8px",
|
||||
textShadow: titleConfig.shadow ? "0 2px 4px rgba(0,0,0,0.8)" : undefined,
|
||||
WebkitTextStroke: titleConfig.stroke ? "1.5px #000" : undefined,
|
||||
...(titleConfig.position === "top"
|
||||
? { top: 8 }
|
||||
: titleConfig.position === "bottom"
|
||||
? { bottom: 8 }
|
||||
: { top: "50%", transform: "translateX(-50%) translateY(-50%)" }),
|
||||
}
|
||||
: null
|
||||
|
||||
const handleTitlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !previewContainerRef.current) return
|
||||
@@ -172,10 +105,7 @@ export function PanelLipsyncPreview({
|
||||
const rect = previewContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
// 发送百分比坐标(0-100),与后端 drawtext 百分比表达式对齐
|
||||
const xpct = Math.round((relX / rect.width) * 1000) / 10
|
||||
const ypct = Math.round((relY / rect.height) * 1000) / 10
|
||||
onTitlePositionChange({ pos_x: xpct, pos_y: ypct, position: "custom" })
|
||||
onTitlePositionChange({ pos_x: relX, pos_y: relY })
|
||||
}
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
}
|
||||
@@ -244,7 +174,7 @@ export function PanelLipsyncPreview({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 对口型预览(标题字号按 previewScale 动态缩放) ─ */}
|
||||
{/* ── 对口型预览(v3.1: 缩放1/2 + 标题叠加) ─ */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">对口型预览</div>
|
||||
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
* AI数字人 — 出镜视频选择面板
|
||||
* - 未选视频:虚线上传区,点击打开素材库弹窗
|
||||
* - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮
|
||||
*
|
||||
* 注意:本面板只展示原始素材视频,不叠加标题(标题在对口型预览和最终成片上展示)
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
import { getFontFamily } from "@/pages/generate/constants"
|
||||
|
||||
export interface PanelVideoSelectorProps {
|
||||
selectedVideo: AssetItem | null
|
||||
/** 触发打开素材库弹窗 */
|
||||
onSelectVideo: () => void
|
||||
onRemoveVideo: () => void
|
||||
titleConfig?: AiAvatarTitleConfig
|
||||
}
|
||||
|
||||
/** 格式化时长(秒 → mm:ss) */
|
||||
@@ -26,6 +27,7 @@ export function PanelVideoSelector({
|
||||
selectedVideo,
|
||||
onSelectVideo,
|
||||
onRemoveVideo,
|
||||
titleConfig,
|
||||
}: PanelVideoSelectorProps) {
|
||||
/* 未选视频:虚线上传区,点击打开素材库弹窗 */
|
||||
if (!selectedVideo) {
|
||||
@@ -55,13 +57,42 @@ export function PanelVideoSelector({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 竖屏 9:16 视频预览播放器(纯素材预览,不叠加标题) */}
|
||||
<div className="aa-video-preview">
|
||||
{/* 竖屏 9:16 视频预览播放器 + 标题实时预览 */}
|
||||
<div className="aa-video-preview" style={{ position: "relative" }}>
|
||||
{fileUrl ? (
|
||||
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
|
||||
) : (
|
||||
<div className="aa-video-preview__placeholder">视频暂不可预览</div>
|
||||
)}
|
||||
{titleConfig?.title && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
...(titleConfig.position === "top"
|
||||
? { top: "10%" }
|
||||
: titleConfig.position === "bottom"
|
||||
? { bottom: "10%" }
|
||||
: { top: "50%", transform: "translate(-50%, -50%)" }),
|
||||
fontSize: Math.max(titleConfig.size, 32),
|
||||
fontFamily: getFontFamily(titleConfig.font),
|
||||
color: titleConfig.color,
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
textShadow: "0 2px 4px rgba(0,0,0,0.5)",
|
||||
WebkitTextStroke: "2px #000",
|
||||
pointerEvents: "none",
|
||||
zIndex: 10,
|
||||
maxWidth: "90%",
|
||||
textAlign: "center",
|
||||
whiteSpace: "pre-wrap",
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
VOICE_EMOTION_OPTIONS,
|
||||
PRESET_VOICE_LANGUAGE_OPTIONS,
|
||||
CLONE_VOICE_LANGUAGE_OPTIONS,
|
||||
VOICE_LANGUAGE_OPTIONS,
|
||||
} from "../types"
|
||||
|
||||
interface PanelVoiceSelectorProps {
|
||||
@@ -92,12 +91,6 @@ export function PanelVoiceSelector({
|
||||
|
||||
const NO_PREVIEW_TIP = "该音色暂无试听音频,请先用此音色生成一段配音后再试听"
|
||||
|
||||
// 系统预置音色仅支持 zh/en;克隆音色支持全语言
|
||||
const languageOptions =
|
||||
voiceSource === "clone" ? CLONE_VOICE_LANGUAGE_OPTIONS : PRESET_VOICE_LANGUAGE_OPTIONS
|
||||
// 当前语言不在可选列表(切回预置时 ja/ko/cantonese/mandarin 失效)→ 自动回退到中文
|
||||
const effectiveLanguage = languageOptions.some((o) => o.value === language) ? language : "zh"
|
||||
|
||||
/** 用指定 URL 真实播放(抽取公共) */
|
||||
const playAudioUrl = (voiceId: string, url: string) => {
|
||||
// 临时兼容:后端 /tts/preview 返回 HTTP URL,staging 是 HTTPS,Mixed Content 会阻止加载
|
||||
@@ -146,6 +139,13 @@ export function PanelVoiceSelector({
|
||||
}
|
||||
const targetId = voice.voice_clone_profile_id || voice.id
|
||||
// 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)
|
||||
try {
|
||||
const res = await previewTts({
|
||||
@@ -154,6 +154,10 @@ export function PanelVoiceSelector({
|
||||
speed: speed, // 透传用户选择的语速(#1822)
|
||||
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
|
||||
})
|
||||
console.log("[AI数字人-克隆试听] previewTts 响应:", {
|
||||
audio_url: res.audio_url?.substring(0, 80),
|
||||
duration: res.duration,
|
||||
})
|
||||
if (!res.audio_url) {
|
||||
setPreviewingId(null)
|
||||
message.error("合成试听失败:未返回音频")
|
||||
@@ -296,12 +300,10 @@ export function PanelVoiceSelector({
|
||||
<select
|
||||
id="aa-voice-language"
|
||||
className="aa-select"
|
||||
value={effectiveLanguage}
|
||||
onChange={(e) => {
|
||||
onLanguageChange(e.target.value as VoiceLanguage)
|
||||
}}
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value as VoiceLanguage)}
|
||||
>
|
||||
{languageOptions.map((opt) => (
|
||||
{VOICE_LANGUAGE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* AI数字人 — 页面全局状态管理 hook(v3 + #1845 配音前置)
|
||||
* AI数字人 — 页面全局状态管理 hook(v3)
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
@@ -13,19 +13,10 @@ import {
|
||||
type BRollSegment,
|
||||
type AiAvatarTitleConfig,
|
||||
type AiAvatarCoverConfig,
|
||||
type TtsPreviewResult,
|
||||
DEFAULT_TITLE_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
|
||||
const DEFAULT_TTS_PREVIEW: TtsPreviewResult = {
|
||||
audioUrl: null,
|
||||
duration: 0,
|
||||
sentenceTimings: [],
|
||||
status: "idle",
|
||||
error: null,
|
||||
}
|
||||
|
||||
export function useAiAvatar() {
|
||||
/* ── 面板1:出镜视频 ── */
|
||||
const [selectedVideo, setSelectedVideo] = useState<AssetItem | null>(null)
|
||||
@@ -34,9 +25,9 @@ export function useAiAvatar() {
|
||||
/* ── 面板2:配音库 ── */
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
|
||||
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("neutral")
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("natural")
|
||||
const [speed, setSpeed] = useState(1.0)
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("zh")
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("mandarin")
|
||||
|
||||
/* ── 面板3:文案 & 对口型 ── */
|
||||
const [script, setScript] = useState<Script | null>(null)
|
||||
@@ -45,9 +36,6 @@ export function useAiAvatar() {
|
||||
const [showScriptModal, setShowScriptModal] = useState(false)
|
||||
const [showBRollModal, setShowBRollModal] = useState(false)
|
||||
|
||||
/* ── #1845 TTS 预合成(步骤1「生成配音」) ── */
|
||||
const [ttsPreview, setTtsPreview] = useState<TtsPreviewResult>(DEFAULT_TTS_PREVIEW)
|
||||
|
||||
/* ── 面板3.5:B-roll ── */
|
||||
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
|
||||
|
||||
@@ -93,7 +81,6 @@ export function useAiAvatar() {
|
||||
setScript(null)
|
||||
setScriptText("")
|
||||
setLipsyncJob(null)
|
||||
setTtsPreview(DEFAULT_TTS_PREVIEW)
|
||||
setBRollSegments([])
|
||||
setTitleConfig(DEFAULT_TITLE_CONFIG)
|
||||
setCoverConfig(DEFAULT_COVER_CONFIG)
|
||||
@@ -131,10 +118,6 @@ export function useAiAvatar() {
|
||||
showBRollModal,
|
||||
setShowBRollModal,
|
||||
selectScript,
|
||||
// #1845 TTS 预合成
|
||||
ttsPreview,
|
||||
setTtsPreview,
|
||||
resetTtsPreview: useCallback(() => setTtsPreview(DEFAULT_TTS_PREVIEW), []),
|
||||
// B-roll
|
||||
bRollSegments,
|
||||
addBRollSegment,
|
||||
|
||||
@@ -6,56 +6,28 @@ import type { AssetItem } from "@/api/assets"
|
||||
/* ── 音色来源切换 ── */
|
||||
export type VoiceSource = "preset" | "clone"
|
||||
|
||||
/* ── 情绪(对齐 CosyVoice 7 种情绪) ── */
|
||||
export type VoiceEmotion =
|
||||
"neutral" | "happy" | "sad" | "angry" | "surprised" | "fearful" | "disgusted"
|
||||
/* ── 情绪 ── */
|
||||
export type VoiceEmotion = "natural" | "excited" | "calm" | "friendly"
|
||||
|
||||
export const VOICE_EMOTION_OPTIONS: { value: VoiceEmotion; label: string }[] = [
|
||||
{ value: "neutral", label: "自然" },
|
||||
{ value: "happy", label: "开心" },
|
||||
{ value: "sad", label: "难过" },
|
||||
{ value: "angry", label: "生气" },
|
||||
{ value: "surprised", label: "惊讶" },
|
||||
{ value: "fearful", label: "恐惧" },
|
||||
{ value: "disgusted", label: "厌恶" },
|
||||
{ value: "natural", label: "自然" },
|
||||
{ value: "excited", label: "兴奋" },
|
||||
{ value: "calm", label: "沉稳" },
|
||||
{ value: "friendly", label: "亲切" },
|
||||
]
|
||||
|
||||
/* ── 语言 ── */
|
||||
/** 系统预置音色支持的语言(zh/en) */
|
||||
export type PresetVoiceLanguage = "zh" | "en"
|
||||
/** 克隆音色支持的完整语言列表 */
|
||||
export type CloneVoiceLanguage = "zh" | "en" | "ja" | "ko"
|
||||
export type VoiceLanguage = PresetVoiceLanguage | CloneVoiceLanguage
|
||||
export type VoiceLanguage = "mandarin" | "english" | "cantonese"
|
||||
|
||||
export const PRESET_VOICE_LANGUAGE_OPTIONS: { value: PresetVoiceLanguage; label: string }[] = [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
export const VOICE_LANGUAGE_OPTIONS: { value: VoiceLanguage; label: string }[] = [
|
||||
{ value: "mandarin", label: "普通话" },
|
||||
{ value: "english", label: "English" },
|
||||
{ value: "cantonese", label: "粤语" },
|
||||
]
|
||||
|
||||
export const CLONE_VOICE_LANGUAGE_OPTIONS: { value: CloneVoiceLanguage; label: string }[] = [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "ko", label: "한국어" },
|
||||
]
|
||||
|
||||
/** 默认(预置音色)语言选项 */
|
||||
export const VOICE_LANGUAGE_OPTIONS = PRESET_VOICE_LANGUAGE_OPTIONS
|
||||
|
||||
/* ── 对口型任务状态 ── */
|
||||
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 {
|
||||
id: string
|
||||
@@ -72,23 +44,12 @@ export interface LipsyncJob {
|
||||
status: LipsyncStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
/** 对口型成片总时长(秒),后端返回 */
|
||||
script_text: string
|
||||
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
|
||||
output_duration?: number
|
||||
/** 精确句子时间戳(后端基于 TTS 音频静音检测计算) */
|
||||
sentence_timings?: SentenceTiming[] | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/* ── 句子时间戳(后端精确计算) ── */
|
||||
export interface SentenceTiming {
|
||||
index: number
|
||||
text: string
|
||||
start_time: number
|
||||
end_time: number
|
||||
}
|
||||
|
||||
/* ── B-roll 画面插入 ── */
|
||||
export type BRollInsertMode = "fullscreen" | "pip"
|
||||
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
@@ -116,7 +77,7 @@ export interface AiAvatarTitleConfig {
|
||||
shadow: boolean
|
||||
color: string
|
||||
auto_subtitle: boolean
|
||||
/** 自定义位置坐标(position=custom 时生效,百分比 0-100) */
|
||||
/** 自定义位置坐标(position=custom 时生效,像素) */
|
||||
pos_x?: number
|
||||
pos_y?: number
|
||||
}
|
||||
@@ -140,7 +101,6 @@ export interface RenderJob {
|
||||
status: RenderStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
output_cover_url: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -150,7 +110,7 @@ export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
title: "",
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 48,
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
|
||||
@@ -6,50 +6,21 @@
|
||||
*/
|
||||
import type { AiAvatarTitleConfig, AiAvatarCoverConfig, VoiceEmotion } from "../types"
|
||||
|
||||
/* ── 情绪:中文/旧枚举 → CosyVoice 7 种英文枚举 ── */
|
||||
const EMOTION_ALIAS: Record<string, VoiceEmotion> = {
|
||||
// 新英文枚举
|
||||
neutral: "neutral",
|
||||
happy: "happy",
|
||||
sad: "sad",
|
||||
angry: "angry",
|
||||
surprised: "surprised",
|
||||
fearful: "fearful",
|
||||
disgusted: "disgusted",
|
||||
// 旧英文枚举(4 种,向前兼容)
|
||||
natural: "neutral",
|
||||
excited: "happy",
|
||||
calm: "neutral",
|
||||
friendly: "happy",
|
||||
// 中文
|
||||
自然: "neutral",
|
||||
开心: "happy",
|
||||
难过: "sad",
|
||||
生气: "angry",
|
||||
惊讶: "surprised",
|
||||
恐惧: "fearful",
|
||||
厌恶: "disgusted",
|
||||
// 旧中文
|
||||
兴奋: "happy",
|
||||
沉稳: "neutral",
|
||||
亲切: "happy",
|
||||
/* ── 情绪:中文 → 英文(防御性映射;state 默认已是英文) ── */
|
||||
const EMOTION_ZH_TO_EN: Record<string, VoiceEmotion> = {
|
||||
自然: "natural",
|
||||
兴奋: "excited",
|
||||
沉稳: "calm",
|
||||
亲切: "friendly",
|
||||
}
|
||||
const VALID_EMOTIONS: VoiceEmotion[] = [
|
||||
"neutral",
|
||||
"happy",
|
||||
"sad",
|
||||
"angry",
|
||||
"surprised",
|
||||
"fearful",
|
||||
"disgusted",
|
||||
]
|
||||
const VALID_EMOTIONS: VoiceEmotion[] = ["natural", "excited", "calm", "friendly"]
|
||||
|
||||
/** 归一化为后端英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted;非法/空值回退 neutral。 */
|
||||
/** 归一化为后端英文枚举 natural/excited/calm/friendly;非法/空值回退 natural。 */
|
||||
export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
|
||||
if (!raw) return "neutral"
|
||||
if (!raw) return "natural"
|
||||
const v = raw.trim()
|
||||
if ((VALID_EMOTIONS as string[]).includes(v)) return v as VoiceEmotion
|
||||
return EMOTION_ALIAS[v] ?? "neutral"
|
||||
return EMOTION_ZH_TO_EN[v] ?? "natural"
|
||||
}
|
||||
|
||||
/* ── 标题:前端 state → 后端 build_title_drawtext_filter 字段(单个 title_config dict) ── */
|
||||
@@ -57,13 +28,10 @@ export function normalizeEmotion(raw: string | undefined | null): VoiceEmotion {
|
||||
* 后端真实字段:text(或content)、font(或font_preset)、font_size(或size)、
|
||||
* font_color(或color,可传 #RRGGBB)、position(top/center/bottom/custom)、
|
||||
* 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 跑到画面顶部)。
|
||||
*/
|
||||
export function buildTitleConfigPayload(
|
||||
cfg: AiAvatarTitleConfig,
|
||||
titleImageDataUrl?: string | null,
|
||||
): Record<string, unknown> {
|
||||
export function buildTitleConfigPayload(cfg: AiAvatarTitleConfig): Record<string, unknown> {
|
||||
const text = (cfg.title || "").trim()
|
||||
if (!text) return {}
|
||||
const position = cfg.position || "bottom"
|
||||
@@ -71,7 +39,7 @@ export function buildTitleConfigPayload(
|
||||
text,
|
||||
enabled: true,
|
||||
font: cfg.font || "思源黑体",
|
||||
font_size: Math.round(cfg.size) || 48,
|
||||
font_size: Math.round(cfg.size) || 36,
|
||||
font_color: cfg.color || "#ffffff",
|
||||
position,
|
||||
bold: !!cfg.bold,
|
||||
@@ -85,10 +53,6 @@ export function buildTitleConfigPayload(
|
||||
payload.pos_x = cfg.pos_x
|
||||
payload.pos_y = cfg.pos_y
|
||||
}
|
||||
// 前端 Canvas 渲染好的 PNG dataURL(所见即所得,后端优先 overlay 此图片图层)
|
||||
if (titleImageDataUrl) {
|
||||
payload.title_image_dataurl = titleImageDataUrl
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -103,14 +67,9 @@ export function buildCoverConfigPayload(
|
||||
// build_cover_extract_command 读取 timestamp(截帧秒数)
|
||||
timestamp: cfg.frame_time || 0,
|
||||
}
|
||||
// 智能封面 URL(后端字段名为 url/imageUrl/cover_url 都兼容,优先 url)
|
||||
if (smartCoverUrl) {
|
||||
payload.url = smartCoverUrl
|
||||
payload.cover_url = smartCoverUrl
|
||||
}
|
||||
if (smartCoverUrl) payload.cover_url = smartCoverUrl
|
||||
// 自定义上传:blob: 本地预览地址无法给后端,仅 OSS URL 可用
|
||||
if (cfg.mode === "upload" && cfg.upload_url && !cfg.upload_url.startsWith("blob:")) {
|
||||
payload.url = cfg.upload_url
|
||||
payload.upload_url = cfg.upload_url
|
||||
}
|
||||
return payload
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
/**
|
||||
* AI数字人 — 文案分句 & B-roll 时间计算
|
||||
*
|
||||
* 数据来源优先级:
|
||||
* 1. 后端 sentence_timings(基于 TTS 音频静音检测,精确到句子边界)—— 直接使用,不重新分句
|
||||
* 2. 后端 output_duration(最终渲染视频时长) + 本地分句 —— 按字数比例估算
|
||||
* 3. 两者都没有(对口型还在生成中)—— 返回分句文本但 startTime/endTime 全部 0,等数据到位重算
|
||||
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
|
||||
*/
|
||||
|
||||
export interface ScriptSentence {
|
||||
@@ -16,67 +11,25 @@ export interface ScriptSentence {
|
||||
charCount: number
|
||||
/** 累计起始字数(用于时间估算) */
|
||||
startChar: number
|
||||
/** 对口型视频内起始时间(秒)——后端精确值或前端估算 */
|
||||
/** 估算的对口型视频内起始时间(秒) */
|
||||
startTime: number
|
||||
/** 对口型视频内结束时间(秒)——后端精确值或前端估算 */
|
||||
/** 估算的对口型视频内结束时间(秒) */
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/** 句子分隔符:中英文句号/问号/感叹号/分号/换行(按句断,不在逗号处切;保持与后端一致) */
|
||||
const SENTENCE_SPLIT_RE = /[。!?!??!;;\n\r]+/
|
||||
|
||||
/**
|
||||
* 分句并计算每句的起止时间。
|
||||
*
|
||||
* @param sentenceTimings 后端返回的精确句子时间戳(来自 lipsync_job.sentence_timings)。
|
||||
* 非空时直接按后端返回的句子列表渲染,不再本地分句(避免前后端分句不一致导致时间错位)。
|
||||
* @param outputDuration 最终视频时长(秒)。对口型预览阶段可能为 0,此时降级估算只能给 0。
|
||||
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
|
||||
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
|
||||
*/
|
||||
export function splitScriptIntoSentences(
|
||||
scriptText: string,
|
||||
sentenceTimings?:
|
||||
{ index?: number; text?: string; start_time: number; end_time: number }[] | null,
|
||||
outputDuration: number = 0,
|
||||
outputDuration: number,
|
||||
): ScriptSentence[] {
|
||||
const text = (scriptText || "").trim()
|
||||
if (!text) return []
|
||||
|
||||
// 1. 后端返回了 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
|
||||
.split(SENTENCE_SPLIT_RE)
|
||||
.split(/[。!?!?;;\n\r]+/)
|
||||
.map((part) => part.trim())
|
||||
.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
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 模板编辑器 — 制作/编辑剪辑模板
|
||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 全局配置 → hooks/useGlobalSettings
|
||||
* 配音素材 → hooks/useVoiceMaterials
|
||||
* 撤销重做 → hooks/useUndoRedo
|
||||
* 抽屉管理 → hooks/useEditorDrawers
|
||||
* 播放控制 → hooks/usePlaybackControl
|
||||
* 片段操作 → hooks/useClipOperations
|
||||
* 模板管理 → hooks/useTemplateManagement
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import { MODE_LIST } from "./constants"
|
||||
import MediaPanel from "./components/MediaPanel"
|
||||
import PreviewPlayer from "./components/PreviewPlayer"
|
||||
import TimelinePanel from "./components/TimelinePanel"
|
||||
import TopBar from "./components/TopBar"
|
||||
import ModeBar from "./components/ModeBar"
|
||||
import RightPanel from "./components/RightPanel"
|
||||
import StatusBar from "./components/StatusBar"
|
||||
import EditorDrawers from "./components/EditorDrawers"
|
||||
import SaveModal from "./components/SaveModal"
|
||||
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo"
|
||||
import { useEditorDrawers } from "./hooks/useEditorDrawers"
|
||||
import { usePlaybackControl } from "./hooks/usePlaybackControl"
|
||||
import { useClipOperations } from "./hooks/useClipOperations"
|
||||
import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement"
|
||||
import { useGlobalSettings } from "./hooks/useGlobalSettings"
|
||||
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
||||
|
||||
import type { ClipData } from "./types"
|
||||
import "./EditingPlanner.css"
|
||||
|
||||
const EditingPlanner: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const urlTemplateId = searchParams.get("templateId") || ""
|
||||
const urlPlanId = searchParams.get("planId") || ""
|
||||
|
||||
/* ── 片段(撤销/重做) ── */
|
||||
const {
|
||||
state: clips,
|
||||
set: setClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
reset: resetClips,
|
||||
} = useUndoRedo<ClipData[]>([])
|
||||
|
||||
/* ── 全局配置 ── */
|
||||
const {
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
subtitleSettings,
|
||||
setSubtitleSettings,
|
||||
bgmSettings,
|
||||
setBgmSettings,
|
||||
watermarkSettings,
|
||||
setWatermarkSettings,
|
||||
introOutroSettings,
|
||||
setIntroOutroSettings,
|
||||
pipSettings,
|
||||
setPipSettings,
|
||||
filterSettings,
|
||||
setFilterSettings,
|
||||
chromaKeySettings,
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
} = useGlobalSettings()
|
||||
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||
|
||||
/* ── 配音素材 ── */
|
||||
const {
|
||||
voiceMaterials,
|
||||
loading: voiceMaterialsLoading,
|
||||
refetch: refetchVoiceMaterials,
|
||||
} = useVoiceMaterials()
|
||||
|
||||
/* ── 派生计算 ── */
|
||||
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0)
|
||||
|
||||
/* ── Hook: 抽屉管理 ── */
|
||||
const drawers = useEditorDrawers()
|
||||
|
||||
/* ── Hook: 播放控制 ── */
|
||||
const playback = usePlaybackControl(totalDuration)
|
||||
|
||||
/* ── Hook: 片段操作 ── */
|
||||
const clipOps = useClipOperations({ clips, setClips })
|
||||
|
||||
/* ── Hook: 模板管理 ── */
|
||||
const tpl = useTemplateManagement({
|
||||
urlTemplateId,
|
||||
urlPlanId,
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId: clipOps.setSelectedClipId,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
watermarkSettings,
|
||||
introOutroSettings,
|
||||
pipSettings,
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
})
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
return (
|
||||
<div className="ep-v8-root">
|
||||
{/* ═══ 第1行:顶栏 42px ═══ */}
|
||||
<TopBar
|
||||
currentTemplate={tpl.currentTemplate}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onOpenSaveModal={tpl.handleOpenSaveModal}
|
||||
/>
|
||||
|
||||
{/* ═══ 第2行:模式栏 56px ═══ */}
|
||||
<ModeBar
|
||||
modeList={MODE_LIST}
|
||||
currentMode={tpl.currentMode}
|
||||
onModeChange={tpl.handleModeChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 第3行:三栏主体 ═══ */}
|
||||
<div className="ep-main-body">
|
||||
{/* 左栏 220px:模板列表 */}
|
||||
<MediaPanel
|
||||
templates={tpl.filteredTemplates}
|
||||
loading={tpl.loadingTemplates}
|
||||
searchQuery={tpl.searchQuery}
|
||||
currentFilter={tpl.currentFilter}
|
||||
filterCategories={FILTER_CATEGORIES}
|
||||
loadedTemplateId={tpl.loadedTemplateId}
|
||||
onLoadTemplate={tpl.handleLoadTemplate}
|
||||
onSearchChange={tpl.setSearchQuery}
|
||||
onFilterChange={tpl.setCurrentFilter}
|
||||
/>
|
||||
|
||||
{/* 中栏 flex-1 */}
|
||||
<div className="ep-center-col">
|
||||
{/* 上半部:视频预览 + 封面预览 */}
|
||||
<PreviewPlayer
|
||||
clips={clips}
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
isPlaying={playback.isPlaying}
|
||||
titleConfig={titleConfig}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
}}
|
||||
onClipSelect={clipOps.handleClipSelect}
|
||||
onPlayPause={() => playback.setIsPlaying(!playback.isPlaying)}
|
||||
/>
|
||||
|
||||
{/* 下半部:水平时间线 */}
|
||||
<TimelinePanel
|
||||
clips={clips}
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
currentMode={tpl.currentMode}
|
||||
onClipSelect={clipOps.handleClipSelect}
|
||||
onClipReorder={clipOps.handleClipReorder}
|
||||
onClipRemove={clipOps.handleClipRemove}
|
||||
onAddClip={clipOps.handleAddClip}
|
||||
onClipTrim={clipOps.handleClipTrim}
|
||||
onClipSplit={clipOps.handleClipSplit}
|
||||
onClipResetTrim={clipOps.handleClipResetTrim}
|
||||
currentTime={playback.currentTime}
|
||||
pixelsPerSecond={playback.pixelsPerSecond}
|
||||
onZoomChange={playback.handleZoomChange}
|
||||
onSeek={playback.handleSeek}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右栏 260px:设置面板 */}
|
||||
<RightPanel
|
||||
titleConfig={titleConfig}
|
||||
onTitleConfigChange={setTitleConfig}
|
||||
rightTab={rightTab}
|
||||
onTabChange={setRightTab}
|
||||
selectedClip={clipOps.selectedClip}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={tpl.currentMode}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))}
|
||||
onClipUpdate={clipOps.handleClipUpdate}
|
||||
onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onRefreshVoiceMaterials={refetchVoiceMaterials}
|
||||
onClipVoiceSelect={clipOps.handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={drawers.openTransitionDrawer}
|
||||
onOpenSpeedDrawer={drawers.openSpeedDrawer}
|
||||
onOpenTtsDrawer={drawers.openTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => drawers.setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => drawers.setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => drawers.setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => drawers.setStickerDrawerOpen(true)}
|
||||
clips={clips}
|
||||
selectedClipId={clipOps.selectedClipId}
|
||||
onClipSelect={clipOps.handleClipSelect}
|
||||
onClipMoveUp={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId)
|
||||
if (idx > 0) clipOps.handleClipReorder(idx, idx - 1)
|
||||
}}
|
||||
onClipMoveDown={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId)
|
||||
if (idx < clips.length - 1) clipOps.handleClipReorder(idx, idx + 1)
|
||||
}}
|
||||
onClipRemove={clipOps.handleClipRemove}
|
||||
onClipAdd={() => clipOps.handleAddClip("pip", 3)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第4行:底栏 40px ═══ */}
|
||||
<StatusBar
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentModeLabel={MODE_LABELS[tpl.currentMode]}
|
||||
templateSegments={tpl.currentTemplate?.segments.length || 0}
|
||||
/>
|
||||
|
||||
{/* ═══ 弹窗 ═══ */}
|
||||
<SaveModal
|
||||
open={tpl.saveModalOpen}
|
||||
loading={tpl.saveLoading}
|
||||
isUpdate={!!tpl.loadedTemplateId}
|
||||
draftName={tpl.draftName}
|
||||
draftCategory={tpl.draftCategory}
|
||||
draftTags={tpl.draftTags}
|
||||
categories={tpl.categories}
|
||||
estimatedDuration={totalDuration}
|
||||
onNameChange={tpl.setDraftName}
|
||||
onCategoryChange={tpl.setDraftCategory}
|
||||
onTagsChange={tpl.setDraftTags}
|
||||
onSave={tpl.handleSave}
|
||||
onCancel={() => tpl.setSaveModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ═══ Drawer 集合 ═══ */}
|
||||
<EditorDrawers
|
||||
bgmDrawerOpen={drawers.bgmDrawerOpen}
|
||||
bgmSettings={bgmSettings}
|
||||
onBgmSettingsChange={setBgmSettings}
|
||||
onCloseBgmDrawer={() => drawers.setBgmDrawerOpen(false)}
|
||||
subtitleDrawerOpen={drawers.subtitleDrawerOpen}
|
||||
subtitleSettings={subtitleSettings}
|
||||
onSubtitleSettingsChange={setSubtitleSettings}
|
||||
onCloseSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(false)}
|
||||
transitionDrawerOpen={drawers.transitionDrawerOpen}
|
||||
transitionTargetClipId={drawers.transitionTargetClipId}
|
||||
clips={clips}
|
||||
onTransitionChange={(config) =>
|
||||
clipOps.handleTransitionChange(drawers.transitionTargetClipId, config)
|
||||
}
|
||||
onCloseTransitionDrawer={() => drawers.setTransitionDrawerOpen(false)}
|
||||
speedDrawerOpen={drawers.speedDrawerOpen}
|
||||
speedTargetClipId={drawers.speedTargetClipId}
|
||||
onSpeedChange={(config) => clipOps.handleSpeedChange(drawers.speedTargetClipId, config)}
|
||||
onApplySpeedAll={clipOps.handleApplySpeedAll}
|
||||
onCloseSpeedDrawer={() => drawers.setSpeedDrawerOpen(false)}
|
||||
ttsDrawerOpen={drawers.ttsDrawerOpen}
|
||||
ttsTargetClipId={drawers.ttsTargetClipId}
|
||||
onTtsChange={(config) => clipOps.handleTtsChange(drawers.ttsTargetClipId, config)}
|
||||
onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)}
|
||||
watermarkDrawerOpen={drawers.watermarkDrawerOpen}
|
||||
watermarkSettings={watermarkSettings}
|
||||
onWatermarkChange={setWatermarkSettings}
|
||||
onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)}
|
||||
introOutroDrawerOpen={drawers.introOutroDrawerOpen}
|
||||
introOutroSettings={introOutroSettings}
|
||||
onIntroOutroChange={setIntroOutroSettings}
|
||||
onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)}
|
||||
pipDrawerOpen={drawers.pipDrawerOpen}
|
||||
pipSettings={pipSettings}
|
||||
totalDuration={totalDuration}
|
||||
onPipChange={setPipSettings}
|
||||
onClosePipDrawer={() => drawers.setPipDrawerOpen(false)}
|
||||
filterDrawerOpen={drawers.filterDrawerOpen}
|
||||
filterSettings={filterSettings}
|
||||
onFilterChange={setFilterSettings}
|
||||
onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)}
|
||||
chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen}
|
||||
chromaKeySettings={chromaKeySettings}
|
||||
onChromaKeyChange={setChromaKeySettings}
|
||||
onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)}
|
||||
stickerDrawerOpen={drawers.stickerDrawerOpen}
|
||||
stickerSettings={stickerSettings}
|
||||
onStickerChange={setStickerSettings}
|
||||
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditingPlanner
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* BGM 选择器入口(向后兼容)
|
||||
* 实际实现位于 ./bgm-selector/ 目录
|
||||
*/
|
||||
export { default } from "./bgm-selector"
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 右栏设置面板 — V8 原型 1:1 还原
|
||||
* 字幕设置 + BGM设置 + 片段详情
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
|
||||
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
|
||||
import TitleSettingsSection from "./clip-properties/TitleSettingsSection"
|
||||
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
|
||||
import ClipDetailSection from "./clip-properties/ClipDetailSection"
|
||||
import StatsSection from "./clip-properties/StatsSection"
|
||||
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
titleConfig,
|
||||
onTitleConfigChange,
|
||||
selectedClip,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange: _onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
onOpenBgmDrawer,
|
||||
onOpenSubtitleDrawer,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
onOpenWatermarkDrawer,
|
||||
onOpenIntroOutroDrawer,
|
||||
onOpenPipDrawer,
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
}) => {
|
||||
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
|
||||
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 — #1789 ═══ */}
|
||||
{titleConfig && onTitleConfigChange && (
|
||||
<TitleSettingsSection config={titleConfig} onChange={onTitleConfigChange} />
|
||||
)}
|
||||
|
||||
{/* ═══ 字幕设置 ═══ */}
|
||||
<SubtitleSettingsSection
|
||||
settings={subtitleSettings}
|
||||
onChange={onSubtitleSettingsChange}
|
||||
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 设置 ═══ */}
|
||||
<BgmSettingsSection settings={bgmSettings} onOpenBgmDrawer={onOpenBgmDrawer} />
|
||||
|
||||
{/* ═══ 水印设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🔖</span>
|
||||
水印设置
|
||||
</div>
|
||||
{onOpenWatermarkDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🔖</span>
|
||||
<span className="ep-advanced-btn-label">水印配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片头片尾设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎬</span>
|
||||
片头片尾
|
||||
</div>
|
||||
{onOpenIntroOutroDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">片头片尾配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 混剪 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
混剪
|
||||
</div>
|
||||
{onOpenPipDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">配置混剪图层</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 滤镜调色 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎨</span>
|
||||
滤镜调色
|
||||
</div>
|
||||
{onOpenFilterDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎨</span>
|
||||
<span className="ep-advanced-btn-label">配置滤镜调色</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 绿幕抠像 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🟩</span>
|
||||
绿幕抠像
|
||||
</div>
|
||||
{onOpenGreenScreenDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🟩</span>
|
||||
<span className="ep-advanced-btn-label">配置绿幕抠像</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 贴纸 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🏷️</span>
|
||||
贴纸
|
||||
</div>
|
||||
{onOpenStickerDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🏷️</span>
|
||||
<span className="ep-advanced-btn-label">配置贴纸花字</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<ClipDetailSection
|
||||
clip={selectedClip}
|
||||
currentMode={currentMode}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
|
||||
onClipVoiceSelect={onClipVoiceSelect}
|
||||
onOpenTransitionDrawer={onOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={onOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={onOpenTtsDrawer}
|
||||
previewingId={previewingId}
|
||||
onPreviewVoice={handlePreviewVoice}
|
||||
onStopPreview={stopPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ 统计信息(未选中时显示) ═══ */}
|
||||
{!selectedClip && (
|
||||
<StatsSection
|
||||
clipsCount={clipsCount}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipPropertiesPanel
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 所有弹窗和 Drawer 组件的集合
|
||||
* 纯渲染层,业务逻辑和 state 留在父组件
|
||||
*/
|
||||
import React from "react"
|
||||
import SaveModal from "./SaveModal"
|
||||
import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers"
|
||||
import { GlobalDrawers } from "./editing-drawers/GlobalDrawers"
|
||||
import type { EditingDrawersProps } from "./editing-drawers/types"
|
||||
|
||||
const EditingDrawers: React.FC<EditingDrawersProps> = (props) => {
|
||||
const {
|
||||
saveModalOpen,
|
||||
saveLoading,
|
||||
isUpdate,
|
||||
draftName,
|
||||
draftCategory,
|
||||
draftTags,
|
||||
categories,
|
||||
estimatedDuration,
|
||||
onNameChange,
|
||||
onCategoryChange,
|
||||
onTagsChange,
|
||||
onSave,
|
||||
onCancelSave,
|
||||
clips,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 保存弹窗 */}
|
||||
<SaveModal
|
||||
open={saveModalOpen}
|
||||
loading={saveLoading}
|
||||
isUpdate={isUpdate}
|
||||
draftName={draftName}
|
||||
draftCategory={draftCategory}
|
||||
draftTags={draftTags}
|
||||
categories={categories}
|
||||
estimatedDuration={estimatedDuration}
|
||||
onNameChange={onNameChange}
|
||||
onCategoryChange={onCategoryChange}
|
||||
onTagsChange={onTagsChange}
|
||||
onSave={onSave}
|
||||
onCancel={onCancelSave}
|
||||
/>
|
||||
|
||||
{/* 片段级抽屉(转场/调速/TTS) */}
|
||||
<ClipLevelDrawers
|
||||
clips={clips}
|
||||
transitionDrawerOpen={props.transitionDrawerOpen}
|
||||
transitionTargetClipId={props.transitionTargetClipId}
|
||||
onCloseTransitionDrawer={props.onCloseTransitionDrawer}
|
||||
onTransitionChange={props.onTransitionChange}
|
||||
speedDrawerOpen={props.speedDrawerOpen}
|
||||
speedTargetClipId={props.speedTargetClipId}
|
||||
onCloseSpeedDrawer={props.onCloseSpeedDrawer}
|
||||
onSpeedChange={props.onSpeedChange}
|
||||
onApplySpeedAll={props.onApplySpeedAll}
|
||||
ttsDrawerOpen={props.ttsDrawerOpen}
|
||||
ttsTargetClipId={props.ttsTargetClipId}
|
||||
onCloseTtsDrawer={props.onCloseTtsDrawer}
|
||||
onTtsChange={props.onTtsChange}
|
||||
/>
|
||||
|
||||
{/* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) */}
|
||||
<GlobalDrawers
|
||||
bgmDrawerOpen={props.bgmDrawerOpen}
|
||||
bgmSettings={props.bgmSettings}
|
||||
onCloseBgmDrawer={props.onCloseBgmDrawer}
|
||||
onChangeBgmSettings={props.onChangeBgmSettings}
|
||||
subtitleDrawerOpen={props.subtitleDrawerOpen}
|
||||
subtitleSettings={props.subtitleSettings}
|
||||
onCloseSubtitleDrawer={props.onCloseSubtitleDrawer}
|
||||
onChangeSubtitleSettings={props.onChangeSubtitleSettings}
|
||||
totalDuration={props.totalDuration}
|
||||
watermarkDrawerOpen={props.watermarkDrawerOpen}
|
||||
watermarkSettings={props.watermarkSettings}
|
||||
onCloseWatermarkDrawer={props.onCloseWatermarkDrawer}
|
||||
onWatermarkChange={props.onWatermarkChange}
|
||||
introOutroDrawerOpen={props.introOutroDrawerOpen}
|
||||
introOutroSettings={props.introOutroSettings}
|
||||
onCloseIntroOutroDrawer={props.onCloseIntroOutroDrawer}
|
||||
onIntroOutroChange={props.onIntroOutroChange}
|
||||
pipDrawerOpen={props.pipDrawerOpen}
|
||||
pipSettings={props.pipSettings}
|
||||
onClosePipDrawer={props.onClosePipDrawer}
|
||||
onPipChange={props.onPipChange}
|
||||
filterDrawerOpen={props.filterDrawerOpen}
|
||||
filterSettings={props.filterSettings}
|
||||
onCloseFilterDrawer={props.onCloseFilterDrawer}
|
||||
onFilterChange={props.onFilterChange}
|
||||
chromaKeyDrawerOpen={props.chromaKeyDrawerOpen}
|
||||
chromaKeySettings={props.chromaKeySettings}
|
||||
onCloseChromaKeyDrawer={props.onCloseChromaKeyDrawer}
|
||||
onChromaKeyChange={props.onChromaKeyChange}
|
||||
stickerDrawerOpen={props.stickerDrawerOpen}
|
||||
stickerSettings={props.stickerSettings}
|
||||
onCloseStickerDrawer={props.onCloseStickerDrawer}
|
||||
onStickerChange={props.onStickerChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditingDrawers
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 编辑器右侧栏 — 片段列表 Tab
|
||||
* 紧凑版片段管理:选中、上下移动、删除、添加
|
||||
*/
|
||||
import React from "react"
|
||||
import { Button, Tooltip, Empty } from "antd"
|
||||
import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ScissorOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
|
||||
interface EditorClipListProps {
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
onSelect: (clipId: string) => void
|
||||
onMoveUp: (clipId: string) => void
|
||||
onMoveDown: (clipId: string) => void
|
||||
onRemove: (clipId: string) => void
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
const clipTypeIcon: Record<ClipType | string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
image: <PictureOutlined />,
|
||||
voice: <SoundOutlined />,
|
||||
pip: <ScissorOutlined />,
|
||||
}
|
||||
|
||||
const clipTypeLabel: Record<ClipType | string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
onSelect,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}) => {
|
||||
if (clips.length === 0) {
|
||||
return (
|
||||
<div className="ep-clip-list-empty">
|
||||
<Empty
|
||||
description="暂无片段"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ margin: "40px 0" }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={onAdd}>
|
||||
添加片段
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-clip-list">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="ep-clip-list-toolbar">
|
||||
<span className="ep-clip-list-count">
|
||||
共 <b>{clips.length}</b> 个片段
|
||||
</span>
|
||||
<Tooltip title="添加片段">
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
添加
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 片段列表 */}
|
||||
<div className="ep-clip-list-scroll">
|
||||
{clips.map((clip, index) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-list-item${selectedClipId === clip.id ? " selected" : ""}`}
|
||||
onClick={() => onSelect(clip.id)}
|
||||
>
|
||||
{/* 序号 + 类型图标 */}
|
||||
<div className="ep-clip-item-head">
|
||||
<span className="ep-clip-item-index">{index + 1}</span>
|
||||
<span className="ep-clip-item-type">
|
||||
{clipTypeIcon[clip.type] || <ScissorOutlined />}
|
||||
<span className="ep-clip-item-type-label">
|
||||
{clipTypeLabel[clip.type] || "片段"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文案预览 */}
|
||||
{clip.script_text && (
|
||||
<div className="ep-clip-item-text">
|
||||
{clip.script_text.slice(0, 40)}
|
||||
{clip.script_text.length > 40 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="ep-clip-item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title="上移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => onMoveUp(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="下移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
disabled={index === clips.length - 1}
|
||||
onClick={() => onMoveDown(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemove(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorClipList
|
||||
@@ -0,0 +1,246 @@
|
||||
import React from "react"
|
||||
import BgmSelector from "./BgmSelector"
|
||||
import SubtitleStylePanel from "./SubtitleStylePanel"
|
||||
import TransitionSelector from "./TransitionSelector"
|
||||
import SpeedPanel from "./SpeedPanel"
|
||||
import TtsPanel from "./TtsPanel"
|
||||
import WatermarkPanel from "./WatermarkPanel"
|
||||
import IntroOutroPanel from "./IntroOutroPanel"
|
||||
import PipConfigPanel from "./PipConfigPanel"
|
||||
import FilterPanel from "./FilterPanel"
|
||||
import GreenScreenPanel from "./GreenScreenPanel"
|
||||
import StickerPanel from "./StickerPanel"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
||||
|
||||
interface EditorDrawersProps {
|
||||
// BGM
|
||||
bgmDrawerOpen: boolean
|
||||
bgmSettings: BgmMixConfig
|
||||
onBgmSettingsChange: (config: BgmMixConfig) => void
|
||||
onCloseBgmDrawer: () => void
|
||||
// 字幕
|
||||
subtitleDrawerOpen: boolean
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
onSubtitleSettingsChange: (config: SubtitleStyleConfig) => void
|
||||
onCloseSubtitleDrawer: () => void
|
||||
// 转场
|
||||
transitionDrawerOpen: boolean
|
||||
transitionTargetClipId: string | null
|
||||
clips: ClipData[]
|
||||
onTransitionChange: (config: TransitionConfig) => void
|
||||
onCloseTransitionDrawer: () => void
|
||||
// 调速
|
||||
speedDrawerOpen: boolean
|
||||
speedTargetClipId: string | null
|
||||
onSpeedChange: (config: SpeedConfig) => void
|
||||
onApplySpeedAll: (config: SpeedConfig) => void
|
||||
onCloseSpeedDrawer: () => void
|
||||
// TTS
|
||||
ttsDrawerOpen: boolean
|
||||
ttsTargetClipId: string | null
|
||||
onTtsChange: (config: TtsConfig) => void
|
||||
onCloseTtsDrawer: () => void
|
||||
// 水印
|
||||
watermarkDrawerOpen: boolean
|
||||
watermarkSettings: WatermarkConfig
|
||||
onWatermarkChange: (config: WatermarkConfig) => void
|
||||
onCloseWatermarkDrawer: () => void
|
||||
// 片头片尾
|
||||
introOutroDrawerOpen: boolean
|
||||
introOutroSettings: IntroOutroConfig
|
||||
onIntroOutroChange: (config: IntroOutroConfig) => void
|
||||
onCloseIntroOutroDrawer: () => void
|
||||
// 混剪
|
||||
pipDrawerOpen: boolean
|
||||
pipSettings: PipConfig
|
||||
totalDuration: number
|
||||
onPipChange: (config: PipConfig) => void
|
||||
onClosePipDrawer: () => void
|
||||
// 滤镜
|
||||
filterDrawerOpen: boolean
|
||||
filterSettings: FilterConfig
|
||||
onFilterChange: (config: FilterConfig) => void
|
||||
onCloseFilterDrawer: () => void
|
||||
// 绿幕
|
||||
chromaKeyDrawerOpen: boolean
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
onChromaKeyChange: (config: ChromaKeyConfig) => void
|
||||
onCloseChromaKeyDrawer: () => void
|
||||
// 贴纸
|
||||
stickerDrawerOpen: boolean
|
||||
stickerSettings: StickerConfig
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
onCloseStickerDrawer: () => void
|
||||
}
|
||||
|
||||
const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
bgmDrawerOpen,
|
||||
bgmSettings,
|
||||
onBgmSettingsChange,
|
||||
onCloseBgmDrawer,
|
||||
subtitleDrawerOpen,
|
||||
subtitleSettings,
|
||||
onSubtitleSettingsChange,
|
||||
onCloseSubtitleDrawer,
|
||||
transitionDrawerOpen,
|
||||
transitionTargetClipId,
|
||||
clips,
|
||||
onTransitionChange,
|
||||
onCloseTransitionDrawer,
|
||||
speedDrawerOpen,
|
||||
speedTargetClipId,
|
||||
onSpeedChange,
|
||||
onApplySpeedAll,
|
||||
onCloseSpeedDrawer,
|
||||
ttsDrawerOpen,
|
||||
ttsTargetClipId,
|
||||
onTtsChange,
|
||||
onCloseTtsDrawer,
|
||||
watermarkDrawerOpen,
|
||||
watermarkSettings,
|
||||
onWatermarkChange,
|
||||
onCloseWatermarkDrawer,
|
||||
introOutroDrawerOpen,
|
||||
introOutroSettings,
|
||||
onIntroOutroChange,
|
||||
onCloseIntroOutroDrawer,
|
||||
pipDrawerOpen,
|
||||
pipSettings,
|
||||
totalDuration,
|
||||
onPipChange,
|
||||
onClosePipDrawer,
|
||||
filterDrawerOpen,
|
||||
filterSettings,
|
||||
onFilterChange,
|
||||
onCloseFilterDrawer,
|
||||
chromaKeyDrawerOpen,
|
||||
chromaKeySettings,
|
||||
onChromaKeyChange,
|
||||
onCloseChromaKeyDrawer,
|
||||
stickerDrawerOpen,
|
||||
stickerSettings,
|
||||
onStickerChange,
|
||||
onCloseStickerDrawer,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
|
||||
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* BGM 选择器 Drawer */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={onCloseBgmDrawer}
|
||||
config={bgmSettings}
|
||||
onChange={onBgmSettingsChange}
|
||||
/>
|
||||
|
||||
{/* 字幕样式配置 Drawer */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={onCloseSubtitleDrawer}
|
||||
config={subtitleSettings}
|
||||
onChange={onSubtitleSettingsChange}
|
||||
/>
|
||||
|
||||
{/* 转场特效选择器 Drawer */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={onCloseTransitionDrawer}
|
||||
config={transitionConfig}
|
||||
onChange={onTransitionChange}
|
||||
title={transitionTitle}
|
||||
/>
|
||||
|
||||
{/* 片段调速面板 Drawer */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={onCloseSpeedDrawer}
|
||||
config={clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED}
|
||||
onChange={onSpeedChange}
|
||||
onApplyAll={onApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TTS 配音面板 Drawer */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={onCloseTtsDrawer}
|
||||
config={clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG}
|
||||
onChange={onTtsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 水印配置面板 */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={onCloseWatermarkDrawer}
|
||||
config={watermarkSettings}
|
||||
onChange={onWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* 片头片尾配置面板 */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={onCloseIntroOutroDrawer}
|
||||
config={introOutroSettings}
|
||||
onChange={onIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* 混剪配置面板 */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={onClosePipDrawer}
|
||||
config={pipSettings}
|
||||
onChange={onPipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* 滤镜调色面板 */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={onCloseFilterDrawer}
|
||||
config={filterSettings}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
|
||||
{/* 绿幕抠像面板 */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={onCloseChromaKeyDrawer}
|
||||
config={chromaKeySettings}
|
||||
onChange={onChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* 贴纸面板 */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={onCloseStickerDrawer}
|
||||
config={stickerSettings}
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorDrawers
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 滤镜调色配置面板
|
||||
* 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度)
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { FilterConfig, FilterPreset } from "@/pages/editing-planner/types"
|
||||
import { DEFAULT_FILTER_CONFIG } from "@/pages/editing-planner/types"
|
||||
import { PRESET_GRADIENTS } from "@/pages/editing-planner/constants/filter"
|
||||
import FilterPresetGrid from "./filter/FilterPresetGrid"
|
||||
import FilterManualAdjust from "./filter/FilterManualAdjust"
|
||||
|
||||
interface FilterPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: FilterConfig
|
||||
onChange: (config: FilterConfig) => void
|
||||
}
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: FilterPreset) => {
|
||||
if (preset === "none") {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
|
||||
} else {
|
||||
onChange({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
enabled: config.enabled,
|
||||
preset,
|
||||
})
|
||||
}
|
||||
},
|
||||
[config.enabled, onChange],
|
||||
)
|
||||
|
||||
const handleManualChange = useCallback(
|
||||
(key: keyof FilterConfig, value: number) => {
|
||||
onChange({ ...config, [key]: value })
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="滤镜调色"
|
||||
placement="right"
|
||||
width={420}
|
||||
styles={{ wrapper: { maxWidth: "100vw" } }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="filter-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="filter-header">
|
||||
<span className="filter-header-label">启用滤镜</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设滤镜选择 */}
|
||||
<FilterPresetGrid selectedPreset={config.preset} onPresetSelect={handlePresetSelect} />
|
||||
|
||||
{/* 手动调节 */}
|
||||
<FilterManualAdjust config={config} onChange={handleManualChange} />
|
||||
|
||||
{/* 预览色块 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">效果预览</div>
|
||||
<div
|
||||
className="filter-preview-block"
|
||||
style={{
|
||||
background: PRESET_GRADIENTS[config.preset],
|
||||
filter: [
|
||||
`brightness(${100 + config.brightness}%)`,
|
||||
`contrast(${100 + config.contrast}%)`,
|
||||
`saturate(${100 + config.saturation}%)`,
|
||||
].join(" "),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="filter-footer">
|
||||
<button className="filter-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterPanel
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user