Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 854ef02167 |
@@ -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
|
||||
|
||||
@@ -20,7 +20,6 @@ on:
|
||||
default: "手动触发 - CI漏触发补跑"
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: ci-pipeline-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -89,22 +88,9 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
# 优先用 git diff 判断 PR 改动范围(比 API 稳定)
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
if command -v git >/dev/null 2>&1 && [ -d .git ]; then
|
||||
FILES=$(git diff --name-only origin/develop...HEAD 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "${FILES:-}" ]; then
|
||||
# fallback 到 API
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "${FILES:-}" ]; then
|
||||
echo "⚠️ 无法获取变更文件列表,保守运行完整 CI"
|
||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
@@ -196,7 +182,7 @@ jobs:
|
||||
- name: Run style checks
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_style.sh
|
||||
- name: Auto-fix formatting (black + isort + ruff)
|
||||
- name: Auto-fix formatting (black + isort)
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
@@ -827,6 +813,9 @@ jobs:
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker 与 API/Web 统一走持久 builder(ci-builder-persist),共享宿主机层缓存
|
||||
NO_CACHE_FLAG=""
|
||||
@@ -1022,22 +1011,15 @@ 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}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
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 +1032,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 +1236,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 +1462,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:
|
||||
@@ -1572,6 +1553,9 @@ jobs:
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
|
||||
fi
|
||||
|
||||
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
|
||||
NO_CACHE_FLAG=""
|
||||
@@ -2135,4 +2119,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
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: "Debug: Web container v2 (mount conflict)"
|
||||
on:
|
||||
push:
|
||||
branches: [debug/web-crash-v2]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
web-diag:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Setup SSH and diagnose
|
||||
shell: bash
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -x
|
||||
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'
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
retrigger3
|
||||
@@ -263,4 +263,3 @@ pytest --cov=packages --cov-report=html
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
<!-- CI trigger: 1788229339 -->
|
||||
@@ -1,45 +0,0 @@
|
||||
"""lipsync_jobs 增加 TTS 直生字段(voice_id/script_text/speed/emotion)
|
||||
|
||||
Revision ID: 073_add_lipsync_tts_fields
|
||||
Revises: 072_add_ai_avatar_render
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "073_add_lipsync_tts_fields"
|
||||
down_revision = "072_add_ai_avatar_render"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 对口型支持「传音色 + 文案直接生成」:后端内部先 TTS 合成音频再提交对口型
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("voice_id", sa.String(200), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("script_text", sa.Text(), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("speed", sa.Float(), nullable=False, server_default=sa.text("1.0")),
|
||||
)
|
||||
op.add_column(
|
||||
"lipsync_jobs",
|
||||
sa.Column("emotion", sa.String(20), nullable=False, server_default=""),
|
||||
)
|
||||
# audio_url 改为可空:直生模式下音频由后端 TTS 合成后回填
|
||||
op.alter_column("lipsync_jobs", "audio_url", existing_type=sa.Text(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("lipsync_jobs", "audio_url", existing_type=sa.Text(), nullable=False)
|
||||
op.drop_column("lipsync_jobs", "emotion")
|
||||
op.drop_column("lipsync_jobs", "speed")
|
||||
op.drop_column("lipsync_jobs", "script_text")
|
||||
op.drop_column("lipsync_jobs", "voice_id")
|
||||
@@ -1,36 +0,0 @@
|
||||
"""ai_avatar_render_jobs.script_id 放宽为可空串(手动文案直生场景不关联文案库)
|
||||
|
||||
Revision ID: 074_render_script_id_optional
|
||||
Revises: 073_add_lipsync_tts_fields
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "074_render_script_id_optional"
|
||||
down_revision = "073_add_lipsync_tts_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 列保持 NOT NULL(空串占位),仅应用层允许不传;这里显式补 server_default 防止历史约束歧义
|
||||
with op.batch_alter_table("ai_avatar_render_jobs") as batch:
|
||||
batch.alter_column(
|
||||
"script_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("ai_avatar_render_jobs") as batch:
|
||||
batch.alter_column(
|
||||
"script_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=False,
|
||||
server_default=None,
|
||||
)
|
||||
@@ -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,18 +11,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from packages.middleware.points_gate import points_gate
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
FinalizeRenderResponse,
|
||||
SmartCoverResponse,
|
||||
)
|
||||
from app.services.ai_avatar_cover_service import generate_smart_cover
|
||||
from app.services.ai_avatar_render_service import (
|
||||
AiAvatarRenderError,
|
||||
AiAvatarRenderService,
|
||||
@@ -43,12 +38,10 @@ def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService
|
||||
|
||||
|
||||
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
|
||||
@points_gate("ai_digital_human", per_unit=15)
|
||||
def create_render_job(
|
||||
body: CreateAiAvatarRenderRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""提交 AI 数字人渲染任务.
|
||||
|
||||
@@ -56,7 +49,7 @@ def create_render_job(
|
||||
"""
|
||||
try:
|
||||
job = svc.create_render_job(
|
||||
user_id=current_user.user.id,
|
||||
user_id=current_user.id,
|
||||
lipsync_job_id=body.lipsync_job_id,
|
||||
script_id=body.script_id,
|
||||
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
|
||||
@@ -81,16 +74,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 — 任务列表 ─────────────────────────────────────────────────
|
||||
@@ -107,7 +94,7 @@ def list_render_jobs(
|
||||
):
|
||||
"""获取 AI 数字人渲染任务列表."""
|
||||
items, total = svc.list_render_jobs(
|
||||
user_id=current_user.user.id,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
@@ -131,7 +118,7 @@ def get_render_job(
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""获取渲染任务详情."""
|
||||
job = svc.get_render_job(job_id, current_user.user.id)
|
||||
job = svc.get_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
return job
|
||||
@@ -147,7 +134,7 @@ def cancel_render_job(
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""取消渲染任务(仅 pending 状态可取消)."""
|
||||
job = svc.cancel_render_job(job_id, current_user.user.id)
|
||||
job = svc.cancel_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "cancelled":
|
||||
@@ -168,7 +155,7 @@ def retry_render_job(
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""重试失败的渲染任务."""
|
||||
job = svc.retry_render_job(job_id, current_user.user.id)
|
||||
job = svc.retry_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "pending":
|
||||
@@ -182,146 +169,7 @@ 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)
|
||||
|
||||
|
||||
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse)
|
||||
def generate_render_smart_cover(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
|
||||
|
||||
- 必须等渲染任务 completed 后才可调用(否则返回 400)
|
||||
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url
|
||||
"""
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
svc = AiAvatarRenderService(db)
|
||||
job = svc.get_render_job(job_id, current_user.user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "completed":
|
||||
raise HTTPException(status_code=400, detail="请先完成视频生成")
|
||||
video_url = (job.output_video_url or "").strip()
|
||||
if not video_url:
|
||||
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空")
|
||||
|
||||
try:
|
||||
# 从最终成片抽帧,帧本身已含标题/B-roll,直接转存 OSS
|
||||
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s",
|
||||
current_user.user.id,
|
||||
job_id,
|
||||
video_url[:80],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
cover_url = ""
|
||||
|
||||
if not cover_url:
|
||||
return SmartCoverResponse(
|
||||
cover_url="",
|
||||
status="fallback_failed",
|
||||
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
|
||||
)
|
||||
|
||||
# 更新 render_job 的封面字段(异步写入 DB;失败不影响返回)
|
||||
try:
|
||||
job.cover_config = {
|
||||
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
|
||||
"mode": "auto_frame",
|
||||
"url": cover_url,
|
||||
}
|
||||
job.output_cover_url = cover_url
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc)
|
||||
|
||||
logger.info(
|
||||
"渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s",
|
||||
current_user.user.id,
|
||||
job_id,
|
||||
cover_url[:120],
|
||||
)
|
||||
return SmartCoverResponse(cover_url=cover_url, status="completed")
|
||||
|
||||
# ── 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
|
||||
return job
|
||||
|
||||
@@ -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":
|
||||
@@ -177,8 +177,8 @@ def _cleanup_expired_uploads() -> int:
|
||||
meta_file.unlink()
|
||||
cleaned += 1
|
||||
logger.info(f"Cleaned up expired upload: {upload_id}")
|
||||
except Exception:
|
||||
logger.exception("Failed to cleanup upload metadata: %s", meta_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup upload metadata {meta_file}: {e}")
|
||||
|
||||
return cleaned
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from packages.middleware.points_gate import points_gate
|
||||
from app.core.storage import get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
@@ -272,7 +271,6 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
|
||||
|
||||
|
||||
@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201)
|
||||
@points_gate("ai_video", quantity_field="preview_count")
|
||||
def create_preview_generation_task(
|
||||
request: CreatePreviewGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from packages.middleware.points_gate import points_gate
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
@@ -59,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:
|
||||
@@ -168,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(
|
||||
@@ -212,7 +279,6 @@ def _resolve_project_and_library(
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
|
||||
@points_gate("ai_video", quantity_field="count")
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -423,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:批量变体无从选片,明确报错,严禁静默共用/同源
|
||||
@@ -475,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
|
||||
@@ -492,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:
|
||||
@@ -524,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:
|
||||
@@ -554,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.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
|
||||
|
||||
if not source_plan_id:
|
||||
raise HTTPException(
|
||||
@@ -184,7 +122,7 @@ def create_variant_plans(
|
||||
|
||||
voice_durations = _query_voice_durations(db, voices)
|
||||
except Exception:
|
||||
logger.exception("[variant-plans] 配音时长查询失败(按占位段长选片)")
|
||||
logger.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
|
||||
voice_durations = [0.0] * request.count
|
||||
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
@@ -205,7 +143,7 @@ def create_variant_plans(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[variant-plans] 选片异常")
|
||||
logger.error("[variant-plans] 选片异常: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e
|
||||
|
||||
# 组装 clips 响应
|
||||
|
||||
@@ -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,54 +1,32 @@
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整, #1845 配音前置.
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型.
|
||||
|
||||
接口:
|
||||
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.dependencies import get_db_session
|
||||
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 fastapi import APIRouter, 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()
|
||||
|
||||
|
||||
def _get_service(
|
||||
db: Session = Depends(get_db_session),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> LipsyncService:
|
||||
# voice_clone_repo 用于克隆音色 profile 解析
|
||||
return LipsyncService(
|
||||
db,
|
||||
voice_clone_repo=voice_clone_repo,
|
||||
)
|
||||
def _get_service(db: Session = Depends(get_db_session)) -> LipsyncService:
|
||||
return LipsyncService(db)
|
||||
|
||||
|
||||
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
|
||||
@@ -58,194 +36,34 @@ 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)。
|
||||
输入人物视频 + 驱动音频,异步生成口型对齐视频。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=user_id,
|
||||
user_id=current_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,
|
||||
emotion=body.emotion,
|
||||
enable_video_loop=body.enable_video_loop,
|
||||
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}")
|
||||
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}")
|
||||
status_code = 502
|
||||
if exc.code in ("VoiceForbidden",):
|
||||
status_code = 403
|
||||
elif exc.code in ("InvalidInput", "TTSInvalidParam", "VoiceNotReady"):
|
||||
status_code = 400
|
||||
# 创建失败(job 已记录 error),返回 502
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
status_code=502,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": str(exc),
|
||||
"request_id": getattr(exc, "request_id", ""),
|
||||
"request_id": exc.request_id,
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
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 — 任务列表 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -260,7 +78,7 @@ def list_lipsync_jobs(
|
||||
):
|
||||
"""获取对口型任务列表."""
|
||||
items, total = svc.list_jobs(
|
||||
user_id=current_user.user.id,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
@@ -280,33 +98,13 @@ def list_lipsync_jobs(
|
||||
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
|
||||
def get_lipsync_job(
|
||||
job_id: str,
|
||||
background: BackgroundTasks,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务详情."""
|
||||
job = svc.get_job(job_id, current_user.user.id)
|
||||
job = svc.get_job(job_id, current_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)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@@ -320,7 +118,7 @@ def refresh_lipsync_job(
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""从 MediaKit 拉取最新状态并更新."""
|
||||
job = svc.refresh_job_status(job_id, current_user.user.id)
|
||||
job = svc.refresh_job_status(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return job
|
||||
@@ -335,13 +133,13 @@ def cancel_lipsync_job(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""取消对口型任务(仅 pending/tts_processing/submitted 状态可取消)."""
|
||||
job = svc.cancel_job(job_id, current_user.user.id)
|
||||
"""取消对口型任务(仅 pending/submitted 状态可取消)."""
|
||||
job = svc.cancel_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.status != "cancelled":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending/tts_processing/submitted 可取消",
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending/submitted 可取消",
|
||||
)
|
||||
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
|
||||
|
||||
@@ -65,8 +65,8 @@ def _build_asset_analyses(
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(aid)
|
||||
except Exception:
|
||||
logger.exception("获取素材URL失败: asset_id=%s", aid)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", aid, str(e))
|
||||
|
||||
if not video_urls:
|
||||
logger.info("无可用视频素材,跳过视频理解分析")
|
||||
@@ -108,7 +108,7 @@ def _build_asset_analyses(
|
||||
return analyses
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 视频理解异常,将降级到无分析模式: %s", e)
|
||||
logger.warning("MediaKit 视频理解异常,将降级到无分析模式: %s", str(e))
|
||||
return {}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ def editor_ai_recommend(
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
logger.exception("db rollback failed in ai_recommend")
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI推荐结果保存失败,请稍后重试",
|
||||
|
||||
@@ -132,8 +132,8 @@ def _build_asset_url_map(
|
||||
result: dict[str, str | None] = {}
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception as e:
|
||||
logger.exception("获取存储服务失败,跳过asset_url生成: %s", e)
|
||||
except Exception:
|
||||
logger.warning("获取存储服务失败,跳过asset_url生成")
|
||||
return {aid: None for aid in asset_ids}
|
||||
|
||||
# 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1)
|
||||
@@ -141,7 +141,7 @@ def _build_asset_url_map(
|
||||
assets = asset_repo.find_by_ids(unique_ids)
|
||||
asset_map = {a.id: a for a in assets}
|
||||
except Exception:
|
||||
logger.exception("批量查询素材失败: asset_ids=%s", asset_ids)
|
||||
logger.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True)
|
||||
return {aid: None for aid in asset_ids if aid}
|
||||
|
||||
for aid in unique_ids:
|
||||
@@ -156,7 +156,7 @@ def _build_asset_url_map(
|
||||
continue
|
||||
result[aid] = storage.get_download_url(storage_key, expires_seconds=3600)
|
||||
except Exception:
|
||||
logger.exception("生成素材签名URL失败: asset_id=%s", aid)
|
||||
logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True)
|
||||
result[aid] = None
|
||||
|
||||
return result
|
||||
@@ -486,8 +486,8 @@ def _get_mediakit_recommendations(
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(asset_id)
|
||||
except Exception:
|
||||
logger.exception("获取素材URL失败: asset_id=%s", asset_id)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
if not video_urls:
|
||||
return {}
|
||||
@@ -563,7 +563,7 @@ def _get_mediakit_recommendations(
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("MediaKit 智能选片异常,降级为随机选择: %s", e)
|
||||
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -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()]
|
||||
@@ -878,7 +860,7 @@ def create_clips_from_assets_editor(
|
||||
duplicate_warning = None
|
||||
if dup_rate > 50:
|
||||
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
|
||||
logger.exception(
|
||||
logger.warning(
|
||||
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
|
||||
plan_id,
|
||||
dup_rate,
|
||||
@@ -978,8 +960,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
# 尝试获取存储服务(用于生成视频 URL)
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception as e:
|
||||
logger.exception("后台任务: 获取存储服务失败,跳过 SceneChange 更新: %s", e)
|
||||
except Exception:
|
||||
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
|
||||
return
|
||||
|
||||
# 获取 MediaKit 客户端
|
||||
@@ -1005,8 +987,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if storage_key and mime.startswith("video/"):
|
||||
try:
|
||||
video_url = storage.get_download_url(storage_key)
|
||||
except Exception:
|
||||
logger.exception("后台任务: 获取素材URL失败: asset_id=%s", asset_id)
|
||||
except Exception as e:
|
||||
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
# 构建该素材的占用区间列表(排除已更新片段)
|
||||
def _get_other_segments(asset_id_inner, clip_id_inner):
|
||||
@@ -1057,11 +1039,12 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
asset_id,
|
||||
len(scene_changes),
|
||||
)
|
||||
except Exception:
|
||||
except Exception as cache_err:
|
||||
# 缓存写入失败不影响本次片段更新
|
||||
logger.exception(
|
||||
"后台任务: 场景点缓存写入失败: asset_id=%s",
|
||||
logger.warning(
|
||||
"后台任务: 场景点缓存写入失败: asset_id=%s error=%s",
|
||||
asset_id,
|
||||
cache_err,
|
||||
)
|
||||
|
||||
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
|
||||
@@ -1141,10 +1124,11 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s",
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
@@ -1160,8 +1144,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("后台任务: 单个片段更新失败: clip_id=%s", clip.id)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
@@ -1170,9 +1154,9 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
|
||||
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
|
||||
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# 后台任务失败不影响已创建的片段,静默处理
|
||||
logger.exception("后台任务异常: plan_id=%s", plan_id)
|
||||
logger.warning("后台任务异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
|
||||
if db:
|
||||
try:
|
||||
db.rollback()
|
||||
|
||||
@@ -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
+14
-103
@@ -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
|
||||
@@ -203,15 +173,6 @@ def synthesize(
|
||||
# job.voice_id 统一存解析后的 CosyVoice voice_id
|
||||
actual_voice_id = resolved_profile.voice_id
|
||||
|
||||
# 语速/情绪等合成参数随 metadata 落库,workflow 提交 CosyVoice 时读取透传
|
||||
synthesis_meta = {
|
||||
"speed": request.speed,
|
||||
"emotion": request.emotion or "",
|
||||
"language": request.language or "zh-CN",
|
||||
}
|
||||
if request.metadata_:
|
||||
synthesis_meta.update(request.metadata_)
|
||||
|
||||
use_case = CreateTTSJobUseCase(repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
@@ -219,7 +180,7 @@ def synthesize(
|
||||
voice_id=actual_voice_id,
|
||||
voice_model=request.voice_model,
|
||||
voice_clone_profile_id=voice_clone_profile_id,
|
||||
metadata=synthesis_meta,
|
||||
metadata=request.metadata_,
|
||||
)
|
||||
|
||||
# 提交 CosyVoice 合成任务
|
||||
@@ -228,7 +189,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 +196,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 +214,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 +454,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 +537,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 +545,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)
|
||||
@@ -654,19 +567,17 @@ def preview_tts(
|
||||
text=request.text,
|
||||
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
|
||||
@@ -172,12 +163,12 @@ def create_voice_clone(
|
||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
||||
logger.info(f"Celery task dispatched for voice clone {profile.id}")
|
||||
except Exception as e:
|
||||
logger.exception("Failed to dispatch Celery task")
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
|
||||
try:
|
||||
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to mark profile as failed after dispatch error")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||
|
||||
return _to_response(profile)
|
||||
|
||||
@@ -286,111 +277,32 @@ def retry_voice_clone(
|
||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
||||
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
|
||||
except Exception as e:
|
||||
logger.exception("Failed to dispatch Celery task")
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
|
||||
try:
|
||||
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to mark profile as failed after dispatch error")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||
|
||||
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,
|
||||
|
||||
@@ -105,8 +105,8 @@ def _resolve_preset_preview_url(
|
||||
_preset_preview_cache[voice_id] = (audio_url, time.time())
|
||||
logger.info("Preset voice preview generated: %s", voice_id)
|
||||
return audio_url
|
||||
except Exception:
|
||||
logger.exception("Failed to generate preset voice preview: voice_id=%s", voice_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e)
|
||||
return fallback_url
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@ def _resolve_all_preset_preview_urls(
|
||||
try:
|
||||
result_map[p.voice_id] = _resolve_preset_preview_url(p.voice_id, p.preview_url, cosyvoice)
|
||||
except Exception:
|
||||
logger.exception("Failed to resolve preset preview URL: voice_id=%s", p.voice_id)
|
||||
result_map[p.voice_id] = p.preview_url
|
||||
return result_map
|
||||
|
||||
@@ -733,7 +732,7 @@ def _find_or_create_voice_library_for_extract(*, user_id, project_repository, as
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception:
|
||||
logger.exception("session rollback failed in _find_or_create_voice_library")
|
||||
pass
|
||||
for lib in asset_library_repository.find_by_project(project.id):
|
||||
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if kind == AssetLibraryKind.VOICE.value:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -50,11 +50,9 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
"""创建渲染任务请求."""
|
||||
|
||||
lipsync_job_id: str = Field(..., description="对口型任务 ID")
|
||||
script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)")
|
||||
script_id: str = Field(..., description="文案 ID")
|
||||
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
|
||||
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,7 +67,10 @@ class CreateAiAvatarRenderRequest(BaseModel):
|
||||
@field_validator("script_id")
|
||||
@classmethod
|
||||
def validate_script_id(cls, v: str) -> str:
|
||||
return (v or "").strip()
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("script_id 不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class AiAvatarRenderJobResponse(BaseModel):
|
||||
@@ -79,7 +80,7 @@ class AiAvatarRenderJobResponse(BaseModel):
|
||||
user_id: str
|
||||
project_id: str
|
||||
lipsync_job_id: str
|
||||
script_id: str = ""
|
||||
script_id: str
|
||||
b_roll_segments: list[dict[str, Any]]
|
||||
title_config: dict[str, Any]
|
||||
cover_config: dict[str, Any]
|
||||
@@ -108,19 +109,3 @@ class AiAvatarRenderProgressResponse(BaseModel):
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
|
||||
|
||||
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,20 +1,11 @@
|
||||
"""对口型 API Schema 定义 — #1796 / #1809 / #1822 / #1845(配音前置).
|
||||
|
||||
支持三种输入模式:
|
||||
1. TTS 直生模式(兼容旧版前端):传 voice_id + script_text(+ speed/emotion),
|
||||
后端 Celery 异步做 TTS 合成 + 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。
|
||||
"""
|
||||
"""对口型 API Schema 定义 — #1796."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class LipsyncJobResponse(BaseModel):
|
||||
@@ -26,17 +17,12 @@ class LipsyncJobResponse(BaseModel):
|
||||
video_url: str
|
||||
audio_url: str
|
||||
enable_video_loop: bool
|
||||
voice_id: str = ""
|
||||
script_text: str = ""
|
||||
speed: float = 1.0
|
||||
emotion: str = ""
|
||||
mediakit_task_id: str
|
||||
status: str
|
||||
output_video_url: str
|
||||
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
|
||||
@@ -47,92 +33,38 @@ class LipsyncJobResponse(BaseModel):
|
||||
|
||||
|
||||
class CreateLipsyncJobRequest(BaseModel):
|
||||
"""创建对口型任务请求.
|
||||
|
||||
三种模式(三选一):
|
||||
- TTS 直生(旧版/降级):voice_id + script_text 必填;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:直接/预合成音频
|
||||
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,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)",
|
||||
)
|
||||
|
||||
enable_video_loop: bool = Field(
|
||||
True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)"
|
||||
)
|
||||
audio_url: str = Field(..., description="驱动音频 URL(mp3/aac/wav/m4a/flac)")
|
||||
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
|
||||
project_id: str = Field("", description="项目 ID(可选)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_input_mode(self) -> "CreateLipsyncJobRequest":
|
||||
video = (self.video_url or "").strip()
|
||||
if not video:
|
||||
@field_validator("video_url")
|
||||
@classmethod
|
||||
def validate_video_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("video_url 不能为空")
|
||||
if not video.startswith(("http://", "https://")):
|
||||
if not v.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))
|
||||
# 仅支持 MP4
|
||||
lower = v.lower().split("?")[0]
|
||||
if not lower.endswith(".mp4"):
|
||||
raise ValueError("video_url 仅支持 MP4 格式")
|
||||
return v
|
||||
|
||||
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(直接/预合成音频模式),"
|
||||
"要么同时传 voice_id + script_text(TTS 直生模式)"
|
||||
)
|
||||
|
||||
if has_tts and len(self.script_text) > 5000:
|
||||
raise ValueError("script_text 最长 5000 字符")
|
||||
|
||||
if has_audio:
|
||||
au = self.audio_url.strip()
|
||||
if not au.startswith(("http://", "https://")):
|
||||
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
|
||||
au_lower = au.lower().split("?")[0]
|
||||
allowed = (".mp3", ".aac", ".wav", ".m4a", ".flac")
|
||||
if not any(au_lower.endswith(ext) for ext in allowed):
|
||||
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed)}")
|
||||
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="句子级精确时间戳")
|
||||
@field_validator("audio_url")
|
||||
@classmethod
|
||||
def validate_audio_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("audio_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
|
||||
# 支持的音频格式
|
||||
lower = v.lower().split("?")[0]
|
||||
allowed_exts = (".mp3", ".aac", ".wav", ".m4a", ".flac")
|
||||
if not any(lower.endswith(ext) for ext in allowed_exts):
|
||||
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed_exts)}")
|
||||
return v
|
||||
|
||||
@@ -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,10 @@ 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 自然语言指令控制)",
|
||||
)
|
||||
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 +45,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 +79,7 @@ class TTSSynthesizeResponse(BaseModel):
|
||||
class ListTTSJobResponse(BaseModel):
|
||||
"""TTS 任务列表响应。"""
|
||||
|
||||
items: list[TTSJobResponse]
|
||||
items: List[TTSJobResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -113,8 +109,6 @@ 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 等)")
|
||||
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,215 +0,0 @@
|
||||
"""AI 数字人封面服务 — MediaKit 抽帧 + 质量评分选最佳帧 + 转存 OSS.
|
||||
|
||||
与 generation_cover.py 的智能选帧能力对齐(不再用 FFmpeg 简单截帧):
|
||||
1. MediaKit extract_frames 抽取多帧(默认 5 帧,SpecifiedFrames 策略)
|
||||
2. cover_frame_scorer.score_frames 按清晰度/亮度/色彩评分选最佳
|
||||
3. 下载最佳帧并转存 OSS,返回公网封面 URL
|
||||
|
||||
设计原则:封面一律从最终成片(已叠加标题/B-roll)抽帧,帧本身已含标题,
|
||||
本服务**不再叠加标题**。对口型阶段的裸视频封面入口已删除(废弃)。
|
||||
|
||||
降级:MediaKit 不可用或抽帧失败时返回空字符串,由调用方决定回退策略。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
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
|
||||
|
||||
# 帧图片下载超时(秒)
|
||||
FRAME_DOWNLOAD_TIMEOUT = 20
|
||||
# 最佳帧下载超时(用于 persist)
|
||||
BEST_FRAME_DOWNLOAD_TIMEOUT = 30
|
||||
|
||||
# 自家 OSS 私有桶 URL 重签有效期(供 MediaKit GPU worker 拉取)
|
||||
MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
def _sign_video_url_for_mediakit(video_url: str) -> str:
|
||||
"""如果 video_url 是自家 OSS 私有桶 URL,重新签名为长有效期预签名 URL。
|
||||
|
||||
MediaKit GPU worker 需要能公网访问 video_url,裸 public_url 在私有桶下会 403。
|
||||
"""
|
||||
if not video_url:
|
||||
return video_url
|
||||
try:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return video_url
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
url_host = urlparse(video_url).netloc.lower()
|
||||
if own_host and url_host == own_host:
|
||||
signed = storage.get_download_url(video_url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
|
||||
if signed:
|
||||
logger.info("[数字人封面] video_url 已重签(自家 OSS 私有桶)")
|
||||
return signed
|
||||
except Exception:
|
||||
logger.warning("[数字人封面] video_url 重签失败,使用原始 URL", exc_info=True)
|
||||
return video_url
|
||||
|
||||
|
||||
def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
"""从视频抽取多帧并评分选最佳帧,返回最佳帧的临时 URL."""
|
||||
if not video_url:
|
||||
return ""
|
||||
|
||||
video_url = _sign_video_url_for_mediakit(video_url)
|
||||
|
||||
try:
|
||||
from packages.shared.cover_frame_scorer import score_frames
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
mk = get_mediakit_client()
|
||||
if not mk.is_available:
|
||||
logger.warning("[数字人封面] MediaKit 未配置,无法智能抽帧")
|
||||
return ""
|
||||
|
||||
logger.info(
|
||||
"[数字人封面] 开始抽帧: video_url=%s max_frames=%d",
|
||||
video_url[:80],
|
||||
max_frames,
|
||||
)
|
||||
|
||||
snapshots = mk.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=max_frames,
|
||||
poll_interval=COVER_POLL_INTERVAL,
|
||||
max_poll_attempts=COVER_MAX_POLL_ATTEMPTS,
|
||||
max_retries=1,
|
||||
)
|
||||
if not snapshots:
|
||||
logger.warning("[数字人封面] MediaKit 未返回帧: %s", video_url[:80])
|
||||
return ""
|
||||
|
||||
if len(snapshots) == 1:
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
import httpx
|
||||
|
||||
candidates = []
|
||||
with httpx.Client(timeout=FRAME_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
|
||||
for snap in snapshots:
|
||||
url = snap.get("image_url") or snap.get("url") or ""
|
||||
if not url:
|
||||
continue
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp_path = tmp.name
|
||||
candidates.append({"image_path": tmp_path, "url": url})
|
||||
except Exception as e:
|
||||
logger.warning("[数字人封面] 帧下载失败,跳过: url=%s err=%s", url[:80], e)
|
||||
candidates.append({"image_path": None, "url": url, "score": 0.0})
|
||||
|
||||
if not candidates:
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
scored = score_frames(candidates)
|
||||
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:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"[数字人封面] 智能选帧完成: candidates=%d best_score=%s",
|
||||
len(candidates),
|
||||
best.get("score") if best else "n/a",
|
||||
)
|
||||
return best_url
|
||||
|
||||
except Exception:
|
||||
logger.warning("[数字人封面] 智能选帧失败", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def persist_cover_to_oss(
|
||||
frame_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
prefix: str = "ai-avatar/covers",
|
||||
) -> str:
|
||||
"""下载最佳帧图并转存到 OSS,返回公网封面 URL(预签名).
|
||||
|
||||
封面来自最终成片抽帧,帧本身已含标题,本函数不再做任何文字/图片叠加。
|
||||
"""
|
||||
if not frame_url:
|
||||
return ""
|
||||
tmp_path: Optional[str] = None
|
||||
try:
|
||||
import httpx
|
||||
|
||||
with httpx.Client(timeout=BEST_FRAME_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
|
||||
resp = client.get(frame_url)
|
||||
resp.raise_for_status()
|
||||
if not resp.content:
|
||||
logger.warning("[数字人封面] 帧图内容为空: %s", frame_url[:80])
|
||||
return frame_url
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
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)
|
||||
if public_url:
|
||||
signed = storage.get_download_url(cover_key, expires_seconds=86400)
|
||||
return signed
|
||||
return frame_url
|
||||
except Exception:
|
||||
logger.warning("[数字人封面] 封面转存 OSS 失败,返回原始 URL", exc_info=True)
|
||||
return frame_url
|
||||
finally:
|
||||
if tmp_path:
|
||||
try:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def generate_smart_cover(
|
||||
video_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
max_frames: int = 5,
|
||||
) -> str:
|
||||
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS。失败返回空字符串。
|
||||
|
||||
封面从最终成片抽帧,不再叠加任何标题(帧本身已含)。
|
||||
"""
|
||||
best_frame = select_best_cover_frame(video_url, max_frames=max_frames)
|
||||
if not best_frame:
|
||||
return ""
|
||||
return persist_cover_to_oss(best_frame, job_id=job_id)
|
||||
@@ -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
|
||||
|
||||
@@ -57,8 +53,8 @@ class AiAvatarRenderService:
|
||||
*,
|
||||
user_id: str,
|
||||
lipsync_job_id: str,
|
||||
script_id: str = "",
|
||||
b_roll_segments: list[dict[str, Any]] | None = None,
|
||||
script_id: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
title_config: dict[str, Any],
|
||||
cover_config: dict[str, Any],
|
||||
project_id: str = "",
|
||||
@@ -87,19 +83,17 @@ class AiAvatarRenderService:
|
||||
if not lipsync_job.output_video_url:
|
||||
raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput")
|
||||
|
||||
# 2. 验证文案归属(仅当选了文案库条目时;手动输入文案直生场景 script_id 可空)
|
||||
script_id = (script_id or "").strip()
|
||||
if script_id:
|
||||
script = (
|
||||
self.db.query(ScriptModel)
|
||||
.filter(
|
||||
ScriptModel.id == script_id,
|
||||
ScriptModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
# 2. 验证文案归属
|
||||
script = (
|
||||
self.db.query(ScriptModel)
|
||||
.filter(
|
||||
ScriptModel.id == script_id,
|
||||
ScriptModel.user_id == user_id,
|
||||
)
|
||||
if script is None:
|
||||
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
|
||||
.first()
|
||||
)
|
||||
if script is None:
|
||||
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
|
||||
|
||||
# 3. 创建渲染任务
|
||||
job_id = str(uuid.uuid4())
|
||||
@@ -109,7 +103,7 @@ class AiAvatarRenderService:
|
||||
project_id=project_id,
|
||||
lipsync_job_id=lipsync_job_id,
|
||||
script_id=script_id,
|
||||
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])],
|
||||
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in b_roll_segments],
|
||||
title_config=title_config,
|
||||
cover_config=cover_config,
|
||||
status="pending",
|
||||
@@ -117,7 +111,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 +158,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 +180,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 +194,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 +210,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 +226,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 +255,42 @@ 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)
|
||||
if 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 +300,23 @@ 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)
|
||||
|
||||
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 +334,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 fast -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,25 +1,18 @@
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整, #1845 配音前置.
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
|
||||
|
||||
职责:
|
||||
- 创建/查询对口型任务
|
||||
- 三输入模式:
|
||||
1. TTS 直生(voice_id + script_text)→ 走 Celery 异步(降级路径)
|
||||
2. 直接音频(audio_url,前端未传 timings)→ 同步下载 + 算 timings + 提交 MediaKit
|
||||
3. 预合成音频(audio_url + sentence_timings,#1845 新主路径)→ 同步 ffprobe 校验时长 +
|
||||
写入前端传来的 timings → 直接提交 MediaKit(~2-3s)
|
||||
- 创建/查询/取消对口型任务
|
||||
- 调用 MediaKit 客户端提交异步任务
|
||||
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
|
||||
- 轮询更新任务状态
|
||||
- 用户隔离(每个用户只能操作自己的任务)
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
from app.services.mediakit_client import (
|
||||
STATUS_COMPLETED,
|
||||
@@ -29,220 +22,19 @@ from app.services.mediakit_client import (
|
||||
MediaKitError,
|
||||
get_mediakit_client,
|
||||
)
|
||||
|
||||
# Celery 异步任务:TTS 合成 + MediaKit 提交(降级路径)
|
||||
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.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_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
class LipsyncService:
|
||||
"""对口型任务 Service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
client: Optional[MediaKitClient] = None,
|
||||
cosyvoice_service=None,
|
||||
voice_clone_repo=None,
|
||||
):
|
||||
def __init__(self, db: Session, client: Optional[MediaKitClient] = None):
|
||||
self.db = db
|
||||
self.client = client or get_mediakit_client()
|
||||
self._cosyvoice = cosyvoice_service
|
||||
self._voice_clone_repo = voice_clone_repo
|
||||
|
||||
def _get_cosyvoice(self):
|
||||
"""延迟获取 CosyVoiceService(与 tts 路由一致,含 OSS 预签名配置)."""
|
||||
if self._cosyvoice is None:
|
||||
from app.dependencies import get_cosyvoice_service
|
||||
|
||||
self._cosyvoice = get_cosyvoice_service()
|
||||
return self._cosyvoice
|
||||
|
||||
def _resolve_voice_id(self, voice_id: str, user_id: str) -> str:
|
||||
"""将克隆音色 profile UUID 解析为 CosyVoice voice_id。
|
||||
|
||||
与 /tts/synthesize 保持一致:命中 profile → 校验归属 → 返回其 voice_id;
|
||||
未命中(预置音色 ID 或克隆 CosyVoice voice_id)原样返回。
|
||||
"""
|
||||
if not voice_id:
|
||||
return ""
|
||||
if self._voice_clone_repo is None:
|
||||
try:
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
self._voice_clone_repo = get_voice_clone_profile_repository(self.db)
|
||||
except Exception:
|
||||
return voice_id
|
||||
try:
|
||||
profile = self._voice_clone_repo.get(voice_id)
|
||||
except Exception:
|
||||
return voice_id
|
||||
if profile is None:
|
||||
return voice_id
|
||||
if getattr(profile, "user_id", "") != user_id:
|
||||
raise MediaKitError("无权访问该音色", code="VoiceForbidden")
|
||||
if not getattr(profile, "voice_id", ""):
|
||||
raise MediaKitError("音色克隆尚未完成,请稍后再试", code="VoiceNotReady")
|
||||
return profile.voice_id
|
||||
|
||||
def _synthesize_and_persist_audio(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
job_id: str,
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
) -> str:
|
||||
"""TTS 直生:调 CosyVoice 合成音频并转存 OSS,返回可公网访问的音频 URL.
|
||||
|
||||
Raises:
|
||||
MediaKitError: 合成失败
|
||||
"""
|
||||
actual_voice_id = self._resolve_voice_id(voice_id, user_id)
|
||||
cosyvoice = self._get_cosyvoice()
|
||||
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")
|
||||
|
||||
# 转存到自家 OSS,避免临时 URL 过期导致 MediaKit 拉取失败
|
||||
try:
|
||||
audio_data = safe_download_bytes(
|
||||
temp_url,
|
||||
purpose="lipsync_tts_audio",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
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("对口型 TTS 音频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
return permanent_url
|
||||
except Exception as exc:
|
||||
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
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -251,48 +43,15 @@ 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,
|
||||
audio_url: str,
|
||||
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)。
|
||||
"""创建对口型任务并提交到 MediaKit.
|
||||
|
||||
Raises:
|
||||
MediaKitError: 参数校验失败或 MediaKit 提交失败
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
# 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 (voice_id and script_text):
|
||||
raise MediaKitError(
|
||||
"必须提供 audio_url 或 voice_id+script_text",
|
||||
code="InvalidInput",
|
||||
)
|
||||
# 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())
|
||||
job = LipsyncJobModel(
|
||||
@@ -302,140 +61,33 @@ class LipsyncService:
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
voice_id=voice_id or "",
|
||||
script_text=script_text or "",
|
||||
speed=speed,
|
||||
emotion=emotion or "",
|
||||
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
|
||||
status="tts_processing" if is_tts_mode else "pending",
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
# ⚠️ 必须先 commit 再发 Celery 任务 / 后续同步操作,避免事务竞态
|
||||
# 2. 提交到 MediaKit
|
||||
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)
|
||||
|
||||
if is_tts_mode:
|
||||
# 2a. TTS 模式:dispatch Celery 异步任务处理 TTS 合成 + MediaKit 提交(降级路径)
|
||||
try:
|
||||
tts_synthesize_and_submit.apply_async(
|
||||
args=(
|
||||
job_id,
|
||||
user_id,
|
||||
voice_id,
|
||||
script_text,
|
||||
speed,
|
||||
emotion or "",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Celery 任务提交失败,TTS 任务已创建但未触发执行: job_id=%s err=%s",
|
||||
job_id,
|
||||
exc,
|
||||
)
|
||||
job.status = "failed"
|
||||
job.error_message = f"Celery 任务投递失败: {exc}"
|
||||
job.error_code = "AsyncDispatchFailed"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
else:
|
||||
# 2b/2c. 直接音频 / 预合成音频:同步路径
|
||||
self._submit_audio_direct(
|
||||
job=job,
|
||||
supplied_timings=sentence_timings,
|
||||
supplied_duration=audio_duration,
|
||||
)
|
||||
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 +121,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
|
||||
@@ -489,107 +145,36 @@ class LipsyncService:
|
||||
return job
|
||||
|
||||
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
|
||||
job.output_video_url = result.get("video_url", "")
|
||||
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)
|
||||
# running 状态只更新时间戳
|
||||
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."""
|
||||
if not temp_url:
|
||||
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")
|
||||
logger.info("对口型输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key)
|
||||
return self._sign_media_url(permanent_url) or temp_url
|
||||
except Exception as exc:
|
||||
logger.warning("对口型输出视频转存 OSS 失败,回退临时 URL: job_id=%s err=%s", job_id, exc)
|
||||
return temp_url
|
||||
|
||||
def _sign_media_url(self, url: str) -> str:
|
||||
"""对自家 OSS 私有桶 URL 重签长有效期预签名."""
|
||||
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
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
host = urlparse(url).netloc.lower()
|
||||
if not own_host or host != own_host:
|
||||
return url # 外部临时链接原样透传
|
||||
signed = storage.get_download_url(url, expires_seconds=MEDIAKIT_URL_TTL_SECONDS)
|
||||
return signed or url
|
||||
except Exception as exc:
|
||||
logger.warning("对口型 URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
|
||||
return url
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""取消任务(仅 pending/tts_processing/submitted 状态可取消)."""
|
||||
"""取消任务(仅 pending/submitted 状态可取消)."""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
if job.status in ("pending", "tts_processing", "submitted"):
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
"""AI 数字人对口型 TTS 异步任务 — 将 TTS 合成从 HTTP 请求移至 Celery 后台执行.
|
||||
|
||||
优化目标:将 create_job 的 API 响应时间从 6~35s 降到 <1s。
|
||||
任务流程:
|
||||
1. 创建新 DB session,加载 job 记录
|
||||
2. 调用 CosyVoice 合成音频
|
||||
3. 下载音频并转存到自家 OSS
|
||||
4. 更新 job 的 audio_url
|
||||
5. 签名 URL 并提交到 MediaKit
|
||||
6. 更新 job 状态为 submitted
|
||||
7. 异常时标记 job 为 failed
|
||||
|
||||
注意:使用 @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 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 保持一致
|
||||
_MEDIAKIT_URL_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
def _sign_media_url(url: str) -> str:
|
||||
"""对自家 OSS 私有桶 URL 重签长有效期预签名.
|
||||
|
||||
- 自家 OSS URL → 重签 7 天有效期
|
||||
- 外部临时 URL → 原样透传
|
||||
- 任何异常降级原样返回,不阻断主流程
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
try:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
public_base = getattr(storage, "public_url", "")
|
||||
if not isinstance(public_base, str) or not public_base:
|
||||
return url
|
||||
own_host = urlparse(public_base).netloc.lower()
|
||||
host = urlparse(url).netloc.lower()
|
||||
if not own_host or host != own_host:
|
||||
return url
|
||||
signed = storage.get_download_url(url, expires_seconds=_MEDIAKIT_URL_TTL_SECONDS)
|
||||
return signed or url
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("[lipsync_tts] URL 重签失败,原样返回: url_prefix=%s err=%s", url[:80], exc)
|
||||
return url
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="lipsync_tts.synthesize_and_submit",
|
||||
max_retries=5, # 事务竞态重试3次(job not found)+ TTS偶发错误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,
|
||||
job_id: str,
|
||||
user_id: str,
|
||||
voice_id: str,
|
||||
script_text: str,
|
||||
speed: float,
|
||||
emotion: str,
|
||||
):
|
||||
"""异步执行 TTS 合成 + OSS 转存 + MediaKit 提交.
|
||||
|
||||
在 Celery worker 中运行,不阻塞 HTTP 请求。保留作为降级路径
|
||||
(预合成失败 / 旧版前端未传 audio_url 时走此路径)。
|
||||
"""
|
||||
from app.services.mediakit_client import MediaKitError, get_mediakit_client
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.shared.url_security import safe_download_bytes
|
||||
|
||||
# SessionLocal 获取:
|
||||
# - API 容器:app.db.SessionLocal(环境变量完整,导入即建引擎)
|
||||
# - Worker 容器:worker_app.db.SessionLocal(Worker 自己的 settings 初始化引擎)
|
||||
# API 侧没有 worker_app 模块 → ImportError 直接回退;
|
||||
# Worker 侧 app.db 会因缺少 API 专有环境变量抛 pydantic ValidationError,
|
||||
# 此时也要回退到 worker_app.db。
|
||||
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:
|
||||
# 事务竞态防御: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,
|
||||
)
|
||||
return
|
||||
|
||||
# 已取消的任务不再处理
|
||||
if job.status == "cancelled":
|
||||
logger.info("[lipsync_tts] Job already cancelled, skipping: job_id=%s", job_id)
|
||||
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(
|
||||
text=script_text,
|
||||
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)
|
||||
db.commit()
|
||||
return
|
||||
except ValueError 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 = "TTSInvalidParam"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
temp_url = result.get("audio_url", "")
|
||||
if not temp_url:
|
||||
logger.error("[lipsync_tts] TTS 未返回音频 URL: job_id=%s", job_id)
|
||||
job.status = "failed"
|
||||
job.error_message = "TTS 未返回音频 URL"
|
||||
job.error_code = "TTSNoAudio"
|
||||
job.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# 2. 下载 TTS 音频到内存(用于 2.5 静音检测;不转存自家 OSS,直接使用 CosyVoice 临时 URL)
|
||||
audio_data: bytes | None = None
|
||||
try:
|
||||
audio_data = safe_download_bytes(
|
||||
temp_url,
|
||||
purpose="lipsync_tts_audio",
|
||||
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,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[lipsync_tts] TTS 音频下载失败,跳过静音检测,直接使用临时 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)
|
||||
|
||||
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)
|
||||
|
||||
client = get_mediakit_client()
|
||||
try:
|
||||
mk_result = client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=job.enable_video_loop,
|
||||
client_token=job_id,
|
||||
)
|
||||
job.mediakit_task_id = mk_result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(UTC)
|
||||
logger.info(
|
||||
"[lipsync_tts] 已提交 MediaKit: job_id=%s task_id=%s",
|
||||
job_id,
|
||||
mk_result["task_id"],
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
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:
|
||||
logger.exception("[lipsync_tts] 未预期的异常: job_id=%s", job_id)
|
||||
try:
|
||||
job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id).first()
|
||||
if job and job.status not in ("cancelled", "failed", "completed"):
|
||||
job.status = "failed"
|
||||
job.error_message = "TTS 异步任务执行异常"
|
||||
job.error_code = "AsyncTaskError"
|
||||
job.updated_at = datetime.now(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 +0,0 @@
|
||||
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 +0,0 @@
|
||||
/**
|
||||
* 文案库 API(#1811 v2)
|
||||
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
ScriptItem,
|
||||
ScriptListParams,
|
||||
ScriptListResponse,
|
||||
ScriptUpsertRequest,
|
||||
ScriptCategory,
|
||||
} 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
|
||||
}
|
||||
|
||||
/** 新建文案 */
|
||||
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 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 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))
|
||||
}
|
||||
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 +0,0 @@
|
||||
/**
|
||||
* 文案库 API — 类型定义(#1811 v2 完整字段版)
|
||||
* 字段对齐后端契约:title / content / segments / tags / title_text / title_category / title_config
|
||||
* 同时保留 char_count / use_count / timestamps 等展示字段
|
||||
*/
|
||||
|
||||
/** 标题配置(字体、颜色、位置、字号) */
|
||||
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
|
||||
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 {
|
||||
title: string
|
||||
content: string
|
||||
segments?: string[]
|
||||
tags?: string[]
|
||||
title_text?: string
|
||||
title_category?: ScriptCategory
|
||||
title_config?: ScriptTitleConfig
|
||||
}
|
||||
@@ -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,6 @@ 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 与中文标签)
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.xx-app-shell 全屏 flex 容器
|
||||
├── header (xx-top-nav) 顶部导航(Header.tsx 管理)
|
||||
└── .xx-app-body 水平 flex 行
|
||||
├── .xx-app-sidebar 左侧侧边栏(128px / 64px 折叠)
|
||||
├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠)
|
||||
└── .xx-app-content 主内容区(自适应)
|
||||
|
||||
所有尺寸/颜色均使用 global.css 设计系统变量
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
/* ── 侧边栏 ───────────────────────────────────────────────── */
|
||||
.xx-app-sidebar {
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -103,12 +103,6 @@
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 展开态(侧边栏 128px)水平 padding 收窄,为菜单文字留出完整一行空间 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-content {
|
||||
padding-left: var(--space-xs);
|
||||
padding-right: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ── 主内容区 ─────────────────────────────────────────────── */
|
||||
.xx-app-content {
|
||||
flex: 1;
|
||||
@@ -142,7 +136,7 @@
|
||||
|
||||
/* 展开态恢复完整宽度 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) {
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle {
|
||||
@@ -165,7 +159,7 @@
|
||||
top: 56px; /* 移动端 Header 高度 */
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 128px;
|
||||
width: 240px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-slow);
|
||||
box-shadow: none;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MainLayout - 主布局组件(Task 1.2)
|
||||
*
|
||||
* 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区
|
||||
* - 侧边栏:128px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 顶部导航:复用 Header 组件(68px 固定高度)
|
||||
* - 主内容区:自适应填充剩余空间
|
||||
* - 响应式:移动端(<768px)隐藏侧边栏
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
/* 分组标题 */
|
||||
.xx-sidebar-group-title {
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md) var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
@@ -56,9 +56,9 @@
|
||||
.xx-sidebar-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-xs);
|
||||
margin: 0 var(--space-xxs);
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin: 0 var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
@@ -97,12 +97,12 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
@@ -119,9 +119,7 @@
|
||||
/* ── 菜单项文字 ───────────────────────────────────────────── */
|
||||
.xx-sidebar-menu-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -135,16 +133,8 @@
|
||||
/* 折叠时菜单项居中,仅图标 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-item {
|
||||
justify-content: center;
|
||||
padding: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠态图标恢复更大尺寸居中 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
padding: var(--space-sm);
|
||||
margin: 0 var(--space-xxs);
|
||||
}
|
||||
|
||||
/* 折叠时隐藏分组标题 */
|
||||
|
||||
@@ -8,14 +8,15 @@ import {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
AudioOutlined,
|
||||
AppstoreOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
VideoCameraOutlined,
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
@@ -57,18 +58,30 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "scripts",
|
||||
label: "文案库",
|
||||
path: "/app/scripts",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
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 +119,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 +144,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),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -166,18 +173,24 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "scripts",
|
||||
label: "文案库",
|
||||
path: "/app/scripts",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
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 +222,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;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.admin-coming-soon-page {
|
||||
padding: 32px;
|
||||
max-width: 1680px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,131 +1,75 @@
|
||||
/**
|
||||
* AI数字人 — API 封装(#1822 契约对齐)
|
||||
* AI数字人 API 调用封装 (#1798)
|
||||
*/
|
||||
import apiClient from "@/api/client"
|
||||
import type { Script, LipsyncJob, RenderJob, BRollSegment, SentenceTiming } from "../types"
|
||||
import type {
|
||||
Script,
|
||||
LipsyncJob,
|
||||
AiAvatarRenderRequest,
|
||||
AiAvatarRenderJob,
|
||||
} from "../types/aiAvatar"
|
||||
|
||||
/* ── 文案库 ── */
|
||||
export const getScripts = async (): Promise<Script[]> => {
|
||||
const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts")
|
||||
// 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复)
|
||||
const data = response.data as unknown
|
||||
if (Array.isArray(data)) return data
|
||||
const items = (data as { items?: Script[] })?.items
|
||||
return Array.isArray(items) ? items : []
|
||||
export async function getScripts(params?: { search?: string; offset?: number; limit?: number }) {
|
||||
const { data } = await apiClient.get<{ items: Script[]; total: number }>("/scripts", { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const getScriptById = async (id: string): Promise<Script> => {
|
||||
const response = await apiClient.get<Script>(`/scripts/${id}`)
|
||||
return response.data
|
||||
export async function getScript(id: string) {
|
||||
const { data } = await apiClient.get<Script>(`/scripts/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const createScript = async (data: { title: string; content: string }): Promise<Script> => {
|
||||
const response = await apiClient.post<Script>("/scripts", data)
|
||||
return response.data
|
||||
export async function createScript(payload: { title: string; content: string; tags?: string[] }) {
|
||||
const { data } = await apiClient.post<Script>("/scripts", payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteScript = async (id: string): Promise<void> => {
|
||||
export async function deleteScript(id: string) {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
|
||||
/* ── 素材单查(拿到 file_url 作为对口型的 video_url) ── */
|
||||
export const getAssetById = async (id: string): Promise<{ file_url?: string; id: string }> => {
|
||||
const response = await apiClient.get<{ file_url?: string; id: string }>(`/assets/${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
|
||||
*/
|
||||
export const createLipsyncJob = async (data: {
|
||||
/** 人物视频 URL(MP4);由素材 id 经 getAssetById 拿 file_url */
|
||||
/* ── 对口型 ── */
|
||||
export async function createLipsyncJob(payload: {
|
||||
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 直生模式用) */
|
||||
speed?: number
|
||||
/** 情绪英文枚举:neutral/happy/sad/angry/surprised/fearful/disgusted(TTS 直生模式用;前端经 normalizeEmotion 归一化) */
|
||||
emotion?: string
|
||||
audio_url: string
|
||||
enable_video_loop?: boolean
|
||||
project_id?: string
|
||||
}): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
|
||||
return response.data
|
||||
}) {
|
||||
const { data } = await apiClient.post<LipsyncJob>("/lipsync/jobs", payload)
|
||||
return 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 })
|
||||
return response.data
|
||||
export async function getLipsyncJob(id: string) {
|
||||
const { data } = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
|
||||
return response.data
|
||||
/* ── 渲染合成 ── */
|
||||
export async function submitRender(payload: AiAvatarRenderRequest) {
|
||||
const { data } = await apiClient.post<AiAvatarRenderJob>("/ai-avatar/render", payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/* ── 渲染 ── */
|
||||
export const submitRender = async (data: {
|
||||
lipsync_job_id: string
|
||||
script_id?: string
|
||||
b_roll_segments?: BRollSegment[]
|
||||
title_config?: Record<string, unknown>
|
||||
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 async function getRenderJobs(params?: { project_id?: string; status?: string }) {
|
||||
const { data } = await apiClient.get<AiAvatarRenderJob[]>("/ai-avatar/render/jobs", { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
|
||||
return response.data
|
||||
export async function getRenderJob(jobId: string) {
|
||||
const { data } = await apiClient.get<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
export async function cancelRenderJob(jobId: string) {
|
||||
const { data } = await apiClient.post<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}/cancel`)
|
||||
return data
|
||||
}
|
||||
|
||||
/* ── 从最终渲染成片智能抽封面(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
|
||||
export async function retryRenderJob(jobId: string) {
|
||||
const { data } = await apiClient.post<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}/retry`)
|
||||
return 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`,
|
||||
)
|
||||
/* ── 素材上传(复用已有 API) ── */
|
||||
export { prepareDirectUpload, completeDirectUpload } from "@/api/assets"
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { AvatarVideo } from "../types/aiAvatar"
|
||||
|
||||
interface AvatarVideoPanelProps {
|
||||
video: AvatarVideo | null
|
||||
onVideoChange: (v: AvatarVideo | null) => void
|
||||
collapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
}
|
||||
|
||||
const MAX_VIDEO_SIZE = 500 * 1024 * 1024 // 500MB
|
||||
|
||||
/** 格式化秒数为 mm:ss */
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 获取视频元信息 */
|
||||
function getVideoMetadata(
|
||||
file: File,
|
||||
): Promise<{ duration: number; width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const video = document.createElement("video")
|
||||
video.preload = "metadata"
|
||||
video.onloadedmetadata = () => {
|
||||
resolve({
|
||||
duration: video.duration,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
})
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error("无法读取视频信息"))
|
||||
}
|
||||
video.src = url
|
||||
})
|
||||
}
|
||||
|
||||
const AvatarVideoPanel: React.FC<AvatarVideoPanelProps> = ({
|
||||
video,
|
||||
onVideoChange,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > MAX_VIDEO_SIZE) {
|
||||
alert("视频文件大小不能超过 500MB")
|
||||
return
|
||||
}
|
||||
if (!file.type.startsWith("video/")) {
|
||||
alert("请上传 MP4 格式的视频文件")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取视频元信息
|
||||
const metadata = await getVideoMetadata(file)
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
|
||||
// 获取或创建默认项目
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
// 获取或创建默认视频库
|
||||
const library = await ensureDefaultLibrary({
|
||||
project_id: project.id,
|
||||
kind: "video",
|
||||
})
|
||||
|
||||
// 上传文件到 OSS
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: library.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 构建视频对象(使用本地预览 URL)
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
onVideoChange({
|
||||
url: previewUrl,
|
||||
name: file.name,
|
||||
duration: metadata.duration,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
size: file.size,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "上传失败,请重试"
|
||||
alert(message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
},
|
||||
[onVideoChange],
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
// 清空 input 以支持重复选择同一文件
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleUpload],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
handleUpload(file)
|
||||
}
|
||||
},
|
||||
[handleUpload],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOver(false)
|
||||
}, [])
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onVideoChange(null)
|
||||
}, [onVideoChange])
|
||||
|
||||
return (
|
||||
<div className={`ai-avatar-panel panel-avatar-video ${collapsed ? "collapsed" : ""}`}>
|
||||
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
|
||||
<h3>出镜视频</h3>
|
||||
<button className="collapse-btn">◀</button>
|
||||
</div>
|
||||
<div className="ai-avatar-panel-body">
|
||||
{video ? (
|
||||
<div>
|
||||
<div className="ai-avatar-media-preview">
|
||||
<video src={video.url} controls />
|
||||
</div>
|
||||
<div className="ai-avatar-media-info">
|
||||
<span>时长:{formatDuration(video.duration)}</span>
|
||||
<span>
|
||||
分辨率:{video.width}×{video.height}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ai-avatar-media-info">
|
||||
<span>{video.name}</span>
|
||||
</div>
|
||||
<button className="aa-btn aa-btn-sm" onClick={handleRemove} style={{ marginTop: 8 }}>
|
||||
移除视频
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`ai-avatar-upload-zone ${dragOver ? "drag-over" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<span className="upload-icon">🎬</span>
|
||||
{uploading ? (
|
||||
<div>
|
||||
<div>上传中... {uploadProgress}%</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 4,
|
||||
background: "#2a2a2a",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${uploadProgress}%`,
|
||||
height: "100%",
|
||||
background: "#3b82f6",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div>点击或拖拽上传视频</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>MP4 格式,不超过 500MB</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AvatarVideoPanel
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* B-roll 画面插入弹窗
|
||||
* 左右布局:左侧素材缩略图 + 右侧设置
|
||||
*/
|
||||
import React, { useCallback, useState } from "react"
|
||||
import type { BRollSegment } from "../types/aiAvatar"
|
||||
|
||||
interface BRollInsertModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
videoDuration: number
|
||||
}
|
||||
|
||||
/** 画中画位置选项 */
|
||||
const PIP_POSITIONS = [
|
||||
{ value: "top-left", label: "左上" },
|
||||
{ value: "top-right", label: "右上" },
|
||||
{ value: "bottom-left", label: "左下" },
|
||||
{ value: "bottom-right", label: "右下" },
|
||||
]
|
||||
|
||||
/** 格式化秒数为 mm:ss */
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const BRollInsertModal: React.FC<BRollInsertModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
videoDuration,
|
||||
}) => {
|
||||
/* 素材列表(示例数据,实际使用时通过 props 或 API 传入) */
|
||||
const [assets, setAssets] = useState<{ id: string; url: string; name: string }[]>([])
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null)
|
||||
|
||||
/* 设置 */
|
||||
const [insertMode, setInsertMode] = useState<"fullscreen" | "pip">("fullscreen")
|
||||
const [pipPosition, setPipPosition] = useState("top-right")
|
||||
const [startTime, setStartTime] = useState(0)
|
||||
const [endTime, setEndTime] = useState(5)
|
||||
const [scriptIndex, setScriptIndex] = useState(0)
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const asset = assets.find((a) => a.id === selectedAssetId)
|
||||
if (!asset) return
|
||||
|
||||
const segment: BRollSegment = {
|
||||
script_segment_index: scriptIndex,
|
||||
asset_url: asset.url,
|
||||
mode: insertMode,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
pip_position: insertMode === "pip" ? pipPosition : undefined,
|
||||
pip_scale: insertMode === "pip" ? 0.3 : undefined,
|
||||
}
|
||||
onConfirm(segment)
|
||||
onClose()
|
||||
}, [
|
||||
assets,
|
||||
selectedAssetId,
|
||||
scriptIndex,
|
||||
insertMode,
|
||||
pipPosition,
|
||||
startTime,
|
||||
endTime,
|
||||
onConfirm,
|
||||
onClose,
|
||||
])
|
||||
|
||||
/* 上传新素材占位 */
|
||||
const handleUploadAsset = useCallback(() => {
|
||||
/* 实际项目中触发文件上传逻辑 */
|
||||
const newAsset = {
|
||||
id: `asset-${Date.now()}`,
|
||||
url: "",
|
||||
name: "新素材",
|
||||
}
|
||||
setAssets((prev) => [...prev, newAsset])
|
||||
setSelectedAssetId(newAsset.id)
|
||||
}, [])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="ai-avatar-modal-overlay" onClick={onClose}>
|
||||
<div className="ai-avatar-modal" style={{ width: 800 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ai-avatar-modal-header">
|
||||
<h3>插入 B-roll 画面</h3>
|
||||
<button className="aa-btn aa-btn-sm" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ai-avatar-modal-body">
|
||||
<div className="ai-avatar-broll-layout">
|
||||
{/* 左侧:素材缩略图 */}
|
||||
<div className="ai-avatar-broll-timeline">
|
||||
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
|
||||
素材列表
|
||||
</h4>
|
||||
<div className="ai-avatar-broll-thumbnails">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`ai-avatar-broll-thumb ${selectedAssetId === asset.id ? "selected" : ""}`}
|
||||
onClick={() => setSelectedAssetId(asset.id)}
|
||||
title={asset.name}
|
||||
>
|
||||
{asset.url ? (
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={asset.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
fontSize: 11,
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
{asset.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="aa-btn" onClick={handleUploadAsset}>
|
||||
+ 上传新素材
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 右侧:设置 */}
|
||||
<div className="ai-avatar-broll-settings">
|
||||
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
|
||||
插入设置
|
||||
</h4>
|
||||
|
||||
{/* 插入位置 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
|
||||
插入位置(文案段落索引)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={scriptIndex}
|
||||
onChange={(e) => setScriptIndex(Number(e.target.value))}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "#222",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: 4,
|
||||
padding: "6px 8px",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 开始时间 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
|
||||
开始时间:{formatTime(startTime)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 持续时间 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
|
||||
结束时间:{formatTime(endTime)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={startTime}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="aa-divider" />
|
||||
|
||||
{/* 插入模式 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 8 }}>
|
||||
插入模式
|
||||
</label>
|
||||
<div className="aa-radio-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="brollInsertMode"
|
||||
value="fullscreen"
|
||||
checked={insertMode === "fullscreen"}
|
||||
onChange={() => setInsertMode("fullscreen")}
|
||||
/>
|
||||
全屏替换
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="brollInsertMode"
|
||||
value="pip"
|
||||
checked={insertMode === "pip"}
|
||||
onChange={() => setInsertMode("pip")}
|
||||
/>
|
||||
画中画
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画中画位置选择 */}
|
||||
{insertMode === "pip" && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 8 }}>
|
||||
画中画位置
|
||||
</label>
|
||||
<div className="ai-avatar-pip-positions">
|
||||
{PIP_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos.value}
|
||||
className={pipPosition === pos.value ? "active" : ""}
|
||||
onClick={() => setPipPosition(pos.value)}
|
||||
>
|
||||
{pos.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-avatar-modal-footer">
|
||||
<button className="aa-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="aa-btn aa-btn-primary"
|
||||
disabled={!selectedAssetId}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
确认插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BRollInsertModal
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 面板5:封面 & 生成
|
||||
* 封面预览 + 生成设置 + 生成按钮
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import type { AiAvatarCoverConfig, AiAvatarGenerateConfig } from "../types/aiAvatar"
|
||||
|
||||
interface CoverGeneratePanelProps {
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
setCoverConfig: (c: AiAvatarCoverConfig) => void
|
||||
generateConfig: AiAvatarGenerateConfig
|
||||
setGenerateConfig: (c: AiAvatarGenerateConfig) => void
|
||||
isGenerating: boolean
|
||||
onSubmitGenerate: () => void
|
||||
collapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
}
|
||||
|
||||
const CoverGeneratePanel: React.FC<CoverGeneratePanelProps> = ({
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
generateConfig,
|
||||
setGenerateConfig,
|
||||
isGenerating,
|
||||
onSubmitGenerate,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const handleFrameCapture = useCallback(() => {
|
||||
setCoverConfig({ ...coverConfig, mode: "frame", enabled: true })
|
||||
}, [coverConfig, setCoverConfig])
|
||||
|
||||
const handleCustomUpload = useCallback(() => {
|
||||
setCoverConfig({ ...coverConfig, mode: "upload", enabled: true })
|
||||
}, [coverConfig, setCoverConfig])
|
||||
|
||||
return (
|
||||
<div className={`ai-avatar-panel panel-cover-generate ${collapsed ? "collapsed" : ""}`}>
|
||||
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
|
||||
<h3>封面 & 生成</h3>
|
||||
<button className="collapse-btn">◀</button>
|
||||
</div>
|
||||
<div className="ai-avatar-panel-body">
|
||||
{/* 封面预览区 */}
|
||||
<div className="ai-avatar-cover-preview">
|
||||
{coverConfig.thumbnail_url ? (
|
||||
<img src={coverConfig.thumbnail_url} alt="封面预览" />
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面操作按钮 */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
<button className="aa-btn" style={{ flex: 1 }} onClick={handleFrameCapture}>
|
||||
从视频截取
|
||||
</button>
|
||||
<button className="aa-btn" style={{ flex: 1 }} onClick={handleCustomUpload}>
|
||||
自定义上传
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分割线 + 生成设置 */}
|
||||
<hr className="aa-divider" />
|
||||
|
||||
<div className="ai-avatar-generate-section">
|
||||
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
|
||||
生成设置
|
||||
</h4>
|
||||
|
||||
{/* 分辨率 */}
|
||||
<div className="field-row">
|
||||
<span>分辨率</span>
|
||||
<select
|
||||
value={generateConfig.resolution}
|
||||
onChange={(e) =>
|
||||
setGenerateConfig({
|
||||
...generateConfig,
|
||||
resolution: e.target.value as "720p" | "1080p",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 画面插入模式 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#fff", marginBottom: 8 }}>
|
||||
画面插入模式
|
||||
</label>
|
||||
<div className="aa-radio-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bRollMode"
|
||||
value="fullscreen"
|
||||
checked={generateConfig.bRollMode === "fullscreen"}
|
||||
onChange={() => setGenerateConfig({ ...generateConfig, bRollMode: "fullscreen" })}
|
||||
/>
|
||||
全屏切换
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="bRollMode"
|
||||
value="pip"
|
||||
checked={generateConfig.bRollMode === "pip"}
|
||||
onChange={() => setGenerateConfig({ ...generateConfig, bRollMode: "pip" })}
|
||||
/>
|
||||
画中画
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button
|
||||
className="ai-avatar-generate-btn"
|
||||
disabled={isGenerating}
|
||||
onClick={onSubmitGenerate}
|
||||
>
|
||||
{isGenerating ? "生成中..." : "🚀 开始生成视频"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverGeneratePanel
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — 出镜视频选择弹窗(#1809 ③)
|
||||
* 交互对齐智能剪辑 Step2:先选素材库(video 库)→ 再选该库内视频。
|
||||
* 搜索框 + 素材库下拉 + 竖屏 9:16 视频缩略图网格 + 底部确认选择。
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
|
||||
export interface ModalAssetPickerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (asset: AssetItem) => void
|
||||
/** 已选中的素材 ID(用于高亮) */
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalAssetPickerProps) {
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [pickedId, setPickedId] = useState<string | null>(null)
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
/* 弹窗打开:重置状态 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setKeyword("")
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setAssets([])
|
||||
setError("")
|
||||
setPickedId(selectedId ?? null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* 第一步:加载视频素材库列表(仅 kind=video,对齐智能剪辑 #1777) */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
setLoadingLibs(true)
|
||||
getAssetLibraries("video")
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(libs) ? libs : []
|
||||
setLibraries(list)
|
||||
// 默认选中第一个视频库
|
||||
if (list.length > 0) setLibraryId((prev) => prev || list[0].id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("素材库加载失败,请重试")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLibs(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 第二步:选中库后拉取该库视频素材(关键字防抖) */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
const load = async () => {
|
||||
try {
|
||||
// getAssets 返回 { items, total };拉满一页(数字人口播视频库通常不大)
|
||||
const { items } = await getAssets(libraryId, { page_size: 100 })
|
||||
if (cancelled) return
|
||||
let list = Array.isArray(items) ? items : []
|
||||
// 仅保留视频素材(出镜视频要求)
|
||||
list = list.filter((a) => a.mime_type?.includes("video"))
|
||||
const kw = keyword.trim()
|
||||
if (kw) list = list.filter((a) => a.name?.includes(kw))
|
||||
setAssets(list)
|
||||
setError("")
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("素材加载失败,请重试")
|
||||
setAssets([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(load, 300)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [open, libraryId, keyword])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pickedId) return
|
||||
const asset = assets.find((a) => a.id === pickedId)
|
||||
if (asset) onSelect(asset)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<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">
|
||||
{/* 第一步:选素材库 */}
|
||||
<div className="aa-asset-search">
|
||||
<select
|
||||
className="aa-select"
|
||||
style={{ width: 160, flex: "0 0 auto" }}
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
|
||||
) : (
|
||||
libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
📁 {lib.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="text"
|
||||
placeholder="搜索素材名称…"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{libraries.length === 0 && !loadingLibs ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无视频素材库,请先在「素材库」中创建视频库并上传视频
|
||||
</div>
|
||||
) : loadingAssets ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⚠️</div>
|
||||
{error}
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
该素材库暂无视频素材
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-asset-grid">
|
||||
{assets.map((asset) => {
|
||||
const isActive = asset.id === pickedId
|
||||
const thumb = asset.thumbnail_url || asset.file_url
|
||||
const isVideo = asset.mime_type?.includes("video")
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`aa-asset-card${isActive ? " selected" : ""}`}
|
||||
onClick={() => setPickedId(asset.id)}
|
||||
>
|
||||
{isVideo && !asset.thumbnail_url ? (
|
||||
<video src={asset.file_url} muted preload="metadata" />
|
||||
) : (
|
||||
<img src={thumb} alt={asset.name} />
|
||||
)}
|
||||
{isActive && <div className="aa-asset-card__check">✓</div>}
|
||||
<div className="aa-asset-card__name">{asset.name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:取消 + 确认选择 */}
|
||||
<div className="aa-modal__footer">
|
||||
<button type="button" className="aa-btn aa-btn--ghost" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!pickedId}
|
||||
>
|
||||
确认选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗(#1809 ④⑤⑥)
|
||||
*
|
||||
* 布局:
|
||||
* - 左侧:先选素材库(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 { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
|
||||
|
||||
interface ModalBRollEditorProps {
|
||||
open: boolean
|
||||
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
|
||||
}
|
||||
|
||||
const PIP_POSITION_OPTIONS: { value: PipPosition; label: string }[] = [
|
||||
{ value: "top-left", label: "左上" },
|
||||
{ value: "top-right", label: "右上" },
|
||||
{ value: "bottom-left", label: "左下" },
|
||||
{ value: "bottom-right", label: "右下" },
|
||||
]
|
||||
|
||||
const MODE_LABEL: Record<BRollInsertMode, string> = {
|
||||
fullscreen: "全屏切换",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
existingSegments,
|
||||
scriptText,
|
||||
outputDuration: _outputDuration,
|
||||
sentenceTimings,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}) => {
|
||||
/* ── 素材库(④ 先选库再选素材) ── */
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [availableAssets, setAvailableAssets] = useState<AssetItem[]>([])
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [assetError, setAssetError] = useState("")
|
||||
|
||||
/* ── 右侧设置本地状态 ── */
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetItem | null>(null)
|
||||
const [selectedSentence, setSelectedSentence] = useState<ScriptSentence | null>(null)
|
||||
const [mode, setMode] = useState<BRollInsertMode>("fullscreen")
|
||||
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
|
||||
const [pipScale, setPipScale] = useState(0.3)
|
||||
|
||||
/** 文案分句(优先使用后端精确时间戳,降级为字数比例估算) */
|
||||
const sentences = useMemo(
|
||||
() => splitScriptIntoSentences(scriptText, sentenceTimings, _outputDuration),
|
||||
[scriptText, sentenceTimings, _outputDuration],
|
||||
)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
const usedAssetIds = useMemo(
|
||||
() => new Set(existingSegments.map((seg) => seg.asset.id)),
|
||||
[existingSegments],
|
||||
)
|
||||
|
||||
/* 弹窗打开:重置选择 + 加载视频库列表 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
setMode("fullscreen")
|
||||
setPipPosition("top-right")
|
||||
setPipScale(0.3)
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setAvailableAssets([])
|
||||
setAssetError("")
|
||||
setLoadingLibs(true)
|
||||
let cancelled = false
|
||||
getAssetLibraries("video")
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(libs) ? libs : []
|
||||
setLibraries(list)
|
||||
if (list.length > 0) setLibraryId(list[0].id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAssetError("素材库加载失败,请重试")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLibs(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 选中库后拉取该库视频素材 */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
getAssets(libraryId, { page_size: 100 })
|
||||
.then(({ items }) => {
|
||||
if (cancelled) return
|
||||
const list = (Array.isArray(items) ? items : []).filter((a) =>
|
||||
a.mime_type?.includes("video"),
|
||||
)
|
||||
setAvailableAssets(list)
|
||||
setAssetError("")
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAssetError("素材加载失败,请重试")
|
||||
setAvailableAssets([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, libraryId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
/** 选择素材(已选素材因 pointer-events:none 不会触发) */
|
||||
const handleSelectAsset = (asset: AssetItem) => {
|
||||
if (usedAssetIds.has(asset.id)) return
|
||||
setSelectedAsset(asset)
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的精确起止,后端静音检测 / 前端字数比例降级) */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset || !selectedSentence) return
|
||||
const startTime = selectedSentence.startTime
|
||||
const endTime = Math.max(selectedSentence.endTime, startTime + 0.5)
|
||||
const segment: BRollSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
asset: selectedAsset,
|
||||
script_segment_index: selectedSentence.index,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
mode,
|
||||
pip_position: pipPosition,
|
||||
pip_scale: mode === "pip" ? pipScale : 0.3,
|
||||
}
|
||||
onConfirm(segment)
|
||||
// 重置素材/句子选择,保留模式设置便于连续添加
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
}
|
||||
|
||||
const canConfirm = selectedAsset !== null && selectedSentence !== null
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal aa-modal--wide" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">🎞️ 画面插入(B-roll)</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体:左素材 + 右设置 */}
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-broll-modal-body">
|
||||
{/* 左侧:选库 + 素材网格 */}
|
||||
<div className="aa-broll-left">
|
||||
<div className="aa-broll-lib-row">
|
||||
<select
|
||||
className="aa-select"
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
|
||||
) : (
|
||||
libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
📁 {lib.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="aa-broll-asset-grid">
|
||||
{availableAssets.map((asset) => {
|
||||
const alreadySelected = usedAssetIds.has(asset.id)
|
||||
const isCurrent = selectedAsset?.id === asset.id
|
||||
const classNames = [
|
||||
"aa-broll-asset-thumb",
|
||||
isCurrent ? "selected" : "",
|
||||
alreadySelected ? "already-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={classNames}
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
title={asset.name}
|
||||
>
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="aa-broll-asset-placeholder">🎬</div>
|
||||
)}
|
||||
<span className="aa-asset-card__name">{asset.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{loadingAssets ? (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : availableAssets.length === 0 ? (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
{assetError || "该素材库暂无视频素材"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:插入设置 */}
|
||||
<div className="aa-broll-right">
|
||||
<div className="aa-broll-settings">
|
||||
{/* ⑤ 文案句子列表(替代段落索引数字框) */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">对应文案句子(点选)</label>
|
||||
{sentences.length === 0 ? (
|
||||
<div className="aa-sentence-empty">
|
||||
请先在「文案 & 对口型」面板填写或选择文案
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-sentence-list">
|
||||
{sentences.map((sent) => {
|
||||
const active = selectedSentence?.index === sent.index
|
||||
return (
|
||||
<button
|
||||
key={sent.index}
|
||||
type="button"
|
||||
className={`aa-sentence-item${active ? " active" : ""}`}
|
||||
onClick={() => setSelectedSentence(sent)}
|
||||
title={sent.text}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 插入模式 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">插入模式</label>
|
||||
<div className="aa-broll-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-broll-mode-btn${mode === "fullscreen" ? " active" : ""}`}
|
||||
onClick={() => setMode("fullscreen")}
|
||||
>
|
||||
全屏切换
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-broll-mode-btn${mode === "pip" ? " active" : ""}`}
|
||||
onClick={() => setMode("pip")}
|
||||
>
|
||||
画中画
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画中画:四角位置 + 大小 */}
|
||||
{mode === "pip" && (
|
||||
<>
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">画中画位置</label>
|
||||
<div className="aa-pip-positions">
|
||||
{PIP_POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`aa-pip-pos-btn${
|
||||
pipPosition === opt.value ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setPipPosition(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<div className="aa-field-label-row">
|
||||
<label className="aa-label">画中画大小</label>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1" }}>
|
||||
{Math.round(pipScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={0.6}
|
||||
step={0.05}
|
||||
value={pipScale}
|
||||
onChange={(e) => setPipScale(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 当前选择提示(⑥ 自动估算时间在这里展示) */}
|
||||
<div className="aa-broll-hint">
|
||||
{selectedAsset && selectedSentence ? (
|
||||
<>
|
||||
<div>已选素材:{selectedAsset.name}</div>
|
||||
<div>
|
||||
对应第 {selectedSentence.index + 1} 句 · 时间段{" "}
|
||||
{selectedSentence.startTime.toFixed(1)}s -{" "}
|
||||
{Math.max(
|
||||
selectedSentence.endTime,
|
||||
selectedSentence.startTime + 0.5,
|
||||
).toFixed(1)}
|
||||
s
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8c8ca1" }}>
|
||||
{!selectedAsset ? "请从左侧选择一段素材" : "请在上方点选对应的文案句子"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:已配置的画面插入列表 */}
|
||||
<div className="aa-broll-list">
|
||||
<div className="aa-broll-list__title">已配置画面插入({existingSegments.length})</div>
|
||||
{existingSegments.length === 0 ? (
|
||||
<div className="aa-empty" style={{ padding: 12 }}>
|
||||
尚未配置画面插入
|
||||
</div>
|
||||
) : (
|
||||
existingSegments.map((seg) => (
|
||||
<div key={seg.id} className="aa-broll-item">
|
||||
{seg.asset.thumbnail_url ? (
|
||||
<img className="aa-broll-item__thumb" src={seg.asset.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="aa-broll-item__thumb" />
|
||||
)}
|
||||
<div className="aa-broll-item__info">
|
||||
<div style={{ fontWeight: 500, color: "#1a1a2e" }}>{seg.asset.name}</div>
|
||||
<div style={{ color: "#8c8ca1", fontSize: 11 }}>
|
||||
第 {seg.script_segment_index + 1} 句 · {MODE_LABEL[seg.mode]}
|
||||
{seg.mode === "pip" ? ` · ${seg.pip_position}` : ""} ·{" "}
|
||||
{seg.start_time.toFixed(1)}s - {seg.end_time.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-broll-item__remove"
|
||||
title="删除"
|
||||
onClick={() => onRemove(seg.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="aa-modal__footer">
|
||||
<button type="button" className="aa-btn" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
disabled={!canConfirm}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
✅ 添加画面插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalBRollEditor
|
||||
@@ -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,318 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — 面板5 / 封面选择弹窗内容:
|
||||
* - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段;
|
||||
* 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。
|
||||
* - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览),
|
||||
* 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。
|
||||
*
|
||||
* 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。
|
||||
*/
|
||||
import React, { useRef, useState } from "react"
|
||||
import type { AiAvatarCoverConfig, RenderJob } from "../types"
|
||||
|
||||
type PanelVariant = "setup" | "select-cover"
|
||||
|
||||
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?: {
|
||||
videoName: string | null
|
||||
voiceName: string | null
|
||||
scriptLength: number
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
/** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */
|
||||
coverStatus: "not_ready" | "pending" | "selected"
|
||||
}
|
||||
}
|
||||
|
||||
const RESOLUTION_OPTIONS = [
|
||||
{ value: "720p", label: "720p(高清)" },
|
||||
{ value: "1080p", label: "1080p(全高清)" },
|
||||
{ value: "4k", label: "4K(超清)" },
|
||||
]
|
||||
|
||||
const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
idle: { text: "未开始", cls: "aa-status-badge--idle" },
|
||||
pending: { text: "排队中", cls: "aa-status-badge--pending" },
|
||||
processing: { text: "生成中", cls: "aa-status-badge--processing" },
|
||||
completed: { text: "已完成", cls: "aa-status-badge--completed" },
|
||||
failed: { text: "失败", cls: "aa-status-badge--failed" },
|
||||
}
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
variant = "setup",
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
resolution = "720p",
|
||||
onResolutionChange,
|
||||
isGenerating = false,
|
||||
onGenerate,
|
||||
renderJob,
|
||||
onGenerateRenderSmartCover,
|
||||
onUploadCover,
|
||||
onClose,
|
||||
onCoverSelected,
|
||||
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 仅作本地展示)
|
||||
const url = URL.createObjectURL(file)
|
||||
_applyCoverUrl(url, "upload")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating
|
||||
// 渲染已完成 → 封面区可用
|
||||
const isRenderCompleted = renderJob?.status === "completed"
|
||||
const canSmartCover = isRenderCompleted && !smartCoverLoading
|
||||
|
||||
/** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */
|
||||
const coverUrl =
|
||||
coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url
|
||||
const hasCoverImage = Boolean(coverUrl)
|
||||
|
||||
/** 封面区占位文字 */
|
||||
const 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} />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">{coverPlaceholder}</span>
|
||||
)}
|
||||
{smartCoverLoading && <div className="aa-cover-preview__loading">⏳ 智能选帧中…</div>}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleSmartCover}
|
||||
disabled={!canSmartCover}
|
||||
title={isRenderCompleted ? "从成片智能选帧" : "请先生成视频"}
|
||||
>
|
||||
{smartCoverLoading ? "⏳ 智能选帧中…" : "🎬 智能获取封面"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
disabled={!isRenderCompleted || smartCoverLoading}
|
||||
title={isRenderCompleted ? "自定义上传封面" : "请先生成视频"}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── 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}
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 配置汇总 */}
|
||||
<div className="aa-generate-section">
|
||||
<div className="aa-config-summary">
|
||||
<div className="aa-config-summary__row">
|
||||
<span>出镜视频</span>
|
||||
{summary?.videoName ? (
|
||||
<span className="aa-config-summary__value">{summary.videoName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>音色</span>
|
||||
{summary?.voiceName ? (
|
||||
<span className="aa-config-summary__value">{summary.voiceName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>文案</span>
|
||||
{summary && summary.scriptLength > 0 ? (
|
||||
<span className="aa-config-summary__value">{summary.scriptLength} 字</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>对口型</span>
|
||||
{lipsync ? (
|
||||
<span className={`aa-status-badge ${lipsync.cls}`}>{lipsync.text}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>B-roll 画面插入</span>
|
||||
<span className="aa-config-summary__value">
|
||||
{summary && summary.brollCount > 0 ? `${summary.brollCount} 段` : "无"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>标题</span>
|
||||
{summary?.hasTitle ? (
|
||||
<span className="aa-config-summary__value">已设置</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>封面</span>
|
||||
{coverSummaryNode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--generate aa-btn--full"
|
||||
disabled={!canGenerate}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
{isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"}
|
||||
</button>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelCoverAndGenerate
|
||||
@@ -1,338 +0,0 @@
|
||||
/**
|
||||
* AI数字人 — 对口型预览面板(步骤2用)
|
||||
* B-roll 画面插入 + 对口型视频预览 + 生成/重新生成按钮
|
||||
* v3.1: 标题字号按预览容器实际宽度动态计算 previewScale(基准 720p),与成片一致
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { LipsyncJob, BRollSegment, AiAvatarTitleConfig } from "../types"
|
||||
|
||||
interface PanelLipsyncPreviewProps {
|
||||
lipsyncJob: LipsyncJob | null
|
||||
onGenerateLipsync: () => void
|
||||
bRollSegments: BRollSegment[]
|
||||
onOpenBRollModal: () => void
|
||||
onRemoveBRoll: (id: string) => void
|
||||
/** 标题配置(实时叠加预览用) */
|
||||
titleConfig?: AiAvatarTitleConfig
|
||||
/** 标题位置变更回调(拖拽结束时调用,发送百分比坐标 + position:"custom") */
|
||||
onTitlePositionChange?: (pos: { pos_x: number; pos_y: number; position: string }) => void
|
||||
}
|
||||
|
||||
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
|
||||
fullscreen: "全屏",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
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,
|
||||
bRollSegments,
|
||||
onOpenBRollModal,
|
||||
onRemoveBRoll,
|
||||
titleConfig,
|
||||
onTitlePositionChange,
|
||||
}: PanelLipsyncPreviewProps) {
|
||||
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"
|
||||
|
||||
const statusText =
|
||||
lipsyncJob?.status === "processing"
|
||||
? "对口型生成中…"
|
||||
: lipsyncJob?.status === "pending"
|
||||
? "排队中…"
|
||||
: "对口型生成中…"
|
||||
|
||||
// 监听预览容器尺寸变化,动态测量宽度以计算 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 handleTitlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !previewContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
;(e.target as Element).setPointerCapture(e.pointerId)
|
||||
draggingTitleRef.current = true
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
|
||||
}
|
||||
const handleTitlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current || !previewContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (titleDragRef.current) {
|
||||
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))
|
||||
const xpct = (relX / rect.width) * 100
|
||||
const ypct = (relY / rect.height) * 100
|
||||
titleDragRef.current.style.left = `${xpct}%`
|
||||
titleDragRef.current.style.top = `${ypct}%`
|
||||
}
|
||||
}
|
||||
const handleTitlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current) return
|
||||
draggingTitleRef.current = false
|
||||
if (onTitlePositionChange && previewContainerRef.current) {
|
||||
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" })
|
||||
}
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-script-lipsync">
|
||||
{/* ── B-roll 画面 ── */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">
|
||||
<span style={{ marginRight: 8 }}>🎞️ 插入画面</span>
|
||||
{bRollSegments.length > 0 && (
|
||||
<span className="aa-broll-badge">🎬 {bRollSegments.length} 个画面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-lipsync-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary aa-btn--full"
|
||||
onClick={onOpenBRollModal}
|
||||
>
|
||||
🎬 插入画面
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bRollSegments.length > 0 && (
|
||||
<div className="aa-broll-list">
|
||||
{bRollSegments.map((seg) => (
|
||||
<div key={seg.id} className="aa-broll-item">
|
||||
{seg.asset.thumbnail_url || seg.asset.file_url ? (
|
||||
<img
|
||||
className="aa-broll-item__thumb"
|
||||
src={seg.asset.thumbnail_url || seg.asset.file_url}
|
||||
alt={seg.asset.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="aa-broll-item__thumb" style={{ padding: "6px 4px" }}>
|
||||
🎬
|
||||
</span>
|
||||
)}
|
||||
<div className="aa-broll-item__info">
|
||||
<div
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{seg.asset.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#8c8ca1", marginTop: 2 }}>
|
||||
{BROLL_MODE_LABEL[seg.mode]} · {formatTime(seg.start_time)}-
|
||||
{formatTime(seg.end_time)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-broll-item__remove"
|
||||
title="删除"
|
||||
onClick={() => onRemoveBRoll(seg.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 对口型预览(标题字号按 previewScale 动态缩放) ─ */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">对口型预览</div>
|
||||
|
||||
<div className="aa-lipsync-preview" ref={previewContainerRef}>
|
||||
{isDone && lipsyncJob?.output_video_url ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
{titleOverlayStyle && (
|
||||
<div
|
||||
ref={titleDragRef}
|
||||
style={{
|
||||
...titleOverlayStyle,
|
||||
cursor: onTitlePositionChange ? "grab" : "default",
|
||||
pointerEvents: onTitlePositionChange ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
onPointerCancel={handleTitlePointerUp}
|
||||
>
|
||||
{titleConfig!.title.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isGenerating ? (
|
||||
<div style={{ width: "80%", textAlign: "center", color: "#fff" }}>
|
||||
<div style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
{statusText} {Math.round(lipsyncJob?.progress ?? 0)}%
|
||||
</div>
|
||||
<div className="aa-progress">
|
||||
<div
|
||||
className="aa-progress__bar"
|
||||
style={{ width: `${lipsyncJob?.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-video-preview__placeholder">
|
||||
{isFailed ? (
|
||||
<>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>❌</div>
|
||||
<div>对口型生成失败</div>
|
||||
{lipsyncJob?.error_message && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
color: "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{lipsyncJob.error_message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"生成对口型视频后在此预览"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-lipsync-actions">
|
||||
{isDone ? (
|
||||
<button type="button" className="aa-btn aa-btn--full" onClick={onGenerateLipsync}>
|
||||
🔄 重新生成对口型
|
||||
</button>
|
||||
) : isGenerating ? (
|
||||
<button type="button" className="aa-btn aa-btn--full" disabled>
|
||||
对口型生成中…
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary aa-btn--full"
|
||||
onClick={onGenerateLipsync}
|
||||
>
|
||||
🎬 生成对口型视频
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelLipsyncPreview
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user