Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 423ee5bb48 | |||
| c8789670e9 |
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -31,6 +31,8 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Generation"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -47,10 +49,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,66 +61,6 @@ class GenerateCoverResponse(BaseModel):
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _persist_cover_frame(frame_url: str, plan_id: str, title_text: str = "") -> str:
|
||||
"""下载 MediaKit 返回的临时帧图,可选叠加标题后转存到 OSS covers/ 路径。
|
||||
|
||||
Args:
|
||||
frame_url: MediaKit 返回的临时帧图 URL
|
||||
plan_id: 剪辑计划 ID(生成 OSS key)
|
||||
title_text: 非空时用 Pillow 在帧上叠加标题(用于 E2 从源素材抽帧,
|
||||
因为源素材本身没有烧录标题)
|
||||
"""
|
||||
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)
|
||||
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,
|
||||
@@ -142,29 +80,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)
|
||||
@@ -256,31 +171,44 @@ 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
|
||||
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
|
||||
storage_svc = get_shared_storage_service()
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
|
||||
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
|
||||
@@ -355,118 +283,6 @@ def generate_cover(
|
||||
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
|
||||
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 ""
|
||||
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)
|
||||
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,
|
||||
)
|
||||
|
||||
if cover_url_from_task:
|
||||
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
|
||||
cover_data = {
|
||||
@@ -482,13 +298,13 @@ def generate_cover(
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
logger.warning(
|
||||
"[封面生成] 统一管道未找到 cover_url (A/B/C/D均未命中): plan_id=%s",
|
||||
"[封面生成] 统一管道未找到 cover_url: plan_id=%s",
|
||||
plan_id,
|
||||
)
|
||||
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="封面生成失败:未找到可抽帧的视频素材,请确认已上传视频素材后重试",
|
||||
detail="封面尚未生成,请先重新生成预览视频以触发封面自动提取",
|
||||
)
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
@@ -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,
|
||||
@@ -71,7 +69,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
title_config=getattr(task, "title_config", {}) or {},
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -145,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,
|
||||
@@ -238,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",
|
||||
@@ -356,7 +304,6 @@ def create_generation_task(
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
title_config=request.title_config or {},
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -368,15 +315,6 @@ def create_generation_task(
|
||||
log_task_status=True,
|
||||
):
|
||||
created_tasks.append(task)
|
||||
# 只在首个成功任务时回写一次 plan.config,
|
||||
# 避免批量生成时循环覆盖 generation_task_id
|
||||
if request.source_edit_plan_id and len(created_tasks) == 1:
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=request.source_edit_plan_id,
|
||||
task_id=task.id,
|
||||
title_config=request.title_config,
|
||||
db=db,
|
||||
)
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
@@ -46,7 +45,6 @@ from ._fallback import (
|
||||
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateRequest,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
@@ -56,11 +54,9 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Template Editor"])
|
||||
|
||||
|
||||
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
|
||||
@router.post("/generate", response_model=EditPlanGenerateResponse)
|
||||
def generate_editor_draft(
|
||||
template_id: str,
|
||||
request: Optional[EditPlanGenerateRequest] = None,
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
@@ -69,7 +65,6 @@ def generate_editor_draft(
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发模板草稿渲染生成"""
|
||||
req = request or EditPlanGenerateRequest()
|
||||
_, plan_svc = services
|
||||
plan_check = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
@@ -90,36 +85,6 @@ def generate_editor_draft(
|
||||
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
# 如果前端传了 title_config,需要创建新任务(因为预览任务的 custom_title 可能不同)
|
||||
title_config_reuse = req.title_config or {}
|
||||
title_text_reuse = (title_config_reuse.get("text") or "").strip()
|
||||
existing_custom_title = getattr(reusable_task, "custom_title", "") or ""
|
||||
if title_text_reuse and existing_custom_title:
|
||||
# 如果新标题和已有标题不同,不能复用,走新建任务流程
|
||||
new_title_json = json.dumps(title_config_reuse, ensure_ascii=False)
|
||||
if new_title_json != existing_custom_title:
|
||||
logger.info(
|
||||
"[模板生成] 标题已变更,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
elif title_text_reuse and not existing_custom_title:
|
||||
# 原来没标题,现在有标题,不能复用
|
||||
logger.info(
|
||||
"[模板生成] 新增标题,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
elif not title_text_reuse and existing_custom_title:
|
||||
# 原来有标题,现在移除了,不能复用
|
||||
logger.info(
|
||||
"[模板生成] 移除标题,跳过复用: task_id=%s",
|
||||
reusable_task.id,
|
||||
)
|
||||
reusable_task = None
|
||||
|
||||
if reusable_task:
|
||||
# 复用预览产物:标记为正式产出,跳过渲染
|
||||
reusable_task.mark_confirmed()
|
||||
@@ -168,21 +133,6 @@ def generate_editor_draft(
|
||||
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", [])
|
||||
# 从 plan config 读取封面 URL(由 generate-cover 保存)
|
||||
cover_url_from_config = (plan.config or {}).get("cover", {}).get("image_url", "")
|
||||
|
||||
# 处理标题配置:序列化 title_config 为 JSON 存入 custom_title
|
||||
title_config = req.title_config or {}
|
||||
title_text = (title_config.get("text") or "").strip()
|
||||
custom_title_value = ""
|
||||
if title_text:
|
||||
custom_title_value = json.dumps(title_config, ensure_ascii=False)
|
||||
logger.info(
|
||||
"[模板生成] 标题配置: text=%s, config_keys=%s",
|
||||
title_text[:30],
|
||||
list(title_config.keys()),
|
||||
)
|
||||
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=plan.project_id or "",
|
||||
@@ -190,8 +140,6 @@ def generate_editor_draft(
|
||||
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 [],
|
||||
cover_url=cover_url_from_config,
|
||||
custom_title=custom_title_value,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -286,7 +234,6 @@ def _get_task_output_url(task, gen_task_repo, db) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# DEPRECATED: 前端已改用 /generation/tasks 体系,此路由保留仅供旧版兼容,计划下线
|
||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
||||
def get_editor_generation_status(
|
||||
template_id: str,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re as _re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
@@ -44,14 +44,6 @@ class EditPlanGenerationStatusResponse(BaseModel):
|
||||
clips: List[ClipStatusItem]
|
||||
|
||||
|
||||
class EditPlanGenerateRequest(BaseModel):
|
||||
"""模板编辑器触发生成请求体"""
|
||||
title_config: Optional[Dict[str, Any]] = Field(
|
||||
default_factory=dict,
|
||||
description="标题配置(可选),渲染时烧录到视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
|
||||
)
|
||||
|
||||
|
||||
class EditPlanGenerateResponse(BaseModel):
|
||||
"""剪辑计划触发生成响应体"""
|
||||
|
||||
@@ -235,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列表")
|
||||
|
||||
@@ -448,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):
|
||||
|
||||
@@ -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):
|
||||
"""批量删除请求(软删除)。"""
|
||||
|
||||
|
||||
@@ -33,11 +33,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── 标题配置(结构化,优先于 custom_title 纯文本)──
|
||||
title_config: dict | None = Field(
|
||||
default=None,
|
||||
description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。",
|
||||
)
|
||||
# ── 视频标题 ──
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
# ── 批量生成 ──
|
||||
@@ -114,7 +109,6 @@ class GenerationTaskResponse(BaseModel):
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
title_config: dict = Field(default_factory=dict)
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -214,8 +214,12 @@ 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 })
|
||||
// Step 5: 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 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,37 +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
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
}
|
||||
|
||||
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
|
||||
export interface GenerationTaskDetail {
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
@@ -97,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
|
||||
}
|
||||
|
||||
@@ -4,29 +4,51 @@
|
||||
import apiClient from "../client"
|
||||
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> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`)
|
||||
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`)
|
||||
@@ -50,3 +72,20 @@ export async function getGenerationTaskResults(taskId: string): Promise<Generate
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(templateId: string): Promise<void> {
|
||||
await apiClient.post(`/templates/${templateId}/editor/cancel`)
|
||||
}
|
||||
|
||||
/** 复制模板草稿(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
templateId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/templates/${templateId}/editor/copy`,
|
||||
data || {},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ export type {
|
||||
EditPlanSegment,
|
||||
EditPlanConfig,
|
||||
EditPlan,
|
||||
CreateEditPlanRequest,
|
||||
UpdateEditPlanRequest,
|
||||
EditPlanListParams,
|
||||
EditPlanListResponse,
|
||||
GenerateResponse,
|
||||
EditPlanGeneration,
|
||||
ClipStatusItem,
|
||||
@@ -34,6 +37,7 @@ export type {
|
||||
ClipReorderResponse,
|
||||
ClipBatchDeleteResponse,
|
||||
ClipsFromAssetsResponse,
|
||||
CopyEditPlanRequest,
|
||||
TransitionEffect,
|
||||
MediaAsset,
|
||||
} from "./types"
|
||||
@@ -49,12 +53,17 @@ export {
|
||||
|
||||
// 模板草稿 CRUD + 生成
|
||||
export {
|
||||
getEditPlans,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
copyEditPlan,
|
||||
} from "./editPlans"
|
||||
|
||||
// 片段 CRUD + 批量操作
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V24 前端预览播放器架构改造
|
||||
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
|
||||
* 智能剪辑页面 — V22 多预览 + 配音前置
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 选择标题 → 生成预览 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
*
|
||||
* 架构改造:
|
||||
* - Step5 预览改为前端素材切片播放(FrontendPreviewPlayer)
|
||||
* - 完全去除后端 FFmpeg 预览依赖
|
||||
* - 标题样式通过 CSS 层实时叠加,所见即所得
|
||||
* - 最终成片仍走后端 FFmpeg 渲染(Step7 确认生成)
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
* 步骤导航 → hooks/useStepNavigation
|
||||
* 步骤内容 → components/GenerateStepContent
|
||||
* 底部按钮 → components/GenerateStepActions
|
||||
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||
*/
|
||||
import React, { useMemo } 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 type { VoiceClone } from "@/api/voice-clone"
|
||||
import { getAssetsByKind } from "@/api/assets/assets"
|
||||
import type { AssetItem } from "@/api/assets/types"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
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 PreviewVideoPanel from "./components/PreviewVideoPanel"
|
||||
@@ -30,8 +27,7 @@ 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 { useStep5Preview } from "./hooks/useStep5Preview"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -78,12 +74,20 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewModalOpen,
|
||||
} = formState
|
||||
|
||||
/* ── 标题样式回调(Step5 样式面板 + 右侧预览 CSS 层共用) ── */
|
||||
const styleUpdaters = useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
onTitleSettingsChange: setTitleSettings,
|
||||
/* ── 查询视频素材,用于 Step4 标题预览背景 ── */
|
||||
const { data: videoAssets = [] } = useQuery({
|
||||
queryKey: ["generate-video-assets"],
|
||||
queryFn: () => getAssetsByKind("video", { limit: 50 }),
|
||||
})
|
||||
|
||||
// 获取第一个选中素材的 URL
|
||||
const sourceVideoUrl = useMemo(() => {
|
||||
const firstId = selectedMaterials[0]
|
||||
if (!firstId) return undefined
|
||||
const asset = videoAssets.find((a: AssetItem) => a.id === firstId)
|
||||
return asset?.file_url
|
||||
}, [selectedMaterials, videoAssets])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
@@ -93,44 +97,37 @@ const GeneratePage: React.FC = () => {
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── 前端预览:加载选中素材的视频文件信息 ── */
|
||||
const previewAssetIds = useMemo(
|
||||
() => (materialMode === "auto" ? smartSelectedIds : selectedMaterials),
|
||||
[materialMode, smartSelectedIds, selectedMaterials],
|
||||
)
|
||||
const previewAssetsEnabled = currentStep >= 4 && previewAssetIds.length > 0
|
||||
const {
|
||||
assets: previewAssets,
|
||||
loading: previewAssetsLoading,
|
||||
ready: previewAssetsReady,
|
||||
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
/* ── 预览数量(多预览) ── */
|
||||
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])
|
||||
|
||||
/* ── 视频总时长计算(用于配音时长校验) ── */
|
||||
const totalVideoDuration = useMemo(() => {
|
||||
// 优先用素材精确时长;素材未加载时用模板 segments 的 duration_max 之和估算
|
||||
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
|
||||
if (exact > 0) return exact
|
||||
return estimateTotalVideoDuration(currentTemplate ?? undefined)
|
||||
}, [previewAssets, currentTemplate])
|
||||
|
||||
/* ── 配音音频 URL ── */
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
/* ── Step5 预览生成(多预览 + voice_ids) ── */
|
||||
const step5Preview = useStep5Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
})
|
||||
|
||||
const voiceAudioUrl = useMemo(() => {
|
||||
if (!selectedVoice) return undefined
|
||||
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
|
||||
return asset?.file_url || undefined
|
||||
}, [selectedVoice, voiceMaterials])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
@@ -140,7 +137,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: previewAssetsReady,
|
||||
previewReady: step5Preview.canProceed,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -171,7 +168,7 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
sourceEditPlanId: editPlanId,
|
||||
previewTaskId: step5Preview.selectedTaskId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
@@ -203,23 +200,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}
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
duration={duration}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
@@ -239,6 +224,19 @@ const GeneratePage: React.FC = () => {
|
||||
onRetry={handleRetryGenerate}
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
/* Step5 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step5Preview.items}
|
||||
previewSelectedIndex={step5Preview.selectedIndex}
|
||||
onSelectPreview={step5Preview.setSelectedIndex}
|
||||
previewOverallStatus={step5Preview.previewStatus}
|
||||
previewOverallError={step5Preview.previewError}
|
||||
previewOverallProgress={step5Preview.progress}
|
||||
previewAnyGenerating={step5Preview.anyGenerating}
|
||||
onGeneratePreview={step5Preview.generatePreview}
|
||||
onRegeneratePreview={step5Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -254,18 +252,23 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step4+ 显示,含 CSS 标题实时预览层) */}
|
||||
{/* 预览视频面板(Step4+ 显示,Step4 显示标题预览叠加,Step5+ 仅视频) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
previewStatus={step5Preview.previewStatus}
|
||||
previewResult={step5Preview.previewResult}
|
||||
previewError={step5Preview.previewError}
|
||||
progress={step5Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
assetsReady={previewAssetsReady}
|
||||
assetsLoading={previewAssetsLoading}
|
||||
onRegenerate={step5Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrl={voiceAudioUrl}
|
||||
showTitlePreview={currentStep === 4}
|
||||
sourceVideoUrl={sourceVideoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果(Step6+ 才显示) */}
|
||||
{currentStep >= 6 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
@@ -287,7 +290,6 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ── 视频预览弹窗 ── */}
|
||||
<Modal
|
||||
className="xx-preview-modal"
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
footer={null}
|
||||
|
||||
@@ -1,525 +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: _videoRatio,
|
||||
ready,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
// 默认走原生 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
|
||||
className="xx-preview-empty"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }} />
|
||||
<p className="xx-preview-empty-title">准备预览素材...</p>
|
||||
<p className="xx-preview-empty-desc">加载素材后即可预览播放</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
|
||||
return (
|
||||
<div
|
||||
className="xx-preview-empty"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)" }}>加载中...</p>
|
||||
</>
|
||||
) : showDecodeError ? (
|
||||
<>
|
||||
<PlayCircleOutlined style={{ fontSize: 48, color: "#ef4444", marginBottom: 12 }} />
|
||||
<p className="xx-preview-empty-title" style={{ color: "rgba(255,255,255,0.9)" }}>
|
||||
视频解码失败
|
||||
</p>
|
||||
<p
|
||||
className="xx-preview-empty-desc"
|
||||
style={{ color: "rgba(255,255,255,0.6)", maxWidth: 300, textAlign: "center" }}
|
||||
>
|
||||
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<p className="xx-preview-empty-title">暂无可播放素材</p>
|
||||
<p className="xx-preview-empty-desc">请先在左侧选择素材</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── 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
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 播放按钮 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
className="xx-preview-play-btn"
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: 56,
|
||||
height: 56,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 28,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{`片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
||||
</div>
|
||||
|
||||
{/* 控制条 */}
|
||||
<div
|
||||
className="xx-preview-controls"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.6))",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: 18,
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
minWidth: 80,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 4,
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
background: "#3b82f6",
|
||||
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: "#3b82f6",
|
||||
border: "2px solid #fff",
|
||||
opacity: isDragging ? 1 : 0,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FrontendPreviewPlayer
|
||||
@@ -2,8 +2,6 @@
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* V24: 移除 Step5 预览生成相关 props,改为纯标题样式编辑
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -11,6 +9,7 @@ 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/useStep5Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
@@ -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,6 +63,19 @@ export interface GenerateStepContentProps {
|
||||
onDismissError: () => void
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
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
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
@@ -92,22 +92,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,
|
||||
@@ -121,6 +110,18 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
previewItems,
|
||||
previewSelectedIndex,
|
||||
onSelectPreview,
|
||||
previewOverallStatus,
|
||||
previewOverallError,
|
||||
previewOverallProgress,
|
||||
previewAnyGenerating,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
|
||||
switch (currentStep) {
|
||||
@@ -141,7 +142,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSelectedMaterialsChange={onSelectedMaterialsChange}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -149,7 +149,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
<Step3VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
@@ -157,23 +156,23 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5GeneratePreview
|
||||
titleSettings={titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
onUpdateFont={onUpdateFont}
|
||||
onUpdateSize={onUpdateSize}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStroke={onToggleStroke}
|
||||
onToggleShadow={onToggleShadow}
|
||||
onApplyPreset={onApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
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 6:
|
||||
@@ -184,7 +183,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
duration={duration}
|
||||
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
|
||||
selectedTemplate={selectedTemplate}
|
||||
titleSettings={titleSettings}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
|
||||
@@ -1,256 +1,293 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step4+: 显示预览视频面板
|
||||
* Step5: 前端实时预览 — 用原生 video 播放素材片段 + CSS 标题叠加
|
||||
* Step4 生成预览后常驻显示预览视频
|
||||
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
*
|
||||
* 架构改造:完全去除后端 FFmpeg 预览依赖
|
||||
* - 使用 FrontendPreviewPlayer 直接播放素材片段
|
||||
* - TitleOverlay CSS 层实时响应标题样式变化
|
||||
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
|
||||
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
|
||||
*
|
||||
* 布局:本组件提供 .xx-preview-video 容器(position: relative + overflow: hidden)
|
||||
* FrontendPreviewPlayer 的内容通过 absolute 定位填充容器
|
||||
* TitleOverlay 通过 absolute 定位 + z-index: 30 覆盖在最上层
|
||||
* Canvas 居中修复说明:
|
||||
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect),
|
||||
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
|
||||
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移。
|
||||
*/
|
||||
import React, { useMemo, useRef, useState, useEffect } from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import React, { useRef, useEffect, useCallback } from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { getFontFamily } from "../constants"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
import { drawTitleOnCanvas } from "../utils/drawTitleOnCanvas"
|
||||
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
/** 已加载的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 当前模板 */
|
||||
template: EditingTemplate | null
|
||||
/** 视频比例 */
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
videoRatio: string
|
||||
/** 素材是否已加载就绪 */
|
||||
assetsReady: boolean
|
||||
/** 素材是否正在加载 */
|
||||
assetsLoading: boolean
|
||||
/** 标题设置 — 用于 CSS 实时预览层 */
|
||||
onRegenerate: () => void
|
||||
/** 标题文字 */
|
||||
titleText?: string
|
||||
/** 标题样式设置 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 配音音频 URL */
|
||||
voiceAudioUrl?: string
|
||||
/** Step4 标题预览模式 */
|
||||
showTitlePreview?: boolean
|
||||
/** 素材视频 URL(用于 Step4 标题预览背景) */
|
||||
sourceVideoUrl?: string
|
||||
}
|
||||
|
||||
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
|
||||
const ASS_VIDEO_HEIGHT = 720
|
||||
const ASS_TITLE_MARGIN_TOP = 60
|
||||
const ASS_TITLE_MARGIN_BOTTOM = 60
|
||||
const ASS_TITLE_MARGIN_SIDE = 40
|
||||
|
||||
/**
|
||||
* 根据 position 计算 CSS 垂直定位
|
||||
* 与后端 position_to_ass_alignment() 对齐:top→8, center→5, bottom→2
|
||||
*/
|
||||
function getPositionStyle(position: string): React.CSSProperties {
|
||||
const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100
|
||||
|
||||
switch (position) {
|
||||
case "bottom":
|
||||
return {
|
||||
bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 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: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 CSS 标题层的样式
|
||||
* 所有渲染参数与后端 FFmpeg ASS 字幕一致
|
||||
*/
|
||||
function buildTitleStyle(settings: TitleSettings, containerHeight: number): React.CSSProperties {
|
||||
// 用 px 计算 fontSize,不再依赖父元素 font-size 的百分比
|
||||
const fontSizePx =
|
||||
containerHeight > 0
|
||||
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight
|
||||
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 // fallback
|
||||
|
||||
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",
|
||||
paddingLeft: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
|
||||
paddingRight: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
|
||||
}
|
||||
|
||||
if (settings.stroke) {
|
||||
base.WebkitTextStroke = "1px #000000"
|
||||
}
|
||||
|
||||
if (settings.shadow) {
|
||||
base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 标题预览覆盖层
|
||||
* 始终渲染:有标题显示标题,无标题显示占位文本"标题预览"
|
||||
* z-index: 20(在视频 z-index:1 和控制条 z-index:10 之上)
|
||||
*/
|
||||
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400) // fallback
|
||||
|
||||
// ResizeObserver 获取容器实际高度
|
||||
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 positionStyle = useMemo(
|
||||
() => getPositionStyle(titleSettings.position),
|
||||
[titleSettings.position],
|
||||
)
|
||||
const titleStyle = useMemo(
|
||||
() => buildTitleStyle(titleSettings, containerHeight),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 已逐字段列出 titleSettings 依赖
|
||||
[
|
||||
containerHeight,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
titleSettings.bold,
|
||||
titleSettings.italic,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
],
|
||||
)
|
||||
|
||||
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> = ({
|
||||
assets,
|
||||
template,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
videoRatio,
|
||||
assetsReady,
|
||||
assetsLoading,
|
||||
onRegenerate,
|
||||
titleText,
|
||||
titleSettings,
|
||||
voiceAudioUrl,
|
||||
showTitlePreview,
|
||||
sourceVideoUrl,
|
||||
}) => {
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
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>
|
||||
{assetsReady && assets.length > 0 && <span className="xx-preview-badge">实时预览</span>}
|
||||
<h3>{showTitlePreview && !hasPreview ? "标题预览" : "预览视频"}</h3>
|
||||
{hasPreview && !showTitlePreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
</div>
|
||||
|
||||
{/* ✅ 预览容器 — 唯一的 .xx-preview-video 容器
|
||||
内部所有内容(视频、控制条、标题叠加层)通过 absolute 定位填充 */}
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
{/* 加载中状态 */}
|
||||
{assetsLoading && (
|
||||
<div
|
||||
className="xx-preview-loading-center"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
加载素材中...
|
||||
</p>
|
||||
{/* Step4 标题预览模式 */}
|
||||
{showTitlePreview && titleSettings && titleText && (
|
||||
<div style={{ padding: "0 16px 16px" }}>
|
||||
<TitlePreviewCanvas
|
||||
titleText={titleText}
|
||||
titleSettings={titleSettings}
|
||||
videoRatio="9:16"
|
||||
sourceVideoUrl={sourceVideoUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step4 但无标题时的空状态 */}
|
||||
{showTitlePreview && (!titleText || !titleSettings) && (
|
||||
<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">在左侧设置标题后,这里会实时预览效果</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态:还没生成预览(非 Step4 模式) */}
|
||||
{!showTitlePreview && 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">在左侧生成预览后在此查看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中(非 Step4 模式) */}
|
||||
{!showTitlePreview && 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>
|
||||
)}
|
||||
|
||||
{/* 前端播放器(视频 + 控制条 + 播放按钮)*/}
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
template={template}
|
||||
videoRatio={videoRatio}
|
||||
ready={assetsReady}
|
||||
voiceAudioUrl={voiceAudioUrl}
|
||||
/>
|
||||
{/* 生成失败(非 Step4 模式) */}
|
||||
{!showTitlePreview && 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>
|
||||
)}
|
||||
|
||||
{/* CSS 标题实时预览层 — z-index: 20,始终渲染在内容层之上 */}
|
||||
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
|
||||
</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={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材信息 */}
|
||||
{assetsReady && assets.length > 0 && (
|
||||
{/* 预览信息(非 Step4 模式) */}
|
||||
{!showTitlePreview && hasPreview && previewResult && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>素材数</span>
|
||||
<span>{assets.length} 个</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>
|
||||
|
||||
@@ -15,8 +15,6 @@ interface Step2MaterialSelectProps {
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
@@ -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,82 +1,274 @@
|
||||
/**
|
||||
* Step 5 生成预览组件
|
||||
* 架构改造:移除后端预览生成,改为前端实时预览
|
||||
* 左侧仅保留标题样式面板,视频在右侧 PreviewVideoPanel 实时播放
|
||||
* Step 5 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
*/
|
||||
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"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
|
||||
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 }[]
|
||||
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 Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
titleSettings,
|
||||
onUpdatePosition,
|
||||
onUpdateFont,
|
||||
onUpdateSize,
|
||||
onToggleBold,
|
||||
onToggleItalic,
|
||||
onToggleStroke,
|
||||
onToggleShadow,
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
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>
|
||||
<h3>🎬 生成预览</h3>
|
||||
|
||||
{/* 前端实时预览提示 */}
|
||||
<div
|
||||
className="xx-preview-tip"
|
||||
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>
|
||||
{/* 预览数量选择器(仅在 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>
|
||||
)}
|
||||
|
||||
{/* 标题样式面板 */}
|
||||
<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}
|
||||
/>
|
||||
{/* 预览生成按钮(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: "#1a1a2e",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{/* 中心:预览编号 */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</span>
|
||||
|
||||
{/* 状态指示 */}
|
||||
{item.status === "generating" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<LoadingOutlined style={{ fontSize: 14, color: "#fff" }} spin />
|
||||
<span style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
|
||||
生成中 {item.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "pending" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<ClockCircleOutlined
|
||||
style={{ fontSize: 14, color: "rgba(255,255,255,0.6)" }}
|
||||
/>
|
||||
<span style={{ color: "rgba(255,255,255,0.6)", fontSize: 12 }}>
|
||||
排队中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "ready" && (
|
||||
<CheckCircleFilled style={{ fontSize: 18, color: "#52c41a" }} />
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<ExclamationCircleFilled style={{ fontSize: 18, color: "#ef4444" }} />
|
||||
)}
|
||||
|
||||
{/* 选中角标 */}
|
||||
{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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,8 +14,6 @@ interface Step6CoverSettingsProps {
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
titleSettings?: import("../types").TitleSettings
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
@@ -42,7 +40,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
duration: props.duration,
|
||||
assetIds: props.assetIds,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
titleSettings: props.titleSettings,
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 标题实时预览 Canvas 组件
|
||||
*
|
||||
* 在 Step4 标题设置面板中嵌入,让用户实时看到标题文字、字体、大小、颜色、
|
||||
* 位置、描边、阴影等样式的实际渲染效果(所见即所得)。
|
||||
*
|
||||
* 使用共享的 drawTitleOnCanvas 工具函数,与 PreviewVideoPanel 行为一致。
|
||||
*/
|
||||
import React, { useRef, useEffect } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { drawTitleOnCanvas } from "../../utils/drawTitleOnCanvas"
|
||||
|
||||
interface TitlePreviewCanvasProps {
|
||||
/** 标题文字 */
|
||||
titleText: string
|
||||
/** 标题样式设置 */
|
||||
titleSettings: TitleSettings
|
||||
/** 视频比例,默认 "9:16"(竖屏) */
|
||||
videoRatio?: string
|
||||
/** 素材视频 URL(作为背景显示) */
|
||||
sourceVideoUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 videoRatio 字符串为 aspect-ratio CSS 值
|
||||
*/
|
||||
function parseAspect(ratio: string): string {
|
||||
return (ratio || "9:16").replace(":", "/")
|
||||
}
|
||||
|
||||
const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
|
||||
titleText,
|
||||
titleSettings,
|
||||
videoRatio = "9:16",
|
||||
sourceVideoUrl,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// 字体加载状态
|
||||
const fontLoadedRef = useRef(false)
|
||||
|
||||
/** 在 Canvas 上绘制标题 */
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return
|
||||
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
|
||||
// 更新 Canvas CSS 尺寸匹配容器
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
drawTitleOnCanvas(ctx, w, h, titleText, titleSettings, 24, titleSettings.position, 40)
|
||||
}
|
||||
|
||||
// 字体加载:确保 measureText 使用正确字体
|
||||
useEffect(() => {
|
||||
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
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) draw()
|
||||
})
|
||||
}
|
||||
|
||||
// 用 FontFace API 加载字体,失败则降级
|
||||
try {
|
||||
const fontFace = new FontFace(titleSettings.font, `local("${titleSettings.font}")`)
|
||||
fontFace
|
||||
.load()
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- FontFaceSet.add() exists at runtime
|
||||
;(document.fonts as any).add(fontFace)
|
||||
onFontReady()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 字体加载失败,用默认字体继续
|
||||
onFontReady()
|
||||
})
|
||||
} catch {
|
||||
// FontFace 不可用,直接绘制
|
||||
onFontReady()
|
||||
}
|
||||
|
||||
// 同时检查 document.fonts 是否已有该字体
|
||||
if (document.fonts.check(fontSpec)) {
|
||||
onFontReady()
|
||||
return
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
|
||||
}, [titleSettings.font, titleSettings.size, titleSettings.bold, titleSettings.italic])
|
||||
|
||||
// props 变化时重绘
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(draw)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
|
||||
}, [titleText, titleSettings])
|
||||
|
||||
// ResizeObserver 监听容器尺寸变化
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
requestAnimationFrame(draw)
|
||||
})
|
||||
observer.observe(container)
|
||||
|
||||
return () => observer.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginBottom: 6,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
预览效果
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: parseAspect(videoRatio),
|
||||
background: sourceVideoUrl
|
||||
? "#000"
|
||||
: "linear-gradient(135deg, #1a1a2e, #16213e, #0f3460)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{sourceVideoUrl && (
|
||||
<video
|
||||
src={sourceVideoUrl}
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitlePreviewCanvas
|
||||
@@ -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;
|
||||
@@ -2668,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;
|
||||
}
|
||||
|
||||
@@ -2774,7 +2772,7 @@
|
||||
|
||||
.xx-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2883,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,8 +19,8 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */
|
||||
sourceEditPlanId?: string | null
|
||||
/** 预览任务的 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
|
||||
@@ -5,7 +5,7 @@
|
||||
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"
|
||||
@@ -34,6 +34,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}, [])
|
||||
|
||||
const { startPolling, clearTimer } = useGenerationPolling({
|
||||
templateId: selectedTemplate,
|
||||
onProgress: handleProgress,
|
||||
onComplete: handleComplete,
|
||||
onFailed: handleFailed,
|
||||
@@ -54,17 +55,21 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 解析分辨率
|
||||
// 使用确认生成 API(基于预览任务)
|
||||
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
|
||||
const ratio = props.videoRatio || "9:16"
|
||||
let outputWidth: number
|
||||
let outputHeight: number
|
||||
|
||||
if (ratio.includes(":")) {
|
||||
// 宽高比格式,如 "9:16" → 基于基准高度 1920 计算
|
||||
const [rw, rh] = ratio.split(":").map(Number)
|
||||
if (rw > 0 && rh > 0) {
|
||||
// 基准:长边 1920,短边按比例计算
|
||||
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
|
||||
const baseLong = 1920
|
||||
const baseShort = Math.round((baseLong * shortSide) / longSide)
|
||||
// 确保偶数(FFmpeg 要求)
|
||||
const evenShort = baseShort - (baseShort % 2)
|
||||
if (rw < rh) {
|
||||
outputWidth = evenShort
|
||||
@@ -78,6 +83,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
outputHeight = 1920
|
||||
}
|
||||
} else if (ratio.includes("x")) {
|
||||
// 分辨率格式,如 "1080x1920"
|
||||
const [wStr, hStr] = ratio.split("x")
|
||||
outputWidth = parseInt(wStr, 10) || 1080
|
||||
outputHeight = parseInt(hStr, 10) || 1920
|
||||
@@ -86,45 +92,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
outputHeight = 1920
|
||||
}
|
||||
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
|
||||
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
// 直接创建正式生成任务
|
||||
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,
|
||||
...(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 || "",
|
||||
})
|
||||
|
||||
// 从创建响应直接拿 task_id,改用新接口轮询
|
||||
const taskId = taskResp.items?.[0]?.id
|
||||
if (!taskId) {
|
||||
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
|
||||
}
|
||||
startPolling(taskId)
|
||||
startPolling()
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
@@ -134,7 +109,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
}, [props, clearTimer, startPolling])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
|
||||
@@ -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
|
||||
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useRef } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -15,8 +14,6 @@ interface UseStep2MaterialsProps {
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -26,7 +23,6 @@ export function useStep2Materials({
|
||||
onSelectedMaterialsChange,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
selectedTemplate,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
@@ -57,14 +53,6 @@ export function useStep2Materials({
|
||||
handleSmartMatch()
|
||||
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
|
||||
|
||||
/* ── Step2 选择素材后自动保存草稿(防抖 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])
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Step 5 生成预览 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"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 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 UseStep5PreviewProps {
|
||||
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
|
||||
/** 标题设置(传递给后端,让预览视频包含标题) */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
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 useStep5Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
titleSettings,
|
||||
}: UseStep5PreviewProps) {
|
||||
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(","),
|
||||
titleSettings: titleSettings?.title || "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
[...(voiceIds || [])].sort().join(","),
|
||||
titleSettings?.title || "",
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
prevDepsRef.current.voiceIds,
|
||||
prevDepsRef.current.titleSettings,
|
||||
].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(","),
|
||||
titleSettings: titleSettings?.title || "",
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
titleSettings,
|
||||
])
|
||||
|
||||
// 组件卸载时清理所有轮询
|
||||
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,
|
||||
// 标题烧录配置
|
||||
title_config: titleSettings?.title
|
||||
? {
|
||||
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,
|
||||
}
|
||||
: 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,
|
||||
titleSettings,
|
||||
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 useStep5Preview
|
||||
@@ -6,9 +6,6 @@ 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,8 +21,6 @@ interface UseStep6CoverProps {
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
@@ -34,7 +29,6 @@ export function useStep6Cover({
|
||||
duration,
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
titleSettings,
|
||||
}: UseStep6CoverProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
@@ -97,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 || ""
|
||||
@@ -129,160 +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 {
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || 30,
|
||||
...(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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
// 轮询等待预览渲染完成:递归 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,
|
||||
])
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
|
||||
*
|
||||
* V24: previewReady 改为前端素材加载状态
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -16,7 +14,7 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** 预览是否就绪(前端素材已加载) */
|
||||
/** Step4 是否已生成预览 */
|
||||
previewReady: boolean
|
||||
}
|
||||
|
||||
@@ -56,7 +54,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
return
|
||||
}
|
||||
if (currentStep === 5 && !previewReady) {
|
||||
message.warning("请先选择素材以预览效果")
|
||||
message.warning("请先生成剪辑预览")
|
||||
return
|
||||
}
|
||||
if (currentStep < 7) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Canvas 标题绘制工具函数(共享模块)
|
||||
*
|
||||
* 供 PreviewVideoPanel(预览视频标题叠加)和 TitlePreviewCanvas(标题设置实时预览)共用。
|
||||
* 绘制行为与 ASS 字幕引擎一致:逐字换行、居中、描边/阴影。
|
||||
*/
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
/**
|
||||
* 将文本按 maxWidth 逐字换行,返回行数组。
|
||||
* 与 ASS 字幕引擎的逐字换行行为一致。
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
|
||||
*
|
||||
* @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 顶部/底部偏移量
|
||||
*/
|
||||
export function drawTitleOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
text: string,
|
||||
settings: TitleSettings,
|
||||
paddingX: number,
|
||||
position: string,
|
||||
topOffset: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
// 设置 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
|
||||
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
|
||||
}
|
||||
}
|
||||
+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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,16 +20,9 @@ vi.mock("@/api/auth", () => ({
|
||||
refreshAccessToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/tokenRefresh", () => ({
|
||||
scheduleProactiveRefresh: vi.fn(),
|
||||
cancelProactiveRefresh: vi.fn(),
|
||||
executeTokenRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "@/api/auth"
|
||||
import { executeTokenRefresh } from "@/api/auth/tokenRefresh"
|
||||
import apiClient from "@/api/client"
|
||||
|
||||
// 从真实实例取出拦截器回调
|
||||
@@ -271,28 +264,23 @@ describe("apiClient - 401 token refresh", () => {
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
|
||||
it("refreshes token on 401 and calls executeTokenRefresh", async () => {
|
||||
it("refreshes token on 401 and calls setAuth", async () => {
|
||||
const mockSetAuth = vi.fn()
|
||||
let currentAccessToken = "old-access"
|
||||
vi.mocked(useAuthStore.getState).mockImplementation(() => ({
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: currentAccessToken,
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: ((_user: any, newAccess: string, _newRefresh: string) => {
|
||||
currentAccessToken = newAccess
|
||||
mockSetAuth(_user, newAccess, _newRefresh)
|
||||
}) as any,
|
||||
}))
|
||||
|
||||
// Mock executeTokenRefresh to simulate successful refresh
|
||||
vi.mocked(executeTokenRefresh).mockImplementation(() => {
|
||||
currentAccessToken = "new-access"
|
||||
mockSetAuth({ id: "1", email: "test@test.com" }, "new-access", "new-refresh")
|
||||
return Promise.resolve()
|
||||
})
|
||||
setAuth: mockSetAuth,
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockResolvedValue({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
} as never)
|
||||
|
||||
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
|
||||
// 但我们只关心刷新逻辑是否正确执行
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
@@ -301,45 +289,31 @@ describe("apiClient - 401 token refresh", () => {
|
||||
// 重试会因为没有真实网络而失败,忽略
|
||||
}
|
||||
|
||||
expect(executeTokenRefresh).toHaveBeenCalled()
|
||||
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles refresh failure by logging out", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
// After executeTokenRefresh fails, it clears auth (sets accessToken to null)
|
||||
// and redirects to /login. The promise resolves (doesn't reject).
|
||||
let currentAccessToken: string | null = "old-access"
|
||||
vi.mocked(useAuthStore.getState).mockImplementation(() => ({
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: currentAccessToken,
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: currentAccessToken !== null,
|
||||
clearAuth: (() => {
|
||||
currentAccessToken = null
|
||||
mockClearAuth()
|
||||
window.location.href = "/login"
|
||||
}) as any,
|
||||
isAuthenticated: true,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
}))
|
||||
// Mock executeTokenRefresh: simulates failure → clears auth + redirects
|
||||
vi.mocked(executeTokenRefresh).mockImplementation(() => {
|
||||
currentAccessToken = null
|
||||
mockClearAuth()
|
||||
window.location.href = "/login"
|
||||
return Promise.resolve()
|
||||
})
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// expected - rejects because accessToken is null after failed refresh
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(executeTokenRefresh).toHaveBeenCalled()
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/login")
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
getEditPlans,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
aiRecommendClips,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
getEditPlanClips,
|
||||
getEditPlanClip,
|
||||
createEditPlanClip,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
copyEditPlan,
|
||||
getMediaAssets,
|
||||
getMediaAsset,
|
||||
} from "@/api/template-editor"
|
||||
@@ -48,6 +53,22 @@ describe("editPlans API", () => {
|
||||
mockPatch.mockResolvedValue({ data: { success: true, items: [] } })
|
||||
})
|
||||
|
||||
describe("getEditPlans", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlans("test-params?")).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(getEditPlans("test-params?")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlan("test-planId")).resolves.not.toThrow()
|
||||
@@ -64,6 +85,22 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("createEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(createEditPlan({ 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(createEditPlan({ name: "test-item" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(updateEditPlan("test-planId")).resolves.not.toThrow()
|
||||
@@ -80,6 +117,22 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(deleteEditPlan("test-planId")).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(deleteEditPlan("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(generateEditPlan("test-planId")).resolves.not.toThrow()
|
||||
@@ -160,6 +213,22 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancelGeneration", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(cancelGeneration("test-planId")).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(cancelGeneration("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEditPlanClips", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getEditPlanClips("test-planId")).resolves.not.toThrow()
|
||||
@@ -288,6 +357,22 @@ describe("editPlans API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("copyEditPlan", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(copyEditPlan("test-planId")).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(copyEditPlan("test-planId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMediaAssets", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getMediaAssets("test-libraryId?")).resolves.not.toThrow()
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
} from "@/api/templates"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
@@ -115,4 +116,20 @@ describe("templates API", () => {
|
||||
await expect(copyTemplate("test-templateId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateFromTemplate", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(generateFromTemplate("test-templateId")).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(generateFromTemplate("test-templateId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,7 @@ vi.mock("@/api/voice-clone", () => ({
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
uploadAssetDirect: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ storage_key: "test", ingest_job_id: "test", url: "http://test" }),
|
||||
uploadAsset: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
|
||||
@@ -31,27 +31,14 @@ vi.mock("react-router-dom", async () => {
|
||||
})
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector: any) =>
|
||||
selector({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
{
|
||||
getState: () => ({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
},
|
||||
),
|
||||
useAuthStore: (selector: any) =>
|
||||
selector({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -150,10 +150,12 @@ vi.mock("@/api/template-editor", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlan: vi.fn().mockResolvedValue({}),
|
||||
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
|
||||
updateEditPlan: vi.fn().mockResolvedValue({}),
|
||||
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
|
||||
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
cancelGeneration: vi.fn().mockResolvedValue({}),
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -180,6 +180,7 @@ vi.mock("@/api/assets", () => ({
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
smartMatchAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -109,6 +109,7 @@ vi.mock("@/api/templates", () => ({
|
||||
getTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
|
||||
toggleFavoriteTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
|
||||
copyTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
|
||||
generateFromTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/templates/TemplateLibrary.css", () => ({}))
|
||||
|
||||
@@ -165,6 +165,7 @@ vi.mock("@/api/assets", () => ({
|
||||
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
uploadAssetDirect: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -47,8 +47,7 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
@@ -93,5 +93,5 @@ describe("useStep5Voice smoke test", () => {
|
||||
)
|
||||
expect(result.current).toBeDefined()
|
||||
expect(typeof result.current.handlePlayCloneSample).toBe("function")
|
||||
}, 15_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
testTimeout: 15_000, // 全局 15 秒,防止 CI 高负载时偶发超时
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
|
||||
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# OSS 上传配置
|
||||
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT = 900 # 单文件上传总超时(秒),防止网络慢时无限卡住
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒),防止网络慢时无限卡住
|
||||
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片
|
||||
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
|
||||
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
|
||||
@@ -127,10 +127,10 @@ def download_asset(asset_storage_key: str, local_path: Path) -> bool:
|
||||
def _download_via_http(url: str, local_path: Path) -> bool:
|
||||
"""通过 HTTP 下载文件(支持预签名 URL)。
|
||||
|
||||
使用流式下载避免大文件内存溢出,超时 900s。
|
||||
使用流式下载避免大文件内存溢出,超时 300s。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(url, stream=True, timeout=900)
|
||||
resp = requests.get(url, stream=True, timeout=300)
|
||||
resp.raise_for_status()
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024):
|
||||
@@ -146,7 +146,7 @@ def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公开 URL。
|
||||
|
||||
大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。
|
||||
上传加总超时保护(默认 900s),防止网络异常时无限挂死。
|
||||
上传加总超时保护(默认 300s),防止网络异常时无限挂死。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径(Path 或 str 均可)
|
||||
|
||||
@@ -585,11 +585,14 @@ class RenderAdapter:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
|
||||
|
||||
# 已渲染视频在统一渲染阶段已通过 ASS 字幕把标题烧录进画面,
|
||||
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
|
||||
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
|
||||
# 从 plan config 提取标题文字,叠加到封面候选帧上
|
||||
_title_cfg = (plan_config or {}).get("title", {}) or {}
|
||||
if not isinstance(_title_cfg, dict):
|
||||
_title_cfg = {}
|
||||
_title_text = (_title_cfg.get("text", "") or "").strip() if _title_cfg.get("enabled", True) else ""
|
||||
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=""
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=_title_text
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""视频封面抽帧工具 — 从视频中抽取帧作为封面,支持标题文字叠加。
|
||||
"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。
|
||||
|
||||
统一封面管道:
|
||||
- 从已渲染视频抽帧:标题已通过 ASS 字幕烧进视频,帧天然带标题,无需再叠加。
|
||||
- 从源素材抽帧(API E2 兜底):源素材无标题,通过 Pillow 在帧上绘制标题文字。
|
||||
统一封面管道:视频渲染时标题已通过 ASS 字幕烧进视频,
|
||||
渲染完成后直接从此视频抽帧,封面天然带标题,无需额外叠加逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,38 +12,6 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 标题叠加(Pillow)──────────────────────────────────────────────────────
|
||||
# 实现统一放在 packages/shared/title_overlay.py,API 和 Worker 共用。
|
||||
|
||||
|
||||
def apply_title_overlay(
|
||||
image_path: str,
|
||||
title_text: str,
|
||||
*,
|
||||
position: str = "bottom",
|
||||
font_size: int | None = None,
|
||||
margin_ratio: float = 0.06,
|
||||
stroke_width_ratio: float = 0.04,
|
||||
) -> str:
|
||||
"""在图片上绘制标题文字(白色 + 黑色描边/阴影)。
|
||||
|
||||
委托给 packages.shared.title_overlay.apply_title_to_image,
|
||||
保持 Worker 内调用方式不变。title_text 为空时直接返回原路径。
|
||||
"""
|
||||
from packages.shared.title_overlay import apply_title_to_image
|
||||
|
||||
if not title_text or not title_text.strip():
|
||||
return image_path
|
||||
result = apply_title_to_image(
|
||||
image_path,
|
||||
title_text,
|
||||
position=position,
|
||||
font_size=font_size,
|
||||
margin_ratio=margin_ratio,
|
||||
stroke_width_ratio=stroke_width_ratio,
|
||||
)
|
||||
return result or image_path
|
||||
|
||||
|
||||
def extract_first_frame(
|
||||
video_path: str,
|
||||
@@ -167,92 +134,3 @@ def _format_seek_time(seconds: float) -> str:
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = seconds % 60
|
||||
return f"{h:02d}:{m:02d}:{s:05.2f}"
|
||||
|
||||
|
||||
def generate_and_upload_thumbnail(
|
||||
video_path: str,
|
||||
storage_key: str,
|
||||
*,
|
||||
seek_ratio: float = 0.15,
|
||||
) -> str:
|
||||
"""从视频中提取一帧缩略图并上传到 OSS。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
storage_key: OSS 存储 key
|
||||
seek_ratio: 抽帧位置比例(默认 0.15)
|
||||
|
||||
Returns:
|
||||
上传后的 URL 字符串
|
||||
|
||||
Raises:
|
||||
RuntimeError: 抽帧或上传失败
|
||||
"""
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
try:
|
||||
frame_path = extract_first_frame(video_path, output_path=tmp.name, seek_ratio=seek_ratio)
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if not url:
|
||||
raise RuntimeError(f"上传缩略图到 OSS 失败: {storage_key}")
|
||||
return url
|
||||
finally:
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def extract_and_upload_cover_frames(
|
||||
video_path: str,
|
||||
plan_id: str,
|
||||
*,
|
||||
num_frames: int = 3,
|
||||
title_text: str = "",
|
||||
) -> list[dict]:
|
||||
"""从视频中抽取多帧作为封面候选,上传到 OSS。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||||
num_frames: 抽取帧数(默认 3)
|
||||
title_text: 标题文字;非空时用 Pillow 叠加到每帧(白色 + 黑色描边)。
|
||||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||||
|
||||
Returns:
|
||||
封面候选列表,每项包含 {"url": str, "position": float}
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
except Exception:
|
||||
duration = 0.0
|
||||
|
||||
candidates: list[dict] = []
|
||||
# 均匀分布抽帧点:从 10% 到 90%
|
||||
for i in range(num_frames):
|
||||
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
try:
|
||||
frame_path = extract_first_frame(
|
||||
video_path,
|
||||
output_path=tmp.name,
|
||||
seek_ratio=ratio,
|
||||
min_seek_seconds=0.5,
|
||||
)
|
||||
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
|
||||
if title_text and title_text.strip():
|
||||
apply_title_overlay(frame_path, title_text)
|
||||
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if url:
|
||||
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
|
||||
candidates.append({"url": url, "position": round(seek_time, 2)})
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
|
||||
finally:
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
return candidates
|
||||
|
||||
@@ -19,14 +19,4 @@ celery_app.conf.imports = (
|
||||
"worker_app.tasks.batch_download",
|
||||
"worker_app.tasks._startup",
|
||||
"apps.worker.video_processing.dedup",
|
||||
"worker_app.tasks.cleanup",
|
||||
)
|
||||
|
||||
# Celery Beat 定时任务调度
|
||||
celery_app.conf.beat_schedule = {
|
||||
"cleanup-stale-pending-tasks": {
|
||||
"task": "worker.cleanup_stale_pending_tasks",
|
||||
"schedule": 600.0, # 每 10 分钟(秒)
|
||||
"options": {"expires": 300}, # 5 分钟过期,避免堆积
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@ logger = logging.getLogger(__name__)
|
||||
# 孤儿任务超时阈值:渲染任务超过此时间未更新则视为卡死
|
||||
ORPHAN_TASK_TIMEOUT_MINUTES = 10
|
||||
|
||||
# Pending 任务超时阈值:pending 任务在队列中等待超过此时间则自动清理
|
||||
PENDING_TASK_TIMEOUT_MINUTES = 30
|
||||
|
||||
|
||||
def cleanup_orphan_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> int: # pragma: no cover
|
||||
"""清理数据库中超时未更新的 running GenerationTask(孤儿任务)。
|
||||
@@ -86,38 +83,6 @@ def cleanup_stale_jobs(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> in
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_stale_pending_tasks(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINUTES) -> int: # pragma: no cover
|
||||
"""清理数据库中卡在 pending 状态超时的 GenerationTask。
|
||||
|
||||
全局任务队列有 pending 数量上限,长期卡在 pending 的任务会占满队列,
|
||||
导致新用户无法创建任务。通过 created_at 超时判断并标记为 failed。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 PENDING_TASK_TIMEOUT_MINUTES
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
count = repo.cleanup_stale_pending(timeout_minutes)
|
||||
if count > 0:
|
||||
logger.warning("清理了 %d 个超时的 pending GenerationTask(超过 %d 分钟未处理)", count, timeout_minutes)
|
||||
else:
|
||||
logger.info("无超时 pending GenerationTask 需要清理")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error("清理超时 pending GenerationTask 失败: %s", e, exc_info=True)
|
||||
return 0
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def cleanup_all_stale_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> dict: # pragma: no cover
|
||||
"""统一清理所有超时的孤儿任务。
|
||||
|
||||
@@ -128,17 +93,15 @@ def cleanup_all_stale_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES)
|
||||
"""
|
||||
gen_count = cleanup_orphan_tasks(timeout_minutes)
|
||||
job_count = cleanup_stale_jobs(timeout_minutes)
|
||||
pending_count = cleanup_stale_pending_tasks(PENDING_TASK_TIMEOUT_MINUTES)
|
||||
total = gen_count + job_count + pending_count
|
||||
total = gen_count + job_count
|
||||
if total > 0:
|
||||
logger.warning(
|
||||
"任务清理完成: 孤儿 GenerationTask=%d, 孤儿 Job=%d, 超时 pending=%d, 总计=%d",
|
||||
"孤儿任务清理完成: GenerationTask=%d, Job=%d, 总计=%d",
|
||||
gen_count,
|
||||
job_count,
|
||||
pending_count,
|
||||
total,
|
||||
)
|
||||
return {"generation_tasks": gen_count, "jobs": job_count, "pending": pending_count}
|
||||
return {"generation_tasks": gen_count, "jobs": job_count}
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""定期清理任务 — Celery Beat 调度。
|
||||
|
||||
包含:
|
||||
- cleanup_stale_pending_tasks: 定期清理卡在 pending 超时的 generation_tasks
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from worker_app.tasks._startup import (
|
||||
PENDING_TASK_TIMEOUT_MINUTES,
|
||||
cleanup_stale_pending_tasks,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="worker.cleanup_stale_pending_tasks")
|
||||
def scheduled_cleanup_stale_pending(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINUTES) -> dict:
|
||||
"""Celery Beat 调度的定期任务:清理超时的 pending 任务。
|
||||
|
||||
每 10 分钟执行一次(由 celery_app.py 的 beat_schedule 配置),
|
||||
查找所有 status='pending' 且 created_at < NOW() - timeout_minutes
|
||||
的 generation_tasks,批量更新为 failed。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 30 分钟
|
||||
|
||||
Returns:
|
||||
{"cleaned": int}
|
||||
"""
|
||||
count = cleanup_stale_pending_tasks(timeout_minutes)
|
||||
if count > 0:
|
||||
logger.info("[Beat] 清理了 %d 个超时 pending 任务(超时阈值 %d 分钟)", count, timeout_minutes)
|
||||
return {"cleaned": count}
|
||||
@@ -98,7 +98,6 @@ def _finalize_render_success(
|
||||
output_path: Path,
|
||||
engine: str,
|
||||
thumbnail_url: str = "",
|
||||
cover_candidates: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""渲染成功后的统一收尾:查重 + 更新状态 + 返回结果。"""
|
||||
# 创建 GeneratedVideo 记录 + 查重
|
||||
@@ -164,19 +163,6 @@ def _finalize_render_success(
|
||||
clip_count=len(rendered_clip_ids),
|
||||
)
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
|
||||
# 回写封面 URL 到 GenerationTask,供封面生成接口读取
|
||||
if cover_candidates:
|
||||
first_cover = cover_candidates[0].get("image_url") or cover_candidates[0].get("url") or ""
|
||||
if first_cover:
|
||||
gen_task.cover_url = first_cover
|
||||
logger.info(
|
||||
"预览渲染完成,回写 cover_url: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
first_cover[:80],
|
||||
)
|
||||
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
logger.info(
|
||||
@@ -302,7 +288,6 @@ def _render_with_unified(
|
||||
output_path=output_path,
|
||||
engine="unified",
|
||||
thumbnail_url=thumbnail_url,
|
||||
cover_candidates=result.cover_candidates,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -36,6 +37,8 @@ from packages.domain.bgm_utils import merge_bgm_config
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
OUTPUT_FPS = 25.0
|
||||
OUTPUT_DURATION_SECONDS = 5.0
|
||||
GENERATED_FILES_DIR = Path(os.getenv("GENERATED_FILES_DIR", "/app/generated"))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -152,6 +155,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
from video_processing.oss_helpers import (
|
||||
download_asset,
|
||||
get_signed_download_url,
|
||||
@@ -326,6 +330,61 @@ def _build_plan_and_clips_from_task(
|
||||
return plan, clips, asset_path_map
|
||||
|
||||
|
||||
def _create_fallback_clip(output_path: Path, title: str) -> None:
|
||||
"""创建 fallback 视频(无素材时)"""
|
||||
safe_title = title.replace(":", "\\:").replace("'", "\\'")[:80]
|
||||
run_ffmpeg(
|
||||
[
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}",
|
||||
"-vf",
|
||||
f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _mux_audio_track(video_path: Path, audio_path: str, output_path: Path) -> None:
|
||||
"""将音频轨混入已渲染的视频(后处理步骤)。
|
||||
|
||||
使用 FFmpeg 将视频和音频合并,视频时长为准,音频不足则循环,
|
||||
音频过长则截断。
|
||||
"""
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
audio_path,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-shortest",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
run_ffmpeg(command)
|
||||
|
||||
|
||||
def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
"""下载配音文件。
|
||||
|
||||
@@ -369,6 +428,105 @@ def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
return download_asset(storage_key, local_path)
|
||||
|
||||
|
||||
def _prepare_bgm_track(
|
||||
*,
|
||||
bgm_config: dict,
|
||||
temp_path: Path,
|
||||
task_id: str = "",
|
||||
) -> str | None:
|
||||
"""准备 BGM 音频文件(下载到本地).
|
||||
|
||||
支持 3 种来源(按优先级):
|
||||
1. audio_url — 外部直链 URL(最高优先级)
|
||||
2. asset_id — 素材库中的音频素材
|
||||
3. preset_id — 预设 BGM 库
|
||||
|
||||
Returns:
|
||||
BGM 本地文件路径,准备失败返回 None
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
audio_url = bgm_config.get("audio_url", "") or ""
|
||||
asset_id = bgm_config.get("asset_id", "") or ""
|
||||
preset_id = bgm_config.get("preset_id", "") or ""
|
||||
|
||||
bgm_file = temp_path / f"bgm_{task_id or 'track'}.mp3"
|
||||
|
||||
# 优先级1:外部直链 URL
|
||||
if audio_url:
|
||||
try:
|
||||
parsed = urlparse(audio_url)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
|
||||
safe_download_file(
|
||||
audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] URL下载失败: %s", task_id, e)
|
||||
|
||||
# 优先级2:素材库素材
|
||||
if asset_id:
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
model = session.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model and (model.storage_key or model.file_url):
|
||||
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
|
||||
storage_key = model.storage_key or model.file_url
|
||||
logger.info("[task_id=%s] [BGM] 从素材库下载: asset_id=%s", task_id, asset_id)
|
||||
ok = download_asset(storage_key, bgm_file)
|
||||
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] 素材库下载失败: %s", task_id, e)
|
||||
|
||||
# 优先级3:预设 BGM 库
|
||||
if preset_id:
|
||||
try:
|
||||
from packages.domain.preset_bgm import get_preset_bgm
|
||||
|
||||
preset = get_preset_bgm(preset_id)
|
||||
if preset and preset.audio_url:
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
|
||||
safe_download_file(
|
||||
preset.audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_preset_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] 预设库下载失败: %s", task_id, e)
|
||||
|
||||
# 所有来源都失败
|
||||
logger.warning("[task_id=%s] [BGM] 所有来源都无法获取BGM,跳过", task_id)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_url_accessible(
|
||||
url: str,
|
||||
timeout: float = 10.0,
|
||||
@@ -887,7 +1045,6 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
"output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT,
|
||||
"cover_url": getattr(gen_task, "cover_url", "") or "",
|
||||
"custom_title": getattr(gen_task, "custom_title", "") or "",
|
||||
"title_config": dict(getattr(gen_task, "title_config", {}) or {}),
|
||||
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
|
||||
}
|
||||
finally:
|
||||
@@ -968,15 +1125,14 @@ def _render_video(
|
||||
bgm_config: dict | None = None,
|
||||
voice_ids: list[str] | None = None,
|
||||
custom_title: str = "",
|
||||
title_config: dict | None = None,
|
||||
) -> tuple[Path, float, list[dict] | None]:
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/缩略图逻辑。
|
||||
|
||||
Args:
|
||||
Returns:
|
||||
(output_path, render_duration, cover_candidates)
|
||||
(output_path, render_duration)
|
||||
"""
|
||||
if not downloaded_videos:
|
||||
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
|
||||
@@ -1001,34 +1157,27 @@ def _render_video(
|
||||
list(template_config.keys()),
|
||||
)
|
||||
|
||||
# ── 用户自定义标题:title_config 优先,custom_title 兜底 ─────────────
|
||||
effective_title_cfg: dict | None = None
|
||||
if title_config and isinstance(title_config, dict) and title_config.get("text", "").strip():
|
||||
effective_title_cfg = dict(title_config)
|
||||
elif custom_title:
|
||||
# ── 用户自定义标题覆盖模板标题配置 ──────────────────────────────────
|
||||
if custom_title:
|
||||
try:
|
||||
parsed = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
|
||||
if isinstance(parsed, dict) and parsed.get("text", "").strip():
|
||||
effective_title_cfg = parsed
|
||||
user_title_cfg = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
|
||||
if isinstance(user_title_cfg, dict) and user_title_cfg.get("text", "").strip():
|
||||
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
|
||||
if "font_size" in user_title_cfg and "size" not in user_title_cfg:
|
||||
user_title_cfg["size"] = user_title_cfg["font_size"]
|
||||
if "font_color" in user_title_cfg and "color" not in user_title_cfg:
|
||||
user_title_cfg["color"] = user_title_cfg["font_color"]
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["title"] = user_title_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 用户自定义标题已注入: text=%s",
|
||||
task_id,
|
||||
user_title_cfg.get("text", "")[:30],
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("[task_id=%s] custom_title JSON解析失败: %s", task_id, custom_title[:100])
|
||||
|
||||
if effective_title_cfg:
|
||||
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
|
||||
if "font_size" in effective_title_cfg and "size" not in effective_title_cfg:
|
||||
effective_title_cfg["size"] = effective_title_cfg["font_size"]
|
||||
if "font_color" in effective_title_cfg and "color" not in effective_title_cfg:
|
||||
effective_title_cfg["color"] = effective_title_cfg["font_color"]
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["title"] = effective_title_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 标题配置已注入(source=%s): text=%s",
|
||||
task_id,
|
||||
"title_config" if title_config else "custom_title",
|
||||
effective_title_cfg.get("text", "")[:30],
|
||||
)
|
||||
|
||||
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
|
||||
if bgm_config:
|
||||
plan_cfg = virtual_plan.config or {}
|
||||
@@ -1122,10 +1271,8 @@ def _render_video(
|
||||
|
||||
# 配音素材库音频已在统一渲染引擎内部通过 audio 图层混音处理
|
||||
output_path = render_output_path
|
||||
# RenderAdapter 在渲染完成后用本地 ffmpeg 抽取的封面候选帧(已上传 OSS)
|
||||
cover_candidates = getattr(render_result, "cover_candidates", None)
|
||||
|
||||
return output_path, render_duration, cover_candidates
|
||||
return output_path, render_duration
|
||||
|
||||
|
||||
def _upload_and_record(
|
||||
@@ -1396,7 +1543,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
else:
|
||||
_resolved_resolution = task_info.get("resolution", "")
|
||||
|
||||
output_path, render_duration, cover_candidates = _render_video(
|
||||
output_path, render_duration = _render_video(
|
||||
task_id=task_id,
|
||||
downloaded_videos=downloaded_videos,
|
||||
voice_path=audio_path,
|
||||
@@ -1410,7 +1557,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
custom_title=task_info.get("custom_title", ""),
|
||||
title_config=task_info.get("title_config", {}),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
@@ -1442,47 +1588,51 @@ def generate_video(self, task_id: str) -> dict:
|
||||
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
|
||||
# ── 4.5 封面帧持久化 ────────────────────────────────────────────
|
||||
# RenderAdapter 在渲染完成后已用本地 ffmpeg 从 output_path 抽帧
|
||||
# (标题通过 ASS 烧录,帧天然带标题),并上传 OSS 返回 cover_candidates。
|
||||
# 这里把第一帧写入 gen_task.cover_url,完整列表写入 metadata,
|
||||
# 封面路由(generation_cover.py)的 A/B/C/D 步骤即可直接命中。
|
||||
# ── 4.5 封面抽帧 ────────────────────────────────────────────────
|
||||
# 预览视频上传完成后,提取封面帧写入 gen_task.cover_url
|
||||
# 这样封面路由(generation_cover.py 步骤A)可以通过 generation_task_id 直接找到
|
||||
try:
|
||||
if cover_candidates:
|
||||
first = cover_candidates[0]
|
||||
# 候选帧字段兼容:RenderAdapter 用 image_url,thumbnail_generator 用 url
|
||||
cover_frame_url = first.get("image_url") or first.get("url") or ""
|
||||
if cover_frame_url:
|
||||
_cover_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
_cover_model = (
|
||||
_cover_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _cover_model:
|
||||
_cover_model.cover_url = cover_frame_url
|
||||
# 持久化完整候选列表到 metadata
|
||||
meta = dict(_cover_model.metadata or {})
|
||||
meta["cover_candidates"] = cover_candidates
|
||||
_cover_model.metadata = meta
|
||||
_cover_session.commit()
|
||||
logger.info(
|
||||
"[task_id=%s] 封面帧已持久化(ffmpeg本地抽帧): cover_url=%s candidates=%d",
|
||||
task_id,
|
||||
cover_frame_url[:80],
|
||||
len(cover_candidates),
|
||||
mk_client = get_mediakit_client()
|
||||
if mk_client.is_available:
|
||||
_update_task_progress(task_id, 96, "提取封面帧")
|
||||
snapshots = mk_client.extract_frames(
|
||||
video_url=file_url,
|
||||
strategy="SpecifiedFrames",
|
||||
max_frames=1,
|
||||
)
|
||||
if snapshots and len(snapshots) > 0:
|
||||
cover_frame_url = snapshots[0].get("image_url", "")
|
||||
if cover_frame_url and gen_task:
|
||||
# 通过独立 session 持久化 cover_url
|
||||
_cover_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
GenerationTaskModel,
|
||||
)
|
||||
finally:
|
||||
_cover_session.close()
|
||||
|
||||
_cover_model = (
|
||||
_cover_session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.id == task_id)
|
||||
.first()
|
||||
)
|
||||
if _cover_model:
|
||||
_cover_model.cover_url = cover_frame_url
|
||||
_cover_session.commit()
|
||||
logger.info(
|
||||
"[task_id=%s] 封面帧提取成功: %s",
|
||||
task_id,
|
||||
cover_frame_url[:80],
|
||||
)
|
||||
finally:
|
||||
_cover_session.close()
|
||||
else:
|
||||
logger.warning("[task_id=%s] 封面帧提取返回空结果", task_id)
|
||||
else:
|
||||
logger.warning("[task_id=%s] 渲染未产出 cover_candidates,封面将依赖 API 兜底", task_id)
|
||||
logger.warning("[task_id=%s] MediaKit 未配置,跳过封面帧提取", task_id)
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 封面帧持久化失败(不影响主流程)", task_id, exc_info=True)
|
||||
logger.warning("[task_id=%s] 封面帧提取失败(不影响主流程)", task_id, exc_info=True)
|
||||
|
||||
# ── 5. 标记完成 ──────────────────────────────────────────────────
|
||||
_update_task_status(task_id, "mark_completed", result_count=video_count)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
@@ -234,183 +233,6 @@ def ingest_asset(job_id: str) -> dict:
|
||||
job_id,
|
||||
thumb_err,
|
||||
)
|
||||
|
||||
# ── HEVC 自动转码为 1080p H.264 ──────────────────────────────
|
||||
# 浏览器 WebCodecs 硬件解码 HEVC 输出黑帧,上传时自动转码
|
||||
# 失败时降级使用原始文件,不阻塞上传流程
|
||||
if media_type == "video" and local_file and local_file.exists():
|
||||
codec = (metadata.get("codec") or "").lower()
|
||||
if codec in ("hevc", "h265", "hvh1"):
|
||||
logger.info(
|
||||
"检测到 HEVC 编码 (codec=%s),启动转码: job_id=%s",
|
||||
codec,
|
||||
job_id,
|
||||
)
|
||||
_tc_tmp = None
|
||||
_needs_rotation = False
|
||||
|
||||
# ── Step 1: 磁盘空间检查(独立 try/except,失败仍尝试转码)──
|
||||
try:
|
||||
_disk_usage = shutil.disk_usage("/tmp")
|
||||
_free_gb = _disk_usage.free / (1024**3)
|
||||
if _free_gb < 2:
|
||||
raise RuntimeError(f"磁盘空间不足 ({_free_gb:.1f}GB < 2GB)")
|
||||
except Exception as _disk_err:
|
||||
logger.warning("磁盘检查失败,仍尝试转码: job_id=%s err=%s", job_id, _disk_err)
|
||||
|
||||
# ── Step 2: ffprobe 旋转检测(独立 try/except,失败不阻塞转码)──
|
||||
try:
|
||||
_probe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"side_data=rotation",
|
||||
"-show_entries",
|
||||
"stream_tags=rotate",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_file),
|
||||
]
|
||||
_probe_result = subprocess.run(
|
||||
_probe_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=60, # 大文件在容器 overlay 文件系统上解析可能较慢
|
||||
)
|
||||
_rotation_str = (_probe_result.stdout or "").strip().split("\n")[0]
|
||||
if _rotation_str in ("90", "270", "-90"):
|
||||
_needs_rotation = True
|
||||
logger.info(
|
||||
"检测到竖屏视频 (rotation=%s),将物理旋转画面: job_id=%s",
|
||||
_rotation_str,
|
||||
job_id,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"ffprobe 旋转检测超时(60s),跳过旋转继续转码: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
_needs_rotation = False
|
||||
except Exception as _probe_err:
|
||||
logger.warning(
|
||||
"ffprobe 旋转检测异常,跳过旋转继续转码: job_id=%s err=%s",
|
||||
job_id,
|
||||
_probe_err,
|
||||
)
|
||||
_needs_rotation = False
|
||||
|
||||
# ── Step 3: ffmpeg 转码(独立 try/except)──
|
||||
try:
|
||||
_tc_tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix="_h264.mp4")
|
||||
_tc_tmp = Path(_tc_tmp_file.name)
|
||||
_tc_tmp_file.close() # 关闭文件描述符,ffmpeg 会自己打开
|
||||
|
||||
# 构建 video filter:竖屏先旋转再缩放
|
||||
if _needs_rotation:
|
||||
_vf = "transpose=1,scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'"
|
||||
else:
|
||||
_vf = "scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'"
|
||||
|
||||
_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(local_file),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-vf",
|
||||
_vf + ",format=yuv420p",
|
||||
"-colorspace",
|
||||
"bt709",
|
||||
"-color_primaries",
|
||||
"bt709",
|
||||
"-color_trc",
|
||||
"bt709",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-level",
|
||||
"4.2",
|
||||
]
|
||||
# 竖屏视频:清除旋转元数据
|
||||
if _needs_rotation:
|
||||
_cmd.extend(["-metadata:s:v:0", "rotate=0"])
|
||||
_cmd.extend(
|
||||
[
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(_tc_tmp),
|
||||
]
|
||||
)
|
||||
_proc = subprocess.run(
|
||||
_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=900,
|
||||
)
|
||||
if _proc.returncode == 0 and _tc_tmp.exists() and _tc_tmp.stat().st_size > 0:
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
_p = Path(job.storage_key)
|
||||
_new_key = str(_p.parent / (_p.stem + "_h264" + _p.suffix))
|
||||
_url = upload_to_oss(_tc_tmp, _new_key)
|
||||
if _url:
|
||||
# 先提取元数据,确认成功后再更新 storage_key(避免脏数据)
|
||||
_new_metadata, _new_extract_success = extract_media_metadata(
|
||||
str(_tc_tmp),
|
||||
media_type,
|
||||
)
|
||||
if _new_extract_success:
|
||||
job.storage_key = _new_key
|
||||
metadata = _new_metadata
|
||||
extract_success = _new_extract_success
|
||||
logger.info(
|
||||
"HEVC→H.264 转码完成: job_id=%s key=%s",
|
||||
job_id,
|
||||
_new_key[:80],
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"转码文件上传 OSS 失败,使用原始文件: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
else:
|
||||
_tail = _proc.stderr[-300:] if _proc.stderr else ""
|
||||
logger.warning(
|
||||
"FFmpeg 转码失败 rc=%s stderr=%s: job_id=%s",
|
||||
_proc.returncode,
|
||||
_tail,
|
||||
job_id,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"FFmpeg 转码超时(900s),降级原始文件: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.warning(
|
||||
"HEVC 转码异常(降级原始文件): job_id=%s err=%s",
|
||||
job_id,
|
||||
_e,
|
||||
)
|
||||
finally:
|
||||
if _tc_tmp and _tc_tmp.exists():
|
||||
try:
|
||||
_tc_tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if local_file and local_file.exists():
|
||||
try:
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# ============================================================
|
||||
# API 基础镜像(预构建)
|
||||
# 预装系统依赖 + Python 依赖,业务构建从此镜像开始
|
||||
# 当 requirements-base.txt 或 requirements.txt 变更时重新构建
|
||||
# 目标:将 API Image 构建时间从 15-20 分钟降至 3-5 分钟
|
||||
# ============================================================
|
||||
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 预装系统依赖(gcc 编译 psycopg/pg 扩展,libpq-dev 编译期,libpq5 运行期,ffmpeg 封面取帧)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
ffmpeg \
|
||||
fonts-noto-cjk \
|
||||
fontconfig \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# 预装 Python 基础依赖
|
||||
COPY requirements-base.txt requirements.txt ./
|
||||
RUN pip install --no-cache-dir \
|
||||
-i https://mirrors.aliyun.com/pypi/simple/ \
|
||||
--trusted-host mirrors.aliyun.com \
|
||||
-r requirements-base.txt -r requirements.txt
|
||||
|
||||
# 虚拟环境瘦身
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# 清理临时文件
|
||||
RUN rm -f /tmp/requirements-base.txt /tmp/requirements.txt
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
+66
-12
@@ -1,28 +1,81 @@
|
||||
# ============================================================
|
||||
# API Dockerfile - FastAPI 应用
|
||||
# 优化:从预构建基础镜像开始,仅叠加业务代码
|
||||
# 基础镜像包含所有系统依赖和 Python 依赖,构建时间 < 5 分钟
|
||||
# 优化:多阶段构建 + pip cache mount + 依赖分层缓存
|
||||
# ============================================================
|
||||
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest
|
||||
# ==================== Builder 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装编译依赖(仅 builder 需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-base.txt \
|
||||
&& rm /tmp/requirements-base.txt
|
||||
|
||||
# ---- 依赖分层:业务依赖(变化频繁)----
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# ---- Python 依赖瘦身 ----
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# ==================== Runtime 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
|
||||
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖,ffmpeg 用于封面兜底取帧)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制应用代码(按变化频率从低到高排序,最大化层缓存命中)
|
||||
COPY alembic.ini ./alembic.ini
|
||||
COPY migrations/ ./migrations/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/api/ ./apps/api/
|
||||
# 复制应用代码
|
||||
COPY apps/api/ /app/apps/api/
|
||||
COPY packages/ /app/packages/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY alembic/ /app/alembic/
|
||||
COPY scripts/ /app/scripts/
|
||||
|
||||
# 设置环境变量
|
||||
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
ENV PYTHONPATH=/app:/app/apps/api
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
@@ -31,4 +84,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
|
||||
|
||||
# API 入口点
|
||||
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
WORKDIR /app/apps/api
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -6,13 +6,8 @@ set -e
|
||||
|
||||
CONCURRENCY="${WORKER_CONCURRENCY:-2}"
|
||||
|
||||
# ⚠️ 部署约束:此 Worker 必须且只能运行单实例(replicas=1)
|
||||
# -B 标志嵌入 celery beat,beat 负责定期触发 pending 超时清理等定时任务
|
||||
# 多实例部署会导致每个 Worker 独立运行 Beat,造成定时任务重复执行
|
||||
# 若需横向扩展 Worker,必须将 Beat 拆分为独立服务(celery beat -A worker_app.celery_app)
|
||||
exec celery \
|
||||
-A worker_app.celery_app \
|
||||
worker \
|
||||
--loglevel=info \
|
||||
"-B" \
|
||||
"--concurrency=${CONCURRENCY}"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user