Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6eeca75fb | |||
| df4de37f73 | |||
| 88c07ef142 | |||
| 0996ba45ec | |||
| a34639975c | |||
| 4b68c9be9c | |||
| fbe0a6e826 | |||
| 7f2861e98e | |||
| b1aa866252 | |||
| f484de79c4 | |||
| 9263db56db | |||
| ebe43f163b | |||
| 3e38395f46 | |||
| 9cb20c38ae | |||
| d784af7f67 | |||
| 1b6ecc39f0 | |||
| 92bd1fe201 | |||
| 955613efd3 | |||
| f08a2bfa0d | |||
| 3441b3fece | |||
| b7be479ae7 | |||
| 95c33f6604 | |||
| 161c1a61b6 | |||
| 8b30193f3e | |||
| 665228a58f | |||
| 56d45c2d49 | |||
| ab4a1b7262 | |||
| a39e5ea936 | |||
| 2d0d8a2777 | |||
| d3469d9799 | |||
| 068010b059 | |||
| 231d5153bf | |||
| f4af6ec8b7 | |||
| 6a3f4e4127 | |||
| ec3ba19342 | |||
| 26728dfffb | |||
| cb903e3bd6 | |||
| b447ad84ea | |||
| 2356f12f11 | |||
| 3c42541ff7 | |||
| 85afadd166 | |||
| 634d05c010 | |||
| 355431b867 | |||
| 041cec8670 | |||
| 472f371d2f | |||
| 709d65ce52 | |||
| ae1b9f8ddb | |||
| 1c9903574a | |||
| cfedc06df0 | |||
| 4429784a79 | |||
| aa36e63591 | |||
| c3373de4f3 | |||
| 60d95e64ab | |||
| 7775d5d118 | |||
| f025514d16 | |||
| e043b0aa6a | |||
| fdce08356b | |||
| 89cccf294c | |||
| 5f3ff6bb8c | |||
| ca78b182af | |||
| ca8f1079b1 | |||
| ee92f72252 | |||
| 7b63219d9e | |||
| dfd821f5ba | |||
| bfc9e07170 | |||
| 2c2fcabd6c | |||
| 4e19d08a98 | |||
| d9b4041215 | |||
| 48d1b4dd33 | |||
| 25ce6d6286 | |||
| 89a56eaf96 | |||
| 71dddec3af | |||
| 938b9d3529 | |||
| 4102864218 | |||
| 95893916a2 | |||
| ad56ab8c23 | |||
| cc0f439bd2 | |||
| 0a16f6c38b | |||
| c21c0fb748 | |||
| ee3546b371 |
@@ -633,58 +633,25 @@ jobs:
|
||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Pre-build worker base images (fallback if not exist)
|
||||
- name: Pre-build worker base image (fallback if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
id: prebuild
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
BASE_IMAGE="${REGISTRY}/saas-worker-base:latest"
|
||||
|
||||
# 尝试拉取基础镜像
|
||||
echo "检查基础镜像..."
|
||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||
echo "基础镜像已存在,使用远程镜像"
|
||||
echo "检查 Worker 基础镜像..."
|
||||
if docker pull "$BASE_IMAGE" 2>/dev/null; then
|
||||
echo "✅ 基础镜像已存在"
|
||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
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模式)..."
|
||||
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
|
||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像本地构建完成"
|
||||
echo "✅ Worker 基础镜像本地构建完成"
|
||||
fi
|
||||
|
||||
- name: Build PR image (verify only, no push)
|
||||
@@ -700,15 +667,15 @@ jobs:
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||
# Worker: 始终用普通docker build(基础镜像已预装全部依赖,无需buildx)
|
||||
if [ "${{ matrix.service }}" = "worker" ]; then
|
||||
echo "Worker: 使用普通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 "Fallback PR Build successful"
|
||||
echo "PR Build successful (worker, no buildx)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -831,6 +798,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Setup buildx builder
|
||||
if: matrix.service != 'worker'
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
@@ -843,41 +811,64 @@ jobs:
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push ${{ matrix.service_display }} image (with retry)
|
||||
- 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
|
||||
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}"
|
||||
|
||||
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"
|
||||
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}"
|
||||
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: always()
|
||||
if: matrix.service != 'worker' && always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
|
||||
|
||||
@@ -7,26 +7,16 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-worker.txt'
|
||||
- 'infra/docker/worker-base-builder.Dockerfile'
|
||||
- 'infra/docker/worker-base-runtime.Dockerfile'
|
||||
workflow_dispatch: # 支持手动触发
|
||||
- 'infra/docker/worker-base.Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-worker-base:
|
||||
name: Build Worker Base Images
|
||||
name: Build Worker Base Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: builder
|
||||
dockerfile: infra/docker/worker-base-builder.Dockerfile
|
||||
image_name: worker-base-builder
|
||||
- name: runtime
|
||||
dockerfile: infra/docker/worker-base-runtime.Dockerfile
|
||||
image_name: worker-base-runtime
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -57,39 +47,40 @@ jobs:
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Build and push base image
|
||||
- name: Build and push Worker base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
|
||||
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 ${{ matrix.name }} base image ==="
|
||||
echo "Dockerfile: ${{ matrix.dockerfile }}"
|
||||
echo "Image: ${IMAGE_TAG}"
|
||||
echo "=== Building Worker base image ==="
|
||||
|
||||
# 使用普通 docker build(单平台不需要 buildx)
|
||||
docker build \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
-f infra/docker/worker-base.Dockerfile \
|
||||
-t "${ACR_IMAGE}" \
|
||||
.
|
||||
|
||||
docker push "${IMAGE_TAG}"
|
||||
|
||||
# 同时推送到 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 "✅ ${{ matrix.name }} base image built and pushed"
|
||||
echo "✅ Image built successfully"
|
||||
|
||||
- name: Cleanup local images
|
||||
# 推送到 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: |
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
docker rmi "${REGISTRY}/${{ matrix.image_name }}:latest" 2>/dev/null || true
|
||||
docker rmi "git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest" 2>/dev/null || true
|
||||
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 "Image cleanup done"
|
||||
echo "Cleanup done"
|
||||
|
||||
@@ -18,7 +18,6 @@ from app.schemas.asset import (
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
SmartMatchItem,
|
||||
SmartMatchRequest,
|
||||
@@ -29,11 +28,6 @@ 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__)
|
||||
@@ -677,51 +671,12 @@ def untag_asset(
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
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,
|
||||
)
|
||||
def create_asset() -> None:
|
||||
"""
|
||||
已废弃接口。
|
||||
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。",
|
||||
)
|
||||
return _to_asset_response(item)
|
||||
|
||||
@@ -206,6 +206,7 @@ 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(
|
||||
@@ -215,7 +216,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)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id, url=storage_service.get_url(normalized_key))
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -39,6 +39,7 @@ 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):
|
||||
|
||||
@@ -226,15 +226,14 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
|
||||
// Wait for generation API to be called
|
||||
// 确认生成走新流程:POST /tasks/{taskId}/confirm(复用预览产物)
|
||||
// 或旧流程:POST /editor/generate(向后兼容)
|
||||
// 前端直接创建生成任务:POST /generation/tasks
|
||||
const generatePromise = page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url()
|
||||
const path = new URL(url).pathname
|
||||
return (
|
||||
response.request().method() === "POST" &&
|
||||
(path.endsWith("/confirm") || path.endsWith("/editor/generate"))
|
||||
path.endsWith("/generation/tasks")
|
||||
)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
|
||||
@@ -178,7 +178,7 @@ test.describe("素材库流程", () => {
|
||||
expect(kinds).toContain("image")
|
||||
})
|
||||
|
||||
test("创建素材记录", async ({ request }) => {
|
||||
test("创建素材记录 — POST /assets 已废弃返回 410", 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,16 +210,9 @@ test.describe("素材库流程", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy()
|
||||
|
||||
expect(response.status()).toBe(410)
|
||||
const data = await response.json()
|
||||
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)
|
||||
expect(data.error?.code).toBe("HTTP_410")
|
||||
})
|
||||
|
||||
test("列出素材", async ({ request }) => {
|
||||
@@ -232,51 +225,50 @@ test.describe("素材库流程", () => {
|
||||
data: {
|
||||
project_id: projectId,
|
||||
name: `List Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
kind: "image",
|
||||
},
|
||||
})
|
||||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
|
||||
// 创建 2 个素材
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
// 通过 multipart upload 上传 2 个小图片作为测试素材
|
||||
// 创建一个 1x1 的 PNG buffer
|
||||
const tinyPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
)
|
||||
|
||||
await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
data: {
|
||||
multipart: {
|
||||
project_id: projectId,
|
||||
library_id: libData.id,
|
||||
name: `clip_a_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/clip_a.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
file: { name: "clip_a.png", mimeType: "image/png", buffer: tinyPng },
|
||||
},
|
||||
})
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
data: {
|
||||
multipart: {
|
||||
project_id: projectId,
|
||||
library_id: libData.id,
|
||||
name: `clip_b_${Date.now()}.mp4`,
|
||||
storage_key: `uploads/e2e/clip_b.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
file: { name: "clip_b.png", mimeType: "image/png", buffer: tinyPng },
|
||||
},
|
||||
})
|
||||
|
||||
// 列出素材
|
||||
const response = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libData.id },
|
||||
})
|
||||
// 列出素材(可能需要等待 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))
|
||||
}
|
||||
|
||||
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,6 +12,7 @@
|
||||
"@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",
|
||||
@@ -4623,6 +4624,15 @@
|
||||
"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,6 +23,7 @@
|
||||
"@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,18 +60,6 @@ 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,7 +13,6 @@ export type {
|
||||
ClassificationJob,
|
||||
AssetDiagnosis,
|
||||
BatchOperationResult,
|
||||
UploadResult,
|
||||
DirectUploadPrepareResult,
|
||||
DirectUploadCompleteResult,
|
||||
} from "./types"
|
||||
@@ -34,14 +33,13 @@ export {
|
||||
getAssets,
|
||||
getAssetsByKind,
|
||||
smartMatchAssets,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
updateAssetReviewStatus,
|
||||
deleteAsset,
|
||||
} from "./assets"
|
||||
|
||||
// 上传
|
||||
export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
export { prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
|
||||
// 任务
|
||||
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
|
||||
|
||||
@@ -135,4 +135,5 @@ export interface DirectUploadPrepareResult {
|
||||
export interface DirectUploadCompleteResult {
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
@@ -3,16 +3,7 @@
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
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
|
||||
}
|
||||
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
|
||||
@@ -23,13 +23,9 @@ export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post<GenerateCoverResponse>(
|
||||
"/generation/generate-cover",
|
||||
{ ...data, template_id: templateId },
|
||||
{
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
},
|
||||
)
|
||||
const response = await apiClient.post<GenerateCoverResponse>("/generation/generate-cover", data, {
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -3,13 +3,9 @@ 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"
|
||||
|
||||
@@ -57,8 +57,31 @@ export interface TaskListResponse {
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_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
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
||||
import { validateFile } from "./utils"
|
||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
||||
@@ -181,9 +182,15 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
})
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
// 获取默认项目和素材库
|
||||
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,
|
||||
})
|
||||
|
||||
// 组件已卸载则中止后续操作
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { uploadAssetDirect, 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" })
|
||||
|
||||
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)
|
||||
// 直传到 OSS
|
||||
const uploadResult = await uploadAssetDirect({
|
||||
file: fileToUpload,
|
||||
library_id: library.id,
|
||||
})
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
|
||||
@@ -18,6 +18,10 @@ 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"
|
||||
@@ -107,6 +111,14 @@ const GeneratePage: React.FC = () => {
|
||||
[userTemplates, selectedTemplate],
|
||||
)
|
||||
|
||||
/* ── 视频总时长计算(用于配音时长校验) ── */
|
||||
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"],
|
||||
@@ -159,7 +171,6 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
previewTaskId: "",
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
@@ -207,6 +218,7 @@ const GeneratePage: React.FC = () => {
|
||||
duration={duration}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
@@ -274,6 +286,7 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ── 视频预览弹窗 ── */}
|
||||
<Modal
|
||||
className="xx-preview-modal"
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
footer={null}
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
/**
|
||||
* 前端预览播放器
|
||||
* 用原生 <video> 标签按时间线播放素材片段
|
||||
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
|
||||
* 前端预览播放器 — Canvas + WebCodecs 方案
|
||||
*
|
||||
* 注意:本组件不创建 .xx-preview-video 容器(由父组件 PreviewVideoPanel 提供)
|
||||
* 避免嵌套 .xx-preview-video 导致 CSS 冲突
|
||||
* 架构:
|
||||
* - 浏览器支持 WebCodecs → Canvas 渲染(帧级精确控制 + 标题合成)
|
||||
* - 浏览器不支持 → fallback 到多 video 元素方案
|
||||
*
|
||||
* 对外 API 不变:assets, template, videoRatio, ready, voiceAudioUrl
|
||||
*/
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
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, isWebCodecsSupported } from "../hooks/useCanvasPlayer"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
/** 选中的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 当前模板(用于获取片段时长配置) */
|
||||
template: EditingTemplate | null
|
||||
/** 视频比例 */
|
||||
videoRatio: string
|
||||
/** 是否准备好播放(素材已加载) */
|
||||
ready: boolean
|
||||
/** 配音音频 URL */
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
title: string
|
||||
size: number
|
||||
font: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom"
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
@@ -33,8 +45,7 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将素材映射为播放片段
|
||||
* 每个素材对应一个模板片段,按顺序分配
|
||||
* 将素材映射为播放片段(复用原逻辑)
|
||||
*/
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
@@ -54,20 +65,9 @@ function buildPlaybackSegments(
|
||||
|
||||
const startTime = 0
|
||||
const endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
|
||||
console.log(
|
||||
`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl?.substring(0, 80)}, file_url=${!!asset.file_url}`,
|
||||
)
|
||||
|
||||
segments.push({
|
||||
assetId: asset.id,
|
||||
videoUrl,
|
||||
startTime,
|
||||
endTime,
|
||||
order: i,
|
||||
})
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
|
||||
})
|
||||
|
||||
return segments
|
||||
@@ -79,28 +79,88 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
videoRatio: _videoRatio,
|
||||
ready,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
const useWebCodecs = isWebCodecsSupported()
|
||||
|
||||
// ── 两条路径共用同一个 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,
|
||||
canvasSegments,
|
||||
useWebCodecs && !forceVideoFallback ? canvasTitle : undefined,
|
||||
handleCanvasError,
|
||||
)
|
||||
|
||||
// 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,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
canPlay,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRef,
|
||||
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)
|
||||
|
||||
// 创建/更新 Audio 元素
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
// 没有配音,清理已有 audio
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
@@ -108,7 +168,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio()
|
||||
audioRef.current.preload = "auto"
|
||||
@@ -118,53 +177,54 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
}
|
||||
}, [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])
|
||||
|
||||
// seek 时同步音频
|
||||
// 片段切换时同步音频(仅 fallback 路径需要)
|
||||
const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src || !isPlaying) return
|
||||
audio.currentTime = currentTime
|
||||
}, [segmentSyncKey, isPlaying, currentTime])
|
||||
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
seekTo(time)
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.seek(time)
|
||||
} else {
|
||||
videoSeekTo(time)
|
||||
}
|
||||
const audio = audioRef.current
|
||||
if (audio && audio.src) {
|
||||
audio.currentTime = time
|
||||
}
|
||||
},
|
||||
[seekTo],
|
||||
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
|
||||
)
|
||||
|
||||
// 播放结束时暂停音频
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
const audio = audioRef.current
|
||||
if (audio) audio.pause()
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
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)
|
||||
|
||||
@@ -205,7 +265,34 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
// 未就绪状态
|
||||
// ── Canvas ResizeObserver ──
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!effectiveUseWebCodecs || !canPlay) return
|
||||
const container = canvasContainerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
// 立即设置一次 canvas 像素分辨率,避免默认 300×150 导致首帧变形
|
||||
const initRect = container.getBoundingClientRect()
|
||||
if (initRect.width > 0 && initRect.height > 0) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = initRect.width * dpr
|
||||
canvas.height = initRect.height * dpr
|
||||
}
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect
|
||||
if (width > 0 && height > 0) {
|
||||
canvas.width = width * window.devicePixelRatio
|
||||
canvas.height = height * window.devicePixelRatio
|
||||
}
|
||||
}
|
||||
})
|
||||
ro.observe(container)
|
||||
return () => ro.disconnect()
|
||||
}, [effectiveUseWebCodecs, canPlay])
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
<div
|
||||
@@ -227,8 +314,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// 无播放片段
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
|
||||
return (
|
||||
<div
|
||||
className="xx-preview-empty"
|
||||
@@ -242,40 +330,92 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<p className="xx-preview-empty-title">暂无可播放素材</p>
|
||||
<p className="xx-preview-empty-desc">请先在左侧选择素材</p>
|
||||
{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 (
|
||||
<>
|
||||
{/* ✅ 不再创建 .xx-preview-video 容器,直接渲染 video + 覆盖层 */}
|
||||
{/* video 元素 — 由父组件 .xx-preview-video 容器提供定位和尺寸 */}
|
||||
<video
|
||||
muted
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
background: "#000",
|
||||
zIndex: 1,
|
||||
}}
|
||||
playsInline
|
||||
/>
|
||||
{/* ── 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 渲染层(fallback 路径,或 WebCodecs 解码失败时自动切换) ── */}
|
||||
{!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={togglePlayPause}
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
@@ -293,7 +433,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
color: "#fff",
|
||||
fontSize: 28,
|
||||
zIndex: 10,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
@@ -314,10 +453,14 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
片段 {currentSegmentIndex + 1}/{segments.length}
|
||||
{effectiveUseWebCodecs
|
||||
? "Canvas"
|
||||
: forceVideoFallback
|
||||
? "Canvas 解码失败,已切换原生播放"
|
||||
: `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
||||
</div>
|
||||
|
||||
{/* 控制条 — 绝对定位在底部 */}
|
||||
{/* 控制条 */}
|
||||
<div
|
||||
className="xx-preview-controls"
|
||||
style={{
|
||||
@@ -333,9 +476,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* 播放/暂停 */}
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
@@ -350,7 +492,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 时间 */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@@ -362,7 +503,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface GenerateStepContentProps {
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
@@ -106,6 +107,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
duration,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
clonedVoices,
|
||||
@@ -146,6 +148,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
<Step3VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* FrontendPreviewPlayer 的内容通过 absolute 定位填充容器
|
||||
* TitleOverlay 通过 absolute 定位 + z-index: 30 覆盖在最上层
|
||||
*/
|
||||
import React, { useMemo } from "react"
|
||||
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"
|
||||
@@ -79,12 +79,16 @@ function getPositionStyle(position: string): React.CSSProperties {
|
||||
* 构建 CSS 标题层的样式
|
||||
* 所有渲染参数与后端 FFmpeg ASS 字幕一致
|
||||
*/
|
||||
function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
|
||||
const fontSizePercent = (Math.min(settings.size, 36) / ASS_VIDEO_HEIGHT) * 100
|
||||
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: settings.font || "思源黑体",
|
||||
fontSize: `${fontSizePercent}%`,
|
||||
fontSize: `${fontSizePx}px`,
|
||||
color: settings.color || "#ffffff",
|
||||
fontWeight: settings.bold ? 700 : 400,
|
||||
fontStyle: settings.italic ? "italic" : "normal",
|
||||
@@ -113,15 +117,35 @@ function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
|
||||
* 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],
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const titleStyle = useMemo(
|
||||
() => buildTitleStyle(titleSettings),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
() => buildTitleStyle(titleSettings, containerHeight),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 已逐字段列出 titleSettings 依赖
|
||||
[
|
||||
containerHeight,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
@@ -132,11 +156,11 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
|
||||
],
|
||||
)
|
||||
|
||||
// 标题为空时显示占位文本
|
||||
const displayTitle = titleSettings.title?.trim() || "标题预览"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
@@ -169,7 +193,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
titleSettings,
|
||||
voiceAudioUrl,
|
||||
}) => {
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "9:16").replace(":", "/") }
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { AudioOutlined, SoundOutlined, WarningOutlined } from "@ant-design/icons"
|
||||
import { Modal } from "antd"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
}
|
||||
|
||||
/** 格式化时长 mm:ss */
|
||||
@@ -34,10 +36,13 @@ 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({
|
||||
@@ -78,14 +83,38 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
[playingId],
|
||||
)
|
||||
|
||||
/** 选中素材 */
|
||||
/** 选中素材(含时长校验) */
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
// 如果启用了时长校验,且配音时长不足
|
||||
if (totalVideoDuration > 0) {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (material && (material.duration || 0) < totalVideoDuration) {
|
||||
setPendingVoiceId(id)
|
||||
setDurationWarningOpen(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
onSelectedVoiceChange(id)
|
||||
},
|
||||
[onSelectedVoiceChange],
|
||||
[onSelectedVoiceChange, totalVideoDuration, materials],
|
||||
)
|
||||
|
||||
/** 确认使用时长不足的配音 */
|
||||
const handleConfirmUseAnyway = useCallback(() => {
|
||||
if (pendingVoiceId) {
|
||||
onSelectedVoiceChange(pendingVoiceId)
|
||||
}
|
||||
setDurationWarningOpen(false)
|
||||
setPendingVoiceId(null)
|
||||
}, [pendingVoiceId, onSelectedVoiceChange])
|
||||
|
||||
/** 取消选择 */
|
||||
const handleCancelSelection = useCallback(() => {
|
||||
setDurationWarningOpen(false)
|
||||
setPendingVoiceId(null)
|
||||
}, [])
|
||||
|
||||
/** 跳转到配音库上传 */
|
||||
const handleGoToUpload = useCallback(() => {
|
||||
navigate("/app/voices")
|
||||
@@ -238,15 +267,65 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>{formatDuration(item.duration)}</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{formatDuration(item.duration)}
|
||||
{totalVideoDuration > 0 &&
|
||||
(Number(item.duration) || 0) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
</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(pendingMaterial.duration) : "--"}</strong>
|
||||
)短于视频总时长(
|
||||
<strong>{formatDuration(totalVideoDuration)}</strong>
|
||||
),播放时配音可能提前结束,建议选择更长的配音素材。
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,建议 16:9 比例</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 9:16 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -891,7 +891,7 @@
|
||||
|
||||
/* ── 视频预览 ── */
|
||||
.xx-preview-video {
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
@@ -1516,7 +1516,7 @@
|
||||
.xx-smart-match-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2144,7 +2144,7 @@
|
||||
}
|
||||
|
||||
.xx-cover-frame-placeholder {
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
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: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
@@ -2308,7 +2308,7 @@
|
||||
|
||||
.xx-cover-preview-box {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
@@ -2666,7 +2666,7 @@
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -2772,7 +2772,7 @@
|
||||
|
||||
.xx-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2881,11 +2881,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
.xx-preview-modal .ant-modal-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
.xx-preview-modal .ant-modal-close {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 预览任务的 task_id(用于新确认生成 API) */
|
||||
previewTaskId: string
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { confirmGeneration, createPreview } from "@/api/generation"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
@@ -55,7 +55,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
|
||||
// 解析分辨率
|
||||
const ratio = props.videoRatio || "9:16"
|
||||
let outputWidth: number
|
||||
let outputHeight: number
|
||||
@@ -87,20 +87,22 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
outputHeight = 1920
|
||||
}
|
||||
|
||||
// 获取或创建后端任务 ID
|
||||
// 预览改为前端播放后,不再有预览任务,需要在此处创建
|
||||
let taskId = props.previewTaskId
|
||||
if (!taskId) {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
const previewResp = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
voice_ids: undefined,
|
||||
title_config: props.titleSettings?.title
|
||||
? {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
|
||||
// 直接创建正式生成任务
|
||||
await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings?.upload_url || "",
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
text: props.titleSettings.title,
|
||||
font: props.titleSettings.font,
|
||||
font_size: props.titleSettings.size,
|
||||
@@ -109,17 +111,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
taskId = previewResp.task_id
|
||||
}
|
||||
|
||||
await confirmGeneration(taskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
custom_title: props.titleSettings.title || "",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
startPolling()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 素材片段调度器 Hook
|
||||
* 控制原生 <video> 元素按时间线依次播放素材片段(入点→出点)
|
||||
* 实现前端预览播放,替代后端 FFmpeg 渲染
|
||||
* 素材片段调度器 Hook(多 video 元素方案 v2)
|
||||
* 每个片段对应一个独立 <video> 元素,全部预加载,通过 display 切换实现无缝播放
|
||||
* 替代单 video + 切 src 方案,消除片段切换延迟
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
/** 单个播放片段 */
|
||||
export interface PlaybackSegment {
|
||||
@@ -27,7 +27,7 @@ export interface SegmentSchedulerState {
|
||||
currentTime: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 当前片段索引(在 segments 数组中的位置) */
|
||||
/** 当前片段索引 */
|
||||
currentSegmentIndex: number
|
||||
/** 当前片段的本地播放时间 */
|
||||
segmentLocalTime: number
|
||||
@@ -43,8 +43,8 @@ export interface SegmentSchedulerState {
|
||||
togglePlayPause: () => void
|
||||
/** 跳转到全局时间 */
|
||||
seekTo: (time: number) => void
|
||||
/** 绑定到 <video> 元素 */
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
/** 每个片段对应的 video 元素 ref 数组 */
|
||||
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,10 +80,17 @@ function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* useSegmentScheduler — 素材片段调度器
|
||||
* useSegmentScheduler — 多 video 元素版素材片段调度器
|
||||
*
|
||||
* 核心改变:
|
||||
* - 每个片段对应一个独立 <video> 元素(由组件渲染,ref 传入)
|
||||
* - 所有 video 在挂载时即设置 src + preload="auto",浏览器自动预加载
|
||||
* - 切换片段仅改 currentSegmentIndex + display,无需重新 load
|
||||
* - 实现无缝切换,无加载延迟
|
||||
*/
|
||||
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
/** 每个片段对应的 video 元素 ref(由组件 JSX 渲染并绑定) */
|
||||
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
|
||||
@@ -91,13 +98,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
const rafRef = useRef<number>(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
|
||||
// ✅ 关键修复:用 ref 标记是否已加载过片段(替代检查 video.src)
|
||||
// video.src = "" 在浏览器中会被解析为页面 URL,导致 "video.src === ''" 永远为 false
|
||||
const srcLoadedRef = useRef(false)
|
||||
|
||||
// 预加载用的隐藏 video 元素
|
||||
const preloadVideoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
// 计算时间线
|
||||
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
|
||||
const totalDuration = useMemo(
|
||||
@@ -113,122 +113,63 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
|
||||
: 0
|
||||
|
||||
/** 切换到指定片段,返回 Promise 在视频可播放后 resolve */
|
||||
/**
|
||||
* 切换到指定片段
|
||||
* 不改变 src(video 已在 JSX 中设置),仅 seek + 等待可播
|
||||
*/
|
||||
const switchToSegment = useCallback(
|
||||
(index: number, seekToLocalTime?: number): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
const video = videoRef.current
|
||||
// 暂停当前视频
|
||||
const prevVideo = videoRefs.current[currentSegmentIndex]
|
||||
if (prevVideo) prevVideo.pause()
|
||||
|
||||
const video = videoRefs.current[index]
|
||||
if (!video || index >= segments.length) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const seg = segments[index]
|
||||
const videoUrl = seg.videoUrl
|
||||
|
||||
if (!videoUrl || videoUrl === "") {
|
||||
console.error(
|
||||
`[useSegmentScheduler] 片段 ${index} 的 videoUrl 为空!assetId: ${seg.assetId}`,
|
||||
)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是完整的 HTTP URL
|
||||
if (!videoUrl.startsWith("http://") && !videoUrl.startsWith("https://")) {
|
||||
console.warn(`[useSegmentScheduler] 片段 ${index} 的 videoUrl 不是完整 URL: ${videoUrl}`)
|
||||
}
|
||||
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
|
||||
// ✅ 关键修复:清除旧事件监听,防止重复绑定
|
||||
const cleanup = () => {
|
||||
video.removeEventListener("loadedmetadata", onLoaded)
|
||||
video.removeEventListener("error", onError)
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
// 设置播放位置
|
||||
video.currentTime = localTime
|
||||
|
||||
const onLoaded = () => {
|
||||
video.currentTime = localTime
|
||||
srcLoadedRef.current = true
|
||||
cleanup()
|
||||
console.log(
|
||||
`[useSegmentScheduler] 片段 ${index} loadedmetadata, src=${videoUrl.substring(0, 80)}`,
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onCanPlay = () => {
|
||||
video.currentTime = localTime
|
||||
srcLoadedRef.current = true
|
||||
cleanup()
|
||||
console.log(
|
||||
`[useSegmentScheduler] 片段 ${index} canplay, src=${videoUrl.substring(0, 80)}`,
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
console.error(
|
||||
`[useSegmentScheduler] 视频加载失败: ${videoUrl}`,
|
||||
video.error?.message || `code=${video.error?.code}`,
|
||||
)
|
||||
srcLoadedRef.current = true // 标记为已尝试,避免死循环
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
// ✅ 关键修复:10秒超时防止 Promise 永远不 resolve
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn(
|
||||
`[useSegmentScheduler] 片段 ${index} 加载超时 (10s), readyState=${video.readyState}`,
|
||||
)
|
||||
cleanup()
|
||||
resolve()
|
||||
}, 10000)
|
||||
|
||||
// 先绑定事件,再设置 src(避免错过已触发的loadedmetadata)
|
||||
video.addEventListener("loadedmetadata", onLoaded)
|
||||
video.addEventListener("canplay", onCanPlay)
|
||||
video.addEventListener("error", onError)
|
||||
|
||||
// 设置 src 并显式调用 load()
|
||||
video.src = videoUrl
|
||||
video.load()
|
||||
|
||||
// ✅ 关键修复:如果 readyState >= 1(已有 metadata),说明加载已完成
|
||||
// 直接设置 currentTime 并 resolve,不等待事件
|
||||
if (video.readyState >= 1) {
|
||||
video.currentTime = localTime
|
||||
srcLoadedRef.current = true
|
||||
cleanup()
|
||||
console.log(
|
||||
`[useSegmentScheduler] 片段 ${index} 已缓存(readyState=${video.readyState}), 直接播放`,
|
||||
)
|
||||
// 如果已有足够帧数据,直接 resolve
|
||||
if (video.readyState >= 2) {
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentSegmentIndex(index)
|
||||
|
||||
// 预加载下一段
|
||||
if (index + 1 < segments.length) {
|
||||
const nextSeg = segments[index + 1]
|
||||
if (!preloadVideoRef.current) {
|
||||
preloadVideoRef.current = document.createElement("video")
|
||||
preloadVideoRef.current.preload = "auto"
|
||||
}
|
||||
preloadVideoRef.current.src = nextSeg.videoUrl
|
||||
// 等待 canplay 事件(预加载机制应已提前触发缓冲)
|
||||
const onCanPlay = () => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
clearTimeout(timeoutId)
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 3 秒超时保护(预加载正常情况下不需要等太久)
|
||||
const timeoutId = setTimeout(() => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
console.warn(
|
||||
`[useSegmentScheduler] 片段 ${index} 预加载超时 (3s), readyState=${video.readyState},强制切换`,
|
||||
)
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
}, 3000)
|
||||
|
||||
video.addEventListener("canplay", onCanPlay)
|
||||
})
|
||||
},
|
||||
[segments],
|
||||
[segments, currentSegmentIndex],
|
||||
)
|
||||
|
||||
/** 播放循环 — 检测片段边界并切换 */
|
||||
const tick = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
if (!video || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
@@ -237,36 +178,56 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
const seg = segments[currentSegmentIndex]
|
||||
if (!seg) return
|
||||
|
||||
// 提前 2 秒预加载下一段
|
||||
if (currentSegmentIndex + 1 < segments.length) {
|
||||
const nextSeg = segments[currentSegmentIndex + 1]
|
||||
if (!preloadVideoRef.current) {
|
||||
preloadVideoRef.current = document.createElement("video")
|
||||
preloadVideoRef.current.preload = "auto"
|
||||
}
|
||||
if (preloadVideoRef.current.src !== nextSeg.videoUrl) {
|
||||
preloadVideoRef.current.src = nextSeg.videoUrl
|
||||
preloadVideoRef.current.load()
|
||||
// 预加载下一个片段:距离出点 3 秒时,提前设置下一个视频的 currentTime 触发缓冲
|
||||
const nextIndex = currentSegmentIndex + 1
|
||||
if (nextIndex < segments.length) {
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
const timeToEnd = seg.endTime - video.currentTime
|
||||
if (timeToEnd <= 3 && nextVideo && nextVideo.readyState < 3) {
|
||||
const nextSeg = segments[nextIndex]
|
||||
// 只在还没 seek 过时设置(避免反复 seek)
|
||||
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 1) {
|
||||
nextVideo.currentTime = nextSeg.startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否到达出点(容差 0.15s)
|
||||
if (video.currentTime >= seg.endTime - 0.15) {
|
||||
video.pause()
|
||||
const nextIndex = currentSegmentIndex + 1
|
||||
if (nextIndex < segments.length) {
|
||||
switchToSegment(nextIndex).then(() => {
|
||||
const v = videoRef.current
|
||||
if (v) {
|
||||
v.play().catch((e) =>
|
||||
console.warn("[useSegmentScheduler] auto-play next segment failed:", e),
|
||||
)
|
||||
setIsPlaying(true)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
if (nextVideo) {
|
||||
const canPlay = () => {
|
||||
nextVideo
|
||||
.play()
|
||||
.catch((e) =>
|
||||
console.warn("[useSegmentScheduler] auto-play next segment failed:", e),
|
||||
)
|
||||
}
|
||||
if (nextVideo.readyState >= 3) {
|
||||
canPlay()
|
||||
} else {
|
||||
const timeout = setTimeout(canPlay, 300)
|
||||
nextVideo.addEventListener(
|
||||
"canplay",
|
||||
() => {
|
||||
clearTimeout(timeout)
|
||||
canPlay()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
const accumulatedTime =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
|
||||
setCurrentTime(accumulatedTime)
|
||||
} else {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
setIsEnded(true)
|
||||
setCurrentTime(totalDuration)
|
||||
@@ -283,52 +244,38 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
|
||||
/** 播放 */
|
||||
const play = useCallback(async () => {
|
||||
const video = videoRef.current
|
||||
if (!video || !canPlay) {
|
||||
console.log("[useSegmentScheduler] play: video=", !!video, "canPlay=", canPlay)
|
||||
return
|
||||
}
|
||||
if (!canPlay) return
|
||||
|
||||
setIsEnded(false)
|
||||
|
||||
// ✅ 关键修复:用 ref 标记代替 video.src 检查
|
||||
if (!srcLoadedRef.current) {
|
||||
console.log("[useSegmentScheduler] 首次播放,加载片段 0...")
|
||||
// 确保第一段可播放
|
||||
const firstVideo = videoRefs.current[0]
|
||||
if (firstVideo && currentSegmentIndex === 0 && firstVideo.readyState < 2) {
|
||||
await switchToSegment(0)
|
||||
}
|
||||
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
if (!video) return
|
||||
|
||||
try {
|
||||
const playPromise = video.play()
|
||||
if (playPromise !== undefined) {
|
||||
await playPromise
|
||||
}
|
||||
console.log("[useSegmentScheduler] 播放开始, src=", video.src?.substring(0, 80))
|
||||
setIsPlaying(true)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch (err) {
|
||||
console.warn("[useSegmentScheduler] 播放失败:", err, "src=", video.src?.substring(0, 80))
|
||||
// 如果播放失败,尝试重新加载片段
|
||||
if (!srcLoadedRef.current) {
|
||||
srcLoadedRef.current = false
|
||||
await switchToSegment(0)
|
||||
try {
|
||||
await video.play()
|
||||
setIsPlaying(true)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch (retryErr) {
|
||||
console.error("[useSegmentScheduler] 重试播放仍然失败:", retryErr)
|
||||
}
|
||||
}
|
||||
console.warn("[useSegmentScheduler] 播放失败:", err)
|
||||
}
|
||||
}, [canPlay, switchToSegment, tick])
|
||||
}, [canPlay, switchToSegment, tick, currentSegmentIndex])
|
||||
|
||||
/** 暂停 */
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
if (video) video.pause()
|
||||
setIsPlaying(false)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
}, [currentSegmentIndex])
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const togglePlayPause = useCallback(() => {
|
||||
@@ -338,9 +285,8 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
if (isEnded) {
|
||||
// 播放结束后再次播放,从头开始
|
||||
setIsEnded(false)
|
||||
srcLoadedRef.current = false // ✅ 重置标记,强制重新加载
|
||||
switchToSegment(0, segments[0]?.startTime).then(() => {
|
||||
const video = videoRef.current
|
||||
const video = videoRefs.current[0]
|
||||
if (video) {
|
||||
video.play().catch((e) => console.warn("[useSegmentScheduler] restart play failed:", e))
|
||||
setIsPlaying(true)
|
||||
@@ -357,8 +303,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
/** 跳转到指定全局时间 */
|
||||
const seekTo = useCallback(
|
||||
async (time: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video || !canPlay) return
|
||||
if (!canPlay) return
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDuration))
|
||||
const { index, localTime } = findSegmentAtTime(segments, clampedTime)
|
||||
@@ -368,11 +313,11 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
if (index !== currentSegmentIndex) {
|
||||
await switchToSegment(index, localTime)
|
||||
} else {
|
||||
video.currentTime = localTime
|
||||
const video = videoRefs.current[index]
|
||||
if (video) video.currentTime = localTime
|
||||
}
|
||||
|
||||
setCurrentTime(clampedTime)
|
||||
setCurrentSegmentIndex(index)
|
||||
setIsEnded(false)
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -382,14 +327,24 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
[canPlay, totalDuration, segments, currentSegmentIndex, switchToSegment],
|
||||
)
|
||||
|
||||
// 确保 videoRefs 数组长度与 segments 一致 + 强制预加载
|
||||
useEffect(() => {
|
||||
videoRefs.current = videoRefs.current.slice(0, segments.length)
|
||||
while (videoRefs.current.length < segments.length) {
|
||||
videoRefs.current.push(null)
|
||||
}
|
||||
// 强制预加载:所有 video 元素挂载后,调用 load() 确保浏览器真正开始加载数据
|
||||
videoRefs.current.forEach((video) => {
|
||||
if (video) {
|
||||
video.load()
|
||||
}
|
||||
})
|
||||
}, [segments])
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
if (preloadVideoRef.current) {
|
||||
preloadVideoRef.current.src = ""
|
||||
preloadVideoRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -400,13 +355,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
setCurrentTime(0)
|
||||
setCurrentSegmentIndex(0)
|
||||
setIsEnded(false)
|
||||
srcLoadedRef.current = false // ✅ 重置标记
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
video.removeAttribute("src")
|
||||
video.load()
|
||||
}
|
||||
}, [segments])
|
||||
|
||||
return {
|
||||
@@ -421,7 +369,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRef,
|
||||
videoRefs,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 {
|
||||
fetchCoverTemplates,
|
||||
createCoverTemplate,
|
||||
@@ -111,8 +112,82 @@ export function useStep6Cover({
|
||||
// 提取详细错误信息
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyErr = err as any
|
||||
// 如果 API 拦截器已经弹出了后端返回的具体错误信息,这里跳过重复 toast
|
||||
if (anyErr?.__msgShown) {
|
||||
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,
|
||||
})
|
||||
// 轮询等待预览渲染完成:递归 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") {
|
||||
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",
|
||||
})
|
||||
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 = "封面生成失败"
|
||||
@@ -137,7 +212,7 @@ export function useStep6Cover({
|
||||
clearTimeout(timeoutId)
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating, duration])
|
||||
|
||||
// ── 模板操作方法 ──
|
||||
const handleSelectTemplate = useCallback((id: string) => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 共享:根据素材列表和模板片段计算总视频时长
|
||||
* 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)
|
||||
}
|
||||
+23
-22
@@ -2,14 +2,13 @@ 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, buildMetadata } from "../../../types"
|
||||
import { getAudioDuration } from "../../../utils/audio"
|
||||
import { type VoiceGender, type VoiceMaterial } from "../../../types"
|
||||
|
||||
interface UseVoiceUploadOptions {
|
||||
voiceLibrary?: { id: string; kind: string }
|
||||
@@ -48,32 +47,34 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
}
|
||||
|
||||
// 2. 上传文件(带进度)
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
// 2. 上传文件(带进度,后端自动创建 ingest job)
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 3. 获取音频时长
|
||||
const duration = await getAudioDuration(data.file)
|
||||
// 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++
|
||||
}
|
||||
|
||||
// 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,
|
||||
}),
|
||||
})
|
||||
if (!job || job.status === "failed") {
|
||||
throw new Error("音频处理失败,请重试")
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
throw new Error("音频处理超时,请稍后在素材库查看")
|
||||
}
|
||||
|
||||
// 5. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0) {
|
||||
await tagAsset(asset.id, data.tagIds)
|
||||
// 4. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0 && job.result_asset_id) {
|
||||
await tagAsset(job.result_asset_id, data.tagIds)
|
||||
}
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface VoiceMaterial {
|
||||
fileUrl?: string
|
||||
}
|
||||
|
||||
/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
|
||||
/** 配音素材上传元数据(上传素材的 metadata) */
|
||||
export interface VoiceAssetMetadata {
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import { getAudioDuration } from "../utils/audio"
|
||||
import { buildVoiceMetadata } from "../types"
|
||||
import { uploadAssetDirect, getAssetLibraries, getIngestJob } from "@/api/assets"
|
||||
|
||||
/**
|
||||
* 配音上传 Hook
|
||||
@@ -33,27 +31,30 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
const lib = libs.find((l) => l.kind === "voice")
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
/* 直传文件 */
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
/* 直传文件(后端会自动创建 ingest job) */
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
/* 获取音频时长 */
|
||||
const duration = await getAudioDuration(data.file)
|
||||
/* 轮询 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++
|
||||
}
|
||||
|
||||
/* 创建素材记录 */
|
||||
await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildVoiceMetadata({
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
if (jobStatus === "failed") {
|
||||
throw new Error("音频处理失败,请重试")
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
throw new Error("音频处理超时,请稍后在素材库查看")
|
||||
}
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ClonedVoiceDisplay {
|
||||
sampleUrl?: string
|
||||
}
|
||||
|
||||
/** 音色上传元数据(传递给 createAsset 的 metadata) */
|
||||
/** 音色上传元数据(上传素材的 metadata) */
|
||||
export interface VoiceUploadMetadata {
|
||||
gender?: string
|
||||
description?: string
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
deleteAssetLibrary,
|
||||
getAssets,
|
||||
getAssetsByKind,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
updateAssetReviewStatus,
|
||||
deleteAsset,
|
||||
uploadAsset,
|
||||
prepareDirectUpload,
|
||||
completeDirectUpload,
|
||||
uploadAssetDirect,
|
||||
@@ -175,22 +173,6 @@ 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()
|
||||
@@ -239,22 +221,6 @@ 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()
|
||||
|
||||
@@ -10,7 +10,9 @@ vi.mock("@/api/voice-clone", () => ({
|
||||
}))
|
||||
|
||||
vi.mock("@/api/assets", () => ({
|
||||
uploadAsset: vi.fn(),
|
||||
uploadAssetDirect: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ storage_key: "test", ingest_job_id: "test", url: "http://test" }),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
|
||||
@@ -180,7 +180,6 @@ 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({}),
|
||||
|
||||
@@ -165,7 +165,6 @@ 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({}),
|
||||
|
||||
@@ -93,5 +93,5 @@ describe("useStep5Voice smoke test", () => {
|
||||
)
|
||||
expect(result.current).toBeDefined()
|
||||
expect(typeof result.current.handlePlayCloneSample).toBe("function")
|
||||
})
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 = 300 # 单文件上传总超时(秒),防止网络慢时无限卡住
|
||||
OSS_UPLOAD_TOTAL_TIMEOUT = 900 # 单文件上传总超时(秒),防止网络慢时无限卡住
|
||||
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)。
|
||||
|
||||
使用流式下载避免大文件内存溢出,超时 300s。
|
||||
使用流式下载避免大文件内存溢出,超时 900s。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(url, stream=True, timeout=300)
|
||||
resp = requests.get(url, stream=True, timeout=900)
|
||||
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 风险。
|
||||
上传加总超时保护(默认 300s),防止网络异常时无限挂死。
|
||||
上传加总超时保护(默认 900s),防止网络异常时无限挂死。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径(Path 或 str 均可)
|
||||
|
||||
@@ -134,3 +134,88 @@ 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: 标题文字(当前版本未叠加,预留参数)
|
||||
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
@@ -233,6 +234,183 @@ 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:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# ============================================================
|
||||
# Worker 统一基础镜像(预构建)
|
||||
# 预装系统依赖 + Python 全部依赖 + CJK 字体
|
||||
# 业务构建从此镜像开始,只需 COPY 业务代码,构建时间 < 5 分钟
|
||||
# 当 requirements-*.txt 变更时重新构建
|
||||
# ============================================================
|
||||
|
||||
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
|
||||
|
||||
# 预装系统依赖(编译工具 + 运行时 + CJK 字体用于 ASS 字幕渲染)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
binutils \
|
||||
ffmpeg \
|
||||
libglib2.0-0 \
|
||||
fonts-noto-cjk \
|
||||
&& fc-cache -fv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# 预装全部 Python 依赖(基础 + Worker 大包 + 业务依赖)
|
||||
COPY requirements-base.txt requirements.txt requirements-worker.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-worker.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 /tmp/requirements-worker.txt
|
||||
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
@@ -1,51 +1,16 @@
|
||||
# ============================================================
|
||||
# Worker Dockerfile - 分层缓存优化版
|
||||
# 优化:基础依赖 + Worker大包预构建为基础镜像,业务构建仅叠加业务依赖
|
||||
# 基础镜像:worker-base-builder / worker-base-runtime
|
||||
# Worker Dockerfile - 极简化
|
||||
# 从预构建统一基础镜像开始,仅叠加业务代码
|
||||
# 基础镜像包含:系统依赖 + 全部 Python 依赖 + CJK 字体
|
||||
# 构建时间目标:< 5 分钟
|
||||
# ============================================================
|
||||
|
||||
# ==================== Builder 阶段 ====================
|
||||
# 从预构建的builder基础镜像开始,已经包含:
|
||||
# - 编译工具 (gcc/g++/python3-dev/binutils)
|
||||
# - requirements-base.txt 全部依赖
|
||||
# - requirements-worker.txt 全部依赖 (numpy/scipy/opencv)
|
||||
# - 预strip的.so文件
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-builder:latest AS builder
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# ---- 安装业务依赖(变化频繁,单独一层)----
|
||||
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
|
||||
|
||||
# ---- 增量瘦身(清理新增业务依赖的冗余文件)----
|
||||
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 阶段 ====================
|
||||
# 从预构建的runtime基础镜像开始,已经包含:
|
||||
# - ffmpeg
|
||||
# - libglib2.0-0
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-runtime:latest AS runtime
|
||||
|
||||
# 构建参数:版本号
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 从 builder 复制 Python 虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# 设置 Python 环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 创建非 root 用户(极少变化,放最前)
|
||||
# 创建非 root 用户
|
||||
RUN groupadd -r celery \
|
||||
&& useradd -r -g celery -d /app -s /sbin/nologin celery \
|
||||
&& mkdir -p /app/generated \
|
||||
@@ -53,25 +18,26 @@ RUN groupadd -r celery \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制文件按变化频率从低到高排序,最大化层缓存命中
|
||||
# 设置 Python 环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 复制文件(按变化频率从低到高排序,最大化层缓存命中)
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY packages/ /app/packages/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
COPY apps/api/app/core/ /app/apps/api/app/core/
|
||||
|
||||
# 复制 Worker 启动脚本
|
||||
# Worker 启动脚本
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 业务代码(变化最频繁,放最后)
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
|
||||
|
||||
# ---- Install CJK fonts for ASS subtitle rendering ----
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-cjk && fc-cache -fv && rm -rf /var/lib/apt/lists/*
|
||||
USER celery
|
||||
|
||||
# Worker 入口点
|
||||
WORKDIR /app/apps/worker
|
||||
CMD ["/usr/local/bin/entrypoint-worker.sh"]
|
||||
|
||||
@@ -121,6 +121,7 @@ class GenerationTask:
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
extra_meta: dict = field(default_factory=dict)
|
||||
logs: str = "[]"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -152,6 +153,7 @@ class GenerationTask:
|
||||
output_height: int = 720,
|
||||
cover_url: str = "",
|
||||
custom_title: str = "",
|
||||
extra_meta: dict | None = None,
|
||||
) -> "GenerationTask":
|
||||
if not project_id.strip() and not template_id.strip():
|
||||
raise ValueError("project_id 或 template_id 至少需要提供一个")
|
||||
@@ -182,6 +184,7 @@ class GenerationTask:
|
||||
output_height=output_height,
|
||||
cover_url=cover_url,
|
||||
custom_title=custom_title,
|
||||
extra_meta=dict(extra_meta) if extra_meta else {},
|
||||
)
|
||||
|
||||
# ── 状态查询 ────────────────────────────────────────────────────────────
|
||||
@@ -301,6 +304,7 @@ class GenerationTask:
|
||||
*,
|
||||
cover_url: str = "",
|
||||
custom_title: str = "",
|
||||
extra_meta: dict | None = None,
|
||||
output_width: int = 0,
|
||||
output_height: int = 0,
|
||||
) -> None:
|
||||
@@ -318,6 +322,8 @@ class GenerationTask:
|
||||
self.output_width = output_width
|
||||
if output_height > 0:
|
||||
self.output_height = output_height
|
||||
if extra_meta:
|
||||
self.extra_meta.update(extra_meta)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── 日志辅助 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -260,6 +260,30 @@ def _make_user(**overrides) -> User:
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _direct_insert_asset(client, name="test-video.mp4", storage_key=None, mime_type="video/mp4", status=None):
|
||||
"""Helper: insert asset directly into repo (bypass deprecated create_asset API)."""
|
||||
import uuid as _uuid
|
||||
|
||||
app = client.app
|
||||
asset_repo = app.dependency_overrides[get_asset_repository]()
|
||||
kw = {}
|
||||
if status is not None:
|
||||
kw["status"] = status
|
||||
else:
|
||||
kw["status"] = AssetStatus.READY
|
||||
asset = Asset(
|
||||
id=_uuid.uuid4().hex,
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=name,
|
||||
storage_key=storage_key or f"uploads/{name}",
|
||||
mime_type=mime_type,
|
||||
**kw,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
return asset.id
|
||||
|
||||
|
||||
def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
|
||||
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
|
||||
|
||||
@@ -344,8 +368,8 @@ def client(mock_storage):
|
||||
class TestCreateAsset:
|
||||
"""创建素材端点测试。"""
|
||||
|
||||
def test_create_asset_success(self, client):
|
||||
"""正常创建素材成功。"""
|
||||
def test_create_asset_returns_410_gone(self, client):
|
||||
"""create_asset 已废弃,返回 410 Gone 提示使用 ingest-jobs。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
@@ -354,59 +378,23 @@ class TestCreateAsset:
|
||||
"name": "new-video.mp4",
|
||||
"storage_key": "uploads/new-video.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"file_size": 2048,
|
||||
"duration": 15.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "new-video.mp4"
|
||||
assert data["project_id"] == "proj-1"
|
||||
assert data["library_id"] == "lib-1"
|
||||
assert data["mime_type"] == "video/mp4"
|
||||
assert "id" in data
|
||||
assert data["status"] == "uploading"
|
||||
assert resp.status_code == 410
|
||||
|
||||
def test_create_asset_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
def test_create_asset_any_type_returns_410(self, client):
|
||||
"""所有类型都返回 410 Gone(图片/音频也废弃)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "nonexistent",
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "test.mp4",
|
||||
"storage_key": "uploads/test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"name": "photo.jpg",
|
||||
"storage_key": "uploads/photo.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "Project" in resp.json()["detail"]
|
||||
|
||||
def test_create_asset_library_not_found(self, client):
|
||||
"""素材库不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "nonexistent",
|
||||
"name": "test.mp4",
|
||||
"storage_key": "uploads/test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "AssetLibrary" in resp.json()["detail"]
|
||||
|
||||
def test_create_asset_missing_required_fields(self, client):
|
||||
"""缺少必填字段返回 422。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"name": "test.mp4",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.status_code == 410
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -418,20 +406,26 @@ class TestListAssets:
|
||||
"""获取素材列表端点测试。"""
|
||||
|
||||
def _create_test_assets(self, client, count: int = 3):
|
||||
"""辅助方法:创建测试素材(status=ready)。"""
|
||||
"""辅助方法:直接插入测试素材到 repository(绕过已废弃的 create_asset API)。"""
|
||||
# 通过依赖覆盖获取 asset_repo
|
||||
app = client.app
|
||||
asset_repo = app.dependency_overrides.get(get_asset_repository, lambda: None)()
|
||||
if asset_repo is None:
|
||||
return
|
||||
for i in range(count):
|
||||
client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": f"video-{i}.mp4",
|
||||
"storage_key": f"uploads/video-{i}.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"file_size": 1024 * (i + 1),
|
||||
"status": "ready",
|
||||
},
|
||||
import uuid
|
||||
|
||||
asset = Asset(
|
||||
id=uuid.uuid4().hex,
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"video-{i}.mp4",
|
||||
storage_key=f"uploads/video-{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
file_size=1024 * (i + 1),
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无素材时返回空列表。"""
|
||||
@@ -517,17 +511,7 @@ class TestListAssets:
|
||||
|
||||
def test_list_status_filter_uploading_visible(self, client):
|
||||
"""uploading状态的素材默认能看到(上传后立即显示处理中)。"""
|
||||
client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "uploading-test.mp4",
|
||||
"storage_key": "uploads/uploading-test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"status": "uploading",
|
||||
},
|
||||
)
|
||||
_direct_insert_asset(client, name="uploading-test.mp4", status=AssetStatus.UPLOADING)
|
||||
|
||||
resp = client.get("/api/v1/assets?library_id=lib-1")
|
||||
assert resp.status_code == 200
|
||||
@@ -537,28 +521,8 @@ class TestListAssets:
|
||||
|
||||
def test_list_with_keyword_filter(self, client):
|
||||
"""按名称关键词过滤。"""
|
||||
client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "hello-world.mp4",
|
||||
"storage_key": "uploads/hello.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"status": "ready",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "goodbye.mp4",
|
||||
"storage_key": "uploads/goodbye.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"status": "ready",
|
||||
},
|
||||
)
|
||||
_direct_insert_asset(client, name="hello-world.mp4")
|
||||
_direct_insert_asset(client, name="goodbye.mp4", mime_type="video/mp4", status=AssetStatus.READY)
|
||||
|
||||
resp = client.get("/api/v1/assets?library_id=lib-1&keyword=hello")
|
||||
assert resp.status_code == 200
|
||||
@@ -576,22 +540,8 @@ class TestGetAsset:
|
||||
"""获取单个素材详情端点测试。"""
|
||||
|
||||
def _create_asset(self, client) -> str:
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "detail-test.mp4",
|
||||
"storage_key": "uploads/detail-test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"file_size": 5000,
|
||||
"duration": 25.0,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30.0,
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
"""Direct insert into repo (create_asset API is deprecated/410)."""
|
||||
return _direct_insert_asset(client)
|
||||
|
||||
def test_get_asset_success(self, client):
|
||||
"""获取存在的素材详情成功。"""
|
||||
@@ -601,11 +551,7 @@ class TestGetAsset:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == asset_id
|
||||
assert data["name"] == "detail-test.mp4"
|
||||
assert data["file_size"] == 5000
|
||||
assert data["duration"] == 25.0
|
||||
assert data["width"] == 1280
|
||||
assert data["height"] == 720
|
||||
assert data["name"] == "test-video.mp4"
|
||||
assert "file_url" in data
|
||||
assert "status" in data
|
||||
|
||||
@@ -625,17 +571,8 @@ class TestUpdateAsset:
|
||||
"""更新素材端点测试。"""
|
||||
|
||||
def _create_asset(self, client) -> str:
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "old-name.mp4",
|
||||
"storage_key": "uploads/old-name.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
"""Direct insert into repo (create_asset API is deprecated/410)."""
|
||||
return _direct_insert_asset(client)
|
||||
|
||||
def test_update_asset_name(self, client):
|
||||
"""更新素材名称成功。"""
|
||||
@@ -675,7 +612,7 @@ class TestUpdateAsset:
|
||||
|
||||
resp = client.put(f"/api/v1/assets/{asset_id}", json={})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "old-name.mp4"
|
||||
assert resp.json()["name"] == "test-video.mp4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -687,17 +624,8 @@ class TestDeleteAsset:
|
||||
"""删除素材端点测试。"""
|
||||
|
||||
def _create_asset(self, client) -> str:
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "delete-test.mp4",
|
||||
"storage_key": "uploads/delete-test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
"""Direct insert into repo (create_asset API is deprecated/410)."""
|
||||
return _direct_insert_asset(client)
|
||||
|
||||
def test_delete_asset_success(self, client):
|
||||
"""删除存在的素材成功,返回 204。"""
|
||||
@@ -737,17 +665,8 @@ class TestBatchDeleteAssets:
|
||||
def _create_assets(self, client, count: int = 3) -> list[str]:
|
||||
ids = []
|
||||
for i in range(count):
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": f"batch-{i}.mp4",
|
||||
"storage_key": f"uploads/batch-{i}.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
ids.append(resp.json()["id"])
|
||||
aid = _direct_insert_asset(client, name=f"batch-{i}.mp4")
|
||||
ids.append(aid)
|
||||
return ids
|
||||
|
||||
def test_batch_delete_success(self, client):
|
||||
@@ -802,17 +721,8 @@ class TestAssetTags:
|
||||
"""素材标签相关端点测试。"""
|
||||
|
||||
def _create_asset(self, client) -> str:
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "tag-test.mp4",
|
||||
"storage_key": "uploads/tag-test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
"""Direct insert into repo (create_asset API is deprecated/410)."""
|
||||
return _direct_insert_asset(client)
|
||||
|
||||
def test_add_tags_to_asset(self, client):
|
||||
"""给素材打标签。需要先在 tag_repo 中创建标签。"""
|
||||
@@ -847,22 +757,8 @@ class TestAssetsCRUDFlow:
|
||||
|
||||
def test_full_crud_flow(self, client):
|
||||
"""测试完整的创建 → 列表 → 详情 → 更新 → 删除流程。"""
|
||||
# 1. 创建
|
||||
create_resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "crud-flow.mp4",
|
||||
"storage_key": "uploads/crud-flow.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"file_size": 8192,
|
||||
"metadata": {"source": "test"},
|
||||
"status": "ready",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
asset_id = create_resp.json()["id"]
|
||||
# 1. 创建 (direct insert since create_asset is 410)
|
||||
asset_id = _direct_insert_asset(client, name="crud-flow.mp4")
|
||||
|
||||
# 2. 列表中应包含
|
||||
list_resp = client.get("/api/v1/assets?library_id=lib-1")
|
||||
|
||||
@@ -147,6 +147,7 @@ def _build_app(
|
||||
storage._normalize_storage_key = lambda key: key
|
||||
storage.file_exists = lambda key: True
|
||||
storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4")
|
||||
storage.get_url = MagicMock(return_value="https://oss.example.com/file.mp4")
|
||||
|
||||
mock_user = MagicMock(spec=AuthenticatedUser)
|
||||
mock_user.id = "user-1"
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
"""测试 create_asset 端点:project_id 可选,从 library 自动推导。"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from app.api.routes.assets import create_asset
|
||||
from app.auth import AuthenticatedUser
|
||||
from app.schemas.asset import CreateAssetRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
user = MagicMock(spec=AuthenticatedUser)
|
||||
user.user.id = "user-123"
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_library():
|
||||
lib = MagicMock()
|
||||
lib.id = "lib-abc"
|
||||
lib.project_id = "proj-from-library"
|
||||
return lib
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project():
|
||||
proj = MagicMock()
|
||||
proj.id = "proj-from-library"
|
||||
proj.can_access.return_value = True
|
||||
return proj
|
||||
|
||||
|
||||
def _make_request(**overrides):
|
||||
defaults = dict(
|
||||
library_id="lib-abc",
|
||||
name="test-audio.mp3",
|
||||
storage_key="uploads/test.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
file_size=1024,
|
||||
status="uploading",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return CreateAssetRequest(**defaults)
|
||||
|
||||
|
||||
def test_project_id_derived_from_library_when_not_provided(mock_user, mock_library, mock_project):
|
||||
"""前端不传 project_id 时,从 library.project_id 自动推导。"""
|
||||
request = _make_request() # project_id 默认 None
|
||||
|
||||
asset_repo = MagicMock()
|
||||
lib_repo = MagicMock()
|
||||
lib_repo.get.return_value = mock_library
|
||||
proj_repo = MagicMock()
|
||||
proj_repo.find_by_id.return_value = mock_project
|
||||
|
||||
expected_asset = MagicMock()
|
||||
expected_asset.id = "asset-1"
|
||||
expected_asset.project_id = "proj-from-library"
|
||||
expected_asset.library_id = "lib-abc"
|
||||
expected_asset.name = "test-audio.mp3"
|
||||
expected_asset.storage_key = ""
|
||||
expected_asset.mime_type = "audio/mpeg"
|
||||
expected_asset.metadata = {}
|
||||
expected_asset.file_size = 1024
|
||||
expected_asset.thumbnail_url = None
|
||||
expected_asset.duration = None
|
||||
expected_asset.width = None
|
||||
expected_asset.height = None
|
||||
expected_asset.fps = None
|
||||
expected_asset.codec = None
|
||||
expected_asset.status = AssetStatus.UPLOADING
|
||||
expected_asset.classification_status = ClassificationStatus.PENDING
|
||||
expected_asset.quality_score = None
|
||||
expected_asset.created_at = None
|
||||
expected_asset.uploaded_by_user_id = "user-123"
|
||||
expected_asset.tag_ids = []
|
||||
with patch("app.api.routes.assets.CreateAssetUseCase") as mock_uc:
|
||||
mock_uc.return_value.execute.return_value = expected_asset
|
||||
result = create_asset(
|
||||
request=request,
|
||||
authenticated_user=mock_user,
|
||||
asset_repository=asset_repo,
|
||||
asset_library_repository=lib_repo,
|
||||
project_repository=proj_repo,
|
||||
)
|
||||
|
||||
# 验证 project_id 被正确推导
|
||||
proj_repo.find_by_id.assert_called_once_with("proj-from-library")
|
||||
# 验证 use case 使用的是推导出的 project_id
|
||||
cmd = mock_uc.return_value.execute.call_args[0][0]
|
||||
assert cmd.project_id == "proj-from-library"
|
||||
|
||||
|
||||
def test_explicit_project_id_used_when_provided(mock_user, mock_library, mock_project):
|
||||
"""前端显式传 project_id 时,优先使用请求值。"""
|
||||
mock_project.id = "proj-explicit"
|
||||
mock_project.can_access.return_value = True
|
||||
mock_library.project_id = "proj-explicit" # 匹配
|
||||
|
||||
request = _make_request(project_id="proj-explicit")
|
||||
|
||||
asset_repo = MagicMock()
|
||||
lib_repo = MagicMock()
|
||||
lib_repo.get.return_value = mock_library
|
||||
proj_repo = MagicMock()
|
||||
proj_repo.find_by_id.return_value = mock_project
|
||||
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.id = "asset-1"
|
||||
mock_asset.storage_key = ""
|
||||
mock_asset.mime_type = "audio/mpeg"
|
||||
mock_asset.project_id = "proj-explicit"
|
||||
mock_asset.library_id = "lib-abc"
|
||||
mock_asset.name = "test"
|
||||
mock_asset.metadata = {}
|
||||
mock_asset.file_size = 0
|
||||
mock_asset.thumbnail_url = None
|
||||
mock_asset.duration = None
|
||||
mock_asset.width = None
|
||||
mock_asset.height = None
|
||||
mock_asset.fps = None
|
||||
mock_asset.codec = None
|
||||
mock_asset.status = AssetStatus.UPLOADING
|
||||
mock_asset.classification_status = ClassificationStatus.PENDING
|
||||
mock_asset.quality_score = None
|
||||
mock_asset.created_at = None
|
||||
mock_asset.uploaded_by_user_id = "user-123"
|
||||
mock_asset.tag_ids = []
|
||||
|
||||
with patch("app.api.routes.assets.CreateAssetUseCase") as mock_uc:
|
||||
mock_uc.return_value.execute.return_value = mock_asset
|
||||
create_asset(
|
||||
request=request,
|
||||
authenticated_user=mock_user,
|
||||
asset_repository=asset_repo,
|
||||
asset_library_repository=lib_repo,
|
||||
project_repository=proj_repo,
|
||||
)
|
||||
|
||||
proj_repo.find_by_id.assert_called_once_with("proj-explicit")
|
||||
cmd = mock_uc.return_value.execute.call_args[0][0]
|
||||
assert cmd.project_id == "proj-explicit"
|
||||
|
||||
|
||||
def test_library_not_found_returns_404(mock_user):
|
||||
"""素材库不存在时返回 404。"""
|
||||
request = _make_request()
|
||||
|
||||
lib_repo = MagicMock()
|
||||
lib_repo.get.return_value = None
|
||||
proj_repo = MagicMock()
|
||||
asset_repo = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_asset(
|
||||
request=request,
|
||||
authenticated_user=mock_user,
|
||||
asset_repository=asset_repo,
|
||||
asset_library_repository=lib_repo,
|
||||
project_repository=proj_repo,
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_library_project_mismatch_returns_400(mock_user, mock_library, mock_project):
|
||||
"""当 library.project_id 与请求的 project_id 不一致时返回 400。"""
|
||||
mock_library.project_id = "proj-A"
|
||||
mock_project.id = "proj-B"
|
||||
|
||||
request = _make_request(project_id="proj-B")
|
||||
|
||||
lib_repo = MagicMock()
|
||||
lib_repo.get.return_value = mock_library
|
||||
proj_repo = MagicMock()
|
||||
proj_repo.find_by_id.return_value = mock_project
|
||||
asset_repo = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_asset(
|
||||
request=request,
|
||||
authenticated_user=mock_user,
|
||||
asset_repository=asset_repo,
|
||||
asset_library_repository=lib_repo,
|
||||
project_repository=proj_repo,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -545,3 +545,44 @@ class TestGenerationTaskTimestamps:
|
||||
new_task.mark_pending_from_failed()
|
||||
assert new_task.started_at is None
|
||||
assert new_task.completed_at is None
|
||||
|
||||
|
||||
class TestExtraMeta:
|
||||
"""extra_meta 字段测试"""
|
||||
|
||||
def test_create_with_extra_meta(self):
|
||||
"""create() 传入 extra_meta 应正确存储"""
|
||||
task = GenerationTask.create(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
extra_meta={"source": "preview", "resolution": "1080p"},
|
||||
)
|
||||
assert task.extra_meta == {"source": "preview", "resolution": "1080p"}
|
||||
|
||||
def test_create_without_extra_meta_defaults_empty(self):
|
||||
"""create() 不传 extra_meta 应为空 dict"""
|
||||
task = GenerationTask.create(project_id="proj-1", asset_library_id="lib-1")
|
||||
assert task.extra_meta == {}
|
||||
|
||||
def test_mark_confirmed_with_extra_meta(self):
|
||||
"""mark_confirmed() 传入 extra_meta 应合并到已有字段"""
|
||||
task = GenerationTask.create(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
extra_meta={"source": "preview"},
|
||||
)
|
||||
task.mark_confirmed(extra_meta={"confirmed_by": "user", "resolution": "1080p"})
|
||||
assert task.extra_meta["source"] == "preview"
|
||||
assert task.extra_meta["confirmed_by"] == "user"
|
||||
assert task.extra_meta["resolution"] == "1080p"
|
||||
|
||||
def test_mark_confirmed_without_extra_meta_preserves_existing(self):
|
||||
"""mark_confirmed() 不传 extra_meta 不应影响已有值"""
|
||||
task = GenerationTask.create(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
extra_meta={"key": "value"},
|
||||
)
|
||||
task.mark_confirmed(cover_url="https://example.com/cover.jpg")
|
||||
assert task.extra_meta == {"key": "value"}
|
||||
assert task.cover_url == "https://example.com/cover.jpg"
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""HEVC 自动转码逻辑单元测试 (ingest.py)
|
||||
|
||||
测试覆盖:
|
||||
- HEVC 编码检测逻辑
|
||||
- 转码后文件命名规则
|
||||
- 元数据提取失败时的脏数据防护
|
||||
- FFmpeg 超时/错误降级策略
|
||||
- 安全修复(tempfile、subprocess)
|
||||
- Scale filter 逻辑
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestHEVCAutoTranscode:
|
||||
"""测试 ingest_asset 中的 HEVC 自动转码逻辑"""
|
||||
|
||||
def test_hevc_detection_keywords(self):
|
||||
"""验证 HEVC 编码的所有关键词"""
|
||||
hevc_keywords = ("hevc", "h265", "hvh1")
|
||||
|
||||
assert "hevc" in hevc_keywords
|
||||
assert "h265" in hevc_keywords
|
||||
assert "hvh1" in hevc_keywords
|
||||
assert "h264" not in hevc_keywords
|
||||
assert "avc1" not in hevc_keywords
|
||||
|
||||
def test_h264_not_detected_as_hevc(self):
|
||||
"""H.264 视频不应触发转码"""
|
||||
codec = "h264"
|
||||
hevc_keywords = ("hevc", "h265", "hvh1")
|
||||
assert codec not in hevc_keywords, "H.264 不应触发转码"
|
||||
|
||||
def test_transcode_storage_key_naming(self):
|
||||
"""验证转码后文件命名规则"""
|
||||
original_key = "uploads/video_123/test.mp4"
|
||||
p = Path(original_key)
|
||||
new_key = str(p.parent / (p.stem + "_h264" + p.suffix))
|
||||
|
||||
assert new_key == "uploads/video_123/test_h264.mp4"
|
||||
|
||||
def test_transcode_storage_key_naming_complex_path(self):
|
||||
"""验证复杂路径的命名规则"""
|
||||
original_key = "uploads/2026/08/20/abc123/video_4k.mov"
|
||||
p = Path(original_key)
|
||||
new_key = str(p.parent / (p.stem + "_h264" + p.suffix))
|
||||
|
||||
assert new_key == "uploads/2026/08/20/abc123/video_4k_h264.mov"
|
||||
|
||||
def test_metadata_failure_no_dirty_data(self):
|
||||
"""验证元数据提取失败时不更新 storage_key(避免脏数据)
|
||||
|
||||
这是 AI Code Review 发现的 BUG 修复:
|
||||
- 旧逻辑:先更新 storage_key,再提取元数据 → 可能产生脏数据
|
||||
- 新逻辑:先提取元数据,确认成功后再更新 storage_key
|
||||
"""
|
||||
original_storage_key = "uploads/test/video.mp4"
|
||||
new_storage_key = "uploads/test/video_h264.mp4"
|
||||
|
||||
# 初始状态
|
||||
job_storage_key = original_storage_key
|
||||
metadata = {"codec": "hevc", "width": 3840, "height": 2160}
|
||||
|
||||
# 模拟转码成功
|
||||
transcode_success = True
|
||||
|
||||
# 模拟元数据提取失败
|
||||
new_metadata = {}
|
||||
new_extract_success = False
|
||||
|
||||
# 修复后的逻辑:先提取元数据,确认成功后再更新
|
||||
if transcode_success:
|
||||
if new_extract_success:
|
||||
job_storage_key = new_storage_key
|
||||
metadata = new_metadata
|
||||
# 如果元数据提取失败,不更新 job_storage_key
|
||||
|
||||
# 验证:storage_key 保持原值,没有脏数据
|
||||
assert job_storage_key == original_storage_key
|
||||
assert metadata["codec"] == "hevc" # 保持原始元数据
|
||||
|
||||
def test_metadata_success_updates_storage_key(self):
|
||||
"""验证元数据提取成功时正确更新 storage_key"""
|
||||
original_storage_key = "uploads/test/video.mp4"
|
||||
new_storage_key = "uploads/test/video_h264.mp4"
|
||||
|
||||
job_storage_key = original_storage_key
|
||||
metadata = {"codec": "hevc", "width": 3840, "height": 2160}
|
||||
|
||||
# 模拟转码成功
|
||||
transcode_success = True
|
||||
|
||||
# 模拟元数据提取成功
|
||||
new_metadata = {"codec": "h264", "width": 1920, "height": 1080}
|
||||
new_extract_success = True
|
||||
|
||||
# 修复后的逻辑
|
||||
if transcode_success:
|
||||
if new_extract_success:
|
||||
job_storage_key = new_storage_key
|
||||
metadata = new_metadata
|
||||
|
||||
# 验证:storage_key 和 metadata 都更新为新值
|
||||
assert job_storage_key == new_storage_key
|
||||
assert metadata["codec"] == "h264"
|
||||
assert metadata["width"] == 1920
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_ffmpeg_timeout_degradation(self, mock_subprocess):
|
||||
"""验证 FFmpeg 超时降级使用原始文件"""
|
||||
mock_subprocess.side_effect = subprocess.TimeoutExpired(cmd="ffmpeg", timeout=300)
|
||||
|
||||
# 模拟降级逻辑
|
||||
transcode_success = False
|
||||
try:
|
||||
raise subprocess.TimeoutExpired(cmd="ffmpeg", timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
transcode_success = False
|
||||
|
||||
assert not transcode_success, "超时应该导致转码失败"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_ffmpeg_error_degradation(self, mock_subprocess):
|
||||
"""验证 FFmpeg 执行失败降级使用原始文件"""
|
||||
mock_subprocess.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stderr="Error: Invalid data found when processing input",
|
||||
)
|
||||
|
||||
result = mock_subprocess.return_value
|
||||
transcode_success = result.returncode == 0
|
||||
|
||||
assert not transcode_success, "FFmpeg 返回非零退出码应该导致转码失败"
|
||||
|
||||
def test_scale_filter_logic_4k_video(self):
|
||||
"""验证 4K 视频会被缩放到 1080p"""
|
||||
ih = 2160
|
||||
should_scale = ih > 1080
|
||||
assert should_scale, "4K 视频应该被缩放"
|
||||
|
||||
def test_scale_filter_logic_1080p_video(self):
|
||||
"""验证 1080p 视频不会被缩放"""
|
||||
ih = 1080
|
||||
should_scale = ih > 1080
|
||||
assert not should_scale, "1080p 视频不应该被缩放"
|
||||
|
||||
def test_scale_filter_logic_720p_video(self):
|
||||
"""验证 720p 视频不会被缩放"""
|
||||
ih = 720
|
||||
should_scale = ih > 1080
|
||||
assert not should_scale, "720p 视频不应该被缩放"
|
||||
|
||||
def test_tempfile_security_fix(self):
|
||||
"""验证使用 NamedTemporaryFile 替代 mktemp(安全修复)
|
||||
|
||||
AI Code Review 发现的安全漏洞:
|
||||
- tempfile.mktemp 存在 TOCTOU 竞态条件
|
||||
- 应该使用 NamedTemporaryFile(delete=False)
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
with patch("tempfile.NamedTemporaryFile") as mock_ntf:
|
||||
mock_file = MagicMock()
|
||||
mock_file.name = "/tmp/test_h264.mp4"
|
||||
mock_ntf.return_value = mock_file
|
||||
|
||||
# 新代码的调用方式
|
||||
_tc_tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix="_h264.mp4")
|
||||
_tc_tmp = Path(_tc_tmp_file.name)
|
||||
_tc_tmp_file.close()
|
||||
|
||||
# 验证使用了 NamedTemporaryFile
|
||||
mock_ntf.assert_called_once_with(delete=False, suffix="_h264.mp4")
|
||||
|
||||
def test_subprocess_output_handling(self):
|
||||
"""验证 subprocess 输出处理(避免内存溢出)
|
||||
|
||||
AI Code Review 发现的稳定性风险:
|
||||
- capture_output=True 会将所有输出加载到内存
|
||||
- 应该使用 stdout=DEVNULL, stderr=PIPE
|
||||
"""
|
||||
import subprocess as sp
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
# 新代码的调用方式
|
||||
sp.run(
|
||||
["ffmpeg", "-i", "input.mp4", "output.mp4"],
|
||||
stdout=sp.DEVNULL,
|
||||
stderr=sp.PIPE,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# 验证使用了 stdout=DEVNULL, stderr=PIPE
|
||||
call_kwargs = mock_run.call_args[1]
|
||||
assert call_kwargs.get("stdout") == sp.DEVNULL
|
||||
assert call_kwargs.get("stderr") == sp.PIPE
|
||||
assert call_kwargs.get("timeout") == 300
|
||||
|
||||
def test_ffmpeg_command_parameters(self):
|
||||
"""验证 FFmpeg 命令参数正确性"""
|
||||
expected_params = [
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
]
|
||||
|
||||
# 验证所有关键参数都在命令中
|
||||
cmd = ["ffmpeg", "-y", "-i", "input.mp4"]
|
||||
cmd.extend(expected_params)
|
||||
cmd.append("output.mp4")
|
||||
|
||||
assert "-c:v" in cmd
|
||||
assert "libx264" in cmd
|
||||
assert "-crf" in cmd
|
||||
assert "18" in cmd
|
||||
assert "-pix_fmt" in cmd
|
||||
assert "yuv420p" in cmd
|
||||
assert "-movflags" in cmd
|
||||
assert "+faststart" in cmd
|
||||
|
||||
def test_hevc_codec_case_insensitive(self):
|
||||
"""验证 HEVC 检测不区分大小写"""
|
||||
test_cases = ["hevc", "HEVC", "Hevc", "h265", "H265", "hvh1", "HVH1"]
|
||||
hevc_keywords = ("hevc", "h265", "hvh1")
|
||||
|
||||
for codec in test_cases:
|
||||
assert codec.lower() in hevc_keywords, f"{codec} 应该被检测为 HEVC"
|
||||
|
||||
def test_non_hevc_codecs(self):
|
||||
"""验证非 HEVC 编码不会触发转码"""
|
||||
non_hevc_codecs = ["h264", "avc1", "vp9", "av1", "mpeg4", ""]
|
||||
hevc_keywords = ("hevc", "h265", "hvh1")
|
||||
|
||||
for codec in non_hevc_codecs:
|
||||
assert codec.lower() not in hevc_keywords, f"{codec} 不应触发转码"
|
||||
Reference in New Issue
Block a user