Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia f412b322f0 fix(ci): actions/cache@v4 加 continue-on-error——缓存故障不阻断 job
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m20s
AI Code Review / AI Code Review (pull_request) Successful in 3m32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m5s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m28s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 14m53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 16m10s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 26m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 37m22s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 47m49s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m38s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 375h45m36s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 375h45m37s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 375h45m38s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 375h45m37s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 375h45m38s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 376h18m0s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 376h18m4s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 376h18m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 376h18m32s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 376h20m2s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 376h20m4s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 376h20m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 376h31m37s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 376h31m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 376h33m1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 376h31m39s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 376h33m3s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 376h36m49s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 376h19m53s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 377h5m55s
所有 5 个 actions/cache@v4 步骤加 continue-on-error: true:
- validate-style pip cache
- validate-security pip cache
- validate-python pip cache
- unit-tests pip cache
- frontend-unit-test npm cache

当 Gitea Actions 缓存基础设施出现瞬态故障(git cache EOF /
归档损坏)时,cache restore 步骤会标黄但不中断 job,
后续的 pip install 重试逻辑自动 fresh download 兜底。
2026-08-31 22:42:01 +08:00
192 changed files with 1981 additions and 15346 deletions
-1
View File
@@ -1 +0,0 @@
CI re-trigger after runner add-host/DNS fix. This file is harmless and not referenced.
-105
View File
@@ -1,105 +0,0 @@
name: CI Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements-dev.txt'
- 'infra/docker/ci.Dockerfile'
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - ci-base 镜像重建"
concurrency:
group: ci-base-image-build
cancel-in-progress: false
jobs:
build-ci-base:
name: Build CI Base Image
runs-on: runtime-builder
timeout-minutes: 60
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Gitea Registry
shell: sh
env:
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
sleep 5
done
- name: Build and push CI base image
shell: sh
run: |
set -eu
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
VERSION_TAG="deps-$(date +%Y%m%d-%H%M)-${GITHUB_SHA::8}"
echo "=== Building CI base image (tags: latest, ${VERSION_TAG}) ==="
docker build --progress=plain \
-f infra/docker/ci.Dockerfile \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${VERSION_TAG}" \
.
echo "✅ Image built successfully"
echo "=== Pushing ${VERSION_TAG} ==="
docker push "${IMAGE}:${VERSION_TAG}"
echo "=== Pushing latest ==="
docker push "${IMAGE}:latest"
echo "✅ Pushed to Gitea Registry"
- name: Verify image
shell: sh
run: |
set -eu
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base:latest"
echo "=== Verifying pinned deps in fresh image ==="
docker run --rm "${IMAGE}" /opt/xiaoxia-ci-venv/bin/python -c \
"import httpcore, h2, numpy, httpx; print('VERSIONS:', httpcore.__version__, h2.__version__, numpy.__version__, httpx.__version__)"
- name: Notify result
if: always()
continue-on-error: true
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
if [ "${{ job.status }}" = "success" ]; then
NOTIFY_MODE=success JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
else
NOTIFY_MODE=failure JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
fi
- name: Cleanup
if: always()
shell: sh
run: |
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
docker rmi "${IMAGE}:latest" 2>/dev/null || true
echo "Cleanup done"
-51
View File
@@ -1,51 +0,0 @@
name: CI Canary Check
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
jobs:
canary:
runs-on: ci-l2
timeout-minutes: 10
steps:
- name: Canary (runner -> docker -> network -> gitea)
run: |
set -e
echo "== runner/container basic =="
date; hostname; whoami
echo "== gitea api reachability =="
code=$(curl -s -o /tmp/v.json -w '%{http_code}' -m 15 "$GITHUB_API_URL/version")
echo "gitea api http_code=$code"
[ "$code" = "200" ] || { echo "::error::Gitea API unreachable, http_code=$code"; exit 1; }
cat /tmp/v.json; echo
echo "== external egress =="
ext=$(curl -s -o /dev/null -w '%{http_code}' -m 15 https://www.baidu.com || echo 000)
echo "external http_code=$ext"
echo "== gitea domain resolves NOT to loopback =="
set -o pipefail
ip=$(getent hosts git.xiaoxiajianji.com | awk '{print $1}' | head -1)
echo "git.xiaoxiajianji.com -> $ip"
if [ -z "$ip" ]; then
echo "::error::DNS resolution failed, git.xiaoxiajianji.com unresolvable"; exit 1
fi
if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then
echo "::error::Gitea domain resolves to loopback inside job container (hosts/DNS leak)"; exit 1
fi
echo "CANARY OK"
- name: Notify failure
if: failure()
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
if [ -n "$CI_NOTIFY_WEBHOOK" ]; then
MSG="🚨 CI 金丝雀失败:runner->docker->网络->Gitea 链路异常,时间 $(date '+%Y-%m-%d %H:%M:%S'),请立即检查构建服务器"
python3 - "$CI_NOTIFY_WEBHOOK" "$MSG" <<'PY'
import json,sys,urllib.request
hook,msg=sys.argv[1],sys.argv[2]
data=json.dumps({"msg_type":"text","content":{"text":msg}}).encode()
urllib.request.urlopen(urllib.request.Request(hook,data=data,headers={"Content-Type":"application/json"}),timeout=10)
PY
fi
exit 0
+11 -60
View File
@@ -283,7 +283,6 @@ jobs:
sleep 5 sleep 5
done done
- name: Run security checks - name: Run security checks
continue-on-error: true # Security scan is advisory; runner failure must not block deploy
shell: bash shell: bash
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
@@ -334,7 +333,7 @@ jobs:
PIP_NO_CACHE_DIR: '' PIP_NO_CACHE_DIR: ''
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false' USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'false' CI_USE_SHARED_PG: 'true'
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -518,7 +517,7 @@ jobs:
env: env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false' USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'false' CI_USE_SHARED_PG: 'true'
OSS_ACCESS_KEY_ID: placeholder OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut OSS_BUCKET_NAME: xiaoxia-autocut
@@ -1170,31 +1169,6 @@ jobs:
run: | run: |
set +e set +e
NOTIFY_MODE=start JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py NOTIFY_MODE=start JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
- name: Render .env from template
shell: sh
env:
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
STAGING_REDIS_URL: ${{ secrets.STAGING_REDIS_URL }}
STAGING_CELERY_BROKER_URL: ${{ secrets.STAGING_CELERY_BROKER_URL }}
STAGING_CELERY_RESULT_BACKEND: ${{ secrets.STAGING_CELERY_RESULT_BACKEND }}
STAGING_JWT_SECRET_KEY: ${{ secrets.STAGING_JWT_SECRET_KEY }}
STAGING_MINIO_ENDPOINT: ${{ secrets.STAGING_MINIO_ENDPOINT }}
STAGING_MINIO_ACCESS_KEY: ${{ secrets.STAGING_MINIO_ACCESS_KEY }}
STAGING_MINIO_SECRET_KEY: ${{ secrets.STAGING_MINIO_SECRET_KEY }}
STAGING_MINIO_BUCKET: ${{ secrets.STAGING_MINIO_BUCKET }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
COSYVOICE_API_KEY: ${{ secrets.COSYVOICE_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
MEDIAKIT_API_KEY: ${{ secrets.MEDIAKIT_API_KEY }}
run: |
set -eu
echo "Rendering .env from template + secrets..."
bash scripts/render_env.sh staging
echo "✅ .env rendered (file contains secrets, not printed to log)"
# 验证文件存在且非空
test -s .env.rendered
echo "✅ .env.rendered validated ($(wc -l < .env.rendered) lines)"
- name: Docker login to Registry - name: Docker login to Registry
shell: sh shell: sh
env: env:
@@ -1267,31 +1241,9 @@ jobs:
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "echo SSH_CONNECTION_OK && hostname" ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified" echo "SSH connection verified"
# 配置 Diff 检查:下载服务器当前 .env,对比渲染结果,检测漂移
echo "Running config diff check..."
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no \
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/.env" .env.current 2>/dev/null \
|| touch .env.current # 首次部署时文件不存在,创建空文件
bash scripts/config_diff_check.sh .env.rendered .env.current
rm -f .env.current
echo "Config diff check done"
# 上传渲染后的 .env 到服务器(替代服务器上旧的 .env)
echo "Uploading rendered .env to staging server..."
# 备份旧 .env
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \
"cp -f /var/lib/xiaoxia-saas-staging/.env /var/lib/xiaoxia-saas-staging/.env.bak.\$(date +%Y%m%d%H%M%S) 2>/dev/null || true"
# 上传新 .env
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no .env.rendered \
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/.env"
echo "✅ .env uploaded to staging server"
# 通过环境变量传递凭证,避免命令行引号转义问题 # 通过环境变量传递凭证,避免命令行引号转义问题
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh" cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
# 清理 CI runner 上的渲染文件
rm -f .env.rendered
- name: Staging health check + auto rollback - name: Staging health check + auto rollback
if: success() if: success()
shell: sh shell: sh
@@ -1462,7 +1414,7 @@ jobs:
- unit-tests - unit-tests
- frontend-lint - frontend-lint
- frontend-unit-test - frontend-unit-test
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled() if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -1546,7 +1498,11 @@ jobs:
set -eu set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji" REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha # 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
TAG_NAME="${GITHUB_SHA}" if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAG_NAME="${GITHUB_REF_NAME}"
else
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
fi
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}" IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main" CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
@@ -1608,7 +1564,7 @@ jobs:
concurrency: concurrency:
group: deploy-production-${{ gitea.ref }} group: deploy-production-${{ gitea.ref }}
cancel-in-progress: false cancel-in-progress: false
if: github.event_name == 'push' && github.ref_name == 'main' if: startsWith(github.ref, 'refs/tags/v')
needs: needs:
- build-production - build-production
steps: steps:
@@ -1681,7 +1637,7 @@ jobs:
echo "SSH connection verified" echo "SSH connection verified"
# 通过环境变量传递凭证,避免命令行引号转义问题 # 通过环境变量传递凭证,避免命令行引号转义问题
cat scripts/ci_production_deploy.sh | ssh -p "$production_port" -i "$key_path" -o StrictHostKeyChecking=no "${production_user}@${production_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh" cat scripts/ci_production_deploy.sh | ssh -p "$production_port" -i "$key_path" -o StrictHostKeyChecking=no "${production_user}@${production_host}" "IMAGE_TAG=${GITHUB_REF_NAME} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
- name: Production health check + auto rollback - name: Production health check + auto rollback
if: success() if: success()
@@ -1740,7 +1696,7 @@ jobs:
name: Production Browser E2E name: Production Browser E2E
runs-on: runtime-builder runs-on: runtime-builder
timeout-minutes: 15 timeout-minutes: 15
# if: removed - runs after deploy-production succeeds if: startsWith(github.ref, 'refs/tags/v')
needs: deploy-production needs: deploy-production
steps: steps:
- name: Checkout code - name: Checkout code
@@ -2060,11 +2016,6 @@ jobs:
echo " ⏳ $name: pending(审查中,暂不阻塞)" echo " ⏳ $name: pending(审查中,暂不阻塞)"
continue continue
fi fi
# Security scan cancelled/failed时不阻塞部署(runner故障不应卡住流水线)
if [ "$name" = "validate-security" ] && { [ "$result" = "cancelled" ] || [ "$result" = "failure" ]; }; then
echo " ⚠️ $name: $result(安全扫描为非阻塞项,不卡住部署)"
continue
fi
check_job "$name" "$result" check_job "$name" "$result"
done done
@@ -1,59 +0,0 @@
name: Playwright Base Image Build
on:
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "构建 playwright 基础镜像"
jobs:
build-playwright:
name: Build Playwright Base Image
runs-on: runtime-builder
timeout-minutes: 30
steps:
- name: Docker login to Gitea Registry
shell: sh
env:
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login attempt $i/3 ==="
if printf '%s' "${GITEA_REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" --password-stdin; then
echo "Docker login successful"
break
fi
echo "Docker login failed (attempt $i/3), retrying in 5s..."
sleep 5
[ $i -eq 3 ] && exit 1
done
- name: Pull, retag and push Playwright image
shell: sh
run: |
set -eu
OFFICIAL_IMAGE="mcr.microsoft.com/playwright:v1.45.0-jammy"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy"
echo "=== Pulling official Playwright image ==="
docker pull "${OFFICIAL_IMAGE}"
echo "=== Tagging ==="
docker tag "${OFFICIAL_IMAGE}" "${GITEA_IMAGE}"
echo "=== Pushing to Gitea Registry ==="
docker push "${GITEA_IMAGE}"
echo "Done: ${GITEA_IMAGE}"
- name: Cleanup
if: always()
shell: sh
run: |
docker rmi "mcr.microsoft.com/playwright:v1.45.0-jammy" 2>/dev/null || true
docker rmi "git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy" 2>/dev/null || true
echo "Cleanup done"
+1 -1
View File
@@ -3,7 +3,7 @@ name: PR Auto Scan
# 作为短作业模式的兜底,防止事件驱动遗漏 # 作为短作业模式的兜底,防止事件驱动遗漏
on: on:
schedule: schedule:
# - cron: "*/15 * * * *" # DISABLED: temporarily to stop failure spam (2026-09-02) # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负) - cron: "*/10 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+2 -3
View File
@@ -18,7 +18,7 @@ jobs:
name: Auto Approve on CI Green name: Auto Approve on CI Green
runs-on: ci-check runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 10 # 等待CI全绿+审批,需要充足时间 timeout-minutes: 3 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
steps: steps:
- name: Checkout code - name: Checkout code
shell: sh shell: sh
@@ -61,8 +61,7 @@ jobs:
name: Auto Merge on CI Green + Approved name: Auto Merge on CI Green + Approved
runs-on: ci-check runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop' if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
needs: [auto-approve] # 修复竞态:必须等审批完成后再尝试合并 timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
timeout-minutes: 15 # 等待审批+CI就绪+合并,需要充足时间
steps: steps:
- name: Checkout code - name: Checkout code
shell: sh shell: sh
-6
View File
@@ -24,11 +24,6 @@ ruff_cache/
.env.production .env.production
.env.staging .env.staging
!.env.example !.env.example
# 配置模板不受忽略规则限制
!deploy/configs/.env.staging
!deploy/configs/.env.production
# 渲染后的 env 文件包含真实密钥,绝不能提交
.env.rendered
# OS / editor # OS / editor
.DS_Store .DS_Store
@@ -59,4 +54,3 @@ frontend-v21-ui-prototype-final.html
!.vscode/settings.json !.vscode/settings.json
.vscode/extensions.json .vscode/extensions.json
.coverage .coverage
.env.current
@@ -1,26 +0,0 @@
"""add sort_order to template_categories
Revision ID: 061_sort_order
Revises: 060_migrate_segments
Create Date: 2026-09-02
"""
import sqlalchemy as sa
from alembic import op
revision = "061_sort_order"
down_revision = "060_migrate_segments"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"template_categories",
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("template_categories", "sort_order")
@@ -1,28 +0,0 @@
"""re-add edit_plan_id to generation_tasks (align staging with production)
Revision ID: 062_edit_plan_id
Revises: 061_sort_order
Create Date: 2026-09-02
"""
import sqlalchemy as sa
from alembic import op
revision = "062_edit_plan_id"
down_revision = "061_sort_order"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("edit_plan_id", sa.String(36), nullable=True),
)
op.create_index("ix_generation_tasks_edit_plan_id_2", "generation_tasks", ["edit_plan_id"])
def downgrade() -> None:
op.drop_index("ix_generation_tasks_edit_plan_id_2", table_name="generation_tasks")
op.drop_column("generation_tasks", "edit_plan_id")
@@ -1,46 +0,0 @@
"""add video_fingerprint_chunks table for per-chunk fingerprint storage
Revision ID: 063_fingerprint_chunks
Revises: 062_edit_plan_id
Create Date: 2026-09-03
"""
import sqlalchemy as sa
from alembic import op
revision = "063_fingerprint_chunks"
down_revision = "062_edit_plan_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"video_fingerprint_chunks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("video_id", sa.String(36), nullable=False),
sa.Column("project_id", sa.String(36), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False, server_default=""),
sa.Column("start_time_ms", sa.Integer, nullable=False),
sa.Column("end_time_ms", sa.Integer, nullable=False),
sa.Column("phash_binary", sa.String(16), nullable=False),
sa.Column("color_histogram", sa.JSON, nullable=False),
sa.Column("frame_count", sa.Integer, nullable=False, server_default="1"),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_vfc_video_id", "video_fingerprint_chunks", ["video_id"])
op.create_index("ix_vfc_project_id", "video_fingerprint_chunks", ["project_id"])
op.create_index("ix_vfc_user_id", "video_fingerprint_chunks", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_vfc_user_id", table_name="video_fingerprint_chunks")
op.drop_index("ix_vfc_project_id", table_name="video_fingerprint_chunks")
op.drop_index("ix_vfc_video_id", table_name="video_fingerprint_chunks")
op.drop_table("video_fingerprint_chunks")
@@ -1,25 +0,0 @@
"""add match_count and visual_similarity to generated_videos
Revision ID: 064_match_count_visual_sim
Revises: 063_fingerprint_chunks
Create Date: 2026-09-03
"""
import sqlalchemy as sa
from alembic import op
revision = "064_match_count_visual_sim"
down_revision = "063_fingerprint_chunks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generated_videos", sa.Column("match_count", sa.Integer(), nullable=True, server_default="0"))
op.add_column("generated_videos", sa.Column("visual_similarity", sa.Float(), nullable=True, server_default="0.0"))
def downgrade() -> None:
op.drop_column("generated_videos", "visual_similarity")
op.drop_column("generated_videos", "match_count")
@@ -1,25 +0,0 @@
"""add visual_similarity and match_count to duplication_records
Revision ID: 065_dup_record_sim_match
Revises: 064_match_count_visual_sim
Create Date: 2026-09-04
"""
import sqlalchemy as sa
from alembic import op
revision = "065_dup_record_sim_match"
down_revision = "064_match_count_visual_sim"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("duplication_records", sa.Column("visual_similarity", sa.Float(), nullable=True))
op.add_column("duplication_records", sa.Column("match_count", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("duplication_records", "match_count")
op.drop_column("duplication_records", "visual_similarity")
+35 -49
View File
@@ -290,7 +290,7 @@ def list_assets(
else: else:
total = asset_repository.count_by_project_ids(project_ids, status=status_list) total = asset_repository.count_by_project_ids(project_ids, status=status_list)
# 跨项目分页:逐项目累积直到凑够一页 # 跨项目分页:逐项目累积直到凑够一页
paged_items = [] paged_items: list = []
offset = skip offset = skip
remaining = limit remaining = limit
for pid in project_ids: for pid in project_ids:
@@ -579,45 +579,39 @@ def smart_match_assets(
filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000) filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000)
total_candidates = len(filtered_assets) total_candidates = len(filtered_assets)
# ── 过滤前置:余量 + 高频使用,过滤在评分/截取 limit 之前完成 ────────── # 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
# 旧实现先 smart_select_assets(limit=N) 再对这 N 条做过滤,过滤后不回补, results = smart_select_assets(
# 当排名靠前的素材恰好都被排除时返回空 items(前端回退全选,smart-match 名存实亡)。 filtered_assets,
# 现在先过滤全量候选,每级过滤后为空/不足则回退上一级,最后才评分截取。 limit=request.limit,
kind=None,
)
# 1) 余量过滤:usable=False(零重复可切区间耗尽且历史区间均达复用上限)的素材排除 # 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
usable_assets = [] # 不返回给前端;不动 smart_select_assets 评分逻辑本身
exhausted_assets = [] filtered_results = []
for a in filtered_assets: for r in results:
try: try:
avail = compute_asset_availability(a) avail = compute_asset_availability(r.asset)
except Exception: except Exception:
logger.warning( logger.warning(
"smart-match 余量计算失败,按可用处理: asset_id=%s", "smart-match 余量计算失败,按可用处理: asset_id=%s",
getattr(a, "id", "?"), getattr(r.asset, "id", "?"),
exc_info=True, exc_info=True,
) )
avail = None avail = None
if avail is not None and not avail["usable"]: if avail is not None and not avail["usable"]:
exhausted_assets.append(a) logger.info(
else: "smart-match 排除已用尽素材: asset_id=%s name=%s",
usable_assets.append(a) getattr(r.asset, "id", "?"),
getattr(r.asset, "name", ""),
)
continue
filtered_results.append(r)
if exhausted_assets: # 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
logger.info(
"smart-match 余量过滤: 候选 %d,可切区间耗尽 %d",
len(filtered_assets), len(exhausted_assets),
)
# 回退策略:余量过滤后为空(全部耗尽)时,保留全部候选,不返回空结果。
# 宁可让用户在已耗尽素材上复用,也比 smart-match 空结果回退全选更可控
# (全选同样会选到这些素材,且不经过评分排序)。
pool = usable_assets if usable_assets else filtered_assets
# 2) 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
MAX_RECENT_USE_COUNT = 3 MAX_RECENT_USE_COUNT = 3
high_freq_assets = set() if filtered_results:
if pool: asset_ids = [getattr(r.asset, "id", "") for r in filtered_results if getattr(r.asset, "id", "")]
asset_ids = [getattr(a, "id", "") for a in pool if getattr(a, "id", "")]
if asset_ids: if asset_ids:
try: try:
use_counts = get_asset_recent_use_counts( use_counts = get_asset_recent_use_counts(
@@ -625,35 +619,27 @@ def smart_match_assets(
asset_ids=asset_ids, asset_ids=asset_ids,
recent_video_count=5, recent_video_count=5,
) )
for a in pool: high_use_excluded = set()
aid = getattr(a, "id", "") for r in filtered_results:
aid = getattr(r.asset, "id", "")
count = use_counts.get(aid, 0) count = use_counts.get(aid, 0)
if count > MAX_RECENT_USE_COUNT: if count > MAX_RECENT_USE_COUNT:
high_freq_assets.add(aid)
logger.info( logger.info(
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d", "smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
aid, count, MAX_RECENT_USE_COUNT, aid, count, MAX_RECENT_USE_COUNT,
) )
# 回退策略:排除后剩余素材不足(为空或不够 limit)时, high_use_excluded.add(id(r))
# 不再全部排除,保留全部可用素材
if high_freq_assets:
remaining_count = len(pool) - len(high_freq_assets)
enough = request.limit is None or remaining_count >= request.limit
if remaining_count > 0 and enough:
pool = [a for a in pool if getattr(a, "id", "") not in high_freq_assets]
else: else:
logger.info( pass
"smart-match 高频排除后素材不足(%d<%s),保留全部 %d 条", # 如果排除后不够 limit,放宽到不限制
remaining_count, remaining = [r for r in filtered_results if id(r) not in high_use_excluded]
request.limit if request.limit is not None else "不限", if len(remaining) >= request.limit:
len(pool), filtered_results = remaining
) else:
logger.info("smart-match 高频排除后素材不足(%d<%d),保留全部", len(remaining), request.limit)
except Exception: except Exception:
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True) logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
# 3) 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
results = smart_select_assets(pool, limit=request.limit, kind=None)
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层, # 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读 # 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
items = [ items = [
@@ -662,7 +648,7 @@ def smart_match_assets(
score=r.score, score=r.score,
breakdown=r.breakdown, breakdown=r.breakdown,
) )
for r in results for r in filtered_results
] ]
return SmartMatchResponse(items=items, total_candidates=total_candidates) return SmartMatchResponse(items=items, total_candidates=total_candidates)
+1 -1
View File
@@ -456,7 +456,7 @@ async def wechat_callback(
user = user_repository.find_by_id(response.user_id) user = user_repository.find_by_id(response.user_id)
binding_complete = False binding_complete = False
if user: if user:
binding_complete = bool( binding_complete = (
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
) )
-9
View File
@@ -7,7 +7,6 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_duplication_repository from app.dependencies import get_duplication_repository
from app.schemas.duplication import ( from app.schemas.duplication import (
@@ -77,8 +76,6 @@ def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
status=record.status, status=record.status,
duplicate_rate=record.duplicate_rate, duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count, duplicate_count=record.duplicate_count,
visual_similarity=getattr(record, "visual_similarity", None),
match_count=getattr(record, "match_count", None),
created_at=record.created_at.isoformat(), created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(), updated_at=record.updated_at.isoformat(),
) )
@@ -93,8 +90,6 @@ def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
status=record.status, status=record.status,
duplicate_rate=record.duplicate_rate, duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count, duplicate_count=record.duplicate_count,
visual_similarity=getattr(record, "visual_similarity", None),
match_count=getattr(record, "match_count", None),
created_at=record.created_at.isoformat(), created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(), updated_at=record.updated_at.isoformat(),
segments=[ segments=[
@@ -197,8 +192,6 @@ async def upload_for_duplication(
authenticated_user.user.id, authenticated_user.user.id,
) )
celery_app.send_task("worker.process_duplication_check", args=[record.id])
return DuplicationUploadResponse( return DuplicationUploadResponse(
id=record.id, id=record.id,
status=record.status, status=record.status,
@@ -303,8 +296,6 @@ def retry_duplication(
detail=f"查重记录 {record_id} 不存在", detail=f"查重记录 {record_id} 不存在",
) )
celery_app.send_task("worker.process_duplication_check", args=[updated.id])
return DuplicationUploadResponse( return DuplicationUploadResponse(
id=updated.id, id=updated.id,
status=updated.status, status=updated.status,
+3 -3
View File
@@ -513,7 +513,7 @@ def generate_cover(
if generation_task_id: if generation_task_id:
try: try:
task = gen_task_repo.get(generation_task_id) task = gen_task_repo.get(generation_task_id)
if task and getattr(task, "cover_url", ""): # type: ignore[arg-type] if task and getattr(task, "cover_url", ""):
cover_url_from_task = task.cover_url cover_url_from_task = task.cover_url
logger.info( logger.info(
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s", "[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
@@ -538,7 +538,7 @@ def generate_cover(
gv_task_id = getattr(gv, "generation_task_id", "") or "" gv_task_id = getattr(gv, "generation_task_id", "") or ""
if gv_task_id: if gv_task_id:
task_a2 = gen_task_repo.get(gv_task_id) task_a2 = gen_task_repo.get(gv_task_id)
if task_a2 and getattr(task_a2, "cover_url", ""): # type: ignore[arg-type] if task_a2 and getattr(task_a2, "cover_url", ""):
cover_url_from_task = task_a2.cover_url cover_url_from_task = task_a2.cover_url
logger.info( logger.info(
"[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s", "[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s",
@@ -747,7 +747,7 @@ def generate_cover(
if cover_url_from_task: if cover_url_from_task:
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题 # 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
cover_data: dict[str, object] = { # type: ignore[no-redef] cover_data = {
"type": "ai_frame", "type": "ai_frame",
"image_url": cover_url_from_task, "image_url": cover_url_from_task,
"frame_time": 0.0, "frame_time": 0.0,
+125 -240
View File
@@ -23,7 +23,6 @@ from app.dependencies import (
get_generation_task_repository, get_generation_task_repository,
) )
from app.schemas.generation_task import ( from app.schemas.generation_task import (
BatchPreviewGenerationTaskResponse,
CreatePreviewGenerationTaskRequest, CreatePreviewGenerationTaskRequest,
PreviewGenerationTaskResponse, PreviewGenerationTaskResponse,
) )
@@ -99,7 +98,7 @@ def _resolve_strategy_id_from_template(template_id: str, db: Session, user_id: s
try: try:
new_repo = SQLAlchemyEditTemplateRepository(db) new_repo = SQLAlchemyEditTemplateRepository(db)
new_template = new_repo.get(template_id) new_template = new_repo.get(template_id)
if new_template and getattr(new_template, "editing_mode", ""): # type: ignore[arg-type] if new_template and getattr(new_template, "editing_mode", ""):
mode = new_template.editing_mode.strip() mode = new_template.editing_mode.strip()
if mode: if mode:
logger.info( logger.info(
@@ -194,19 +193,11 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
if started_at and completed_at: if started_at and completed_at:
generate_duration = (completed_at - started_at).total_seconds() generate_duration = (completed_at - started_at).total_seconds()
title_cfg = getattr(task, "title_config", None)
title_cfg = title_cfg if isinstance(title_cfg, dict) else {}
extra_meta = getattr(task, "extra_meta", None)
extra_meta = extra_meta if isinstance(extra_meta, dict) else {}
voice_library_id = getattr(task, "voice_library_id", "") or ""
if not isinstance(voice_library_id, str):
voice_library_id = str(voice_library_id) if voice_library_id else ""
return PreviewGenerationTaskResponse( return PreviewGenerationTaskResponse(
task_id=task.id, task_id=task.id,
status=task.status.value if hasattr(task.status, "value") else str(task.status), status=task.status.value if hasattr(task.status, "value") else str(task.status),
progress=float(task.progress or 0.0), progress=float(task.progress or 0.0),
is_preview=bool(getattr(task, "is_preview", True)), is_preview=bool(getattr(task, "is_preview", True)),
variant_index=int(extra_meta.get("variant_index", 0) or 0),
resolution=getattr(task, "resolution", "") or "", resolution=getattr(task, "resolution", "") or "",
video_url=video_url, video_url=video_url,
duration=duration, duration=duration,
@@ -215,8 +206,6 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
transition_count=transition_count, transition_count=transition_count,
material_usage=material_usage, material_usage=material_usage,
error_message=task.error_message or "", error_message=task.error_message or "",
title_text=str(title_cfg.get("text", "") or ""),
voice_library_id=voice_library_id,
created_at=task.created_at, created_at=task.created_at,
started_at=started_at, started_at=started_at,
finished_at=completed_at, finished_at=completed_at,
@@ -224,95 +213,45 @@ def _to_preview_response(task, generated_videos: list | None = None) -> PreviewG
) )
def _resolve_preview_edit_plan_id( @router.post("/preview", response_model=PreviewGenerationTaskResponse, status_code=201)
*,
request: CreatePreviewGenerationTaskRequest,
task,
db: Session,
user_id: str,
) -> str:
"""确定任务关联的编辑计划ID:优先前端传入,否则按 template_id+user 兜底查找。"""
if task.source_edit_plan_id:
return task.source_edit_plan_id
if not request.template_id:
return ""
try:
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
_plan_repo = SQLAlchemyEditPlanRepository(db)
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
for _p in _plans:
if (_p.created_by_user_id or "") == user_id:
logger.info(
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_p.id,
)
return _p.id
except Exception:
logger.warning(
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
return ""
def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
"""从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。"""
if not values:
return fallback
if len(values) == 1:
return values[0]
return values[index] if index < len(values) else fallback
@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201)
def create_preview_generation_task( def create_preview_generation_task(
request: CreatePreviewGenerationTaskRequest, request: CreatePreviewGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository=Depends(get_generation_task_repository), generation_task_repository=Depends(get_generation_task_repository),
db: Session = Depends(get_db_session), db: Session = Depends(get_db_session),
asset_repo=Depends(get_asset_repository), asset_repo=Depends(get_asset_repository),
) -> BatchPreviewGenerationTaskResponse: ) -> PreviewGenerationTaskResponse:
"""创建预览生成任务(支持批量)。 """创建预览生成任务。
preview_count=1 时行为与旧版完全一致(创建 1 个任务); 预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。
preview_count=N 时一次创建 N 个独立变体任务:
- 每个变体克隆独立编辑计划(独立 clips、独立随机素材起点),N 个预览内容互不相同 Args:
- 每个变体拥有独立 task_id / 状态 / 预览视频 URL,前端按 task_id 分别轮询 request: 预览任务创建请求(template_id + asset_ids 等)
- 标题样式(font/color/position 等)全局共用;标题文字/配音/封面可按变体独立
(titles[] / voice_library_ids[] / cover_urls[],长度1=共用,长度N=独立)
Returns: Returns:
201 + 变体任务数组 {items: [...], total: N} 201 + 预览任务详情
""" """
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
count = max(1, request.preview_count)
logger.info( logger.info(
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d", "[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
user_id, user_id,
request.template_id, request.template_id,
len(request.asset_ids), len(request.asset_ids),
count, request.preview_count,
) )
# 预检查队列限流(按变体总数计) # 预检查队列限流
try: try:
user_pending = generation_task_repository.count_pending_by_user(user_id) user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total() global_pending = generation_task_repository.count_pending_total()
if user_pending + count > USER_PENDING_LIMIT: if user_pending + 1 > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded( raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT)
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT if global_pending + 1 > GLOBAL_PENDING_LIMIT:
) raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT)
if global_pending + count > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e: except UserPendingLimitExceeded as e:
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待后再提交", detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
) from e ) from e
except GlobalQueueFull as e: except GlobalQueueFull as e:
raise HTTPException( raise HTTPException(
@@ -334,11 +273,14 @@ def create_preview_generation_task(
w, h = int(parts[0]), int(parts[1]) w, h = int(parts[0]), int(parts[1])
base = 1920 base = 1920
if w < h: if w < h:
# 竖屏
output_width = round(base * w / h) output_width = round(base * w / h)
output_height = base output_height = base
else: else:
# 横屏
output_width = base output_width = base
output_height = round(base * h / w) output_height = round(base * h / w)
# 对齐到偶数
output_width = output_width - output_width % 2 output_width = output_width - output_width % 2
output_height = output_height - output_height % 2 output_height = output_height - output_height % 2
except (ValueError, ZeroDivisionError): except (ValueError, ZeroDivisionError):
@@ -347,71 +289,42 @@ def create_preview_generation_task(
logger.info( logger.info(
"[预览生成] 分辨率: video_ratio=%s → %s (%dx%d)", "[预览生成] 分辨率: video_ratio=%s → %s (%dx%d)",
video_ratio, video_ratio, resolution, output_width, output_height,
resolution,
output_width,
output_height,
) )
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id) strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
base_title_config = request.title_config or {}
title_config = request.title_config or {}
use_case = CreateGenerationTaskUseCase(generation_task_repository) use_case = CreateGenerationTaskUseCase(generation_task_repository)
# ── 预创建第一个任务,仅用于解析源编辑计划(不落库为最终任务)──
# 先创建一个临时任务拿到 task 对象上下文,实际 N 个任务在循环中统一创建;
# 为保持与旧版一致的源 plan 解析逻辑,先创建任务0、解析源 plan,
# 再预克隆 N 个变体 plan,最后重建任务关联。
# 简化实现:直接创建全部任务,plan 关联在创建后、入队前完成。
created_tasks: list = []
variant_plan_ids: list[str] = [] # 每个变体最终关联的 plan_id(按变体顺序)
try: try:
for variant_index in range(count): task = use_case.execute(
# 变体独立标题文字:titles[] 覆盖 title_config.text CreateGenerationTaskCommand(
variant_title_text = _variant_value(request.titles, variant_index, "") project_id="",
variant_title_config = dict(base_title_config) asset_library_id="",
if variant_title_text.strip(): strategy_id=strategy_id,
variant_title_config["text"] = variant_title_text.strip() voice_library_id=request.voice_library_id,
template_id=request.template_id,
# 变体独立配音 asset_ids=list(request.asset_ids),
variant_voice_library_id = _variant_value( title_ids=list(request.title_ids),
request.voice_library_ids, variant_index, request.voice_library_id voice_ids=list(request.voice_ids),
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode="",
batch_id="",
video_title=request.video_title,
resolution=resolution,
bgm_config=request.bgm_config or {},
auto_retry_enabled=False,
auto_retry_max=0,
is_preview=True,
title_config=title_config,
output_width=output_width,
output_height=output_height,
) )
)
task = use_case.execute(
CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id=strategy_id,
voice_library_id=variant_voice_library_id,
template_id=request.template_id,
asset_ids=list(request.asset_ids),
title_ids=list(request.title_ids),
voice_ids=list(request.voice_ids),
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode="",
batch_id="",
video_title=request.video_title,
resolution=resolution,
bgm_config=request.bgm_config or {},
auto_retry_enabled=False,
auto_retry_max=0,
is_preview=True,
title_config=variant_title_config,
output_width=output_width,
output_height=output_height,
)
)
task.extra_meta["variant_index"] = variant_index
# 解析源编辑计划(前端传入或按模板兜底查找)
source_plan_id = _resolve_preview_edit_plan_id(request=request, task=task, db=db, user_id=user_id)
task.source_edit_plan_id = source_plan_id
generation_task_repository.update(task)
created_tasks.append(task)
except ValueError as e: except ValueError as e:
logger.warning("[预览生成] 创建失败: %s", e) logger.warning("[预览生成] 创建失败: %s", e)
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
@@ -419,121 +332,93 @@ def create_preview_generation_task(
logger.error("[预览生成] 创建失败: %s", e, exc_info=True) logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
# ── 克隆独立变体 plan:N 个预览全部克隆(预览不污染源 plan)── # 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
# 源 plan 不存在(无编辑历史)时各任务走自身随机选片流程,不克隆。 if not task.source_edit_plan_id and request.template_id:
source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else "" try:
if source_plan_id: from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
_plan_repo = SQLAlchemyEditPlanRepository(db)
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
for _p in _plans:
if (_p.created_by_user_id or "") == user_id:
task.source_edit_plan_id = _p.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_p.id,
)
break
except Exception:
logger.warning(
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 每条预览都关联独立克隆 plan:多预览前端为 N 次并发调用,若共用同一 plan
# 则 N 条预览片段完全相同;克隆时片段起点按持久化历史区间重算(含受控复用),
# 保证各预览版本内容不同
if task.source_edit_plan_id:
try: try:
from app.services.edit_plan_service import EditPlanService from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db) _plan_svc = EditPlanService(db)
for variant_index in range(count): _preview_plan = _plan_svc.clone_plan_for_variant(
last_err: Exception | None = None task.source_edit_plan_id,
variant_plan = None created_by_user_id=user_id,
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动 name_suffix="预览变体",
try: )
variant_plan = _plan_svc.clone_plan_for_variant( task.source_edit_plan_id = _preview_plan.id
source_plan_id, generation_task_repository.update(task)
created_by_user_id=user_id, logger.info(
name_suffix=f"预览变体{variant_index + 1}" if count > 1 else "预览变体", "[预览生成] 预览关联独立克隆 plan: task_id=%s clone_plan_id=%s",
) task.id,
break _preview_plan.id,
except Exception as clone_err: # noqa: PERF203 )
last_err = clone_err except Exception as clone_err:
logger.warning( # 不退回共用原 plan(否则多条预览内容相同,违反去重诉求):
"[预览生成] 克隆变体 plan 失败(尝试%d/2): variant=%d error=%s", # 标记任务失败并中断,前端可重新发起预览
_attempt + 1, logger.error(
variant_index, "[预览生成] 克隆预览变体 plan 失败,任务标记失败: task_id=%s error=%s",
clone_err, task.id,
exc_info=True, clone_err,
) exc_info=True,
if variant_plan is None: )
logger.error( _mark_task_failed(generation_task_repository, task, "预览变体计划创建失败")
"[预览生成] 克隆预览变体 plan 重试仍失败: variant=%d source=%s",
variant_index,
source_plan_id,
exc_info=last_err,
)
# 标记已创建任务失败
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant_plan.id)
except HTTPException:
raise
except Exception as e:
logger.error("[预览生成] 克隆变体 plan 异常: %s", e, exc_info=True)
for t in created_tasks:
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试", detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from e ) from clone_err
# 关联变体 plan 并回写标题配置 # 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
for variant_index, task in enumerate(created_tasks): try:
if variant_plan_ids: if not safe_enqueue_generation_task(
task.source_edit_plan_id = variant_plan_ids[variant_index] task,
generation_task_repository.update(task) generation_task_repository,
# 回写变体标题到 plan config(worker 渲染时从 plan 读取 title 配置) user_id=user_id,
if task.source_edit_plan_id and (task.title_config or {}).get("text", "").strip(): log_prefix="[预览生成]",
try: log_task_status=True,
from app.api.routes.generation_tasks import _writeback_edit_plan_config ):
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队失败")
raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试")
except UserPendingLimitExceeded as e:
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
) from None
except GlobalQueueFull:
_mark_task_failed(generation_task_repository, task, "系统队列已满")
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
_writeback_edit_plan_config( return _to_preview_response(task)
plan_id=task.source_edit_plan_id,
task_id=task.id,
title_config=task.title_config,
db=db,
)
except Exception:
logger.warning(
"[预览生成] 回写标题配置失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# ── 入队 ──
responses: list[PreviewGenerationTaskResponse] = []
for variant_index, task in enumerate(created_tasks):
try:
enqueued = safe_enqueue_generation_task(
task,
generation_task_repository,
user_id=user_id,
log_prefix=f"[预览生成][变体{variant_index + 1}]",
log_task_status=True,
)
if not enqueued:
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队失败")
except UserPendingLimitExceeded:
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
except GlobalQueueFull:
_mark_task_failed(generation_task_repository, task, "系统队列已满")
except Exception:
logger.exception("[预览生成] 入队异常: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队异常")
# enqueue 会原地更新 task 状态/进度,直接用 task 构造响应
responses.append(_to_preview_response(task))
# 队列满/限流时若全部失败,返回明确错误码
if all(r.status == "failed" for r in responses):
first_err = next((r.error_message for r in responses if r.error_message), "")
if "待处理任务" in first_err:
raise HTTPException(status_code=429, detail=first_err or "待处理任务超限")
if "队列" in first_err:
raise HTTPException(status_code=503, detail=first_err or "系统繁忙,请稍后再试")
logger.info(
"[预览生成] 创建完成: %d 个变体任务, task_ids=%s",
len(responses),
[r.task_id for r in responses],
)
return BatchPreviewGenerationTaskResponse(items=responses, total=len(responses))
@router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse) @router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse)
+11 -35
View File
@@ -47,15 +47,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
"""从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。"""
if not values:
return fallback
if len(values) == 1:
return values[0]
return values[index] if index < len(values) else fallback
def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse( return GenerationTaskResponse(
id=task.id, id=task.id,
@@ -101,9 +92,6 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener
height=item.height, height=item.height,
fps=item.fps, fps=item.fps,
download_url=download_url, download_url=download_url,
duplicate_rate=getattr(item, "duplicate_rate", None),
visual_similarity=getattr(item, "visual_similarity", None),
match_count=getattr(item, "match_count", None),
) )
@@ -149,6 +137,7 @@ def _select_assets_from_library(
return [a.id for a in ready_video_assets] return [a.id for a in ready_video_assets]
def _writeback_edit_plan_config( def _writeback_edit_plan_config(
plan_id: str, plan_id: str,
task_id: str, task_id: str,
@@ -173,7 +162,7 @@ def _writeback_edit_plan_config(
current_config = plan_model.config if isinstance(plan_model.config, dict) else {} current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config) merged = dict(current_config)
merged["generation_task_id"] = task_id merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面 # 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config: if title_config:
old_title_config = merged.get("title_config", {}) or {} old_title_config = merged.get("title_config", {}) or {}
@@ -185,12 +174,10 @@ def _writeback_edit_plan_config(
del merged["cover"] del merged["cover"]
logger.info( logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s", "[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id, plan_id, old_title_text, new_title_text,
old_title_text,
new_title_text,
) )
merged["title_config"] = title_config merged["title_config"] = title_config
plan_model.config = merged plan_model.config = merged
db.commit() db.commit()
logger.info( logger.info(
@@ -398,7 +385,7 @@ def create_generation_task(
use_case = CreateGenerationTaskUseCase(generation_task_repository) use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count count = request.count
created_tasks: list = [] created_tasks = []
failed_tasks = [] failed_tasks = []
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
# 同批次任务共享 batch_id,用于视频查重时批次内比对 # 同批次任务共享 batch_id,用于视频查重时批次内比对
@@ -481,21 +468,12 @@ def create_generation_task(
if task_index > 0 and variant_plan_ids: if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1] effective_plan_id = variant_plan_ids[task_index - 1]
# 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[]
# 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段
variant_title_text = _variant_value(request.titles, task_index, "")
variant_title_config = dict(request.title_config or {})
if variant_title_text.strip():
variant_title_config["text"] = variant_title_text.strip()
variant_voice_library_id = _variant_value(request.voice_library_ids, task_index, request.voice_library_id)
variant_cover_url = _variant_value(request.cover_urls, task_index, request.cover_url)
task = use_case.execute( task = use_case.execute(
CreateGenerationTaskCommand( CreateGenerationTaskCommand(
project_id=project_id, project_id=project_id,
asset_library_id=asset_library_id, asset_library_id=asset_library_id,
strategy_id=effective_strategy_id, strategy_id=effective_strategy_id,
voice_library_id=variant_voice_library_id, voice_library_id=request.voice_library_id,
template_id=request.template_id, template_id=request.template_id,
asset_ids=resolved_asset_ids, asset_ids=resolved_asset_ids,
title_ids=request.title_ids, title_ids=request.title_ids,
@@ -513,12 +491,10 @@ def create_generation_task(
source_task_id=request.source_task_id, source_task_id=request.source_task_id,
output_width=request.output_width, output_width=request.output_width,
output_height=request.output_height, output_height=request.output_height,
cover_url=variant_cover_url, cover_url=request.cover_url,
title_config=variant_title_config, title_config=request.title_config or {},
) )
) )
# 变体序号写入 extra_meta(响应/排查时可辨识)
task.extra_meta["variant_index"] = task_index
try: try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时, # 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。 # 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
@@ -553,13 +529,13 @@ def create_generation_task(
# 回写 plan.config:必须在 enqueue 之前执行, # 回写 plan.config:必须在 enqueue 之前执行,
# 确保 worker 读取 plan 时 config 中已包含 generation_task_id。 # 确保 worker 读取 plan 时 config 中已包含 generation_task_id。
# 批量场景下每个变体关联独立 plan,需各自回写自己的变体标题配置。 # 只在首个任务时回写一次,避免批量生成时循环覆盖。
_effective_plan_id = task.source_edit_plan_id _effective_plan_id = task.source_edit_plan_id
if _effective_plan_id: if _effective_plan_id and len(created_tasks) == 0:
_writeback_edit_plan_config( _writeback_edit_plan_config(
plan_id=_effective_plan_id, plan_id=_effective_plan_id,
task_id=task.id, task_id=task.id,
title_config=variant_title_config, title_config=request.title_config,
db=db, db=db,
) )
+110 -373
View File
@@ -23,10 +23,6 @@ import re
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository, get_db_session from app.dependencies import get_asset_repository, get_db_session
# 默认转场时长(与 worker 端保持一致)
_DEFAULT_TRANSITION_DURATION = 0.5
from app.services.asset_segment_tracker import ( from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT, REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP, SEGMENT_EDGE_GAP,
@@ -41,20 +37,10 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, s
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import ( from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository, SQLAlchemyTemplateRepository,
) )
from packages.domain.plan_generator_utils import ( from packages.domain.plan_generator_utils import _calc_random_start_time
_calc_random_start_time,
build_scene_segments,
extract_scene_points_from_metadata,
pick_scene_aware_start,
pick_start_in_scene_segment,
)
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
from packages.shared.mediakit_client import get_mediakit_client from packages.shared.mediakit_client import get_mediakit_client
from .dependencies import get_draft_plan_id, get_editor_services from .dependencies import get_draft_plan_id, get_editor_services
@@ -425,25 +411,7 @@ def _get_template_segments(
result.append((cc.order, dur_min, dur_max)) result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0]) return sorted(result, key=lambda x: x[0])
except Exception: except Exception:
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True) logger.warning("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
try:
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
direct_configs = direct_repo.list_by_template(template_id)
if direct_configs:
result = []
for cc in direct_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表) # 回退到旧模板系统(template_segments表)
try: try:
@@ -481,12 +449,6 @@ def _recommended_time_conflicts(
return False return False
# 向后兼容别名:镜头段构建/段内取点逻辑已下沉到 packages.domain.plan_generator_utils,
# 旧测试与历史代码仍按 clips._build_scene_segments / _pick_start_in_scene_segment 导入
_build_scene_segments = build_scene_segments
_pick_start_in_scene_segment = pick_start_in_scene_segment
def _get_mediakit_recommendations( def _get_mediakit_recommendations(
asset_ids: list[str], asset_ids: list[str],
asset_repo, asset_repo,
@@ -599,47 +561,6 @@ def _get_mediakit_recommendations(
return {} return {}
def _calc_plan_internal_duplicate_rate(clips_data: list[dict]) -> float:
"""估算单条成片内部重复率(%).
检查本条成片中同一素材是否有重叠的时间区间。
重叠时长 / 成片总时长 * 100 = 内部重复率。
这是一个轻量估算,不依赖视频指纹;完整查重由 worker 异步完成。
"""
if not clips_data:
return 0.0
# 按素材分组
by_asset: dict[str, list[tuple[float, float]]] = {}
total_duration = 0.0
for c in clips_data:
aid = c.get("asset_id", "")
if not aid:
continue
start = c.get("start_time", 0.0)
end = start + c.get("duration", 0.0)
by_asset.setdefault(aid, []).append((start, end))
total_duration += c.get("duration", 0.0)
if total_duration <= 0:
return 0.0
# 检查同素材内的区间重叠
overlap_duration = 0.0
for segments in by_asset.values():
if len(segments) < 2:
continue
segments_sorted = sorted(segments, key=lambda s: s[0])
last_end = segments_sorted[0][1]
for start, end in segments_sorted[1:]:
overlap = max(0.0, min(end, last_end) - start)
if overlap > 0:
overlap_duration += overlap
last_end = max(last_end, end)
return round(overlap_duration / total_duration * 100, 1)
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse) @router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets_editor( def create_clips_from_assets_editor(
template_id: str, template_id: str,
@@ -684,26 +605,10 @@ def create_clips_from_assets_editor(
# 2. 获取素材实际时长(去重查询) # 2. 获取素材实际时长(去重查询)
unique_asset_ids = list(dict.fromkeys(asset_ids)) unique_asset_ids = list(dict.fromkeys(asset_ids))
asset_durations: dict[str, float] = {} asset_durations: dict[str, float] = {}
asset_smart_scores: dict[str, float] = {}
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
asset_scene_points: dict[str, list[float]] = {}
for asset_id in unique_asset_ids: for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id) asset = asset_repo.get(asset_id)
if asset and hasattr(asset, "duration"): if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0) asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算 smart_match 综合评分,用于候选排序
smart_score, _ = score_asset(asset)
asset_smart_scores[asset_id] = smart_score
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
asset_scene_points[asset_id] = cached_points
logger.info(
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
len(asset_scene_points),
len(unique_asset_ids),
)
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit) # 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重), # 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
@@ -736,52 +641,19 @@ def create_clips_from_assets_editor(
return False return False
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
# 素材耗尽标志:某轮循环中所有素材均被跳过时为 True for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
all_assets_exhausted = False
# 计算转场重叠补偿:每个 clip 需要额外增加的时长
# 目标:渲染后视频总时长 = 模板设定的各片段时长之和
# 公式:每 clip 增加 (n_segments - 1) * td / n_segments
n_segments = len(segments)
if n_segments > 1:
transition_compensation = (n_segments - 1) * _DEFAULT_TRANSITION_DURATION / n_segments
else:
transition_compensation = 0.0
# 打乱 segments 的处理顺序(分配素材的顺序随机化),但最终 clips_data 按原始 order 排序
shuffled_indices = list(range(len(segments)))
random.shuffle(shuffled_indices)
for idx in shuffled_indices:
_seg_order, dur_min, dur_max = segments[idx]
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数) # 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max) raw_duration = random.uniform(dur_min, dur_max)
# 加上转场补偿,确保最终输出时长 = 模板设定总时长
raw_duration += transition_compensation
# 贪心分配素材:按"已使用次数"升序排列候选素材(使用最少的优先), # 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
# 同次数随机打散,避免"A-B-C-D"的固定组合反复出现。
# 跳过时长缺失、复用占比已超 10% 阈值的素材;
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None), # 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
# 继续尝试下一个素材 # 继续轮询下一个素材
asset_id = "" asset_id = ""
clip_duration = 0.0 clip_duration = 0.0
start_time: float | None = None start_time: float | None = None
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散 n_assets = len(asset_ids)
asset_use_counts = {aid: len(used_segments.get(aid, [])) for aid in asset_ids} for offset in range(n_assets):
# 排序键:smart_match 评分(注入随机噪声)→ 使用次数 → 纯随机。 candidate = asset_ids[(i + offset) % n_assets]
# 噪声让得分接近的素材排名每次浮动,避免同一批素材反复选出相同组合,
# 从素材组合层面降低成片查重率;分差 > SCORE_RANDOM_NOISE_MAX 时排名稳定,
# 质量差距显著的素材仍保持优先级。
sorted_candidates = sorted(
asset_ids,
key=lambda aid: (
-(asset_smart_scores.get(aid, 0.0) + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX)),
asset_use_counts.get(aid, 0),
random.random(),
),
)
for candidate in sorted_candidates:
candidate_total = asset_durations.get(candidate, 0.0) candidate_total = asset_durations.get(candidate, 0.0)
if candidate_total <= 0: if candidate_total <= 0:
continue continue
@@ -795,30 +667,16 @@ def create_clips_from_assets_editor(
candidate, candidate,
) )
continue continue
# 起始时间选取(不调用 MediaKit,保证接口快速返回): # 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
# 1) 素材有场景切换点缓存时,优先从随机镜头段中选起点(不同片段来自不同镜头, # 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
# 画面内容本质不同),与 used_segments 做冲突避让(含 1.5s 边缘间隙) # 预判复用后占比超 15% 则拒绝并返回 None)
# 2) 无缓存 / 镜头段全冲突 → _calc_random_start_time 随机起点兜底; candidate_start = _calc_random_start_time(
# 100 次避不开历史区间时走受控复用回调(复用片段累加 reused_durations, candidate,
# 回调内部预判复用后占比超 10% 则拒绝并返回 None) candidate_duration,
candidate_start = None asset_durations,
if candidate in asset_scene_points: used_segments,
candidate_start = pick_scene_aware_start( on_exhausted=reuse_cb,
candidate, )
candidate_duration,
asset_durations,
asset_scene_points,
used_segments,
edge_gap=SEGMENT_EDGE_GAP,
)
if candidate_start is None:
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
)
if candidate_start is None: if candidate_start is None:
# 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材 # 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材
logger.info( logger.info(
@@ -833,7 +691,6 @@ def create_clips_from_assets_editor(
if not asset_id or start_time is None: if not asset_id or start_time is None:
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足 # 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
all_assets_exhausted = True
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="素材可切区间不足,请补充新素材", detail="素材可切区间不足,请补充新素材",
@@ -849,7 +706,7 @@ def create_clips_from_assets_editor(
clips_data.append( clips_data.append(
{ {
"order": _seg_order, "order": i,
"asset_id": asset_id, "asset_id": asset_id,
"start_time": start_time, "start_time": start_time,
"duration": clip_duration, "duration": clip_duration,
@@ -857,9 +714,6 @@ def create_clips_from_assets_editor(
} }
) )
# 按原始 segment order 排序,确保 clips_data 的 order 字段有序(0,1,2,3...)
clips_data.sort(key=lambda c: c["order"])
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚) # 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data) created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
@@ -879,31 +733,11 @@ def create_clips_from_assets_editor(
unique_asset_ids, unique_asset_ids,
) )
# 6. 估算成片内部重复率(本条成片中同一素材的重叠片段时长占比) # 6. 立即返回响应
dup_rate = _calc_plan_internal_duplicate_rate(clips_data)
duplicate_warning = None
if dup_rate > 50:
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
logger.warning(
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
plan_id,
dup_rate,
)
# 7. 素材耗尽提示
exhaustion_warning = None
if all_assets_exhausted and created_count < len(segments):
exhaustion_warning = (
"素材可切区间不足,部分片段使用了复用素材。" "建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
)
# 8. 立即返回响应
return ClipsFromAssetsResponse( return ClipsFromAssetsResponse(
created_count=created_count, created_count=created_count,
plan_id=plan_id, plan_id=plan_id,
clip_ids=[], clip_ids=[],
duplicate_warning=duplicate_warning,
exhaustion_warning=exhaustion_warning,
) )
@@ -911,15 +745,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover
plan_id: str, plan_id: str,
asset_ids: list[str], asset_ids: list[str],
) -> None: ) -> None:
"""后台任务:使用 SceneChange 智能选帧并更新片段的起始时间. """后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
降级策略:
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
此函数在后台异步执行,不影响接口响应时间。 此函数在后台异步执行,不影响接口响应时间。
失败时静默处理,不影响已创建的片段。 失败时静默处理,不影响已创建的片段。
@@ -941,6 +767,12 @@ def _update_mediakit_recommendations_async( # pragma: no cover
asset_repo = SQLAlchemyAssetRepository(db) asset_repo = SQLAlchemyAssetRepository(db)
plan_svc = EditPlanService(db) plan_svc = EditPlanService(db)
# 调用 MediaKit 获取推荐时间
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
if not recommendations:
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
return
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断) # 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
batch_size = 500 batch_size = 500
all_clips = [] all_clips = []
@@ -963,16 +795,15 @@ def _update_mediakit_recommendations_async( # pragma: no cover
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""}) unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)} assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
# 按 asset_id 预分组片段对象(按 order 排序,保证按模板顺序分配镜头段) # 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
clips_by_asset: dict[str, list] = defaultdict(list) clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
for clip in clips: for clip in clips:
aid = getattr(clip, "asset_id", "") or "" aid = getattr(clip, "asset_id", "") or ""
if aid: if aid and clip.start_time is not None:
clips_by_asset[aid].append(clip) clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
for aid in clips_by_asset:
clips_by_asset[aid].sort(key=lambda c: c.order)
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录) # 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
historical_segments = get_used_segments(db, unique_asset_ids) historical_segments = get_used_segments(db, unique_asset_ids)
# 已更新的片段ID(用于排除已移动的旧时间段) # 已更新的片段ID(用于排除已移动的旧时间段)
@@ -981,22 +812,16 @@ def _update_mediakit_recommendations_async( # pragma: no cover
updated_segments: dict[str, list[tuple[float, float]]] = {} updated_segments: dict[str, list[tuple[float, float]]] = {}
updated_count = 0 updated_count = 0
# 尝试获取存储服务(用于生成视频 URL) # 遍历片段,按 asset_id 匹配推荐时间
try: for clip in clips:
storage = get_storage_service() asset_id = getattr(clip, "asset_id", "") or ""
except Exception: if not asset_id or asset_id not in recommendations:
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
return
# 获取 MediaKit 客户端
client = get_mediakit_client()
# 对每个素材,检测场景切换点并分配镜头段
for asset_id in unique_asset_ids:
asset_clips = clips_by_asset.get(asset_id, [])
if not asset_clips:
continue continue
recommended_start = recommendations[asset_id]
clip_duration = clip.duration
# 从预加载字典获取素材(O(1) 查找)
asset = assets_map.get(asset_id) asset = assets_map.get(asset_id)
if not asset: if not asset:
continue continue
@@ -1004,177 +829,89 @@ def _update_mediakit_recommendations_async( # pragma: no cover
if asset_total <= 0: if asset_total <= 0:
continue continue
# 获取素材视频 URL # 推荐时间 + 片段时长不能超过素材总时长
video_url: str | None = None if recommended_start + clip_duration > asset_total:
storage_key = getattr(asset, "storage_key", None) or ""
mime = getattr(asset, "mime_type", "") or ""
if storage_key and mime.startswith("video/"):
try:
video_url = storage.get_download_url(storage_key)
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):
segs: list[tuple[float, float]] = []
for c in clips_by_asset.get(asset_id_inner, []):
cid = c.id
if cid != clip_id_inner and cid not in updated_clip_ids:
segs.append((c.start_time, c.start_time + c.duration))
segs.extend(updated_segments.get(asset_id_inner, []))
# 并入历史已用区间
def _norm(segs_in):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs_in}
return list(_norm(segs) | _norm(historical_segments.get(asset_id_inner, [])))
# 优先使用 SceneChange 策略
scene_segments: list[tuple[float, float]] = []
# 先查素材 metadata 中的场景点缓存:命中则直接复用,跳过 MediaKit 检测
# (缓存由本任务首次检测后写入,跨任务/跨 plan 复用)
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if cached_points:
scene_segments = build_scene_segments(cached_points, asset_total)
logger.info( logger.info(
"后台任务: 命中场景点缓存: asset_id=%s scenes=%d", "后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
asset_id,
len(scene_segments),
)
if not scene_segments and client.is_available and video_url:
scene_changes = client.detect_scene_changes(video_url)
if scene_changes is not None:
scene_segments = build_scene_segments(scene_changes, asset_total)
logger.info(
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
asset_id,
len(scene_segments),
)
# 检测结果写入素材 metadata 缓存:首次生成用随机起点,
# 检测完成后后续生成的渲染前同步路径即可读缓存选镜头段
try:
existing_meta = dict(getattr(asset, "metadata", None) or {})
existing_meta["scene_change_points"] = scene_changes
asset.metadata = existing_meta # type: ignore[attr-defined]
asset_repo.update(asset) # type: ignore[arg-type]
logger.info(
"后台任务: 场景点已写入素材缓存: asset_id=%s points=%d",
asset_id,
len(scene_changes),
)
except Exception as cache_err:
# 缓存写入失败不影响本次片段更新
logger.warning(
"后台任务: 场景点缓存写入失败: asset_id=%s error=%s",
asset_id,
cache_err,
)
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
if not scene_segments and video_url:
fallback_recs = _get_mediakit_recommendations([asset_id], asset_repo)
if fallback_recs and asset_id in fallback_recs:
# analyze_videos 只返回单个推荐点,转为单镜头段
rec_start = fallback_recs[asset_id]
scene_segments = [(rec_start, asset_total)]
logger.info(
"后台任务: 使用 analyze_videos fallback: asset_id=%s start=%.2f",
asset_id,
rec_start,
)
if not scene_segments:
# 所有方式都失败 → 保持现有随机 start_time
logger.info(
"后台任务: SceneChange 与 analyze_videos 均无结果,保持随机起点: asset_id=%s",
asset_id, asset_id,
recommended_start,
clip_duration,
asset_total,
) )
continue continue
# 为每个片段分配不同的镜头段 # 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
scene_segments_pool = list(scene_segments) # 可消费的镜头段池 other_segments: list[tuple[float, float]] = [
for clip in asset_clips: (cs, ce)
clip_duration = clip.duration for cid, cs, ce in clips_by_asset.get(asset_id, [])
recommended_start: float | None = None if cid != clip.id and cid not in updated_clip_ids
]
other_segments.extend(updated_segments.get(asset_id, []))
# 从镜头段池中依次尝试,选一个不冲突的 # 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
for seg_idx, (seg_start, seg_end) in enumerate(scene_segments_pool): # 本 plan 片段创建时已写入历史记录
candidate_start = pick_start_in_scene_segment(seg_start, seg_end, clip_duration) # 并入该素材全部历史已用区间(含其他 plan/其他任务)。
if candidate_start is None: # set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
continue # 镜头段太短,跳过 # 区间(如 1.0 与 1.0000000001)被误判为不同区间
def _norm(segs):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
# 检查越界 other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
if candidate_start + clip_duration > asset_total:
continue
# 检查与已用区间冲突 # 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
other_segs = _get_other_segments(asset_id, clip.id) # 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
if _recommended_time_conflicts(candidate_start, clip_duration, other_segs): if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
continue logger.info(
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
asset_id,
recommended_start,
)
continue
recommended_start = candidate_start # 逐个更新并捕获异常(单点失败不影响其他片段)
# 消费该镜头段(从池中移除,下一个片段用不同镜头段) try:
scene_segments_pool.pop(seg_idx) old_start = clip.start_time
break old_end = old_start + clip_duration
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
if recommended_start is None: # 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
# 镜头段用完或都冲突 → 尝试 _calc_random_start_time 兜底 # 写新区间,最后统一 commit;任一步失败整体 rollback,
used_segs_for_calc: dict[str, list[tuple[float, float]]] = { # 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
asset_id: _get_other_segments(asset_id, clip.id) plan_svc.update_clip(clip.id, start_time=recommended_start)
}
fallback_start = _calc_random_start_time(
asset_id,
clip_duration,
{asset_id: asset_total},
used_segs_for_calc,
)
if fallback_start is None:
continue # 完全无法分配,保持原起点
recommended_start = fallback_start
# 更新片段起始时间
try: try:
old_start = clip.start_time if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
old_end = old_start + clip_duration record_used_segments(
db,
plan_svc.update_clip(clip.id, start_time=recommended_start) asset_id,
try: recommended_start,
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id): recommended_start + clip_duration,
record_used_segments( plan_id,
db,
asset_id,
recommended_start,
recommended_start + clip_duration,
plan_id,
)
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
me,
) )
db.rollback() except Exception as me:
continue logger.warning(
db.commit() "后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
updated_count += 1
updated_clip_ids.add(clip.id)
updated_segments.setdefault(asset_id, []).append(
(recommended_start, recommended_start + clip_duration)
)
logger.info(
"后台任务: 更新片段起始时间(场景选帧): clip_id=%s asset_id=%s start_time=%.2f",
clip.id, clip.id,
asset_id, me,
recommended_start,
) )
except Exception as ue: db.rollback()
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
continue continue
db.commit()
updated_count += 1
updated_clip_ids.add(clip.id)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
continue
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
logger.info(
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
clip.id,
asset_id,
recommended_start,
)
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count) logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
@@ -41,17 +41,17 @@ def list_editor_transition_presets(
_: AuthenticatedUser = Depends(get_current_user), _: AuthenticatedUser = Depends(get_current_user),
) -> TransitionPresetListResponse: ) -> TransitionPresetListResponse:
"""获取转场预设列表""" """获取转场预设列表"""
from packages.domain.transition_presets import TRANSITION_PRESET_LIBRARY from packages.domain.transition_presets import TRANSITION_PRESETS
items = [ items = [
{ {
"id": p.id, "id": p["id"],
"name": p.name, "name": p["name"],
"category": p.category, "category": p.get("category", "通用"),
"duration": p.default_duration, "duration": p.get("default_duration", 0.5),
"description": p.description, "description": p.get("description", ""),
} }
for p in TRANSITION_PRESET_LIBRARY for p in TRANSITION_PRESETS
] ]
return TransitionPresetListResponse(items=items, total=len(items)) return TransitionPresetListResponse(items=items, total=len(items))
@@ -123,17 +123,17 @@ def list_editor_filter_presets(
_: AuthenticatedUser = Depends(get_current_user), _: AuthenticatedUser = Depends(get_current_user),
) -> FilterPresetListResponse: ) -> FilterPresetListResponse:
"""获取滤镜预设列表""" """获取滤镜预设列表"""
from packages.domain.filter_presets import FILTER_PRESET_LIBRARY from packages.domain.filter_presets import FILTER_PRESETS
items = [ items = [
{ {
"id": p.id, "id": p["id"],
"name": p.name, "name": p["name"],
"category": p.category, "category": p.get("category", "通用"),
"thumbnail": p.lut_url, "thumbnail": p.get("thumbnail", ""),
"description": p.description, "description": p.get("description", ""),
} }
for p in FILTER_PRESET_LIBRARY for p in FILTER_PRESETS
] ]
return FilterPresetListResponse(items=items, total=len(items)) return FilterPresetListResponse(items=items, total=len(items))
@@ -189,8 +189,6 @@ class ClipsFromAssetsResponse(BaseModel):
plan_id: str = "" plan_id: str = ""
message: 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="素材耗尽警告")
# ── 封面配置 ──────────────────────────────────────────────────────────────── # ── 封面配置 ────────────────────────────────────────────────────────────────
+1 -22
View File
@@ -2,9 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -432,8 +430,6 @@ def save_tts_job_to_library(
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}" storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
tmp_path: Path | None = None tmp_path: Path | None = None
audio_duration: float | None = None
file_size = 0
try: try:
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
@@ -449,23 +445,6 @@ def save_tts_job_to_library(
) )
file_size = tmp_path.stat().st_size file_size = tmp_path.stat().st_size
storage_service.upload_file(tmp_path, storage_key, content_type=content_type) storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
# 从音频文件提取时长(ffprobe),作为 job.duration 的兜底
try:
proc = subprocess.run(
[
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", str(tmp_path),
],
capture_output=True, text=True, timeout=10,
)
if proc.returncode == 0:
fmt = json.loads(proc.stdout).get("format", {})
dur = float(fmt.get("duration", 0))
if dur > 0:
audio_duration = dur
except Exception:
logger.warning("ffprobe 提取时长失败: job_id=%s", job.id, exc_info=True)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -503,7 +482,7 @@ def save_tts_job_to_library(
mime_type=content_type, mime_type=content_type,
metadata=metadata_, metadata=metadata_,
file_size=file_size, file_size=file_size,
duration=job.duration or audio_duration or None, duration=job.duration or None,
status=AssetStatus.READY, status=AssetStatus.READY,
classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致 classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致
uploaded_by_user_id=user_id, uploaded_by_user_id=user_id,
+1 -68
View File
@@ -23,7 +23,6 @@ from app.schemas.upload import (
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.domain import Asset, AssetStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -81,40 +80,6 @@ def _validate_mime_type(content_type: str | None) -> str:
return base_type return base_type
def _infer_mime_type_from_storage_key(storage_key: str) -> str:
"""从 storage_key 推断 MIME 类型(与 worker 端保持一致)。"""
lower_filename = storage_key.rsplit("/", 1)[-1].lower()
_MIME_MAP = {
".mov": "video/quicktime", ".mp4": "video/mp4", ".avi": "video/x-msvideo",
".mkv": "video/x-matroska", ".webm": "video/webm",
".png": "image/png", ".gif": "image/gif", ".bmp": "image/bmp",
".svg": "image/svg+xml", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
".flac": "audio/flac", ".m4a": "audio/x-m4a",
}
for ext, mime in _MIME_MAP.items():
if lower_filename.endswith(ext):
return mime
return "video/mp4" # default
def _create_pending_asset(
asset_repository, project_id, library_id, storage_key, filename, mime_type, user_id, file_hash=""
):
"""立即创建一条 PROCESSING 状态的 Asset 记录,使前端能马上看到新素材。"""
asset = Asset.create(
project_id=project_id,
library_id=library_id,
name=filename,
storage_key=storage_key,
mime_type=mime_type,
status=AssetStatus.PROCESSING,
uploaded_by_user_id=user_id,
file_hash=file_hash,
)
return asset_repository.create(asset)
def _submit_ingest_job( def _submit_ingest_job(
project_id: str, project_id: str,
library_id: str, library_id: str,
@@ -244,20 +209,6 @@ async def complete_direct_upload(
url=storage_service.get_url(normalized_key), url=storage_service.get_url(normalized_key),
) )
# 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材
filename = normalized_key.rsplit("/", 1)[-1]
mime_type = _infer_mime_type_from_storage_key(normalized_key)
pending_asset = _create_pending_asset(
asset_repository=asset_repository,
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
filename=filename,
mime_type=mime_type,
user_id=authenticated_user.user.id,
file_hash=request.file_hash,
)
job = _submit_ingest_job( job = _submit_ingest_job(
project_id=request.project_id, project_id=request.project_id,
library_id=request.library_id, library_id=request.library_id,
@@ -265,12 +216,7 @@ async def complete_direct_upload(
ingest_job_repository=ingest_job_repository, ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash, file_hash=request.file_hash,
) )
return DirectUploadCompleteResponse( return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id, url=storage_service.get_url(normalized_key))
storage_key=normalized_key,
ingest_job_id=job.id,
asset_id=pending_asset.id,
url=storage_service.get_url(normalized_key),
)
@router.post( @router.post(
@@ -338,18 +284,6 @@ async def upload_asset(
detail=f"Failed to upload file: {type(error).__name__}", detail=f"Failed to upload file: {type(error).__name__}",
) from error ) from error
# 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材
pending_asset = _create_pending_asset(
asset_repository=asset_repository,
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
filename=safe_filename,
mime_type=validated_content_type,
user_id=authenticated_user.user.id,
file_hash=file_hash,
)
job = _submit_ingest_job( job = _submit_ingest_job(
project_id=project_id, project_id=project_id,
library_id=library_id, library_id=library_id,
@@ -361,6 +295,5 @@ async def upload_asset(
return UploadAssetResponse( return UploadAssetResponse(
storage_key=storage_key, storage_key=storage_key,
ingest_job_id=job.id, ingest_job_id=job.id,
asset_id=pending_asset.id,
url=file_url, url=file_url,
) )
-70
View File
@@ -15,7 +15,6 @@ from app.schemas.video_center import (
VideoItemResponse, VideoItemResponse,
) )
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field
from packages.application import ( from packages.application import (
GetGeneratedVideoUseCase, GetGeneratedVideoUseCase,
@@ -54,8 +53,6 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
download_url=download_url, download_url=download_url,
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "", generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
duplicate_rate=getattr(item, "duplicate_rate", None), duplicate_rate=getattr(item, "duplicate_rate", None),
visual_similarity=getattr(item, "visual_similarity", None),
match_count=getattr(item, "match_count", None),
) )
@@ -240,70 +237,3 @@ def get_batch_download_status(
status=api_status, status=api_status,
download_url=download_url, download_url=download_url,
) )
# ── 重新计算查重率 ─────────────────────────────────────────────────
class RecomputeDedupRequest(BaseModel):
"""重新计算查重率请求。"""
video_ids: list[str] | None = Field(
None,
description="指定视频 ID 列表。为空则对当前用户所有缺少查重数据的视频重新计算。",
)
class RecomputeDedupResponse(BaseModel):
"""重新计算查重率响应。"""
enqueued: int = Field(..., description="已入队的任务数量")
total_scanned: int = Field(..., description="扫描的视频总数")
skipped: int = Field(..., description="已有查重数据跳过的数量")
message: str = ""
@router.post("/videos/recompute-dedup", response_model=RecomputeDedupResponse)
def recompute_dedup(
request: RecomputeDedupRequest = RecomputeDedupRequest(),
repo=Depends(get_generated_video_repository),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""重新计算视频的查重率/视觉相似度。
对于已存在但缺少 duplicate_rate / video_fingerprint 的视频,
触发异步 Celery 任务重新下载并计算指纹 + 查重率。
不传 video_ids 时,对当前用户所有视频进行检查。
"""
user_id = current_user.user.id
# 获取目标视频列表
if request.video_ids:
all_videos = repo.get_by_ids(request.video_ids)
# 安全校验:只处理当前用户的视频
target_videos = [v for v in all_videos if v.user_id == user_id]
else:
target_videos = repo.list_by_user(user_id)
total_scanned = len(target_videos)
enqueued = 0
skipped = 0
for video in target_videos:
# 已有完整查重数据的跳过
if video.duplicate_rate is not None and video.video_fingerprint:
skipped += 1
continue
# 触发异步查重任务
celery_app.send_task("worker.check_duplicate", args=[video.id])
enqueued += 1
logger.info("Enqueued re-dedup for video %s (user=%s)", video.id, user_id)
return RecomputeDedupResponse(
enqueued=enqueued,
total_scanned=total_scanned,
skipped=skipped,
message=f"已入队 {enqueued} 个查重任务" if enqueued > 0 else "所有视频查重数据已完整",
)
+2 -260
View File
@@ -6,26 +6,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import shutil
import subprocess
import tempfile
import time import time
from pathlib import Path
from typing import Literal, Optional from typing import Literal, Optional
from uuid import uuid4
from app.api.routes._helpers import get_user_plan from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_audio_url_signer,
get_cosyvoice_service,
get_db_session,
get_project_repository,
get_user_repository,
)
from app.schemas.voice import ( from app.schemas.voice import (
PresetVoiceItemResponse, PresetVoiceItemResponse,
PresetVoiceListResponse, PresetVoiceListResponse,
@@ -38,7 +24,7 @@ from app.schemas.voice_library import (
UpdateVoiceLibraryRequest, UpdateVoiceLibraryRequest,
VoiceLibraryItemResponse, VoiceLibraryItemResponse,
) )
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
@@ -54,12 +40,8 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError, QuotaExceededError,
UpdateVoiceLibraryUseCase, UpdateVoiceLibraryUseCase,
) )
from packages.domain import Asset, AssetStatus
from packages.domain.classification import AssetLibraryKind, ClassificationStatus
from packages.domain.entities import AssetLibrary
from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
from packages.ports.user_repository import UserRepository from packages.ports.user_repository import UserRepository
from packages.shared.storage import SharedStorageService
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -525,243 +507,3 @@ def delete_voice(
if not deleted: if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return return
# ── 提取视频配音 ─────────────────────────────────────────────────────
# 支持的视频格式
EXTRACT_VIDEO_MIMES = frozenset({"video/mp4", "video/quicktime", "video/webm", "video/x-msvideo"})
MAX_EXTRACT_SIZE = 500 * 1024 * 1024 # 500MB
@router.post(
"/extract-voice",
status_code=status.HTTP_201_CREATED,
)
def extract_voice_from_video(
file: UploadFile = File(...),
project_id: str = Form(...),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_library_repository=Depends(get_asset_library_repository),
asset_repository=Depends(get_asset_repository),
storage_service: SharedStorageService = Depends(get_storage_service),
sign_url=Depends(get_audio_url_signer),
):
"""从上传的视频中提取人声配音。
流程:
1. 接收视频文件(mp4/mov/webm)
2. ffmpeg 提取音频 + 降噪 + 编码为 mp3
3. 上传到 OSS,创建 Asset 记录到配音素材库
4. 返回素材信息(时长、文件大小、URL)
"""
user_id = authenticated_user.user.id
# 校验文件类型
content_type = file.content_type or ""
if content_type and content_type not in EXTRACT_VIDEO_MIMES:
# 兜底:按扩展名判断
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
ext_to_mime = {"mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm", "avi": "video/x-msvideo"}
if ext not in ext_to_mime:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="仅支持 mp4/mov/webm/avi 格式的视频文件",
)
content_type = ext_to_mime[ext]
# 找到(或自动创建)用户 voice 素材库(复用 TTS 的逻辑)
library = _find_or_create_voice_library_for_extract(
user_id=user_id,
project_repository=project_repository,
asset_library_repository=asset_library_repository,
)
tmp_dir = None
try:
tmp_dir = Path(tempfile.mkdtemp(prefix="voice_extract_"))
video_path = tmp_dir / f"input_{uuid4().hex[:8]}_{file.filename or 'video.mp4'}"
audio_path = tmp_dir / f"output_{uuid4().hex[:8]}.mp3"
# 保存上传的视频到临时文件
with open(video_path, "wb") as f:
total = 0
while chunk := file.file.read(1024 * 1024): # 1MB chunks
total += len(chunk)
if total > MAX_EXTRACT_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="视频文件过大,最大支持 500MB",
)
f.write(chunk)
if video_path.stat().st_size == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="视频文件为空")
# ffmpeg: 提取音频 + 降噪 + 编码 mp3
# 滤镜链:highpass(去低频噪声) → afftdn(FFT降噪) → lowpass(去高频噪声)
ffmpeg_cmd = [
"ffmpeg",
"-y",
"-i",
str(video_path),
"-vn", # 不要视频
"-af",
"highpass=f=80,afftdn=nf=-25:tn=1,lowpass=f=8000",
"-acodec",
"libmp3lame",
"-ab",
"192k",
"-ar",
"44100",
"-ac",
"1", # 单声道(人声足够)
str(audio_path),
]
result = subprocess.run(
ffmpeg_cmd,
capture_output=True,
timeout=300, # 5 分钟超时
)
if result.returncode != 0:
stderr_text = result.stderr.decode("utf-8", errors="replace")[-500:]
logger.error("ffmpeg 提取配音失败: %s", stderr_text)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="视频音频提取失败,可能该视频没有音轨或格式不支持",
)
if not audio_path.exists() or audio_path.stat().st_size == 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="音频提取结果为空",
)
# 获取音频时长
duration = _get_audio_duration(audio_path)
file_size = audio_path.stat().st_size
# 上传到 OSS
audio_ext = "mp3"
storage_key = f"uploads/voice/extracted/{uuid4().hex}.{audio_ext}"
storage_service.upload_file(audio_path, storage_key, content_type="audio/mpeg")
# 创建 Asset 记录
original_name = (file.filename or "video").rsplit(".", 1)[0]
asset_name = f"{original_name}-配音"
asset = Asset.create(
project_id=library.project_id,
library_id=library.id,
name=asset_name,
storage_key=storage_key,
mime_type="audio/mpeg",
metadata={
"source": "video_extract",
"original_video": file.filename or "unknown",
},
file_size=file_size,
duration=duration,
status=AssetStatus.READY,
classification_status=ClassificationStatus.PENDING,
uploaded_by_user_id=user_id,
)
asset = asset_repository.create(asset)
return {
"id": asset.id,
"name": asset.name,
"audio_url": sign_url(storage_key),
"duration": duration,
"file_size": file_size,
"status": "completed",
"source": "video_extract",
}
except HTTPException:
raise
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="视频处理超时,请尝试较短的视频",
) from None
except Exception as e:
logger.exception("提取视频配音失败: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="提取配音失败,请稍后重试",
) from e
finally:
# 清理临时文件
if tmp_dir and Path(tmp_dir).exists():
shutil.rmtree(tmp_dir, ignore_errors=True)
def _find_or_create_voice_library_for_extract(*, user_id, project_repository, asset_library_repository):
"""为用户找到或创建 voice 素材库(与 TTS 保存逻辑一致)。"""
projects = project_repository.find_accessible_projects(user_id)
if not projects:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有可用的项目,请先创建项目",
)
for project in projects:
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:
return lib
# 自动创建
from sqlalchemy.exc import IntegrityError
project = projects[0]
library = AssetLibrary.create(
project_id=project.id,
name="配音素材库",
kind=AssetLibraryKind.VOICE,
)
try:
return asset_library_repository.create(library)
except IntegrityError:
session = getattr(asset_library_repository, "session", None)
if session is not None:
try:
session.rollback()
except Exception:
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:
return lib
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="配音素材库创建失败",
) from None
def _get_audio_duration(audio_path: Path) -> float:
"""用 ffprobe 获取音频时长(秒)。"""
try:
result = subprocess.run(
[
"ffprobe",
"-v",
"quiet",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
str(audio_path),
],
capture_output=True,
timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
return float(result.stdout.strip())
except (ValueError, subprocess.TimeoutExpired):
pass
return 0.0
-3
View File
@@ -28,9 +28,6 @@ class DuplicationRecordResponse(BaseModel):
status: str = "pending" status: str = "pending"
duplicate_rate: float | None = None duplicate_rate: float | None = None
duplicate_count: int = 0 duplicate_count: int = 0
# #1661 视觉相似度(归一化 0~1)/ 匹配视频数
visual_similarity: float | None = None
match_count: int | None = None
created_at: str created_at: str
updated_at: str updated_at: str
-4
View File
@@ -25,10 +25,6 @@ class GeneratedVideoResponse(BaseModel):
review_status: str = "pending_review" review_status: str = "pending_review"
generation_params: dict = Field(default_factory=dict) generation_params: dict = Field(default_factory=dict)
download_url: str | None = None download_url: str | None = None
# #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数
duplicate_rate: float | None = None
visual_similarity: float | None = None
match_count: int | None = None
class GeneratedVideoDownloadUrlResponse(BaseModel): class GeneratedVideoDownloadUrlResponse(BaseModel):
+2 -69
View File
@@ -25,12 +25,6 @@ class CreateGenerationTaskRequest(BaseModel):
asset_library_id: str = "" asset_library_id: str = ""
strategy_id: str = "" strategy_id: str = ""
voice_library_id: str = "" voice_library_id: str = ""
# ── 多变体独立配音(批量生成)──
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立配音;空数组 = 回退 voice_library_id
voice_library_ids: list[str] = Field(
default_factory=list,
description="各变体独立配音素材库ID数组:长度1=共用,长度=count=独立。为空时回退 voice_library_id",
)
created_by_user_id: str = "" created_by_user_id: str = ""
# ── 模板模式新增字段 ── # ── 模板模式新增字段 ──
template_id: str = "" template_id: str = ""
@@ -81,27 +75,6 @@ class CreateGenerationTaskRequest(BaseModel):
output_width: int = Field(default=1280, description="输出视频宽度") output_width: int = Field(default=1280, description="输出视频宽度")
output_height: int = Field(default=720, description="输出视频高度") output_height: int = Field(default=720, description="输出视频高度")
cover_url: str = Field(default="", description="封面图片 URL") cover_url: str = Field(default="", description="封面图片 URL")
# ── 多变体独立封面(批量生成)──
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立封面;空数组 = 回退 cover_url
cover_urls: list[str] = Field(
default_factory=list,
description="各变体独立封面URL数组:长度1=共用,长度=count=独立。为空时回退 cover_url",
)
# ── 多变体独立标题文字(批量生成)──
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立标题文字;空数组 = 使用 title_config.text
titles: list[str] = Field(
default_factory=list,
description="各变体独立标题文字数组:长度1=共用,长度=count=独立。为空时使用 title_config.text",
)
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreateGenerationTaskRequest":
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = count(独立)。"""
for name in ("voice_library_ids", "cover_urls", "titles"):
arr = getattr(self, name)
if arr and len(arr) != 1 and len(arr) != self.count:
raise ValueError(f"{name} 长度必须为 1(共用)或 {self.count}(与 count 一致),当前为 {len(arr)}")
return self
@model_validator(mode="after") @model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -212,33 +185,8 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
) )
title_config: dict = Field( title_config: dict = Field(
default_factory=dict, default_factory=dict,
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow。N个变体时样式全局共用", description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
) )
# ── 多变体独立配置(preview_count > 1)──
# 长度 1 = 所有变体共用;长度 = preview_count = 每个变体独立;空数组 = 回退单值字段
titles: list[str] = Field(
default_factory=list,
description="各变体独立标题文字数组:长度1=共用,长度=preview_count=独立。为空时使用 title_config.text",
)
voice_library_ids: list[str] = Field(
default_factory=list,
description="各变体独立配音素材库ID数组:长度1=共用,长度=preview_count=独立。为空时回退 voice_library_id",
)
cover_urls: list[str] = Field(
default_factory=list,
description="各变体独立封面URL数组:长度1=共用,长度=preview_count=独立(预览阶段通常为空)",
)
@model_validator(mode="after")
def _check_variant_arrays(self) -> "CreatePreviewGenerationTaskRequest":
"""变体数组字段长度校验:空数组(回退单值)、长度 1(共用)、或长度 = preview_count(独立)。"""
for name in ("titles", "voice_library_ids", "cover_urls"):
arr = getattr(self, name)
if arr and len(arr) != 1 and len(arr) != self.preview_count:
raise ValueError(
f"{name} 长度必须为 1(共用)或 {self.preview_count}(与 preview_count 一致),当前为 {len(arr)}"
)
return self
@model_validator(mode="after") @model_validator(mode="after")
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest": def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
@@ -254,7 +202,7 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
class PreviewGenerationTaskResponse(BaseModel): class PreviewGenerationTaskResponse(BaseModel):
"""单个预览变体任务响应。 """预览生成任务响应。
包含任务状态、进度、分辨率、生成结果 URL 等关键字段。 包含任务状态、进度、分辨率、生成结果 URL 等关键字段。
""" """
@@ -263,7 +211,6 @@ class PreviewGenerationTaskResponse(BaseModel):
status: str status: str
progress: float progress: float
is_preview: bool = True is_preview: bool = True
variant_index: int = 0
resolution: str = "" resolution: str = ""
video_url: str = "" video_url: str = ""
duration: float = 0.0 duration: float = 0.0
@@ -272,21 +219,7 @@ class PreviewGenerationTaskResponse(BaseModel):
transition_count: int = 0 transition_count: int = 0
material_usage: dict = Field(default_factory=dict) material_usage: dict = Field(default_factory=dict)
error_message: str = "" error_message: str = ""
title_text: str = ""
voice_library_id: str = ""
created_at: datetime | None = None created_at: datetime | None = None
started_at: datetime | None = None started_at: datetime | None = None
finished_at: datetime | None = None finished_at: datetime | None = None
generate_duration: float = 0.0 generate_duration: float = 0.0
class BatchPreviewGenerationTaskResponse(BaseModel):
"""批量预览任务响应:preview_count=N 时返回 N 个独立变体任务。
- items: 变体任务数组,按 variant_index 顺序排列,每个含独立 task_id/状态/预览视频URL
- total: 变体总数(= preview_count)
- 前端按 items[i].task_id 分别轮询 GET /preview/{task_id} 获取进度与结果
"""
items: list[PreviewGenerationTaskResponse]
total: int
-3
View File
@@ -22,10 +22,7 @@ class VideoItemResponse(BaseModel):
generation_params: dict = Field(default_factory=dict) generation_params: dict = Field(default_factory=dict)
download_url: str | None = None download_url: str | None = None
generated_at: str = "" generated_at: str = ""
# #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数
duplicate_rate: float | None = None duplicate_rate: float | None = None
visual_similarity: float | None = None
match_count: int | None = None
class ListVideosResponse(BaseModel): class ListVideosResponse(BaseModel):
@@ -3,7 +3,7 @@
在素材 metadata(assets.classification_result JSON)中持久化已使用的片段时间区间, 在素材 metadata(assets.classification_result JSON)中持久化已使用的片段时间区间,
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重; 供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用 素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 10%),把任意两条 的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
成片的画面重复率控制在阈值内。 成片的画面重复率控制在阈值内。
metadata 中的记录字段 ``used_time_ranges``:: metadata 中的记录字段 ``used_time_ranges``::
@@ -40,14 +40,14 @@ logger = logging.getLogger(__name__)
USED_RANGES_KEY = "used_time_ranges" USED_RANGES_KEY = "used_time_ranges"
# ── 受控复用配置常量 ───────────────────────────────────────────────────────── # ── 受控复用配置常量 ─────────────────────────────────────────────────────────
MAX_RANGE_USE_COUNT = 2 MAX_RANGE_USE_COUNT = 3
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。""" """单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
REUSE_RATIO_LIMIT = 0.10 REUSE_RATIO_LIMIT = 0.15
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(10%)。 """单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。""" 超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
SEGMENT_EDGE_GAP = 1.5 SEGMENT_EDGE_GAP = 0.3
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测, """冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。""" 避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
@@ -397,12 +397,12 @@ def make_reuse_callback(
db: SQLAlchemy session db: SQLAlchemy session
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束) asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时 reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
会把本次片段时长累加进去,供调用方统计成片复用占比(10% 阈值)。 会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
在复用前预判:若复用本片段后占比 (reused + clip_duration) / 在复用前预判:若复用本片段后占比 (reused + clip_duration) /
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None (assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
(保证成片复用占比不超阈值)。 (保证成片复用占比不超阈值)。
ratio_limit: 单条成片复用时长占比上限,默认 10%。 ratio_limit: 单条成片复用时长占比上限,默认 15%。
Returns: Returns:
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。 回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
@@ -13,7 +13,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import random
from typing import Any, List from typing import Any, List
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -30,11 +29,9 @@ from packages.domain.editing_mode import EditingMode
from packages.domain.plan_generator_utils import ( from packages.domain.plan_generator_utils import (
create_clips_from_configs, create_clips_from_configs,
distribute_assets, distribute_assets,
extract_scene_points_from_metadata,
generate_default_clips, generate_default_clips,
map_clip_types_for_mode, map_clip_types_for_mode,
) )
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
from packages.domain.template_clip_config import TemplateClipConfig from packages.domain.template_clip_config import TemplateClipConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -131,7 +128,6 @@ class PlanGeneratorService:
editing_mode, editing_mode,
random_selection=random_preview, random_selection=random_preview,
asset_durations=asset_durations, asset_durations=asset_durations,
user_id=created_by_user_id,
) )
# 5. 持久化所有 clips 并计算总时长 # 5. 持久化所有 clips 并计算总时长
@@ -219,82 +215,19 @@ class PlanGeneratorService:
*, *,
random_selection: bool = False, random_selection: bool = False,
asset_durations: dict[str, float] | None = None, asset_durations: dict[str, float] | None = None,
user_id: str = "",
) -> None: ) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化). """按 editing_mode 将素材分配到 clips(就地修改,未持久化).
先用 smart_match 评分对素材排序(高分优先),再委托给 委托给 plan_generator_utils.distribute_assets 纯函数。
plan_generator_utils.distribute_assets 纯函数完成分配。
""" """
# 预览随机模式:素材顺序已 shuffle,纯随机起点即可,不读 DB 评分/缓存
asset_scene_points: dict[str, list[float]] = {}
if not random_selection:
# 正式生成:smart_match 评分排序(高分优先)+ 场景切换点缓存
if self._asset_repo:
asset_ids = self._sort_assets_by_smart_score(asset_ids)
# 读取素材 metadata 中的场景切换点缓存(后台 SceneChange 检测写入):
# 有缓存的素材片段起点从随机镜头段选取,无缓存走随机起点兜底
asset_scene_points = self._fetch_asset_scene_points(asset_ids)
# 正式生成也随机重排片段顺序(降重,默认开启无开关)
# smart_match 决定选哪些素材,shuffle 只改变分配到 clips 的顺序
asset_ids = list(asset_ids) # 复制避免修改调用方原列表
random.shuffle(asset_ids)
# 查询已有视频的已用区间(跨视频避让)
external_used_segments = None
if user_id and self._clip_repo:
try:
external_used_segments = self._clip_repo.list_used_segments_by_user(user_id, limit_recent=50)
except Exception:
logger.warning("跨视频避让查询失败,回退到纯随机", exc_info=True)
distribute_assets( distribute_assets(
clips, clips,
asset_ids, asset_ids,
editing_mode, editing_mode,
random_selection=random_selection, random_selection=random_selection,
asset_durations=asset_durations, asset_durations=asset_durations,
asset_scene_points=asset_scene_points,
external_used_segments=external_used_segments,
) )
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:
return points_map
for asset_id in asset_ids:
asset = self._asset_repo.get(asset_id)
if asset:
points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
if points:
points_map[asset_id] = points
return points_map
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
排序时给每个素材的得分注入 0~SCORE_RANDOM_NOISE_MAX 的随机噪声,
使得分接近的素材排名每次浮动,避免一键生成反复选出相同素材组合,
从素材组合层面降低成片查重率。分差大于噪声上限时排名保持稳定。
"""
scored: list[tuple[str, float]] = []
for asset_id in asset_ids:
asset = self._asset_repo.get(asset_id)
if asset:
score, _ = score_asset(asset)
scored.append((asset_id, score))
else:
scored.append((asset_id, 0.0))
# 评分 + 随机噪声后按降序排列
scored.sort(
key=lambda x: x[1] + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX),
reverse=True,
)
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]:
"""从数据库获取素材时长信息. """从数据库获取素材时长信息.
@@ -1,174 +0,0 @@
#!/usr/bin/env python3
"""存量指纹重建脚本 — 为已有视频生成 video_fingerprint_chunks 分片数据。
功能:
- 查询 generated_videos 中 video_fingerprint IS NOT NULL 但尚无分片数据的视频
- 从 OSS 下载视频 → 用新的分片算法重新计算指纹 → 写入分片表
- 支持 --dry-run(只打印不写入)和 --batch-size(默认 50)
- 幂等:已存在分片数据的视频跳过
用法:
# 预览(不写入)
python rebuild_fingerprint_chunks.py --dry-run
# 执行重建
python rebuild_fingerprint_chunks.py --batch-size 50
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
import tempfile
# 确保可以 import worker_app 和 packages
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "worker"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("rebuild_fingerprint_chunks")
def find_videos_needing_rebuild(session, batch_size: int) -> list[dict]:
"""查询需要重建分片指纹的视频。"""
from sqlalchemy import and_
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel, VideoFingerprintChunkModel
# 有 video_fingerprint 的视频
has_fingerprint = GeneratedVideoModel.video_fingerprint.isnot(None)
has_fingerprint = and_(has_fingerprint, GeneratedVideoModel.video_fingerprint != "")
# 排除已有分片数据的视频
subq = session.query(VideoFingerprintChunkModel.video_id).distinct().subquery()
no_chunks = ~GeneratedVideoModel.id.in_(subq)
videos = (
session.query(GeneratedVideoModel)
.filter(and_(has_fingerprint, no_chunks))
.order_by(GeneratedVideoModel.generated_at.desc())
.limit(batch_size)
.all()
)
return [
{
"id": v.id,
"project_id": v.project_id,
"user_id": v.user_id or "",
"duration": v.duration,
}
for v in videos
]
def rebuild_one(video_info: dict, dry_run: bool = False) -> int:
"""重建单个视频的分片数据。返回写入的 chunk 数量。"""
from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
from packages.shared.storage import get_storage_service
video_id = video_info["id"]
project_id = video_info["project_id"]
user_id = video_info["user_id"]
if dry_run:
logger.info("[DRY-RUN] Would rebuild video %s (project=%s)", video_id, project_id)
return 0
session = SessionLocal()
temp_dir = tempfile.mkdtemp()
try:
# 再次检查幂等性
existing_count = (
session.query(VideoFingerprintChunkModel).filter(VideoFingerprintChunkModel.video_id == video_id).count()
)
if existing_count > 0:
logger.info("Video %s already has %d chunks, skipping", video_id, existing_count)
return 0
# 下载视频
storage_service = get_storage_service()
local_path = os.path.join(temp_dir, f"{video_id}.mp4")
storage_key = f"projects/{project_id}/generated/{video_id}/{video_id}.mp4"
storage_service.download_file(storage_key, local_path)
# 重新计算指纹
deduplicator = VideoDeduplicator()
fingerprint = deduplicator.compute_fingerprint(local_path)
# 写入分片表
_save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session)
session.commit()
chunk_count = len(fingerprint.chunks)
logger.info("Rebuilt %d chunks for video %s", chunk_count, video_id)
return chunk_count
except Exception as e:
logger.error("Failed to rebuild video %s: %s", video_id, e)
session.rollback()
return -1
finally:
session.close()
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
def main():
parser = argparse.ArgumentParser(description="存量指纹重建脚本")
parser.add_argument("--dry-run", action="store_true", help="只打印不写入")
parser.add_argument("--batch-size", type=int, default=50, help="每批处理数量(默认 50)")
parser.add_argument("--total-limit", type=int, default=0, help="总处理数量限制(0=不限制)")
args = parser.parse_args()
from worker_app.db import SessionLocal
session = SessionLocal()
try:
videos = find_videos_needing_rebuild(session, args.batch_size)
logger.info("Found %d videos needing rebuild", len(videos))
if args.dry_run:
for v in videos:
logger.info("[DRY-RUN] Video %s | project=%s | duration=%.1fs", v["id"], v["project_id"], v["duration"])
return
total_chunks = 0
processed = 0
failed = 0
for v in videos:
if args.total_limit > 0 and processed >= args.total_limit:
break
result = rebuild_one(v, dry_run=False)
if result < 0:
failed += 1
else:
total_chunks += result
processed += 1
logger.info(
"Rebuild complete: processed=%d, chunks=%d, failed=%d",
processed,
total_chunks,
failed,
)
finally:
session.close()
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
+25 -3
View File
@@ -255,10 +255,32 @@ test.describe("Core generation flow", () => {
expect(genData.items.length).toBeGreaterThan(0) expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy() expect(genData.items[0].id).toBeTruthy()
// 单视频(N=1):点击「确认生成视频」后直接跳 Step 5 封面(与旧流程一致) // Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({
timeout: 180_000, timeout: 15_000,
}) })
// Step 5 → Step 6:等待渲染终态
// - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面
// - 失败:出现「生成失败」,停在确认生成页也算向导流程走通
// - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通
const renderSucceeded = await page
.getByText("视频生成完成", { exact: false })
.waitFor({ timeout: 180_000 })
.then(() => true)
.catch(() => false)
if (renderSucceeded) {
// 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转)
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: 封面(最后一步,无主按钮),仅验证页面渲染
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 15_000,
})
} else {
// 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)")
}
} else { } else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`) console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
// 创建失败时停留在标题页并展示错误提示 // 创建失败时停留在标题页并展示错误提示
+14 -7
View File
@@ -1848,9 +1848,10 @@
}, },
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
@@ -1937,9 +1938,10 @@
}, },
"node_modules/@types/aria-query": { "node_modules/@types/aria-query": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true "peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
@@ -3111,9 +3113,10 @@
}, },
"node_modules/dom-accessibility-api": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true "peer": true
}, },
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
@@ -4454,9 +4457,10 @@
}, },
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
@@ -5004,9 +5008,10 @@
}, },
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
@@ -5019,9 +5024,10 @@
}, },
"node_modules/pretty-format/node_modules/ansi-styles": { "node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -5729,9 +5735,10 @@
}, },
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true "peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
-4
View File
@@ -20,10 +20,6 @@ export interface DuplicationRecord {
duplicate_rate?: number duplicate_rate?: number
/** 重复片段数 */ /** 重复片段数 */
duplicate_count?: number duplicate_count?: number
/** 视觉相似度(0-100),#1660 新增 */
visual_similarity?: number
/** 匹配帧数,#1660 新增 */
match_count?: number
/** 创建时间 */ /** 创建时间 */
created_at: string created_at: string
/** 更新时间 */ /** 更新时间 */
+6 -27
View File
@@ -33,36 +33,15 @@ export interface CreatePreviewRequest {
preset_id?: string preset_id?: string
volume?: number volume?: number
} }
/** 批量预览数量(1~10),默认1。N>1 时返回 N 个独立变体任务 */
preview_count?: number
/** 各变体独立标题文字:长度1=共用,长度=preview_count=独立,空数组=使用 title_config.text */
titles?: string[]
/** 各变体独立配音素材库ID:长度1=共用,长度=preview_count=独立,空数组=回退 voice_library_id */
voice_library_ids?: string[]
/** 各变体独立封面URL:长度1=共用,长度=preview_count=独立(预览阶段通常为空) */
cover_urls?: string[]
} }
/** 单个预览变体任务 */ /** 创建预览任务响应 */
export interface PreviewVariantItem {
task_id: string
status: string
progress: number
is_preview: boolean
variant_index: number
resolution: string
video_url: string
duration: number
error_message: string
title_text: string
voice_library_id: string
created_at?: string | null
}
/** 创建预览任务响应(单变体,preview_count=1 时 items 长度为1) */
export interface CreatePreviewResponse { export interface CreatePreviewResponse {
items: PreviewVariantItem[] task_id: string
total: number status: PreviewStatus
is_preview: boolean
resolution: string
created_at: string
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id) */ /** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id) */
source_edit_plan_id?: string source_edit_plan_id?: string
} }
-3
View File
@@ -13,8 +13,6 @@ export type {
VideoItem, VideoItem,
} from "./types" } from "./types"
export type { RecomputeDedupResponse } from "./products"
// 工具函数 // 工具函数
export { mapVideoToProductItem } from "./utils" export { mapVideoToProductItem } from "./utils"
@@ -27,5 +25,4 @@ export {
updateReviewStatus, updateReviewStatus,
batchDownload, batchDownload,
getBatchDownloadStatus, getBatchDownloadStatus,
recomputeDedup,
} from "./products" } from "./products"
-15
View File
@@ -78,18 +78,3 @@ export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownlo
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId) console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
return { job_id: jobId, status: "processing", progress: 0 } return { job_id: jobId, status: "processing", progress: 0 }
} }
/** 重新计算存量视频查重率(异步) */
export interface RecomputeDedupResponse {
enqueued: number
total_scanned: number
skipped: number
message: string
}
export const recomputeDedup = async (videoIds?: string[]): Promise<RecomputeDedupResponse> => {
const response = await apiClient.post("/videos/recompute-dedup", {
video_ids: videoIds,
})
return response.data
}
-9
View File
@@ -23,10 +23,6 @@ export interface ProductItem {
project_name?: string project_name?: string
/** 查重率(百分比) */ /** 查重率(百分比) */
duplicate_rate?: number duplicate_rate?: number
/** 视觉相似度(0-1),#1660 新增 */
visual_similarity?: number
/** 匹配帧数,#1660 新增 */
match_count?: number
created_at?: string created_at?: string
updated_at?: string updated_at?: string
} }
@@ -75,9 +71,4 @@ export interface VideoItem {
generation_params: Record<string, unknown> generation_params: Record<string, unknown>
download_url: string download_url: string
generated_at: string generated_at: string
duplicate_rate?: number
/** 视觉相似度(0-1),#1660 新增 */
visual_similarity?: number
/** 匹配帧数,#1660 新增 */
match_count?: number
} }
+2 -3
View File
@@ -29,8 +29,7 @@ export function mapVideoToProductItem(video: VideoItem): ProductItem {
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用 // 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at, created_at: video.generated_at,
updated_at: video.generated_at, updated_at: video.generated_at,
duplicate_rate: video.duplicate_rate, // 后端 /videos 接口暂无 duplicate_rate 字段
visual_similarity: video.visual_similarity, duplicate_rate: undefined,
match_count: video.match_count,
} }
} }
-8
View File
@@ -92,14 +92,6 @@ export interface CreateGenerationTaskRequest {
preset_id?: string preset_id?: string
volume?: number volume?: number
} }
/** 批量生成数量(1~10),默认1。不传=单条旧逻辑 */
count?: number
/** 各变体独立标题文字:长度1=共用,长度=count=独立,空数组=使用 title_config/custom_title */
titles?: string[]
/** 各变体独立配音素材库ID:长度1=共用,长度=count=独立,空数组=回退 voice_library_id */
voice_library_ids?: string[]
/** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
cover_urls?: string[]
} }
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */ /** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
-1
View File
@@ -28,5 +28,4 @@ export {
deleteTTSJob, deleteTTSJob,
getTtsVoices, getTtsVoices,
previewTts, previewTts,
extractVideoVoice,
} from "./jobs" } from "./jobs"
-54
View File
@@ -70,57 +70,3 @@ export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewRes
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data) const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
return response.data return response.data
} }
/**
* 从视频中提取配音(上传视频 → 后端提取人声 → 保存到配音素材库)
* 支持 mp4/mov/webm 格式
*/
export const extractVideoVoice = async (
file: File,
onProgress?: (percent: number) => void,
): Promise<{ asset_id: string; duration: number }> => {
const formData = new FormData()
formData.append("file", file)
formData.append("project_id", "default")
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", "/api/v1/voices/extract-voice")
// 携带认证 token(从 localStorage 获取,与 apiClient 拦截器一致)
const token = localStorage.getItem("access_token")
if (token) {
xhr.setRequestHeader("Authorization", `Bearer ${token}`)
}
xhr.timeout = 10 * 60 * 1000 // 10 分钟超时
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText))
} catch {
reject(new Error("服务器返回数据解析失败"))
}
} else {
try {
const err = JSON.parse(xhr.responseText)
reject(new Error(err.detail || err.message || `提取失败: HTTP ${xhr.status}`))
} catch {
reject(new Error(`提取失败: HTTP ${xhr.status}`))
}
}
}
xhr.onerror = () => reject(new Error("网络错误,请检查网络连接"))
xhr.ontimeout = () => reject(new Error("上传超时(10分钟),请检查网络或尝试更小的文件"))
xhr.send(formData)
})
}
@@ -297,7 +297,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
buttonSize="sm" buttonSize="sm"
onClick={() => { onClick={() => {
handleClose() handleClose()
navigate("/app/voices?tab=material&upload=1") navigate("/app/voice-materials")
}} }}
> >
去配音库上传 去配音库上传
@@ -3,11 +3,9 @@ import { useQuery } from "@tanstack/react-query"
import { import {
getAssetLibraries, getAssetLibraries,
getAssets, getAssets,
ensureDefaultLibrary,
type AssetLibraryItem, type AssetLibraryItem,
type AssetItem as ApiAssetItem, type AssetItem as ApiAssetItem,
} from "@/api/assets" } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types" import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
/** /**
@@ -18,18 +16,7 @@ export function useAssetsData() {
/* ── 视频库列表查询 ── */ /* ── 视频库列表查询 ── */
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({ const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
queryKey: ["asset-libraries"], queryKey: ["asset-libraries"],
queryFn: async () => { queryFn: getAssetLibraries,
const libs = await getAssetLibraries()
// 如果没有 video 类型的库,自动创建默认视频素材库(与 useVoiceMaterials 保持一致)
const hasVideoLib = libs.some((lib) => lib.kind === "video")
if (!hasVideoLib) {
const project = await getOrCreateDefaultProject()
await ensureDefaultLibrary({ project_id: project.id, kind: "video" })
// 创建后重新拉取最新列表
return getAssetLibraries()
}
return libs
},
staleTime: 60_000, staleTime: 60_000,
}) })
@@ -98,7 +98,7 @@ const DuplicationDetail: React.FC = () => {
<div className="dup-detail-grid"> <div className="dup-detail-grid">
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} /> <RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
<InfoCard detail={detail} /> <InfoCard detail={detail} />
<SegmentsSection segments={detail.segments} totalDuration={detail.duration_seconds} /> <SegmentsSection segments={detail.segments} />
</div> </div>
</div> </div>
) )
@@ -1,7 +1,7 @@
import React from "react" import React from "react"
import { Button, Tag, Tooltip } from "@/components/ui" import { Button, Tag, Tooltip } from "@/components/ui"
import type { DuplicationRecord } from "@/api/duplication" import type { DuplicationRecord } from "@/api/duplication"
import { STATUS_CONFIG, RISK_TAG_VARIANT, RISK_LABELS } from "../constants" import { STATUS_CONFIG } from "../constants"
import { getRiskLevel, formatSize, formatDuration } from "../utils" import { getRiskLevel, formatSize, formatDuration } from "../utils"
interface ResultCardProps { interface ResultCardProps {
@@ -54,9 +54,6 @@ const ResultCard: React.FC<ResultCardProps> = ({ record, onView, onDelete, onRet
/> />
</div> </div>
<span className={`dup-score-value ${riskLevel}`}>{rateValue.toFixed(1)}%</span> <span className={`dup-score-value ${riskLevel}`}>{rateValue.toFixed(1)}%</span>
<Tag variant={RISK_TAG_VARIANT[riskLevel]} className="dup-score-risk-tag">
{RISK_LABELS[riskLevel]}
</Tag>
</> </>
) : record.status === "failed" ? ( ) : record.status === "failed" ? (
<Tooltip title="重新查重"> <Tooltip title="重新查重">
@@ -2,81 +2,34 @@ import React from "react"
import { Tag } from "@/components/ui" import { Tag } from "@/components/ui"
import type { DuplicateSegment } from "@/api/duplication" import type { DuplicateSegment } from "@/api/duplication"
import { SegmentCard } from "./SegmentCard" import { SegmentCard } from "./SegmentCard"
import { formatTime } from "../utils"
interface SegmentsSectionProps { interface SegmentsSectionProps {
segments?: DuplicateSegment[] segments?: DuplicateSegment[]
/** 视频总时长(秒),用于渲染时间轴 */
totalDuration?: number
}
/** 片段相似度 → 风险等级(时间轴配色用) */
const getSegmentRisk = (similarity: number): "low" | "medium" | "high" => {
if (similarity >= 90) return "high"
if (similarity >= 70) return "medium"
return "low"
} }
/** /**
* 重复片段列表区域(含时间轴可视化) * 重复片段列表区域
*/ */
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ segments = [] }) => (
segments = [], <div className="dup-checks-section">
totalDuration, <h3>
}) => { 🔍 重复片段详情
const showTimeline = segments.length > 0 && totalDuration !== undefined && totalDuration > 0 <Tag variant="primary" style={{ marginLeft: 8 }}>
{segments.length} 个片段
</Tag>
</h3>
return ( {segments.length > 0 ? (
<div className="dup-checks-section"> <div className="dup-checks-list">
<h3> {segments.map((segment, index) => (
🔍 重复片段详情 <SegmentCard key={segment.id} segment={segment} index={index} />
<Tag variant="primary" style={{ marginLeft: 8 }}> ))}
{segments.length} 个片段 </div>
</Tag> ) : (
</h3> <div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
{showTimeline && ( <p>未发现重复片段,内容原创度很高</p>
<div className="dup-timeline"> </div>
<div className="dup-timeline-bar"> )}
{segments.map((seg, i) => { </div>
const left = (seg.source_start / totalDuration) * 100 )
const width = Math.max(
((seg.source_end - seg.source_start) / totalDuration) * 100,
0.5,
)
const segRisk = getSegmentRisk(seg.similarity)
return (
<div
key={seg.id ?? i}
className={`dup-timeline-segment ${segRisk}`}
style={{
left: `${Math.min(left, 100)}%`,
width: `${Math.min(width, 100 - Math.min(left, 100))}%`,
}}
title={`${formatTime(seg.source_start)} - ${formatTime(seg.source_end)} · 相似度 ${seg.similarity.toFixed(0)}% · ${seg.matched_video_name}`}
/>
)
})}
</div>
<div className="dup-timeline-labels">
<span>0s</span>
<span>{formatTime(totalDuration ?? 0)}</span>
</div>
</div>
)}
{segments.length > 0 ? (
<div className="dup-checks-list">
{segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p>未发现重复片段,内容原创度很高</p>
</div>
)}
</div>
)
}
@@ -831,61 +831,3 @@
font-size: 16px; font-size: 16px;
} }
} }
/* ============================================================
查重率风险标签(列表卡片)
============================================================ */
.dup-score-risk-tag {
flex-shrink: 0;
margin-left: 2px;
}
/* ============================================================
重复片段时间轴可视化(#1662)
============================================================ */
.dup-timeline {
margin: 16px 0;
padding: 0 8px;
}
.dup-timeline-bar {
position: relative;
height: 24px;
background: var(--bg-secondary, #f1f5f9);
border-radius: 4px;
overflow: hidden;
}
.dup-timeline-segment {
position: absolute;
top: 2px;
height: 20px;
border-radius: 3px;
opacity: 0.8;
cursor: pointer;
transition: opacity 0.2s;
}
.dup-timeline-segment:hover {
opacity: 1;
}
.dup-timeline-segment.low {
background: #22c55e;
}
.dup-timeline-segment.medium {
background: #f59e0b;
}
.dup-timeline-segment.high {
background: #ef4444;
}
.dup-timeline-labels {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
+3 -3
View File
@@ -1,9 +1,9 @@
/** 根据查重率获取风险等级 */ /** 根据查重率获取风险等级 */
export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => { export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
if (rate === undefined) return "low" if (rate === undefined) return "low"
if (rate < 15) return "low" // <15% 绿色(安全) if (rate <= 10) return "low"
if (rate <= 30) return "medium" // 15-30% 黄色(注意) if (rate <= 30) return "medium"
return "high" // >30% 红色(危险) return "high"
} }
/** 格式化时间(秒 → mm:ss) */ /** 格式化时间(秒 → mm:ss) */
@@ -40,6 +40,13 @@ const clipTypeLabel: Record<ClipType | string, string> = {
pip: "混剪", pip: "混剪",
} }
const formatDuration = (sec: number) => {
if (sec < 60) return `${sec.toFixed(1)}s`
const m = Math.floor(sec / 60)
const s = (sec % 60).toFixed(0)
return `${m}m${s.padStart(2, "0")}s`
}
const EditorClipList: React.FC<EditorClipListProps> = ({ const EditorClipList: React.FC<EditorClipListProps> = ({
clips, clips,
selectedClipId, selectedClipId,
@@ -95,6 +102,7 @@ const EditorClipList: React.FC<EditorClipListProps> = ({
{clipTypeLabel[clip.type] || "片段"} {clipTypeLabel[clip.type] || "片段"}
</span> </span>
</span> </span>
<span className="ep-clip-item-duration">{formatDuration(clip.duration)}</span>
</div> </div>
{/* 文案预览 */} {/* 文案预览 */}
@@ -87,7 +87,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
WebkitTextStroke: "1px rgba(0,0,0,0.6)", WebkitTextStroke: "1px rgba(0,0,0,0.6)",
top: top:
titleConfig.position === "top" titleConfig.position === "top"
? "6.25%" ? "8px"
: titleConfig.position === "center" : titleConfig.position === "center"
? "50%" ? "50%"
: "auto", : "auto",
@@ -7,6 +7,7 @@
* - ClipCard - 片段卡片 * - ClipCard - 片段卡片
* - ClipTrack - 片段轨道(播放头+片段列表+添加卡片) * - ClipTrack - 片段轨道(播放头+片段列表+添加卡片)
* - TimelineHeader - 时间线头部(标题+缩放+操作按钮) * - TimelineHeader - 时间线头部(标题+缩放+操作按钮)
* - AddClipPicker - 添加片段选择器
* - TrimPreview - 裁剪预览 tooltip * - TrimPreview - 裁剪预览 tooltip
* - ContextMenu - 右键菜单 * - ContextMenu - 右键菜单
* *
@@ -26,6 +27,7 @@ import { usePlayheadDrag } from "./timeline/hooks/usePlayheadDrag"
import { TimeRuler } from "./timeline/TimeRuler" import { TimeRuler } from "./timeline/TimeRuler"
import { ClipTrack } from "./timeline/ClipTrack" import { ClipTrack } from "./timeline/ClipTrack"
import { TimelineHeader } from "./timeline/TimelineHeader" import { TimelineHeader } from "./timeline/TimelineHeader"
import { AddClipPicker } from "./timeline/AddClipPicker"
import { TrimPreview } from "./timeline/TrimPreview" import { TrimPreview } from "./timeline/TrimPreview"
import { ContextMenu } from "./timeline/ContextMenu" import { ContextMenu } from "./timeline/ContextMenu"
@@ -98,7 +100,16 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
handleContextSplit, handleContextSplit,
handleContextResetTrim, handleContextResetTrim,
handleContextDelete, handleContextDelete,
showAddPicker,
pickerRef,
addCardRef, addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd, handleConfirmAdd,
hoveredClipId, hoveredClipId,
setHoveredClipId, setHoveredClipId,
@@ -166,7 +177,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onClipMouseLeave={() => setHoveredClipId(null)} onClipMouseLeave={() => setHoveredClipId(null)}
onTrimHandleMouseDown={handleTrimHandleMouseDown} onTrimHandleMouseDown={handleTrimHandleMouseDown}
onClipRemove={onClipRemove} onClipRemove={onClipRemove}
onTogglePicker={handleConfirmAdd} onTogglePicker={handleTogglePicker}
/> />
)} )}
@@ -193,6 +204,20 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onDelete={handleContextDelete} onDelete={handleContextDelete}
/> />
)} )}
{/* 类型+时长选择面板 */}
{showAddPicker && (
<AddClipPicker
pickerRef={pickerRef}
position={pickerPos}
availableTypes={availableTypes}
addType={addType}
addDuration={addDuration}
onTypeChange={setAddType}
onDurationChange={setAddDuration}
onConfirm={handleConfirmAdd}
/>
)}
</div> </div>
) )
} }
@@ -7,8 +7,12 @@ interface AddClipPickerProps {
position: { top: number; right: number } position: { top: number; right: number }
availableTypes: ClipType[] availableTypes: ClipType[]
addType: ClipType addType: ClipType
addDuration: number
onTypeChange: (type: ClipType) => void onTypeChange: (type: ClipType) => void
onDurationChange: (duration: number) => void
onConfirm: () => void onConfirm: () => void
minDuration?: number
maxDuration?: number
} }
export const AddClipPicker: React.FC<AddClipPickerProps> = ({ export const AddClipPicker: React.FC<AddClipPickerProps> = ({
@@ -16,8 +20,12 @@ export const AddClipPicker: React.FC<AddClipPickerProps> = ({
position, position,
availableTypes, availableTypes,
addType, addType,
addDuration,
onTypeChange, onTypeChange,
onDurationChange,
onConfirm, onConfirm,
minDuration = 1,
maxDuration = 120,
}) => { }) => {
return ( return (
<div <div
@@ -45,6 +53,24 @@ export const AddClipPicker: React.FC<AddClipPickerProps> = ({
))} ))}
</div> </div>
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">时长:</span>
<input
type="number"
className="ep-duration-input"
min={minDuration}
max={maxDuration}
value={addDuration}
onChange={(e) =>
onDurationChange(
Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)),
)
}
/>
<span className="ep-add-clip-duration-unit">秒</span>
</div>
{/* 确认按钮 */} {/* 确认按钮 */}
<button className="ep-add-clip-confirm-btn" onClick={onConfirm}> <button className="ep-add-clip-confirm-btn" onClick={onConfirm}>
添加 添加
@@ -105,11 +105,14 @@ export const ClipCard: React.FC<ClipCardProps> = ({
<span className="ep-clip-name"> <span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1} {CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span> </span>
{hasTrim && ( <span className="ep-clip-duration">
<span className="ep-trim-indicator" title="已裁剪"> {clip.duration}s
✂ {hasTrim && (
</span> <span className="ep-trim-indicator" title="已裁剪">
)} ✂
</span>
)}
</span>
</div> </div>
{/* 速度徽章 */} {/* 速度徽章 */}
@@ -26,6 +26,8 @@ export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
}, [currentMode]) }, [currentMode])
const [addType, setAddType] = useState<ClipType>(defaultAddType) const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
useEffect(() => { useEffect(() => {
if (!availableTypes.includes(addType)) { if (!availableTypes.includes(addType)) {
setAddType(defaultAddType) setAddType(defaultAddType)
@@ -93,9 +95,9 @@ export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
}, [showAddPicker]) }, [showAddPicker])
const handleConfirmAdd = useCallback(() => { const handleConfirmAdd = useCallback(() => {
onAddClip(addType, DEFAULT_ADD_DURATION) onAddClip(addType, addDuration)
setShowAddPicker(false) setShowAddPicker(false)
}, [onAddClip, addType]) }, [onAddClip, addType, addDuration])
return { return {
showAddPicker, showAddPicker,
@@ -105,8 +107,9 @@ export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
pickerPos, pickerPos,
availableTypes, availableTypes,
addType, addType,
addDuration: DEFAULT_ADD_DURATION, addDuration,
setAddType, setAddType,
setAddDuration,
handleTogglePicker, handleTogglePicker,
handleConfirmAdd, handleConfirmAdd,
} }
@@ -35,6 +35,7 @@ export const useTimelineMenus = (
addType, addType,
addDuration, addDuration,
setAddType, setAddType,
setAddDuration,
handleTogglePicker, handleTogglePicker,
handleConfirmAdd, handleConfirmAdd,
} = useAddPicker({ currentMode, onAddClip }) } = useAddPicker({ currentMode, onAddClip })
@@ -56,6 +57,7 @@ export const useTimelineMenus = (
addType, addType,
addDuration, addDuration,
setAddType, setAddType,
setAddDuration,
handleTogglePicker, handleTogglePicker,
handleConfirmAdd, handleConfirmAdd,
// 悬停状态 // 悬停状态
+71 -366
View File
@@ -1,11 +1,12 @@
/** /**
* 智能剪辑页面(Issue #1677 多视频批量生成) * 智能剪辑页面 — 前端实时预览架构
* 5 步向导:选择模板(弹数量) → 素材 → 配音 → 标题(预览+确认生成) → 封面 * 6 步向导:选择模板 → 素材 → 配音 → 标题(含预览) → 确认生成 → 选择封面
* *
* 架构: * 架构:
* - N=1:前端 Canvas 实时预览(FrontendPreviewPlayer),零回归 * - 步骤 4 右侧显示 FrontendPreviewPlayer 实时预览
* - N>1:服务器批量预览(POST /generation/preview?preview_count=N), * - 步骤 5 右侧内联播放生成中的/最终视频
* N 个变体分别轮询,网格展示、独立可播放、CSS 标题浮层实时叠加、勾选批量生成 * - 步骤 6 封面从最终成片中智能选帧(MediaKit)
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
*/ */
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react" import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { message } from "antd" import { message } from "antd"
@@ -15,21 +16,21 @@ import { useQuery } from "@tanstack/react-query"
import { useCloneProgress } from "@/hooks/useCloneProgress" import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal" import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader" import GenerateHeader from "./components/GenerateHeader"
import {
calculateTotalVideoDuration,
estimateTotalVideoDuration,
} from "./utils/calculateTotalVideoDuration"
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer" import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
import ServerPreviewGrid from "./components/ServerPreviewGrid"
import PreviewCountModal from "./components/PreviewCountModal"
import GenerateStepsBar from "./components/GenerateStepsBar" import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateStepContent from "./components/GenerateStepContent" import GenerateStepContent from "./components/GenerateStepContent"
import GenerateStepActions from "./components/GenerateStepActions" import GenerateStepActions from "./components/GenerateStepActions"
import { useGenerateFormState } from "./hooks/useGenerateFormState" import { useGenerateFormState } from "./hooks/useGenerateFormState"
import { useStepNavigation } from "./hooks/useStepNavigation" import { useStepNavigation } from "./hooks/useStepNavigation"
import { useGenerateVideo } from "./hooks/useGenerateVideo" import { useGenerateVideo } from "./hooks/useGenerateVideo"
import { useBatchPreview } from "./hooks/useBatchPreview"
import { usePreviewAssets } from "./hooks/usePreviewAssets" import { usePreviewAssets } from "./hooks/usePreviewAssets"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters" import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
import { getAssetsByKind } from "@/api/assets" import { getAssetsByKind } from "@/api/assets"
import { previewTts } from "@/api/tts" import { previewTts } from "@/api/tts"
import { calculateResolution } from "./utils/calculateResolution"
import "./generate.css" import "./generate.css"
const GeneratePage: React.FC = () => { const GeneratePage: React.FC = () => {
@@ -56,8 +57,10 @@ const GeneratePage: React.FC = () => {
selectedVoice, selectedVoice,
setSelectedVoice, setSelectedVoice,
voiceMode, voiceMode,
setVoiceMode,
selectedClonedVoice, selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen, cloneModalOpen,
setCloneModalOpen, setCloneModalOpen,
videoRatio, videoRatio,
@@ -73,51 +76,8 @@ const GeneratePage: React.FC = () => {
setStoredSourceEditPlanId, setStoredSourceEditPlanId,
serverClips, serverClips,
setServerClips, setServerClips,
previewCount,
setPreviewCount,
previewTitles,
setPreviewTitles,
voiceModePerVideo,
setVoiceModePerVideo,
voiceLibraryIds,
setVoiceLibraryIds,
previewCovers,
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
} = formState } = formState
const isBatch = previewCount > 1
/* ── 配音选择同步:共用配音 ↔ 变体数组 ── */
// 触发场景:①共用配音变化 ②批量模式进入/退出 ③独立→共用切换(需把所有变体刷成共用配音)
// 独立模式下:仅同步变体[0](其选择器绑定共用配音),用户单独选择的其他变体不覆盖
const prevVoiceSyncRef = useRef({
voice: selectedVoice,
batch: isBatch,
perVideo: voiceModePerVideo,
})
useEffect(() => {
const prev = prevVoiceSyncRef.current
const voiceChanged = prev.voice !== selectedVoice
const modeChanged = prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo
prevVoiceSyncRef.current = { voice: selectedVoice, batch: isBatch, perVideo: voiceModePerVideo }
if (!voiceChanged && !modeChanged) return
if (!isBatch) return
if (!voiceModePerVideo) {
// 共用模式(含刚从独立切回):所有变体跟随共用配音,未选择的补默认值
setVoiceLibraryIds((prevIds) => (prevIds || []).map((id) => id || selectedVoice))
} else if (voiceChanged) {
// 独立模式下共用配音变化:仅同步变体[0](与共用选择器绑定),其余不覆盖
setVoiceLibraryIds((prevIds) =>
(prevIds || []).map((id, i) => (i === 0 ? selectedVoice : id)),
)
}
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── 标题样式回调 ── */ /* ── 标题样式回调 ── */
const styleUpdaters = useTitleStyleUpdaters({ const styleUpdaters = useTitleStyleUpdaters({
titleSettings, titleSettings,
@@ -168,10 +128,11 @@ const GeneratePage: React.FC = () => {
cancelled = true cancelled = true
controller.abort() controller.abort()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials]) }, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
/* ── 克隆声音 ── */ /* ── 克隆声音 ── */
const { addClone } = useCloneProgress() const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
const handleCloneSuccess = (voice: VoiceClone) => { const handleCloneSuccess = (voice: VoiceClone) => {
addClone(voice) addClone(voice)
@@ -200,94 +161,25 @@ const GeneratePage: React.FC = () => {
[bgm, currentTemplate], [bgm, currentTemplate],
) )
/* ── 加载素材详情(供前端预览播放器使用) ── */ /* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
const previewAssetsEnabled = previewAssetIds.length > 0 const previewAssetsEnabled = previewAssetIds.length > 0
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets( const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
previewAssetIds, previewAssetIds,
previewAssetsEnabled, previewAssetsEnabled,
) )
/* ── 预览就绪 ── */ /* ── 预览就绪:素材已加载,且有模板 ── */
const singlePreviewReady = useMemo( const previewReady = useMemo(
() => previewAssetsReady && !!currentTemplate, () => previewAssetsReady && !!currentTemplate,
[previewAssetsReady, currentTemplate], [previewAssetsReady, currentTemplate],
) )
/* ── 批量服务器预览(N>1) ── */ /* ── 视频总时长计算 ── */
const buildPreviewRequest = useCallback(() => { const totalVideoDuration = useMemo(() => {
const { width, height } = calculateResolution(videoRatio || "9:16") const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
const voiceLibraryId = if (exact > 0) return exact
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || "" return estimateTotalVideoDuration(currentTemplate ?? undefined)
return { }, [previewAssets, currentTemplate])
template_id: selectedTemplate,
asset_ids: previewAssetIds,
output_width: width,
output_height: height,
video_ratio: videoRatio,
voice_library_id: voiceLibraryId,
...(voiceModePerVideo && voiceLibraryIds.some(Boolean)
? { voice_library_ids: voiceLibraryIds.map((id) => id || voiceLibraryId) }
: {}),
preview_count: previewCount,
// 批量预览不传 titles/title_config:标题文字与样式由前端 CSS 浮层实时叠加
// (用户改标题/样式即时可见,无需重渲染);正式生成时才把标题烧录进成片
duration: duration || undefined,
bgm_config: {
enabled: bgm !== false,
...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}),
},
...(storedSourceEditPlanId || sourceEditPlanId
? { source_edit_plan_id: storedSourceEditPlanId || sourceEditPlanId || undefined }
: {}),
}
}, [
videoRatio,
voiceMode,
selectedClonedVoice,
selectedVoice,
selectedTemplate,
previewAssetIds,
voiceModePerVideo,
voiceLibraryIds,
previewCount,
duration,
bgm,
bgmConfig,
storedSourceEditPlanId,
sourceEditPlanId,
])
const {
variants,
status: batchPreviewStatus,
progress: batchPreviewProgress,
failedCount: batchFailedCount,
trigger: retryBatchPreview,
} = useBatchPreview({
enabled: isBatch && currentStep >= 4 && previewAssetIds.length > 0 && !!selectedTemplate,
buildRequest: buildPreviewRequest,
onPreviewTasksCreated: (_taskIds, planId) => {
if (planId) setStoredSourceEditPlanId(planId)
},
})
/** 批量预览就绪:全部变体渲染完成 */
const batchPreviewReady =
isBatch && variants.length > 0 && variants.every((v) => v.status === "ready")
/** 步骤4整体预览就绪状态 */
const previewReady = isBatch ? batchPreviewReady : singlePreviewReady
/* ── 勾选变体 ── */
const toggleVariantSelect = useCallback(
(index: number) => {
setSelectedVariantIds((prev) => {
const list = prev || []
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
})
},
[setSelectedVariantIds],
)
/* ── 视频生成核心逻辑 ── */ /* ── 视频生成核心逻辑 ── */
const { const {
@@ -319,61 +211,16 @@ const GeneratePage: React.FC = () => {
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId, sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
previewTaskId, previewTaskId,
bgmConfig, bgmConfig,
previewCount,
variantTitles: previewTitles,
variantVoiceLibraryIds: voiceLibraryIds,
voiceModePerVideo,
variantCoverUrls: previewCovers,
selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
onGenerationSuccess: () => { onGenerationSuccess: () => {
setPreviewTaskId(null) setPreviewTaskId(null)
setStoredSourceEditPlanId(null) setStoredSourceEditPlanId(null)
}, },
}) })
/* ── 数量弹窗确认:设置数量 + 同步批量数组长度 + 进入步骤2 ── */ /* ── 步骤4「确认生成视频」:校验标题/预览 → 创建最终渲染任务 → 成功后进入步骤5 ── */
const handleCountConfirm = useCallback(
(count: number) => {
setPreviewCount(count)
setCountModalOpen(false)
// 同步批量数组长度
setPreviewTitles((prev) => {
const list = prev || []
const base = list[0] || titleSettings.title || ""
return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : ""))
})
setVoiceLibraryIds((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "")
})
setPreviewCovers((prev) => {
const list = prev || []
return Array.from({ length: count }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
setCurrentStep(2)
},
[
setPreviewCount,
setPreviewTitles,
setVoiceLibraryIds,
setPreviewCovers,
setSelectedVariantIds,
setCurrentStep,
titleSettings.title,
selectedVoice,
],
)
/* ── 步骤4「确认生成视频」 ── */
const handleConfirmGenerate = useCallback(async () => { const handleConfirmGenerate = useCallback(async () => {
// 标题校验 if (!titleSettings.title.trim()) {
if (previewTitles.some((t) => !t?.trim())) { message.warning("请选择或输入标题")
message.warning("请为每个视频输入标题")
return
}
if (isBatch && selectedVariantIds.length === 0) {
message.warning("请至少勾选一个视频")
return return
} }
if (!previewReady) { if (!previewReady) {
@@ -381,18 +228,10 @@ const GeneratePage: React.FC = () => {
return return
} }
const ok = await handleGenerate() const ok = await handleGenerate()
if (ok && !isBatch) { if (ok) {
setCurrentStep(5) setCurrentStep(5)
} }
// 批量模式停留在步骤4,右侧网格显示生成进度,完成后点"下一步"进封面 }, [titleSettings.title, previewReady, handleGenerate, setCurrentStep])
}, [
isBatch,
selectedVariantIds.length,
previewReady,
previewTitles,
handleGenerate,
setCurrentStep,
])
/* ── 步骤导航 ── */ /* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({ const { goNext, goPrev } = useStepNavigation({
@@ -405,24 +244,11 @@ const GeneratePage: React.FC = () => {
titleSettings, titleSettings,
previewReady, previewReady,
generated, generated,
previewTitles,
selectedCount: isBatch ? selectedVariantIds.length : 1,
onOpenCountModal: () => setCountModalOpen(true),
}) })
/* ── 最终成片(单视频右侧播放) ── */ /* ── 最终成片(步骤5/6 右侧播放) ── */
const finalVideo = generatedVideos[0] const finalVideo = generatedVideos[0]
/** 批量生成进度文案 */
const batchGeneratingText = useMemo(() => {
if (batchPreviewStatus === "loading")
return `AI 正在渲染 ${previewCount} 个预览视频… ${batchPreviewProgress}%`
if (batchPreviewStatus === "failed") return "预览渲染失败,请重试"
if (batchPreviewStatus === "partial_failed")
return `${batchFailedCount} 个预览失败,可重新生成或勾选成功的视频`
return ""
}, [batchPreviewStatus, batchPreviewProgress, batchFailedCount, previewCount])
/* ================================================================ /* ================================================================
渲染 渲染
================================================================ */ ================================================================ */
@@ -433,142 +259,8 @@ const GeneratePage: React.FC = () => {
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} /> <GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
<div <div className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}`}>
className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}${ {/* ════ 左侧:表单区 ════ */}
currentStep === 4 ? " step4-layout" : ""
}`}
>
{/* ════ 步骤4:左侧预览大区域 ════ */}
{currentStep === 4 && !!currentTemplate && (
<div className="xx-generate-preview-col">
{!isBatch ? (
/* 单视频:前端 Canvas 实时预览(与旧版一致) */
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
) : (
/* 批量:服务器预览网格 */
<div className="xx-form-section">
<div className="xx-preview-header">
<h3>🎬 {previewCount} 个视频预览</h3>
{batchPreviewStatus === "loading" && (
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
{batchPreviewProgress}%
</span>
)}
{(batchPreviewStatus === "failed" || batchPreviewStatus === "partial_failed") && (
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
onClick={retryBatchPreview}
>
🔄 重新生成预览
</button>
)}
</div>
{batchGeneratingText && (
<div
style={{
fontSize: 13,
color:
batchPreviewStatus === "failed"
? "var(--error-color, #ef4444)"
: "var(--text-secondary, #666)",
marginBottom: 12,
}}
>
{batchGeneratingText}
</div>
)}
<ServerPreviewGrid
variants={variants}
titles={previewTitles}
titleStyle={{
position: titleSettings.position,
color: titleSettings.color,
size: titleSettings.size,
}}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelect}
selectable={!generating}
/>
{/* 生成中进度(批量) */}
{generating && (
<div className="xx-gen-progress-card" style={{ marginTop: 16 }}>
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase">
⏳ 正在渲染 {selectedVariantIds.length} 个最终视频… {Math.round(progress)}
%
</div>
<div className="xx-gen-progress-sub">
生成过程中可以切换到其他页面,完成后可在任务历史查看
</div>
</div>
</div>
<div className="xx-gen-progress-bar">
<div
className="xx-gen-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
</div>
)}
{generateError && !generating && (
<div className="xx-gen-error-card" style={{ marginTop: 16 }}>
<div className="xx-gen-error-info">
<div className="xx-gen-error-title">生成失败</div>
<div className="xx-gen-error-msg">{generateError}</div>
</div>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
onClick={handleRetryGenerate}
>
🔄 重试
</button>
</div>
)}
{generated && !generating && (
<div className="xx-gen-success-card" style={{ marginTop: 16 }}>
<div className="xx-gen-success-info">
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
<div className="xx-gen-success-sub">
共生成 {generatedVideos.length} 条视频,点击「下一步」为每个视频选择封面
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 封面 ════ */}
<div className="xx-generate-form"> <div className="xx-generate-form">
<GenerateStepContent <GenerateStepContent
currentStep={currentStep} currentStep={currentStep}
@@ -599,7 +291,17 @@ const GeneratePage: React.FC = () => {
onCoverSettingsChange={setCoverSettings} onCoverSettingsChange={setCoverSettings}
selectedVoice={selectedVoice} selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice} onSelectedVoiceChange={setSelectedVoice}
totalVideoDuration={totalVideoDuration}
onServerClipsChange={setServerClips} onServerClipsChange={setServerClips}
voiceMode={voiceMode}
onVoiceModeChange={setVoiceMode}
selectedClonedVoice={selectedClonedVoice}
onSelectedClonedVoiceChange={setSelectedClonedVoice}
clonedVoices={clonedVoices}
addClone={addClone}
hasProcessing={hasProcessing}
cloneModalOpen={cloneModalOpen}
onCloneModalOpenChange={setCloneModalOpen}
generating={generating} generating={generating}
generated={generated} generated={generated}
generateError={generateError} generateError={generateError}
@@ -607,16 +309,7 @@ const GeneratePage: React.FC = () => {
generatedVideos={generatedVideos} generatedVideos={generatedVideos}
onRetry={handleRetryGenerate} onRetry={handleRetryGenerate}
onDismissError={handleDismissError} onDismissError={handleDismissError}
previewCount={previewCount} presetVoices={presetVoices}
previewTitles={previewTitles}
onPreviewTitlesChange={setPreviewTitles}
voiceModePerVideo={voiceModePerVideo}
onVoiceModePerVideoChange={setVoiceModePerVideo}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={setVoiceLibraryIds}
previewCovers={previewCovers}
onPreviewCoversChange={setPreviewCovers}
selectedVariantIds={selectedVariantIds}
/> />
<GenerateStepActions <GenerateStepActions
@@ -627,20 +320,40 @@ const GeneratePage: React.FC = () => {
generating={generating} generating={generating}
generated={generated} generated={generated}
generateError={generateError} generateError={generateError}
selectedCount={isBatch ? selectedVariantIds.length : 1}
/> />
</div> </div>
{/* ════ 步骤5(封面):成片播放器(单视频) ════ */} {/* ════ 右侧:步骤4实时预览,步骤5/6最终视频 ════ */}
{currentStep === 5 && !isBatch && generated && finalVideo && ( <div className="xx-generate-right-col">
<div className="xx-generate-right-col"> {currentStep === 4 && !!currentTemplate && (
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
}}
/>
)}
{currentStep >= 5 && generated && finalVideo && (
<div className="xx-inline-video-player"> <div className="xx-inline-video-player">
<video <video
src={finalVideo.download_url || finalVideo.file_url} src={finalVideo.download_url || finalVideo.file_url}
controls controls
autoPlay autoPlay={currentStep === 5}
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }} style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
poster={finalVideo.thumbnail_url} poster={finalVideo.thumbnail_url || undefined}
/> />
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}> <div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}> <button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
@@ -657,18 +370,10 @@ const GeneratePage: React.FC = () => {
</button> </button>
</div> </div>
</div> </div>
</div> )}
)} </div>
</div> </div>
{/* 数量选择弹窗 */}
<PreviewCountModal
open={countModalOpen}
defaultCount={1}
onConfirm={handleCountConfirm}
onCancel={() => setCountModalOpen(false)}
/>
{/* 音色克隆弹窗 */} {/* 音色克隆弹窗 */}
<CloneModal <CloneModal
open={cloneModalOpen} open={cloneModalOpen}
@@ -32,15 +32,12 @@ interface FrontendPreviewPlayerProps {
size: number size: number
font: string font: string
color: string color: string
position: "top" | "center" | "bottom" | "custom" position: "top" | "center" | "bottom"
bold?: boolean bold?: boolean
italic?: boolean italic?: boolean
stroke?: boolean stroke?: boolean
shadow?: boolean shadow?: boolean
posX?: number | null
posY?: number | null
} }
onTitlePositionChange?: (posX: number, posY: number) => void
} }
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
@@ -108,7 +105,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
serverClips, serverClips,
voiceAudioUrl, voiceAudioUrl,
titleSettings, titleSettings,
onTitlePositionChange,
}) => { }) => {
const segments = useMemo( const segments = useMemo(
() => buildPlaybackSegments(assets, template, serverClips), () => buildPlaybackSegments(assets, template, serverClips),
@@ -130,69 +126,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
return { width: 1080, height: 1920 } return { width: 1080, height: 1920 }
} }
})() })()
const customTitleXPct =
titleSettings?.posX != null && playRes.width > 0
? (titleSettings.posX / playRes.width) * 100
: null
const customTitleYPct =
titleSettings?.posY != null && playRes.height > 0
? (titleSettings.posY / playRes.height) * 100
: null
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
const draggingTitleRef = useRef(false)
const titleDragRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
;(e.target as Element).setPointerCapture(e.pointerId)
draggingTitleRef.current = true
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
},
[onTitlePositionChange],
)
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
if (titleDragRef.current) {
const rect = playerContainerRef.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 = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
// 拖拽结束时才调用 onTitlePositionChange 保存最终位置
if (onTitlePositionChange && playerContainerRef.current) {
const rect = playerContainerRef.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 posX = Math.round((relX / rect.width) * playRes.width)
const posY = Math.round((relY / rect.height) * playRes.height)
onTitlePositionChange(posX, posY)
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
try {
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
}
} catch {
/* ignore */
}
},
[onTitlePositionChange, playRes.width, playRes.height],
)
const playerContainerRef = useRef<HTMLDivElement>(null) const playerContainerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(0) const [containerHeight, setContainerHeight] = useState(0)
useEffect(() => { useEffect(() => {
@@ -591,38 +524,15 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<div <div
style={{ style={{
position: "absolute", position: "absolute",
width: `${100 - 2 * titleSidePct}%`, left: `${titleSidePct}%`,
maxWidth: `${100 - 2 * titleSidePct}%`, right: `${titleSidePct}%`,
...(customTitleXPct != null && customTitleYPct != null textAlign: "center",
? { ...(titleSettings.position === "top"
left: `${customTitleXPct}%`, ? { top: `${titleTopPct}%` }
top: `${customTitleYPct}%`, : titleSettings.position === "center"
transform: "translate(-50%, -50%)", ? { top: "50%", transform: "translateY(-50%)" }
textAlign: "center" as const, : { bottom: `${titleBottomPct}%` }),
}
: {
left: "50%",
transform: "translateX(-50%)",
textAlign: "center" as const,
...(titleSettings.position === "top"
? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translate(-50%, -50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
pointerEvents: "auto",
cursor: onTitlePositionChange ? "grab" : "default",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
padding: "8px 12px",
boxShadow: "inset 0 0 0 16px transparent",
}} }}
ref={titleDragRef}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
> >
<span <span
style={{ style={{
@@ -1,10 +1,10 @@
/** /**
* GeneratePage 步骤底部操作按钮(Issue #1677 改造后 5 步) * GeneratePage 步骤底部操作按钮
* *
* 步骤 1~3:上一步 / 下一步 * 步骤 1~3:上一步 / 下一步
* 步骤 4(标题+预览+确认生成):确认生成按钮在右侧边栏底部(含勾选数量), * 步骤 4(标题+预览):上一步 / 确认生成视频(点击后直接创建最终渲染任务,成功后跳转步骤5)
* 渲染中显示进度;生成完成后显示"下一步 → 选择封面" * 步骤 5(确认生成):上一步 / 下一步(渲染中禁用,渲染完成后可进入封面)
* 步骤 5(选择封面):仅上一步 * 步骤 6(选择封面):仅上一步
*/ */
import React from "react" import React from "react"
@@ -17,11 +17,9 @@ export interface GenerateStepActionsProps {
generating: boolean generating: boolean
generated: boolean generated: boolean
generateError: string | null generateError: string | null
/** 批量模式下勾选的视频数量(N=1 时为1) */
selectedCount?: number
} }
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
currentStep, currentStep,
onPrev, onPrev,
onNext, onNext,
@@ -29,10 +27,9 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
generating, generating,
generated, generated,
generateError, generateError,
selectedCount = 1,
}) => { }) => {
const renderPrimaryButton = () => { const renderPrimaryButton = () => {
/* 步骤 1~3:上一步 / 下一步 */ /* 步骤 1~3:上一步 / 下一步(必填校验由 useStepNavigation.goNext 统一处理) */
if (currentStep < 4) { if (currentStep < 4) {
return ( return (
<button className="xx-btn xx-btn-primary" onClick={onNext}> <button className="xx-btn xx-btn-primary" onClick={onNext}>
@@ -41,7 +38,7 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
) )
} }
/* 步骤 4:标题+预览+确认生成 */ /* 步骤 4:确认生成视频(触发按钮在标题页) */
if (currentStep === 4) { if (currentStep === 4) {
if (generating) { if (generating) {
return ( return (
@@ -60,18 +57,31 @@ const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
if (generated) { if (generated) {
return ( return (
<button className="xx-btn xx-btn-primary" onClick={onNext}> <button className="xx-btn xx-btn-primary" onClick={onNext}>
下一步:选择封面 → 下一步 →
</button> </button>
) )
} }
return ( return (
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}> <button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
{selectedCount > 1 ? `✨ 确认生成 ${selectedCount} 个视频` : "✨ 确认生成视频"} ✨ 确认生成视频
</button> </button>
) )
} }
/* 步骤 5(封面,最后一步):无主按钮 */ /* 步骤 5:渲染中禁用,完成后下一步进入封面 */
if (currentStep === 5) {
return (
<button
className="xx-btn xx-btn-primary"
onClick={onNext}
disabled={generating || !generated}
>
{generating ? "视频生成中…" : "下一步 →"}
</button>
)
}
/* 步骤 6(最后一步):无主按钮 */
return null return null
} }
@@ -1,16 +1,19 @@
/** /**
* GeneratePage 步骤内容渲染 * GeneratePage 步骤内容渲染
* 步骤顺序(5步,Issue #1677):模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5) * 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
*/ */
import React from "react" import React from "react"
import type { EditingTemplate } from "@/api/editing-planner" import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor" import type { EditPlanClip } from "@/api/template-editor"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import type { CoverConfig } from "../types/cover" import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types" import type { TitleSettings } from "../types"
import Step1TemplateSelect from "../components/Step1TemplateSelect" import Step1TemplateSelect from "../components/Step1TemplateSelect"
import Step2MaterialSelect from "../components/Step2MaterialSelect" import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3VoiceWithMode from "./Step3VoiceWithMode" import Step3VoiceSelect from "../components/Step5VoiceSelect"
import Step4TitleSettings from "../components/Step4TitleSettings" import Step4TitleSettings from "../components/Step4TitleSettings"
import Step5ConfirmGenerate from "../components/Step7ConfirmGenerate"
import Step6CoverSettings from "../components/Step6CoverSettings" import Step6CoverSettings from "../components/Step6CoverSettings"
import type { GeneratedVideo } from "@/api/template-editor" import type { GeneratedVideo } from "@/api/template-editor"
@@ -46,7 +49,17 @@ export interface GenerateStepContentProps {
/* 配音 */ /* 配音 */
selectedVoice: string selectedVoice: string
onSelectedVoiceChange: (id: string) => void onSelectedVoiceChange: (id: string) => void
totalVideoDuration?: number
onServerClipsChange: (clips: EditPlanClip[]) => void onServerClipsChange: (clips: EditPlanClip[]) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
onSelectedClonedVoiceChange: (id: string) => void
clonedVoices: VoiceClone[]
addClone: (voice: VoiceClone) => void
hasProcessing: boolean
cloneModalOpen: boolean
onCloneModalOpenChange: (open: boolean) => void
/* 生成 */ /* 生成 */
generating: boolean generating: boolean
generated: boolean generated: boolean
@@ -55,22 +68,12 @@ export interface GenerateStepContentProps {
generatedVideos: GeneratedVideo[] generatedVideos: GeneratedVideo[]
onRetry: () => void onRetry: () => void
onDismissError: () => void onDismissError: () => void
/* 其他 */
presetVoices: PresetVoiceItem[]
/** BGM 开关 */ /** BGM 开关 */
bgm: boolean bgm: boolean
/** BGM 配置(来自模板) */ /** BGM 配置(来自模板) */
bgmConfig?: { enabled: boolean; music_id?: string } bgmConfig?: { enabled: boolean; music_id?: string }
/* ── 批量生成(#1677)── */
previewCount: number
previewTitles: string[]
onPreviewTitlesChange: (titles: string[]) => void
voiceModePerVideo: boolean
onVoiceModePerVideoChange: (v: boolean) => void
voiceLibraryIds: string[]
onVoiceLibraryIdsChange: (ids: string[]) => void
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
/** 批量模式勾选的变体索引(封面卡片按勾选顺序展示) */
selectedVariantIds?: number[]
} }
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => { export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
@@ -101,18 +104,19 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onCoverSettingsChange, onCoverSettingsChange,
selectedVoice, selectedVoice,
onSelectedVoiceChange, onSelectedVoiceChange,
totalVideoDuration,
onServerClipsChange, onServerClipsChange,
voiceMode,
selectedClonedVoice,
clonedVoices,
generating,
generated,
generateError,
progress,
generatedVideos, generatedVideos,
previewCount, onRetry,
previewTitles, onDismissError,
onPreviewTitlesChange, presetVoices,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
} = props } = props
/* 当前模板的 segments,传给 Step2 构建 clips */ /* 当前模板的 segments,传给 Step2 构建 clips */
@@ -144,14 +148,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
) )
case 3: case 3:
return ( return (
<Step3VoiceWithMode <Step3VoiceSelect
previewCount={previewCount}
selectedVoice={selectedVoice} selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange} onSelectedVoiceChange={onSelectedVoiceChange}
voiceModePerVideo={voiceModePerVideo} totalVideoDuration={totalVideoDuration}
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
/> />
) )
case 4: case 4:
@@ -170,12 +170,33 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onApplyPreset={onApplyPreset} onApplyPreset={onApplyPreset}
activePreset={activePreset} activePreset={activePreset}
titlePresets={titlePresets} titlePresets={titlePresets}
previewCount={previewCount}
previewTitles={previewTitles}
onPreviewTitlesChange={onPreviewTitlesChange}
/> />
) )
case 5: case 5:
return (
<Step5ConfirmGenerate
templates={userTemplates}
selectedTemplate={selectedTemplate}
materialMode={materialMode}
selectedMaterials={selectedMaterials}
smartSelectedIds={smartSelectedIds}
title={titleSettings.title}
voiceMode={voiceMode}
selectedVoice={selectedVoice}
selectedClonedVoice={selectedClonedVoice}
presetVoices={presetVoices}
clonedVoices={clonedVoices}
coverSettings={coverSettings}
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
onRetry={onRetry}
onDismissError={onDismissError}
/>
)
case 6:
return ( return (
<Step6CoverSettings <Step6CoverSettings
coverSettings={coverSettings} coverSettings={coverSettings}
@@ -183,11 +204,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedTemplate={selectedTemplate} selectedTemplate={selectedTemplate}
titleSettings={titleSettings} titleSettings={titleSettings}
generatedVideos={generatedVideos} generatedVideos={generatedVideos}
previewCount={previewCount}
previewTitles={previewTitles}
previewCovers={previewCovers}
onPreviewCoversChange={onPreviewCoversChange}
selectedVariantIndexes={selectedVariantIds}
/> />
) )
default: default:
@@ -1,127 +0,0 @@
/**
* 生成数量选择弹窗(Issue #1677)
* Step1 选完模板点「下一步」时弹出:要生成几个视频?(1~10)
* 默认 1,回车 = 1(零额外操作)
*/
import React, { useState, useEffect, useRef } from "react"
import { MAX_PREVIEW_COUNT } from "../constants"
interface PreviewCountModalProps {
open: boolean
/** 默认值(上次选择,默认1) */
defaultCount?: number
onConfirm: (count: number) => void
onCancel: () => void
}
const PreviewCountModal: React.FC<PreviewCountModalProps> = ({
open,
defaultCount = 1,
onConfirm,
onCancel,
}) => {
const [count, setCount] = useState(defaultCount)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setCount(defaultCount)
// 弹窗打开后聚焦并选中,方便直接回车=默认1
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, defaultCount])
const clamp = (n: number) => Math.max(1, Math.min(MAX_PREVIEW_COUNT, n || 1))
const handleConfirm = () => {
onConfirm(clamp(count))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
handleConfirm()
}
if (e.key === "Escape") {
onCancel()
}
}
if (!open) return null
return (
<div className="xx-modal-mask" onClick={onCancel}>
<div className="xx-modal-box xx-count-modal" onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: "0 0 8px", fontSize: 18 }}>要生成几个视频?</h3>
<p style={{ margin: "0 0 20px", fontSize: 13, color: "var(--text-secondary, #666)" }}>
素材共用,AI 随机剪辑出不同版本,每个视频可独立设置标题、配音和封面
</p>
<div className="xx-count-selector">
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c - 1))}
disabled={count <= 1}
aria-label="减少"
>
−
</button>
<input
ref={inputRef}
type="number"
min={1}
max={MAX_PREVIEW_COUNT}
value={count}
onChange={(e) => setCount(clamp(parseInt(e.target.value, 10) || 1))}
onKeyDown={handleKeyDown}
className="xx-count-input"
/>
<button
type="button"
className="xx-count-btn"
onClick={() => setCount((c) => clamp(c + 1))}
disabled={count >= MAX_PREVIEW_COUNT}
aria-label="增加"
>
+
</button>
</div>
<div className="xx-count-quick">
{[1, 3, 5, 10].map((n) => (
<button
key={n}
type="button"
className={`xx-count-chip ${count === n ? "active" : ""}`}
onClick={() => setCount(n)}
>
{n} 个
</button>
))}
</div>
<div className="xx-count-actions">
<button type="button" className="xx-btn xx-btn-ghost" onClick={onCancel}>
取消
</button>
<button type="button" className="xx-btn xx-btn-primary" onClick={handleConfirm}>
{count === 1 ? "生成 1 个视频" : `生成 ${count} 个视频`}
</button>
</div>
<p
style={{
margin: "12px 0 0",
fontSize: 12,
color: "var(--text-tertiary, #999)",
textAlign: "center",
}}
>
直接按回车 = 生成 1 个
</p>
</div>
</div>
)
}
export default PreviewCountModal
@@ -1,127 +0,0 @@
/**
* 批量预览网格(Issue #1677)
* N 个服务器渲染的预览视频,网格排列、各自独立播放、CSS 标题浮层实时叠加、勾选框批量选择
*/
import React from "react"
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
import type { VariantPreview } from "../hooks/useBatchPreview"
interface ServerPreviewGridProps {
variants: VariantPreview[]
/** 每个变体的标题文字(实时叠加浮层) */
titles: string[]
/** 标题样式(全局共用) */
titleStyle: {
position: string
color: string
size: number
}
/** 勾选的变体索引 */
selectedIds: number[]
onToggleSelect: (index: number) => void
/** 是否显示勾选框(确认生成前) */
selectable?: boolean
}
const ServerPreviewGrid: React.FC<ServerPreviewGridProps> = ({
variants,
titles,
titleStyle,
selectedIds,
onToggleSelect,
selectable = true,
}) => {
if (variants.length === 0) return null
return (
<div className="xx-variant-grid">
{variants.map((v) => {
const selected = selectedIds.includes(v.index)
const titleText = titles[v.index] || ""
return (
<div
key={v.index}
className={`xx-variant-card ${selected ? "selected" : ""} ${
v.status === "failed" ? "failed" : ""
}`}
onClick={() => {
if (selectable && v.status === "ready") onToggleSelect(v.index)
}}
role="button"
tabIndex={0}
>
{/* 勾选框 */}
{selectable && v.status === "ready" && (
<div className={`xx-variant-check ${selected ? "checked" : ""}`}>
{selected && "✓"}
</div>
)}
{/* 变体序号 */}
<div className="xx-variant-index">视频 {v.index + 1}</div>
{/* 视频区域 */}
<div className="xx-variant-video-wrap">
{v.status === "loading" && (
<div className="xx-variant-loading">
<LoadingOutlined style={{ fontSize: 28, color: "#3b82f6" }} />
<div className="xx-variant-progress">
<div
className="xx-variant-progress-bar"
style={{ width: `${Math.min(v.progress, 100)}%` }}
/>
</div>
<span className="xx-variant-progress-text">{v.progress}%</span>
</div>
)}
{v.status === "failed" && (
<div className="xx-variant-failed">
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
<span>{v.error || "预览失败"}</span>
</div>
)}
{v.status === "ready" && v.videoUrl && (
<>
<video
src={v.videoUrl}
controls
style={{ width: "100%", display: "block", background: "#000", borderRadius: 8 }}
onClick={(e) => e.stopPropagation()}
/>
{/* 标题浮层(CSS 实时叠加,改标题即时可见) */}
{titleText && (
<div
className={`xx-variant-title-overlay pos-${titleStyle.position}`}
style={{
color: titleStyle.color,
fontSize: Math.max(13, Math.round(titleStyle.size * 0.55)),
WebkitTextStroke: "0.5px rgba(0,0,0,0.6)",
}}
onClick={(e) => e.stopPropagation()}
>
{titleText}
</div>
)}
</>
)}
</div>
{/* 底部状态 */}
<div className="xx-variant-footer">
{v.status === "ready" && selected && (
<span className="xx-variant-ready-tag">
<CheckCircleFilled style={{ color: "#52c41a" }} /> 已选择
</span>
)}
{v.status === "ready" && !selected && selectable && (
<span className="xx-variant-skip-tag">点击卡片取消/勾选</span>
)}
</div>
</div>
)
})}
</div>
)
}
export default ServerPreviewGrid
@@ -47,7 +47,9 @@ const Step1TemplateSelect: React.FC<Step1TemplateSelectProps> = (props) => {
🎬 🎬
</div> </div>
<h4>{tpl.name}</h4> <h4>{tpl.name}</h4>
<p>{tpl.segments.length}片段</p> <p>
{tpl.estimated_duration}s · {tpl.segments.length}片段
</p>
{tpl.tags.length > 0 && ( {tpl.tags.length > 0 && (
<div <div
style={{ style={{
@@ -1,102 +0,0 @@
/**
* Step3 配音选择(Issue #1677 批量生成)
* - 单视频 / 共用模式:与原配音选择完全一致
* - 独立模式(开关开启):N 个配音选择器,每个视频独立选择
*/
import React from "react"
import Step3VoiceSelect from "./Step5VoiceSelect"
interface Step3VoiceWithModeProps {
previewCount: number
/** 共用配音ID */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
/** 是否独立配音 */
voiceModePerVideo: boolean
onVoiceModePerVideoChange: (v: boolean) => void
/** 各变体独立配音ID */
voiceLibraryIds: string[]
onVoiceLibraryIdsChange: (ids: string[]) => void
}
const Step3VoiceWithMode: React.FC<Step3VoiceWithModeProps> = ({
previewCount,
selectedVoice,
onSelectedVoiceChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
}) => {
const isBatch = previewCount > 1
if (!isBatch) {
return (
<Step3VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
/>
)
}
return (
<div className="xx-form-section">
{/* 共用/独立切换 */}
<div className="xx-title-ai-toggle" style={{ marginBottom: 16 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>
🎙️ 配音方式:{voiceModePerVideo ? "每个视频独立配音" : "所有视频共用配音"}
</div>
<div style={{ fontSize: 12, color: "var(--text-tertiary, #999)", marginTop: 2 }}>
{voiceModePerVideo
? `为 ${previewCount} 个视频分别选择不同配音`
: "所有视频使用同一个配音(默认)"}
</div>
</div>
<div
className={`xx-switch ${voiceModePerVideo ? "active" : ""}`}
onClick={() => onVoiceModePerVideoChange(!voiceModePerVideo)}
role="switch"
aria-checked={voiceModePerVideo}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onVoiceModePerVideoChange(!voiceModePerVideo)
}
}}
>
<div className="xx-switch-knob" />
</div>
</div>
{!voiceModePerVideo ? (
<Step3VoiceSelect
heading="🎙️ 共用配音"
description={`所有 ${previewCount} 个视频使用同一个配音,点击卡片可预览播放`}
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
/>
) : (
<div className="xx-per-voice-list">
{Array.from({ length: previewCount }, (_, i) => (
<Step3VoiceSelect
key={i}
heading={`🎙️ 视频 ${i + 1} 的配音`}
description="为这个视频单独选择配音"
compact
selectedVoice={voiceLibraryIds[i] || ""}
onSelectedVoiceChange={(id) => {
const next = [...voiceLibraryIds]
next[i] = id
onVoiceLibraryIdsChange(next)
}}
/>
))}
</div>
)}
</div>
)
}
export default Step3VoiceWithMode
@@ -1,13 +1,12 @@
/** /**
* Step 4 选择标题(Issue #1677 批量生成改造) * Step 4 选择标题(合并原 Step4 标题输入 + Step5 标题样式面板)
* *
* 布局(由 GeneratePage 编排):左侧大区域预览,右侧边栏标题设置。 * 左侧:标题文字输入 + AI生成标题 + 样式设置(位置/字号/字体/颜色/样式/预设)
* 本组件渲染在右侧边栏: * 右侧:FrontendPreviewPlayer 实时预览(由 GeneratePage 统一渲染)
* - 标题文字:1 个视频 1 个输入框;N 个视频 N 个输入框各自独立
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
*/ */
import React from "react" import React from "react"
import { AutoComplete, Input } from "antd" import { AutoComplete } from "antd"
import { PlayCircleOutlined } from "@ant-design/icons"
import type { TitleSettings } from "../types" import type { TitleSettings } from "../types"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants" import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import { useStep4Title } from "../hooks/useStep4Title" import { useStep4Title } from "../hooks/useStep4Title"
@@ -30,12 +29,6 @@ interface Step4TitleSettingsProps {
onApplyPreset: (presetKey: string) => void onApplyPreset: (presetKey: string) => void
activePreset: string | null activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* ── 批量生成(#1677)── */
/** 生成数量 */
previewCount?: number
/** 每个变体的标题文字(长度=previewCount) */
previewTitles?: string[]
onPreviewTitlesChange?: (titles: string[]) => void
} }
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => { const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -51,138 +44,122 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
onApplyPreset, onApplyPreset,
activePreset, activePreset,
titlePresets, titlePresets,
previewCount = 1,
previewTitles,
onPreviewTitlesChange,
} = props } = props
const isBatch = previewCount > 1
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿保存依赖) */
const updateVariantTitle = (index: number, val: string) => {
if (!previewTitles || !onPreviewTitlesChange) return
const next = [...previewTitles]
next[index] = val
onPreviewTitlesChange(next)
if (index === 0) {
t.updateTitle(val)
}
}
return ( return (
<div className="xx-form-section xx-title-sidebar"> <div className="xx-form-section">
<h3>📝 选择标题</h3> <h3>📝 选择标题</h3>
{!isBatch ? ( {/* AI 自动选择模式 */}
/* ── 单视频:原有 AI 标题 + 输入框(保持不变) ── */ {t.titleSettings.aiAutoSelect && (
<> <>
{t.titleSettings.aiAutoSelect ? ( <div className="xx-title-ai-toggle">
<> <span className="xx-toggle-label">AI 自动选择标题</span>
<div className="xx-title-ai-toggle"> <div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<span className="xx-toggle-label">AI 自动选择标题</span> <div className="xx-switch-knob" />
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label>当前 AI 选定标题</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
</span>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄 换一个
</button>
</div>
</div>
</>
) : (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label>标题</label>
<AutoComplete
placeholder="输入标题文字…"
allowClear
maxLength={50}
style={{ width: "100%" }}
value={(previewTitles?.[0] ?? t.titleSettings.title) || undefined}
onChange={(val) => {
t.updateTitle(val || "")
onPreviewTitlesChange?.([val || ""])
}}
options={t.userTitles.map((ut) => ({ label: ut.content, value: ut.content }))}
filterOption={(inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}}
/>
</div>
</>
)}
</>
) : (
/* ── 批量:N 个独立标题输入框(CSS 浮层实时叠加到对应预览) ── */
<div className="xx-batch-titles">
<div
style={{
fontSize: 12,
color: "var(--text-secondary, #666)",
marginBottom: 10,
lineHeight: 1.6,
}}
>
为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。
</div>
{Array.from({ length: previewCount }, (_, i) => (
<div className="xx-form-field" key={i}>
<label>视频 {i + 1} 标题</label>
<Input
placeholder={`视频 ${i + 1} 的标题…`}
maxLength={50}
showCount
value={previewTitles?.[i] || ""}
onChange={(e) => updateVariantTitle(i, e.target.value)}
/>
</div> </div>
))} </div>
</div>
{/* 显示当前 AI 选中的标题(只读)+ 换一个按钮 */}
<div className="xx-form-field">
<label>当前 AI 选定标题</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
borderRadius: 8,
fontSize: 14,
color: "var(--text-primary, #333)",
}}
>
<span style={{ flex: 1 }}>{t.titleSettings.title || "AI 将自动为你选择标题"}</span>
<button
type="button"
className="xx-btn xx-btn-primary"
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
onClick={t.autoGenerateTitle}
>
🔄 换一个
</button>
</div>
</div>
</>
)} )}
{/* 标题样式面板(全局共用) */} {/* 手动选择模式 */}
{!t.titleSettings.aiAutoSelect && (
<>
<AiTitleGenerator
inputValue={t.aiTitleInput}
onInputChange={t.setAiTitleInput}
generating={t.aiTitleGenerating}
onGenerate={t.handleGenerateAiTitles}
results={t.aiTitleResults}
hasGenerated={t.hasGeneratedTitles}
onSelect={t.handleSelectAiTitle}
selectedTitle={t.titleSettings.title}
onRefresh={t.handleRefreshAiTitles}
/>
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI 自动选择标题</span>
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
<div className="xx-switch-knob" />
</div>
</div>
<div className="xx-form-field">
<label>标题</label>
<AutoComplete
placeholder="输入或从标题库选择…"
allowClear
maxLength={50}
style={{ width: "100%" }}
value={t.titleSettings.title || undefined}
onChange={(val) => t.updateTitle(val || "")}
options={t.userTitles.map((ut) => ({
label: ut.content,
value: ut.content,
}))}
filterOption={(inputValue, option) => {
const title = (option?.label || option?.value || "") as string
return title.toLowerCase().includes((inputValue || "").toLowerCase())
}}
notFoundContent={
t.userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
标题库为空,请前往「标题管理」添加
</span>
) : null
}
/>
</div>
</>
)}
{/* 标题样式面板(原 Step5) */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "rgba(59, 130, 246, 0.08)",
borderRadius: 8,
marginTop: 16,
marginBottom: 12,
border: "1px solid rgba(59, 130, 246, 0.15)",
}}
>
<PlayCircleOutlined style={{ fontSize: 16, color: "#3b82f6" }} />
<span style={{ fontSize: 12, color: "var(--text-secondary, #666)" }}>
右侧为实时预览,调整样式即时生效
</span>
</div>
<TitleStylePanel <TitleStylePanel
settings={t.titleSettings} settings={t.titleSettings}
onUpdatePosition={onUpdatePosition} onUpdatePosition={onUpdatePosition}
@@ -5,21 +5,18 @@
import React, { useState, useRef, useCallback } from "react" import React, { useState, useRef, useCallback } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { AudioOutlined, SoundOutlined } from "@ant-design/icons" import { AudioOutlined, SoundOutlined, WarningOutlined } from "@ant-design/icons"
import { Modal } from "antd"
import { getAssetsByKind } from "@/api/assets" import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets" import type { AssetItem } from "@/api/assets"
interface Step5VoiceSelectProps { interface Step5VoiceSelectProps {
selectedVoice: string selectedVoice: string
onSelectedVoiceChange: (id: string) => void onSelectedVoiceChange: (id: string) => void
/** 卡片标题(独立配音模式下显示"视频 N 的配音"),默认"选择配音" */ totalVideoDuration?: number
heading?: string
/** 描述文案 */
description?: string
/** 是否使用紧凑卡片样式(独立配音模式下 N 个并排) */
compact?: boolean
} }
/** 格式化时长 mm:ss */
/** 获取素材实际时长(优先顶层 duration,fallback 到 metadata.duration) */ /** 获取素材实际时长(优先顶层 duration,fallback 到 metadata.duration) */
const getDuration = (item: AssetItem): number => { const getDuration = (item: AssetItem): number => {
return item.duration ?? (item.metadata?.duration as number) ?? 0 return item.duration ?? (item.metadata?.duration as number) ?? 0
@@ -37,6 +34,13 @@ const isAiVoice = (item: AssetItem): boolean => {
return (!duration || duration <= 0) && (!size || size <= 0) return (!duration || duration <= 0) && (!size || size <= 0)
} }
const formatDuration = (seconds?: number): string => {
if (!seconds || seconds <= 0) return "00:00"
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
/** 格式化文件大小 */ /** 格式化文件大小 */
const formatFileSize = (bytes?: number): string => { const formatFileSize = (bytes?: number): string => {
if (!bytes || bytes <= 0) return "未知" if (!bytes || bytes <= 0) return "未知"
@@ -49,13 +53,13 @@ const formatFileSize = (bytes?: number): string => {
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
selectedVoice, selectedVoice,
onSelectedVoiceChange, onSelectedVoiceChange,
heading = "🎙️ 选择配音", totalVideoDuration = 0,
description = "从配音库中选择已上传的素材,点击卡片可预览播放",
compact = false,
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const [playingId, setPlayingId] = useState<string | null>(null) const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const [durationWarningOpen, setDurationWarningOpen] = useState(false)
const [pendingVoiceId, setPendingVoiceId] = useState<string | null>(null)
// 获取用户上传的配音素材 // 获取用户上传的配音素材
const { data: materials = [], isLoading } = useQuery({ const { data: materials = [], isLoading } = useQuery({
@@ -96,17 +100,41 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
[playingId], [playingId],
) )
/** 选中素材(直接选中,不再做时长校验弹窗) */ /** 选中素材(含时长校验) */
const handleSelect = useCallback( const handleSelect = useCallback(
(id: string) => { (id: string) => {
// 如果启用了时长校验,且配音时长不足(AI 音色按脚本实时合成,不参与时长校验)
if (totalVideoDuration > 0) {
const material = materials.find((m) => m.id === id)
if (material && !isAiVoice(material) && getDuration(material) < totalVideoDuration) {
setPendingVoiceId(id)
setDurationWarningOpen(true)
return
}
}
onSelectedVoiceChange(id) onSelectedVoiceChange(id)
}, },
[onSelectedVoiceChange], [onSelectedVoiceChange, totalVideoDuration, materials],
) )
/** 确认使用时长不足的配音 */
const handleConfirmUseAnyway = useCallback(() => {
if (pendingVoiceId) {
onSelectedVoiceChange(pendingVoiceId)
}
setDurationWarningOpen(false)
setPendingVoiceId(null)
}, [pendingVoiceId, onSelectedVoiceChange])
/** 取消选择 */
const handleCancelSelection = useCallback(() => {
setDurationWarningOpen(false)
setPendingVoiceId(null)
}, [])
/** 跳转到配音库上传 */ /** 跳转到配音库上传 */
const handleGoToUpload = useCallback(() => { const handleGoToUpload = useCallback(() => {
navigate("/app/voices?tab=material&upload=1") navigate("/app/voices")
}, [navigate]) }, [navigate])
// 加载中状态 // 加载中状态
@@ -157,14 +185,14 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
return ( return (
<div className="xx-form-section"> <div className="xx-form-section">
<h3>{heading}</h3> <h3>🎙️ 选择配音</h3>
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>{description}</p> <p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
从配音库中选择已上传的素材,点击卡片可预览播放
</p>
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: compact gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
? "repeat(auto-fill, minmax(160px, 1fr))"
: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 12, gap: 12,
}} }}
> >
@@ -249,7 +277,7 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
{item.name} {item.name}
</div> </div>
{/* 文件大小 */} {/* 时长 + 大小 */}
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -261,13 +289,65 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
> >
{isAiVoice(item) ? ( {isAiVoice(item) ? (
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI 音色</span> <span style={{ color: "#1677ff", fontWeight: 500 }}>AI 音色</span>
) : null} ) : (
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
{formatDuration(getDuration(item))}
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
<span
style={{
color: "#ff4d4f",
fontSize: 11,
fontWeight: 500,
display: "inline-flex",
alignItems: "center",
gap: 2,
}}
>
<WarningOutlined />
时长不足
</span>
)}
</span>
)}
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span> <span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
</div> </div>
</div> </div>
) )
})} })}
</div> </div>
{/* 时长不足警告弹窗 */}
<Modal
title={
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<WarningOutlined style={{ color: "#faad14" }} />
配音时长不足
</span>
}
open={durationWarningOpen}
onOk={handleConfirmUseAnyway}
onCancel={handleCancelSelection}
okText="仍要使用"
cancelText="重新选择"
okButtonProps={{ danger: true }}
>
{(() => {
const pendingMaterial = pendingVoiceId
? materials.find((m) => m.id === pendingVoiceId)
: null
return (
<p>
该配音时长(
<strong>
{pendingMaterial ? formatDuration(getDuration(pendingMaterial)) : "--"}
</strong>
)短于视频总时长(
<strong>{formatDuration(totalVideoDuration)}</strong>
),播放时配音可能提前结束,建议选择更长的配音素材。
</p>
)
})()}
</Modal>
</div> </div>
) )
} }
@@ -1,16 +1,9 @@
/** import React from "react"
* Step 5 选择封面(Issue #1677 批量生成改造)
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
*/
import React, { useRef } from "react"
import { Modal, Spin } from "antd" import { Modal, Spin } from "antd"
import { LoadingOutlined } from "@ant-design/icons"
import type { CoverConfig } from "../types/cover" import type { CoverConfig } from "../types/cover"
import type { GeneratedVideo } from "@/api/template-editor" import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types" import type { TitleSettings } from "../types"
import { useStep6Cover } from "../hooks/useStep6Cover" import { useStep6Cover } from "../hooks/useStep6Cover"
import { useBatchCovers } from "../hooks/useBatchCovers"
import Button from "@/components/ui/Button" import Button from "@/components/ui/Button"
import CoverSettingsModal from "./cover-settings/CoverSettingsModal" import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal" import CoverEditorModal from "./cover-settings/CoverEditorModal"
@@ -24,15 +17,6 @@ interface Step6CoverSettingsProps {
titleSettings?: TitleSettings titleSettings?: TitleSettings
/** 确认生成步骤产出的最终视频列表 */ /** 确认生成步骤产出的最终视频列表 */
generatedVideos: GeneratedVideo[] generatedVideos: GeneratedVideo[]
/* ── 批量生成(#1677)── */
previewCount?: number
/** 每个变体的标题文字 */
previewTitles?: string[]
/** 每个变体的封面URL(按变体索引) */
previewCovers?: string[]
onPreviewCoversChange?: (urls: string[]) => void
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
selectedVariantIndexes?: number[]
} }
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => { const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
@@ -62,173 +46,13 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
generatedVideos: props.generatedVideos, generatedVideos: props.generatedVideos,
}) })
const previewCount = props.previewCount || 1 const handleAutoGenerate = () => {
const isBatch = previewCount > 1 generateAutoCover()
const previewTitles = props.previewTitles || [] }
const previewCovers = props.previewCovers || []
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
const cardIndexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: previewCount }, (_, i) => i)
const uploadInputRef = useRef<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
const batchCovers = useBatchCovers({
selectedTemplate: props.selectedTemplate || "",
generatedVideos: props.generatedVideos,
titles: batchTitles,
titleStyle: {
font: props.titleSettings?.font || "思源黑体",
size: props.titleSettings?.size || 28,
color: props.titleSettings?.color || "#ffffff",
position: props.titleSettings?.position || "top",
bold: props.titleSettings?.bold ?? true,
stroke: props.titleSettings?.stroke ?? true,
shadow: props.titleSettings?.shadow ?? false,
},
covers: batchCoversList,
onCoversChange: (urls) => {
// 按卡片顺序写回对应变体索引
const next = [...(props.previewCovers || [])]
cardIndexes.forEach((vi, cardPos) => {
next[vi] = urls[cardPos] || ""
})
props.onPreviewCoversChange?.(next)
},
})
// 预览图:优先 thumbnail_url,其次 upload_url
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
const handleUploadClick = (variantIndex: number) => {
uploadTargetRef.current = variantIndex
uploadInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) {
const variantIndex = uploadTargetRef.current
const cardPos = cardIndexes.indexOf(variantIndex)
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
}
}
/* ── 批量封面 ── */
if (isBatch) {
return (
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
<div
style={{
padding: "10px 14px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(16, 185, 129, 0.15)",
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<Button
buttonType="primary"
onClick={() => void batchCovers.generateAll()}
disabled={completedVideos.length === 0 || batchCovers.loadingIndex !== null}
>
✨ 一键全部自动生成
</Button>
</div>
<div className="xx-cover-grid">
{cardIndexes.map((variantIndex, cardPos) => {
const url = batchCoversList[cardPos]
const isLoading = batchCovers.loadingIndex === cardPos
const isUploading = batchCovers.uploadingIndex === cardPos
const title = batchTitles[cardPos]
return (
<div className="xx-cover-card" key={variantIndex}>
<div className="xx-cover-card-title">视频 {variantIndex + 1}</div>
<div className="xx-cover-card-box">
{isLoading || isUploading ? (
<div className="xx-cover-card-loading">
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<span>{isLoading ? "AI 选帧中…" : "上传中…"}</span>
</div>
) : url ? (
<img
src={url}
alt={`视频${variantIndex + 1}封面`}
className="xx-cover-card-img"
/>
) : (
<div className="xx-cover-card-placeholder">
<span style={{ fontSize: 26 }}>🖼️</span>
<span style={{ fontSize: 12 }}>未设置封面</span>
</div>
)}
<div className="xx-cover-card-ratio">9:16</div>
</div>
{title && (
<div
style={{
fontSize: 12,
color: "var(--text-secondary, #666)",
marginTop: 6,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={title}
>
标题:{title}
</div>
)}
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
<button
type="button"
className="xx-btn xx-btn-primary xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => void batchCovers.generateOne(cardPos)}
disabled={isLoading || isUploading}
>
{url ? "🔄 重新生成" : "✨ 自动生成"}
</button>
<button
type="button"
className="xx-btn xx-btn-ghost xx-btn-sm"
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
onClick={() => handleUploadClick(variantIndex)}
disabled={isLoading || isUploading}
>
📤 上传
</button>
</div>
</div>
)
})}
</div>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
)
}
/* ── 单视频:原有流程保持不变 ── */
return ( return (
<div className="xx-form-section"> <div className="xx-form-section">
<h3>🖼️ 选择封面</h3> <h3>🖼️ 选择封面</h3>
@@ -251,7 +75,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
)} )}
<div className="xx-cover-actions"> <div className="xx-cover-actions">
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}> <Button buttonType="primary" onClick={handleAutoGenerate} disabled={!finalVideo}>
✨ 自动生成封面 ✨ 自动生成封面
</Button> </Button>
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}> <Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
@@ -0,0 +1,79 @@
/**
* Step 7 确认生成组件
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../types/cover"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { useStep7Generate } from "../hooks/useStep7Generate"
import SummaryCard from "./step7-confirm/SummaryCard"
import GenerationStatus from "./step7-confirm/GenerationStatus"
interface Step7ConfirmGenerateProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
title: string
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
onRetry: () => void
onDismissError: () => void
}
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
const {
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
handleScrollToPreview,
} = useStep7Generate(props)
const { onRetry, onDismissError } = props
return (
<div className="xx-form-section">
<h3>✨ 确认生成</h3>
<SummaryCard
templateName={templateName}
materialSummary={materialSummary}
title={title}
voiceName={voiceName}
coverSummary={coverSummary}
/>
<GenerationStatus
generating={generating}
generated={generated}
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
getGenerationPhase={getGenerationPhase}
onScrollToPreview={handleScrollToPreview}
onRetry={onRetry}
onDismissError={onDismissError}
/>
</div>
)
}
export default Step7ConfirmGenerate
@@ -23,7 +23,7 @@ const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange
onClick={() => onModeChange("auto")} onClick={() => onModeChange("auto")}
type="button" type="button"
> >
AI智能匹配 选择视频库自动匹配
</button> </button>
</div> </div>
) )
@@ -0,0 +1,44 @@
import React from "react"
interface SummaryCardProps {
templateName: string
materialSummary: string
title: string
voiceName: string
coverSummary: string
}
const SummaryCard: React.FC<SummaryCardProps> = ({
templateName,
materialSummary,
title,
voiceName,
coverSummary,
}) => {
return (
<div className="xx-summary-card">
<div className="xx-summary-row">
<span className="xx-summary-label">模板</span>
<span className="xx-summary-value">{templateName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">素材</span>
<span className="xx-summary-value">{materialSummary}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">标题</span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">配音</span>
<span className="xx-summary-value">{voiceName}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label">封面</span>
<span className="xx-summary-value">{coverSummary}</span>
</div>
</div>
)
}
export default SummaryCard
@@ -1,7 +1,5 @@
/** /**
* 标题预设样式网格 * 标题预设样式网格
* 双图层渲染:底层=描边轮廓(text-shadow模拟),上层=填充色
* 避免 -webkit-text-stroke 在 Chromium 中吞掉填充色的问题
*/ */
import React from "react" import React from "react"
import { getFontFamily } from "../../constants" import { getFontFamily } from "../../constants"
@@ -9,10 +7,7 @@ import { getFontFamily } from "../../constants"
interface TitlePresetItem { interface TitlePresetItem {
key: string key: string
label: string label: string
previewStyle: React.CSSProperties & { previewStyle: React.CSSProperties
_strokeColor?: string
_strokeWidth?: number
}
} }
interface TitlePresetsGridProps { interface TitlePresetsGridProps {
@@ -22,32 +17,6 @@ interface TitlePresetsGridProps {
fontFamily?: string fontFamily?: string
} }
/**
* 用 text-shadow 模拟描边轮廓(8方向 + 4对角 = 12层阴影)
*/
function buildStrokeShadow(color: string, width: number): string {
const w = width
const parts: string[] = []
// 4 cardinal directions
parts.push(`${w}px 0 ${color}`)
parts.push(`${-w}px 0 ${color}`)
parts.push(`0 ${w}px ${color}`)
parts.push(`0 ${-w}px ${color}`)
// 4 diagonal directions
const d = Math.round(w * 0.71 * 10) / 10 // 0.71 ≈ sqrt(2)/2
parts.push(`${d}px ${d}px ${color}`)
parts.push(`${-d}px ${d}px ${color}`)
parts.push(`${d}px ${-d}px ${color}`)
parts.push(`${-d}px ${-d}px ${color}`)
// 4 extra mid-points for smoother stroke
const h = Math.round(w * 0.5 * 10) / 10
parts.push(`${w}px ${h}px ${color}`)
parts.push(`${w}px ${-h}px ${color}`)
parts.push(`${-w}px ${h}px ${color}`)
parts.push(`${-w}px ${-h}px ${color}`)
return parts.join(", ")
}
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
presets, presets,
activePreset, activePreset,
@@ -58,53 +27,20 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
<div className="xx-title-presets-grid"> <div className="xx-title-presets-grid">
{presets.map((p) => { {presets.map((p) => {
const isActive = activePreset === p.key const isActive = activePreset === p.key
const { _strokeColor, _strokeWidth, ...fillStyle } = p.previewStyle
const ff = getFontFamily(fontFamily || "思源黑体")
// 底层:描边轮廓(用 text-shadow 模拟粗描边)
const strokeStyle: React.CSSProperties = {
color: _strokeColor || "transparent",
textShadow:
_strokeColor && _strokeWidth
? buildStrokeShadow(_strokeColor, _strokeWidth)
: undefined,
fontWeight: fillStyle.fontWeight,
fontSize: fillStyle.fontSize,
lineHeight: 1,
}
// 上层:仅填充色 + 可选 textShadow(发光/投影效果)
const topStyle: React.CSSProperties = {
color: fillStyle.color,
textShadow: fillStyle.textShadow,
fontWeight: fillStyle.fontWeight,
fontSize: fillStyle.fontSize,
lineHeight: 1,
}
return ( return (
<button <button
key={p.key} key={p.key}
className={`xx-title-preset-card${isActive ? " active" : ""}`} className={`xx-title-preset-card${isActive ? " active" : ""}`}
onClick={() => onApply(p.key)} onClick={() => onApply(p.key)}
title={p.label}
> >
<span className="xx-title-preset-preview-text" style={{ position: "relative" }}> <span
{/* 底层:描边轮廓 */} className="xx-title-preset-preview-text"
<span style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
aria-hidden >
style={{ 标题
...strokeStyle,
fontFamily: ff,
position: "absolute",
top: 0,
left: 0,
}}
>
T
</span>
{/* 上层:填充色 */}
<span style={{ ...topStyle, fontFamily: ff, position: "relative" }}>T</span>
</span> </span>
<span className="xx-title-preset-card-label">{p.label}</span>
</button> </button>
) )
})} })}
+18 -224
View File
@@ -33,19 +33,15 @@ export const STEPS = [
{ key: 2, label: "选择素材" }, { key: 2, label: "选择素材" },
{ key: 3, label: "选择配音" }, { key: 3, label: "选择配音" },
{ key: 4, label: "选择标题" }, { key: 4, label: "选择标题" },
{ key: 5, label: "选择封面" }, { key: 5, label: "确认生成" },
{ key: 6, label: "选择封面" },
] ]
/* ── 批量生成限制 ── */
export const MAX_PREVIEW_COUNT = 10
export const MIN_PREVIEW_COUNT = 1
/* ── 标题位置选项 ── */ /* ── 标题位置选项 ── */
export const POSITION_OPTIONS = [ export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" }, { value: "top", label: "顶部" },
{ value: "center", label: "居中" }, { value: "center", label: "居中" },
{ value: "bottom", label: "底部" }, { value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
] ]
/* ── 标题字体选项 ── */ /* ── 标题字体选项 ── */
@@ -81,11 +77,10 @@ export const TITLE_PRESETS = [
label: "经典白字", label: "经典白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false }, style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: { previewStyle: {
color: "#ffffff",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700, fontWeight: 700,
fontSize: "32px", color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
}, },
}, },
{ {
@@ -93,54 +88,45 @@ export const TITLE_PRESETS = [
label: "黑金质感", label: "黑金质感",
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true }, style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: { previewStyle: {
color: "#d4a843",
fontWeight: 700, fontWeight: 700,
fontSize: "32px", color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)", textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
}, },
}, },
{ {
key: "fresh_minimal", key: "fresh_minimal",
label: "清新简约", label: "清新简约",
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false }, style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
color: "#e8e8e8",
fontWeight: 400,
fontSize: "32px",
},
}, },
{ {
key: "variety_show", key: "variety_show",
label: "综艺花字", label: "综艺花字",
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true }, style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
previewStyle: { previewStyle: {
color: "#ff4081",
_strokeColor: "#ffffff",
_strokeWidth: 2,
fontWeight: 900, fontWeight: 900,
fontSize: "32px", color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)", textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
}, },
}, },
{ {
key: "business", key: "business",
label: "商务极简", label: "商务极简",
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false }, style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
color: "#e0e0e0",
fontWeight: 400,
fontSize: "32px",
},
}, },
{ {
key: "retro_film", key: "retro_film",
label: "复古胶片", label: "复古胶片",
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true }, style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: { previewStyle: {
color: "#e8d5b7",
fontWeight: 400, fontWeight: 400,
fontSize: "32px", color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)", textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
}, },
}, },
{ {
@@ -148,10 +134,10 @@ export const TITLE_PRESETS = [
label: "霓虹发光", label: "霓虹发光",
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true }, style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: { previewStyle: {
color: "#00e5ff",
fontWeight: 700, fontWeight: 700,
fontSize: "32px", color: "#00e5ff",
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)", textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
}, },
}, },
{ {
@@ -159,202 +145,10 @@ export const TITLE_PRESETS = [
label: "手写字", label: "手写字",
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true }, style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: { previewStyle: {
color: "#e0e0e0",
fontWeight: 400, fontWeight: 400,
fontSize: "32px", color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)", textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
}, fontSize: "20px",
},
{
key: "outline_yellow",
label: "黄色描边",
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ffd54f",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_pink",
label: "粉色描边",
style: { size: 28, color: "#ff80ab", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ff80ab",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_blue",
label: "蓝色描边",
style: { size: 28, color: "#82b1ff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#82b1ff",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_green",
label: "绿色描边",
style: { size: 28, color: "#69f0ae", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#69f0ae",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_gray",
label: "灰色描边",
style: { size: 28, color: "#bdbdbd", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#bdbdbd",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "bg_white",
label: "白底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ffffff",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_yellow",
label: "黄底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ffd54f",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_pink",
label: "粉底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ff80ab",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_red",
label: "红底白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
background: "#ef5350",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "neon_orange",
label: "橙色发光",
style: { size: 32, color: "#ff9100", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ff9100",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 4px #ff9100, 0 0 8px #ff9100, 0 0 16px rgba(255,145,0,0.5)",
},
},
{
key: "neon_purple",
label: "紫色发光",
style: { size: 32, color: "#d500f9", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#d500f9",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 4px #d500f9, 0 0 8px #d500f9, 0 0 16px rgba(213,0,249,0.5)",
},
},
{
key: "bordered_white",
label: "白字绿框",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
background: "#1a1a1a",
border: "2px solid #69f0ae",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "gradient_warm",
label: "暖色渐变",
style: { size: 32, color: "#ff6d00", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ff6d00",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(255,109,0,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
},
},
{
key: "gradient_cool",
label: "冷色渐变",
style: { size: 32, color: "#00b0ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#00b0ff",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(0,176,255,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
},
},
{
key: "shadow_deep",
label: "深影白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
textShadow: "2px 2px 4px rgba(0,0,0,0.8), 0 0 8px rgba(0,0,0,0.4)",
},
},
{
key: "soft_gold",
label: "柔光金",
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ffd54f",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(255,213,79,0.5), 1px 1px 2px rgba(0,0,0,0.4)",
}, },
}, },
] ]
+23 -391
View File
@@ -1733,40 +1733,50 @@
/* 标题预设卡片网格 */ /* 标题预设卡片网格 */
.xx-title-presets-grid { .xx-title-presets-grid {
display: grid; display: grid;
grid-template-columns: repeat(6, 52px); grid-template-columns: repeat(4, 1fr);
gap: 1px; gap: 10px;
} }
.xx-title-preset-card { .xx-title-preset-card {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 52px; padding: 14px 8px;
height: 52px; background: var(--bg-secondary);
padding: 0;
background: #404040;
border: 2px solid transparent; border: 2px solid transparent;
border-radius: 8px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
text-align: center;
} }
.xx-title-preset-card:hover { .xx-title-preset-card:hover {
border-color: #666; border-color: var(--primary-200);
background: #4d4d4d; background: var(--bg-primary);
} }
.xx-title-preset-card.active { .xx-title-preset-card.active {
border-color: #409eff; border-color: var(--primary-color);
background: #4d4d4d; background: var(--primary-50);
} }
.xx-title-preset-preview-text { .xx-title-preset-preview-text {
font-size: 32px; line-height: 1.4;
line-height: 1; margin-bottom: 6px;
user-select: none; user-select: none;
} }
.xx-title-preset-card-label {
font-size: 11px;
color: var(--text-secondary);
}
.xx-title-preset-card.active .xx-title-preset-card-label {
color: var(--primary-color);
font-weight: 500;
}
/* 样式按钮组 */ /* 样式按钮组 */
.xx-style-btns { .xx-style-btns {
display: flex; display: flex;
@@ -2900,381 +2910,3 @@
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
white-space: nowrap; white-space: nowrap;
} }
/* ================================================================
Issue #1677 多视频批量生成
================================================================ */
/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr 380px;
align-items: start;
}
.xx-generate-preview-col {
min-width: 0;
position: sticky;
top: 16px;
}
.xx-generate-preview-col .xx-form-section {
margin: 0;
}
.xx-title-sidebar {
max-height: calc(100vh - 140px);
overflow-y: auto;
}
/* ── 数量选择弹窗 ── */
.xx-modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.xx-modal-box {
background: var(--bg-primary, #fff);
border-radius: 16px;
padding: 28px;
width: 420px;
max-width: calc(100vw - 32px);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18);
}
.xx-count-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 8px 0 16px;
}
.xx-count-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-primary, #d9d9d9);
background: var(--bg-secondary, #f5f5f5);
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--text-primary, #333);
transition: all 0.15s;
}
.xx-count-btn:hover:not(:disabled) {
border-color: #1677ff;
color: #1677ff;
}
.xx-count-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.xx-count-input {
width: 88px;
height: 52px;
text-align: center;
font-size: 26px;
font-weight: 700;
border: 2px solid var(--border-primary, #d9d9d9);
border-radius: 12px;
color: var(--text-primary, #333);
background: var(--bg-primary, #fff);
}
.xx-count-input:focus {
outline: none;
border-color: #1677ff;
}
/* 隐藏 number input 上下箭头 */
.xx-count-input::-webkit-outer-spin-button,
.xx-count-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.xx-count-input {
-moz-appearance: textfield;
appearance: textfield;
}
.xx-count-quick {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 20px;
}
.xx-count-chip {
padding: 6px 16px;
border-radius: 999px;
border: 1px solid var(--border-primary, #d9d9d9);
background: var(--bg-primary, #fff);
font-size: 13px;
cursor: pointer;
color: var(--text-secondary, #666);
transition: all 0.15s;
}
.xx-count-chip:hover {
border-color: #1677ff;
color: #1677ff;
}
.xx-count-chip.active {
background: #1677ff;
border-color: #1677ff;
color: #fff;
}
.xx-count-actions {
display: flex;
gap: 12px;
}
.xx-count-actions .xx-btn {
flex: 1;
}
/* ── 批量预览网格 ── */
.xx-variant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.xx-variant-card {
position: relative;
border: 2px solid var(--border-primary, #e8e8e8);
border-radius: 12px;
padding: 10px;
background: var(--bg-primary, #fff);
cursor: pointer;
transition: all 0.18s;
}
.xx-variant-card:hover {
border-color: #91caff;
}
.xx-variant-card.selected {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
}
.xx-variant-card.failed {
border-color: #ffccc7;
cursor: default;
}
.xx-variant-check {
position: absolute;
top: 14px;
left: 14px;
z-index: 3;
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid #fff;
background: rgba(0, 0, 0, 0.35);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.xx-variant-check.checked {
background: #1677ff;
border-color: #1677ff;
}
.xx-variant-index {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
margin-bottom: 8px;
}
.xx-variant-video-wrap {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
aspect-ratio: 9 / 16;
max-height: 420px;
display: flex;
align-items: center;
justify-content: center;
}
.xx-variant-loading,
.xx-variant-failed {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text-secondary, #999);
font-size: 12px;
padding: 16px;
text-align: center;
}
.xx-variant-progress {
width: 120px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.25);
overflow: hidden;
}
.xx-variant-progress-bar {
height: 100%;
background: #1677ff;
border-radius: 2px;
transition: width 0.4s;
}
.xx-variant-progress-text {
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
}
.xx-variant-title-overlay {
position: absolute;
left: 8%;
right: 8%;
text-align: center;
font-weight: 700;
line-height: 1.3;
pointer-events: none;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
word-break: break-all;
}
.xx-variant-title-overlay.pos-top {
top: 8%;
}
.xx-variant-title-overlay.pos-center {
top: 50%;
transform: translateY(-50%);
}
.xx-variant-title-overlay.pos-bottom,
.xx-variant-title-overlay.pos-custom {
bottom: 10%;
}
.xx-variant-footer {
min-height: 22px;
margin-top: 8px;
font-size: 12px;
}
.xx-variant-ready-tag {
color: #52c41a;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-variant-skip-tag {
color: var(--text-tertiary, #999);
}
/* ── 批量配音列表 ── */
.xx-per-voice-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.xx-per-voice-list .xx-form-section {
margin: 0;
}
/* ── 批量封面网格 ── */
.xx-cover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.xx-cover-card {
border: 1px solid var(--border-primary, #e8e8e8);
border-radius: 12px;
padding: 10px;
background: var(--bg-primary, #fff);
}
.xx-cover-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
margin-bottom: 8px;
}
.xx-cover-card-box {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
aspect-ratio: 9 / 16;
max-height: 300px;
}
.xx-cover-card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.xx-cover-card-placeholder,
.xx-cover-card-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary, #999);
background: var(--bg-secondary, #f7f7f7);
font-size: 13px;
}
.xx-cover-card-ratio {
position: absolute;
right: 6px;
bottom: 6px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
}
/* ── 响应式:窄屏 Step4 回退单列 ── */
@media (max-width: 960px) {
.xx-generate-layout.step4-layout {
grid-template-columns: 1fr;
}
.xx-generate-preview-col {
position: static;
}
.xx-title-sidebar {
max-height: none;
}
}
@@ -29,19 +29,6 @@ export interface UseGenerateVideoProps {
} }
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */ /** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
onGenerationSuccess?: () => void onGenerationSuccess?: () => void
/* ── 批量生成(#1677)── */
/** 生成数量(1=单条旧逻辑,>1=批量) */
previewCount?: number
/** 每个变体的标题文字(长度=count 时各自独立) */
variantTitles?: string[]
/** 独立配音模式下每个变体的配音ID(空数组=共用 selectedVoice) */
variantVoiceLibraryIds?: string[]
/** 是否独立配音 */
voiceModePerVideo?: boolean
/** 每个变体的封面URL(空数组=回退 coverSettings) */
variantCoverUrls?: string[]
/** 勾选要生成的变体索引(批量模式) */
selectedVariantIndexes?: number[]
} }
/** 生成阶段 */ /** 生成阶段 */
@@ -57,7 +44,7 @@ export interface UseGenerateVideoResult {
generated: boolean generated: boolean
generateError: string | null generateError: string | null
generatedVideos: GeneratedVideo[] generatedVideos: GeneratedVideo[]
generate: () => Promise<boolean> generate: () => Promise<void>
retry: () => void retry: () => void
dismissError: () => void dismissError: () => void
download: () => Promise<void> download: () => Promise<void>
@@ -17,25 +17,31 @@ const MAX_RETRYABLE_ERRORS = 10
const MAX_RESULTS_RETRIES = 3 const MAX_RESULTS_RETRIES = 3
/** /**
* 生成状态轮询 Hook(v3 — 支持批量多任务) * 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id})
* *
* startPolling(taskId) 轮询单个任务; * 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态,
* startPollingBatch(taskIds) 并行轮询 N 个任务,全部完成后聚合结果, * 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks
* 任一任务失败即整体失败(其余任务仍在后端继续,不影响)。 * 返回的 task_id 轮询任务详情,不再依赖 plan。
* 进度为所有任务平均值。 *
* 错误处理:
* - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试
* - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次
* - 任务完成后获取结果失败会重试 MAX_RESULTS_RETRIES 次,仍失败则 onFailed
*/ */
export function useGenerationPolling({ export const useGenerationPolling = ({
onProgress, onProgress,
onComplete, onComplete,
onFailed, onFailed,
}: UseGenerationPollingOptions) { }: UseGenerationPollingOptions) => {
const progressTimer = useRef<ReturnType<typeof setTimeout>[]>([]) const progressTimer = useRef<ReturnType<typeof setTimeout>>()
const cancelledRef = useRef(false) const cancelledRef = useRef(false)
const clearTimer = useCallback(() => { const clearTimer = useCallback(() => {
cancelledRef.current = true cancelledRef.current = true
progressTimer.current.forEach((t) => clearTimeout(t)) if (progressTimer.current) {
progressTimer.current = [] clearTimeout(progressTimer.current)
progressTimer.current = undefined
}
}, []) }, [])
/** 任务完成后拉取结果列表,带重试 */ /** 任务完成后拉取结果列表,带重试 */
@@ -56,151 +62,85 @@ export function useGenerationPolling({
[], [],
) )
const extractErrorMessage = (pollErr: unknown, status: number): string => {
const msg =
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)?.detail) ||
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)?.message) ||
`查询任务失败 (${status})`
return safeExtractError(msg)
}
/** 轮询单个任务,resolve 该任务的结果视频数组;失败时 reject(new Error(msg)) */
const pollSingleTask = useCallback(
(taskId: string, runId: number, onTaskProgress?: (pct: number) => void): Promise<unknown[]> => {
return new Promise((resolve, reject) => {
let consecutiveErrors = 0
let done = false
const poll = async () => {
if (cancelledRef.current || done) return
try {
const task = await getGenerationTask(taskId)
if (cancelledRef.current || done) return
consecutiveErrors = 0
if (task.status === "completed") {
done = true
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
reject(new Error("视频已生成,但获取结果列表失败,请稍后在任务列表查看"))
return
}
resolve(videos)
return
}
if (task.status === "failed" || task.status === "cancelled") {
done = true
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
reject(new Error(safeExtractError(rawMsg)))
return
}
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
if (onTaskProgress) {
onTaskProgress(pct)
} else if (runId === 0) {
onProgress(pct)
}
const timer = setTimeout(poll, 2000)
progressTimer.current.push(timer)
} catch (pollErr) {
if (cancelledRef.current || done) return
console.error("[轮询出错] taskId:", taskId, pollErr)
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
if (status && status >= 400 && status < 500) {
done = true
reject(new Error(extractErrorMessage(pollErr, status)))
return
}
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
done = true
reject(new Error("任务状态查询连续失败,请稍后在任务列表查看结果"))
return
}
const timer = setTimeout(poll, 3000)
progressTimer.current.push(timer)
}
}
const timer = setTimeout(poll, 1500)
progressTimer.current.push(timer)
})
},
[onProgress, fetchResultsWithRetry],
)
/** 单任务轮询(兼容旧调用) */
const startPolling = useCallback( const startPolling = useCallback(
(taskId: string) => { (taskId: string) => {
cancelledRef.current = false cancelledRef.current = false
const runId = 0 let consecutiveErrors = 0
pollSingleTask(taskId, runId)
.then((videos) => {
if (cancelledRef.current) return
onProgress(100)
onComplete(videos)
message.success("视频生成完成!")
})
.catch((err: Error) => {
if (cancelledRef.current) return
console.error("[生成失败] taskId:", taskId, err.message)
onFailed(err.message)
message.error(err.message)
})
},
[pollSingleTask, onProgress, onComplete, onFailed],
)
/** 批量多任务轮询:全部完成后聚合结果;任一失败即整体失败 */ const poll = async () => {
const startPollingBatch = useCallback(
(taskIds: string[]) => {
cancelledRef.current = false
const runId = Date.now()
const progressMap = new Map<string, number>()
const reportAggregateProgress = () => {
if (cancelledRef.current) return if (cancelledRef.current) return
const values = taskIds.map((id) => progressMap.get(id) ?? 0) try {
const avg = Math.round(values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1)) const task = await getGenerationTask(taskId)
onProgress(Math.min(avg, 99)) consecutiveErrors = 0
if (task.status === "completed") {
onProgress(100)
const videos = await fetchResultsWithRetry(taskId)
if (cancelledRef.current) return
if (videos === null) {
const errorMsg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
console.error("[生成结果获取失败] taskId:", taskId)
onFailed(errorMsg)
message.error(errorMsg)
return
}
onComplete(videos)
message.success("视频生成完成!")
return
}
if (task.status === "failed" || task.status === "cancelled") {
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] taskId:", taskId, "响应:", task)
onFailed(errorMsg)
message.error(errorMsg)
return
}
// pending / waiting / running — 继续轮询
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
onProgress(pct)
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
if (cancelledRef.current) return
console.error("[轮询出错] taskId:", taskId, pollErr)
// 4xx 不可恢复,立即失败
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
if (status && status >= 400 && status < 500) {
const msg =
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.detail) ||
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.message) ||
`查询任务失败 (${status})`
const errorMsg = safeExtractError(msg)
onFailed(errorMsg)
message.error(errorMsg)
return
}
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
const errorMsg = "任务状态查询连续失败,请稍后在任务列表查看结果"
onFailed(errorMsg)
message.error(errorMsg)
return
}
progressTimer.current = setTimeout(poll, 3000)
}
} }
const tasks = taskIds.map((taskId) => progressTimer.current = setTimeout(poll, 1500)
pollSingleTask(taskId, runId, (pct) => {
progressMap.set(taskId, pct)
reportAggregateProgress()
}).then((videos) => {
progressMap.set(taskId, 100)
reportAggregateProgress()
return videos
}),
)
Promise.all(tasks)
.then((results) => {
if (cancelledRef.current) return
onProgress(100)
const allVideos = results.flat()
onComplete(allVideos)
message.success(`全部 ${taskIds.length} 个视频生成完成!`)
})
.catch((err: Error) => {
if (cancelledRef.current) return
console.error("[批量生成失败]", err.message)
onFailed(err.message)
message.error(err.message)
})
}, },
[pollSingleTask, onProgress, onComplete, onFailed], [onProgress, onComplete, onFailed, fetchResultsWithRetry],
) )
return { startPolling, startPollingBatch, clearTimer } return { startPolling, clearTimer }
} }
@@ -20,14 +20,13 @@ const SECONDS_PER_ASSET = 15
/** /**
* 根据模板 segments 计算所需素材数量上限。 * 根据模板 segments 计算所需素材数量上限。
* 取每个 segment 的 duration_min 之和作为目标视频总时长, * 取每个 segment 的 duration_min 之和作为目标视频总时长,
* 再按 15 秒/素材估算需要多少个素材,且保证不少于片段数(每个片段至少 1 个素材); * 再按 15 秒/素材估算需要多少个素材;结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
* 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
*/ */
function computeLimitFromSegments(segments?: TemplateSegment[]): number { function computeLimitFromSegments(segments?: TemplateSegment[]): number {
if (!segments || segments.length === 0) return DEFAULT_LIMIT if (!segments || segments.length === 0) return DEFAULT_LIMIT
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0) const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
if (totalSeconds <= 0) return DEFAULT_LIMIT if (totalSeconds <= 0) return DEFAULT_LIMIT
const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET)) const limit = Math.ceil(totalSeconds / SECONDS_PER_ASSET)
return Math.max(1, Math.min(limit, 200)) return Math.max(1, Math.min(limit, 200))
} }
@@ -1,150 +0,0 @@
/**
* 批量封面 Hook(Issue #1677)
* N 个视频时:逐个自动生成封面(从对应成片抽帧 + 叠加对应标题)或上传自定义封面
*/
import { useCallback, useState } from "react"
import { message } from "antd"
import { generateCover } from "@/api/generation"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import type { GeneratedVideo } from "@/api/template-editor"
interface UseBatchCoversOptions {
selectedTemplate: string
generatedVideos: GeneratedVideo[]
/** 每个变体的标题文字 */
titles: string[]
/** 标题样式(全局共用) */
titleStyle: {
font: string
size: number
color: string
position: string
bold: boolean
stroke: boolean
shadow: boolean
}
covers: string[]
onCoversChange: (urls: string[]) => void
}
export function useBatchCovers({
selectedTemplate,
generatedVideos,
titles,
titleStyle,
covers,
onCoversChange,
}: UseBatchCoversOptions) {
const [loadingIndex, setLoadingIndex] = useState<number | null>(null)
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null)
const patchCover = useCallback(
(index: number, url: string) => {
const next = [...covers]
next[index] = url
onCoversChange(next)
},
[covers, onCoversChange],
)
/** 为第 index 个视频自动生成封面 */
const generateOne = useCallback(
async (index: number) => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
return
}
setLoadingIndex(index)
try {
const titleText = titles[index] || ""
const response = await generateCover(selectedTemplate, {
generated_video_id: target.id,
video_url: target.file_url || target.download_url || "",
cover_type: "ai_frame",
...(titleText
? {
title_config: {
text: titleText,
font: titleStyle.font,
font_size: titleStyle.size,
font_color: titleStyle.color,
position: titleStyle.position,
bold: titleStyle.bold,
stroke: titleStyle.stroke,
shadow: titleStyle.shadow,
},
}
: {}),
})
const url = response.cover?.image_url || response.cover?.thumbnail_url || ""
if (url) {
patchCover(index, url)
message.success(`视频 ${index + 1} 封面生成成功`)
} else {
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
}
} catch (err) {
console.error(`[封面] 视频 ${index + 1} 生成失败:`, err)
message.error(`视频 ${index + 1} 封面生成失败,请重试`)
} finally {
setLoadingIndex(null)
}
},
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover],
)
/** 为第 index 个视频上传自定义封面 */
const uploadOne = useCallback(
async (index: number, file: File) => {
setUploadingIndex(index)
try {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
message.error("未找到素材库,请先创建")
return
}
const result = await uploadAssetDirect({
file,
library_id: imageLib.id,
})
const url = result?.url || ""
if (url) {
patchCover(index, url)
message.success(`视频 ${index + 1} 封面已上传`)
} else {
message.warning("上传完成但未获取到图片URL,请重试")
}
} catch (err) {
console.error(`[封面] 视频 ${index + 1} 上传失败:`, err)
message.error("封面上传失败,请重试")
} finally {
setUploadingIndex(null)
}
},
[patchCover],
)
/** 一键全部自动生成(串行,避免队列限流) */
const generateAll = useCallback(async () => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
for (let i = 0; i < finalVideos.length; i++) {
if (covers[i]) continue // 已有封面跳过
// eslint-disable-next-line no-await-in-loop
await generateOne(i)
}
message.success("全部封面已生成")
}, [generatedVideos, covers, generateOne])
return {
loadingIndex,
uploadingIndex,
generateOne,
uploadOne,
generateAll,
}
}
export default useBatchCovers
@@ -1,285 +0,0 @@
/**
* 批量服务器预览 Hook(Issue #1677 多视频批量生成)
*
* 核心职责:
* 1. 调用 POST /generation/preview(preview_count=N)一次创建 N 个独立变体任务
* 2. 对每个变体 task_id 分别轮询 GET /generation/preview/{task_id}
* 3. 返回每个变体的状态/进度/视频URL,供网格播放器展示
*
* N=1 时不启用(走前端 Canvas 实时预览,零回归);
* N>1 时进入标题页自动触发;素材/配音等配置变化后重新触发。
*/
import { useState, useCallback, useRef, useEffect } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import type { CreatePreviewRequest } from "@/api/generation/types"
export type VariantPreviewStatus = "loading" | "ready" | "failed"
export interface VariantPreview {
/** 变体序号(0-based) */
index: number
taskId: string
status: VariantPreviewStatus
progress: number
videoUrl: string | null
error: string | null
}
interface UseBatchPreviewOptions {
/** 是否启用(仅 previewCount>1 且在标题页时启用) */
enabled: boolean
/** 构建预览请求参数(每次触发时调用,获取最新配置) */
buildRequest: () => CreatePreviewRequest
/** 批量预览任务创建成功回调(回传变体 taskId 列表与 source_edit_plan_id) */
onPreviewTasksCreated?: (taskIds: string[], sourceEditPlanId?: string) => void
}
interface UseBatchPreviewReturn {
variants: VariantPreview[]
/** 整体状态:loading=任一进行中,ready=全部完成,failed=有失败 */
status: "idle" | "loading" | "ready" | "partial_failed" | "failed"
/** 总进度 0-100(各变体平均值) */
progress: number
/** 失败的变体数量 */
failedCount: number
/** 手动重新触发 */
trigger: () => void
}
const POLL_INTERVAL = 2000
const POLL_TIMEOUT = 180_000
const MAX_NETWORK_RETRIES = 2
/**
* 对配置参数做指纹,用于检测配置是否变化(标题文字/样式变化不触发重渲染,仅CSS浮层叠加)
*/
function buildFingerprint(req: CreatePreviewRequest): string {
// 不含 titles/title_config:标题文字与样式由 CSS 浮层实时叠加,变化不触发重渲染
return JSON.stringify({
t: req.template_id,
a: [...(req.asset_ids || [])].sort(),
r: req.video_ratio,
v: req.voice_library_id,
vs: req.voice_library_ids,
pc: req.preview_count,
b: req.bgm_config,
})
}
export function useBatchPreview({
enabled,
buildRequest,
onPreviewTasksCreated,
}: UseBatchPreviewOptions): UseBatchPreviewReturn {
const [variants, setVariants] = useState<VariantPreview[]>([])
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "partial_failed" | "failed">(
"idle",
)
const requestSeqRef = useRef(0)
const pollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const buildRequestRef = useRef(buildRequest)
buildRequestRef.current = buildRequest
const onCreatedRef = useRef(onPreviewTasksCreated)
onCreatedRef.current = onPreviewTasksCreated
const clearTimers = useCallback(() => {
pollTimersRef.current.forEach((t) => clearTimeout(t))
pollTimersRef.current = []
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
clearTimers()
}
}, [clearTimers])
/** 更新单个变体状态 */
const patchVariant = useCallback((taskId: string, patch: Partial<VariantPreview>) => {
setVariants((prev) => prev.map((v) => (v.taskId === taskId ? { ...v, ...patch } : v)))
}, [])
/** 轮询单个变体任务 */
const pollVariant = useCallback(
async (taskId: string, seq: number, retries = 0) => {
if (seq !== requestSeqRef.current || !mountedRef.current) return
try {
const st = await getPreviewStatus(taskId)
if (seq !== requestSeqRef.current || !mountedRef.current) return
if (st.status === "completed" && st.video_url) {
patchVariant(taskId, {
status: "ready",
videoUrl: st.video_url,
progress: 100,
error: null,
})
return
}
if (st.status === "failed" || st.status === "cancelled") {
patchVariant(taskId, {
status: "failed",
error:
st.status === "cancelled" ? "预览任务已取消" : st.error_message || "预览渲染失败",
})
return
}
if (typeof st.progress === "number") {
patchVariant(taskId, { progress: Math.round(st.progress) })
}
const timer = setTimeout(() => pollVariant(taskId, seq), POLL_INTERVAL)
pollTimersRef.current.push(timer)
} catch (err) {
if (seq !== requestSeqRef.current || !mountedRef.current) return
if (retries < MAX_NETWORK_RETRIES) {
console.warn(`[BatchPreview] 变体 ${taskId} 轮询网络错误,第 ${retries + 1} 次重试`, err)
const timer = setTimeout(() => pollVariant(taskId, seq, retries + 1), POLL_INTERVAL * 2)
pollTimersRef.current.push(timer)
} else {
patchVariant(taskId, { status: "failed", error: "网络错误,无法获取预览状态" })
}
}
},
[patchVariant],
)
/** 创建批量预览任务并开始轮询 */
const trigger = useCallback(() => {
if (!enabled) return
const request = buildRequestRef.current()
if (!request.template_id || !request.asset_ids?.length) return
const count = request.preview_count && request.preview_count > 1 ? request.preview_count : 0
if (!count) return
clearTimers()
const seq = ++requestSeqRef.current
setStatus("loading")
setVariants(
Array.from({ length: count }, (_, i) => ({
index: i,
taskId: "",
status: "loading" as const,
progress: 0,
videoUrl: null,
error: null,
})),
)
createPreview(request)
.then((resp) => {
if (seq !== requestSeqRef.current || !mountedRef.current) return
const items = resp.items || []
const taskIds = items.map((it) => it.task_id).filter(Boolean)
if (taskIds.length === 0) {
setStatus("failed")
setVariants((prev) =>
prev.map((v) => ({ ...v, status: "failed", error: "未创建预览任务" })),
)
return
}
onCreatedRef.current?.(taskIds, resp.source_edit_plan_id)
// 用返回的 task_id 填充变体(按 variant_index 对齐)
setVariants((prev) =>
prev.map((v) => {
const item = items.find((it) => it.variant_index === v.index) || items[v.index]
return item ? { ...v, taskId: item.task_id } : v
}),
)
// 超时保护
timeoutTimerRef.current = setTimeout(() => {
if (seq !== requestSeqRef.current || !mountedRef.current) return
setVariants((prev) =>
prev.map((v) =>
v.status === "loading"
? { ...v, status: "failed", error: "预览渲染超时,请重试" }
: v,
),
)
}, POLL_TIMEOUT)
// 分别轮询每个变体
items.forEach((item) => {
if (item.task_id) pollVariant(item.task_id, seq)
})
})
.catch((err: unknown) => {
if (seq !== requestSeqRef.current || !mountedRef.current) return
console.error("[BatchPreview] 创建批量预览失败:", err)
const errData = (err as { response?: { data?: { detail?: string; message?: string } } })
?.response?.data
setStatus("failed")
setVariants((prev) =>
prev.map((v) => ({
...v,
status: "failed",
error: errData?.detail || errData?.message || "预览任务创建失败,请重试",
})),
)
})
}, [enabled, clearTimers, pollVariant])
/* ── 自动触发 + 配置变更检测 ── */
const request = enabled ? buildRequest() : null
const currentFingerprint = request
? request.template_id && request.asset_ids?.length && (request.preview_count || 1) > 1
? buildFingerprint(request)
: ""
: ""
const didInitRef = useRef(false)
useEffect(() => {
if (!enabled || !currentFingerprint) {
didInitRef.current = false
requestSeqRef.current += 1
clearTimers()
setStatus("idle")
setVariants([])
return
}
if (!didInitRef.current) {
didInitRef.current = true
trigger()
}
}, [enabled, currentFingerprint, trigger, clearTimers])
// 配置变更(素材/配音/数量)→ 重新渲染;标题文字变化不触发(CSS浮层实时叠加)
const prevFingerprintRef = useRef(currentFingerprint)
useEffect(() => {
if (!enabled || !currentFingerprint) return
const prev = prevFingerprintRef.current
prevFingerprintRef.current = currentFingerprint
if (!prev || prev === currentFingerprint) return
trigger()
}, [enabled, currentFingerprint, trigger])
/* ── 派生状态 ── */
const progress =
variants.length > 0
? Math.round(variants.reduce((sum, v) => sum + v.progress, 0) / variants.length)
: 0
const failedCount = variants.filter((v) => v.status === "failed").length
const readyCount = variants.filter((v) => v.status === "ready").length
useEffect(() => {
if (status !== "loading" || variants.length === 0) return
if (readyCount === variants.length) {
setStatus("ready")
} else if (readyCount + failedCount === variants.length && failedCount > 0) {
setStatus(failedCount === variants.length ? "failed" : "partial_failed")
}
}, [variants, status, readyCount, failedCount])
return { variants, status, progress, failedCount, trigger }
}
export default useBatchPreview
@@ -233,7 +233,7 @@ export function useCanvasPlayer(
fontSize: number fontSize: number
fontFamily: string fontFamily: string
color: string color: string
position: "top" | "center" | "bottom" | "custom" position: "top" | "center" | "bottom"
bold?: boolean bold?: boolean
stroke?: boolean stroke?: boolean
shadow?: boolean shadow?: boolean
@@ -1,6 +1,6 @@
/** /**
* GeneratePage 表单状态管理 * GeneratePage 表单状态管理
* 集中管理 5 步向导的所有共享状态、API 加载、URL 参数解析 * 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
*/ */
import { useState } from "react" import { useState } from "react"
import { useSearchParams } from "react-router-dom" import { useSearchParams } from "react-router-dom"
@@ -27,8 +27,6 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = {
stroke: true, stroke: true,
shadow: false, shadow: false,
color: "#ffffff", color: "#ffffff",
posX: null,
posY: null,
} }
export interface GenerateFormState { export interface GenerateFormState {
@@ -100,26 +98,6 @@ export interface GenerateFormState {
/** 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) */ /** 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) */
storedSourceEditPlanId: string | null storedSourceEditPlanId: string | null
setStoredSourceEditPlanId: (planId: string | null) => void setStoredSourceEditPlanId: (planId: string | null) => void
/* ── 批量生成(Issue #1677)── */
/** 生成数量(1~10),1=单条旧逻辑 */
previewCount: number
setPreviewCount: (n: number) => void
/** 每个变体的标题文字,长度=previewCount;[0] 与 titleSettings.title 保持同步 */
previewTitles: string[]
setPreviewTitles: (titles: string[] | ((prev: string[]) => string[])) => void
/** false=所有视频共用一个配音;true=每个视频独立配音 */
voiceModePerVideo: boolean
setVoiceModePerVideo: (v: boolean) => void
/** 独立配音模式下每个变体的配音素材ID,长度=previewCount */
voiceLibraryIds: string[]
setVoiceLibraryIds: (ids: string[] | ((prev: string[]) => string[])) => void
/** 每个变体的封面URL(自动生成或上传),长度=previewCount,空串=未设置 */
previewCovers: string[]
setPreviewCovers: (urls: string[] | ((prev: string[]) => string[])) => void
/** 确认生成时勾选的变体索引 */
selectedVariantIds: number[]
setSelectedVariantIds: (ids: number[] | ((prev: number[]) => number[])) => void
} }
export const useGenerateFormState = (): GenerateFormState => { export const useGenerateFormState = (): GenerateFormState => {
@@ -205,14 +183,6 @@ export const useGenerateFormState = (): GenerateFormState => {
null, null,
) )
/* ── 批量生成状态(Issue #1677)── */
const [previewCount, setPreviewCount] = useState(1)
const [previewTitles, setPreviewTitles] = useState<string[]>([""])
const [voiceModePerVideo, setVoiceModePerVideo] = useState(false)
const [voiceLibraryIds, setVoiceLibraryIds] = useState<string[]>([""])
const [previewCovers, setPreviewCovers] = useState<string[]>([""])
const [selectedVariantIds, setSelectedVariantIds] = useState<number[]>([0])
/* ── 从 URL / 编辑计划加载配置 ── */ /* ── 从 URL / 编辑计划加载配置 ── */
usePlanConfigLoader({ usePlanConfigLoader({
editPlanId, editPlanId,
@@ -261,17 +231,5 @@ export const useGenerateFormState = (): GenerateFormState => {
setPreviewTaskId, setPreviewTaskId,
storedSourceEditPlanId, storedSourceEditPlanId,
setStoredSourceEditPlanId, setStoredSourceEditPlanId,
previewCount,
setPreviewCount,
previewTitles,
setPreviewTitles,
voiceModePerVideo,
setVoiceModePerVideo,
voiceLibraryIds,
setVoiceLibraryIds,
previewCovers,
setPreviewCovers,
selectedVariantIds,
setSelectedVariantIds,
} }
} }
@@ -38,7 +38,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(errorMsg) setGenerateError(errorMsg)
}, []) }, [])
const { startPolling, startPollingBatch, clearTimer } = useGenerationPolling({ const { startPolling, clearTimer } = useGenerationPolling({
onProgress: handleProgress, onProgress: handleProgress,
onComplete: handleComplete, onComplete: handleComplete,
onFailed: handleFailed, onFailed: handleFailed,
@@ -83,11 +83,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
} }
} }
const isBatch = (props.previewCount || 1) > 1 const hide = message.loading("正在生成预览视频...", 0)
const hide = message.loading(
isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...",
0,
)
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || "" const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
@@ -96,29 +92,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
? props.selectedClonedVoice || props.selectedVoice || "" ? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || "" : props.selectedVoice || ""
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
const indexes =
isBatch && props.selectedVariantIndexes?.length
? props.selectedVariantIndexes
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
const batchCount = isBatch ? indexes.length : 1
// 标题文字数组:批量时按勾选顺序
const titlesArr =
isBatch && (props.variantTitles?.length || 0) >= batchCount
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
: []
// 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id)
const voiceArr =
isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId)
: []
// 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面)
const coversArr =
isBatch && props.variantCoverUrls?.length
? indexes.map((i) => props.variantCoverUrls![i] || "")
: []
try { try {
const taskResp = await createGenerationTask({ const taskResp = await createGenerationTask({
template_id: selectedTemplate, template_id: selectedTemplate,
@@ -136,10 +109,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}), ...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
}, },
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}), ...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(isBatch ? { count: batchCount } : {}),
...(titlesArr.length ? { titles: titlesArr } : {}),
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
...(coversArr.length ? { cover_urls: coversArr } : {}),
...(props.titleSettings?.title ...(props.titleSettings?.title
? { ? {
title_config: { title_config: {
@@ -148,14 +117,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
font_size: props.titleSettings.size, font_size: props.titleSettings.size,
font_color: props.titleSettings.color, font_color: props.titleSettings.color,
position: props.titleSettings.position, position: props.titleSettings.position,
...(props.titleSettings.position === "custom" &&
props.titleSettings.posX != null &&
props.titleSettings.posY != null
? {
pos_x: Math.round(props.titleSettings.posX),
pos_y: Math.round(props.titleSettings.posY),
}
: {}),
bold: props.titleSettings.bold, bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke, stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow, shadow: props.titleSettings.shadow,
@@ -164,16 +125,12 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
: {}), : {}),
}) })
hide() hide()
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean) const taskId = taskResp.items?.[0]?.id
if (taskIds.length === 0) { if (!taskId) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看") throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
} }
if (taskIds.length > 1) { startPolling(taskId)
startPollingBatch(taskIds)
} else {
startPolling(taskIds[0])
}
} catch (err) { } catch (err) {
hide() hide()
throw err throw err
@@ -189,7 +146,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
return false return false
} }
return true return true
}, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate]) }, [props, clearTimer, startPolling, selectedTemplate])
const retry = useCallback(() => { const retry = useCallback(() => {
setGenerateError(null) setGenerateError(null)
@@ -114,11 +114,8 @@ export function useServerPreview({
const resp = await createPreview(request) const resp = await createPreview(request)
if (seq !== requestSeqRef.current || !mountedRef.current) return if (seq !== requestSeqRef.current || !mountedRef.current) return
// 兼容批量响应 {items, total}:取第一个变体 setTaskId(resp.task_id)
const firstTask = resp.items?.[0] onCreatedRef.current?.(resp.task_id, resp.source_edit_plan_id)
const taskId = firstTask?.task_id || ""
setTaskId(taskId)
onCreatedRef.current?.(taskId, resp.source_edit_plan_id)
let completed = false let completed = false
@@ -135,7 +132,7 @@ export function useServerPreview({
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
try { try {
const st = await getPreviewStatus(taskId) const st = await getPreviewStatus(resp.task_id)
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
if (st.status === "completed" && st.video_url) { if (st.status === "completed" && st.video_url) {
@@ -46,16 +46,7 @@ export function useTitleStyleUpdaters({
const updatePosition = useCallback( const updatePosition = useCallback(
(position: string) => { (position: string) => {
// 切回三档预设时清掉自定义坐标 onTitleSettingsChange({ ...titleSettings, position })
onTitleSettingsChange({ ...titleSettings, position, posX: null, posY: null })
},
[titleSettings, onTitleSettingsChange],
)
/** 拖拽更新自定义位置(由预览播放器调用) */
const updateTitlePosition = useCallback(
(posX: number, posY: number) => {
onTitleSettingsChange({ ...titleSettings, position: "custom", posX, posY })
}, },
[titleSettings, onTitleSettingsChange], [titleSettings, onTitleSettingsChange],
) )
@@ -120,7 +111,6 @@ export function useTitleStyleUpdaters({
updateTitle, updateTitle,
toggleAiAutoSelect, toggleAiAutoSelect,
updatePosition, updatePosition,
updateTitlePosition,
updateFont, updateFont,
updateSize, updateSize,
updateColor, updateColor,
@@ -0,0 +1,112 @@
/**
* Step 7 确认生成 Hook
* 封装生成确认页的展示逻辑
*/
import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import type { EditingTemplate } from "@/api/editing-planner"
import type { GeneratedVideo } from "@/api/template-editor"
import type { CoverConfig } from "../types/cover"
import type { VoiceClone } from "@/api/voice-clone"
import type { PresetVoiceItem } from "@/api/voices"
import { getAssetsByKind } from "@/api/assets"
import { COVER_MODE_LABELS } from "../constants"
interface UseStep7GenerateProps {
templates: EditingTemplate[]
selectedTemplate: string
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
title: string
voiceMode: "preset" | "custom" | "clone"
selectedVoice: string
selectedClonedVoice: string
presetVoices: PresetVoiceItem[]
clonedVoices: VoiceClone[]
coverSettings: CoverConfig
generating: boolean
generated: boolean
generateError: string | null
progress: number
generatedVideos: GeneratedVideo[]
}
export function useStep7Generate({
templates,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
title,
voiceMode: _voiceMode,
selectedVoice,
selectedClonedVoice: _selectedClonedVoice,
presetVoices: _presetVoices,
clonedVoices: _clonedVoices,
coverSettings,
generating,
generated,
generateError,
progress,
generatedVideos,
}: UseStep7GenerateProps) {
const templateName = useMemo(
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
[templates, selectedTemplate],
)
const materialSummary = useMemo(() => {
if (materialMode === "auto") {
return `${smartSelectedIds.length} 个素材(智能匹配)`
}
return `${selectedMaterials.length} 个素材`
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
// 从配音素材库中查找 voiceName
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
const voiceName = useMemo(() => {
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
return asset ? asset.name : "未选择"
}, [voiceMaterials, selectedVoice])
const coverSummary = useMemo(() => {
if (!coverSettings.enabled) return "不使用"
return COVER_MODE_LABELS[coverSettings.mode] || "智能封面"
}, [coverSettings])
const getGenerationPhase = (p: number) => {
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
return { label: "即将完成", icon: "✨" }
}
const handleScrollToPreview = () => {
const el =
document.querySelector(".xx-inline-video-player") ||
document.querySelector(".xx-preview-section")
el?.scrollIntoView({ behavior: "smooth", block: "start" })
}
return {
templateName,
materialSummary,
title,
voiceName,
coverSummary,
generating,
generated,
generateError,
progress,
generatedVideos,
getGenerationPhase,
handleScrollToPreview,
}
}
export default useStep7Generate
@@ -1,6 +1,6 @@
/** /**
* GeneratePage 步骤导航(Issue #1677 改造后 5 步) * GeneratePage 步骤导航
* 步骤:模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5) * 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
*/ */
import { message } from "antd" import { message } from "antd"
import type { TitleSettings } from "../types" import type { TitleSettings } from "../types"
@@ -13,16 +13,10 @@ export interface UseStepNavigationOptions {
selectedMaterials: string[] selectedMaterials: string[]
smartSelectedIds: string[] smartSelectedIds: string[]
titleSettings: TitleSettings titleSettings: TitleSettings
/** 预览是否已就绪(单视频=前端预览素材已加载;批量=服务器预览全部完成) */ /** 预览是否已就绪(素材已加载,可播放) */
previewReady: boolean previewReady: boolean
/** 是否已完成视频生成(步骤4确认生成后才能进入封面) */ /** 是否已完成视频生成(步骤5确认生成后才能进入封面) */
generated: boolean generated: boolean
/** 批量模式下每个变体的标题 */
previewTitles: string[]
/** 批量模式勾选的变体数 */
selectedCount: number
/** Step1 点下一步时弹出数量选择弹窗 */
onOpenCountModal: () => void
} }
export interface UseStepNavigationReturn { export interface UseStepNavigationReturn {
@@ -38,21 +32,14 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
materialMode, materialMode,
selectedMaterials, selectedMaterials,
smartSelectedIds, smartSelectedIds,
titleSettings,
previewReady, previewReady,
generated, generated,
previewTitles,
selectedCount,
onOpenCountModal,
} = options } = options
const goNext = () => { const goNext = () => {
if (currentStep === 1) { if (currentStep === 1 && !selectedTemplate) {
if (!selectedTemplate) { message.warning("请先选择一个模板")
message.warning("请先选择一个模板")
return
}
// 选完模板弹数量选择弹窗(每次都弹,不记忆)
onOpenCountModal()
return return
} }
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) { if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
@@ -63,27 +50,23 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材") message.warning("请先进行智能匹配并选择素材")
return return
} }
// Step4(标题+预览+确认生成):标题必填 + 预览必须已加载 // Step4(标题+预览):标题必填 + 预览必须已加载
if (currentStep === 4) { if (currentStep === 4) {
const allTitlesFilled = previewTitles.every((t) => t && t.trim()) if (!titleSettings.title.trim()) {
if (!allTitlesFilled) { message.warning("请选择或输入标题")
message.warning("请为每个视频输入标题")
return
}
if (selectedCount === 0) {
message.warning("请至少勾选一个视频")
return return
} }
if (!previewReady) { if (!previewReady) {
message.warning("预览视频正在加载,请稍候") message.warning("预览视频正在加载,请稍候")
return return
} }
if (!generated) {
message.warning("请先点击「确认生成视频」完成渲染")
return
}
} }
if (currentStep < 5) { // Step5(确认生成):必须已完成生成才能进入封面
if (currentStep === 5 && !generated) {
message.warning("请先生成视频")
return
}
if (currentStep < 6) {
setCurrentStep((s) => s + 1) setCurrentStep((s) => s + 1)
} }
} }
-3
View File
@@ -16,9 +16,6 @@ export interface TitleSettings {
stroke: boolean stroke: boolean
shadow: boolean shadow: boolean
color: string color: string
/** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */
posX: number | null
posY: number | null
} }
/* ── 智能匹配结果 ── */ /* ── 智能匹配结果 ── */
@@ -35,13 +35,7 @@ export const useTaskHistory = () => {
} = useQuery<TaskItem[], Error>({ } = useQuery<TaskItem[], Error>({
queryKey: ["tasks"], queryKey: ["tasks"],
queryFn: getUserTasks, queryFn: getUserTasks,
staleTime: 5_000, staleTime: 30_000,
// 有进行中任务时每 3 秒自动刷新,全部结束后停止轮询
refetchInterval: (query) => {
const list = query.state.data ?? []
const hasActive = list.some((t) => ["pending", "waiting", "running"].includes(t.status))
return hasActive ? 3_000 : false
},
}) })
// 重试 mutation // 重试 mutation
+1 -13
View File
@@ -11,7 +11,7 @@
* 产品卡片 → components/ProductCard(内联视频播放) * 产品卡片 → components/ProductCard(内联视频播放)
*/ */
import React from "react" import React from "react"
import { VideoCameraOutlined, DownloadOutlined, ReloadOutlined } from "@ant-design/icons" import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui" import { Button } from "@/components/ui"
import { ProductCard } from "./components/ProductCard" import { ProductCard } from "./components/ProductCard"
import { ProductFilterBar } from "./components/ProductFilterBar" import { ProductFilterBar } from "./components/ProductFilterBar"
@@ -19,7 +19,6 @@ import { ProductBatchBar } from "./components/ProductBatchBar"
import { ProductEmptyState } from "./components/ProductEmptyState" import { ProductEmptyState } from "./components/ProductEmptyState"
import { useProductList } from "./hooks/useProductList" import { useProductList } from "./hooks/useProductList"
import { useProductActions } from "./hooks/useProductActions" import { useProductActions } from "./hooks/useProductActions"
import { useRecomputeDedup } from "./hooks/product-actions/useRecomputeDedup"
import "./products.css" import "./products.css"
const ProductLibrary: React.FC = () => { const ProductLibrary: React.FC = () => {
@@ -68,8 +67,6 @@ const ProductLibrary: React.FC = () => {
setPlayingProduct: () => {}, // 不再使用弹窗播放 setPlayingProduct: () => {}, // 不再使用弹窗播放
}) })
const { recomputeDedup, isRecomputing } = useRecomputeDedup()
// ── Loading 状态 ── // ── Loading 状态 ──
if (isLoading) { if (isLoading) {
return <ProductEmptyState type="loading" /> return <ProductEmptyState type="loading" />
@@ -97,15 +94,6 @@ const ProductLibrary: React.FC = () => {
<Button buttonType="ghost" buttonSize="sm" icon={<DownloadOutlined />}> <Button buttonType="ghost" buttonSize="sm" icon={<DownloadOutlined />}>
批量导出 批量导出
</Button> </Button>
<Button
buttonType="ghost"
buttonSize="sm"
icon={<ReloadOutlined />}
loading={isRecomputing}
onClick={recomputeDedup}
>
重新查重
</Button>
</div> </div>
</div> </div>
@@ -11,7 +11,7 @@ import {
} from "@ant-design/icons" } from "@ant-design/icons"
import type { ProductItem } from "../types" import type { ProductItem } from "../types"
import { statusConfig, reviewStatusConfig } from "../constants" import { statusConfig, reviewStatusConfig } from "../constants"
import { formatTime, formatSize } from "../utils" import { formatTime } from "../utils"
interface ProductCardProps { interface ProductCardProps {
product: ProductItem product: ProductItem
@@ -210,20 +210,11 @@ export const ProductCard: React.FC<ProductCardProps> = ({
<span className={`xx-product-status ${st.className}`}>{st.text}</span> <span className={`xx-product-status ${st.className}`}>{st.text}</span>
<span className="xx-product-date">{product.date}</span> <span className="xx-product-date">{product.date}</span>
</div> </div>
<div className="xx-product-meta-grid"> {product.duplicateRate > 0 && (
<span className="xx-product-meta-item">分辨率:{product.resolution || "-"}</span> <span className={`xx-product-dup-rate ${dupClass}`}>
<span className="xx-product-meta-item"> 查重率:{product.duplicateRate.toFixed(1)}%
时长:{product.duration > 0 ? formatTime(product.duration) : "-"}
</span> </span>
<span className="xx-product-meta-item">大小:{formatSize(product.fileSize)}</span> )}
<span
className={`xx-product-meta-item xx-product-dup-rate${
product.duplicateRate > 0 ? ` ${dupClass}` : ""
}`}
>
查重率:{product.duplicateRate != null ? `${product.duplicateRate.toFixed(1)}%` : "-"}
</span>
</div>
</div> </div>
{/* 操作按钮 */} {/* 操作按钮 */}
@@ -2,7 +2,6 @@ import React from "react"
import type { ProductItem } from "../../../api/products" import type { ProductItem } from "../../../api/products"
import { STATUS_MAP } from "../constants" import { STATUS_MAP } from "../constants"
import { formatDuration, formatFileSize, formatDate } from "../detailUtils" import { formatDuration, formatFileSize, formatDate } from "../detailUtils"
import { getRiskLevel } from "../../duplication/utils"
interface ProductInfoPanelProps { interface ProductInfoPanelProps {
product: ProductItem product: ProductItem
@@ -45,28 +44,12 @@ export const ProductInfoPanel: React.FC<ProductInfoPanelProps> = ({ product }) =
</div> </div>
<div className="xx-detail-meta-item"> <div className="xx-detail-meta-item">
<span className="xx-detail-meta-label">查重率</span> <span className="xx-detail-meta-label">查重率</span>
<span <span className="xx-detail-meta-value">
className={`xx-detail-meta-value dup-risk-text dup-risk-${getRiskLevel(product.duplicate_rate)}`}
>
{(product.duplicate_rate ?? 0) > 0 {(product.duplicate_rate ?? 0) > 0
? `${(product.duplicate_rate ?? 0).toFixed(1)}%` ? `${(product.duplicate_rate ?? 0).toFixed(1)}%`
: "-"} : "-"}
</span> </span>
</div> </div>
{product.visual_similarity != null && (
<div className="xx-detail-meta-item">
<span className="xx-detail-meta-label">视觉相似度</span>
<span className="xx-detail-meta-value">
{(product.visual_similarity * 100).toFixed(1)}%
</span>
</div>
)}
{product.match_count != null && (
<div className="xx-detail-meta-item">
<span className="xx-detail-meta-label">匹配帧数</span>
<span className="xx-detail-meta-value">{product.match_count}</span>
</div>
)}
<div className="xx-detail-meta-item"> <div className="xx-detail-meta-item">
<span className="xx-detail-meta-label">创建时间</span> <span className="xx-detail-meta-label">创建时间</span>
<span className="xx-detail-meta-value">{formatDate(product.created_at ?? "")}</span> <span className="xx-detail-meta-value">{formatDate(product.created_at ?? "")}</span>
@@ -1,27 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { recomputeDedup } from "@/api/products"
export function useRecomputeDedup() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: () => recomputeDedup(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["products"] })
if (data.enqueued > 0) {
message.success(`已提交 ${data.enqueued} 个视频的查重任务,后台处理中`)
} else {
message.info("所有视频查重率已是最新,无需重算")
}
},
onError: () => {
message.error("查重任务提交失败,请稍后重试")
},
})
return {
recomputeDedup: () => mutation.mutate(),
isRecomputing: mutation.isPending,
}
}
+29 -51
View File
@@ -91,6 +91,8 @@
height: 18px; height: 18px;
border: 2px solid var(--border-color); border: 2px solid var(--border-color);
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
display: grid;
place-items: center;
transition: var(--transition-all); transition: var(--transition-all);
background: var(--bg-primary); background: var(--bg-primary);
flex-shrink: 0; flex-shrink: 0;
@@ -135,7 +137,7 @@
============================================================ */ ============================================================ */
.xx-products-grid { .xx-products-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(4, 1fr);
gap: var(--space-md); gap: var(--space-md);
} }
@@ -185,6 +187,8 @@
height: 22px; height: 22px;
border: 2px solid rgba(255, 255, 255, 0.8); border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
cursor: pointer; cursor: pointer;
@@ -258,8 +262,11 @@
.xx-product-thumb { .xx-product-thumb {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: grid;
place-items: center;
color: var(--text-inverse); color: var(--text-inverse);
background: var(--color-gray-950); background: var(--color-gray-950);
max-height: 320px;
} }
.xx-product-thumb-bg { .xx-product-thumb-bg {
@@ -269,11 +276,11 @@
background-position: center; background-position: center;
} }
/* 内联视频播放器(cover 填满容器,竖屏视频不留左右空白) */ /* 内联视频播放器 */
.xx-product-thumb-video { .xx-product-thumb-video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
display: block; display: block;
} }
@@ -282,28 +289,22 @@
} }
.xx-product-play { .xx-product-play {
position: absolute; position: relative;
top: 50%; z-index: 1;
left: 50%; width: 44px;
transform: translate(-50%, -50%); height: 44px;
z-index: 2; border-radius: var(--radius-full);
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: flex; display: grid;
align-items: center; place-items: center;
justify-content: center; font-size: var(--font-size-md);
font-size: 28px; transition: var(--transition-all);
color: #fff;
transition: all 0.2s;
cursor: pointer;
} }
.xx-product-card:hover .xx-product-play { .xx-product-card:hover .xx-product-play {
background: var(--primary-color); background: var(--primary-color);
transform: translate(-50%, -50%) scale(1.1); transform: scale(1.1);
} }
/* 时长标签 */ /* 时长标签 */
@@ -323,7 +324,7 @@
/* 卡片信息区 */ /* 卡片信息区 */
.xx-product-info { .xx-product-info {
padding: 8px 10px; padding: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
@@ -346,21 +347,6 @@
gap: var(--space-xs); gap: var(--space-xs);
} }
/* 卡片信息网格:分辨率/时长 一行,大小/查重率 一行 */
.xx-product-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px var(--space-sm);
}
.xx-product-meta-item {
font-size: var(--font-size-xs);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 状态标签 */ /* 状态标签 */
.xx-product-status { .xx-product-status {
padding: 2px 10px; padding: 2px 10px;
@@ -505,6 +491,8 @@
z-index: 1000; z-index: 1000;
background: rgba(0, 0, 0, 0.75); background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
display: grid;
place-items: center;
animation: player-fade-in 0.25s ease-out; animation: player-fade-in 0.25s ease-out;
} }
@@ -543,6 +531,8 @@
background: var(--color-gray-950); background: var(--color-gray-950);
aspect-ratio: 9 / 16; aspect-ratio: 9 / 16;
max-height: 60vh; max-height: 60vh;
display: grid;
place-items: center;
} }
.xx-player-video-wrap video { .xx-player-video-wrap video {
@@ -564,6 +554,8 @@
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
color: var(--text-inverse); color: var(--text-inverse);
font-size: 28px; font-size: 28px;
display: grid;
place-items: center;
cursor: pointer; cursor: pointer;
border: none; border: none;
transition: var(--transition-all); transition: var(--transition-all);
@@ -660,6 +652,8 @@
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
color: var(--text-inverse); color: var(--text-inverse);
font-size: var(--font-size-md); font-size: var(--font-size-md);
display: grid;
place-items: center;
cursor: pointer; cursor: pointer;
border: none; border: none;
transition: var(--transition-all); transition: var(--transition-all);
@@ -1076,19 +1070,3 @@
gap: var(--space-sm); gap: var(--space-sm);
} }
} }
/* 查重率风险颜色(#1662) */
.xx-detail-meta-value.dup-risk-low {
color: var(--success-color, #22c55e);
font-weight: 600;
}
.xx-detail-meta-value.dup-risk-medium {
color: var(--warning-color, #f59e0b);
font-weight: 600;
}
.xx-detail-meta-value.dup-risk-high {
color: var(--error-color, #ef4444);
font-weight: 600;
}
@@ -1,11 +1,6 @@
import { useMemo, useEffect } from "react" import { useMemo, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { import { getAssetsByKind, getAssetLibraries, createAssetLibrary } from "@/api/assets"
getAssetsByKind,
getAssetLibraries,
createAssetLibrary,
type AssetItem,
} from "@/api/assets"
import { type VoiceMaterial, mapAssetToMaterial } from "../../types" import { type VoiceMaterial, mapAssetToMaterial } from "../../types"
interface UseVoiceMaterialDataOptions { interface UseVoiceMaterialDataOptions {
@@ -49,15 +44,6 @@ export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMateri
queryKey: ["assets", "voice", { keyword, gender, tag_ids: tagIds }], queryKey: ["assets", "voice", { keyword, gender, tag_ids: tagIds }],
queryFn: () => getAssetsByKind("voice", { keyword, gender, tag_ids: tagIds }), queryFn: () => getAssetsByKind("voice", { keyword, gender, tag_ids: tagIds }),
staleTime: 30_000, staleTime: 30_000,
// 列表中存在上传中/处理中素材时每 3s 轮询;全部就绪后自动停止
refetchInterval: (query) => {
const items = (query.state.data as AssetItem[] | undefined) ?? []
const processing = items.some((a) => {
const st = a.status ?? ""
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
})
return processing ? 3000 : false
},
}) })
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets]) const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
+1 -1
View File
@@ -41,7 +41,7 @@ export const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
tagIds: Array.isArray(asset.tag_ids) ? asset.tag_ids : [], tagIds: Array.isArray(asset.tag_ids) ? asset.tag_ids : [],
fileName: asset.storage_key?.split("/").pop() || asset.name, fileName: asset.storage_key?.split("/").pop() || asset.name,
fileSize: asset.file_size || 0, fileSize: asset.file_size || 0,
duration: asset.duration || (meta.duration as number) || 0, duration: (meta.duration as number) || 0,
mimeType: asset.mime_type || "audio/mpeg", mimeType: asset.mime_type || "audio/mpeg",
createdAt: asset.created_at || new Date().toISOString(), createdAt: asset.created_at || new Date().toISOString(),
fileUrl: asset.file_url, fileUrl: asset.file_url,
+3 -56
View File
@@ -14,14 +14,8 @@
* 弹窗集合 → components/VoiceModals * 弹窗集合 → components/VoiceModals
* Toast 提示 → components/VoiceToasts * Toast 提示 → components/VoiceToasts
*/ */
import React, { useCallback, useEffect, useState } from "react" import React, { useCallback, useState } from "react"
import { useSearchParams } from "react-router-dom" import { UploadOutlined, AudioOutlined, RobotOutlined } from "@ant-design/icons"
import {
UploadOutlined,
AudioOutlined,
RobotOutlined,
VideoCameraOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui" import { Button } from "@/components/ui"
import PageHead from "@/components/layout/PageHead" import PageHead from "@/components/layout/PageHead"
import { type AssetItem } from "@/api/assets" import { type AssetItem } from "@/api/assets"
@@ -40,8 +34,6 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import { useVoiceUpload } from "./hooks/useVoiceUpload" import { useVoiceUpload } from "./hooks/useVoiceUpload"
import { useMaterialDelete } from "./hooks/useMaterialDelete" import { useMaterialDelete } from "./hooks/useMaterialDelete"
import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete" import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete"
import { useVideoExtract } from "./hooks/useVideoExtract"
import VideoExtractModal from "./components/VideoExtractModal"
import "./voices.css" import "./voices.css"
let toastIdSeq = 0 let toastIdSeq = 0
@@ -166,32 +158,6 @@ const VoiceLibrary: React.FC = () => {
handleUploadClose, handleUploadClose,
} = useVoiceUpload({ showToast }) } = useVoiceUpload({ showToast })
// ── 提取视频配音 ──────────────────────────────────────
const {
extractOpen,
extractFile,
extractProgress,
isExtracting,
setExtractOpen,
handleFileSelect: handleExtractFileSelect,
handleExtract,
handleExtractClose,
} = useVideoExtract({ showToast })
// ── URL 参数自动打开上传弹窗 ────────────────────────────
const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => {
if (searchParams.get("upload") === "1") {
setActiveTab("material")
setUploadOpen(true)
// 一次性触发器:清理 upload 参数,避免切换 Tab 时重复触发
const next = new URLSearchParams(searchParams)
next.delete("upload")
setSearchParams(next, { replace: true })
}
}, [searchParams, setActiveTab, setUploadOpen, setSearchParams])
// ── 切换 Tab 时停止播放 ─────────────────────────────── // ── 切换 Tab 时停止播放 ───────────────────────────────
const handleTabChange = useCallback( const handleTabChange = useCallback(
(tab: VoiceTabKey) => { (tab: VoiceTabKey) => {
@@ -219,14 +185,6 @@ const VoiceLibrary: React.FC = () => {
> >
上传音频 上传音频
</Button> </Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<VideoCameraOutlined />}
onClick={() => setExtractOpen(true)}
>
提取视频配音
</Button>
<Button <Button
buttonType="ghost" buttonType="ghost"
buttonSize="sm" buttonSize="sm"
@@ -327,18 +285,7 @@ const VoiceLibrary: React.FC = () => {
/> />
)} )}
{/* ── 视频提取配音弹窗 ─────────────────────────────── */} {/* ── 弹窗集合 ──────────────────────────────────── */}
<VideoExtractModal
open={extractOpen}
file={extractFile}
progress={extractProgress}
isExtracting={isExtracting}
onClose={handleExtractClose}
onFileSelect={handleExtractFileSelect}
onExtract={handleExtract}
/>
{/* ── 弹窗集合 ─────────────────────────────────── */}
<VoiceModals <VoiceModals
cloneModalOpen={cloneModalOpen} cloneModalOpen={cloneModalOpen}
onCloneClose={() => setCloneModalOpen(false)} onCloneClose={() => setCloneModalOpen(false)}
@@ -136,9 +136,6 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
const material = mapAssetToMaterial(asset) const material = mapAssetToMaterial(asset)
// duration 优先取顶层(后端从 metadata 提取),兜底 metadata // duration 优先取顶层(后端从 metadata 提取),兜底 metadata
const cardDuration = asset.duration || material.duration || 0 const cardDuration = asset.duration || material.duration || 0
// AI 生成素材标识:兼容旧素材(无 source 字段但有 tts_job_id)
const meta = asset.metadata as Record<string, unknown>
const isAiMaterial = meta?.source === "tts_job" || !!meta?.tts_job_id
const isPlaying = playingId === asset.id const isPlaying = playingId === asset.id
const isSelected = selectedIds.has(asset.id) const isSelected = selectedIds.has(asset.id)
// 播放中以 audio 真实时长为准,未播放显示卡片时长 // 播放中以 audio 真实时长为准,未播放显示卡片时长
@@ -185,11 +182,8 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
</div> </div>
<div className="xx-voice-info vmat-info"> <div className="xx-voice-info vmat-info">
<div className="xx-voice-name-row"> <div className="xx-voice-name" title={asset.name}>
<div className="xx-voice-name" title={asset.name}> {asset.name}
{asset.name}
</div>
{isAiMaterial && <span className="vmat-ai-badge">AI</span>}
</div> </div>
<div className="xx-voice-subtitle"> <div className="xx-voice-subtitle">
{asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"} {asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"}
@@ -1,208 +0,0 @@
import React, { useRef } from "react"
import { Modal } from "antd"
import { InboxOutlined, CloseOutlined } from "@ant-design/icons"
interface VideoExtractModalProps {
open: boolean
file: File | null
progress: number | null
isExtracting: boolean
onClose: () => void
onFileSelect: (file: File | null) => void
onExtract: () => void
}
const ACCEPT_TYPES = ".mp4,.mov,.webm"
const VideoExtractModal: React.FC<VideoExtractModalProps> = ({
open,
file,
progress,
isExtracting,
onClose,
onFileSelect,
onExtract,
}) => {
const inputRef = useRef<HTMLInputElement>(null)
return (
<Modal
title={<span style={{ fontSize: 16, fontWeight: 600 }}>提取视频配音</span>}
open={open}
onCancel={() => {
if (inputRef.current) inputRef.current.value = ""
if (isExtracting) return
onClose()
}}
footer={null}
width={480}
maskClosable={!isExtracting}
>
{!file ? (
<div
className="vmat-upload-dropzone"
onClick={() => inputRef.current?.click()}
style={{
border: "2px dashed #d9d9d9",
borderRadius: 8,
padding: "40px 20px",
textAlign: "center",
cursor: "pointer",
transition: "border-color 0.3s",
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#7c3aed")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#d9d9d9")}
>
<InboxOutlined style={{ fontSize: 32, color: "#7c3aed", marginBottom: 12 }} />
<p style={{ margin: "0 0 8px", fontSize: 14, color: "#333" }}>点击选择视频文件</p>
<span style={{ fontSize: 12, color: "#999" }}>支持 MP4、MOV、WebM 格式</span>
<input
ref={inputRef}
type="file"
accept={ACCEPT_TYPES}
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onFileSelect(f)
}}
/>
</div>
) : (
<div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
background: "#fafafa",
borderRadius: 8,
marginBottom: 16,
}}
>
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: 14,
fontWeight: 500,
}}
title={file.name}
>
{file.name}
</span>
<span style={{ fontSize: 12, color: "#999", marginLeft: 8, flexShrink: 0 }}>
{(file.size / (1024 * 1024)).toFixed(1)} MB
</span>
{!isExtracting && (
<button
type="button"
onClick={() => {
if (inputRef.current) inputRef.current.value = ""
onFileSelect(null)
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
color: "#999",
marginLeft: 8,
fontSize: 14,
}}
aria-label="移除文件"
>
<CloseOutlined />
</button>
)}
</div>
{progress !== null && (
<div style={{ marginBottom: 12 }}>
<div
style={{
height: 6,
background: "#f0f0f0",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${progress}%`,
background: "linear-gradient(90deg, #7c3aed, #a78bfa)",
borderRadius: 3,
transition: "width 0.3s",
}}
/>
</div>
<div
style={{
textAlign: "right",
fontSize: 12,
color: "#999",
marginTop: 4,
}}
>
{progress}%
</div>
</div>
)}
{isExtracting && (
<p style={{ textAlign: "center", fontSize: 13, color: "#7c3aed", margin: "12px 0 0" }}>
{"正在提取音频,请稍后..."}
</p>
)}
</div>
)}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 24,
}}
>
<button
type="button"
onClick={onClose}
disabled={isExtracting}
style={{
padding: "6px 16px",
borderRadius: 6,
border: "1px solid #d9d9d9",
background: "#fff",
cursor: isExtracting ? "not-allowed" : "pointer",
fontSize: 14,
opacity: isExtracting ? 0.5 : 1,
}}
>
取消
</button>
<button
type="button"
onClick={onExtract}
disabled={!file || isExtracting}
style={{
padding: "6px 16px",
borderRadius: 6,
border: "none",
background: !file || isExtracting ? "#d9d9d9" : "#7c3aed",
color: "#fff",
cursor: !file || isExtracting ? "not-allowed" : "pointer",
fontSize: 14,
fontWeight: 500,
}}
>
{isExtracting ? "提取中..." : "开始提取"}
</button>
</div>
</Modal>
)
}
export default VideoExtractModal

Some files were not shown because too many files have changed in this diff Show More