Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eab4bc508 |
@@ -1,84 +0,0 @@
|
||||
name: API Base Image Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements.txt'
|
||||
- 'infra/docker/api-base.Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-api-base:
|
||||
name: Build API Base Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 45
|
||||
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 Registry
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
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 printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
|
||||
&& 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 API base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest"
|
||||
|
||||
echo "=== Building API base image ==="
|
||||
|
||||
# 使用普通 docker build(单平台不需要 buildx)
|
||||
docker build \
|
||||
-f infra/docker/api-base.Dockerfile \
|
||||
-t "${ACR_IMAGE}" \
|
||||
.
|
||||
|
||||
echo ""
|
||||
echo "✅ Image built successfully"
|
||||
|
||||
# 推送到 ACR
|
||||
echo "=== Pushing to ACR ==="
|
||||
docker push "${ACR_IMAGE}"
|
||||
echo "✅ Pushed to ACR"
|
||||
|
||||
# 打标签并推送到 Gitea Packages 作为备份
|
||||
echo "=== Pushing to Gitea Packages ==="
|
||||
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
|
||||
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
|
||||
echo "✅ Gitea backup push completed"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
|
||||
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
@@ -633,25 +633,58 @@ jobs:
|
||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Pre-build worker base image (fallback if not exist)
|
||||
- name: Pre-build worker base images (fallback if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
id: prebuild
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
BASE_IMAGE="${REGISTRY}/saas-worker-base:latest"
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# 尝试拉取基础镜像
|
||||
echo "检查 Worker 基础镜像..."
|
||||
if docker pull "$BASE_IMAGE" 2>/dev/null; then
|
||||
echo "✅ 基础镜像已存在"
|
||||
echo "检查基础镜像..."
|
||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||
echo "基础镜像已存在,使用远程镜像"
|
||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "⚠️ 基础镜像不存在,本地构建(fallback模式)..."
|
||||
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
|
||||
echo "基础镜像不存在,本地构建(fallback模式)..."
|
||||
|
||||
# 尝试用buildx构建,失败则回退到普通docker build(DooD模式下buildx builder偶发崩溃)
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
BUILDX_AVAILABLE=true
|
||||
if ! docker buildx create --use --name "$BUILDER_NAME" --driver docker-container > /dev/null 2>&1; then
|
||||
BUILDX_AVAILABLE=false
|
||||
fi
|
||||
if [ "$BUILDX_AVAILABLE" = true ] && ! docker buildx inspect --bootstrap > /dev/null 2>&1; then
|
||||
BUILDX_AVAILABLE=false
|
||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
build_base() {
|
||||
local df="$1"
|
||||
local tag="$2"
|
||||
local name="$3"
|
||||
if [ "$BUILDX_AVAILABLE" = true ]; then
|
||||
echo "构建 $name(buildx)..."
|
||||
if docker buildx build --load -f "$df" -t "$tag" . > /dev/null 2>&1; then
|
||||
echo "$name 构建成功"
|
||||
return 0
|
||||
fi
|
||||
echo "buildx失败,回退到普通docker build"
|
||||
BUILDX_AVAILABLE=false
|
||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
||||
fi
|
||||
echo "构建 $name(docker build)..."
|
||||
docker build -f "$df" -t "$tag" .
|
||||
}
|
||||
|
||||
build_base infra/docker/worker-base-builder.Dockerfile "$BASE_BUILDER" "worker-base-builder"
|
||||
build_base infra/docker/worker-base-runtime.Dockerfile "$BASE_RUNTIME" "worker-base-runtime"
|
||||
|
||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Worker 基础镜像本地构建完成"
|
||||
echo "基础镜像本地构建完成"
|
||||
fi
|
||||
|
||||
- name: Build PR image (verify only, no push)
|
||||
@@ -667,15 +700,15 @@ jobs:
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker: 始终用普通docker build(基础镜像已预装全部依赖,无需buildx)
|
||||
if [ "${{ matrix.service }}" = "worker" ]; then
|
||||
echo "Worker: 使用普通docker build"
|
||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||
BUILD_ARG_STR=""
|
||||
for arg in $EXTRA_BUILD_ARGS; do
|
||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||
done
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "PR Build successful (worker, no buildx)"
|
||||
echo "Fallback PR Build successful"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -798,7 +831,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Setup buildx builder
|
||||
if: matrix.service != 'worker'
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
@@ -811,64 +843,41 @@ jobs:
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Pre-build worker base image (fallback if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
BASE_IMAGE="${REGISTRY}/saas-worker-base:latest"
|
||||
|
||||
echo "检查 Worker 基础镜像..."
|
||||
if docker pull "$BASE_IMAGE" 2>/dev/null; then
|
||||
echo "✅ 基础镜像已存在"
|
||||
else
|
||||
echo "⚠️ 基础镜像不存在,本地构建(fallback)..."
|
||||
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
|
||||
echo "✅ Worker 基础镜像本地构建完成"
|
||||
fi
|
||||
|
||||
- name: Build and push ${{ matrix.service_display }} image
|
||||
- name: Build and push ${{ matrix.service_display }} image (with retry)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
|
||||
if [ "${{ matrix.service }}" = "worker" ]; then
|
||||
# Worker: plain docker build(基础镜像已预装全部依赖,无需 buildx)
|
||||
echo "=== Worker: plain docker build ==="
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" --build-arg APP_VERSION="${GITHUB_SHA}" .
|
||||
docker push "${IMAGE_TAG}"
|
||||
echo "✅ Worker image pushed: ${IMAGE_TAG}"
|
||||
else
|
||||
# API/Web: buildx with registry cache
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker build 尝试 $i/3 ==="
|
||||
if bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "✅ Docker build 成功"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker build 失败(尝试 $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "下次重试将使用 --no-cache"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker build 尝试 $i/3 ==="
|
||||
if bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "✅ Docker build 成功"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker build 失败(尝试 $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
# 第2次重试使用 --no-cache
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "下次重试将使用 --no-cache"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
|
||||
- name: Cleanup buildx builder
|
||||
if: matrix.service != 'worker' && always()
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
|
||||
|
||||
@@ -7,25 +7,35 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-worker.txt'
|
||||
- 'infra/docker/worker-base.Dockerfile'
|
||||
workflow_dispatch:
|
||||
- 'infra/docker/worker-base-builder.Dockerfile'
|
||||
- 'infra/docker/worker-base-runtime.Dockerfile'
|
||||
workflow_dispatch: # 支持手动触发
|
||||
|
||||
jobs:
|
||||
build-worker-base:
|
||||
name: Build Worker Base Image
|
||||
name: Build Worker Base Images
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: builder
|
||||
dockerfile: infra/docker/worker-base-builder.Dockerfile
|
||||
image_name: worker-base-builder
|
||||
cache_name: worker-base-builder-cache
|
||||
- name: runtime
|
||||
dockerfile: infra/docker/worker-base-runtime.Dockerfile
|
||||
image_name: worker-base-runtime
|
||||
cache_name: worker-base-runtime-cache
|
||||
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
|
||||
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 Registry
|
||||
shell: sh
|
||||
@@ -38,8 +48,7 @@ jobs:
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
|
||||
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
@@ -47,40 +56,48 @@ jobs:
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Build and push Worker base image
|
||||
- name: Setup buildx builder
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-worker-base:latest"
|
||||
|
||||
echo "=== Building Worker base image ==="
|
||||
|
||||
# 使用普通 docker build(单平台不需要 buildx)
|
||||
docker build \
|
||||
-f infra/docker/worker-base.Dockerfile \
|
||||
-t "${ACR_IMAGE}" \
|
||||
.
|
||||
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
echo "Created $BUILDER_NAME"
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
echo "Using existing $BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
|
||||
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
|
||||
|
||||
echo "=== Building ${{ matrix.name }} base image ==="
|
||||
echo "Image: ${IMAGE_TAG}"
|
||||
echo "Cache: ${CACHE_REF}"
|
||||
|
||||
# 用通用构建脚本
|
||||
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
|
||||
|
||||
# 同时推送到 Gitea Packages 作为备份(可选)
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
|
||||
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
|
||||
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
|
||||
|
||||
echo ""
|
||||
echo "✅ Image built successfully"
|
||||
echo "✅ ${{ matrix.name }} base image built and pushed"
|
||||
|
||||
# 推送到 ACR
|
||||
echo "=== Pushing to ACR ==="
|
||||
docker push "${ACR_IMAGE}"
|
||||
echo "✅ Pushed to ACR"
|
||||
|
||||
# 打标签并推送到 Gitea Packages 作为备份
|
||||
echo "=== Pushing to Gitea Packages ==="
|
||||
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
|
||||
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
|
||||
echo "✅ Gitea backup push completed"
|
||||
|
||||
- name: Cleanup
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
|
||||
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
|
||||
docker image prune -f 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
|
||||
@@ -10,6 +10,8 @@ Changes:
|
||||
3. config 为 JSON 字段,存储封面配置信息
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
@@ -71,7 +73,7 @@ def upgrade() -> None:
|
||||
name=name,
|
||||
thumbnail_url="",
|
||||
is_system=True,
|
||||
config=config,
|
||||
config=json.dumps(config),
|
||||
created_at=sa.func.now(),
|
||||
updated_at=sa.func.now(),
|
||||
)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""修复 cover_templates.config 双重序列化
|
||||
|
||||
Revision ID: 056_fix_cover_templates_config
|
||||
Revises: 055_cover_templates
|
||||
Create Date: 2026-08-13
|
||||
|
||||
问题: 055 迁移 seed 数据时 json.dumps(config) 导致 config 被双重序列化为 JSON 字符串
|
||||
例如 "{}"(字符串)而不是 {}(对象),导致 Pydantic CoverTemplateResponse 校验失败 500。
|
||||
|
||||
修复: 从 JSON 字符串中提取文本值,再 cast 回 json 对象类型。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "056_fix_cover_templates_config"
|
||||
down_revision = "055_cover_templates"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# PostgreSQL: 从 JSON string scalar 中提取文本内容,cast 为 json object
|
||||
# 例如: JSON string "{}" -> text "{}" -> JSON object {}
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE cover_templates SET config = (config#>>'{}')::json "
|
||||
"WHERE jsonb_typeof(config::jsonb) = 'string'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No safe rollback — the original data was incorrect
|
||||
pass
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Add title_config to generation_tasks
|
||||
|
||||
Revision ID: 057_title_config
|
||||
Revises: 056_fix_cover_templates_config
|
||||
Create Date: 2026-08-23
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "057_title_config"
|
||||
down_revision = "056_fix_cover_templates_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "title_config")
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.api.routes._helpers import check_project_access, format_utc_datetime
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -14,10 +14,10 @@ from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchClassifyRequest,
|
||||
BatchDeleteRequest,
|
||||
BatchGetRequest,
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
SmartMatchItem,
|
||||
SmartMatchRequest,
|
||||
@@ -28,6 +28,11 @@ from app.schemas.asset import (
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
CreateAssetUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -365,18 +370,6 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[AssetResponse])
|
||||
def batch_get_assets(
|
||||
request: BatchGetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> list[AssetResponse]:
|
||||
"""批量获取素材详情(根据 ID 列表)。"""
|
||||
items = asset_repository.find_by_ids(request.ids)
|
||||
storage_service = get_storage_service()
|
||||
return [_to_asset_response(item, storage_service) for item in items]
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=BatchOperationResponse)
|
||||
def batch_delete_assets(
|
||||
request: BatchDeleteRequest,
|
||||
@@ -671,12 +664,51 @@ def untag_asset(
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset() -> None:
|
||||
"""
|
||||
已废弃接口。
|
||||
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。",
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
# 先获取素材库,用于推导 project_id(前端可能不传)
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
|
||||
# project_id 自动推导:优先用请求值,否则从 library 关联的项目获取
|
||||
project_id = request.project_id or library.project_id
|
||||
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
# 确保 library 和 project 归属一致
|
||||
if library.project_id != project_id:
|
||||
raise HTTPException(status_code=400, detail="AssetLibrary does not belong to the specified project")
|
||||
|
||||
use_case = CreateAssetUseCase(asset_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetCommand(
|
||||
project_id=project_id,
|
||||
library_id=request.library_id,
|
||||
name=request.name,
|
||||
storage_key=request.storage_key,
|
||||
mime_type=request.mime_type,
|
||||
metadata=request.metadata,
|
||||
file_size=request.file_size,
|
||||
thumbnail_url=request.thumbnail_url,
|
||||
duration=request.duration,
|
||||
width=request.width,
|
||||
height=request.height,
|
||||
fps=request.fps,
|
||||
codec=request.codec,
|
||||
status=AssetStatus(request.status),
|
||||
classification_status=ClassificationStatus(request.classification_status),
|
||||
quality_score=request.quality_score,
|
||||
uploaded_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
return _to_asset_response(item)
|
||||
|
||||
@@ -47,10 +47,6 @@ class GenerateCoverRequest(BaseModel):
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
cover_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
@@ -63,83 +59,6 @@ class GenerateCoverResponse(BaseModel):
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _persist_cover_frame(
|
||||
frame_url: str,
|
||||
plan_id: str,
|
||||
title_text: str = "",
|
||||
*,
|
||||
title_color: str = "#ffffff",
|
||||
title_position: str = "bottom",
|
||||
title_font_size: int | None = None,
|
||||
) -> str:
|
||||
"""下载 MediaKit 返回的临时帧图,可选叠加标题后转存到 OSS covers/ 路径。
|
||||
|
||||
Args:
|
||||
frame_url: MediaKit 返回的临时帧图 URL
|
||||
plan_id: 剪辑计划 ID(生成 OSS key)
|
||||
title_text: 非空时用 Pillow 在帧上叠加标题(用于 E2 从源素材抽帧,
|
||||
因为源素材本身没有烧录标题)
|
||||
title_color: 标题字体颜色(#RRGGBB)
|
||||
title_position: 标题位置 top/center/bottom
|
||||
title_font_size: 标题字号,None 时自动计算
|
||||
"""
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
tmp_path: str | None = None
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
if not resp.content:
|
||||
return frame_url
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
# E2 从源素材抽帧时,源素材无标题,叠加标题文字
|
||||
if title_text and title_text.strip():
|
||||
try:
|
||||
from packages.shared.title_overlay import apply_title_to_image
|
||||
|
||||
applied = apply_title_to_image(
|
||||
tmp_path,
|
||||
title_text,
|
||||
color=title_color,
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
if applied:
|
||||
logger.info("[封面生成] E2 帧图已叠加标题: plan_id=%s", plan_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] E2 标题叠加失败(返回无标题帧): plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage = get_shared_storage_service()
|
||||
cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg"
|
||||
storage.upload_file(
|
||||
file_or_path=tmp_path,
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
public_url = storage.get_url(cover_key)
|
||||
return public_url or frame_url
|
||||
except Exception:
|
||||
logger.warning("封面帧转存失败,返回原始 URL: plan_id=%s", plan_id, exc_info=True)
|
||||
return frame_url
|
||||
finally:
|
||||
if tmp_path:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
@@ -159,29 +78,6 @@ def generate_cover(
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# ── upload 类型:直接保存前端上传的封面图片,不需要预览视频 ──────
|
||||
if body.cover_type == "upload":
|
||||
if not body.cover_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="cover_type=upload 时必须提供 cover_url",
|
||||
)
|
||||
cover_data = {
|
||||
"type": "upload",
|
||||
"image_url": body.cover_url,
|
||||
}
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
logger.info(
|
||||
"封面上传完成: plan_id=%s cover_url=%s by user=%s",
|
||||
plan_id,
|
||||
body.cover_url[:80] if body.cover_url else "",
|
||||
current_user.user.id,
|
||||
)
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
||||
@@ -209,6 +105,10 @@ def generate_cover(
|
||||
generation_task_id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
logger.info(
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
@@ -216,33 +116,6 @@ def generate_cover(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(pt.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
@@ -273,252 +146,67 @@ def generate_cover(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读);找不到渲染视频时不立即报错,
|
||||
# 因为步骤 E 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
|
||||
# 仍然找不到才报 400
|
||||
if not rendered_storage_key:
|
||||
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请先生成预览视频,再生成封面",
|
||||
)
|
||||
|
||||
# 回写到 plan.config
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读)
|
||||
primary_video_url = None
|
||||
if rendered_storage_key:
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("获取预览视频URL失败: plan_id=%s err=%s", plan_id, e)
|
||||
primary_video_url = None
|
||||
|
||||
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
|
||||
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
|
||||
if body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
cover_url_from_task = None
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
|
||||
# 步骤 A:通过 generation_task_id 直接查找
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
if generation_task_id:
|
||||
try:
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task and getattr(task, "cover_url", ""):
|
||||
cover_url_from_task = task.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤A读取 cover_url 失败: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 C:通过 user+template 查找最近的已完成预览任务的 cover_url
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "cover_url", ""):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤C-user+template): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤C查找 cover_url 失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 D:从 plan.config.cover_candidates 读取(Worker 渲染时写入)
|
||||
if not cover_url_from_task:
|
||||
_candidates = (plan.config or {}).get("cover_candidates") or []
|
||||
if isinstance(_candidates, list) and _candidates:
|
||||
_first = _candidates[0]
|
||||
if isinstance(_first, dict):
|
||||
cover_url_from_task = _first.get("image_url") or _first.get("url") or ""
|
||||
if cover_url_from_task:
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤D-cover_candidates): plan_id=%s url=%s",
|
||||
plan_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
|
||||
# 步骤 E1:如果有已渲染的预览视频 URL 但 cover_url 未持久化(历史数据),
|
||||
# 直接从渲染视频抽帧
|
||||
if not cover_url_from_task and primary_video_url:
|
||||
try:
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
mk_client = get_mediakit_client()
|
||||
if mk_client.is_available:
|
||||
logger.info(
|
||||
"[封面生成] 步骤E1-从渲染视频抽帧: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80],
|
||||
)
|
||||
snapshots = mk_client.extract_frames(
|
||||
video_url=primary_video_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=1,
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=5,
|
||||
max_retries=0,
|
||||
)
|
||||
if snapshots:
|
||||
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
if raw:
|
||||
cover_url_from_task = _persist_cover_frame(raw, plan_id)
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤E1-rendered-video): plan_id=%s url=%s",
|
||||
plan_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤E1从渲染视频抽帧失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 E2:当 A/B/C/D/E1 均未命中(如历史预览任务无 cover_url)时,
|
||||
# 直接从用户选择的第一个视频素材中抽取封面帧作为兜底。API 请求内短超时,不阻塞。
|
||||
if not cover_url_from_task and body.asset_ids:
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
storage_svc = get_shared_storage_service()
|
||||
mk_client = get_mediakit_client()
|
||||
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
|
||||
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
|
||||
if not isinstance(_e2_title_cfg, dict):
|
||||
_e2_title_cfg = {}
|
||||
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
|
||||
# 读取标题样式:前端可能传 color 或 font_color,都兼容
|
||||
_e2_title_color = _e2_title_cfg.get("color") or _e2_title_cfg.get("font_color") or "#ffffff"
|
||||
_e2_title_position = _e2_title_cfg.get("position", "bottom") or "bottom"
|
||||
_e2_title_font_size = _e2_title_cfg.get("font_size") or _e2_title_cfg.get("size")
|
||||
if mk_client.is_available:
|
||||
for aid in body.asset_ids:
|
||||
try:
|
||||
asset = asset_repo.get(aid)
|
||||
if not asset or asset.file_type != "video":
|
||||
continue
|
||||
sk = asset.storage_key or ""
|
||||
if not sk:
|
||||
continue
|
||||
src_url = sk if sk.startswith("http") else storage_svc.get_url(sk)
|
||||
if not src_url:
|
||||
continue
|
||||
logger.info(
|
||||
"[封面生成] 步骤E-从素材抽帧: plan_id=%s asset_id=%s url=%s",
|
||||
plan_id,
|
||||
aid,
|
||||
src_url[:80],
|
||||
)
|
||||
snapshots = mk_client.extract_frames(
|
||||
video_url=src_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=1,
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=5,
|
||||
max_retries=0,
|
||||
)
|
||||
if snapshots:
|
||||
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
if raw:
|
||||
cover_url_from_task = _persist_cover_frame(
|
||||
raw,
|
||||
plan_id,
|
||||
title_text=_e2_title_text,
|
||||
title_color=_e2_title_color,
|
||||
title_position=_e2_title_position,
|
||||
title_font_size=_e2_title_font_size,
|
||||
)
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤E-source-asset): plan_id=%s url=%s",
|
||||
plan_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤E从素材抽帧失败: plan_id=%s asset_id=%s",
|
||||
plan_id,
|
||||
aid,
|
||||
exc_info=True,
|
||||
)
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
|
||||
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
|
||||
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
if cover_url_from_task:
|
||||
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
|
||||
cover_data = {
|
||||
"type": "ai_frame",
|
||||
"image_url": cover_url_from_task,
|
||||
"frame_time": 0.0,
|
||||
"confidence": 0.95,
|
||||
}
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取预览视频URL失败: {e}",
|
||||
) from e
|
||||
|
||||
# 优先使用渲染时预抽的封面候选帧(跳过 MediaKit,秒级返回)
|
||||
cover_candidates = (plan.config or {}).get("cover_candidates", [])
|
||||
if cover_candidates and body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
logger.info(
|
||||
"[封面生成] 使用预存封面候选帧: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(cover_candidates),
|
||||
)
|
||||
first_frame = cover_candidates[0]
|
||||
cover_data = {
|
||||
"type": "ai_frame",
|
||||
"image_url": first_frame.get("image_url", ""),
|
||||
"frame_time": first_frame.get("frame_time", 0.0),
|
||||
"confidence": 0.9,
|
||||
}
|
||||
if cover_data["image_url"]:
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
logger.warning(
|
||||
"[封面生成] 统一管道未找到 cover_url (A/B/C/D均未命中): plan_id=%s",
|
||||
plan_id,
|
||||
)
|
||||
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="封面生成失败:未找到可抽帧的视频素材,请确认已上传视频素材后重试",
|
||||
)
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
try:
|
||||
|
||||
@@ -264,39 +264,9 @@ def create_preview_generation_task(
|
||||
if not video_ratio and request.template_id:
|
||||
video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id)
|
||||
|
||||
# 根据 video_ratio 计算输出分辨率(默认竖屏 1080x1920)
|
||||
output_width, output_height = 1080, 1920
|
||||
if video_ratio:
|
||||
parts = video_ratio.split(":")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
w, h = int(parts[0]), int(parts[1])
|
||||
base = 1920
|
||||
if w < h:
|
||||
# 竖屏
|
||||
output_width = round(base * w / h)
|
||||
output_height = base
|
||||
else:
|
||||
# 横屏
|
||||
output_width = base
|
||||
output_height = round(base * h / w)
|
||||
# 对齐到偶数
|
||||
output_width = output_width - output_width % 2
|
||||
output_height = output_height - output_height % 2
|
||||
except (ValueError, ZeroDivisionError):
|
||||
output_width, output_height = 1080, 1920
|
||||
resolution = f"{output_width}x{output_height}"
|
||||
|
||||
logger.info(
|
||||
"[预览生成] 分辨率: video_ratio=%s → %s (%dx%d)",
|
||||
video_ratio, 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)
|
||||
|
||||
title_config = request.title_config or {}
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
|
||||
try:
|
||||
@@ -315,14 +285,11 @@ def create_preview_generation_task(
|
||||
asset_select_mode="",
|
||||
batch_id="",
|
||||
video_title=request.video_title,
|
||||
resolution=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,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
@@ -332,32 +299,6 @@ def create_preview_generation_task(
|
||||
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
|
||||
|
||||
# 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
|
||||
if not task.source_edit_plan_id and request.template_id:
|
||||
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:
|
||||
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,
|
||||
)
|
||||
|
||||
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
|
||||
@@ -16,7 +16,6 @@ from app.core.task_enqueue import (
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
@@ -33,7 +32,6 @@ from app.schemas.generation_task import (
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
@@ -70,7 +68,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
title_config=getattr(task, "title_config", {}) or {},
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -144,54 +142,6 @@ def _select_assets_from_library(
|
||||
return [a.id for a in ready_video_assets]
|
||||
|
||||
|
||||
|
||||
def _writeback_edit_plan_config(
|
||||
plan_id: str,
|
||||
task_id: str,
|
||||
title_config: dict | None,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。
|
||||
|
||||
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
|
||||
失败只记日志,不影响任务创建。
|
||||
"""
|
||||
if not plan_id:
|
||||
return
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
|
||||
if plan_model is None:
|
||||
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
|
||||
return
|
||||
|
||||
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
|
||||
merged = dict(current_config)
|
||||
merged["generation_task_id"] = task_id
|
||||
if title_config:
|
||||
merged["title_config"] = title_config
|
||||
plan_model.config = merged
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
|
||||
plan_id,
|
||||
task_id,
|
||||
list(merged.keys()),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
request: CreateGenerationTaskRequest,
|
||||
project_repository: Any,
|
||||
@@ -237,7 +187,6 @@ def create_generation_task(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
logger.info(
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
|
||||
@@ -293,89 +242,6 @@ def create_generation_task(
|
||||
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
|
||||
)
|
||||
|
||||
# ── 兜底复用预览产物 ──
|
||||
# 前端刷新后 previewTaskId 丢失,降级调 create 接口时,
|
||||
# 如果同一 edit_plan 有已完成的预览任务,直接复用(秒出)。
|
||||
if request.source_edit_plan_id and not request.is_preview:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
|
||||
_preview_model = (
|
||||
db.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.source_edit_plan_id == request.source_edit_plan_id,
|
||||
GenerationTaskModel.is_preview.is_(True),
|
||||
GenerationTaskModel.status == "completed",
|
||||
GenerationTaskModel.created_by_user_id == authenticated_user.user.id,
|
||||
)
|
||||
.order_by(GenerationTaskModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _preview_model is not None:
|
||||
# 校验分辨率一致性(与 confirm 端点逻辑相同)
|
||||
req_w = request.output_width or 0
|
||||
req_h = request.output_height or 0
|
||||
src_w = getattr(_preview_model, "output_width", 0) or 0
|
||||
src_h = getattr(_preview_model, "output_height", 0) or 0
|
||||
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
|
||||
|
||||
if resolution_match:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
_to_domain,
|
||||
)
|
||||
|
||||
preview_task = _to_domain(_preview_model)
|
||||
|
||||
# 如果传了标题,更新 title_config
|
||||
fallback_title_config = None
|
||||
if request.title_config and request.title_config.get("text", "").strip():
|
||||
fallback_title_config = dict(preview_task.title_config or {})
|
||||
fallback_title_config.update(request.title_config)
|
||||
|
||||
preview_task.mark_confirmed(
|
||||
cover_url=request.cover_url or preview_task.cover_url,
|
||||
output_width=request.output_width or preview_task.output_width,
|
||||
output_height=request.output_height or preview_task.output_height,
|
||||
title_config=fallback_title_config,
|
||||
)
|
||||
generation_task_repository.update(preview_task)
|
||||
|
||||
# 同步标题到 EditPlan.config
|
||||
if fallback_title_config:
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=request.source_edit_plan_id,
|
||||
task_id=preview_task.id,
|
||||
title_config=fallback_title_config,
|
||||
db=db,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[生成任务] 兜底复用预览产物: preview_task_id=%s, plan_id=%s",
|
||||
preview_task.id,
|
||||
request.source_edit_plan_id,
|
||||
)
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(preview_task)],
|
||||
total=1,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[生成任务] 兜底复用跳过(分辨率不一致): plan_id=%s, src=%sx%s, req=%sx%s",
|
||||
request.source_edit_plan_id,
|
||||
src_w,
|
||||
src_h,
|
||||
req_w,
|
||||
req_h,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[生成任务] 兜底复用预览产物异常(不影响主流程): plan_id=%s",
|
||||
request.source_edit_plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
count = request.count
|
||||
created_tasks = []
|
||||
@@ -437,53 +303,10 @@ def create_generation_task(
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
title_config=request.title_config or {},
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
try:
|
||||
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
|
||||
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
|
||||
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
|
||||
if not task.source_edit_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_plan_model = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id,
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _plan_model:
|
||||
task.source_edit_plan_id = _plan_model.id
|
||||
generation_task_repository.update(task)
|
||||
logger.info(
|
||||
"[生成任务] 自动关联编辑计划: task_id=%s plan_id=%s",
|
||||
task.id,
|
||||
_plan_model.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[生成任务] 查找关联编辑计划失败(不影响主流程): task_id=%s",
|
||||
task.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 回写 plan.config:必须在 enqueue 之前执行,
|
||||
# 确保 worker 读取 plan 时 config 中已包含 generation_task_id。
|
||||
# 只在首个任务时回写一次,避免批量生成时循环覆盖。
|
||||
_effective_plan_id = task.source_edit_plan_id
|
||||
if _effective_plan_id and len(created_tasks) == 0:
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=_effective_plan_id,
|
||||
task_id=task.id,
|
||||
title_config=request.title_config,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
@@ -528,7 +351,6 @@ def confirm_generation(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
|
||||
|
||||
@@ -557,29 +379,13 @@ def confirm_generation(
|
||||
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
|
||||
|
||||
if resolution_match:
|
||||
# 如果用户传了 custom_title,同步更新 title_config
|
||||
confirmed_title_config = None
|
||||
if request.custom_title and request.custom_title.strip():
|
||||
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
|
||||
confirmed_title_config["text"] = request.custom_title.strip()
|
||||
|
||||
source_task.mark_confirmed(
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
title_config=confirmed_title_config,
|
||||
)
|
||||
generation_task_repository.update(source_task)
|
||||
|
||||
# 同步标题到 EditPlan.config
|
||||
if confirmed_title_config and source_task.source_edit_plan_id:
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=source_task.source_edit_plan_id,
|
||||
task_id=source_task.id,
|
||||
title_config=confirmed_title_config,
|
||||
db=db,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
|
||||
task_id,
|
||||
@@ -621,6 +427,7 @@ def confirm_generation(
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -753,6 +560,7 @@ def retry_generation_task(
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg
|
||||
import psycopg2
|
||||
import redis
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, status
|
||||
@@ -49,7 +49,7 @@ async def _check_database() -> dict:
|
||||
"message": "Using in-memory database",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
@@ -124,7 +124,7 @@ async def _check_migrations() -> dict:
|
||||
"message": "Using in-memory database, no migrations needed",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""模板编辑器 API 路由包.
|
||||
|
||||
模块拆分:
|
||||
将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
|
||||
- schemas.py: 所有 Pydantic model
|
||||
- dependencies.py: 依赖注入
|
||||
- _utils.py: 工具函数
|
||||
- _fallback.py: 自动兜底逻辑
|
||||
- draft.py: 草稿管理(详情/更新/发布/版本/回滚)
|
||||
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
|
||||
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
|
||||
@@ -12,6 +13,7 @@
|
||||
- export.py: 导出配置
|
||||
- subtitles.py: 字幕管理
|
||||
- ai_features.py: AI 推荐
|
||||
- generation.py: 生成(触发/进度/记录)
|
||||
- timeline.py: 时间线
|
||||
|
||||
挂载路径: /api/v1/templates/{template_id}/editor/
|
||||
@@ -32,6 +34,7 @@ from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||
from .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
from .export import router as export_router
|
||||
from .generation import router as generation_router
|
||||
from .subtitles import router as subtitles_router
|
||||
from .timeline import router as timeline_router
|
||||
|
||||
@@ -48,6 +51,7 @@ _sub_routers = [
|
||||
export_router,
|
||||
subtitles_router,
|
||||
ai_features_router,
|
||||
generation_router,
|
||||
timeline_router,
|
||||
]
|
||||
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
"""模板编辑器自动兜底逻辑.
|
||||
|
||||
generate_editor_draft 触发生成前的自动修复流程:
|
||||
1. draft → editing 状态迁移
|
||||
2. 无片段时从模板复制片段配置
|
||||
3. 为无素材片段分配指定素材
|
||||
4. 项目有素材库时自动选素材
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||
"""自动兜底 1: draft → editing"""
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
|
||||
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||
plan_id,
|
||||
plan_check.template_id,
|
||||
)
|
||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||
if configs:
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
|
||||
plan_id,
|
||||
len(configs),
|
||||
)
|
||||
else:
|
||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||
for seg in segments:
|
||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
duration=avg_duration,
|
||||
config={
|
||||
"material_type": seg.material_type or "",
|
||||
"template_segment_id": seg.id,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段",
|
||||
plan_id,
|
||||
len(segments),
|
||||
)
|
||||
|
||||
|
||||
def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list:
|
||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r",
|
||||
plan_id,
|
||||
len(all_clips),
|
||||
len(clips_without_asset),
|
||||
config_asset_ids[:5] if config_asset_ids else [],
|
||||
)
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
assigned = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
try:
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
|
||||
plan_id,
|
||||
assigned,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新检查剩余无素材片段
|
||||
all_clips_after = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
|
||||
if clips_without_asset:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
elif not clips_without_asset:
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
|
||||
elif not config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
|
||||
plan_id,
|
||||
)
|
||||
|
||||
return clips_without_asset
|
||||
|
||||
|
||||
def _auto_fallback_auto_material_mode(
|
||||
svc: EditPlanService,
|
||||
plan_id: str,
|
||||
plan_check,
|
||||
clips_without_asset: list,
|
||||
asset_library_repo: Any,
|
||||
asset_repo: Any,
|
||||
user_id: str = "",
|
||||
) -> None:
|
||||
"""自动兜底 4: 自动选素材分配给无素材片段
|
||||
|
||||
查找策略(按优先级):
|
||||
1. plan 有 project_id → 从项目素材库查找
|
||||
2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找
|
||||
"""
|
||||
if not clips_without_asset:
|
||||
return
|
||||
|
||||
ready_videos: list = []
|
||||
source_desc = ""
|
||||
|
||||
# 策略 1: 通过 project_id 查找项目素材库
|
||||
if plan_check.project_id:
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
source_desc = f"素材库 {video_lib.name}"
|
||||
|
||||
# 策略 2: 通过 user_id 查找用户上传的素材
|
||||
if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"):
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材",
|
||||
plan_id,
|
||||
user_id,
|
||||
)
|
||||
ready_videos = asset_repo.find_ready_videos_by_user(user_id)
|
||||
source_desc = f"用户上传 (user_id={user_id[:8]}...)"
|
||||
|
||||
if not ready_videos:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)",
|
||||
plan_id,
|
||||
plan_check.project_id or "(empty)",
|
||||
user_id[:8] + "..." if user_id else "(empty)",
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
)
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底4: plan=%s 从 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
source_desc,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
@@ -16,16 +16,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_asset_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
ClipBatchDeleteRequest,
|
||||
@@ -46,100 +43,30 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse:
|
||||
"""统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐"""
|
||||
|
||||
def _enum_str(val) -> str:
|
||||
return val.value if hasattr(val, "value") else str(val)
|
||||
|
||||
def _fmt_dt(val) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
if hasattr(val, "isoformat"):
|
||||
return val.isoformat()
|
||||
return str(val)
|
||||
|
||||
def _clip_to_response(clip) -> EditorClipResponse:
|
||||
"""统一构造片段响应"""
|
||||
return EditorClipResponse(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=_enum_str(getattr(clip, "clip_type", "")),
|
||||
clip_type=clip.clip_type.value
|
||||
if hasattr(clip.clip_type, "value")
|
||||
else str(clip.clip_type),
|
||||
order=clip.order,
|
||||
duration=clip.duration,
|
||||
start_time=getattr(clip, "start_time", 0.0) or 0.0,
|
||||
text_content=clip.text_content or "",
|
||||
transition_effect=_enum_str(getattr(clip, "transition_effect", "cut")),
|
||||
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
|
||||
transition_effect=clip.transition_effect.value
|
||||
if hasattr(clip.transition_effect, "value")
|
||||
else str(clip.transition_effect),
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
asset_id=getattr(clip, "asset_id", "") or "",
|
||||
asset_url=asset_url,
|
||||
status=getattr(clip, "status", "pending") or "pending",
|
||||
template_clip_config_id=getattr(clip, "template_clip_config_id", "") or "",
|
||||
config=clip.config or {},
|
||||
created_at=_fmt_dt(getattr(clip, "created_at", None)),
|
||||
updated_at=_fmt_dt(getattr(clip, "updated_at", None)),
|
||||
)
|
||||
|
||||
|
||||
def _build_asset_url_map(
|
||||
asset_ids: list[str],
|
||||
asset_repo: SQLAlchemyAssetRepository,
|
||||
) -> dict[str, str | None]:
|
||||
"""批量查询素材并生成签名URL映射.
|
||||
|
||||
Returns:
|
||||
{asset_id: signed_url_or_None}
|
||||
"""
|
||||
if not asset_ids:
|
||||
return {}
|
||||
|
||||
# 去重:多个 clip 可能引用同一个素材
|
||||
# 去重并保持顺序
|
||||
seen: set[str] = set()
|
||||
unique_ids = []
|
||||
for aid in asset_ids:
|
||||
if aid and aid not in seen:
|
||||
seen.add(aid)
|
||||
unique_ids.append(aid)
|
||||
|
||||
result: dict[str, str | None] = {}
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception:
|
||||
logger.warning("获取存储服务失败,跳过asset_url生成")
|
||||
return {aid: None for aid in asset_ids}
|
||||
|
||||
# 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1)
|
||||
try:
|
||||
assets = asset_repo.find_by_ids(unique_ids)
|
||||
asset_map = {a.id: a for a in assets}
|
||||
except Exception:
|
||||
logger.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True)
|
||||
return {aid: None for aid in asset_ids if aid}
|
||||
|
||||
for aid in unique_ids:
|
||||
try:
|
||||
asset = asset_map.get(aid)
|
||||
if asset is None:
|
||||
result[aid] = None
|
||||
continue
|
||||
storage_key = getattr(asset, "storage_key", None) or ""
|
||||
if not storage_key:
|
||||
result[aid] = None
|
||||
continue
|
||||
result[aid] = storage.get_download_url(storage_key, expires_seconds=3600)
|
||||
except Exception:
|
||||
logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True)
|
||||
result[aid] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/clips", response_model=EditorClipListResponse)
|
||||
def list_draft_clips(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -148,17 +75,8 @@ def list_draft_clips(
|
||||
_, plan_svc = services
|
||||
clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit)
|
||||
total = plan_svc.count_clips(plan_id)
|
||||
|
||||
# 批量解析素材签名URL
|
||||
asset_ids = [getattr(c, "asset_id", "") or "" for c in clips]
|
||||
asset_ids = [aid for aid in asset_ids if aid]
|
||||
url_map = _build_asset_url_map(asset_ids, asset_repo)
|
||||
|
||||
return EditorClipListResponse(
|
||||
items=[
|
||||
_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or ""))
|
||||
for c in clips
|
||||
],
|
||||
items=[_clip_to_response(c) for c in clips],
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -238,7 +156,6 @@ def get_draft_clip_detail(
|
||||
clip_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""获取草稿中的片段详情"""
|
||||
@@ -248,20 +165,16 @@ def get_draft_clip_detail(
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail="片段不存在")
|
||||
|
||||
asset_id = getattr(clip, "asset_id", "") or ""
|
||||
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
|
||||
return _clip_to_response(clip, asset_url=url_map.get(asset_id))
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.post("/clips/{clip_id}/split", status_code=status.HTTP_200_OK)
|
||||
@router.post("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
|
||||
def split_draft_clip(
|
||||
template_id: str,
|
||||
clip_id: str,
|
||||
body: SplitClipRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""将一个片段从指定时间点分割为两个片段"""
|
||||
@@ -277,22 +190,32 @@ def split_draft_clip(
|
||||
) from exc
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""]
|
||||
asset_ids = [a for a in asset_ids if a]
|
||||
url_map = _build_asset_url_map(asset_ids, asset_repo)
|
||||
return {
|
||||
"left_clip": _clip_to_response(left, asset_url=url_map.get(getattr(left, "asset_id", "") or "")),
|
||||
"right_clip": _clip_to_response(right, asset_url=url_map.get(getattr(right, "asset_id", "") or "")),
|
||||
"left_clip": {
|
||||
"id": left.id,
|
||||
"plan_id": left.plan_id,
|
||||
"clip_type": left.clip_type,
|
||||
"order": left.order,
|
||||
"duration": left.duration,
|
||||
"start_time": left.start_time,
|
||||
},
|
||||
"right_clip": {
|
||||
"id": right.id,
|
||||
"plan_id": right.plan_id,
|
||||
"clip_type": right.clip_type,
|
||||
"order": right.order,
|
||||
"duration": right.duration,
|
||||
"start_time": right.start_time,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/clips/merge", status_code=status.HTTP_200_OK)
|
||||
@router.post("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
|
||||
def merge_draft_clips(
|
||||
template_id: str,
|
||||
body: MergeClipsRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""将多个连续的同类型片段合并为一个片段"""
|
||||
@@ -307,11 +230,13 @@ def merge_draft_clips(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
asset_id = getattr(merged, "asset_id", "") or ""
|
||||
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
|
||||
return {
|
||||
"merged_clip": _clip_to_response(merged, asset_url=url_map.get(asset_id)),
|
||||
"deleted_clip_ids": body.clip_ids,
|
||||
"id": merged.id,
|
||||
"plan_id": merged.plan_id,
|
||||
"clip_type": merged.clip_type,
|
||||
"order": merged.order,
|
||||
"duration": merged.duration,
|
||||
"text_content": merged.text_content,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
核心依赖:
|
||||
- get_editor_services: 获取模板+计划服务
|
||||
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
|
||||
- _check_queue_limits: 生成队列限流检查
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,6 +11,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||
from app.dependencies import get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
@@ -111,3 +113,29 @@ def get_draft_plan_id(
|
||||
user_id,
|
||||
)
|
||||
return plan.id
|
||||
|
||||
|
||||
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
||||
"""队列限流预检查"""
|
||||
try:
|
||||
has_count = (
|
||||
hasattr(gen_task_repo, "count_pending_by_user")
|
||||
and hasattr(gen_task_repo, "count_pending_total")
|
||||
)
|
||||
if has_count:
|
||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||
global_pending = gen_task_repo.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e)
|
||||
|
||||
@@ -17,8 +17,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
EditorClipBatchUpdateRequest,
|
||||
EditorClipBatchUpdateResponse,
|
||||
EditorDraftResponse,
|
||||
EditorPublishResponse,
|
||||
EditorRollbackRequest,
|
||||
@@ -128,7 +126,11 @@ def list_template_versions(
|
||||
clip_count=len(v.clip_configs),
|
||||
change_note=v.change_note,
|
||||
published_by=v.published_by,
|
||||
created_at=(v.created_at.isoformat() if hasattr(v.created_at, "isoformat") else str(v.created_at)),
|
||||
created_at=(
|
||||
v.created_at.isoformat()
|
||||
if hasattr(v.created_at, "isoformat")
|
||||
else str(v.created_at)
|
||||
),
|
||||
)
|
||||
for v in versions
|
||||
]
|
||||
@@ -160,35 +162,3 @@ def rollback_template(
|
||||
new_version=tpl.version,
|
||||
clip_count=len(clip_configs),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/clips", response_model=EditorClipBatchUpdateResponse)
|
||||
def batch_update_clips(
|
||||
template_id: str,
|
||||
req: EditorClipBatchUpdateRequest,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""批量替换草稿clips(全量覆盖,用于前端选择素材后同步片段)
|
||||
|
||||
事务保证:清空→创建→标记ready 在同一数据库事务内完成,
|
||||
任何步骤失败时自动回滚,避免数据不一致。
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
clips_data = []
|
||||
for clip_item in req.clips:
|
||||
item = {
|
||||
"asset_id": clip_item.asset_id,
|
||||
"start_time": clip_item.start_time,
|
||||
"duration": clip_item.duration,
|
||||
}
|
||||
if clip_item.order is not None:
|
||||
item["order"] = clip_item.order
|
||||
clips_data.append(item)
|
||||
|
||||
plan_svc.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
return EditorClipBatchUpdateResponse(plan_id=plan_id, clip_count=len(req.clips))
|
||||
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
"""草稿生成路由.
|
||||
|
||||
端点:
|
||||
- POST /generate 触发生成
|
||||
- GET /generation-status 生成进度
|
||||
- GET /generations 生成记录列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
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.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
)
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
from ._fallback import (
|
||||
_auto_fallback_assign_assets,
|
||||
_auto_fallback_auto_material_mode,
|
||||
_auto_fallback_copy_template_clips,
|
||||
_auto_fallback_draft_to_editing,
|
||||
)
|
||||
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
@router.post("/generate", response_model=EditPlanGenerateResponse)
|
||||
def generate_editor_draft(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发模板草稿渲染生成"""
|
||||
_, plan_svc = services
|
||||
plan_check = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# 自动兜底流程
|
||||
_auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check)
|
||||
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
|
||||
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
|
||||
_auto_fallback_auto_material_mode(
|
||||
plan_svc,
|
||||
plan_id,
|
||||
plan_check,
|
||||
clips_without_asset,
|
||||
asset_library_repo,
|
||||
asset_repo,
|
||||
user_id=str(current_user.user.id),
|
||||
)
|
||||
|
||||
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
reusable_task.mark_confirmed()
|
||||
gen_task_repo.update(reusable_task)
|
||||
|
||||
# 将产物 URL 写入 plan config
|
||||
rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db)
|
||||
plan_svc.update_plan_config(
|
||||
plan_id,
|
||||
{
|
||||
"generation_task_id": reusable_task.id,
|
||||
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
|
||||
},
|
||||
)
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
logger.info(
|
||||
"模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
reusable_task.id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=reusable_task.id,
|
||||
clip_count=len((plan_check.config or {}).get("clips", [])),
|
||||
)
|
||||
|
||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
||||
try:
|
||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if not can_gen:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||
|
||||
try:
|
||||
clip_count = plan_svc.mark_clips_ready(plan_id)
|
||||
|
||||
user_id = current_user.user.id
|
||||
_check_queue_limits(gen_task_repo, user_id)
|
||||
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=plan.project_id or "",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
asset_ids=list(config_asset_ids) if config_asset_ids else [],
|
||||
),
|
||||
)
|
||||
|
||||
plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||
|
||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
logger.info(
|
||||
"模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as _e:
|
||||
logger.exception(
|
||||
"模板编辑器触发生成失败: template_id=%s plan_id=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
)
|
||||
try:
|
||||
plan_svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成失败,请稍后重试",
|
||||
) from _e
|
||||
|
||||
|
||||
def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None":
|
||||
"""查找该 plan 关联的已完成预览任务,判断是否可复用。
|
||||
|
||||
复用条件:
|
||||
1. 存在 source_edit_plan_id == plan_id 的已完成预览任务
|
||||
2. plan 在预览完成后未被修改(updated_at <= 预览完成时间)
|
||||
|
||||
Returns:
|
||||
可复用的 GenerationTask,或 None
|
||||
"""
|
||||
try:
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for task in tasks:
|
||||
if not getattr(task, "is_preview", False):
|
||||
continue
|
||||
if not task.is_completed:
|
||||
continue
|
||||
# 检查 plan 是否在预览完成后被修改
|
||||
completed_at = getattr(task, "completed_at", None)
|
||||
if completed_at and hasattr(plan, "updated_at"):
|
||||
plan_updated = plan.updated_at
|
||||
# 如果 plan.updated_at 为空,无法判断是否修改过,跳过
|
||||
if plan_updated is None:
|
||||
continue
|
||||
# 如果 plan 在预览完成后又被修改了,不能复用
|
||||
if plan_updated > completed_at:
|
||||
continue
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
"""获取任务的输出视频 URL。"""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
url = getattr(videos[0], "file_url", "") or ""
|
||||
# 规范化:合并路径中的双斜杠(保留协议头 ://)
|
||||
if url:
|
||||
import re as _re
|
||||
url = _re.sub(r"(?<!:)//", "/", url)
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
||||
def get_editor_generation_status(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditPlanGenerationStatusResponse:
|
||||
"""查询草稿生成进度"""
|
||||
_, plan_svc = services
|
||||
try:
|
||||
gen_status = plan_svc.get_generation_status(plan_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
plan = gen_status["plan"]
|
||||
clips = gen_status["clips"]
|
||||
|
||||
clip_items = [
|
||||
ClipStatusItem(
|
||||
clip_id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
asset_id=c.asset_id or "",
|
||||
text_content=c.text_content or "",
|
||||
duration=c.duration,
|
||||
)
|
||||
for c in clips
|
||||
]
|
||||
|
||||
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
|
||||
video_url = ""
|
||||
if raw_video_url:
|
||||
if raw_video_url.startswith("http"):
|
||||
video_url = raw_video_url # 已经是完整 URL
|
||||
else:
|
||||
try:
|
||||
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
|
||||
except Exception as e:
|
||||
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
|
||||
video_url = raw_video_url
|
||||
|
||||
progress = gen_status.get("progress", 0.0)
|
||||
error_message = gen_status.get("error_message", "")
|
||||
gen_task_status = gen_status.get("generation_task_status")
|
||||
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status_val == "completed" and progress < 100:
|
||||
progress = 100.0
|
||||
|
||||
return EditPlanGenerationStatusResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=plan_status_val,
|
||||
generation_task_id=gen_status["generation_task_id"],
|
||||
generation_task_status=gen_task_status,
|
||||
progress=progress,
|
||||
video_url=video_url,
|
||||
error_message=error_message,
|
||||
clips=clip_items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/generations", response_model=EditPlanGenerationsResponse)
|
||||
def list_editor_generations(
|
||||
template_id: str,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EditPlanGenerationsResponse:
|
||||
"""查询草稿关联的生成记录列表"""
|
||||
_, plan_svc = services
|
||||
plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
items = [
|
||||
GenerationTaskResponse(
|
||||
id=t.id,
|
||||
project_id=t.project_id,
|
||||
asset_library_id=t.asset_library_id,
|
||||
strategy_id=t.strategy_id,
|
||||
voice_library_id=t.voice_library_id,
|
||||
template_id=t.template_id,
|
||||
asset_ids=t.asset_ids,
|
||||
title_ids=t.title_ids,
|
||||
voice_ids=t.voice_ids,
|
||||
source_edit_plan_id=t.source_edit_plan_id or "",
|
||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||||
progress=t.progress,
|
||||
result_count=t.result_count,
|
||||
error_message=t.error_message,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
import re as _re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
|
||||
@@ -15,6 +16,50 @@ _EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best
|
||||
_EXPORT_VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
|
||||
# ── 生成状态相关 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipStatusItem(BaseModel):
|
||||
"""片段生成状态"""
|
||||
|
||||
clip_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
status: str
|
||||
asset_id: str
|
||||
text_content: str
|
||||
duration: float
|
||||
|
||||
|
||||
class EditPlanGenerationStatusResponse(BaseModel):
|
||||
"""剪辑计划生成进度响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: Optional[str] = None
|
||||
generation_task_status: Optional[str] = None
|
||||
progress: float = 0.0
|
||||
video_url: str = ""
|
||||
error_message: str = ""
|
||||
clips: List[ClipStatusItem]
|
||||
|
||||
|
||||
class EditPlanGenerateResponse(BaseModel):
|
||||
"""剪辑计划触发生成响应体"""
|
||||
|
||||
plan_id: str
|
||||
plan_status: str
|
||||
generation_task_id: str
|
||||
clip_count: int
|
||||
|
||||
|
||||
class EditPlanGenerationsResponse(BaseModel):
|
||||
"""剪辑计划关联的生成记录列表响应体"""
|
||||
|
||||
items: List[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── AI 推荐 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -22,8 +67,12 @@ class AIRecommendRequest(BaseModel):
|
||||
"""AI 推荐片段方案请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
|
||||
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||
editing_mode: str = Field(
|
||||
default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip"
|
||||
)
|
||||
target_duration: float = Field(
|
||||
default=30.0, ge=1.0, le=600.0, description="目标时长(秒)"
|
||||
)
|
||||
|
||||
|
||||
class AIRecommendClipItem(BaseModel):
|
||||
@@ -50,6 +99,8 @@ class AIRecommendResponse(BaseModel):
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── BGM ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -165,7 +216,9 @@ class ClipBatchDeleteResponse(BaseModel):
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
asset_ids: List[str] = Field(
|
||||
..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾"
|
||||
)
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
|
||||
|
||||
@@ -174,7 +227,6 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
|
||||
success: bool = True
|
||||
created_count: int
|
||||
plan_id: str = ""
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
|
||||
@@ -387,28 +439,17 @@ class EditorUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
class EditorClipResponse(BaseModel):
|
||||
"""片段响应 — 与数据库 edit_plan_clips 表字段对齐"""
|
||||
"""片段响应"""
|
||||
|
||||
id: str
|
||||
plan_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
duration: float
|
||||
start_time: float = 0.0
|
||||
text_content: str = ""
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
asset_id: str = ""
|
||||
asset_url: str | None = Field(
|
||||
default=None,
|
||||
description="素材视频签名URL(1小时有效),用于前端预览播放",
|
||||
)
|
||||
status: str = "pending"
|
||||
template_clip_config_id: str = ""
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
class EditorClipListResponse(BaseModel):
|
||||
@@ -440,28 +481,6 @@ class EditorClipUpdateRequest(BaseModel):
|
||||
config: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class EditorClipBatchItem(BaseModel):
|
||||
"""批量更新clips的单个片段"""
|
||||
|
||||
asset_id: str = Field(default="", max_length=100, description="关联素材ID,可为空(占位片段)")
|
||||
start_time: float = Field(default=0.0, ge=0.0)
|
||||
duration: float = Field(default=0.0, ge=0.0)
|
||||
order: Optional[int] = Field(default=None, ge=0, description="排序,None表示按数组顺序")
|
||||
|
||||
|
||||
class EditorClipBatchUpdateRequest(BaseModel):
|
||||
"""批量替换clips请求(全量覆盖)"""
|
||||
|
||||
clips: List[EditorClipBatchItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EditorClipBatchUpdateResponse(BaseModel):
|
||||
"""批量更新clips响应"""
|
||||
|
||||
plan_id: str
|
||||
clip_count: int
|
||||
|
||||
|
||||
class EditorPublishResponse(BaseModel):
|
||||
"""发布草稿响应"""
|
||||
|
||||
|
||||
@@ -20,8 +20,6 @@ from app.schemas.tts import (
|
||||
SaveToLibraryRequest,
|
||||
SaveToLibraryResponse,
|
||||
TTSJobResponse,
|
||||
TTSPreviewRequest,
|
||||
TTSPreviewResponse,
|
||||
TTSStatusResponse,
|
||||
TTSSynthesizeRequest,
|
||||
TTSSynthesizeResponse,
|
||||
@@ -33,7 +31,7 @@ from packages.adapters.sqlalchemy_impl.tts_job_repository import (
|
||||
SQLAlchemyTTSJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
from packages.application.tts_job.use_cases import (
|
||||
CreateTTSJobUseCase,
|
||||
@@ -375,59 +373,6 @@ def save_tts_job_to_library(
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/preview", response_model=TTSPreviewResponse)
|
||||
def preview_tts(
|
||||
request: TTSPreviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> TTSPreviewResponse:
|
||||
"""TTS 预览(试听)——同步合成,立即返回音频 URL。
|
||||
|
||||
用于前端预览配音效果,限制文本长度 200 字以内。
|
||||
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
|
||||
"""
|
||||
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
|
||||
actual_voice_id = request.voice_id
|
||||
profile = voice_clone_repo.get(request.voice_id)
|
||||
if profile is not None:
|
||||
# 命中克隆音色 profile — 校验归属权限
|
||||
if profile.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问该音色",
|
||||
)
|
||||
if not profile.voice_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="音色克隆尚未完成,请稍后再试",
|
||||
)
|
||||
actual_voice_id = profile.voice_id
|
||||
|
||||
try:
|
||||
result = cosyvoice_service.synthesize_speech(
|
||||
text=request.text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=request.speed,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"TTS 合成失败: {e}",
|
||||
) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
return TTSPreviewResponse(
|
||||
audio_url=result.audio_url,
|
||||
duration=result.duration if result.duration and result.duration > 0 else None,
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws/tts/stream")
|
||||
async def tts_websocket_stream(
|
||||
websocket: WebSocket,
|
||||
|
||||
@@ -206,7 +206,6 @@ async def complete_direct_upload(
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
url=storage_service.get_url(normalized_key),
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
@@ -216,7 +215,7 @@ async def complete_direct_upload(
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id, url=storage_service.get_url(normalized_key))
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -58,12 +58,6 @@ class AssetResponse(BaseModel):
|
||||
MAX_BATCH_SIZE = 200
|
||||
|
||||
|
||||
class BatchGetRequest(BaseModel):
|
||||
"""批量获取素材详情请求。"""
|
||||
|
||||
ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求(软删除)。"""
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
class ConfirmGenerationRequest(BaseModel):
|
||||
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
|
||||
|
||||
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
|
||||
output_width: int = Field(default=1080, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
||||
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
@@ -33,11 +33,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── 标题配置(结构化)──
|
||||
title_config: dict | None = Field(
|
||||
default=None,
|
||||
description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。",
|
||||
)
|
||||
# ── 视频标题 ──
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
# ── 批量生成 ──
|
||||
@@ -77,6 +72,7 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
output_width: int = Field(default=1280, description="输出视频宽度")
|
||||
output_height: int = Field(default=720, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -112,7 +108,7 @@ class GenerationTaskResponse(BaseModel):
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
title_config: dict = Field(default_factory=dict)
|
||||
custom_title: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
@@ -185,10 +181,6 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
default="",
|
||||
description="关联的编辑计划ID(可选),用于确认生成时复用预览产物",
|
||||
)
|
||||
title_config: dict = Field(
|
||||
default_factory=dict,
|
||||
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
|
||||
@@ -101,19 +101,3 @@ class SaveToLibraryResponse(BaseModel):
|
||||
voice_id: str
|
||||
voice_name: str
|
||||
status: str
|
||||
|
||||
|
||||
class TTSPreviewRequest(BaseModel):
|
||||
"""TTS 预览(试听)请求。"""
|
||||
|
||||
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
|
||||
voice_id: str = Field(..., min_length=1, description="音色 ID")
|
||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
|
||||
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
|
||||
|
||||
|
||||
class TTSPreviewResponse(BaseModel):
|
||||
"""TTS 预览(试听)响应。"""
|
||||
|
||||
audio_url: str = Field(..., description="合成音频 URL")
|
||||
duration: Optional[float] = Field(default=None, description="音频时长(秒)")
|
||||
|
||||
@@ -39,7 +39,6 @@ class DirectUploadCompleteResponse(BaseModel):
|
||||
ingest_job_id: str
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
url: str = Field(default="", description="Public URL of uploaded file")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
|
||||
@@ -371,88 +371,6 @@ class EditPlanService:
|
||||
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
|
||||
return count
|
||||
|
||||
def replace_all_clips_transactional(
|
||||
self,
|
||||
plan_id: str,
|
||||
clips_data: list[dict],
|
||||
) -> int:
|
||||
"""事务性地替换所有片段:清空→创建→标记ready,单事务保证原子性。
|
||||
|
||||
Args:
|
||||
plan_id: 计划 ID
|
||||
clips_data: 片段数据列表,每项包含 asset_id/start_time/duration/order
|
||||
|
||||
Returns:
|
||||
int: 创建的片段数量
|
||||
|
||||
Raises:
|
||||
Exception: 任何步骤失败时自动回滚
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
|
||||
|
||||
db = self._clip_repo.session
|
||||
try:
|
||||
# 1. 清空现有 clips(不 commit)
|
||||
deleted_count = db.query(EditPlanClipModel).filter(EditPlanClipModel.plan_id == plan_id).delete()
|
||||
|
||||
# 2. 批量创建新 clips(不 commit)
|
||||
for i, clip_item in enumerate(clips_data):
|
||||
order = clip_item.get("order") or i
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=order,
|
||||
asset_id=clip_item.get("asset_id", ""),
|
||||
start_time=clip_item.get("start_time", 0.0),
|
||||
duration=clip_item.get("duration", 0.0),
|
||||
)
|
||||
model = EditPlanClipModel(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id,
|
||||
text_content=clip.text_content,
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect,
|
||||
transition_duration=clip.transition_duration,
|
||||
playback_speed=clip.playback_speed,
|
||||
status=clip.status.value,
|
||||
config=clip.config,
|
||||
)
|
||||
db.add(model)
|
||||
|
||||
# flush 让新建 clip 写入当前事务(未 commit),后续查询才能找到它们
|
||||
db.flush()
|
||||
|
||||
# 3. 标记有 asset_id 的 clips 为 ready(不 commit)
|
||||
pending_with_asset = (
|
||||
db.query(EditPlanClipModel)
|
||||
.filter(
|
||||
EditPlanClipModel.plan_id == plan_id,
|
||||
EditPlanClipModel.status == "pending",
|
||||
EditPlanClipModel.asset_id != "",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for m in pending_with_asset:
|
||||
m.status = "ready"
|
||||
|
||||
# 4. 一次性提交
|
||||
db.commit()
|
||||
logger.info(
|
||||
"事务性替换片段: plan_id=%s deleted=%d created=%d",
|
||||
plan_id,
|
||||
deleted_count,
|
||||
len(clips_data),
|
||||
)
|
||||
return len(clips_data)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
|
||||
raise
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
|
||||
@@ -200,7 +200,15 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: title(新顺序:标题在预览之前)
|
||||
// Step 4: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 等待组件完全渲染
|
||||
await page.waitForTimeout(2000)
|
||||
@@ -214,10 +222,6 @@ test.describe("Core generation flow", () => {
|
||||
await titleInput.fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: preview — 前端实时预览架构改造,无需后端生成预览
|
||||
await expect(page.getByRole("heading", { name: /预览设置/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
@@ -226,12 +230,16 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
|
||||
// Wait for generation API to be called
|
||||
// 前端直接创建生成任务:POST /generation/tasks
|
||||
// 确认生成走新流程:POST /tasks/{taskId}/confirm(复用预览产物)
|
||||
// 或旧流程:POST /editor/generate(向后兼容)
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
(path.endsWith("/confirm") || path.endsWith("/editor/generate"))
|
||||
)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
@@ -250,12 +258,9 @@ test.describe("Core generation flow", () => {
|
||||
// Generate API may return 400 in test env if template has no ready segments
|
||||
// That is OK for a wizard flow smoke test
|
||||
if (genResp.ok()) {
|
||||
const genData = (await genResp.json()) as {
|
||||
items: Array<{ id: string; status: string }>
|
||||
total: number
|
||||
}
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
expect(genData.items[0].id).toBeTruthy()
|
||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||
expect(genData.plan_id).toBeTruthy()
|
||||
expect(genData.generation_task_id).toBeTruthy()
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ test.describe("素材库流程", () => {
|
||||
expect(kinds).toContain("image")
|
||||
})
|
||||
|
||||
test("创建素材记录 — POST /assets 已废弃返回 410", async ({ request }) => {
|
||||
test("创建素材记录", async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-create")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
|
||||
@@ -194,7 +194,7 @@ test.describe("素材库流程", () => {
|
||||
expect(lib.ok()).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
|
||||
// POST /assets 已废弃,应返回 410 Gone
|
||||
// 创建素材记录
|
||||
const response = await request.post(`${apiBase}/assets`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -210,9 +210,16 @@ test.describe("素材库流程", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status()).toBe(410)
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.error?.code).toBe("HTTP_410")
|
||||
expect(data.id, "应返回素材 ID").toBeTruthy()
|
||||
expect(data.name).toContain("test_video")
|
||||
expect(data.mime_type).toBe("video/mp4")
|
||||
expect(data.library_id).toBe(libData.id)
|
||||
})
|
||||
|
||||
test("列出素材", async ({ request }) => {
|
||||
@@ -225,50 +232,51 @@ test.describe("素材库流程", () => {
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: `List Lib ${Date.now()}`,
|
||||
kind: "image",
|
||||
kind: "video",
|
||||
},
|
||||
})
|
||||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
|
||||
// 通过 multipart upload 上传 2 个小图片作为测试素材
|
||||
// 创建一个 1x1 的 PNG buffer
|
||||
const tinyPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
)
|
||||
|
||||
await request.post(`${apiBase}/upload`, {
|
||||
// 创建 2 个素材
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
headers,
|
||||
multipart: {
|
||||
data: {
|
||||
project_id: projectId,
|
||||
library_id: libData.id,
|
||||
file: { name: "clip_a.png", mimeType: "image/png", buffer: tinyPng },
|
||||
name: `clip_a_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/clip_a.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
})
|
||||
await request.post(`${apiBase}/upload`, {
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
headers,
|
||||
multipart: {
|
||||
data: {
|
||||
project_id: projectId,
|
||||
library_id: libData.id,
|
||||
file: { name: "clip_b.png", mimeType: "image/png", buffer: tinyPng },
|
||||
name: `clip_b_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/clip_b.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 列出素材(可能需要等待 ingest job 完成)
|
||||
let items: any[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const response = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libData.id },
|
||||
})
|
||||
expect(response.ok(), `列出素材应返回 2xx`).toBeTruthy()
|
||||
const data = await response.json()
|
||||
items = data.items || []
|
||||
if (items.length >= 2) break
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
// 列出素材
|
||||
const response = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libData.id },
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || []
|
||||
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
|
||||
Generated
-10
@@ -12,7 +12,6 @@
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"antd": "^5.18.0",
|
||||
"axios": "^1.7.2",
|
||||
"mp4box": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.0",
|
||||
@@ -4624,15 +4623,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mp4box": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/mp4box/-/mp4box-2.4.1.tgz",
|
||||
"integrity": "sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"antd": "^5.18.0",
|
||||
"axios": "^1.7.2",
|
||||
"mp4box": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.0",
|
||||
|
||||
@@ -60,6 +60,18 @@ export const smartMatchAssets = async (libraryId: string): Promise<{ items: Asse
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||
export const createAsset = async (data: {
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata?: AssetMetadata
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
|
||||
@@ -13,6 +13,7 @@ export type {
|
||||
ClassificationJob,
|
||||
AssetDiagnosis,
|
||||
BatchOperationResult,
|
||||
UploadResult,
|
||||
DirectUploadPrepareResult,
|
||||
DirectUploadCompleteResult,
|
||||
} from "./types"
|
||||
@@ -33,13 +34,14 @@ export {
|
||||
getAssets,
|
||||
getAssetsByKind,
|
||||
smartMatchAssets,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
updateAssetReviewStatus,
|
||||
deleteAsset,
|
||||
} from "./assets"
|
||||
|
||||
// 上传
|
||||
export { prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
|
||||
// 任务
|
||||
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
|
||||
|
||||
@@ -135,5 +135,4 @@ export interface DirectUploadPrepareResult {
|
||||
export interface DirectUploadCompleteResult {
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
||||
import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
||||
|
||||
/** 表单上传素材(小文件) */
|
||||
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
|
||||
const response = await apiClient.post("/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 30 * 60 * 1000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* 主动 Token 刷新模块
|
||||
*
|
||||
* 在 access_token 过期前主动刷新,避免 API 请求触发 401。
|
||||
* JWT payload 是 base64 编码的 JSON,无需第三方库即可解码。
|
||||
*/
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./login"
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 正在执行刷新操作的 Promise,防止主动刷新和 401 被动刷新并发竞争 */
|
||||
let activeRefreshPromise: Promise<void> | null = null
|
||||
|
||||
/** 提前刷新的缓冲时间(秒) */
|
||||
const REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
/**
|
||||
* 解码 JWT payload(不验签,仅读取 exp 字段)
|
||||
*/
|
||||
function decodeJwtPayload(token: string): { exp?: number } | null {
|
||||
try {
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
// JWT 使用 base64url 编码,需要转换为标准 base64
|
||||
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
|
||||
const decoded = atob(padded)
|
||||
return JSON.parse(decoded)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消已调度的主动刷新
|
||||
*/
|
||||
export function cancelProactiveRefresh(): void {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 token 刷新(带并发锁,供主动刷新和被动 401 共用)
|
||||
* 返回当前刷新操作的 Promise;若已有刷新进行中则复用该 Promise。
|
||||
*/
|
||||
export function executeTokenRefresh(): Promise<void> | null {
|
||||
// 已有刷新进行中 → 复用
|
||||
if (activeRefreshPromise) {
|
||||
return activeRefreshPromise
|
||||
}
|
||||
|
||||
const { user, refreshToken: refreshTokenValue } = useAuthStore.getState()
|
||||
|
||||
// 安全检查:user 或 refreshToken 为空时跳过刷新
|
||||
if (!user || !refreshTokenValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
activeRefreshPromise = (async () => {
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshTokenValue)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshTokenValue
|
||||
|
||||
// 更新 Zustand store + localStorage
|
||||
useAuthStore.getState().setAuth(user, newAccessToken, newRefreshToken)
|
||||
|
||||
// 递归调度下一次刷新
|
||||
scheduleProactiveRefresh()
|
||||
} catch {
|
||||
// 刷新失败 → 清除认证状态,跳转登录页
|
||||
cancelProactiveRefresh()
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/login"
|
||||
} finally {
|
||||
activeRefreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return activeRefreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
|
||||
*/
|
||||
export function scheduleProactiveRefresh(): void {
|
||||
cancelProactiveRefresh()
|
||||
|
||||
// 统一从 Zustand store 读取(与 setAuth 写入保持一致)
|
||||
const { accessToken, refreshToken: refreshTokenValue } = useAuthStore.getState()
|
||||
|
||||
if (!accessToken || !refreshTokenValue) return
|
||||
|
||||
const payload = decodeJwtPayload(accessToken)
|
||||
if (!payload?.exp) return
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const secondsUntilExpiry = payload.exp - now
|
||||
|
||||
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
|
||||
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
|
||||
|
||||
refreshTimer = setTimeout(() => {
|
||||
executeTokenRefresh()
|
||||
}, delaySeconds * 1000)
|
||||
}
|
||||
@@ -4,20 +4,11 @@
|
||||
import apiClient from "../client"
|
||||
import type { BgmPreset, BgmPresetsQuery } from "./types"
|
||||
|
||||
/**
|
||||
* 获取 BGM 预设列表
|
||||
* @param templateId 模板/草稿 ID
|
||||
* @param params 分类/关键词筛选
|
||||
*/
|
||||
export const getBgmPresets = async (
|
||||
templateId: string,
|
||||
params?: BgmPresetsQuery,
|
||||
): Promise<BgmPreset[]> => {
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {}
|
||||
if (params?.category) searchParams.category = params.category
|
||||
if (params?.keyword) searchParams.keyword = params.keyword
|
||||
const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, {
|
||||
params: searchParams,
|
||||
})
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams })
|
||||
return res.data?.data ?? res.data ?? []
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
|
||||
import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh"
|
||||
import { refreshAccessToken } from "./auth"
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -98,22 +97,14 @@ apiClient.interceptors.response.use(
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
// 使用共享的刷新函数(带并发锁 + 安全检查)
|
||||
const refreshPromise = executeTokenRefresh()
|
||||
if (!refreshPromise) {
|
||||
// user 或 refreshToken 为空,无法刷新
|
||||
cancelProactiveRefresh()
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
return Promise.reject(new Error("Unable to refresh: missing user or refresh token"))
|
||||
}
|
||||
await refreshPromise
|
||||
const data = await refreshAccessToken(refreshToken)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken
|
||||
|
||||
// 获取刷新后的新 token
|
||||
const newAccessToken = useAuthStore.getState().accessToken
|
||||
if (!newAccessToken) {
|
||||
return Promise.reject(new Error("Token refresh failed: no new access token"))
|
||||
}
|
||||
// 更新 Zustand + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
|
||||
|
||||
// 处理排队的请求
|
||||
processQueue(null, newAccessToken)
|
||||
@@ -125,7 +116,6 @@ apiClient.interceptors.response.use(
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// 刷新失败 → 登出
|
||||
cancelProactiveRefresh()
|
||||
processQueue(refreshError, null)
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
export interface GenerateCoverTitleConfig {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
/** 标题样式,用于在封面上叠加标题文字 */
|
||||
title_config?: GenerateCoverTitleConfig
|
||||
}
|
||||
|
||||
export interface GenerateCoverResponse {
|
||||
@@ -36,9 +23,13 @@ export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post<GenerateCoverResponse>("/generation/generate-cover", data, {
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
})
|
||||
const response = await apiClient.post<GenerateCoverResponse>(
|
||||
"/generation/generate-cover",
|
||||
{ ...data, template_id: templateId },
|
||||
{
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ export type {
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
ConfirmGenerationResponse,
|
||||
ConfirmGenerationTaskItem,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
export { generateCover } from "./cover"
|
||||
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
|
||||
|
||||
@@ -13,21 +13,6 @@ export interface CreatePreviewRequest {
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
/** 输出视频宽度(与 video_ratio 匹配,如 9:16 → 1080) */
|
||||
output_width?: number
|
||||
/** 输出视频高度(与 video_ratio 匹配,如 9:16 → 1920) */
|
||||
output_height?: number
|
||||
/* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
bgm_config?: {
|
||||
enabled: boolean
|
||||
preset_id?: string
|
||||
@@ -42,8 +27,6 @@ export interface CreatePreviewResponse {
|
||||
is_preview: boolean
|
||||
resolution: string
|
||||
created_at: string
|
||||
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id) */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 预览任务详情响应 */
|
||||
|
||||
@@ -6,7 +6,6 @@ import apiClient from "../client"
|
||||
import type {
|
||||
CreateGenerationTaskRequest,
|
||||
CreateGenerationTaskResponse,
|
||||
GenerationTaskDetail,
|
||||
TaskItem,
|
||||
TaskListParams,
|
||||
TaskListResponse,
|
||||
@@ -20,12 +19,6 @@ export const createGenerationTask = async (
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取单个生成任务详情(轮询用) */
|
||||
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskDetail> => {
|
||||
const { data } = await apiClient.get<GenerationTaskDetail>(`/generation/tasks/${taskId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
|
||||
@@ -57,45 +57,12 @@ export interface TaskListResponse {
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
/** 输出视频宽度 */
|
||||
output_width?: number
|
||||
/** 输出视频高度 */
|
||||
output_height?: number
|
||||
/** 自定义封面图片 URL */
|
||||
cover_url?: string
|
||||
/** 自定义视频标题 */
|
||||
custom_title?: string
|
||||
/** 视频时长(秒) */
|
||||
duration?: number
|
||||
/** 视频宽高比,如 "9:16" */
|
||||
video_ratio?: string
|
||||
/** 标题烧录配置 */
|
||||
title_config?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
font_color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
/** 关联的草稿 ID(编辑流程数据链路用) */
|
||||
source_edit_plan_id?: string
|
||||
/** 配音素材库 ID(用户上传的音频或 AI 配音素材) */
|
||||
voice_library_id?: string
|
||||
/** 自定义 BGM 配置,覆盖模板 BGM 设置 */
|
||||
bgm_config?: {
|
||||
enabled: boolean
|
||||
preset_id?: string
|
||||
volume?: number
|
||||
}
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
}
|
||||
|
||||
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
|
||||
export interface GenerationTaskDetail {
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
@@ -105,18 +72,8 @@ export interface GenerationTaskDetail {
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
source_edit_plan_id?: string
|
||||
status: string
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
error_info?: TaskErrorInfo
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
/** 创建生成任务响应(后端返回批量结构 {items, total}) */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
items: GenerationTaskDetail[]
|
||||
total: number
|
||||
}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
/**
|
||||
* 模板草稿 CRUD API
|
||||
* 模板草稿 CRUD + 生成相关 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types"
|
||||
import type {
|
||||
EditPlan,
|
||||
EditPlanListParams,
|
||||
EditPlanListResponse,
|
||||
CreateEditPlanRequest,
|
||||
UpdateEditPlanRequest,
|
||||
GenerateResponse,
|
||||
GenerationStatusResponse,
|
||||
EditPlanGeneration,
|
||||
GeneratedVideo,
|
||||
CopyEditPlanRequest,
|
||||
} from "./types"
|
||||
|
||||
/** 获取模板草稿列表(支持分页和筛选) */
|
||||
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
@@ -10,44 +29,63 @@ export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
|
||||
/** 创建模板草稿 */
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/templates/drafts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿 */
|
||||
export async function updateEditPlan(
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板草稿 */
|
||||
export async function deleteEditPlan(templateId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}/editor`)
|
||||
}
|
||||
|
||||
/** 触发生成 */
|
||||
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取生成状态(轮询用) */
|
||||
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取模板草稿关联的生成记录 */
|
||||
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** ── 草稿 clips 批量更新 ── */
|
||||
|
||||
export interface EditPlanClipInput {
|
||||
asset_id: string
|
||||
start_time: number
|
||||
duration: number
|
||||
order: number
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(templateId: string): Promise<void> {
|
||||
await apiClient.post(`/templates/${templateId}/editor/cancel`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量替换草稿的 clips(先全删再批量插入)
|
||||
* 后端路由:PUT /templates/{template_id}/editor/clips
|
||||
*/
|
||||
export async function updateEditPlanClips(
|
||||
/** 复制模板草稿(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
templateId: string,
|
||||
clips: EditPlanClipInput[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ count: number }> {
|
||||
const response = await apiClient.put(
|
||||
`/templates/${templateId}/editor/clips`,
|
||||
{ clips },
|
||||
{ signal },
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/templates/${templateId}/editor/copy`,
|
||||
data || {},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -15,8 +15,14 @@ export type {
|
||||
EditPlanSegment,
|
||||
EditPlanConfig,
|
||||
EditPlan,
|
||||
CreateEditPlanRequest,
|
||||
UpdateEditPlanRequest,
|
||||
EditPlanListParams,
|
||||
EditPlanListResponse,
|
||||
GenerateResponse,
|
||||
EditPlanGeneration,
|
||||
ClipStatusItem,
|
||||
GenerationStatusResponse,
|
||||
GeneratedVideo,
|
||||
AIRecommendRequest,
|
||||
AIRecommendClipItem,
|
||||
@@ -31,6 +37,7 @@ export type {
|
||||
ClipReorderResponse,
|
||||
ClipBatchDeleteResponse,
|
||||
ClipsFromAssetsResponse,
|
||||
CopyEditPlanRequest,
|
||||
TransitionEffect,
|
||||
MediaAsset,
|
||||
} from "./types"
|
||||
@@ -46,12 +53,18 @@ export {
|
||||
|
||||
// 模板草稿 CRUD + 生成
|
||||
export {
|
||||
getEditPlans,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
updateEditPlanClips,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
copyEditPlan,
|
||||
} from "./editPlans"
|
||||
export type { EditPlanClipInput } from "./editPlans"
|
||||
|
||||
// 片段 CRUD + 批量操作
|
||||
export {
|
||||
|
||||
@@ -118,17 +118,6 @@ export interface EditPlanConfig {
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string
|
||||
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
|
||||
title?: {
|
||||
text?: string
|
||||
font?: string
|
||||
font_size?: number
|
||||
color?: string
|
||||
position?: string
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
/** 预览视频 URL(封面生成用) */
|
||||
rendered_storage_key?: string
|
||||
/** 生成任务 ID */
|
||||
@@ -187,6 +176,31 @@ export interface EditPlanListResponse {
|
||||
|
||||
/* ── 生成相关 ── */
|
||||
|
||||
/** 生成响应 */
|
||||
export interface GenerateResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id: string
|
||||
clip_count: number
|
||||
}
|
||||
|
||||
/** 模板草稿关联的生成记录 */
|
||||
export interface EditPlanGeneration {
|
||||
id: string
|
||||
source_edit_plan_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
status: EditPlanStatus
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
error_info: Record<string, unknown>
|
||||
logs: Array<Record<string, unknown>>
|
||||
retry_count: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 片段生成状态 */
|
||||
export interface ClipStatusItem {
|
||||
clip_id: string
|
||||
@@ -199,6 +213,17 @@ export interface ClipStatusItem {
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
/** 生成状态轮询响应 */
|
||||
export interface GenerationStatusResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id?: string
|
||||
error_message?: string
|
||||
clips: ClipStatusItem[]
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** 生成视频详情 */
|
||||
export interface GeneratedVideo {
|
||||
id: string
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
TemplateSegment,
|
||||
TemplateListParams,
|
||||
TemplateListResponse,
|
||||
GenerateFromTemplateRequest,
|
||||
GenerateFromTemplateResponse,
|
||||
CopyTemplateResponse,
|
||||
} from "./types"
|
||||
|
||||
@@ -22,4 +24,5 @@ export {
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
} from "./templates"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
CopyTemplateResponse,
|
||||
GenerateFromTemplateRequest,
|
||||
GenerateFromTemplateResponse,
|
||||
TemplateItem,
|
||||
TemplateListParams,
|
||||
TemplateListResponse,
|
||||
@@ -43,3 +45,15 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
|
||||
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从模板生成 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
||||
import { validateFile } from "./utils"
|
||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
||||
@@ -182,15 +181,9 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
// 直传到 OSS
|
||||
const uploadResult = await uploadAssetDirect({
|
||||
file: fileToUpload,
|
||||
library_id: library.id,
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 组件已卸载则中止后续操作
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
@@ -62,15 +62,15 @@ export function useCloneSubmit({
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库
|
||||
// 获取默认项目和素材库(后端 /upload 接口必填)
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
// 直传到 OSS
|
||||
const uploadResult = await uploadAssetDirect({
|
||||
file: fileToUpload,
|
||||
library_id: library.id,
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
formData.append("project_id", project.id)
|
||||
formData.append("library_id", library.id)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
|
||||
@@ -71,7 +71,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑模板",
|
||||
label: "剪辑编辑器",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
@@ -133,7 +133,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑模板",
|
||||
label: "剪辑编辑器",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import * as authApi from "@/api/auth"
|
||||
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
|
||||
// 登录 Hook
|
||||
@@ -32,9 +31,6 @@ export const useLogin = () => {
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
|
||||
// 启动主动 token 刷新,避免后续请求触发 401
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
|
||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
||||
localStorage.removeItem("login_redirect")
|
||||
@@ -77,9 +73,6 @@ export const useWechatCallback = () => {
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
// 启动主动 token 刷新
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
@@ -128,7 +121,6 @@ export const useLogout = () => {
|
||||
} catch (error) {
|
||||
// 即使登出失败也清除本地状态
|
||||
} finally {
|
||||
cancelProactiveRefresh()
|
||||
clearAuth()
|
||||
queryClient.clear()
|
||||
navigate("/")
|
||||
|
||||
@@ -9,13 +9,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { ConfigProvider, App as AntApp } from "antd"
|
||||
import zhCN from "antd/locale/zh_CN"
|
||||
import router from "./router"
|
||||
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
|
||||
|
||||
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
|
||||
// 这样可以在 token 过期前自动刷新,避免 API 请求触发 401
|
||||
if (localStorage.getItem("access_token")) {
|
||||
scheduleProactiveRefresh()
|
||||
}
|
||||
import "./index.css"
|
||||
import "./styles/global.css"
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 生成历史弹窗 — 展示当前模板草稿的生成任务记录
|
||||
* 从 EditingPlanner 拆分,避免主文件过大
|
||||
*/
|
||||
import React from "react"
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
|
||||
import type { EditPlanGeneration } from "@/api/template-editor"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/template-editor"
|
||||
|
||||
interface GenerationHistoryModalProps {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
history: EditPlanGeneration[]
|
||||
onClose: () => void
|
||||
onCancel?: (taskId: string) => void
|
||||
cancelLoading?: boolean
|
||||
}
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
open,
|
||||
loading,
|
||||
history,
|
||||
onClose,
|
||||
onCancel,
|
||||
cancelLoading,
|
||||
}) => {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="ep-modal-overlay" onClick={onClose}>
|
||||
<div className="ep-modal ep-gh-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ep-modal-header">
|
||||
<h3>生成历史</h3>
|
||||
<button className="ep-modal-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ep-modal-body ep-gh-body">
|
||||
{loading ? (
|
||||
<div className="ep-gh-empty">
|
||||
<div className="ep-skeleton">
|
||||
<div className="ep-skeleton-item ep-skeleton-item--header" />
|
||||
<div className="ep-skeleton-item" />
|
||||
<div className="ep-skeleton-item" />
|
||||
<div className="ep-skeleton-item" />
|
||||
</div>
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="ep-gh-empty">
|
||||
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
||||
<span>暂无生成记录</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ep-gh-table">
|
||||
<thead>
|
||||
<tr className="ep-gh-table-header-row">
|
||||
<th className="ep-gh-th">任务ID</th>
|
||||
<th className="ep-gh-th">状态</th>
|
||||
<th className="ep-gh-th">创建时间</th>
|
||||
<th className="ep-gh-th">更新时间</th>
|
||||
{onCancel && <th className="ep-gh-th">操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((gen) => {
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`
|
||||
const canCancel = gen.status === "rendering" || gen.status === "editing"
|
||||
return (
|
||||
<tr key={gen.id} className="ep-gh-table-row">
|
||||
<td className="ep-gh-td ep-gh-td-id">
|
||||
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td">
|
||||
<span className={`ep-gh-status-tag ${statusClass}`}>
|
||||
{PLAN_STATUS_LABELS[gen.status] || gen.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"}
|
||||
</td>
|
||||
{onCancel && (
|
||||
<td className="ep-gh-td ep-gh-td-action">
|
||||
{canCancel ? (
|
||||
<button
|
||||
className="ep-gh-cancel-btn"
|
||||
onClick={() => onCancel(gen.id)}
|
||||
disabled={cancelLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<span className="ep-gh-action-placeholder">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-modal-footer">
|
||||
<button className="ep-btn ep-btn-secondary" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationHistoryModal
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 入口文件(向后兼容)
|
||||
* 实际实现已移至 ./generation-progress-modal/ 目录
|
||||
*/
|
||||
export { default } from "./generation-progress-modal"
|
||||
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
|
||||
@@ -16,17 +16,9 @@ interface BgmSelectorProps {
|
||||
onClose: () => void
|
||||
config: BgmMixConfig
|
||||
onChange: (config: BgmMixConfig) => void
|
||||
/** 模板/草稿 ID,用于请求 BGM 预设 */
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
templateId,
|
||||
}) => {
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
|
||||
const {
|
||||
presets,
|
||||
loading,
|
||||
@@ -38,7 +30,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
loadPresets,
|
||||
handlePreview,
|
||||
stopPreview,
|
||||
} = useBgmSelector(open, templateId)
|
||||
} = useBgmSelector(open)
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CATEGORY_LIST: {
|
||||
* BGM 选择器数据与交互 Hook
|
||||
* 封装列表加载、搜索、分类筛选、试听播放逻辑
|
||||
*/
|
||||
export function useBgmSelector(open: boolean, templateId?: string) {
|
||||
export function useBgmSelector(open: boolean) {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
|
||||
@@ -30,23 +30,19 @@ export function useBgmSelector(open: boolean, templateId?: string) {
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
if (!templateId) {
|
||||
setPresets([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {}
|
||||
if (activeCategory !== "all") params.category = activeCategory
|
||||
if (keyword.trim()) params.keyword = keyword.trim()
|
||||
const data = await getBgmPresets(templateId, params)
|
||||
const data = await getBgmPresets(params)
|
||||
setPresets(data)
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeCategory, keyword, templateId])
|
||||
}, [activeCategory, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets()
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
import { getStepLabel, getStatusColor } from "./constants"
|
||||
|
||||
interface ProgressPhaseProps {
|
||||
open: boolean
|
||||
task: TaskItem | null
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** progress(进度轮询)阶段弹窗 */
|
||||
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
|
||||
const progress = task?.progress ?? 0
|
||||
const status = task?.status ?? ""
|
||||
const currentStep = task?.current_step ?? ""
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const stepColor = getStatusColor(status, currentStep)
|
||||
|
||||
return (
|
||||
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
|
||||
<div className="ep-gen-progress">
|
||||
{/* 进度环 */}
|
||||
<div className="ep-gen-progress-ring-wrap">
|
||||
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
||||
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
|
||||
<circle
|
||||
className="ep-gen-progress-ring-fill"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * 52}`,
|
||||
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
|
||||
stroke: stepColor,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 当前步骤 */}
|
||||
<div className="ep-gen-step-text">
|
||||
{userMessage || getStepLabel(currentStep) || "处理中…"}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="ep-gen-progress-bar">
|
||||
<div
|
||||
className="ep-gen-progress-bar-fill"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: stepColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import React from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
interface ResultPhaseProps {
|
||||
open: boolean
|
||||
phase: "completed" | "failed"
|
||||
task: TaskItem | null
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** completed / failed(结果)阶段弹窗 */
|
||||
export const ResultPhase: React.FC<ResultPhaseProps> = ({
|
||||
open,
|
||||
phase,
|
||||
task,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const errorMessage = task?.error_message ?? ""
|
||||
const retryable = task?.retryable ?? false
|
||||
const handleClose = onClose || onCancel
|
||||
|
||||
if (phase === "completed") {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="✅ 生成完成"
|
||||
footer={null}
|
||||
onCancel={handleClose}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result">
|
||||
<div className="ep-gen-result-icon">🎉</div>
|
||||
<div className="ep-gen-result-title">视频生成完成!</div>
|
||||
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
|
||||
<div className="ep-gen-result-actions">
|
||||
<Button buttonType="primary" onClick={handleClose}>
|
||||
查看结果
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="❌ 生成失败"
|
||||
footer={null}
|
||||
onCancel={handleClose}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result ep-gen-result--error">
|
||||
<div className="ep-gen-result-icon">😥</div>
|
||||
<div className="ep-gen-result-title">视频生成失败</div>
|
||||
{(errorMessage || userMessage) && (
|
||||
<div className="ep-gen-result-msg ep-gen-result-msg--error">
|
||||
{errorMessage || userMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-gen-result-actions">
|
||||
{retryable && onRetry && (
|
||||
<Button buttonType="primary" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="secondary" onClick={handleClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
|
||||
interface SetupPhaseProps {
|
||||
open: boolean
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
submitting: boolean
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** setup(配置)阶段弹窗 */
|
||||
export const SetupPhase: React.FC<SetupPhaseProps> = ({
|
||||
open,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
submitting,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
}) => (
|
||||
<Modal
|
||||
open={open}
|
||||
title="使用模板生成视频"
|
||||
confirmLoading={submitting}
|
||||
onOk={onGenerate}
|
||||
onCancel={onCancel}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-setup">
|
||||
<label className="ep-gen-field-label">配音时长(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-gen-duration-input"
|
||||
placeholder="请输入配音时长"
|
||||
value={voiceoverDuration ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value ? Number(e.target.value) : null
|
||||
onDurationChange(v)
|
||||
}}
|
||||
min={1}
|
||||
max={600}
|
||||
/>
|
||||
<div className="ep-gen-estimate">
|
||||
预估总时长:<strong>{estimatedDuration}s</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
/* ──────────── 步骤文案映射 ──────────── */
|
||||
|
||||
export const STEP_LABELS: Record<string, string> = {
|
||||
queued: "排队中…",
|
||||
preparing: "准备素材…",
|
||||
generating_video: "渲染视频中…",
|
||||
adding_effects: "添加特效…",
|
||||
composing: "合成中…",
|
||||
encoding: "编码输出中…",
|
||||
completed: "生成完成!",
|
||||
failed: "生成失败",
|
||||
}
|
||||
|
||||
export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
|
||||
|
||||
/* ──────────── 状态徽标颜色 ──────────── */
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
queued: "#6b7280",
|
||||
pending: "#6b7280",
|
||||
preparing: "#f59e0b",
|
||||
generating_video: "#4f46e5",
|
||||
adding_effects: "#7c3aed",
|
||||
composing: "#2563eb",
|
||||
encoding: "#0891b2",
|
||||
completed: "#10b981",
|
||||
failed: "#ef4444",
|
||||
}
|
||||
|
||||
export const getStatusColor = (status: string, currentStep: string) =>
|
||||
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 任务 2.17
|
||||
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
||||
* V21 设计系统,CSS 类名前缀 ep-gen-
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import type { GenPhase, GenerationProgressModalProps } from "./types"
|
||||
import { SetupPhase } from "./SetupPhase"
|
||||
import { ProgressPhase } from "./ProgressPhase"
|
||||
import { ResultPhase } from "./ResultPhase"
|
||||
|
||||
/* 重新导出类型,保持向后兼容 */
|
||||
export type { GenPhase, GenerationProgressModalProps }
|
||||
|
||||
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
open,
|
||||
phase,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
task,
|
||||
submitting,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
|
||||
const prevOpen = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevOpen.current && !open) {
|
||||
/* modal just closed — parent handles reset */
|
||||
}
|
||||
prevOpen.current = open
|
||||
}, [open])
|
||||
|
||||
/* setup 阶段 */
|
||||
if (phase === "setup") {
|
||||
return (
|
||||
<SetupPhase
|
||||
open={open}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={estimatedDuration}
|
||||
submitting={submitting}
|
||||
onDurationChange={onDurationChange}
|
||||
onGenerate={onGenerate}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* progress 阶段 */
|
||||
if (phase === "progress") {
|
||||
return <ProgressPhase open={open} task={task} onCancel={onCancel} />
|
||||
}
|
||||
|
||||
/* completed / failed 阶段 */
|
||||
return (
|
||||
<ResultPhase
|
||||
open={open}
|
||||
phase={phase as "completed" | "failed"}
|
||||
task={task}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationProgressModal
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
export type GenPhase = "setup" | "progress" | "completed" | "failed"
|
||||
|
||||
export interface GenerationProgressModalProps {
|
||||
open: boolean
|
||||
phase: GenPhase
|
||||
|
||||
/* setup 阶段 */
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
|
||||
/* progress / 结果阶段 */
|
||||
task: TaskItem | null
|
||||
|
||||
/* 通用 */
|
||||
submitting: boolean
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -1,37 +1,30 @@
|
||||
/**
|
||||
* 智能剪辑页面 — 前端实时预览架构
|
||||
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
|
||||
* 智能剪辑页面 — V22 多预览 + 配音前置
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
*
|
||||
* 架构:
|
||||
* - Step4+ 右侧预览面板使用 FrontendPreviewPlayer 实时播放素材片段
|
||||
* - 标题样式编辑时 CSS 层实时叠加预览,所见即所得
|
||||
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
* 步骤导航 → hooks/useStepNavigation
|
||||
* 步骤内容 → components/GenerateStepContent
|
||||
* 底部按钮 → components/GenerateStepActions
|
||||
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||
*/
|
||||
import React, { useMemo, useState, useEffect, useRef } from "react"
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import {
|
||||
calculateTotalVideoDuration,
|
||||
estimateTotalVideoDuration,
|
||||
} from "./utils/calculateTotalVideoDuration"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import PreviewVideoPanel from "./components/PreviewVideoPanel"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { useStep4Preview } from "./hooks/useStep4Preview"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -72,76 +65,12 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
previewTaskId,
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
} = formState
|
||||
|
||||
/* ── 标题样式回调 ── */
|
||||
const styleUpdaters = useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
onTitleSettingsChange: setTitleSettings,
|
||||
})
|
||||
|
||||
/* ── 配音预览音频(TTS 试听)── */
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
|
||||
const ttsAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 如果 selectedVoice 是已上传的配音素材,直接用 file_url
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
if (voiceAsset?.file_url) {
|
||||
setPreviewVoiceAudioUrl(voiceAsset.file_url)
|
||||
return
|
||||
}
|
||||
|
||||
// 没有选中的 voice 或标题,跳过
|
||||
const voiceId = selectedClonedVoice || selectedVoice
|
||||
if (!voiceId || !titleSettings.title) {
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 预设音色 / 克隆音色 → 调 TTS 合成
|
||||
ttsAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
ttsAbortRef.current = controller
|
||||
let cancelled = false
|
||||
|
||||
previewTts({
|
||||
text: titleSettings.title,
|
||||
voice_id: voiceId,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled && res.audio_url) {
|
||||
setPreviewVoiceAudioUrl(res.audio_url)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn("[预览配音生成失败]", err)
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
@@ -151,37 +80,35 @@ const GeneratePage: React.FC = () => {
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── 素材 ID 列表 ── */
|
||||
const previewAssetIds = useMemo(
|
||||
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
|
||||
[materialMode, smartSelectedIds, selectedMaterials],
|
||||
)
|
||||
/* ── 预览数量(多预览) ── */
|
||||
const [previewCount, setPreviewCount] = useState(1)
|
||||
|
||||
/* ── 当前模板对象 ── */
|
||||
const currentTemplate = useMemo(
|
||||
() => userTemplates.find((t) => t.id === selectedTemplate) || null,
|
||||
[userTemplates, selectedTemplate],
|
||||
)
|
||||
/* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */
|
||||
/* selectedVoice / selectedClonedVoice 均为 string 类型(voice ID),
|
||||
见 useGenerateFormState 返回值类型定义 */
|
||||
const previewVoiceIds = useMemo((): string[] => {
|
||||
if (voiceMode === "clone") {
|
||||
const id: string = selectedClonedVoice
|
||||
return id ? [id] : []
|
||||
}
|
||||
// preset / custom 模式
|
||||
const id: string = selectedVoice
|
||||
return id ? [id] : []
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice])
|
||||
|
||||
/* ── BGM 配置 ── */
|
||||
const bgmConfig = useMemo(
|
||||
() => ({
|
||||
enabled: bgm,
|
||||
music_id: currentTemplate?.bgm_config?.music_id || "",
|
||||
}),
|
||||
[bgm, currentTemplate],
|
||||
)
|
||||
|
||||
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0
|
||||
const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
|
||||
/* ── 视频总时长计算 ── */
|
||||
const totalVideoDuration = useMemo(() => {
|
||||
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
|
||||
if (exact > 0) return exact
|
||||
return estimateTotalVideoDuration(currentTemplate ?? undefined)
|
||||
}, [previewAssets, currentTemplate])
|
||||
/* ── Step4 预览生成(多预览 + voice_ids) ── */
|
||||
const step4Preview = useStep4Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
})
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
@@ -192,6 +119,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: step4Preview.canProceed,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -222,25 +150,22 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
|
||||
previewTaskId,
|
||||
bgmConfig,
|
||||
onGenerationSuccess: () => {
|
||||
setPreviewTaskId(null)
|
||||
setStoredSourceEditPlanId(null)
|
||||
},
|
||||
previewTaskId: step4Preview.selectedTaskId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
渲染 — 主页面
|
||||
================================================================ */
|
||||
|
||||
return (
|
||||
<div className="xx-generate-page">
|
||||
{/* ── 页头 ── */}
|
||||
<GenerateHeader fromEditPlan={!!editPlanId} />
|
||||
|
||||
{/* ── 步骤条 ── */}
|
||||
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
|
||||
{/* ── 主布局 ── */}
|
||||
<div className="xx-generate-layout">
|
||||
{/* ════ 左侧:表单区 ════ */}
|
||||
<div className="xx-generate-form">
|
||||
@@ -257,26 +182,11 @@ const GeneratePage: React.FC = () => {
|
||||
onSmartSelectedIdsChange={setSmartSelectedIds}
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={setTitleSettings}
|
||||
onUpdatePosition={styleUpdaters.updatePosition}
|
||||
onUpdateFont={styleUpdaters.updateFont}
|
||||
onUpdateSize={styleUpdaters.updateSize}
|
||||
onToggleBold={styleUpdaters.toggleBold}
|
||||
onToggleItalic={styleUpdaters.toggleItalic}
|
||||
onToggleStroke={styleUpdaters.toggleStroke}
|
||||
onToggleShadow={styleUpdaters.toggleShadow}
|
||||
onApplyPreset={styleUpdaters.applyPreset}
|
||||
activePreset={styleUpdaters.activePreset}
|
||||
titlePresets={styleUpdaters.titlePresets}
|
||||
onPreviewTaskCreated={setPreviewTaskId}
|
||||
onSourceEditPlanIdExtracted={setStoredSourceEditPlanId}
|
||||
bgm={bgm}
|
||||
bgmConfig={bgmConfig}
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
duration={duration}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
@@ -296,6 +206,21 @@ const GeneratePage: React.FC = () => {
|
||||
onRetry={handleRetryGenerate}
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
/* Step4 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step4Preview.items}
|
||||
previewSelectedIndex={step4Preview.selectedIndex}
|
||||
onSelectPreview={step4Preview.setSelectedIndex}
|
||||
previewOverallStatus={step4Preview.previewStatus}
|
||||
previewOverallError={step4Preview.previewError}
|
||||
previewOverallProgress={step4Preview.progress}
|
||||
previewAnyGenerating={step4Preview.anyGenerating}
|
||||
previewTemplateName={step4Preview.templateName}
|
||||
previewMaterialCount={step4Preview.materialCount}
|
||||
onGeneratePreview={step4Preview.generatePreview}
|
||||
onRegeneratePreview={step4Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -309,29 +234,24 @@ const GeneratePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:预览 + 结果 ════ */}
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{currentStep >= 4 && !!currentTemplate && (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
{/* 预览视频面板(Step4+ 常驻,展示选中的预览) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step4Preview.previewStatus}
|
||||
previewResult={step4Preview.previewResult}
|
||||
previewError={step4Preview.previewError}
|
||||
progress={step4Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
ready={previewAssets.length > 0}
|
||||
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,
|
||||
}}
|
||||
onRegenerate={step4Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={currentStep >= 5 ? titleSettings : undefined}
|
||||
/>
|
||||
)}
|
||||
{currentStep >= 6 && (
|
||||
|
||||
{/* 正式生成结果(Step5+ 才显示) */}
|
||||
{currentStep >= 5 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
@@ -350,9 +270,8 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频预览弹窗 */}
|
||||
{/* ── 视频预览弹窗 ── */}
|
||||
<Modal
|
||||
className="xx-preview-modal"
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
footer={null}
|
||||
@@ -373,7 +292,7 @@ const GeneratePage: React.FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 音色克隆弹窗 */}
|
||||
{/* ── 音色克隆弹窗 ── */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
|
||||
@@ -1,696 +0,0 @@
|
||||
/**
|
||||
* 前端预览播放器 — Canvas + WebCodecs 方案
|
||||
*
|
||||
* 架构:
|
||||
* - 浏览器支持 WebCodecs → Canvas 渲染(帧级精确控制 + 标题合成)
|
||||
* - 浏览器不支持 → fallback 到多 video 元素方案
|
||||
*
|
||||
* 对外 API 不变:assets, template, videoRatio, ready, voiceAudioUrl
|
||||
*/
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
assets: AssetItem[]
|
||||
template: EditingTemplate | null
|
||||
videoRatio: string
|
||||
ready: boolean
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
title: string
|
||||
size: number
|
||||
font: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom"
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将素材映射为播放片段(复用原逻辑)
|
||||
*/
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
template: EditingTemplate | null,
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length) return []
|
||||
|
||||
const templateSegments = template?.segments || []
|
||||
const segments: PlaybackSegment[] = []
|
||||
|
||||
assets.forEach((asset, i) => {
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
const startTime = 0
|
||||
const endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
|
||||
})
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
ready,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
|
||||
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
|
||||
const TITLE_MARGIN_TOP = 60
|
||||
const TITLE_MARGIN_BOTTOM = 60
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
const playRes = (() => {
|
||||
switch (videoRatio) {
|
||||
case "16:9":
|
||||
return { width: 1920, height: 1080 }
|
||||
case "1:1":
|
||||
return { width: 1080, height: 1080 }
|
||||
case "9:16":
|
||||
default:
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
})()
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
useEffect(() => {
|
||||
const el = playerContainerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height
|
||||
if (h > 0) setContainerHeight(h)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height > 0) setContainerHeight(rect.height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// 标题字号按容器高度与 PlayResY 的比例缩放
|
||||
const titleFontSizePx =
|
||||
containerHeight > 0
|
||||
? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight
|
||||
: (titleSettings?.size ?? 36)
|
||||
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
|
||||
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
|
||||
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
|
||||
// 描边/阴影也要按缩放比例放大
|
||||
const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1
|
||||
const titleStrokeWidth = Math.max(1, 2 * titleScale)
|
||||
const titleShadowBlur = 4 * titleScale
|
||||
const titleShadowOffset = 2 * titleScale
|
||||
|
||||
// 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
|
||||
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
|
||||
const useWebCodecs = false
|
||||
|
||||
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// ── Canvas 播放器(WebCodecs 路径) ──
|
||||
const canvasTitle = titleSettings
|
||||
? {
|
||||
text: titleSettings.title || "标题预览",
|
||||
fontSize: titleSettings.size,
|
||||
fontFamily: titleSettings.font || "思源黑体",
|
||||
color: titleSettings.color || "#ffffff",
|
||||
position: titleSettings.position || "bottom",
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const canvasSegments = useMemo(
|
||||
() =>
|
||||
segments.map((s) => ({
|
||||
assetId: s.assetId,
|
||||
videoUrl: s.videoUrl,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
})),
|
||||
[segments],
|
||||
)
|
||||
|
||||
// WebCodecs 解码失败后强制走 video fallback
|
||||
const [forceVideoFallback, setForceVideoFallback] = useState(false)
|
||||
|
||||
const handleCanvasError = useCallback((err: Error) => {
|
||||
console.error("[FrontendPreviewPlayer] Canvas decode Error, switching to video fallback:", err)
|
||||
setForceVideoFallback(true)
|
||||
}, [])
|
||||
|
||||
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
|
||||
canvasRef,
|
||||
useWebCodecs && !forceVideoFallback ? canvasSegments : [],
|
||||
useWebCodecs && !forceVideoFallback ? canvasTitle : undefined,
|
||||
handleCanvasError,
|
||||
useWebCodecs && !forceVideoFallback,
|
||||
)
|
||||
|
||||
// WebCodecs 报告解码失败时自动切换到 video fallback
|
||||
useEffect(() => {
|
||||
if (canvasState.hasDecodeError && !forceVideoFallback) {
|
||||
console.warn("[FrontendPreviewPlayer] hasDecodeError detected, forcing video fallback")
|
||||
setForceVideoFallback(true)
|
||||
}
|
||||
}, [canvasState.hasDecodeError, forceVideoFallback])
|
||||
|
||||
// ── Video 播放器(fallback 路径) ──
|
||||
const {
|
||||
isPlaying: videoIsPlaying,
|
||||
currentTime: videoCurrentTime,
|
||||
totalDuration: videoTotalDuration,
|
||||
currentSegmentIndex: videoCurrentSegIdx,
|
||||
canPlay: videoCanPlay,
|
||||
togglePlayPause: videoTogglePlayPause,
|
||||
seekTo: videoSeekTo,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
// 选择哪条路径的状态(WebCodecs 解码失败时强制走 video fallback)
|
||||
const effectiveUseWebCodecs = useWebCodecs && !forceVideoFallback
|
||||
const isPlaying = effectiveUseWebCodecs ? canvasState.isPlaying : videoIsPlaying
|
||||
const currentTime = effectiveUseWebCodecs ? canvasState.currentTime : videoCurrentTime
|
||||
const totalDuration = effectiveUseWebCodecs ? canvasState.duration : videoTotalDuration
|
||||
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
|
||||
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
|
||||
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
audioRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio()
|
||||
audioRef.current.preload = "auto"
|
||||
}
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
}, [voiceAudioUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
if (isPlaying && !prevIsPlayingRef.current) {
|
||||
audio.currentTime = currentTime
|
||||
audio.play().catch(() => {})
|
||||
} else if (!isPlaying && prevIsPlayingRef.current) {
|
||||
audio.pause()
|
||||
}
|
||||
prevIsPlayingRef.current = isPlaying
|
||||
}, [isPlaying, currentTime])
|
||||
|
||||
// 片段切换时同步音频(仅 fallback 路径需要)
|
||||
const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src || !isPlaying) return
|
||||
audio.currentTime = currentTime
|
||||
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segmentSyncKey, isPlaying])
|
||||
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.seek(time)
|
||||
} else {
|
||||
videoSeekTo(time)
|
||||
}
|
||||
const audio = audioRef.current
|
||||
if (audio && audio.src) {
|
||||
audio.currentTime = time
|
||||
}
|
||||
},
|
||||
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
|
||||
)
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) {
|
||||
canvasControls.pause()
|
||||
} else {
|
||||
canvasControls.play()
|
||||
}
|
||||
} else {
|
||||
videoTogglePlayPause()
|
||||
}
|
||||
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
},
|
||||
[totalDuration, handleSeekTo],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true)
|
||||
handleProgressClick(e)
|
||||
},
|
||||
[handleProgressClick],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
}
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, totalDuration, handleSeekTo])
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
// ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ──
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
aspectRatio: "9 / 16",
|
||||
background: "#0a0a0a",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }} />
|
||||
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
|
||||
准备预览素材...
|
||||
</p>
|
||||
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
|
||||
加载素材后即可预览播放
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
aspectRatio: "9 / 16",
|
||||
background: "#0a0a0a",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ fontSize: 40, color: "#fff", marginBottom: 12 }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14, margin: 0 }}>加载中...</p>
|
||||
</>
|
||||
) : showDecodeError ? (
|
||||
<>
|
||||
<PlayCircleOutlined style={{ fontSize: 40, color: "#ef4444", marginBottom: 12 }} />
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: 14,
|
||||
margin: "0 0 4px",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
视频解码失败
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 12,
|
||||
margin: 0,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
|
||||
/>
|
||||
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
|
||||
暂无可播放素材
|
||||
</p>
|
||||
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
|
||||
请先在左侧选择素材
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
aspectRatio: "9 / 16",
|
||||
background: "#0a0a0a",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
|
||||
{effectiveUseWebCodecs && (
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
background: "#000",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
|
||||
{!effectiveUseWebCodecs &&
|
||||
segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
muted
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
preload="auto"
|
||||
src={seg.videoUrl}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
background: "#000",
|
||||
zIndex: 1,
|
||||
opacity: i === videoCurrentSegIdx ? 1 : 0,
|
||||
pointerEvents: i === videoCurrentSegIdx ? "auto" : "none",
|
||||
}}
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
|
||||
{titleSettings?.title && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 5,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${titleSidePct}%`,
|
||||
right: `${titleSidePct}%`,
|
||||
textAlign: "center",
|
||||
...(titleSettings.position === "top"
|
||||
? { top: `${titleTopPct}%` }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${titleFontSizePx}px`,
|
||||
fontFamily: titleSettings.font || "思源黑体",
|
||||
color: titleSettings.color || "#ffffff",
|
||||
fontWeight: titleSettings.bold ? 700 : 400,
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
lineHeight: 1.3,
|
||||
wordBreak: "break-word",
|
||||
WebkitTextStroke: titleSettings.stroke
|
||||
? `${titleStrokeWidth}px #000000`
|
||||
: undefined,
|
||||
textShadow: titleSettings.shadow
|
||||
? `${titleShadowOffset}px ${titleShadowOffset}px ${titleShadowBlur}px rgba(0,0,0,0.8)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{titleSettings.title.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 中央播放按钮 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "50%",
|
||||
width: 52,
|
||||
height: 52,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 26,
|
||||
zIndex: 10,
|
||||
transition: "transform 0.2s ease, background 0.2s ease",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 — 右上角胶囊 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
zIndex: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{`${videoCurrentSegIdx + 1} / ${segments.length}`}
|
||||
</div>
|
||||
|
||||
{/* 控制条 — 手机风格毛玻璃 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 16px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
cursor: "pointer",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
minWidth: 72,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 3,
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
transition: isDragging ? "none" : "width 0.1s linear",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: `${progressPercent}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
|
||||
opacity: isDragging ? 1 : 0,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FrontendPreviewPlayer
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* V24: 移除 Step5 预览生成相关 props,改为纯标题样式编辑
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -11,11 +9,12 @@ import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step5GeneratePreview from "../components/Step5GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step4GeneratePreview from "../components/Step4GeneratePreview"
|
||||
import Step5TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
@@ -36,17 +35,6 @@ export interface GenerateStepContentProps {
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/* 标题样式回调 — Step5 样式面板使用 */
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
onToggleBold: () => void
|
||||
onToggleItalic: () => void
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
/* 封面 */
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
@@ -54,7 +42,6 @@ export interface GenerateStepContentProps {
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
@@ -76,14 +63,21 @@ export interface GenerateStepContentProps {
|
||||
onDismissError: () => void
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
/** 预览任务创建回调——传递给 Step6CoverSettings */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** BGM 开关 */
|
||||
bgm: boolean
|
||||
/** BGM 配置(来自模板) */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
videoRatio: string
|
||||
/* Step4 预览(多预览) */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
previewItems: PreviewItem[]
|
||||
previewSelectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
previewOverallStatus: PreviewStatus
|
||||
previewOverallError: string
|
||||
previewOverallProgress: number
|
||||
previewAnyGenerating: boolean
|
||||
previewTemplateName: string
|
||||
previewMaterialCount: string
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
@@ -100,22 +94,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSmartSelectedIdsChange,
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
clonedVoices,
|
||||
@@ -129,16 +112,22 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
previewItems,
|
||||
previewSelectedIndex,
|
||||
onSelectPreview,
|
||||
previewOverallStatus,
|
||||
previewOverallError,
|
||||
previewOverallProgress,
|
||||
previewAnyGenerating,
|
||||
previewTemplateName,
|
||||
previewMaterialCount,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
|
||||
/* 当前模板的 segments,传给 Step2 构建 clips */
|
||||
const currentTemplate = userTemplates.find((t) => t.id === selectedTemplate)
|
||||
const templateSegments = currentTemplate?.segments
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
@@ -157,8 +146,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSelectedMaterialsChange={onSelectedMaterialsChange}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
templateSegments={templateSegments}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -166,31 +153,33 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
<Step3VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
<Step4GeneratePreview
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
items={previewItems}
|
||||
selectedIndex={previewSelectedIndex}
|
||||
onSelectPreview={onSelectPreview}
|
||||
overallStatus={previewOverallStatus}
|
||||
overallError={previewOverallError}
|
||||
overallProgress={previewOverallProgress}
|
||||
anyGenerating={previewAnyGenerating}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5GeneratePreview
|
||||
<Step5TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
@@ -201,14 +190,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
duration={duration}
|
||||
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
|
||||
selectedTemplate={selectedTemplate}
|
||||
titleSettings={titleSettings}
|
||||
onPreviewTaskCreated={onPreviewTaskCreated}
|
||||
onSourceEditPlanIdExtracted={onSourceEditPlanIdExtracted}
|
||||
voiceMode={voiceMode}
|
||||
selectedVoice={selectedVoice}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
bgm={bgm}
|
||||
bgmConfig={bgmConfig}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
|
||||
@@ -1,371 +1,387 @@
|
||||
/**
|
||||
* 右侧预览视频面板 — 服务器渲染预览架构
|
||||
* 右侧预览视频面板
|
||||
* Step4 生成预览后常驻显示预览视频
|
||||
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
*
|
||||
* Step4+: 显示预览面板
|
||||
* Step5: 播放服务器渲染的真实视频(POST /generation/preview)
|
||||
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
|
||||
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
|
||||
*
|
||||
* 架构:
|
||||
* - 进入 Step4/5 时自动创建服务器预览渲染任务
|
||||
* - 轮询完成后用 <video> 标签播放返回的 video_url
|
||||
* - 标题样式编辑时 CSS TitleOverlay 实时叠加预览
|
||||
* - 素材/配音/BGM 变更自动重新渲染
|
||||
* - 标题文字/样式变更标记 stale,保留旧视频 + 显示"重新预览"按钮
|
||||
*
|
||||
* 点"确认生成"时走 confirm 路径,成品就是预览视频本身,100% 一致。
|
||||
* Canvas 居中修复说明:
|
||||
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect),
|
||||
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
|
||||
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移。
|
||||
*/
|
||||
import React, { useMemo, useRef, useState, useEffect } from "react"
|
||||
import { LoadingOutlined, ReloadOutlined, ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
import { Button } from "antd"
|
||||
import React, { useRef, useEffect, useCallback } from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { getFontFamily } from "../constants"
|
||||
import type { ServerPreviewStatus } from "../hooks/useServerPreview"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
/** 服务器预览状态 */
|
||||
previewStatus: ServerPreviewStatus
|
||||
/** 服务器渲染视频 URL */
|
||||
videoUrl: string | null
|
||||
/** 渲染进度 0-100 */
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
/** 错误信息 */
|
||||
error: string | null
|
||||
/** 重新预览回调 */
|
||||
onRetry: () => void
|
||||
/** 视频比例 */
|
||||
videoRatio: string
|
||||
/** 标题设置 — CSS 实时预览层 */
|
||||
onRegenerate: () => void
|
||||
/** 标题文字(Step5 起传入) */
|
||||
titleText?: string
|
||||
/** 标题样式设置(Step5 起传入) */
|
||||
titleSettings?: TitleSettings
|
||||
/** 素材数量 */
|
||||
assetCount?: number
|
||||
}
|
||||
|
||||
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
|
||||
const TITLE_MARGIN_TOP = 60
|
||||
const TITLE_MARGIN_BOTTOM = 60
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
/* ── Canvas 绘制工具函数 ── */
|
||||
|
||||
/** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY) */
|
||||
function getResolution(ratio: string): { width: number; height: number } {
|
||||
switch (ratio) {
|
||||
case "16:9":
|
||||
return { width: 1920, height: 1080 }
|
||||
case "1:1":
|
||||
return { width: 1080, height: 1080 }
|
||||
case "9:16":
|
||||
default:
|
||||
return { width: 1080, height: 1920 }
|
||||
/**
|
||||
* 将文本按 maxWidth 逐字换行,返回行数组。
|
||||
* 与 ASS 字幕引擎的逐字换行行为一致。
|
||||
*/
|
||||
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||||
const lines: string[] = []
|
||||
let currentLine = ""
|
||||
for (const char of text) {
|
||||
const testLine = currentLine + char
|
||||
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
|
||||
lines.push(currentLine)
|
||||
currentLine = char
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push(currentLine)
|
||||
return lines
|
||||
}
|
||||
|
||||
function getPositionStyle(
|
||||
position: string,
|
||||
playResX: number,
|
||||
playResY: number,
|
||||
): React.CSSProperties {
|
||||
const sidePercent = (TITLE_MARGIN_SIDE / playResX) * 100
|
||||
switch (position) {
|
||||
case "bottom":
|
||||
return {
|
||||
bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
case "center":
|
||||
return {
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
case "top":
|
||||
default:
|
||||
return {
|
||||
top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTitleStyle(
|
||||
/**
|
||||
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
|
||||
*
|
||||
* Canvas 已通过 CSS 定位到视频实际渲染位置,
|
||||
* 坐标系基于 Canvas 自身尺寸,居中直接使用 w/2。
|
||||
*
|
||||
* @param ctx canvas 上下文
|
||||
* @param w canvas CSS 宽度(= 视频渲染宽度)
|
||||
* @param h canvas CSS 高度(= 视频渲染高度)
|
||||
* @param text 标题文字
|
||||
* @param settings 标题样式
|
||||
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
|
||||
* @param position "top" | "center" | "bottom"
|
||||
* @param topOffset 顶部/底部偏移量
|
||||
*/
|
||||
function drawTitleOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
text: string,
|
||||
settings: TitleSettings,
|
||||
containerHeight: number,
|
||||
playResY: number,
|
||||
): React.CSSProperties {
|
||||
// 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致)
|
||||
const fontSizePx =
|
||||
containerHeight > 0
|
||||
? (settings.size / playResY) * containerHeight
|
||||
: (settings.size / playResY) * 400
|
||||
paddingX: number,
|
||||
position: string,
|
||||
topOffset: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: getFontFamily(settings.font),
|
||||
fontSize: `${fontSizePx}px`,
|
||||
color: settings.color || "#ffffff",
|
||||
fontWeight: settings.bold ? 700 : 400,
|
||||
fontStyle: settings.italic ? "italic" : "normal",
|
||||
lineHeight: 1.3,
|
||||
wordBreak: "break-word",
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
// 设置 canvas 物理像素尺寸(高清屏适配)
|
||||
ctx.canvas.width = Math.round(w * dpr)
|
||||
ctx.canvas.height = Math.round(h * dpr)
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// 清除
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// 可用宽度 = 总宽 - 左右边距
|
||||
const availableWidth = w - paddingX * 2
|
||||
if (availableWidth <= 0) return
|
||||
|
||||
// 字体设置
|
||||
const fontSize = Math.round(Math.min(settings.size, 36))
|
||||
const fontWeight = settings.bold ? "bold" : "normal"
|
||||
const fontStyle = settings.italic ? "italic" : "normal"
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
|
||||
|
||||
// 文字属性
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillStyle = settings.color
|
||||
|
||||
const lineHeight = fontSize * 1.4
|
||||
|
||||
// 描边 & 阴影
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.6)"
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = "round"
|
||||
}
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.7)"
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 2
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
// 换行
|
||||
const displayText = text && text.trim() ? text : "请选择或输入标题"
|
||||
const lines = wrapText(ctx, displayText, availableWidth)
|
||||
|
||||
// 起始 Y:根据 position 计算
|
||||
const totalTextHeight = lines.length * lineHeight
|
||||
let startY: number
|
||||
switch (position) {
|
||||
case "top":
|
||||
startY = topOffset
|
||||
break
|
||||
case "center":
|
||||
startY = (h - totalTextHeight) / 2 + lineHeight / 2
|
||||
break
|
||||
case "bottom":
|
||||
default:
|
||||
startY = h - topOffset - totalTextHeight + lineHeight / 2
|
||||
break
|
||||
}
|
||||
|
||||
// 居中 x = w/2(Canvas 已定位到视频位置,无需额外偏移)
|
||||
const x = w / 2
|
||||
lines.forEach((line, i) => {
|
||||
const y = startY + i * lineHeight
|
||||
if (settings.stroke) ctx.strokeText(line, x, y)
|
||||
ctx.fillText(line, x, y)
|
||||
})
|
||||
|
||||
// 重置 shadow(避免影响后续绘制)
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "transparent"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
if (settings.stroke) base.WebkitTextStroke = "1px #000000"
|
||||
if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
return base
|
||||
}
|
||||
|
||||
/** CSS 标题实时预览覆盖层 */
|
||||
const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({
|
||||
titleSettings,
|
||||
videoRatio,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height
|
||||
if (h > 0) setContainerHeight(h)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height > 0) setContainerHeight(rect.height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const { width: playResX, height: playResY } = getResolution(videoRatio)
|
||||
|
||||
const positionStyle = useMemo(
|
||||
() => getPositionStyle(titleSettings.position, playResX, playResY),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[titleSettings.position, playResX, playResY],
|
||||
)
|
||||
const titleStyle = useMemo(
|
||||
() => buildTitleStyle(titleSettings, containerHeight, playResY),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
containerHeight,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
titleSettings.bold,
|
||||
titleSettings.italic,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
playResY,
|
||||
],
|
||||
)
|
||||
|
||||
const displayTitle = titleSettings.title?.trim() || "标题预览"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 20,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ ...positionStyle, ...titleStyle, position: "absolute" }}>
|
||||
{displayTitle.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 主组件 ── */
|
||||
/* ── 组件 ── */
|
||||
|
||||
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
previewStatus,
|
||||
videoUrl,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
error,
|
||||
onRetry,
|
||||
videoRatio,
|
||||
onRegenerate,
|
||||
titleText,
|
||||
titleSettings,
|
||||
assetCount,
|
||||
}) => {
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
|
||||
const isLoading = previewStatus === "loading"
|
||||
const isReady = previewStatus === "ready" || previewStatus === "stale"
|
||||
const isFailed = previewStatus === "failed"
|
||||
const isIdle = previewStatus === "idle"
|
||||
const isStale = previewStatus === "stale"
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const showTitlePreview = !!titleSettings
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
// video 模式 refs
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
|
||||
const fontLoadedRef = useRef(false)
|
||||
|
||||
/** 在 video canvas 上绘制标题 */
|
||||
const drawVideoTitle = useCallback(() => {
|
||||
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
|
||||
if (!fontLoadedRef.current) return
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container || !titleSettings) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
if (containerRect.width <= 0 || containerRect.height <= 0) return
|
||||
|
||||
// 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
|
||||
const videoEl = videoRef.current
|
||||
let drawW = containerRect.width
|
||||
let drawH = containerRect.height
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
|
||||
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
|
||||
const videoRect = videoEl.getBoundingClientRect()
|
||||
drawW = videoRect.width
|
||||
drawH = videoRect.height
|
||||
offsetX = videoRect.left - containerRect.left
|
||||
offsetY = videoRect.top - containerRect.top
|
||||
}
|
||||
|
||||
// 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
|
||||
canvas.style.left = `${offsetX}px`
|
||||
canvas.style.top = `${offsetY}px`
|
||||
canvas.style.width = `${drawW}px`
|
||||
canvas.style.height = `${drawH}px`
|
||||
|
||||
// 绘制时,坐标系基于 Canvas 自身尺寸,无需额外偏移
|
||||
drawTitleOnCanvas(
|
||||
ctx,
|
||||
drawW,
|
||||
drawH,
|
||||
titleText || "",
|
||||
titleSettings,
|
||||
40,
|
||||
titleSettings.position,
|
||||
60,
|
||||
)
|
||||
}, [titleText, titleSettings])
|
||||
|
||||
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
|
||||
useEffect(() => {
|
||||
if (!showTitlePreview || !hasPreview) return
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
drawVideoTitle()
|
||||
})
|
||||
observer.observe(container)
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [showTitlePreview, hasPreview, drawVideoTitle])
|
||||
|
||||
// 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体
|
||||
useEffect(() => {
|
||||
if (!showTitlePreview || !titleSettings) {
|
||||
fontLoadedRef.current = false
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fontLoadedRef.current = false
|
||||
|
||||
const fontWeight = titleSettings.bold ? "bold" : ""
|
||||
const fontStyle = titleSettings.italic ? "italic" : ""
|
||||
const fontSpec =
|
||||
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
|
||||
|
||||
const onFontReady = () => {
|
||||
if (cancelled) return
|
||||
fontLoadedRef.current = true
|
||||
// ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态)
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) {
|
||||
drawVideoTitle()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (document.fonts.check(fontSpec)) {
|
||||
onFontReady()
|
||||
return
|
||||
}
|
||||
|
||||
document.fonts
|
||||
.load(fontSpec)
|
||||
.then(() => onFontReady())
|
||||
.catch(() => {
|
||||
document.fonts.ready.then(() => onFontReady())
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [showTitlePreview, titleSettings, drawVideoTitle])
|
||||
|
||||
// video 加载完成后重绘
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
if (showTitlePreview) {
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
}
|
||||
}, [showTitlePreview, drawVideoTitle])
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{isReady && !isStale && <span className="xx-preview-badge">服务器渲染</span>}
|
||||
{isStale && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#faad14",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleOutlined /> 配置已变更
|
||||
</span>
|
||||
)}
|
||||
{isLoading && <span className="xx-preview-badge">渲染中</span>}
|
||||
{hasPreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
</div>
|
||||
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
{/* 加载中 */}
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 5,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
}}
|
||||
>
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.9)", fontSize: 14 }}>
|
||||
正在渲染预览视频{progress > 0 ? `...${progress}%` : "..."}
|
||||
</p>
|
||||
<p style={{ marginTop: 4, color: "rgba(255,255,255,0.5)", fontSize: 12 }}>
|
||||
首次渲染约需 30-60 秒
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空闲状态(尚未触发预览) */}
|
||||
{isIdle && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "rgba(255,255,255,0.7)", fontSize: 14 }}>等待素材选择...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 服务器渲染的真实视频 */}
|
||||
{isReady && videoUrl && (
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
{/* 空状态:还没生成预览 */}
|
||||
{previewStatus === "idle" && (
|
||||
<div className="xx-preview-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
<p className="xx-preview-empty-title">暂无预览</p>
|
||||
<p className="xx-preview-empty-desc">在第 3 步生成预览后在此查看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标题样式实时预览层(仅在有视频时叠加) */}
|
||||
{isReady && titleSettings && (
|
||||
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
|
||||
)}
|
||||
|
||||
{/* stale 遮罩:配置变更提示 */}
|
||||
{isStale && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: "10px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.85))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "rgba(255,255,255,0.9)", fontSize: 12 }}>
|
||||
配置已变更,预览内容可能不是最新
|
||||
</span>
|
||||
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={onRetry}>
|
||||
重新预览
|
||||
</Button>
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
{previewStatus === "pending" ? "排队中..." : `生成中 ${progress}%`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-preview-progress-bar-wrap">
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{isFailed && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.7)",
|
||||
zIndex: 10,
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleOutlined style={{ fontSize: 40, color: "#ff4d4f" }} />
|
||||
<p
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
</div>
|
||||
<p className="xx-preview-error-msg">
|
||||
{typeof previewError === "string" && previewError ? previewError : "请重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onRegenerate}>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览成功 + Canvas 标题叠加 */}
|
||||
{hasPreview && (
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
onLoadedMetadata={handleVideoLoaded}
|
||||
/>
|
||||
</div>
|
||||
{showTitlePreview && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{error || "预览渲染失败"}
|
||||
</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetry}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
重新预览
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材信息 */}
|
||||
{assetCount !== undefined && assetCount > 0 && (
|
||||
{/* 预览信息 */}
|
||||
{hasPreview && previewResult && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>素材数</span>
|
||||
<span>{assetCount} 个</span>
|
||||
<span>时长</span>
|
||||
<span>
|
||||
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(1)}{" "}
|
||||
秒
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>片段数</span>
|
||||
<span>{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Step 2 素材选择组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { useStep2Materials } from "../hooks/useStep2Materials"
|
||||
import MaterialModeTabs from "./material/MaterialModeTabs"
|
||||
import ManualMaterialList from "./material/ManualMaterialList"
|
||||
@@ -16,10 +15,6 @@ interface Step2MaterialSelectProps {
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Step 4 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
|
||||
interface Step4GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
videoRatio: string
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
items: PreviewItem[]
|
||||
selectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
overallStatus: PreviewStatus
|
||||
overallError: string
|
||||
overallProgress: number
|
||||
anyGenerating: boolean
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
/** 预览数量选项 */
|
||||
const PREVIEW_COUNT_OPTIONS = [
|
||||
{ value: 1, label: "1个" },
|
||||
{ value: 2, label: "2个" },
|
||||
{ value: 3, label: "3个" },
|
||||
]
|
||||
|
||||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelectPreview,
|
||||
overallStatus,
|
||||
overallError,
|
||||
overallProgress,
|
||||
anyGenerating,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
|
||||
const isIdle = overallStatus === "idle"
|
||||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
|
||||
{/* 预览数量选择器(仅在 idle 状态显示) */}
|
||||
{isIdle && (
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: 14, color: "#666" }}>预览数量:</span>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{PREVIEW_COUNT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(opt.value)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
border: previewCount === opt.value ? "1px solid #1677ff" : "1px solid #d9d9d9",
|
||||
background: previewCount === opt.value ? "#e6f4ff" : "#fff",
|
||||
color: previewCount === opt.value ? "#1677ff" : "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: previewCount === opt.value ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={previewCount}
|
||||
onChange={(val) => val && onPreviewCountChange(val)}
|
||||
style={{ width: 70 }}
|
||||
placeholder="自定义"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#999", marginLeft: 4 }}>(1~10)</span>
|
||||
</div>
|
||||
{previewCount > 1 && (
|
||||
<span style={{ fontSize: 12, color: "#999" }}>生成多个预览可对比不同剪辑效果</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览生成按钮(idle 状态) */}
|
||||
{isIdle && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板、素材和配音,智能生成
|
||||
{previewCount > 1 ? `${previewCount}个不同版本的` : ""}视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览{previewCount > 1 ? `(${previewCount}个)` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体排队中(所有都在 pending) */}
|
||||
{anyGenerating && items.every((it) => it.status === "pending") && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||||
<div
|
||||
className="xx-preview-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||||
gap: 12,
|
||||
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
|
||||
margin: "0 auto 16px",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.index === selectedIndex
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
onClick={() => {
|
||||
if (item.status === "ready") onSelectPreview(item.index)
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||||
overflow: "hidden",
|
||||
cursor: item.status === "ready" ? "pointer" : "default",
|
||||
opacity: item.status === "error" ? 0.6 : 1,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{/* 缩略图/状态区域 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio,
|
||||
background: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{item.status === "ready" && item.result && (
|
||||
<video
|
||||
src={item.result.videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(item.status === "pending" || item.status === "generating") && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
|
||||
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
生成失败
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 选中角标 */}
|
||||
{isSelected && item.status === "ready" && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
fontSize: 10,
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息 */}
|
||||
{item.status === "ready" && item.result && (
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
background: "#fafafa",
|
||||
fontSize: 11,
|
||||
color: "#666",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>{item.result.duration.toFixed(1)}秒</span>
|
||||
<span>{item.result.clipCount}段</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体进度条(多预览生成中) */}
|
||||
{anyGenerating && (
|
||||
<div className="xx-preview-progress-bar" style={{ marginBottom: 12 }}>
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${overallProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部完成提示 */}
|
||||
{overallStatus === "ready" && (
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>
|
||||
{items.filter((it) => it.status === "ready").length} 个预览生成成功
|
||||
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
|
||||
</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof overallError === "string" && overallError ? overallError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4GeneratePreview
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* Step 4 标题设置组件
|
||||
* 仅包含标题文字输入 + AI 标题生成
|
||||
* 标题样式面板已迁移到 Step5(生成预览页面)
|
||||
*/
|
||||
import React from "react"
|
||||
import { AutoComplete } from "antd"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
@@ -112,6 +110,23 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 样式面板始终可见,两种模式下都可调整标题展示样式 */}
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={t.updatePosition}
|
||||
onUpdateFont={t.updateFont}
|
||||
onUpdateSize={t.updateSize}
|
||||
onToggleBold={t.toggleBold}
|
||||
onToggleItalic={t.toggleItalic}
|
||||
onToggleStroke={t.toggleStroke}
|
||||
onToggleShadow={t.toggleShadow}
|
||||
onApplyPreset={t.applyPreset}
|
||||
activePreset={t.activePreset}
|
||||
titlePresets={t.titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Step 5 预览设置组件
|
||||
*
|
||||
* 前端实时预览架构:
|
||||
* - 右侧面板使用 FrontendPreviewPlayer 实时播放素材片段
|
||||
* - 标题样式可实时调整,CSS 层即时叠加预览
|
||||
* - 点"确认生成"时触发一次服务器渲染
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined } from "@ant-design/icons"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
|
||||
interface Step5GeneratePreviewProps {
|
||||
titleSettings: TitleSettings
|
||||
onUpdatePosition: (position: string) => void
|
||||
onUpdateFont: (font: string) => void
|
||||
onUpdateSize: (size: number) => void
|
||||
onToggleBold: () => void
|
||||
onToggleItalic: () => void
|
||||
onToggleStroke: () => void
|
||||
onToggleShadow: () => void
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
}
|
||||
|
||||
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
titleSettings,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 预览设置</h3>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "12px 16px",
|
||||
background: "rgba(59, 130, 246, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: "1px solid rgba(59, 130, 246, 0.15)",
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: "#3b82f6" }} />
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
右侧为实时预览,选完素材即可播放。确认生成后服务器渲染最终视频
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TitleStylePanel
|
||||
settings={titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step5GeneratePreview
|
||||
@@ -5,28 +5,16 @@
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { AudioOutlined, SoundOutlined, WarningOutlined } from "@ant-design/icons"
|
||||
import { Modal } from "antd"
|
||||
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
}
|
||||
|
||||
/** 格式化时长 mm:ss */
|
||||
/** 获取素材实际时长(优先顶层 duration,fallback 到 metadata.duration) */
|
||||
const getDuration = (item: AssetItem): number => {
|
||||
return item.duration ?? (item.metadata?.duration as number) ?? 0
|
||||
}
|
||||
|
||||
/** 获取素材实际文件大小 */
|
||||
const getFileSize = (item: AssetItem): number => {
|
||||
return item.file_size ?? (item.metadata?.file_size as number) ?? 0
|
||||
}
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
@@ -46,13 +34,10 @@ const formatFileSize = (bytes?: number): string => {
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration = 0,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [playingId, setPlayingId] = useState<string | 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({
|
||||
@@ -93,38 +78,14 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
[playingId],
|
||||
)
|
||||
|
||||
/** 选中素材(含时长校验) */
|
||||
/** 选中素材 */
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
// 如果启用了时长校验,且配音时长不足
|
||||
if (totalVideoDuration > 0) {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (material && getDuration(material) < totalVideoDuration) {
|
||||
setPendingVoiceId(id)
|
||||
setDurationWarningOpen(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
onSelectedVoiceChange(id)
|
||||
},
|
||||
[onSelectedVoiceChange, totalVideoDuration, materials],
|
||||
[onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
/** 确认使用时长不足的配音 */
|
||||
const handleConfirmUseAnyway = useCallback(() => {
|
||||
if (pendingVoiceId) {
|
||||
onSelectedVoiceChange(pendingVoiceId)
|
||||
}
|
||||
setDurationWarningOpen(false)
|
||||
setPendingVoiceId(null)
|
||||
}, [pendingVoiceId, onSelectedVoiceChange])
|
||||
|
||||
/** 取消选择 */
|
||||
const handleCancelSelection = useCallback(() => {
|
||||
setDurationWarningOpen(false)
|
||||
setPendingVoiceId(null)
|
||||
}, [])
|
||||
|
||||
/** 跳转到配音库上传 */
|
||||
const handleGoToUpload = useCallback(() => {
|
||||
navigate("/app/voices")
|
||||
@@ -277,66 +238,15 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<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>{formatFileSize(getFileSize(item))}</span>
|
||||
<span>{formatDuration(item.duration)}</span>
|
||||
<span>{formatFileSize(item.file_size)}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,22 +14,6 @@ interface Step6CoverSettingsProps {
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
titleSettings?: import("../types").TitleSettings
|
||||
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** 配音模式 */
|
||||
voiceMode?: "preset" | "custom" | "clone"
|
||||
/** 选中的配音素材 ID */
|
||||
selectedVoice?: string
|
||||
/** 选中的克隆音色 ID */
|
||||
selectedClonedVoice?: string
|
||||
/** BGM 开关 */
|
||||
bgm?: boolean
|
||||
/** BGM 配置 */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
@@ -56,14 +40,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
duration: props.duration,
|
||||
assetIds: props.assetIds,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
titleSettings: props.titleSettings,
|
||||
onPreviewTaskCreated: props.onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted: props.onSourceEditPlanIdExtracted,
|
||||
voiceMode: props.voiceMode,
|
||||
selectedVoice: props.selectedVoice,
|
||||
selectedClonedVoice: props.selectedClonedVoice,
|
||||
bgm: props.bgm,
|
||||
bgmConfig: props.bgmConfig,
|
||||
})
|
||||
|
||||
const handleAutoGenerate = () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl,
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 9:16 比例</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* 标题预设样式网格
|
||||
*/
|
||||
import React from "react"
|
||||
import { getFontFamily } from "../../constants"
|
||||
|
||||
interface TitlePresetItem {
|
||||
key: string
|
||||
@@ -36,7 +35,7 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
|
||||
>
|
||||
<span
|
||||
className="xx-title-preset-preview-text"
|
||||
style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
|
||||
style={{ ...p.previewStyle, ...(fontFamily ? { fontFamily } : {}) }}
|
||||
>
|
||||
标题
|
||||
</span>
|
||||
|
||||
@@ -32,8 +32,8 @@ export const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "生成预览" },
|
||||
{ key: 4, label: "生成预览" },
|
||||
{ key: 5, label: "选择标题" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
@@ -56,21 +56,6 @@ export const FONT_OPTIONS = [
|
||||
"华康俪金黑",
|
||||
]
|
||||
|
||||
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
|
||||
export const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
思源黑体: '"Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
思源宋体: '"Source Han Serif SC", "Noto Serif SC", "Songti SC", "SimSun", serif',
|
||||
苹方: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
|
||||
PingFang: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
|
||||
微软雅黑: '"Microsoft YaHei", "PingFang SC", sans-serif',
|
||||
楷体: '"KaiTi", "STKaiti", "DFKai-SB", serif',
|
||||
华康俪金黑: '"华康俪金黑", "DFLiJinHei-W8", "Source Han Sans SC", "Microsoft YaHei", sans-serif',
|
||||
}
|
||||
|
||||
export function getFontFamily(font: string): string {
|
||||
return FONT_FAMILY_MAP[font] || FONT_FAMILY_MAP["思源黑体"]
|
||||
}
|
||||
|
||||
/* ── 标题样式预设 ── */
|
||||
export const TITLE_PRESETS = [
|
||||
{
|
||||
|
||||
@@ -891,16 +891,17 @@
|
||||
|
||||
/* ── 视频预览 ── */
|
||||
.xx-preview-video {
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 36px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.xx-preview-video::before {
|
||||
@@ -909,7 +910,6 @@
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.xx-preview-video video {
|
||||
@@ -1516,7 +1516,7 @@
|
||||
.xx-smart-match-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2144,7 +2144,7 @@
|
||||
}
|
||||
|
||||
.xx-cover-frame-placeholder {
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
@@ -2247,7 +2247,7 @@
|
||||
}
|
||||
|
||||
.xx-cover-upload-area {
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
@@ -2308,9 +2308,7 @@
|
||||
|
||||
.xx-cover-preview-box {
|
||||
position: relative;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-width: 180px;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
@@ -2525,7 +2523,6 @@
|
||||
.xx-generate-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -2669,7 +2666,7 @@
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2775,7 +2772,7 @@
|
||||
|
||||
.xx-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2884,11 +2881,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-modal .ant-modal-content {
|
||||
.ant-modal-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.xx-preview-modal .ant-modal-close {
|
||||
.ant-modal-close {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,17 +19,8 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */
|
||||
sourceEditPlanId?: string | null
|
||||
/** 预览任务 ID(由 useStep6Cover 创建后写入,供 confirmGeneration 复用预览产物) */
|
||||
previewTaskId?: string | null
|
||||
/** BGM 配置(来自模板 bgm_config,受 bgm 开关控制) */
|
||||
bgmConfig?: {
|
||||
enabled: boolean
|
||||
music_id?: string
|
||||
}
|
||||
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
|
||||
onGenerationSuccess?: () => void
|
||||
/** 预览任务的 task_id(用于新确认生成 API) */
|
||||
previewTaskId: string
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
@@ -1,146 +1,87 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import axios from "axios"
|
||||
import { getGenerationTask } from "@/api/tasks/tasks"
|
||||
import { getGenerationTaskResults } from "@/api/template-editor"
|
||||
import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor"
|
||||
import { safeExtractError } from "./errorUtils"
|
||||
|
||||
interface UseGenerationPollingOptions {
|
||||
templateId: string
|
||||
onProgress: (progress: number) => void
|
||||
onComplete: (videos: unknown[]) => void
|
||||
onFailed: (errorMsg: string) => void
|
||||
}
|
||||
|
||||
/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */
|
||||
const MAX_RETRYABLE_ERRORS = 10
|
||||
/** 获取结果的最大重试次数 */
|
||||
const MAX_RESULTS_RETRIES = 3
|
||||
|
||||
/**
|
||||
* 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id})
|
||||
*
|
||||
* 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态,
|
||||
* 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks
|
||||
* 返回的 task_id 轮询任务详情,不再依赖 plan。
|
||||
*
|
||||
* 错误处理:
|
||||
* - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试
|
||||
* - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次
|
||||
* - 任务完成后获取结果失败会重试 MAX_RESULTS_RETRIES 次,仍失败则 onFailed
|
||||
* 生成状态轮询 Hook
|
||||
* 轮询生成状态,更新进度,处理完成/失败
|
||||
*/
|
||||
export const useGenerationPolling = ({
|
||||
templateId,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onFailed,
|
||||
}: UseGenerationPollingOptions) => {
|
||||
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const cancelledRef = useRef(false)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
if (progressTimer.current) {
|
||||
clearTimeout(progressTimer.current)
|
||||
progressTimer.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 任务完成后拉取结果列表,带重试 */
|
||||
const fetchResultsWithRetry = useCallback(
|
||||
async (taskId: string, attempt = 0): Promise<unknown[] | null> => {
|
||||
const startPolling = useCallback(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
return await getGenerationTaskResults(taskId)
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return null
|
||||
console.error(`[获取生成结果失败] 第 ${attempt + 1} 次`, err)
|
||||
if (attempt < MAX_RESULTS_RETRIES - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)))
|
||||
return fetchResultsWithRetry(taskId, attempt + 1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
const data = await getGenerationStatus(templateId)
|
||||
|
||||
const startPolling = useCallback(
|
||||
(taskId: string) => {
|
||||
cancelledRef.current = false
|
||||
let consecutiveErrors = 0
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelledRef.current) return
|
||||
try {
|
||||
const task = await getGenerationTask(taskId)
|
||||
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
|
||||
if (data.plan_status === "completed") {
|
||||
onProgress(100)
|
||||
// 获取生成的视频结果
|
||||
let videos: unknown[] = []
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
videos = await getGenerationTaskResults(data.generation_task_id)
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err)
|
||||
}
|
||||
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)
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
const dataAny = data as unknown as Record<string, unknown>
|
||||
const rawMsg =
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(Array.isArray(data.clips)
|
||||
? (data.clips as { status: string; error_message?: string }[]).find(
|
||||
(c) => c.status === "failed",
|
||||
)?.error_message
|
||||
: undefined) ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
const errorMsg = safeExtractError(rawMsg)
|
||||
console.error("[生成失败] templateId:", templateId, "响应:", data)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 1500)
|
||||
},
|
||||
[onProgress, onComplete, onFailed, fetchResultsWithRetry],
|
||||
)
|
||||
const clips = data.clips || []
|
||||
const total = clips.length || 1
|
||||
const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length
|
||||
onProgress(Math.round((done / total) * 100))
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
} catch (pollErr) {
|
||||
console.error("[轮询出错] templateId:", templateId, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
}, [templateId, onProgress, onComplete, onFailed])
|
||||
|
||||
return { startPolling, clearTimer }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* 草稿自动保存工具 Hook
|
||||
*
|
||||
* 背景:后端 PUT /templates/{id}/editor 的 config 是「整体替换」语义,
|
||||
* 直接发送 { config: { asset_ids } } 会把 title 等其他字段覆盖掉。
|
||||
* 本 Hook 统一执行「GET 当前 config → 浅合并新字段 → PUT 回去」,
|
||||
* 并用串行队列 + AbortController 保证:
|
||||
* - 同一时刻只有一个保存请求在飞
|
||||
* - 快速连续变化时只提交最后一次
|
||||
* - 组件卸载时取消未完成请求
|
||||
* - 保存失败时保留补丁,自动重试(指数退避,最多 5 次)
|
||||
*
|
||||
* 保存失败只 console.warn,不弹窗、不阻塞。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { getEditPlan, updateEditPlan } from "@/api/template-editor"
|
||||
|
||||
type ConfigPatch = Record<string, unknown>
|
||||
|
||||
/** 最大自动重试次数 */
|
||||
const MAX_RETRIES = 5
|
||||
/** 初始重试延迟(ms),每次翻倍 */
|
||||
const BASE_RETRY_DELAY = 1000
|
||||
|
||||
export function useDraftAutoSave(templateId?: string) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// 待合并的补丁队列(解决「保存进行中又来了新变化」)
|
||||
const pendingPatchRef = useRef<ConfigPatch | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
const templateIdRef = useRef(templateId)
|
||||
templateIdRef.current = templateId
|
||||
|
||||
const flush = useCallback(async (retryCount = 0) => {
|
||||
const tid = templateIdRef.current
|
||||
if (!tid) return
|
||||
// 已有保存在飞:把新补丁暂存,等当前请求结束后再合并一次
|
||||
if (savingRef.current) return
|
||||
|
||||
// 快照当前补丁,但先不清空 —— 成功后才清除,失败时保留以便重试
|
||||
const patchToSave = pendingPatchRef.current
|
||||
if (!patchToSave) {
|
||||
savingRef.current = false
|
||||
return
|
||||
}
|
||||
savingRef.current = true
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
try {
|
||||
// 1. 读当前 config(拿最新,避免覆盖别人/别的步骤写入的字段)
|
||||
const current = await getEditPlan(tid)
|
||||
if (controller.signal.aborted) return
|
||||
const merged = { ...(current.config || {}), ...patchToSave }
|
||||
// 2. 写回完整合并后的 config
|
||||
await updateEditPlan(tid, { config: merged }, controller.signal)
|
||||
// 3. 保存成功才清除已保存的补丁
|
||||
// (保存期间可能有新补丁进来,只清除我们已经保存的部分)
|
||||
pendingPatchRef.current = null
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name === "CanceledError" || name === "AbortError") {
|
||||
// 组件卸载或新请求取消,不重试
|
||||
return
|
||||
}
|
||||
console.warn("[useDraftAutoSave] 自动保存草稿失败:", err)
|
||||
|
||||
// 保存失败:把本次尝试保存的补丁合并回 pendingPatchRef
|
||||
// (保存期间可能有新补丁,新补丁优先)
|
||||
pendingPatchRef.current = {
|
||||
...patchToSave,
|
||||
...(pendingPatchRef.current || {}),
|
||||
}
|
||||
|
||||
// 指数退避重试
|
||||
if (retryCount < MAX_RETRIES && !controller.signal.aborted) {
|
||||
const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount)
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush(retryCount + 1)
|
||||
}, delay)
|
||||
}
|
||||
// 超过最大重试次数后,补丁仍保留在 pendingPatchRef 中,
|
||||
// 下次 scheduleSave 触发时会一起带上
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
// 保存期间又积累了新变化(且不是在重试路径中),再触发一次
|
||||
if (pendingPatchRef.current && !controller.signal.aborted && retryCount === 0) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 调度一次自动保存(防抖)
|
||||
* @param patch 要合并进 config 的局部字段
|
||||
* @param delay 防抖毫秒数
|
||||
*/
|
||||
const scheduleSave = useCallback(
|
||||
(patch: ConfigPatch, delay = 500) => {
|
||||
const tid = templateIdRef.current
|
||||
if (!tid) return
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
// 累计补丁(同一周期内多次变化合并成一次写入)
|
||||
pendingPatchRef.current = { ...(pendingPatchRef.current || {}), ...patch }
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush()
|
||||
}, delay)
|
||||
},
|
||||
[flush],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { scheduleSave }
|
||||
}
|
||||
|
||||
export default useDraftAutoSave
|
||||
@@ -14,7 +14,6 @@ import { useTemplateSelection } from "./useTemplateSelection"
|
||||
import { useTitleCoverSync } from "./useTitleCoverSync"
|
||||
import { useVoiceState } from "./useVoiceState"
|
||||
import { usePlanConfigLoader } from "./usePlanConfigLoader"
|
||||
import { usePersistedState } from "../usePersistedState"
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
@@ -83,27 +82,11 @@ export interface GenerateFormState {
|
||||
editPlanId: string | null
|
||||
planConfigStr: string | null
|
||||
|
||||
/**
|
||||
* 传给 Worker 的 source_edit_plan_id。
|
||||
* 仅使用 URL 中的 edit_plan_id(从剪辑模板编辑器跳转时携带)。
|
||||
* URL 没有时传 null,后端正式生成 API 会通过 template_id+user_id 兜底查找正确的 plan。
|
||||
* 注意:selectedTemplate 是模板 ID,不是 edit_plan_id,不能作为此值传递。
|
||||
*/
|
||||
sourceEditPlanId: string | null
|
||||
|
||||
/* 预览弹窗 */
|
||||
previewVideo: GeneratedVideo | null
|
||||
setPreviewVideo: (v: GeneratedVideo | null) => void
|
||||
previewModalOpen: boolean
|
||||
setPreviewModalOpen: (open: boolean) => void
|
||||
|
||||
/** 预览任务 ID(由 useStep6Cover 创建后写入,供 useGenerateVideo 复用) */
|
||||
previewTaskId: string | null
|
||||
setPreviewTaskId: (id: string | null) => void
|
||||
|
||||
/** 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) */
|
||||
storedSourceEditPlanId: string | null
|
||||
setStoredSourceEditPlanId: (planId: string | null) => void
|
||||
}
|
||||
|
||||
export const useGenerateFormState = (): GenerateFormState => {
|
||||
@@ -117,11 +100,6 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
/* ── 模板选择 ── */
|
||||
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
|
||||
|
||||
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
|
||||
// selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆;
|
||||
// 后端正式生成 API 会在 source_edit_plan_id 为空时通过 template_id+user_id 自动关联。
|
||||
const sourceEditPlanId = editPlanId || null
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
@@ -169,30 +147,6 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
/* ── 预览任务 ID(useStep6Cover 创建预览时写入,useGenerateVideo 复用) ── */
|
||||
// 持久化到 localStorage,key 按 editPlanId/templateId 区分,刷新页面后可恢复
|
||||
const previewStorageKey = editPlanId
|
||||
? `preview_task_id_${editPlanId}`
|
||||
: selectedTemplate
|
||||
? `preview_task_id_tpl_${selectedTemplate}`
|
||||
: null
|
||||
const [previewTaskId, setPreviewTaskId] = usePersistedState<string | null>(
|
||||
previewStorageKey,
|
||||
null,
|
||||
)
|
||||
|
||||
/* ── 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) ── */
|
||||
// 持久化到 localStorage,刷新页面后 fallback 路径仍能正确传递 source_edit_plan_id
|
||||
const planIdStorageKey = editPlanId
|
||||
? `source_edit_plan_id_${editPlanId}`
|
||||
: selectedTemplate
|
||||
? `source_edit_plan_id_tpl_${selectedTemplate}`
|
||||
: null
|
||||
const [storedSourceEditPlanId, setStoredSourceEditPlanId] = usePersistedState<string | null>(
|
||||
planIdStorageKey,
|
||||
null,
|
||||
)
|
||||
|
||||
/* ── 从 URL / 编辑计划加载配置 ── */
|
||||
usePlanConfigLoader({
|
||||
editPlanId,
|
||||
@@ -235,15 +189,10 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
editPlanId,
|
||||
sourceEditPlanId,
|
||||
planConfigStr,
|
||||
previewVideo,
|
||||
setPreviewVideo,
|
||||
previewModalOpen,
|
||||
setPreviewModalOpen,
|
||||
previewTaskId,
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
import { confirmGeneration } from "@/api/generation"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
import { validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { calculateResolution } from "../utils/calculateResolution"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const { selectedTemplate, onGenerationSuccess } = props
|
||||
const { selectedTemplate } = props
|
||||
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -24,22 +23,18 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
|
||||
const handleProgress = useCallback((p: number) => setProgress(p), [])
|
||||
const handleComplete = useCallback(
|
||||
(videos: unknown[]) => {
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
setGeneratedVideos(videos as GeneratedVideo[])
|
||||
// 生成成功后清除持久化的预览状态,避免下次进入复用旧任务
|
||||
onGenerationSuccess?.()
|
||||
},
|
||||
[onGenerationSuccess],
|
||||
)
|
||||
const handleComplete = useCallback((videos: unknown[]) => {
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
setGeneratedVideos(videos as GeneratedVideo[])
|
||||
}, [])
|
||||
const handleFailed = useCallback((errorMsg: string) => {
|
||||
setGenerating(false)
|
||||
setGenerateError(errorMsg)
|
||||
}, [])
|
||||
|
||||
const { startPolling, clearTimer } = useGenerationPolling({
|
||||
templateId: selectedTemplate,
|
||||
onProgress: handleProgress,
|
||||
onComplete: handleComplete,
|
||||
onFailed: handleFailed,
|
||||
@@ -60,61 +55,20 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 解析分辨率(共享工具函数)
|
||||
const { width: outputWidth, height: outputHeight } = calculateResolution(
|
||||
props.videoRatio || "9:16",
|
||||
)
|
||||
// 使用确认生成 API(基于预览任务)
|
||||
// 解析分辨率
|
||||
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
|
||||
const outputWidth = parseInt(widthStr, 10) || 1080
|
||||
const outputHeight = parseInt(heightStr, 10) || 1920
|
||||
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
|
||||
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
|
||||
const voiceLibraryId =
|
||||
props.voiceMode === "clone" ? props.selectedClonedVoice || "" : props.selectedVoice || ""
|
||||
|
||||
// 创建生成任务(服务器渲染)
|
||||
const taskResp = await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: coverUrl,
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
// 配音:优先用 voice_library_id(配音素材库 asset),兜底 voice_ids
|
||||
...(voiceLibraryId ? { voice_library_id: voiceLibraryId } : {}),
|
||||
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
|
||||
// BGM 配置:受 bgm 开关控制,enabled=false 时也显式传覆盖模板 BGM
|
||||
bgm_config: {
|
||||
enabled: props.bgm !== false,
|
||||
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
|
||||
},
|
||||
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
font_color: props.titleSettings.color,
|
||||
position: props.titleSettings.position,
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
custom_title: props.titleSettings.title || "",
|
||||
})
|
||||
const taskId = taskResp.items?.[0]?.id
|
||||
|
||||
if (!taskId) {
|
||||
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
|
||||
}
|
||||
startPolling(taskId)
|
||||
startPolling()
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
@@ -124,7 +78,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
}, [props, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
|
||||
/**
|
||||
* 持久化到 localStorage 的 state hook
|
||||
*
|
||||
* 用于在页面刷新后恢复 previewTaskId / sourceEditPlanId 等关键状态。
|
||||
* 当 localStorage 不可用(SSR、隐私模式等)时自动降级为普通 useState。
|
||||
* 当 key 变化时(例如切换模板),自动从新 key 重新读取并更新 state。
|
||||
*/
|
||||
export function usePersistedState<T>(
|
||||
key: string | null | undefined,
|
||||
defaultValue: T,
|
||||
): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const storageKey = key ? `xiaoxia_${key}` : null
|
||||
// defaultValue 用 ref 持有,避免作为 useEffect 依赖导致频繁重跑
|
||||
const defaultValueRef = useRef(defaultValue)
|
||||
defaultValueRef.current = defaultValue
|
||||
|
||||
const readFromStorage = useCallback((k: string | null): T => {
|
||||
if (!k) return defaultValueRef.current
|
||||
try {
|
||||
const stored = localStorage.getItem(k)
|
||||
if (stored !== null) {
|
||||
return JSON.parse(stored) as T
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage 不可用或 JSON 解析失败,使用默认值
|
||||
console.warn("[usePersistedState] 读取 localStorage 失败:", e)
|
||||
}
|
||||
return defaultValueRef.current
|
||||
}, [])
|
||||
|
||||
const [state, setState] = useState<T>(() => readFromStorage(storageKey))
|
||||
|
||||
// key 变化时(如切换模板/草稿),从新 key 重新读取,避免状态与存储不同步
|
||||
useEffect(() => {
|
||||
setState(readFromStorage(storageKey))
|
||||
}, [storageKey, readFromStorage])
|
||||
|
||||
const setPersistedState = useCallback(
|
||||
(value: T | ((prev: T) => T)) => {
|
||||
setState((prev) => {
|
||||
const nextValue = typeof value === "function" ? (value as (prev: T) => T)(prev) : value
|
||||
if (storageKey) {
|
||||
try {
|
||||
if (nextValue === null || nextValue === undefined || nextValue === "") {
|
||||
localStorage.removeItem(storageKey)
|
||||
} else {
|
||||
localStorage.setItem(storageKey, JSON.stringify(nextValue))
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage 写入失败(存储空间满/隐私模式),静默忽略
|
||||
console.warn("[usePersistedState] 写入 localStorage 失败:", e)
|
||||
}
|
||||
}
|
||||
return nextValue
|
||||
})
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
return [state, setPersistedState]
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* 预览素材加载 Hook
|
||||
* 根据选中的素材 ID 列表,逐个获取素材详情(含 file_url、duration 等)
|
||||
* 供前端预览播放器使用
|
||||
*
|
||||
* 注意:后端没有批量接口(/assets/batch 返回 405),
|
||||
* 因此直接使用 Promise.allSettled 并发请求单个 GET /assets/{id}
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { AxiosResponse } from "axios"
|
||||
|
||||
/**
|
||||
* 通过 ID 列表逐个获取素材(并发)
|
||||
* 使用 Promise.allSettled 确保单个失败不影响整体
|
||||
*/
|
||||
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
|
||||
if (!ids.length) return []
|
||||
|
||||
try {
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
|
||||
)
|
||||
return results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<AxiosResponse<AssetItem>> =>
|
||||
r.status === "fulfilled" && !!r.value?.data,
|
||||
)
|
||||
.map((r) => r.value.data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface UsePreviewAssetsReturn {
|
||||
/** 加载后的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 是否正在加载 */
|
||||
loading: boolean
|
||||
/** 是否已就绪(加载完成) */
|
||||
ready: boolean
|
||||
/** 手动触发重新加载 */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* usePreviewAssets — 加载选中素材的视频文件信息
|
||||
*/
|
||||
export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn {
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [ready, setReady] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// 稳定化 assetIds:只有内容真正变化时才更新引用
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!stableAssetIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
}
|
||||
|
||||
const thisRequestId = ++requestIdRef.current
|
||||
setLoading(true)
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(stableAssetIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
setReady(result.length > 0)
|
||||
}
|
||||
} catch {
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
}
|
||||
} finally {
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [stableAssetIds, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return { assets, loading, ready, reload: load }
|
||||
}
|
||||
|
||||
/**
|
||||
* useStableArray — 数组内容稳定化 Hook
|
||||
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
|
||||
*/
|
||||
function useStableArray<T>(array: T[]): T[] {
|
||||
const ref = useRef<T[]>(array)
|
||||
|
||||
// 比较数组内容是否真正变化
|
||||
const hasChanged =
|
||||
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
|
||||
|
||||
if (hasChanged) {
|
||||
ref.current = array
|
||||
}
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default usePreviewAssets
|
||||
@@ -1,365 +0,0 @@
|
||||
/**
|
||||
* 素材片段调度器 Hook(多 video 元素方案 v3)
|
||||
*
|
||||
* v3 修复:
|
||||
* - 所有动态状态存入 ref,tick 为稳定函数,彻底消除 RAF 闭包陷阱
|
||||
* - 片段切换时先启动下一个 video 再切可见性,消除冻屏间隔
|
||||
* - 进度更新 200ms 节流
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
export interface PlaybackSegment {
|
||||
assetId: string
|
||||
videoUrl: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface SegmentSchedulerState {
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
currentSegmentIndex: number
|
||||
segmentLocalTime: number
|
||||
isEnded: boolean
|
||||
canPlay: boolean
|
||||
play: () => void
|
||||
pause: () => void
|
||||
togglePlayPause: () => void
|
||||
seekTo: (time: number) => void
|
||||
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
|
||||
}
|
||||
|
||||
function findSegmentAtTime(
|
||||
segments: PlaybackSegment[],
|
||||
globalTime: number,
|
||||
): { index: number; localTime: number } {
|
||||
let accumulated = 0
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]
|
||||
const segDuration = seg.endTime - seg.startTime
|
||||
if (globalTime < accumulated + segDuration || i === segments.length - 1) {
|
||||
return { index: i, localTime: seg.startTime + (globalTime - accumulated) }
|
||||
}
|
||||
accumulated += segDuration
|
||||
}
|
||||
return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime }
|
||||
}
|
||||
|
||||
function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
const starts: number[] = []
|
||||
let acc = 0
|
||||
for (const seg of segments) {
|
||||
starts.push(acc)
|
||||
acc += seg.endTime - seg.startTime
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
|
||||
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
|
||||
const [isEnded, setIsEnded] = useState(false)
|
||||
const rafRef = useRef(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
const lastTimeUpdateRef = useRef(0)
|
||||
|
||||
// 所有动态值存入 ref,tick 始终读取最新值,不依赖闭包
|
||||
const segIdxRef = useRef(0)
|
||||
const segmentsRef = useRef(segments)
|
||||
const timelineStartsData = useMemo(() => buildTimeline(segments), [segments])
|
||||
const totalDurationData = useMemo(
|
||||
() => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0),
|
||||
[segments],
|
||||
)
|
||||
const timelineStartsRef = useRef(timelineStartsData)
|
||||
const totalDurationRef = useRef(totalDurationData)
|
||||
const isPlayingRef = useRef(false)
|
||||
|
||||
segmentsRef.current = segments
|
||||
timelineStartsRef.current = timelineStartsData
|
||||
totalDurationRef.current = totalDurationData
|
||||
|
||||
const canPlay = segments.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
segIdxRef.current = currentSegmentIndex
|
||||
}, [currentSegmentIndex])
|
||||
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying
|
||||
}, [isPlaying])
|
||||
|
||||
const waitForReady = useCallback((video: HTMLVideoElement, timeout = 3000): Promise<void> => {
|
||||
if (video.readyState >= 3) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const onCanPlay = () => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
resolve()
|
||||
}, timeout)
|
||||
video.addEventListener("canplay", onCanPlay)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const switchToSegment = useCallback(
|
||||
async (index: number, seekToLocalTime?: number) => {
|
||||
const segs = segmentsRef.current
|
||||
const video = videoRefs.current[index]
|
||||
if (!video || index >= segs.length) return
|
||||
|
||||
const seg = segs[index]
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
const oldIdx = segIdxRef.current
|
||||
const oldVideo = videoRefs.current[oldIdx]
|
||||
|
||||
if (oldVideo && oldVideo !== video) oldVideo.pause()
|
||||
|
||||
if (!video.src && seg.videoUrl) {
|
||||
video.src = seg.videoUrl
|
||||
video.load()
|
||||
}
|
||||
|
||||
if (Math.abs(video.currentTime - localTime) > 0.05) {
|
||||
video.currentTime = localTime
|
||||
}
|
||||
|
||||
segIdxRef.current = index
|
||||
setCurrentSegmentIndex(index)
|
||||
|
||||
await waitForReady(video)
|
||||
},
|
||||
[waitForReady],
|
||||
)
|
||||
|
||||
// 稳定的 tick 函数,空依赖,所有值从 ref 读取
|
||||
const tick = useCallback(() => {
|
||||
const segs = segmentsRef.current
|
||||
const idx = segIdxRef.current
|
||||
const video = videoRefs.current[idx]
|
||||
|
||||
if (!video || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
const seg = segs[idx]
|
||||
if (!seg) return
|
||||
|
||||
// 预加载下一个片段
|
||||
const nextIndex = idx + 1
|
||||
if (nextIndex < segs.length) {
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
if (nextVideo) {
|
||||
const timeToEnd = seg.endTime - video.currentTime
|
||||
if (timeToEnd <= 2 && nextVideo.readyState < 3) {
|
||||
const nextSeg = segs[nextIndex]
|
||||
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.5) {
|
||||
nextVideo.currentTime = nextSeg.startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测片段边界
|
||||
if (video.currentTime >= seg.endTime - 0.1) {
|
||||
if (nextIndex < segs.length) {
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
const nextSeg = segs[nextIndex]
|
||||
const accumulatedTime =
|
||||
(timelineStartsRef.current[idx] || 0) + (seg.endTime - seg.startTime)
|
||||
|
||||
if (nextVideo) {
|
||||
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.1) {
|
||||
nextVideo.currentTime = nextSeg.startTime
|
||||
}
|
||||
// 先启动下一个视频(muted,可安全同时播放)
|
||||
nextVideo
|
||||
.play()
|
||||
.catch((e) => console.warn("[useSegmentScheduler] next segment play failed:", e))
|
||||
}
|
||||
|
||||
// 立即切换可见性
|
||||
segIdxRef.current = nextIndex
|
||||
setCurrentSegmentIndex(nextIndex)
|
||||
setCurrentTime(accumulatedTime)
|
||||
lastTimeUpdateRef.current = 0
|
||||
setIsPlaying(true)
|
||||
|
||||
// 下一帧暂停旧视频(让新视频先渲染,避免冻屏)
|
||||
const oldVideo = video
|
||||
requestAnimationFrame(() => {
|
||||
oldVideo.pause()
|
||||
})
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
} else {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
setIsEnded(true)
|
||||
setCurrentTime(totalDurationRef.current)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const globalTime = (timelineStartsRef.current[idx] || 0) + (video.currentTime - seg.startTime)
|
||||
const now = performance.now()
|
||||
if (now - lastTimeUpdateRef.current >= 200) {
|
||||
lastTimeUpdateRef.current = now
|
||||
setCurrentTime(Math.max(0, Math.min(globalTime, totalDurationRef.current)))
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}, [])
|
||||
|
||||
const play = useCallback(async () => {
|
||||
if (!canPlay) return
|
||||
setIsEnded(false)
|
||||
const idx = segIdxRef.current
|
||||
const video = videoRefs.current[idx]
|
||||
if (!video) return
|
||||
|
||||
if (idx === 0 && video.readyState < 2) {
|
||||
if (!video.src && segmentsRef.current[0]?.videoUrl) {
|
||||
video.src = segmentsRef.current[0].videoUrl
|
||||
video.load()
|
||||
}
|
||||
await waitForReady(video)
|
||||
}
|
||||
|
||||
try {
|
||||
await video.play()
|
||||
setIsPlaying(true)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch (err) {
|
||||
console.warn("[useSegmentScheduler] 播放失败:", err)
|
||||
}
|
||||
}, [canPlay, waitForReady, tick])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRefs.current[segIdxRef.current]
|
||||
if (video) video.pause()
|
||||
setIsPlaying(false)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (isPlayingRef.current) {
|
||||
pause()
|
||||
} else {
|
||||
if (isEnded) {
|
||||
setIsEnded(false)
|
||||
lastTimeUpdateRef.current = 0
|
||||
const firstVideo = videoRefs.current[0]
|
||||
if (firstVideo) {
|
||||
videoRefs.current.forEach((v, i) => {
|
||||
if (v && i !== 0) v.pause()
|
||||
})
|
||||
firstVideo.currentTime = segmentsRef.current[0]?.startTime || 0
|
||||
segIdxRef.current = 0
|
||||
setCurrentSegmentIndex(0)
|
||||
setCurrentTime(0)
|
||||
firstVideo
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
})
|
||||
.catch((e) => console.warn("[useSegmentScheduler] restart failed:", e))
|
||||
}
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
}, [isEnded, pause, play, tick])
|
||||
|
||||
const seekTo = useCallback(
|
||||
async (time: number) => {
|
||||
if (!canPlay) return
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDurationRef.current))
|
||||
const { index, localTime } = findSegmentAtTime(segmentsRef.current, clampedTime)
|
||||
|
||||
isSeekingRef.current = true
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
if (index !== segIdxRef.current) {
|
||||
await switchToSegment(index, localTime)
|
||||
} else {
|
||||
const video = videoRefs.current[index]
|
||||
if (video) video.currentTime = localTime
|
||||
}
|
||||
|
||||
setCurrentTime(clampedTime)
|
||||
setIsEnded(false)
|
||||
lastTimeUpdateRef.current = 0
|
||||
|
||||
if (isPlayingRef.current) {
|
||||
const video = videoRefs.current[index]
|
||||
if (video) {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false
|
||||
}, 200)
|
||||
},
|
||||
[canPlay, switchToSegment, tick],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
videoRefs.current = videoRefs.current.slice(0, segments.length)
|
||||
while (videoRefs.current.length < segments.length) {
|
||||
videoRefs.current.push(null)
|
||||
}
|
||||
}, [segments])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
segIdxRef.current = 0
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setCurrentSegmentIndex(0)
|
||||
setIsEnded(false)
|
||||
}, [segments])
|
||||
|
||||
const currentSegment = segments[currentSegmentIndex] || null
|
||||
const segmentLocalTime = currentSegment
|
||||
? currentTime - (timelineStartsRef.current[currentSegmentIndex] || 0) + currentSegment.startTime
|
||||
: 0
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration: totalDurationData,
|
||||
currentSegmentIndex,
|
||||
segmentLocalTime,
|
||||
isEnded,
|
||||
canPlay,
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRefs,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSegmentScheduler
|
||||
@@ -1,294 +0,0 @@
|
||||
/**
|
||||
* 服务器渲染预览 Hook
|
||||
*
|
||||
* 核心职责:
|
||||
* 1. 调用 POST /generation/preview 创建服务器预览渲染任务
|
||||
* 2. 轮询 GET /generation/preview/{task_id} 直到完成
|
||||
* 3. 返回服务器渲染的真实视频 URL(供 <video> 标签播放)
|
||||
* 4. 检测配置变更,标记预览失效(stale)或自动重新渲染
|
||||
* 5. 网络错误自动重试 2 次
|
||||
*
|
||||
* 状态机:
|
||||
* idle → loading → ready → stale (config changed)
|
||||
* ↘ failed → idle (retry)
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import type { CreatePreviewRequest } from "@/api/generation/types"
|
||||
|
||||
export type ServerPreviewStatus = "idle" | "loading" | "ready" | "stale" | "failed"
|
||||
|
||||
interface UseServerPreviewOptions {
|
||||
/** 是否启用预览(Step4+ 且有素材和模板时) */
|
||||
enabled: boolean
|
||||
/** 构建预览请求参数(每次 render 调用,获取最新配置) */
|
||||
buildRequest: () => CreatePreviewRequest
|
||||
/** 预览任务创建成功回调 */
|
||||
onPreviewTaskCreated?: (taskId: string, sourceEditPlanId?: string) => void
|
||||
}
|
||||
|
||||
interface UseServerPreviewReturn {
|
||||
status: ServerPreviewStatus
|
||||
videoUrl: string | null
|
||||
error: string | null
|
||||
/** 进度 0-100 */
|
||||
progress: number
|
||||
/** 手动触发预览创建("重新预览"按钮或标题变更后手动刷新) */
|
||||
triggerPreview: () => void
|
||||
/** 当前预览任务 ID */
|
||||
taskId: string | null
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 2000
|
||||
const POLL_TIMEOUT = 120_000
|
||||
const MAX_NETWORK_RETRIES = 2
|
||||
|
||||
/**
|
||||
* 对配置参数做指纹,用于检测配置是否变化
|
||||
*/
|
||||
function buildFingerprint(req: CreatePreviewRequest): string {
|
||||
return JSON.stringify({
|
||||
t: req.template_id,
|
||||
a: [...req.asset_ids].sort(),
|
||||
d: req.duration,
|
||||
r: req.video_ratio,
|
||||
v: req.voice_library_id,
|
||||
b: req.bgm_config,
|
||||
title: req.title_config,
|
||||
})
|
||||
}
|
||||
|
||||
export function useServerPreview({
|
||||
enabled,
|
||||
buildRequest,
|
||||
onPreviewTaskCreated,
|
||||
}: UseServerPreviewOptions): UseServerPreviewReturn {
|
||||
const [status, setStatus] = useState<ServerPreviewStatus>("idle")
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [taskId, setTaskId] = useState<string | null>(null)
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const requestSeqRef = useRef(0)
|
||||
const renderedFingerprintRef = useRef<string>("")
|
||||
const mountedRef = useRef(true)
|
||||
const networkRetriesRef = useRef(0)
|
||||
|
||||
// 始终持有最新的 buildRequest 和回调
|
||||
const buildRequestRef = useRef(buildRequest)
|
||||
buildRequestRef.current = buildRequest
|
||||
const onCreatedRef = useRef(onPreviewTaskCreated)
|
||||
onCreatedRef.current = onPreviewTaskCreated
|
||||
|
||||
/* ── 清理 ── */
|
||||
const clearTimers = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
if (timeoutTimerRef.current) {
|
||||
clearTimeout(timeoutTimerRef.current)
|
||||
timeoutTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearTimers()
|
||||
}
|
||||
}, [clearTimers])
|
||||
|
||||
/* ── 创建预览 + 轮询 ── */
|
||||
const createAndPoll = useCallback(
|
||||
async (request: CreatePreviewRequest, seq: number) => {
|
||||
setStatus("loading")
|
||||
setProgress(0)
|
||||
setError(null)
|
||||
networkRetriesRef.current = 0
|
||||
|
||||
try {
|
||||
const resp = await createPreview(request)
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
setTaskId(resp.task_id)
|
||||
onCreatedRef.current?.(resp.task_id, resp.source_edit_plan_id)
|
||||
|
||||
let completed = false
|
||||
|
||||
// 超时保护
|
||||
timeoutTimerRef.current = setTimeout(() => {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError("预览渲染超时(120秒),请重试")
|
||||
}, POLL_TIMEOUT)
|
||||
|
||||
const poll = async () => {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
try {
|
||||
const st = await getPreviewStatus(resp.task_id)
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
if (st.status === "completed" && st.video_url) {
|
||||
completed = true
|
||||
clearTimers()
|
||||
renderedFingerprintRef.current = buildFingerprint(request)
|
||||
setVideoUrl(st.video_url)
|
||||
setProgress(100)
|
||||
setStatus("ready")
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (st.status === "failed" || st.status === "cancelled") {
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError(
|
||||
st.status === "cancelled"
|
||||
? "预览任务已取消"
|
||||
: st.error_message || "预览渲染失败,请重试",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating
|
||||
if (typeof st.progress === "number") setProgress(st.progress)
|
||||
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL)
|
||||
} catch (pollErr) {
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
if (networkRetriesRef.current < MAX_NETWORK_RETRIES) {
|
||||
networkRetriesRef.current += 1
|
||||
console.warn(
|
||||
`[ServerPreview] 轮询网络错误,第 ${networkRetriesRef.current} 次重试`,
|
||||
pollErr,
|
||||
)
|
||||
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL * 2)
|
||||
} else {
|
||||
completed = true
|
||||
clearTimers()
|
||||
setStatus("failed")
|
||||
setError("网络错误,无法获取预览状态,请重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
} catch (createErr) {
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
console.error("[ServerPreview] 创建预览任务失败:", createErr)
|
||||
|
||||
const isNetworkError =
|
||||
!!(createErr as { request?: unknown })?.request ||
|
||||
(createErr as { code?: string })?.code === "ERR_NETWORK"
|
||||
|
||||
if (isNetworkError && networkRetriesRef.current < MAX_NETWORK_RETRIES) {
|
||||
networkRetriesRef.current += 1
|
||||
console.warn(`[ServerPreview] 创建任务网络错误,第 ${networkRetriesRef.current} 次重试`)
|
||||
setTimeout(() => {
|
||||
if (seq === requestSeqRef.current && mountedRef.current) {
|
||||
createAndPoll(request, seq)
|
||||
}
|
||||
}, POLL_INTERVAL * 2)
|
||||
return
|
||||
}
|
||||
|
||||
const errData = (
|
||||
createErr as { response?: { data?: { detail?: string; message?: string } } }
|
||||
)?.response?.data
|
||||
setStatus("failed")
|
||||
setError(errData?.detail || errData?.message || "预览任务创建失败,请重试")
|
||||
}
|
||||
},
|
||||
[clearTimers],
|
||||
)
|
||||
|
||||
/* ── 手动触发预览 ── */
|
||||
const triggerPreview = useCallback(() => {
|
||||
if (!enabled) return
|
||||
const request = buildRequestRef.current()
|
||||
if (!request.template_id || request.asset_ids.length === 0) return
|
||||
|
||||
clearTimers()
|
||||
const seq = ++requestSeqRef.current
|
||||
setVideoUrl(null)
|
||||
setTaskId(null)
|
||||
createAndPoll(request, seq)
|
||||
}, [enabled, clearTimers, createAndPoll])
|
||||
|
||||
/* ── 自动触发 + 配置变更检测 ── */
|
||||
// 每次 render 都检查最新配置 fingerprint,与已渲染的 fingerprint 比较
|
||||
const request = enabled ? buildRequest() : null
|
||||
const currentFingerprint = request
|
||||
? request.template_id && request.asset_ids.length > 0
|
||||
? buildFingerprint(request)
|
||||
: ""
|
||||
: ""
|
||||
|
||||
// 首次进入自动触发
|
||||
const didInitRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentFingerprint) {
|
||||
didInitRef.current = false
|
||||
// 禁用时取消进行中的轮询,避免回到前序步骤后仍在后台轮询
|
||||
requestSeqRef.current += 1
|
||||
clearTimers()
|
||||
return
|
||||
}
|
||||
if (!didInitRef.current) {
|
||||
didInitRef.current = true
|
||||
renderedFingerprintRef.current = currentFingerprint
|
||||
triggerPreview()
|
||||
}
|
||||
}, [enabled, currentFingerprint, triggerPreview, clearTimers])
|
||||
|
||||
// 配置变更检测:素材/配音/BGM 等变化 → 自动重渲染;标题样式变化 → 标记 stale
|
||||
const prevFingerprintRef = useRef(currentFingerprint)
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentFingerprint) return
|
||||
const prev = prevFingerprintRef.current
|
||||
prevFingerprintRef.current = currentFingerprint
|
||||
|
||||
if (!prev || prev === currentFingerprint) return
|
||||
if (currentFingerprint === renderedFingerprintRef.current) return
|
||||
|
||||
// 配置已变更
|
||||
// 判断是标题样式变更还是素材/配音/BGM 变更
|
||||
const prevParsed = JSON.parse(prev) as Record<string, unknown>
|
||||
const currParsed = JSON.parse(currentFingerprint) as Record<string, unknown>
|
||||
const nonTitleChanged =
|
||||
prevParsed.t !== currParsed.t ||
|
||||
prevParsed.a !== currParsed.a ||
|
||||
prevParsed.d !== currParsed.d ||
|
||||
prevParsed.r !== currParsed.r ||
|
||||
prevParsed.v !== currParsed.v ||
|
||||
JSON.stringify(prevParsed.b) !== JSON.stringify(currParsed.b)
|
||||
|
||||
if (nonTitleChanged) {
|
||||
// 素材/配音/BGM/模板等变化 → 自动重新渲染
|
||||
renderedFingerprintRef.current = currentFingerprint
|
||||
triggerPreview()
|
||||
} else {
|
||||
// 仅标题文字/样式变化 → 标记 stale,不自动重渲染(避免频繁请求)
|
||||
// 实时预览由 CSS TitleOverlay 提供
|
||||
setStatus((s) => (s === "ready" ? "stale" : s))
|
||||
}
|
||||
}, [enabled, currentFingerprint, triggerPreview])
|
||||
|
||||
return {
|
||||
status,
|
||||
videoUrl,
|
||||
error,
|
||||
progress,
|
||||
triggerPreview,
|
||||
taskId,
|
||||
}
|
||||
}
|
||||
|
||||
export default useServerPreview
|
||||
@@ -3,14 +3,9 @@
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { updateEditPlanClips } from "@/api/template-editor"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { buildClipsFromAssets } from "../utils/buildClipsFromAssets"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -19,10 +14,6 @@ interface UseStep2MaterialsProps {
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -32,8 +23,6 @@ export function useStep2Materials({
|
||||
onSelectedMaterialsChange,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
selectedTemplate,
|
||||
templateSegments,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
@@ -64,69 +53,6 @@ export function useStep2Materials({
|
||||
handleSmartMatch()
|
||||
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
|
||||
|
||||
/* ── Step2 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */
|
||||
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) return
|
||||
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
scheduleSave({ asset_ids: ids }, 500)
|
||||
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
|
||||
|
||||
/* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ── */
|
||||
const clipsTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const clipsAbortRef = useRef<AbortController | null>(null)
|
||||
const templateSegmentsRef = useRef(templateSegments)
|
||||
templateSegmentsRef.current = templateSegments
|
||||
const selectedTemplateRef = useRef(selectedTemplate)
|
||||
selectedTemplateRef.current = selectedTemplate
|
||||
const materialsRef = useRef(materials)
|
||||
materialsRef.current = materials
|
||||
const smartMatchedRef = useRef<AssetItem[]>(smartMatch.smartMatchedResults)
|
||||
smartMatchedRef.current = smartMatch.smartMatchedResults
|
||||
|
||||
useEffect(() => {
|
||||
const tid = selectedTemplateRef.current
|
||||
if (!tid) return
|
||||
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
if (!ids.length) return
|
||||
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
clipsTimerRef.current = setTimeout(async () => {
|
||||
// 取消上一次未完成的请求
|
||||
if (clipsAbortRef.current) clipsAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
clipsAbortRef.current = controller
|
||||
|
||||
const clips = buildClipsFromAssets({
|
||||
selectedIds: ids,
|
||||
materials: materialsRef.current.items,
|
||||
smartMatchedAssets: smartMatchedRef.current,
|
||||
templateSegments: templateSegmentsRef.current || [],
|
||||
})
|
||||
|
||||
try {
|
||||
await updateEditPlanClips(tid, clips, controller.signal)
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name !== "CanceledError" && name !== "AbortError") {
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
}
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => {
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
}
|
||||
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, templateSegments])
|
||||
|
||||
// 组件卸载时取消未完成请求
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
if (clipsAbortRef.current) clipsAbortRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Step 4 生成预览 Hook(支持多预览 + voice_ids)
|
||||
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import { updateEditPlan } from "@/api/template-editor/editPlans"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep4PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||||
voiceIds?: string[]
|
||||
/** 配音素材库ID(用户选择的上传音频或AI配音素材) */
|
||||
voiceLibraryId?: string
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 单个预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
/** 单个预览项的完整状态(用于多预览) */
|
||||
export interface PreviewItem {
|
||||
index: number
|
||||
status: PreviewStatus
|
||||
result: PreviewResult | null
|
||||
error: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
/** 初始单项状态 */
|
||||
const createInitialItem = (index: number): PreviewItem => ({
|
||||
index,
|
||||
status: "idle",
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
export function useStep4Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 多预览状态 ── */
|
||||
const [items, setItems] = useState<PreviewItem[]>(() =>
|
||||
Array.from({ length: previewCount }, (_, i) => createInitialItem(i)),
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储)
|
||||
const taskIdsRef = useRef<Map<number, string>>(new Map())
|
||||
const pollTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback((index?: number) => {
|
||||
if (index !== undefined) {
|
||||
const timer = pollTimersRef.current.get(index)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pollTimersRef.current.delete(index)
|
||||
}
|
||||
} else {
|
||||
pollTimersRef.current.forEach((timer) => clearTimeout(timer))
|
||||
pollTimersRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 同步 previewCount 变化(增减项)
|
||||
useEffect(() => {
|
||||
setItems((prev) => {
|
||||
if (prev.length === previewCount) return prev
|
||||
if (prev.length > previewCount) return prev.slice(0, previewCount)
|
||||
return [
|
||||
...prev,
|
||||
...Array.from({ length: previewCount - prev.length }, (_, i) =>
|
||||
createInitialItem(prev.length + i),
|
||||
),
|
||||
]
|
||||
})
|
||||
// 如果 selectedIndex 超出范围,重置
|
||||
setSelectedIndex((prev) => Math.min(prev, previewCount - 1))
|
||||
}, [previewCount])
|
||||
|
||||
/* ── 参数变化时重置所有预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
[...(voiceIds || [])].sort().join(","),
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
prevDepsRef.current.voiceIds,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
|
||||
taskIdsRef.current.clear()
|
||||
clearPollTimer()
|
||||
setItems(Array.from({ length: previewCount }, (_, i) => createInitialItem(i)))
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
])
|
||||
|
||||
// 组件卸载时清理所有轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询单个预览任务状态 */
|
||||
const pollPreviewStatus = useCallback(
|
||||
(index: number, taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 竞态检查
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
const result: PreviewResult = {
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
}
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||||
),
|
||||
)
|
||||
|
||||
// 保存预览视频 URL 到 plan config,供封面生成使用
|
||||
if (result.videoUrl && selectedTemplate) {
|
||||
updateEditPlan(selectedTemplate, {
|
||||
config: { rendered_storage_key: result.videoUrl },
|
||||
}).catch((err) => {
|
||||
console.warn("[Step4] 保存预览视频URL到plan config失败:", err)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index
|
||||
? {
|
||||
...it,
|
||||
status: "error",
|
||||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||||
}
|
||||
: it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
const prog = safeNumber(data.progress)
|
||||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||||
),
|
||||
)
|
||||
const delay = status === "pending" ? 5000 : 2000
|
||||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||||
} catch {
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||||
},
|
||||
[selectedTemplate],
|
||||
)
|
||||
|
||||
/** 生成所有预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择模板" })))
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择素材" })))
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的所有轮询
|
||||
clearPollTimer()
|
||||
taskIdsRef.current.clear()
|
||||
|
||||
// 初始化所有项为 pending
|
||||
setItems(
|
||||
Array.from({ length: previewCount }, (_, i) => ({
|
||||
index: i,
|
||||
status: "pending" as PreviewStatus,
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})),
|
||||
)
|
||||
setSelectedIndex(0)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
|
||||
// 并发创建所有预览任务(Promise.all 并行请求,减少串行等待)
|
||||
const createTasks = Array.from({ length: previewCount }, async (_, i) => {
|
||||
try {
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
voice_library_id: voiceLibraryId || undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
|
||||
taskIdsRef.current.set(i, response.task_id)
|
||||
pollPreviewStatus(i, response.task_id)
|
||||
} catch (e) {
|
||||
const errMsg = safeString(e instanceof Error ? e.message : e, "预览生成失败")
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.index === i ? { ...it, status: "error", error: errMsg } : it)),
|
||||
)
|
||||
}
|
||||
})
|
||||
await Promise.all(createTasks)
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成所有预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否所有预览都已完成 */
|
||||
const allReady = items.length > 0 && items.every((it) => it.status === "ready")
|
||||
/** 是否至少有一个预览已完成 */
|
||||
const anyReady = items.some((it) => it.status === "ready")
|
||||
/** 是否有任一正在生成中 */
|
||||
const anyGenerating = items.some((it) => it.status === "pending" || it.status === "generating")
|
||||
|
||||
/** 当前选中的预览结果 */
|
||||
const selectedResult = items[selectedIndex]?.result ?? null
|
||||
|
||||
/** 综合状态(兼容旧逻辑) */
|
||||
const previewStatus: PreviewStatus = useMemo(() => {
|
||||
if (items.every((it) => it.status === "idle")) return "idle"
|
||||
if (items.some((it) => it.status === "pending" || it.status === "generating"))
|
||||
return "generating"
|
||||
if (allReady) return "ready"
|
||||
if (items.every((it) => it.status === "error")) return "error"
|
||||
// 部分完成部分出错
|
||||
if (anyReady) return "ready"
|
||||
return "error"
|
||||
}, [items, allReady, anyReady])
|
||||
|
||||
/** 综合进度(取平均) */
|
||||
const progress = useMemo(() => {
|
||||
if (items.length === 0) return 0
|
||||
return Math.round(items.reduce((sum, it) => sum + it.progress, 0) / items.length)
|
||||
}, [items])
|
||||
|
||||
/** 综合错误信息 */
|
||||
const previewError = useMemo(() => {
|
||||
const errorItems = items.filter((it) => it.status === "error" && it.error)
|
||||
if (errorItems.length === 0) return ""
|
||||
if (errorItems.length === 1) return errorItems[0].error
|
||||
return `${errorItems.length} 个预览生成失败`
|
||||
}, [items])
|
||||
|
||||
const canProceed = anyReady
|
||||
|
||||
/** 当前选中预览的 taskId(用于确认生成时复用预览产物) */
|
||||
const selectedTaskId = selectedResult?.taskId ?? ""
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 多预览状态
|
||||
items,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
previewCount,
|
||||
// 综合状态
|
||||
previewStatus,
|
||||
previewResult: selectedResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
allReady,
|
||||
anyReady,
|
||||
anyGenerating,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
// 确认生成复用预览产物
|
||||
selectedTaskId,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Preview
|
||||
@@ -4,24 +4,17 @@ import { getTitles } from "@/api/titles"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { useAiTitleGenerator } from "./useAiTitleGenerator"
|
||||
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
|
||||
import { useDraftAutoSave } from "../useDraftAutoSave"
|
||||
|
||||
interface UseStep4TitleProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
*/
|
||||
export function useStep4Title({
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
selectedTemplate,
|
||||
}: UseStep4TitleProps) {
|
||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||
// 标题库数据
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
@@ -49,38 +42,6 @@ export function useStep4Title({
|
||||
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
|
||||
const isFirstMount = useRef(true)
|
||||
|
||||
/* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */
|
||||
const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate)
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) return
|
||||
scheduleTitleSave(
|
||||
{
|
||||
title: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
},
|
||||
800,
|
||||
)
|
||||
}, [
|
||||
selectedTemplate,
|
||||
titleSettings.title,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
titleSettings.position,
|
||||
titleSettings.bold,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
scheduleTitleSave,
|
||||
])
|
||||
|
||||
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
|
||||
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { CoverConfig, CoverTemplate } from "../types/cover"
|
||||
import { generateCover } from "@/api/generation"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import { updateEditPlan } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
@@ -24,22 +21,6 @@ interface UseStep6CoverProps {
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
|
||||
onPreviewTaskCreated?: (taskId: string) => void
|
||||
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
|
||||
onSourceEditPlanIdExtracted?: (planId: string) => void
|
||||
/** 配音模式 */
|
||||
voiceMode?: "preset" | "custom" | "clone"
|
||||
/** 选中的配音素材 ID(配音素材库 asset ID) */
|
||||
selectedVoice?: string
|
||||
/** 选中的克隆音色 ID */
|
||||
selectedClonedVoice?: string
|
||||
/** BGM 开关 */
|
||||
bgm?: boolean
|
||||
/** BGM 配置(来自模板) */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
@@ -48,18 +29,8 @@ export function useStep6Cover({
|
||||
duration,
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
titleSettings,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
}: UseStep6CoverProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
// 防竞态:记录当前预览生成的参数指纹,任务完成时校验一致性
|
||||
const previewParamsRef = useRef<string>("")
|
||||
|
||||
// ── 封面设置弹窗状态 ──
|
||||
const [showCoverSettings, setShowCoverSettings] = useState(false)
|
||||
@@ -120,20 +91,6 @@ export function useStep6Cover({
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
const thumbnailUrl = response.cover?.image_url || ""
|
||||
@@ -152,188 +109,31 @@ export function useStep6Cover({
|
||||
console.error("[Step6] 智能封面生成失败:", err)
|
||||
|
||||
// 提取详细错误信息
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyErr = err as any
|
||||
const statusCode = anyErr?.response?.status
|
||||
|
||||
// 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errCode = anyErr?.response?.data?.code as string | undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errMsg = (anyErr?.response?.data?.message ||
|
||||
anyErr?.response?.data?.detail ||
|
||||
"") as string
|
||||
const isPreviewMissing =
|
||||
statusCode === 400 &&
|
||||
(errCode?.includes("PREVIEW") ||
|
||||
/预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
|
||||
errMsg,
|
||||
))
|
||||
|
||||
if (isPreviewMissing) {
|
||||
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
|
||||
message.info("正在准备预览视频,请稍候...")
|
||||
try {
|
||||
// 记录当前参数指纹,用于任务完成时校验一致性(防竞态)
|
||||
previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
|
||||
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
|
||||
const previewVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || "" : selectedVoice || ""
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || 30,
|
||||
// 配音:voice_library_id 是配音素材库 asset ID(用户上传的音频或 AI 配音)
|
||||
...(previewVoiceLibraryId ? { voice_library_id: previewVoiceLibraryId } : {}),
|
||||
// BGM 配置:受 bgm 开关控制
|
||||
bgm_config: {
|
||||
enabled: bgm !== false,
|
||||
...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}),
|
||||
},
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
// 将预览任务 ID 暴露给父组件,供 Step7 确认生成时复用(confirmGeneration)
|
||||
const currentFingerprint = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
|
||||
if (previewResp.task_id && previewParamsRef.current === currentFingerprint) {
|
||||
onPreviewTaskCreated?.(previewResp.task_id)
|
||||
// 提取后端自动关联的 source_edit_plan_id,供 fallback 路径使用
|
||||
if (previewResp.source_edit_plan_id) {
|
||||
onSourceEditPlanIdExtracted?.(previewResp.source_edit_plan_id)
|
||||
}
|
||||
}
|
||||
// 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let finished = false
|
||||
const done = (fn: () => void) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearTimeout(timeoutId)
|
||||
fn()
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
done(() => reject(new Error("预览生成超时,请稍后重试")))
|
||||
}, 120_000)
|
||||
const poll = async () => {
|
||||
if (finished) return
|
||||
try {
|
||||
const status = await getPreviewStatus(previewResp.task_id)
|
||||
if (status.status === "completed") {
|
||||
// 保存预览视频地址到 plan.config.rendered_storage_key,
|
||||
// 供封面 API 的 E1 兜底路径定位渲染后的视频(含标题烧录)。
|
||||
// video_url 可能是完整 http(s) URL 或 OSS storage_key,两种格式后端都能处理。
|
||||
if (status.video_url) {
|
||||
try {
|
||||
await updateEditPlan(selectedTemplate, {
|
||||
config: { rendered_storage_key: status.video_url },
|
||||
})
|
||||
} catch (saveErr) {
|
||||
console.warn(
|
||||
"[Step6] 保存 rendered_storage_key 失败(不阻塞封面重试):",
|
||||
saveErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
done(() => resolve())
|
||||
} else if (status.status === "failed") {
|
||||
done(() => reject(new Error(status.error_message || "预览渲染失败")))
|
||||
} else {
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
done(() => reject(e))
|
||||
}
|
||||
}
|
||||
poll()
|
||||
})
|
||||
message.success("预览视频就绪,重新生成封面...")
|
||||
// 重试封面生成
|
||||
const retryResp = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
...(titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: titleSettings.title,
|
||||
font: titleSettings.font,
|
||||
font_size: titleSettings.size,
|
||||
font_color: titleSettings.color,
|
||||
position: titleSettings.position,
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const retryUrl = retryResp.cover?.image_url || ""
|
||||
if (retryUrl) {
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
thumbnail_url: retryUrl,
|
||||
ai_suggested_time: retryResp.cover?.frame_time ?? null,
|
||||
})
|
||||
message.success("封面生成成功")
|
||||
} else {
|
||||
message.warning("封面生成未返回图片,请重试")
|
||||
}
|
||||
} catch (retryErr) {
|
||||
console.error("[Step6] 自动创建预览后重试失败:", retryErr)
|
||||
message.error("预览视频创建失败,请稍后重试")
|
||||
}
|
||||
} else if (anyErr?.__msgShown) {
|
||||
// 拦截器已处理,不再重复弹出
|
||||
} else {
|
||||
let errorMsg = "封面生成失败"
|
||||
const e = anyErr as {
|
||||
response?: { data?: { detail?: string; message?: string }; status?: number }
|
||||
request?: unknown
|
||||
message?: string
|
||||
}
|
||||
if (e.response) {
|
||||
const detail = e.response.data?.detail || e.response.data?.message || ""
|
||||
errorMsg = detail || `后端错误 (${e.response.status})`
|
||||
console.error("[Step6] 后端返回:", e.response.data)
|
||||
} else if (e.request) {
|
||||
errorMsg = "服务器无响应,请检查网络连接"
|
||||
console.error("[Step6] 请求无响应:", e.request)
|
||||
} else if (e.message) {
|
||||
errorMsg = e.message
|
||||
}
|
||||
message.error(errorMsg)
|
||||
let errorMsg = "封面生成失败"
|
||||
const e = err as {
|
||||
response?: { data?: { detail?: string; message?: string }; status?: number }
|
||||
request?: unknown
|
||||
message?: string
|
||||
}
|
||||
if (e.response) {
|
||||
// 后端返回错误
|
||||
const detail = e.response.data?.detail || e.response.data?.message || ""
|
||||
errorMsg = detail || `后端错误 (${e.response.status})`
|
||||
console.error("[Step6] 后端返回:", e.response.data)
|
||||
} else if (e.request) {
|
||||
// 请求已发送但无响应
|
||||
errorMsg = "服务器无响应,请检查网络连接"
|
||||
console.error("[Step6] 请求无响应:", e.request)
|
||||
} else if (e.message) {
|
||||
errorMsg = e.message
|
||||
}
|
||||
|
||||
message.error(errorMsg)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
assetIds,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
generating,
|
||||
duration,
|
||||
titleSettings,
|
||||
onPreviewTaskCreated,
|
||||
onSourceEditPlanIdExtracted,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
])
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* 前端实时预览架构:Step5 无需等待服务器渲染
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -16,6 +14,8 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** Step4 是否已生成预览 */
|
||||
previewReady: boolean
|
||||
}
|
||||
|
||||
export interface UseStepNavigationReturn {
|
||||
@@ -32,6 +32,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady,
|
||||
} = options
|
||||
|
||||
const goNext = () => {
|
||||
@@ -47,7 +48,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
// Step3 配音:配音为可选项,不强制校验,用户可跳过
|
||||
if (currentStep === 4 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
return
|
||||
}
|
||||
if (currentStep === 5 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* 将选中素材 + 模板 segments 构建为 edit_plan_clips 写入数据。
|
||||
*
|
||||
* 逻辑必须与 FrontendPreviewPlayer.tsx 中 buildPlaybackSegments 完全一致:
|
||||
* assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
* tplSeg = templateSegments[i] || lastSegment
|
||||
* segDuration = clamp(assetDuration, tplSeg.duration_min, tplSeg.duration_max)
|
||||
* start_time = 0
|
||||
* // 关键:预览播放器中 endTime = min(startTime + segDuration, assetDuration)
|
||||
* // 因此 clips.duration 也必须用 min(segDuration, assetDuration) 截断,
|
||||
* // 避免素材实际时长比 clamp 后的 segDuration 短时,Worker 尝试读取不存在的片段
|
||||
* duration = min(segDuration, assetDuration)
|
||||
*
|
||||
* 预览播放器(Canvas 实时预览)直接在内存中构建 segments 播放,不读 edit_plan_clips;
|
||||
* 本函数产出的 clips 写入 DB 后由 Worker 渲染。两边用完全相同的时长计算,
|
||||
* 保证用户在编辑过程中看到的预览与最终生成视频一致。
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClipInput } from "@/api/template-editor"
|
||||
|
||||
interface BuildClipsOptions {
|
||||
/** 选中的素材 ID 列表(按选择顺序) */
|
||||
selectedIds: string[]
|
||||
/** 已加载的素材列表(用于查 duration) */
|
||||
materials: AssetItem[]
|
||||
/** 智能匹配返回的素材(auto 模式下可能不在 materials 列表中) */
|
||||
smartMatchedAssets?: AssetItem[]
|
||||
/** 模板 segments */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
export function buildClipsFromAssets({
|
||||
selectedIds,
|
||||
materials,
|
||||
smartMatchedAssets = [],
|
||||
templateSegments = [],
|
||||
}: BuildClipsOptions): EditPlanClipInput[] {
|
||||
if (!selectedIds.length) return []
|
||||
|
||||
// 合并两个素材来源,建立 id → asset 索引
|
||||
const assetMap = new Map<string, AssetItem>()
|
||||
for (const a of materials) assetMap.set(a.id, a)
|
||||
for (const a of smartMatchedAssets) assetMap.set(a.id, a)
|
||||
|
||||
const lastSeg = templateSegments[templateSegments.length - 1]
|
||||
|
||||
return selectedIds.map((assetId, i) => {
|
||||
const asset = assetMap.get(assetId)
|
||||
const assetDuration = asset?.duration || asset?.metadata?.duration || 30
|
||||
|
||||
const tplSeg = templateSegments[i] || lastSeg
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
// 与 FrontendPreviewPlayer.buildPlaybackSegments 中
|
||||
// endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
// 保持一致:duration 不能超过素材实际时长
|
||||
const duration = Math.min(segDuration, assetDuration)
|
||||
|
||||
return {
|
||||
asset_id: assetId,
|
||||
start_time: 0,
|
||||
duration,
|
||||
order: i,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* 根据视频比例计算输出分辨率
|
||||
*
|
||||
* 规则:
|
||||
* - 长边固定 1920
|
||||
* - 短边按 1920 × (短/长) 计算,对齐到偶数
|
||||
* - 9:16 → 1080×1920(竖屏)
|
||||
* - 16:9 → 1920×1080(横屏)
|
||||
* - 1:1 → 1920×1920
|
||||
* - 无法解析时 fallback 到 1080×1920
|
||||
*/
|
||||
export interface Resolution {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function calculateResolution(ratio: string): Resolution {
|
||||
if (!ratio) return { width: 1080, height: 1920 }
|
||||
|
||||
if (ratio.includes(":")) {
|
||||
const [rw, rh] = ratio.split(":").map(Number)
|
||||
if (rw > 0 && rh > 0) {
|
||||
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
|
||||
const baseLong = 1920
|
||||
const baseShort = Math.round((baseLong * shortSide) / longSide)
|
||||
const evenShort = baseShort - (baseShort % 2)
|
||||
if (rw < rh) {
|
||||
// 竖屏:短边是宽,长边是高
|
||||
return { width: evenShort, height: baseLong }
|
||||
} else {
|
||||
// 横屏:长边是宽,短边是高
|
||||
return { width: baseLong, height: evenShort }
|
||||
}
|
||||
}
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
|
||||
if (ratio.includes("x")) {
|
||||
const [wStr, hStr] = ratio.split("x")
|
||||
const w = parseInt(wStr, 10) || 1080
|
||||
const h = parseInt(hStr, 10) || 1920
|
||||
return { width: w, height: h }
|
||||
}
|
||||
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 共享:根据素材列表和模板片段计算总视频时长
|
||||
* GeneratePage(配音校验)和 FrontendPreviewPlayer(播放控制)共用
|
||||
*/
|
||||
|
||||
export interface DurationAsset {
|
||||
id?: string
|
||||
duration?: number
|
||||
metadata?: { duration?: number }
|
||||
}
|
||||
|
||||
export interface DurationTemplateSegment {
|
||||
duration_min?: number
|
||||
duration_max?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算总视频时长
|
||||
* @param assets 素材列表
|
||||
* @param template 模板(含 segments)
|
||||
* @returns 总时长(秒),无有效数据时返回 0
|
||||
*/
|
||||
export function calculateTotalVideoDuration(
|
||||
assets: DurationAsset[] | undefined,
|
||||
template: { segments?: DurationTemplateSegment[] } | undefined,
|
||||
): number {
|
||||
if (!assets || assets.length === 0 || !template) return 0
|
||||
|
||||
const templateSegments = template.segments || []
|
||||
|
||||
return assets.reduce((sum, asset, i) => {
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
|
||||
const segDuration = tplSeg
|
||||
? Math.min(
|
||||
tplSeg.duration_max ?? assetDuration,
|
||||
Math.max(tplSeg.duration_min ?? 0, assetDuration),
|
||||
)
|
||||
: Math.min(assetDuration, 10)
|
||||
return sum + segDuration
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算总视频时长(仅依赖模板 segments)
|
||||
* 当素材未加载或加载失败时,用各片段 duration_max 之和作为估算值
|
||||
* 确保配音时长校验不会因素材未就绪而跳过
|
||||
*/
|
||||
export function estimateTotalVideoDuration(
|
||||
template: { segments?: DurationTemplateSegment[] } | undefined,
|
||||
): number {
|
||||
if (!template?.segments || template.segments.length === 0) return 0
|
||||
return template.segments.reduce((sum, seg) => sum + (seg.duration_max || 0), 0)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export const useTemplateLibrary = () => {
|
||||
[copyMutation],
|
||||
)
|
||||
|
||||
/* 操作:使用模板 → 跳转剪辑模板 */
|
||||
/* 操作:使用模板 → 跳转剪辑编辑器 */
|
||||
const handleUse = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
navigate(`/app/editing-planner?templateId=${template.id}`)
|
||||
|
||||
+22
-23
@@ -2,13 +2,14 @@ import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
createAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
getIngestJob,
|
||||
type AssetLibraryItem,
|
||||
} from "@/api/assets"
|
||||
import { tagAsset } from "@/api/tags"
|
||||
import { type VoiceGender, type VoiceMaterial } from "../../../types"
|
||||
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
|
||||
import { getAudioDuration } from "../../../utils/audio"
|
||||
|
||||
interface UseVoiceUploadOptions {
|
||||
voiceLibrary?: { id: string; kind: string }
|
||||
@@ -47,34 +48,32 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
}
|
||||
|
||||
// 2. 上传文件(带进度,后端自动创建 ingest job)
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
// 2. 上传文件(带进度)
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 3. 轮询 ingest job 状态
|
||||
let job: Awaited<ReturnType<typeof getIngestJob>> | null = null
|
||||
let retries = 0
|
||||
const maxRetries = 60 // 最多等待 5 分钟
|
||||
while (retries < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
job = await getIngestJob(ingest_job_id)
|
||||
if (job.status === "completed" || job.status === "failed") break
|
||||
retries++
|
||||
}
|
||||
// 3. 获取音频时长
|
||||
const duration = await getAudioDuration(data.file)
|
||||
|
||||
if (!job || job.status === "failed") {
|
||||
throw new Error("音频处理失败,请重试")
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
throw new Error("音频处理超时,请稍后在素材库查看")
|
||||
}
|
||||
// 4. 创建素材记录
|
||||
const asset = await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
|
||||
// 4. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0 && job.result_asset_id) {
|
||||
await tagAsset(job.result_asset_id, data.tagIds)
|
||||
// 5. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0) {
|
||||
await tagAsset(asset.id, data.tagIds)
|
||||
}
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface VoiceMaterial {
|
||||
fileUrl?: string
|
||||
}
|
||||
|
||||
/** 配音素材上传元数据(上传素材的 metadata) */
|
||||
/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
|
||||
export interface VoiceAssetMetadata {
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { uploadAssetDirect, getAssetLibraries, getIngestJob } from "@/api/assets"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import { getAudioDuration } from "../utils/audio"
|
||||
import { buildVoiceMetadata } from "../types"
|
||||
|
||||
/**
|
||||
* 配音上传 Hook
|
||||
@@ -31,30 +33,27 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
/* 直传文件(后端会自动创建 ingest job) */
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
/* 直传文件 */
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
/* 轮询 ingest job 状态,等待 Worker 处理完成 */
|
||||
let jobStatus = ""
|
||||
let retries = 0
|
||||
const maxRetries = 60 // 最多等待 5 分钟(60 * 5秒)
|
||||
while (jobStatus !== "ready" && jobStatus !== "failed" && retries < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
const job = await getIngestJob(ingest_job_id)
|
||||
jobStatus = job.status
|
||||
retries++
|
||||
}
|
||||
/* 获取音频时长 */
|
||||
const duration = await getAudioDuration(data.file)
|
||||
|
||||
if (jobStatus === "failed") {
|
||||
throw new Error("音频处理失败,请重试")
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
throw new Error("音频处理超时,请稍后在素材库查看")
|
||||
}
|
||||
/* 创建素材记录 */
|
||||
await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildVoiceMetadata({
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ClonedVoiceDisplay {
|
||||
sampleUrl?: string
|
||||
}
|
||||
|
||||
/** 音色上传元数据(上传素材的 metadata) */
|
||||
/** 音色上传元数据(传递给 createAsset 的 metadata) */
|
||||
export interface VoiceUploadMetadata {
|
||||
gender?: string
|
||||
description?: string
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
deleteAssetLibrary,
|
||||
getAssets,
|
||||
getAssetsByKind,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
updateAssetReviewStatus,
|
||||
deleteAsset,
|
||||
uploadAsset,
|
||||
prepareDirectUpload,
|
||||
completeDirectUpload,
|
||||
uploadAssetDirect,
|
||||
@@ -173,6 +175,22 @@ describe("assets API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAsset", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(createAsset({ name: "test-item" })).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(createAsset({ name: "test-item" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateAsset", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(updateAsset("test-assetId")).resolves.not.toThrow()
|
||||
@@ -221,6 +239,22 @@ describe("assets API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("uploadAsset", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(uploadAsset(new FormData())).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
mockPut.mockRejectedValue(new Error("Network error"))
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(uploadAsset(new FormData())).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("prepareDirectUpload", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(prepareDirectUpload({ name: "test-item" })).resolves.not.toThrow()
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("bgm API", () => {
|
||||
|
||||
describe("getBgmPresets", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getBgmPresets("test-template", { category: "test" })).resolves.not.toThrow()
|
||||
await expect(getBgmPresets("test-params?")).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
@@ -42,7 +42,7 @@ describe("bgm API", () => {
|
||||
mockDelete.mockRejectedValue(new Error("Network error"))
|
||||
mockPatch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(getBgmPresets("test-template", { category: "test" })).rejects.toThrow()
|
||||
await expect(getBgmPresets("test-params?")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user